mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
chore: release v1.39.0
This commit is contained in:
@@ -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
@@ -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`
|
||||
|
||||
@@ -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 再补一条全局约束:
|
||||
|
||||
|
||||
@@ -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。
|
||||
|
||||
|
||||
@@ -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` 主链
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
只有这条链路跑通后,再进入内容工程化的知识库和批量生成业务实现。
|
||||
@@ -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。
|
||||
@@ -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 删除门禁。
|
||||
Generated
+2
-2
@@ -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
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@limecloud/lime-cli",
|
||||
"version": "1.38.0",
|
||||
"version": "1.39.0",
|
||||
"description": "Lime 官方任务 CLI",
|
||||
"bin": {
|
||||
"lime": "scripts/run.js"
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
null
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"oldPrompt": false,
|
||||
"oldLimePrompt": false,
|
||||
"imagePreviewCount": 0,
|
||||
"errorsText": false
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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
|
||||
}
|
||||
@@ -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马上生成"
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Generated
+18
-18
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 回复。
|
||||
|
||||
@@ -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", ¶ms, &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(¤t_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!({
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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,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,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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" }}>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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("<typeof schema>");
|
||||
});
|
||||
|
||||
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 () => {
|
||||
|
||||
+7
-3
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+31
-20
@@ -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>
|
||||
}
|
||||
>
|
||||
|
||||
+57
-1
@@ -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,
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
+61
-26
@@ -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
Reference in New Issue
Block a user