chore: release v1.39.0

This commit is contained in:
coso
2026-05-15 02:38:18 +08:00
parent 16d8bb5053
commit af6133164f
334 changed files with 44460 additions and 27233 deletions
+4
View File
@@ -71,6 +71,8 @@ docs/roadmap/*
!docs/roadmap/ai-layered-design/*.md
!docs/roadmap/i18n/
!docs/roadmap/i18n/*.md
!docs/roadmap/agentapp/
!docs/roadmap/agentapp/*.md
docs/gongzonghao/
docs/bussniss/
docs/oem/
@@ -136,3 +138,5 @@ governance/
!src/lib/governance/*.test.ts
src-tauri/crates/aster-rust/target/
playwright*
+29 -29
View File
@@ -1,49 +1,49 @@
## Lime v1.38.0
## Lime v1.39.0
发布日期:`2026-05-14`
递交范围:当前完整 worktree,包含 tracked、deleted 与新增文件;本次按发布要求完成版本号、release note 与发布前校验,提交和推送将在获得明确确认后执行。
发布日期:`2026-05-15`
递交范围:当前完整 worktree,包含 tracked、deleted 与新增文件;本次按发布要求完成版本号、release note、核心质量校验与发布提交,不包含 tag / push / 正式分发。
> 发布说明:上一版 release tag 为 `v1.37.0`。本版升级到 `v1.38.0`,并继续清理旧 `RELEASE_NOTES.md` 历史堆叠内容,只保留当前版本说明;旧 v1.37.0 发布说明不再作为当前 release note 事实源保留。
> 发布说明:上一版 release tag 为 `v1.38.0`。本版升级到 `v1.39.0`,并继续清理旧 `RELEASE_NOTES.md` 历史堆叠内容,只保留当前版本说明;旧 v1.38.0 发布说明不再作为当前 release note 事实源保留。
### 发布概览
- 应用版本从 `1.37.0` 升级到 `1.38.0`,同步 `package.json`、`package-lock.json`、`packages/lime-cli-npm/package.json`、`src-tauri/Cargo.toml`、`src-tauri/Cargo.lock`、`src-tauri/tauri.conf.json` 与 `src-tauri/tauri.conf.headless.json`。
- `smart-input` 独立浮窗整体下线,删除旧页面、组件、窗口配置、前端 API 与治理回流入口,不再保留可关闭、拖动、圆角、旧 Logo 等旧浮窗问题面。
- 旧 `voice-input` 独立语音窗口下线,`Fn` / 语音快捷键改为进入首页 / Claw 输入框录音按钮同一状态机,通过主窗口事件 `voice-start-recording` / `voice-stop-recording` 驱动输入栏录音。
- 翻译快捷键、截图对话快捷键与截图实验入口清理,保留真正仍属于 current 主链的 `@翻译` skill、`translate_instruction_id` 与首页 `voice-input` guide 语义。
- 命令边界同步清理:移除旧 `open_voice_window` / `close_voice_window`、截图命令与 smart-input 调用面,补齐 Rust 注册、前端网关、DevBridge mock、governance catalog 与 contracts 守卫。
- 应用版本从 `1.38.0` 升级到 `1.39.0`,同步 `package.json`、`package-lock.json`、`packages/lime-cli-npm/package.json`、`src-tauri/Cargo.toml`、`src-tauri/Cargo.lock`、`src-tauri/tauri.conf.json` 与 `src-tauri/tauri.conf.headless.json`。
- Agent Chat / Workbench 主路径继续拆分巨型渲染面,`MessageList`、输入栏、历史水合、滚动控制、任务轻卡、图片预览和 Team Workspace 选择器收敛到更小的组件、selector 与 hook。
- i18n 资源按 Agent 子域拆包,新增并同步 `agentHome`、`agentInputbar`、`agentMessageList`、`agentRuntime`、`agentSkills`、`agentTeamWorkspace` 五语言资源,原 `agent.json` 大幅瘦身。
- Tauri mock / DevBridge mock 从单体 `core.ts` 拆分为多个域模块,命令契约检查支持识别 `defaultMocks` 的 spread registry,避免 mock 拆分后契约扫描失真。
- Release updater manifest 强化 macOS DMG / Windows installer URL 处理,缺失 macOS DMG 时阻断错误清单,避免官网下载页错误回退到 updater 包。
- Agent App 方向新增路线图与技术设计文档,明确 capability SDK、AI 内容工程和 P0 技术闭环的后续主线。
### 用户可见更新
- 首页 / Claw 输入框成为语音识别唯一 current 入口,快捷键不再打开单独快捷输入框,也不会再残留无法关闭或无法停止识别的旧浮窗。
- 设置页快捷键列表移除已过时、未实现或已下线的翻译 / 截图 / smart-input 入口,避免用户看到无效快捷键。
- 输入栏录音按钮、语音润色失败兜底、图片附件、排队回合与工具按钮补齐回归测试,保证快捷键进入同一输入栏状态后仍能稳定发送。
- 历史消息合并修复:只有在 assistant 历史中存在工具、任务预览或过程轨迹缺用户 turn 时才补回用户消息,避免普通分页历史重复补用户消息。
- 图片任务缓存恢复回归补齐:空历史从图片工作台缓存恢复时保留原始用户输入与完成卡,新草稿不再回灌上一条会话的图片轻卡。
- 聊天消息区在历史恢复、运行中消息、图片 / 音频 / 转写任务卡和 artifact 入口上更稳定,避免新草稿、历史水合或旧会话恢复时串入上一轮轻卡状态。
- 输入栏与 Team 选择体验继续收敛:Team selector、工作流状态、技能选择、图片附件和多模态提示补齐五语言文案与回归断言。
- 图片、音频、转写等媒体任务轻卡继续以统一 media task index 为事实源,LimeCore policy 输入缺口、阻断和 allow 状态展示进入本地化资源。
- Skill Forge / Service Skill 入口链路继续补齐挂起参数、A2UI 表单、runtime binding 和显式 session enable 证据,减少“已注册”与“已可自动执行”之间的误解。
- GUI smoke 覆盖 workspace ready、browser runtime、site adapter、runtime tool surface、`@` 命令注册、Claw streaming、知识库 GUI 和设计画布主路径。
### 开发者与治理更新
- 新语音快捷键事实源固定为 `src-tauri/src/voice/events.rs`、`voice/shortcut.rs`、`voice/fn_shortcut.rs` 与前端 `src/lib/api/voiceShortcutEvents.ts` / `useInputbarDictation`。
- 旧 `smart-input` / 旧语音窗口 / 截图对话链路均归类为 `dead`,不再新增 compat 包装层。
- `ShortcutSettings` 收敛到 Settings v2 shared 目录,旧 `src/components/smart-input/ShortcutSettings*` 删除。
- 五语言 i18n 资源清理旧快捷键与旧入口文案,继续保持 `zh-CN`、`zh-TW`、`en-US`、`ja-JP`、`ko-KR` 覆盖一致。
- `protocol-fact-source-guard`、`legacySurfaceCatalog` 与 hotkey catalog 回归补齐旧入口防回流断言。
- `src/lib/tauri-mock/` 拆出 agent runtime、browser、knowledge、media task、memory、provider、skill forge、update、voice、workspace 等 mock registry,降低单文件复杂度。
- `scripts/check-command-contracts.mjs` 适配拆分后的 mock 事实源,继续检查前端命令、Rust 注册、mock priority 与 default mock 的一致性。
- `scripts/release-updater-manifest.mjs` 和对应测试覆盖 installer_url、macOS DMG 缺失、Windows 安装包与 updater manifest 组合边界。
- Rust runtime、Skill 执行与 timeline 服务补充 sequence 归一化、synthetic item、fast response model routing、workspace skill runtime enable 等定向测试。
- 删除旧 Playwright 调试 JSON 快照,并通过 `.gitignore` 收敛临时调试产物,避免 release 提交继续携带一次性浏览器状态文件。
### 校验状态
- `npm run verify:app-version`:通过,目标版本 `1.38.0`。
- `cargo fmt --manifest-path "src-tauri/Cargo.toml" --all`:通过。
- `CARGO_TARGET_DIR="/tmp/lime-cargo-target-v138" cargo test --manifest-path "src-tauri/Cargo.toml"`:通过,主库 `1340 passed; 0 failed; 1 ignored`,集成测试通过。
- `CARGO_TARGET_DIR="/tmp/lime-cargo-target-v138" cargo clippy --manifest-path "src-tauri/Cargo.toml" --workspace --all-targets -- -D warnings`:通过。
- `npm run verify:app-version`:通过,目标版本 `1.39.0`。
- `CARGO_TARGET_DIR="$HOME/.cache/lime-cargo-target-v139" cargo test --manifest-path "src-tauri/Cargo.toml"`:通过,主库 `1347 passed; 0 failed; 1 ignored`,集成测试通过;真实联网测试保持 ignored。
- `CARGO_TARGET_DIR="$HOME/.cache/lime-cargo-target-v139" cargo clippy --manifest-path "src-tauri/Cargo.toml" --workspace --all-targets -- -D warnings`:通过。
- `npm run lint`:通过。
- `npm test -- src/components/agent/chat/hooks/agentChatHistory.test.ts src/components/agent/chat/hooks/sessionHistoryMergeController.test.ts`:通过。
- `npm test -- src/components/agent/chat/workspace/useWorkspaceImageTaskPreviewRuntime.test.tsx`:通过。
- `npm test`:通过,55/55 批次通过。
- `npm run test:contracts`:通过,命令契约、Harness 契约、modality contracts 与 cleanup report contract 均通过。
- `npm run governance:legacy-report`:通过,边界违规 `0`;本轮 smart-input / screenshot / onboarding dead surface 作为已删除或零引用清理项受控记录。
- `npm run typecheck`:通过。
- `npm test`:通过,`56/56` 批次通过。
- `npm run test -- "src/components/agent/chat/workspace/useWorkspaceAudioTaskPreviewRuntime.test.tsx" "src/components/agent/chat/workspace/useWorkspaceTranscriptionTaskPreviewRuntime.test.tsx"`:通过,用于锁定媒体任务 policy meta 本地化回归。
- `npm run test:contracts`:通过;命令契约、Harness 契约、modality contracts 与 cleanup report contract 均通过。
- `npm run verify:gui-smoke`:通过;复用 headless Tauri / DevBridge,覆盖 workspace、browser runtime、site adapters、Skill Forge、runtime tool surface、`@` 命令、Claw streaming、knowledge GUI 与 design canvas。
- `git diff --check`:通过。
- `npm run verify:local`:通过,包含版本一致性、前端 lint/typecheck/Vitest/contracts、Rust test 与 GUI smoke。
---
**完整变更**: `v1.37.0` -> `v1.38.0`
**完整变更**: `v1.38.0` -> `v1.39.0`
+1 -1
View File
@@ -87,7 +87,7 @@ Lime 的命令体系固定按以下关系理解:
对图片任务再补一条固定约束:
`@配图/@修图/@重绘` 原始文本必须先进入 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,避免模型在“搜技能目录”里空转或把权限错误暴露给用户。当前 `image_generate` 的唯一 current 执行面是 `Skill(image_generate) -> lime_create_image_generation_task -> 标准 image task artifact + worker`;旧的 `Bash -> lime media image generate --json` / `lime task create image --json` 只允许停留在 compat 或手工 CLI 场景,不能再作为 `@配图` 首发路径。即使经过 compat 入口,最终也必须委托同一条 task artifact + worker 执行链,并禁止把任务改写到 `outputPath` / markdown 文稿。
`@配图/@修图/@重绘` 原始文本必须先进入 Agent turn,再由 `harness.image_skill_launch` 辅助首刀 `Skill(image_generate)`;文稿 inline 配图、封面位、图片工作台编辑/变体这类显式图片动作也一样,必须先组装 `image_task` 上下文后再复用统一发送主线。不要把这些通用图片命令重新改回前端预翻 slash skill、前端直建任务或“按钮直调 task API”。统一目录显式声明的图片模型标签,例如用户在“设置 -> AI 服务商”里从已配置 Provider 模型创建的 `@Nano Banana 2` / `@GPT Images 2`,或 Lime Cloud 下发的同构 command entry,也只表示“用户已经指定图片执行模型”;发送边界仍必须把 `entry_source / provider_id / model / executor_mode / modality_contract_key / runtime_contract / routing_slot` 等上下文并入 `harness.image_skill_launch.image_task`,由同一条 Agent / Skill 回合调用 `lime_create_image_generation_task` 创建标准 task artifact。未在 catalog 中声明的任意 `@模型名` 不得自动变成图片 API 入口。图片 launch 还必须显式压制 `ToolSearch / WebSearch / Read / Glob / Grep` 这类通用偏航工具,并在必要时直接从当前 session tool surface 移除这些 detour tools,避免模型在“搜技能目录”里空转或把权限错误暴露给用户。当前通用 `image_generate` 的唯一 current 执行面是 `Skill(image_generate) -> lime_create_image_generation_task -> 标准 image task artifact + worker`;旧的 `Bash -> lime media image generate --json` / `lime task create image --json` 和前端直接 `create_image_generation_task_artifact` 只允许停留在 compat、显式工具动作或手工 CLI 场景,不能再作为聊天 @图片命令首发路径。即使经过 compat 入口,最终也必须委托同一条 task artifact + worker 执行链,并禁止把任务改写到 `outputPath` / markdown 文稿。
对所有 skills 再补一条全局约束:
+2 -2
View File
@@ -224,7 +224,7 @@ Skill Forge P3E tool_runtime authorization 第一刀仍继续走 `agent_runtime_
`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)` 的系统提示。当前图片 launch 还会在 session permission 中显式压制 `ToolSearch / WebSearch / Read / Glob / Grep` 这类偏航工具,并在当前 session registry 中直接移除这些 detour tools,避免模型把 `@配图` 卡死在“先搜工具目录”或把权限报错直接暴露给用户。当前 `@配图` / `@海报` 的唯一 current 执行面是 `Skill(image_generate) -> lime_create_image_generation_task`;旧的 `Bash -> lime media image generate --json` 与 `lime task create image --json` 只允许停留在 compat 或人工 CLI 场景,不能再被 skill 当成首发路径。即使经过 compat 入口,最终也必须复用同一个 `image_generate` task artifact + worker 执行链,且忽略 `outputPath` 这类非标准落盘出口,最终仍只落到标准 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,避免模型把 `@配图` 卡死在“先搜工具目录”或把权限错误暴露给用户。统一目录显式声明的图片模型绑定标签,例如用户在“设置 -> AI 服务商”里从已配置 Provider 模型创建的 `@Nano Banana 2` / `@GPT Images 2`,或 Lime Cloud 下发的同构 command entry,也必须进入同一 `image_skill_launch -> Agent 首刀 Skill(image_generate)` 回合;这些标签只表示“用户指定了图片执行模型”,发送边界必须把 `entry_source / provider_id / model / executor_mode / modality_contract_key / runtime_contract / routing_slot` 作为路由上下文并入 `image_task`,不得绕过 Agent / Skill 编排去前端创建任务。未在 catalog 中声明的任意 `@模型名` 不得自动变成图片 API 入口。当前 `@配图` / `@海报` 的唯一 current 执行面仍是 `Skill(image_generate) -> lime_create_image_generation_task`;旧的 `Bash -> lime media image generate --json` 与 `lime task create image --json` 只允许停留在 compat 或人工 CLI 场景,不能再被 skill 当成首发路径。即使经过 compat 入口,最终也必须复用同一个 `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` 来源的产物默认后台更新,不自动选中、不自动展开工作台,避免命令执行过程中抢焦点。
@@ -235,7 +235,7 @@ Skill Forge P3E tool_runtime authorization 第一刀仍继续走 `agent_runtime_
- `list_media_task_artifacts`
- `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` 为唯一事实源,不允许重新回到前端直连图片服务。
无论入口来自纯文本命令、图片模型绑定标签、slash scene 组合还是显式图片动作,最终都只允许写入当前项目根目录下的标准 `image_generate` task file,并写入 `session_id / project_id / content_id / entry_source / mode` 等上下文。若当前来源是图片模型绑定标签,还必须额外保留显式 `provider_id / model / executor_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。
+2 -2
View File
@@ -260,7 +260,7 @@ CI 里的 `.github/workflows/quality.yml` 结果摘要现在也会透出 `bridge
- 检查浏览器桥接 / mock 优先路径是否同步
- 检查 `DevBridge` 是否可用
- 检查 task / service / workflow 类能力是否命中了各自的 current binding family;若某能力当前属于 `typed local_cli`,验证 runtime 是否结构化组装 `Lime CLI` 并回写统一真相源;若属于 `server_api / hybrid`,验证 run/job/task snapshot 是否正确回流,而不是把“模型有没有先写 Bash”当成通过条件
- 检查纯文本 `Claw @配图` 是否已经走 `原始用户消息 -> harness.image_skill_launch -> Agent 首刀 Skill(image_generate) -> 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`、退回旧的 `Bash/lime media image generate --json` / `lime task create image --json` 旁路,或把 `任务 ID:{task_id}` 这类模板占位原样显示出来
- 检查纯文本 `Claw @配图` 是否已经走 `原始用户消息 -> harness.image_skill_launch -> Agent 首刀 Skill(image_generate) -> lime_create_image_generation_task -> task/timeline` 主链,显式图片动作是否也已经走 `synthetic user message / displayContent -> harness.image_skill_launch -> Agent 首刀 Skill(image_generate) -> task/timeline`,统一目录声明的图片模型标签(例如设置页从 Provider 模型创建的 `@Nano Banana 2` / `@GPT Images 2`,或 Lime Cloud 下发的同构命令)是否也进入同一 `image_skill_launch` 回合并只把 Provider / model / executor_mode 作为路由上下文;未在 catalog 中声明的任意 `@模型名` 不应自动调用图片 API。图片主链不能卡在 `ToolSearch / WebSearch / Read / Glob / Grep`、退回旧的 `Bash/lime media image generate --json` / `lime task create image --json` 或聊天前端直建任务旁路,或把 `任务 ID:{task_id}` 这类模板占位原样显示出来
- 检查纯文本 `Claw @封面` 是否已经走 `原始用户消息 -> harness.cover_skill_launch -> Agent 首刀 Skill(cover_generate) -> task file` 主链,而不是回流成普通图片命令、卡在 `ToolSearch / WebSearch / Read / Glob / Grep`,或前端本地伪造结果
- 检查纯文本 `Claw @海报` 是否已经走 `原始用户消息 -> harness.image_skill_launch -> Agent 首刀 Skill(image_generate) -> lime_create_image_generation_task -> task/timeline` 主链,而不是回流成普通聊天、另一套海报协议、旧的 Bash/CLI 图片旁路,卡在 `ToolSearch / WebSearch / Read / Glob / Grep`,或前端本地伪造结果;同时确认默认 `entry_source=at_poster_command`、默认尺寸 `4:5 -> 864x1152` 与“海报设计”语义补齐仍然成立
- 检查纯文本 `Claw @视频` 是否已经走 `原始用户消息 -> harness.video_skill_launch -> Agent 首刀 Skill(video_generate) -> current binding -> task/timeline` 主链;如果当前 binding family 是 `typed local_cli`,应看到 runtime 结构化组装的 `lime media video generate --json`;如果当前 binding family 是原生结构化 binding,则应看到对应的 create task 调用。无论哪种都不能退化成模型自由写 Bash、卡在 `ToolSearch / WebSearch / Read / Glob / Grep`,或前端本地伪造结果
@@ -313,7 +313,7 @@ CI 里的 `.github/workflows/quality.yml` 结果摘要现在也会透出 `bridge
- 修改浏览器资料 / 环境预设命令族,或调整它们在 `mockPriorityCommands` 里的优先级
- 修改浏览器连接器命令族,例如安装目录、启用状态、系统连接器、浏览器动作配置、扩展安装状态、打开 Chrome 扩展 / 远程调试页,或主动断开扩展连接
- 修改 `get_model_registry_provider_ids` 兼容空结果、Provider 实时模型读取或用户 `custom_models` 合并语义
- 修改 `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` 的异步图片任务主链
- 修改 `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`、`image_skill_launch`、`lime_create_image_generation_task`、`image_generate` skill 或相关图片 timeline 展示,尤其是调整 `Claw @海报 -> harness.image_skill_launch -> Agent 首刀 Skill(image_generate) -> task/timeline` 主链
- 修改 `@播报` 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` 主链
+84
View File
@@ -0,0 +1,84 @@
# Agent App 客户端路线图
更新时间:2026-05-15
## 定位
本目录只承载 Lime Desktop / Lime 客户端侧的 Agent App 路线图。
客户端职责是把 Agent App 真正安装并跑起来:本地 package cache、manifest projection、readiness、Capability SDK、UI extension host、storage namespace、worker / workflow runtime、Agent Runtime bridge、Artifact / Evidence、权限和本地数据边界。
服务端 / Cloud / LimeCore 的职责不写在本目录;对应文档在 `/Users/coso/Documents/dev/ai/limecloud/limecore/docs/roadmap/agentapp`。
```text
Lime Cloud / LimeCore
Catalog / Release / License / Tenant Enablement / Gateway / ToolHub
↓
Lime Desktop Client
Install / Projection / Capability SDK / UI Host / Storage / Runtime / Evidence
↓
Agent App Runtime Package
App UI / Worker / Workflow / Storage Schema / Business Code
```
## 当前事实源
| 分类 | 对象 | 说明 |
|---|---|---|
| current | `/Users/coso/Documents/dev/ai/limecloud/agentapp` | Agent App v0.2 标准、runtime package、schema、参考 CLI 与示例包。 |
| current | `docs/roadmap/agentapp/capability-sdk.md` | Lime Desktop 侧 Capability SDK 与 Host Bridge 方案。 |
| current | `docs/roadmap/agentapp/implementation-plan.md` | 客户端落地实施方案,覆盖 P0-P4。 |
| current | `docs/roadmap/agentapp/p0-technical-design.md` | P0 只读 App Host 技术设计,覆盖 manifest、projection、readiness、cleanup dry-run。 |
| current | `docs/roadmap/agentapp/ai-content-engineering.md` | AI 内容工程化 App 作为客户端标杆案例的 UI / storage / workflow / artifact 方案。 |
| reference | `/Users/coso/Documents/dev/ai/limecloud/limecore/docs/roadmap/agentapp` | 服务端 catalog、release、tenant enablement、gateway、ToolHub 路线图。 |
## 文档索引
| 文档 | 说明 |
|---|---|
| [capability-sdk.md](./capability-sdk.md) | 客户端 Capability SDK、runtime bridge、能力注入、权限拦截和 mock host。 |
| [implementation-plan.md](./implementation-plan.md) | 客户端 App Host 落地实施方案:manifest parser、projection、installer、storage、UI host、worker runtime。 |
| [p0-technical-design.md](./p0-technical-design.md) | P0 技术设计:只读 package、manifest normalize、projection、readiness、cleanup dry-run、Lab 展示。 |
| [ai-content-engineering.md](./ai-content-engineering.md) | AI 内容工程化 Product-level Agent App 的客户端产品与实现方案。 |
## 客户端边界
1. 客户端负责运行 Agent App;服务端不默认运行 Agent。
2. App 不能 import Lime 内部模块,只能通过 Capability SDK 使用 `lime.*` 能力。
3. UI、worker、workflow、storage migration 都在客户端受控 runtime 中执行。
4. App storage namespace、artifact namespace、event namespace 必须隔离。
5. 客户数据、workspace data、secrets 和 overlay 不进入官方 App package。
6. Expert 只是 `expert-chat` entry;客户端必须支持非聊天页面和业务 workflow。
7. Cloud 下发 release / tenant enablement,客户端做本地 readiness 和能力注入。
## 主路线
```mermaid
flowchart TD
Standard[Agent App v0.2 Standard] --> Parser[Manifest Parser]
Parser --> Projection[Client Projection]
Projection --> Installer[Local Installer]
Installer --> SDK[Capability SDK]
SDK --> UI[UI Extension Host]
SDK --> Storage[Storage Namespace]
SDK --> Worker[Worker / Workflow Runtime]
SDK --> Agent[Local Agent Runtime Bridge]
SDK --> Artifact[Artifact + Evidence]
Artifact --> App[AI 内容工程化 App P0]
```
## 当前执行顺序
先按 P0 技术设计落最小只读闭环:
```text
feature flags
→ manifest model / normalizer
→ projection model
→ readiness result
→ cleanup dry-run
→ Lab readonly UI
→ Mock SDK host
```
不要先做市场页、Cloud、完整深澜内容系统,也不要先把 entry 接入正式主路径。
@@ -0,0 +1,404 @@
# AI 内容工程化 Agent App PRD
更新时间:2026-05-14
## 一句话目标
基于 Lime Agent App 平台开发一个独立垂直产品:`AI 内容工程化 App`。它不是“内容专家聊天框”,而是有自己 UI、storage、worker、workflow、Knowledge 绑定、Artifact 和数据复盘闭环的完整业务应用,安装到 Lime Desktop 后本地运行。
## 关键校准
这份需求不能用 `APP.md + prompt` 解决。功能背后必须有完整实现:
```text
AI 内容工程化 App
├── UI:项目首页 / 三层知识库 / 场景穷尽 / 内容工厂 / 策略报告 / 数据复盘
├── Storage:项目、知识条目、场景、内容资产、复盘数据、洞察
├── Worker:文件解析、知识结构化、批量生成、去 AI 味评分、PPT / 报告生成
├── Workflow:建项目、知识库构建、场景穷尽、内容生产、策略交付、数据复盘
├── Agent Entries:内容策略专家、知识库整理专家、文案优化专家
├── Skills / Tools / Knowledge:复用生态能力,不重复造底座
└── Artifacts / Evidence / Evals:内容表、脚本、报告、PPT、复盘和质量记录
```
`APP.md` 只声明这些能力和入口;真实功能在 runtime package 中实现,并通过 `@lime/app-sdk` 调用 Lime 能力。
## 背景
来自深澜智能内容工程化方案的真实需求:团队希望把个人 IP、项目事实、内容方法论、素材资产和运营复盘沉淀成可复用系统,而不是每次在 Claude、豆包、Gemini、图片工具、PPT 工具之间复制粘贴。
核心链路:
```text
三层知识库
→ 场景穷尽
→ 批量内容生产
→ 脚本 / 图片提示词 / 策略报告 / PPT
→ 数据复盘
→ 选题库和知识库迭代
```
这不是 Lime Core 的垂直功能,而是验证 Agent App 平台能力的标杆 App。
## 用户与收益
| 用户 | 核心诉求 | 收益 |
|------|----------|------|
| 内容运营团队 | 项目资料一次沉淀,批量生产文案、脚本、提示词。 | 从灵感驱动变为系统化生产。 |
| 个人 IP / 讲师 | 基于本人经历和表达风格产出文章、课程稿、朋友圈和私域话术。 | 语气一致、素材复用、减少重复讲背景。 |
| 交付顾问 | 为客户交付可运行系统,而不是复制 prompt 或 fork Lime。 | 交付可复制,官方 App 可升级,客户数据不被覆盖。 |
| 团队管理员 | 控制 App 可见性、模型、工具、默认知识模板、质量门禁。 | 团队能力标准化、权限可控。 |
| Lime 平台 | 验证 App SDK、UI extension、storage namespace、worker runtime、artifact/evidence。 | 新行业通过 App 扩展,不污染 Core。 |
## 非目标
- 不把深澜内容系统写进 Lime Core。
- 不把客户真实资料、私有 SOP、品牌语气、投放数据打进官方 App 包。
- 不在 Lime Cloud 增加默认云端 Agent Runtime。
- P0 不做完整 SaaS:不做账号体系、计费、复杂审核、公开 marketplace。
- P0 不做全部外部平台 API,对数据复盘先支持 CSV / Excel 手动导入。
## 产品形态
### 主导航
```text
AI 内容工程化 App
├── 项目首页
├── 知识库
│ ├── IP 知识库
│ ├── 项目知识库
│ └── 素材库
├── 场景穷尽
├── 内容工厂
│ ├── 批量文案
│ ├── 短视频脚本
│ └── 图片提示词
├── 策略分析
├── PPT / 报告
├── 数据复盘
├── 交付物
├── 专家对话
└── 设置
```
### Expert entries
专家只是 App 内的入口:
| Expert | 用途 |
|---|---|
| 内容策略专家 | 项目定位、竞品分析、内容方向判断。 |
| 知识库整理专家 | 协助资料分层、裁剪、版本化。 |
| 文案优化专家 | 去 AI 味、平台适配、人工改稿。 |
| 数据复盘专家 | 解释方向表现、生成下周建议。 |
## App Package 草案
```text
shenlan-content-engineering/
├── APP.md
├── dist/
│ ├── ui/
│ └── worker/
├── storage/
│ ├── schema.json
│ └── migrations/
├── workflows/
│ ├── create-project.workflow.md
│ ├── build-knowledge.workflow.md
│ ├── exhaust-scenes.workflow.md
│ ├── batch-copy.workflow.md
│ ├── strategy-report.workflow.md
│ └── data-review.workflow.md
├── agents/
│ ├── content-strategist.md
│ ├── knowledge-architect.md
│ └── review-analyst.md
├── skills/
├── knowledge-templates/
├── artifacts/
├── evals/
└── policies/
```
## Manifest 草案
```yaml
manifestVersion: 0.2.0
name: shenlan-content-engineering
version: 0.1.0
status: draft
appType: domain-app
description: AI 内容工程化 App,用于知识库构建、场景穷尽、批量内容生产和数据复盘。
runtimeTargets:
- local
requires:
lime:
appRuntime: ">=0.2.0 <1.0.0"
capabilities:
lime.ui: "^0.1.0"
lime.storage: "^0.1.0"
lime.files: "^0.1.0"
lime.agent: "^0.1.0"
lime.knowledge: "^0.1.0"
lime.tools: "^0.1.0"
lime.artifacts: "^0.1.0"
lime.workflow: "^0.1.0"
lime.evidence: "^0.1.0"
runtimePackage:
ui:
path: ./dist/ui
worker:
path: ./dist/worker
storage:
schema: ./storage/schema.json
migrations: ./storage/migrations
storage:
namespace: shenlan-content-engineering
schema: ./storage/schema.json
migrations: ./storage/migrations
entries:
- key: dashboard
kind: page
title: 项目首页
route: /dashboard
- key: knowledge
kind: page
title: 三层知识库
route: /knowledge
- key: scene_exhaustion
kind: workflow
title: 场景穷尽
workflow: ./workflows/exhaust-scenes.workflow.md
- key: content_factory
kind: page
title: 内容工厂
route: /content-factory
- key: content_strategist
kind: expert-chat
title: 内容策略专家
persona: ./agents/content-strategist.md
knowledgeTemplates:
- key: ip_knowledge
standard: agentknowledge
type: personal-profile
required: false
- key: project_knowledge
standard: agentknowledge
type: brand-product
required: true
- key: material_library
standard: agentknowledge
type: content-operations
required: false
```
## Storage 模型
| 表 | 说明 |
|---|---|
| `projects` | 项目、行业、平台、是否需要 IP、默认模型和状态。 |
| `knowledge_spaces` | IP / project / material 三层知识库空间、版本和健康度。 |
| `knowledge_items` | 分区知识条目、来源、证据、字数、版本。 |
| `scenes` | 场景穷尽结果:维度、痛点、解决方案、决策阶段、标签。 |
| `content_assets` | 文案、脚本、图片提示词、报告等内容资产。 |
| `review_imports` | CSV / Excel 数据导入批次。 |
| `metrics` | 完播率、搜索占比、转化、素材复用率等指标。 |
| `insights` | 自动归因、方向判断、下步建议。 |
| `app_settings` | 禁用词、平台规则、评分阈值、默认导出格式。 |
## Workflow
### 知识库构建
```mermaid
flowchart TD
Upload[上传 PDF / Word / Excel] --> Parse[lime.files + lime.tools 解析]
Parse --> Extract[lime.agent 结构化抽取]
Extract --> Review[人工确认]
Review --> Store[lime.storage 写入 knowledge_items]
Store --> Bind[lime.knowledge 绑定 / 导出]
Bind --> Evidence[lime.evidence 记录来源和版本]
```
### 内容生产
```mermaid
flowchart TD
Select[选择项目 / 场景 / 平台 / 数量] --> Retrieve[lime.knowledge 检索]
Retrieve --> Generate[lime.agent 批量生成]
Generate --> Score[去 AI 味 + 事实支撑 Eval]
Score --> Grade[A/B/C 分级]
Grade --> Save[lime.storage 保存内容资产]
Save --> Artifact[lime.artifacts 生成内容表]
```
### 数据复盘
```mermaid
flowchart TD
Import[导入运营数据] --> Normalize[字段映射 / 清洗]
Normalize --> Analyze[归因分析]
Analyze --> Insight[生成洞察和动作]
Insight --> Update[更新选题库 / 场景权重]
Update --> Report[复盘 Artifact]
```
## Capability 调用映射
| 业务功能 | Lime Capability |
|---|---|
| 文件上传和解析 | `lime.files`、`lime.tools` |
| 三层知识库管理 | `lime.storage`、`lime.knowledge` |
| 结构化抽取 / 文案 / 脚本 | `lime.agent`、Agent Skills |
| 批量任务、进度、中断 | `lime.workflow`、`lime.agent` |
| 内容表、报告、PPT | `lime.artifacts` |
| 事实来源、版本、评估 | `lime.evidence`、Evals |
| 外部调研、飞书同步 | `lime.tools`、`lime.secrets` |
| 权限、成本、风险 | `lime.policy` |
## MVP 范围
P0 只做最小闭环:
```text
创建项目
→ 上传资料
→ 生成三层知识库草稿
→ 人工确认
→ 生成 120 个场景
→ 批量生成 20 条文案 / 脚本
→ 去 AI 味评分
→ A/B/C 分级
→ 保存为 Artifact
```
P0 页面:
1. 项目首页。
2. 三层知识库。
3. 场景穷尽。
4. 内容工厂。
5. 交付物列表。
P0 不做:
- PPT 完整排版引擎。
- 飞书自动同步。
- 外部平台 API 自动抓数。
- 多租户商业化后台。
- 完整 marketplace。
## 分期计划
| 阶段 | 目标 | 验收 |
|---|---|---|
| P0 App Host 验证 | 用 mock SDK 跑通 App UI + storage + workflow + artifact。 | 不修改 Lime Core 业务代码即可打开 App 页面和保存数据。 |
| P1 内容生产 MVP | 跑通知识库构建、场景穷尽、批量文案 / 脚本。 | 30 秒内输出 20 条,评分和分级可见,Artifact 可追溯。 |
| P2 策略交付 | 策略分析、报告、PPT 大纲或 pptx 导出。 | 输入项目与竞品资料,10 分钟内生成可编辑交付物。 |
| P3 数据飞轮 | 运营数据导入、自动归因、选题库迭代。 | 导入一周数据后自动生成复盘 Artifact。 |
| P4 企业启用 | Cloud catalog、tenant enablement、overlay、secrets。 | 租户可启用 App,客户数据不进入官方包。 |
## Readiness 规则
| 检查 | 级别 | 说明 |
|---|---|---|
| `lime.storage` 缺失 | blocking | App 无法保存项目和内容资产。 |
| `lime.files` 缺失 | blocking | 无法上传 / 解析资料。 |
| `project_knowledge` 未绑定 | blocking | 内容生产不能运行。 |
| 必需 writer skill 缺失 | blocking | 批量文案和脚本不可运行。 |
| document parser 缺失 | warning / blocking | P0 上传资料流程 blocking;手工录入可降级。 |
| eval 未启用 | warning | 可生成草稿,但不能标记 publish-ready。 |
| 外部调研工具缺失 | warning | 策略报告降级为用户提供资料模式。 |
## 验收标准
- App 不作为 Lime Core 垂直功能出现;业务 UI 来自 App runtime package。
- App 所有底层能力调用都通过 Capability SDK。
- App storage namespace 独立,卸载时可选择保留或导出数据。
- 每个 Artifact 带 app version、knowledge version、skill/tool/eval provenance。
- 官方包升级不覆盖客户 Knowledge、workspace data、secrets 或 overlay。
- Expert entry 可用,但不是唯一入口;非聊天页面和 workflow 是一等公民。
## P0 用户故事
| 编号 | 用户故事 | 验收标准 |
|---|---|---|
| US-01 | 作为运营,我可以创建一个内容项目,并选择行业、目标平台和是否需要 IP 库。 | `projects` 写入 App storage namespace;不新增 Lime Core 业务表。 |
| US-02 | 作为运营,我可以上传产品资料,系统生成三层知识库草稿。 | 文件通过 `lime.files` 授权读取;结构化结果进入 `knowledge_items`。 |
| US-03 | 作为负责人,我可以逐条确认、裁剪和版本化知识条目。 | 知识库健康度显示字数、分区、来源和版本。 |
| US-04 | 作为内容同学,我可以基于确认后的项目知识库生成 120+ 场景。 | `scenes` 表保存应用场景、用户痛点、解决方案、决策阶段。 |
| US-05 | 作为内容同学,我可以选择场景和平台批量生成 20 条文案或脚本。 | 生成结果进入 `content_assets`,带 A/B/C 分级和去 AI 味评分。 |
| US-06 | 作为交付顾问,我可以把内容批次保存为 Artifact 并追溯来源。 | Artifact 带 app / knowledge / skill / tool / eval provenance。 |
| US-07 | 作为管理员,我可以看到缺失能力或缺失知识绑定的 readiness 提示。 | 缺少 `project_knowledge`、`lime.files` 或 writer skill 时不能静默运行。 |
## 典型用例
### 用例 1:项目知识库构建
```text
输入:产品说明书、竞品资料、检测报告、用户差评表
过程:文件解析 → AI 分区 → 人工确认 → 字数健康检查 → 版本化
输出:项目知识库 v1、知识健康度、Evidence
```
### 用例 2:内容工厂批量生产
```text
输入:项目、场景、平台、数量、人群画像
过程:知识检索 → 技能编排 → 批量生成 → 去 AI 味评分 → A/B/C 分级
输出:内容表 Artifact、可编辑内容资产、Eval 记录
```
### 用例 3:专家辅助修改
```text
输入:某条文案或脚本
过程:用户从内容表打开专家对话 → 专家读取当前资产和知识版本 → 给出修改建议
输出:修改后的内容版本、人工确认记录
```
## 实现边界
| 责任 | 放在 App 实现 | 放在 Lime 平台 |
|---|---|---|
| 三层知识库 UI | 是 | 提供 UI host 和 storage capability。 |
| 文案生成业务规则 | 是 | 提供 Agent Runtime、Skills 调用、Evidence。 |
| 去 AI 味规则库 | 是 | 提供 Eval 执行和结果记录。 |
| 文件权限与解析入口 | 编排 | `lime.files` 和 `lime.tools` 提供授权与工具执行。 |
| 数据表物理存储 | 否 | `lime.storage` 管 namespace、CRUD、migration。 |
| Artifact 持久化 | 定义内容 | `lime.artifacts` 管创建、打开、导出和 provenance。 |
| 凭证 | 否 | `lime.secrets` 托管。 |
| 租户默认配置 | App 默认值 | Cloud / Desktop overlay 覆盖。 |
## P0 页面验收细节
| 页面 | 必备组件 | Capability |
|---|---|---|
| 项目首页 | 项目列表、知识健康度、最近 Artifact、快捷操作。 | `lime.storage`、`lime.artifacts` |
| 三层知识库 | IP / 项目 / 素材 tabs、分区表、字数警告、来源查看。 | `lime.files`、`lime.storage`、`lime.knowledge` |
| 场景穷尽 | 场景表、维度筛选、人工编辑、批量保存。 | `lime.agent`、`lime.storage` |
| 内容工厂 | 平台选择、数量、场景选择、批量结果、评分与分级。 | `lime.agent`、`lime.artifacts`、`lime.evidence` |
| 交付物 | 内容表、报告、导出、版本历史。 | `lime.artifacts`、`lime.evidence` |
## 暂缓项
这些功能应该进入 P2 以后,避免 P0 变成完整 SaaS:
- 自动抓取抖音 / 小红书 / 淘宝 / 京东数据。
- 完整 PPT 排版与模板市场。
- 飞书自动同步和审批流。
- 多团队权限后台。
- 公开模板商城。
- 自定义代码插件市场。
## 下一刀
先做 App Host 与 SDK 的垂直切片,而不是先做内容系统全部功能:
```text
App manifest → projection → readiness → mock SDK → storage namespace → 一个页面 → 一个 workflow → 一个 Artifact
```
只有这条链路跑通后,再进入内容工程化的知识库和批量生成业务实现。
+203
View File
@@ -0,0 +1,203 @@
# Agent App 客户端 Capability SDK 方案
更新时间:2026-05-14
## 一句话目标
在 Lime Desktop 客户端侧提供稳定、版本化、可授权、可 mock 的 Capability SDK;Agent App 通过 SDK 调用本地平台能力,自己实现 UI、业务流程、数据模型和交付物,而不重复造 Lime 客户端底座、不依赖内部实现。
## 边界
本文只讨论 Lime Desktop / Lime 客户端侧能力:App Host、Capability Bridge、UI Host、Storage Namespace、Worker / Workflow Runtime、本地 Agent Runtime Bridge。Cloud / LimeCore 的 catalog、release、tenant enablement、gateway、ToolHub 只作为上游输入,不在本文设计。
## 背景
真实 Agent App 和底层系统高度耦合。一个 App 可能同时需要:文件、知识库、模型任务、工具调用、Artifact、UI、storage、background task、权限、凭证、Evidence、升级和 tenant overlay。
如果没有 SDK,每个 App 都会重复实现这些底座;Lime 底层升级后,所有 App 都要大改。Agent App 平台能成立的前提,是把 Lime 能力封装成稳定 capability facade。
## 非目标
- P0 不做公开 marketplace、支付分账、审核流。
- P0 不允许 App 直接运行任意无沙箱代码访问系统资源。
- P0 不把 Cloud 变成默认 Agent Runtime。
- P0 不把某个垂直业务写进 Lime Core。
- P0 不要求一次性支持所有 UI 插件形态;先支持可控 extension slot。
## 核心原则
1. **SDK 暴露能力,不暴露实现**:App 不能 import Lime internal path。
2. **声明先于调用**:manifest 先声明 capability、permission、storage、secret、network、tool。
3. **Host 注入能力**:运行时由 Desktop 注入 capability handles。
4. **权限双层拦截**:UI 提示只是体验,runtime bridge 必须强制拦截。
5. **App 数据命名空间化**:每个 App 有独立 storage namespace、artifact namespace、event namespace。
6. **Cloud 不跑默认 Agent**:Cloud 做 catalog、release、license、tenant enablement、gateway。
7. **可 mock 和 contract test**:每个 capability 都要有 mock host 和契约测试。
8. **升级不覆盖用户资产**:官方包、tenant overlay、workspace data、secrets 分离。
## 能力地图
| Capability | P0 范围 | 典型 API |
|---|---|---|
| `lime.ui` | 注册 page、panel、command、settings、artifact viewer。 | `registerRoute`、`openPanel`、`openArtifact` |
| `lime.storage` | App namespace、CRUD、schema、migration。 | `namespace`、`table`、`migrate` |
| `lime.files` | 用户选中文件、读取 file ref、基础解析。 | `pick`、`read`、`parse` |
| `lime.agent` | 本地 Agent task、stream、cancel、retry、trace。 | `startTask`、`streamTask`、`cancelTask` |
| `lime.knowledge` | Knowledge binding、search、export、version。 | `bind`、`search`、`export` |
| `lime.tools` | Tool Broker 调用、权限、长任务状态。 | `invoke`、`getProgress` |
| `lime.artifacts` | 创建、读取、打开、导出 Artifact。 | `create`、`open`、`export` |
| `lime.workflow` | workflow state、human review、background task。 | `start`、`checkpoint`、`awaitHuman` |
| `lime.policy` | 权限、风险、成本、企业策略。 | `requestPermission`、`check` |
| `lime.evidence` | provenance、tool call、knowledge citation、eval。 | `record`、`linkArtifact` |
| `lime.secrets` | OAuth、API key、外部凭证槽位。 | `requestSecret`、`getHandle` |
| `lime.events` | App 内外事件,UI/worker 解耦。 | `emit`、`subscribe` |
## 架构图
```mermaid
flowchart TD
AppUI[App UI Bundle] --> SDK[ @lime/app-sdk]
AppWorker[App Worker / Workflow] --> SDK
SDK --> Bridge[Capability Bridge]
Bridge --> UI[Lime UI Host]
Bridge --> Storage[Storage Service]
Bridge --> Files[File Service]
Bridge --> Runtime[Local Agent Runtime]
Bridge --> Knowledge[Agent Knowledge Resolver]
Bridge --> Tool[Tool Broker / ToolHub]
Bridge --> Artifact[Artifact Store]
Bridge --> Policy[Policy Engine]
Bridge --> Evidence[Evidence Store]
Bridge --> Secrets[Secret Manager]
Cloud[Lime Cloud Catalog / Release / License] --> Installer[Desktop Installer]
Installer --> Bridge
```
## 安装时序
```mermaid
sequenceDiagram
participant User as 用户
participant Desktop as Lime Desktop
participant Cloud as Lime Cloud
participant Installer as App Installer
participant Bridge as Capability Bridge
participant Store as App Storage
participant Runtime as App Runtime
User->>Desktop: 安装 Agent App
Desktop->>Cloud: 拉取 manifest / package / signature
Cloud-->>Desktop: package + release metadata
Desktop->>Installer: 校验 hash / signature / manifest
Installer->>Bridge: capability negotiation
Bridge-->>Installer: supported / degraded / blocked
Installer->>Store: 创建 namespace / 执行 migration plan
Installer->>Desktop: 生成 projection / readiness
User->>Desktop: 启用 entry
Desktop->>Runtime: 加载 UI / worker
Runtime->>Bridge: 请求 capability handles
Bridge-->>Runtime: 注入已授权 handles
```
## Runtime Package 契约
```text
app-package/
├── APP.md
├── dist/ui
├── dist/worker
├── storage/schema.json
├── storage/migrations
├── workflows
├── agents
├── artifacts
├── policies
└── examples
```
安装器必须做到:
- 只信任 package 内声明过的入口。
- UI、worker、workflow、migration 都有 package provenance。
- storage migration 先 dry-run / plan,再执行。
- user data、workspace data、tenant overlay 不进入 package hash。
## App Manifest 核心字段
```yaml
requires:
lime:
appRuntime: ">=0.2.0 <1.0.0"
capabilities:
lime.ui: "^0.1.0"
lime.storage: "^0.1.0"
lime.agent: "^0.1.0"
runtimePackage:
ui:
path: ./dist/ui
worker:
path: ./dist/worker
storage:
namespace: app-id
schema: ./storage/schema.json
migrations: ./storage/migrations
entries:
- key: dashboard
kind: page
- key: advisor
kind: expert-chat
- key: nightly_review
kind: background-task
```
## P0 交付物
| 交付物 | 说明 | 验收 |
|---|---|---|
| App manifest v0.2 parser | 支持 `requires`、`runtimePackage`、`storage`、`entries`。 | 示例 App 可 validate / project。 |
| Capability SDK 类型草案 | TypeScript types + mock host。 | App 示例可以用 mock 运行单测。 |
| Desktop Installer 方案 | 安装、hash、projection、readiness、权限。 | 能生成 projection,不运行代码。 |
| Storage namespace 方案 | schema、migration、保留/删除策略。 | 卸载时可选择保留数据。 |
| UI extension slot 方案 | page / panel / settings / artifact viewer。 | App 页面不需要写进 Core。 |
| Worker runtime 方案 | long task、cancel、trace、policy。 | 能执行受控后台任务。 |
| Evidence 串联 | task/tool/knowledge/artifact/eval provenance。 | 产物能追溯 App 和知识版本。 |
## 分期计划
### P0:单机 App Host 骨架
- 完成 Agent App v0.2 标准对齐。
- 设计 `@lime/app-sdk` 最小 API surface 和 mock host。
- Desktop 支持安装本地 package、projection、readiness。
- 支持 page / expert-chat / workflow 三类 entry。
- 支持 storage namespace + basic migration。
- 支持 Artifact create + Evidence provenance。
### P1:真实垂直 App 验证
- 用 AI 内容工程化 App 验证 UI、storage、workflow、worker、Agent task、Artifact。
- 支持文件选择、文档解析、Knowledge binding、批量生成和去 AI 味 eval。
- App 所有业务 UI 不进 Lime Core。
### P2:Cloud Catalog / Tenant Enablement
- Cloud 下发 app release、package hash、tenant enablement、license。
- Desktop 只执行已启用 App。
- 支持 tenant overlay 覆盖默认知识、工具、模型、eval 阈值。
### P3:安全、升级和生态
- App signature、sandbox、permission review、compat matrix。
- SDK capability deprecation 策略和 contract tests。
- App-to-App capability sharing 规则。
- Marketplace / 私有分发 / 企业策略。
## 风险与应对
| 风险 | 影响 | 应对 |
|---|---|---|
| SDK 过厚 | 变成第二套 Lime 内部 API。 | 只暴露 capability facade,不暴露 store/internal path。 |
| SDK 过薄 | App 重复造轮子。 | P0 优先封装高频底座:storage、files、agent、artifact、knowledge、tools。 |
| UI 安全 | App UI 诱导授权或越权访问。 | Host 控制容器 + runtime permission bridge 双拦截。 |
| Migration 破坏数据 | App 升级损坏用户数据。 | migration plan、dry-run、backup、保留数据策略。 |
| Cloud 变 Runtime | 破坏 Lime 本地运行定位。 | server-assisted 必须显式声明并受 policy 控制。 |
@@ -0,0 +1,853 @@
# Agent App 客户端实施方案计划
更新时间:2026-05-15
## 一句话结论
Agent App 在 Lime Desktop 里必须先做成一个可关闭、可卸载、可删除数据、可移除代码的“实验岛”,而不是一开始改造 Chat、Skill、Workspace、Artifact 主路径。
第一阶段目标不是做一个完整生态,也不是复刻深澜内容系统,而是证明:一个真实 App package 可以在本地被安装、解析、投影、检查 readiness、调用 Capability SDK、生成可追溯产物,并且在方向失败时能干净清理。
```text
Agent App Standard v0.2
↓
Local Package / Fixture
↓
Lime Desktop Agent App Host(实验岛)
↓
Capability SDK Facade + Mock Host
↓
少量可删除 Adapter
↓
现有 Lime 本地能力:Agent / Knowledge / Tools / Artifact / Evidence / Files
```
## 背景
Agent App 不是专家卡片、不是 Prompt 包、不是 Markdown 目录,也不是把某个垂直业务写进 Lime Core。它是安装在 Lime Desktop 中运行的完整应用包:有自己的 UI、业务流程、数据模型、worker、workflow、artifact、权限和升级策略。
这件事最大的风险不在 Cloud,而在客户端运行时:
1. App 需要调用 Lime 底层能力,天然和 Agent Runtime、Knowledge、Tool、Artifact、Evidence、Files、Policy 绑定。
2. App 又不能直接依赖 Lime 内部实现,否则 Lime 一升级,所有 App 都要跟着大改。
3. 如果一开始把 App entry 接进主导航、命令面板、Chat、Skill Catalog、Artifact schema,会快速污染主路径。
4. 如果路线判断失败,必须能关掉开关、删掉实验目录、清掉 App 数据,而不是留下半套平台代码。
所以客户端实施必须采用“实验岛 + Capability SDK + 可删除 Adapter”的路线。
## 目标
| 目标 | 说明 |
|---|---|
| 验证 App package | 本地读取 Agent App v0.2 package / fixture,生成 manifest、projection、readiness。 |
| 验证 SDK 边界 | App 只能通过 `@lime/app-sdk` capability facade 调 Lime 能力,不能 import Lime internal modules。 |
| 验证真实业务切片 | 用 AI 内容工程化 App 跑通一个最小业务闭环,而不是停留在专家聊天。 |
| 验证本地数据隔离 | App storage、artifact、evidence、log、package cache 独立命名空间,可按 app 清理。 |
| 验证失败退出 | P0 就实现 cleanup plan / uninstall plan,保证失败后能删干净。 |
| 验证未来 Cloud 对接 | 客户端先用 local JSON / fixture,Cloud 只在 P5 作为 catalog / release / tenant enablement 输入。 |
## 非目标
1. P0 不做公开市场、审核流、支付分账、企业分发控制台。
2. P0 不执行任意 App 代码,不支持任意 npm install、native binary、任意文件系统访问。
3. P0 不把 AI 内容工程化写进 Lime Core。
4. P0 不改造 AgentChat、Skill Catalog、Artifact 主 schema。
5. P0 不要求 Cloud 先完成;Cloud 不运行默认 Agent Runtime。
6. P0 不承诺兼容历史实验数据;实验数据必须可删除。
7. P0 不把 App projection 写入全局 registry。
## 总体原则
| 原则 | 落地方式 |
|---|---|
| 实验岛优先 | 代码集中在 `src/features/agent-app/`,主系统只保留 feature flag 入口。 |
| 先静态后运行 | 先做 manifest / projection / readiness,不执行 UI bundle 或 worker。 |
| 先 mock 后 adapter | SDK 先接 mock host,确认接口稳定后再接少量真实能力。 |
| 先本地后 Cloud | P0-P4 使用本地 fixture,P5 才消费 LimeCore bootstrap payload。 |
| 先可删除后扩展 | 安装、运行、产物、日志都必须有 cleanup plan。 |
| SDK 暴露能力 | App 只调用 capability,不知道 Lime 内部 store、command、runtime 路径。 |
| 产物必须可追溯 | Artifact / Evidence / Task / Log 都带 `sourceKind: agent_app` provenance。 |
## 仓库边界
本文只描述 Lime Desktop 客户端:
```text
/Users/coso/Documents/dev/ai/aiclientproxy/lime/docs/roadmap/agentapp
```
服务端 / Cloud / LimeCore 控制面文档在:
```text
/Users/coso/Documents/dev/ai/limecloud/limecore/docs/roadmap/agentapp
```
标准仓库事实源在:
```text
/Users/coso/Documents/dev/ai/limecloud/agentapp
```
三者分工:
| 仓库 | 责任 | 不做什么 |
|---|---|---|
| `agentapp` | 标准、schema、示例包、reference CLI。 | 不实现 Lime Desktop 内部能力。 |
| `lime` | 客户端安装、运行、SDK host、UI host、storage、runtime bridge。 | 不实现 Cloud catalog / tenant 管理。 |
| `limecore` | Catalog、release、hash、license、tenant enablement、gateway、audit metadata。 | 不运行默认 Agent、不渲染 App UI、不管理本地 App storage。 |
## 架构图
```mermaid
flowchart TD
Standard[Agent App v0.2 Standard] --> Source[Local Package / Fixture]
Cloud[LimeCore Control Plane] --> Bootstrap[Optional Bootstrap Payload]
Source --> Host[Agent App Host 实验岛]
Bootstrap --> Host
Host --> Flag[Feature Flags]
Host --> Installer[Local Installer]
Host --> Manifest[Manifest Parser]
Host --> Projection[Projection + Readiness]
Host --> Store[App Package / Storage Namespace]
Host --> SDK[Capability SDK Facade]
Host --> UIHost[UI Extension Host]
Host --> Worker[Worker / Workflow Runtime]
SDK --> Mock[Mock Capability Host]
SDK --> Adapter[Thin Lime Adapters]
Adapter --> Agent[Lime Local Agent Runtime]
Adapter --> Knowledge[Agent Knowledge]
Adapter --> Tools[Tool Broker]
Adapter --> Artifacts[Artifact Store]
Adapter --> Evidence[Evidence Store]
Adapter --> Files[File Service]
Adapter --> Policy[Policy / Secrets]
UIHost --> AppUI[App UI Bundle]
Worker --> AppWorker[App Worker]
Store --> AppData[App Namespace Data]
```
关键点:
- `Host` 是实验岛,不是主路径。
- `SDK` 是稳定 facade,不是内部 API 透传。
- `Adapter` 必须薄,且可以按目录删除。
- `Cloud` 只提供元数据,不成为默认 runtime。
## 模块设计
客户端代码集中放在一个独立功能目录:
```text
src/features/agent-app/
├── README.md
├── featureFlag.ts
├── types.ts
├── manifest/
│ ├── parseManifest.ts
│ ├── normalizeManifest.ts
│ └── parseManifest.test.ts
├── projection/
│ ├── projectApp.ts
│ ├── projectionGuards.ts
│ └── projectApp.test.ts
├── readiness/
│ ├── checkReadiness.ts
│ ├── capabilityNegotiation.ts
│ └── checkReadiness.test.ts
├── install/
│ ├── packageSource.ts
│ ├── packageVerifier.ts
│ ├── localPackageStore.ts
│ ├── installedAppState.ts
│ ├── cleanupPlan.ts
│ ├── uninstallApp.ts
│ └── installFlow.test.ts
├── sdk/
│ ├── LimeAppSdk.ts
│ ├── CapabilityHost.ts
│ ├── MockCapabilityHost.ts
│ ├── capabilityErrors.ts
│ ├── provenance.ts
│ └── contract.test.ts
├── adapters/
│ ├── artifactAdapter.ts
│ ├── evidenceAdapter.ts
│ ├── knowledgeAdapter.ts
│ ├── agentRuntimeAdapter.ts
│ └── adapterGuards.ts
├── runtime/
│ ├── uiExtensionHost.ts
│ ├── workerRuntimeHost.ts
│ ├── runtimePolicy.ts
│ └── runtimeSandbox.test.ts
├── ui/
│ ├── AgentAppLabPage.tsx
│ ├── AgentAppCard.tsx
│ ├── AgentAppEntriesPanel.tsx
│ ├── AgentAppReadinessPanel.tsx
│ ├── AgentAppRunPanel.tsx
│ └── AgentAppCleanupPanel.tsx
└── fixtures/
├── content-engineering-app.json
└── content-engineering-run.fixture.json
```
主系统只允许极薄入口:
```ts
if (featureFlags.agentAppHost.labEnabled) {
registerAgentAppLabEntry()
}
```
禁止在 P0-P2 出现:
```text
agent-app 逻辑散落到 Chat 主流程
agent-app 逻辑散落到 Skill Catalog 主数据结构
agent-app 逻辑散落到 Artifact 主 schema
agent-app entry 直接进入正式命令面板
App package 直接 import src/ 内部模块
```
### Tauri / Rust 边界
如果 P0 必须增加 Rust 能力,也必须独立目录,且只服务实验岛:
```text
src-tauri/src/agent_app/
├── manifest.rs
├── installer.rs
├── projection.rs
├── readiness.rs
├── storage.rs
├── cleanup.rs
└── commands.rs
```
原则:
1. 能先用 TypeScript + local fixture 验证的,不先加 Rust 命令。
2. 必须加命令时,遵守 Lime command boundary,同步前端 gateway、Rust registration、catalog、mock。
3. Rust 只暴露安装、读取、清理等本地能力,不承载 App 业务逻辑。
## Feature Flag 策略
所有能力默认关闭,按层启用:
```ts
agentAppHost: {
labEnabled: false,
localPackageEnabled: false,
projectionEnabled: false,
readinessEnabled: false,
mockSdkEnabled: false,
localStorageEnabled: false,
realAdapterEnabled: false,
uiRuntimeEnabled: false,
workerRuntimeEnabled: false,
cloudBootstrapEnabled: false
}
```
| 开关 | 启用内容 | 默认 | 失败止血 |
|---|---|---|---|
| `labEnabled` | 显示 Agent App Lab 页面。 | off | UI 入口消失。 |
| `localPackageEnabled` | 读取本地 package / fixture。 | off | 不扫描本地 App。 |
| `projectionEnabled` | 生成 projection。 | off | 不产生派生对象。 |
| `readinessEnabled` | 检查 capability / permission / runtime。 | off | 不做启用判断。 |
| `mockSdkEnabled` | 使用 mock capability host。 | off | 不运行 mock action。 |
| `localStorageEnabled` | 创建 App namespace。 | off | 不写本地实验数据。 |
| `realAdapterEnabled` | 接入少量真实 Lime adapter。 | off | 回退 mock。 |
| `uiRuntimeEnabled` | 加载受控 UI bundle。 | off | 回退只读 projection。 |
| `workerRuntimeEnabled` | 执行受控 worker。 | off | 不跑后台任务。 |
| `cloudBootstrapEnabled` | 消费 LimeCore bootstrap。 | off | 只用本地 fixture。 |
## 数据边界
P0 使用独立物理目录,不混入现有主业务表:
```text
<LimeAppData>/agent-apps/
├── installed-apps.json
├── packages/
│ └── sha256-<package-hash>/
├── projections/
│ └── <app-id>.json
├── readiness/
│ └── <app-id>.json
├── storage/
│ └── <app-id>/
│ └── app.sqlite 或 data.json
├── artifacts/
│ └── <app-id>/
├── evidence/
│ └── <app-id>/
├── logs/
│ └── <app-id>/
└── exports/
└── <app-id>/
```
如果进入现有 Artifact / Evidence 系统,必须附带统一 provenance:
```ts
type AgentAppProvenance = {
sourceKind: 'agent_app'
appId: string
appVersion: string
packageHash: string
manifestHash: string
entryKey?: string
workflowRunId?: string
workspaceId?: string
taskId?: string
}
```
禁止:
```text
App storage 直接写主业务表
App 产物没有 sourceKind
App package hash 包含用户数据
App 升级覆盖用户 storage
App 卸载默认删除用户数据
```
## 安装流程
```mermaid
sequenceDiagram
participant User as 用户
participant Lab as Agent App Lab
participant Source as Local Package Source
participant Installer as Installer
participant Parser as Manifest Parser
participant Readiness as Readiness Checker
participant Store as App Store
participant SDK as Capability Host
User->>Lab: 选择本地 App package / fixture
Lab->>Source: 读取 package metadata
Source-->>Installer: package path + hash
Installer->>Parser: parse APP.md / manifest
Parser-->>Installer: normalized manifest
Installer->>Readiness: capability negotiation
Readiness->>SDK: 查询 host 支持能力
SDK-->>Readiness: supported / degraded / blocked
Readiness-->>Installer: readiness result
Installer->>Store: 写入 package cache / projection / readiness
Store-->>Lab: installed app state
Lab-->>User: 展示 App card / entries / blockers
```
安装器 P0 验收:
- hash / manifest hash 可计算。
- projection 是只读派生对象。
- readiness 可解释缺失能力。
- 失败不会写入半安装状态。
- 重复安装同 hash 可幂等。
## 运行流程
P0-P1 不执行 App 自带代码,只运行内置 mock action:
```mermaid
sequenceDiagram
participant User as 用户
participant Lab as Lab UI
participant Runner as Entry Runner
participant SDK as Mock Capability Host
participant Store as App Namespace
participant Artifact as Mock Artifact Store
participant Evidence as Evidence Recorder
User->>Lab: 点击 fixture entry
Lab->>Runner: runEntry(appId, entryKey)
Runner->>SDK: create scoped sdk context
SDK->>Store: 写入 app run record
SDK->>Artifact: 创建 mock artifact
SDK->>Evidence: 记录 provenance
Evidence-->>Lab: run summary
Lab-->>User: 展示产物、日志、清理入口
```
P2 之后才允许把部分 SDK capability 接到真实 adapter:
```text
MockCapabilityHost
↓ contract tests 通过
Thin Lime Adapter
↓ feature flag 启用
真实本地能力
```
## Projection 边界
Projection 是安装时生成的只读派生对象,不是全局注册:
```ts
type AgentAppProjection = {
app: AppSummary
package: PackageIdentity
entries: ProjectedEntry[]
requiredCapabilities: CapabilityRequirement[]
storage?: StorageProjection
artifacts?: ArtifactProjection[]
policies: PolicyProjection[]
readinessHints: ReadinessHint[]
provenance: AgentAppProvenance
}
```
允许:
```text
在 Lab UI 渲染 projection
用 projection 做 readiness 检查
用 projection 创建 scoped SDK context
按 projection 生成 cleanup plan
```
禁止:
```text
写入 command registry
写入 skill catalog
写入 artifact catalog
写入 workspace routes
自动注册快捷键
自动注册全局搜索结果
```
## Capability SDK 实施策略
P0 SDK 先做 facade、错误码、mock host 和 provenance,不绑定内部实现。
```ts
interface LimeAppSdk {
storage: LimeStorageCapability
files: LimeFilesCapability
agent: LimeAgentCapability
knowledge: LimeKnowledgeCapability
tools: LimeToolsCapability
artifacts: LimeArtifactsCapability
workflow: LimeWorkflowCapability
evidence: LimeEvidenceCapability
policy: LimePolicyCapability
secrets: LimeSecretsCapability
events: LimeEventsCapability
}
```
所有 capability 必须满足:
1. 不暴露 Lime internal path。
2. 所有调用都接收 scoped app context。
3. 所有错误都走稳定错误码。
4. 同一接口有 mock host 和真实 adapter 两种实现。
5. host 自动附加 provenance。
6. permission、readiness、feature flag 在 bridge 层强制拦截。
7. contract test 固定接口行为。
稳定错误码:
```text
CAPABILITY_MISSING
VERSION_UNSUPPORTED
PERMISSION_DENIED
READINESS_BLOCKED
HOST_UNAVAILABLE
APP_DISABLED
APP_STORAGE_UNAVAILABLE
APP_RUNTIME_UNSUPPORTED
APP_PACKAGE_INVALID
APP_PACKAGE_HASH_MISMATCH
APP_CLEANUP_FAILED
```
## UI Runtime 策略
P0-P2 不加载 App UI bundle,只用 Lab projection 展示。
P3 才做受控 UI Extension Host:
| 能力 | P3 范围 | 禁止 |
|---|---|---|
| `page` entry | 在受控容器中展示 App 页面。 | 直接注册主路由。 |
| `panel` entry | 在 Lab 中打开侧栏 / 面板。 | 直接改 Workspace layout。 |
| `settings` entry | 展示 App 自己的设置页。 | 修改全局设置结构。 |
| SDK 注入 | Host 注入 scoped handles。 | App import 内部模块。 |
| 权限提示 | UI 解释权限用途。 | 只靠 UI 提示,不做 runtime 拦截。 |
UI Host 最小要求:
- App UI 无法访问 Node / Tauri raw API。
- App UI 无法直接读写文件、secret、网络。
- App UI 只能通过 injected SDK bridge 访问能力。
- 关闭 `uiRuntimeEnabled` 后,所有 App 页面回退到只读 Lab 展示。
## Worker / Workflow Runtime 策略
Worker 是最高风险项,必须晚于 UI Host:
| 阶段 | Runtime 能力 | 是否执行 App 代码 |
|---|---|---|
| P0 | manifest / projection / readiness | 否 |
| P1 | mock entry action | 否,只跑内置 mock handler |
| P2 | thin adapter action | 否,仍由 Lime 内置 runner 调 adapter |
| P3 | 受控 UI bundle | 是,仅 UI 容器 |
| P4 | 受控 workflow DSL / worker | 是,必须有 policy、cancel、trace、resource limit |
Worker 禁止项:
```text
任意 JS worker
任意 npm dependency install
native binary
未声明网络访问
未声明文件系统访问
App 自带模型网关
明文 secret
不可取消长任务
无 trace 的工具调用
```
## AI 内容工程化验证切片
AI 内容工程化 App 只作为平台验证样板,不进入 Lime Core。
P4 最小闭环:
```text
创建项目
→ 保存到 app namespace
→ 选择本地 fixture 知识
→ 生成场景表 mock / local agent result
→ 生成内容资产
→ 创建内容表 Artifact
→ 记录 Evidence provenance
→ 展示 cleanup plan
→ uninstall delete data 清理
```
最小业务对象:
| 对象 | 存储位置 | 产物 |
|---|---|---|
| project | App storage namespace | 项目配置。 |
| knowledge binding | App storage + lime.knowledge ref | 三层知识库引用。 |
| scenes | App storage | 场景穷尽表。 |
| content assets | App storage | 文案 / 脚本 / 图片提示词。 |
| content table | Artifact store 或实验 artifact dir | 可导出内容表。 |
| evidence | Evidence store 或实验 evidence dir | 来源、模型、知识版本、App provenance。 |
停止条件:
- 为了跑通 P4,需要修改超过 3 个 Lime 核心主路径模块。
- App 需要绕过 SDK 直接调用 internal store。
- App 数据无法按 namespace 清理。
- 产物无法区分 `sourceKind: agent_app`。
触发停止条件时,不继续堆业务功能,回到 SDK / Host 边界设计。
## 清理与卸载
P0 必须同步实现 cleanup plan,不允许“先装上再说”。
```ts
type AppCleanupPlan = {
appId: string
packageHash: string
packageCachePaths: string[]
projectionPaths: string[]
readinessPaths: string[]
storageNamespaces: string[]
artifactRefs: string[]
evidenceRefs: string[]
taskRefs: string[]
secretRefs: string[]
logPaths: string[]
exportPaths: string[]
}
```
卸载策略:
| 策略 | 行为 | 用途 |
|---|---|---|
| Disable only | 禁用 App,保留 package 和数据。 | 临时止血。 |
| Uninstall keep data | 删除 package / projection / readiness,保留 storage / artifacts。 | 升级失败或重装。 |
| Uninstall delete data | 删除 package、projection、storage、artifacts、evidence、logs。 | 实验失败或用户明确清理。 |
| Export then delete | 先导出 storage / artifacts,再删除本地数据。 | 数据迁移。 |
清理流程:
```mermaid
flowchart TD
Start[用户选择卸载 / 路线失败] --> Plan[生成 Cleanup Plan]
Plan --> Preview[展示将删除 / 保留对象]
Preview --> Choice{用户选择策略}
Choice --> Disable[Disable only]
Choice --> Keep[Uninstall keep data]
Choice --> Delete[Uninstall delete data]
Choice --> Export[Export then delete]
Delete --> Verify[校验残留]
Keep --> Verify
Disable --> Verify
Export --> Verify
Verify --> Done[写入 uninstall record]
```
失败清理验收:
```text
rg "agent-app|AgentApp|agent_app" src src-tauri docs/roadmap/agentapp
```
路线失败时允许保留历史文档,但运行时代码、feature flag、数据目录、mock fixture 必须能被完整删除。
## 分期实施
### P0:只读 App Host 骨架
目标:证明客户端能读取 Agent App v0.2 package,并生成稳定 projection / readiness。
交付:
- `AppManifest` / `NormalizedAppManifest` 类型。
- `parseManifest` / `normalizeManifest`。
- `projectApp`。
- `checkReadiness`。
- `InstalledAppState`。
- `AgentAppProjection`。
- `AgentAppLabPage` 只读展示。
- 本地 fixture:`content-engineering-app`。
- `cleanupPlan` 类型和 dry-run。
验收:
- Lab 页面展示 App 卡片、entries、capability requirements、readiness blockers。
- 不执行 App UI / worker。
- 不写全局 registry。
- 关闭 `labEnabled` 后 UI 完全消失。
- dry-run cleanup 能列出 package / projection / readiness / storage 路径。
### P1:Mock Capability Host 与实验产物
目标:证明 App entry 可以通过 mock capability host 运行最小动作,并生成可追溯实验产物。
交付:
- `LimeAppSdk` 类型。
- `CapabilityHost` 接口。
- `MockCapabilityHost`。
- `lime.storage` mock namespace。
- `lime.artifacts.create` mock。
- `lime.evidence.record` mock。
- `uninstallApp`。
- SDK contract tests。
验收:
- 点击 fixture entry 生成 mock Artifact。
- Artifact / Evidence 带 `sourceKind: agent_app` provenance。
- uninstall delete data 删除 package / projection / readiness / storage / artifact / evidence。
- mock SDK 不依赖真实 Lime internal store。
### P2:少量真实 Adapter
目标:用最小真实能力验证 SDK facade 是否能隔离内部实现。
允许接入:
| Capability | Adapter | 原则 |
|---|---|---|
| `lime.artifacts.create` | Artifact 创建 adapter。 | 自动附加 provenance。 |
| `lime.evidence.record` | Evidence 记录 adapter。 | 可按 appId 查询。 |
| `lime.knowledge.search` | Knowledge resolver adapter。 | 只读检索。 |
| `lime.agent.startTask` | 本地 Agent Runtime adapter。 | 可 cancel / trace。 |
禁止:
- 修改 AgentChat 主流程。
- 修改 Skill Catalog 主结构。
- 修改 Artifact 主 schema。
- 把 App entry 放进正式命令面板。
验收:
- 所有 adapter 由 `realAdapterEnabled` 控制。
- 关闭真实 adapter 后可回退 mock。
- Adapter 删除后主路径仍可编译。
- Agent App 产物可按 provenance 查询和清理。
### P3:受控 UI Extension Host
目标:验证 App 可以拥有自己的 UI 表现形式,而不是只能作为专家对话框。
交付:
- `uiExtensionHost`。
- `page` entry 受控容器。
- SDK bridge 注入。
- UI permission guard。
- runtime sandbox smoke test。
验收:
- App UI 无法 import Lime internal module。
- App UI 无法直接访问 Tauri raw API、文件、secret、网络。
- 权限在 bridge 层强制拦截。
- 关闭 `uiRuntimeEnabled` 后回退 Lab projection 展示。
### P4:AI 内容工程化最小业务闭环
目标:验证 Product-level App 是否值得继续投入。
交付:
- AI 内容工程化 App fixture / demo package。
- 项目创建 UI。
- App namespace storage。
- 知识 fixture binding。
- 场景穷尽 mock / local agent result。
- 内容资产表。
- Artifact + Evidence。
- cleanup / uninstall UI。
验收:
- 业务 UI 来自 Agent App 实验岛,不写进 Lime Core。
- 数据在 app namespace 下。
- Artifact 和 Evidence 可追溯、可清理。
- 完成一次从项目创建到内容表生成再到卸载清理的闭环。
### P5:Cloud Bootstrap 接入
目标:客户端 P0-P4 成立后,再消费 LimeCore 控制面。
范围:
- Cloud 下发 catalog、release metadata、package hash、tenant enablement、license、policy defaults。
- Desktop 仍负责本地 install、projection、readiness、runtime。
- 本地 JSON source 和 Cloud payload 使用同一 adapter。
验收:
- 断网时已安装 App 仍可用。
- Cloud disable 后客户端禁用 App,但不删除用户数据。
- package hash / manifest hash 校验失败时拒绝启用。
- tenant enablement 不绕过本地 readiness / permission guard。
## 任务拆分
| 阶段 | 任务 | 主要文件 | 验证 |
|---|---|---|---|
| P0.1 | 建立类型和 feature flag。 | `types.ts`、`featureFlag.ts` | typecheck。 |
| P0.2 | manifest parser / normalizer。 | `manifest/*` | parser unit tests。 |
| P0.3 | projection / readiness。 | `projection/*`、`readiness/*` | projection tests。 |
| P0.4 | Lab 只读 UI。 | `ui/*` | UI test / GUI smoke。 |
| P0.5 | cleanup dry-run。 | `install/cleanupPlan.ts` | cleanup tests。 |
| P1.1 | SDK facade。 | `sdk/LimeAppSdk.ts` | type contract。 |
| P1.2 | Mock host。 | `sdk/MockCapabilityHost.ts` | contract tests。 |
| P1.3 | mock artifact / evidence。 | `sdk/*`、`install/*` | run entry test。 |
| P2.1 | artifact / evidence adapter。 | `adapters/*` | adapter tests。 |
| P2.2 | knowledge / agent adapter。 | `adapters/*` | targeted integration test。 |
| P3.1 | UI host。 | `runtime/uiExtensionHost.ts` | sandbox smoke。 |
| P4.1 | 内容工程化切片。 | `fixtures/*`、`ui/*` | end-to-end demo。 |
## 验证策略
最低验证层级:
| 改动 | 必跑 |
|---|---|
| 类型 / parser / projection | 定向单测 + `npm run typecheck` 或项目等价命令。 |
| Capability SDK | contract tests。 |
| Tauri command | `npm run test:contracts`。 |
| 用户可见 UI | 相关 `*.test.tsx` + GUI smoke。 |
| 主路径接入 | `npm run verify:local` + `npm run verify:gui-smoke`。 |
进入正式主路径前必须满足:
1. `npm run verify:local` 通过。
2. `npm run verify:gui-smoke` 覆盖 Lab 入口和关闭 flag 场景。
3. SDK contract tests 覆盖 P0 capabilities。
4. cleanup / uninstall tests 覆盖 keep data、delete data、export then delete。
5. Artifact / Evidence provenance 查询和删除通过。
## 主路径接入门槛
只有满足以下条件,Agent App 才能从 Lab 进入正式产品入口:
1. 至少一个 Product-level App 完成 P4 闭环。
2. cleanup plan 经验证能删除所有实验产物。
3. Capability SDK contract tests 覆盖核心 capability。
4. 所有 App 产物都有 provenance。
5. 不需要修改 AgentChat、Skill Catalog、Artifact 主 schema。
6. UI / worker runtime 安全评审通过。
7. 用户数据保留、导出和删除策略明确。
8. Cloud bootstrap 失败不影响本地已安装 App。
不满足时,保持 Lab 实验,不进入正式入口。
## 失败退出方案
如果 Agent App 路线停止,按以下顺序清理:
```text
1. 关闭 agentAppHost.* feature flags
2. 删除主系统极薄入口 registerAgentAppLabEntry
3. 删除 src/features/agent-app/
4. 删除 src-tauri/src/agent_app/ 如果存在
5. 删除 <LimeAppData>/agent-apps/
6. 删除 docs/roadmap/agentapp/ 或保留为 archived roadmap
7. 删除 .gitignore 中 docs/roadmap/agentapp 例外
8. 清理 sourceKind = agent_app 的 Artifact / Evidence / Task / Log
9. 删除 mock fixture 和 test harness
10. 运行验证,确认 Chat / Skill / Artifact / Workspace 主流程无行为变化
```
失败退出成功标准:
- 普通 Chat、Skill、Artifact、Workspace 流程无行为变化。
- 非 Agent App 用户数据未被删除。
- 运行时代码中不再有 Agent App 入口。
- 只剩历史文档或 archived roadmap。
## 决策点
| 决策点 | 时间 | 判断问题 | Go 条件 | No-Go 动作 |
|---|---|---|---|---|
| D0 | P0 结束 | Manifest / projection 是否稳定? | fixture 可稳定 project。 | 停止 runtime 设计,收缩标准。 |
| D1 | P1 结束 | SDK facade 是否足够表达业务? | mock entry 生成可追溯产物。 | 重写 SDK,不接真实 adapter。 |
| D2 | P2 结束 | Adapter 是否足够薄? | 删除 adapter 不影响主路径。 | 回退 mock,重画边界。 |
| D3 | P3 结束 | UI Host 是否安全可控? | App UI 不能越权访问资源。 | 暂停 UI bundle,保留 projection UI。 |
| D4 | P4 结束 | Product-level App 是否有平台价值? | 内容工程化闭环跑通。 | 清理实验岛,不进入正式入口。 |
| D5 | P5 结束 | Cloud bootstrap 是否只是控制面? | 断网已安装 App 可用。 | Cloud 回退为 catalog-only。 |
## 下一刀
P0 技术设计已落到 [p0-technical-design.md](./p0-technical-design.md)。下一刀只按该设计落最小只读闭环,不做市场页、不做 Cloud、不做完整 AI 内容工程化业务:
```text
Feature flags
→ AppManifest / NormalizedAppManifest
→ PackageIdentity
→ AgentAppProjection
→ ReadinessResult
→ CapabilityRequirement
→ AgentAppProvenance
→ AppCleanupPlan
→ content-engineering local fixture
→ Lab readonly projection UI
```
完成 P0 后再决定是否进入 Mock SDK。没有 P0 的 cleanup dry-run,不进入 P1。
@@ -0,0 +1,584 @@
# Agent App P0 技术设计
更新时间:2026-05-15
## 一句话目标
P0 只做“只读 App Host 骨架”:客户端能读取一个 Agent App v0.2 本地 package / fixture,得到稳定的 manifest、projection、readiness、cleanup dry-run,并在 Lab 页面展示出来;不执行 App UI,不执行 App worker,不写入主路径 registry。
P0 成功后,才允许进入 P1 Mock Capability Host。没有 cleanup dry-run,不进入 P1。
## P0 范围
| 范围 | 做 | 不做 |
|---|---|---|
| Package source | 读取本地 fixture / local package metadata。 | 不接 Cloud 下载,不做 marketplace。 |
| Manifest | parse、normalize、校验核心字段。 | 不执行 APP.md 中引用的 runtime 文件。 |
| Projection | 生成只读派生对象。 | 不注册到 command / skill / artifact / route 全局 registry。 |
| Readiness | 检查 host capability、runtime target、权限声明、storage 声明。 | 不弹真实授权,不执行 migration。 |
| Cleanup | 生成 dry-run cleanup plan。 | P0 不真实删除用户数据,除非后续进入卸载功能。 |
| UI | Lab 只读展示 App、entries、readiness、cleanup plan。 | 不加载 App UI bundle。 |
## 设计原则
1. **只读优先**:P0 不执行 App 代码,只处理声明和派生对象。
2. **显式降级**:缺 capability 时给出 `blocked` / `degraded` / `ready`,不静默失败。
3. **派生对象可重建**:projection、readiness、cleanup plan 都可由 package + host profile 重新生成。
4. **主路径零污染**:不写 global registry,不修改 Chat / Skill / Artifact 主模型。
5. **可删除优先**:每个 P0 产物都有 appId、packageHash、路径或 ref,方便后续清理。
## P0 数据流
```mermaid
flowchart TD
Fixture[Local Package / Fixture] --> Identity[compute PackageIdentity]
Fixture --> Parse[parseManifest]
Parse --> Normalize[normalizeManifest]
Normalize --> Validate[validateManifest]
Validate --> Project[projectApp]
Project --> Readiness[checkReadiness]
Project --> Cleanup[buildCleanupPlan]
Readiness --> State[InstalledAppPreview]
Cleanup --> State
State --> Lab[AgentAppLabPage]
```
P0 不产生正式安装状态,只产生 `InstalledAppPreview`。
## 文件边界
```text
src/features/agent-app/
├── featureFlag.ts
├── types.ts
├── manifest/
│ ├── parseManifest.ts
│ ├── normalizeManifest.ts
│ ├── validateManifest.ts
│ └── parseManifest.test.ts
├── projection/
│ ├── projectApp.ts
│ ├── projectionGuards.ts
│ └── projectApp.test.ts
├── readiness/
│ ├── hostCapabilityProfile.ts
│ ├── checkReadiness.ts
│ ├── capabilityNegotiation.ts
│ └── checkReadiness.test.ts
├── install/
│ ├── packageIdentity.ts
│ ├── packageSource.ts
│ ├── installedAppPreview.ts
│ ├── cleanupPlan.ts
│ └── cleanupPlan.test.ts
├── ui/
│ ├── AgentAppLabPage.tsx
│ ├── AgentAppCard.tsx
│ ├── AgentAppEntriesPanel.tsx
│ ├── AgentAppReadinessPanel.tsx
│ └── AgentAppCleanupPanel.tsx
└── fixtures/
└── content-engineering-app.json
```
P0 如果能全部在前端完成,就不增加 Tauri command。只有本地 package hash、文件枚举或 app data 路径必须走系统能力时,才增加 `src-tauri/src/agent_app/`。
## Feature Flag
P0 只需要前五个开关:
```ts
type AgentAppHostFlags = {
labEnabled: boolean
localPackageEnabled: boolean
projectionEnabled: boolean
readinessEnabled: boolean
cleanupDryRunEnabled: boolean
mockSdkEnabled: false
localStorageEnabled: false
realAdapterEnabled: false
uiRuntimeEnabled: false
workerRuntimeEnabled: false
cloudBootstrapEnabled: false
}
```
P0 默认:
```ts
const defaultAgentAppHostFlags: AgentAppHostFlags = {
labEnabled: false,
localPackageEnabled: false,
projectionEnabled: false,
readinessEnabled: false,
cleanupDryRunEnabled: false,
mockSdkEnabled: false,
localStorageEnabled: false,
realAdapterEnabled: false,
uiRuntimeEnabled: false,
workerRuntimeEnabled: false,
cloudBootstrapEnabled: false
}
```
验收要求:关闭 `labEnabled` 后,所有 Agent App UI 入口消失。
## 核心类型
### PackageIdentity
```ts
type PackageSourceKind = 'fixture' | 'local_folder' | 'local_archive' | 'cloud_release'
type PackageIdentity = {
sourceKind: PackageSourceKind
sourceUri: string
appId: string
appVersion: string
packageHash: string
manifestHash: string
loadedAt: string
}
```
P0 只实现 `fixture`,可预留 `local_folder` 类型但不接 UI。
### AppManifest
`AppManifest` 对齐 Agent App v0.2 声明,P0 只解析核心字段:
```ts
type AppManifest = {
manifestVersion: string
name: string
version: string
status?: 'draft' | 'preview' | 'stable' | 'deprecated'
appType?: 'domain-app' | 'tool-app' | 'expert-pack' | 'workflow-app'
description?: string
runtimeTargets?: RuntimeTarget[]
requires?: AppRequires
runtimePackage?: RuntimePackageDeclaration
capabilities?: string[]
permissions?: PermissionDeclaration[]
entries: AppEntry[]
storage?: StorageDeclaration
knowledgeTemplates?: KnowledgeTemplateDeclaration[]
artifacts?: ArtifactDeclaration[]
policies?: PolicyDeclaration[]
}
```
P0 校验规则:
| 字段 | P0 规则 |
|---|---|
| `manifestVersion` | 必须存在,支持 `0.2.x`。 |
| `name` | 必须存在,作为 appId 原始来源。 |
| `version` | 必须存在,语义化版本或标准允许的版本字符串。 |
| `runtimeTargets` | 缺省按 `local` 处理;P0 只允许 `local`。 |
| `requires.capabilities` | 可以为空;为空时 readiness 显示无额外能力需求。 |
| `entries` | 必须至少一个;P0 只投影,不执行。 |
| `storage` | 可选;存在时只生成 storage projection,不创建真实 namespace。 |
### NormalizedAppManifest
normalize 的目标是消除可选字段和命名差异,让后续 projection 不重复判断:
```ts
type NormalizedAppManifest = {
manifestVersion: '0.2'
appId: string
displayName: string
version: string
status: 'draft' | 'preview' | 'stable' | 'deprecated'
appType: 'domain-app' | 'tool-app' | 'expert-pack' | 'workflow-app'
description: string
runtimeTargets: RuntimeTarget[]
requires: NormalizedRequires
runtimePackage: NormalizedRuntimePackage
permissions: PermissionDeclaration[]
entries: NormalizedAppEntry[]
storage?: NormalizedStorageDeclaration
knowledgeTemplates: KnowledgeTemplateDeclaration[]
artifacts: ArtifactDeclaration[]
policies: PolicyDeclaration[]
}
```
normalize 不做 readiness 判断,只做结构归一化。
### AppEntry
```ts
type AppEntryKind =
| 'page'
| 'panel'
| 'expert-chat'
| 'command'
| 'workflow'
| 'artifact-viewer'
| 'background-task'
| 'settings'
type NormalizedAppEntry = {
key: string
kind: AppEntryKind
title: string
description?: string
route?: string
workflow?: string
persona?: string
requiredCapabilities: string[]
permissions: string[]
enabledByDefault: boolean
}
```
P0 entry 只展示,不注册、不执行。
## Projection 设计
Projection 是只读派生对象:
```ts
type AgentAppProjection = {
app: AppSummary
package: PackageIdentity
entries: ProjectedEntry[]
requiredCapabilities: CapabilityRequirement[]
runtimePackage: RuntimePackageProjection
storage?: StorageProjection
knowledgeBindings: KnowledgeBindingProjection[]
artifactTypes: ArtifactProjection[]
policies: PolicyProjection[]
readinessHints: ReadinessHint[]
provenance: AgentAppProvenance
}
```
### AppSummary
```ts
type AppSummary = {
appId: string
displayName: string
version: string
status: string
appType: string
description: string
}
```
### ProjectedEntry
```ts
type ProjectedEntry = {
appId: string
key: string
kind: AppEntryKind
title: string
description?: string
presentation: 'lab-only' | 'eligible-for-main-entry'
readiness: 'unknown' | 'ready' | 'degraded' | 'blocked'
requiredCapabilities: CapabilityRequirement[]
provenance: AgentAppProvenance
}
```
P0 所有 entry 的 `presentation` 都是 `lab-only`。
### CapabilityRequirement
```ts
type CapabilityRequirement = {
capability: string
requestedRange: string
required: boolean
declaredBy: 'requires' | 'entry' | 'storage' | 'policy' | 'runtimePackage'
}
```
Projection 生成规则:
1. 从 `requires.capabilities` 收集全局能力。
2. 从 entry 收集入口级能力。
3. 从 `storage` 自动推导 `lime.storage`。
4. 从 `runtimePackage.ui` 自动推导 `lime.ui`。
5. 从 `runtimePackage.worker` / workflow 自动推导 `lime.workflow` 或 `lime.agent`,但 P0 只标记,不运行。
6. 去重后保留来源信息;相同 capability 多来源时合并 `declaredBy` 或保留多条 detail。
## Readiness 设计
Readiness 只回答“当前 Host 能否启用这个 App / entry”,不执行修复动作。
```ts
type ReadinessStatus = 'ready' | 'degraded' | 'blocked'
type ReadinessResult = {
appId: string
status: ReadinessStatus
checkedAt: string
blockers: ReadinessIssue[]
warnings: ReadinessIssue[]
supportedCapabilities: CapabilitySupport[]
missingCapabilities: CapabilityRequirement[]
entryReadiness: EntryReadiness[]
}
```
### ReadinessIssue
```ts
type ReadinessIssue = {
code:
| 'MANIFEST_VERSION_UNSUPPORTED'
| 'RUNTIME_TARGET_UNSUPPORTED'
| 'CAPABILITY_MISSING'
| 'CAPABILITY_VERSION_UNSUPPORTED'
| 'PERMISSION_REQUIRED'
| 'STORAGE_DECLARED_BUT_DISABLED'
| 'UI_RUNTIME_DISABLED'
| 'WORKER_RUNTIME_DISABLED'
| 'PACKAGE_HASH_MISSING'
| 'PACKAGE_HASH_MISMATCH'
severity: 'blocker' | 'warning'
message: string
capability?: string
entryKey?: string
}
```
### HostCapabilityProfile
P0 使用静态 profile,不读取真实 runtime:
```ts
type HostCapabilityProfile = {
appRuntimeVersion: string
runtimeTargets: RuntimeTarget[]
capabilities: Record<string, {
version: string
enabled: boolean
implementation: 'none' | 'mock' | 'adapter' | 'native'
}>
featureFlags: AgentAppHostFlags
}
```
P0 默认 profile:
```ts
const p0HostCapabilityProfile: HostCapabilityProfile = {
appRuntimeVersion: '0.2.0',
runtimeTargets: ['local'],
capabilities: {
'lime.ui': { version: '0.1.0', enabled: false, implementation: 'none' },
'lime.storage': { version: '0.1.0', enabled: false, implementation: 'none' },
'lime.agent': { version: '0.1.0', enabled: false, implementation: 'none' },
'lime.knowledge': { version: '0.1.0', enabled: false, implementation: 'none' },
'lime.artifacts': { version: '0.1.0', enabled: false, implementation: 'none' },
'lime.evidence': { version: '0.1.0', enabled: false, implementation: 'none' }
},
featureFlags: defaultAgentAppHostFlags
}
```
Readiness 判定:
| 情况 | 状态 |
|---|---|
| manifest 版本不支持 | `blocked` |
| runtime target 不含 `local` | `blocked` |
| 必需 capability 缺失 | `blocked` |
| 可选 capability 缺失 | `degraded` |
| storage 声明存在但 `localStorageEnabled=false` | `degraded`,P0 不创建 namespace。 |
| UI entry 存在但 `uiRuntimeEnabled=false` | `degraded`,Lab 只读展示。 |
| worker / background-task 存在但 `workerRuntimeEnabled=false` | `degraded`,Lab 只读展示。 |
| 仅有 expert-chat 但 mock SDK 未启用 | `degraded`,不运行。 |
## Cleanup Dry-run 设计
P0 只生成清理计划,不删除文件。
```ts
type AppCleanupPlan = {
mode: 'dry-run'
appId: string
packageHash: string
generatedAt: string
packageCachePaths: CleanupTarget[]
projectionPaths: CleanupTarget[]
readinessPaths: CleanupTarget[]
storageNamespaces: CleanupTarget[]
artifactRefs: CleanupTarget[]
evidenceRefs: CleanupTarget[]
taskRefs: CleanupTarget[]
secretRefs: CleanupTarget[]
logPaths: CleanupTarget[]
exportPaths: CleanupTarget[]
warnings: CleanupWarning[]
}
type CleanupTarget = {
kind: 'path' | 'namespace' | 'ref'
value: string
exists: boolean | 'unknown'
safeToDelete: boolean
reason: string
}
```
P0 cleanup plan 来源:
| 目标 | 来源 |
|---|---|
| package cache | `PackageIdentity.packageHash`。 |
| projection | `appId`。 |
| readiness | `appId`。 |
| storage namespace | `manifest.storage.namespace` 或 `appId`。 |
| artifacts | P0 无真实 artifact,返回空数组。 |
| evidence | P0 无真实 evidence,返回空数组。 |
| secrets | P0 不申请 secret,返回空数组。 |
| logs | `<LimeAppData>/agent-apps/logs/<appId>`。 |
P0 Lab 必须展示 cleanup dry-run,让用户和开发者知道未来失败时会删哪些东西。
## InstalledAppPreview
P0 不写正式 installed app registry,只生成 preview:
```ts
type InstalledAppPreview = {
identity: PackageIdentity
manifest: NormalizedAppManifest
projection: AgentAppProjection
readiness: ReadinessResult
cleanupPlan: AppCleanupPlan
}
```
后续 P1 / P2 若需要持久化,再引入 `InstalledAppState`:
```ts
type InstalledAppState = InstalledAppPreview & {
installedAt: string
enabled: boolean
source: PackageSourceKind
installState: 'installed' | 'disabled' | 'blocked' | 'uninstalling'
}
```
## Lab UI 设计
P0 Lab 页面只做开发者 / 内测入口:
```text
Agent App Lab
├── Package Source
│ └── content-engineering-app fixture
├── App Card
│ ├── name / version / status / packageHash
│ └── readiness badge
├── Entries
│ ├── page / expert-chat / workflow / background-task
│ └── lab-only 标记
├── Capability Requirements
│ ├── supported
│ ├── missing
│ └── degraded
├── Readiness Issues
│ ├── blockers
│ └── warnings
└── Cleanup Dry-run
├── package / projection / storage / logs
└── safe-to-delete 标记
```
P0 UI 不出现“运行 App”“打开 App 页面”“启用 App 到主导航”等按钮。最多可以出现 disabled button,并说明需要 P1/P3。
## Fixture 要求
`content-engineering-app.json` 应该覆盖足够多字段,避免 P0 只验证玩具样例:
```json
{
"manifestVersion": "0.2.0",
"name": "shenlan-content-engineering",
"version": "0.1.0",
"status": "draft",
"appType": "domain-app",
"description": "AI 内容工程化 App fixture",
"runtimeTargets": ["local"],
"requires": {
"lime": {
"appRuntime": ">=0.2.0 <1.0.0"
},
"capabilities": {
"lime.ui": "^0.1.0",
"lime.storage": "^0.1.0",
"lime.agent": "^0.1.0",
"lime.knowledge": "^0.1.0",
"lime.artifacts": "^0.1.0",
"lime.evidence": "^0.1.0"
}
},
"runtimePackage": {
"ui": { "path": "./dist/ui" },
"worker": { "path": "./dist/worker" }
},
"storage": {
"namespace": "shenlan-content-engineering",
"schema": "./storage/schema.json",
"migrations": "./storage/migrations"
},
"entries": [
{ "key": "dashboard", "kind": "page", "title": "项目首页", "route": "/dashboard" },
{ "key": "content_strategist", "kind": "expert-chat", "title": "内容策略专家", "persona": "./agents/content-strategist.md" },
{ "key": "scene_exhaustion", "kind": "workflow", "title": "场景穷尽", "workflow": "./workflows/exhaust-scenes.workflow.md" }
]
}
```
注意:fixture 是 manifest fixture,不是业务实现;P4 才需要 demo package。
## P0 测试用例
| 测试 | 输入 | 期望 |
|---|---|---|
| parse valid fixture | content engineering fixture | 返回 `AppManifest`。 |
| reject missing entries | 无 entries | manifest validation error。 |
| normalize defaults | 缺 status / runtimeTargets | 默认 draft / local。 |
| project entries | page + expert-chat + workflow | 生成 3 个 `ProjectedEntry`。 |
| derive storage capability | 有 storage 声明 | requiredCapabilities 包含 `lime.storage`。 |
| readiness blocked | 不支持 manifestVersion | status `blocked`。 |
| readiness degraded | UI runtime 关闭但有 page entry | status `degraded`。 |
| cleanup dry-run | appId + packageHash | 返回 package / projection / storage / logs targets。 |
| flag off hides UI | `labEnabled=false` | 不展示 Lab entry。 |
## P0 验收标准
P0 完成必须同时满足:
1. 本地 fixture 可以 parse / normalize / project。
2. Lab 页面展示 app card、entries、capability requirements、readiness、cleanup dry-run。
3. 关闭 `labEnabled` 后没有任何可见入口。
4. Projection 不写入任何全局 registry。
5. 不执行 App UI bundle / worker / workflow。
6. 不创建真实 App storage namespace。
7. cleanup dry-run 能列出未来会删除的 package / projection / readiness / storage / logs。
8. 单测覆盖 parser、projection、readiness、cleanup plan。
## 进入 P1 的门槛
只有 P0 验收通过后,才能进入 P1:
```text
LimeAppSdk types
→ CapabilityHost interface
→ MockCapabilityHost
→ mock storage / artifact / evidence
→ runEntry 内置 mock action
→ uninstall delete data
```
P1 之前不接真实 Agent Runtime,不接真实 Artifact Store,不接 UI bundle。
+9
View File
@@ -1318,3 +1318,12 @@ Patch Layer 不能无限期作为事实源。建议退出条件:
306. 2026-05-13 Agent Chat QueuedTurnsPanel 排队消息 chrome source fallback 收口续测:`src/components/agent/chat/components/Inputbar/components/inputbarQueuedTurnsCopy.ts` 新增 `InputbarQueuedTurnsCopy` / `InputbarQueuedTurnsCopyKey` / `buildInputbarQueuedTurnsCopy()` 作为常规输入区排队消息面板文案事实源,`src/components/agent/chat/components/Inputbar/components/QueuedTurnsPanel.tsx` 通过 `useTranslation("agent")` 把稍后处理数量、依次开始提示、空白输入 fallback、查看 / 收起、附图数量、纯文本请求、立即执行 / 切换中、移除 / 正在移除等按钮与 aria chrome 从组件内中文 literal / template literal 改为 `agentChat.inputbar.queuedTurns.*` key-based 读取。同步 5 locale `agent.json` 新增 12 个 `agentChat.inputbar.queuedTurns.*` 正式资源,`src/i18n/__tests__/loadNamespace.test.ts` / `types.test.ts` 补充 header / action / meta 主键与 `count` 插值覆盖;`src/components/agent/chat/components/Inputbar/components/QueuedTurnsPanel.test.tsx` 接入真实 locale 初始化,并新增 en-US 回归验证 `Handle later 1`、`Starts in order`、`Blank input`、`2 images attached`、`Run queued message now` 与 `Remove queued message` 均来自资源,同时保留 promote / remove / loading 状态行为断言。queued turn 的 message preview/text、position、image_count 与 queued_turn_id 继续作为 runtime 数据保留,不迁入资源。验证:目标 fallback 扫描 `rg -n "稍后处理|会依次开始|空白输入|收起|查看|附图|纯文本请求|插队立即执行|切换中|立即执行|移除排队消息|正在移除排队消息|defaultValue|t\([^\n]*,\s*[\"']" "src/components/agent/chat/components/Inputbar/components/QueuedTurnsPanel.tsx" "src/components/agent/chat/components/Inputbar/components/inputbarQueuedTurnsCopy.ts"` 无命中;5 个 `agent.json` 的 `python3 -m json.tool` 解析通过;`npm test -- "src/components/agent/chat/components/Inputbar/components/QueuedTurnsPanel.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、11 个用例;`npx eslint --max-warnings 0 "src/components/agent/chat/components/Inputbar/components/inputbarQueuedTurnsCopy.ts" "src/components/agent/chat/components/Inputbar/components/QueuedTurnsPanel.tsx" "src/components/agent/chat/components/Inputbar/components/QueuedTurnsPanel.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 与 `npm run detect-translations -- --verbose`(5 locale、6 namespace、6546 source keys)通过。`npm run typecheck` 仍被非本轮 dirty `src/components/image-gen/useImageGen.ts:1773` 的 `input` 类型错误,以及非本轮 `src/lib/api/asrProvider.test.ts` 引用已不存在导出 `closeVoiceWindow` / `openInputWithText` / `openVoiceWindow` 阻断;`npm run verify:local` 已通过 `verify:app-version`,随后仍在 `npm run lint` 被非本轮 dirty `src/components/agent/chat/hooks/useAsterAgentChat.ts` 未使用导入 `DEFAULT_AGENT_MODEL` / `DEFAULT_AGENT_PROVIDER` 阻断,因此本条不宣称新的完整 typecheck / verify:local / GUI smoke 绿色。本条属于 Agent Chat 常规输入区排队消息面板普通前端 i18n chrome 收口,不新增或修改 Tauri 命令、Bridge、DevBridge mock、`agentCommandCatalog`、`mockPriorityCommands` 或 `defaultMocks`;后续仍需继续知识资料中枢、TeamSelectorPanel、InputbarModelExtra、InputbarWorkflowStatusPanel / ExecutionStrategySelect、Agent Chat runtime / projection / helper 路径、Workspace / Browser Runtime 长尾、P3 自动化治理和 legacy Patch 删除门禁。
307. 2026-05-14 Agent Chat Inputbar 知识资料中枢 source fallback 收口续测:`src/components/agent/chat/components/Inputbar/knowledge/InputbarKnowledgeControl.tsx` 接入 `useTranslation("agent")` 与 `formatList()`,把项目资料 toggle 的 aria / title / label、资料中枢标题说明、主资料 / 协同资料分区、默认 / 协同 badge、已确认 meta、自动搭配人设资料提示、待确认数量提示、关闭 / 检查 / 补充资料操作文案全部收口到 `agentChat.inputbar.knowledge.*`;`src/components/agent/chat/components/Inputbar/knowledge/knowledgeHubState.ts` 不再返回中文 presentation 文案,而是返回稳定 key + interpolation values,由组件统一翻译。项目资料名称、packName、runtimeMode、companionPacks、选择状态与发送 request metadata 继续作为 runtime / API 数据保留,不迁入静态资源。同步 5 locale `agent.json` 复用既有 state/action 资源并补齐 22 个知识资料 chrome / menu / meta key;`src/i18n/__tests__/loadNamespace.test.ts` / `types.test.ts` 覆盖 fallback label、enabledWithCompanions、state.using、pendingNotice、check action 与 companion meta;`src/components/agent/chat/components/Inputbar/index.test.tsx` 新增 en-US 真实资源回归,验证 Knowledge: Brand knowledge +1、Open project knowledge、Main knowledge、Default、Reviewed and ready for generation、Persona knowledge added automatically、Companion knowledge、Close knowledge、Add to knowledge 与 Check knowledge 均来自 `agent` namespace;`knowledgeHubState.test.ts` 改为断言 copy key / values,防止 state helper 重新沉淀源码中文。验证:`npm test -- "src/components/agent/chat/components/Inputbar/index.test.tsx" "src/components/agent/chat/components/Inputbar/knowledge/knowledgeHubState.test.ts" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 4 个文件、77 个用例;`npx eslint --max-warnings 0 "src/components/agent/chat/components/Inputbar/knowledge/InputbarKnowledgeControl.tsx" "src/components/agent/chat/components/Inputbar/knowledge/knowledgeHubState.ts" "src/components/agent/chat/components/Inputbar/knowledge/knowledgeHubState.test.ts" "src/components/agent/chat/components/Inputbar/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过;`npm run detect-translations -- --verbose` 通过,当前资源口径为 5 个 locale、6 个 namespace、6511 个 source key;5 个 `agent.json` 的 `python3 -m json.tool` 解析通过;目标文件中文 / `defaultValue` / `t(key, "中文")` fallback 扫描无命中;`npm run typecheck` 与定向 `git diff --check` 通过。额外尝试 `npm run verify:local`,已通过 `verify:app-version`、`lint`、`typecheck`、smart Vitest 前端批次、Rust 测试以及 GUI smoke 的 workspace-ready / browser-runtime / site-adapters / agent-service-skill-entry / agent-runtime-tool-surface / agent-runtime-tool-surface-page,但最终 `smoke:at-command-registry` 在 `stage=submit-image-command` 后超过 630000ms 超时并退出 124,因此本条不宣称完整统一入口绿色。本条属于 Agent Chat 常规输入区知识资料中枢普通前端 i18n chrome 收口,不新增或修改 Tauri 命令、Bridge、DevBridge mock、`agentCommandCatalog`、`mockPriorityCommands` 或 `defaultMocks`;执行时工作区仍存在多组非本轮 Rust / Agent Chat / 图片任务 dirty 改动与 Playwright snapshot 删除,本条未接手、未回滚,后续仍需继续 TeamSelectorPanel、InputbarModelExtra、InputbarWorkflowStatusPanel / ExecutionStrategySelect、Agent Chat runtime / projection / helper 路径、Workspace / Browser Runtime 长尾、P3 自动化治理和 legacy Patch 删除门禁。
308. 2026-05-14 Agent Chat Inputbar TeamSelector / TeamSelectorPanel source fallback 收口续测:新增 `src/components/agent/chat/components/Inputbar/components/inputbarTeamSelectorCopy.ts` 作为常规输入区分工选择器文案事实源,`TeamSelector.tsx` 与 `TeamSelectorPanel.tsx` 通过 `useTranslation("agent")` 构建 copy,把触发按钮、Suspense loading、header / scope、当前选择、搜索 placeholder、推荐 / 自定义 / 系统模板区、卡片 badge / 详情 / aria-title、inspector 操作、editor 标题 / 输入 placeholder / role 控制 / profile 与 skill 帮助、toast 成功失败与 summary prefix 全部迁移到 `agentChat.inputbar.teamSelector.*` key-based 读取;自定义空白 draft 的默认角色 label / summary 也进入资源。团队模板 label / description / role summary、内置 profile / skill label、roleKey / skillId、用户自定义团队名称描述、底层 error message 与输入内容继续作为 runtime / builtin data / 用户数据边界保留,不误迁为静态资源。同步 5 locale `agent.json` 新增 81 个 `agentChat.inputbar.teamSelector.*` 正式资源,`src/i18n/__tests__/loadNamespace.test.ts` / `types.test.ts` 补充 trigger / toast / editor / section 等 key 与 interpolation 覆盖;新增 `src/components/agent/chat/components/Inputbar/components/TeamSelector.test.tsx`,并扩展 `TeamSelectorPanel.test.tsx`,用真实 en-US / zh-CN locale 回归触发按钮、Panel chrome、editor placeholder、推荐分工区、保存自定义分工 profileId / roleKey / skillIds 与项目级删除持久化。验证:`npm exec prettier -- --write ...` 已格式化本刀文件;`npm test -- "src/components/agent/chat/components/Inputbar/components/TeamSelectorPanel.test.tsx" "src/components/agent/chat/components/Inputbar/components/TeamSelector.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 4 个文件、13 个用例;`npx eslint --max-warnings 0 "src/components/agent/chat/components/Inputbar/components/inputbarTeamSelectorCopy.ts" "src/components/agent/chat/components/Inputbar/components/TeamSelectorPanel.tsx" "src/components/agent/chat/components/Inputbar/components/TeamSelector.tsx" "src/components/agent/chat/components/Inputbar/components/TeamSelectorPanel.test.tsx" "src/components/agent/chat/components/Inputbar/components/TeamSelector.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过;`npm run detect-translations -- --verbose` 通过,当前资源口径为 5 个 locale、6 个 namespace、6717 个 source key;5 个 `agent.json` 的 `python3 -m json.tool` 解析通过;目标文件中文 / `defaultValue` / `t(key, "中文")` fallback 扫描无命中;`npm run typecheck` 与本刀定向 `git diff --check` 通过。本轮同时复核了用户提出的 `agent.json` 规模问题:当前 5 个 locale 的 `agent.json` 均为 2542 行,`src/i18n/loadNamespace.ts` 仍以 `import.meta.glob("./resources/*/*.json", { eager: true, import: "default" })` 只加载平级 namespace 文件;结合 i18next namespace、react-i18next multiple translation files、Vite glob import 与 i18next TypeScript 大资源指导,建议下一刀专门做平级 namespace split,而不是在本条 TeamSelector 小刀里顺手深改 loader:优先新增 `agentInputbar.json`、`agentHome.json`、`agentMessageList.json`、`agentSkills.json`、`agentRuntime.json`、`agentTeamWorkspace.json`,`agent.json` 只保留共享 / 过渡 key;第一阶段仍 eager,待资源分布稳定后再评估 lazy chunk 与 selector API。本条属于 Agent Chat 常规输入区分工选择器普通前端 i18n chrome 收口,不新增或修改 Tauri 命令、Bridge、DevBridge mock、`agentCommandCatalog`、`mockPriorityCommands` 或 `defaultMocks`;未重复执行 `npm run verify:local` / GUI smoke,后续仍需继续 InputbarModelExtra、InputbarWorkflowStatusPanel / ExecutionStrategySelect、Agent Chat runtime / projection / helper 路径,以及专门的 `agent` namespace split 设计 / 实施刀。
309. 2026-05-15 Agent namespace 物理拆分 / 逻辑兼容收口:按记录 308 的 Context7 / 官方资料判断,完成 `agent.json` 首阶段 namespace split,但采用 KISS / YAGNI 的兼容实现:业务组件继续 `useTranslation("agent")`,不大面积改成多个逻辑 namespace;`src/i18n/loadNamespace.ts` 新增 `NAMESPACE_RESOURCE_PARTS`,把平级物理 shard `agentHome.json`、`agentInputbar.json`、`agentMessageList.json`、`agentRuntime.json`、`agentSkills.json`、`agentTeamWorkspace.json` 与保留的 `agent.json` 合并为逻辑 `agent` namespace。`src/i18n/types.d.ts` 同步把 7 个 zh-CN agent shard 合并为 `AgentResources`,新增 `src/i18n/agentResources.ts` 作为需要 source resource 的测试 / helper 的唯一合并事实源,避免继续直接 import 拆薄后的 `resources/zh-CN/agent.json` 并误以为它包含全量 agent key;相关 source-resource helper / 测试已改为引用 `agentZhCNResource` / `agentEnUSResource` 或 `AgentI18nResource`。拆分后 5 个 locale 的逻辑 `agent` 合并 key 数均为 2533,物理 `zh-CN` 行数从单个 `agent.json` 2542 行降为:`agent.json` 181、`agentInputbar.json` 202、`agentHome.json` 148、`agentMessageList.json` 73、`agentRuntime.json` 603、`agentSkills.json` 934、`agentTeamWorkspace.json` 406;`agentSkills` 与 `agentRuntime` 后续如继续超过维护阈值,可再按 Skills workspace / curated task / reliability / image workbench 二级职责拆,但本刀不继续扩大拆分。验证:`npm test -- "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" "src/components/agent/chat/components/Inputbar/components/TeamSelectorPanel.test.tsx" "src/components/agent/chat/components/Inputbar/components/TeamSelector.test.tsx" "src/components/agent/chat/components/EmptyStateComposerPanel.test.tsx" "src/components/agent/chat/components/Inputbar/components/InputbarCore.test.tsx" "src/components/agent/chat/utils/imageWorkbenchPresentation.test.ts"` 通过,覆盖 7 个文件、69 个用例;`npx eslint --max-warnings 0` 覆盖 loader / types / agentResources 与所有改过的 source-resource helper / 测试通过;`npm run typecheck` 通过;`npm run detect-translations -- --verbose` 通过,当前物理资源口径为 5 locale、12 namespace、6710 source keys;全部 `src/i18n/resources/*/*.json` 通过 `python3 -m json.tool`;合并后 5 locale 的逻辑 `agent` key 计数均为 2533 且无重复;定向 `git diff --check` 通过。本条只拆资源物理组织和 loader/type 事实源,不新增或修改 Tauri 命令、Bridge、DevBridge mock、`agentCommandCatalog`、`mockPriorityCommands` 或 `defaultMocks`;未切 lazy loading,后续等资源布局稳定后再单独评估 Vite lazy chunk 与 i18next selector API。
310. 2026-05-15 Agent Chat Inputbar workflow chrome / execution strategy source fallback 收口续测:`src/components/agent/chat/components/Inputbar/inputbarWorkflowCopy.ts` 新增输入区 workflow / execution strategy copy 事实源,`InputbarExecutionStrategySelect.tsx`、`InputbarWorkflowStatusPanel.tsx`、`InputbarComposerSection.tsx`、`Inputbar/index.tsx` 与 `useInputbarController.ts` 改为从 `useTranslation("agent")` 注入 `agentChat.inputbar.workflow.*` / `agentChat.inputbar.executionStrategy.*` copy,把计划执行开关、当前进展、任务队列、停止生成、队列展开 / 折叠、空态任务节点、进度与状态 label,以及输入区 workflow quick actions 全部迁出源码中文 literal;`workflowInputState.ts` 改为接收 caller 注入 copy,避免在 current 主路径上保留新的中文 fallback,`workflowStepPresentation.ts` 补齐 workflow presentation copy 接口并允许 progress / summary 注入。同步 5 locale `agentInputbar.json` 新增 40 个 `agentChat.inputbar.workflow.*` / `agentChat.inputbar.executionStrategy.*` 正式资源;`src/i18n/__tests__/loadNamespace.test.ts` / `types.test.ts` 补充 execution strategy、workflow summary / progress / queue / quick action key 覆盖;`src/components/agent/chat/components/Inputbar/index.test.tsx` 新增 en-US 回归验证 Plan、Turn on planned execution、Current progress、Task queue、Stop generation、Collapse task queue 等均来自资源;`EmptyStateComposerPanel.tsx` 也同步接入 execution strategy copy,避免首页输入区复用时留下旧中文。验证:`npm test -- "src/components/agent/chat/components/Inputbar/index.test.tsx" "src/components/agent/chat/components/EmptyStateComposerPanel.test.tsx" "src/components/agent/chat/utils/workflowInputState.test.ts" "src/components/agent/chat/utils/workflowStepPresentation.test.ts" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 6 个文件、111 个用例;`npx eslint --max-warnings 0 "src/components/agent/chat/components/Inputbar/inputbarWorkflowCopy.ts" "src/components/agent/chat/components/Inputbar/components/InputbarExecutionStrategySelect.tsx" "src/components/agent/chat/components/Inputbar/components/InputbarWorkflowStatusPanel.tsx" "src/components/agent/chat/components/Inputbar/components/InputbarComposerSection.tsx" "src/components/agent/chat/components/Inputbar/hooks/useInputbarController.ts" "src/components/agent/chat/components/Inputbar/index.tsx" "src/components/agent/chat/components/EmptyState.tsx" "src/components/agent/chat/components/EmptyStateComposerPanel.tsx" "src/components/agent/chat/components/EmptyStateComposerPanel.test.tsx" "src/components/agent/chat/utils/workflowInputState.ts" "src/components/agent/chat/utils/workflowStepPresentation.ts" "src/components/agent/chat/components/Inputbar/index.test.tsx" "src/components/agent/chat/utils/workflowInputState.test.ts" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"`、`npm run detect-translations -- --verbose`(5 locale、12 namespace、6798 source keys)、`for f in src/i18n/resources/*/*.json; do python3 -m json.tool "$f" >/dev/null || exit 1; done`、`git diff --check` 通过;`npm run typecheck` 第三轮在全仓并行脏改环境下持续运行超过验证窗口,最终仍只暴露非本轮 `src/features/agent-app/manifest/parseManifest.ts` 以及本刀已修的 `src/components/agent/chat/components/Inputbar/index.tsx` 类型问题,因此本条不把 typecheck 计为绿色证据,后续可在更干净的工作区再复核。本条属于 Agent Chat 常规输入区 workflow / execution strategy 邻接 chrome 普通前端 i18n 收口,不新增或修改 Tauri 命令、Bridge、DevBridge mock、`agentCommandCatalog`、`mockPriorityCommands` 或 `defaultMocks`;后续仍需继续 InputbarModelExtra、TeamSelectorPanel、Agent Chat runtime / projection / helper 路径、Workspace / Browser Runtime 长尾、P3 自动化治理和 legacy Patch 删除门禁。
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "lime",
"version": "1.38.0",
"version": "1.39.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "lime",
"version": "1.38.0",
"version": "1.39.0",
"license": "GPL-3.0-only",
"dependencies": {
"@babel/standalone": "^7.29.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "lime",
"private": true,
"version": "1.38.0",
"version": "1.39.0",
"description": "AI content workspace for Chinese creators: desktop writing, research, prompt management, knowledge base, and multi-model workflows.",
"keywords": [
"ai",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@limecloud/lime-cli",
"version": "1.38.0",
"version": "1.39.0",
"description": "Lime 官方任务 CLI",
"bin": {
"lime": "scripts/run.js"
-1
View File
@@ -1 +0,0 @@
null
@@ -1,6 +0,0 @@
{
"oldPrompt": false,
"oldLimePrompt": false,
"imagePreviewCount": 0,
"errorsText": false
}
-183
View File
@@ -1,183 +0,0 @@
{
"url": "http://127.0.0.1:1420/?e2e=1&ts=1778691100",
"body": "Lime\n邀请好友\n搜索任务\n新建任务\nSkills\n灵感\n项目资料\n最近对话\n好的,我来生成一张青柠极简插画。\n2分钟前\n新对话\n5分钟前\n新对话\n8分钟前\n国际形势分析无法执行\n23分钟前\n新对话\n45分钟前\n新对话\n46分钟前\n新对话\n50分钟前\n新对话\n51分钟前\nsmoke harness\n52分钟前\n<planner>\n1小时前\n查看更多对话\n归档\n开\n开源使用\n本地可用\nSkill Forge E2E 1778592514150\nHarness\n新对话\n工作台\n好啊,用 GPT Images 2 生成:一张广州塔,从花城汇看过去的春天照片\n先获取下工具参数\n马上生成\n图片生成\n|\nGPT Images 2\n搞定,图已经生成好了\n要调整的话直接说,我继续改\n添加资料\n模型\ndeepseek-v4-pro\n高级设置",
"sessionIds": [
[
"agent_session_workspace_8f16920f-b0f5-4198-9e20-bc643910be73",
"\"240ed157-3e7a-456c-a2c2-a05bd852b71c\""
],
[
"agent_session_workspace_a4828a71-abed-4e31-9e43-791c58d644e1",
"\"240ed157-3e7a-456c-a2c2-a05bd852b71c\""
],
[
"aster_session_access_mode_240ed157-3e7a-456c-a2c2-a05bd852b71c_bd1f156d-5d37-41f5-9d59-d80363da7928",
"\"full-access\""
],
[
"agent_session_workspace_eed7439e-273e-4b49-97a2-df6c96be5fa3",
"\"240ed157-3e7a-456c-a2c2-a05bd852b71c\""
],
[
"agent_session_workspace_3239e159-dacd-4a27-b5fa-cfd0aea11d28",
"\"240ed157-3e7a-456c-a2c2-a05bd852b71c\""
],
[
"agent_session_workspace_8d383c48-80e2-42e7-9bf3-c2bf5c4ef864",
"\"240ed157-3e7a-456c-a2c2-a05bd852b71c\""
],
[
"agent_session_workspace_4d6b14fa-d4cb-4fe5-b3fa-6f1827b19f5e",
"\"240ed157-3e7a-456c-a2c2-a05bd852b71c\""
],
[
"agent_session_workspace_43e5ac59-422c-465a-9312-d86ce678e317",
"\"240ed157-3e7a-456c-a2c2-a05bd852b71c\""
],
[
"aster_session_access_mode_240ed157-3e7a-456c-a2c2-a05bd852b71c_3fe77ab3-e1d3-4f47-9515-02404d70d1e5",
"\"full-access\""
],
[
"agent_session_workspace_700543e4-9b1f-42d3-811a-0b1f0601fad2",
"\"240ed157-3e7a-456c-a2c2-a05bd852b71c\""
],
[
"aster_session_access_mode_240ed157-3e7a-456c-a2c2-a05bd852b71c_8d383c48-80e2-42e7-9bf3-c2bf5c4ef864",
"\"full-access\""
],
[
"agent_session_workspace_e2545a39-417c-4ba4-a1d7-d5a7b5517b28",
"\"240ed157-3e7a-456c-a2c2-a05bd852b71c\""
],
[
"agent_session_workspace_3fe77ab3-e1d3-4f47-9515-02404d70d1e5",
"\"240ed157-3e7a-456c-a2c2-a05bd852b71c\""
],
[
"aster_session_access_mode_240ed157-3e7a-456c-a2c2-a05bd852b71c_d9fd2dca-4f1c-4fc9-9ab1-57a8584a365b",
"\"full-access\""
],
[
"aster_session_access_mode_240ed157-3e7a-456c-a2c2-a05bd852b71c_8f16920f-b0f5-4198-9e20-bc643910be73",
"\"full-access\""
],
[
"aster_last_sessionId_global",
"null"
],
[
"agent_session_workspace_9ec7e2c6-01c3-4654-9881-cead648710a1",
"\"240ed157-3e7a-456c-a2c2-a05bd852b71c\""
],
[
"agent_session_workspace_31cc9300-7e26-4c85-8a0d-3f00e33168c2",
"\"240ed157-3e7a-456c-a2c2-a05bd852b71c\""
],
[
"agent_session_workspace_a2738669-04c1-40db-9c88-251e1328771b",
"\"240ed157-3e7a-456c-a2c2-a05bd852b71c\""
],
[
"agent_session_workspace_1b215641-00bc-41fa-8bb6-0ee64bd87819",
"\"240ed157-3e7a-456c-a2c2-a05bd852b71c\""
],
[
"agent_session_workspace_2123a0bd-445f-4ded-84ee-0bdff0c86284",
"\"240ed157-3e7a-456c-a2c2-a05bd852b71c\""
],
[
"aster_session_access_mode_240ed157-3e7a-456c-a2c2-a05bd852b71c_31cc9300-7e26-4c85-8a0d-3f00e33168c2",
"\"full-access\""
],
[
"agent_session_workspace_2b64278a-aa4a-442e-846c-6113dd1d4153",
"\"240ed157-3e7a-456c-a2c2-a05bd852b71c\""
],
[
"agent_session_workspace_a0e782ef-a050-4126-8e8f-6bc258e13de9",
"\"240ed157-3e7a-456c-a2c2-a05bd852b71c\""
],
[
"agent_session_workspace_d9fd2dca-4f1c-4fc9-9ab1-57a8584a365b",
"\"240ed157-3e7a-456c-a2c2-a05bd852b71c\""
],
[
"aster_session_access_mode_240ed157-3e7a-456c-a2c2-a05bd852b71c_9ec7e2c6-01c3-4654-9881-cead648710a1",
"\"full-access\""
],
[
"aster_session_access_mode_240ed157-3e7a-456c-a2c2-a05bd852b71c_69c81ba7-782a-47e1-92d5-cfec94ea7760",
"\"full-access\""
],
[
"aster_last_sessionId_240ed157-3e7a-456c-a2c2-a05bd852b71c",
"\"8f16920f-b0f5-4198-9e20-bc643910be73\""
],
[
"agent_session_workspace_6a7d0ff5-0cae-4edd-a396-9411cfede225",
"\"240ed157-3e7a-456c-a2c2-a05bd852b71c\""
],
[
"aster_session_access_mode_240ed157-3e7a-456c-a2c2-a05bd852b71c_6a7d0ff5-0cae-4edd-a396-9411cfede225",
"\"full-access\""
],
[
"agent_session_workspace_614c5e60-9b49-4a53-aba6-1c7b7301cb26",
"\"240ed157-3e7a-456c-a2c2-a05bd852b71c\""
],
[
"agent_session_workspace_dc7fba43-1c2f-4251-8d9b-05c93943307a",
"\"240ed157-3e7a-456c-a2c2-a05bd852b71c\""
],
[
"agent_session_workspace_69c81ba7-782a-47e1-92d5-cfec94ea7760",
"\"240ed157-3e7a-456c-a2c2-a05bd852b71c\""
],
[
"agent_session_workspace_bd1f156d-5d37-41f5-9d59-d80363da7928",
"\"240ed157-3e7a-456c-a2c2-a05bd852b71c\""
],
[
"aster_session_snapshots_persisted_240ed157-3e7a-456c-a2c2-a05bd852b71c",
"{\"8d383c48-80e2-42e7-9bf3-c2bf5c4ef864\":{\"messages\":[{\"id\":\"2b28ee8f-2588-4721-94f8-d9f9f31ffe24\",\"role\":\"user\",\"content\":\"@analysis 帮我分析一下今天的国际形势\",\"timestamp\":\"2026-05-13T16:31:22.532Z\"},{\"id\":\"9ddd7"
]
],
"activeButtons": [
{
"tag": "DIV",
"text": "新对话8分钟前",
"testid": null,
"active": "true",
"ariaCurrent": null
},
{
"tag": "BUTTON",
"text": "新对话8分钟前",
"testid": null,
"active": null,
"ariaCurrent": "page"
},
{
"tag": "BUTTON",
"text": "Skill Forge E2E 1778592514150",
"testid": null,
"active": null,
"ariaCurrent": null
},
{
"tag": "DIV",
"text": "新对话",
"testid": "task-center-tab-8f16920f-b0f5-4198-9e20-bc643910be73",
"active": "true",
"ariaCurrent": null
},
{
"tag": "BUTTON",
"text": "新对话",
"testid": null,
"active": null,
"ariaCurrent": "page"
}
]
}
-62
View File
@@ -1,62 +0,0 @@
[
{
"i": 8,
"text": "",
"aria": "新建对话",
"testid": null
},
{
"i": 9,
"text": "新对话3分钟前",
"aria": null,
"testid": null
},
{
"i": 11,
"text": "新对话5分钟前",
"aria": null,
"testid": null
},
{
"i": 15,
"text": "新对话42分钟前",
"aria": null,
"testid": null
},
{
"i": 17,
"text": "新对话43分钟前",
"aria": null,
"testid": null
},
{
"i": 19,
"text": "新对话47分钟前",
"aria": null,
"testid": null
},
{
"i": 21,
"text": "新对话49分钟前",
"aria": null,
"testid": null
},
{
"i": 27,
"text": "新对话1小时前",
"aria": null,
"testid": null
},
{
"i": 38,
"text": "新对话",
"aria": null,
"testid": null
},
{
"i": 39,
"text": "",
"aria": "新建对话",
"testid": "task-center-tab-create-button"
}
]
-32
View File
@@ -1,32 +0,0 @@
{
"text": "Lime\n邀请好友\n搜索任务\n新建任务\nSkills\n灵感\n项目资料\n最近对话\n新对话\n5分钟前\n新对话\n8分钟前\n国际形势分析无法执行\n23分钟前\n新对话\n44分钟前\n新对话\n45分钟前\n新对话\n49分钟前\n新对话\n51分钟前\nsmoke harness\n52分钟前\n<planner>\n1小时前\n新对话\n1小时前\n查看更多对话\n归档\n开\n开源使用\n本地可用\nSkill Forge E2E 1778592514150\nHarness\n新对话\n工作台\n好啊,用 GPT Images 2 生成:一张广州塔,从花城汇看过去的春天照片\n先获取下工具参数\n马上生成\n图片生成\n|\nGPT Images 2\n搞定,图已经生成好了\n要调整的话直接说,我继续改\n添加资料\n模型\ndeepseek-v4-pro\n高级设置",
"guangzhou": true,
"lime": false,
"tags": [],
"intros": [
"好啊,用 GPT Images 2 生成:一张广州塔,从花城汇看过去的春天照片\n先获取下工具参数\n马上生成"
],
"imageCards": [
{
"testid": "image-workbench-message-preview-e05d557e-60b5-4d47-b9e4-f825b3150df5",
"text": "图片生成|GPT Images 2搞定,图已经生成好了\n要调整的话直接说,我继续改",
"imgCount": 1
},
{
"testid": "image-workbench-message-preview-toolbar-e05d557e-60b5-4d47-b9e4-f825b3150df5",
"text": "图片生成|GPT Images 2",
"imgCount": 0
},
{
"testid": "image-workbench-message-preview-single-media-e05d557e-60b5-4d47-b9e4-f825b3150df5",
"text": "",
"imgCount": 1
},
{
"testid": "image-workbench-message-preview-caption-e05d557e-60b5-4d47-b9e4-f825b3150df5",
"text": "搞定,图已经生成好了\n要调整的话直接说,我继续改",
"imgCount": 0
}
],
"failureText": false
}
@@ -1,31 +0,0 @@
{
"text": "Lime\n邀请好友\n搜索任务\n新建任务\nSkills\n灵感\n项目资料\n最近对话\n国际形势分析无法执行\n17分钟前\n新对话\n38分钟前\n新对话\n39分钟前\n新对话\n43分钟前\n新对话\n45分钟前\nsmoke harness\n46分钟前\n<planner>\n54分钟前\n新对话\n1小时前\n新对话\n2小时前\n新对话\n3小时前\n查看更多对话\n归档\n开\n开源使用\n本地可用\nSkill Think Keep Audit 20260513\nHarness\n新对话\n@配图\n生成一张广州塔,从花城汇看过去的春天照片\n好啊,用 GPT Images 2 生成:一张广州塔,从花城汇看过去的春天照片\n先获取下工具参数\n马上生成\n图片生成\n|\nGPT Images 2\n搞定,图已经生成好了\n要调整的话直接说,我继续改\n添加资料\n模型\ndeepseek-v4-pro\n高级设置",
"imageCards": [
{
"testid": "image-workbench-message-preview-e05d557e-60b5-4d47-b9e4-f825b3150df5",
"text": "图片生成|GPT Images 2搞定,图已经生成好了\n要调整的话直接说,我继续改",
"imgCount": 1,
"retryCount": 0
},
{
"testid": "image-workbench-message-preview-toolbar-e05d557e-60b5-4d47-b9e4-f825b3150df5",
"text": "图片生成|GPT Images 2",
"imgCount": 0,
"retryCount": 0
},
{
"testid": "image-workbench-message-preview-single-media-e05d557e-60b5-4d47-b9e4-f825b3150df5",
"text": "",
"imgCount": 1,
"retryCount": 0
},
{
"testid": "image-workbench-message-preview-caption-e05d557e-60b5-4d47-b9e4-f825b3150df5",
"text": "搞定,图已经生成好了\n要调整的话直接说,我继续改",
"imgCount": 0,
"retryCount": 0
}
],
"retryButtons": [],
"messageCount": 2
}
-30
View File
@@ -1,30 +0,0 @@
{
"text": "Lime\n邀请好友\n搜索任务\n新建任务\nSkills\n灵感\n项目资料\n最近对话\n国际形势分析无法执行\n16分钟前\n新对话\n37分钟前\n新对话\n38分钟前\n新对话\n43分钟前\n新对话\n44分钟前\nsmoke harness\n45分钟前\n<planner>\n54分钟前\n新对话\n1小时前\n新对话\n2小时前\n新对话\n2小时前\n查看更多对话\n归档\n开\n开源使用\n本地可用\nSkill Think Keep Audit 20260513\nHarness\n新对话\n@配图\n生成一张广州塔,从花城汇看过去的春天照片\n好啊,用 GPT Images 2 生成:一张广州塔,从花城汇看过去的春天照片\n先获取下工具参数\n马上生成\n图片生成\n|\nGPT Images 2\n正在生成图片\n添加资料\n模型\ndeepseek-v4-pro\n高级设置",
"imageCards": [
{
"testid": "image-workbench-message-preview-e05d557e-60b5-4d47-b9e4-f825b3150df5",
"text": "图片生成|GPT Images 2正在生成图片",
"imgCount": 0,
"buttons": []
},
{
"testid": "image-workbench-message-preview-toolbar-e05d557e-60b5-4d47-b9e4-f825b3150df5",
"text": "图片生成|GPT Images 2",
"imgCount": 0,
"buttons": []
},
{
"testid": "image-workbench-message-preview-single-media-e05d557e-60b5-4d47-b9e4-f825b3150df5",
"text": "正在生成图片",
"imgCount": 0,
"buttons": []
}
],
"retryButtons": [],
"userTags": [
"@配图"
],
"intros": [
"好啊,用 GPT Images 2 生成:一张广州塔,从花城汇看过去的春天照片\n先获取下工具参数\n马上生成"
]
}
-7
View File
@@ -1,7 +0,0 @@
{
"text": "Lime\n邀请好友\n搜索任务\n新建任务\nSkills\n灵感\n项目资料\n最近对话\n国际形势分析无法执行\n17分钟前\n新对话\n39分钟前\n新对话\n40分钟前\n新对话\n44分钟前\n新对话\n45分钟前\nsmoke harness\n46分钟前\n<planner>\n55分钟前\n新对话\n1小时前\n新对话\n2小时前\n新对话\n3小时前\n查看更多对话\n归档\n开\n开源使用\n本地可用\nSkill Think Keep Audit 20260513\n新对话\n创作\n\n青柠一下,灵感即来\n\n说一句目标,Lime 就接着帮你做。\n\n文案、图片、视频、搜索和网页任务围绕同一目标持续推进,并沉淀上下文、偏好和做法。\n\n帮我梳理一下需求分析\ntab\n添加资料\n高级设置\n当前模型\ndeepseek-v4-pro\n引导帮助\n写作\n添加资料\nPPT\n调研报告\n需求分析\n视频\n设计\nExcel\n编程\n更多做法\n回看最近结果 · 国际形势分析无法执行\n向下滑,看看 Lime 可以帮你做什么\n↓\n你可以从这些任务开始\n\n往下看更多任务样例;真正执行仍会回到生成里继续补充。\n\n拆解一条爆款内容\n把一条高表现内容拆成标题钩子、结构节奏、素材手法和可复用套路,方便快速复刻。\n复盘这个账号/项目\n围绕目标、已有结果和下一步动作判断当前该怎么推进,适合内容账号、项目推进和运营回看。\n脚本转口播/字幕稿\n把现有脚本整理成更适合口播和字幕的版本,补齐停顿、节奏和适合出镜表达的语气。\n每日趋势摘要\n先收一版内容趋势、热点方向和值得继续跟进的切口,适合每天快速开工前拉一遍。\n内容主稿生成\n围绕目标受众、表达结构和关键信息,先生成一版可继续迭代的内容首稿。\n长文转多平台发布稿\n把长文、资料或文章整理成多平台可直接发布的版本,不用再自己拆标题和结构。\n复制轮播帖\n拆解参考轮播帖的结构、文风和卖点,再输出一版可继续改写的轮播内容。\n视频配音\n围绕视频素材、语言要求与旁白意图,先在本地工作区整理一版可继续加工的配音稿。\n视频配音成其他语言\n先整理配音脚本和语言要求,输出一版可继续进入配音流程的执行稿。\n文章转 Slide 视频提纲\n把文章拆成镜头化结构,先生成一版适合做 Slide 视频的提纲。\n账号增长跟踪\n围绕参考账号和目标平台先整理一版增长打法,再持续跟踪内容节奏、关键指标和提醒条件。",
"oldPrompt": false,
"generating": false,
"imagePreviewCount": 0,
"userTags": []
}
@@ -1,29 +0,0 @@
{
"text": "Lime\n邀请好友\n搜索任务\n新建任务\nSkills\n灵感\n项目资料\n最近对话\n新对话\n3分钟前\n新对话\n5分钟前\n国际形势分析无法执行\n20分钟前\n新对话\n42分钟前\n新对话\n43分钟前\n新对话\n47分钟前\n新对话\n49分钟前\nsmoke harness\n49分钟前\n<planner>\n58分钟前\n新对话\n1小时前\n查看更多对话\n归档\n开\n开源使用\n本地可用\nSkill Think Keep Audit 20260513\nHarness\n新对话\n@配图\n再生成一张青柠极简插画\n好啊,用 GPT Images 2 生成:再生成一张青柠极简插画\n先获取下工具参数\n马上生成\n图片生成\n|\nGPT Images 2\n搞定,图已经生成好了\n要调整的话直接说,我继续改\n添加资料\n模型\ndeepseek-v4-pro\n高级设置",
"oldPrompt": false,
"newPrompt": true,
"imageCards": [
{
"testid": "image-workbench-message-preview-968e9568-1dc4-4fba-be89-d3df274a4adb",
"text": "图片生成|GPT Images 2搞定,图已经生成好了\n要调整的话直接说,我继续改",
"imgCount": 1
},
{
"testid": "image-workbench-message-preview-toolbar-968e9568-1dc4-4fba-be89-d3df274a4adb",
"text": "图片生成|GPT Images 2",
"imgCount": 0
},
{
"testid": "image-workbench-message-preview-single-media-968e9568-1dc4-4fba-be89-d3df274a4adb",
"text": "",
"imgCount": 1
},
{
"testid": "image-workbench-message-preview-caption-968e9568-1dc4-4fba-be89-d3df274a4adb",
"text": "搞定,图已经生成好了\n要调整的话直接说,我继续改",
"imgCount": 0
}
],
"retryButtons": [],
"forbidden": []
}
@@ -1,28 +0,0 @@
{
"text": "Lime\n邀请好友\n搜索任务\n新建任务\nSkills\n灵感\n项目资料\n最近对话\n新对话\n3分钟前\n新对话\n5分钟前\n国际形势分析无法执行\n20分钟前\n新对话\n42分钟前\n新对话\n43分钟前\n新对话\n47分钟前\n新对话\n49分钟前\nsmoke harness\n49分钟前\n<planner>\n58分钟前\n新对话\n1小时前\n查看更多对话\n归档\n开\n开源使用\n本地可用\nSkill Think Keep Audit 20260513\nHarness\n新对话\n@配图\n再生成一张青柠极简插画\n好啊,用 GPT Images 2 生成:再生成一张青柠极简插画\n先获取下工具参数\n马上生成\n图片生成\n|\nGPT Images 2\n正在生成图片\n添加资料\n模型\ndeepseek-v4-pro\n高级设置",
"oldPrompt": false,
"newPrompt": true,
"tags": [
"@配图"
],
"intros": [
"好啊,用 GPT Images 2 生成:再生成一张青柠极简插画\n先获取下工具参数\n马上生成"
],
"imageCards": [
{
"testid": "image-workbench-message-preview-968e9568-1dc4-4fba-be89-d3df274a4adb",
"text": "图片生成|GPT Images 2正在生成图片",
"imgCount": 0
},
{
"testid": "image-workbench-message-preview-toolbar-968e9568-1dc4-4fba-be89-d3df274a4adb",
"text": "图片生成|GPT Images 2",
"imgCount": 0
},
{
"testid": "image-workbench-message-preview-single-media-968e9568-1dc4-4fba-be89-d3df274a4adb",
"text": "正在生成图片",
"imgCount": 0
}
]
}
-8
View File
@@ -1,8 +0,0 @@
{
"text": "Lime\n邀请好友\n搜索任务\n新建任务\nSkills\n灵感\n项目资料\n最近对话\n国际形势分析无法执行\n18分钟前\n新对话\n39分钟前\n新对话\n40分钟前\n新对话\n45分钟前\n新对话\n46分钟前\nsmoke harness\n47分钟前\n<planner>\n56分钟前\n新对话\n1小时前\n新对话\n2小时前\n新对话\n3小时前\n查看更多对话\n归档\n开\n开源使用\n本地可用\nSkill Think Keep Audit 20260513\n新对话\n创作\n\n青柠一下,灵感即来\n\n说一句目标,Lime 就接着帮你做。\n\n文案、图片、视频、搜索和网页任务围绕同一目标持续推进,并沉淀上下文、偏好和做法。\n\n帮我整理项目资料\ntab\n添加资料\n高级设置\n当前模型\ndeepseek-v4-pro\n引导帮助\n写作\n添加资料\nPPT\n调研报告\n需求分析\n视频\n设计\nExcel\n编程\n更多做法\n回看最近结果 · 国际形势分析无法执行\n向下滑,看看 Lime 可以帮你做什么\n↓\n你可以从这些任务开始\n\n往下看更多任务样例;真正执行仍会回到生成里继续补充。\n\n拆解一条爆款内容\n把一条高表现内容拆成标题钩子、结构节奏、素材手法和可复用套路,方便快速复刻。\n复盘这个账号/项目\n围绕目标、已有结果和下一步动作判断当前该怎么推进,适合内容账号、项目推进和运营回看。\n脚本转口播/字幕稿\n把现有脚本整理成更适合口播和字幕的版本,补齐停顿、节奏和适合出镜表达的语气。\n每日趋势摘要\n先收一版内容趋势、热点方向和值得继续跟进的切口,适合每天快速开工前拉一遍。\n内容主稿生成\n围绕目标受众、表达结构和关键信息,先生成一版可继续迭代的内容首稿。\n长文转多平台发布稿\n把长文、资料或文章整理成多平台可直接发布的版本,不用再自己拆标题和结构。\n短视频编排\n把文本、线框图、配乐、剧本和短视频草稿收口成一条多模态结果链。\n复制轮播帖\n拆解参考轮播帖的结构、文风和卖点,再输出一版可继续改写的轮播内容。\n视频配音\n围绕视频素材、语言要求与旁白意图,先在本地工作区整理一版可继续加工的配音稿。\n视频配音成其他语言\n先整理配音脚本和语言要求,输出一版可继续进入配音流程的执行稿。\n文章转 Slide 视频提纲\n把文章拆成镜头化结构,先生成一版适合做 Slide 视频的提纲。\n我来读取已导出的 Markdown 文件,然后翻...\n沉淀自一次成功结果:我来读取已导出的 Markdown 文件,然后翻译成中文。 文件已读取完毕。现在我将正文翻译为中文,保留所有 Markdown 结构、代码块、链...\n加载对话历史失败: normalizeUserVisibleContent is not defined",
"oldPrompt": false,
"newPrompt": false,
"tags": [],
"intros": [],
"imagePreviewCount": 0
}
+118 -46
View File
@@ -202,6 +202,17 @@ function readImageLaunch(metadata) {
return { launch, imageTask };
}
function isImageGenerateSkillExecution(invoke) {
return (
invoke?.cmd === "execute_skill" &&
invoke.args?.skillName === "image_generate"
);
}
function isLegacyImageRuntimeSubmit(invoke) {
return invoke?.cmd === "agent_runtime_submit_turn";
}
function parseInvokeRequest(request, invokeUrl) {
const requestUrl = request.url();
const normalizedInvokeUrl = invokeUrl.replace(/\/$/, "");
@@ -507,59 +518,110 @@ async function main() {
});
const submitInvoke = await waitForCondition(
"等待 @配图 提交 agent_runtime_submit_turn",
"等待 @配图 提交 image_generate 执行请求",
() =>
invokes
.slice(submitStart)
.find((item) => item.cmd === "agent_runtime_submit_turn") || null,
.find(
(item) =>
isImageGenerateSkillExecution(item) ||
isLegacyImageRuntimeSubmit(item),
) || null,
options.timeoutMs,
options.intervalMs,
);
const request = submitInvoke.args?.request || {};
const turnConfig = request.turn_config || {};
const metadata = turnConfig.metadata || {};
const { launch, imageTask } = readImageLaunch(metadata);
const runtimeContract = readNestedObject(imageTask, [
"runtime_contract",
"runtimeContract",
]);
const contractKey =
imageTask?.modality_contract_key ||
imageTask?.modalityContractKey ||
runtimeContract?.contract_key ||
runtimeContract?.contractKey;
assert(launch, "@配图 提交缺少 harness.image_skill_launch");
assert(imageTask, "@配图 提交缺少 image_task");
assert(
String(imageTask.prompt || "").includes(IMAGE_PROMPT),
"@配图 image_task.prompt 未保留用户输入",
);
assert(
contractKey === "image_generation",
`@配图 image_task 合同应为 image_generation,实际为 ${String(contractKey)}`,
);
assert(
turnConfig.provider_preference == null,
"@配图 不应把当前聊天 provider 提交为 request provider_preference",
);
assert(
turnConfig.model_preference == null,
"@配图 不应把当前聊天 model 提交为 request model_preference",
);
if (isImageGenerateSkillExecution(submitInvoke)) {
const args = submitInvoke.args || {};
const userInput = String(args.userInput || "");
assert(
userInput.includes("@配图"),
"@配图 execute_skill.userInput 应保留命令标签",
);
assert(
userInput.includes(IMAGE_PROMPT),
"@配图 execute_skill.userInput 未保留用户输入",
);
assert(
typeof args.executionId === "string" && args.executionId.trim(),
"@配图 execute_skill 缺少 executionId",
);
assert(
typeof args.sessionId === "string" && args.sessionId.trim(),
"@配图 execute_skill 缺少 sessionId",
);
summary.assertions.imageSkillLaunchSubmitted = true;
summary.assertions.imageGenerationContractPreserved = true;
summary.assertions.chatModelPreferenceSuppressed = true;
summary.submitRequest = {
message: request.message,
sessionId: request.session_id || null,
turnId: request.turn_id || null,
providerPreference: turnConfig.provider_preference ?? null,
modelPreference: turnConfig.model_preference ?? null,
contractKey,
imagePrompt: imageTask.prompt,
};
await page
.getByText("先执行技能 image_generate", { exact: false })
.first()
.waitFor({ state: "visible", timeout: options.timeoutMs });
await page
.getByText("图片生成", { exact: false })
.first()
.waitFor({ state: "visible", timeout: options.timeoutMs });
summary.assertions.imageSkillExecutionSubmitted = true;
summary.assertions.imageCommandTagPreserved = true;
summary.assertions.imagePromptPreserved = true;
summary.assertions.imageGenerateProcessVisible = true;
summary.submitRequest = {
routeMode: "skill_execution",
command: submitInvoke.cmd,
skillName: args.skillName,
userInput,
sessionId: args.sessionId || null,
executionId: args.executionId || null,
providerOverride: args.providerOverride ?? null,
modelOverride: args.modelOverride ?? null,
};
} else {
const request = submitInvoke.args?.request || {};
const turnConfig = request.turn_config || {};
const metadata = turnConfig.metadata || {};
const { launch, imageTask } = readImageLaunch(metadata);
const runtimeContract = readNestedObject(imageTask, [
"runtime_contract",
"runtimeContract",
]);
const contractKey =
imageTask?.modality_contract_key ||
imageTask?.modalityContractKey ||
runtimeContract?.contract_key ||
runtimeContract?.contractKey;
assert(launch, "@配图 提交缺少 harness.image_skill_launch");
assert(imageTask, "@配图 提交缺少 image_task");
assert(
String(imageTask.prompt || "").includes(IMAGE_PROMPT),
"@配图 image_task.prompt 未保留用户输入",
);
assert(
contractKey === "image_generation",
`@配图 image_task 合同应为 image_generation,实际为 ${String(contractKey)}`,
);
assert(
turnConfig.provider_preference == null,
"@配图 不应把当前聊天 provider 提交为 request provider_preference",
);
assert(
turnConfig.model_preference == null,
"@配图 不应把当前聊天 model 提交为 request model_preference",
);
summary.assertions.imageSkillLaunchSubmitted = true;
summary.assertions.imageGenerationContractPreserved = true;
summary.assertions.chatModelPreferenceSuppressed = true;
summary.submitRequest = {
routeMode: "agent_runtime_submit_turn",
message: request.message,
sessionId: request.session_id || null,
turnId: request.turn_id || null,
providerPreference: turnConfig.provider_preference ?? null,
modelPreference: turnConfig.model_preference ?? null,
contractKey,
imagePrompt: imageTask.prompt,
};
}
await page.screenshot({
path: path.join(
@@ -569,7 +631,17 @@ async function main() {
fullPage: true,
});
summary.cleanup = await interruptSubmittedTurn(options, request);
if (summary.submitRequest?.routeMode === "agent_runtime_submit_turn") {
summary.cleanup = await interruptSubmittedTurn(
options,
submitInvoke.args?.request || {},
);
} else {
summary.cleanup = {
attempted: false,
reason: "skill-execution-route",
};
}
const summaryPath = path.join(
options.evidenceDir,
+58
View File
@@ -312,10 +312,68 @@ function collectDefaultMockCommands() {
for (const match of objectBody.matchAll(/^ ([A-Za-z0-9_]+)\s*:/gm)) {
mockCommands.add(match[1]);
}
for (const spreadMatch of objectBody.matchAll(
/^ \.\.\.([A-Za-z0-9_]+),/gm,
)) {
const registryName = spreadMatch[1];
for (const command of collectSpreadMockRegistryCommands(
sourceCode,
registryName,
)) {
mockCommands.add(command);
}
}
return mockCommands;
}
function collectSpreadMockRegistryCommands(sourceCode, registryName) {
const importPattern =
/import\s+(?:type\s+)?\{([^}]*)\}\s+from\s+["'`]([^"'`]+)["'`]/g;
let importSource;
for (const importMatch of sourceCode.matchAll(importPattern)) {
const namedImports = importMatch[1];
if (new RegExp(`\\b${registryName}\\b`).test(namedImports)) {
importSource = importMatch[2];
break;
}
}
if (!importSource?.startsWith("./")) {
return [];
}
const registrySourcePath = path.resolve(
repoRoot,
"src/lib/tauri-mock",
`${importSource.replace(/^\.\//, "")}.ts`,
);
if (!fs.existsSync(registrySourcePath)) {
throw new Error(`未找到 spread mock registry 文件: ${registrySourcePath}`);
}
const registrySourceCode = fs.readFileSync(registrySourcePath, "utf8");
const markerPattern = new RegExp(
`export\\s+const\\s+${registryName}\\b[\\s\\S]*?=\\s*\\{`,
);
const markerMatch = markerPattern.exec(registrySourceCode);
if (!markerMatch) {
throw new Error(`未找到 spread mock registry 定义: ${registryName}`);
}
const braceStart = markerMatch.index + markerMatch[0].length - 1;
const registryBody = extractBalancedBlock(
registrySourceCode,
braceStart,
"{",
"}",
);
const commands = [];
for (const match of registryBody.matchAll(/^ ([A-Za-z0-9_]+)\s*:/gm)) {
commands.push(match[1]);
}
return commands;
}
function readAgentCommandCatalog() {
const catalogPath = path.join(
repoRoot,
+127 -2
View File
@@ -17,6 +17,28 @@ const PLATFORM_BY_TARGET_TRIPLE = {
"x86_64-pc-windows-msvc": "windows-x86_64",
};
const TARGET_TRIPLE_BY_PLATFORM = Object.fromEntries(
Object.entries(PLATFORM_BY_TARGET_TRIPLE).map(([targetTriple, platform]) => [
platform,
targetTriple,
]),
);
const INSTALLER_ASSET_BY_PLATFORM = {
"darwin-aarch64": {
required: true,
assetName: (version) => `Lime_${version}_aarch64.dmg`,
},
"darwin-x86_64": {
required: true,
assetName: (version) => `Lime_${version}_x64.dmg`,
},
"windows-x86_64": {
required: false,
assetName: (version) => `Lime_${version}_x64-setup.exe`,
},
};
function parseArgs(argv) {
const args = {};
for (let index = 0; index < argv.length; index += 1) {
@@ -176,6 +198,86 @@ function platformKeyFromAssetFile(assetsDir, assetFile) {
return PLATFORM_BY_TARGET_TRIPLE[targetTriple] || "";
}
function targetTripleFromAssetFile(assetsDir, assetFile) {
const relativeDir = path.relative(assetsDir, path.dirname(assetFile));
return relativeDir.split(path.sep).find(Boolean) || "";
}
function resolveInstallerAssetFile({
assetsDir,
filesByBasename,
platformKey,
referenceFile,
version,
}) {
const spec = INSTALLER_ASSET_BY_PLATFORM[platformKey];
if (!spec) {
return null;
}
const assetName = spec.assetName(version);
const candidates = filesByBasename.get(assetName) || [];
if (candidates.length === 0) {
if (spec.required) {
throw new Error(
`platform ${platformKey} is missing installer asset: ${assetName}`,
);
}
return null;
}
const expectedTargetTriple = TARGET_TRIPLE_BY_PLATFORM[platformKey];
return (
(referenceFile &&
candidates.find(
(candidate) => path.dirname(candidate) === path.dirname(referenceFile),
)) ||
candidates.find(
(candidate) =>
targetTripleFromAssetFile(assetsDir, candidate) === expectedTargetTriple,
) ||
candidates[0]
);
}
function addReferencedFile(referencedFiles, item) {
referencedFiles.set(item.targetPath, item);
}
function addInstallerUrlIfAvailable({
assetsDir,
baseUrl,
channel,
filesByBasename,
platform,
platformKey,
referenceFile,
referencedFiles,
version,
versionTag,
}) {
const installerFile = resolveInstallerAssetFile({
assetsDir,
filesByBasename,
platformKey,
referenceFile,
version,
});
if (!installerFile) {
return;
}
const installerName = path.basename(installerFile);
const installerAssetPath = buildPlatformAssetPath(platformKey, installerName);
platform.installer_url = `${baseUrl}/lime/${channel}/${versionTag}/${installerAssetPath}`;
addReferencedFile(referencedFiles, {
assetName: installerName,
file: installerFile,
platformKey,
targetPath: installerAssetPath,
});
}
function collectSignedUpdaterArtifacts(files, assetsDir) {
const fileSet = new Set(files);
return files
@@ -277,12 +379,24 @@ function collectUpdaterManifest(options) {
signature,
url: targetUrl,
};
referencedFiles.set(platformAssetPath, {
addReferencedFile(referencedFiles, {
assetName,
file: assetFile,
platformKey,
targetPath: platformAssetPath,
});
addInstallerUrlIfAvailable({
assetsDir,
baseUrl,
channel,
filesByBasename,
platform: platforms[platformKey],
platformKey,
referenceFile: manifestFile,
referencedFiles,
version,
versionTag,
});
}
}
} else {
@@ -306,12 +420,23 @@ function collectUpdaterManifest(options) {
signature: artifact.signature,
url: targetUrl,
};
referencedFiles.set(platformAssetPath, {
addReferencedFile(referencedFiles, {
assetName: artifact.assetName,
file: artifact.assetFile,
platformKey: artifact.platformKey,
targetPath: platformAssetPath,
});
addInstallerUrlIfAvailable({
assetsDir,
baseUrl,
channel,
filesByBasename,
platform: platforms[artifact.platformKey],
platformKey: artifact.platformKey,
referencedFiles,
version,
versionTag,
});
}
}
+82 -2
View File
@@ -26,9 +26,15 @@ describe("release updater manifest", () => {
const assetsDir = path.join(root, "release-assets");
writeFile(path.join(assetsDir, "aarch64-apple-darwin", "Lime.app.tar.gz"));
writeFile(
path.join(assetsDir, "aarch64-apple-darwin", "Lime_1.20.0_aarch64.dmg"),
);
writeFile(
path.join(assetsDir, "x86_64-apple-darwin", "Lime-x64.app.tar.gz"),
);
writeFile(
path.join(assetsDir, "x86_64-apple-darwin", "Lime_1.20.0_x64.dmg"),
);
writeFile(path.join(assetsDir, "x86_64-pc-windows-msvc", "Lime.nsis.zip"));
writeJson(path.join(assetsDir, "aarch64-apple-darwin", "latest.json"), {
@@ -78,7 +84,19 @@ describe("release updater manifest", () => {
expect(result.manifest.platforms["darwin-aarch64"].url).toBe(
"https://updates.limecloud.com/lime/stable/v1.20.0/darwin-aarch64/Lime.app.tar.gz",
);
expect(result.r2UploadPlan).toHaveLength(3);
expect(result.manifest.platforms["darwin-aarch64"].installer_url).toBe(
"https://updates.limecloud.com/lime/stable/v1.20.0/darwin-aarch64/Lime_1.20.0_aarch64.dmg",
);
expect(result.manifest.platforms["darwin-x86_64"].installer_url).toBe(
"https://updates.limecloud.com/lime/stable/v1.20.0/darwin-x86_64/Lime_1.20.0_x64.dmg",
);
expect(result.r2UploadPlan.map((item) => item.key).sort()).toEqual([
"lime/stable/v1.20.0/darwin-aarch64/Lime.app.tar.gz",
"lime/stable/v1.20.0/darwin-aarch64/Lime_1.20.0_aarch64.dmg",
"lime/stable/v1.20.0/darwin-x86_64/Lime-x64.app.tar.gz",
"lime/stable/v1.20.0/darwin-x86_64/Lime_1.20.0_x64.dmg",
"lime/stable/v1.20.0/windows-x86_64/Lime.nsis.zip",
]);
const output = writeOutputs(result, path.join(root, "out"), "stable");
expect(fs.existsSync(output.latestPath)).toBe(true);
@@ -91,6 +109,9 @@ describe("release updater manifest", () => {
);
const assetsDir = path.join(root, "release-assets");
writeFile(path.join(assetsDir, "aarch64-apple-darwin", "Lime.app.tar.gz"));
writeFile(
path.join(assetsDir, "aarch64-apple-darwin", "Lime_1.20.0_aarch64.dmg"),
);
writeJson(path.join(assetsDir, "aarch64-apple-darwin", "latest.json"), {
version: "1.20.0",
platforms: {
@@ -147,10 +168,18 @@ describe("release updater manifest", () => {
path.join(assetsDir, "aarch64-apple-darwin", "Lime.app.tar.gz"),
"arm",
);
writeFile(
path.join(assetsDir, "aarch64-apple-darwin", "Lime_1.20.0_aarch64.dmg"),
"arm-dmg",
);
writeFile(
path.join(assetsDir, "x86_64-apple-darwin", "Lime.app.tar.gz"),
"x64",
);
writeFile(
path.join(assetsDir, "x86_64-apple-darwin", "Lime_1.20.0_x64.dmg"),
"x64-dmg",
);
writeJson(path.join(assetsDir, "aarch64-apple-darwin", "latest.json"), {
version: "1.20.0",
@@ -185,15 +214,23 @@ describe("release updater manifest", () => {
expect(result.manifest.platforms["darwin-x86_64"].url).toBe(
"https://updates.limecloud.com/lime/stable/v1.20.0/darwin-x86_64/Lime.app.tar.gz",
);
expect(result.manifest.platforms["darwin-aarch64"].installer_url).toBe(
"https://updates.limecloud.com/lime/stable/v1.20.0/darwin-aarch64/Lime_1.20.0_aarch64.dmg",
);
expect(result.manifest.platforms["darwin-x86_64"].installer_url).toBe(
"https://updates.limecloud.com/lime/stable/v1.20.0/darwin-x86_64/Lime_1.20.0_x64.dmg",
);
expect(result.r2UploadPlan.map((item) => item.key).sort()).toEqual([
"lime/stable/v1.20.0/darwin-aarch64/Lime.app.tar.gz",
"lime/stable/v1.20.0/darwin-aarch64/Lime_1.20.0_aarch64.dmg",
"lime/stable/v1.20.0/darwin-x86_64/Lime.app.tar.gz",
"lime/stable/v1.20.0/darwin-x86_64/Lime_1.20.0_x64.dmg",
]);
expect(
result.r2UploadPlan
.map((item) => fs.readFileSync(item.file, "utf8"))
.sort(),
).toEqual(["arm", "x64"]);
).toEqual(["arm", "arm-dmg", "x64", "x64-dmg"]);
});
it("没有 latest.json 时应从 Tauri 签名产物生成清单", () => {
@@ -216,11 +253,17 @@ describe("release updater manifest", () => {
path.join(assetsDir, "aarch64-apple-darwin", "Lime.app.tar.gz.sig"),
encodedSignature,
);
writeFile(
path.join(assetsDir, "aarch64-apple-darwin", "Lime_1.20.0_aarch64.dmg"),
);
writeFile(path.join(assetsDir, "x86_64-apple-darwin", "Lime.app.tar.gz"));
writeFile(
path.join(assetsDir, "x86_64-apple-darwin", "Lime.app.tar.gz.sig"),
rawSignature,
);
writeFile(
path.join(assetsDir, "x86_64-apple-darwin", "Lime_1.20.0_x64.dmg"),
);
writeFile(
path.join(
assetsDir,
@@ -257,12 +300,49 @@ describe("release updater manifest", () => {
expect(result.manifest.platforms["windows-x86_64"].url).toBe(
"https://updates.limecloud.com/lime/stable/v1.20.0/windows-x86_64/Lime_1.20.0_x64-setup.exe",
);
expect(result.manifest.platforms["darwin-aarch64"].installer_url).toBe(
"https://updates.limecloud.com/lime/stable/v1.20.0/darwin-aarch64/Lime_1.20.0_aarch64.dmg",
);
expect(result.manifest.platforms["darwin-x86_64"].installer_url).toBe(
"https://updates.limecloud.com/lime/stable/v1.20.0/darwin-x86_64/Lime_1.20.0_x64.dmg",
);
expect(result.manifest.platforms["windows-x86_64"].installer_url).toBe(
"https://updates.limecloud.com/lime/stable/v1.20.0/windows-x86_64/Lime_1.20.0_x64-setup.exe",
);
expect(result.r2UploadPlan.map((item) => item.key).sort()).toEqual([
"lime/stable/v1.20.0/darwin-aarch64/Lime.app.tar.gz",
"lime/stable/v1.20.0/darwin-aarch64/Lime_1.20.0_aarch64.dmg",
"lime/stable/v1.20.0/darwin-x86_64/Lime.app.tar.gz",
"lime/stable/v1.20.0/darwin-x86_64/Lime_1.20.0_x64.dmg",
"lime/stable/v1.20.0/windows-x86_64/Lime_1.20.0_x64-setup.exe",
]);
});
it("macOS 平台缺少 DMG 时失败,避免官网回退下载 updater 包", () => {
const root = fs.mkdtempSync(
path.join(os.tmpdir(), "lime-release-missing-dmg-"),
);
const assetsDir = path.join(root, "release-assets");
writeFile(path.join(assetsDir, "aarch64-apple-darwin", "Lime.app.tar.gz"));
writeJson(path.join(assetsDir, "aarch64-apple-darwin", "latest.json"), {
version: "1.20.0",
platforms: {
"darwin-aarch64": {
signature: "sig-arm",
url: "https://example.com/Lime.app.tar.gz",
},
},
});
expect(() =>
collectUpdaterManifest({
assetsDir,
baseUrl: "https://updates.limecloud.com",
requiredPlatforms: ["darwin-aarch64"],
version: "v1.20.0",
}),
).toThrow(/missing installer asset/);
});
});
describe("R2 release cleanup", () => {
+18 -18
View File
@@ -5131,7 +5131,7 @@ dependencies = [
[[package]]
name = "lime"
version = "1.38.0"
version = "1.39.0"
dependencies = [
"anyhow",
"arboard",
@@ -5238,7 +5238,7 @@ dependencies = [
[[package]]
name = "lime-agent"
version = "1.38.0"
version = "1.39.0"
dependencies = [
"anyhow",
"aster-core",
@@ -5267,7 +5267,7 @@ dependencies = [
[[package]]
name = "lime-browser-runtime"
version = "1.38.0"
version = "1.39.0"
dependencies = [
"chrono",
"futures",
@@ -5284,7 +5284,7 @@ dependencies = [
[[package]]
name = "lime-cli"
version = "1.38.0"
version = "1.39.0"
dependencies = [
"clap",
"lime-core",
@@ -5296,7 +5296,7 @@ dependencies = [
[[package]]
name = "lime-config"
version = "1.38.0"
version = "1.39.0"
dependencies = [
"async-trait",
"lime-core",
@@ -5312,7 +5312,7 @@ dependencies = [
[[package]]
name = "lime-core"
version = "1.38.0"
version = "1.39.0"
dependencies = [
"aster-models",
"async-trait",
@@ -5365,7 +5365,7 @@ dependencies = [
[[package]]
name = "lime-gateway"
version = "1.38.0"
version = "1.39.0"
dependencies = [
"aes",
"axum 0.7.9",
@@ -5395,7 +5395,7 @@ dependencies = [
[[package]]
name = "lime-infra"
version = "1.38.0"
version = "1.39.0"
dependencies = [
"chrono",
"dashmap 5.5.3",
@@ -5415,7 +5415,7 @@ dependencies = [
[[package]]
name = "lime-knowledge"
version = "1.38.0"
version = "1.39.0"
dependencies = [
"chrono",
"hex",
@@ -5428,7 +5428,7 @@ dependencies = [
[[package]]
name = "lime-mcp"
version = "1.38.0"
version = "1.39.0"
dependencies = [
"async-trait",
"dirs 5.0.1",
@@ -5444,7 +5444,7 @@ dependencies = [
[[package]]
name = "lime-media-runtime"
version = "1.38.0"
version = "1.39.0"
dependencies = [
"axum 0.7.9",
"base64 0.22.1",
@@ -5477,7 +5477,7 @@ dependencies = [
[[package]]
name = "lime-processor"
version = "1.38.0"
version = "1.39.0"
dependencies = [
"async-trait",
"lime-core",
@@ -5496,7 +5496,7 @@ dependencies = [
[[package]]
name = "lime-providers"
version = "1.38.0"
version = "1.39.0"
dependencies = [
"anyhow",
"async-stream",
@@ -5551,7 +5551,7 @@ dependencies = [
[[package]]
name = "lime-server"
version = "1.38.0"
version = "1.39.0"
dependencies = [
"aster-core",
"async-stream",
@@ -5595,7 +5595,7 @@ dependencies = [
[[package]]
name = "lime-server-utils"
version = "1.38.0"
version = "1.39.0"
dependencies = [
"axum 0.7.9",
"futures",
@@ -5610,7 +5610,7 @@ dependencies = [
[[package]]
name = "lime-services"
version = "1.38.0"
version = "1.39.0"
dependencies = [
"anyhow",
"aster-core",
@@ -5655,7 +5655,7 @@ dependencies = [
[[package]]
name = "lime-skills"
version = "1.38.0"
version = "1.39.0"
dependencies = [
"async-trait",
"dirs 5.0.1",
@@ -5673,7 +5673,7 @@ dependencies = [
[[package]]
name = "lime-websocket"
version = "1.38.0"
version = "1.39.0"
dependencies = [
"axum 0.7.9",
"chrono",
+2 -2
View File
@@ -9,7 +9,7 @@ exclude = [
resolver = "2"
[workspace.package]
version = "1.38.0"
version = "1.39.0"
edition = "2021"
authors = ["coso"]
repository = "https://github.com/aiclientproxy/lime"
@@ -198,7 +198,7 @@ version = "2.4.9"
[package]
name = "lime"
version = "1.38.0"
version = "1.39.0"
description = "AI API Proxy Desktop App"
authors = ["you"]
edition = "2021"
+109 -24
View File
@@ -169,9 +169,8 @@ fn build_skill_turn_context(
.allowed_tools
.as_ref()
.filter(|tools| !tools.is_empty());
let user_visible_input_text = user_visible_input.and_then(|input| {
(!input.trim().is_empty()).then(|| input.trim().to_string())
});
let user_visible_input_text = user_visible_input
.and_then(|input| (!input.trim().is_empty()).then(|| input.trim().to_string()));
if allowed_tools.is_none() && user_visible_input_text.is_none() {
return None;
@@ -194,6 +193,40 @@ fn build_skill_turn_context(
})
}
fn build_prompt_session_config(
session_id: &str,
skill: &LoadedSkillDefinition,
user_visible_input: Option<&str>,
memory_prompt: Option<&str>,
) -> SessionConfig {
let mut session_config_builder = SessionConfigBuilder::new(session_id)
.system_prompt(build_prompt_system_prompt(
&skill.markdown_content,
memory_prompt,
))
.system_prompt_override(true)
.include_context_trace(true);
if let Some(turn_context) = build_skill_turn_context(skill, user_visible_input) {
session_config_builder = session_config_builder.turn_context(turn_context);
}
session_config_builder.build()
}
fn build_step_session_config(
step_session_id: &str,
step_system_prompt: String,
skill_turn_context: Option<TurnContextOverride>,
) -> SessionConfig {
let mut session_config_builder = SessionConfigBuilder::new(step_session_id)
.system_prompt(step_system_prompt)
.system_prompt_override(true)
.include_context_trace(true);
if let Some(turn_context) = skill_turn_context {
session_config_builder = session_config_builder.turn_context(turn_context);
}
session_config_builder.build()
}
async fn stream_skill_session(
aster_state: &AsterAgentState,
session_id: &str,
@@ -310,13 +343,11 @@ pub async fn execute_skill_workflow(
memory_prompt,
);
let step_session_id = format!("{session_id}-step-{}", step.id);
let mut session_config_builder = SessionConfigBuilder::new(&step_session_id)
.system_prompt(step_system_prompt)
.include_context_trace(true);
if let Some(turn_context) = skill_turn_context.clone() {
session_config_builder = session_config_builder.turn_context(turn_context);
}
let session_config = session_config_builder.build();
let session_config = build_step_session_config(
&step_session_id,
step_system_prompt,
skill_turn_context.clone(),
);
let step_input = build_step_input(user_input, &accumulated_context, idx == 0);
let user_message = build_user_message(&step_input, user_visible_input, images);
@@ -407,16 +438,8 @@ pub async fn execute_skill_prompt(
emitter,
} = request;
let event_name = format!("skill-exec-{execution_id}");
let mut session_config_builder = SessionConfigBuilder::new(session_id)
.system_prompt(build_prompt_system_prompt(
&skill.markdown_content,
memory_prompt,
))
.include_context_trace(true);
if let Some(turn_context) = build_skill_turn_context(skill, user_visible_input) {
session_config_builder = session_config_builder.turn_context(turn_context);
}
let session_config = session_config_builder.build();
let session_config =
build_prompt_session_config(session_id, skill, user_visible_input, memory_prompt);
let user_message = build_user_message(user_input, user_visible_input, images);
let reply = stream_skill_session(
aster_state,
@@ -461,7 +484,10 @@ pub async fn execute_skill_prompt(
#[cfg(test)]
mod tests {
use super::{build_skill_turn_context, build_user_message};
use super::{
build_prompt_session_config, build_skill_turn_context, build_step_session_config,
build_step_system_prompt, build_user_message,
};
use lime_skills::LoadedSkillDefinition;
use std::collections::HashMap;
@@ -523,9 +549,8 @@ mod tests {
fn build_skill_turn_context_keeps_user_visible_input_without_allowed_tools() {
let skill = build_loaded_skill(None);
let turn_context =
build_skill_turn_context(&skill, Some(" @analysis 帮我分析一下 "))
.expect("turn context");
let turn_context = build_skill_turn_context(&skill, Some(" @analysis 帮我分析一下 "))
.expect("turn context");
assert_eq!(
turn_context.user_visible_input_text.as_deref(),
@@ -553,4 +578,64 @@ mod tests {
assert!(message.is_user_visible());
assert!(message.is_agent_visible());
}
#[test]
fn prompt_skill_session_uses_skill_markdown_as_full_system_prompt() {
let mut skill = build_loaded_skill(Some(vec!["read_file"]));
skill.skill_name = "analysis".to_string();
skill.markdown_content =
"你是 Lime 的分析助手。\n\n## 输出格式(固定)\n# 分析结果".to_string();
let session_config = build_prompt_session_config(
"skill-session",
&skill,
Some("@analysis 帮我分析一下今天的国际形势"),
Some("记忆补充"),
);
assert_eq!(session_config.system_prompt_override, Some(true));
assert_eq!(session_config.include_context_trace, Some(true));
let system_prompt = session_config.system_prompt.expect("system prompt");
assert!(system_prompt.contains("你是 Lime 的分析助手"));
assert!(system_prompt.contains("## 输出格式(固定)"));
assert!(system_prompt.contains("记忆补充"));
assert_eq!(
session_config
.turn_context
.as_ref()
.and_then(|context| context.user_visible_input_text.as_deref()),
Some("@analysis 帮我分析一下今天的国际形势")
);
assert_eq!(
session_config
.turn_context
.as_ref()
.and_then(|context| context.metadata.get("subagent"))
.and_then(|metadata| metadata.get("allowed_tools")),
Some(&serde_json::json!(["read_file"]))
);
}
#[test]
fn workflow_step_session_uses_step_skill_prompt_as_full_system_prompt() {
let mut skill = build_loaded_skill(None);
skill.markdown_content = "Skill 主体指令".to_string();
let step_prompt = build_step_system_prompt(
&skill.markdown_content,
"分析",
1,
2,
"执行当前分析步骤",
None,
);
let session_config = build_step_session_config("skill-step", step_prompt, None);
assert_eq!(session_config.system_prompt_override, Some(true));
assert_eq!(session_config.include_context_trace, Some(true));
let system_prompt = session_config.system_prompt.expect("system prompt");
assert!(system_prompt.contains("Skill 主体指令"));
assert!(system_prompt.contains("## 当前步骤: 分析 (1/2)"));
assert!(system_prompt.contains("执行当前分析步骤"));
}
}
@@ -1585,6 +1585,30 @@ where
}
impl Agent {
fn build_user_visible_context_message(
user_message: &Message,
session_config: &SessionConfig,
) -> Option<Message> {
if user_message.is_user_visible() {
return None;
}
let visible_text = session_config
.turn_context
.as_ref()
.and_then(|context| context.user_visible_input_text.as_deref())
.map(str::trim)
.filter(|text| !text.is_empty())?;
let mut visible_message = Message::user().with_text(visible_text).user_only();
for content in &user_message.content {
if matches!(content, MessageContent::Image(_)) {
visible_message = visible_message.with_content(content.clone());
}
}
Some(visible_message)
}
pub fn new() -> Self {
// Create channels with buffer size 32 (adjust if needed)
let (confirm_tx, confirm_rx) = mpsc::channel(32);
@@ -3525,6 +3549,12 @@ impl Agent {
.await?;
}
Ok(None) => {
if let Some(visible_message) =
Self::build_user_visible_context_message(&user_message, &session_config)
{
self.store_add_message(&session_config.id, &visible_message)
.await?;
}
self.store_add_message(&session_config.id, &user_message)
.await?;
}
@@ -5112,9 +5142,7 @@ mod tests {
"thread-1",
Some("内部结构化执行输入".to_string()),
Some(TurnContextOverride {
user_visible_input_text: Some(
"@analysis 帮我分析一下今天的国际形势".to_string(),
),
user_visible_input_text: Some("@analysis 帮我分析一下今天的国际形势".to_string()),
..TurnContextOverride::default()
}),
);
@@ -5124,18 +5152,51 @@ mod tests {
.project_user_input(&turn)
.expect("user input event");
let AgentEvent::ItemCompleted { item } = event else {
panic!("expected item completed event");
};
assert_eq!(item.id, "user:turn-skill-visible");
assert!(matches!(
event,
AgentEvent::ItemStarted { item }
if item.id == "user_input:turn-skill-visible"
&& matches!(
item.payload,
ItemRuntimePayload::UserInput { ref text }
if text == "@analysis 帮我分析一下今天的国际形势"
)
item.payload,
ItemRuntimePayload::UserMessage { ref content }
if content == "@analysis 帮我分析一下今天的国际形势"
));
}
#[test]
fn test_build_user_visible_context_message_keeps_visible_skill_input_only_for_user() {
let hidden_user_message = Message::user()
.with_text("{\"analysis_request\":{\"content\":\"内部结构化输入\"}}")
.with_image("base64-image", "image/png")
.agent_only();
let session_config = SessionConfig {
id: "session-skill-visible".to_string(),
turn_context: Some(TurnContextOverride {
user_visible_input_text: Some("@analysis 帮我分析一下今天的国际形势".to_string()),
..TurnContextOverride::default()
}),
..SessionConfig::default()
};
let visible_message =
Agent::build_user_visible_context_message(&hidden_user_message, &session_config)
.expect("visible user message");
assert!(visible_message.is_user_visible());
assert!(!visible_message.is_agent_visible());
assert_eq!(
visible_message.as_concat_text(),
"@analysis 帮我分析一下今天的国际形势"
);
assert!(visible_message
.content
.iter()
.any(|content| matches!(content, MessageContent::Image(_))));
assert!(!visible_message
.as_concat_text()
.contains("analysis_request"));
}
#[test]
fn test_project_tool_response_emits_file_artifact_runtime_item() {
let turn = TurnRuntime::new(
@@ -5,7 +5,7 @@ allowed-tools: list_directory, read_file
metadata:
lime_argument_hint: 输入待分析内容、重点、风格与输出格式要求。
lime_when_to_use: 用户需要对文本、对话或文件内容做拆解、判断、评估或风险分析时使用。
lime_version: 1.0.0
lime_version: 1.0.1
lime_execution_mode: prompt
lime_surface: chat
lime_category: reasoning
@@ -21,10 +21,12 @@ metadata:
- 优先分析 `analysis_request.content` 或用户明确给出的正文内容。
- 如果用户明确提供本地文件路径或资料目录,可先最小化使用 `list_directory` / `read_file` 读取必要内容,再进行分析。
- 若未提供显式内容,则默认分析当前对话里与请求最相关的内容。
- 若未提供显式内容,但用户问题本身有明确分析对象,则基于当前可见对话与用户问题做可用范围内的分析;不要仅因没有外部材料就输出“无法分析”。
- 面对“今天”“最新”“当前局势”等实时性请求,必须先说明实时信息边界:只能基于已知背景与当前可见上下文给出结构化判断,并把需要实时核验的新闻、数据或出处列入待确认项。
- 只有在完全没有分析对象,或用户明确要求必须依赖某份缺失材料/精确实时数据时,才最多追问 1 个关键问题;追问前仍应说明当前可先分析的范围。
- 分析必须区分“原文事实”“你的判断”“待确认项”,不要把推断写成已确认事实。
- 用户要求分析重点、风格、输出格式时要显式遵循;未指定时默认给出简洁结论与依据。
- 信息不足时最多追问 1 个关键问题,不要假装已经读到不存在的内容。
- 不要假装已经读到不存在的文件、网页、实时新闻或私有数据。
## 输出格式(固定)
@@ -5,7 +5,7 @@ allowed-tools: lime_create_image_generation_task
metadata:
lime_argument_hint: 输入主题、画面主体、风格、构图、数量、尺寸。
lime_when_to_use: 用户显式使用 @配图/@修图/@重绘/@image,或在普通对话中已确认“调用画图/现在生成”时使用;封面需求优先交给 cover_generate。
lime_version: 1.3.3
lime_version: 1.3.5
lime_execution_mode: prompt
lime_surface: workbench
lime_category: media
@@ -20,11 +20,18 @@ metadata:
## 执行规则
- 先判断是否属于封面需求;封面需求请转 `cover_generate`。
- 普通聊天里如果用户只是给出视觉提示词、设计海报 brief、完善画面描述,但没有显式 `@配图/@修图/@重绘/@image` 或明确确认“现在调用画图功能”,不要调用本 Skill;也不要改写成 HTML/CSS/SVG/Markdown 草图或长方案,应先用一句话询问是否开始生成。
- 普通聊天里如果用户只是给出视觉提示词、设计海报 brief、完善画面描述,但没有显式 `@配图/@修图/@重绘/@image` 或明确确认“现在调用画图功能”,不要调用本 Skill;也不要改写成 HTML/CSS/SVG/Markdown 草图或长方案,应先按用户当前语言和上下文自然询问是否开始生成,不要使用固定确认模板。
- 当前已经进入 `@配图/@修图/@重绘 -> image_skill_launch -> Skill(image_generate)` 主链,不要先调用 `ToolSearch`、`WebSearch`、`Read`、`Glob`、`Grep` 去“找技能”或“确认工具”。
- 如果发现人物肖像、相似公众人物、深度伪造、版权、商标、未成年人、裸露、暴力、仇恨、违法等图片安全或合规风险,可以最多用一句自然短句提示风险或建议安全改写;不要展开长篇审查说明。
- 已进入 `@配图/@修图/@重绘` 图片主链时,外层 Agent 和本 Skill 不得以安全审查、政策不确定或“不能生成”为由只输出拒绝说明、停止任务或跳过工具调用;风险提示不是任务拦截。
- 给出必要风险提示后,仍必须直接调用 `lime_create_image_generation_task`,并把原始需求、必要的风险提示或安全替代建议放入任务参数;最终是否拒绝、改写或降级由图片生成执行器/图片模型自己的策略决定,外层 Agent 和本 Skill 不要自行替图片模型下最终拒绝结论。
- 聊天区禁止输出“非常抱歉,我无法完成这个请求”“我无法为此类请求提供帮助”“这些内容违反原则”“禁止生成,所以不能继续”这类拒绝话术;这类话术即使任务卡继续生成,也会被用户理解为外层模型阻止了图片执行。
- 可见风险提示应改写为非阻断句式,例如“这类真人肖像/代言请求可能有合规风险,我会把风险提示一并交给图片模型处理。”随后立即继续工具调用。
- 不要搜索 “Skill image_generate”、“lime media image generate --json”、“lime_create_image_generation_task” 之类目录信息;当前上下文已经明确要求执行图片任务。
- 提示词必须包含主体、场景、风格,不要空泛。
- 如果结构化上下文提供 `persona_context`,聊天输出必须服从其中的单条消息、短确认和隐藏运行时细节约束。
- 如果结构化上下文提供 `presentation.assistant_intro_request`,调用工具前要在 `presentation.assistant_intro` 中补一条按用户目标、当前语言和品味上下文自然生成的开场承接;这条文案必须短,不能套固定模板,也不能包含任务 ID、任务文件、排队说明或内部工具名。
- 如果结构化上下文提供 `presentation.completion_caption_request`,调用工具前要在 `presentation.completion_caption` 中补一条按用户目标、当前语言和品味上下文自然生成的结果收尾;这条文案必须短、可继续迭代,不能套固定模板,也不能包含任务 ID、任务文件、排队说明或内部工具名。
- 如果结构化上下文提供 `taste_context`,应结合其中的 `memory_sources`、`style_keywords`、`reference_summaries`、`avoid_keywords` 和 `cold_start_policy` 优化工具参数;这层只影响 prompt/style/reference,不要在聊天区解释内部来源。
- 若调用方在结构化上下文里提供了 `image_task`,必须优先复用其中的 `mode`、`reference_images`、`target_output_*`、`session_id`、`project_id`、`content_id`、`entry_source`、`requested_target`、`executor_mode`、`outer_model`、`runtime_contract`、`modality_contract_key`、`routing_slot`、`slot_id`、`anchor_*` 等字段,不要擅自丢失。
- 若 `image_task.runtime_contract.layered_design` 存在,说明这是 `LayeredDesignDocument -> canvas:design -> DesignCanvas` 的图层生成任务;必须原样透传 `runtime_contract.layered_design`、`target_output_id`、`target_output_ref_id` 和 `slot_id`,不要改写成 `poster_generate`、`canvas:poster`、脚本生成或 markdown 配图流程。
@@ -45,6 +52,6 @@ metadata:
## 输出规则(固定)
工具调用成功后,不要再输出“任务类型 / 任务 ID / 任务文件 / 状态”这类递交摘要;Lime 会在同一条 assistant 消息里展示工具调用、任务进度和图片结果。若确实需要补充文字,只用一句自然说明,不重复 task_id、path 或排队模板。
工具调用成功后,不要再输出“任务类型 / 任务 ID / 任务文件 / 状态”这类递交摘要;Lime 会在同一条 assistant 消息里展示工具调用、任务进度和图片结果。若任务仍是 pending / queued / running,只保留一句自然承接,不重复 task_id、path 或排队模板;若已经拿到真实图片结果,可以用一两句自然收尾,引导用户继续调整,不要复述固定模板。
聊天输出必须极简自然:工具前最多保留 `presentation.assistant_intro` 和 `presentation.process_lines` 这类短过程;结果态优先使用 `presentation.completion_caption` / `presentation.result_captions`;不要输出任务表格、任务 ID、任务文件、排队说明、Image Workbench/图片工作台文案,也不要拆成第二条 assistant 回复。
聊天输出必须极简自然:工具前只用贴合上下文的一句短承接,然后直接调用工具;不要复述固定过程句,也不要把 `presentation` 当成可见模板。结果态优先使用你写入任务参数的 `presentation.completion_caption` 或自己按图片结果自然收尾;不要输出任务表格、任务 ID、任务文件、排队说明、Image Workbench/图片工作台文案,也不要拆成第二条 assistant 回复。
+85 -4
View File
@@ -81,6 +81,7 @@ pub struct WorkspaceExecutionPermissionInput<'a> {
pub surface: WorkspaceToolSurface,
pub workspace_root: &'a str,
pub auto_mode: bool,
pub bypass_restrictions: bool,
pub execution_policy_input: ToolExecutionResolverInput<'a>,
}
@@ -203,20 +204,22 @@ pub fn should_auto_approve_tool_warnings(
pub fn build_workspace_execution_permissions(
input: WorkspaceExecutionPermissionInput<'_>,
) -> Vec<ToolPermission> {
let patterns = build_workspace_permission_patterns(input.workspace_root, input.auto_mode);
let unrestricted = input.auto_mode || input.bypass_restrictions;
let patterns = build_workspace_permission_patterns(input.workspace_root, unrestricted);
let mut permissions = tool_catalog_entries_for_surface(input.surface)
.into_iter()
.filter_map(|entry| {
build_parameter_restricted_permission(
entry.name,
input.auto_mode,
input.bypass_restrictions,
&patterns,
input.execution_policy_input,
)
})
.collect::<Vec<_>>();
if input.auto_mode {
if unrestricted {
permissions.push(ToolPermission {
tool: "*".to_string(),
allowed: true,
@@ -224,7 +227,11 @@ pub fn build_workspace_execution_permissions(
conditions: Vec::new(),
parameter_restrictions: Vec::new(),
scope: PermissionScope::Session,
reason: Some("Auto 模式:允许所有工具与参数".to_string()),
reason: Some(if input.bypass_restrictions {
"full-access:允许所有工具与参数".to_string()
} else {
"Auto 模式:允许所有工具与参数".to_string()
}),
expires_at: None,
metadata: HashMap::new(),
});
@@ -493,6 +500,7 @@ fn build_workspace_permission_patterns(
fn build_parameter_restricted_permission(
tool_name: &str,
auto_mode: bool,
bypass_restrictions: bool,
patterns: &WorkspacePermissionPatterns,
execution_policy_input: ToolExecutionResolverInput<'_>,
) -> Option<ToolPermission> {
@@ -502,7 +510,8 @@ fn build_parameter_restricted_permission(
}
let policy = resolve_tool_execution_policy(tool_name, execution_policy_input);
let parameter_restrictions = if auto_mode {
let unrestricted = auto_mode || bypass_restrictions;
let parameter_restrictions = if unrestricted {
Vec::new()
} else {
build_parameter_restrictions(tool_name, policy.restriction_profile, patterns)
@@ -519,6 +528,7 @@ fn build_parameter_restricted_permission(
tool_name,
policy.restriction_profile,
auto_mode,
bypass_restrictions,
)),
expires_at: None,
metadata: HashMap::new(),
@@ -627,7 +637,30 @@ fn permission_reason(
tool_name: &str,
profile: ToolExecutionRestrictionProfile,
auto_mode: bool,
bypass_restrictions: bool,
) -> String {
if bypass_restrictions {
return match profile {
ToolExecutionRestrictionProfile::WorkspaceShellCommand => {
format!("full-access:允许 {tool_name} 执行任意命令")
}
ToolExecutionRestrictionProfile::SafeHttpsUrlRequired => {
format!("full-access:允许 {tool_name} 访问任意 URL")
}
ToolExecutionRestrictionProfile::AnalyzeImageInput => {
format!("full-access:允许 {tool_name} 分析任意图片路径或 base64")
}
ToolExecutionRestrictionProfile::WorkspaceAbsolutePathRequired => {
format!("full-access:允许 {tool_name} 访问任意绝对路径")
}
ToolExecutionRestrictionProfile::WorkspacePathRequired
| ToolExecutionRestrictionProfile::WorkspacePathOptional => {
format!("full-access:允许 {tool_name} 访问任意路径")
}
ToolExecutionRestrictionProfile::None => format!("full-access:允许工具 {tool_name}"),
};
}
if auto_mode {
return match profile {
ToolExecutionRestrictionProfile::WorkspaceShellCommand => {
@@ -685,6 +718,26 @@ mod tests {
};
use serde_json::json;
fn assert_bash_allows_restricted_command(permissions: Vec<ToolPermission>) {
let mut manager = aster::permission::ToolPermissionManager::new(None);
for permission in permissions {
manager.add_permission(permission, PermissionScope::Session);
}
let params = HashMap::from([(
"command".to_string(),
json!("test -f .baoyu-skills/baoyu-xhs-images/EXTEND.md"),
)]);
let context = aster::permission::PermissionContext {
working_directory: std::path::PathBuf::from("/tmp/workspace"),
session_id: "session-full-access".to_string(),
timestamp: chrono::Utc::now().timestamp(),
user: None,
environment: HashMap::new(),
metadata: HashMap::new(),
};
assert!(manager.is_allowed("Bash", &params, &context).allowed);
}
#[test]
fn test_tool_execution_policy_marks_bash_as_sandboxed_shell_risk() {
let policy = tool_execution_policy("Bash");
@@ -709,6 +762,7 @@ mod tests {
surface: WorkspaceToolSurface::core(),
workspace_root: "/tmp/workspace",
auto_mode: false,
bypass_restrictions: false,
execution_policy_input: ToolExecutionResolverInput::default(),
});
@@ -744,6 +798,7 @@ mod tests {
surface: WorkspaceToolSurface::core(),
workspace_root: "/tmp/workspace",
auto_mode: true,
bypass_restrictions: false,
execution_policy_input: ToolExecutionResolverInput::default(),
});
@@ -755,6 +810,31 @@ mod tests {
assert!(permissions
.iter()
.any(|permission| permission.tool == "*" && permission.allowed));
assert_bash_allows_restricted_command(permissions);
}
#[test]
fn test_build_workspace_execution_permissions_full_access_bypasses_shell_restrictions() {
let permissions =
build_workspace_execution_permissions(WorkspaceExecutionPermissionInput {
surface: WorkspaceToolSurface::core(),
workspace_root: "/tmp/workspace",
auto_mode: false,
bypass_restrictions: true,
execution_policy_input: ToolExecutionResolverInput::default(),
});
let bash = permissions
.iter()
.find(|permission| permission.tool == "Bash")
.expect("Bash permission should exist");
assert!(bash.parameter_restrictions.is_empty());
assert!(permissions
.iter()
.any(|permission| permission.tool == "*" && permission.allowed));
assert_bash_allows_restricted_command(permissions);
}
#[test]
@@ -933,6 +1013,7 @@ mod tests {
surface: WorkspaceToolSurface::core(),
workspace_root: "/tmp/workspace",
auto_mode: false,
bypass_restrictions: false,
execution_policy_input: ToolExecutionResolverInput {
persisted_policy: None,
request_metadata: Some(&request_metadata),
@@ -456,7 +456,7 @@ fn build_image_generation_confirmation_guard_prompt() -> String {
"- 只有当用户显式使用 @配图/@修图/@重绘/@image,或在你询问后明确确认“调用画图/现在生成/开始画”时,才允许进入图片生成主链。",
"- 如果用户没有 @ 命令但内容明显像配图提示词、海报 brief、封面 brief 或视觉设计 brief,必须先用 1 句简洁确认:是否要调用画图功能生成图片。",
"- 上述普通视觉 brief 确认回合不要输出 HTML/CSS/SVG/Markdown 草图,不要写完整设计方案,不要生成提示词全文,不要输出任务详情表、排队状态或任务已提交模板。",
"- 推荐确认句式:这看起来是图片生成需求,要我直接调用画图功能生成吗?",
"- 确认句必须按用户当前语言和上下文自然表达,不要机械复述固定句式。",
]
.join("\n")
}
@@ -600,6 +600,11 @@ fn build_image_skill_launch_system_prompt(
"- 当前底层运行合同:modality_contract_key={modality_contract_key}, modality={modality}, routing_slot={routing_slot};后续创建任务必须原样保留 contract 字段。"
),
"- 先快速归纳用户目标,然后立刻把任务交给 Skill 工具;不要停留在泛泛解释。".to_string(),
"- 如果发现人物肖像、相似公众人物、深度伪造、版权、商标、未成年人、裸露、暴力、仇恨、违法等图片安全或合规风险,可以最多用一句自然短句提示风险或建议安全改写;不要展开长篇审查说明。".to_string(),
"- 当前已经进入 @配图/@修图/@重绘 -> image_skill_launch 图片主链时,外层 Agent 不得以安全审查、政策不确定或“不能生成”为由拒绝、停止、跳过或延迟图片任务;风险提示不是任务拦截。".to_string(),
"- 给出必要风险提示后,仍必须继续调用 Skill(image_generate),并把原始需求、必要的风险提示或安全替代建议放入 image_task/prompt 上下文;最终是否拒绝、改写或降级由图片生成执行器/图片模型自己的策略决定,外层 Agent 不要自行替图片模型下最终拒绝结论。".to_string(),
"- 可见聊天区禁止输出“非常抱歉,我无法完成这个请求”“我无法为此类请求提供帮助”“这些内容违反原则”“禁止生成,所以不能继续”这类拒绝话术;这类话术即使任务卡继续生成,也会被用户理解为外层模型阻止了图片执行。".to_string(),
"- 可见风险提示应改写为非阻断句式,例如“这类真人肖像/代言请求可能有合规风险,我会把风险提示一并交给图片模型处理。”随后立即继续工具调用。".to_string(),
format!("- 第一优先工具调用必须是 Skill,且 skill=\"{skill_name}\"。"),
"- 调用 Skill 时,args 必须是一个严格 JSON 字符串,不要漏引号、不要写注释、不要只传半截字段。".to_string(),
format!("- 推荐传给 Skill.args 的 JSON:{args_json}"),
@@ -622,8 +627,8 @@ fn build_image_skill_launch_system_prompt(
"- Skill 内部调用 lime_create_image_generation_task 时,必须只提交标准 image task 参数;不要传 outputPath,不要把任务写成 markdown 文稿。".to_string(),
"- 不要伪造“图片已生成完成”;在 task file 真正返回结果前,只能让工具轨迹展示任务已提交、排队或执行中,不要额外输出递交模板。".to_string(),
"- 如果当前回合已经拿到任何图片任务结果,且结果里含 task_id、path,或 status=pending_submit/queued/running/partial/succeeded,说明任务已提交;不要再次调用 Skill(image_generate) 或重复创建第二个图片任务。".to_string(),
"- 拿到上述任务结果后,不要再输出“任务类型 / 任务 ID / 任务文件 / 状态”这类提交摘要;让同一条 assistant 消息内的工具调用和图片任务轻卡继续展示进度与结果。".to_string(),
"- 聊天输出必须服从 persona_context 与 presentation:工具前最多保留短确认和短过程,结果态优先使用 presentation.completion_caption / presentation.result_captions;不要输出任务表格、任务 ID、任务文件、排队说明、Image Workbench/图片工作台文案,也不要拆成第二条 assistant 回复。".to_string(),
"- 拿到上述任务结果后,不要再输出“任务类型 / 任务 ID / 任务文件 / 状态”这类提交摘要;让同一条 assistant 消息内的工具调用和图片任务轻卡继续展示进度与结果。任务仍在 pending/queued/running 时只保留一句自然承接,不要写递交流程说明。".to_string(),
"- 聊天输出必须服从 persona_context 与 presentation:工具前只用贴合上下文的一句自然短承接,然后直接调用 Skill(image_generate);不要复述固定过程句,也不要把 presentation 当成可见模板。若 presentation 只有 assistant_intro_request/completion_caption_request/result_caption_policy 而没有具体 assistant_intro/completion_caption,应让 Skill 在工具参数里补充按用户目标、当前语言和品味上下文自然生成的 assistant_intro 与 completion_caption;不要在前端或协议里套固定模板。结果态按真实图片结果自然收尾并提示可以继续调整;不要输出任务表格、任务 ID、任务文件、排队说明、Image Workbench/图片工作台文案,也不要拆成第二条 assistant 回复。".to_string(),
format!("- 当前图片任务上下文(JSON):{image_task_json}"),
format!("- 当前模式:{mode}。"),
format!("- 当前入口来源:{entry_source}。"),
@@ -643,7 +648,7 @@ fn build_image_skill_launch_system_prompt(
}
if let Some(value) = presentation_json.as_deref() {
lines.push(format!(
"- 当前聊天展示契约(JSON):{value}。这只用于同一条对话里的自然展示,不要改写成任务摘要。"
"- 当前聊天展示契约(JSON):{value}。这只提供自然展示约束,不是可见文案模板;不要机械复述其中字段,也不要改写成任务摘要。"
));
}
if let Some(value) = taste_context_json.as_deref() {
@@ -5,8 +5,8 @@ use crate::config::GlobalConfigManagerState;
mod fast_response;
mod responsive_chat;
use fast_response::{
extract_fast_response_routing, extract_fast_response_routing_slot,
extract_fast_response_service_model_slot,
extract_fast_response_fallback_preference, extract_fast_response_routing,
extract_fast_response_routing_slot, extract_fast_response_service_model_slot,
};
use lime_core::database::dao::api_key_provider::{ApiProviderType, ProviderGroup};
use lime_core::models::model_registry::{
@@ -2990,6 +2990,7 @@ enum RequestPreferenceSource {
ServiceSceneLaunch,
Session,
ServiceModelSetting,
FastResponseFallback,
ResponsiveChatAuto,
}
@@ -3930,6 +3931,99 @@ pub(super) async fn resolve_runtime_request_provider_resolution(
}
}
if request_targets_responsive_chat {
if let Some(fallback_preference) =
extract_fast_response_fallback_preference(request.metadata.as_ref())
{
match build_runtime_request_provider_config_from_preference(
app,
db,
api_key_provider_service,
request,
&task_profile,
&fallback_preference.provider_selector,
&fallback_preference.model_name,
RequestPreferenceSource::FastResponseFallback,
true,
)
.await
{
Ok(selection) => {
tracing::info!(
"[AsterAgent] 快速响应后端候选不可用,回退当前工作区 provider/model: session={}, provider={}, model={}",
request.session_id,
fallback_preference.provider_selector,
fallback_preference.model_name
);
let mut notes = vec![
"快速响应后端候选不可用,已回退到当前工作区 provider/model。".to_string(),
];
if let Some(note) = fallback_note.clone() {
notes.push(note);
}
let limit_state = build_limit_state(
runtime_limit_status_for_selection(&selection),
selection.candidate_count,
true,
false,
oem_locked,
selection.capability_gap.clone(),
notes,
);
let routing_decision = build_routing_decision(
&task_profile,
"fast_response_fallback",
compose_routing_decision_reason(
"当前回合命中快速响应路由,但后端服务模型或自动候选不可用;运行时回退到前端当前工作区模型。",
Some(&selection),
oem_locked,
fallback_note.as_deref(),
),
Some(&selection),
Some(fallback_preference.provider_selector),
Some(fallback_preference.model_name),
Some("fast_response_routing.fallback".to_string()),
);
let cost_state = build_cost_state(Some(&selection), None, "estimated");
let runtime_summary = build_runtime_summary(
&routing_decision,
&limit_state,
&cost_state,
&permission_state,
oem_limit_event.as_ref(),
);
return Ok(RuntimeRequestProviderResolution {
provider_config: Some(selection.provider_config),
task_profile,
routing_decision,
limit_state,
cost_state,
permission_state: permission_state.clone(),
limit_event: oem_limit_event,
oem_policy: oem_policy.clone(),
runtime_summary,
});
}
Err(error) => {
tracing::warn!(
"[AsterAgent] 快速响应 fallback provider/model 不可用,继续回退会话默认: session={}, provider={}, model={}, error={}",
request.session_id,
fallback_preference.provider_selector,
fallback_preference.model_name,
error
);
if fallback_note.is_none() {
fallback_note = Some(
"快速响应 fallback provider/model 不可用,已继续回退会话默认。"
.to_string(),
);
}
}
}
}
}
let Some((provider_selector, provider_preference_source)) =
resolve_provider_preference_with_session_fallback(None, session_context.as_ref())
else {
@@ -3,6 +3,12 @@ use super::{
responsive_chat::{RESPONSIVE_CHAT_ROUTING_SLOT, RESPONSIVE_CHAT_SERVICE_MODEL_SLOT},
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct FastResponseFallbackPreference {
pub(super) provider_selector: String,
pub(super) model_name: String,
}
pub(super) fn extract_fast_response_routing(
request_metadata: Option<&serde_json::Value>,
) -> Option<&serde_json::Map<String, serde_json::Value>> {
@@ -29,3 +35,38 @@ pub(super) fn extract_fast_response_routing_slot(
.filter(|slot| normalize_identifier(slot) == RESPONSIVE_CHAT_ROUTING_SLOT)
.or_else(|| Some(RESPONSIVE_CHAT_ROUTING_SLOT.to_string()))
}
pub(super) fn extract_fast_response_fallback_preference(
request_metadata: Option<&serde_json::Value>,
) -> Option<FastResponseFallbackPreference> {
let routing = extract_fast_response_routing(request_metadata)?;
let provider_selector = extract_metadata_text(
routing,
&[
"fallback_provider_preference",
"fallbackProviderPreference",
"fallback_provider",
"fallbackProvider",
"provider_preference",
"providerPreference",
"provider",
],
)?;
let model_name = extract_metadata_text(
routing,
&[
"fallback_model_preference",
"fallbackModelPreference",
"fallback_model",
"fallbackModel",
"model_preference",
"modelPreference",
"model",
],
)?;
Some(FastResponseFallbackPreference {
provider_selector,
model_name,
})
}
@@ -2414,6 +2414,26 @@ fn runtime_task_profile_maps_fast_response_to_responsive_chat_slot() {
.any(|value| value == "fast_response_routing"));
}
#[test]
fn fast_response_fallback_preference_reads_workspace_selection() {
let metadata = serde_json::json!({
"harness": {
"fast_response_routing": {
"service_model_slot": "responsive_chat",
"routing_slot": "responsive_chat_model",
"fallback_provider_preference": "deepseek",
"fallback_model_preference": "deepseek-v4-pro"
}
}
});
let preference =
extract_fast_response_fallback_preference(Some(&metadata)).expect("fallback preference");
assert_eq!(preference.provider_selector, "deepseek");
assert_eq!(preference.model_name, "deepseek-v4-pro");
}
#[test]
fn runtime_task_profile_marks_oem_runtime_from_harness_oem_routing() {
let request = AsterChatRequest {
@@ -1874,6 +1874,10 @@ async fn prepare_runtime_turn_submit_bootstrap(
.set_provider_continuation(provider_continuation_state);
let request_metadata = request.metadata.clone();
let bypass_workspace_restrictions = matches!(
resolve_runtime_access_mode_from_request(request),
Some(lime_agent::SessionExecutionRuntimeAccessMode::FullAccess)
);
let sandbox_outcome = apply_workspace_sandbox_permissions(
state,
config_manager,
@@ -1890,6 +1894,7 @@ async fn prepare_runtime_turn_submit_bootstrap(
execution_profile,
request_tool_policy,
requested_strategy,
bypass_workspace_restrictions,
)
.await
.map_err(|error| format!("注入 workspace 安全策略失败: {error}"))?;
@@ -5068,7 +5068,11 @@ mod tests {
},
"presentation": {
"version": "lime-image-chat-v1",
"assistant_intro": "好啊,用 GPT Image 2 处理:把这张海报改成更清爽的青柠风格\n先获取下工具参数\n马上生成"
"opening_guidance": {
"source": "model_stream",
"avoid_fixed_templates": true,
"avoid_visible_process_lines": true
}
},
"taste_context": {
"version": "lime-image-taste-v1",
@@ -5095,6 +5099,20 @@ mod tests {
assert!(merged.contains("skill=\"image_generate\""));
assert!(merged.contains("Skill.args 的 JSON"));
assert!(merged.contains("第一工具调用示例(Skill 参数 JSON)"));
assert!(merged.contains("可以最多用一句自然短句提示风险"));
assert!(merged.contains(
"不得以安全审查、政策不确定或“不能生成”为由拒绝、停止、跳过或延迟图片任务"
));
assert!(merged.contains("仍必须继续调用 Skill(image_generate)"));
assert!(merged.contains(
"最终是否拒绝、改写或降级由图片生成执行器/图片模型自己的策略决定"
));
assert!(merged.contains("外层 Agent 不要自行替图片模型下最终拒绝结论"));
assert!(merged.contains(
"禁止输出“非常抱歉,我无法完成这个请求”“我无法为此类请求提供帮助”"
));
assert!(merged.contains("这类话术即使任务卡继续生成,也会被用户理解为外层模型阻止了图片执行"));
assert!(merged.contains("可见风险提示应改写为非阻断句式"));
assert!(merged.contains("\"image_task\":"));
assert!(merged.contains("不要为了确认技能名、工具名或命令名再去调用 ToolSearch"));
assert!(merged.contains("当前主会话第一刀必须先调用 Skill(image_generate)"));
@@ -5113,6 +5131,9 @@ mod tests {
assert!(merged.contains("不要伪造“图片已生成完成”"));
assert!(merged.contains("不要再次调用 Skill(image_generate) 或重复创建第二个图片任务"));
assert!(merged.contains("不要再输出“任务类型 / 任务 ID / 任务文件 / 状态”这类提交摘要"));
assert!(merged.contains("assistant_intro_request"));
assert!(merged.contains("completion_caption_request"));
assert!(merged.contains("不要在前端或协议里套固定模板"));
assert!(!merged.contains("给出提交摘要"));
assert!(merged.contains("provider_id / model"));
assert!(merged.contains(
@@ -5126,6 +5147,8 @@ mod tests {
assert!(merged.contains("当前任务已经显式进入图片技能主链"));
assert!(merged.contains("当前图片生成人设契约(JSON)"));
assert!(merged.contains("当前聊天展示契约(JSON)"));
assert!(merged.contains("不要复述固定过程句"));
assert!(merged.contains("不是可见文案模板"));
assert!(merged.contains("当前品味/记忆规划上下文(JSON)"));
assert!(merged.contains("不要在聊天区暴露内部来源或参考站名称"));
let reference_site_name = ["ri", "bbi"].concat();
@@ -5152,7 +5175,9 @@ mod tests {
assert!(merged.contains("不要因此自动调用 Skill(image_generate)"));
assert!(merged.contains("是否要调用画图功能生成图片"));
assert!(merged.contains("不要输出 HTML/CSS/SVG/Markdown 草图"));
assert!(merged.contains("这看起来是图片生成需求,要我直接调用画图功能生成吗?"));
assert!(merged.contains("按用户当前语言和上下文自然表达"));
assert!(merged.contains("不要机械复述固定句式"));
assert!(!merged.contains("这看起来是图片生成需求,要我直接调用画图功能生成吗?"));
assert!(!merged.contains(super::image_skill_launch::IMAGE_SKILL_LAUNCH_PROMPT_MARKER));
}
@@ -6920,7 +6945,11 @@ mod tests {
"provider_id": "fal",
"model": "fal-ai/nano-banana-pro",
"presentation": {
"assistant_intro": "好啊\n先获取下工具参数\n马上生成"
"opening_guidance": {
"source": "model_stream",
"avoid_fixed_templates": true,
"avoid_visible_process_lines": true
}
},
"taste_context": {
"version": "lime-image-taste-v1"
@@ -343,6 +343,7 @@ pub(crate) async fn apply_workspace_sandbox_permissions(
execution_profile: TurnExecutionProfile,
request_tool_policy: &RequestToolPolicy,
execution_strategy: AsterExecutionStrategy,
bypass_workspace_restrictions: bool,
) -> Result<WorkspaceSandboxApplyOutcome, String> {
let workspace_root = workspace_root.trim();
if workspace_root.is_empty() {
@@ -356,6 +357,8 @@ pub(crate) async fn apply_workspace_sandbox_permissions(
persisted_policy: Some(&current_config.agent.tool_execution),
request_metadata,
};
let auto_approve_bash_warnings = bypass_workspace_restrictions
|| should_auto_approve_tool_warnings("Bash", auto_mode, execution_policy_input);
let lock_service_skill_launch_to_site_tools =
should_lock_service_skill_launch_to_site_tools(request_metadata);
let tool_surface = WorkspaceToolSurface {
@@ -363,12 +366,12 @@ pub(crate) async fn apply_workspace_sandbox_permissions(
browser_assist: is_browser_assist_enabled(request_metadata),
};
let mut sandboxed_bash_tool: Option<WorkspaceSandboxedBashTool> = None;
let apply_outcome = if !sandbox_policy.enabled {
let apply_outcome = if bypass_workspace_restrictions || !sandbox_policy.enabled {
WorkspaceSandboxApplyOutcome::DisabledByConfig
} else {
match WorkspaceSandboxedBashTool::new(
workspace_root,
should_auto_approve_tool_warnings("Bash", auto_mode, execution_policy_input),
auto_approve_bash_warnings,
app_handle.clone(),
) {
Ok(tool) => {
@@ -395,6 +398,7 @@ pub(crate) async fn apply_workspace_sandbox_permissions(
surface: tool_surface,
workspace_root,
auto_mode,
bypass_restrictions: bypass_workspace_restrictions,
execution_policy_input,
});
@@ -503,7 +507,7 @@ pub(crate) async fn apply_workspace_sandbox_permissions(
workspace_tools::register_workspace_runtime_tools(
&mut registry,
task_manager,
should_auto_approve_tool_warnings("Bash", auto_mode, execution_policy_input),
auto_approve_bash_warnings,
app_handle.clone(),
sandboxed_bash_tool,
);
@@ -2192,7 +2192,11 @@ mod tests {
})),
presentation: Some(serde_json::json!({
"version": "lime-image-chat-v1",
"assistant_intro": "好啊,生成:未来感青柠实验室",
"opening_guidance": {
"source": "model_stream",
"avoid_fixed_templates": true,
"avoid_visible_process_lines": true
},
"completion_caption": "青柠实验室已生成"
})),
taste_context: Some(serde_json::json!({
+10 -2
View File
@@ -5758,7 +5758,11 @@ mod tests {
})),
presentation: Some(json!({
"version": "lime-image-chat-v1",
"assistant_intro": "好啊,生成:未来感青柠实验室",
"opening_guidance": {
"source": "model_stream",
"avoid_fixed_templates": true,
"avoid_visible_process_lines": true
},
"completion_caption": "青柠实验室已生成"
})),
taste_context: Some(json!({
@@ -5837,7 +5841,11 @@ mod tests {
})),
presentation: Some(json!({
"version": "lime-image-chat-v1",
"assistant_intro": "好啊,生成:未来感青柠实验室",
"opening_guidance": {
"source": "model_stream",
"avoid_fixed_templates": true,
"avoid_visible_process_lines": true
},
"completion_caption": "青柠实验室已生成"
})),
taste_context: Some(json!({
@@ -144,6 +144,40 @@ impl AgentTimelineRecorder {
})
}
pub fn from_started_turn(db: DbConnection, turn: AgentThreadTurn) -> Result<Self, String> {
let mut sequence_counter = 0;
let mut item_sequences = HashMap::new();
let mut item_statuses = HashMap::new();
let mut plan_text = None;
{
let conn = lock_db(&db)?;
AgentTimelineDao::upsert_turn(&conn, &turn)
.map_err(|e| format!("同步 turn 启动态失败: {e}"))?;
let items = AgentTimelineDao::list_items_by_thread(&conn, &turn.thread_id)
.map_err(|e| format!("读取 turn item 失败: {e}"))?;
for item in items.into_iter().filter(|item| item.turn_id == turn.id) {
sequence_counter = sequence_counter.max(item.sequence);
item_sequences.insert(item.id.clone(), item.sequence);
item_statuses.insert(item.id.clone(), item.status.clone());
if let AgentThreadItemPayload::Plan { text } = item.payload {
plan_text = Some(text);
}
}
}
Ok(Self {
db,
thread_id: turn.thread_id.clone(),
turn_id: turn.id.clone(),
turn,
sequence_counter,
item_sequences,
item_statuses,
plan_text,
})
}
pub fn thread_id(&self) -> &str {
&self.thread_id
}
@@ -394,6 +428,37 @@ impl AgentTimelineRecorder {
Ok(events)
}
pub fn record_synthetic_item(
&mut self,
app: &AppHandle,
event_name: &str,
id: String,
status: AgentThreadItemStatus,
completed_at: Option<String>,
payload: AgentThreadItemPayload,
) -> Result<AgentThreadItem, String> {
let event = self.record_synthetic_item_event(id, status, completed_at, payload)?;
let item = match &event {
RuntimeAgentEvent::ItemStarted { item }
| RuntimeAgentEvent::ItemUpdated { item }
| RuntimeAgentEvent::ItemCompleted { item } => item.clone(),
_ => return Err("synthetic item 写入后产生了非 item 事件".to_string()),
};
emit_event(app, event_name, &event);
Ok(item)
}
pub fn record_synthetic_item_event(
&mut self,
id: String,
status: AgentThreadItemStatus,
completed_at: Option<String>,
payload: AgentThreadItemPayload,
) -> Result<RuntimeAgentEvent, String> {
let item = self.build_item(id, status, completed_at, payload);
self.persist_item_and_build_event(item)
}
fn complete_projection_items(
&mut self,
status: AgentThreadItemStatus,
@@ -496,9 +561,22 @@ impl AgentTimelineRecorder {
&mut self,
app: &AppHandle,
event_name: &str,
item: AgentThreadItem,
mut item: AgentThreadItem,
event: RuntimeAgentEvent,
) -> Result<(), String> {
self.normalize_runtime_item_sequence(&mut item);
let event = match event {
RuntimeAgentEvent::ItemStarted { .. } => {
RuntimeAgentEvent::ItemStarted { item: item.clone() }
}
RuntimeAgentEvent::ItemUpdated { .. } => {
RuntimeAgentEvent::ItemUpdated { item: item.clone() }
}
RuntimeAgentEvent::ItemCompleted { .. } => {
RuntimeAgentEvent::ItemCompleted { item: item.clone() }
}
other => other,
};
self.sync_runtime_item_state(&item);
{
let conn = lock_db(&self.db)?;
@@ -509,6 +587,41 @@ impl AgentTimelineRecorder {
Ok(())
}
fn normalize_runtime_item_sequence(&mut self, item: &mut AgentThreadItem) {
if let Some(existing_sequence) = self.item_sequences.get(&item.id) {
item.sequence = *existing_sequence;
return;
}
self.sequence_counter = self.sequence_counter.max(item.sequence);
if !self.sequence_is_used_by_other_item(&item.id, item.sequence) {
return;
}
item.sequence = self.next_available_sequence();
}
fn sequence_is_used_by_other_item(&self, item_id: &str, sequence: i64) -> bool {
self.item_sequences
.iter()
.any(|(existing_id, existing_sequence)| {
existing_id != item_id && *existing_sequence == sequence
})
}
fn next_available_sequence(&mut self) -> i64 {
loop {
self.sequence_counter += 1;
if !self
.item_sequences
.values()
.any(|sequence| *sequence == self.sequence_counter)
{
return self.sequence_counter;
}
}
}
fn sync_runtime_item_state(&mut self, item: &AgentThreadItem) {
self.thread_id = item.thread_id.clone();
self.turn_id = item.turn_id.clone();
@@ -532,6 +645,7 @@ mod tests {
};
use lime_core::database::schema::create_tables;
use rusqlite::Connection;
use serde_json::json;
use std::sync::{Arc, Mutex};
fn setup_db() -> DbConnection {
@@ -550,6 +664,253 @@ mod tests {
Arc::new(Mutex::new(conn))
}
fn running_turn(id: &str) -> AgentThreadTurn {
AgentThreadTurn {
id: id.to_string(),
thread_id: "thread-1".to_string(),
prompt_text: "@analysis 帮我分析一下今天的国际形势".to_string(),
status: AgentThreadTurnStatus::Running,
started_at: "2026-03-13T00:00:00Z".to_string(),
completed_at: None,
error_message: None,
created_at: "2026-03-13T00:00:00Z".to_string(),
updated_at: "2026-03-13T00:00:00Z".to_string(),
}
}
#[test]
fn from_started_turn_should_upsert_turn_and_seed_existing_items() {
let db = setup_db();
{
let conn = lock_db(&db).expect("获取数据库锁");
AgentTimelineDao::upsert_turn(&conn, &running_turn("turn-skill-1"))
.expect("写入测试 turn");
AgentTimelineDao::upsert_item(
&conn,
&AgentThreadItem {
id: "reasoning:assistant-1".to_string(),
thread_id: "thread-1".to_string(),
turn_id: "turn-skill-1".to_string(),
sequence: 3,
status: AgentThreadItemStatus::InProgress,
started_at: "2026-03-13T00:00:01Z".to_string(),
completed_at: None,
updated_at: "2026-03-13T00:00:01Z".to_string(),
payload: AgentThreadItemPayload::Reasoning {
text: "先确认可用范围".to_string(),
summary: Some(vec!["先确认可用范围".to_string()]),
},
},
)
.expect("写入测试 item");
}
let recorder =
AgentTimelineRecorder::from_started_turn(db.clone(), running_turn("turn-skill-1"))
.expect("应从 turn_started 创建 recorder");
assert_eq!(recorder.thread_id(), "thread-1");
assert_eq!(recorder.turn_id(), "turn-skill-1");
let conn = lock_db(&db).expect("获取数据库锁");
let turns = AgentTimelineDao::list_turns_by_thread(&conn, "thread-1").expect("读取 turn");
assert_eq!(turns.len(), 1);
assert_eq!(turns[0].id, "turn-skill-1");
assert_eq!(turns[0].status, AgentThreadTurnStatus::Running);
}
#[test]
fn synthetic_skill_item_should_keep_recorder_sequence_and_payload() {
let db = setup_db();
{
let conn = lock_db(&db).expect("获取数据库锁");
AgentTimelineDao::upsert_turn(&conn, &running_turn("turn-skill-1"))
.expect("写入测试 turn");
AgentTimelineDao::upsert_item(
&conn,
&AgentThreadItem {
id: "reasoning:assistant-1".to_string(),
thread_id: "thread-1".to_string(),
turn_id: "turn-skill-1".to_string(),
sequence: 3,
status: AgentThreadItemStatus::Completed,
started_at: "2026-03-13T00:00:01Z".to_string(),
completed_at: Some("2026-03-13T00:00:02Z".to_string()),
updated_at: "2026-03-13T00:00:02Z".to_string(),
payload: AgentThreadItemPayload::Reasoning {
text: "已完成思考".to_string(),
summary: Some(vec!["已完成思考".to_string()]),
},
},
)
.expect("写入测试 item");
}
let mut recorder =
AgentTimelineRecorder::from_started_turn(db.clone(), running_turn("turn-skill-1"))
.expect("应从 turn_started 创建 recorder");
let item_id = "skill:exec-1".to_string();
let start_event = recorder
.record_synthetic_item_event(
item_id.clone(),
AgentThreadItemStatus::InProgress,
None,
AgentThreadItemPayload::ToolCall {
tool_name: "Skill".to_string(),
arguments: Some(json!({
"skill": "analysis",
"source": "SKILL.md"
})),
output: Some("正在从 SKILL.md 读取并执行 Skill:Analysis".to_string()),
success: None,
error: None,
metadata: Some(json!({
"tool_family": "skill",
"skill_source": "SKILL.md",
"markdown_content_bytes": 1024
})),
},
)
.expect("应记录 synthetic Skill item");
let RuntimeAgentEvent::ItemStarted { item } = start_event else {
panic!("首次 synthetic item 应发出 ItemStarted");
};
assert_eq!(item.sequence, 4);
assert!(matches!(item.status, AgentThreadItemStatus::InProgress));
match &item.payload {
AgentThreadItemPayload::ToolCall {
tool_name,
arguments,
metadata,
..
} => {
assert_eq!(tool_name, "Skill");
assert_eq!(
arguments
.as_ref()
.and_then(|v| v.get("source"))
.and_then(serde_json::Value::as_str),
Some("SKILL.md")
);
assert_eq!(
metadata
.as_ref()
.and_then(|v| v.get("markdown_content_bytes"))
.and_then(serde_json::Value::as_u64),
Some(1024)
);
}
_ => panic!("Skill invocation 应保存为 tool_call item"),
}
let completed_event = recorder
.record_synthetic_item_event(
item_id,
AgentThreadItemStatus::Completed,
Some("2026-03-13T00:00:03Z".to_string()),
AgentThreadItemPayload::ToolCall {
tool_name: "Skill".to_string(),
arguments: Some(json!({
"skill": "analysis",
"source": "SKILL.md"
})),
output: Some("已从 SKILL.md 读取并执行 Skill:Analysis".to_string()),
success: Some(true),
error: None,
metadata: Some(json!({
"tool_family": "skill",
"skill_source": "SKILL.md",
"markdown_content_bytes": 1024
})),
},
)
.expect("应完成 synthetic Skill item");
let RuntimeAgentEvent::ItemCompleted { item } = completed_event else {
panic!("完成 synthetic item 应发出 ItemCompleted");
};
assert_eq!(item.sequence, 4);
assert_eq!(item.completed_at.as_deref(), Some("2026-03-13T00:00:03Z"));
assert!(matches!(item.status, AgentThreadItemStatus::Completed));
}
#[test]
fn runtime_items_after_synthetic_skill_should_not_reuse_sequence() {
let db = setup_db();
{
let conn = lock_db(&db).expect("获取数据库锁");
AgentTimelineDao::upsert_turn(&conn, &running_turn("turn-skill-1"))
.expect("写入测试 turn");
AgentTimelineDao::upsert_item(
&conn,
&AgentThreadItem {
id: "user:1".to_string(),
thread_id: "thread-1".to_string(),
turn_id: "turn-skill-1".to_string(),
sequence: 1,
status: AgentThreadItemStatus::Completed,
started_at: "2026-03-13T00:00:01Z".to_string(),
completed_at: Some("2026-03-13T00:00:01Z".to_string()),
updated_at: "2026-03-13T00:00:01Z".to_string(),
payload: AgentThreadItemPayload::UserMessage {
content: "@analysis 请分析".to_string(),
},
},
)
.expect("写入测试 user item");
}
let mut recorder =
AgentTimelineRecorder::from_started_turn(db.clone(), running_turn("turn-skill-1"))
.expect("应从 turn_started 创建 recorder");
recorder
.record_synthetic_item_event(
"skill:exec-1".to_string(),
AgentThreadItemStatus::InProgress,
None,
AgentThreadItemPayload::ToolCall {
tool_name: "Skill".to_string(),
arguments: Some(json!({
"skill": "analysis",
"source": "SKILL.md"
})),
output: Some("正在从 SKILL.md 读取并执行 Skill:analysis".to_string()),
success: None,
error: None,
metadata: Some(json!({
"tool_family": "skill",
"skill_source": "SKILL.md",
"markdown_content_bytes": 1024
})),
},
)
.expect("应记录 synthetic Skill item");
let mut reasoning_item = AgentThreadItem {
id: "reasoning:1".to_string(),
thread_id: "thread-1".to_string(),
turn_id: "turn-skill-1".to_string(),
sequence: 2,
status: AgentThreadItemStatus::InProgress,
started_at: "2026-03-13T00:00:02Z".to_string(),
completed_at: None,
updated_at: "2026-03-13T00:00:02Z".to_string(),
payload: AgentThreadItemPayload::Reasoning {
text: "先读取 Skill".to_string(),
summary: Some(vec!["先读取 Skill".to_string()]),
},
};
recorder.normalize_runtime_item_sequence(&mut reasoning_item);
assert_eq!(reasoning_item.sequence, 3);
let conn = lock_db(&db).expect("获取数据库锁");
let items = AgentTimelineDao::list_items_by_thread(&conn, "thread-1").expect("读取 item");
let skill_item = items.iter().find(|item| item.id == "skill:exec-1").unwrap();
assert_eq!(skill_item.sequence, 2);
}
#[test]
fn fail_turn_should_persist_failed_turn_before_emitting_events() {
let db = setup_db();
+27
View File
@@ -534,4 +534,31 @@ Invalid content
assert!(!skill.disable_model_invocation);
assert!(skill.standard_compliance.is_standard);
}
#[test]
fn test_bundled_analysis_skill_loads_full_instruction_body() {
let skill_file = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("resources/default-skills/analysis/SKILL.md");
assert!(skill_file.exists());
let skill = load_skill_from_file("analysis", &skill_file).unwrap();
assert_eq!(skill.display_name, "analysis");
assert_eq!(skill.execution_mode, "prompt");
assert_eq!(
skill.allowed_tools,
Some(vec!["list_directory".to_string(), "read_file".to_string()])
);
assert_eq!(
skill.metadata.get("lime_version").map(String::as_str),
Some("1.0.1")
);
assert!(skill.markdown_content.contains("你是 Lime 的分析助手"));
assert!(skill
.markdown_content
.contains("不要仅因没有外部材料就输出“无法分析”"));
assert!(skill.markdown_content.contains("## 输出格式(固定)"));
assert!(!skill.markdown_content.contains("lime_execution_mode"));
assert!(skill.standard_compliance.is_standard);
}
}
+9
View File
@@ -846,18 +846,23 @@ mod tests {
.contains("必须直接调用 `lime_create_cover_generation_task` 创建真实封面任务记录"));
assert!(MODAL_RESOURCE_SEARCH_SKILL_CONTENT.contains("name: modal_resource_search"));
assert!(IMAGE_GENERATE_SKILL_CONTENT.contains("name: image_generate"));
assert!(IMAGE_GENERATE_SKILL_CONTENT.contains("lime_version: 1.3.5"));
assert!(IMAGE_GENERATE_SKILL_CONTENT
.contains("allowed-tools: lime_create_image_generation_task"));
assert!(IMAGE_GENERATE_SKILL_CONTENT
.contains("必须直接调用 `lime_create_image_generation_task` 创建真实图片任务"));
assert!(IMAGE_GENERATE_SKILL_CONTENT
.contains("必须直接传扁平任务对象参数;不要包成 `{\"image_task\": ...}`"));
assert!(IMAGE_GENERATE_SKILL_CONTENT.contains("assistant_intro_request"));
assert!(IMAGE_GENERATE_SKILL_CONTENT.contains("completion_caption_request"));
assert!(IMAGE_GENERATE_SKILL_CONTENT.contains("runtime_contract.layered_design"));
assert!(
IMAGE_GENERATE_SKILL_CONTENT.contains("不要改写成 `poster_generate`、`canvas:poster`")
);
assert!(IMAGE_GENERATE_SKILL_CONTENT
.contains("不要通过 `Bash` 拼接 `lime media image generate --json`"));
assert!(!IMAGE_GENERATE_SKILL_CONTENT.contains("任务 ID:{task_id}"));
assert!(!IMAGE_GENERATE_SKILL_CONTENT.contains("任务文件:{path}"));
assert!(!IMAGE_GENERATE_SKILL_CONTENT
.contains("allowed-tools: Bash, lime_create_image_generation_task"));
assert!(!IMAGE_GENERATE_SKILL_CONTENT
@@ -875,6 +880,10 @@ mod tests {
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!(ANALYSIS_SKILL_CONTENT.contains("name: analysis"));
assert!(ANALYSIS_SKILL_CONTENT.contains("lime_version: 1.0.1"));
assert!(ANALYSIS_SKILL_CONTENT.contains("不要仅因没有外部材料就输出“无法分析”"));
assert!(ANALYSIS_SKILL_CONTENT.contains("实时信息边界"));
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"));
+540 -20
View File
@@ -8,12 +8,13 @@ use lime_agent::{
artifact_protocol::extend_unique_artifact_protocol_paths,
execute_skill_prompt as execute_agent_skill_prompt,
execute_skill_workflow as execute_agent_skill_workflow, AgentEvent as RuntimeAgentEvent,
AsterAgentState, SkillEventEmitter, SkillExecutionError, SkillInputImage, SkillPromptExecution,
SkillWorkflowExecution,
AgentToolResult, AsterAgentState, SkillEventEmitter, SkillExecutionError, SkillInputImage,
SkillPromptExecution, SkillWorkflowExecution,
};
use lime_core::database::dao::agent_timeline::{AgentThreadItemPayload, AgentThreadItemStatus};
use lime_skills::{ExecutionCallback, LoadedSkillDefinition};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use serde_json::{json, Value};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
@@ -26,6 +27,7 @@ use crate::commands::skill_error::{
};
use crate::config::GlobalConfigManagerState;
use crate::database::DbConnection;
use crate::services::agent_timeline_service::AgentTimelineRecorder;
use crate::services::execution_tracker_service::{ExecutionTracker, RunSource};
use super::execution_callback::TauriExecutionCallback;
@@ -266,20 +268,458 @@ impl ExecutionCallback for TauriExecutionCallbackAdapter<'_> {
}
}
fn create_skill_event_emitter(app_handle: &AppHandle) -> SkillEventEmitter {
let app_handle = app_handle.clone();
Arc::new(move |event_name: String, event: RuntimeAgentEvent| {
if let Err(error) = app_handle.emit(&event_name, &event) {
tracing::error!("[execute_skill_workflow] 发送事件失败: {}", error);
}
})
#[derive(Debug, Clone)]
struct SkillInvocationTrace {
execution_id: String,
skill_name: String,
display_name: String,
execution_mode: String,
version: Option<String>,
is_standard: bool,
markdown_content: String,
markdown_content_bytes: usize,
}
fn emit_skill_final_done(app_handle: &AppHandle, execution_id: &str) {
let event_name = format!("skill-exec-{execution_id}");
if let Err(error) = app_handle.emit(&event_name, RuntimeAgentEvent::FinalDone { usage: None }) {
tracing::error!("[execute_skill] 发送完成事件失败: {}", error);
impl SkillInvocationTrace {
fn new(skill: &LoadedSkillDefinition, execution_id: &str) -> Self {
Self {
execution_id: execution_id.to_string(),
skill_name: skill.skill_name.clone(),
display_name: skill.display_name.clone(),
execution_mode: skill.execution_mode.clone(),
version: skill
.metadata
.get("lime_version")
.or_else(|| skill.metadata.get("version"))
.cloned(),
is_standard: skill.standard_compliance.is_standard,
markdown_content: skill.markdown_content.clone(),
markdown_content_bytes: skill.markdown_content.len(),
}
}
fn tool_id(&self) -> String {
format!("skill:{}", self.execution_id)
}
fn arguments(&self) -> Value {
let mut args = json!({
"skill": self.skill_name,
"name": self.skill_name,
"display_name": self.display_name,
"source": "SKILL.md",
"execution_mode": self.execution_mode,
});
if let Some(version) = self.version.as_deref() {
if let Some(record) = args.as_object_mut() {
record.insert("version".to_string(), json!(version));
}
}
args
}
fn metadata(&self) -> Value {
json!({
"tool_family": "skill",
"skill_name": self.skill_name,
"skill_display_name": self.display_name,
"skill_source": "SKILL.md",
"agent_skills_standard": self.is_standard,
"skill_markdown_content": self.markdown_content.as_str(),
"skill_markdown_content_format": "text/markdown",
"markdown_content_bytes": self.markdown_content_bytes,
"version": self.version,
})
}
fn success_output(&self) -> String {
format!("已从 SKILL.md 读取并执行 Skill:{}", self.display_name)
}
fn running_output(&self) -> String {
format!("正在从 SKILL.md 读取并执行 Skill:{}", self.display_name)
}
}
#[derive(Clone)]
struct SkillRuntimeEventBridge {
app_handle: AppHandle,
db: DbConnection,
recorder: Arc<Mutex<Option<AgentTimelineRecorder>>>,
invocation_trace: Arc<SkillInvocationTrace>,
tool_start_emitted: Arc<Mutex<bool>>,
invocation_item_started: Arc<Mutex<bool>>,
}
impl SkillRuntimeEventBridge {
fn new(
app_handle: &AppHandle,
db: &DbConnection,
skill: &LoadedSkillDefinition,
execution_id: &str,
) -> Self {
Self {
app_handle: app_handle.clone(),
db: db.clone(),
recorder: Arc::new(Mutex::new(None)),
invocation_trace: Arc::new(SkillInvocationTrace::new(skill, execution_id)),
tool_start_emitted: Arc::new(Mutex::new(false)),
invocation_item_started: Arc::new(Mutex::new(false)),
}
}
fn emitter(&self) -> SkillEventEmitter {
let bridge = self.clone();
Arc::new(move |event_name: String, event: RuntimeAgentEvent| {
bridge.emit_runtime_event(event_name, event);
})
}
fn emit_final_done(&self, execution_id: &str) {
let event_name = format!("skill-exec-{execution_id}");
self.emit_runtime_event(event_name, RuntimeAgentEvent::FinalDone { usage: None });
}
fn finish_turn_success(&self, event_name: &str) {
let events = {
let mut recorder_guard = match self.recorder.lock() {
Ok(guard) => guard,
Err(error) => error.into_inner(),
};
let Some(recorder) = recorder_guard.as_mut() else {
return;
};
match recorder.complete_turn_success() {
Ok(events) => events,
Err(error) => {
tracing::warn!(
"[execute_skill] 标记 Skill runtime turn 完成失败,已降级继续: {}",
error
);
return;
}
}
};
for event in events {
self.emit_runtime_event_directly(event_name, &event);
}
}
fn finish_turn_failed(&self, event_name: &str, message: &str) {
let events = {
let mut recorder_guard = match self.recorder.lock() {
Ok(guard) => guard,
Err(error) => error.into_inner(),
};
let Some(recorder) = recorder_guard.as_mut() else {
return;
};
match recorder.fail_turn(message) {
Ok(events) => events,
Err(error) => {
tracing::warn!(
"[execute_skill] 标记 Skill runtime turn 失败状态失败,已降级继续: {}",
error
);
return;
}
}
};
for event in events {
self.emit_runtime_event_directly(event_name, &event);
}
}
fn finish_current_turn(&self, event_name: &str, success: bool, error_message: Option<&str>) {
self.finish_skill_invocation(event_name, success, error_message);
if success {
self.finish_turn_success(event_name);
} else {
self.finish_turn_failed(
event_name,
error_message.unwrap_or("Skill 执行失败,未返回可用结果。"),
);
}
}
fn emit_runtime_event(&self, event_name: String, event: RuntimeAgentEvent) {
let recorder_emitted_equivalent = self.record_runtime_event(&event_name, &event);
if !recorder_emitted_equivalent {
self.emit_runtime_event_directly(&event_name, &event);
}
}
fn emit_runtime_event_directly(&self, event_name: &str, event: &RuntimeAgentEvent) {
if let Err(error) = self.app_handle.emit(event_name, event) {
tracing::error!("[execute_skill] 发送事件失败: {}", error);
}
}
fn emit_skill_invocation_start(&self, event_name: &str) {
let should_emit = {
let mut started_guard = match self.tool_start_emitted.lock() {
Ok(guard) => guard,
Err(error) => error.into_inner(),
};
if *started_guard {
false
} else {
*started_guard = true;
true
}
};
if !should_emit {
return;
}
let arguments = self.invocation_trace.arguments();
let tool_start = RuntimeAgentEvent::ToolStart {
tool_name: "Skill".to_string(),
tool_id: self.invocation_trace.tool_id(),
arguments: Some(arguments.to_string()),
};
self.emit_runtime_event_directly(event_name, &tool_start);
}
fn start_skill_invocation_item(&self, event_name: &str) {
let should_record = {
let mut started_guard = match self.invocation_item_started.lock() {
Ok(guard) => guard,
Err(error) => error.into_inner(),
};
if *started_guard {
false
} else {
*started_guard = true;
true
}
};
if !should_record {
return;
}
self.record_skill_invocation_item(
event_name,
AgentThreadItemStatus::InProgress,
None,
None,
);
}
fn start_skill_invocation(&self, event_name: &str) {
self.emit_skill_invocation_start(event_name);
self.start_skill_invocation_item(event_name);
}
fn finish_skill_invocation(
&self,
event_name: &str,
success: bool,
error_message: Option<&str>,
) {
let started = match self.invocation_item_started.lock() {
Ok(guard) => *guard,
Err(error) => *error.into_inner(),
};
if !started {
self.start_skill_invocation(event_name);
}
let output = if success {
Some(self.invocation_trace.success_output())
} else {
None
};
let error = if success {
None
} else {
Some(
error_message
.unwrap_or("Skill 执行失败,未返回可用结果。")
.to_string(),
)
};
self.record_skill_invocation_item(
event_name,
if success {
AgentThreadItemStatus::Completed
} else {
AgentThreadItemStatus::Failed
},
output.clone(),
error.clone(),
);
let tool_end = RuntimeAgentEvent::ToolEnd {
tool_id: self.invocation_trace.tool_id(),
result: AgentToolResult {
success,
output: output.unwrap_or_default(),
error,
images: None,
metadata: Some(
serde_json::from_value(self.invocation_trace.metadata()).unwrap_or_default(),
),
},
};
self.emit_runtime_event_directly(event_name, &tool_end);
}
fn build_skill_invocation_payload(
&self,
status: AgentThreadItemStatus,
output: Option<String>,
error: Option<String>,
) -> AgentThreadItemPayload {
let success = match status {
AgentThreadItemStatus::Completed => Some(true),
AgentThreadItemStatus::Failed => Some(false),
AgentThreadItemStatus::InProgress => None,
};
let visible_output = if matches!(status, AgentThreadItemStatus::InProgress) {
Some(self.invocation_trace.running_output())
} else {
output
};
AgentThreadItemPayload::ToolCall {
tool_name: "Skill".to_string(),
arguments: Some(self.invocation_trace.arguments()),
output: visible_output,
success,
error,
metadata: Some(self.invocation_trace.metadata()),
}
}
fn record_skill_invocation_item(
&self,
event_name: &str,
status: AgentThreadItemStatus,
output: Option<String>,
error: Option<String>,
) {
let completed_at = if matches!(status, AgentThreadItemStatus::InProgress) {
None
} else {
Some(chrono::Utc::now().to_rfc3339())
};
let payload = self.build_skill_invocation_payload(status.clone(), output, error);
let mut recorder_guard = match self.recorder.lock() {
Ok(guard) => guard,
Err(error) => error.into_inner(),
};
let Some(recorder) = recorder_guard.as_mut() else {
return;
};
if let Err(error) = recorder.record_synthetic_item(
&self.app_handle,
event_name,
self.invocation_trace.tool_id(),
status,
completed_at,
payload,
) {
tracing::warn!("[execute_skill] 记录 Skill invocation item 失败,已降级继续: {error}");
}
}
fn record_runtime_event(&self, event_name: &str, event: &RuntimeAgentEvent) -> bool {
match event {
RuntimeAgentEvent::TurnStarted { turn } => {
let mut recorder_guard = match self.recorder.lock() {
Ok(guard) => guard,
Err(error) => error.into_inner(),
};
if let Some(recorder) = recorder_guard.as_mut() {
if recorder.turn_id() != turn.id {
match recorder.complete_turn_success() {
Ok(events) => {
for event in events {
self.emit_runtime_event_directly(event_name, &event);
}
}
Err(error) => {
tracing::warn!(
"[execute_skill] 结束上一段 Skill runtime turn 失败,已降级继续: {}",
error
);
}
}
}
}
match AgentTimelineRecorder::from_started_turn(self.db.clone(), turn.clone()) {
Ok(recorder) => {
*recorder_guard = Some(recorder);
drop(recorder_guard);
self.emit_skill_invocation_start(event_name);
}
Err(error) => {
tracing::warn!(
"[execute_skill] 创建 Skill runtime timeline 失败,已降级继续: {}",
error
);
}
}
false
}
RuntimeAgentEvent::ItemStarted { .. }
| RuntimeAgentEvent::ItemUpdated { .. }
| RuntimeAgentEvent::ItemCompleted { .. } => {
self.record_with_existing_recorder(event_name, event)
}
RuntimeAgentEvent::ArtifactSnapshot { .. }
| RuntimeAgentEvent::ContextCompactionStarted { .. }
| RuntimeAgentEvent::ContextCompactionCompleted { .. }
| RuntimeAgentEvent::Warning { .. }
| RuntimeAgentEvent::Error { .. } => {
self.record_with_existing_recorder(event_name, event);
false
}
_ => false,
}
}
fn record_with_existing_recorder(&self, event_name: &str, event: &RuntimeAgentEvent) -> bool {
let should_record_skill_item_after_event = matches!(
event,
RuntimeAgentEvent::ItemStarted { item }
| RuntimeAgentEvent::ItemUpdated { item }
| RuntimeAgentEvent::ItemCompleted { item }
if matches!(item.payload, AgentThreadItemPayload::UserMessage { .. })
);
let mut recorder_guard = match self.recorder.lock() {
Ok(guard) => guard,
Err(error) => error.into_inner(),
};
let Some(recorder) = recorder_guard.as_mut() else {
return false;
};
if let Err(error) = recorder.record_runtime_event(&self.app_handle, event_name, event, "") {
tracing::warn!(
"[execute_skill] 记录 Skill runtime timeline 事件失败,已降级继续: {}",
error
);
return false;
}
drop(recorder_guard);
if should_record_skill_item_after_event {
self.start_skill_invocation_item(event_name);
}
true
}
}
fn create_skill_event_bridge(
app_handle: &AppHandle,
db: &DbConnection,
skill: &LoadedSkillDefinition,
execution_id: &str,
) -> SkillRuntimeEventBridge {
SkillRuntimeEventBridge::new(app_handle, db, skill, execution_id)
}
fn map_execution_error(error: SkillExecutionError) -> String {
@@ -396,6 +836,7 @@ pub async fn execute_named_skill(
execute_skill_definition(
&app_handle,
&db,
&aster_state,
&skill,
&user_input_for_run,
@@ -428,6 +869,7 @@ pub async fn execute_named_skill(
pub async fn execute_skill_prompt(
app_handle: &AppHandle,
db: &DbConnection,
aster_state: &AsterAgentState,
skill: &LoadedSkillDefinition,
user_input: &str,
@@ -440,6 +882,8 @@ pub async fn execute_skill_prompt(
) -> Result<SkillExecutionResult, String> {
let callback_adapter = TauriExecutionCallbackAdapter::new(callback);
callback_adapter.on_step_start("main", &skill.display_name, 1, 1);
let event_bridge = create_skill_event_bridge(app_handle, db, skill, execution_id);
let event_name = format!("skill-exec-{execution_id}");
let mut result = map_execution_result(
execute_agent_skill_prompt(SkillPromptExecution {
@@ -451,7 +895,7 @@ pub async fn execute_skill_prompt(
execution_id,
session_id,
memory_prompt,
emitter: create_skill_event_emitter(app_handle),
emitter: event_bridge.emitter(),
})
.await
.map_err(map_execution_error)?,
@@ -464,7 +908,8 @@ pub async fn execute_skill_prompt(
.unwrap_or_else(|| format_skill_error(SKILL_ERR_EXECUTE_FAILED, "Unknown error"));
callback_adapter.on_step_error("main", &error_message, false);
callback_adapter.on_complete(false, None, Some(&error_message));
emit_skill_final_done(app_handle, execution_id);
event_bridge.finish_current_turn(&event_name, false, Some(&error_message));
event_bridge.emit_final_done(execution_id);
return Ok(result);
}
@@ -483,12 +928,14 @@ pub async fn execute_skill_prompt(
callback_adapter.on_step_complete("main", &finalized.final_output);
callback_adapter.on_complete(true, Some(&finalized.final_output), None);
emit_skill_final_done(app_handle, execution_id);
event_bridge.finish_current_turn(&event_name, true, None);
event_bridge.emit_final_done(execution_id);
Ok(result)
}
pub async fn execute_skill_workflow(
app_handle: &AppHandle,
db: &DbConnection,
aster_state: &AsterAgentState,
skill: &LoadedSkillDefinition,
user_input: &str,
@@ -500,7 +947,9 @@ pub async fn execute_skill_workflow(
memory_prompt: Option<&str>,
) -> Result<SkillExecutionResult, String> {
let callback_adapter = TauriExecutionCallbackAdapter::new(callback);
execute_agent_skill_workflow(SkillWorkflowExecution {
let event_bridge = create_skill_event_bridge(app_handle, db, skill, execution_id);
let event_name = format!("skill-exec-{execution_id}");
let result = execute_agent_skill_workflow(SkillWorkflowExecution {
aster_state,
skill,
user_input,
@@ -510,15 +959,19 @@ pub async fn execute_skill_workflow(
session_id,
callback: &callback_adapter,
memory_prompt,
emitter: create_skill_event_emitter(app_handle),
emitter: event_bridge.emitter(),
})
.await
.map(map_execution_result)
.map_err(map_execution_error)
.map_err(map_execution_error)?;
event_bridge.finish_current_turn(&event_name, result.success, result.error.as_deref());
Ok(result)
}
pub async fn execute_skill_definition(
app_handle: &AppHandle,
db: &DbConnection,
aster_state: &AsterAgentState,
skill: &LoadedSkillDefinition,
user_input: &str,
@@ -532,6 +985,7 @@ pub async fn execute_skill_definition(
if skill.execution_mode == "workflow" && !skill.workflow_steps.is_empty() {
execute_skill_workflow(
app_handle,
db,
aster_state,
skill,
user_input,
@@ -546,6 +1000,7 @@ pub async fn execute_skill_definition(
} else {
execute_skill_prompt(
app_handle,
db,
aster_state,
skill,
user_input,
@@ -560,4 +1015,69 @@ pub async fn execute_skill_definition(
}
}
#[cfg(test)]
mod tests {
use super::*;
use lime_core::models::SkillStandardCompliance;
use std::collections::HashMap;
fn test_skill(markdown_content: &str) -> LoadedSkillDefinition {
let mut metadata = HashMap::new();
metadata.insert("lime_version".to_string(), "1.0.1".to_string());
LoadedSkillDefinition {
skill_name: "analysis".to_string(),
display_name: "analysis".to_string(),
description: "分析任务".to_string(),
markdown_content: markdown_content.to_string(),
license: None,
compatibility: None,
metadata,
allowed_tools: None,
argument_hint: None,
when_to_use: None,
when_to_use_config: None,
model: None,
provider: None,
disable_model_invocation: false,
execution_mode: "prompt".to_string(),
workflow_ref: None,
workflow_steps: Vec::new(),
standard_compliance: SkillStandardCompliance {
is_standard: true,
validation_errors: Vec::new(),
deprecated_fields: Vec::new(),
},
}
}
#[test]
fn skill_invocation_trace_metadata_should_include_skill_markdown_snapshot() {
let markdown_content = "---\nname: analysis\n---\n\n# Analysis Skill\n\n必须先读取本文件。";
let skill = test_skill(markdown_content);
let trace = SkillInvocationTrace::new(&skill, "exec-1");
let metadata = trace.metadata();
assert_eq!(
metadata
.get("skill_source")
.and_then(serde_json::Value::as_str),
Some("SKILL.md")
);
assert_eq!(
metadata
.get("skill_markdown_content")
.and_then(serde_json::Value::as_str),
Some(markdown_content)
);
assert_eq!(
metadata
.get("markdown_content_bytes")
.and_then(serde_json::Value::as_u64),
Some(markdown_content.len() as u64)
);
}
}
pub use lime_agent::{SkillExecutionResult, StepResult};
+1
View File
@@ -109,6 +109,7 @@ fn build_skill_workspace_permissions(
surface: WorkspaceToolSurface::workbench(),
workspace_root,
auto_mode: false,
bypass_restrictions: false,
execution_policy_input: ToolExecutionResolverInput {
persisted_policy: Some(&config.agent.tool_execution),
request_metadata: metadata.as_ref(),
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Lime",
"version": "1.38.0",
"version": "1.39.0",
"identifier": "com.limecloud.lime.headless",
"build": {
"beforeDevCommand": "npm run dev:web-bridge",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Lime",
"version": "1.38.0",
"version": "1.39.0",
"identifier": "com.limecloud.lime",
"build": {
"beforeDevCommand": "node scripts/start-tauri-dev-server.mjs",
+31
View File
@@ -36,6 +36,12 @@ const latestKnowledgePageProps = vi.hoisted(
value: null as Record<string, unknown> | null,
}) as { value: Record<string, unknown> | null },
);
const agentAppLabLifecycle = vi.hoisted(
() =>
({
mounts: 0,
}) as { mounts: number },
);
const latestSceneAppsPageProps = vi.hoisted(
() =>
({
@@ -74,6 +80,10 @@ vi.mock("./settings-v2", () => ({
SettingsPageV2: () => <div data-testid="settings-page" />,
}));
vi.mock("./automation", () => ({
AutomationPage: () => <div data-testid="automation-page" />,
}));
vi.mock("./skills", () => ({
SkillsWorkspacePage: (props: Record<string, unknown>) => {
latestSkillsWorkspaceProps.value = props;
@@ -95,6 +105,16 @@ vi.mock("@/features/knowledge", () => ({
},
}));
vi.mock("@/features/agent-app", () => ({
AgentAppLabPage: () => {
useEffect(() => {
agentAppLabLifecycle.mounts += 1;
}, []);
return <div data-testid="agent-app-lab-page" />;
},
}));
vi.mock("./sceneapps", () => ({
SceneAppsPage: (props: Record<string, unknown>) => {
latestSceneAppsPageProps.value = props;
@@ -194,6 +214,7 @@ describe("AppPageContent", () => {
latestSkillsWorkspaceProps.value = null;
latestMemoryPageProps.value = null;
latestKnowledgePageProps.value = null;
agentAppLabLifecycle.mounts = 0;
latestSceneAppsPageProps.value = null;
sceneAppsLifecycle.mounts = 0;
sceneAppsLifecycle.unmounts = 0;
@@ -715,6 +736,16 @@ describe("AppPageContent", () => {
});
});
it("agent-app-lab 页面应渲染 P0 只读实验入口", async () => {
const { container } = renderContent("agent-app-lab");
await flushEffects();
expect(
container.querySelector('[data-testid="agent-app-lab-page"]'),
).not.toBeNull();
expect(agentAppLabLifecycle.mounts).toBe(1);
});
it("sceneapps 页面在持续流程往返后不应重新挂载", async () => {
const { rerender } = renderContent("sceneapps", {
sceneappId: "story-video-suite",
+15
View File
@@ -64,6 +64,10 @@ const loadKnowledgePage = () =>
import("@/features/knowledge").then((module) => ({
default: module.KnowledgePage,
}));
const loadAgentAppLabPage = () =>
import("@/features/agent-app").then((module) => ({
default: module.AgentAppLabPage,
}));
const loadBrowserRuntimeWorkspace = () =>
import("@/features/browser-runtime").then((module) => ({
default: module.BrowserRuntimeWorkspace,
@@ -122,6 +126,7 @@ const ResourcesPage = lazy(loadResourcesPage);
const MemoryPage = lazy(loadMemoryPage);
const SkillsWorkspacePage = lazy(loadSkillsWorkspacePage);
const KnowledgePage = lazy(loadKnowledgePage);
const AgentAppLabPage = lazy(loadAgentAppLabPage);
const BrowserRuntimeWorkspace = lazy(loadBrowserRuntimeWorkspace);
const AgentChatPage = lazy(loadAgentChatPage);
@@ -398,6 +403,16 @@ export function AppPageContent({
return wrapWithSceneApps(content);
}
if (activePage === "agent-app-lab") {
const content = (
<div style={columnPageStyle}>
<AgentAppLabPage />
</div>
);
return wrapWithSceneApps(content);
}
if (activePage === "knowledge") {
const content = (
<div style={{ ...columnPageStyle, overflow: "hidden" }}>
+1
View File
@@ -182,6 +182,7 @@ const SIDEBAR_CONVERSATION_NAVIGATION_DEFER_MS =
SIDEBAR_SESSION_ENTRY_REFRESH_DEFER_MS;
const SIDEBAR_SEARCH_HOVER_PREFETCH_DELAY_MS = 900;
const SIDEBAR_NAV_LABEL_KEYS: Record<string, string> = {
"agent-app-lab": "navigation.sidebar.items.agentAppLab",
automation: "navigation.sidebar.items.automation",
channels: "navigation.sidebar.items.channels",
companion: "navigation.sidebar.items.companion",
+145 -15
View File
@@ -26,6 +26,7 @@ import {
settleLiveArtifactAfterStreamStops,
useArtifactDisplayState,
} from "./hooks/useArtifactDisplayState";
import { clearAgentSessionCachedSnapshot } from "./hooks/agentSessionScopedStorage";
import type { TopicBranchStatus } from "./hooks/useTopicBranchBoard";
import { useSessionFiles } from "./hooks/useSessionFiles";
import { useContentSync } from "./hooks/useContentSync";
@@ -237,6 +238,7 @@ import {
subscribeTaskCenterDraftTaskRequests,
subscribeTaskCenterTaskPrefetchRequests,
subscribeTaskCenterTaskOpenRequests,
type TaskCenterTaskEventSource,
} from "./taskCenterDraftTaskEvents";
import type { GeneralWorkbenchFollowUpActionPayload } from "./components/generalWorkbenchSidebarContract";
import { RuntimeReviewDecisionDialog } from "./components/RuntimeReviewDecisionDialog";
@@ -289,6 +291,9 @@ import {
loadSessionImageWorkbenchCachedState,
saveSessionImageWorkbenchCachedState,
} from "./workspace/imageWorkbenchStateCache";
import { resolveImageWorkbenchStateForPreviewSelection } from "./workspace/imageWorkbenchPreviewSelection";
import { buildImageWorkbenchPreviewResourceManagerInput } from "./workspace/imageWorkbenchResourceManager";
import { openResourceManager } from "@/features/resource-manager";
import {
SOCIAL_ARTICLE_SKILL_KEY,
GENERAL_WORKBENCH_HISTORY_PAGE_SIZE,
@@ -384,6 +389,16 @@ const TASK_CENTER_DRAFT_SESSION_WARMUP_DELAY_MS = 120;
const NOOP_SET_CHAT_MESSAGES: Dispatch<SetStateAction<Message[]>> = () =>
undefined;
function shouldForceRefreshTaskOpenSource(
source?: TaskCenterTaskEventSource,
): boolean {
return (
source === "sidebar" ||
source === "sidebar_search" ||
source === "conversation_shelf"
);
}
function loadFileManagerSidebarOpen(): boolean {
return false;
}
@@ -2341,6 +2356,67 @@ export function AgentChatWorkspace({
: undefined,
getSyncedSessionRecentPreferences,
});
const activeSessionKey = sessionId?.trim() || null;
const restoredInteractiveMessageSnapshotRef = useRef<{
sessionId: string | null;
ids: Set<string>;
capturedInitial: boolean;
pendingRestoreCapture: boolean;
}>({
sessionId: null,
ids: new Set<string>(),
capturedInitial: false,
pendingRestoreCapture: false,
});
const readOnlyInteractiveMessageIds = useMemo<ReadonlySet<string>>(() => {
const snapshot = restoredInteractiveMessageSnapshotRef.current;
if (!activeSessionKey) {
snapshot.sessionId = null;
snapshot.ids = new Set<string>();
snapshot.capturedInitial = false;
snapshot.pendingRestoreCapture = false;
return snapshot.ids;
}
if (snapshot.sessionId !== activeSessionKey) {
snapshot.sessionId = activeSessionKey;
snapshot.ids = new Set<string>();
snapshot.capturedInitial = false;
snapshot.pendingRestoreCapture = false;
}
if (isAutoRestoringSession || isSessionHydrating) {
snapshot.pendingRestoreCapture = true;
}
const shouldCaptureInitialSessionMessages =
normalizedInitialSessionId === activeSessionKey &&
!snapshot.capturedInitial &&
messages.length > 0;
const shouldCaptureRestoredMessages =
snapshot.pendingRestoreCapture ||
shouldCaptureInitialSessionMessages ||
sessionHistoryWindow?.isLoadingFull === true;
let didCaptureRestoredMessages = false;
if (shouldCaptureRestoredMessages && messages.length > 0) {
for (const message of messages) {
snapshot.ids.add(message.id);
}
snapshot.capturedInitial = true;
snapshot.pendingRestoreCapture = false;
didCaptureRestoredMessages = true;
}
return didCaptureRestoredMessages ? new Set(snapshot.ids) : snapshot.ids;
}, [
activeSessionKey,
isAutoRestoringSession,
isSessionHydrating,
messages,
normalizedInitialSessionId,
sessionHistoryWindow?.isLoadingFull,
]);
const topicById = useMemo(
() => new Map(topics.map((topic) => [topic.id, topic])),
[topics],
@@ -3237,6 +3313,7 @@ export function AgentChatWorkspace({
resolvePendingA2UISubmit,
} = useWorkspaceA2UIRuntime({
messages,
readOnlyInteractiveMessageIds,
});
const pendingServiceSkillLaunchForm =
workspaceServiceSkillEntryActions.pendingServiceSkillLaunchForm;
@@ -3867,7 +3944,7 @@ export function AgentChatWorkspace({
const topic = topicById.get(topicId);
return {
allowDetachedSession: true,
forceRefresh: topic?.statusReason === "workspace_error",
forceRefresh: true,
...(shouldResumeTaskSession(topic)
? { resumeSessionStartHooks: true }
: {}),
@@ -3931,6 +4008,7 @@ export function AgentChatWorkspace({
const taskCenterDraftMaterializedSessionIdsRef = useRef<Map<string, string>>(
new Map(),
);
const taskCenterDraftSurfaceActiveRef = useRef(false);
const homePendingPreviewPaintedRequestIdsRef = useRef<Set<string>>(new Set());
const [taskCenterLocalSessionOverride, setTaskCenterLocalSessionOverride] =
useState<{
@@ -3976,6 +4054,7 @@ export function AgentChatWorkspace({
useEffect(() => {
if (agentEntry !== "claw") {
taskCenterDraftSurfaceActiveRef.current = false;
setTaskCenterTransitionTopicId(null);
return;
}
@@ -3996,6 +4075,7 @@ export function AgentChatWorkspace({
);
setTaskCenterDraftTabs((current) => (current.length > 0 ? [] : current));
setActiveTaskCenterDraftTabId(null);
taskCenterDraftSurfaceActiveRef.current = false;
if (agentEntry !== "new-task") {
setTaskCenterDraftSendRequest(null);
setHomePendingPreviewRequest(null);
@@ -4227,6 +4307,7 @@ export function AgentChatWorkspace({
prepareActiveContextPrompt: contextWorkspace.prepareActiveContextPrompt,
},
projectId,
projectRootPath: project?.rootPath || null,
sessionId,
executionStrategy,
accessMode,
@@ -4738,6 +4819,7 @@ export function AgentChatWorkspace({
status: "draft",
};
taskCenterDraftSurfaceActiveRef.current = true;
resetLocalImageWorkbenchSessionScope();
clearMessages({ showToast: false });
startTransition(() => {
@@ -4780,6 +4862,7 @@ export function AgentChatWorkspace({
newSessionId: string,
options?: { preserveInput?: boolean },
) => {
taskCenterDraftSurfaceActiveRef.current = false;
startTransition(() => {
setTaskCenterDraftTabs((current) =>
current.filter((tab) => tab.id !== draftTabId),
@@ -5012,11 +5095,17 @@ export function AgentChatWorkspace({
);
};
taskCenterDraftSurfaceActiveRef.current = false;
resetLocalImageWorkbenchSessionScope();
setTaskCenterTransitionTopicId(topicId);
setTaskCenterDetachedTopicId(null);
setActiveTaskCenterDraftTabId(null);
clearEntryPendingA2UI();
setChatMessages([]);
const targetSnapshotWorkspaceId = topicWorkspaceId ?? taskCenterWorkspaceId;
if (targetSnapshotWorkspaceId) {
clearAgentSessionCachedSnapshot(targetSnapshotWorkspaceId, topicId);
}
if (options?.replaceOpenTabs === true) {
replaceTaskCenterOpenTabs(topicId, topicWorkspaceId);
} else if (shouldMaintainTaskCenterTab) {
@@ -5064,6 +5153,7 @@ export function AgentChatWorkspace({
replaceTaskCenterOpenTabs,
resetLocalImageWorkbenchSessionScope,
setActiveTaskCenterDraftTabId,
setChatMessages,
switchTopic,
taskCenterWorkspaceId,
setTaskCenterDetachedTopicId,
@@ -5075,11 +5165,16 @@ export function AgentChatWorkspace({
const handleOpenArchivedTaskTopic = useCallback(
async (topicId: string) => {
taskCenterDraftSurfaceActiveRef.current = false;
resetLocalImageWorkbenchSessionScope();
setActiveTaskCenterDraftTabId(null);
setTaskCenterDetachedTopicId(topicId);
setTaskCenterTransitionTopicId(topicId);
clearEntryPendingA2UI();
setChatMessages([]);
if (taskCenterWorkspaceId) {
clearAgentSessionCachedSnapshot(taskCenterWorkspaceId, topicId);
}
markTaskCenterLocalSessionOverride(topicId);
rememberInitialSessionNavigationStart(topicId);
const switchResult = await switchTopic(topicId, {
@@ -5103,7 +5198,9 @@ export function AgentChatWorkspace({
clearEntryPendingA2UI,
markTaskCenterLocalSessionOverride,
resetLocalImageWorkbenchSessionScope,
setChatMessages,
switchTopic,
taskCenterWorkspaceId,
],
);
@@ -5116,6 +5213,7 @@ export function AgentChatWorkspace({
return;
}
taskCenterDraftSurfaceActiveRef.current = true;
resetLocalImageWorkbenchSessionScope();
clearMessages({ showToast: false });
startTransition(() => {
@@ -5154,7 +5252,7 @@ export function AgentChatWorkspace({
const handleOpenSidebarTaskTopic = useCallback(
async (topicId: string) => {
await handleOpenTaskTopic(topicId);
await handleOpenTaskTopic(topicId, { forceRefresh: true });
},
[handleOpenTaskTopic],
);
@@ -5301,8 +5399,10 @@ export function AgentChatWorkspace({
}
return subscribeTaskCenterTaskOpenRequests(
({ sessionId: requestedSessionId, workspaceId }) => {
({ sessionId: requestedSessionId, source, workspaceId }) => {
const requestedWorkspaceId = normalizeProjectId(workspaceId);
const shouldForceRefresh =
shouldForceRefreshTaskOpenSource(source);
if (
requestedWorkspaceId &&
requestedWorkspaceId !== normalizeProjectId(taskCenterWorkspaceId)
@@ -5312,11 +5412,18 @@ export function AgentChatWorkspace({
setActiveTaskCenterDraftTabId(null);
markTaskCenterLocalSessionOverride(requestedSessionId);
upsertTaskCenterOpenTab(requestedSessionId, requestedWorkspaceId);
deferTopicSwitch(requestedSessionId, requestedWorkspaceId);
deferTopicSwitch(
requestedSessionId,
requestedWorkspaceId,
shouldForceRefresh ? { forceRefresh: true } : undefined,
);
return;
}
void handleOpenTaskTopic(requestedSessionId);
void handleOpenTaskTopic(
requestedSessionId,
shouldForceRefresh ? { forceRefresh: true } : undefined,
);
},
);
}, [
@@ -5469,6 +5576,8 @@ export function AgentChatWorkspace({
!taskCenterWorkspaceId ||
isAutoRestoringSession ||
isSessionHydrating ||
taskCenterDraftSurfaceActiveRef.current ||
isTaskCenterDraftTabActive ||
initialPendingServiceSkillLaunchSignature
) {
return;
@@ -5543,6 +5652,7 @@ export function AgentChatWorkspace({
initialPendingServiceSkillLaunchSignature,
isAutoRestoringSession,
isSessionHydrating,
isTaskCenterDraftTabActive,
normalizedInitialSessionId,
sessionId,
shouldHideDetachedTaskCenterTabs,
@@ -5855,15 +5965,27 @@ export function AgentChatWorkspace({
const handleOpenMessagePreview = useCallback(
(target: MessagePreviewTarget, message: Message) => {
if (target.kind === "image_workbench") {
updateCurrentImageWorkbenchState((current) => {
if (current.active) {
return current;
}
return {
...current,
active: true,
};
});
const resourceManagerInput =
buildImageWorkbenchPreviewResourceManagerInput({
message,
preview: target.preview,
selection: target.selection,
threadId: sessionId ?? null,
});
if (resourceManagerInput) {
void openResourceManager(resourceManagerInput);
return;
}
updateCurrentImageWorkbenchState((current) =>
resolveImageWorkbenchStateForPreviewSelection({
current,
messages,
preview: target.preview,
selection: target.selection,
}),
);
openCanvasForReason("user_open_message_preview", setLayoutMode);
return;
}
@@ -5917,6 +6039,8 @@ export function AgentChatWorkspace({
[
handleWorkspaceArtifactClick,
handleWorkspaceFileClick,
messages,
sessionId,
setCanvasState,
setLayoutMode,
taskFiles,
@@ -7801,7 +7925,11 @@ export function AgentChatWorkspace({
sendOptions?: HandleSendOptions,
) => {
const normalizedText = text.trim();
const activeDraftTabId = activeTaskCenterDraftTabIdRef.current;
const activeDraftTabId =
activeTaskCenterDraftTabIdRef.current ||
(agentEntry === "claw" && shouldRenderTaskCenterEmbeddedHome
? openTaskCenterDraftTab()
: null);
if (agentEntry === "claw" && activeDraftTabId) {
const submittedAt = Date.now();
const requestId = createTaskCenterDraftSendRequestId();
@@ -7923,7 +8051,9 @@ export function AgentChatWorkspace({
effectiveThreadItems.length,
handleSend,
hasDisplayMessages,
openTaskCenterDraftTab,
sessionId,
shouldRenderTaskCenterEmbeddedHome,
taskCenterWorkspaceId,
turns.length,
],
@@ -61,9 +61,11 @@ function getRendererClassName(
surface: A2UITaskCardSurface,
): string {
return cn(
compact ? "space-y-3" : "space-y-4",
compact
? "space-y-2.5 text-[13px] leading-5"
: "space-y-3 text-sm leading-6",
surface === "embedded" &&
"space-y-2.5 text-[13px] leading-5 [&_.a2ui-text-h3]:text-[15px] [&_.a2ui-text-h3]:leading-6 [&_.a2ui-text-h3]:font-semibold [&_.a2ui-text-h4]:text-[13px] [&_.a2ui-text-h4]:leading-5 [&_.a2ui-text-h4]:font-medium [&_.a2ui-text-body]:text-[13px] [&_.a2ui-text-body]:leading-5 [&_.a2ui-text-body]:text-slate-700 [&_.a2ui-field-stack]:space-y-1.5 [&_.a2ui-field-label]:text-[12px] [&_.a2ui-field-label]:leading-5 [&_.a2ui-helper-text]:text-[11px] [&_.a2ui-helper-text]:leading-4 [&_.a2ui-option-list]:gap-2.5 [&_.a2ui-choice-option]:rounded-[16px] [&_.a2ui-choice-option]:px-3.5 [&_.a2ui-choice-option]:py-3 [&_.a2ui-choice-option]:text-[13px] [&_.a2ui-choice-option-title]:text-[13px] [&_.a2ui-choice-option-title]:leading-5 [&_.a2ui-option-description]:mt-1 [&_.a2ui-option-description]:text-[11px] [&_.a2ui-option-description]:leading-4 [&_.a2ui-text-input]:h-10 [&_.a2ui-text-input]:rounded-[16px] [&_.a2ui-text-input]:px-3 [&_.a2ui-text-input]:text-[13px] [&_.a2ui-textarea]:min-h-[84px] [&_.a2ui-textarea]:rounded-[16px] [&_.a2ui-textarea]:px-3 [&_.a2ui-textarea]:py-2.5 [&_.a2ui-textarea]:text-[13px] [&_.a2ui-textarea]:leading-5 [&_.a2ui-card-shell]:rounded-[16px] [&_.a2ui-card-shell]:border-slate-200/90 [&_.a2ui-card-shell]:bg-white [&_.a2ui-card-shell]:p-3 [&_.a2ui-card-shell]:shadow-none",
"space-y-2.5 text-[13px] leading-5 [&_.a2ui-text-h3]:text-[15px] [&_.a2ui-text-h3]:leading-6 [&_.a2ui-text-h3]:font-semibold [&_.a2ui-text-h4]:text-[13px] [&_.a2ui-text-h4]:leading-5 [&_.a2ui-text-h4]:font-medium [&_.a2ui-text-body]:text-[13px] [&_.a2ui-text-body]:leading-5 [&_.a2ui-field-stack]:space-y-1.5 [&_.a2ui-field-label]:text-[12px] [&_.a2ui-field-label]:leading-5 [&_.a2ui-helper-text]:text-[11px] [&_.a2ui-helper-text]:leading-4 [&_.a2ui-option-list]:gap-2 [&_.a2ui-choice-option]:rounded-[14px] [&_.a2ui-choice-option]:px-3 [&_.a2ui-choice-option]:py-2.5 [&_.a2ui-choice-option]:text-[13px] [&_.a2ui-choice-option-title]:text-[13px] [&_.a2ui-choice-option-title]:leading-5 [&_.a2ui-option-description]:mt-1 [&_.a2ui-option-description]:text-[11px] [&_.a2ui-option-description]:leading-4 [&_.a2ui-text-input]:h-9 [&_.a2ui-text-input]:rounded-[14px] [&_.a2ui-text-input]:px-3 [&_.a2ui-text-input]:text-[13px] [&_.a2ui-textarea]:min-h-[76px] [&_.a2ui-textarea]:rounded-[14px] [&_.a2ui-textarea]:px-3 [&_.a2ui-textarea]:py-2.5 [&_.a2ui-textarea]:text-[13px] [&_.a2ui-textarea]:leading-5 [&_.a2ui-card-shell]:rounded-[14px] [&_.a2ui-card-shell]:p-2.5 [&_.a2ui-card-shell]:shadow-none",
);
}
@@ -124,7 +126,9 @@ export function A2UITaskCard({
</A2UITaskCardBody>
{footerText ? (
<div className="mt-3 text-xs text-slate-500">{footerText}</div>
<div className="mt-2.5 text-xs text-[color:var(--lime-text-muted)]">
{footerText}
</div>
) : null}
</A2UITaskCardShell>
);
@@ -47,9 +47,9 @@ import {
buildSceneAppExecutionReviewPrefillHighlights,
buildSceneAppExecutionReviewPrefillSnapshot,
} from "@/components/agent/chat/utils/sceneAppCuratedTaskReference";
import type agentResource from "@/i18n/resources/zh-CN/agent.json";
import type { AgentI18nResource } from "@/i18n/agentResources";
type AgentI18nKey = keyof typeof agentResource;
type AgentI18nKey = keyof AgentI18nResource;
interface CuratedTaskLauncherDialogProps {
open: boolean;
@@ -79,7 +79,7 @@ import {
type InputCapabilitySelection,
} from "../skill-selection/inputCapabilitySelection";
import type { AgentInitialInputCapabilityParams } from "@/types/page";
import type agentResource from "@/i18n/resources/zh-CN/agent.json";
import type { AgentI18nResource } from "@/i18n/agentResources";
import type {
InputbarKnowledgePackOption,
InputbarKnowledgePackSelection,
@@ -114,13 +114,14 @@ import {
} from "../home/buildHomeSkillSurface";
import { buildHomeSurfaceCopy } from "../home/homeSurfaceCopy";
import { buildInputbarCoreCopy } from "./Inputbar/components/inputbarCoreCopy";
import { buildInputbarExecutionStrategyCopy } from "./Inputbar/inputbarWorkflowCopy";
import type {
HomeGuideCard,
HomeSkillSurfaceItem,
HomeStarterChip,
} from "../home/homeSurfaceTypes";
type AgentI18nKey = keyof typeof agentResource;
type AgentI18nKey = keyof AgentI18nResource;
const contentReveal = keyframes`
from {
@@ -541,6 +542,10 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
() => buildInputbarCoreCopy(translateAgentCopyKey),
[translateAgentCopyKey],
);
const executionStrategyCopy = useMemo(
() => buildInputbarExecutionStrategyCopy(translateAgentCopyKey),
[translateAgentCopyKey],
);
const pageContainerRef = useRef<HTMLDivElement | null>(null);
const handledInitialInputCapabilitySignatureRef = useRef("");
const [activeCapability, setActiveCapability] =
@@ -1620,6 +1625,7 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
onManageKnowledgePacks={onManageKnowledgePacks}
copy={homeSurfaceCopy.composer}
inputbarCopy={inputbarCoreCopy}
executionStrategyCopy={executionStrategyCopy}
showCreationModeSelector={showCreationModeSelector}
creationMode={creationMode}
onCreationModeChange={onCreationModeChange}
@@ -7,8 +7,7 @@ import {
createSkillSelectionProps,
type SkillSelectionProps,
} from "../skill-selection/skillSelectionBindings";
import agentResource from "@/i18n/resources/zh-CN/agent.json";
import enAgentResource from "@/i18n/resources/en-US/agent.json";
import { agentEnUSResource, agentZhCNResource } from "@/i18n/agentResources";
import {
buildHomeSurfaceCopy,
type HomeSurfaceCopyKey,
@@ -17,6 +16,10 @@ import {
buildInputbarCoreCopy,
type InputbarCoreCopyKey,
} from "./Inputbar/components/inputbarCoreCopy";
import {
buildInputbarExecutionStrategyCopy,
type InputbarWorkflowCopyKey,
} from "./Inputbar/inputbarWorkflowCopy";
import { changeLimeLocale } from "@/i18n/createI18n";
vi.mock("./ChatModelSelector", () => ({
@@ -57,31 +60,43 @@ vi.mock("./Inputbar/components/TeamSelector", () => ({
}));
function translateResource(
resource: Partial<Record<HomeSurfaceCopyKey | InputbarCoreCopyKey, string>>,
key: HomeSurfaceCopyKey | InputbarCoreCopyKey,
resource: Partial<
Record<
HomeSurfaceCopyKey | InputbarCoreCopyKey | InputbarWorkflowCopyKey,
string
>
>,
key: HomeSurfaceCopyKey | InputbarCoreCopyKey | InputbarWorkflowCopyKey,
values?: Record<string, number | string>,
) {
return Object.entries(values ?? {}).reduce(
(text, [name, value]) =>
text.split(`{{${name}}}`).join(String(value)),
(text, [name, value]) => text.split(`{{${name}}}`).join(String(value)),
resource[key] ?? key,
);
}
const TEST_COMPOSER_COPY = buildHomeSurfaceCopy((key, values) =>
translateResource(agentResource, key, values),
translateResource(agentZhCNResource, key, values),
).composer;
const TEST_EN_COMPOSER_COPY = buildHomeSurfaceCopy((key, values) =>
translateResource(enAgentResource, key, values),
translateResource(agentEnUSResource, key, values),
).composer;
const TEST_INPUTBAR_CORE_COPY = buildInputbarCoreCopy((key, values) =>
translateResource(agentResource, key, values),
translateResource(agentZhCNResource, key, values),
);
const TEST_EN_INPUTBAR_CORE_COPY = buildInputbarCoreCopy((key, values) =>
translateResource(enAgentResource, key, values),
translateResource(agentEnUSResource, key, values),
);
const TEST_EXECUTION_STRATEGY_COPY = buildInputbarExecutionStrategyCopy(
(key, values) => translateResource(agentZhCNResource, key, values),
);
const TEST_EN_EXECUTION_STRATEGY_COPY = buildInputbarExecutionStrategyCopy(
(key, values) => translateResource(agentEnUSResource, key, values),
);
const mockSelectedTeam = {
@@ -204,6 +219,7 @@ function renderPanel(
skillSelection: createSkillSelection(),
copy: TEST_COMPOSER_COPY,
inputbarCopy: TEST_INPUTBAR_CORE_COPY,
executionStrategyCopy: TEST_EXECUTION_STRATEGY_COPY,
showCreationModeSelector: false,
creationMode: "guided",
onCreationModeChange: vi.fn(),
@@ -262,6 +278,7 @@ function renderStatefulPanel(
skillSelection={createSkillSelection()}
copy={TEST_COMPOSER_COPY}
inputbarCopy={TEST_INPUTBAR_CORE_COPY}
executionStrategyCopy={TEST_EXECUTION_STRATEGY_COPY}
showCreationModeSelector={false}
creationMode="guided"
onCreationModeChange={vi.fn()}
@@ -408,6 +425,7 @@ describe("EmptyStateComposerPanel", () => {
const container = renderPanel({
copy: TEST_EN_COMPOSER_COPY,
inputbarCopy: TEST_EN_INPUTBAR_CORE_COPY,
executionStrategyCopy: TEST_EN_EXECUTION_STRATEGY_COPY,
isGeneralTheme: true,
guideHelpActive: true,
onClearGuideHelp: vi.fn(),
@@ -66,6 +66,7 @@ import type {
InputbarKnowledgePackSelection,
} from "./Inputbar/types";
import type { InputbarCoreCopy } from "./Inputbar/components/inputbarCoreCopy";
import type { InputbarExecutionStrategyCopy } from "./Inputbar/inputbarWorkflowCopy";
interface EmptyStateComposerPanelProps {
input: string;
@@ -108,6 +109,7 @@ interface EmptyStateComposerPanelProps {
onManageKnowledgePacks?: () => void;
copy: HomeSurfaceComposerCopy;
inputbarCopy: InputbarCoreCopy;
executionStrategyCopy: InputbarExecutionStrategyCopy;
showCreationModeSelector: boolean;
creationMode: CreationMode;
onCreationModeChange?: (mode: CreationMode) => void;
@@ -234,6 +236,7 @@ export function EmptyStateComposerPanel({
onManageKnowledgePacks,
copy,
inputbarCopy,
executionStrategyCopy,
showCreationModeSelector,
creationMode,
onCreationModeChange,
@@ -629,6 +632,7 @@ export function EmptyStateComposerPanel({
<InputbarExecutionStrategySelect
executionStrategy={executionStrategy}
setExecutionStrategy={setExecutionStrategy}
copy={executionStrategyCopy}
/>
<InputbarModelExtra
@@ -45,8 +45,8 @@ vi.mock("sonner", () => ({
}));
vi.mock("react-i18next", async () => {
const agentZhCN = (await import("@/i18n/resources/zh-CN/agent.json"))
.default as Record<string, string>;
const { agentZhCNResource } = await import("@/i18n/agentResources");
const agentZhCN = agentZhCNResource as Record<string, string>;
const interpolate = (template: string, values: Record<string, unknown>) =>
template.replace(/{{\s*([^}]+?)\s*}}/g, (_, name: string) => {
@@ -2,6 +2,7 @@ import React from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { changeLimeLocale } from "@/i18n/createI18n";
const { mockOpenResourceManager } = vi.hoisted(() => ({
mockOpenResourceManager: vi.fn(),
@@ -49,6 +50,7 @@ function createProps(
createdAt: 2,
},
],
selectedTaskId: "task-1",
selectedOutputId: "output-1",
viewport: { x: 0, y: 0, scale: 1 },
preferenceSummary: null,
@@ -89,7 +91,8 @@ function renderComponent(
};
}
beforeEach(() => {
beforeEach(async () => {
await changeLimeLocale("zh-CN");
(
globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
@@ -146,7 +149,7 @@ describe("ImageTaskViewer", () => {
expect(mockOpenResourceManager).toHaveBeenCalledWith(
expect.objectContaining({
sourceLabel: "Image Generation",
sourceLabel: "图片生成",
sourceContext: expect.objectContaining({
kind: "image_task",
projectId: "project-1",
@@ -262,6 +265,64 @@ describe("ImageTaskViewer", () => {
expect(outputGrid?.textContent).toContain("2");
});
it("多图结果应按任务 outputIds 计算选中位次,而不是按缓存数组顺序", () => {
const { container } = renderComponent({
tasks: [
{
id: "task-ordered",
mode: "generate",
status: "complete",
prompt: "三张章节配图",
rawText: "@配图 生成三张章节配图",
expectedCount: 3,
outputIds: ["output-1", "output-2", "output-3"],
createdAt: 1,
},
],
outputs: [
{
id: "output-3",
refId: "img-3",
taskId: "task-ordered",
url: "https://example.com/image-3.png",
prompt: "第三张",
createdAt: 3,
},
{
id: "output-2",
refId: "img-2",
taskId: "task-ordered",
url: "https://example.com/image-2.png",
prompt: "第二张",
createdAt: 2,
},
{
id: "output-1",
refId: "img-1",
taskId: "task-ordered",
url: "https://example.com/image-1.png",
prompt: "第一张",
createdAt: 1,
},
],
selectedTaskId: "task-ordered",
selectedOutputId: "output-3",
});
const outputGrid = container.querySelector(
'[data-testid="image-task-viewer-output-grid"]',
);
const selectedThumb = Array.from(
outputGrid?.querySelectorAll("button") || [],
).find((button) =>
button.querySelector('img[src="https://example.com/image-3.png"]'),
);
expect(container.textContent).toContain("已选第 3 张");
expect(selectedThumb?.textContent).toContain("3");
expect(selectedThumb?.textContent).toContain("当前选中");
});
it("任务 viewer 应展示已按多模态运行合同路由的状态", () => {
const { container } = renderComponent({
tasks: [
@@ -350,6 +411,56 @@ describe("ImageTaskViewer", () => {
);
});
it("选中无结果的失败任务时不回退展示上一张成功图", () => {
const { container } = renderComponent({
tasks: [
{
id: "task-failed-2",
mode: "generate",
status: "error",
prompt: "青柠极简插画",
rawText: "@配图 青柠极简插画",
expectedCount: 1,
outputIds: [],
createdAt: 2,
failureMessage:
'默认图片服务调用失败: Fal HTTP 403: {"detail":"User is locked."}',
},
{
id: "task-success-1",
mode: "generate",
status: "complete",
prompt: "广州塔春天照片",
rawText: "@配图 广州塔春天照片",
expectedCount: 1,
outputIds: ["output-success-1"],
createdAt: 1,
},
],
outputs: [
{
id: "output-success-1",
refId: "img-success-1",
taskId: "task-success-1",
url: "https://example.com/success.png",
prompt: "广州塔春天照片",
createdAt: 1,
},
],
selectedTaskId: "task-failed-2",
selectedOutputId: null,
});
expect(container.textContent).toContain("青柠极简插画");
expect(container.textContent).toContain("生成失败");
expect(container.textContent).toContain("这次生成没有拿到可用图片结果。");
expect(container.textContent).not.toContain("广州塔春天照片");
expect(container.textContent).not.toContain("Fal HTTP 403");
expect(
container.querySelector('[data-testid="image-task-viewer-open-image"]'),
).toBeNull();
});
it("3x3 分镜任务应使用九宫格缩略布局并展示编号", () => {
const outputs = Array.from({ length: 9 }, (_, index) => ({
id: `output-storyboard-${index + 1}`,
@@ -576,7 +687,7 @@ describe("ImageTaskViewer", () => {
const sourcePanel = container.querySelector(
'[data-testid="image-task-viewer-source"]',
);
expect(container.textContent).toContain("Image Editing");
expect(container.textContent).toContain("图片编辑");
expect(container.textContent).toContain("已修图");
expect(sourcePanel?.textContent).toContain("来源图");
expect(sourcePanel?.textContent).toContain("原始海报");
@@ -701,7 +812,7 @@ describe("ImageTaskViewer", () => {
const sourcePanel = container.querySelector(
'[data-testid="image-task-viewer-source"]',
);
expect(container.textContent).toContain("Image Redraw");
expect(container.textContent).toContain("图片重绘");
expect(container.textContent).toContain("已重绘");
expect(sourcePanel?.textContent).toContain("参考图");
expect(sourcePanel?.textContent).toContain("原始海报");
@@ -1,47 +1,65 @@
import { useMemo } from "react";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { LoaderCircle, Sparkles, X } from "lucide-react";
import { openResourceManager } from "@/features/resource-manager";
import type { ResourceManagerSourceContext } from "@/features/resource-manager";
import { cn } from "@/lib/utils";
import { RenderableTaskImage } from "./RenderableTaskImage";
import type { ImageTaskViewerProps } from "./imageWorkbenchTypes";
import type { ImageRuntimeContractSnapshot } from "../types";
import { buildLimeCorePolicyEvaluationMetaItem } from "../workspace/mediaTaskPolicyEvaluation";
import { buildImageTaskResourceSourceContext } from "../workspace/imageWorkbenchResourceManager";
const IMAGE_TASK_PRIMARY_BUTTON_CLASSNAME =
"inline-flex items-center justify-center rounded-full border border-emerald-200 bg-[linear-gradient(135deg,#0ea5e9_0%,#14b8a6_52%,#10b981_100%)] px-4 py-2 text-sm font-medium text-white shadow-sm shadow-emerald-950/15 transition hover:opacity-95";
function resolveModeEyebrow(mode?: string): string {
type AgentTranslate = TFunction<"agent", undefined>;
function resolveModeEyebrow(
mode: string | undefined,
t: AgentTranslate,
): string {
switch ((mode || "").trim().toLowerCase()) {
case "edit":
return "Image Editing";
return t("agentChat.imageWorkbenchPreview.tool.editing");
case "variation":
return "Image Redraw";
return t("agentChat.imageWorkbenchPreview.tool.redraw");
case "generate":
default:
return "Image Generation";
return t("agentChat.imageWorkbenchPreview.tool.generation");
}
}
function resolveSourceLabel(mode?: string): string {
function resolveSourceLabel(
mode: string | undefined,
t: AgentTranslate,
): string {
return (mode || "").trim().toLowerCase() === "variation"
? "参考图"
: "来源图";
? t("agentChat.imageTaskViewer.source.reference")
: t("agentChat.imageTaskViewer.source.source");
}
function resolveFollowUpLabel(mode?: string): string {
function resolveFollowUpLabel(
mode: string | undefined,
t: AgentTranslate,
): string {
const normalizedMode = (mode || "").trim().toLowerCase();
if (normalizedMode === "edit") {
return "继续修图";
return t("agentChat.imageTaskViewer.action.continueEdit");
}
if (normalizedMode === "variation") {
return "继续重绘";
return t("agentChat.imageTaskViewer.action.continueVariation");
}
return "基于此图重绘";
return t("agentChat.imageTaskViewer.action.redrawFromImage");
}
function resolveLayoutLabel(layoutHint?: string | null): string | null {
return layoutHint === "storyboard_3x3" ? "3x3 分镜" : null;
function resolveLayoutLabel(
layoutHint: string | null | undefined,
t: AgentTranslate,
): string | null {
return layoutHint === "storyboard_3x3"
? t("agentChat.imageTaskViewer.layout.storyboard3x3")
: null;
}
function resolveOutputGridClassName(params: {
@@ -68,14 +86,19 @@ function resolveSelectedOutputLabel(params: {
selectedIndex: number;
outputCount: number;
layoutHint?: string | null;
t: AgentTranslate;
}): string | null {
if (params.selectedIndex < 0 || params.outputCount <= 1) {
return null;
}
return params.layoutHint === "storyboard_3x3"
? `已选第 ${params.selectedIndex + 1} 格`
: `已选第 ${params.selectedIndex + 1} 张`;
? params.t("agentChat.imageTaskViewer.selected.storyboardSlot", {
index: params.selectedIndex + 1,
})
: params.t("agentChat.imageTaskViewer.selected.image", {
index: params.selectedIndex + 1,
});
}
function resolveOutputDisplayIndex(
@@ -91,6 +114,7 @@ function resolveStoryboardSlotLabel(params: {
slotIndex?: number | null;
slotLabel?: string | null;
taskSlotLabel?: string | null;
t: AgentTranslate;
}): string | null {
if (params.layoutHint !== "storyboard_3x3") {
return null;
@@ -99,7 +123,9 @@ function resolveStoryboardSlotLabel(params: {
return (
params.slotLabel?.trim() ||
params.taskSlotLabel?.trim() ||
`第 ${resolveOutputDisplayIndex(params.outputIndex, params.slotIndex)} 格`
params.t("agentChat.imageTaskViewer.storyboard.slotFallback", {
index: resolveOutputDisplayIndex(params.outputIndex, params.slotIndex),
})
);
}
@@ -127,48 +153,52 @@ function buildFollowUpCommand(params: {
return `@重绘 ${referenceToken} `;
}
function resolveStatusLabel(status?: string, mode?: string): string {
function resolveStatusLabel(
status: string | undefined,
mode: string | undefined,
t: AgentTranslate,
): string {
const normalizedMode = (mode || "").trim().toLowerCase();
switch ((status || "").trim().toLowerCase()) {
case "complete":
switch (normalizedMode) {
case "edit":
return "已修图";
return t("agentChat.imageTaskViewer.status.complete.edit");
case "variation":
return "已重绘";
return t("agentChat.imageTaskViewer.status.complete.variation");
case "generate":
default:
return "已生成";
return t("agentChat.imageTaskViewer.status.complete.generate");
}
case "partial":
return "部分完成";
return t("agentChat.imageTaskViewer.status.partial");
case "cancelled":
return "已取消";
return t("agentChat.imageTaskViewer.status.cancelled");
case "error":
switch (normalizedMode) {
case "edit":
return "修图失败";
return t("agentChat.imageTaskViewer.status.error.edit");
case "variation":
return "重绘失败";
return t("agentChat.imageTaskViewer.status.error.variation");
case "generate":
default:
return "生成失败";
return t("agentChat.imageTaskViewer.status.error.generate");
}
case "queued":
return "等待队列";
return t("agentChat.imageTaskViewer.status.queued");
case "running":
case "routing":
switch (normalizedMode) {
case "edit":
return "修图中";
return t("agentChat.imageTaskViewer.status.running.edit");
case "variation":
return "重绘中";
return t("agentChat.imageTaskViewer.status.running.variation");
case "generate":
default:
return "生成中";
return t("agentChat.imageTaskViewer.status.running.generate");
}
default:
return "准备中";
return t("agentChat.imageTaskViewer.status.preparing");
}
}
@@ -192,7 +222,8 @@ function resolveStatusTone(status?: string): string {
}
function resolveRuntimeContractBadge(
runtimeContract?: ImageRuntimeContractSnapshot | null,
runtimeContract: ImageRuntimeContractSnapshot | null | undefined,
t: AgentTranslate,
): { label: string; tone: string } | null {
if (!runtimeContract) {
return null;
@@ -202,40 +233,43 @@ function resolveRuntimeContractBadge(
const outcome = (runtimeContract.routingOutcome || "").trim().toLowerCase();
if (outcome === "blocked") {
return {
label: `运行合同阻止 · ${
runtimeContract.failureCode?.trim() || contractKey
}`,
label: t("agentChat.imageTaskViewer.runtimeContract.blocked", {
reason: runtimeContract.failureCode?.trim() || contractKey,
}),
tone: "border-amber-200 bg-amber-50 text-amber-800",
};
}
if (outcome === "failed") {
return {
label: `运行合同失败 · ${
runtimeContract.failureCode?.trim() || contractKey
}`,
label: t("agentChat.imageTaskViewer.runtimeContract.failed", {
reason: runtimeContract.failureCode?.trim() || contractKey,
}),
tone: "border-rose-200 bg-rose-50 text-rose-700",
};
}
return {
label: `运行合同 · 已按 ${contractKey} 路由`,
label: t("agentChat.imageTaskViewer.runtimeContract.accepted", {
contractKey,
}),
tone: "border-emerald-200 bg-emerald-50 text-emerald-700",
};
}
function resolveRuntimeContractRegistryLabel(
runtimeContract?: ImageRuntimeContractSnapshot | null,
runtimeContract: ImageRuntimeContractSnapshot | null | undefined,
t: AgentTranslate,
): string | null {
if (runtimeContract?.modelCapabilityAssessmentSource !== "model_registry") {
return null;
}
if (runtimeContract.modelSupportsImageGeneration === false) {
return "模型能力来自 model_registry · 不支持图片生成";
return t("agentChat.imageTaskViewer.runtimeContract.registry.unsupported");
}
if (runtimeContract.modelSupportsImageGeneration === true) {
return "模型能力来自 model_registry · 支持图片生成";
return t("agentChat.imageTaskViewer.runtimeContract.registry.supported");
}
return "模型能力来自 model_registry";
return t("agentChat.imageTaskViewer.runtimeContract.registry.unknown");
}
function resolveRuntimeContractPolicyLabel(
@@ -257,100 +291,105 @@ function resolveRuntimeContractPolicyLabel(
}
function resolveEmptyStateDescription(
status?: string,
failureMessage?: string,
mode?: string,
status: string | undefined,
_failureMessage: string | undefined,
mode: string | undefined,
t: AgentTranslate,
): string {
if (failureMessage?.trim()) {
return failureMessage.trim();
}
switch ((status || "").trim().toLowerCase()) {
case "cancelled":
return "这次任务已经取消,当前不会继续生成新的图片结果。";
return t("agentChat.imageTaskViewer.empty.cancelled");
case "error":
return "这次生成没有拿到可用图片结果。";
return t("agentChat.imageTaskViewer.empty.error");
case "queued":
return "图片任务已经提交,正在等待服务分配执行槽位。";
return t("agentChat.imageTaskViewer.empty.queued");
case "running":
case "routing":
switch ((mode || "").trim().toLowerCase()) {
case "edit":
return "图片编辑中,完成后会直接在这里展示修图结果。";
return t("agentChat.imageTaskViewer.empty.running.edit");
case "variation":
return "图片重绘中,完成后会直接在这里展示结果。";
return t("agentChat.imageTaskViewer.empty.running.variation");
case "generate":
default:
return "图片生成中,完成后会直接在这里展示结果。";
return t("agentChat.imageTaskViewer.empty.running.generate");
}
default:
return "图片任务已创建,结果准备好后会展示在这里。";
return t("agentChat.imageTaskViewer.empty.preparing");
}
}
function resolveImageUnavailableTitle(status?: string): string {
function resolveImageUnavailableTitle(
status: string | undefined,
mode: string | undefined,
t: AgentTranslate,
): string {
switch ((status || "").trim().toLowerCase()) {
case "complete":
case "partial":
return "图片暂时无法显示";
return t("agentChat.imageTaskViewer.unavailable.title");
default:
return resolveStatusLabel(status);
return resolveStatusLabel(status, mode, t);
}
}
function resolveImageUnavailableDescription(mode?: string): string {
function resolveImageUnavailableDescription(
mode: string | undefined,
t: AgentTranslate,
): string {
switch ((mode || "").trim().toLowerCase()) {
case "edit":
return "修图结果已经返回,但当前预览地址暂时无法加载。";
return t("agentChat.imageTaskViewer.unavailable.edit");
case "variation":
return "重绘结果已经返回,但当前预览地址暂时无法加载。";
return t("agentChat.imageTaskViewer.unavailable.variation");
case "generate":
default:
return "图片结果已经返回,但当前预览地址暂时无法加载。";
return t("agentChat.imageTaskViewer.unavailable.generate");
}
}
function resolveSourcePlaceholderLabel(
mode?: string,
reason?: "empty" | "error",
mode: string | undefined,
reason: "empty" | "error",
t: AgentTranslate,
) {
if (reason === "error") {
return (mode || "").trim().toLowerCase() === "variation"
? "参考图暂时无法显示"
: "来源图暂时无法显示";
? t("agentChat.imageTaskViewer.source.referenceUnavailable")
: t("agentChat.imageTaskViewer.source.sourceUnavailable");
}
return (mode || "").trim().toLowerCase() === "variation"
? "参考图待同步"
: "来源图待同步";
? t("agentChat.imageTaskViewer.source.referencePending")
: t("agentChat.imageTaskViewer.source.sourcePending");
}
function normalizeSourceContextText(value?: string | null): string | null {
const normalized = value?.trim();
return normalized ? normalized : null;
}
function orderTaskOutputsByTaskOutputIds(
task: ImageTaskViewerProps["tasks"][number] | null,
outputs: ImageTaskViewerProps["outputs"],
): ImageTaskViewerProps["outputs"] {
if (!task?.outputIds?.length) {
return outputs;
}
function buildImageTaskResourceSourceContext(params: {
taskId?: string | null;
outputId?: string | null;
projectId?: string | null;
contentId?: string | null;
threadId?: string | null;
}): ResourceManagerSourceContext {
return {
kind: "image_task",
projectId: normalizeSourceContextText(params.projectId),
contentId: normalizeSourceContextText(params.contentId),
taskId: normalizeSourceContextText(params.taskId),
outputId: normalizeSourceContextText(params.outputId),
threadId: normalizeSourceContextText(params.threadId),
sourcePage: "image-task-viewer",
};
const outputById = new Map(outputs.map((output) => [output.id, output]));
const ordered = task.outputIds
.map((outputId) => outputById.get(outputId))
.filter((output): output is ImageTaskViewerProps["outputs"][number] =>
Boolean(output),
);
const orderedIds = new Set(ordered.map((output) => output.id));
return [
...ordered,
...outputs.filter((output) => !orderedIds.has(output.id)),
];
}
export function ImageTaskViewer({
tasks,
outputs,
selectedTaskId,
selectedOutputId,
sourceProjectId,
sourceContentId,
@@ -363,16 +402,32 @@ export function ImageTaskViewer({
onSelectOutput,
onClose,
}: ImageTaskViewerProps) {
const { t } = useTranslation("agent");
const selectedTaskById = selectedTaskId
? (tasks.find((item) => item.id === selectedTaskId) ?? null)
: null;
const selectedOutputById = selectedOutputId
? (outputs.find((item) => item.id === selectedOutputId) ?? null)
: null;
const selectedOutput =
outputs.find((item) => item.id === selectedOutputId) ?? outputs[0] ?? null;
selectedOutputById &&
(!selectedTaskById || selectedOutputById.taskId === selectedTaskById.id)
? selectedOutputById
: selectedTaskById
? (outputs.find((item) => item.taskId === selectedTaskById.id) ?? null)
: (selectedOutputById ?? outputs[0] ?? null);
const selectedTask =
selectedTaskById ??
(selectedOutput
? tasks.find((item) => item.id === selectedOutput.taskId)
: null) ??
tasks[0] ??
null;
const selectedTaskOutputs = selectedTask
? outputs.filter((item) => item.taskId === selectedTask.id)
? orderTaskOutputsByTaskOutputIds(
selectedTask,
outputs.filter((item) => item.taskId === selectedTask.id),
)
: outputs;
const expectedOutputCount = Math.max(
selectedTask?.expectedCount ?? 0,
@@ -404,6 +459,7 @@ export function ImageTaskViewer({
slotIndex: selectedSlotIndex,
slotLabel: selectedOutput?.slotLabel,
taskSlotLabel: taskSlot?.label,
t,
}),
prompt: selectedOutput?.slotPrompt || taskSlot?.prompt || null,
};
@@ -413,30 +469,35 @@ export function ImageTaskViewer({
selectedOutput?.slotPrompt,
selectedOutputIndex,
selectedTask,
t,
]);
const statusLabel = resolveStatusLabel(
selectedTask?.status,
selectedTask?.mode,
t,
);
const runtimeContractBadge = resolveRuntimeContractBadge(
selectedTask?.runtimeContract,
t,
);
const runtimeContractRegistryLabel = resolveRuntimeContractRegistryLabel(
selectedTask?.runtimeContract,
t,
);
const runtimeContractPolicyLabel = resolveRuntimeContractPolicyLabel(
selectedTask?.runtimeContract,
);
const layoutLabel = resolveLayoutLabel(selectedTask?.layoutHint);
const layoutLabel = resolveLayoutLabel(selectedTask?.layoutHint, t);
const selectedOutputLabel = resolveSelectedOutputLabel({
selectedIndex: selectedOutputIndex,
outputCount: expectedOutputCount,
layoutHint: selectedTask?.layoutHint,
t,
});
const prompt =
selectedOutput?.prompt?.trim() ||
selectedTask?.prompt?.trim() ||
"当前图片任务未提供提示词。";
t("agentChat.imageTaskViewer.promptFallback");
const sourceOutputId =
selectedTask?.targetOutputId ?? selectedOutput?.parentOutputId ?? null;
const sourceOutput = sourceOutputId
@@ -463,10 +524,12 @@ export function ImageTaskViewer({
const sourceSummary = sourceImagePrompt
? sourceImagePrompt
: sourceImageRef
? `已引用 ${sourceImageRef}`
? t("agentChat.imageTaskViewer.source.refSummary", {
ref: sourceImageRef,
})
: selectedTask?.mode === "variation"
? "当前任务会基于参考图继续生成新的重绘结果。"
: "当前任务会基于已有图片结果继续完成修图。";
? t("agentChat.imageTaskViewer.source.variationSummary")
: t("agentChat.imageTaskViewer.source.editSummary");
const followUpCommand = buildFollowUpCommand({
mode: selectedTask?.mode,
outputRef: selectedOutput?.refId,
@@ -479,13 +542,14 @@ export function ImageTaskViewer({
}
void openResourceManager({
sourceLabel: resolveModeEyebrow(selectedTask?.mode),
sourceLabel: resolveModeEyebrow(selectedTask?.mode, t),
sourceContext: buildImageTaskResourceSourceContext({
taskId: selectedTask?.id ?? selectedOutput.taskId,
outputId: selectedOutput.id,
projectId: sourceProjectId,
contentId: sourceContentId,
threadId: sourceThreadId,
sourcePage: "image-task-viewer",
}),
initialIndex: selectedOutputIndex >= 0 ? selectedOutputIndex : 0,
items: selectedTaskOutputs.map((output, index) => {
@@ -500,6 +564,7 @@ export function ImageTaskViewer({
slotIndex: output.slotIndex,
slotLabel: output.slotLabel,
taskSlotLabel,
t,
});
return {
@@ -521,6 +586,7 @@ export function ImageTaskViewer({
projectId: sourceProjectId,
contentId: sourceContentId,
threadId: sourceThreadId,
sourcePage: "image-task-viewer",
}),
};
}),
@@ -533,7 +599,7 @@ export function ImageTaskViewer({
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-400">
{resolveModeEyebrow(selectedTask?.mode)}
{resolveModeEyebrow(selectedTask?.mode, t)}
</div>
<div className="mt-2 line-clamp-2 text-sm font-semibold leading-6 text-slate-900">
{prompt}
@@ -560,7 +626,7 @@ export function ImageTaskViewer({
type="button"
onClick={onClose}
className="inline-flex h-9 w-9 items-center justify-center rounded-full border border-slate-200 bg-white text-slate-400 transition hover:border-slate-300 hover:text-slate-700"
aria-label="关闭图片查看"
aria-label={t("agentChat.imageTaskViewer.action.close")}
data-testid="image-task-viewer-close"
>
<X className="h-4 w-4" />
@@ -579,7 +645,10 @@ export function ImageTaskViewer({
{selectedOutput ? (
<RenderableTaskImage
src={selectedOutput.url}
alt={selectedOutput.prompt || "图片任务结果"}
alt={
selectedOutput.prompt ||
t("agentChat.imageTaskViewer.media.resultAlt")
}
className="h-full w-full object-contain"
renderImage={(imageProps) => (
<button
@@ -597,7 +666,8 @@ export function ImageTaskViewer({
/>
<span className="pointer-events-none absolute inset-x-4 bottom-4 rounded-[14px] bg-slate-950/66 px-3 py-2 text-left text-xs leading-5 text-white backdrop-blur-[1px]">
<span className="font-medium">
{selectedStoryboardSlot?.label || "点击逐张预览"}
{selectedStoryboardSlot?.label ||
t("agentChat.imageTaskViewer.media.openSingle")}
</span>
{selectedStoryboardSlot?.prompt ? (
<span className="mt-0.5 line-clamp-2 block text-white/80">
@@ -620,18 +690,24 @@ export function ImageTaskViewer({
)}
<div className="text-sm font-semibold text-slate-900">
{reason === "error"
? resolveImageUnavailableTitle(selectedTask?.status)
? resolveImageUnavailableTitle(
selectedTask?.status,
selectedTask?.mode,
t,
)
: statusLabel}
</div>
<div className="text-sm leading-6 text-slate-500">
{reason === "error"
? resolveImageUnavailableDescription(
selectedTask?.mode,
t,
)
: resolveEmptyStateDescription(
selectedTask?.status,
selectedTask?.failureMessage,
selectedTask?.mode,
t,
)}
</div>
</div>
@@ -656,6 +732,7 @@ export function ImageTaskViewer({
selectedTask?.status,
selectedTask?.failureMessage,
selectedTask?.mode,
t,
)}
</div>
</div>
@@ -670,7 +747,7 @@ export function ImageTaskViewer({
className="mt-4 rounded-[20px] border border-slate-200 bg-slate-50 p-4"
>
<div className="text-[11px] font-medium text-slate-500">
{resolveSourceLabel(selectedTask?.mode)}
{resolveSourceLabel(selectedTask?.mode, t)}
</div>
<div className="mt-3 flex items-center gap-3">
<div className="flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-[18px] border border-slate-200 bg-white">
@@ -678,7 +755,8 @@ export function ImageTaskViewer({
src={sourceImageUrl}
data-testid="image-task-viewer-source-image"
alt={
sourceImagePrompt || resolveSourceLabel(selectedTask?.mode)
sourceImagePrompt ||
resolveSourceLabel(selectedTask?.mode, t)
}
className="h-full w-full object-cover"
renderFallback={(reason) => (
@@ -686,6 +764,7 @@ export function ImageTaskViewer({
{resolveSourcePlaceholderLabel(
selectedTask?.mode,
reason,
t,
)}
</span>
)}
@@ -703,7 +782,9 @@ export function ImageTaskViewer({
) : null}
{sourceImageCount && sourceImageCount > 0 ? (
<span className="rounded-full border border-slate-200 bg-white px-2 py-0.5">
{sourceImageCount} 张
{t("agentChat.imageTaskViewer.source.imageCount", {
count: sourceImageCount,
})}
</span>
) : null}
</div>
@@ -773,8 +854,13 @@ export function ImageTaskViewer({
{expectedOutputCount > 0 ? (
<span className="rounded-full border border-slate-200 bg-slate-50 px-2.5 py-1">
{expectedOutputCount > selectedTaskOutputs.length
? `${selectedTaskOutputs.length} / ${expectedOutputCount} 张结果`
: `${selectedTaskOutputs.length} 张结果`}
? t("agentChat.imageTaskViewer.resultCount.partial", {
current: selectedTaskOutputs.length,
total: expectedOutputCount,
})
: t("agentChat.imageTaskViewer.resultCount.complete", {
count: selectedTaskOutputs.length,
})}
</span>
) : null}
</div>
@@ -795,7 +881,7 @@ export function ImageTaskViewer({
}}
className={IMAGE_TASK_PRIMARY_BUTTON_CLASSNAME}
>
{resolveFollowUpLabel(selectedTask?.mode)}
{resolveFollowUpLabel(selectedTask?.mode, t)}
</button>
) : null}
{selectedOutput && onSaveSelectedToLibrary ? (
@@ -820,8 +906,8 @@ export function ImageTaskViewer({
<LoaderCircle className="h-4 w-4 animate-spin" />
) : null}
{selectedOutput?.resourceSaved
? "已保存到素材库"
: "保存到素材库"}
? t("agentChat.imageTaskViewer.action.savedToLibrary")
: t("agentChat.imageTaskViewer.action.saveToLibrary")}
</button>
) : null}
{selectedOutput &&
@@ -863,6 +949,7 @@ export function ImageTaskViewer({
slotIndex: output?.slotIndex,
slotLabel: output?.slotLabel,
taskSlotLabel,
t,
});
return (
<button
@@ -888,7 +975,10 @@ export function ImageTaskViewer({
{output ? (
<RenderableTaskImage
src={output.url}
alt={output.prompt || "图片结果缩略图"}
alt={
output.prompt ||
t("agentChat.imageTaskViewer.media.thumbAlt")
}
className={cn(
"w-full object-cover",
resolveOutputTileAspectClass(
@@ -904,7 +994,7 @@ export function ImageTaskViewer({
),
)}
>
预览失败
{t("agentChat.imageTaskViewer.media.previewFailed")}
</div>
)}
/>
@@ -926,10 +1016,10 @@ export function ImageTaskViewer({
)}
<span className="text-[11px] font-medium text-slate-400">
{selectedTask?.status === "error"
? "本格失败"
? t("agentChat.imageTaskViewer.slot.failed")
: selectedTask?.status === "cancelled"
? "已取消"
: "等待生成"}
? t("agentChat.imageTaskViewer.slot.cancelled")
: t("agentChat.imageTaskViewer.slot.pending")}
</span>
</div>
)}
@@ -952,7 +1042,7 @@ export function ImageTaskViewer({
) : null}
{active && output ? (
<span className="absolute right-2 top-2 rounded-full bg-slate-950/68 px-2.5 py-1 text-[11px] font-medium text-white">
当前选中
{t("agentChat.imageTaskViewer.selected.current")}
</span>
) : null}
</div>
@@ -1,4 +1,5 @@
import { act } from "react";
import type { ComponentProps } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { changeLimeLocale } from "@/i18n/createI18n";
@@ -8,13 +9,18 @@ import { ImageWorkbenchMessagePreview } from "./ImageWorkbenchMessagePreview";
const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = [];
const IMAGE_WORKBENCH_TASK_ACTION_EVENT = "lime:image-workbench-task-action";
function renderPreview(preview: MessageImageWorkbenchPreview) {
function renderPreview(
preview: MessageImageWorkbenchPreview,
props?: Partial<
Omit<ComponentProps<typeof ImageWorkbenchMessagePreview>, "preview">
>,
) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(<ImageWorkbenchMessagePreview preview={preview} />);
root.render(<ImageWorkbenchMessagePreview preview={preview} {...props} />);
});
mountedRoots.push({ root, container });
@@ -130,15 +136,16 @@ describe("ImageWorkbenchMessagePreview", () => {
};
window.addEventListener(IMAGE_WORKBENCH_TASK_ACTION_EVENT, handleAction);
const openButton = container.querySelector(
const openCard = container.querySelector(
'[data-testid="image-workbench-message-preview-image-preview-failed"]',
) as HTMLButtonElement | null;
) as HTMLDivElement | null;
const retryButton = container.querySelector(
'[data-testid="image-workbench-message-preview-action-image-preview-failed-retry"]',
) as HTMLButtonElement | null;
expect(openButton?.getAttribute("aria-label")).toBe("Open image result");
expect(openCard).not.toBeNull();
expect(retryButton?.textContent).toContain("Retry");
expect(container.textContent).not.toContain("Provider timeout");
act(() => {
retryButton?.click();
@@ -151,9 +158,44 @@ describe("ImageWorkbenchMessagePreview", () => {
contentId: "content-1",
});
window.removeEventListener(
IMAGE_WORKBENCH_TASK_ACTION_EVENT,
handleAction,
window.removeEventListener(IMAGE_WORKBENCH_TASK_ACTION_EVENT, handleAction);
});
it("passes the clicked grid image so the viewer does not fall back to the first output", () => {
const onOpen = vi.fn();
const { container } = renderPreview(
{
taskId: "image-preview-storyboard",
prompt: "九张章节配图",
mode: "generate",
status: "complete",
imageUrl: "https://example.com/chapter-1.png",
previewImages: [
"https://example.com/chapter-1.png",
"https://example.com/chapter-2.png",
"https://example.com/chapter-3.png",
],
imageCount: 3,
},
{ onOpen },
);
const secondImageButton = container.querySelector(
'[data-testid="image-workbench-message-preview-media-image-preview-storyboard-2"]',
) as HTMLButtonElement | null;
act(() => {
secondImageButton?.click();
});
expect(onOpen).toHaveBeenCalledWith(
expect.objectContaining({
taskId: "image-preview-storyboard",
}),
{
imageUrl: "https://example.com/chapter-2.png",
imageIndex: 1,
},
);
});
});
@@ -1,19 +1,24 @@
import React from "react";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { Leaf, LoaderCircle, RotateCcw } from "lucide-react";
import { Leaf, RotateCcw } from "lucide-react";
import {
emitImageWorkbenchFocus,
emitImageWorkbenchTaskAction,
} from "@/lib/imageWorkbenchEvents";
import { cn } from "@/lib/utils";
import type { MessageImageWorkbenchPreview } from "../types";
import type {
MessageImageWorkbenchPreview,
MessageImageWorkbenchPreviewSelection,
} from "../types";
import { resolveImageWorkbenchPreviewModelLabel } from "../utils/imageWorkbenchPresentation";
import { RenderableTaskImage } from "./RenderableTaskImage";
import { ImageWorkbenchPreviewMedia } from "./ImageWorkbenchPreviewMedia";
interface ImageWorkbenchMessagePreviewProps {
preview: MessageImageWorkbenchPreview;
onOpen?: (preview: MessageImageWorkbenchPreview) => void;
onOpen?: (
preview: MessageImageWorkbenchPreview,
selection?: MessageImageWorkbenchPreviewSelection,
) => void;
}
type AgentTranslate = TFunction<"agent", undefined>;
@@ -33,175 +38,6 @@ function resolveToolLabel(
}
}
function resolvePlaceholderLabel(
preview: MessageImageWorkbenchPreview,
t: AgentTranslate,
): string {
switch (preview.status) {
case "cancelled":
return t("agentChat.imageWorkbenchPreview.placeholder.cancelled");
case "failed":
return t("agentChat.imageWorkbenchPreview.placeholder.failed");
case "complete":
case "partial":
return t("agentChat.imageWorkbenchPreview.placeholder.imageUnavailable");
case "running":
default:
switch (preview.mode) {
case "edit":
return t("agentChat.imageWorkbenchPreview.placeholder.editing");
case "variation":
return t("agentChat.imageWorkbenchPreview.placeholder.redrawing");
case "generate":
default:
return t("agentChat.imageWorkbenchPreview.placeholder.generating");
}
}
}
function renderPlaceholder(
preview: MessageImageWorkbenchPreview,
reason: string,
t: AgentTranslate,
) {
const detail =
preview.status === "failed" ? preview.statusMessage?.trim() : "";
return (
<div className="flex h-full min-h-0 w-full items-center justify-center rounded-[12px] bg-slate-50 px-5 text-center">
<div className="space-y-1.5">
{reason === "empty" && preview.status === "running" ? (
<LoaderCircle className="mx-auto h-5 w-5 animate-spin text-slate-500" />
) : null}
<div className="text-sm font-medium text-slate-700">
{resolvePlaceholderLabel(preview, t)}
</div>
{detail ? (
<div className="line-clamp-2 max-w-[320px] text-xs leading-5 text-slate-500">
{detail}
</div>
) : null}
</div>
</div>
);
}
function resolvePreviewImages(preview: MessageImageWorkbenchPreview): string[] {
const urls: string[] = [];
(preview.previewImages || []).forEach((value) => {
const normalized = value.trim();
if (!normalized || urls.includes(normalized)) {
return;
}
urls.push(normalized);
});
const primaryUrl = preview.imageUrl?.trim();
if (primaryUrl && !urls.includes(primaryUrl)) {
urls.unshift(primaryUrl);
}
return urls.slice(0, 9);
}
function resolvePreviewGridAspectClass(
preview: MessageImageWorkbenchPreview,
imageCount: number,
): string {
if (preview.layoutHint === "storyboard_3x3" && imageCount >= 4) {
return "aspect-square";
}
return "aspect-[16/10]";
}
function renderPreviewMedia(
preview: MessageImageWorkbenchPreview,
t: AgentTranslate,
) {
const previewImages = resolvePreviewImages(preview);
const expectedImageCount = Math.max(
preview.expectedImageCount ?? 0,
preview.imageCount ?? 0,
previewImages.length,
);
const isStoryboardGrid = preview.layoutHint === "storyboard_3x3";
const totalSlotCount = isStoryboardGrid
? Math.max(expectedImageCount, 9)
: previewImages.length;
const aspectClass = resolvePreviewGridAspectClass(preview, totalSlotCount);
if (!isStoryboardGrid && previewImages.length <= 1) {
return (
<div
data-testid={`image-workbench-message-preview-single-media-${preview.taskId}`}
className="aspect-[16/9] w-[358px] max-w-full overflow-hidden rounded-[12px] bg-slate-50"
>
<RenderableTaskImage
src={previewImages[0] || preview.imageUrl}
alt={preview.prompt || t("agentChat.imageWorkbenchPreview.media.alt")}
className="h-full w-full object-cover"
renderFallback={(reason) => renderPlaceholder(preview, reason, t)}
/>
</div>
);
}
const visibleCount = isStoryboardGrid
? Math.min(totalSlotCount, 9)
: Math.min(previewImages.length, previewImages.length <= 4 ? 4 : 6);
const extraCount = Math.max(0, previewImages.length - visibleCount);
const columnsClass = isStoryboardGrid
? "grid-cols-3"
: visibleCount <= 4
? "grid-cols-2"
: "grid-cols-3";
return (
<div
data-testid={`image-workbench-message-preview-grid-${preview.taskId}`}
className={cn("grid gap-1.5", aspectClass, columnsClass)}
>
{Array.from({ length: visibleCount }, (_, index) => {
const url = previewImages[index];
const isLastWithOverflow = extraCount > 0 && index === visibleCount - 1;
return (
<div
key={`${url || "placeholder"}-${index}`}
className={cn(
"relative overflow-hidden rounded-[14px] bg-slate-50",
isStoryboardGrid ? "aspect-square" : "aspect-[4/3]",
)}
>
{url ? (
<RenderableTaskImage
src={url}
alt={`${preview.prompt || t("agentChat.imageWorkbenchPreview.media.alt")} ${
index + 1
}`}
className="h-full w-full object-cover"
renderFallback={() => (
<div className="flex h-full w-full items-center justify-center bg-slate-100 text-[11px] font-medium text-slate-400">
{t("agentChat.imageWorkbenchPreview.media.previewFailed")}
</div>
)}
/>
) : (
<div className="flex h-full w-full items-center justify-center bg-slate-50">
{preview.status === "running" ? (
<LoaderCircle className="h-4 w-4 animate-spin text-slate-500" />
) : null}
</div>
)}
{isLastWithOverflow ? (
<div className="absolute inset-0 flex items-center justify-center bg-slate-950/48 text-sm font-semibold text-white">
+{extraCount}
</div>
) : null}
</div>
);
})}
</div>
);
}
export const ImageWorkbenchMessagePreview: React.FC<
ImageWorkbenchMessagePreviewProps
> = ({ preview, onOpen }) => {
@@ -211,9 +47,9 @@ export const ImageWorkbenchMessagePreview: React.FC<
const caption = preview.caption?.trim();
const showRetryAction = preview.status === "failed";
const openPreview = () => {
const openPreview = (selection?: MessageImageWorkbenchPreviewSelection) => {
if (onOpen) {
onOpen(preview);
onOpen(preview, selection);
return;
}
emitImageWorkbenchFocus({
@@ -234,12 +70,10 @@ export const ImageWorkbenchMessagePreview: React.FC<
return (
<div className="w-full max-w-[800px]">
<button
type="button"
aria-label={t("agentChat.imageWorkbenchPreview.media.open")}
onClick={openPreview}
<div
onClick={() => openPreview()}
data-testid={`image-workbench-message-preview-${preview.taskId}`}
className="group block w-full text-left"
className="group block w-full cursor-pointer text-left"
>
<div
data-testid={`image-workbench-message-preview-toolbar-${preview.taskId}`}
@@ -255,7 +89,11 @@ export const ImageWorkbenchMessagePreview: React.FC<
) : null}
</div>
<div className="relative max-w-full transition">
{renderPreviewMedia(preview, t)}
<ImageWorkbenchPreviewMedia
preview={preview}
t={t}
onSelect={openPreview}
/>
</div>
{caption ? (
<div
@@ -265,7 +103,7 @@ export const ImageWorkbenchMessagePreview: React.FC<
{caption}
</div>
) : null}
</button>
</div>
{showRetryAction ? (
<div className="mt-2 flex items-center">
<button
@@ -0,0 +1,184 @@
import { LoaderCircle } from "lucide-react";
import type { TFunction } from "i18next";
import { cn } from "@/lib/utils";
import type {
MessageImageWorkbenchPreview,
MessageImageWorkbenchPreviewSelection,
} from "../types";
import { resolveImageWorkbenchPreviewImages } from "../workspace/imageWorkbenchResourceManager";
import { RenderableTaskImage } from "./RenderableTaskImage";
type AgentTranslate = TFunction<"agent", undefined>;
interface ImageWorkbenchPreviewMediaProps {
preview: MessageImageWorkbenchPreview;
onSelect: (selection?: MessageImageWorkbenchPreviewSelection) => void;
t: AgentTranslate;
}
function resolvePlaceholderLabel(
preview: MessageImageWorkbenchPreview,
t: AgentTranslate,
): string {
switch (preview.status) {
case "cancelled":
return t("agentChat.imageWorkbenchPreview.placeholder.cancelled");
case "failed":
return t("agentChat.imageWorkbenchPreview.placeholder.failed");
case "complete":
case "partial":
return t("agentChat.imageWorkbenchPreview.placeholder.imageUnavailable");
case "running":
default:
switch (preview.mode) {
case "edit":
return t("agentChat.imageWorkbenchPreview.placeholder.editing");
case "variation":
return t("agentChat.imageWorkbenchPreview.placeholder.redrawing");
case "generate":
default:
return t("agentChat.imageWorkbenchPreview.placeholder.generating");
}
}
}
function renderPlaceholder(
preview: MessageImageWorkbenchPreview,
reason: string,
t: AgentTranslate,
) {
return (
<div className="flex h-full min-h-0 w-full items-center justify-center rounded-[12px] bg-slate-50 px-5 text-center">
<div className="space-y-1.5">
{reason === "empty" && preview.status === "running" ? (
<LoaderCircle className="mx-auto h-5 w-5 animate-spin text-slate-500" />
) : null}
<div className="text-sm font-medium text-slate-700">
{resolvePlaceholderLabel(preview, t)}
</div>
</div>
</div>
);
}
function resolvePreviewGridAspectClass(
preview: MessageImageWorkbenchPreview,
imageCount: number,
): string {
if (preview.layoutHint === "storyboard_3x3" && imageCount >= 4) {
return "aspect-square";
}
return "aspect-[16/10]";
}
export function ImageWorkbenchPreviewMedia({
preview,
onSelect,
t,
}: ImageWorkbenchPreviewMediaProps) {
const previewImages = resolveImageWorkbenchPreviewImages(preview);
const expectedImageCount = Math.max(
preview.expectedImageCount ?? 0,
preview.imageCount ?? 0,
previewImages.length,
);
const isStoryboardGrid = preview.layoutHint === "storyboard_3x3";
const totalSlotCount = isStoryboardGrid
? Math.max(expectedImageCount, 9)
: previewImages.length;
const aspectClass = resolvePreviewGridAspectClass(preview, totalSlotCount);
if (!isStoryboardGrid && previewImages.length <= 1) {
return (
<button
type="button"
aria-label={t("agentChat.imageWorkbenchPreview.media.open")}
onClick={(event) => {
event.stopPropagation();
onSelect({
imageUrl: previewImages[0] || preview.imageUrl || null,
imageIndex: 0,
});
}}
data-testid={`image-workbench-message-preview-single-media-${preview.taskId}`}
className="block aspect-[16/9] w-[358px] max-w-full overflow-hidden rounded-[12px] border-0 bg-slate-50 p-0 text-left"
>
<RenderableTaskImage
src={previewImages[0] || preview.imageUrl}
alt={preview.prompt || t("agentChat.imageWorkbenchPreview.media.alt")}
className="h-full w-full object-cover"
renderFallback={(reason) => renderPlaceholder(preview, reason, t)}
/>
</button>
);
}
const visibleCount = isStoryboardGrid
? Math.min(totalSlotCount, 9)
: Math.min(previewImages.length, previewImages.length <= 4 ? 4 : 6);
const extraCount = Math.max(0, previewImages.length - visibleCount);
const columnsClass = isStoryboardGrid
? "grid-cols-3"
: visibleCount <= 4
? "grid-cols-2"
: "grid-cols-3";
return (
<div
data-testid={`image-workbench-message-preview-grid-${preview.taskId}`}
className={cn("grid gap-1.5", aspectClass, columnsClass)}
>
{Array.from({ length: visibleCount }, (_, index) => {
const url = previewImages[index];
const isLastWithOverflow = extraCount > 0 && index === visibleCount - 1;
return (
<button
type="button"
key={`${url || "placeholder"}-${index}`}
aria-label={`${t("agentChat.imageWorkbenchPreview.media.open")} ${
index + 1
}`}
onClick={(event) => {
event.stopPropagation();
onSelect({
imageUrl: url || null,
imageIndex: index,
});
}}
data-testid={`image-workbench-message-preview-media-${preview.taskId}-${index + 1}`}
className={cn(
"relative block overflow-hidden rounded-[14px] border-0 bg-slate-50 p-0 text-left",
isStoryboardGrid ? "aspect-square" : "aspect-[4/3]",
)}
>
{url ? (
<RenderableTaskImage
src={url}
alt={`${preview.prompt || t("agentChat.imageWorkbenchPreview.media.alt")} ${
index + 1
}`}
className="h-full w-full object-cover"
renderFallback={() => (
<div className="flex h-full w-full items-center justify-center bg-slate-100 text-[11px] font-medium text-slate-400">
{t("agentChat.imageWorkbenchPreview.media.previewFailed")}
</div>
)}
/>
) : (
<div className="flex h-full w-full items-center justify-center bg-slate-50">
{preview.status === "running" ? (
<LoaderCircle className="h-4 w-4 animate-spin text-slate-500" />
) : null}
</div>
)}
{isLastWithOverflow ? (
<div className="absolute inset-0 flex items-center justify-center bg-slate-950/48 text-sm font-semibold text-white">
+{extraCount}
</div>
) : null}
</button>
);
})}
</div>
);
}
@@ -224,6 +224,75 @@ describe("InlineToolProcessStep", () => {
expect(container.textContent).toContain("&lt;typeof schema&gt;");
});
it("Skill 过程步骤应能展开查看本次执行读取的 SKILL.md", () => {
const { container } = renderTool({
id: "skill:analysis-run-1",
name: "Skill",
arguments: JSON.stringify({
skill: "analysis",
display_name: "analysis",
source: "SKILL.md",
}),
status: "completed",
result: {
success: true,
output: "已从 SKILL.md 读取并执行 Skill:analysis",
metadata: {
tool_family: "skill",
skill_name: "analysis",
skill_display_name: "analysis",
skill_source: "SKILL.md",
agent_skills_standard: true,
markdown_content_bytes: 86,
skill_markdown_content:
"---\nname: analysis\ndescription: 分析任务\n---\n\n# Analysis Skill\n\n必须先确认可见上下文。",
},
},
startTime: new Date("2026-05-14T04:30:00.000Z"),
endTime: new Date("2026-05-14T04:30:02.000Z"),
});
expect(container.textContent).toContain("已执行技能 analysis");
expect(container.textContent).toContain("SKILL.md");
expect(container.textContent).not.toContain("skill_markdown_content");
act(() => {
const skillButton = container.querySelector(
'button[title="查看 SKILL.md"]',
) as HTMLButtonElement | null;
skillButton?.click();
});
expect(
container.querySelector(
'[data-testid="inline-tool-skill-content-panel"]',
),
).not.toBeNull();
expect(container.textContent).toContain("执行时读取的 SKILL.md");
expect(container.textContent).toContain("随本次执行记录保存");
expect(container.textContent).toContain("Agent Skills 标准");
expect(container.textContent).toContain("展开 SKILL.md 内容");
expect(container.textContent).not.toContain("Analysis Skill");
act(() => {
const expandBodyButton = Array.from(
container.querySelectorAll("button"),
).find((button) => button.textContent?.includes("展开 SKILL.md 内容")) as
| HTMLButtonElement
| undefined;
expandBodyButton?.click();
});
expect(
container.querySelector('[data-testid="inline-tool-skill-content-body"]'),
).not.toBeNull();
expect(container.textContent).toContain("收起 SKILL.md 内容");
expect(container.textContent).toContain("Analysis Skill");
expect(container.textContent).toContain("必须先确认可见上下文。");
expect(container.textContent).not.toContain("tool_family");
expect(container.textContent).not.toContain("skill_markdown_content");
});
it("ToolSearch 展开后应展示结构化工具摘要,而不是原始 JSON", () => {
const { container } = renderTool({
id: "tool-search-1",
@@ -357,6 +426,48 @@ describe("InlineToolProcessStep", () => {
);
});
it("图片生成任务失败时不应展示内部错误码、工具名或长提示词", () => {
const { container } = renderTool({
id: "tool-image-generate-failed-1",
name: "lime_create_image_generation_task",
arguments: JSON.stringify({
prompt: "A comic book style illustration of a formal statue",
}),
status: "failed",
result: {
success: false,
error: "-32603: -32002: lime_create_image_generation_task",
output: "",
},
startTime: new Date("2026-05-14T10:22:00.000Z"),
endTime: new Date("2026-05-14T10:22:01.000Z"),
});
expect(container.textContent).toContain("生成失败");
expect(container.textContent).not.toContain("开始失败");
expect(container.textContent).not.toContain("-32603");
expect(container.textContent).not.toContain("-32002");
expect(container.textContent).not.toContain(
"lime_create_image_generation_task",
);
expect(container.textContent).not.toContain(
"A comic book style illustration",
);
act(() => {
const toggle = container.querySelector(
'button[title="展开过程详情"]',
) as HTMLButtonElement | null;
toggle?.click();
});
expect(container.textContent).toContain("生成失败");
expect(container.textContent).not.toContain("-32603");
expect(container.textContent).not.toContain(
"lime_create_image_generation_task",
);
});
it("完成态过程卡不应重复展示执行完成与原始工具名", () => {
const { container } = renderTool({
id: "tool-inline-ask-user-1",
@@ -1,7 +1,9 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { open as openExternal } from "@tauri-apps/plugin-shell";
import { ChevronDown, ExternalLink, FileText, Loader2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import { skillsApi } from "@/lib/api/skills";
import { MarkdownRenderer } from "./MarkdownRenderer";
import { SearchResultPreviewList } from "./SearchResultPreviewList";
import { ToolSearchSummaryPanel } from "./ToolSearchSummaryPanel";
@@ -38,6 +40,7 @@ import {
resolveToolErrorDetailText,
resolveToolProcessNarrative,
} from "../utils/toolProcessSummary";
import { isImageGenerationProtocolFailure } from "../utils/limeTaskProtocolNoise";
interface InlineToolProcessStepProps {
toolCall: ToolCallState;
@@ -71,6 +74,38 @@ function readString(
return null;
}
function readNumber(
record: Record<string, unknown> | null,
keys: string[],
): number | null {
if (!record) {
return null;
}
for (const key of keys) {
const value = record[key];
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
}
return null;
}
function readBoolean(
record: Record<string, unknown> | null,
keys: string[],
): boolean | null {
if (!record) {
return null;
}
for (const key of keys) {
const value = record[key];
if (typeof value === "boolean") {
return value;
}
}
return null;
}
function summarizeResultText(value: string): string | null {
const trimmed = value.trim();
if (!trimmed) {
@@ -182,12 +217,24 @@ export const InlineToolProcessStep: React.FC<InlineToolProcessStepProps> = ({
onFileClick,
onOpenSavedSiteContent,
}) => {
const { t } = useTranslation("agent");
const [expanded, setExpanded] = useState(false);
const [skillContentExpanded, setSkillContentExpanded] = useState(false);
const [fetchedSkillContent, setFetchedSkillContent] = useState<string | null>(
null,
);
const [skillContentLoading, setSkillContentLoading] = useState(false);
const [skillContentError, setSkillContentError] = useState<string | null>(
null,
);
const [skillMarkdownBodyExpanded, setSkillMarkdownBodyExpanded] =
useState(false);
const parsedArgs = useMemo(
() => parseToolCallArguments(toolCall.arguments),
[toolCall.arguments],
);
const argsRecord = useMemo(() => asRecord(parsedArgs), [parsedArgs]);
const toolDisplay = useMemo(
() => getToolDisplayInfo(toolCall.name, toolCall.status),
[toolCall.name, toolCall.status],
@@ -206,19 +253,30 @@ export const InlineToolProcessStep: React.FC<InlineToolProcessStepProps> = ({
() => resolveToolPrimarySubject(toolCall.name, parsedArgs, filePath),
[filePath, parsedArgs, toolCall.name],
);
const headline = useMemo(
() =>
buildToolHeadline({
toolDisplay,
subject,
toolName: toolCall.name,
}),
[subject, toolCall.name, toolDisplay],
);
const rawResultText = useMemo(() => {
const rawText = toolCall.result?.error || toolCall.result?.output || "";
return extractLimeToolMetadataBlock(rawText).text.trim();
}, [toolCall.result?.error, toolCall.result?.output]);
const isImageGenerationFailureProcess = useMemo(
() =>
toolCall.status === "failed" &&
isImageGenerationProtocolFailure({
toolName: toolCall.name,
text: rawResultText,
}),
[rawResultText, toolCall.name, toolCall.status],
);
const headline = useMemo(
() =>
isImageGenerationFailureProcess
? t("agentChat.imageWorkbenchPreview.placeholder.failed")
: buildToolHeadline({
toolDisplay,
subject,
toolName: toolCall.name,
}),
[isImageGenerationFailureProcess, subject, t, toolCall.name, toolDisplay],
);
const resultText = useMemo(() => {
if (toolCall.status !== "failed") {
return rawResultText;
@@ -285,11 +343,50 @@ export const InlineToolProcessStep: React.FC<InlineToolProcessStepProps> = ({
[toolCall],
);
const skillTitle =
readString(asRecord(parsedArgs), ["skill_title", "skillTitle"]) ||
readString(argsRecord, ["skill_title", "skillTitle"]) ||
readString(metadata, ["skill_title", "skillTitle"]);
const isPreload =
metadata?.execution_origin === "preload" || metadata?.preload === true;
const hasOpenableFile = Boolean(filePath && onFileClick);
const skillSource =
readString(metadata, ["skill_source", "skillSource"]) ||
readString(argsRecord, ["source"]);
const isSkillInvocation =
toolDisplay.family === "skill" ||
metadata?.tool_family === "skill" ||
skillSource === "SKILL.md";
const skillName =
readString(metadata, ["skill_name", "skillName"]) ||
readString(argsRecord, ["skill", "name"]);
const skillDisplayName =
readString(metadata, ["skill_display_name", "skillDisplayName"]) ||
readString(argsRecord, ["display_name", "displayName"]) ||
skillName;
const skillSnapshotContent = readString(metadata, [
"skill_markdown_content",
"skillMarkdownContent",
"markdown_content",
"markdownContent",
]);
const skillMarkdownContentBytes = readNumber(metadata, [
"markdown_content_bytes",
"markdownContentBytes",
]);
const isStandardSkillSnapshot = readBoolean(metadata, [
"agent_skills_standard",
"agentSkillsStandard",
]);
const hasSkillContentAccess =
isSkillInvocation &&
Boolean(skillSnapshotContent || fetchedSkillContent || skillName);
const resolvedSkillContent =
skillSnapshotContent || fetchedSkillContent || "";
const skillContentSourceLabel = skillSnapshotContent
? t("agentChat.toolCall.skillContent.source.snapshot")
: t("agentChat.toolCall.skillContent.source.current");
const skillContentTitle = skillSnapshotContent
? t("agentChat.toolCall.skillContent.title.snapshot")
: t("agentChat.toolCall.skillContent.title.current");
const processSummary = useMemo(() => {
const streamingOutputSummary =
toolCall.status === "running" && structuredResultPreview
@@ -356,6 +453,61 @@ export const InlineToolProcessStep: React.FC<InlineToolProcessStepProps> = ({
toolSearchSummary,
]);
useEffect(() => {
setSkillContentExpanded(false);
setFetchedSkillContent(null);
setSkillContentLoading(false);
setSkillContentError(null);
setSkillMarkdownBodyExpanded(false);
}, [toolCall.id]);
const handleToggleSkillContent = useCallback(async () => {
if (skillContentExpanded) {
setSkillContentExpanded(false);
setSkillMarkdownBodyExpanded(false);
return;
}
setSkillContentExpanded(true);
setSkillMarkdownBodyExpanded(false);
if (skillSnapshotContent || fetchedSkillContent) {
return;
}
const normalizedSkillName = skillName?.trim();
if (!normalizedSkillName) {
setSkillContentError(
t("agentChat.toolCall.skillContent.error.unavailable"),
);
return;
}
setSkillContentLoading(true);
setSkillContentError(null);
try {
const inspection = await skillsApi.inspectLocalSkill(normalizedSkillName);
setFetchedSkillContent(inspection.content || "");
} catch (error) {
const message =
error instanceof Error
? error.message
: typeof error === "string"
? error
: t("agentChat.toolCall.skillContent.error.unknown");
setSkillContentError(
t("agentChat.toolCall.skillContent.error.loadFailed", { message }),
);
} finally {
setSkillContentLoading(false);
}
}, [
fetchedSkillContent,
skillContentExpanded,
skillName,
skillSnapshotContent,
t,
]);
const detailBadges = [
isPreload ? "系统预执行" : null,
skillTitle && skillTitle !== subject ? `技能:${skillTitle}` : null,
@@ -429,6 +581,33 @@ export const InlineToolProcessStep: React.FC<InlineToolProcessStepProps> = ({
</button>
<div className="flex shrink-0 items-center gap-1 pt-0.5">
{hasSkillContentAccess ? (
<button
type="button"
className={cn(
"inline-flex items-center gap-1 rounded-md px-2 py-1 text-[11px] font-medium transition-colors",
skillContentExpanded
? "bg-emerald-50 text-emerald-800"
: "text-slate-500 hover:bg-slate-100 hover:text-slate-800",
)}
title={
skillContentExpanded
? t("agentChat.toolCall.skillContent.action.hide")
: t("agentChat.toolCall.skillContent.action.view")
}
aria-label={
skillContentExpanded
? t("agentChat.toolCall.skillContent.action.hide")
: t("agentChat.toolCall.skillContent.action.view")
}
onClick={handleToggleSkillContent}
>
<FileText className="h-3.5 w-3.5" />
<span>
{t("agentChat.toolCall.skillContent.action.viewShort")}
</span>
</button>
) : null}
{hasOpenableFile ? (
<button
type="button"
@@ -462,6 +641,79 @@ export const InlineToolProcessStep: React.FC<InlineToolProcessStepProps> = ({
</div>
</div>
{skillContentExpanded ? (
<div
className="ml-1 mt-2 rounded-[14px] border border-emerald-100 bg-emerald-50/60 p-3"
data-testid="inline-tool-skill-content-panel"
>
<div className="flex items-center gap-1.5 text-xs font-semibold text-emerald-900">
<FileText className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{skillContentTitle}</span>
</div>
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px] text-emerald-700">
<span>{skillContentSourceLabel}</span>
{skillDisplayName ? <span>{skillDisplayName}</span> : null}
{skillMarkdownContentBytes !== null ? (
<span>
{t("agentChat.toolCall.skillContent.meta.bytes", {
count: skillMarkdownContentBytes,
})}
</span>
) : null}
{isStandardSkillSnapshot === true ? (
<span>
{t("agentChat.toolCall.skillContent.meta.standard")}
</span>
) : null}
</div>
<button
type="button"
className="mt-3 flex w-full items-center justify-between rounded-[12px] border border-emerald-100 bg-white px-3 py-2 text-left text-xs font-medium text-emerald-900 transition-colors hover:border-emerald-200 hover:bg-emerald-50/60"
aria-expanded={skillMarkdownBodyExpanded}
aria-controls={`inline-skill-content-body-${toolCall.id}`}
onClick={() =>
setSkillMarkdownBodyExpanded((current) => !current)
}
>
<span>
{skillMarkdownBodyExpanded
? t("agentChat.toolCall.skillContent.action.collapseBody")
: t("agentChat.toolCall.skillContent.action.expandBody")}
</span>
<ChevronDown
className={cn(
"h-3.5 w-3.5 shrink-0 transition-transform",
skillMarkdownBodyExpanded && "rotate-180",
)}
/>
</button>
{skillMarkdownBodyExpanded ? (
<div
id={`inline-skill-content-body-${toolCall.id}`}
className="mt-2 max-h-80 overflow-y-auto rounded-[12px] border border-slate-200 bg-white p-3"
data-testid="inline-tool-skill-content-body"
>
{skillContentLoading ? (
<div className="flex items-center gap-2 text-sm text-slate-500">
<Loader2 className="h-4 w-4 animate-spin" />
{t("agentChat.toolCall.skillContent.loading")}
</div>
) : skillContentError ? (
<div className="text-sm text-rose-700">
{skillContentError}
</div>
) : resolvedSkillContent.trim() ? (
<MarkdownRenderer content={resolvedSkillContent} />
) : (
<div className="text-sm text-slate-500">
{t("agentChat.toolCall.skillContent.empty")}
</div>
)}
</div>
) : null}
</div>
) : null}
{expanded && hasDetails ? (
<div className="ml-1 mt-2 space-y-2 border-l border-slate-200 pl-3">
{siteNoticeLines.length > 0 ? (
@@ -36,6 +36,10 @@ import type {
import { InputbarKnowledgeControl } from "../knowledge/InputbarKnowledgeControl";
import type { InputbarComposerSectionCopy } from "./inputbarComposerSectionCopy";
import type { InputbarCoreCopy } from "./inputbarCoreCopy";
import type {
InputbarExecutionStrategyCopy,
InputbarWorkflowPanelCopy,
} from "../inputbarWorkflowCopy";
import type {
WorkflowGateState,
WorkflowQuickAction,
@@ -116,6 +120,8 @@ interface InputbarComposerSectionProps {
inputCompletionEnabled?: boolean;
copy: InputbarComposerSectionCopy;
inputbarCopy: InputbarCoreCopy;
executionStrategyCopy: InputbarExecutionStrategyCopy;
workflowPanelCopy: InputbarWorkflowPanelCopy;
}
export const InputbarComposerSection: React.FC<
@@ -183,6 +189,8 @@ export const InputbarComposerSection: React.FC<
inputCompletionEnabled = true,
copy,
inputbarCopy,
executionStrategyCopy,
workflowPanelCopy,
}) => {
const [teamSelectorAutoOpenToken, setTeamSelectorAutoOpenToken] = useState<
number | null
@@ -369,6 +377,7 @@ export const InputbarComposerSection: React.FC<
isFullscreen={isFullscreen}
executionStrategy={executionStrategy}
setExecutionStrategy={setExecutionStrategy}
copy={executionStrategyCopy}
/>
{shouldShowModelControls ? (
<InputbarModelExtra
@@ -407,6 +416,7 @@ export const InputbarComposerSection: React.FC<
renderGeneratingPanel
onQuickAction={inputAdapter.actions.setText}
onStop={inputAdapter.actions.stop}
copy={workflowPanelCopy}
/>
);
}
@@ -426,6 +436,7 @@ export const InputbarComposerSection: React.FC<
renderGeneratingPanel={false}
onQuickAction={inputAdapter.actions.setText}
onStop={inputAdapter.actions.stop}
copy={workflowPanelCopy}
/>
<CharacterMention
{...mentionSkillProps}
@@ -4,8 +4,7 @@ import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { OPEN_VOICE_MODEL_SETTINGS_EVENT } from "@/lib/voiceModelSettingsNavigation";
import { changeLimeLocale } from "@/i18n/createI18n";
import agentResource from "@/i18n/resources/zh-CN/agent.json";
import enAgentResource from "@/i18n/resources/en-US/agent.json";
import { agentEnUSResource, agentZhCNResource } from "@/i18n/agentResources";
import { InputbarCore } from "./InputbarCore";
import {
buildInputbarCoreCopy,
@@ -125,18 +124,17 @@ function translateResource(
values?: Record<string, number | string>,
) {
return Object.entries(values ?? {}).reduce(
(text, [name, value]) =>
text.split(`{{${name}}}`).join(String(value)),
(text, [name, value]) => text.split(`{{${name}}}`).join(String(value)),
resource[key] ?? key,
);
}
const TEST_INPUTBAR_CORE_COPY = buildInputbarCoreCopy((key, values) =>
translateResource(agentResource, key, values),
translateResource(agentZhCNResource, key, values),
);
const TEST_EN_INPUTBAR_CORE_COPY = buildInputbarCoreCopy((key, values) =>
translateResource(enAgentResource, key, values),
translateResource(agentEnUSResource, key, values),
);
beforeEach(async () => {
@@ -164,9 +162,7 @@ afterEach(() => {
});
const renderInputbarCore = async (
props?: Partial<
Omit<React.ComponentProps<typeof InputbarCore>, "uiCopy">
> & {
props?: Partial<Omit<React.ComponentProps<typeof InputbarCore>, "uiCopy">> & {
uiCopy?: React.ComponentProps<typeof InputbarCore>["uiCopy"];
},
) => {
@@ -272,13 +268,9 @@ describe("InputbarCore", () => {
expect(
container.querySelector('button[aria-label="Expand input"]'),
).toBeTruthy();
expect(
container.querySelector('button[aria-label="Send"]'),
).toBeNull();
expect(container.querySelector('button[aria-label="Send"]')).toBeNull();
expect(container.textContent).toContain("Handle later");
expect(
container.querySelector('button[aria-label="Stop"]'),
).toBeTruthy();
expect(container.querySelector('button[aria-label="Stop"]')).toBeTruthy();
});
it("添加路径引用时应显示 chip 并允许移除", async () => {
@@ -6,6 +6,7 @@ import {
MetaToggleGlyph,
MetaToggleLabel,
} from "../styles";
import type { InputbarExecutionStrategyCopy } from "../inputbarWorkflowCopy";
interface InputbarExecutionStrategySelectProps {
isFullscreen?: boolean;
@@ -13,6 +14,7 @@ interface InputbarExecutionStrategySelectProps {
setExecutionStrategy?: (
strategy: "react" | "code_orchestrated" | "auto",
) => void;
copy: InputbarExecutionStrategyCopy;
}
export const InputbarExecutionStrategySelect: React.FC<
@@ -22,6 +24,7 @@ export const InputbarExecutionStrategySelect: React.FC<
isFullscreen = false,
executionStrategy,
setExecutionStrategy,
copy,
} = props;
if (isFullscreen || !setExecutionStrategy) {
@@ -29,15 +32,16 @@ export const InputbarExecutionStrategySelect: React.FC<
}
const planEnabled = executionStrategy === "code_orchestrated";
const toggleLabel = planEnabled ? copy.disable : copy.enable;
return (
<MetaToggleButton
type="button"
$checked={planEnabled}
aria-label={planEnabled ? "关闭计划执行" : "开启计划执行"}
aria-label={toggleLabel}
aria-pressed={planEnabled}
data-testid="inputbar-plan-toggle"
title={planEnabled ? "关闭计划执行" : "开启计划执行"}
title={toggleLabel}
onClick={() =>
setExecutionStrategy(planEnabled ? "react" : "code_orchestrated")
}
@@ -46,7 +50,7 @@ export const InputbarExecutionStrategySelect: React.FC<
<MetaToggleGlyph aria-hidden>
<ListChecks strokeWidth={1.8} />
</MetaToggleGlyph>
<MetaToggleLabel>计划执行</MetaToggleLabel>
<MetaToggleLabel>{copy.label}</MetaToggleLabel>
</MetaToggleButton>
);
};
@@ -6,6 +6,7 @@ import type {
WorkflowQuickAction,
WorkflowStep,
} from "../../../utils/workflowInputState";
import type { InputbarWorkflowPanelCopy } from "../inputbarWorkflowCopy";
interface InputbarWorkflowStatusPanelProps {
gate?: WorkflowGateState | null;
@@ -20,6 +21,7 @@ interface InputbarWorkflowStatusPanelProps {
renderGeneratingPanel: boolean;
onQuickAction: (prompt: string) => void;
onStop?: () => void;
copy: InputbarWorkflowPanelCopy;
}
type WorkflowTone = "active" | "pending" | "error";
@@ -302,14 +304,17 @@ function resolveWorkflowTone(
return "active";
}
function getStatusLabel(tone: WorkflowTone): string {
function getStatusLabel(
tone: WorkflowTone,
copy: InputbarWorkflowPanelCopy,
): string {
if (tone === "error") {
return "异常";
return copy.status.error;
}
if (tone === "pending") {
return "待处理";
return copy.status.pending;
}
return "进行中";
return copy.status.active;
}
function renderToneIcon(tone: WorkflowTone) {
@@ -335,6 +340,7 @@ export function InputbarWorkflowStatusPanel({
renderGeneratingPanel,
onQuickAction,
onStop,
copy,
}: InputbarWorkflowStatusPanelProps) {
const [queueCollapsed, setQueueCollapsed] = useState(false);
@@ -346,12 +352,12 @@ export function InputbarWorkflowStatusPanel({
? resolveWorkflowTone(activeItem?.status)
: resolveWorkflowTone(gate?.status);
const summaryTitle = renderGeneratingPanel
? activeItem?.title || "正在编排任务节点"
: gate?.title || "等待继续";
? activeItem?.title || copy.summary.defaultActiveTitle
: gate?.title || copy.summary.defaultGateTitle;
const queueHiddenCount = Math.max(queueTotalCount - queueItems.length, 0);
const queueHintLabel =
queueHiddenCount > 0
? `${progressLabel} · 另有 ${queueHiddenCount} 项收纳`
? copy.queue.hiddenSuffix(progressLabel, queueHiddenCount)
: progressLabel;
return (
@@ -362,7 +368,7 @@ export function InputbarWorkflowStatusPanel({
{renderToneIcon(summaryTone)}
</SummaryIcon>
<SummaryBody>
<SummaryEyebrow>当前进展</SummaryEyebrow>
<SummaryEyebrow>{copy.summary.currentProgress}</SummaryEyebrow>
<SummaryTitle data-testid="workflow-current-title">
{summaryTitle}
</SummaryTitle>
@@ -371,19 +377,19 @@ export function InputbarWorkflowStatusPanel({
<SummaryAside>
<SummaryStatus $tone={summaryTone}>
{renderGeneratingPanel
? getStatusLabel(summaryTone)
? getStatusLabel(summaryTone, copy)
: gate?.status === "waiting"
? "等待决策"
? copy.status.waitingDecision
: gate?.status === "running"
? "自动执行中"
: "待启动"}
? copy.status.autoRunning
: copy.status.ready}
</SummaryStatus>
{renderGeneratingPanel ? (
<StopButton
type="button"
data-testid="workflow-stop"
onClick={() => onStop?.()}
aria-label="停止生成"
aria-label={copy.action.stopGeneration}
>
<StopGlyph />
</StopButton>
@@ -396,9 +402,11 @@ export function InputbarWorkflowStatusPanel({
<QueueToggleButton
type="button"
onClick={() => setQueueCollapsed((previous) => !previous)}
aria-label={queueCollapsed ? "展开任务队列" : "折叠任务队列"}
aria-label={
queueCollapsed ? copy.queue.expand : copy.queue.collapse
}
>
<QueueToggleLabel>任务队列</QueueToggleLabel>
<QueueToggleLabel>{copy.queue.title}</QueueToggleLabel>
<QueueCount>{queueTotalCount}</QueueCount>
<QueueHint>{queueHintLabel}</QueueHint>
<QueueChevron $collapsed={queueCollapsed}>
@@ -413,8 +421,8 @@ export function InputbarWorkflowStatusPanel({
<Loader2 size={13} className="animate-spin" />
</TaskIcon>
<TaskBody>
<TaskTitle>正在编排任务节点...</TaskTitle>
<TaskMeta>进行中</TaskMeta>
<TaskTitle>{copy.queue.placeholderTitle}</TaskTitle>
<TaskMeta>{copy.status.active}</TaskMeta>
</TaskBody>
</TaskRow>
) : (
@@ -430,10 +438,13 @@ export function InputbarWorkflowStatusPanel({
<TaskBody>
<TaskTitle>{item.title}</TaskTitle>
<TaskMeta>
{getStatusLabel(tone)}
{totalCount > 0
? ` · ${completedCount}/${totalCount}`
: ""}
? copy.queue.itemMetaWithProgress(
getStatusLabel(tone, copy),
completedCount,
totalCount,
)
: getStatusLabel(tone, copy)}
</TaskMeta>
</TaskBody>
</TaskRow>
@@ -0,0 +1,74 @@
import { act, type ComponentProps } from "react";
import { createRoot, type Root } from "react-dom/client";
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
import { changeLimeLocale } from "@/i18n/createI18n";
import {
createTeamDefinitionFromPreset,
type TeamDefinition,
} from "../../../utils/teamDefinitions";
import { TeamSelector } from "./TeamSelector";
const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = [];
function renderTeamSelector(
props?: Partial<ComponentProps<typeof TeamSelector>>,
) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
const defaultProps: ComponentProps<typeof TeamSelector> = {
onSelectTeam: vi.fn(),
};
act(() => {
root.render(<TeamSelector {...defaultProps} {...props} />);
});
mountedRoots.push({ root, container });
return container;
}
describe("TeamSelector", () => {
beforeEach(async () => {
(
globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
).IS_REACT_ACT_ENVIRONMENT = true;
await changeLimeLocale("zh-CN");
});
afterEach(() => {
while (mountedRoots.length > 0) {
const mounted = mountedRoots.pop();
if (!mounted) {
break;
}
act(() => {
mounted.root.unmount();
});
mounted.container.remove();
}
vi.clearAllMocks();
});
it("触发按钮 chrome 文案应跟随 en-US 资源", async () => {
await changeLimeLocale("en-US");
const selectedTeam = createTeamDefinitionFromPreset(
"code-triage-team",
) as TeamDefinition;
const container = renderTeamSelector({ selectedTeam });
expect(
container.querySelector('[data-testid="team-selector-trigger"]')
?.textContent,
).toContain("Team · 代码排障团队");
expect(container.textContent).not.toContain("分工 ·");
});
it("未选择分工时应展示 zh-CN 默认入口", async () => {
const container = renderTeamSelector();
expect(container.textContent).toContain("配置分工");
});
});
@@ -1,10 +1,12 @@
import React, { Suspense, lazy, useState } from "react";
import { useTranslation } from "react-i18next";
import { Users } from "lucide-react";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import type { WorkspaceSettings } from "@/types/workspace";
import type { TeamDefinition } from "../../../utils/teamDefinitions";
import { useIdleModulePreload } from "../../../skill-selection/useIdleModulePreload";
import { buildInputbarTeamSelectorCopy } from "./inputbarTeamSelectorCopy";
const preloadTeamSelectorPanel = () => import("./TeamSelectorPanel");
@@ -32,6 +34,11 @@ export const TeamSelector: React.FC<TeamSelectorProps> = ({
workspaceSettings,
onPersistCustomTeams,
}) => {
const { t } = useTranslation("agent");
const copy = React.useMemo(
() => buildInputbarTeamSelectorCopy((key, values) => t(key, values ?? {})),
[t],
);
const [open, setOpen] = useState(false);
useIdleModulePreload(() => {
@@ -46,8 +53,8 @@ export const TeamSelector: React.FC<TeamSelectorProps> = ({
}, [autoOpenToken]);
const resolvedLabel = selectedTeam?.label?.trim()
? `分工 · ${selectedTeam.label.trim()}`
: "配置分工";
? copy.triggerSelected(selectedTeam.label.trim())
: copy.triggerDefault;
const selectedRoleCount = selectedTeam?.roles.length || 0;
@@ -81,7 +88,7 @@ export const TeamSelector: React.FC<TeamSelectorProps> = ({
<Suspense
fallback={
<div className="px-4 py-7 text-center text-sm text-slate-500">
加载中...
{copy.loading}
</div>
}
>
@@ -2,6 +2,7 @@ import { act, type ComponentProps } from "react";
import { createRoot, type Root } from "react-dom/client";
import type { WorkspaceSettings } from "@/types/workspace";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { changeLimeLocale } from "@/i18n/createI18n";
import { TeamSelectorPanel } from "./TeamSelectorPanel";
import {
createTeamDefinitionFromPreset,
@@ -70,12 +71,13 @@ function setInputValue(
}
describe("TeamSelectorPanel", () => {
beforeEach(() => {
beforeEach(async () => {
(
globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
).IS_REACT_ACT_ENVIRONMENT = true;
await changeLimeLocale("zh-CN");
localStorage.clear();
});
@@ -103,6 +105,60 @@ describe("TeamSelectorPanel", () => {
expect(container.textContent).not.toContain("模型生成分工");
});
it("TeamSelectorPanel chrome 文案应跟随 en-US 资源", async () => {
await changeLimeLocale("en-US");
const selectedTeam = createTeamDefinitionFromPreset(
"code-triage-team",
) as TeamDefinition;
const { container } = renderPanel({
activeTheme: "general",
selectedTeam,
input:
"请帮我分析这个前端问题,给出实现方案,完成修复,补测试并最终汇总结论。",
});
await flushEffects();
expect(container.textContent).toContain("Task team setup");
expect(container.textContent).toContain("Selected team");
expect(container.textContent).toContain("Create custom team");
expect(container.textContent).toContain("Recommended team");
expect(container.textContent).toContain("My teams");
expect(container.textContent).toContain("System templates");
expect(container.textContent).toContain("Team overview");
expect(container.textContent).toContain("Role split");
expect(container.textContent).toContain("Roles:");
expect(container.textContent).not.toContain("任务分工配置");
const createButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("Create custom team"),
);
expect(createButton).toBeTruthy();
act(() => {
createButton?.click();
});
await flushEffects();
expect(container.textContent).toContain("Create custom team");
expect(container.textContent).toContain("Team name");
expect(container.textContent).toContain("Add role");
expect(container.textContent).toContain("Save team");
expect(
container.querySelector<HTMLInputElement>(
'input[placeholder="Example: Frontend integration team"]',
),
).toBeTruthy();
expect(
container.querySelector<HTMLTextAreaElement>(
'textarea[placeholder="Describe which tasks this team is best for."]',
),
).toBeTruthy();
expect(mockToast.success).not.toHaveBeenCalled();
});
it("应保存自定义分工的 profileId、roleKey 与 skillIds", async () => {
const onSelectTeam = vi.fn();
const selectedTeam = createTeamDefinitionFromPreset(
@@ -1,4 +1,5 @@
import React, { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Check,
ChevronDown,
@@ -24,7 +25,6 @@ import {
getBuiltinTeamSkillOption,
} from "../../../utils/teamPresets";
import {
buildTeamDefinitionSummary,
cloneTeamDefinitionAsCustom,
createTeamDefinitionFromPreset,
listBuiltinTeamDefinitions,
@@ -37,6 +37,10 @@ import {
resolveCustomTeams,
saveCustomTeams,
} from "../../../utils/teamStorage";
import {
buildInputbarTeamSelectorCopy,
type InputbarTeamSelectorCopy,
} from "./inputbarTeamSelectorCopy";
interface TeamSelectorPanelProps {
activeTheme?: string;
@@ -60,7 +64,10 @@ interface TeamDraft {
roles: TeamRoleDefinition[];
}
function createBlankDraft(theme?: string): TeamDraft {
function createBlankDraft(
copy: InputbarTeamSelectorCopy,
theme?: string,
): TeamDraft {
return {
label: "",
description: "",
@@ -69,18 +76,37 @@ function createBlankDraft(theme?: string): TeamDraft {
roles: [
{
id: "planner",
label: "分析",
summary: "负责拆解任务、澄清边界与输出第一轮事实。",
label: copy.defaultRole.plannerLabel,
summary: copy.defaultRole.plannerSummary,
},
{
id: "executor",
label: "执行",
summary: "负责在明确范围内推进实现或产出草稿。",
label: copy.defaultRole.executorLabel,
summary: copy.defaultRole.executorSummary,
},
],
};
}
function formatTeamDefinitionSummary(
team: TeamDefinition | null | undefined,
copy: InputbarTeamSelectorCopy,
): string {
if (!team) {
return "";
}
const roleSummary = team.roles
.map((role) => `${role.label}: ${role.summary}`)
.join(copy.summary.roleSeparator);
const description = team.description.trim();
if (description && roleSummary) {
return `${description} ${copy.summary.rolesPrefix}${roleSummary}`;
}
return description || roleSummary;
}
function buildDraftFromTeam(team: TeamDefinition): TeamDraft {
return {
id: team.source === "custom" ? team.id : undefined,
@@ -138,6 +164,7 @@ function TeamCard({
onCopy,
onEdit,
onDelete,
copy,
}: {
team: TeamDefinition;
selected: boolean;
@@ -149,6 +176,7 @@ function TeamCard({
onCopy?: () => void;
onEdit?: () => void;
onDelete?: () => void;
copy: InputbarTeamSelectorCopy;
}) {
return (
<div
@@ -178,7 +206,7 @@ function TeamCard({
{selected ? (
<span className="inline-flex items-center gap-1 rounded-full border border-sky-200 bg-white px-2 py-0.5 text-[11px] font-medium text-sky-700">
<Check className="h-3.5 w-3.5" />
{selectedLabel || "当前选择"}
{selectedLabel || copy.card.selected}
</span>
) : null}
</div>
@@ -197,9 +225,11 @@ function TeamCard({
</div>
{expanded ? (
<div className="mt-3 rounded-2xl border border-slate-200 bg-white px-3 py-2.5 text-xs leading-5 text-slate-600">
<div className="font-medium text-slate-800">详细分工</div>
<div className="font-medium text-slate-800">
{copy.card.detailTitle}
</div>
<div className="mt-1 break-words whitespace-normal">
{buildTeamDefinitionSummary(team)}
{formatTeamDefinitionSummary(team, copy)}
</div>
<div className="mt-2 space-y-1.5">
{team.roles.map((role) => (
@@ -212,7 +242,7 @@ function TeamCard({
<div className="mt-1 flex flex-wrap gap-1.5">
{role.profileId ? (
<span className="rounded-full border border-slate-200 bg-slate-50 px-2 py-0.5 text-[11px] text-slate-500">
画像 ·{" "}
{copy.card.profilePrefix} ·{" "}
{getBuiltinTeamProfileOption(role.profileId)
?.label || role.profileId}
</span>
@@ -227,7 +257,7 @@ function TeamCard({
key={`${team.id}-${role.id}-${skillId}`}
className="rounded-full border border-slate-200 bg-slate-50 px-2 py-0.5 text-[11px] text-slate-500"
>
技能 ·{" "}
{copy.card.skillPrefix} ·{" "}
{getBuiltinTeamSkillOption(skillId)?.label ||
skillId}
</span>
@@ -246,8 +276,10 @@ function TeamCard({
type="button"
className="inline-flex h-8 w-8 items-center justify-center rounded-full text-slate-400 transition hover:bg-slate-100 hover:text-slate-700"
onClick={onToggleDetail}
title={expanded ? "收起详情" : "查看详情"}
aria-label={expanded ? "收起详情" : "查看详情"}
title={expanded ? copy.card.collapseDetail : copy.card.viewDetail}
aria-label={
expanded ? copy.card.collapseDetail : copy.card.viewDetail
}
>
{expanded ? (
<ChevronUp className="h-4 w-4" />
@@ -261,8 +293,8 @@ function TeamCard({
type="button"
className="inline-flex h-8 w-8 items-center justify-center rounded-full text-slate-400 transition hover:bg-slate-100 hover:text-slate-700"
onClick={onCopy}
title="复制为自定义分工"
aria-label="复制为自定义分工"
title={copy.card.copyCustom}
aria-label={copy.card.copyCustom}
>
<Copy className="h-4 w-4" />
</button>
@@ -272,8 +304,8 @@ function TeamCard({
type="button"
className="inline-flex h-8 w-8 items-center justify-center rounded-full text-slate-400 transition hover:bg-slate-100 hover:text-slate-700"
onClick={onEdit}
title="编辑分工"
aria-label="编辑分工"
title={copy.card.edit}
aria-label={copy.card.edit}
>
<Pencil className="h-4 w-4" />
</button>
@@ -283,8 +315,8 @@ function TeamCard({
type="button"
className="inline-flex h-8 w-8 items-center justify-center rounded-full text-slate-400 transition hover:bg-rose-50 hover:text-rose-600"
onClick={onDelete}
title="删除分工"
aria-label="删除分工"
title={copy.card.delete}
aria-label={copy.card.delete}
>
<Trash2 className="h-4 w-4" />
</button>
@@ -304,6 +336,11 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
onPersistCustomTeams,
onClose,
}) => {
const { t } = useTranslation("agent");
const copy = useMemo(
() => buildInputbarTeamSelectorCopy((key, values) => t(key, values ?? {})),
[t],
);
const [query, setQuery] = useState("");
const [customTeams, setCustomTeams] = useState<TeamDefinition[]>([]);
const [draft, setDraft] = useState<TeamDraft | null>(null);
@@ -347,7 +384,10 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
[customTeams, query],
);
const currentSelectionSummary = buildTeamDefinitionSummary(selectedTeam);
const currentSelectionSummary = formatTeamDefinitionSummary(
selectedTeam,
copy,
);
const updateDraftRole = (
roleIndex: number,
@@ -382,8 +422,15 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
const handleStartCreate = (base?: TeamDefinition | null) => {
setDraft(
base
? buildDraftFromTeam(cloneTeamDefinitionAsCustom(base))
: createBlankDraft(activeTheme),
? buildDraftFromTeam(
cloneTeamDefinitionAsCustom(base, {
label:
base.source === "builtin"
? copy.editor.customCloneLabel(base.label)
: base.label,
}),
)
: createBlankDraft(copy, activeTheme),
);
};
@@ -415,7 +462,7 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
});
if (!normalized) {
toast.error("请至少填写分工名称和 1 个角色");
toast.error(copy.toast.invalidDraft);
return;
}
@@ -440,12 +487,14 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
onClose?.();
toast.success(
isProjectScopedCustomTeam
? `已将分工「${nextTeam.label}」保存到当前项目`
: `已保存分工「${nextTeam.label}」`,
? copy.toast.saveProjectSuccess(nextTeam.label)
: copy.toast.saveLocalSuccess(nextTeam.label),
);
} catch (error) {
toast.error(
`保存分工失败:${error instanceof Error ? error.message : String(error)}`,
copy.toast.saveFailed(
error instanceof Error ? error.message : String(error),
),
);
}
};
@@ -464,12 +513,14 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
}
toast.success(
isProjectScopedCustomTeam
? `已从当前项目删除分工「${team.label}」`
: `已删除分工「${team.label}」`,
? copy.toast.deleteProjectSuccess(team.label)
: copy.toast.deleteLocalSuccess(team.label),
);
} catch (error) {
toast.error(
`删除分工失败:${error instanceof Error ? error.message : String(error)}`,
copy.toast.deleteFailed(
error instanceof Error ? error.message : String(error),
),
);
}
};
@@ -526,8 +577,7 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
if (!inspectorTeam) {
return (
<div className="rounded-3xl border border-dashed border-slate-200 bg-white px-5 py-6 text-sm text-slate-500">
还没有可预览的分工。你可以从左侧模板中查看详情,或新建一个自定义
分工。
{copy.emptyInspector}
</div>
);
}
@@ -536,10 +586,10 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
const isCustom = inspectorTeam.source === "custom";
const badgeLabel =
inspectorTeam.id === recommendedTeam?.id
? "按任务推荐"
? copy.badge.recommended
: isCustom
? "自定义"
: "系统模板";
? copy.badge.custom
: copy.badge.systemTemplate;
return (
<div
@@ -558,13 +608,12 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
{isCurrent ? (
<span className="inline-flex items-center gap-1 rounded-full border border-sky-200 bg-sky-50 px-2 py-0.5 text-[11px] font-medium text-sky-700">
<Check className="h-3.5 w-3.5" />
当前选择
{copy.inspector.current}
</span>
) : null}
</div>
<div className="mt-2 text-sm leading-6 text-slate-600">
{inspectorTeam.description ||
"这套分工会作为主代理拆分子任务时的分工参考。"}
{inspectorTeam.description || copy.inspector.defaultDescription}
</div>
</div>
<div className="flex flex-wrap gap-2">
@@ -574,7 +623,7 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
className={TEAM_SELECTOR_PRIMARY_BUTTON_CLASSNAME}
onClick={() => handleSelect(inspectorTeam)}
>
采用这套分工
{copy.inspector.select}
</Button>
) : null}
<Button
@@ -584,7 +633,7 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
onClick={() => handleStartCreate(inspectorTeam)}
>
<Copy className="mr-1.5 h-4 w-4" />
复制为自定义
{copy.inspector.copyCustom}
</Button>
{isCustom ? (
<Button
@@ -594,7 +643,7 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
onClick={() => handleStartEdit(inspectorTeam)}
>
<Pencil className="mr-1.5 h-4 w-4" />
编辑
{copy.inspector.edit}
</Button>
) : null}
</div>
@@ -602,16 +651,16 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
<div className="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3">
<div className="text-xs font-semibold tracking-[0.08em] text-slate-500">
分工概览
{copy.inspector.summaryTitle}
</div>
<div className="mt-2 text-sm leading-6 text-slate-700">
{buildTeamDefinitionSummary(inspectorTeam)}
{formatTeamDefinitionSummary(inspectorTeam, copy)}
</div>
</div>
<div className="space-y-3">
<div className="text-xs font-semibold tracking-[0.08em] text-slate-500">
角色分工
{copy.inspector.rolesTitle}
</div>
<div className="grid gap-3 xl:grid-cols-2">
{inspectorTeam.roles.map((role) => (
@@ -625,7 +674,7 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
</span>
{role.profileId ? (
<span className="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[11px] text-slate-500">
画像 ·{" "}
{copy.card.profilePrefix} ·{" "}
{getBuiltinTeamProfileOption(role.profileId)?.label ||
role.profileId}
</span>
@@ -646,7 +695,7 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
key={`${inspectorTeam.id}-${role.id}-${skillId}`}
className="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[11px] text-slate-500"
>
技能 ·{" "}
{copy.card.skillPrefix} ·{" "}
{getBuiltinTeamSkillOption(skillId)?.label || skillId}
</span>
))}
@@ -668,7 +717,7 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
}}
>
<Trash2 className="mr-1.5 h-4 w-4" />
删除这套分工
{copy.inspector.delete}
</Button>
</div>
) : null}
@@ -689,17 +738,17 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-base font-semibold text-slate-900">
{draft.id ? "编辑自定义分工" : "新建自定义分工"}
{draft.id ? copy.editor.editTitle : copy.editor.createTitle}
</div>
<div className="mt-1 text-sm text-slate-500">
在右侧完整配置分工角色、画像和技能,左侧列表用于浏览与切换模板。
{copy.editor.description}
</div>
</div>
<button
type="button"
className="inline-flex h-9 w-9 items-center justify-center rounded-full text-slate-400 transition hover:bg-slate-100 hover:text-slate-700"
onClick={() => setDraft(null)}
aria-label="关闭编辑器"
aria-label={copy.editor.close}
>
<X className="h-4 w-4" />
</button>
@@ -708,7 +757,7 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
<div className="grid gap-3 md:grid-cols-2">
<div className="grid gap-2">
<label className="text-xs font-medium text-slate-600">
分工名称
{copy.editor.teamName}
</label>
<Input
value={draft.label}
@@ -722,14 +771,14 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
: current,
)
}
placeholder="例如:前端联调团队"
placeholder={copy.editor.teamNamePlaceholder}
className="border-slate-200 bg-white"
/>
</div>
<div className="grid gap-2 md:col-span-2">
<label className="text-xs font-medium text-slate-600">
分工说明
{copy.editor.teamDescription}
</label>
<Textarea
value={draft.description}
@@ -743,7 +792,7 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
: current,
)
}
placeholder="说明这套分工适合什么类型任务。"
placeholder={copy.editor.teamDescriptionPlaceholder}
className="min-h-[88px] border-slate-200 bg-white"
/>
</div>
@@ -752,7 +801,7 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<label className="text-xs font-medium text-slate-600">
角色分工
{copy.editor.rolesTitle}
</label>
<Button
type="button"
@@ -779,7 +828,7 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
}
>
<Plus className="mr-1.5 h-3.5 w-3.5" />
添加角色
{copy.editor.addRole}
</Button>
</div>
@@ -800,7 +849,7 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
<>
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-xs font-medium text-slate-500">
角色 {index + 1}
{copy.editor.roleIndex(index + 1)}
</div>
<button
type="button"
@@ -817,7 +866,7 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
: current,
)
}
aria-label={`移除角色 ${index + 1}`}
aria-label={copy.editor.removeRole(index + 1)}
>
<X className="h-4 w-4" />
</button>
@@ -831,7 +880,7 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
label: event.target.value,
}))
}
placeholder="角色名称,例如:分析"
placeholder={copy.editor.roleNamePlaceholder}
className="border-slate-200 bg-white"
/>
<Textarea
@@ -842,13 +891,13 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
summary: event.target.value,
}))
}
placeholder="说明这个角色负责什么。"
placeholder={copy.editor.roleSummaryPlaceholder}
className="min-h-[84px] border-slate-200 bg-white"
/>
<div className="grid gap-3 xl:grid-cols-2">
<div className="grid gap-2">
<label className="text-xs font-medium text-slate-600">
内置画像
{copy.editor.profileLabel}
</label>
<select
value={role.profileId || ""}
@@ -875,7 +924,9 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
data-testid={`team-role-profile-select-${index}`}
className="h-10 rounded-xl border border-slate-200 bg-white px-3 text-sm text-slate-700 outline-none transition focus:border-slate-300"
>
<option value="">不指定内置画像</option>
<option value="">
{copy.editor.profileNone}
</option>
{BUILTIN_TEAM_PROFILE_OPTIONS.map((option) => (
<option key={option.id} value={option.id}>
{option.label} · {option.id}
@@ -891,7 +942,7 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
<span> · {selectedProfile.description}</span>
</>
) : (
"可选内置 subagent profile,用于对齐 Codex 风格的角色画像。"
copy.editor.profileHelp
)}
</div>
</div>
@@ -909,11 +960,11 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
}))
}
data-testid={`team-role-role-key-input-${index}`}
placeholder="例如:explorer / executor / reviewer"
placeholder={copy.editor.roleKeyPlaceholder}
className="border-slate-200 bg-white"
/>
<div className="rounded-xl border border-slate-200 bg-white px-3 py-2 text-[11px] leading-5 text-slate-500">
用于运行时和工作台标记角色职责;建议与所选画像保持一致。
{copy.editor.roleKeyHelp}
</div>
</div>
</div>
@@ -959,14 +1010,18 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
}))
}
data-testid={`team-role-skill-ids-input-${index}`}
placeholder="多个 skill id 用逗号分隔,例如:source-grounding, structured-writing"
placeholder={copy.editor.skillIdsPlaceholder}
className="border-slate-200 bg-white"
/>
<div className="rounded-xl border border-slate-200 bg-white px-3 py-2 text-[11px] leading-5 text-slate-500">
{selectedProfile && suggestedSkillIds.length > 0 ? (
<>推荐技能:{suggestedSkillIds.join("、")}</>
<>
{copy.editor.recommendedSkills(
suggestedSkillIds.join(", "),
)}
</>
) : (
"skillIds 会透传给运行时,用于约束子任务可用的技能集。"
copy.editor.skillIdsHelp
)}
</div>
</div>
@@ -986,7 +1041,7 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
className="border-slate-200 bg-white text-slate-600 hover:bg-slate-50"
onClick={() => setDraft(null)}
>
取消
{copy.editor.cancel}
</Button>
<Button
type="button"
@@ -995,7 +1050,7 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
void handleSaveDraft();
}}
>
保存分工
{copy.editor.save}
</Button>
</div>
</section>
@@ -1011,15 +1066,15 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-[11px] font-semibold tracking-[0.08em] text-slate-500">
任务分工配置
{copy.header.eyebrow}
</div>
<div className="mt-1 text-sm text-slate-700">
只在当前任务适合拆分推进时,为当前任务提供任务分工结构参考。
{copy.header.description}
</div>
<div className="mt-2 text-xs text-slate-500">
{isProjectScopedCustomTeam
? "自定义分工会保存到当前项目,便于项目级复用与评审。"
: "自定义分工当前保存在本地设备,用于快速测试与个人偏好。"}
? copy.header.projectScope
: copy.header.localScope}
</div>
</div>
{selectedTeam ? (
@@ -1027,8 +1082,8 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
type="button"
className="inline-flex h-8 w-8 items-center justify-center rounded-full text-slate-400 transition hover:bg-slate-100 hover:text-slate-700"
onClick={handleClearSelection}
title="清除分工选择"
aria-label="清除分工选择"
title={copy.header.clear}
aria-label={copy.header.clear}
>
<X className="h-4 w-4" />
</button>
@@ -1041,7 +1096,7 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
>
<div className="flex items-center gap-2 text-xs font-medium text-slate-500">
<Users className="h-3.5 w-3.5" />
当前已选分工
{copy.currentTitle}
</div>
<div className="mt-1 text-sm font-semibold text-slate-900">
{selectedTeam.label}
@@ -1061,7 +1116,7 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="搜索分工、角色或职责"
placeholder={copy.searchPlaceholder}
className="border-slate-200 bg-white"
/>
<div className="flex flex-wrap gap-2">
@@ -1075,7 +1130,7 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
}
>
<Plus className="mr-1.5 h-4 w-4" />
新建自定义分工
{copy.createCustom}
</Button>
{selectedTeam ? (
<Button
@@ -1084,7 +1139,7 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
className="border-slate-200 bg-white text-slate-600 hover:bg-slate-50"
onClick={handleClearSelection}
>
清除当前选择
{copy.clearCurrent}
</Button>
) : null}
</div>
@@ -1093,14 +1148,15 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
<section className="space-y-2">
<div className="flex items-center gap-2 text-[11px] font-semibold tracking-[0.08em] text-slate-500">
<Sparkles className="h-3.5 w-3.5" />
推荐分工
{copy.recommendedSection}
</div>
<TeamCard
team={recommendedTeam}
selected={recommendedSelected}
expanded={expandedTeamId === recommendedTeam.id}
selectedLabel="已采用推荐"
badgeLabel="按任务推荐"
selectedLabel={copy.recommendedSelected}
badgeLabel={copy.badge.recommended}
copy={copy}
onSelect={() => handleSelect(recommendedTeam)}
onToggleDetail={() =>
setExpandedTeamId((currentId) =>
@@ -1117,7 +1173,9 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
<section className="space-y-2">
<div className="flex items-center justify-between gap-2">
<div className="text-[11px] font-semibold tracking-[0.08em] text-slate-500">
{isProjectScopedCustomTeam ? "当前项目分工" : "我的分工"}
{isProjectScopedCustomTeam
? copy.customSectionProject
: copy.customSectionLocal}
</div>
<Button
type="button"
@@ -1134,7 +1192,7 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
}
>
<Plus className="mr-1.5 h-3.5 w-3.5" />
新建自定义
{copy.createShort}
</Button>
</div>
{filteredCustomTeams.length > 0 ? (
@@ -1145,7 +1203,8 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
team={team}
selected={selectedTeam?.id === team.id}
expanded={expandedTeamId === team.id}
badgeLabel="自定义"
badgeLabel={copy.badge.custom}
copy={copy}
onSelect={() => handleSelect(team)}
onToggleDetail={() =>
setExpandedTeamId((currentId) =>
@@ -1164,8 +1223,8 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
<div className="rounded-2xl border border-dashed border-slate-200 bg-slate-50 px-3 py-4 text-sm text-slate-500">
<div>
{isProjectScopedCustomTeam
? "当前项目还没有自定义分工。可以从推荐方案或系统模板复制一份后再改。"
: "还没有自定义分工。可以从推荐方案或系统模板复制一份后再改。"}
? copy.emptyCustomProject
: copy.emptyCustomLocal}
</div>
<Button
type="button"
@@ -1182,7 +1241,7 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
}
>
<Plus className="mr-1.5 h-3.5 w-3.5" />
立即创建
{copy.createNow}
</Button>
</div>
)}
@@ -1190,7 +1249,7 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
<section className="space-y-2">
<div className="text-[11px] font-semibold tracking-[0.08em] text-slate-500">
系统模板
{copy.systemSection}
</div>
<div className="space-y-2">
{builtinTeams.map((team) => (
@@ -1199,7 +1258,8 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
team={team}
selected={selectedTeam?.id === team.id}
expanded={expandedTeamId === team.id}
badgeLabel="系统"
badgeLabel={copy.badge.system}
copy={copy}
onSelect={() => handleSelect(team)}
onToggleDetail={() =>
setExpandedTeamId((currentId) =>
@@ -0,0 +1,417 @@
export type InputbarTeamSelectorCopyKey =
| "agentChat.inputbar.teamSelector.trigger.selected"
| "agentChat.inputbar.teamSelector.trigger.default"
| "agentChat.inputbar.teamSelector.loading"
| "agentChat.inputbar.teamSelector.defaultRole.planner.label"
| "agentChat.inputbar.teamSelector.defaultRole.planner.summary"
| "agentChat.inputbar.teamSelector.defaultRole.executor.label"
| "agentChat.inputbar.teamSelector.defaultRole.executor.summary"
| "agentChat.inputbar.teamSelector.card.selected"
| "agentChat.inputbar.teamSelector.card.detailTitle"
| "agentChat.inputbar.teamSelector.card.profilePrefix"
| "agentChat.inputbar.teamSelector.card.skillPrefix"
| "agentChat.inputbar.teamSelector.card.viewDetail"
| "agentChat.inputbar.teamSelector.card.collapseDetail"
| "agentChat.inputbar.teamSelector.card.copyCustom"
| "agentChat.inputbar.teamSelector.card.edit"
| "agentChat.inputbar.teamSelector.card.delete"
| "agentChat.inputbar.teamSelector.summary.rolesPrefix"
| "agentChat.inputbar.teamSelector.summary.roleSeparator"
| "agentChat.inputbar.teamSelector.toast.invalidDraft"
| "agentChat.inputbar.teamSelector.toast.saveProjectSuccess"
| "agentChat.inputbar.teamSelector.toast.saveLocalSuccess"
| "agentChat.inputbar.teamSelector.toast.saveFailed"
| "agentChat.inputbar.teamSelector.toast.deleteProjectSuccess"
| "agentChat.inputbar.teamSelector.toast.deleteLocalSuccess"
| "agentChat.inputbar.teamSelector.toast.deleteFailed"
| "agentChat.inputbar.teamSelector.emptyInspector"
| "agentChat.inputbar.teamSelector.badge.recommended"
| "agentChat.inputbar.teamSelector.badge.custom"
| "agentChat.inputbar.teamSelector.badge.systemTemplate"
| "agentChat.inputbar.teamSelector.badge.system"
| "agentChat.inputbar.teamSelector.inspector.current"
| "agentChat.inputbar.teamSelector.inspector.defaultDescription"
| "agentChat.inputbar.teamSelector.inspector.select"
| "agentChat.inputbar.teamSelector.inspector.copyCustom"
| "agentChat.inputbar.teamSelector.inspector.edit"
| "agentChat.inputbar.teamSelector.inspector.summaryTitle"
| "agentChat.inputbar.teamSelector.inspector.rolesTitle"
| "agentChat.inputbar.teamSelector.inspector.delete"
| "agentChat.inputbar.teamSelector.editor.title.edit"
| "agentChat.inputbar.teamSelector.editor.title.create"
| "agentChat.inputbar.teamSelector.editor.description"
| "agentChat.inputbar.teamSelector.editor.close"
| "agentChat.inputbar.teamSelector.editor.teamName"
| "agentChat.inputbar.teamSelector.editor.teamNamePlaceholder"
| "agentChat.inputbar.teamSelector.editor.teamDescription"
| "agentChat.inputbar.teamSelector.editor.teamDescriptionPlaceholder"
| "agentChat.inputbar.teamSelector.editor.rolesTitle"
| "agentChat.inputbar.teamSelector.editor.addRole"
| "agentChat.inputbar.teamSelector.editor.customCloneLabel"
| "agentChat.inputbar.teamSelector.editor.roleIndex"
| "agentChat.inputbar.teamSelector.editor.removeRole"
| "agentChat.inputbar.teamSelector.editor.roleNamePlaceholder"
| "agentChat.inputbar.teamSelector.editor.roleSummaryPlaceholder"
| "agentChat.inputbar.teamSelector.editor.profileLabel"
| "agentChat.inputbar.teamSelector.editor.profileNone"
| "agentChat.inputbar.teamSelector.editor.profileHelp"
| "agentChat.inputbar.teamSelector.editor.roleKeyPlaceholder"
| "agentChat.inputbar.teamSelector.editor.roleKeyHelp"
| "agentChat.inputbar.teamSelector.editor.skillIdsPlaceholder"
| "agentChat.inputbar.teamSelector.editor.recommendedSkills"
| "agentChat.inputbar.teamSelector.editor.skillIdsHelp"
| "agentChat.inputbar.teamSelector.editor.cancel"
| "agentChat.inputbar.teamSelector.editor.save"
| "agentChat.inputbar.teamSelector.header.eyebrow"
| "agentChat.inputbar.teamSelector.header.description"
| "agentChat.inputbar.teamSelector.header.scope.project"
| "agentChat.inputbar.teamSelector.header.scope.local"
| "agentChat.inputbar.teamSelector.header.clear"
| "agentChat.inputbar.teamSelector.current.title"
| "agentChat.inputbar.teamSelector.searchPlaceholder"
| "agentChat.inputbar.teamSelector.action.createCustom"
| "agentChat.inputbar.teamSelector.action.clearCurrent"
| "agentChat.inputbar.teamSelector.section.recommended"
| "agentChat.inputbar.teamSelector.recommended.selected"
| "agentChat.inputbar.teamSelector.section.custom.project"
| "agentChat.inputbar.teamSelector.section.custom.local"
| "agentChat.inputbar.teamSelector.action.createShort"
| "agentChat.inputbar.teamSelector.emptyCustom.project"
| "agentChat.inputbar.teamSelector.emptyCustom.local"
| "agentChat.inputbar.teamSelector.action.createNow"
| "agentChat.inputbar.teamSelector.section.system";
type InputbarTeamSelectorCopyValue = number | string;
export type InputbarTeamSelectorCopyTranslate = (
key: InputbarTeamSelectorCopyKey,
values?: Record<string, InputbarTeamSelectorCopyValue>,
) => string;
export interface InputbarTeamSelectorCopy {
triggerSelected: (label: string) => string;
triggerDefault: string;
loading: string;
defaultRole: {
plannerLabel: string;
plannerSummary: string;
executorLabel: string;
executorSummary: string;
};
card: {
selected: string;
detailTitle: string;
profilePrefix: string;
skillPrefix: string;
viewDetail: string;
collapseDetail: string;
copyCustom: string;
edit: string;
delete: string;
};
summary: {
rolesPrefix: string;
roleSeparator: string;
};
toast: {
invalidDraft: string;
saveProjectSuccess: (label: string) => string;
saveLocalSuccess: (label: string) => string;
saveFailed: (error: string) => string;
deleteProjectSuccess: (label: string) => string;
deleteLocalSuccess: (label: string) => string;
deleteFailed: (error: string) => string;
};
emptyInspector: string;
badge: {
recommended: string;
custom: string;
systemTemplate: string;
system: string;
};
inspector: {
current: string;
defaultDescription: string;
select: string;
copyCustom: string;
edit: string;
summaryTitle: string;
rolesTitle: string;
delete: string;
};
editor: {
editTitle: string;
createTitle: string;
description: string;
close: string;
teamName: string;
teamNamePlaceholder: string;
teamDescription: string;
teamDescriptionPlaceholder: string;
rolesTitle: string;
addRole: string;
customCloneLabel: (label: string) => string;
roleIndex: (index: number) => string;
removeRole: (index: number) => string;
roleNamePlaceholder: string;
roleSummaryPlaceholder: string;
profileLabel: string;
profileNone: string;
profileHelp: string;
roleKeyPlaceholder: string;
roleKeyHelp: string;
skillIdsPlaceholder: string;
recommendedSkills: (skills: string) => string;
skillIdsHelp: string;
cancel: string;
save: string;
};
header: {
eyebrow: string;
description: string;
projectScope: string;
localScope: string;
clear: string;
};
currentTitle: string;
searchPlaceholder: string;
createCustom: string;
clearCurrent: string;
recommendedSection: string;
recommendedSelected: string;
customSectionProject: string;
customSectionLocal: string;
createShort: string;
emptyCustomProject: string;
emptyCustomLocal: string;
createNow: string;
systemSection: string;
}
export function buildInputbarTeamSelectorCopy(
translate: InputbarTeamSelectorCopyTranslate,
): InputbarTeamSelectorCopy {
return {
triggerSelected: (label) =>
translate("agentChat.inputbar.teamSelector.trigger.selected", { label }),
triggerDefault: translate(
"agentChat.inputbar.teamSelector.trigger.default",
),
loading: translate("agentChat.inputbar.teamSelector.loading"),
defaultRole: {
plannerLabel: translate(
"agentChat.inputbar.teamSelector.defaultRole.planner.label",
),
plannerSummary: translate(
"agentChat.inputbar.teamSelector.defaultRole.planner.summary",
),
executorLabel: translate(
"agentChat.inputbar.teamSelector.defaultRole.executor.label",
),
executorSummary: translate(
"agentChat.inputbar.teamSelector.defaultRole.executor.summary",
),
},
card: {
selected: translate("agentChat.inputbar.teamSelector.card.selected"),
detailTitle: translate(
"agentChat.inputbar.teamSelector.card.detailTitle",
),
profilePrefix: translate(
"agentChat.inputbar.teamSelector.card.profilePrefix",
),
skillPrefix: translate(
"agentChat.inputbar.teamSelector.card.skillPrefix",
),
viewDetail: translate("agentChat.inputbar.teamSelector.card.viewDetail"),
collapseDetail: translate(
"agentChat.inputbar.teamSelector.card.collapseDetail",
),
copyCustom: translate("agentChat.inputbar.teamSelector.card.copyCustom"),
edit: translate("agentChat.inputbar.teamSelector.card.edit"),
delete: translate("agentChat.inputbar.teamSelector.card.delete"),
},
summary: {
rolesPrefix: translate(
"agentChat.inputbar.teamSelector.summary.rolesPrefix",
),
roleSeparator: translate(
"agentChat.inputbar.teamSelector.summary.roleSeparator",
),
},
toast: {
invalidDraft: translate(
"agentChat.inputbar.teamSelector.toast.invalidDraft",
),
saveProjectSuccess: (label) =>
translate("agentChat.inputbar.teamSelector.toast.saveProjectSuccess", {
label,
}),
saveLocalSuccess: (label) =>
translate("agentChat.inputbar.teamSelector.toast.saveLocalSuccess", {
label,
}),
saveFailed: (error) =>
translate("agentChat.inputbar.teamSelector.toast.saveFailed", {
error,
}),
deleteProjectSuccess: (label) =>
translate(
"agentChat.inputbar.teamSelector.toast.deleteProjectSuccess",
{ label },
),
deleteLocalSuccess: (label) =>
translate("agentChat.inputbar.teamSelector.toast.deleteLocalSuccess", {
label,
}),
deleteFailed: (error) =>
translate("agentChat.inputbar.teamSelector.toast.deleteFailed", {
error,
}),
},
emptyInspector: translate("agentChat.inputbar.teamSelector.emptyInspector"),
badge: {
recommended: translate(
"agentChat.inputbar.teamSelector.badge.recommended",
),
custom: translate("agentChat.inputbar.teamSelector.badge.custom"),
systemTemplate: translate(
"agentChat.inputbar.teamSelector.badge.systemTemplate",
),
system: translate("agentChat.inputbar.teamSelector.badge.system"),
},
inspector: {
current: translate("agentChat.inputbar.teamSelector.inspector.current"),
defaultDescription: translate(
"agentChat.inputbar.teamSelector.inspector.defaultDescription",
),
select: translate("agentChat.inputbar.teamSelector.inspector.select"),
copyCustom: translate(
"agentChat.inputbar.teamSelector.inspector.copyCustom",
),
edit: translate("agentChat.inputbar.teamSelector.inspector.edit"),
summaryTitle: translate(
"agentChat.inputbar.teamSelector.inspector.summaryTitle",
),
rolesTitle: translate(
"agentChat.inputbar.teamSelector.inspector.rolesTitle",
),
delete: translate("agentChat.inputbar.teamSelector.inspector.delete"),
},
editor: {
editTitle: translate("agentChat.inputbar.teamSelector.editor.title.edit"),
createTitle: translate(
"agentChat.inputbar.teamSelector.editor.title.create",
),
description: translate(
"agentChat.inputbar.teamSelector.editor.description",
),
close: translate("agentChat.inputbar.teamSelector.editor.close"),
teamName: translate("agentChat.inputbar.teamSelector.editor.teamName"),
teamNamePlaceholder: translate(
"agentChat.inputbar.teamSelector.editor.teamNamePlaceholder",
),
teamDescription: translate(
"agentChat.inputbar.teamSelector.editor.teamDescription",
),
teamDescriptionPlaceholder: translate(
"agentChat.inputbar.teamSelector.editor.teamDescriptionPlaceholder",
),
rolesTitle: translate(
"agentChat.inputbar.teamSelector.editor.rolesTitle",
),
addRole: translate("agentChat.inputbar.teamSelector.editor.addRole"),
customCloneLabel: (label) =>
translate("agentChat.inputbar.teamSelector.editor.customCloneLabel", {
label,
}),
roleIndex: (index) =>
translate("agentChat.inputbar.teamSelector.editor.roleIndex", {
index,
}),
removeRole: (index) =>
translate("agentChat.inputbar.teamSelector.editor.removeRole", {
index,
}),
roleNamePlaceholder: translate(
"agentChat.inputbar.teamSelector.editor.roleNamePlaceholder",
),
roleSummaryPlaceholder: translate(
"agentChat.inputbar.teamSelector.editor.roleSummaryPlaceholder",
),
profileLabel: translate(
"agentChat.inputbar.teamSelector.editor.profileLabel",
),
profileNone: translate(
"agentChat.inputbar.teamSelector.editor.profileNone",
),
profileHelp: translate(
"agentChat.inputbar.teamSelector.editor.profileHelp",
),
roleKeyPlaceholder: translate(
"agentChat.inputbar.teamSelector.editor.roleKeyPlaceholder",
),
roleKeyHelp: translate(
"agentChat.inputbar.teamSelector.editor.roleKeyHelp",
),
skillIdsPlaceholder: translate(
"agentChat.inputbar.teamSelector.editor.skillIdsPlaceholder",
),
recommendedSkills: (skills) =>
translate("agentChat.inputbar.teamSelector.editor.recommendedSkills", {
skills,
}),
skillIdsHelp: translate(
"agentChat.inputbar.teamSelector.editor.skillIdsHelp",
),
cancel: translate("agentChat.inputbar.teamSelector.editor.cancel"),
save: translate("agentChat.inputbar.teamSelector.editor.save"),
},
header: {
eyebrow: translate("agentChat.inputbar.teamSelector.header.eyebrow"),
description: translate(
"agentChat.inputbar.teamSelector.header.description",
),
projectScope: translate(
"agentChat.inputbar.teamSelector.header.scope.project",
),
localScope: translate(
"agentChat.inputbar.teamSelector.header.scope.local",
),
clear: translate("agentChat.inputbar.teamSelector.header.clear"),
},
currentTitle: translate("agentChat.inputbar.teamSelector.current.title"),
searchPlaceholder: translate(
"agentChat.inputbar.teamSelector.searchPlaceholder",
),
createCustom: translate(
"agentChat.inputbar.teamSelector.action.createCustom",
),
clearCurrent: translate(
"agentChat.inputbar.teamSelector.action.clearCurrent",
),
recommendedSection: translate(
"agentChat.inputbar.teamSelector.section.recommended",
),
recommendedSelected: translate(
"agentChat.inputbar.teamSelector.recommended.selected",
),
customSectionProject: translate(
"agentChat.inputbar.teamSelector.section.custom.project",
),
customSectionLocal: translate(
"agentChat.inputbar.teamSelector.section.custom.local",
),
createShort: translate(
"agentChat.inputbar.teamSelector.action.createShort",
),
emptyCustomProject: translate(
"agentChat.inputbar.teamSelector.emptyCustom.project",
),
emptyCustomLocal: translate(
"agentChat.inputbar.teamSelector.emptyCustom.local",
),
createNow: translate("agentChat.inputbar.teamSelector.action.createNow"),
systemSection: translate("agentChat.inputbar.teamSelector.section.system"),
};
}
@@ -58,6 +58,7 @@ import {
} from "../../../utils/pathReferences";
import { toast } from "sonner";
import { buildInputbarControllerCopy } from "./inputbarControllerCopy";
import { buildInputbarWorkflowStateCopy } from "../inputbarWorkflowCopy";
interface UseInputbarControllerParams {
input: string;
@@ -141,6 +142,10 @@ export function useInputbarController({
() => buildInputbarControllerCopy((key) => t(key)),
[t],
);
const workflowCopy = useMemo(
() => buildInputbarWorkflowStateCopy((key, values) => t(key, values ?? {})),
[t],
);
const [activeCapability, setActiveCapability] =
useState<InputCapabilitySelection | null>(null);
const [knowledgeHubOpenRequestKey, setKnowledgeHubOpenRequestKey] =
@@ -381,6 +386,7 @@ export function useInputbarController({
workflowSteps,
workflowRunState,
isSending: inputAdapter.state.isSending,
copy: workflowCopy,
});
const [dismissedTeamSuggestionKey, setDismissedTeamSuggestionKey] = useState<
@@ -2680,7 +2680,6 @@ describe("Inputbar", () => {
expect(planToggle).toBeTruthy();
expect(accessSelect).toBeTruthy();
expect(planToggle?.textContent).toContain("计划执行");
expect(planToggle?.textContent).not.toContain("Plan");
expect(planToggle?.getAttribute("aria-label")).toBe("开启计划执行");
expect(planToggle?.getAttribute("title")).toBe("开启计划执行");
expect(container.querySelector('select[aria-label="执行模式"]')).toBeNull();
@@ -2695,6 +2694,37 @@ describe("Inputbar", () => {
expect(setAccessMode).toHaveBeenCalledWith("full-access");
});
it("高级设置计划执行开关文案应跟随 en-US 资源", async () => {
await changeLimeLocale("en-US");
const setExecutionStrategy = vi.fn();
const { container } = renderInputbar({
executionStrategy: "react",
setExecutionStrategy,
accessMode: "current",
setAccessMode: vi.fn(),
});
await act(async () => {
await Promise.resolve();
});
expandAdvancedControls(container);
const planToggle = container.querySelector(
'[data-testid="inputbar-plan-toggle"]',
) as HTMLButtonElement | null;
expect(planToggle).toBeTruthy();
expect(planToggle?.textContent).toContain("Plan");
expect(planToggle?.textContent).not.toContain("计划执行");
expect(planToggle?.getAttribute("aria-label")).toBe(
"Turn on planned execution",
);
expect(planToggle?.getAttribute("title")).toBe(
"Turn on planned execution",
);
});
it("受控模式下点击联网搜索应透传状态变更", async () => {
const onToolStatesChange = vi.fn();
const container = document.createElement("div");
@@ -3088,6 +3118,83 @@ describe("Inputbar", () => {
expect(container.textContent).not.toContain("知识包");
});
it("项目资料控件应在英文界面读取 agent namespace 文案", async () => {
await changeLimeLocale("en-US");
const { container } = renderInputbar({
knowledgePackSelection: {
enabled: true,
packName: "brand-product-demo",
workingDir: "/tmp/lime-project",
label: "Brand knowledge",
status: "ready",
companionPacks: [
{
name: "founder-persona",
activation: "implicit",
},
],
},
knowledgePackOptions: [
{
packName: "brand-product-demo",
label: "Brand knowledge",
status: "ready",
defaultForWorkspace: true,
runtimeMode: "data",
},
{
packName: "founder-persona",
label: "Founder persona",
status: "ready",
runtimeMode: "persona",
},
{
packName: "campaign-plan",
label: "Campaign plan",
status: "ready",
runtimeMode: "data",
},
],
onToggleKnowledgeCompanionPack: vi.fn(),
onStartKnowledgeOrganize: vi.fn(),
onManageKnowledgePacks: vi.fn(),
});
await act(async () => {
await Promise.resolve();
});
const toggle = container.querySelector(
'[data-testid="inputbar-knowledge-pack-toggle"]',
) as HTMLButtonElement | null;
expect(toggle?.textContent).toContain("Knowledge: Brand knowledge +1");
expect(toggle?.getAttribute("aria-label")).toBe("Open project knowledge");
expect(toggle?.getAttribute("title")).toContain(
"Using project knowledge: Brand knowledge",
);
act(() => {
toggle?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
const hub = container.querySelector(
'[data-testid="inputbar-knowledge-hub"]',
);
expect(hub?.textContent).toContain("Using: Brand knowledge");
expect(hub?.textContent).toContain("Main knowledge");
expect(hub?.textContent).toContain("Default");
expect(hub?.textContent).toContain("Reviewed and ready for generation");
expect(hub?.textContent).toContain(
"Persona knowledge added automatically: Founder persona.",
);
expect(hub?.textContent).toContain("Companion knowledge (multi-select)");
expect(hub?.textContent).toContain("Available");
expect(hub?.textContent).toContain("Close knowledge");
expect(hub?.textContent).toContain("Add to knowledge");
expect(hub?.textContent).toContain("Check knowledge");
});
it("待确认资料即使残留启用状态也不应显示为正在使用", async () => {
const { container } = renderInputbar({
knowledgePackSelection: {
@@ -3851,6 +3958,46 @@ describe("Inputbar", () => {
expect(onStop).toHaveBeenCalledTimes(1);
});
it("工作区工作流面板 chrome 文案应跟随 en-US 资源", async () => {
await changeLimeLocale("en-US");
const onStop = vi.fn();
const { container } = renderInputbar({
variant: "workspace",
isLoading: true,
onStop,
workflowSteps: [
{ id: "research", title: "Review project material", status: "active" },
{ id: "write", title: "Write body draft", status: "pending" },
],
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(container.textContent).toContain("Current progress");
expect(container.textContent).toContain("Task queue");
expect(container.textContent).toContain("In progress · 0/2");
expect(container.textContent).toContain(
"Moving forward; 1 item(s) remain",
);
expect(container.textContent).not.toContain("当前进展");
expect(container.textContent).not.toContain("任务队列");
const stopButton = container.querySelector(
'[data-testid="workflow-stop"]',
) as HTMLButtonElement | null;
expect(stopButton?.getAttribute("aria-label")).toBe("Stop generation");
const collapseButton = Array.from(
container.querySelectorAll("button"),
).find(
(button) => button.getAttribute("aria-label") === "Collapse task queue",
);
expect(collapseButton).toBeTruthy();
});
it("工作区工作流生成中应支持折叠与展开待办列表", async () => {
const { container } = renderInputbar({
variant: "workspace",
@@ -32,6 +32,10 @@ import type {
} from "./types";
import { buildInputbarComposerSectionCopy } from "./components/inputbarComposerSectionCopy";
import { buildInputbarCoreCopy } from "./components/inputbarCoreCopy";
import {
buildInputbarExecutionStrategyCopy,
buildInputbarWorkflowPanelCopy,
} from "./inputbarWorkflowCopy";
const SecondaryControlsRow = styled.div`
position: absolute;
@@ -216,6 +220,20 @@ export const Inputbar: React.FC<InputbarProps> = ({
),
[t],
);
const executionStrategyCopy = React.useMemo(
() =>
buildInputbarExecutionStrategyCopy((key, values) =>
t(key, values ?? {}),
),
[t],
);
const workflowPanelCopy = React.useMemo(
() =>
buildInputbarWorkflowPanelCopy((key, values) =>
t(key, values ?? {}),
),
[t],
);
const {
textareaRef,
isWorkspaceVariant,
@@ -389,6 +407,8 @@ export const Inputbar: React.FC<InputbarProps> = ({
inputCompletionEnabled={inputCompletionEnabled}
copy={inputbarComposerCopy}
inputbarCopy={inputbarCopy}
executionStrategyCopy={executionStrategyCopy}
workflowPanelCopy={workflowPanelCopy}
/>
{dialogLayer}
</InputbarSurface>
@@ -0,0 +1,246 @@
import type { WorkflowInputStateCopy } from "../../utils/workflowInputState";
export type InputbarWorkflowCopyKey =
| "agentChat.inputbar.executionStrategy.label"
| "agentChat.inputbar.executionStrategy.enable"
| "agentChat.inputbar.executionStrategy.disable"
| "agentChat.inputbar.workflow.status.error"
| "agentChat.inputbar.workflow.status.pending"
| "agentChat.inputbar.workflow.status.active"
| "agentChat.inputbar.workflow.status.completed"
| "agentChat.inputbar.workflow.status.skipped"
| "agentChat.inputbar.workflow.status.waitingDecision"
| "agentChat.inputbar.workflow.status.autoRunning"
| "agentChat.inputbar.workflow.status.ready"
| "agentChat.inputbar.workflow.summary.currentProgress"
| "agentChat.inputbar.workflow.summary.defaultActiveTitle"
| "agentChat.inputbar.workflow.summary.defaultGateTitle"
| "agentChat.inputbar.workflow.summary.completed"
| "agentChat.inputbar.workflow.summary.waitingDecision"
| "agentChat.inputbar.workflow.summary.running"
| "agentChat.inputbar.workflow.summary.arranging"
| "agentChat.inputbar.workflow.summary.errorWithTrailing"
| "agentChat.inputbar.workflow.summary.errorLast"
| "agentChat.inputbar.workflow.summary.pendingWithTrailing"
| "agentChat.inputbar.workflow.summary.pendingLast"
| "agentChat.inputbar.workflow.summary.activeWithTrailing"
| "agentChat.inputbar.workflow.summary.activeLast"
| "agentChat.inputbar.workflow.progress.waitingStart"
| "agentChat.inputbar.workflow.progress.completed"
| "agentChat.inputbar.workflow.action.stopGeneration"
| "agentChat.inputbar.workflow.queue.title"
| "agentChat.inputbar.workflow.queue.expand"
| "agentChat.inputbar.workflow.queue.collapse"
| "agentChat.inputbar.workflow.queue.hiddenSuffix"
| "agentChat.inputbar.workflow.queue.placeholderTitle"
| "agentChat.inputbar.workflow.queue.itemMetaWithProgress"
| "agentChat.inputbar.workflow.quickAction.topicOptions.label"
| "agentChat.inputbar.workflow.quickAction.topicOptions.prompt"
| "agentChat.inputbar.workflow.quickAction.topicChooseB.label"
| "agentChat.inputbar.workflow.quickAction.topicChooseB.prompt"
| "agentChat.inputbar.workflow.quickAction.writeFast.label"
| "agentChat.inputbar.workflow.quickAction.writeFast.prompt"
| "agentChat.inputbar.workflow.quickAction.writeCoach.label"
| "agentChat.inputbar.workflow.quickAction.writeCoach.prompt"
| "agentChat.inputbar.workflow.quickAction.publishChecklist.label"
| "agentChat.inputbar.workflow.quickAction.publishChecklist.prompt"
| "agentChat.inputbar.workflow.quickAction.publishNow.label"
| "agentChat.inputbar.workflow.quickAction.publishNow.prompt"
| "agentChat.inputbar.workflow.quickAction.nextStep.label"
| "agentChat.inputbar.workflow.quickAction.nextStep.prompt";
type InputbarWorkflowCopyValue = number | string;
export type InputbarWorkflowCopyTranslate = (
key: InputbarWorkflowCopyKey,
values?: Record<string, InputbarWorkflowCopyValue>,
) => string;
export interface InputbarExecutionStrategyCopy {
label: string;
enable: string;
disable: string;
}
export interface InputbarWorkflowPanelCopy {
status: {
error: string;
pending: string;
active: string;
completed: string;
skipped: string;
waitingDecision: string;
autoRunning: string;
ready: string;
};
summary: {
currentProgress: string;
defaultActiveTitle: string;
defaultGateTitle: string;
};
action: {
stopGeneration: string;
};
queue: {
title: string;
expand: string;
collapse: string;
hiddenSuffix: (progressLabel: string, count: number) => string;
placeholderTitle: string;
itemMetaWithProgress: (
status: string,
completed: number,
total: number,
) => string;
};
}
export function buildInputbarExecutionStrategyCopy(
translate: InputbarWorkflowCopyTranslate,
): InputbarExecutionStrategyCopy {
return {
label: translate("agentChat.inputbar.executionStrategy.label"),
enable: translate("agentChat.inputbar.executionStrategy.enable"),
disable: translate("agentChat.inputbar.executionStrategy.disable"),
};
}
export function buildInputbarWorkflowPanelCopy(
translate: InputbarWorkflowCopyTranslate,
): InputbarWorkflowPanelCopy {
return {
status: {
error: translate("agentChat.inputbar.workflow.status.error"),
pending: translate("agentChat.inputbar.workflow.status.pending"),
active: translate("agentChat.inputbar.workflow.status.active"),
completed: translate("agentChat.inputbar.workflow.status.completed"),
skipped: translate("agentChat.inputbar.workflow.status.skipped"),
waitingDecision: translate(
"agentChat.inputbar.workflow.status.waitingDecision",
),
autoRunning: translate(
"agentChat.inputbar.workflow.status.autoRunning",
),
ready: translate("agentChat.inputbar.workflow.status.ready"),
},
summary: {
currentProgress: translate(
"agentChat.inputbar.workflow.summary.currentProgress",
),
defaultActiveTitle: translate(
"agentChat.inputbar.workflow.summary.defaultActiveTitle",
),
defaultGateTitle: translate(
"agentChat.inputbar.workflow.summary.defaultGateTitle",
),
},
action: {
stopGeneration: translate(
"agentChat.inputbar.workflow.action.stopGeneration",
),
},
queue: {
title: translate("agentChat.inputbar.workflow.queue.title"),
expand: translate("agentChat.inputbar.workflow.queue.expand"),
collapse: translate("agentChat.inputbar.workflow.queue.collapse"),
hiddenSuffix: (progressLabel, count) =>
translate("agentChat.inputbar.workflow.queue.hiddenSuffix", {
progressLabel,
count,
}),
placeholderTitle: translate(
"agentChat.inputbar.workflow.queue.placeholderTitle",
),
itemMetaWithProgress: (status, completed, total) =>
translate("agentChat.inputbar.workflow.queue.itemMetaWithProgress", {
status,
completed,
total,
}),
},
};
}
export function buildInputbarWorkflowStateCopy(
translate: InputbarWorkflowCopyTranslate,
): WorkflowInputStateCopy {
return {
quickActions: {
topicOptionsLabel: translate(
"agentChat.inputbar.workflow.quickAction.topicOptions.label",
),
topicOptionsPrompt: translate(
"agentChat.inputbar.workflow.quickAction.topicOptions.prompt",
),
topicChooseBLabel: translate(
"agentChat.inputbar.workflow.quickAction.topicChooseB.label",
),
topicChooseBPrompt: translate(
"agentChat.inputbar.workflow.quickAction.topicChooseB.prompt",
),
writeFastLabel: translate(
"agentChat.inputbar.workflow.quickAction.writeFast.label",
),
writeFastPrompt: translate(
"agentChat.inputbar.workflow.quickAction.writeFast.prompt",
),
writeCoachLabel: translate(
"agentChat.inputbar.workflow.quickAction.writeCoach.label",
),
writeCoachPrompt: translate(
"agentChat.inputbar.workflow.quickAction.writeCoach.prompt",
),
publishChecklistLabel: translate(
"agentChat.inputbar.workflow.quickAction.publishChecklist.label",
),
publishChecklistPrompt: translate(
"agentChat.inputbar.workflow.quickAction.publishChecklist.prompt",
),
publishNowLabel: translate(
"agentChat.inputbar.workflow.quickAction.publishNow.label",
),
publishNowPrompt: translate(
"agentChat.inputbar.workflow.quickAction.publishNow.prompt",
),
nextStepLabel: translate(
"agentChat.inputbar.workflow.quickAction.nextStep.label",
),
nextStepPrompt: translate(
"agentChat.inputbar.workflow.quickAction.nextStep.prompt",
),
},
summary: {
completed: translate("agentChat.inputbar.workflow.summary.completed"),
waitingDecision: translate(
"agentChat.inputbar.workflow.summary.waitingDecision",
),
running: translate("agentChat.inputbar.workflow.summary.running"),
arranging: translate("agentChat.inputbar.workflow.summary.arranging"),
errorWithTrailing: (count) =>
translate("agentChat.inputbar.workflow.summary.errorWithTrailing", {
count,
}),
errorLast: translate("agentChat.inputbar.workflow.summary.errorLast"),
pendingWithTrailing: (count) =>
translate("agentChat.inputbar.workflow.summary.pendingWithTrailing", {
count,
}),
pendingLast: translate("agentChat.inputbar.workflow.summary.pendingLast"),
activeWithTrailing: (count) =>
translate("agentChat.inputbar.workflow.summary.activeWithTrailing", {
count,
}),
activeLast: translate("agentChat.inputbar.workflow.summary.activeLast"),
},
progress: {
waitingStart: translate(
"agentChat.inputbar.workflow.progress.waitingStart",
),
completed: (completed, total) =>
translate("agentChat.inputbar.workflow.progress.completed", {
completed,
total,
}),
},
};
}
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import {
BookOpen,
@@ -16,7 +17,9 @@ import {
MetaToggleGlyph,
MetaToggleLabel,
} from "../styles";
import { formatList } from "@/i18n/format";
import {
type InputbarKnowledgeHubCopyRef,
isReadyKnowledgePackStatus,
normalizeKnowledgePackOptions,
resolveKnowledgeHubState,
@@ -195,7 +198,11 @@ export function InputbarKnowledgeControl({
onStartKnowledgeOrganize?: () => void;
onManageKnowledgePacks?: () => void;
}) {
const { t, i18n } = useTranslation("agent");
const [showKnowledgeHub, setShowKnowledgeHub] = useState(false);
const fallbackPackLabel = t(
"agentChat.inputbar.knowledge.fallbackPackLabel",
);
const shouldShowKnowledgePackToggle = Boolean(
knowledgePackSelection?.packName && knowledgePackSelection?.workingDir,
);
@@ -239,7 +246,7 @@ export function InputbarKnowledgeControl({
const currentKnowledgePackLabel =
knowledgePackSelection?.label ||
knowledgePackSelection?.packName ||
"项目资料";
fallbackPackLabel;
const effectiveKnowledgeEnabled = Boolean(
knowledgePackSelection?.enabled &&
isReadyKnowledgePackStatus(knowledgePackSelection.status),
@@ -250,7 +257,10 @@ export function InputbarKnowledgeControl({
hasInputText: Boolean(inputText.trim()),
canManageKnowledgePacks: Boolean(onManageKnowledgePacks),
canStartKnowledgeOrganize: Boolean(onStartKnowledgeOrganize),
fallbackPackLabel,
});
const renderHubCopy = (copy: InputbarKnowledgeHubCopyRef) =>
t(copy.key, copy.values ?? {});
const shouldShowSecondaryManageAction = Boolean(
onManageKnowledgePacks &&
hubState.primaryAction !== "manage" &&
@@ -261,17 +271,27 @@ export function InputbarKnowledgeControl({
hubState.primaryAction !== "organize" &&
hubState.primaryAction !== "supplement",
);
const secondaryOrganizeLabel = inputText.trim()
? "整理当前输入"
: "添加新资料";
const secondaryOrganizeLabel = t(
inputText.trim()
? "agentChat.inputbar.knowledge.action.organizeSecondaryWithInput"
: "agentChat.inputbar.knowledge.action.organizeSecondary",
);
const companionCount = companionPacks.length;
const implicitCompanionList = formatList(implicitCompanionLabels, {
locale: i18n.language,
});
const knowledgeToggleLabel = effectiveKnowledgeEnabled
? `资料:${currentKnowledgePackLabel}${companionCount ? ` +${companionCount}` : ""}`
? t(
companionCount
? "agentChat.inputbar.knowledge.toggle.label.enabledWithCompanions"
: "agentChat.inputbar.knowledge.toggle.label.enabled",
{ count: companionCount, label: currentKnowledgePackLabel },
)
: shouldShowKnowledgePackToggle
? isReadyKnowledgePackStatus(knowledgePackSelection?.status)
? "资料可用"
: "资料待确认"
: "添加资料";
? t("agentChat.inputbar.knowledge.toggle.label.ready")
: t("agentChat.inputbar.knowledge.toggle.label.pending")
: t("agentChat.inputbar.knowledge.toggle.label.add");
useEffect(() => {
if (!openKnowledgeHubRequestKey) {
@@ -321,14 +341,18 @@ export function InputbarKnowledgeControl({
<MetaToggleButton
type="button"
$checked={effectiveKnowledgeEnabled || showKnowledgeHub}
aria-label="打开项目资料"
aria-label={t("agentChat.inputbar.knowledge.toggle.aria")}
aria-expanded={showKnowledgeHub}
title={
effectiveKnowledgeEnabled
? `正在使用项目资料:${currentKnowledgePackLabel}`
? t("agentChat.inputbar.knowledge.toggle.title.enabled", {
label: currentKnowledgePackLabel,
})
: shouldShowKnowledgePackToggle
? `已有可用项目资料:${currentKnowledgePackLabel}。点击选择、添加或使用。`
: "查看、添加或使用项目资料"
? t("agentChat.inputbar.knowledge.toggle.title.available", {
label: currentKnowledgePackLabel,
})
: t("agentChat.inputbar.knowledge.toggle.title.organize")
}
data-testid={
shouldShowKnowledgePackToggle
@@ -351,14 +375,16 @@ export function InputbarKnowledgeControl({
<KnowledgeHubCard data-testid="inputbar-knowledge-hub">
<KnowledgeHubTitle>
<BookOpen className="h-4 w-4 text-emerald-600" />
{hubState.title}
{renderHubCopy(hubState.title)}
</KnowledgeHubTitle>
<KnowledgeHubDescription>
{hubState.description}
{renderHubCopy(hubState.description)}
</KnowledgeHubDescription>
{readyOptions.length > 0 ? (
<>
<KnowledgePackSectionTitle>主资料</KnowledgePackSectionTitle>
<KnowledgePackSectionTitle>
{t("agentChat.inputbar.knowledge.section.main")}
</KnowledgePackSectionTitle>
<KnowledgePackMenu
role="menu"
data-testid="inputbar-knowledge-pack-menu"
@@ -381,11 +407,13 @@ export function InputbarKnowledgeControl({
<KnowledgePackMenuItemTitle>
<span>{label}</span>
{option.defaultForWorkspace ? (
<KnowledgePackMenuBadge>默认</KnowledgePackMenuBadge>
<KnowledgePackMenuBadge>
{t("agentChat.inputbar.knowledge.badge.default")}
</KnowledgePackMenuBadge>
) : null}
</KnowledgePackMenuItemTitle>
<KnowledgePackMenuItemMeta>
已确认,可用于生成
{t("agentChat.inputbar.knowledge.meta.ready")}
</KnowledgePackMenuItemMeta>
</KnowledgePackMenuItem>
);
@@ -395,13 +423,15 @@ export function InputbarKnowledgeControl({
) : null}
{implicitCompanionLabels.length > 0 ? (
<KnowledgeHubDescription data-testid="inputbar-knowledge-implicit-companions">
已自动搭配人设资料:{implicitCompanionLabels.join("、")}。
{t("agentChat.inputbar.knowledge.implicitCompanions", {
labels: implicitCompanionList,
})}
</KnowledgeHubDescription>
) : null}
{companionCandidates.length > 0 && onToggleKnowledgeCompanionPack ? (
<>
<KnowledgePackSectionTitle>
协同资料(可多选)
{t("agentChat.inputbar.knowledge.section.companion")}
</KnowledgePackSectionTitle>
<KnowledgePackMenu data-testid="inputbar-knowledge-companion-menu">
{companionCandidates.map((option) => {
@@ -428,11 +458,15 @@ export function InputbarKnowledgeControl({
<KnowledgePackMenuItemTitle>
<span>{label}</span>
<KnowledgePackMenuBadge>
{isSelected ? "已协同" : "可协同"}
{t(
isSelected
? "agentChat.inputbar.knowledge.badge.companionSelected"
: "agentChat.inputbar.knowledge.badge.companionAvailable",
)}
</KnowledgePackMenuBadge>
</KnowledgePackMenuItemTitle>
<KnowledgePackMenuItemMeta>
和主资料一起进入上下文,用于补充运营事实和执行边界
{t("agentChat.inputbar.knowledge.meta.companion")}
</KnowledgePackMenuItemMeta>
</KnowledgePackMenuItem>
);
@@ -442,8 +476,9 @@ export function InputbarKnowledgeControl({
) : null}
{hiddenPendingCount > 0 ? (
<KnowledgeHubDescription>
还有 {hiddenPendingCount}{" "}
份资料待确认,确认后才会出现在可用列表里。
{t("agentChat.inputbar.knowledge.pendingNotice", {
count: hiddenPendingCount,
})}
</KnowledgeHubDescription>
) : null}
<KnowledgeHubActions>
@@ -455,7 +490,7 @@ export function InputbarKnowledgeControl({
setShowKnowledgeHub(false);
}}
>
关闭资料
{t("agentChat.inputbar.knowledge.action.close")}
</KnowledgeHubAction>
) : null}
{shouldShowSecondaryOrganizeAction ? (
@@ -479,7 +514,7 @@ export function InputbarKnowledgeControl({
}}
>
<SlidersHorizontal className="h-3.5 w-3.5" />
检查资料
{t("agentChat.inputbar.knowledge.action.check")}
</KnowledgeHubAction>
) : null}
{hubState.primaryAction !== "none" ? (
@@ -489,7 +524,7 @@ export function InputbarKnowledgeControl({
onClick={handlePrimaryAction}
>
<MessageSquareText className="h-3.5 w-3.5" />
{hubState.primaryLabel}
{renderHubCopy(hubState.primaryLabel)}
</KnowledgeHubAction>
) : null}
</KnowledgeHubActions>
@@ -1,6 +1,8 @@
import { describe, expect, it } from "vitest";
import { resolveKnowledgeHubState } from "./knowledgeHubState";
const fallbackPackLabel = "项目资料";
describe("resolveKnowledgeHubState", () => {
it("无资料时应引导添加项目资料", () => {
const state = resolveKnowledgeHubState({
@@ -9,11 +11,14 @@ describe("resolveKnowledgeHubState", () => {
hasInputText: false,
canManageKnowledgePacks: true,
canStartKnowledgeOrganize: true,
fallbackPackLabel,
});
expect(state.title).toBe("添加项目资料");
expect(state.title.key).toBe("agentChat.inputbar.knowledge.state.add.title");
expect(state.primaryAction).toBe("organize");
expect(state.primaryLabel).toBe("开始添加资料");
expect(state.primaryLabel.key).toBe(
"agentChat.inputbar.knowledge.action.organize",
);
});
it("无资料但输入框已有内容时应引导沉淀当前输入", () => {
@@ -23,10 +28,13 @@ describe("resolveKnowledgeHubState", () => {
hasInputText: true,
canManageKnowledgePacks: true,
canStartKnowledgeOrganize: true,
fallbackPackLabel,
});
expect(state.primaryAction).toBe("organize");
expect(state.primaryLabel).toBe("整理当前输入为资料");
expect(state.primaryLabel.key).toBe(
"agentChat.inputbar.knowledge.action.organizeWithInput",
);
});
it("有待确认资料且无可用选择时应先确认资料", () => {
@@ -42,11 +50,16 @@ describe("resolveKnowledgeHubState", () => {
hasInputText: false,
canManageKnowledgePacks: true,
canStartKnowledgeOrganize: true,
fallbackPackLabel,
});
expect(state.title).toBe("有资料待确认");
expect(state.title.key).toBe(
"agentChat.inputbar.knowledge.state.pendingOnly.title",
);
expect(state.primaryAction).toBe("manage");
expect(state.primaryLabel).toBe("去确认资料");
expect(state.primaryLabel.key).toBe(
"agentChat.inputbar.knowledge.action.review",
);
expect(state.pendingCount).toBe(1);
});
@@ -69,11 +82,16 @@ describe("resolveKnowledgeHubState", () => {
hasInputText: false,
canManageKnowledgePacks: true,
canStartKnowledgeOrganize: true,
fallbackPackLabel,
});
expect(state.title).toBe("选择项目资料");
expect(state.title.key).toBe(
"agentChat.inputbar.knowledge.state.select.title",
);
expect(state.primaryAction).toBe("use");
expect(state.primaryLabel).toBe("使用这份资料");
expect(state.primaryLabel.key).toBe(
"agentChat.inputbar.knowledge.action.use",
);
});
it("已启用资料时应引导补充资料", () => {
@@ -95,10 +113,16 @@ describe("resolveKnowledgeHubState", () => {
hasInputText: true,
canManageKnowledgePacks: true,
canStartKnowledgeOrganize: true,
fallbackPackLabel,
});
expect(state.title).toBe("正在使用:品牌资料");
expect(state.title.key).toBe(
"agentChat.inputbar.knowledge.state.using.title",
);
expect(state.title.values).toEqual({ label: "品牌资料" });
expect(state.primaryAction).toBe("supplement");
expect(state.primaryLabel).toBe("把当前输入补充为资料");
expect(state.primaryLabel.key).toBe(
"agentChat.inputbar.knowledge.action.supplementWithInput",
);
});
});
@@ -10,15 +10,51 @@ export type InputbarKnowledgeHubPrimaryAction =
| "supplement"
| "none";
export type InputbarKnowledgeHubCopyKey =
| "agentChat.inputbar.knowledge.state.pendingReview.title"
| "agentChat.inputbar.knowledge.state.pendingReview.description"
| "agentChat.inputbar.knowledge.state.using.title"
| "agentChat.inputbar.knowledge.state.using.description"
| "agentChat.inputbar.knowledge.state.select.title"
| "agentChat.inputbar.knowledge.state.select.descriptionWithPending"
| "agentChat.inputbar.knowledge.state.select.descriptionReady"
| "agentChat.inputbar.knowledge.state.pendingOnly.title"
| "agentChat.inputbar.knowledge.state.pendingOnly.description"
| "agentChat.inputbar.knowledge.state.add.title"
| "agentChat.inputbar.knowledge.state.add.description"
| "agentChat.inputbar.knowledge.action.review"
| "agentChat.inputbar.knowledge.action.supplement"
| "agentChat.inputbar.knowledge.action.supplementWithInput"
| "agentChat.inputbar.knowledge.action.use"
| "agentChat.inputbar.knowledge.action.organize"
| "agentChat.inputbar.knowledge.action.organizeWithInput";
export type InputbarKnowledgeHubCopyValues = Record<
string,
string | number
>;
export interface InputbarKnowledgeHubCopyRef {
key: InputbarKnowledgeHubCopyKey;
values?: InputbarKnowledgeHubCopyValues;
}
export interface InputbarKnowledgeHubState {
title: string;
description: string;
title: InputbarKnowledgeHubCopyRef;
description: InputbarKnowledgeHubCopyRef;
primaryAction: InputbarKnowledgeHubPrimaryAction;
primaryLabel: string;
primaryLabel: InputbarKnowledgeHubCopyRef;
readyCount: number;
pendingCount: number;
}
function copyRef(
key: InputbarKnowledgeHubCopyKey,
values?: InputbarKnowledgeHubCopyValues,
): InputbarKnowledgeHubCopyRef {
return values ? { key, values } : { key };
}
export function normalizeKnowledgePackOptions({
knowledgePackOptions,
knowledgePackSelection,
@@ -62,12 +98,14 @@ export function resolveKnowledgeHubState({
hasInputText,
canManageKnowledgePacks,
canStartKnowledgeOrganize,
fallbackPackLabel,
}: {
knowledgePackSelection?: InputbarKnowledgePackSelection | null;
knowledgePackOptions: InputbarKnowledgePackOption[];
hasInputText: boolean;
canManageKnowledgePacks: boolean;
canStartKnowledgeOrganize: boolean;
fallbackPackLabel: string;
}): InputbarKnowledgeHubState {
const readyCount = knowledgePackOptions.filter((option) =>
isReadyKnowledgePackStatus(option.status),
@@ -76,17 +114,22 @@ export function resolveKnowledgeHubState({
const currentLabel =
knowledgePackSelection?.label ||
knowledgePackSelection?.packName ||
"项目资料";
fallbackPackLabel;
const selectedIsReady = isReadyKnowledgePackStatus(
knowledgePackSelection?.status,
);
if (knowledgePackSelection && !selectedIsReady && canManageKnowledgePacks) {
return {
title: "资料待确认",
description: `「${currentLabel}」还没有确认。先检查事实、适用场景和风险提示,确认后再用于生成。`,
title: copyRef(
"agentChat.inputbar.knowledge.state.pendingReview.title",
),
description: copyRef(
"agentChat.inputbar.knowledge.state.pendingReview.description",
{ label: currentLabel },
),
primaryAction: "manage",
primaryLabel: "去确认资料",
primaryLabel: copyRef("agentChat.inputbar.knowledge.action.review"),
readyCount,
pendingCount,
};
@@ -94,11 +137,18 @@ export function resolveKnowledgeHubState({
if (knowledgePackSelection?.enabled) {
return {
title: `正在使用:${currentLabel}`,
description:
"本次生成会参考这份项目资料。需要补充新内容时,先把资料贴进输入框,再整理为项目资料。",
title: copyRef("agentChat.inputbar.knowledge.state.using.title", {
label: currentLabel,
}),
description: copyRef(
"agentChat.inputbar.knowledge.state.using.description",
),
primaryAction: canStartKnowledgeOrganize ? "supplement" : "none",
primaryLabel: hasInputText ? "把当前输入补充为资料" : "补充资料",
primaryLabel: copyRef(
hasInputText
? "agentChat.inputbar.knowledge.action.supplementWithInput"
: "agentChat.inputbar.knowledge.action.supplement",
),
readyCount,
pendingCount,
};
@@ -106,13 +156,19 @@ export function resolveKnowledgeHubState({
if (knowledgePackSelection) {
return {
title: "选择项目资料",
title: copyRef("agentChat.inputbar.knowledge.state.select.title"),
description:
pendingCount > 0
? `当前可用:${currentLabel}。另有资料需要确认;待确认内容请先检查。`
: `当前可用:${currentLabel}。使用后,本次生成会参考其中的事实、语气和边界。`,
? copyRef(
"agentChat.inputbar.knowledge.state.select.descriptionWithPending",
{ label: currentLabel },
)
: copyRef(
"agentChat.inputbar.knowledge.state.select.descriptionReady",
{ label: currentLabel },
),
primaryAction: "use",
primaryLabel: "使用这份资料",
primaryLabel: copyRef("agentChat.inputbar.knowledge.action.use"),
readyCount,
pendingCount,
};
@@ -120,22 +176,26 @@ export function resolveKnowledgeHubState({
if (pendingCount > 0 && canManageKnowledgePacks) {
return {
title: "有资料待确认",
description:
"先检查事实、适用场景和风险提示,确认后再用于生成,避免把未核对内容写进长期资料。",
title: copyRef("agentChat.inputbar.knowledge.state.pendingOnly.title"),
description: copyRef(
"agentChat.inputbar.knowledge.state.pendingOnly.description",
),
primaryAction: "manage",
primaryLabel: "去确认资料",
primaryLabel: copyRef("agentChat.inputbar.knowledge.action.review"),
readyCount,
pendingCount,
};
}
return {
title: "添加项目资料",
description:
"粘贴访谈稿、产品说明、SOP 或历史文案后,让当前 Agent 整理成可确认的项目资料;之后可在这里一键使用。",
title: copyRef("agentChat.inputbar.knowledge.state.add.title"),
description: copyRef("agentChat.inputbar.knowledge.state.add.description"),
primaryAction: canStartKnowledgeOrganize ? "organize" : "none",
primaryLabel: hasInputText ? "整理当前输入为资料" : "开始添加资料",
primaryLabel: copyRef(
hasInputText
? "agentChat.inputbar.knowledge.action.organizeWithInput"
: "agentChat.inputbar.knowledge.action.organize",
),
readyCount,
pendingCount,
};
@@ -62,8 +62,38 @@ vi.mock("./ArtifactPlaceholder", () => ({
}));
vi.mock("./A2UITaskCard", () => ({
A2UITaskCard: () => <div data-testid="a2ui-task-card" />,
A2UITaskLoadingCard: () => <div data-testid="a2ui-task-loading-card" />,
A2UITaskCard: ({
compact,
className,
preview,
onSubmit,
}: {
compact?: boolean;
className?: string;
preview?: boolean;
onSubmit?: unknown;
}) => (
<div
data-testid="a2ui-task-card"
data-compact={String(compact)}
data-preview={String(preview)}
data-has-on-submit={onSubmit ? "yes" : "no"}
className={className}
/>
),
A2UITaskLoadingCard: ({
compact,
className,
}: {
compact?: boolean;
className?: string;
}) => (
<div
data-testid="a2ui-task-loading-card"
data-compact={String(compact)}
className={className}
/>
),
}));
vi.mock("@/lib/api/fileSystem", () => ({
@@ -86,6 +116,7 @@ interface RenderOptions {
shouldCollapseCodeBlock?: (language: string, code: string) => boolean;
showBlockActions?: boolean;
onQuoteContent?: (content: string) => void;
readOnlyA2UI?: boolean;
}
const mountedRoots: MountedHarness[] = [];
@@ -121,6 +152,7 @@ function render(
shouldCollapseCodeBlock,
showBlockActions = false,
onQuoteContent,
readOnlyA2UI = false,
}: RenderOptions = {},
): HTMLDivElement {
const container = document.createElement("div");
@@ -137,6 +169,7 @@ function render(
shouldCollapseCodeBlock={shouldCollapseCodeBlock}
showBlockActions={showBlockActions}
onQuoteContent={onQuoteContent}
readOnlyA2UI={readOnlyA2UI}
/>,
);
});
@@ -154,6 +187,7 @@ function renderHarness(
shouldCollapseCodeBlock,
showBlockActions = false,
onQuoteContent,
readOnlyA2UI = false,
}: RenderOptions = {},
) {
const container = document.createElement("div");
@@ -170,6 +204,7 @@ function renderHarness(
nextShouldCollapseCodeBlock = shouldCollapseCodeBlock,
showBlockActions: nextShowBlockActions = showBlockActions,
onQuoteContent: nextOnQuoteContent = onQuoteContent,
readOnlyA2UI: nextReadOnlyA2UI = readOnlyA2UI,
}: RenderOptions = {},
) => {
act(() => {
@@ -182,6 +217,7 @@ function renderHarness(
shouldCollapseCodeBlock={nextShouldCollapseCodeBlock}
showBlockActions={nextShowBlockActions}
onQuoteContent={nextOnQuoteContent}
readOnlyA2UI={nextReadOnlyA2UI}
/>,
);
});
@@ -193,6 +229,7 @@ function renderHarness(
shouldCollapseCodeBlock,
showBlockActions,
onQuoteContent,
readOnlyA2UI,
});
mountedRoots.push({ container, root });
@@ -378,6 +415,8 @@ describe("MarkdownRenderer", () => {
expect(tableScroll).not.toBeNull();
expect(tableScroll?.querySelector("table")).not.toBeNull();
expect(document.head.textContent).toContain("--lime-surface-border");
expect(document.head.textContent).toContain("--lime-brand-soft");
expect(container.textContent).toContain("Browser Runtime");
});
@@ -454,6 +493,59 @@ describe("MarkdownRenderer", () => {
).not.toBeNull();
});
it("聊天内联 A2UI 应使用紧凑卡片尺寸", () => {
const content = [
"```a2ui",
JSON.stringify({
id: "a2ui-demo",
root: "root",
data: {},
components: [
{
id: "root",
component: "Text",
text: "请选择开始方式",
variant: "body",
},
],
}),
"```",
].join("\n");
const container = render(content);
const card = container.querySelector('[data-testid="a2ui-task-card"]');
expect(card?.getAttribute("data-compact")).toBe("true");
expect(card?.className).toContain("max-w-[760px]");
});
it("历史 Markdown A2UI 代码块应只读回显并移除提交回调", () => {
const content = [
"```a2ui",
JSON.stringify({
id: "history-a2ui-demo",
root: "root",
data: {},
components: [
{
id: "root",
component: "Text",
text: "历史表单",
variant: "body",
},
],
submitAction: { label: "提交", action: { name: "submit" } },
}),
"```",
].join("\n");
const container = render(content, { readOnlyA2UI: true });
const card = container.querySelector('[data-testid="a2ui-task-card"]');
expect(card?.getAttribute("data-preview")).toBe("true");
expect(card?.getAttribute("data-has-on-submit")).toBe("no");
});
it("标题后的正文应保持聊天正文排版,不应缩小变灰", () => {
const container = document.createElement("div");
container.style.setProperty("--foreground", "17 24 39");
@@ -190,8 +190,8 @@ const MarkdownContainer = styled.div`
th,
td {
border-right: 1px solid rgba(167, 243, 208, 0.42);
border-bottom: 1px solid rgba(167, 243, 208, 0.42);
border-right: 1px solid var(--lime-surface-border, hsl(var(--border)));
border-bottom: 1px solid var(--lime-surface-border, hsl(var(--border)));
padding: 0.45rem 0.65rem;
vertical-align: top;
text-align: left;
@@ -201,10 +201,10 @@ const MarkdownContainer = styled.div`
font-weight: 600;
background: linear-gradient(
180deg,
rgba(220, 252, 231, 0.45) 0%,
rgba(220, 252, 231, 0.8) 100%
var(--lime-brand-soft, hsl(var(--muted))) 0%,
var(--lime-surface-soft, hsl(var(--secondary))) 100%
);
color: #14532d;
color: var(--lime-brand-strong, hsl(var(--foreground)));
white-space: nowrap;
}
@@ -217,7 +217,7 @@ const MarkdownContainer = styled.div`
}
tbody tr:nth-child(even) td {
background: rgba(220, 252, 231, 0.15);
background: var(--lime-surface-soft, hsl(var(--secondary) / 0.5));
}
a {
@@ -256,12 +256,13 @@ const MarkdownDivider = styled.hr`
const MarkdownQuoteCard = styled.blockquote`
margin: 0 0 0.95em;
padding: 0;
border: 1px solid rgba(167, 243, 208, 0.5);
border: 1px solid
var(--lime-surface-border-strong, hsl(var(--border)));
border-radius: 20px;
background: linear-gradient(
180deg,
hsl(var(--background)) 0%,
rgba(220, 252, 231, 0.3) 100%
var(--lime-surface, hsl(var(--background))) 0%,
var(--lime-surface-soft, hsl(var(--secondary) / 0.5)) 100%
);
box-shadow: 0 14px 34px -30px rgba(15, 23, 42, 0.18);
overflow: hidden;
@@ -447,9 +448,10 @@ const MarkdownBlockActionButton = styled.button`
const MarkdownTableScroll = styled.div`
margin: 0 0 0.82em;
overflow-x: auto;
border: 1px solid rgba(167, 243, 208, 0.6);
border: 1px solid
var(--lime-surface-border-strong, hsl(var(--border)));
border-radius: 14px;
background: hsl(var(--background));
background: var(--lime-surface, hsl(var(--background)));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7);
`;
@@ -492,6 +494,8 @@ interface MarkdownRendererProps {
onA2UISubmit?: (formData: A2UIFormData) => void;
/** 是否渲染消息内联 A2UI */
renderA2UIInline?: boolean;
/** 历史消息中的 A2UI 只允许回显,不能再次提交。 */
readOnlyA2UI?: boolean;
/** 是否折叠代码块(当画布打开时) */
collapseCodeBlocks?: boolean;
/** 按代码块决定是否折叠 */
@@ -662,6 +666,7 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = memo(
baseFilePath,
onA2UISubmit,
renderA2UIInline = true,
readOnlyA2UI = false,
collapseCodeBlocks = false,
shouldCollapseCodeBlock,
onCodeBlockClick,
@@ -1132,12 +1137,18 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = memo(
const parsed = parseA2UIJson(codeContent);
if (parsed) {
const response = readOnlyA2UI
? { ...parsed, submitAction: undefined }
: parsed;
// 解析成功,直接渲染 A2UI 组件(不包裹在 pre 中)
return (
<A2UITaskCard
response={parsed}
onSubmit={onA2UISubmit}
response={response}
onSubmit={readOnlyA2UI ? undefined : onA2UISubmit}
preset={CHAT_A2UI_TASK_CARD_PRESET}
compact={true}
className="max-w-[760px]"
preview={readOnlyA2UI}
/>
);
} else {
@@ -1146,6 +1157,8 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = memo(
<A2UITaskLoadingCard
preset={CHAT_A2UI_TASK_CARD_PRESET}
subtitle="正在解析结构化问题,请稍等。"
compact={true}
className="max-w-[760px]"
/>
);
}
@@ -0,0 +1,158 @@
import React from "react";
import {
BookmarkPlus,
Check,
Copy,
FileText,
Quote,
Sparkles,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { MessageActions } from "../styles";
interface MessageActionButtonsProps {
actionContent: string;
canCopyMessage: boolean;
canQuoteMessage: boolean;
canSaveMessageAsInspiration: boolean;
canSaveMessageAsKnowledge: boolean;
canSaveMessageAsSkill: boolean;
copied: boolean;
isImageWorkbenchMessage: boolean;
knowledgeContent?: string;
knowledgeDescription?: string | null;
knowledgeSourceName?: string;
messageId: string;
onCopy?: (content: string, messageId: string) => void;
onQuoteMessage?: (content: string, messageId: string) => void;
onSaveMessageAsInspiration?: (source: {
messageId: string;
content: string;
}) => void;
onSaveMessageAsKnowledge?: (source: {
messageId: string;
content: string;
sourceName?: string;
description?: string | null;
}) => void;
onSaveMessageAsSkill?: (source: {
messageId: string;
content: string;
}) => void;
}
export function MessageActionButtons({
actionContent,
canCopyMessage,
canQuoteMessage,
canSaveMessageAsInspiration,
canSaveMessageAsKnowledge,
canSaveMessageAsSkill,
copied,
isImageWorkbenchMessage,
knowledgeContent,
knowledgeDescription,
knowledgeSourceName,
messageId,
onCopy,
onQuoteMessage,
onSaveMessageAsInspiration,
onSaveMessageAsKnowledge,
onSaveMessageAsSkill,
}: MessageActionButtonsProps) {
const { t } = useTranslation("agent");
return (
<MessageActions
className={[
"message-actions",
isImageWorkbenchMessage ? "image-workbench-message-actions" : "",
]
.filter(Boolean)
.join(" ")}
>
{canQuoteMessage ? (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-full border border-slate-200/90 bg-white/92 text-slate-400 shadow-sm shadow-slate-950/5 hover:bg-slate-50 hover:text-slate-700"
onClick={() => onQuoteMessage?.(actionContent, messageId)}
aria-label={t("agentChat.messageList.actions.quote")}
title={t("agentChat.messageList.actions.quote")}
>
<Quote size={12} />
</Button>
) : null}
{canCopyMessage ? (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-full border border-slate-200/90 bg-white/92 text-slate-400 shadow-sm shadow-slate-950/5 hover:bg-slate-50 hover:text-slate-700"
onClick={() => onCopy?.(actionContent, messageId)}
aria-label={t("agentChat.messageList.actions.copy")}
title={t("agentChat.messageList.actions.copy")}
>
{copied ? (
<Check size={12} className="text-emerald-600" />
) : (
<Copy size={12} />
)}
</Button>
) : null}
{canSaveMessageAsSkill ? (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-full border border-emerald-200/90 bg-emerald-50/92 text-emerald-600 shadow-sm shadow-emerald-950/5 hover:bg-emerald-100 hover:text-emerald-700"
onClick={() =>
onSaveMessageAsSkill?.({
messageId,
content: actionContent,
})
}
aria-label={t("agentChat.messageList.actions.saveAsSkill")}
title={t("agentChat.messageList.actions.saveAsSkill")}
>
<Sparkles size={12} />
</Button>
) : null}
{canSaveMessageAsInspiration ? (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-full border border-amber-200/90 bg-amber-50/92 text-amber-600 shadow-sm shadow-amber-950/5 hover:bg-amber-100 hover:text-amber-700"
onClick={() =>
onSaveMessageAsInspiration?.({
messageId,
content: actionContent,
})
}
aria-label={t("agentChat.messageList.actions.saveToInspiration")}
title={t("agentChat.messageList.actions.saveToInspiration")}
>
<BookmarkPlus size={12} />
</Button>
) : null}
{canSaveMessageAsKnowledge ? (
<Button
variant="ghost"
className="relative z-10 h-8 w-auto gap-1.5 rounded-full border border-sky-200/90 bg-sky-50/92 px-2.5 text-xs font-semibold text-sky-700 shadow-sm shadow-sky-950/5 hover:bg-sky-100 hover:text-sky-800"
onClick={() =>
onSaveMessageAsKnowledge?.({
messageId,
content: knowledgeContent || actionContent,
sourceName: knowledgeSourceName,
description: knowledgeDescription,
})
}
aria-label={t("agentChat.messageList.actions.saveToKnowledge")}
title={t("agentChat.messageList.actions.saveToKnowledge")}
>
<FileText size={12} />
<span>{t("agentChat.messageList.actions.saveToKnowledge")}</span>
</Button>
) : null}
</MessageActions>
);
}
@@ -0,0 +1,137 @@
import React from "react";
import { ExternalLink, FileText, Loader2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { Artifact } from "@/lib/artifact/types";
import { resolveArtifactProtocolFilePath } from "@/lib/artifact-protocol";
import {
formatArtifactWritePhaseLabel,
resolveArtifactPreviewText,
resolveArtifactWritePhase,
} from "../utils/messageArtifacts";
import { resolveContentPostArtifactDisplayTitle } from "../utils/contentPostSkill";
import { isHiddenConversationArtifactPath } from "../utils/internalArtifactVisibility";
import { resolveKnowledgeSourceFromArtifacts } from "./messageListKnowledgeSource";
interface SaveMessageAsKnowledgeSource {
messageId: string;
content: string;
sourceName?: string;
description?: string | null;
}
interface MessageArtifactCardsProps {
artifacts: Artifact[] | undefined;
messageId: string;
onArtifactClick?: (artifact: Artifact) => void;
onSaveMessageAsKnowledge?: (source: SaveMessageAsKnowledgeSource) => void;
}
export function MessageArtifactCards({
artifacts,
messageId,
onArtifactClick,
onSaveMessageAsKnowledge,
}: MessageArtifactCardsProps) {
const { t } = useTranslation("agent");
const visibleArtifacts =
artifacts?.filter(
(artifact) =>
!isHiddenConversationArtifactPath(
resolveArtifactProtocolFilePath(artifact),
),
) || [];
if (visibleArtifacts.length === 0) {
return null;
}
return (
<div className="flex flex-col gap-2">
{visibleArtifacts.map((artifact) => {
const filePath = resolveArtifactProtocolFilePath(artifact);
const displayTitle = resolveContentPostArtifactDisplayTitle({
title: artifact.title,
filePath,
metadata: artifact.meta,
});
const writePhase = resolveArtifactWritePhase(artifact);
const statusLabel = formatArtifactWritePhaseLabel(writePhase);
const previewText = resolveArtifactPreviewText(artifact, 180);
const knowledgeSource = resolveKnowledgeSourceFromArtifacts([artifact]);
const canSaveArtifactAsKnowledge = Boolean(
onSaveMessageAsKnowledge && knowledgeSource,
);
return (
<div
key={artifact.id}
className="flex w-full flex-col items-stretch gap-2 rounded-xl border border-sky-200/80 bg-sky-50 p-2 text-left shadow-sm shadow-sky-950/5 sm:flex-row"
>
<button
type="button"
onClick={() => onArtifactClick?.(artifact)}
className="flex min-w-0 flex-1 items-center gap-3 rounded-lg px-1 py-0.5 text-left transition-colors hover:bg-white"
>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-sky-100 text-sky-700">
{artifact.status === "streaming" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<FileText className="h-4 w-4" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="mb-1 flex flex-wrap items-center gap-1.5">
<span className="inline-flex rounded-full border border-sky-200 bg-white px-2 py-0.5 text-[11px] font-semibold text-sky-700">
{t("agentChat.messageList.artifact.documentBadge")}
</span>
{knowledgeSource ? (
<span className="inline-flex rounded-full bg-emerald-50 px-2 py-0.5 text-[11px] font-medium text-emerald-700">
{t("agentChat.messageList.artifact.saveableBadge")}
</span>
) : null}
</div>
<div className="truncate text-sm font-semibold text-slate-900">
{displayTitle}
</div>
<div className="truncate text-xs text-slate-500">
{filePath}
</div>
<div className="mt-1 flex items-center gap-2">
<span className="inline-flex rounded-full bg-white px-2 py-0.5 text-[11px] text-slate-500">
{statusLabel}
</span>
{previewText ? (
<span className="line-clamp-1 text-xs text-slate-600">
{previewText}
</span>
) : artifact.status === "streaming" ? (
<span className="text-xs text-slate-500">
{t("agentChat.messageList.artifact.streaming")}
</span>
) : null}
</div>
</div>
<ExternalLink className="h-3.5 w-3.5 shrink-0 text-slate-400" />
</button>
{canSaveArtifactAsKnowledge ? (
<button
type="button"
onClick={() =>
onSaveMessageAsKnowledge?.({
messageId,
content: knowledgeSource?.content || artifact.content,
sourceName: knowledgeSource?.sourceName,
description: knowledgeSource?.description,
})
}
className="flex shrink-0 items-center justify-center rounded-lg border border-sky-200 bg-white px-3 py-2 text-xs font-semibold text-sky-700 transition-colors hover:border-sky-300 hover:bg-sky-100 sm:py-0"
>
{t("agentChat.messageList.artifact.saveDocument")}
</button>
) : null}
</div>
);
})}
</div>
);
}
@@ -0,0 +1,230 @@
import React from "react";
import type { A2UIFormData } from "@/lib/workspace/a2ui";
import { StreamingRenderer } from "./StreamingRenderer";
import {
HistoricalAssistantMessagePreview,
HistoricalMarkdownHydrationPreview,
} from "./MessageListHistoricalPreviews";
import { MessageCanvasShortcut } from "./MessageCanvasShortcut";
import { MessagePreviewCards } from "./MessagePreviewCards";
import type { resolveImageWorkbenchRendererProcessState } from "./imageWorkbenchMessageDisplay";
import type {
ConfirmResponse,
Message,
MessagePreviewTarget,
SiteSavedContentTarget,
WriteArtifactContext,
} from "../types";
type ImageWorkbenchRendererState = ReturnType<
typeof resolveImageWorkbenchRendererProcessState
>;
interface MessageAssistantBodyProps {
a2uiFormDataMap?: Record<string, { formId: string; formData: A2UIFormData }>;
actionContent: string;
collapseCodeBlocks?: boolean;
displayContent: string;
handleExpandHistoricalAssistantMessage: (messageId: string) => void;
handleExpandLongHistoricalMessage: (messageId: string) => void;
hasImageWorkbenchLeadContent: boolean;
historicalAssistantPreviewContent: string;
imageWorkbenchRendererState: ImageWorkbenchRendererState;
message: Message;
messageCanvasShortcutPath: string | null;
messageCanvasShortcutTitle: string;
messageSavedSiteContentTarget: SiteSavedContentTarget | null;
onA2UIFormChange?: (formId: string, formData: A2UIFormData) => void;
onA2UISubmit?: (formData: A2UIFormData, messageId: string) => void;
onCodeBlockClick?: (language: string, code: string) => void;
onFileClick?: (fileName: string, content: string) => void;
onOpenMessagePreview?: (
target: MessagePreviewTarget,
message: Message,
) => void;
onOpenSavedSiteContent?: (target: SiteSavedContentTarget) => void;
onPermissionResponse?: (response: ConfirmResponse) => void;
onQuoteMessage?: (content: string, id: string) => void;
onWriteFile?: (
content: string,
fileName: string,
context?: WriteArtifactContext,
) => void;
primaryTimeline: React.ReactNode;
promoteActionRequestsToA2UI: boolean;
readOnlyInteractiveContent: boolean;
renderA2UIInline: boolean;
rendererActionRequests: Message["actionRequests"] | undefined;
rendererContent: string;
rendererContentParts: Message["contentParts"] | undefined;
rendererMarkdownRenderMode: "light" | "standard";
rendererRawContent: string;
rendererThinkingContent: string | undefined;
rendererToolCalls: Message["toolCalls"] | undefined;
renderProposedPlanBlocks: boolean;
shouldCollapseCodeBlock?: (language: string, code: string) => boolean;
shouldCollapseLongHistoricalMessage: boolean;
shouldDeferHistoricalMarkdownRender: boolean;
shouldPreviewHistoricalAssistantMessage: boolean;
shouldRenderMessageCanvasShortcut: boolean;
shouldRenderPrimaryTimelineOutsideBubble: boolean;
shouldSuppressInlineA2UI: boolean;
shouldSuppressRendererProcessFlow: boolean;
suppressedActionRequestId: string | null;
}
export function MessageAssistantBody({
a2uiFormDataMap,
actionContent,
collapseCodeBlocks,
displayContent,
handleExpandHistoricalAssistantMessage,
handleExpandLongHistoricalMessage,
hasImageWorkbenchLeadContent,
historicalAssistantPreviewContent,
imageWorkbenchRendererState,
message,
messageCanvasShortcutPath,
messageCanvasShortcutTitle,
messageSavedSiteContentTarget,
onA2UIFormChange,
onA2UISubmit,
onCodeBlockClick,
onFileClick,
onOpenMessagePreview,
onOpenSavedSiteContent,
onPermissionResponse,
onQuoteMessage,
onWriteFile,
primaryTimeline,
promoteActionRequestsToA2UI,
readOnlyInteractiveContent,
renderA2UIInline,
rendererActionRequests,
rendererContent,
rendererContentParts,
rendererMarkdownRenderMode,
rendererRawContent,
rendererThinkingContent,
rendererToolCalls,
renderProposedPlanBlocks,
shouldCollapseCodeBlock,
shouldCollapseLongHistoricalMessage,
shouldDeferHistoricalMarkdownRender,
shouldPreviewHistoricalAssistantMessage,
shouldRenderMessageCanvasShortcut,
shouldRenderPrimaryTimelineOutsideBubble,
shouldSuppressInlineA2UI,
shouldSuppressRendererProcessFlow,
suppressedActionRequestId,
}: MessageAssistantBodyProps) {
return (
<>
{shouldRenderPrimaryTimelineOutsideBubble ? null : primaryTimeline}
{shouldPreviewHistoricalAssistantMessage ? (
<HistoricalAssistantMessagePreview
content={historicalAssistantPreviewContent}
contentLength={actionContent.length}
variant={shouldCollapseLongHistoricalMessage ? "long" : "compact"}
onExpand={() => {
if (shouldCollapseLongHistoricalMessage) {
handleExpandLongHistoricalMessage(message.id);
return;
}
handleExpandHistoricalAssistantMessage(message.id);
}}
/>
) : shouldDeferHistoricalMarkdownRender ? (
<HistoricalMarkdownHydrationPreview content={rendererContent} />
) : message.imageWorkbenchPreview ? (
hasImageWorkbenchLeadContent ||
imageWorkbenchRendererState.shouldRenderInlineProcess ? (
<StreamingRenderer
content={rendererContent}
rawContent={rendererRawContent}
isStreaming={message.isThinking}
showCursor={message.isThinking && !displayContent}
thinkingContent={imageWorkbenchRendererState.thinkingContent}
contentParts={imageWorkbenchRendererState.contentParts}
toolCalls={imageWorkbenchRendererState.toolCalls}
actionRequests={rendererActionRequests}
markdownRenderMode={rendererMarkdownRenderMode}
suppressProcessFlow={false}
showContentBlockActions={Boolean(actionContent)}
onQuoteContent={
onQuoteMessage
? (quotedContent) => onQuoteMessage(quotedContent, message.id)
: undefined
}
/>
) : null
) : (
<StreamingRenderer
content={rendererContent}
rawContent={rendererRawContent}
isStreaming={message.isThinking}
toolCalls={rendererToolCalls}
showCursor={message.isThinking && !displayContent}
thinkingContent={rendererThinkingContent}
runtimeStatus={message.runtimeStatus}
contentParts={rendererContentParts}
actionRequests={rendererActionRequests}
onA2UISubmit={
onA2UISubmit
? (formData) => onA2UISubmit(formData, message.id)
: undefined
}
a2uiFormId={a2uiFormDataMap?.[message.id]?.formId}
a2uiInitialFormData={a2uiFormDataMap?.[message.id]?.formData}
onA2UIFormChange={onA2UIFormChange}
renderA2UIInline={renderA2UIInline && !shouldSuppressInlineA2UI}
onWriteFile={
onWriteFile
? (content, fileName, context) =>
onWriteFile(content, fileName, {
...context,
sourceMessageId: context?.sourceMessageId || message.id,
source: context?.source || "message_content",
})
: undefined
}
onFileClick={onFileClick}
onOpenSavedSiteContent={onOpenSavedSiteContent}
onPermissionResponse={onPermissionResponse}
collapseCodeBlocks={collapseCodeBlocks}
shouldCollapseCodeBlock={shouldCollapseCodeBlock}
onCodeBlockClick={onCodeBlockClick}
promoteActionRequestsToA2UI={promoteActionRequestsToA2UI}
suppressedActionRequestId={suppressedActionRequestId}
showRuntimeStatusInline={true}
renderProposedPlanBlocks={renderProposedPlanBlocks}
suppressProcessFlow={shouldSuppressRendererProcessFlow}
showContentBlockActions={Boolean(actionContent)}
markdownRenderMode={rendererMarkdownRenderMode}
readOnlyA2UI={readOnlyInteractiveContent}
readOnlyActionRequests={readOnlyInteractiveContent}
onQuoteContent={
onQuoteMessage
? (quotedContent) => onQuoteMessage(quotedContent, message.id)
: undefined
}
/>
)}
{shouldRenderMessageCanvasShortcut ? (
<MessageCanvasShortcut
target={messageSavedSiteContentTarget!}
title={messageCanvasShortcutTitle}
path={messageCanvasShortcutPath}
onOpenSavedSiteContent={onOpenSavedSiteContent!}
/>
) : null}
<MessagePreviewCards
message={message}
hasImageWorkbenchLeadContent={hasImageWorkbenchLeadContent}
onOpenMessagePreview={onOpenMessagePreview}
/>
</>
);
}
@@ -0,0 +1,79 @@
import React from "react";
import { InputbarRuntimeStatusLine } from "./Inputbar/components/InputbarRuntimeStatusLine";
import {
TokenUsageDisplay,
type TokenUsagePromptCacheNotice,
} from "./TokenUsageDisplay";
import {
AssistantStreamingInlineIndicator,
MessageRuntimeStatusPill,
} from "./MessageListRuntimeStatus";
import type { MessageAssistantMetaFooterState } from "./messageAssistantMetaFooterState";
import { resolvePromptCacheActivity } from "../utils/tokenUsageSummary";
import type { InputbarRuntimeStatusLineModel } from "../utils/inputbarRuntimeStatusLine";
import type { Message } from "../types";
interface MessageAssistantMetaFooterProps {
activeConversationRuntimeStatusLine: InputbarRuntimeStatusLineModel | null;
hasAssistantBodyContent: boolean;
message: Message;
onInterruptCurrentTurn?: () => void | Promise<void>;
promptCacheNotice?: TokenUsagePromptCacheNotice | null;
providerType?: string;
state: MessageAssistantMetaFooterState;
tailRuntimeStatusLine: InputbarRuntimeStatusLineModel | null;
}
export function MessageAssistantMetaFooter({
activeConversationRuntimeStatusLine,
hasAssistantBodyContent,
message,
onInterruptCurrentTurn,
promptCacheNotice,
providerType,
state,
tailRuntimeStatusLine,
}: MessageAssistantMetaFooterProps) {
if (!state.hasAssistantMetaFooter) {
return null;
}
return (
<div
className={
hasAssistantBodyContent
? "mt-2 flex flex-wrap items-center gap-2"
: "flex flex-wrap items-center gap-2 px-1 py-0.5"
}
data-testid="assistant-message-meta-footer"
>
{state.shouldRenderTailRuntimeStatusLine ? (
<InputbarRuntimeStatusLine
runtime={tailRuntimeStatusLine || null}
providerType={providerType}
canStop={Boolean(onInterruptCurrentTurn)}
/>
) : null}
{state.shouldRenderActiveRuntimeFooterIndicator &&
activeConversationRuntimeStatusLine ? (
<AssistantStreamingInlineIndicator
runtime={activeConversationRuntimeStatusLine}
/>
) : null}
{state.shouldRenderStatusPill && message.runtimeStatus ? (
<MessageRuntimeStatusPill status={message.runtimeStatus} />
) : null}
{state.shouldRenderUsageFooter ? (
<TokenUsageDisplay
usage={message.usage!}
inline={true}
promptCacheNotice={
resolvePromptCacheActivity(message.usage!) <= 0
? promptCacheNotice
: undefined
}
/>
) : null}
</div>
);
}
@@ -0,0 +1,41 @@
import React from "react";
import { ExternalLink, FileText } from "lucide-react";
import type { SiteSavedContentTarget } from "../types";
interface MessageCanvasShortcutProps {
target: SiteSavedContentTarget;
title: string;
path?: string | null;
onOpenSavedSiteContent: (target: SiteSavedContentTarget) => void;
}
export function MessageCanvasShortcut({
target,
title,
path,
onOpenSavedSiteContent,
}: MessageCanvasShortcutProps) {
return (
<button
type="button"
className="mt-3 flex w-full items-center gap-3 rounded-2xl border border-emerald-200 bg-emerald-50/70 px-3 py-2.5 text-left transition-colors hover:bg-emerald-100/80"
data-testid="message-canvas-shortcut"
onClick={() => onOpenSavedSiteContent(target)}
>
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border border-emerald-200 bg-white text-emerald-700">
<FileText className="h-4 w-4" />
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium leading-6 text-emerald-900">
在画布中打开 {title}
</span>
{path ? (
<span className="block truncate text-xs leading-5 text-emerald-700/80">
{path}
</span>
) : null}
</span>
<ExternalLink className="h-4 w-4 shrink-0 text-emerald-700" />
</button>
);
}
@@ -0,0 +1,27 @@
import React from "react";
import type { Message } from "../types";
interface MessageImageAttachmentsProps {
images: Message["images"];
}
export function MessageImageAttachments({
images,
}: MessageImageAttachmentsProps) {
if (!images?.length) {
return null;
}
return (
<div className="flex flex-wrap gap-2">
{images.map((img, index) => (
<img
key={index}
src={`data:${img.mediaType};base64,${img.data}`}
className="max-w-xs rounded-lg border border-border"
alt="attachment"
/>
))}
</div>
);
}
@@ -4,7 +4,12 @@ import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { changeLimeLocale } from "@/i18n/createI18n";
import { MessageList } from "./MessageList";
import type { AgentThreadItem, AgentThreadTurn, Message } from "../types";
import type {
AgentThreadItem,
AgentThreadTurn,
Message,
MessagePreviewTarget,
} from "../types";
import {
clearAgentUiPerformanceMetrics,
getAgentUiPerformanceMetrics,
@@ -112,6 +117,8 @@ const mockStreamingRenderer = vi.fn(
showContentBlockActions,
onQuoteContent,
markdownRenderMode,
readOnlyA2UI,
readOnlyActionRequests,
}: {
content: string;
contentParts?: unknown[];
@@ -125,6 +132,8 @@ const mockStreamingRenderer = vi.fn(
showContentBlockActions?: boolean;
onQuoteContent?: (content: string) => void;
markdownRenderMode?: string;
readOnlyA2UI?: boolean;
readOnlyActionRequests?: boolean;
onOpenSavedSiteContent?: (target: {
projectId: string;
contentId: string;
@@ -143,6 +152,8 @@ const mockStreamingRenderer = vi.fn(
data-show-content-block-actions={showContentBlockActions ? "yes" : "no"}
data-has-on-quote-content={onQuoteContent ? "yes" : "no"}
data-markdown-render-mode={markdownRenderMode || "standard"}
data-read-only-a2ui={readOnlyA2UI ? "yes" : "no"}
data-read-only-action-requests={readOnlyActionRequests ? "yes" : "no"}
>
{content || "<empty-assistant>"}
</div>
@@ -181,6 +192,8 @@ vi.mock("./StreamingRenderer", () => ({
renderA2UIInline?: boolean;
suppressedActionRequestId?: string | null;
markdownRenderMode?: string;
readOnlyA2UI?: boolean;
readOnlyActionRequests?: boolean;
}) => mockStreamingRenderer(props),
}));
@@ -561,7 +574,9 @@ describe("MessageList", () => {
expect(container.textContent).toContain("消息 40");
expect(container.textContent).toContain("消息 31");
expect(container.textContent).not.toContain("消息 30");
expect(container.textContent).toContain("30 earlier messages can be expanded");
expect(container.textContent).toContain(
"30 earlier messages can be expanded",
);
act(() => {
vi.advanceTimersByTime(2_000);
@@ -783,7 +798,9 @@ describe("MessageList", () => {
'[data-testid="message-list-historical-timeline-preview:leading"]',
);
expect(deferredPreview).not.toBeNull();
expect(deferredPreview?.textContent).toContain("Expand to load execution details");
expect(deferredPreview?.textContent).toContain(
"Expand to load execution details",
);
const idleCommit = getAgentUiPerformanceMetrics()
.filter((entry) => entry.phase === "messageList.commit")
.find(
@@ -1324,9 +1341,15 @@ describe("MessageList", () => {
expect(container.textContent).toContain(
"When there are no chats yet, start from “New chat”. Results, materials, and intermediate steps will stay here later.",
);
expect(container.textContent).toContain("Chats to continue appear on the left first");
expect(container.textContent).toContain("Recent chats and archives are organized by time");
expect(container.textContent).toContain("Restoring sessions return here automatically");
expect(container.textContent).toContain(
"Chats to continue appear on the left first",
);
expect(container.textContent).toContain(
"Recent chats and archives are organized by time",
);
expect(container.textContent).toContain(
"Restoring sessions return here automatically",
);
expect(container.textContent).not.toContain("Start a new conversation");
});
@@ -2288,7 +2311,7 @@ describe("MessageList", () => {
const container = await renderZh(messages);
const previewCard = container.querySelector(
'[data-testid="image-workbench-message-preview-task-1"]',
) as HTMLButtonElement | null;
) as HTMLDivElement | null;
expect(previewCard?.textContent).toContain("图片生成");
expect(previewCard?.textContent).not.toContain("一颗戴耳机的青柠");
@@ -2297,11 +2320,9 @@ describe("MessageList", () => {
expect(container.textContent).not.toContain("图片生成已完成");
expect(previewCard?.className).not.toContain("max-w-[620px]");
expect(
previewCard
?.querySelector(
'[data-testid="image-workbench-message-preview-single-media-task-1"]',
)
?.className,
previewCard?.querySelector(
'[data-testid="image-workbench-message-preview-single-media-task-1"]',
)?.className,
).toContain("w-[358px]");
expect(previewCard?.querySelector("img")).not.toBeNull();
@@ -2358,15 +2379,13 @@ describe("MessageList", () => {
{
id: "msg-user-image-workbench-natural",
role: "user",
content:
"@Nanobanana Pro 生成一张广州塔,从花城汇看过去的春天的照片",
content: "@Nanobanana Pro 生成一张广州塔,从花城汇看过去的春天的照片",
timestamp: new Date(),
},
{
id: "msg-assistant-image-workbench-natural",
role: "assistant",
content:
"好啊,用 Nanobanana Pro 生成:一张广州塔,从花城汇看过去的春天的照片\n先获取下工具参数\n马上生成",
content: "收到,我按花城汇视角来生成广州塔的春天照片。",
timestamp: new Date(),
usage: {
input_tokens: 31_000,
@@ -2405,34 +2424,44 @@ describe("MessageList", () => {
imageUrl: "https://example.com/guangzhou-tower.png",
imageCount: 1,
modelName: "fal-ai/nano-banana-pro",
caption:
"搞定,图已经生成好了\n要调整的话直接说,我继续改",
caption: null,
},
},
];
const container = await renderZh(messages);
const text = container.textContent || "";
const intro = container.querySelector(
'[data-testid="image-workbench-assistant-intro"]',
const leadRenderer = container.querySelector(
'[data-testid="streaming-renderer"]',
);
expect(text).toContain("好啊,用 Nanobanana Pro");
expect(text).toContain("先获取下工具参数");
expect(text).toContain("马上生成");
expect(text).toContain("收到,我按花城汇视角来生成广州塔的春天照片。");
expect(text).not.toContain("先获取下工具参数");
expect(text).not.toContain("马上生成");
expect(text).toContain("图片生成");
expect(text).toContain("Nanobanana Pro");
expect(text).toContain("我继续改");
expect(text).not.toContain("我继续改");
expect(
container.querySelector('[data-testid="token-usage-display"]'),
).not.toBeNull();
expect(text).not.toContain("limeCreateImageGenerationTask");
expect(text).not.toContain("异步图片任务");
expect(
container.querySelector('[data-testid="image-workbench-assistant-header"]'),
container.querySelector(
'[data-testid="image-workbench-assistant-header"]',
),
).toBeNull();
expect(intro).not.toBeNull();
expect(intro?.textContent).toContain("先获取下工具参数");
expect(leadRenderer).not.toBeNull();
expect(mockStreamingRenderer).toHaveBeenCalledWith(
expect.objectContaining({
content: "收到,我按花城汇视角来生成广州塔的春天照片。",
suppressProcessFlow: false,
toolCalls: expect.arrayContaining([
expect.objectContaining({ id: "tool-image-natural" }),
]),
contentParts: undefined,
}),
);
expect(
container.querySelector('[data-testid="message-user-command-tag"]')
?.textContent,
@@ -2441,9 +2470,7 @@ describe("MessageList", () => {
container.querySelector('[data-testid="message-user-command-content"]')
?.textContent,
).toContain("广州塔");
expect(container.querySelector('[data-testid="streaming-renderer"]')).toBe(
null,
);
expect(leadRenderer).not.toBeNull();
});
it("同一会话连续两次图片生成应分别保留用户指令、自然铺垫和对应轻卡", async () => {
@@ -2458,14 +2485,14 @@ describe("MessageList", () => {
{
id: "msg-assistant-image-turn-1",
role: "assistant",
content: "好啊,先生成广州塔春天照片\n先获取下工具参数\n马上生成",
content: "我先生成广州塔春天照片,保留春天的光线和城市视角。",
timestamp: new Date(now.getTime() + 1_000),
thinkingContent: "先判断广州塔照片的季节和视角。",
contentParts: [
{ type: "thinking", text: "先判断广州塔照片的季节和视角。" },
{
type: "text",
text: "好啊,先生成广州塔春天照片\n先获取下工具参数\n马上生成",
text: "我先生成广州塔春天照片,保留春天的光线和城市视角。",
},
],
imageWorkbenchPreview: {
@@ -2488,14 +2515,14 @@ describe("MessageList", () => {
{
id: "msg-assistant-image-turn-2",
role: "assistant",
content: "好啊,再生成青柠极简插画\n先获取下工具参数\n马上生成",
content: "这次换成青柠极简插画,我会把画面压得更干净。",
timestamp: new Date(now.getTime() + 3_000),
thinkingContent: "再判断青柠插画的极简构图。",
contentParts: [
{ type: "thinking", text: "再判断青柠插画的极简构图。" },
{
type: "text",
text: "好啊,再生成青柠极简插画\n先获取下工具参数\n马上生成",
text: "这次换成青柠极简插画,我会把画面压得更干净。",
},
],
imageWorkbenchPreview: {
@@ -2515,22 +2542,18 @@ describe("MessageList", () => {
const commandTags = Array.from(
container.querySelectorAll('[data-testid="message-user-command-tag"]'),
).map((node) => node.textContent);
const intros = Array.from(
container.querySelectorAll('[data-testid="image-workbench-assistant-intro"]'),
).map((node) => node.textContent || "");
const leadTexts = mockStreamingRenderer.mock.calls
.map((call) => call[0].content as string | undefined)
.filter((content): content is string => Boolean(content));
expect(commandTags).toEqual(["@配图", "@配图"]);
expect(intros).toHaveLength(2);
expect(intros[0]).toContain("广州塔春天照片");
expect(intros[0]).not.toContain("青柠极简插画");
expect(intros[1]).toContain("青柠极简插画");
expect(intros[1]).not.toContain("广州塔春天照片");
expect(leadTexts).toEqual([
"我先生成广州塔春天照片,保留春天的光线和城市视角。",
"这次换成青柠极简插画,我会把画面压得更干净。",
]);
expect(
mockStreamingRenderer.mock.calls.map((call) => call[0].thinkingContent),
).toEqual([
"先判断广州塔照片的季节和视角。",
"再判断青柠插画的极简构图。",
]);
).toEqual(["先判断广州塔照片的季节和视角。", "再判断青柠插画的极简构图。"]);
expect(
mockStreamingRenderer.mock.calls.every(
(call) => !call[0].toolCalls && !call[0].contentParts,
@@ -2570,9 +2593,7 @@ describe("MessageList", () => {
);
expect(skillTag?.textContent).toContain("@");
expect(skillTag?.textContent).toContain(
"brand-product-knowledge-builder",
);
expect(skillTag?.textContent).toContain("brand-product-knowledge-builder");
expect(
container.querySelector('[data-testid="message-user-command-tag"]'),
).toBeNull();
@@ -2666,7 +2687,7 @@ describe("MessageList", () => {
);
});
it("图片任务消息应保留思考但收起内部工具 process flow", () => {
it("图片任务消息应保留思考并把内部工具过程折叠到同一回复里", () => {
const container = render(
[
{
@@ -2747,9 +2768,12 @@ describe("MessageList", () => {
expect(rendererProps).toMatchObject({
content: "",
thinkingContent: "先执行图片技能。",
suppressProcessFlow: false,
});
expect(rendererProps?.contentParts).toBeUndefined();
expect(rendererProps?.toolCalls).toBeUndefined();
expect(rendererProps?.toolCalls).toEqual([
expect.objectContaining({ id: "tool-image-skill" }),
]);
expect(
container.querySelector(
'[data-testid="image-workbench-message-preview-task-image-process-flow"]',
@@ -2760,7 +2784,7 @@ describe("MessageList", () => {
).toBeNull();
});
it("旧图片提交过程消息没有轻卡时也应整体隐藏", () => {
it("旧图片提交过程消息没有轻卡时应隐藏协议正文并折叠保留过程", () => {
const container = render(
[
{
@@ -2821,7 +2845,27 @@ describe("MessageList", () => {
expect(container.textContent).not.toContain("图片生成任务已提交");
expect(container.textContent).not.toContain("工具输入");
expect(mockStreamingRenderer).not.toHaveBeenCalled();
expect(mockStreamingRenderer).toHaveBeenCalledTimes(1);
const rendererProps = mockStreamingRenderer.mock.calls[0]?.[0] as
| {
content?: string;
contentParts?: unknown[];
rawContent?: string;
suppressProcessFlow?: boolean;
toolCalls?: unknown[];
}
| undefined;
expect(rendererProps).toMatchObject({
content: "",
rawContent: "",
suppressProcessFlow: false,
});
expect(rendererProps?.contentParts).toEqual([
{ type: "thinking", text: "开始中 广州塔春天照片" },
]);
expect(rendererProps?.toolCalls).toEqual([
expect.objectContaining({ id: "tool-image-generate" }),
]);
expect(
container.querySelector('[data-testid="agent-thread-timeline:leading"]'),
).toBeNull();
@@ -3509,7 +3553,7 @@ describe("MessageList", () => {
).toBeNull();
});
it("失败的图片任务卡应保留错误文案,即使原状态标记不可重试也提供单独重试入口", async () => {
it("失败的图片任务卡不暴露底层错误,即使原状态标记不可重试也提供单独重试入口", async () => {
const now = new Date();
const messages: Message[] = [
{
@@ -3535,7 +3579,7 @@ describe("MessageList", () => {
);
expect(previewCard?.textContent).toContain("生成失败");
expect(previewCard?.textContent).toContain(
expect(previewCard?.textContent).not.toContain(
"FAL 请求参数无效,请先调整配置。",
);
expect(previewCard?.textContent).not.toContain("不可重试");
@@ -3608,7 +3652,7 @@ describe("MessageList", () => {
const container = render(messages);
const previewCard = container.querySelector(
'[data-testid="image-workbench-message-preview-task-open-1"]',
) as HTMLButtonElement | null;
) as HTMLDivElement | null;
act(() => {
previewCard?.click();
@@ -3689,6 +3733,59 @@ describe("MessageList", () => {
expect(grid?.textContent).not.toContain("9");
});
it("点击九宫格后面的图片时应把具体图片选择传给工作台", async () => {
const now = new Date();
const onOpenMessagePreview = vi.fn();
const messages: Message[] = [
{
id: "msg-assistant-image-workbench-storyboard-select",
role: "assistant",
content: "已生成章节配图。",
timestamp: now,
imageWorkbenchPreview: {
taskId: "task-storyboard-select",
prompt: "章节配图",
status: "complete",
imageCount: 3,
imageUrl: "https://example.com/chapter-1.png",
previewImages: [
"https://example.com/chapter-1.png",
"https://example.com/chapter-2.png",
"https://example.com/chapter-3.png",
],
layoutHint: "storyboard_3x3",
projectId: "project-1",
contentId: "content-1",
},
},
];
const container = await renderZh(messages, { onOpenMessagePreview });
const secondImageButton = container.querySelector(
'[data-testid="image-workbench-message-preview-media-task-storyboard-select-2"]',
) as HTMLButtonElement | null;
act(() => {
secondImageButton?.click();
});
const [target, message] = onOpenMessagePreview.mock.calls[0] as [
MessagePreviewTarget,
Message,
];
expect(message.id).toBe("msg-assistant-image-workbench-storyboard-select");
expect(target).toEqual({
kind: "image_workbench",
preview: expect.objectContaining({
taskId: "task-storyboard-select",
}),
selection: {
imageUrl: "https://example.com/chapter-2.png",
imageIndex: 1,
},
});
});
it("当前由聊天区底部承载的 assistant A2UI 不应继续在正文里内联渲染", () => {
const now = new Date();
const messages: Message[] = [
@@ -3744,6 +3841,81 @@ describe("MessageList", () => {
);
});
it("非活动历史 assistant A2UI 与 action_request 应只读回显,不能再次提交", () => {
const now = new Date();
const messages: Message[] = [
{
id: "msg-assistant-history-a2ui",
role: "assistant",
content: "```a2ui\n{}\n```",
timestamp: now,
actionRequests: [
{
requestId: "req-history-ask",
actionType: "ask_user",
status: "pending",
prompt: "请选择执行方式",
questions: [
{
question: "请选择执行方式",
options: [{ label: "直接执行" }, { label: "稍后处理" }],
},
],
},
],
},
];
render(messages);
expect(mockStreamingRenderer).toHaveBeenCalledWith(
expect.objectContaining({
readOnlyA2UI: true,
readOnlyActionRequests: true,
}),
);
});
it("当前活动 assistant action_request 仍保持可提交,不降级为历史只读", () => {
const now = new Date();
const messages: Message[] = [
{
id: "msg-assistant-current-action",
role: "assistant",
content: "请先确认执行方式。",
timestamp: now,
actionRequests: [
{
requestId: "req-current-ask",
actionType: "ask_user",
status: "pending",
prompt: "请选择执行方式",
questions: [{ question: "请选择执行方式" }],
},
],
},
];
render(messages, {
pendingActions: [
{
requestId: "req-current-ask",
actionType: "ask_user",
status: "pending",
prompt: "请选择执行方式",
questions: [{ question: "请选择执行方式" }],
},
],
});
expect(mockStreamingRenderer).toHaveBeenCalledWith(
expect.objectContaining({
readOnlyA2UI: false,
readOnlyActionRequests: false,
}),
);
});
it("应向助手消息正文透传已保存站点内容打开回调", () => {
const onOpenSavedSiteContent = vi.fn();
const now = new Date();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,131 @@
import type React from "react";
import type { Artifact } from "@/lib/artifact/types";
import type { A2UIFormData } from "@/lib/workspace/a2ui";
import type {
AsterSubagentSessionInfo,
AgentRuntimeThreadReadModel,
QueuedTurnSnapshot,
} from "@/lib/api/agentRuntime";
import type { ArtifactTimelineOpenTarget } from "../utils/artifactTimelineNavigation";
import type { buildMessageRenderGroupsProjection } from "../projection/messageTimelineRenderProjection";
import type {
ActionRequired,
AgentThreadItem,
AgentThreadTurn,
ConfirmResponse,
Message,
MessagePreviewTarget,
PendingA2UISource,
SiteSavedContentTarget,
WriteArtifactContext,
} from "../types";
export type MessageListRenderGroup = ReturnType<
typeof buildMessageRenderGroupsProjection
>[number];
export interface MessageListProps {
sessionId?: string | null;
messages: Message[];
leadingContent?: React.ReactNode;
emptyStateVariant?: "default" | "task-center";
turns?: AgentThreadTurn[];
threadItems?: AgentThreadItem[];
currentTurnId?: string | null;
threadRead?: AgentRuntimeThreadReadModel | null;
pendingActions?: ActionRequired[];
submittedActionsInFlight?: ActionRequired[];
queuedTurns?: QueuedTurnSnapshot[];
childSubagentSessions?: AsterSubagentSessionInfo[];
sessionHistoryWindow?: {
loadedMessages: number;
totalMessages: number;
isLoadingFull: boolean;
error?: string | null;
} | null;
onLoadFullHistory?: () => void | Promise<void>;
isSending?: boolean;
assistantLabel?: string;
onDeleteMessage?: (id: string) => void;
onEditMessage?: (id: string, content: string) => void;
onQuoteMessage?: (content: string, id: string) => void;
/** A2UI 表单提交回调 */
onA2UISubmit?: (formData: A2UIFormData, messageId: string) => void;
/** 是否渲染消息内联 A2UI */
renderA2UIInline?: boolean;
/** A2UI 表单数据映射(按消息 ID 索引) */
a2uiFormDataMap?: Record<string, { formId: string; formData: A2UIFormData }>;
/** A2UI 表单数据变化回调(用于持久化) */
onA2UIFormChange?: (formId: string, formData: A2UIFormData) => void;
/** 文件写入回调 */
onWriteFile?: (
content: string,
fileName: string,
context?: WriteArtifactContext,
) => void;
/** 文件点击回调 */
onFileClick?: (fileName: string, content: string) => void;
/** 时间线内 artifact 精确跳转 */
onOpenArtifactFromTimeline?: (target: ArtifactTimelineOpenTarget) => void;
/** 打开站点能力已保存内容 */
onOpenSavedSiteContent?: (target: SiteSavedContentTarget) => void;
/** Artifact 点击回调 */
onArtifactClick?: (artifact: Artifact) => void;
/** 打开消息结果预览 */
onOpenMessagePreview?: (
target: MessagePreviewTarget,
message: Message,
) => void;
/** 将助手结果沉淀为技能草稿 */
onSaveMessageAsSkill?: (source: {
messageId: string;
content: string;
}) => void;
/** 将助手结果沉淀到灵感库 */
onSaveMessageAsInspiration?: (source: {
messageId: string;
content: string;
}) => void;
/** 将助手结果保存到项目资料 */
onSaveMessageAsKnowledge?: (source: {
messageId: string;
content: string;
sourceName?: string;
description?: string | null;
}) => void;
/** 打开子代理会话 */
onOpenSubagentSession?: (sessionId: string) => void;
/** 权限确认响应回调 */
onPermissionResponse?: (response: ConfirmResponse) => void;
/** 是否折叠代码块(当画布打开时) */
collapseCodeBlocks?: boolean;
/** 按代码块决定是否折叠 */
shouldCollapseCodeBlock?: (language: string, code: string) => boolean;
/** 代码块点击回调(用于在画布中显示) */
onCodeBlockClick?: (language: string, code: string) => void;
/** 是否将待处理问答提升为输入区 A2UI 表单 */
promoteActionRequestsToA2UI?: boolean;
/** 会话是否仍在自动恢复 */
isRestoringSession?: boolean;
/** 中断当前执行 */
onInterruptCurrentTurn?: () => void | Promise<void>;
/** 恢复当前线程排队执行 */
onResumeThread?: () => boolean | Promise<boolean>;
/** 重新拉起当前最重要的待处理请求 */
onReplayPendingRequest?: (
requestId: string,
assistantMessageId: string,
) => boolean | Promise<boolean>;
/** 立即恢复下一条排队回合 */
onPromoteQueuedTurn?: (queuedTurnId: string) => boolean | Promise<boolean>;
/** 是否压缩左侧留白,适用于工作台右栏 */
compactLeadingSpacing?: boolean;
/** 需要高亮的 timeline item */
focusedTimelineItemId?: string | null;
/** 触发 timeline item 聚焦的请求序号 */
timelineFocusRequestKey?: number;
/** 当前由聊天区底部承载的待处理 A2UI 来源 */
activePendingA2UISource?: PendingA2UISource | null;
/** 当前会话的 provider 选择器 */
providerType?: string;
}
@@ -0,0 +1,138 @@
import React, { useMemo } from "react";
import { useTranslation } from "react-i18next";
import type { AgentThreadItem } from "../types";
import {
formatHistoricalContentLength,
summarizeHistoricalTimelineItems,
} from "./messageListHistoricalPreviewText";
interface HistoricalAssistantMessagePreviewProps {
content: string;
contentLength: number;
variant: "compact" | "long";
onExpand: () => void;
}
export function HistoricalAssistantMessagePreview({
content,
contentLength,
variant,
onExpand,
}: HistoricalAssistantMessagePreviewProps) {
const { t } = useTranslation("agent");
const isLong = variant === "long";
const noticeKey = isLong
? "agentChat.messageList.historicalAssistantPreview.longNotice"
: "agentChat.messageList.historicalAssistantPreview.compactNotice";
return (
<div
data-testid={
isLong
? "message-list-long-history-preview"
: "message-list-historical-assistant-preview"
}
data-preview-variant={variant}
className="space-y-3"
>
<div className="whitespace-pre-wrap break-words text-[15px] leading-7 text-slate-800">
{content}
</div>
<div className="flex flex-wrap items-center justify-between gap-2 rounded-2xl border border-slate-200/80 bg-slate-50/80 px-3 py-2 text-sm text-slate-600">
<span>
{t(noticeKey, {
countLabel: formatHistoricalContentLength(contentLength),
})}
</span>
<button
type="button"
className="rounded-full border border-slate-200 bg-white px-3 py-1.5 text-xs font-medium text-slate-700 transition-colors hover:bg-slate-100"
onClick={onExpand}
>
{t("agentChat.messageList.historicalAssistantPreview.expandFull")}
</button>
</div>
</div>
);
}
export const HistoricalMarkdownHydrationPreview: React.FC<{
content: string;
}> = ({ content }) => (
<div
data-testid="message-list-historical-markdown-preview"
className="whitespace-pre-wrap break-words text-[15px] leading-7 text-slate-800"
>
{content}
</div>
);
export const HistoricalTimelinePreview: React.FC<{
items: AgentThreadItem[];
placement: "leading" | "trailing" | "default";
detailsDeferred?: boolean;
onExpand: () => void;
}> = ({ items, placement, detailsDeferred = false, onExpand }) => {
const { t } = useTranslation("agent");
const summary = useMemo(
() => summarizeHistoricalTimelineItems(items),
[items],
);
if (summary.stepsCount <= 0 && !detailsDeferred) {
return null;
}
const metaParts = [
summary.toolStepsCount > 0
? t("agentChat.messageList.historicalTimeline.toolSteps", {
countLabel: formatHistoricalContentLength(summary.toolStepsCount),
})
: null,
summary.thinkingStepsCount > 0
? t("agentChat.messageList.historicalTimeline.thinkingSteps", {
countLabel: formatHistoricalContentLength(
summary.thinkingStepsCount,
),
})
: null,
summary.artifactStepsCount > 0
? t("agentChat.messageList.historicalTimeline.artifactSteps", {
countLabel: formatHistoricalContentLength(
summary.artifactStepsCount,
),
})
: null,
].filter((part): part is string => Boolean(part));
const summaryMetaText =
metaParts.length > 0
? metaParts.join(t("agentChat.messageList.historicalTimeline.separator"))
: t("agentChat.messageList.historicalTimeline.foldedMeta");
const metaText =
summary.stepsCount > 0
? t("agentChat.messageList.historicalTimeline.meta", {
stepCountLabel: formatHistoricalContentLength(summary.stepsCount),
meta: summaryMetaText,
})
: t("agentChat.messageList.historicalTimeline.deferredMeta");
return (
<button
type="button"
data-testid={`message-list-historical-timeline-preview:${placement}`}
className="flex w-full items-start justify-between gap-3 rounded-2xl border border-slate-200/80 bg-slate-50/80 px-3 py-2.5 text-left text-sm text-slate-600 transition-colors hover:border-slate-300 hover:bg-slate-100/80"
onClick={onExpand}
>
<span className="min-w-0 flex-1">
<span className="block font-medium text-slate-800">
{t("agentChat.messageList.historicalTimeline.title")}
</span>
<span className="mt-0.5 block text-xs leading-5 text-slate-500">
{metaText}
</span>
</span>
<span className="shrink-0 rounded-full border border-slate-200 bg-white px-2.5 py-1 text-xs font-medium text-slate-700">
{t("agentChat.messageList.historicalTimeline.expand")}
</span>
</button>
);
};
@@ -0,0 +1,192 @@
import React from "react";
import { Loader2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { formatNumber } from "@/i18n/format";
import { LIME_BRAND_LOGO_SRC, LIME_BRAND_NAME } from "@/lib/branding";
function formatContentLength(value: number): string {
return formatNumber(value);
}
interface PersistedHistoryWindowProps {
loadedMessages: number;
totalMessages: number;
isLoadingFull?: boolean;
error?: string | null;
onLoadFullHistory?: () => void | Promise<void>;
}
export function PersistedHistoryWindow({
loadedMessages,
totalMessages,
isLoadingFull = false,
error = null,
onLoadFullHistory,
}: PersistedHistoryWindowProps) {
const { t } = useTranslation("agent");
return (
<div
data-testid="message-list-persisted-history-window"
className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-slate-200/80 bg-slate-50/80 px-4 py-3 text-sm text-slate-600"
>
<div className="min-w-0 flex-1">
{t("agentChat.messageList.history.persistedSummary", {
loaded: formatContentLength(loadedMessages),
total: formatContentLength(totalMessages),
})}
{error ? <span className="ml-2 text-red-600">{error}</span> : null}
</div>
<button
type="button"
data-testid="message-list-load-full-history"
className="shrink-0 rounded-full border border-slate-200 bg-white px-3 py-1.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-60"
disabled={isLoadingFull || !onLoadFullHistory}
onClick={() => {
void onLoadFullHistory?.();
}}
>
{isLoadingFull
? t("agentChat.messageList.history.loadingMore")
: t("agentChat.messageList.history.loadMore")}
</button>
</div>
);
}
interface HistoryWindowProps {
hiddenHistoryCount: number;
isRestoredHistoryWindow: boolean;
renderedMessagesCount: number;
onExpandAllHistory: () => void;
}
export function HistoryWindow({
hiddenHistoryCount,
isRestoredHistoryWindow,
renderedMessagesCount,
onExpandAllHistory,
}: HistoryWindowProps) {
const { t } = useTranslation("agent");
return (
<div
data-testid="message-list-history-window"
className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-slate-200/80 bg-slate-50/80 px-4 py-3 text-sm text-slate-600"
>
<div className="min-w-0 flex-1">
{t(
isRestoredHistoryWindow
? "agentChat.messageList.history.windowSummaryRestored"
: "agentChat.messageList.history.windowSummaryDeferred",
{
loaded: formatContentLength(renderedMessagesCount),
hidden: formatContentLength(hiddenHistoryCount),
},
)}
</div>
<button
type="button"
data-testid="message-list-expand-history"
className="shrink-0 rounded-full border border-slate-200 bg-white px-3 py-1.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-100"
onClick={onExpandAllHistory}
>
{t("agentChat.messageList.history.expandEarlier")}
</button>
</div>
);
}
export function RestoringSessionEmptyState() {
const { t } = useTranslation("agent");
return (
<div
className="flex h-64 flex-col items-center justify-center gap-3 text-muted-foreground"
data-testid="message-list-restoring-session"
role="status"
aria-live="polite"
>
<div className="flex h-12 w-12 items-center justify-center rounded-full border border-border/70 bg-background/80 shadow-sm">
<Loader2 className="h-5 w-5 animate-spin" />
</div>
<div className="space-y-1 text-center">
<p className="text-lg font-medium text-foreground">
{t("agentChat.messageList.restoring.title")}
</p>
<p className="text-sm text-muted-foreground">
{t("agentChat.messageList.restoring.description")}
</p>
</div>
</div>
);
}
export function TaskCenterEmptyState() {
const { t } = useTranslation("agent");
return (
<div className="flex min-h-[24rem] items-center justify-center py-8">
<section
data-testid="message-list-empty-task-center"
className="w-full max-w-[760px] rounded-[30px] border border-slate-200/80 bg-white px-6 py-7 text-left shadow-sm shadow-slate-950/5 md:px-8 md:py-8"
>
<div className="flex items-start gap-4">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-[18px] border border-slate-200/80 bg-slate-50/80">
<img
src={LIME_BRAND_LOGO_SRC}
alt={LIME_BRAND_NAME}
className="h-7 w-7 opacity-80"
/>
</div>
<div className="min-w-0 flex-1">
<span className="inline-flex items-center rounded-full border border-slate-200/80 bg-slate-50 px-3 py-1 text-xs font-medium text-slate-600">
{t("agentChat.messageList.taskCenterEmpty.badge")}
</span>
<div className="mt-4 space-y-2">
<h2 className="text-[32px] font-semibold tracking-tight text-slate-900 md:text-[36px]">
{t("agentChat.messageList.taskCenterEmpty.title")}
</h2>
<p className="max-w-[48rem] text-[15px] leading-7 text-slate-600">
{t("agentChat.messageList.taskCenterEmpty.description")}
</p>
<p className="text-sm leading-7 text-slate-500">
{t("agentChat.messageList.taskCenterEmpty.helper")}
</p>
</div>
</div>
</div>
<div className="mt-6 flex flex-wrap gap-2">
<span className="inline-flex items-center rounded-full border border-slate-200/80 bg-white px-3 py-1.5 text-xs text-slate-500">
{t("agentChat.messageList.taskCenterEmpty.chip.pending")}
</span>
<span className="inline-flex items-center rounded-full border border-slate-200/80 bg-white px-3 py-1.5 text-xs text-slate-500">
{t("agentChat.messageList.taskCenterEmpty.chip.organized")}
</span>
<span className="inline-flex items-center rounded-full border border-slate-200/80 bg-white px-3 py-1.5 text-xs text-slate-500">
{t("agentChat.messageList.taskCenterEmpty.chip.restore")}
</span>
</div>
</section>
</div>
);
}
export function DefaultConversationEmptyState() {
const { t } = useTranslation("agent");
return (
<div className="flex h-64 flex-col items-center justify-center text-muted-foreground opacity-50">
<img
src={LIME_BRAND_LOGO_SRC}
alt={LIME_BRAND_NAME}
className="mb-4 h-12 w-12 opacity-20"
/>
<p className="text-lg font-medium">
{t("agentChat.messageList.empty.defaultTitle")}
</p>
</div>
);
}
@@ -0,0 +1,483 @@
import React from "react";
import type { Artifact } from "@/lib/artifact/types";
import type { A2UIFormData } from "@/lib/workspace/a2ui";
import type { AgentRuntimeThreadReadModel } from "@/lib/api/agentRuntime";
import { MessageWrapper, ContentColumn, MessageBubble } from "../styles";
import { type TokenUsagePromptCacheNotice } from "./TokenUsageDisplay";
import type { InputbarRuntimeStatusLineModel } from "../utils/inputbarRuntimeStatusLine";
import type { ArtifactTimelineOpenTarget } from "../utils/artifactTimelineNavigation";
import type { AgentStreamTextOverlaySnapshot } from "../hooks/agentStreamTextOverlayStore";
import type {
ConfirmResponse,
Message,
MessagePreviewTarget,
PendingA2UISource,
SiteSavedContentTarget,
WriteArtifactContext,
} from "../types";
import { AssistantFirstTokenRuntimeStatus } from "./MessageListRuntimeStatus";
import { MessageArtifactCards } from "./MessageArtifactCards";
import { MessageActionButtons } from "./MessageActionButtons";
import { MessageImageAttachments } from "./MessageImageAttachments";
import { MessageAssistantBody } from "./MessageAssistantBody";
import { MessageAssistantMetaFooter } from "./MessageAssistantMetaFooter";
import {
resolveMessageAssistantMetaFooterState,
} from "./messageAssistantMetaFooterState";
import { MessageTimelineSection } from "./MessageTimelineSection";
import { MessageUserBody } from "./MessageUserBody";
import type { MessageListRenderGroup } from "./MessageList.types";
import { resolveMessageListItemProjection } from "./messageListItemProjection";
export interface MessageListItemProps {
msg: Message;
group: MessageListRenderGroup;
streamingTextOverlay?: AgentStreamTextOverlaySnapshot | null;
activeConversationRuntimeStatusLine: InputbarRuntimeStatusLineModel | null;
activeCurrentTurnId: string | null;
activePendingA2UISource: PendingA2UISource | null;
assistantLabel: string;
a2uiFormDataMap?: Record<string, { formId: string; formData: A2UIFormData }>;
collapseCodeBlocks?: boolean;
compactLeadingSpacing: boolean;
copiedId: string | null;
expandedHistoricalAssistantMessageIds: Set<string>;
expandedHistoricalTimelineKeys: Set<string>;
expandedLongHistoricalMessageIds: Set<string>;
focusedTimelineItemId?: string | null;
hasActiveInteractiveRuntime: boolean;
isRestoredHistoryWindow: boolean;
isSending: boolean;
lastAssistantMessageId: string | null;
promoteActionRequestsToA2UI: boolean;
promptCacheNotice?: TokenUsagePromptCacheNotice | null;
providerType?: string;
renderA2UIInline: boolean;
shouldDeferHistoricalAssistantMessageDetails: (message: Message) => boolean;
shouldDeferHistoricalTimelineDetails: boolean;
shouldDeferThreadItemsScan: boolean;
shouldCollapseCodeBlock?: (language: string, code: string) => boolean;
tailRuntimeStatusLine: InputbarRuntimeStatusLineModel | null;
threadRead: AgentRuntimeThreadReadModel | null;
timelineFocusRequestKey: number;
handleCopy: (content: string, id: string) => void | Promise<void>;
handleExpandHistoricalAssistantMessage: (messageId: string) => void;
handleExpandHistoricalTimeline: (timelineKey: string) => void;
handleExpandLongHistoricalMessage: (messageId: string) => void;
onA2UIFormChange?: (formId: string, formData: A2UIFormData) => void;
onA2UISubmit?: (formData: A2UIFormData, messageId: string) => void;
onArtifactClick?: (artifact: Artifact) => void;
onCodeBlockClick?: (language: string, code: string) => void;
onFileClick?: (fileName: string, content: string) => void;
onInterruptCurrentTurn?: () => void | Promise<void>;
onOpenArtifactFromTimeline?: (target: ArtifactTimelineOpenTarget) => void;
onOpenMessagePreview?: (
target: MessagePreviewTarget,
message: Message,
) => void;
onOpenSavedSiteContent?: (target: SiteSavedContentTarget) => void;
onOpenSubagentSession?: (sessionId: string) => void;
onPermissionResponse?: (response: ConfirmResponse) => void;
onQuoteMessage?: (content: string, id: string) => void;
onSaveMessageAsInspiration?: (source: {
messageId: string;
content: string;
}) => void;
onSaveMessageAsKnowledge?: (source: {
messageId: string;
content: string;
sourceName?: string;
description?: string | null;
}) => void;
onSaveMessageAsSkill?: (source: {
messageId: string;
content: string;
}) => void;
onWriteFile?: (
content: string,
fileName: string,
context?: WriteArtifactContext,
) => void;
}
export function MessageListItem({
msg,
group,
streamingTextOverlay = null,
activeConversationRuntimeStatusLine,
activeCurrentTurnId,
activePendingA2UISource,
assistantLabel,
a2uiFormDataMap,
collapseCodeBlocks,
compactLeadingSpacing,
copiedId,
expandedHistoricalAssistantMessageIds,
expandedHistoricalTimelineKeys,
expandedLongHistoricalMessageIds,
focusedTimelineItemId,
hasActiveInteractiveRuntime,
isRestoredHistoryWindow,
isSending,
lastAssistantMessageId,
promoteActionRequestsToA2UI,
promptCacheNotice,
providerType,
renderA2UIInline,
shouldCollapseCodeBlock,
shouldDeferHistoricalAssistantMessageDetails,
shouldDeferHistoricalTimelineDetails,
shouldDeferThreadItemsScan,
tailRuntimeStatusLine,
threadRead,
timelineFocusRequestKey,
handleCopy,
handleExpandHistoricalAssistantMessage,
handleExpandHistoricalTimeline,
handleExpandLongHistoricalMessage,
onA2UIFormChange,
onA2UISubmit,
onArtifactClick,
onCodeBlockClick,
onFileClick,
onInterruptCurrentTurn,
onOpenArtifactFromTimeline,
onOpenMessagePreview,
onOpenSavedSiteContent,
onOpenSubagentSession,
onPermissionResponse,
onQuoteMessage,
onSaveMessageAsInspiration,
onSaveMessageAsKnowledge,
onSaveMessageAsSkill,
onWriteFile,
}: MessageListItemProps) {
const projection = resolveMessageListItemProjection({
activeCurrentTurnId,
activePendingA2UISource,
canOpenSavedSiteContent: Boolean(onOpenSavedSiteContent),
expandedHistoricalAssistantMessageIds,
expandedHistoricalTimelineKeys,
expandedLongHistoricalMessageIds,
focusedTimelineItemId,
group,
hasActiveInteractiveRuntime,
isRestoredHistoryWindow,
isSending,
lastAssistantMessageId,
message: msg,
shouldDeferHistoricalAssistantMessageDetails,
shouldDeferThreadItemsScan,
streamingTextOverlay,
});
const {
actionContent,
arePrimaryTimelineDetailsDeferred,
canCopyMessage,
displayContent,
hasAssistantBodyContent,
hasImageWorkbenchLeadContent,
historicalAssistantPreviewContent,
imageWorkbenchRendererState,
inlineProcessCoverage,
installedSkillMessageLabel,
isConversationTailAssistant,
isUserCommandMessage,
knowledgeArtifactSource,
messageCanvasShortcutPath,
messageCanvasShortcutTitle,
messageSavedSiteContentTarget,
primaryActionRequests,
primaryTimeline,
primaryTimelineKey,
rawRuntimePeerContent,
rendererActionRequests,
rendererContent,
rendererContentParts,
rendererMarkdownRenderMode,
rendererRawContent,
rendererThinkingContent,
rendererToolCalls,
shouldCollapseLongHistoricalMessage,
shouldDeferHistoricalMarkdownRender,
shouldPreviewHistoricalAssistantMessage,
shouldReadOnlyInteractiveContent,
shouldRenderCompactPrimaryTimeline,
shouldRenderFirstTokenRuntimeStatus,
shouldRenderImageWorkbenchBareBubble,
shouldRenderMessageCanvasShortcut,
shouldRenderPrimaryTimelineOutsideBubble,
shouldRenderRuntimePeerCards,
shouldSuppressInlineA2UI,
shouldSuppressRendererProcessFlow,
shouldSuppressStandaloneImageWorkbenchProcess,
suppressedActionRequestId,
trailingActionRequests,
trailingTimeline,
visibleAssistantArtifacts,
} = projection;
const canQuoteMessage = Boolean(onQuoteMessage && projection.canQuoteMessage);
const canSaveMessageAsSkill = Boolean(
onSaveMessageAsSkill && projection.canSaveMessageAsSkill,
);
const canSaveMessageAsInspiration = Boolean(
onSaveMessageAsInspiration && projection.canSaveMessageAsInspiration,
);
const canSaveMessageAsKnowledge = Boolean(
onSaveMessageAsKnowledge && projection.canSaveMessageAsKnowledge,
);
const showMessageActions =
(msg.role === "user" &&
!isUserCommandMessage &&
(canQuoteMessage || canCopyMessage)) ||
(msg.role === "assistant" &&
Boolean(msg.imageWorkbenchPreview) &&
(canQuoteMessage || canCopyMessage)) ||
canSaveMessageAsSkill ||
canSaveMessageAsInspiration ||
canSaveMessageAsKnowledge;
const assistantMetaFooterState = resolveMessageAssistantMetaFooterState({
activeConversationRuntimeStatusLine,
hasAssistantBodyContent,
isConversationTailAssistant,
lastAssistantMessageId,
message: msg,
shouldDeferHistoricalMarkdownRender,
shouldPreviewHistoricalAssistantMessage,
shouldSuppressStandaloneImageWorkbenchProcess,
tailRuntimeStatusLine,
});
const assistantMetaFooter = (
<MessageAssistantMetaFooter
activeConversationRuntimeStatusLine={activeConversationRuntimeStatusLine}
hasAssistantBodyContent={hasAssistantBodyContent}
message={msg}
onInterruptCurrentTurn={onInterruptCurrentTurn}
promptCacheNotice={promptCacheNotice}
providerType={providerType}
state={assistantMetaFooterState}
tailRuntimeStatusLine={tailRuntimeStatusLine}
/>
);
const firstTokenRuntimeStatusNode = shouldRenderFirstTokenRuntimeStatus ? (
<AssistantFirstTokenRuntimeStatus status={msg.runtimeStatus} />
) : null;
if (
msg.role === "assistant" &&
!hasAssistantBodyContent &&
!assistantMetaFooterState.hasAssistantMetaFooter &&
!firstTokenRuntimeStatusNode
) {
return null;
}
const primaryTimelineNode = primaryTimeline ? (
<MessageTimelineSection
timeline={primaryTimeline}
actionRequests={primaryActionRequests}
activeCurrentTurnId={activeCurrentTurnId}
detailsDeferred={arePrimaryTimelineDetailsDeferred}
focusedTimelineItemId={focusedTimelineItemId}
focusRequestKey={timelineFocusRequestKey}
isCurrentTurnSending={isSending}
messageId={msg.id}
onExpandPreview={
primaryTimelineKey
? () => handleExpandHistoricalTimeline(primaryTimelineKey)
: undefined
}
onFileClick={onFileClick}
onOpenArtifactFromTimeline={onOpenArtifactFromTimeline}
onOpenSavedSiteContent={onOpenSavedSiteContent}
onOpenSubagentSession={onOpenSubagentSession}
onPermissionResponse={onPermissionResponse}
onSaveMessageAsKnowledge={onSaveMessageAsKnowledge}
placement="leading"
renderCompactPreview={
shouldRenderCompactPrimaryTimeline && Boolean(primaryTimelineKey)
}
shouldDeferHistoricalTimelineDetails={
shouldDeferHistoricalTimelineDetails
}
threadRead={threadRead}
/>
) : null;
return (
<MessageWrapper
key={msg.id}
$isUser={msg.role === "user"}
$compactLeadingSpacing={compactLeadingSpacing}
>
<ContentColumn $isUser={msg.role === "user"}>
{shouldRenderPrimaryTimelineOutsideBubble ? (
<div className="mb-2" data-testid="assistant-primary-timeline-shell">
{primaryTimelineNode}
</div>
) : null}
{firstTokenRuntimeStatusNode ? (
<div
className="mb-1.5 px-1"
data-testid="assistant-runtime-status-shell"
>
{firstTokenRuntimeStatusNode}
</div>
) : null}
{hasAssistantBodyContent ? (
<MessageBubble
$isUser={msg.role === "user"}
$bareMedia={shouldRenderImageWorkbenchBareBubble}
className={
isUserCommandMessage ? "message-bubble-user-command" : undefined
}
aria-label={msg.role === "assistant" ? assistantLabel : undefined}
>
{msg.role === "assistant" ? (
<MessageAssistantBody
a2uiFormDataMap={a2uiFormDataMap}
actionContent={actionContent}
collapseCodeBlocks={collapseCodeBlocks}
displayContent={displayContent}
handleExpandHistoricalAssistantMessage={
handleExpandHistoricalAssistantMessage
}
handleExpandLongHistoricalMessage={
handleExpandLongHistoricalMessage
}
hasImageWorkbenchLeadContent={hasImageWorkbenchLeadContent}
historicalAssistantPreviewContent={
historicalAssistantPreviewContent
}
imageWorkbenchRendererState={imageWorkbenchRendererState}
message={msg}
messageCanvasShortcutPath={messageCanvasShortcutPath}
messageCanvasShortcutTitle={messageCanvasShortcutTitle}
messageSavedSiteContentTarget={messageSavedSiteContentTarget}
onA2UIFormChange={onA2UIFormChange}
onA2UISubmit={onA2UISubmit}
onCodeBlockClick={onCodeBlockClick}
onFileClick={onFileClick}
onOpenMessagePreview={onOpenMessagePreview}
onOpenSavedSiteContent={onOpenSavedSiteContent}
onPermissionResponse={onPermissionResponse}
onQuoteMessage={onQuoteMessage}
onWriteFile={onWriteFile}
primaryTimeline={primaryTimelineNode}
promoteActionRequestsToA2UI={promoteActionRequestsToA2UI}
readOnlyInteractiveContent={shouldReadOnlyInteractiveContent}
renderA2UIInline={renderA2UIInline}
rendererActionRequests={rendererActionRequests}
rendererContent={rendererContent}
rendererContentParts={rendererContentParts}
rendererMarkdownRenderMode={rendererMarkdownRenderMode}
rendererRawContent={rendererRawContent}
rendererThinkingContent={rendererThinkingContent}
rendererToolCalls={rendererToolCalls}
renderProposedPlanBlocks={
!primaryTimeline ||
inlineProcessCoverage.hasInlineProcessEntries
}
shouldCollapseCodeBlock={shouldCollapseCodeBlock}
shouldCollapseLongHistoricalMessage={
shouldCollapseLongHistoricalMessage
}
shouldDeferHistoricalMarkdownRender={
shouldDeferHistoricalMarkdownRender
}
shouldPreviewHistoricalAssistantMessage={
shouldPreviewHistoricalAssistantMessage
}
shouldRenderMessageCanvasShortcut={
shouldRenderMessageCanvasShortcut
}
shouldRenderPrimaryTimelineOutsideBubble={
shouldRenderPrimaryTimelineOutsideBubble
}
shouldSuppressInlineA2UI={shouldSuppressInlineA2UI}
shouldSuppressRendererProcessFlow={
shouldSuppressRendererProcessFlow
}
suppressedActionRequestId={suppressedActionRequestId}
/>
) : (
<MessageUserBody
content={displayContent}
installedSkillMessageLabel={installedSkillMessageLabel}
isUserCommandMessage={isUserCommandMessage}
message={msg}
onA2UISubmit={onA2UISubmit}
rawRuntimePeerContent={rawRuntimePeerContent}
renderA2UIInline={renderA2UIInline}
shouldRenderRuntimePeerCards={shouldRenderRuntimePeerCards}
/>
)}
<MessageImageAttachments images={msg.images} />
{msg.role === "assistant" &&
visibleAssistantArtifacts.length > 0 ? (
<MessageArtifactCards
artifacts={visibleAssistantArtifacts}
messageId={msg.id}
onArtifactClick={onArtifactClick}
onSaveMessageAsKnowledge={onSaveMessageAsKnowledge}
/>
) : null}
{msg.role === "assistant" &&
trailingTimeline &&
!shouldRenderFirstTokenRuntimeStatus ? (
<MessageTimelineSection
timeline={trailingTimeline}
actionRequests={trailingActionRequests}
activeCurrentTurnId={activeCurrentTurnId}
focusedTimelineItemId={focusedTimelineItemId}
focusRequestKey={timelineFocusRequestKey}
isCurrentTurnSending={isSending}
messageId={msg.id}
onFileClick={onFileClick}
onOpenArtifactFromTimeline={onOpenArtifactFromTimeline}
onOpenSavedSiteContent={onOpenSavedSiteContent}
onOpenSubagentSession={onOpenSubagentSession}
onPermissionResponse={onPermissionResponse}
onSaveMessageAsKnowledge={onSaveMessageAsKnowledge}
placement="trailing"
shouldDeferHistoricalTimelineDetails={
shouldDeferHistoricalTimelineDetails
}
threadRead={threadRead}
/>
) : null}
{assistantMetaFooter}
{showMessageActions ? (
<MessageActionButtons
actionContent={actionContent}
canCopyMessage={canCopyMessage}
canQuoteMessage={canQuoteMessage}
canSaveMessageAsInspiration={canSaveMessageAsInspiration}
canSaveMessageAsKnowledge={canSaveMessageAsKnowledge}
canSaveMessageAsSkill={canSaveMessageAsSkill}
copied={copiedId === msg.id}
isImageWorkbenchMessage={Boolean(msg.imageWorkbenchPreview)}
knowledgeContent={knowledgeArtifactSource?.content}
knowledgeDescription={knowledgeArtifactSource?.description}
knowledgeSourceName={knowledgeArtifactSource?.sourceName}
messageId={msg.id}
onCopy={handleCopy}
onQuoteMessage={onQuoteMessage}
onSaveMessageAsInspiration={onSaveMessageAsInspiration}
onSaveMessageAsKnowledge={onSaveMessageAsKnowledge}
onSaveMessageAsSkill={onSaveMessageAsSkill}
/>
) : null}
</MessageBubble>
) : null}
{!hasAssistantBodyContent ? assistantMetaFooter : null}
</ContentColumn>
</MessageWrapper>
);
}
@@ -0,0 +1,127 @@
import React from "react";
import { AlertTriangle, Loader2, Square } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { InputbarRuntimeStatusLineModel } from "../utils/inputbarRuntimeStatusLine";
import type { AgentRuntimeStatus } from "../types";
function normalizeRuntimeStatusMetaText(value?: string | null): string {
return (value || "").trim().replace(/\s+/g, " ");
}
function truncateRuntimeStatusText(value: string, maxLength = 96): string {
const normalized = normalizeRuntimeStatusMetaText(value);
if (normalized.length <= maxLength) {
return normalized;
}
return `${Array.from(normalized).slice(0, maxLength).join("")}...`;
}
export const MessageRuntimeStatusPill: React.FC<{
status: AgentRuntimeStatus;
}> = ({ status }) => {
const { t } = useTranslation("agent");
const failed = status.phase === "failed";
const cancelled = status.phase === "cancelled";
const ToneIcon = failed ? AlertTriangle : cancelled ? Square : Loader2;
const titleText = normalizeRuntimeStatusMetaText(status.title);
const detailText = normalizeRuntimeStatusMetaText(status.detail);
const checkpointsText = (status.checkpoints || [])
.map((item) => normalizeRuntimeStatusMetaText(item))
.filter(Boolean)
.slice(0, 2)
.join(" · ");
const tooltip = [titleText, detailText, checkpointsText]
.filter(
(item, index, array) => Boolean(item) && array.indexOf(item) === index,
)
.join("\n");
return (
<div
data-testid="message-runtime-status-pill"
className={[
"inline-flex max-w-full items-center gap-1.5 rounded-md border px-2 py-1 text-[11px] leading-none",
failed
? "border-rose-200 bg-rose-50 text-rose-700"
: cancelled
? "border-slate-200 bg-slate-50 text-slate-600"
: "border-sky-200 bg-sky-50 text-sky-700",
].join(" ")}
title={tooltip || undefined}
>
<ToneIcon
className={[
"h-3.5 w-3.5 shrink-0",
failed || cancelled ? "" : "animate-spin",
].join(" ")}
/>
<span className="truncate">
{titleText || t("agentChat.messageList.firstTokenStatus.routing.title")}
</span>
</div>
);
};
export const AssistantFirstTokenRuntimeStatus: React.FC<{
status?: AgentRuntimeStatus | null;
}> = ({ status }) => {
const { t } = useTranslation("agent");
const phase = status?.phase || "submitted";
const title = truncateRuntimeStatusText(
t(`agentChat.messageList.firstTokenStatus.${phase}.title`),
48,
);
const detail = truncateRuntimeStatusText(
t(`agentChat.messageList.firstTokenStatus.${phase}.detail`),
120,
);
return (
<div
data-testid="assistant-first-token-runtime-status"
className="inline-flex max-w-full items-start gap-2 rounded-xl border border-slate-200 bg-slate-50 px-2.5 py-1.5 text-xs text-slate-600"
aria-live="polite"
>
<Loader2 className="mt-0.5 h-3.5 w-3.5 shrink-0 animate-spin text-slate-500" />
<span className="min-w-0">
<span className="block font-medium leading-5 text-slate-700">
{title}
</span>
<span className="mt-0.5 block leading-5 text-slate-500">{detail}</span>
</span>
</div>
);
};
export const AssistantStreamingInlineIndicator: React.FC<{
runtime: InputbarRuntimeStatusLineModel;
}> = ({ runtime }) => {
const { t } = useTranslation("agent");
const status = runtime.status === "queued" ? "queued" : "running";
const isQueued = status === "queued";
return (
<div
data-testid="assistant-streaming-inline-indicator"
data-status={status}
role="status"
aria-live="polite"
className={[
"inline-flex items-center gap-1.5 rounded-md px-2 py-[3px] text-[11px] font-medium leading-4",
isQueued
? "bg-slate-100 text-slate-500"
: "bg-slate-100 text-slate-600",
].join(" ")}
>
<Loader2
className={[
"h-3 w-3 shrink-0 animate-spin motion-reduce:animate-none",
isQueued ? "text-slate-400" : "text-emerald-600",
].join(" ")}
aria-hidden
/>
<span>{t(`agentChat.messageList.streamingInline.${status}`)}</span>
</div>
);
};

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