diff --git a/.gitignore b/.gitignore index 5ec24fdb4..889532b8d 100644 --- a/.gitignore +++ b/.gitignore @@ -65,6 +65,8 @@ docs/roadmap/* !docs/roadmap/skill-forge/*.md !docs/roadmap/managed-objective/ !docs/roadmap/managed-objective/*.md +docs/roadmap/agentruntime/ +docs/roadmap/agentruntime/*.md !docs/roadmap/ai-layered-design/ !docs/roadmap/ai-layered-design/*.md !docs/roadmap/i18n/ diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 881415907..332b85a1d 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,283 +1,42 @@ -## Lime v1.34.0 +## Lime v1.35.0 -发布日期:`2026-05-11` -递交范围:完整 dirty worktree,包含 tracked、deleted 与新增文件;`.lime/` 等本地运行态忽略目录不纳入发布提交。 +发布日期:`2026-05-12` +递交范围:当前完整 dirty worktree,包含 tracked、deleted 与新增文件;本次按用户要求先递交并推送代码,暂不打 tag。 -> 发布说明:上一版 release tag 为 `v1.33.0`。本版按用户要求升级到 `v1.34.0`,并把当前完整 dirty worktree 的源码、文档、脚本、测试与发布说明索引纳入 release notes。 +> 发布说明:上一版 release tag 为 `v1.34.0`。本版升级到 `v1.35.0`,并清理旧 `RELEASE_NOTES.md` 历史堆叠内容,只保留当前版本说明;`v1.35.0` tag 等全量校验跑通后再打。 ### 发布概览 -- 应用版本从 `1.33.0` 升级到 `1.34.0`,同步 `package.json`、`package-lock.json`、`src-tauri/Cargo.toml`、`src-tauri/Cargo.lock`、`src-tauri/tauri.conf.json`、`src-tauri/tauri.conf.headless.json`、`packages/lime-cli-npm/package.json` 与 `@limecloud/lime-cli` wrapper 版本。 -- Agent QC 保留 scenario / GUI flow / Evidence Pack schema、qcloop job / status / preflight / export / release-summary / audit 脚本作为本地与人工证据工具;按本版要求从 GitHub Actions 发布/夜间/合同验证链路移除。 -- Agent Runtime 与 Team / Workspace 主链补齐 approval sandbox、Claw chat ready streaming、tool surface、service skill entry、browser runtime、knowledge GUI 与 GUI owner 检查脚本,降低只靠浅层 smoke 误判的风险。 -- i18n 主链从 legacy DOM Patch 迁到 key-based resources:新增 `createI18n`、namespace loader、locale resources、格式化工具与覆盖测试,旧 patch 文件移入 `src/i18n/legacy-patch/` 并从 current 入口退场。 -- 设置 v2、侧边栏、Agent 输入栏、任务中心、Skills 页面与系统诊断页完成一批用户可见回归,覆盖账号、统计、用户中心、Provider、媒体能力、语音、快捷键、环境、渠道日志与工作区修复历史。 -- 对话功能 E2E 证据新增到 `output/dialogue-feature-e2e-20260510/`,覆盖 24 个对话 / 任务 / artifact / 搜索 / 多媒体场景截图、manifest 与审计 JSON。 -- 清理旧版本临时证据与旧实现残留:删除根目录旧 Playwright JSON、旧 qcloop 截图、旧 i18n patch 入口文件,避免 `v1.32.0` 以前的临时产物继续混入 release 范围。 +- 应用版本从 `1.34.0` 升级到 `1.35.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`。 +- 本次发布直接纳入当前主线 dirty worktree 的完整源码与文档改动,重点覆盖插件中心清退、Agent Runtime / Skills / i18n / 设置页回归、Rust runtime 与前端命令面同步收口。 +- 旧插件文档、旧插件命令、旧插件 UI 和大量 provider model 资源索引已从 current 路径移除,减少 legacy/compat 面继续回流。 +- 设置 v2、Agent 对话工作台、Skills 页面、资源渲染器、快捷键、环境与系统面板补齐一批用户可见测试与结构调整。 +- `RELEASE_NOTES.md` 旧版本累积内容已清理,后续发布将以单版本事实源维护,避免历史内容继续混入新发布。 ### 用户可见更新 -#### 1. Agent 对话、任务与工作台 - -- Agent 输入栏、轻量预览、任务中心 tab、Team Workspace board、curated task reference selection 与 fast response model 增加更稳定的状态与测试覆盖。 -- Claw chat ready streaming smoke 固化为可复跑入口,覆盖 ready 状态、流式输出、cancel / provider fixed 场景与 GUI evidence 记录。 -- AppSidebar 会话 shelf 与会话格式化补齐测试,历史会话、队列状态和工作区入口展示更稳定。 - -#### 2. 设置页与能力配置 - -- 设置 v2 增强账号资料、统计、用户中心会话、Provider、媒体服务、图片 / 视频 / 语音能力、快捷键、开发者、环境和渠道日志页面。 -- Companion capability preferences、MediaPreferenceSection 与 Provider 页面补齐能力偏好和模型配置回归。 -- WorkspaceRepairHistoryCard、ChannelLogTailPanel 与渠道日志 filter 补齐可见状态和过滤测试。 - -#### 3. 本地化与旧版本清理 - -- current i18n resources 覆盖 `zh-CN`、`zh-TW`、`en-US`、`ja-JP`、`ko-KR` 的 `common`、`navigation`、`workspace`、`agent`、`settings`、`errors` namespace。 -- 新增缺失翻译检测、legacy patch metrics report 与 namespace load / locale / format / type 测试,避免新增用户可见文案回落到旧 DOM patch。 -- 旧 `I18nPatchProvider`、`dom-replacer`、`patches/*.json` 与 `text-map` 已从 current 位置删除,仅作为 `legacy-patch` 迁移目录保留。 +- Agent 对话工作台补齐 timeline、incident、workspace notice、session overview、fast response routing 与 harness summary 相关回归。 +- 设置 v2 的账号、Provider、媒体服务、语音、快捷键、环境、开发者、自动化、渠道日志和实验设置继续收口并补齐测试。 +- Skills / Repo Manager / Resource Manager / Workspace breadcrumb / Smart Input 快捷键等页面和组件补齐用户可见回归,降低桌面 GUI 主路径回退风险。 +- i18n current resources 与启动加载界面继续完善,覆盖 `zh-CN`、`zh-TW`、`en-US`、`ja-JP`、`ko-KR` 多语言资源与类型/覆盖测试。 ### 开发者与治理更新 -- Agent QC 文档新增 `docs/test/*.json`、`docs/tests/*`、completion audit、current blockers、evidence contract、qcloop operations、rollout plan 与 stale worker 分析,发布证据口径回到仓库内版本化事实源。 -- `npm run test:contracts` 回到 command / harness / modality / harness cleanup 合同检查,不再间接执行 `agent-qc:check`。 -- `scripts/quality-task-planner`、`local-ci` 与 completion audit 同步解耦 Agent QC:Agent QC 文档和本地脚本不再触发 GitHub Actions bridge/contracts 验证原因。 -- `.github/workflows/release.yml` 删除 `agent_qc_*` workflow inputs、Evidence Pack 检查和 release-summary 拼接;`.github/workflows/harness-nightly.yml` 删除 `artifacts/agent-qc/*` 生成,nightly 只保留 harness eval 资产。 -- `legacySurfaceCatalog`、`agentCommandCatalog`、DevBridge dispatcher、mockPriorityCommands 与 tauri mock 同步 runtime / tool / QC 新边界。 -- Rust 发布门禁收口三处稳定性问题:MCP tool search 精确 inner tool name 优先于后缀命中,Fal queue 超时测试禁用本机代理并加宽 client timeout,Agent 流式策略测试改用注入式内存 `SessionStore`,避免全量并发测试写全局 SQLite 时出现 I/O 抖动。 - -### 已知边界 - -- 官方 Agent QC Evidence Pack 仍应由真实 qcloop 8/8 P0 pass 后导出,作为本地 / 人工发布证据;它不再作为 GitHub Actions 发布硬门禁。 -- `output/dialogue-feature-e2e-20260510/` 是本轮对话功能 E2E 证据,不等同于 installer artifact 验证。 -- GitHub Release artifact 仍由 tag 推送后的发布流水线生成;release workflow 不再读取 `.lime/qc/agent-qc-evidence.json`。 +- 插件中心相关 Rust 命令、前端 API、页面入口、README 与文档路径继续清退,current 路径不再保留插件中心动态入口。 +- `agentCommandCatalog`、DevBridge dispatcher、mockPriorityCommands、tauri mock 与 Agent Runtime 前端类型继续对齐新的 runtime/skills/tool surface 边界。 +- Rust 侧围绕 Agent runtime、request model resolution、runtime evidence/review/handoff、skill service 与 app path/config 持续收口。 +- 文档导航与质量工作流同步更新,仓库规则继续强调 current 事实源、版本一致性和 GUI 产品交付门槛。 ### 校验状态 -- `npm run verify:app-version`:待本轮版本更新后复跑确认版本一致性为 `1.34.0`。 +- `npm run verify:app-version`:通过。 - `cargo fmt --manifest-path "src-tauri/Cargo.toml" --all`:通过。 -- `cargo test --manifest-path "src-tauri/Cargo.toml" --workspace --locked`:通过;使用 `CARGO_HOME=/tmp/lime-cargo-home-v133` 与 `CARGO_TARGET_DIR=src-tauri/target-release-v133-temp-home` 避开本机全局 Cargo 缓存损坏。 -- `npm run lint:rust`:通过;已修复 `lime-media-runtime` 大 Err variant clippy warning,并在后续 MCP / server / agent 测试稳定性修复后复跑通过。 +- `npm run format`:通过。 +- `cargo test --manifest-path "src-tauri/Cargo.toml" --workspace --locked`:通过。 +- `cargo clippy --manifest-path "src-tauri/Cargo.toml"`:通过。 - `npm run lint`:通过。 -- `npm test`:通过,`54/54` 批退出码为 `0`。 -- `npm run test:contracts`:通过,覆盖 command / harness / modality / harness cleanup contract;Agent QC 已从该 GitHub Actions 可触发入口拆出。 -- `rg -n "agent-qc|Agent QC|qcloop|agent_qc" ".github/workflows"`:无结果,确认 GitHub Actions workflow 不再直接触发 Agent QC。 -- `git diff --check`:通过。 +- `npm test`:未完成;最后一次全量运行被用户中断以先提交当前版本,已暴露失败项已定向修复并通过相关定向测试。 --- -**完整变更**: `v1.33.0` -> `v1.34.0` - -## Lime v1.32.0 - -发布日期:`2026-05-10` -递交范围:完整 dirty worktree,包含 tracked、deleted 与新增文件;不把本版误标为单一功能发布。 - -> 发布说明:上一版本地 release tag 为 `v1.31.0`。本版按用户确认的“所有代码都要递交”口径整理 `v1.32.0` release notes;最终 `git commit` / tag / push / GitHub Release 仍属于高风险操作,需要提交前再次确认。 - -### 发布概览 - -- 应用版本从 `1.31.0` 升级到 `1.32.0`,同步 `package.json`、`package-lock.json`、`src-tauri/Cargo.toml`、`src-tauri/Cargo.lock`、`src-tauri/tauri.conf.json`、`src-tauri/tauri.conf.headless.json` 与 `packages/lime-cli-npm/package.json`。 -- README 与仓库元信息改为面向中文创作者的开源 AI 内容工作台定位,补齐 GitHub release、平台、license badge、关键词和 GitHub About 推荐配置。 -- AgentUI 对齐推进到 v0.6.0 Team Workbench 主链:conversation projection、team surfaces、review lane、teammate transcript、worker notification 与 runtime entity 都回到结构化事件事实源。 -- 聊天流式渲染、DeepSeek / OpenAI-compatible reasoning 与 tool input/output 处理、历史消息 hydration、失败态 overlay 和性能指标做了集中稳定性修复。 -- Provider 与模型配置、Lime Hub 未登录态、Chrome Relay 引导、项目资料保存 / Knowledge 产品 E2E、Design Canvas smoke 与 GUI 质量脚本同步更新。 -- 本版继续减少 legacy surface:移除旧启动布局 / Chrome bridge / NewAPI image 测试脚本、旧 scene apps rail、旧语音 ASR 凭证 UI、旧 Knowledge 页面碎片组件与若干临时 patch 脚本。 - -### 用户可见更新 - -#### 1. Agent 对话与 Team Workbench - -- Team Workbench 可消费 `team_roster`、`work_board`、`delegation_graph`、`handoff_lane`、`worker_notifications`、`review_lane`、`teammate_transcript`、`background_teammate`、`remote_teammate`、`team_policy` 十类标准 surface,并提供分组 lane、surface detail 与操作视图。 -- `teammate_transcript/open_detail` 增加 Transcript Zoom:可定位父会话、子会话、最近回合、live activity 与历史 activity snapshot,但不会把队友内部输出混进主 final answer。 -- Review lane 展示 reviewer、risk、checklist、followup、regression requirements、requested fixes 与回归结果;requested fix 会投影成 work item,但仍保持 pending,不伪造成已执行。 -- Worker notification 展示 transcript ref、token usage、duration、tool count 与 result ref;Rust source 只透传真值,不从 assistant prose 或 teammate transcript 反推。 -- Streaming 渲染保留低频 text overlay,`final_done` 后再回填 message content / contentParts;失败态保留工具和过程信息,并追加失败正文。 - -#### 2. Provider、项目资料与桌面体验 - -- Provider 模型添加面板补齐模型抓取 helper、API 模型筛选、图片模型提示与未登录态展示;Lime Hub / OEM 登录改为用户显式触发,不再静默弹浏览器。 -- Knowledge 页面和项目资料保存路径继续使用普通用户词表,聊天消息、文件 artifact 与 canvas artifact 可进入待确认的项目资料流程。 -- 新增 `knowledge:product-e2e`,覆盖项目资料创作者版首页、状态、确认、选择、保存和整理闭环;GUI smoke 可选择串联该产品 E2E。 -- Chrome Relay 引导窗口支持更稳定的显式打开路径,相关测试覆盖 guide window launcher 与外部 URL 边界。 -- README 截图更新为 2026-05-09 的新建任务、生成过程和 AI 服务商配置三张产品图。 - -#### 3. Design Canvas 与质量脚本 - -- Design Canvas smoke 增强到可验证图层文档、artifact 预览、canvas endpoint、真实工程导出和截图证据,减少只测静态页面的假通过。 -- `verify:gui-smoke`、`knowledge-gui-smoke`、`local-ci` 与 quality planner 对 Knowledge 产品 E2E、GUI 主路径和发布完整性有更清晰的任务选择。 -- `.github/workflows/quality.yml` 与 ESLint 配置更新,减少旧脚本 / 旧页面残留造成的 CI 噪声。 - -### 开发者与治理更新 - -- `agentUiEventProjection`、`conversationProjectionStore`、`agentUiProjectionSummary`、`agentUiTeamWorkbenchViewModel` 与 `AgentUiTeamWorkbenchSurfaceView` 构成 AgentUI v0.6 projection -> selector -> UI 的 current 主链。 -- Rust runtime 事件扩展 `SubagentStatusChangedEvent`、request tool policy、runtime turn、event converter 与 provider format,支持 structured action、reasoning、tool delta、usage / duration / tool count 和 DeepSeek reasoning 输出稳定化。 -- `external_tools_cmd` 与 DevBridge dispatcher 拆出外部 URL 打开边界,避免前端直接把外部链接处理逻辑散落到 UI。 -- `legacySurfaceCatalog` 与相关测试扩展 legacy / deprecated surface 识别,明确旧 ASR UI、旧 Knowledge 组件、旧 scene apps rail 和临时 patch 脚本的清退状态。 -- `docs/roadmap/agentui/lime-agentui-standard-alignment.md` 记录 AgentUI v0.6.0 标准、当前对齐状态、验证结果和下一刀,不把 Team Workbench 做成第二套 runtime taxonomy。 - -### 已知边界 - -- Team Workbench action 当前以标准 target 定位为主;非本地 target runtime dispatch、reassignment、requested fixes 真实执行 / 完成状态仍是后续项。 -- Remote teammate 只有 adapter baseline;必须等 current remote ingress 提供真实 task id、Agent Card、artifact update 后才能接入,不用 heartbeat 猜完成。 -- Worker `result_ref` 仍需要 durable worker result artifact / source;本版只展示已有结构化 source 的字段。 -- Knowledge 产品 E2E 已进入脚本与 smoke 入口,但真实用户历史 workspace 的长期 fallback 清零仍需单独治理。 - -### 校验状态 - -- `npm run verify:app-version`:通过,版本一致性为 `1.32.0`。 -- `cargo fmt --manifest-path "src-tauri/Cargo.toml" --all`:通过。 -- `cargo test --manifest-path "src-tauri/Cargo.toml"`:通过,`1293` 个 lib 测试通过,`deepseek_reasoner_output_schema_runtime` 2 个集成测试通过,2 个真实联网测试保持 `ignored`。 -- `npm run lint:rust`:通过;保留既有 `lime-media-runtime` 的 `clippy::result_large_err` warning,命令退出码为 0。 -- `npm run lint`:通过。 -- `npm test`:通过,52 批 Vitest / 组件测试完成;首轮发现并修复空白新建任务发送后轻量预览过早清理的问题。 -- `npm run test:contracts`:通过。 -- `npm run verify:gui-smoke`:通过,覆盖 workspace-ready、browser-runtime、site-adapters、agent-service-skill-entry、agent-runtime-tool-surface、agent-runtime-tool-surface-page、knowledge-gui 与 design-canvas。 -- `git diff --check`:通过,已清理本轮 diff 中的文件末尾多余空行。 - ---- - -**完整变更**: `v1.31.0` -> `v1.32.0` - -## Lime v1.31.0 - -发布日期:`2026-05-08` -递交补充:`2026-05-09` - -> 发布说明:GitHub 上一版为 `Lime v1.30.0` Release(tag `v1.30.0` 指向 `28b2a751104a3aa982fdca50f5058aed7db9fee1`)。本版按完整 worktree 递交范围整理 `v1.31.0` release notes。当前本地已存在 `v1.31.0` tag;若本轮 dirty worktree 也要纳入同一个版本发布,最终 Git 操作时仍需确认 tag / GitHub Release 指向最终提交。 - -### 发布概览 - -- 本次发布按当前准备递交的完整 worktree 编写,范围包括版本发布、Skill Forge 主线、只读 HTTP/API 能力草案、Agent Runtime / Evidence Pack、Knowledge v2、AI 图层化设计、GUI 页面与质量脚本;机密执行计划不进入发布提交。 -- 应用版本从 `1.30.0` 升级到 `1.31.0`,同步 `package.json`、`package-lock.json`、`src-tauri/Cargo.toml`、`src-tauri/Cargo.lock`、`src-tauri/tauri.conf.json`、`src-tauri/tauri.conf.headless.json`、`packages/lime-cli-npm/package.json` 与 `@limecloud/lime-cli` 发布示例。 -- `CREAOAI` 相关 research、roadmap 与 exec plan 已收口为 `Skill Forge` 命名,并同步导航、路线图、执行计划和治理文档,减少旧命名与 current 主线并存。 -- 本版继续坚持 current-first:capability draft、registered skill、runtime binding、Query Loop metadata、runtime enable、Evidence Pack、Knowledge 与 Layered Design 都回到仓库内单一事实源,不新增 legacy / compat 平行执行入口。 -- 2026-05-09 递交补充覆盖 Provider 模型真相源收敛、Lime Hub 未登录态、项目资料 v3 创作者体验、启动窗口稳定展示、Design Canvas HTTP JSON 拆层端点、聊天产物沉淀到项目资料,以及图层化设计外部证据瘦身。 - -### 用户可见更新 - -#### 0. 2026-05-09 递交补充 - -- Provider 模型列表不再依赖打包内置 `resources/models` 或本地 Lime Hub dev fallback;实时 `/models` 成功结果允许缓存 10 天,读取时优先用缓存,失败时只保留用户显式 `custom_models`。 -- Lime Hub / OEM 托管 Provider 未登录时在设置页和模型选择器中显示“需要登录”,不会自动弹出 OAuth 浏览器,也不会用本地兜底模型伪装为可用;用户可从 Provider UI 显式进入登录。 -- Provider 设置页增加 API 模型筛选、服务商配置删除入口、Responses 图片模型 `/models` 不可枚举时的专用提示,并对 `gpt-image*` / `gpt-images*` 图片模型给出成功或手动添加指引。 -- Knowledge 默认产品层改为“项目资料”:普通用户路径使用“写作口吻 / 参考资料 / 完整资料文档 / 确认可用 / 保存到项目资料”等词表;`Builder Skill`、`Knowledge Pack`、`Resolver` 等工程术语退到高级语境。 -- 新增 `docs/roadmap/knowledge/prd-v3.md` 与 7 张项目资料创作者版原型图,作为 Knowledge v3 UI、词表、状态和故事板的产品事实源。 -- 聊天消息、文件 artifact 与 canvas artifact 可把文档类内容保存回项目资料;保存后进入待确认流程,确认前不会自动用于创作。 -- 启动窗口展示改为等待 Logo 解码、viewport 连续稳定和下一帧渲染后再 `show / setFocus`,避免启动最大化过程中的横向漂移;启动期云端登录改为手动提示,不再自动打开登录浏览器。 -- Design Canvas 增加可持久化的 HTTP JSON 模型拆层端点配置;上传扁平图时优先走当前 canvas 端点,失败后回退 current analyzer,同时在工作台 artifact 中提供图层文档内联预览和完整编辑器。 -- 图层化设计 live evidence 脚本支持本地 Lime image gateway、自动选择启用图片 Provider、dry-run 健康检查、PNG 尺寸校验和外部证据 schema;大型真实样张与 PSD-like 导出证据从 Git 跟踪中移出,保留可复跑脚本与 Playwright 证据。 -- 发布包下线内置 models 资源检查与 `scripts/download-models.sh`,`tauri.conf.headless.json` 不再打包 `resources/models/**/*`,避免旧模型目录继续作为事实源。 - -#### 1. Skill Forge 与能力沉淀闭环 - -- 新增并落地 `Skill Forge` 研究、路线图、架构图与产品原型入口,替换旧 `docs/research/creaoai` 与 `docs/roadmap/creaoai` 命名。 -- Capability Draft 主线从 authoring、verification、registration、registered discovery、runtime binding、Query Loop metadata、runtime enable 延伸到 managed agent envelope 与 prompt-to-artifact smoke。 -- Skills 工作台与 Workspace Registered Skills 面板展示更完整的 registered skill provenance、binding readiness、Agent envelope draft、automation draft、approval request 与 session gate 状态。 -- “本回合启用”继续只走 `agent_runtime_submit_turn` metadata + SkillTool allowlist,不创建 marketplace、scheduler 或平行 runtime command。 -- Managed Job / Agent Envelope 草案只在 evidence 与 owner audit 满足条件时进入可转化语义,避免把“已注册 / 已发现 / 已校验”误读成“已自动执行”。 - -#### 2. 只读 HTTP/API 能力草案 - -- Capability Draft verification 增加只读 HTTP/API gate:fixture input、fixture 文件、expected output、dry-run 入口、expected-output binding、offline dry-run、no-credentials、session authorization policy、credential reference 与 execution preflight。 -- 新增只读 HTTP/API authoring 模板与 smoke:`scripts/lib/readonly-http-api-draft-template.mjs`、`scripts/readonly-http-api-smoke.mjs`,统一正向样例和负向 gate 样例。 -- 注册摘要新增 `verification_gates` 与 `approval_requests`,把 passed preflight provenance 投影到 registered discovery 与 Workspace 面板,而不是发起真实 HTTP。 -- Approval artifact 新增 consumption gate、credential resolver、consumption input schema、session input intake、submission contract、dry preflight plan 与 controlled GET preflight。 -- 新增 session 输入提交与受控 GET 执行命令:`capability_draft_submit_approval_session_inputs`、`capability_draft_execute_controlled_get`;受控 GET 仅允许一次性 session 输入、GET 方法、非敏感 evidence 与 request / response hash。 -- loopback / localhost 受控 GET 测试绕过系统代理,避免本地 fixture 被代理环境污染。 - -#### 3. Prompt-to-Artifact 与证据闭环 - -- 新增 `scripts/prompt-to-artifact-smoke.mjs`,通过 DevBridge 串联 `create -> verify -> register -> list_registered_skills -> list_workspace_skill_bindings`,输出结构化 smoke summary。 -- Prompt-to-Artifact smoke 记录结构化样例证据,明确 demo / smoke / evidence 不扩展 P4 runtime,也不打开 scheduler、queue 或 Marketplace。 -- Evidence Pack completion audit 纳入受控 GET evidence 计数、执行状态分布、owner run、Workspace Skill ToolCall 与 artifact evidence;单一成功 run 不再被当成 completed。 -- Runtime evidence summary、`runtime.json`、`artifacts.json`、`summary.md` 与前端 normalizer 消费同一份 completion audit 结构。 - -#### 4. Agent Runtime、聊天与执行可靠性 - -- Agent Chat 补齐 session state、history、timeline、thread reliability、stream runtime handler、text delta controller、action request A2UI、internal artifact visibility 与 task runtime 回归。 -- 新增 `runtimeActionConfirmation` 与更稳定的 runtime action / permission confirmation 展示,避免把被拒绝的权限确认误写成成功证据。 -- Agent thread timeline、reliability panel、ChatSidebar、Inputbar runtime status line 与 MessageList 对排队任务、内部 artifact、action request 和线程读模型状态的展示更一致。 -- Execution run command、agent run DAO、execution tracker 与 runtime evidence service 补齐 owner metadata、status history 和 review / analysis handoff 证据链。 -- `agent_runtime_export_evidence_pack`、analysis handoff、review decision template、review decision save 与 replay case 的前端 API 回归继续走统一 `agent_runtime_*` 命令面。 - -#### 5. Knowledge、Memory 与工作区页面 - -- 新增 `docs/roadmap/knowledge/prd-v2.md` 与可视化图,明确 Knowledge v2 采用 Skills-first、persona / data 双族、Builder Skill、KnowledgePack resolver 与 context run 证据链。 -- `docs/knowledge/谢晶_个人IP知识库v1.0_深澜智能.md` 已作为个人 IP golden sample 完成本地质量基线审计,覆盖 999 行、19 个章节与 15 项质量关键词,用于后续真实 Provider 输出人审对照。 -- 新增内置 `src-tauri/resources/default-skills/personal-ip-knowledge-builder` Builder Skill 包,包含 `SKILL.md`、OpenAI agent 配置、个人 IP 知识骨架、访谈问题、模板、质量清单与 `docx_to_markdown.py` 转换脚本。 -- `personal-ip-knowledge-builder` 模板根据 golden sample 审计补充“平台迁移与新赛道判断”“技术、AI 与效率判断”和 AI / 自动化 / 智能体效率能力,避免真实成品只停留在履历流水账。 -- 新增内置 `src-tauri/resources/default-skills/brand-persona-knowledge-builder` Builder Skill 包,包含 `SKILL.md`、`agents/openai.yaml`、品牌人设模板、访谈问题和质量检查表,补齐 persona 族的品牌人设知识库生产工艺。 -- 新增内置 data Builder Skill:`brand-product-knowledge-builder`、`organization-knowhow-knowledge-builder`、`growth-strategy-knowledge-builder`、`content-operations-knowledge-builder`、`private-domain-operations-knowledge-builder`、`live-commerce-operations-knowledge-builder`、`campaign-operations-knowledge-builder`,每个包都包含 `SKILL.md`、`agents/openai.yaml`、章节模板和质量检查表。 -- `knowledge_compile_pack` 对个人 IP、品牌人设与七类 data pack 已接入 Runtime Binding seam:命令层按 metadata 生成 Builder Skill 运行计划并调用 `execute_named_skill`,成功时写回 `KnowledgeBuilderSkillOutput` 与 `runtimeBinding.executed=true`,失败时记录真实 `attempted/status/error` 并回退 deterministic adapter;未知或历史 pack 才继续保持 `knowledge_builder` compat provenance。 -- Knowledge v2 已完成一次真实 Provider E2E:DevBridge 通过 `custom-da3283c4-8405-45e9-81cd-12991ffdf41c` / `claude-sonnet-4-6` 调用 `personal-ip-knowledge-builder`,产出 `documents/`、`compiled/index.json`、25 个 document-first splits 和 persona fenced context;同时修复模型返回不严格 JSON fence 时把 wrapper 写入主文档的问题,改为宽容提取 `primaryDocument.content`。 -- 新增 `npm run knowledge:provider-e2e`,把真实 Provider E2E 固化为可复用脚本;脚本支持 `--list-providers` 输出脱敏摘要,且必须显式传 `--allow-external-provider` 才会调用外部模型。 -- 新增 `npm run knowledge:release-scope-report`,以只读方式报告 dirty worktree 中 Knowledge-only 候选、明确非 Knowledge 改动和未知项,并把本次审计结果保存到 `docs/roadmap/knowledge/evidence/release-scope-report-20260508.json`。 -- `knowledge_compile_pack` 现在会从 `documents/.md` 派生 `compiled/splits//` 与 `compiled/index.json`,Resolver 与 API `compiledView` 优先消费 document-first splits;前端回归、DevBridge mock 和 Knowledge GUI smoke 已退出 `compiled/brief.md` current 路径,新 pack 不再写入 `compiled/brief.md`,旧 `compiled/brief.md` 仅保留为历史 pack fallback,命中时会产生迁移 warning,重新整理后会删除旧 brief 并回到 splits。 -- 新增 `npm run knowledge:legacy-fallback-report`,可扫描指定 workspace 的 `.lime/knowledge/packs`,列出仍依赖旧 `compiled/brief.md` fallback 或同时残留 stale brief 的历史 pack,为最终删除 fallback 提供清单。 -- `knowledge_resolve_context` 增加 persona/data 与多 pack 运行时语义:persona pack 优先注入应用指南、金句、性格、价值观等核心人设切片;`1 persona + N data` 会按 persona 先、data 后合并 fenced wrapper,并在 context run 记录多个 `activated_packs`。 -- 知识页和输入框的整理请求 metadata 已按 pack type 选择 Builder Skill:个人 IP、品牌人设与七类 data pack 指向内置 `agent-skill`,未知 pack 才保留 deprecated `knowledge_builder` compat。 -- Knowledge GUI 补齐初始 pack 选择、输入框资料中枢、File Manager 资料导入和 Agent 结果回填路径,`knowledge_compile_pack` 在 DevBridge 下保留 Builder Skill 长请求窗口,避免真实整理任务被短超时误杀。 -- Deprecated `knowledge_builder` 已收口为 v1.2.0 compat delegate:标准 pack 类型只提示委托专用 Builder Skill,未知 / 历史 pack 只允许输出最小 document-first `KnowledgeBuilderSkillOutput`,不再维护 `wiki/` 或 `compiled/brief.md` 模板;frontmatter 同步补齐 `license: Apache-2.0` 与 `compatibility.agentKnowledge: ">=0.6.0"`。 -- 项目资料类型选择和文本资料推断新增运营类知识库:`content-operations`、`private-domain-operations`、`live-commerce-operations`、`campaign-operations`;data pack 用于生成时会自动携带一个 ready persona pack,输入框资料中枢和 KnowledgePage chooser 都可显式追加 N 个 ready data pack,支持 `1 persona + N data` 的可见 metadata 闭环。 -- Knowledge Rust crate 与前端 API 扩展 pack metadata、primary document、compile run、Builder runtime options、runtime mode、fenced context、source anchors、context resolver warning 与 context run validation。 -- Knowledge GUI smoke 与 DevBridge mock 对齐 `knowledge_*` current 命令,页面回归覆盖 pack label、visibility 与默认工作区准备态。 -- Memory 页面更新任务建议、灵感投影、工作区入口和状态文案;Scene Apps 页面、详情、运行列表、scorecard、governance 与 project pack runtime 面板做了大范围结构整理和回归。 -- Sidebar、Skills Workspace、Workspace Canvas 与 Scene Apps 测试补齐导航、页面状态和主路径可见性断言。 - -#### 6. AI 图层化设计与 Design Canvas - -- Layered Design 新增 structured analyzer、native analysis API、model slot config/runtime/transport、provider capability gate 与 worker-first analyzer 组合能力。 -- 新增 subject matting、clean plate、text OCR、structured analyzer worker、worker client、heuristic worker seam 与 deterministic fallback,支持 mask、clean plate、TextLayer 与候选层质量评估。 -- Design Canvas 增加扁平图拆层、候选确认、extraction quality、PSD / zip export、artifact 写回、worker 状态、model slot readiness 与 native analyzer fallback 的可见主路径。 -- 图像任务与画布主链新增 agent protocol / preview metadata 贯通:图片生成 Skill、media task 命令、CanvasFactory、Workspace Canvas 与 layered-design image task 可共享更稳定的来源、预览和交付状态。 -- Tauri `layered_design_*` 命令、前端 `layeredDesignAnalysis` API、browser mock、`DesignCanvas.test.tsx` 与 `design-canvas-smoke` 同步更新,减少 UI、mock 与 Rust command 漂移。 -- 新增 AI 图层化设计真实样张、benchmark、PSD / Photopea 互通证据、completion evidence 与人工 review rubric;本地 smoke 可验证真实工程导出、质量报告和设计工具打开链路。 -- `src-tauri/Cargo.toml` / `Cargo.lock` 同步 `image` 相关依赖,用于图层分析与导出链路。 - -### 开发者与治理更新 - -#### 1. 命令边界、Mock 与 Contract - -- Capability Draft、Agent Runtime、Knowledge、Layered Design、DevBridge dispatcher、`mockPriorityCommands`、`defaultMocks` 与 `agentCommandCatalog` 按本轮新增命令和返回结构同步。 -- `src/lib/api/capabilityDrafts.ts` 扩展大量 snake_case / camelCase normalizer,覆盖 verification evidence、approval requests、session input、dry preflight、controlled GET preflight 与执行结果。 -- `src/lib/tauri-mock/core.ts` 和相关测试对齐无后端 GUI 预览,避免浏览器模式把未满足 gate 的能力显示为假通过。 -- `src/lib/base-setup` 的 service skill catalog projection / seeded package / adapter 更新 Skill Forge 与 Service Skill 口径。 - -#### 2. 质量脚本与发布管线 - -- `scripts/check-app-version-consistency.mjs` 已把 `packages/lime-cli-npm/package.json` 纳入版本一致性检查,防止 npm wrapper 版本再次落后于应用版本。 -- `scripts/quality-task-planner.mjs` 把 npm wrapper package 纳入 integrity 文件;版本、配置或依赖变化会触发对应本地质量任务。 -- `scripts/run-vitest-smart.mjs`、GUI smoke 脚本、Knowledge smoke、Agent runtime tool surface smoke 与 Design Canvas smoke 更新,覆盖本轮新增 GUI / command 风险。 -- 新增 `docs/aiprompts/agent-protocol-standards-map.md`,把 Agent Protocol / A2UI / runtime metadata / artifact evidence 的 current 事实源集中映射,降低 GUI、Tauri 命令和 mock 漂移风险。 -- `AGENTS.md`、`docs/aiprompts/commands.md`、`docs/aiprompts/quality-workflow.md`、`docs/aiprompts/skill-standard.md` 与文档索引同步 Skill Forge、只读 HTTP/API、Knowledge v2 和 Layered Design 的 current 边界。 - -### 已知边界 - -- Workspace skill binding readiness 仍不等于自动注入 tool surface;只有显式 runtime enable 且通过 allowlist 的 binding 才能进入当前 session 的 SkillTool gate。 -- 只读 HTTP/API 草案仍是受控能力:verification / registration / approval artifact / session input / controlled GET evidence 不保存 endpoint、token 或 response preview,也不代表技能已进入自动运行面。 -- Session credential resolver 只声明 session scope 与引用边界,不读取、不存储、不注入 secret material。 -- AI 图层化设计仍以本地图层文档、worker 处理、候选层复核、导出和 artifact 写回为主;provider-backed PSD / mask / inpaint 能力仍受 model slot readiness 与 capability gate 约束。 - -### 校验状态 - -- 已完成校验: - - `npm run verify:app-version` - - `cargo fmt --manifest-path "src-tauri/Cargo.toml" --all` - - `npm run lint:rust` - - `cargo test --manifest-path "src-tauri/Cargo.toml"` - - `npm run lint` - - `npm test` - - `npm run test:contracts` - - `npm run verify:local` - - `git diff --check` -- 结果说明: - - 版本一致性检查通过:`1.31.0`,包含 npm wrapper 版本一致性。 - - Rust fmt 通过。 - - Rust clippy 通过;当前仍有 `lime-media-runtime` 的 `clippy::result_large_err` 警告,但脚本退出码为 0。 - - Rust 测试通过:`1277` 个 lib 测试通过,`deepseek_reasoner_output_schema_runtime` 2 个集成测试通过,2 个真实联网测试按环境变量门禁保持 ignored。 - - 前端 lint、typecheck 与 Vitest smart suite 通过,`npm test` 共 51 批通过。 - - 命令契约、Harness 契约、modality runtime contract 与 cleanup report contract 通过。 - - `npm run verify:gui-smoke` 通过:复用 headless Tauri / DevBridge,覆盖 workspace ready、browser runtime、site adapters、service skill entry、runtime tool surface、Knowledge GUI 与 Design Canvas smoke。 - ---- - -**完整变更**: `v1.30.0` -> `v1.31.0` +**完整变更**: `v1.34.0` -> `v1.35.0` diff --git a/a2ui-hitl-mcp-second-turn-eval.json b/a2ui-hitl-mcp-second-turn-eval.json deleted file mode 100644 index 525199b10..000000000 --- a/a2ui-hitl-mcp-second-turn-eval.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "generatedAt": "2026-05-11T01:42:37.680Z", - "url": "http://127.0.0.1:1420/", - "title": "Lime", - "groupCount": 0, - "firstGroupIndex": null, - "secondGroupIndex": null, - "checks": { - "distinctGroups": false, - "firstGroupDoesNotContainSecondPrompt": false, - "secondGroupDoesNotContainFirstPrompt": false, - "secondGroupHasRuntimeFlowOrMeta": false, - "noOldPendingVisible": true - }, - "groups": [], - "bodyPreview": "Lime\n邀请好友\n搜索任务\n新建任务\nSkills\n灵感\n项目资料\n最近对话\nPPT大纲需求沟通\n现在\n新对话\n13分钟前\n新对话\n15分钟前\n新对话\n17分钟前\n新对话\n17分钟前\nAgent QC approval resolved 1778461404847\n39分钟前\nAgent QC approval denied 1778461394810\n39分钟前\n新对话\n42分钟前\n新对话\n43分钟前\n新对话\n49分钟前\n查看更多对话\n归档\n开\n开源使用\n本地可用\n选择工作区\n新对话\n创作\n\n青柠一下,灵感即来\n\n说一句目标,Lime 就接着帮你做。\n\n文案、图片、视频、搜索和网页任务围绕同一目标持续推进,并沉淀上下文、偏好和做法。\n\n帮我做一份 PPT 大纲\ntab\n添加资料\n高级设置\n当前模型\ndeepseek-v4-pro\n引导帮助\n写作\n添加资料\nPPT\n调研报告\n需求分析\n视频\n设计\nExcel\n编程\n更多做法\n回看最近结果 · PPT大纲需求沟通\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围绕参考账号和目标平台先整理一版增长打法,再持续跟踪内容节奏、关键指标和提醒条件。" -} \ No newline at end of file diff --git a/docs/README.md b/docs/README.md index cdc5b26c0..7a630f7e3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -20,7 +20,6 @@ - `bussniss/`:商务合作与代理运营方案 - `oem/`:品牌、slogan、Logo 替换与 OEM 物料 - `develop/`:开发流程与协作规范 -- `plugins/`:插件与扩展相关文档 - `tests/`:测试策略与用例文档 - `iteration-notes/`:迭代备忘与下版本建议(暂不进入当前发布范围的问题) - `images/`:文档图片资源 diff --git a/docs/aiprompts/README.md b/docs/aiprompts/README.md index afff92380..960c9e59c 100644 --- a/docs/aiprompts/README.md +++ b/docs/aiprompts/README.md @@ -53,7 +53,6 @@ - `services.md` - Rust 服务层 - `server.md` - HTTP Server 与接口边界 - `mcp.md` - MCP 服务器与工具管理 -- `plugins.md` - 插件系统 - `aster-integration.md` - Aster Agent 集成 ### Provider 与数据 diff --git a/docs/aiprompts/commands.md b/docs/aiprompts/commands.md index 22e97633f..7a3fb655d 100644 --- a/docs/aiprompts/commands.md +++ b/docs/aiprompts/commands.md @@ -48,6 +48,8 @@ 旧 onboarding 插件安装流与 Provider Switch 命令链也已经下线。`get_switch_providers`、`get_current_switch_provider`、`add_switch_provider`、`update_switch_provider`、`delete_switch_provider`、`switch_provider`、`import_default_config`、`read_live_provider_settings`、`check_config_sync_status`、`sync_from_external_config` 都应视为 `dead`;初装引导当前只保留语音体验流程,不再允许通过 `config-switch`、插件推荐或配置切换 UI 重新接回这条旧链。 +插件中心与桌面插件安装 / 管理命令族已经下线。`get_plugin_status`、`get_plugins`、`get_plugin_info`、`enable_plugin`、`disable_plugin`、`update_plugin_config`、`get_plugin_config`、`reload_plugins`、`unload_plugin`、`get_plugins_dir`、`list_plugin_tasks`、`get_plugin_task`、`cancel_plugin_task`、`get_plugin_queue_stats`、`install_plugin_from_file`、`install_plugin_from_url`、`uninstall_plugin`、`list_installed_plugins`、`get_installed_plugin`、`is_plugin_installed`、`get_plugins_with_ui`、`get_plugin_ui`、`handle_plugin_action`、`read_plugin_manifest_cmd`、`launch_plugin_ui`、`frontend_debug_log`、`plugin_rpc_connect`、`plugin_rpc_disconnect`、`plugin_rpc_call` 都应视为 `dead`;不得重新接回前端插件中心、DevBridge、mock 或 `tauri::generate_handler!`。 + 图库素材链路也遵循同一原则。当前主入口为 `src/lib/api/galleryMaterials.ts`,统一承接: - `create_gallery_material_metadata` diff --git a/docs/aiprompts/lib.md b/docs/aiprompts/lib.md index 933893d56..947e4cebd 100644 --- a/docs/aiprompts/lib.md +++ b/docs/aiprompts/lib.md @@ -9,8 +9,7 @@ ``` src/lib/ ├── api/ # API 封装 -│ ├── apiKeyProvider.ts -│ └── pluginUI.ts # 插件元数据 API +│ └── apiKeyProvider.ts ├── config/ # 配置 │ └── providers.ts ├── types/ # 类型定义 diff --git a/docs/aiprompts/overview.md b/docs/aiprompts/overview.md index 870f99127..24463bc32 100644 --- a/docs/aiprompts/overview.md +++ b/docs/aiprompts/overview.md @@ -55,7 +55,6 @@ lime/ │ ├── converter/ # 协议转换 │ ├── server/ # HTTP 服务器 │ └── ... -├── plugins/ # 插件目录 └── docs/ # 文档 ``` @@ -238,7 +237,6 @@ lime/ - [skill-standard.md](skill-standard.md) - 统一技能标准、目录与运行边界 - [../../src-tauri/src/skills/README.md](../../src-tauri/src/skills/README.md) - Skills 标准与集成 - [mcp.md](mcp.md) - MCP 服务器 -- [plugins.md](plugins.md) - 插件系统 - [aster-integration.md](aster-integration.md) - Agent Runtime 集成 ### 基础设施 diff --git a/docs/aiprompts/plugins.md b/docs/aiprompts/plugins.md deleted file mode 100644 index 50cff8431..000000000 --- a/docs/aiprompts/plugins.md +++ /dev/null @@ -1,88 +0,0 @@ -# 插件系统 - -## 概述 - -插件系统支持扩展 Lime 功能,包含声明式 UI 系统。 - -## 目录结构 - -``` -src-tauri/src/plugin/ -├── mod.rs # 模块入口 -├── loader.rs # 插件加载器 -├── runtime.rs # 插件运行时 -└── ui/ # 声明式 UI - ├── types.rs - └── renderer.rs - -plugins/ # 插件目录 -└── example/ - ├── manifest.json - └── main.js -``` - -## 插件清单 - -```json -{ - "name": "example-plugin", - "version": "1.0.0", - "description": "示例插件", - "main": "main.js", - "permissions": ["network", "storage"], - "ui": { - "settings": "settings.json" - } -} -``` - -## 声明式 UI - -```json -{ - "type": "form", - "fields": [ - { - "name": "apiKey", - "type": "password", - "label": "API Key", - "required": true - }, - { - "name": "enabled", - "type": "switch", - "label": "启用", - "default": true - } - ] -} -``` - -## 插件 API - -```typescript -// 插件可用的 API -interface PluginAPI { - // 存储 - storage: { - get(key: string): Promise; - set(key: string, value: any): Promise; - }; - - // 网络 - http: { - fetch(url: string, options?: RequestInit): Promise; - }; - - // UI - ui: { - showNotification(message: string): void; - showDialog(options: DialogOptions): Promise; - }; -} -``` - -## 相关文档 - -- [components.md](components.md) - 组件系统 -- [services.md](services.md) - 业务服务 diff --git a/docs/aiprompts/quality-workflow.md b/docs/aiprompts/quality-workflow.md index 7c9145884..50ba4b939 100644 --- a/docs/aiprompts/quality-workflow.md +++ b/docs/aiprompts/quality-workflow.md @@ -113,6 +113,8 @@ 如果本轮是在清退旧 onboarding 插件安装流或 Provider Switch 命令面,`get_switch_providers`、`get_current_switch_provider`、`add_switch_provider`、`update_switch_provider`、`delete_switch_provider`、`switch_provider`、`import_default_config`、`read_live_provider_settings`、`check_config_sync_status`、`sync_from_external_config` 也必须同步从前端常量、Rust 注册、services、默认 mock 与 GUI 入口中撤掉;当前 onboarding 只允许保留语音体验链,不再保留 `config-switch` 推荐安装面。最低校验至少包含 `npm run test:contracts` 与 `npm run governance:legacy-report`。 +如果本轮是在清退插件中心模块,插件安装 / 管理 / UI / RPC 命令族也必须同步从 `src/lib/api` 网关、页面入口、设置入口、Rust 注册、DevBridge 与默认 mock 中撤掉;当前 GUI 不再保留插件中心或动态插件侧栏入口。最低校验至少包含 `npm run test:contracts`、`npm run governance:legacy-report` 与受影响前端回归。 + 如果本轮涉及 `companion_*` 桌宠命令族,还要同步检查本地 companion `WebSocket` 入口、前端 `src/lib/api/companion.ts` 网关、Rust 注册、治理目录册以及浏览器模式 mock 返回形态;浏览器模式下这组命令默认也要保持可 mock,不要让桌宠接入把默认页面渲染链路卡死。 如果本轮涉及 team runtime 工具面或主线程用户消息工具,还要同步检查 Rust catalog / inventory、runtime 注册、浏览器 fallback mock 与前端 tool display;`Agent / TeamCreate / TeamDelete / SendMessage / ListPeers` 必须保持同一组 current surface,`SendUserMessage` 也必须继续停留在 current 主线程工具面,不要把已删除的 `SubAgentTask` compat 工具重新接回 Rust catalog、runtime 注册、mock 或前端 tool display。 diff --git a/docs/content/02.user-guide/13.plugins.md b/docs/content/02.user-guide/13.plugins.md deleted file mode 100644 index 7cae16a1c..000000000 --- a/docs/content/02.user-guide/13.plugins.md +++ /dev/null @@ -1,73 +0,0 @@ ---- -title: 插件中心 -description: 通过本地包或 URL 安装、启停与卸载插件 -navigation: - icon: i-heroicons-puzzle-piece ---- - -# 插件中心 - -::alert{type="info"} -插件属于进阶能力。当前页面只覆盖已经落地的安装与管理链路,不再承诺“推荐插件市场”或“一键精选区”。 -:: - -插件中心用于安装和管理扩展插件。当前可确认的主路径只有两条:从本地插件包安装,或从 URL 安装。 - -## 当前可做的事 - -- 从本地文件安装插件包 -- 从 URL 安装插件包 -- 查看 `已安装插件包` 与 `已加载插件` -- 启用、禁用、卸载、重新加载插件 -- 查看基础状态、最近错误与任务执行情况 - -## 安装插件 - -### 从本地文件安装 - -1. 打开 `插件中心` -2. 点击 `安装插件` -3. 选择本地插件包 -4. 等待校验、解压和安装完成 - -当前文件安装支持: - -- `.zip` -- `.tar.gz` -- `.tgz` - -### 从 URL 安装 - -1. 打开 `插件中心` -2. 点击 `安装插件` -3. 切换到 `URL` 页签 -4. 输入插件包直链并开始安装 - -当前 URL 安装接受 `http://` 或 `https://` 链接;如果用于团队分发,建议优先使用 `https://` 直链。 - -## 安装时会校验什么 - -插件安装器会先做基础校验,再继续安装: - -- 压缩包格式是否有效 -- 包内是否包含可解析的 `plugin.json` -- `name`、`version`、`entry` 等清单字段是否满足当前规则 -- 如果声明了 `hooks`,其命名是否满足当前格式限制 - -通过校验后,插件会进入 `已安装插件包` 列表;是否真正加载成功,还要以 `已加载插件` 区域的状态为准。 - -## 日常管理 - -你可以在插件中心完成这些操作: - -- 启用或禁用已安装插件 -- 卸载不再需要的插件包 -- 手动重新加载插件 -- 查看插件状态、最近错误和基础执行信息 - -如果某个插件声明了最低 Lime 版本要求,插件中心会给出版本提示;发布方是否兼容当前版本,仍应以实际安装和加载结果为准。 - -## 继续阅读 - -- [插件打包指南](/open-platform/plugin-development) -- [扩展接入概览](/open-platform/overview) diff --git a/docs/content/index.md b/docs/content/index.md index 58776a769..990004495 100644 --- a/docs/content/index.md +++ b/docs/content/index.md @@ -15,14 +15,13 @@ Lime 是给中文创作者用的桌面端 AI 内容工作台。它把资料、 1. 先讲创作者要完成什么:写文章、做脚本、整理资料、改版本、复盘结果 2. 先讲使用体验:资料和参考能沉淀,结果能继续改,常用做法能复用 3. 技术词只放在进阶页面,不放在普通用户的第一屏 -4. 模型、插件、连接器和开放接口都作为扩展能力说明,不能盖过创作主线 +4. 模型、连接器和开放接口都作为扩展能力说明,不能盖过创作主线 ## 当前保留内容 当前文档站仍在重建中,仅保留与现有功能对齐的少量进阶页面: - [模型连接概览](/providers/overview) -- [插件中心](/user-guide/plugins) - [进阶工具扩展](/user-guide/mcp) - [远程入口与 Webhook](/user-guide/gateway-tunnel-webhook) - [API 与开放接口概览](/api-reference/overview) diff --git a/docs/plugins/README.md b/docs/plugins/README.md deleted file mode 100644 index 1045c9dc3..000000000 --- a/docs/plugins/README.md +++ /dev/null @@ -1,11 +0,0 @@ -# Provider 插件文档退役说明 - -旧 OAuth Provider 插件文档已随凭证池退役下线,分类为 `dead`。 - -后续 Provider 文档只允许收敛到: - -- API Key Provider / configured providers -- 模型注册表 -- 协议转换器 - -Antigravity、Kiro、Codex、Claude、Gemini 等登录型或本地 CLI 凭证插件不得重新作为运行时凭证入口恢复。Antigravity 的 `openai_to_antigravity` 协议转换器不属于凭证池功能,仍可按 coding plan 需要继续使用。 diff --git a/docs/roadmap/agentui/images/agentui-stream-latency-map-20260509.svg b/docs/roadmap/agentui/images/agentui-stream-latency-map-20260509.svg index 5e4c32ead..d2631b233 100644 --- a/docs/roadmap/agentui/images/agentui-stream-latency-map-20260509.svg +++ b/docs/roadmap/agentui/images/agentui-stream-latency-map-20260509.svg @@ -1,9 +1,9 @@ - + - - - + + + @@ -16,14 +16,14 @@ - + - - + + - Lime 首字 Token 慢点复查图 - 2026-05-10 真实 GUI 复测:前端首响已经足够快,真正拖慢 TTFT 的是 WebSearch allowed 误入 FullRuntime 后的 MCP / 工具面预热。 + Lime 首字 Token 慢点复查图 v2 + 2026-05-11 更新:首字慢不靠删除 extension/MCP prompt context 解决;只精简 provider 工具 schema,并保持上下文与 broker 工具可达。 - - 点击发送到首字可见的实测链路 + + 已纠偏:context 保留,schema 收敛 - - 50ms - 首页 pending - 草稿壳和占位态出现 - assistantDraftPaint 88ms + + 点击发送到首字可见的链路归因 - + + <100ms + 前端占位态 + draft / pending paint 快 + 不是主瓶颈 - - 298ms - 运行态到达 - firstEvent 297ms - submitAccepted 258ms + - + + ~300ms + runtime 事件 + submit accepted / first event + 桥接响应正常 - - 6372ms - 提交准备阻塞 - context7 MCP 启动约 3.6s - playwright MCP 启动约 2.5s - 旧逻辑:allowed search 也触发 FullRuntime + - + + 旧:6s+ + FullRuntime 预热 + search-only 误触 MCP 启动 + context7 / playwright 前置 + 已改:web-search-only 不预热 MCP - - 3553ms - 模型 / policy 首字 - headers 456ms - first provider msg 1139ms + - + + 新:payload + provider 首字等待 + Anthropic / OpenAI / Gemini + 工具 schema 从 50+ 收敛到 broker 面 + stream 空内容时有诊断与 fallback - - 首正文到可见仅 130ms + 旧数据仍有效:firstTextPaint 慢主要来自后端准备与 provider 首 token。v2 把“启动 MCP”和“发送 50+ tool schema”从首轮路径里拆开。 - 总结果:firstThinkingDelta 9024ms,firstTextDelta 10656ms,firstTextPaint 10786ms。前端 paint 不是主瓶颈。 + + 对标后确定不能做的事 - - 这次补查到的真实慢点 + + 1. 不能删除完整 extension/MCP prompt context + ClaudeCode 与 Warp 都保留上下文,只把工具加载 / MCP 执行面分层。 - - 1. WebSearch allowed 被当成重型执行入口 - 只是“允许联网”,不等于本轮必须启动完整 MCP 工具面。 + + 2. 不能把 allowed search 等同于重型 runtime + allowed 只是能力许可;required / skill / browser 才需要更重准备。 - - 2. FullRuntime 无差别预热 enabled_lime MCP - 简单短问也会串行启动 context7 / playwright,直接吃掉 6s+。 + + 3. 不能让 provider 看到所有 MCP 具体工具 schema + 具体 MCP 工具通过 ToolSearch / resource broker / 延迟加载触达。 - - 3. 搜索开启时前端 Prompt 退出紧凑模式 - 首轮输入体积变大,进一步放大 provider 首 token 等待。 + + v2 当前策略 - - 本轮优化后的路由原则 + + 完整上下文:保留 + system prompt 仍包含 extension instructions 与 MCP resource 说明。 - - Allowed Search:仍走 FastChat - WebSearch / WebFetch 原生工具保留,模型按需搜索,但不启动 MCP。 + + 初始 schema:compact broker surface + ToolSearch + 本地核心工具 + MCP resource broker,避免 50+ tools 入参。 - - Required Search:只保留搜索约束,不预热 MCP - 明确 required 时可等待 WebSearch preflight,但 context7 / playwright 不应前置。 + + 跨协议一致:Anthropic / OpenAI / Gemini + 同一组 prepared tools 进入各 provider payload,避免只修 Anthropic。 - - Full Context:有真实技能 / 项目 / 浏览器需求才预热 - service skill、图片任务、项目上下文、多代理等仍保持重型 runtime。 + + ClaudeCode 参考 + ToolSearch / deferred tools 负责动态加载。 + MCP prompt / skills / resources 不被粗暴删除。 - - 慢点排序与预期收益 - 优先级 1:MCP / tool surface startup 约 6.37s,本轮通过 search-only 快路径移除;优先级 2:provider / policy 首字约 3.55s,后续继续看模型路由、Prompt 体积和缓存;优先级 3:前端首字 paint 约 0.13s,暂非瓶颈。 - 数据源:2026-05-10 Lime Playwright 真实复测 + Tauri TTFT 日志。优化提交后需继续用同一句短问复测 submit preparation 与 firstTextDelta。 + + Warp 参考 + supported_tools 是能力枚举。 + mcp_context 独立携带 server/tools/resources。 - - 已复查:前端快,后端预热慢 + + 下一步复测 + 同一句短问记录 TTFT / firstTextDelta。 + 重点看 submit preparation 与 provider payload。 + + + 更新口径:v2 解决首字慢的低风险主刀是“schema 收敛 + search-only 不预热 MCP + stream 诊断 fallback”,不是 prompt context 删除。 diff --git a/docs/roadmap/agentui/lime-agentui-standard-alignment.md b/docs/roadmap/agentui/lime-agentui-standard-alignment.md index ab8dea190..cbfab74d5 100644 --- a/docs/roadmap/agentui/lime-agentui-standard-alignment.md +++ b/docs/roadmap/agentui/lime-agentui-standard-alignment.md @@ -1,7 +1,7 @@ # Lime AgentUI 标准对齐与缺口追踪 > 状态:v0.6.0 标准对齐完成;后续只跟踪 future boundary 与 v0.5 尾项增强 -> 更新时间:2026-05-10 +> 更新时间:2026-05-11 > 标准版本:Agent UI `v0.6.0`,commit `dcf4bc5`(`Release Agent UI v0.6.0 team workbench`) > 范围:对齐 `/Users/coso/Documents/dev/ai/limecloud/agentui` v0.6.0、Claude Code、Codex、Vercel AI SDK、assistant-ui、LangGraph、OpenAI ChatKit/Apps SDK 等调研结论,并回挂 Lime 当前实现缺口。 @@ -38,6 +38,10 @@ Agent UI 标准引用统一落到专门页面,便于后续追溯,不在 Lime | 全流程 taxonomy | `/Users/coso/Documents/dev/ai/limecloud/agentui/docs/zh/reference/flow-and-taxonomy.md` | Conversation、Process、Task、Team、Artifact、Evidence、Diagnostics 的全流程分类。 | | 来源索引 | `/Users/coso/Documents/dev/ai/limecloud/agentui/docs/zh/reference/source-index.md` | Claude Code、Codex、AI SDK、assistant-ui、LangGraph、ChatKit 等来源追溯。 | | Runtime projection contract | `/Users/coso/Documents/dev/ai/limecloud/agentui/docs/zh/contracts/runtime-event-projection.md` | runtime source event 到 Agent UI envelope 的投影约束。 | +| AgentRuntime Profile | `/Users/coso/Documents/dev/ai/limecloud/agentruntime/docs/zh/specification.md`、`docs/roadmap/agentruntime/README.md` | Lime runtime facts 主链与 `lime-profile-0.4.0` profile event 边界。 | +| AgentContext | `/Users/coso/Documents/dev/ai/limecloud/agentcontext/docs/zh/specification.md` | context envelope、selection、budget、compaction、missing context 的 owner 边界。 | +| AgentPolicy | `/Users/coso/Documents/dev/ai/limecloud/agentpolicy/docs/zh/specification.md` | permission、approval、grant、risk 与 policy trace 的 owner 边界。 | +| AgentEvidence | `/Users/coso/Documents/dev/ai/limecloud/agentevidence/docs/zh/specification.md` | evidence pack、claim/source/provenance、review、replay 与 completeness 的 owner 边界。 | | JSON Schema | `https://limecloud.github.io/agentui/schemas/agentui-event.schema.json` | Lime adapter / contract test 对齐的公开 schema。 | | 线上规范页 | `https://limecloud.github.io/agentui/zh/reference/flow-and-taxonomy` | 对外可引用的标准说明。 | @@ -62,6 +66,26 @@ Agent UI 标准已经从“thinking/progressive rendering 局部规则”扩展 | Artifact / evidence | artifact body 离开 transcript,evidence/replay/review 走 durable refs。 | artifact/evidence/review/requested-fix execution metadata 已接入;artifact body 继续留在 artifact service/workbench。 | | Context / permission / diagnostics | context budget、missing、compaction、policy、sandbox、safe diagnostics 独立分类。 | runtime context、permission、metrics 已有 projection;memory budget / missing context 等细分 source event 属于 v0.5 尾项增强,不阻塞 v0.6 Team Workbench baseline。 | +### 3.1 AgentRuntime Profile 与相邻标准增补 + +2026-05-11 起,AgentUI 与 AgentRuntime 的关系进一步收紧为: + +```text +AgentRuntimeProfileEvent / ThreadReadModel / EvidenceSummary + -> Agent UI envelope projection + -> Workspace / Harness / Timeline / Task Capsule / Team Workbench +``` + +固定规则: + +1. `schemaVersion = lime-profile-0.4.0` 的 dotted runtime profile event 是 runtime fact,不是未知产品噪声;P1 只要求前端保持 stream 活跃且不告警。 +2. AgentUI 不直接拥有 `turn.submitted`、`model.requested`、`snapshot.updated` 等 execution truth;P2 后必须从 `ThreadReadModel` 或 profile projection 读取。 +3. AgentContext facts 只能作为 context refs、budget、missing context、compaction summary 显示,不能成为 turn/task 状态。 +4. AgentPolicy facts 只能作为 permission/action/risk/obligation 显示,不能绕过 runtime action response 写回。 +5. AgentEvidence facts 只能作为 evidence/replay/review/completeness 显示,不能在 UI 里重新生成 verdict。 + +本增补对应 AgentRuntime 路线图的 [相邻标准边界](../agentruntime/adjacent-protocols.md),属于 `current` 主链约束;旧的 UI-only runtime truth builder 继续归入 `deprecated/dead`。 + ## 4. Lime current 对齐情况 ### 4.1 已对齐主链 diff --git a/docs/roadmap/i18n/prd.md b/docs/roadmap/i18n/prd.md index dce797c69..3941f949a 100644 --- a/docs/roadmap/i18n/prd.md +++ b/docs/roadmap/i18n/prd.md @@ -973,8 +973,7 @@ Patch Layer 不能无限期作为事实源。建议退出条件: 148. 2026-05-11 Workspace 视频 VideoWorkspace 会话主预览迁移续测:`src/components/workspace/video/VideoWorkspace.tsx` 继续复用 `useTranslation("workspace")` 与 `formatNumber()`,把生成后会话 header、状态 detail、主预览标题 / 失败 / 生成中 / 等待空态、同步规则 tip 与任务更新时间收口到 `workspace.video.workspace.session.*`;视频 URL、任务时间、错误消息、模型名、分辨率与进度数值仍作为 runtime 数据或 interpolation 保留。同步 5 个 `workspace.json` 资源,并在 `src/components/workspace/video/VideoWorkspaceSelection.test.tsx` 扩展英文 locale 回归,验证 session 标题、主预览标题、历史结果说明与同步规则来自 `workspace` namespace;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `workspace.video.workspace.session.title` 与 `workspace.video.workspace.session.preview.title`。`npm test -- "src/components/workspace/video/VideoWorkspaceSelection.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、7 个用例;`npx eslint "src/components/workspace/video/VideoWorkspace.tsx" "src/components/workspace/video/VideoWorkspaceSelection.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0`、`npm run detect-translations -- --verbose`、5 个 `workspace.json` 的 JSON 解析与定向 `git diff --check` 通过,最新资源口径为 5 个 locale、6 个 namespace、4300 个 source key。执行时隔壁 qcloop / P0 smoke / `verify:gui-smoke` / Rust 编译链路仍有 active owner,本条未启动 `npm run typecheck` / `npm run verify:local` / GUI smoke,也不触碰 QC、Agent runtime、Browser Runtime、Settings、Chrome Relay 或 Agent Skills。 - -149. 2026-05-11 Settings Home 分组说明 aria 迁移续测:`src/components/settings-v2/home/index.tsx` 把设置首页分组卡片 `WorkbenchInfoTip` 的 `ariaLabel` 从中文模板 ``${group.title}说明`` 收口到 `settings.home.group.tipAria`,分组标题仍由 `useSettingsCategory()` 的既有 `settings.group.*` / `settings.tab.*` 事实源提供,分组说明继续复用 `settings.home.group.*.description`,不复制目录命名。同步 5 个 `settings.json` 资源,并在 `src/components/settings-v2/home/index.test.tsx` 增加英文资源回归,验证分组说明 aria 来自 `settings` namespace;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `settings.home.group.tipAria`。`npx eslint "src/components/settings-v2/home/index.tsx" "src/components/settings-v2/home/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0` 通过;`npm test -- "src/components/settings-v2/home/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、12 个用例;`npm run detect-translations -- --verbose` 通过,最新资源口径为 5 个 locale、6 个 namespace、4301 个 source key;5 个 `settings.json` 的 JSON 解析与定向 `git diff --check` 通过。执行时隔壁 qcloop / P0 smoke / `verify:gui-smoke` / `npm run typecheck` / Rust 编译链路仍有 active owner,本条未启动 `npm run typecheck` / `npm run verify:local` / GUI smoke,也不触碰 QC、Agent runtime、Browser Runtime、Workspace 视频、Chrome Relay 或 Agent Skills。 +149. 2026-05-11 Settings Home 分组说明 aria 迁移续测:`src/components/settings-v2/home/index.tsx` 把设置首页分组卡片 `WorkbenchInfoTip` 的 `ariaLabel` 从中文模板 `${group.title}说明` 收口到 `settings.home.group.tipAria`,分组标题仍由 `useSettingsCategory()` 的既有 `settings.group.*` / `settings.tab.*` 事实源提供,分组说明继续复用 `settings.home.group.*.description`,不复制目录命名。同步 5 个 `settings.json` 资源,并在 `src/components/settings-v2/home/index.test.tsx` 增加英文资源回归,验证分组说明 aria 来自 `settings` namespace;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `settings.home.group.tipAria`。`npx eslint "src/components/settings-v2/home/index.tsx" "src/components/settings-v2/home/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0` 通过;`npm test -- "src/components/settings-v2/home/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、12 个用例;`npm run detect-translations -- --verbose` 通过,最新资源口径为 5 个 locale、6 个 namespace、4301 个 source key;5 个 `settings.json` 的 JSON 解析与定向 `git diff --check` 通过。执行时隔壁 qcloop / P0 smoke / `verify:gui-smoke` / `npm run typecheck` / Rust 编译链路仍有 active owner,本条未启动 `npm run typecheck` / `npm run verify:local` / GUI smoke,也不触碰 QC、Agent runtime、Browser Runtime、Workspace 视频、Chrome Relay 或 Agent Skills。 150. 2026-05-11 Settings Stats 热力图日期范围迁移续测:`src/components/settings-v2/account/stats/index.tsx` 把活跃度日历覆盖范围值从固定 `{{start}} - {{end}}` 字符串拼接收口到 `settings.stats.heatmap.rangeValue`,开始 / 结束日期继续通过 `formatDate()` 按当前 locale 格式化,统计数值、Token、模型名与原始日期仍作为 runtime 数据或 interpolation 保留。同步 5 个 `settings.json` 资源,并在 `src/components/settings-v2/account/stats/index.test.tsx` 增加英文资源回归,验证热力图日期范围模板来自 `settings` namespace;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `settings.stats.heatmap.rangeValue`。`npx eslint "src/components/settings-v2/account/stats/index.tsx" "src/components/settings-v2/account/stats/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0` 通过;`npm test -- "src/components/settings-v2/account/stats/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、9 个用例;`npm run detect-translations -- --verbose` 通过,最新资源口径为 5 个 locale、6 个 namespace、4302 个 source key;5 个 `settings.json` 的 JSON 解析与定向 `git diff --check` 通过。执行时隔壁 qcloop / P0 smoke / `verify:gui-smoke` / 两个 `npm run typecheck` / Rust 编译链路仍有 active owner,本条未启动 `npm run typecheck` / `npm run verify:local` / GUI smoke,也不触碰 QC、Agent runtime、Browser Runtime、Workspace 视频、Chrome Relay 或 Agent Skills。 @@ -1014,9 +1013,147 @@ Patch Layer 不能无限期作为事实源。建议退出条件: 168. 2026-05-11 Settings Automation access mode helper 续测:`src/components/settings-v2/system/automation/automationAccessMode.ts` 已改为支持 `AutomationAccessModeCopy` 注入,不再把“只读 / 按需确认 / 完全访问”与正式策略说明硬编码为 helper 唯一事实源;`AutomationJobDialog.tsx` 改为在组件内基于 `settings.automation.jobDialog.accessMode.*` 构建 option copy,避免下拉项继续依赖全局中文常量;`automationPresentation.ts` 与 `AutomationJobDetailsDialog.tsx` 也统一复用 access mode copy 输出展示 label,使权限模式在列表、详情和 helper 调用面都回到 `settings` namespace。`npm test -- "src/components/settings-v2/system/automation/AutomationJobDialog.test.tsx" "src/components/settings-v2/system/automation/AutomationJobDetailsDialog.test.tsx" "src/components/settings-v2/system/automation/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 5 个文件、32 个用例;定向 `npx eslint` 检查 `automationAccessMode.ts`、`AutomationJobDialog.tsx`、`automationPresentation.ts` 与 `AutomationJobDetailsDialog.tsx` 未报错。本条未新增资源 key,也未启动 `npm run verify:local` / GUI smoke。 +169. 2026-05-11 Settings Providers Companion Bridge / 桌宠偏好卡 fallback 收口续测:`src/components/settings-v2/agent/providers/index.tsx` 的 Companion Bridge 当前主路径已把状态读取 / 监听 / 刷新 / 启动 / 同步反馈、接入检查、同步诊断、最近事件 / 平台 / 最近同步 / Provider 计数、安装引导、操作按钮与预览 provider 状态等展示文案统一切回 `settings.providers.companion.bridge.*` 正式资源,不再继续依赖 JSX 内 `defaultValue` / 中文 fallback 作为 current 事实源;`src/components/settings-v2/agent/providers/CompanionCapabilityPreferencesCard.tsx` 也把桌宠偏好读取 / 保存 / Provider 缺失警告、标题说明、主链摘要、通用模型区与回退说明统一切回 `settings.providers.companion.preference.*` 资源。为避免组件 fallback 退场后测试继续误把默认值当事实源,`src/components/settings-v2/agent/providers/index.test.tsx` 与 `CompanionCapabilityPreferencesCard.test.tsx` 的 `react-i18next` mock 已改为读取 `src/i18n/resources/zh-CN/settings.json`,直接用实际 key 渲染中文资源并保留插值能力。`npm test -- "src/components/settings-v2/agent/providers/index.test.tsx" "src/components/settings-v2/agent/providers/CompanionCapabilityPreferencesCard.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 4 个文件、24 个用例;定向 `npx eslint "src/components/settings-v2/agent/providers/index.tsx" "src/components/settings-v2/agent/providers/index.test.tsx" "src/components/settings-v2/agent/providers/CompanionCapabilityPreferencesCard.tsx" "src/components/settings-v2/agent/providers/CompanionCapabilityPreferencesCard.test.tsx"` 未报错。本条未新增资源 key,也未启动 `npm run verify:local` / GUI smoke。 + +170. 2026-05-11 Settings Providers workspace/cloud feedback 与 ProviderSetting fallback 收口续测:`src/components/settings-v2/agent/providers/index.tsx` 已把 workspace 顶部切换器 label / summary、云端品牌 fallback、用户中心缺入口 / 登录页打开 / 用户中心打开 / 浏览器重试 / 打开失败等反馈统一切回 `settings.providers.workspaceView.*` 与 `settings.providers.cloud.message.*` 正式资源,使 `agent/providers` current 页面不再残留 `defaultValue`;`src/components/api-key-provider/ProviderSetting.tsx` 继续把 model fetch unsupported / Responses / Fal 特例、缺失 API Key、获取结果摘要、删除确认 / 成功 / 失败、连接测试成功耗时 / 失败等反馈统一切回 `settings.providers.setting.feedback.*` 与 `settings.providers.setting.body.models.*`,不再依赖页面层 fallback。为避免 UI 测试继续把 fallback 当事实源,`src/components/api-key-provider/ProviderSetting.ui.test.tsx` 的 `react-i18next` mock 已改为读取 `src/i18n/resources/zh-CN/settings.json`,直接使用真实 key 与插值渲染。`npm test -- "src/components/api-key-provider/ProviderSetting.ui.test.tsx" "src/components/api-key-provider/ProviderSetting.test.ts" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 4 个文件、34 个用例;`npm test -- "src/components/settings-v2/agent/providers/index.test.tsx" "src/components/settings-v2/agent/providers/CompanionCapabilityPreferencesCard.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 4 个文件、24 个用例;定向 `npx eslint "src/components/api-key-provider/ProviderSetting.tsx" "src/components/api-key-provider/ProviderSetting.ui.test.tsx"` 与 `npx eslint "src/components/settings-v2/agent/providers/index.tsx" "src/components/settings-v2/agent/providers/index.test.tsx" "src/components/settings-v2/agent/providers/CompanionCapabilityPreferencesCard.tsx" "src/components/settings-v2/agent/providers/CompanionCapabilityPreferencesCard.test.tsx"` 未报错。本条未新增资源 key,也未启动 `npm run verify:local` / GUI smoke。 + +171. 2026-05-11 Artifact ErrorFallbackRenderer 错误回退面板资源补齐续测:`src/components/artifact/ErrorFallbackRenderer.tsx` 当前已接入 `useTranslation("errors")`,本条补齐 `errors.artifactFallback.*` 正式资源,把渲染失败标题、未知错误、错误类型行、重试 / 查看源码 / 复制 / 已复制 / 复制错误报告按钮、Artifact 信息字段、无标题 / 空内容、错误堆栈 / 原始内容折叠区,以及复制错误报告中的标题、时间、Artifact 字段、错误信息 / 堆栈 / 内容段落收口到 `errors` namespace;Artifact id / type / title / status / content、Error name / message / stack 与报告生成时间继续作为 runtime 数据保留。同步 5 个 `errors.json` 资源,并新增 `src/components/artifact/ErrorFallbackRenderer.test.tsx` 英文 locale 回归,验证 Render failed、Error type、Retry、View source、Copy error report、Error stack、Original content 与复制报告标题来自 `errors` namespace;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `errors.artifactFallback.title.renderFailed` 与 `errors.artifactFallback.action.copyErrorReport`。`npx eslint "src/components/artifact/ErrorFallbackRenderer.tsx" "src/components/artifact/ErrorFallbackRenderer.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0` 通过;`npm test -- "src/components/artifact/ErrorFallbackRenderer.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、7 个用例;`npm run detect-translations -- --verbose` 通过,最新资源口径为 5 个 locale、6 个 namespace、4547 个 source key;5 个 `errors.json` 的 JSON 解析与定向 `git diff --check` 通过。执行时隔壁仍有 Rust / GUI / qcloop 相关 owner,本条未启动 `npm run typecheck` / `npm run verify:local` / GUI smoke,也不触碰 OpenClaw、QC、Agent runtime、Browser Runtime、Workspace 视频、Chrome Relay 或 Agent Skills。 + +172. 2026-05-11 Artifact SvgRenderer 预览控制与错误态迁移续测:`src/components/artifact/renderers/SvgRenderer.tsx` 接入 `useTranslation("errors")`,把流式生成提示、预览 / 源码切换 label 与 title、缩小 / 放大 / 适应视图 title、下载 SVG title、SVG 渲染失败标题、无效 SVG 内容提示与 SVG 处理失败兜底收口到 `errors.svgRenderer.*`;SVG 内容、缩放百分比、文件名、sanitized SVG、底层 Error message 与 source mode 内部 `CodeRenderer` 展示继续作为 runtime 数据或后续独立切片保留。同步 5 个 `errors.json` 资源,并新增 `src/components/artifact/renderers/SvgRenderer.test.tsx` 英文 locale 回归,验证 Preview / Source / Generating... / Zoom out / Download SVG 以及 SVG render failed / Invalid SVG content 来自 `errors` namespace;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `errors.svgRenderer.error.renderFailed`。`npx eslint "src/components/artifact/renderers/SvgRenderer.tsx" "src/components/artifact/renderers/SvgRenderer.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0` 通过;`npm test -- "src/components/artifact/renderers/SvgRenderer.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、7 个用例;`npm run detect-translations -- --verbose` 通过,最新资源口径为 5 个 locale、6 个 namespace、4559 个 source key;5 个 `errors.json` 的 JSON 解析与定向 `git diff --check` 通过;随后尝试 `npm run typecheck`,但被当前工作区已有的 `src/components/artifact/ArtifactToolbar.tsx` / `workspace.artifactToolbar.*` typed key 缺失与 React children 类型错误阻断,未作为本条通过证据。本条未启动 `npm run verify:local` / GUI smoke,也不触碰 OpenClaw、QC、Agent runtime、Browser Runtime、Workspace 视频、Chrome Relay 或 Agent Skills。 + +173. 2026-05-11 ArtifactToolbar 工具栏 chrome 迁移续测:`src/components/artifact/ArtifactToolbar.tsx` 接入 `useTranslation("workspace")`,把 Artifact 类型标签、写入阶段 badge、源码 / 预览切换、预览尺寸 title、复制 / 已复制 / 下载 / 显示源码 / 显示预览 / 新窗口打开 / 关闭等工具栏可见 chrome 收口到 `workspace.artifactToolbar.*`;Artifact 标题、语言标签、文件内容、文件名、display badge 与 actions slot 继续作为 runtime / 调用方数据保留。同步 5 个 `workspace.json` 资源,并新增 `src/components/artifact/ArtifactToolbar.ui.test.tsx` 英文 locale 回归,验证 Code / Writing / Source / Preview / Phone / Tablet / Desktop / Copy content / Download file / Open in new window / Close 来自 `workspace` namespace;本条也覆盖记录 172 中提到的 ArtifactToolbar typed key / children typecheck blocker。`npm test -- "src/components/artifact/ArtifactToolbar.ui.test.tsx" "src/components/artifact/ArtifactToolbar.test.ts" "src/components/artifact/renderers/SvgRenderer.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 5 个文件、18 个用例;`npm run detect-translations -- --verbose` 通过,最新资源口径为 5 个 locale、6 个 namespace、4586 个 source key;`npm run typecheck` 通过;`npx eslint "src/components/artifact/ArtifactToolbar.tsx" "src/components/artifact/ArtifactToolbar.ui.test.tsx" --max-warnings 0`、5 个 `workspace.json` 的 JSON 解析与定向 `git diff --check` 通过。本条不触碰隔壁 `SvgRenderer` 实现、Rust provider compact 工具面、OpenClaw、QC、Agent runtime、Browser Runtime、Chrome Relay 或 Agent Skills,也未启动 `npm run verify:local` / GUI smoke。 + +174. 2026-05-11 Artifact DocumentRenderer 文档工具栏 chrome 迁移续测:`src/components/artifact/renderers/DocumentRenderer.tsx` 接入 `useTranslation("workspace")`,把文档 Artifact 内部预览 / 源码切换按钮与流式生成提示收口到 `workspace.documentRenderer.*`;Artifact 标题、Markdown / artifact document 内容、file path、source mode 代码内容与调用方 viewMode 状态继续作为 runtime 数据保留。同步 5 个 `workspace.json` 资源,并新增 `src/components/artifact/renderers/DocumentRenderer.test.tsx` 英文 locale 回归,验证 Preview / Source / Generating... 来自 `workspace` namespace。`npm test -- "src/components/artifact/renderers/DocumentRenderer.test.tsx" "src/components/artifact/ArtifactToolbar.ui.test.tsx" "src/components/artifact/ArtifactToolbar.test.ts" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 5 个文件、17 个用例;`npm run detect-translations -- --verbose` 通过,最新资源口径为 5 个 locale、6 个 namespace、4604 个 source key;`npm run typecheck` 通过;`npx eslint "src/components/artifact/renderers/DocumentRenderer.tsx" "src/components/artifact/renderers/DocumentRenderer.test.tsx" --max-warnings 0`、5 个 `workspace.json` 的 JSON 解析与定向 `git diff --check` 通过。本条不触碰隔壁 `CodeRenderer` / `SvgRenderer` 实现、Rust provider / skills 改动、OpenClaw、QC、Agent runtime、Browser Runtime、Chrome Relay 或 Agent Skills,也未启动 `npm run verify:local` / GUI smoke。 + +175. 2026-05-11 Artifact CodeRenderer 代码工具栏与错误态迁移续测:`src/components/artifact/renderers/CodeRenderer.tsx` 接入 `useTranslation("errors")`,把复制 / 已复制 / 复制代码、源码 / 预览切换、预览尺寸 title、刷新 HTML 预览、HTML iframe title、流式生成提示、行数、代码渲染失败、代码内容为空与原始内容标题收口到 `errors.codeRenderer.*`;代码内容、语言名、文件名、HTML 预览内容、高亮 HTML 与底层异常日志继续作为 runtime / 诊断数据保留。同步 5 个 `errors.json` 资源,并新增 `src/components/artifact/renderers/CodeRenderer.test.tsx` 英文 locale 回归,验证 Generating... / Source / Preview / Copy / Copy code / Phone / Tablet / Desktop / Refresh preview / HTML preview 以及 Code render failed / Code content is empty 来自 `errors` namespace;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `errors.codeRenderer.action.copyCode` 与 `errors.codeRenderer.error.emptyContent`。`npx eslint "src/components/artifact/ArtifactToolbar.tsx" "src/components/artifact/ArtifactToolbar.ui.test.tsx" "src/components/artifact/ErrorFallbackRenderer.test.tsx" "src/components/artifact/renderers/SvgRenderer.tsx" "src/components/artifact/renderers/SvgRenderer.test.tsx" "src/components/artifact/renderers/CodeRenderer.tsx" "src/components/artifact/renderers/CodeRenderer.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0` 通过;`npm test -- "src/components/artifact/ArtifactToolbar.ui.test.tsx" "src/components/artifact/ErrorFallbackRenderer.test.tsx" "src/components/artifact/renderers/SvgRenderer.test.tsx" "src/components/artifact/renderers/CodeRenderer.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 6 个文件、12 个用例;`npm run detect-translations -- --verbose` 通过,最新资源口径为 5 个 locale、6 个 namespace、4604 个 source key;10 个 `errors.json` / `workspace.json` 资源 JSON 解析、定向 `git diff --check` 与新增测试 `git diff --no-index --check` 通过;`npm run typecheck` 初次受并行 `DocumentRenderer` resource 快照中间态影响失败,重新基于最新工作区复跑后通过。本条不触碰 OpenClaw、QC、Agent runtime、Browser Runtime、Workspace 视频、Chrome Relay、Agent Skills 或 Rust provider compact 工具面,也未启动 `npm run verify:local` / GUI smoke。 + +176. 2026-05-11 Artifact HtmlRenderer 预览控制与错误态迁移续测:`src/components/artifact/renderers/HtmlRenderer.tsx` 接入 `useTranslation("errors")`,把预览 / 源码切换 label 与 title、手机 / 平板 / 桌面预览尺寸 title、刷新预览 title、流式生成提示、HTML iframe fallback title、HTML 渲染失败、内容为空或格式无效、验证失败、刷新失败、iframe 加载失败、重试按钮与源码内容标题收口到 `errors.htmlRenderer.*`;HTML 内容、Artifact 标题、预览宽度、source mode 内部 `CodeRenderer` 展示与底层异常日志继续作为 runtime / 诊断数据保留。同步 5 个 `errors.json` 资源,并新增 `src/components/artifact/renderers/HtmlRenderer.test.tsx` 英文 locale 回归,验证 Preview / Source / Generating... / Preview mode / Source mode / Phone / Tablet / Desktop / Refresh preview / HTML preview 以及 HTML render failed / HTML content is empty or invalid / Source content 来自 `errors` namespace;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `errors.htmlRenderer.error.renderFailed` 与 `errors.htmlRenderer.action.refreshPreview`。`npx eslint "src/components/artifact/ArtifactToolbar.tsx" "src/components/artifact/ArtifactToolbar.ui.test.tsx" "src/components/artifact/ErrorFallbackRenderer.test.tsx" "src/components/artifact/renderers/SvgRenderer.tsx" "src/components/artifact/renderers/SvgRenderer.test.tsx" "src/components/artifact/renderers/CodeRenderer.tsx" "src/components/artifact/renderers/CodeRenderer.test.tsx" "src/components/artifact/renderers/HtmlRenderer.tsx" "src/components/artifact/renderers/HtmlRenderer.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0` 通过;`npm test -- "src/components/artifact/ArtifactToolbar.ui.test.tsx" "src/components/artifact/ErrorFallbackRenderer.test.tsx" "src/components/artifact/renderers/SvgRenderer.test.tsx" "src/components/artifact/renderers/CodeRenderer.test.tsx" "src/components/artifact/renderers/HtmlRenderer.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 7 个文件、14 个用例;`npm run detect-translations -- --verbose` 通过,最新资源口径为 5 个 locale、6 个 namespace、4621 个 source key;`npm run typecheck` 通过;10 个 `errors.json` / `workspace.json` 资源 JSON 解析、定向 `git diff --check` 与新增测试 `git diff --no-index --check` 通过。本条不触碰 OpenClaw、QC、Agent runtime、Browser Runtime、Workspace 视频、Chrome Relay、Agent Skills 或 Rust provider compact 工具面,也未启动 `npm run verify:local` / GUI smoke。 + +177. 2026-05-11 Artifact ReactRenderer 预览 chrome / 编译错误态迁移续测:`src/components/artifact/renderers/ReactRenderer.tsx` 接入 `useTranslation("errors")`,把 React Artifact 的生成中 / 编译中状态、生成占位标题说明、预览 / 源码切换 label 与 title、编译 / 渲染错误标题、重试、源码内容标题、重新编译 title、空内容提示,以及编译结果为空 / 编译失败 / 组件创建失败 / 渲染失败兜底收口到 `errors.reactRenderer.*`;React 源码、Babel 原始异常 message、组件运行时渲染内容、source mode 内部 `CodeRenderer` 展示与 console 诊断继续作为 runtime / 诊断数据保留。同步 5 个 `errors.json` 资源,并新增 `src/components/artifact/renderers/ReactRenderer.test.tsx` 英文 locale 回归,验证 Preview / Source / Generating component... / Wait for content generation / Preview mode / Source mode,以及 Compile / render error / Retry / Source content 来自 `errors` namespace。`npm test -- "src/components/artifact/renderers/ReactRenderer.test.tsx" "src/components/artifact/renderers/CodeRenderer.test.tsx" "src/components/artifact/renderers/DocumentRenderer.test.tsx" "src/components/artifact/ArtifactToolbar.ui.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 6 个文件、11 个用例;`npm run detect-translations -- --verbose` 通过,最新资源口径为 5 个 locale、6 个 namespace、4639 个 source key;`npm run typecheck` 通过;`npx eslint "src/components/artifact/renderers/ReactRenderer.tsx" "src/components/artifact/renderers/ReactRenderer.test.tsx" --max-warnings 0`、5 个 `errors.json` 的 JSON 解析与定向 `git diff --check` 通过。本条不触碰隔壁 `CodeRenderer` / `HtmlRenderer` / `SvgRenderer` 实现、Rust provider / skills 改动、OpenClaw、QC、Agent runtime、Browser Runtime、Chrome Relay 或 Agent Skills,也未启动 `npm run verify:local` / GUI smoke。 + +178. 2026-05-11 Artifact MermaidRenderer 预览控制 / 导出菜单 / 错误态迁移续测:`src/components/artifact/renderers/MermaidRenderer.tsx` 接入 `useTranslation("errors")`,把 Mermaid 主题 label、流式生成提示与占位标题说明、预览 / 源码切换 label 与 title、缩放控制 title、切换主题 title、导出图表 / 导出 SVG / 导出 PNG 菜单、Mermaid 语法错误、图表渲染失败、Canvas 2D / SVG 图像导出错误兜底、导出失败兜底、源码内容标题与空内容提示收口到 `errors.mermaidRenderer.*`;Mermaid 源码、缩放百分比、导出文件名、rendered SVG 与底层 Error message 继续作为 runtime / 诊断数据保留。同步 5 个 `errors.json` 资源,并新增 `src/components/artifact/renderers/MermaidRenderer.test.tsx` 英文 locale 回归,验证 Preview / Source / Default / Export / Generating diagram... / Wait for generation... / Zoom out / Switch theme / Export diagram 以及 Mermaid syntax error / Source content 来自 `errors` namespace;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `errors.mermaidRenderer.error.syntax` 与 `errors.mermaidRenderer.action.exportDiagram`。`npx eslint "src/components/artifact/ArtifactToolbar.tsx" "src/components/artifact/ArtifactToolbar.ui.test.tsx" "src/components/artifact/ErrorFallbackRenderer.test.tsx" "src/components/artifact/renderers/SvgRenderer.tsx" "src/components/artifact/renderers/SvgRenderer.test.tsx" "src/components/artifact/renderers/CodeRenderer.tsx" "src/components/artifact/renderers/CodeRenderer.test.tsx" "src/components/artifact/renderers/HtmlRenderer.tsx" "src/components/artifact/renderers/HtmlRenderer.test.tsx" "src/components/artifact/renderers/MermaidRenderer.tsx" "src/components/artifact/renderers/MermaidRenderer.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0` 通过;`npm test -- "src/components/artifact/ArtifactToolbar.ui.test.tsx" "src/components/artifact/ErrorFallbackRenderer.test.tsx" "src/components/artifact/renderers/SvgRenderer.test.tsx" "src/components/artifact/renderers/CodeRenderer.test.tsx" "src/components/artifact/renderers/HtmlRenderer.test.tsx" "src/components/artifact/renderers/MermaidRenderer.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 8 个文件、16 个用例;`npm run detect-translations -- --verbose` 通过,最新资源口径为 5 个 locale、6 个 namespace、4665 个 source key;`npm run typecheck` 通过;10 个 `errors.json` / `workspace.json` 资源 JSON 解析、定向 `git diff --check` 与新增测试 `git diff --no-index --check` 通过。本条不触碰 OpenClaw、QC、Agent runtime、Browser Runtime、Workspace 视频、Chrome Relay、Agent Skills 或 Rust provider compact 工具面;执行中发现隔壁已追加 ReactRenderer 记录并使用重复编号 176,本条仅将其顺延为 177 以避免 PRD 编号冲突,未改其实现内容;本条未启动 `npm run verify:local` / GUI smoke。 + +179. 2026-05-11 ArtifactRenderer 统一入口空内容态 / 流式状态 / 未知类型回退迁移续测:`src/components/artifact/ArtifactRenderer.tsx` 接入 `useTranslation("workspace")`,把渲染器懒加载骨架、空内容写入阶段 / 写入失败 / 写入完成说明、右下角流式生成 / 完成状态、未知 Artifact 类型回退说明,以及渲染失败后的源码视图标题与重试按钮收口到 `workspace.artifactRenderer.*`;Artifact 文件路径、底层写入错误、未知类型值、原始内容与 renderer registry 仍作为 runtime / 诊断数据保留。同步 5 个 `workspace.json` 资源,并扩展 `src/components/artifact/ArtifactRenderer.ui.test.tsx` 英文 locale 回归,验证 Writing、Content is still being written、Generating...、Unknown type 与未知类型详情来自 `workspace` namespace;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `workspace.artifactRenderer.empty.failed.title` 与 `workspace.artifactRenderer.fallback.unknownType`。`npm test -- "src/components/artifact/ArtifactRenderer.ui.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、16 个用例;`npx eslint "src/components/artifact/ArtifactRenderer.tsx" "src/components/artifact/ArtifactRenderer.ui.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0`、`npm run detect-translations -- --verbose`、5 个 `workspace.json` 的 JSON 解析与定向 `git diff --check` 通过,最新资源口径为 5 个 locale、6 个 namespace、4711 个 source key。`npm run typecheck` 已尝试,但被并行脏改 `src/components/AppSidebar.test.tsx` 的 `mockGetPluginsForSurface` 未定义,以及 `src/components/settings-v2/account/profile/index.tsx` 的若干 `count` 字符串插值类型不匹配阻断,未作为本条通过证据。本条不触碰隔壁 `CodeRenderer` / `HtmlRenderer` / `SvgRenderer` / `MermaidRenderer` 实现、Rust provider / skills 改动、OpenClaw、QC、Agent runtime、Browser Runtime、Chrome Relay 或 Agent Skills,也未启动 `npm run verify:local` / GUI smoke。 + +180. 2026-05-11 Artifact CanvasAdapter 图层设计预览 chrome 迁移续测:`src/components/artifact/CanvasAdapter.tsx` 接入 `useTranslation("workspace")`,把 Canvas 加载态、不支持类型提示、Canvas 类型 label、图层类型 label、图层设计 inline preview 的统计卡 / 当前层 / 图层摘要 / top N / 显示隐藏 / 锁定编辑状态、完整编辑器 aria / title / 关闭按钮、头部标题、生成中状态、打开完整编辑器 title / aria 与编辑按钮收口到 `workspace.canvasAdapter.*`;Canvas 文档标题、尺寸、状态、层名、zIndex、资产数量、Artifact type、CanvasFactory 状态与项目 / 图层生成上下文继续作为 runtime 数据保留。同步 5 个 `workspace.json` 资源,并新增 `src/components/artifact/CanvasAdapter.test.tsx` 英文 locale 回归,验证 Layered design Canvas、Visible layers、Assets、Generated、Current layer、Layer summary、Open in full editor、Unsupported Canvas type 等 chrome 来自 `workspace` namespace;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `workspace.canvasAdapter.action.openFullEditor` 与 `workspace.canvasAdapter.designPreview.visibleLayers`。`npx eslint "src/components/artifact/CanvasAdapter.tsx" "src/components/artifact/CanvasAdapter.test.tsx" "src/components/artifact/renderers/MermaidRenderer.tsx" "src/components/artifact/renderers/MermaidRenderer.test.tsx" "src/components/artifact/renderers/HtmlRenderer.tsx" "src/components/artifact/renderers/HtmlRenderer.test.tsx" "src/components/artifact/renderers/CodeRenderer.tsx" "src/components/artifact/renderers/CodeRenderer.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0` 通过;`npm test -- "src/components/artifact/CanvasAdapter.test.tsx" "src/components/artifact/renderers/MermaidRenderer.test.tsx" "src/components/artifact/renderers/HtmlRenderer.test.tsx" "src/components/artifact/renderers/CodeRenderer.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 6 个文件、13 个用例;`npm run detect-translations -- --verbose` 通过,最新资源口径为 5 个 locale、6 个 namespace、4711 个 source key;`npm run typecheck` 通过;10 个 `errors.json` / `workspace.json` 资源 JSON 解析、定向 `git diff --check` 与新增测试 `git diff --no-index --check` 通过。执行时发现隔壁已对 `src/components/artifact/ArtifactRenderer.tsx` / `ArtifactRenderer.ui.test.tsx` 做并行脏改,本条避开不接手;本条不触碰 OpenClaw、QC、Agent runtime、Browser Runtime、Workspace 视频、Chrome Relay、Agent Skills 或 Rust provider compact 工具面,也未启动 `npm run verify:local` / GUI smoke。 + +181. 2026-05-11 ArtifactDocumentRenderer 结构化阅读面 chrome / block fallback 迁移续测:`src/components/artifact/renderers/ArtifactDocumentRenderer.tsx` 接入 `useTranslation("workspace")`,把结构化文档 kind / status / theme label、统计卡 label / detail、来源附录标题、主题 / 面向 / 目标 / 语言 metadata chip 与失败恢复说明收口到 `workspace.artifactDocument.*`;`src/components/artifact/renderers/artifact-document/ArtifactDocumentBlocks.tsx` 同步把图片占位 fallback、章节 badge、摘要亮点序号、表格标题 fallback、引用列表标题与来源 fallback 收口到 `workspace.artifactDocumentBlock.*`。文档标题、摘要、block 内容、来源 URL / path / snippet、audience / intent / language 原始值与底层 runtime 数据继续保留为运行时数据。同步 5 个 `workspace.json` 资源,并新增 `src/components/artifact/renderers/ArtifactDocumentRenderer.test.tsx` 英文 locale 回归,验证 Report / Readable / Theme General / Blocks / Sections / Sources / Highlights / Section / Point 01 / Table / References / Source 1 / Image placeholder / Source appendix 与失败恢复说明来自 `workspace` namespace;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `workspace.artifactDocument.kind.report`、`workspace.artifactDocument.stats.blocks.label` 与 `workspace.artifactDocumentBlock.citation.titleFallback`。`npx eslint "src/components/artifact/renderers/ArtifactDocumentRenderer.tsx" "src/components/artifact/renderers/ArtifactDocumentRenderer.test.tsx" "src/components/artifact/renderers/artifact-document/ArtifactDocumentBlocks.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0` 通过;`npm test -- "src/components/artifact/renderers/ArtifactDocumentRenderer.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、7 个用例;`npm run detect-translations -- --verbose` 通过,最新资源口径为 5 个 locale、6 个 namespace、4753 个 source key;`npm run typecheck` 通过;5 个 `workspace.json` 资源 JSON 解析、定向 `git diff --check` 与新增测试 `git diff --no-index --check` 通过。执行时避开隔壁已处理的 `ArtifactRenderer` / `ReactRenderer` 与 OpenClaw 清理,不触碰 QC、Agent runtime、Browser Runtime、Chrome Relay、Agent Skills 或 Rust provider compact 工具面,也未启动 `npm run verify:local` / GUI smoke。 + +182. 2026-05-11 Artifact BrowserAssistRenderer 状态说明 / browserActionIndex 复盘迁移续测:`src/components/artifact/renderers/BrowserAssistRenderer.tsx` 接入 `useTranslation("workspace")`,把浏览器协助 pending / failed / not ready / migrated 状态说明、会话 / 配置可接管提示、browserActionIndex 复盘标题 / 描述 / 统计卡 / 最近动作列表 / 状态 badge / 截图与 URL/session/target/entry label 收口到 `workspace.browserAssistRenderer.*`;`browser_replay_viewer`、`browser_control`、browser action kind、URL、session / target / profile id、backend、request id 与底层 launch error / launch hint 继续作为 runtime / 诊断数据保留。同步 5 个 `workspace.json` 资源,并扩展 `src/components/artifact/renderers/BrowserAssistRenderer.test.tsx` 英文 locale 回归,验证 Starting browser assist、Browser Assist replay、Browser actions、Recent browser actions、Screenshot、Browser assist has moved to Browser Workspace 与 session takeover copy 来自 `workspace` namespace;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `workspace.browserAssistRenderer.launching.title` 与 `workspace.browserAssistRenderer.replay.title`。`npm test -- "src/components/artifact/renderers/BrowserAssistRenderer.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、9 个用例;`npx eslint "src/components/artifact/renderers/BrowserAssistRenderer.tsx" "src/components/artifact/renderers/BrowserAssistRenderer.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0`、`npm run detect-translations -- --verbose`、`npm run typecheck`、5 个 `workspace.json` 的 JSON 解析与定向 `git diff --check` 通过,最新资源口径为 5 个 locale、6 个 namespace、4788 个 source key。本条避开已 dirty 的 `ArtifactDocumentRenderer` / `ArtifactDocumentBlocks` / Code / Html / Svg / Mermaid / React / CanvasAdapter 与 Rust provider / skills 改动,不触碰 OpenClaw、QC、Agent runtime、Browser Runtime、Chrome Relay 或 Agent Skills,也未启动 `npm run verify:local` / GUI smoke。 + +183. 2026-05-11 Settings Agent Skills 高级入口 defaultValue fallback 收口续测:`src/components/settings-v2/agent/skills/index.tsx` 复用既有 `settings.agent.skills.advancedEntry.*` 正式资源,把高级技能入口标题、Tips aria / 内容与问题反馈链接从 `t(key, 中文 fallback)` 改为纯 key-based 读取,避免测试或运行时继续把中文 fallback 当事实源;`SkillsPage` 主体仍按独立 Skills 工作台后续迁移,不在本条改写。`src/components/settings-v2/agent/skills/index.test.tsx` 改用真实 i18n resource 与英文 locale 回归,验证 Advanced Skill Entry、Issue Feedback 与 Advanced skill entry help 来自 `settings` namespace,且页面不再泄露中文 fallback 或 key 字符串;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `settings.agent.skills.advancedEntry.title` 与 `settings.agent.skills.advancedEntry.tipAria`。`npx eslint "src/components/settings-v2/agent/skills/index.tsx" "src/components/settings-v2/agent/skills/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0` 通过;`npm test -- "src/components/settings-v2/agent/skills/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、6 个用例;`npm run detect-translations -- --verbose` 通过,当前资源口径为 5 个 locale、6 个 namespace、4788 个 source key;`npm run typecheck` 通过;10 个 `workspace.json` / `settings.json` 资源 JSON 解析与定向 `git diff --check` 通过。本条不触碰隔壁 BrowserAssistRenderer / Artifact 渲染器脏改、OpenClaw、QC、Agent runtime、Browser Runtime、Chrome Relay、Agent Skills 主体或 Rust provider compact 工具面,也未启动 `npm run verify:local` / GUI smoke。 + +184. 2026-05-11 Settings Account Stats 统计页 defaultValue fallback 收口续测:`src/components/settings-v2/account/stats/index.tsx` 复用既有 `settings.stats.*` 正式资源,把统计页 duration、时间范围、weekday、区段卡、错误 fallback、峰值日、热力图范围、hero / summary / observation、阶段概览、模型排行、每日趋势与活跃度日历等 `t(key, 中文 fallback)` / `defaultValue` 全部改为纯 key-based 读取;统计数值、模型名、Token、日期、百分比和运行时 API 错误继续作为 runtime / interpolation 数据保留。`src/components/settings-v2/account/stats/index.test.tsx` 改用真实 i18n resource,验证 zh-CN 热力图范围使用 `settings.stats.heatmap.rangeValue` 的“至”模板、en-US 使用 `-` 模板,且紧凑数字仍走 locale-aware `Intl`;`loadNamespace.test.ts` / `types.test.ts` 额外覆盖 `settings.stats.range.week.label` 与 `settings.stats.segment.messages`。`npx eslint "src/components/settings-v2/account/stats/index.tsx" "src/components/settings-v2/account/stats/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0` 通过;`npm test -- "src/components/settings-v2/account/stats/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、9 个用例;`npm run detect-translations -- --verbose` 通过,当前资源口径为 5 个 locale、6 个 namespace、4794 个 source key;5 个 `settings.json` 资源 JSON 解析与定向 `git diff --check` 通过。执行时隔壁 `npm run verify:gui-smoke` 与独立 `npm run typecheck -- --pretty false && npm run lint` 正在活动,本条未单独启动 `npm run typecheck` / `npm run verify:local` / GUI smoke;本条也不触碰隔壁 BrowserAssistRenderer / Artifact 渲染器脏改、OpenClaw、QC、Agent runtime、Browser Runtime、Chrome Relay、Agent Skills 主体或 Rust provider compact 工具面。 + +185. 2026-05-11 Agent Chat A2UI 提交反馈 chrome 迁移续测:`src/components/agent/chat/workspace/A2UISubmissionNotice.tsx` 与 `src/components/agent/chat/workspace/WorkspacePendingA2UIPanel.tsx` 接入 `useTranslation("workspace")`,把提交完成提示的展开 / 收起按钮、待处理 A2UI 表单 stale 状态、渐进式步骤状态与底部提示收口到 `workspace.a2uiSubmissionNotice.*` / `workspace.pendingA2UI.*`;notice 标题、summary、表单字段、submitAction label 与 progressiveA2UI step 数字继续作为 runtime 数据保留。同步 5 个 `workspace.json` 资源,并新增 `src/components/agent/chat/workspace/A2UISubmissionNotice.test.tsx` 英文 locale 回归,扩展 `WorkspacePendingA2UIPanel.test.tsx` 验证 Show more / Show less、Still processing、Step 2/3 与底部提示来自 `workspace` namespace;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `workspace.a2uiSubmissionNotice.action.expand` 与 `workspace.pendingA2UI.status.progressStep`。`npm test -- "src/components/agent/chat/workspace/A2UISubmissionNotice.test.tsx" "src/components/agent/chat/workspace/WorkspacePendingA2UIPanel.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 4 个文件、9 个用例;`npx eslint "src/components/agent/chat/workspace/A2UISubmissionNotice.tsx" "src/components/agent/chat/workspace/A2UISubmissionNotice.test.tsx" "src/components/agent/chat/workspace/WorkspacePendingA2UIPanel.tsx" "src/components/agent/chat/workspace/WorkspacePendingA2UIPanel.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0`、`npm run detect-translations -- --verbose`、5 个 `workspace.json` 资源 JSON 解析、定向 `git diff --check` 与新增测试 `git diff --no-index --check` 通过,最新资源口径为 5 个 locale、6 个 namespace、4794 个 source key。随后单独尝试 `npm run typecheck`,但被并行脏改 `src/components/agent/chat/utils/fastResponseModel.test.ts` 的 `shouldUseAgentFastResponseSelection` 缺失 / `configuredProviders` 与 `currentProviderType` 旧参数,以及 `src/components/agent/chat/workspace/useWorkspaceSendActions.test.tsx` 的同类旧参数阻断,未作为本条通过证据。执行时发现隔壁已新增记录 183 / 184,本条顺延为 185 并未改其实现内容;本条不触碰 OpenClaw、QC、Artifact 渲染器、Settings provider/profile/appearance/stats、Rust provider compact 工具面或 Browser Runtime,也未启动新的 `npm run verify:local` / GUI smoke。 + +186. 2026-05-11 Settings Channels Log Tail defaultValue fallback 收口续测:`src/components/settings-v2/system/channels/ChannelLogTailPanel.tsx` 复用既有 `settings.channels.logTail.*` 正式资源,把日志 Tail 的无效正则、日志源不可用、复制空态 / 成功 / 权限失败、清空确认 / 成功 / 失败、标题说明、暂停 / 继续、复制视图、清空日志、过滤模式 / 自定义正则 / 自动滚动、拉取失败、加载态与空态等 `t(key, 中文 fallback)` / `defaultValue` 全部改为纯 key-based 读取;日志内容、Gateway 名称、RPC、时间戳与底层 API 错误继续作为 runtime / 诊断数据保留。`src/components/settings-v2/system/channels/ChannelLogTailPanel.test.tsx` 去掉 `react-i18next` mock,改用真实 i18n resource 与英文 locale 回归,验证 Channel Log Tail、Filter Mode、Copy View、Loading...、No logs to copy in the current view、Clear logs? 确认文案与 Invalid regex. Falling back to no filter. 均来自 `settings` namespace,且页面不再泄露中文 fallback 或 key 字符串;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `settings.channels.logTail.title` 与 `settings.channels.logTail.error.invalidRegex`。`npx eslint "src/components/settings-v2/system/channels/ChannelLogTailPanel.tsx" "src/components/settings-v2/system/channels/ChannelLogTailPanel.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0` 通过;`npm test -- "src/components/settings-v2/system/channels/ChannelLogTailPanel.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、9 个用例;`npm run detect-translations -- --verbose` 通过,当前资源口径为 5 个 locale、6 个 namespace、4794 个 source key;5 个 `settings.json` 资源 JSON 解析通过。执行时发现隔壁已新增记录 185,并且 `npm run verify:local` / 独立 `npm run typecheck` / qcloop 仍在活动,本条顺延为 186,未单独启动 `npm run typecheck` / `npm run verify:local` / GUI smoke;本条不触碰 OpenClaw、Agent Chat A2UI、Artifact 渲染器、Rust provider compact 工具面、Browser Runtime 或 Chrome Relay。 + +187. 2026-05-11 Settings useSettingsCategory 导航分组 fallback 收口续测:`src/components/settings-v2/hooks/useSettingsCategory.ts` 复用既有 `settings.group.*` 与 `settings.tab.*` 正式资源,把设置分组标题与 Settings 首页、Profile、Stats、Appearance、Hotkeys、Memory、AI Providers、Skills、Service Models、MCP Servers、Web Search、Environment Variables、Connector、Automation、Developer & Labs、About 等导航项从 `t(key, 中文 fallback)` 改为纯 key-based 读取;`SettingsTabs` / `SettingsGroupKey` 与 icon / experimental 标记继续作为路由与展示结构事实源。`src/components/settings-v2/hooks/useSettingsCategory.test.tsx` 去掉 `react-i18next` mock,改用真实 i18n resource 与英文 locale 回归,验证 System 与 Developer & Labs 来自 `settings` namespace,并确保不再泄露“开发者与实验功能”中文 fallback 或 key 字符串;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `settings.group.system` 与 `settings.tab.developerLab`。`npx eslint "src/components/settings-v2/hooks/useSettingsCategory.ts" "src/components/settings-v2/hooks/useSettingsCategory.test.tsx" "src/components/settings-v2/system/channels/ChannelLogTailPanel.tsx" "src/components/settings-v2/system/channels/ChannelLogTailPanel.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0` 通过;`npm test -- "src/components/settings-v2/hooks/useSettingsCategory.test.tsx" "src/components/settings-v2/system/channels/ChannelLogTailPanel.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 4 个文件、10 个用例;`npm run detect-translations -- --verbose` 通过,当前资源口径为 5 个 locale、6 个 namespace、4794 个 source key;5 个 `settings.json` 资源 JSON 解析通过。随后尝试 `npm run typecheck -- --pretty false`,但被并行 / 既有 `src/components/agent/chat/utils/fastResponseModel.ts` 与 `AgentChatWorkspace.tsx` / `useWorkspaceSendActions.test.tsx` 的 fast response selection API 不一致阻断,未作为本条通过证据;本条不触碰 OpenClaw、Agent Chat fast response、A2UI、Artifact 渲染器、Rust provider compact 工具面、Browser Runtime 或 Chrome Relay,也未启动 `npm run verify:local` / GUI smoke。 + +188. 2026-05-11 Settings DeveloperLab 合并入口 fallback 收口续测:`src/components/settings-v2/system/developer-lab/index.tsx` 复用既有 `settings.developerLab.*` 正式资源,把开发者与实验功能合并入口标题、说明、Developer Tools / Experimental Features tab 从 `t(key, 中文 fallback)` 改为纯 key-based 读取;`initialTab`、Tabs 状态、`DeveloperSettings` / `ExperimentalSettings` 嵌入关系继续作为路由与组件结构事实源。`src/components/settings-v2/system/developer-lab/index.test.tsx` 去掉 `react-i18next` mock,改用真实 i18n resource 与英文 locale 回归,验证 Developer & Labs、Developer Tools、Experimental Features 来自 `settings` namespace,并确保不再泄露“开发者与实验功能”中文 fallback 或 key 字符串;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `settings.developerLab.title` 与 `settings.developerLab.tabs.experimental`。`npx eslint "src/components/settings-v2/system/developer-lab/index.tsx" "src/components/settings-v2/system/developer-lab/index.test.tsx" "src/components/settings-v2/hooks/useSettingsCategory.ts" "src/components/settings-v2/hooks/useSettingsCategory.test.tsx" "src/components/settings-v2/system/channels/ChannelLogTailPanel.tsx" "src/components/settings-v2/system/channels/ChannelLogTailPanel.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0` 通过;`npm test -- "src/components/settings-v2/system/developer-lab/index.test.tsx" "src/components/settings-v2/hooks/useSettingsCategory.test.tsx" "src/components/settings-v2/system/channels/ChannelLogTailPanel.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 5 个文件、13 个用例;`npm run detect-translations -- --verbose` 通过,当前资源口径为 5 个 locale、6 个 namespace、4797 个 source key;5 个 `settings.json` 资源 JSON 解析通过。由于记录 187 后最近一次 `npm run typecheck -- --pretty false` 已被 `src/components/agent/chat/utils/fastResponseModel.ts` 与 `AgentChatWorkspace.tsx` / `useWorkspaceSendActions.test.tsx` 的 fast response selection API 不一致阻断,本条未重复启动全量 typecheck / `npm run verify:local` / GUI smoke;本条不触碰 OpenClaw、Agent Chat fast response、A2UI、Artifact 渲染器、Rust provider compact 工具面、Browser Runtime 或 Chrome Relay。 + +189. 2026-05-11 Settings AutomationJobFocusStrip 焦点条 fallback 收口续测:`src/components/settings-v2/system/automation/AutomationJobFocusStrip.tsx` 复用既有 `settings.automation.focus.*` 正式资源,把当前焦点 badge、加载占位、这轮判断 / 最近结果 / 先做插值、继续看结果与看最近结果按钮从 `t(key, 中文 fallback)` / `defaultValue` 改为纯 key-based 读取;job title、statusLabel、runDetailView 状态、completion label、nextAction、error 与点击回调继续作为 runtime / 业务数据保留。`src/components/settings-v2/system/automation/AutomationJobFocusStrip.test.tsx` 去掉 `react-i18next` mock,改用真实 i18n resource 与英文 locale 回归,验证 Continue this one first、This run、Recent result、Do first、Review result、View recent results 与 loading 占位来自 `settings` namespace,并确保不再泄露“现在先继续这条”中文 fallback 或 key 字符串;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `settings.automation.focus.label` 与 `settings.automation.focus.strip.recentResult`。`npx eslint "src/components/settings-v2/system/automation/AutomationJobFocusStrip.tsx" "src/components/settings-v2/system/automation/AutomationJobFocusStrip.test.tsx" "src/components/settings-v2/system/developer-lab/index.tsx" "src/components/settings-v2/system/developer-lab/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0` 通过;`npm test -- "src/components/settings-v2/system/automation/AutomationJobFocusStrip.test.tsx" "src/components/settings-v2/system/developer-lab/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 4 个文件、11 个用例;`npm run detect-translations -- --verbose` 通过,当前资源口径为 5 个 locale、6 个 namespace、4797 个 source key;5 个 `settings.json` 资源 JSON 解析通过。由于最近一次 `npm run typecheck -- --pretty false` 仍被 Agent Chat fast response selection API 不一致阻断,本条未重复启动全量 typecheck / `npm run verify:local` / GUI smoke;本条不触碰 OpenClaw、Agent Chat fast response、A2UI、Artifact 渲染器、Rust provider compact 工具面、Browser Runtime 或 Chrome Relay。 + +190. 2026-05-11 Settings Image / Video Generation 服务模型 fallback 收口续测:`src/components/settings-v2/agent/image-gen/index.tsx`、`src/components/settings-v2/agent/video-gen/index.tsx` 与共享 `MediaPreferenceSection` / `SettingModelSelectorField` 复用既有 `settings.mediaGeneration.*` 正式资源,把图片 / 视频服务模型页标题、说明、默认模型 selector、自动选择 placeholder、不可用配置 warning、自动回退开关、空态、恢复默认按钮和保存反馈从 `t(key, 中文 fallback)` / `defaultValue` / 组件默认中文 placeholder 改为纯 key-based 读取;Provider 名称、模型 id、配置 id 与控制台诊断日志继续作为 runtime / 诊断数据保留。`src/components/settings-v2/agent/image-gen/index.test.tsx` 与 `src/components/settings-v2/agent/video-gen/index.test.tsx` 去掉 `react-i18next` mock,改用真实 i18n resource 与英文 locale 回归,验证 Image Service Model / Video Service Model、Auto fallback when Provider is unavailable、Restore defaults、Settings saved、Auto select 以及 hover tip 均来自 `settings` namespace,并确保不再泄露中文 fallback 或 `settings.mediaGeneration` key 字符串;新增 `src/components/settings-v2/agent/shared/SettingModelSelectorField.test.tsx` 覆盖共享 selector 默认占位文案来自 `settings.mediaGeneration.selector.auto`;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `settings.mediaGeneration.image.title` 与 `settings.mediaGeneration.video.hint.ready`。`npm test -- "src/components/settings-v2/agent/image-gen/index.test.tsx" "src/components/settings-v2/agent/video-gen/index.test.tsx" "src/components/settings-v2/agent/shared/SettingModelSelectorField.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 5 个文件、13 个用例;`npx eslint "src/components/settings-v2/agent/image-gen/index.tsx" "src/components/settings-v2/agent/image-gen/index.test.tsx" "src/components/settings-v2/agent/video-gen/index.tsx" "src/components/settings-v2/agent/video-gen/index.test.tsx" "src/components/settings-v2/agent/shared/MediaPreferenceSection.tsx" "src/components/settings-v2/agent/shared/SettingModelSelectorField.tsx" "src/components/settings-v2/agent/shared/SettingModelSelectorField.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0`、`npm run detect-translations -- --verbose`、`npm run typecheck -- --pretty false` 与 5 个 `settings.json` 资源 JSON 解析通过,当前资源口径为 5 个 locale、6 个 namespace、4797 个 source key;定向 `git diff --check` 与新增测试 `git diff --no-index --check` 通过。本条未启动 `npm run verify:local` / GUI smoke;本条不触碰 Agent Chat fast response、A2UI、Artifact 渲染器、Rust provider compact 工具面、Browser Runtime、Chrome Relay、Settings media-services / voice / providers 脏区或插件清理面。 + +191. 2026-05-11 Settings AutomationOverviewFocusCard 概览焦点卡 fallback 收口续测:`src/components/settings-v2/system/automation/AutomationOverviewFocusCard.tsx` 复用既有 `settings.automation.focus.*` 正式资源,把概览焦点卡标题说明、空态、加载态、nextAction 插值、最近结果标题,以及 Review this run / View recent results / Fill in this run / View details 四个动作按钮从 `t(key, 中文 fallback)` / `defaultValue` 改为纯 key-based 读取;job name、summary card title/status/action/signal、runDetailView 状态 / completion / summary 与回调继续作为 runtime / 业务数据保留。`src/components/settings-v2/system/automation/AutomationOverviewFocusCard.test.tsx` 去掉 `react-i18next` mock,改用真实 i18n resource 与英文 locale 回归,验证 Continue this one first、Do first、Recent result、Review this run、View recent results、Fill in this run、View details 与 empty copy 来自 `settings` namespace,并确保不再泄露“现在先继续这条”中文 fallback 或 key 字符串;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `settings.automation.focus.title` 与 `settings.automation.focus.action.openDetail`。`npx eslint "src/components/settings-v2/system/automation/AutomationOverviewFocusCard.tsx" "src/components/settings-v2/system/automation/AutomationOverviewFocusCard.test.tsx" "src/components/settings-v2/system/automation/AutomationJobFocusStrip.tsx" "src/components/settings-v2/system/automation/AutomationJobFocusStrip.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0` 通过;`npm test -- "src/components/settings-v2/system/automation/AutomationOverviewFocusCard.test.tsx" "src/components/settings-v2/system/automation/AutomationJobFocusStrip.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 4 个文件、11 个用例;`npm run detect-translations -- --verbose` 通过,当前资源口径为 5 个 locale、6 个 namespace、4797 个 source key;5 个 `settings.json` 资源 JSON 解析通过。执行时隔壁 `npm run verify:local` / `npm run typecheck` / Rust responsive_chat 定向测试正在活动,本条未启动全量 typecheck / `npm run verify:local` / GUI smoke;本条不触碰 OpenClaw、Agent Chat fast response、A2UI、Artifact 渲染器、Rust provider compact 工具面、Browser Runtime 或 Chrome Relay。 + +192. 2026-05-11 Settings AutomationHealthPanel 风险提醒 fallback 收口续测:`src/components/settings-v2/system/automation/AutomationHealthPanel.tsx` 复用既有 `settings.automation.health.*` 正式资源,把风险提醒标题说明、轮询状态、累计执行、summary pills、最近 / 下次轮询、最近命中、risk 失败 / 重试、冷却 / 更新时间、状态 badge 与空态从 `t(key, 中文 fallback)` / `defaultValue` 改为纯 key-based 读取;job name、detail_message、status、时间和计数继续作为 runtime / API 数据保留。`src/components/settings-v2/system/automation/AutomationHealthPanel.test.tsx` 去掉 `react-i18next` mock,改用真实 i18n resource 与英文 locale 回归,验证 Risk Alerts、Polling running、Total runs 8、Enabled、Last poll hits: 1、Waiting for human 与 runtime detail message 来自 `settings` namespace / runtime 数据,并确保不再泄露“风险提醒”中文 fallback 或 `settings.automation.health` key 字符串;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `settings.automation.health.title` 与 `settings.automation.health.status.waitingForHuman`。`npx eslint "src/components/settings-v2/system/automation/AutomationHealthPanel.tsx" "src/components/settings-v2/system/automation/AutomationHealthPanel.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0` 通过;`npm test -- "src/components/settings-v2/system/automation/AutomationHealthPanel.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、6 个用例;`npm run detect-translations -- --verbose` 通过,当前资源口径为 5 个 locale、6 个 namespace、4797 个 source key;5 个 `settings.json` 资源 JSON 解析通过;定向 `git diff --check` 通过。执行时隔壁 `npm run verify:local` / smart vitest 仍在活动,本条未启动全量 typecheck / `npm run verify:local` / GUI smoke;本条不触碰 OpenClaw、Agent Chat fast response、A2UI、Artifact 渲染器、Rust provider compact 工具面、Browser Runtime 或 Chrome Relay。 + +193. 2026-05-11 Settings Clipboard 权限指引 fallback 收口续测:`src/components/settings-v2/system/shared/ClipboardPermissionGuideCard.tsx` 复用既有 `settings.system.clipboardPermission.*` 正式资源,把 macOS / Windows / Linux / generic 剪贴板权限指引标题、步骤、打开系统设置按钮与打开失败错误从 `t(key, 中文 fallback)` / `defaultValue` / `getClipboardPermissionGuide()` presentation fallback 改为纯 key-based 读取;平台识别结果、系统设置 URL 与底层 open 错误 message 继续作为 runtime / 系统数据保留。`src/components/settings-v2/system/shared/ClipboardPermissionGuideCard.test.tsx` 去掉 `react-i18next` mock,改用真实 i18n resource 与英文 locale 回归,验证 macOS Clipboard Permission Guide、Open System Settings、权限步骤与 Failed to open system settings: denied 来自 `settings` namespace / runtime 插值,并确保不再泄露“macOS 剪贴板权限指引”“打开系统设置失败”中文 fallback 或 `settings.system.clipboardPermission` key 字符串;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `settings.system.clipboardPermission.macos.title` 与 `settings.system.clipboardPermission.message.openSettingsFailedWithMessage`。`npm test -- "src/components/settings-v2/system/shared/ClipboardPermissionGuideCard.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、8 个用例;`npx eslint "src/components/settings-v2/system/shared/ClipboardPermissionGuideCard.tsx" "src/components/settings-v2/system/shared/ClipboardPermissionGuideCard.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0`、`npm run detect-translations -- --verbose`、`npm run typecheck -- --pretty false`、5 个 `settings.json` 资源 JSON 解析与定向 `git diff --check` 通过,当前资源口径为 5 个 locale、6 个 namespace、4797 个 source key。本条未启动 `npm run verify:local` / GUI smoke;本条不触碰 OpenClaw、Agent Chat fast response、A2UI、Artifact 渲染器、Rust provider compact 工具面、Browser Runtime、Chrome Relay、Automation 其他脏区或 Settings agent 长尾脏区。 + +194. 2026-05-11 Settings UserCenterSession 账户中心会话页 fallback 收口续测:`src/components/settings-v2/account/user-center-session/index.tsx` 复用既有 `settings.userCenterSession.*` 正式资源,把账户资料标题说明、登录状态 badge、默认服务、运行时缺失 / 恢复态、头像 alt、登录方式 / 已同步摘要、资料同步提示、刷新 / 退出 / 打开账号中心操作、会话字段卡、资料维护方式 tips、验证码发送提示、Google 登录面板、浏览器同步说明、备用登录方式、邮箱验证码 / 密码表单 label / placeholder / submit 从 `t(key, 中文 fallback)` / `defaultValue` 改为纯 key-based 读取;账号名、邮箱、用户 ID、Provider、默认服务摘要、验证码邮箱、时间和能力数量继续作为 runtime / locale-aware 数据保留。`src/components/settings-v2/account/user-center-session/index.test.tsx` 去掉 `react-i18next` mock,改用真实 i18n resource 与英文 locale 回归,验证 Account Profile、Status: Signed out / Signed in、Default service、Sign in with Google、Auto-sync after browser sign-in、Use email code / account password、Email code、Edit profile in Account Center、Sign out of this account、tips 内容与 en-US 日期 / 数字格式来自 `settings` namespace / `Intl`,并确保不再泄露“账户资料”中文 fallback 或 `settings.userCenterSession` key 字符串;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `settings.userCenterSession.title` 与 `settings.userCenterSession.login.google.title`。`npx eslint "src/components/settings-v2/account/user-center-session/index.tsx" "src/components/settings-v2/account/user-center-session/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0` 通过;`npm test -- "src/components/settings-v2/account/user-center-session/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、11 个用例;`npm run detect-translations -- --verbose` 通过,当前资源口径为 5 个 locale、6 个 namespace、4797 个 source key;5 个 `settings.json` 资源 JSON 解析通过;定向 `git diff --check` 通过。执行时隔壁 `npm run typecheck --pretty false` 仍在活动,本条未单独启动全量 typecheck / `npm run verify:local` / GUI smoke;本条不触碰 OpenClaw、Agent Chat fast response、A2UI、Artifact 渲染器、Rust provider compact 工具面、Browser Runtime、Chrome Relay、Automation 主页面或 Settings agent 长尾脏区。 + +195. 2026-05-11 Settings WorkspaceRepairHistoryCard 自愈记录共享卡片 fallback 收口续测:`src/components/settings-v2/system/shared/WorkspaceRepairHistoryCard.tsx` 复用既有 `settings.system.workspaceRepair.*` 正式资源,把 Workspace 自动修复记录标题说明、最近记录数量、刷新 / 清空 / 复制最新 / 复制全部按钮、空态、来源标签、来源枚举与清空 / 复制成功 / 无可复制记录 / 剪贴板权限失败等 action message 从 `t(key, 中文 fallback)` / `defaultValue` 改为纯 key-based 读取;workspace id、root path、timestamp、source 枚举、复制摘要正文与剪贴板底层错误继续作为 runtime / 诊断数据保留。`src/components/settings-v2/system/shared/WorkspaceRepairHistoryCard.test.tsx` 去掉 `react-i18next` mock,改用真实 i18n resource 与英文 locale 回归,验证 Workspace Auto-Repair History、Recent records: 0 / 1、No auto-repair records yet、Source: Creation session page、Copy All、Copied the latest 1 repair records 与 Copy failed. Check the window focus or system clipboard permission. 来自 `settings` namespace,并确保不再泄露“Workspace 自动修复记录”“复制失败,请检查窗口焦点”等中文 fallback 或 `settings.system.workspaceRepair` key 字符串;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `settings.system.workspaceRepair.title` 与 `settings.system.workspaceRepair.message.copiedAll`。`npm test -- "src/components/settings-v2/system/shared/WorkspaceRepairHistoryCard.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、8 个用例;`npx eslint "src/components/settings-v2/system/shared/WorkspaceRepairHistoryCard.tsx" "src/components/settings-v2/system/shared/WorkspaceRepairHistoryCard.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0`、`npm run detect-translations -- --verbose`、5 个 `settings.json` 资源 JSON 解析与定向 `git diff --check` 通过,当前资源口径为 5 个 locale、6 个 namespace、4797 个 source key。执行时隔壁 `npm run verify:local` / 后续 `npm run typecheck --pretty false` 仍在活动,本条未单独启动全量 typecheck / `npm run verify:local` / GUI smoke;本条不触碰 OpenClaw、Agent Chat fast response、A2UI、Artifact 渲染器、Rust provider compact 工具面、Browser Runtime、Chrome Relay、Automation 主页面、UserCenterSession 或 Settings agent 长尾脏区。 + +196. 2026-05-11 Settings Layout / Sidebar 壳层 fallback 收口续测:`src/components/settings-v2/_layout/index.tsx` 与 `src/components/settings-v2/_layout/SettingsSidebar.tsx` 复用既有 `settings.layout.*` 正式资源,把设置页各 tab 懒加载 fallback、页面不存在占位、回到首页按钮 aria / 文案、侧栏实验 badge 与浮动导航 aria / 兜底标题从 `t(key, 中文 fallback)` 改为纯 key-based 读取;active tab、初始 provider view、DeveloperLab 初始 tab、预取行为、workspace navigate 参数与分类 label 继续作为 runtime / hook 数据保留。`src/components/settings-v2/_layout/index.test.tsx` 与 `src/components/settings-v2/_layout/SettingsSidebar.test.tsx` 去掉 `react-i18next` mock,改用真实 i18n resource 与英文 locale 回归,验证 Back Home、Open settings navigation、Settings navigation 与 Labs 来自 `settings` namespace,并确保不再泄露“打开设置导航”中文 fallback 或 `settings.layout.sidebar` key 字符串;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `settings.layout.action.backHome`、`settings.layout.loading.profile`、`settings.layout.placeholder.notFound` 与 `settings.layout.sidebar.floatingNav.openAria`。`npx eslint "src/components/settings-v2/_layout/index.tsx" "src/components/settings-v2/_layout/index.test.tsx" "src/components/settings-v2/_layout/SettingsSidebar.tsx" "src/components/settings-v2/_layout/SettingsSidebar.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0` 通过;`npm test -- "src/components/settings-v2/_layout/index.test.tsx" "src/components/settings-v2/_layout/SettingsSidebar.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 4 个文件、16 个用例;`npm run detect-translations -- --verbose` 通过,当前资源口径为 5 个 locale、6 个 namespace、4797 个 source key;5 个 `settings.json` 资源 JSON 解析通过;定向 `git diff --check` 通过。执行时隔壁 `npm run verify:local` / smart vitest 仍在活动,本条未单独启动全量 typecheck / `npm run verify:local` / GUI smoke;本条不触碰 OpenClaw、Agent Chat fast response、A2UI、Artifact 渲染器、Rust provider compact 工具面、Browser Runtime、Chrome Relay、Automation 主页面、WorkspaceRepairHistoryCard 或 Settings agent 长尾脏区。 + +197. 2026-05-11 Settings Home 首屏总览 fallback 收口续测:`src/components/settings-v2/home/index.tsx` 复用既有 `settings.home.*` 正式资源,把设置首页 hero 标题说明、summary badges、常用入口标题说明 / 计数 / 卡片 tip aria、quick access meta 标题说明、桌宠入口、current entrypoints 标题说明 / 动作按钮 / 四张入口卡、分组说明 tip aria / 计数,以及 item 实验 / 配置状态从 `t(key, 中文 fallback)` / `defaultValue` / 本地 meta fallback 字段改为纯 key-based 读取;分组标题、item label、tab key、navigate page、prefetch / click 回调仍作为 `useSettingsCategory()` / runtime 数据保留。`src/components/settings-v2/home/index.test.tsx` 去掉 `react-i18next` mock,改用真实 i18n resource 与英文 locale 回归,验证 Settings Home、Quick Access、Groups / items summary、Current Entrypoints、Go to Skills、Open Automation、Open Channels、Open Project Knowledge、Companion、Appearance help 与 hero / quick access tips 来自 `settings` namespace,并确保不再泄露中文 fallback 或 `settings.home` key 字符串;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `settings.home.title`、`settings.home.summary.groups`、`settings.home.quickAccess.cardTipAria` 与 `settings.home.current.title`。`npx eslint "src/components/settings-v2/home/index.tsx" "src/components/settings-v2/home/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0` 通过;`npm test -- "src/components/settings-v2/home/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、12 个用例;`npm run detect-translations -- --verbose` 通过,当前资源口径为 5 个 locale、6 个 namespace、4797 个 source key;5 个 `settings.json` 资源 JSON 解析通过;定向 `git diff --check` 通过。执行时隔壁 `npm run verify:local` / `npm run lint` 仍在活动,本条未单独启动全量 typecheck / `npm run verify:local` / GUI smoke;本条不触碰 OpenClaw、Agent Chat fast response、A2UI、Artifact 渲染器、Rust provider compact 工具面、Browser Runtime、Chrome Relay、Automation 主页面、WorkspaceRepairHistoryCard、system/about 或 Settings agent 长尾脏区。 + +198. 2026-05-11 Settings About 版本与更新区 fallback 收口续测:`src/components/settings-v2/system/about/index.tsx` 复用既有 `settings.about.*` 正式资源,把版本 loading / label、更新状态 badge、更新检查 / 下载错误、安装完成提示、Copyright、检查更新 / 下载 / 网页下载按钮与 Windows setup 安装包提示从 `t(key, 中文 fallback)` / `defaultValue` / 本地友好错误常量改为纯 key-based 读取;品牌名、Logo、版本号、release URL 与底层更新诊断日志继续作为 runtime / 诊断数据保留。`src/components/settings-v2/system/about/index.test.tsx` 去掉 `react-i18next` mock,改用真实 i18n resource 与英文 locale 回归,验证 Version 1.10.0、Update available: 1.10.1、Check for Updates、Download Update、Windows setup notice、下载失败与更新检查失败友好文案均来自 `settings` namespace,并确保不再泄露“可更新到 1.10.1”、HTTP 404 / signature mismatch 等技术错误或 `settings.about` key 字符串;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `settings.about.version.label` 与 `settings.about.update.errorDownload`。`npm test -- "src/components/settings-v2/system/about/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、10 个用例;`npx eslint "src/components/settings-v2/system/about/index.tsx" "src/components/settings-v2/system/about/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0`、`npm run detect-translations -- --verbose`、About 组件硬编码中文扫描、5 个 `settings.json` 资源 JSON 解析与定向 `git diff --check` 通过,当前资源口径为 5 个 locale、6 个 namespace、4797 个 source key。执行时隔壁 `npm run verify:local` / `npm run typecheck`、Tauri dev / Rust 编译仍在活动,本条未单独启动全量 typecheck / `npm run verify:local` / GUI smoke;本条不触碰 OpenClaw、Agent Chat fast response、A2UI、Artifact 渲染器、Rust provider compact 工具面、Browser Runtime、Chrome Relay、Settings Home、Layout / Sidebar 或 Settings agent 长尾脏区。 + +199. 2026-05-11 Settings Hotkeys 已审计快捷键页 fallback 收口续测:`src/components/settings-v2/general/hotkeys/index.tsx` 与 `src/components/settings-v2/general/hotkeys/hotkeyCatalog.ts` 复用既有 `settings.hotkeys.*` 正式资源,把平台兜底、item / section tip aria、scope / source / condition / unset key、section count、加载错误 / 重试 / 不可用、页面标题说明、hero tip、global ready / runtime / audited / ready / attention summary、audit tip,以及 catalog definitions、scene、status copy、voice translate ready source 从 `t(key, 中文 fallback)` / `defaultValue` 改为纯 key-based 读取;实际 shortcut、platform、runtimeStatus、experimental / voice 配置、错误 message 与 `UNSET_SHORTCUT_TOKEN` 仍作为 runtime 数据保留。`src/components/settings-v2/general/hotkeys/index.test.tsx` 去掉 `react-i18next` mock,改用真实 i18n resource 与英文 locale 回归,验证 Hotkeys、Review implemented and audited hotkeys、Global running 2 / 3、Runtime status connected / unreadable、8 audited、2 total、Document Canvas、Failed to load hotkeys: 网络异常、Retry、Feature disabled、Not registered、Instruction not bound、Not set 以及 hero / audit tips 来自 `settings` namespace / runtime 插值,并确保不再泄露中文 fallback、`settings.hotkeys` key 字符串或 `__lime_unset_shortcut__` sentinel;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `settings.hotkeys.title`、`settings.hotkeys.summary.globalReady`、`settings.hotkeys.catalog.scene.global.title` 与 `settings.hotkeys.catalog.status.voiceTranslate.ready.source`。`npx eslint "src/components/settings-v2/general/hotkeys/index.tsx" "src/components/settings-v2/general/hotkeys/index.test.tsx" "src/components/settings-v2/general/hotkeys/hotkeyCatalog.ts" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0` 通过;`npm test -- "src/components/settings-v2/general/hotkeys/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、10 个用例;`npm run detect-translations -- --verbose` 通过,当前资源口径为 5 个 locale、6 个 namespace、4797 个 source key;5 个 `settings.json` 资源 JSON 解析通过;定向 `git diff --check` 通过。执行时隔壁 Rust `responsive_chat` 定向测试仍在活动,本条未单独启动全量 typecheck / `npm run verify:local` / GUI smoke;本条不触碰 OpenClaw、Agent Chat fast response、A2UI、Artifact 渲染器、Rust provider compact 工具面、Browser Runtime、Chrome Relay、Automation 主页面、system/about 或 Settings agent 长尾脏区。 + +200. 2026-05-11 Settings Chrome Relay 独立连接引导窗口 fallback 收口续测:`src/components/settings-v2/system/chrome-relay/guide-window.tsx` 复用既有 `settings.chromeRelay.guide.*` 正式资源,把扩展安装 / CDP 直连独立窗口的安装路径空态、刷新 / 同步 / 打开 / 复制错误与成功消息、安装状态、header 标题说明、刷新按钮、扩展连接状态、三步安装引导、源码目录警告、CDP 会话状态、远程调试步骤与返回扩展引导按钮从 `t(key, 中文 fallback)` / `defaultValue` 改为纯 key-based 读取;Chrome URL、扩展目录、bridge observer/control 数量、CDP session 数量、文件路径与底层错误 message 继续作为 runtime / 诊断数据保留。`src/components/settings-v2/system/chrome-relay/guide-window.test.tsx` 去掉 `react-i18next` mock,改用真实 i18n resource 与英文 locale 回归,验证 Install Lime Browser Bridge、Open chrome://extensions、Sync and open the extension folder、Do not load the repository source directory、Enable Direct Browser Connection、Open chrome://inspect/#remote-debugging 与 Allow Lime to connect 来自 `settings` namespace,并确保不再泄露“安装 Lime Browser Bridge”“启用浏览器直连”等中文 fallback 或 `settings.chromeRelay.guide` key 字符串;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `settings.chromeRelay.guide.header.extension.title`、`settings.chromeRelay.guide.action.openRemoteDebugging` 与 `settings.chromeRelay.guide.message.copySuccess`。`npm test -- "src/components/settings-v2/system/chrome-relay/guide-window.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、13 个用例;`npx eslint "src/components/settings-v2/system/chrome-relay/guide-window.tsx" "src/components/settings-v2/system/chrome-relay/guide-window.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0`、`npm run detect-translations -- --verbose`、Chrome Relay 引导窗口硬编码中文扫描、5 个 `settings.json` 资源 JSON 解析与定向 `git diff --check` 通过,当前资源口径为 5 个 locale、6 个 namespace、4799 个 source key(已包含并行记录 201 的资源增量)。随后尝试 `npm run typecheck -- --pretty false`,但进程长时间无诊断输出后以 code -1 结束,未作为通过证据;本条未启动 `npm run verify:local` / GUI smoke,且不触碰 Chrome Relay 主设置页核心首屏 / 高级工具、OpenClaw、Agent Chat fast response、A2UI、Artifact 渲染器、Rust provider compact 工具面、Browser Runtime、Automation 主页面、system/about、Hotkeys 或 Settings agent 长尾脏区。 + +201. 2026-05-11 Settings Experimental 主体 fallback 收口续测:`src/components/settings-v2/system/experimental/index.tsx` 复用既有 `settings.experimental.*` 正式资源,并补齐 `settings.experimental.crashReporting.dsn.label` / `settings.experimental.crashReporting.environment.label` 5 locale 资源,把实验页标题说明、details 展开、懒加载 fallback、加载 / 保存 / WebMCP / Tool Calling / 动态过滤 / 原生 input_examples / 截图对话 / 崩溃上报 / 诊断复制导出 / 下载目录反馈、截图权限提示、WebMCP 预留开关、崩溃上报诊断区按钮与字段 label 从 `t(key, 中文 fallback)` / `defaultValue` / 硬编码英文 label 改为纯 key-based 读取;DSN 示例 URL、platform、userAgent、配置值、快捷键、底层错误 message、诊断文件名 / 路径与 console 诊断日志继续作为 runtime / 技术数据保留。`src/components/settings-v2/system/experimental/index.test.tsx` 去掉 `react-i18next` mock,改用真实 i18n resource 与 en-US locale 回归,验证 Experimental Features、Screenshot Chat、WebMCP (Reserved)、Toggle WebMCP reserved entry、WebMCP reserved entry enabled、Update settings placeholder 与 Workspace repair placeholder 来自 `settings` namespace / test mock runtime,并确保不再泄露 `settings.experimental` key 字符串;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `settings.experimental.title`、`settings.experimental.webMcp.title`、`settings.experimental.message.webMcpEnabled` 与 `settings.experimental.crashReporting.environment.label`。`npm test -- "src/components/settings-v2/system/experimental/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、10 个用例;`npx eslint "src/components/settings-v2/system/experimental/index.tsx" "src/components/settings-v2/system/experimental/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0`、`npm run detect-translations -- --verbose`、`npm run typecheck -- --pretty false`、5 个 `settings.json` 资源 JSON 解析与定向 `git diff --check` 通过,当前资源口径为 5 个 locale、6 个 namespace、4799 个 source key。本条未启动 `npm run verify:local` / GUI smoke;本条不触碰 OpenClaw、Agent Chat fast response、A2UI、Artifact 渲染器、Rust provider compact 工具面、Browser Runtime、Chrome Relay、Automation 主页面、system/developer / environment / channels 或 Settings agent 长尾脏区。 + +202. 2026-05-11 Settings Environment 主体 fallback 收口续测:`src/components/settings-v2/system/environment/index.tsx` 复用既有 `settings.environment.*` 正式资源,把来源标签、Shell 导入状态、duration、导入提示、加载 / 刷新 / 保存反馈、reload / refresh / save 按钮、hero 标题说明与 tip、summary chips、Shell 导入卡、导入超时字段、导入状态 / 已导入项 / 最近耗时、覆盖项表单、变量名 / 变量值 / 启用开关 / 删除按钮、合并规则、使用提示、预览区标题说明 / 显隐按钮 / 空态 / 表头 / 覆盖来源提示从 `t(key, 中文 fallback)` / `defaultValue` 改为纯 key-based 读取;变量名、变量值、实际环境值、maskedValue、source 枚举、runtime sourceLabel、底层错误 message 与超时数值继续作为 runtime / API 数据保留。`src/components/settings-v2/system/environment/index.test.tsx` 去掉 `react-i18next` mock,改用真实 i18n resource 与 en-US locale 回归,验证 Environment Variables、Shell import: Imported、Overrides: 1/1、Preview variables: 2、Shell Environment Import、Environment Overrides、Merge Rules、Effective Preview、Imported 5 variables from the Shell environment、Add variable、Save and apply、Show values 与 Overridden sources: Shell environment import 来自 `settings` namespace / runtime 数据,并确保不再泄露 `settings.environment` key 字符串;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `settings.environment.hero.title`、`settings.environment.summary.shellImport` 与 `settings.environment.preview.overriddenSources`。`npm test -- "src/components/settings-v2/system/environment/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、10 个用例;`npx eslint "src/components/settings-v2/system/environment/index.tsx" "src/components/settings-v2/system/environment/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0`、`npm run detect-translations -- --verbose`、`npm run typecheck -- --pretty false`、5 个 `settings.json` 资源 JSON 解析与定向 `git diff --check` 通过,当前资源口径为 5 个 locale、6 个 namespace、4799 个 source key。本条未新增资源 key,未启动 `npm run verify:local` / GUI smoke;本条不触碰 OpenClaw、Agent Chat fast response、A2UI、Artifact 渲染器、Rust provider compact 工具面、Browser Runtime、Chrome Relay、Automation 主页面、system/developer / channels 或 Settings agent 长尾脏区。 + +203. 2026-05-11 Settings Chrome Relay 主设置页全量 fallback 收口续测:`src/components/settings-v2/system/chrome-relay/index.tsx` 复用既有 `settings.chromeRelay.main.*` 正式资源,把主设置页核心首屏、高级工具起步区、连接方式卡、系统连接器卡、浏览器动作配置、后端策略详情、扩展桥接详情、Profile 面板、Overview / Usage / Debug 面板、共享 action / toast / error、安装状态、后端 label / description、tab label 与 info tip aria 从 `t(key, 中文 fallback)` / `defaultValue` / 本地中文拼接改为纯 key-based 读取;Google / Xiaohongshu engine label 补齐 5 locale 资源,Chrome URL、WebSocket 端点、Bridge Key、PID、端口、runtime page title、系统 connector / action capability label 与底层错误 message 继续作为 runtime / 诊断数据保留。`src/components/settings-v2/system/chrome-relay/index.test.tsx` 去掉 `react-i18next` mock,改用真实 i18n resource 与 en-US locale 回归,验证 Browser List、Connect via Extension、CDP Direct、Connection Guide、Open Advanced Tools、Connection Methods、Chrome Extension Bridge、Browser Backend Policy、Browser Action Configuration、System Connector、Copy Config、Disconnect Connected Extension 与相关 toast 来自 `settings` namespace / runtime 插值,并确保不再泄露 `settings.chromeRelay.main` key 字符串;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `settings.chromeRelay.main.engine.xiaohongshu.label`、`settings.chromeRelay.main.profile.title`、`settings.chromeRelay.main.profile.status.running`、`settings.chromeRelay.main.action.openAdvancedTools` 与 `settings.chromeRelay.main.message.copyConfigSuccess`。`npm test -- "src/components/settings-v2/system/chrome-relay/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、20 个用例;`npx eslint "src/components/settings-v2/system/chrome-relay/index.tsx" "src/components/settings-v2/system/chrome-relay/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0`、`npm run detect-translations -- --verbose`、Chrome Relay 主设置页硬编码中文 / defaultValue 扫描、5 个 `settings.json` 资源 JSON 解析与定向 `git diff --check` 通过,当前资源口径为 5 个 locale、6 个 namespace、4801 个 source key。本条未启动 `npm run typecheck` / `npm run verify:local` / GUI smoke;执行时已有两条 `npm run typecheck --pretty false` / `tsc --noEmit --pretty false` 与 headless Tauri dev 在活动,避免抢占并行 owner;本条不触碰 OpenClaw、Agent Chat fast response、A2UI、Artifact 渲染器、Rust provider compact 工具面、Browser Runtime、Automation 主页面、system/environment / experimental / about / hotkeys 或 Settings agent 长尾脏区。 + +204. 2026-05-11 Settings Developer 壳层与目录工具 fallback 收口续测:`src/components/settings-v2/system/developer/index.tsx`、`ServiceSkillCatalogTools.tsx` 与 `SiteAdapterCatalogTools.tsx` 复用既有 `settings.developer.*` 正式资源,把开发者页标题说明、details 展开、懒加载 fallback、Workspace Harness / Component Debug 开关、诊断日志按钮与复制 / JSON / 导出 / 下载目录 / 清空历史反馈、剪贴板权限指引 fallback、服务型技能目录联调、站点脚本目录联调、站点脚本示例 Payload / YAML 示例、目录状态、来源标签、动态计数、bootstrap 注入 / 清空缓存 / 外部导入反馈从 `t(key, 中文 fallback)` / `defaultValue` / BasicTranslate 默认文案改为纯 key-based 读取;catalog id、tenantId、version、site adapter name/domain/description、YAML / JSON 输入、底层错误 message、文件路径与诊断 payload 继续作为 runtime / API 数据保留。`src/components/settings-v2/system/developer/index.test.tsx` 去掉 `react-i18next` mock,改用真实 i18n resource 与 en-US locale 回归,验证 Developer、Debug Toggles、Workspace debug info、Service Skill Catalog Debugging、Site Script Catalog Debugging、Diagnostic Logs、Workspace Repair History、Copy diagnostics、Load Current Catalog、Inject via Event、Clear Catalog Cache、Import to Lime Standard、Inject Site Catalog、Clear Site Catalog Cache、Bundled / Imported / Server Synced 与诊断 / 目录操作反馈来自 `settings` namespace / runtime 数据,并确保不再泄露 `settings.developer` key 字符串;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `settings.developer.title`、`settings.developer.serviceSkill.title` 与 `settings.developer.siteAdapterCatalog.message.injected`。`npm test -- "src/components/settings-v2/system/developer/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、19 个用例;`npx eslint "src/components/settings-v2/system/developer/index.tsx" "src/components/settings-v2/system/developer/index.test.tsx" "src/components/settings-v2/system/developer/ServiceSkillCatalogTools.tsx" "src/components/settings-v2/system/developer/SiteAdapterCatalogTools.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0`、`npm run detect-translations -- --verbose`、`npm run typecheck -- --pretty false`、5 个 `settings.json` 资源 JSON 解析与定向 `git diff --check` 通过,当前资源口径为 5 个 locale、6 个 namespace、4801 个 source key。本条未新增资源 key,未启动 `npm run verify:local` / GUI smoke;本条不触碰 OpenClaw、Agent Chat fast response、A2UI、Artifact 渲染器、Rust provider compact 工具面、Browser Runtime、Chrome Relay、Automation 主页面、system/environment / experimental 或 Settings agent 长尾脏区。 + +205. 2026-05-11 CrashRecoveryPanel 恢复模式外壳二次 fallback 收口续测:`src/components/layout/CrashRecoveryPanel.tsx` 复用既有 `errors.crashRecovery.*` 正式资源,移除恢复模式标题说明、最近错误、模块资源加载失败提示、诊断复制 / JSON / 导出 / 清理 / 下载目录操作、workspace 路径修复反馈、清空历史 confirm 与强制刷新资源入口中的 `t(key, 中文 fallback)` / `defaultValue` / 常量 fallback;后端中文 workspace 错误 message 的识别与正则提取继续作为 runtime 兼容解析边界保留,不新增协议或后端错误事实源。`src/components/layout/CrashRecoveryPanel.test.tsx` 去掉 `react-i18next` mock,改用真实 i18n resource 与 en-US locale 回归,验证 The app hit an error and entered recovery mode、Copy Diagnostics、Copy Raw JSON、Export Diagnostic JSON、Open Downloads、Retry Recovery、Force Resource Refresh、Retry Only 与模块资源加载失败提示来自 `errors` namespace,并确保不再泄露中文 fallback 或 `errors.crashRecovery` key 字符串;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `errors.crashRecovery.title`、`errors.crashRecovery.action.forceReload`、`errors.crashRecovery.action.retryOnly`、`errors.crashRecovery.message.diagnosticExportedAndOpened` 与 `errors.crashRecovery.moduleImportFailure.prefix`。`npm test -- "src/components/layout/CrashRecoveryPanel.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、11 个用例;`npx eslint "src/components/layout/CrashRecoveryPanel.tsx" "src/components/layout/CrashRecoveryPanel.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0`、`npm run detect-translations -- --verbose`、CrashRecoveryPanel defaultValue / fallback 常量扫描、5 个 `errors.json` 资源 JSON 解析、35 个 `errors.crashRecovery.*` key 的 5 locale 存在性检查与定向 `git diff --check` 通过,当前资源口径为 5 个 locale、6 个 namespace、4801 个 source key。本条未新增资源 key,未启动 `npm run typecheck` / `npm run verify:local` / GUI smoke;执行时已有 `npm run verify:local`、headless Tauri dev、Rust `cargo test` / `rustc` 与 Vitest owner 在活动,避免抢占并行 owner;本条不触碰 system/developer、Chrome Relay、Automation、Agent Chat fast response、A2UI、Artifact 渲染器、Rust provider / model resources 或 Settings agent 长尾脏区。 + + +206. 2026-05-11 Settings Agent Media Services 服务模型总页 fallback 收口续测:`src/components/settings-v2/agent/media-services/index.tsx` 复用既有 `settings.mediaServices.*` 正式资源,把服务模型总页标题说明、hero tip、快速响应对话助理、话题命名、图片话题命名、翻译、历史压缩、助理信息生成、输入自动补全、提示词重写、项目资料提词重写、当前行为、模型选择空态、自定义提示词、AI 图片默认数量、保存成功 / 失败 toast 与 info tip aria 从 `t(key, 中文 fallback)` / `defaultValue` 改为纯 key-based 读取;`responsive_chat`、providerId、modelId、service_models 配置、image count、customPrompt、模型 task family 与底层错误 message 继续作为 runtime / API 数据保留,不把运行时 provider / model / 用户提示词固化进资源。`src/components/settings-v2/agent/media-services/index.test.tsx` 去掉 `react-i18next` mock,改用真实 i18n resource 与 en-US locale 回归,验证 Service Models、Fast Response Chat Assistant、Topic Auto-Naming Assistant、AI Image Topic Naming Assistant、Message Translation Assistant、Conversation History Compression Assistant、Assistant Info Generation Assistant、Input Autocomplete Assistant、Prompt Rewrite Assistant、Project Resource Prompt Rewrite Assistant、AI Image Settings 与 Add custom prompt 来自 `settings` namespace,并确保不再泄露 `settings.mediaServices` key 字符串或“媒体服务”等中文 fallback;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `settings.mediaServices.title`、`settings.mediaServices.sections.responsiveChat.title` 与 `settings.mediaServices.card.tipAria`。`npm test -- "src/components/settings-v2/agent/media-services/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、11 个用例;`npx eslint "src/components/settings-v2/agent/media-services/index.tsx" "src/components/settings-v2/agent/media-services/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0`、`npm run detect-translations -- --verbose`、Media Services defaultValue / 中文 fallback 扫描、5 个 `settings.json` 资源 JSON 解析与定向 `git diff --check` 通过,当前资源口径为 5 个 locale、6 个 namespace、4801 个 source key。本条未新增资源 key,未启动 `npm run typecheck` / `npm run verify:local` / GUI smoke;写入 PRD 前后已有隔壁 `npm run verify:local` / `npm run lint` 与 Rust `cargo test` owner 在活动,避免抢占并行 owner;本条不触碰 OpenClaw、system/chrome-relay、system/developer、system/environment、system/experimental、CrashRecoveryPanel、Automation 主页面、Agent Chat fast response、A2UI、Artifact 渲染器、Rust provider / model resources 或 agent/voice 长尾。 + +207. 2026-05-11 Settings Agent Voice 语音设置页 fallback 收口续测:`src/components/settings-v2/agent/voice/index.tsx` 复用既有 `settings.voice.*` 正式资源,把语音输入、语音模型、模型下载 / 删除 / 默认识别服务 / 测试转写、快捷键 / Fn 状态、语音处理模型与指令、语音服务模型、保存 / 下载 / 测试反馈、空态、按钮、aria、dialog title / filter、progress aria、provider / model warning 与 info tip aria 从 `t(key, 中文 fallback)` / `defaultValue` 改为纯 key-based 读取;ASR credential name、providerId、modelId、SenseVoice catalog runtime 字段、Fn runtime note、下载进度事件 message、WAV 路径、转写结果与 console 诊断日志继续作为 runtime / 技术数据保留。`src/components/settings-v2/agent/voice/index.test.tsx` 去掉 `react-i18next` mock,改用真实 i18n resource 与 en-US locale 回归,验证 Voice Input、Voice Models、Voice input shortcut、Hold to record, release to transcribe、Download model、Voice Processing、Voice Service Model、Fn is not supported on this platform; shortcut fallback is used、Voice model download progress、Choose WAV、Test transcription、Toggle voice input、Default polish instruction 与 Restore defaults 来自 `settings` namespace / runtime 插值,并确保不再泄露 `settings.voice` key 字符串;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `settings.voice.input.title`、`settings.voice.model.action.download`、`settings.voice.shortcut.status.fnUnsupported`、`settings.voice.processing.title`、`settings.voice.media.title` 与 `settings.voice.model.message.downloaded`。`npm test -- "src/components/settings-v2/agent/voice/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、16 个用例;`npx eslint "src/components/settings-v2/agent/voice/index.tsx" "src/components/settings-v2/agent/voice/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0`、`npm run detect-translations -- --verbose`、Voice 设置页 defaultValue / 中文 fallback 扫描、5 个 `settings.json` 资源 JSON 解析、118 个 `settings.voice.*` key 的 5 locale 存在性检查与定向 `git diff --check` 通过,当前资源口径为 5 个 locale、6 个 namespace、4801 个 source key。本条未新增资源 key,未启动 `npm run typecheck` / `npm run verify:local` / GUI smoke;执行时已有隔壁 `npm run verify:local` / `npm run lint`、headless Tauri dev 与 Rust `cargo test` / `rustc` owner 在活动,避免抢占并行资源;本条不触碰 agent/media-services、system/developer、Chrome Relay、Automation、Agent Chat fast response、A2UI、Artifact 渲染器、Rust provider / model resources 或 Settings agent/providers 长尾脏区。 + + +208. 2026-05-11 Settings Experimental Update Check 子面板二次 fallback 收口续测:`src/components/settings-v2/system/experimental/UpdateCheckSettings.tsx` 复用既有 `settings.experimental.updateCheck.*` 正式资源,把自动更新检查子面板标题、自动检查 / 通知开关标题说明与 aria、检查间隔、每周 / 小时间隔 label、已跳过版本、清除按钮、上次检查、稍后提醒时间、提醒转化指标、展示 / 立即更新 / 稍后 / 跳过 / 关闭指标,以及开发环境测试更新弹窗按钮从 `t(key, 中文 fallback)` / `defaultValue` 改为纯 key-based 读取;开关状态、interval hours、timestamp、metrics 数值、skipped version 与测试弹窗调用仍作为 runtime / API 数据保留,并继续通过 `formatDate` / `formatNumber` 走 locale-aware formatter。`src/components/settings-v2/system/experimental/UpdateCheckSettings.test.tsx` 去掉 `react-i18next` mock,改用真实 i18n resource 与 en-US locale 回归,验证 Automatic Update Check、Automatic update checks、Weekly、Skipped version、Reminder conversion metrics、Shown 20 times; update now 3 times (15%)、Last check、Remind later until、Toggle automatic update checks 与 Test update prompt 来自 `settings` namespace / locale formatter,并确保不再泄露 `settings.experimental.updateCheck` key 字符串或“自动更新检查”等中文 fallback;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `settings.experimental.updateCheck.autoCheck.title`、`settings.experimental.updateCheck.metrics.updateNow` 与 `settings.experimental.updateCheck.remindLaterUntil`。`npm test -- "src/components/settings-v2/system/experimental/UpdateCheckSettings.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、8 个用例;`npx eslint "src/components/settings-v2/system/experimental/UpdateCheckSettings.tsx" "src/components/settings-v2/system/experimental/UpdateCheckSettings.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0`、`npm run detect-translations -- --verbose`、UpdateCheckSettings defaultValue / 中文 fallback 扫描、5 个 `settings.json` 资源 JSON 解析与定向 `git diff --check` 通过,当前资源口径为 5 个 locale、6 个 namespace、4801 个 source key。本条未新增资源 key,未启动 `npm run typecheck` / `npm run verify:local` / GUI smoke;执行时已有隔壁 `npm run verify:local` / `npm run verify:gui-smoke` / Vitest owner 在活动,且 PRD 已追加记录 207 的 Voice 收口,本条避让 agent/voice、OpenClaw、system/chrome-relay、system/developer、system/environment、CrashRecoveryPanel、Automation 主页面、Agent Chat fast response、A2UI、Artifact 渲染器、Rust provider / model resources 与 agent/providers 长尾。 + + +209. 2026-05-11 Settings General Memory 记忆总页 fallback 收口续测:`src/components/settings-v2/general/memory/index.tsx` 复用既有 `settings.memory.*` 正式资源,把记忆总页 hero、总开关、保存 / 取消 / 恢复消息、画像问卷、命中层可用性、来源链状态 / 策略 / 详情、memdir 初始化 / 写入 / 整理 / 模板 / gitignore 操作、source bucket / relative time / loaded status、表单按钮、空态、校验错误、summary chip、info tip aria 与 yes/no label 从 `t(key, 中文 fallback)` / `defaultValue` / `memoryText(..., 中文 fallback)` 改为纯 key-based 读取;profile option value、runtime source preview、working_dir、memdir 路径、用户输入、topic、relative date token、layer metric card title / unit、API 返回 summary 与底层错误 message 继续作为 runtime / API 数据保留。`src/components/settings-v2/general/memory/index.test.tsx` 去掉 `react-i18next` mock,改用真实 i18n resource 与 en-US locale 回归,验证 Memory、Memory Master Switch、Preference Profile、Memory Hit Layer Availability、Source Chain Status Overview、Source Chain Policy、Source Chain Hit Details、Memory Directory (memdir)、Disable now、Write to memdir、Initialize memdir、Organize memdir、Feedback memory、Generate Workspace template、Add local template to .gitignore、Enter memdir content before saving、memdir organized、Feedback / project memory must include a Why section 与 Project memory relative-date 校验来自 `settings` namespace / runtime 插值,并确保不再泄露 `settings.memory` key 字符串;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `settings.memory.title`、`settings.memory.hero.profileCompletion`、`settings.memory.memdir.message.emptyNote` 与 `settings.memory.source.detail.followImports`。`npm test -- "src/components/settings-v2/general/memory/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、17 个用例;`npx eslint "src/components/settings-v2/general/memory/index.tsx" "src/components/settings-v2/general/memory/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0`、`npm run detect-translations -- --verbose`、Memory 设置页 defaultValue / 中文 fallback 扫描、5 个 `settings.json` 资源 JSON 解析与定向 `git diff --check` 通过,当前资源口径为 5 个 locale、6 个 namespace、4801 个 source key。本条未新增资源 key,未启动 `npm run typecheck` / `npm run verify:local` / GUI smoke;执行时已有隔壁 `npm run verify:local` 与 Vitest owner 在活动,避免抢占并行 owner;本条不触碰 OpenClaw、system/channels、system/automation、account/profile、agent/providers、Agent Chat fast response、A2UI、Artifact 渲染器、Rust provider / model resources 或 Browser Runtime 长尾。 + +210. 2026-05-11 Skills 仓库管理面板 fallback 收口续测:`src/components/skills/RepoManagerPanel.tsx` 接入 `useTranslation("agent")` 并新增 `skills.repoManager.*` 正式资源,把仓库管理弹窗标题说明、刷新 / 关闭 / 添加 / 删除 / GitHub 查看操作、添加仓库表单字段、仓库列表空态、分支行、必填校验、添加失败与删除失败反馈从硬编码中文和 `alert` 拼接改为 key-based 读取;repo owner/name/branch、GitHub URL 与底层错误 message 继续作为 runtime / 用户输入数据保留。新增 `src/components/skills/RepoManagerPanel.test.tsx` 使用真实 i18n resource 与 en-US locale 回归,验证 Repository Manager、Manage Skill repository sources、Add a new repository、Owner、Repository name、Branch (optional)、Added repositories、No repositories yet、Branch: main、View on GitHub、Remove、Enter the repository owner and name 与 Add failed: network down 来自 `agent` namespace,并确保不再泄露“仓库管理”“添加新仓库”等中文 fallback;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `skills.repoManager.title`、`skills.repoManager.action.add` 与 `skills.repoManager.message.addFailed`。`npm test -- "src/components/skills/RepoManagerPanel.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、8 个用例;`npx eslint "src/components/skills/RepoManagerPanel.tsx" "src/components/skills/RepoManagerPanel.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0`、`npm run detect-translations -- --verbose`、5 个 `agent.json` 资源 JSON 解析、18 个 `skills.repoManager.*` key 的 5 locale 存在性检查、RepoManagerPanel 硬编码中文 / defaultValue / fallback 扫描与定向 `git diff --check` 通过,当前资源口径为 5 个 locale、6 个 namespace、4819 个 source key。本条未启动 `npm run typecheck` / `npm run verify:local` / GUI smoke;执行时已有隔壁 `npm run verify:local`、`npm run verify:gui-smoke`、`npm run typecheck`、Rust `cargo test` / `cargo run` 与 headless Tauri owner 在活动,避免抢占并行资源;本条不触碰 Settings Memory、system/automation、system/channels、agent/providers、Agent Chat fast response、A2UI、Artifact 渲染器、Rust provider / model resources 或 Browser Runtime 长尾。 + +211. 2026-05-11 Settings Automation Job Dialog fallback 收口续测:`src/components/settings-v2/system/automation/AutomationJobDialog.tsx` 复用既有 `settings.automation.jobDialog.*` 正式资源,把持续流程创建 / 编辑弹窗的标题说明、header tip、workspace / schedule / start method / delivery / permission / status 徽章、调度提示、调度类型、权限策略摘要、投递目标 placeholder / 描述、旧浏览器自动化下线提示、历史配置快照、表单 label / helper、校验错误与 footer 操作从 `t(key, 中文 fallback)` / `defaultValue` / 未使用的中文插值 fallback 改为纯 key-based 读取;job name、description、workspace name/id、schedule 数值、prompt / system prompt、content_id、request_metadata、delivery target、旧 browser payload、底层 submit error 与运行时状态继续作为 runtime / API 数据保留,不固化进资源。`src/components/settings-v2/system/automation/AutomationJobDialog.test.tsx` 去掉 `react-i18next` mock,改用真实 i18n resource 与 en-US locale 回归,验证 New Ongoing Flow、Configure the flow name, cadence, starting prompt, and output destination.、Start Method: Agent Conversation、Ongoing flow dialog help、This type cannot be saved、Browser automation has been retired、Historical Configuration Snapshot、Save Changes 与 Create Ongoing Flow 来自 `settings` namespace / runtime 插值,并确保不再泄露 `settings.automation.jobDialog` key 字符串或中文 fallback;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `settings.automation.jobDialog.title.create`、`settings.automation.jobDialog.badge.startMethod`、`settings.automation.jobDialog.legacy.title` 与 `settings.automation.jobDialog.footer.create`。`npm test -- "src/components/settings-v2/system/automation/AutomationJobDialog.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、10 个用例;`npx eslint "src/components/settings-v2/system/automation/AutomationJobDialog.tsx" "src/components/settings-v2/system/automation/AutomationJobDialog.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0`、`npm run detect-translations -- --verbose`、AutomationJobDialog 硬编码中文 / defaultValue / fallback 参数扫描、5 个 `settings.json` 资源 JSON 解析与定向 `git diff --check` 通过,当前资源口径为 5 个 locale、6 个 namespace、4836 个 source key(已包含并行 i18n 资源增量)。本条未新增资源 key,未启动 `npm run typecheck` / `npm run verify:local` / GUI smoke;执行时已有隔壁 `npm run verify:local` 与 Rust `cargo test` owner 在活动,避免抢占并行资源;本条不触碰 OpenClaw、system/automation 主页面、system/channels、account/profile、agent/providers、Agent Chat fast response、A2UI、Artifact 渲染器、Rust provider / model resources 或 Browser Runtime 长尾。 + + +212. 2026-05-11 Skills SkillCard fallback 收口续测:`src/components/skills/SkillCard.tsx` 接入 `useTranslation("agent")` 并新增 `skills.skillCard.*` 正式资源,把 Skill 来源徽标、标准合规徽标、空描述、已安装状态、安装 / 卸载 / 执行 / 查看内容 / 检查详情操作与安装中 / 卸载中状态从硬编码中文改为 key-based 读取;Skill 名称、description、directory、sourceKind、catalogSource、repoOwner / repoName / repoBranch 与 standardCompliance 结构继续作为 runtime / API 数据保留。`src/components/skills/SkillCard.test.ts` 改用真实 i18n resource 与 en-US locale 回归,验证 No description yet、Built-in、Installed、Run、View content、Standard、Local 等来自 `agent` namespace,并把纯逻辑 helper 改为返回 `skills.skillCard.action.*` key,避免 UI helper 继续沉淀中文标签;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `skills.skillCard.source.builtin`、`skills.skillCard.action.install` 与 `skills.skillCard.compliance.standard`。`npm test -- "src/components/skills/SkillCard.test.ts" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、30 个用例;`npx eslint "src/components/skills/SkillCard.tsx" "src/components/skills/SkillCard.test.ts" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0`、`npm run detect-translations -- --verbose`、5 个 `agent.json` 资源 JSON 解析、17 个 `skills.skillCard.*` key 的 5 locale 存在性检查、SkillCard defaultValue / 中文 fallback 扫描与定向 `git diff --check` 通过,当前资源口径为 5 个 locale、6 个 namespace、4836 个 source key。本条未启动 `npm run typecheck` / `npm run verify:local` / GUI smoke;执行时已有隔壁 `npm run verify:local`、Vitest、headless Tauri 与多条 Codex owner 在活动,避免抢占并行资源;本条不触碰 Settings Memory、system/automation 主页面、system/channels、agent/providers、Agent Chat fast response、A2UI、Artifact 渲染器、Rust provider / model resources 或 Browser Runtime 长尾。 + +213. 2026-05-11 Skills 内容检查弹窗 fallback 收口续测:`src/components/skills/SkillContentDialog.tsx` 接入 `useTranslation("agent")` 并新增 `skills.contentDialog.*` 正式资源,把 Skill 内容检查弹窗的默认说明、加载态、合规状态徽标、License 标签、验证错误 / 兼容字段 / 允许工具指标、验证错误 / 兼容字段 / 元数据 / 允许工具 / 原始 SKILL.md 分区标题、metadata / allowed-tools / SKILL.md 空态与无检查结果空态从硬编码中文改为 key-based 读取;skillName、skillDescription、inspection content、license、metadata key/value、allowedTools、resourceSummary tag、validationErrors 与 deprecatedFields 继续作为 runtime / API 数据保留。`src/components/skills/SkillContentDialog.test.tsx` 改用真实 i18n resource 与 en-US locale 回归,验证 Checking Skill package...、Standard、Validation errors、Compat fields、Allowed tools、Metadata、Original SKILL.md、Needs fix 与 No Skill inspection result yet 来自 `agent` namespace,同时保留 markdown 内容和后端检查错误作为 runtime 数据;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `skills.contentDialog.loading`、`skills.contentDialog.section.metadata` 与 `skills.contentDialog.emptyInspection`。`npm test -- "src/components/skills/SkillContentDialog.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、10 个用例;`npx eslint "src/components/skills/SkillContentDialog.tsx" "src/components/skills/SkillContentDialog.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0`、`npm run detect-translations -- --verbose`、5 个 `agent.json` 资源 JSON 解析、18 个 `skills.contentDialog.*` key 的 5 locale 存在性检查、SkillContentDialog defaultValue / 中文 fallback 扫描与定向 `git diff --check` 通过,当前资源口径为 5 个 locale、6 个 namespace、4854 个 source key。本条未启动 `npm run typecheck` / `npm run verify:local` / GUI smoke;执行时已有隔壁 `npm run verify:local`、`npm run typecheck`、Rust `cargo test` / `rustc`、headless Tauri 与多条 Codex owner 在活动,避免抢占并行资源;本条不触碰 Settings Memory、system/automation 主页面、system/channels、agent/providers、Agent Chat fast response、A2UI、Artifact 渲染器、Rust provider / model resources 或 Browser Runtime 长尾。 + +214. 2026-05-11 Settings Automation Job Details Dialog fallback 收口续测:`src/components/settings-v2/system/automation/AutomationJobDetailsDialog.tsx` 复用既有 `settings.automation.details.*` 与 `settings.automation.history.*` 正式资源,把持续流程详情弹窗标题说明、header tip、job / workspace / schedule / payload / status 徽章、状态 / 调度 / 权限 / 投递 / 旧浏览器 payload / service skill / SceneApp 回流 / 最近运行区标题与 meta 从 `detailsText(..., 中文 fallback)`、`t(key, 中文 fallback)` 与 `defaultValue` 改为纯 key-based 读取;job name、workspace name/id、run id / session id、schedule expr/time、payload prompt、delivery target/message/output preview、service skill runtime context、SceneApp summary / actions / artifact entries 与底层 error message 继续作为 runtime / API 数据保留。`src/components/settings-v2/system/automation/AutomationJobDetailsDialog.test.tsx` 去掉 `react-i18next` mock,改用真实 i18n resource 与 en-US locale 回归,验证 Ongoing Flow Details、Review this ongoing flow's status, output destination, and recent runs.、Workspace: 默认工作区、Ongoing flow detail help、This Run Judgment、Refresh 与 Access Mode: Full access 来自 `settings` namespace / runtime 插值;`src/components/settings-v2/system/automation/index.test.tsx` 同步补齐 automation details/history mock resource 映射,避免主页面回归因详情弹窗 key-only mock 漂移,并继续覆盖 legacy browser、Google Sheets delivery、service skill runtime、compat 标记与 SceneApp 回流主链;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `settings.automation.details.title`、`settings.automation.details.badge.workspace`、`settings.automation.details.legacy.message` 与 `settings.automation.history.action.refresh`。`npm test -- "src/components/settings-v2/system/automation/AutomationJobDetailsDialog.test.tsx" "src/components/settings-v2/system/automation/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 4 个文件、27 个用例;`npx eslint "src/components/settings-v2/system/automation/AutomationJobDetailsDialog.tsx" "src/components/settings-v2/system/automation/AutomationJobDetailsDialog.test.tsx" "src/components/settings-v2/system/automation/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0`、`npm run detect-translations -- --verbose`、AutomationJobDetailsDialog 硬编码中文 / defaultValue / fallback 扫描、5 个 `settings.json` 资源 JSON 解析与定向 `git diff --check` 通过,当前资源口径为 5 个 locale、6 个 namespace、4854 个 source key(已包含并行 Skills 记录 212 / 213 的资源增量)。本条未新增资源 key,未启动 `npm run typecheck` / `npm run verify:local` / GUI smoke;执行时已有隔壁 `npm run verify:local`、`npm run verify:gui-smoke`、Rust `cargo test` 与多条 Codex owner 在活动,避免抢占并行资源;本条不触碰 OpenClaw、system/automation 主页面 `index.tsx` 文案迁移、system/channels、account/profile、agent/providers、Agent Chat fast response、A2UI、Artifact 渲染器、Rust provider / model resources 或 Browser Runtime 长尾。 + + +215. 2026-05-11 Skills WorkflowProgress 进度组件 fallback 收口续测:`src/components/skills/WorkflowProgress.tsx` 接入 `useTranslation("agent")` 并新增 `skills.workflowProgress.*` 正式资源,把工作流进度空态、重试提示、运行中 / 失败 / 完成 / 已完成摘要从硬编码中文改为 key-based 读取;step name、StepResult error、全局 error、current / total / progress 数值继续作为 runtime / API 数据保留,不固化进资源。`src/components/skills/WorkflowProgress.test.tsx` 改用真实 i18n resource 与 en-US locale 回归,验证 Completed: 1/2、No workflow steps yet 与 Retrying... 来自 `agent` namespace,同时保留“准备输入”“执行生成”“执行失败,请稍后重试”作为 runtime 测试数据,并确保不再泄露“已完成”“暂无工作流步骤”“正在重试”等中文 fallback;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `skills.workflowProgress.empty`、`skills.workflowProgress.status.retrying` 与 `skills.workflowProgress.summary.running`。`npm test -- "src/components/skills/WorkflowProgress.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、8 个用例;`npx eslint "src/components/skills/WorkflowProgress.tsx" "src/components/skills/WorkflowProgress.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0`、`npm run detect-translations -- --verbose`、5 个 `agent.json` 资源 JSON 解析、6 个 `skills.workflowProgress.*` key 的 5 locale 存在性检查、WorkflowProgress 硬编码中文 / defaultValue / fallback 扫描与定向 `git diff --check` 通过,当前资源口径为 5 个 locale、6 个 namespace、4860 个 source key。本条未启动 `npm run typecheck` / `npm run verify:local` / GUI smoke;执行时已有隔壁 `npm run verify:local`、`npm run verify:gui-smoke`、Rust `cargo run` / `rustc`、headless Tauri 与多条 Codex owner 在活动,避免抢占并行资源;本条不触碰 Settings Memory、system/automation 主页面与详情弹窗 owner、system/channels、agent/providers、Agent Chat fast response、A2UI、Artifact 渲染器、Rust provider / model resources 或 Browser Runtime 长尾。 + +216. 2026-05-11 ProjectSelector 项目 / 工作区切换器 fallback 二次收口续测:`src/components/projects/ProjectSelector.tsx` 复用既有 `common.projectSelector.*`、`common.save`、`common.cancel`、`common.delete` 与 `common.loading` 正式资源,把项目 / 工作区 entity label、placeholder、管理区标题说明、默认 badge、workspace type、搜索 / 空态 / 计数、重命名 / 删除弹窗、创建 / 重命名 / 删除 toast 与保存 / 删除中状态从 `defaultValue` fallback 改为纯 key-based 读取;project name、workspaceType、rootPath、用户输入的新名称、API error message、外部传入 placeholder 与 create/delete runtime 结果继续作为 runtime / API 数据保留。`src/components/projects/ProjectSelector.ui.test.tsx` 补齐 common projectSelector mock resource 映射,继续覆盖默认项目不可重命名 / 删除、删除后回退默认项目、workspace-tab 文案、延后列表加载与被动展示摘要加载;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `common.projectSelector.management.title.workspace`、`common.projectSelector.toast.renameFailed` 与 `common.projectSelector.delete.dangerDescription`。`npm test -- "src/components/projects/ProjectSelector.ui.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、12 个用例;`npx eslint "src/components/projects/ProjectSelector.tsx" "src/components/projects/ProjectSelector.ui.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0`、`npm run detect-translations -- --verbose`、ProjectSelector defaultValue / 中文 fallback 扫描、5 个 `common.json` 资源 JSON 解析与定向 `git diff --check` 通过,当前资源口径为 5 个 locale、6 个 namespace、4860 个 source key(已包含并行 Skills 记录 215 的资源增量)。本条未新增资源 key,未启动 `npm run typecheck` / `npm run verify:local` / GUI smoke;执行时已有隔壁 `npm run verify:local`、`npm run verify:gui-smoke` 与多条 Codex owner 在活动,避免抢占并行资源;本条不触碰 OpenClaw、Settings automation 主页面、system/channels、agent/providers、Agent Chat fast response、A2UI、Artifact 渲染器、Rust provider / model resources 或 Browser Runtime 长尾。 + + +217. 2026-05-11 SkillsPage 高级入口 fallback 收口续测:`src/components/skills/SkillsPage.tsx` 接入 `useTranslation("agent")`,并把 `src/components/skills/skillsUtils.ts` 的分组标题 / 说明改为 `skills.page.sections.*` key,把 Skills 高级入口页标题说明、info tip aria / 内容、刷新 / 新建 / 导入 / 仓库操作、导入目录选择标题、安装 / 卸载 / 导入 / 检查失败反馈、搜索 placeholder、安装状态筛选、分组标题说明 / sr-only、筛选无结果空态、加载态、远程缓存空态与通用空态从硬编码中文改为 key-based 读取;Skill 名称、description、directory、repo owner/name/branch、底层 API error message、选择的本地路径、用户创建表单输入与 scaffold draft 继续作为 runtime / API 数据保留。`src/components/skills/SkillsPage.test.tsx` 保持 zh-CN 主回归,同时新增 en-US 真实资源断言,验证 Manage and use AI skill extensions、Refresh、New Skill、Import Skill、Repositories、Search Skills... 与 Skills workspace info tooltip 来自 `agent` namespace;既有分组测试同步断言 zh-CN 资源中的内置 / 本地 / 远程 Skills,导入对话框仍验证中文资源标题;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `skills.page.title`、`skills.page.action.refresh` 与 `skills.page.sections.remote.empty`。`npm test -- "src/components/skills/SkillsPage.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、20 个用例;`npx eslint "src/components/skills/SkillsPage.tsx" "src/components/skills/SkillsPage.test.tsx" "src/components/skills/skillsUtils.ts" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0`、`npm run detect-translations -- --verbose`、5 个 `agent.json` 资源 JSON 解析、34 个 `skills.page.*` key 的 5 locale 存在性检查、SkillsPage / skillsUtils 硬编码中文 / defaultValue / fallback 扫描与定向 `git diff --check` 通过,当前资源口径为 5 个 locale、6 个 namespace、4894 个 source key。本条未启动 `npm run typecheck` / `npm run verify:local` / GUI smoke;执行时已有隔壁 `npm run verify:local`、`npm run verify:gui-smoke`、Rust `cargo test` / `rustc`、headless Tauri 与多条 Codex owner 在活动,避免抢占并行资源;本条不触碰 Settings Memory、system/automation 主页面与详情弹窗 owner、system/channels、agent/providers、Agent Chat fast response、A2UI、Artifact 渲染器、Rust provider / model resources、ProjectSelector 或 Browser Runtime 长尾。 + +218. 2026-05-11 Common / Workspace 小型 source fallback 二次收口续测:`src/components/execution/LatestRunStatusBadge.tsx` 复用既有 `common.execution.latestRunStatus.*` 正式资源,把最近执行默认标签、无记录空态、6 类运行状态与时间标签从 `defaultValue` fallback 改为纯 key-based 读取;`src/pages/update-notification.tsx` 复用既有 `common.updateNotification.*` 正式资源,把独立更新提醒窗口的新版本提示、当前版本补充、稍后提醒、关闭 / 跳过 / 浏览器打开 aria 与下载 / 立即更新按钮从字符串 fallback 改为纯 key-based 读取;`src/components/workspace/video/PromptInput.tsx` 复用既有 `workspace.video.promptInput.*` 正式资源,把视频 PromptInput 的模型 / 时长 / 参考图 meta、eyebrow、标题、tips aria / 内容、placeholder、快捷键提示与生成按钮从 `defaultValue` fallback 改为纯 key-based 读取。执行状态数据、版本号、下载 URL、模型 id、画幅 / 分辨率 / 时长 / 参考图数量仍作为 runtime / URL / 用户数据保留,并继续通过 `formatDate` / `formatNumber` 走 locale-aware formatter;本条未新增资源 key。既有真实 i18n 回归继续覆盖英文界面:`LatestRunStatusBadge.test.tsx` 验证 Latest run / Success / Time / No records yet,`update-notification.test.tsx` 验证 New version / In 1 day / Update now / Downloading 与 aria label,`VideoTipsBehavior.test.tsx` 验证 VIDEO STUDIO、Prompt guidance、Shortcut guidance、reference image(s) 与生成按钮来自 `workspace` namespace;`loadNamespace.test.ts` / `types.test.ts` 继续覆盖相关 common / workspace source resource。`npm test -- "src/components/execution/LatestRunStatusBadge.test.tsx" "src/pages/update-notification.test.tsx" "src/components/workspace/video/VideoTipsBehavior.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 5 个文件、16 个用例;`npx eslint "src/components/execution/LatestRunStatusBadge.tsx" "src/components/execution/LatestRunStatusBadge.test.tsx" "src/pages/update-notification.tsx" "src/pages/update-notification.test.tsx" "src/components/workspace/video/PromptInput.tsx" "src/components/workspace/video/VideoTipsBehavior.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0`、`npm run detect-translations -- --verbose`、10 个 `common.json` / `workspace.json` 资源 JSON 解析、3 个目标源文件 `defaultValue` / 中文 fallback 扫描与定向 `git diff --check` 通过,当前资源口径为 5 个 locale、6 个 namespace、4894 个 source key(包含并行 i18n 资源增量)。本条未启动 `npm run typecheck` / `npm run verify:local` / GUI smoke;当前仍有 Rust `cargo test` owner 在活动,且工作区存在插件清理、Artifact、Settings、Agent Runtime 等并行脏区,最终合并前仍需基于最新 diff 复跑统一入口;本条不触碰 OpenClaw、Skills、Settings automation 主页面、system/channels、agent/providers、Artifact 渲染器、Rust provider / model resources 或 Browser Runtime 长尾。 + +219. 2026-05-11 SmartInput 独立悬浮输入窗 source fallback 二次收口续测:`src/pages/smart-input.tsx` 复用既有 `common.smartInput.*` 正式资源,把语音模型缺失、麦克风权限、开始 / 停止录音失败、语音识别失败、发送失败、拖动窗口 title、识别 / 润色 / 录音状态、图片附件、移除图片、输入 placeholder、语音输入 / 停止录音 / 关闭 / 发送按钮的 title 与 aria 从 `t(key, 中文 fallback)` / `defaultValue` 改为纯 key-based 读取;本地语音模型 readiness.message、录音 / 转写 / 润色错误 message、实时转写文本、用户输入、图片路径、URL 参数与录音时长继续作为 runtime / API / 用户数据保留。既有真实 i18n 回归继续覆盖英文悬浮窗 chrome 与中文语音流程:`src/pages/smart-input.test.tsx` 验证 Drag to move window、Ask anything...、Image、Remove image、Voice input、Close (Esc)、Send (Enter) 来自 `common` namespace,并继续覆盖语音模式录音、实时识别、静音片段跳过、本地语音模型未安装提示与设置跳转;`loadNamespace.test.ts` / `types.test.ts` 继续覆盖 `common.smartInput.status.recording`。`npm test -- "src/pages/smart-input.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、10 个用例;`npx eslint "src/pages/smart-input.tsx" "src/pages/smart-input.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0`、`npm run detect-translations -- --verbose`、5 个 `common.json` 资源 JSON 解析、SmartInput 源文件 `defaultValue` / 中文 fallback 扫描与定向 `git diff --check` 通过,当前资源口径为 5 个 locale、6 个 namespace、4894 个 source key。本条未新增资源 key,未启动 `npm run typecheck` / `npm run verify:local` / GUI smoke;当前仍有 Rust `cargo test` owner 在活动,且工作区存在插件清理、Artifact、Settings、Agent Runtime 等并行脏区,最终合并前仍需基于最新 diff 复跑统一入口;本条不触碰 OpenClaw、Skills、Settings automation 主页面、system/channels、agent/providers、Artifact 渲染器、Rust provider / model resources 或 Browser Runtime 长尾。 + +220. 2026-05-11 Skills 脚手架弹窗 source fallback 二次收口续测:`src/components/skills/SkillScaffoldDialog.tsx` 复用既有 `skills.scaffoldDialog.*` 正式资源,把新建 Skill 弹窗标题说明、预填来源提示、创建位置、目录 / 名称 / 描述字段、校验错误、取消 / 带回创作输入 / 创建操作与创建中状态从 `t(key, 中文 fallback)` / `defaultValue` 改为纯 key-based 读取;sourceHint 继续通过 i18n interpolation 注入,目录、Skill 名称、描述、结构化草稿字段、source excerpt / message id 与底层 `onCreate` 错误继续作为 runtime / 用户输入数据保留。本条未新增资源 key,5 locale 已存在的 `skills.scaffoldDialog.*` 22 个 key 继续作为事实源。`src/components/skills/SkillScaffoldDialog.test.tsx` 去掉 `react-i18next` mock,改用真实 i18n resource,新增 en-US 回归验证 New Skill、current workspace `./.agents/skills` directory、User 与 app-level Skills directory 来自 `agent` namespace,并保留 zh-CN 表单提交、预填来源、带回创作输入、隐藏骨架字段与目录必填校验回归;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `skills.scaffoldDialog.title`、`skills.scaffoldDialog.action.create`、`skills.scaffoldDialog.sourceHint` 与 `skills.scaffoldDialog.validation.directory`。`npm test -- "src/components/skills/SkillScaffoldDialog.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、11 个用例;`npx eslint "src/components/skills/SkillScaffoldDialog.tsx" "src/components/skills/SkillScaffoldDialog.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0`、`npm run detect-translations -- --verbose`、5 个 `agent.json` 资源 JSON 解析、22 个 `skills.scaffoldDialog.*` key 的 5 locale 存在性检查、SkillScaffoldDialog source 硬编码中文 / defaultValue / fallback 扫描与定向 `git diff --check` 通过,当前资源口径仍为 5 个 locale、6 个 namespace、4894 个 source key。本条未启动 `npm run typecheck` / `npm run verify:local` / GUI smoke;执行时已有 Rust `cargo test` / `rustc`、headless Tauri、Vite 与多条 Codex owner 在活动,避免抢占并行资源;本条不触碰 Settings、Agent Chat、Artifact、ProjectSelector、Rust provider / model resources、SkillsPage、SkillCard、RepoManagerPanel、SkillContentDialog 或 Browser Runtime 长尾。 + +221. 2026-05-11 Capability Draft Panel source fallback 二次收口续测:`src/features/capability-drafts/components/CapabilityDraftPanel.tsx` 复用既有 `capabilityDraft.panel.*` 正式资源,把能力草案面板的 summary copy、状态 label / description、evidence key label、注册成功反馈、badge / title / description、刷新按钮、缺项目 / 加载 / 空态 / 错误提示、目标 / 权限 / 文件 / 验证 / 注册字段、guard 文案、验证证据标题 / subtitle,以及运行验证 / 注册到 Workspace 操作从 `t(key, 中文 fallback)` / `defaultValue` / 英文常量 fallback 改为纯 key-based 读取;draft name / description / goal、sourceKind、permissionSummary、generatedFiles、verification report summary / check label / evidence value、registration directory 与 API error 继续作为 runtime / API 数据保留,并继续通过 `formatNumber` 走 locale-aware formatter。`src/features/capability-drafts/components/CapabilityDraftPanel.test.tsx` 去掉 `react-i18next` mock,改用真实 `zh-CN` resource 回归,继续验证无项目提示、未验证草案、验证后只展示注册入口、证据 label / duration / hash 截断、注册后目录提示与不暴露运行 / 自动化入口;`loadNamespace.test.ts` / `types.test.ts` 新增覆盖 `capabilityDraft.panel.title`、`capabilityDraft.panel.action.verify`、`capabilityDraft.panel.feedback.registered` 与 `capabilityDraft.panel.summary.filesWithMore`。`npm test -- "src/features/capability-drafts/components/CapabilityDraftPanel.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、9 个用例;`npx eslint "src/features/capability-drafts/components/CapabilityDraftPanel.tsx" "src/features/capability-drafts/components/CapabilityDraftPanel.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0`、`npm run detect-translations -- --verbose`、5 个 `agent.json` 资源 JSON 解析、CapabilityDraftPanel source `defaultValue` / 中文 fallback 扫描与定向 `git diff --check` 通过,当前资源口径仍为 5 个 locale、6 个 namespace、4894 个 source key。本条未新增资源 key,未启动 `npm run typecheck` / `npm run verify:local` / GUI smoke;执行时仍有 Rust `cargo test` owner 和多条并行脏区,最终合并前仍需基于最新 diff 复跑统一入口;本条不触碰 OpenClaw、Skills 脚手架弹窗 / SkillsPage、Settings、Agent Chat、Artifact、ProjectSelector、Rust provider / model resources 或 Browser Runtime 长尾。 + +222. 2026-05-11 Skills 执行对话框 source fallback 二次收口续测:`src/components/skills/SkillExecutionDialog.tsx` 复用既有 `skills.executionDialog.*` 正式资源,把执行模式 label、未知模式、workflow badge、参数提示、结果成功 / 失败 / 输出标题、加载失败标题、关闭 / 取消 / 执行 / 重试 / 执行中操作、缺省描述、输入 label / placeholder、Provider label / auto placeholder / helper、工作流进度标题与非工作流执行中状态从 `t(key, 中文 fallback)` / `defaultValue` 改为纯 key-based 读取;skill display_name、description、when_to_use、argument_hint、workflow step、provider id、用户输入、执行输出、API error 与 hook error 继续作为 runtime / Skill 包 / 用户数据保留。`src/components/skills/SkillExecutionDialog.test.tsx` 去掉 `react-i18next` mock,改用真实 en-US i18n resource 回归,验证 Workflow mode、Includes workflow、Hint: 输入主题和目标、Input、Enter the content to process...、Provider、Leave blank to auto select based on Skill config and available credentials.、Progress、Cancel、Run 来自 `agent` namespace,并新增加载失败用例验证 Failed to load / Close 来自资源而 network down 作为 runtime error 保留;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `skills.executionDialog.mode.workflow`、`skills.executionDialog.argumentHint` 与 `skills.executionDialog.loadFailed.title`。`npm test -- "src/components/skills/SkillExecutionDialog.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、7 个用例;`npx eslint "src/components/skills/SkillExecutionDialog.tsx" "src/components/skills/SkillExecutionDialog.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0`、`npm run detect-translations -- --verbose`、5 个 `agent.json` 资源 JSON 解析、23 个 `skills.executionDialog.*` key 的 5 locale 存在性检查、SkillExecutionDialog source 硬编码中文 / defaultValue / fallback 扫描与定向 `git diff --check` 通过,当前资源口径仍为 5 个 locale、6 个 namespace、4894 个 source key。本条未新增资源 key,未启动 `npm run typecheck` / `npm run verify:local` / GUI smoke;执行时仍有 Rust `cargo test` / `rustc`、headless Tauri、Vite 与多条 Codex owner 在活动,避免抢占并行资源;本条不触碰 Settings、Agent Chat、Artifact、ProjectSelector、Rust provider / model resources、SkillsPage、SkillScaffoldDialog、CapabilityDraftPanel 或 Browser Runtime 长尾。 + +223. 2026-05-11 Provider ImportExportDialog source fallback 二次收口续测:`src/components/api-key-provider/ImportExportDialog.tsx` 复用既有 `settings.providers.importExport.*` 正式资源,把 Provider 配置导入 / 导出弹窗的导出 / 复制 / 导入 / 文件读取错误、标题说明、导出 / 导入 tab、API Key 元数据选项、导出生成 / 复制 / 下载操作、选择文件、粘贴 JSON 提示、导入提交、导入完成 / 部分完成、导入 Provider 数、跳过 Provider 数、错误列表与关闭按钮从 `t(key, 中文 fallback)` / `defaultValue` 改为纯 key-based 读取;导出的 JSON、导入 JSON、Provider 计数、导入错误列表、用户选择文件和底层 API error 继续作为 runtime / 用户数据保留。`src/components/api-key-provider/ImportExportDialog.test.tsx` 去掉 `react-i18next` mock,改用真实 `zh-CN` resource 回归,继续验证导出页 key-based 文案、生成导出配置、复制 / 下载文件,以及导入页选择文件、粘贴 JSON、部分完成、导入 / 跳过 Provider 计数和 runtime 错误列表;`loadNamespace.test.ts` / `types.test.ts` 新增覆盖 `settings.providers.importExport.title`、`settings.providers.importExport.export.action.generate`、`settings.providers.importExport.import.result.importedProviders` 与 `settings.providers.importExport.import.result.errors`。`npm test -- "src/components/api-key-provider/ImportExportDialog.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、7 个用例;`npx eslint "src/components/api-key-provider/ImportExportDialog.tsx" "src/components/api-key-provider/ImportExportDialog.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0`、`npm run detect-translations -- --verbose`、5 个 `settings.json` 资源 JSON 解析、ImportExportDialog source `defaultValue` / 中文 fallback 扫描与定向 `git diff --check` 通过,当前资源口径仍为 5 个 locale、6 个 namespace、4894 个 source key。本条未新增资源 key,未启动 `npm run typecheck` / `npm run verify:local` / GUI smoke;执行时仍有 Rust `cargo test` owner 和多条并行脏区,最终合并前仍需基于最新 diff 复跑统一入口;本条不触碰 OpenClaw、Skills 执行 / 脚手架弹窗、CapabilityDraftPanel、Settings automation 主页面、system/channels、Agent Chat、Artifact、Rust provider / model resources 或 Browser Runtime 长尾。 + +224. 2026-05-11 SmartInput / Settings 共享快捷键设置组件 source fallback 二次收口续测:`src/components/smart-input/ShortcutSettings.tsx` 接入 `useTranslation("common")`,新增 5 locale `common.shortcutSettings.*` 11 个正式资源,把共享快捷键组件的 label、空快捷键占位、录制 placeholder / hint、保存 / 取消 / 清空 / 修改操作 title / 按钮、清空失败 / 快捷键格式无效 / 保存失败兜底错误从硬编码中文改为 key-based 读取;当前快捷键、录制出的快捷键、调用方传入 `emptyLabel` 与底层 `onShortcutChange` Error message 继续作为 runtime / 调用方数据保留。新增 `src/components/smart-input/ShortcutSettings.test.tsx` 使用真实 en-US i18n resource 回归,验证 Shortcut、Clear、Edit、Press a shortcut combination...、录制提示、Save / Cancel title 与 Invalid shortcut format 来自 `common` namespace,并断言不泄露“快捷键 / 修改 / 快捷键格式无效”等中文 fallback;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `common.shortcutSettings.label` 与 `common.shortcutSettings.error.invalid`。`npm test -- "src/components/smart-input/ShortcutSettings.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、7 个用例;`npx eslint "src/components/smart-input/ShortcutSettings.tsx" "src/components/smart-input/ShortcutSettings.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0`、`npm run detect-translations -- --verbose`、5 个 `common.json` 资源 JSON 解析、11 个 `common.shortcutSettings.*` key 的 5 locale 存在性检查、ShortcutSettings source 硬编码中文 / defaultValue / fallback 扫描与定向 `git diff --check` 通过,当前资源口径为 5 个 locale、6 个 namespace、4905 个 source key。本条未启动 `npm run typecheck` / `npm run verify:local` / GUI smoke;执行时仍有 Rust `cargo test`、headless Tauri、Vite 与多条 Codex owner 在活动,避免抢占并行资源;本条不触碰 Settings voice / experimental 页面实现、Agent Chat、Artifact、ProjectSelector、Rust provider / model resources、Provider ImportExportDialog、Skills 或 Browser Runtime 长尾。 + +225. 2026-05-11 Agent Chat CanvasSessionOverviewPanel source fallback 与 locale-aware 时间收口续测:`src/components/agent/chat/components/CanvasSessionOverviewPanel.tsx` 接入 `useTranslation("agent")`、`formatDate` 与 `formatNumber`,新增 5 locale `agentChat.sessionOverview.*` 62 个正式资源,把会话过程索引面板的 turn / item 状态、activity 标题与兜底摘要、聚焦 badge、说明文案、当前 turn / 轨迹 / 待补 / 排队统计、最近更新时间 / 当前焦点、执行时间线、待处理交互、排队消息、空态、排队图片数与时间占位从硬编码中文和 `toLocaleTimeString("zh-CN")` 改为 key-based + locale-aware formatter;turn id、tool / command / web search action、prompt / question、artifact path、subagent title / summary、runtime error / warning、queued message preview 与 request id 继续作为 runtime / API / 用户输入数据保留。新增 `src/components/agent/chat/components/CanvasSessionOverviewPanel.test.tsx` 使用真实 en-US i18n resource 回归,验证 Session Process Index、Running、Focused on exec_command、Current turn、In progress、Needs input、Last updated、Execution timeline、Command is running、Pending interactions、Waiting for confirmation、Queued messages、Queued 1 与 2 image(s) 来自 `agent` namespace / locale formatter,并断言不泄露“会话过程索引 / 执行时间线 / 排队消息”等中文 fallback;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `agentChat.sessionOverview.panel.title`、`agentChat.sessionOverview.status.turn.running`、`agentChat.sessionOverview.timeline.empty` 与 `agentChat.sessionOverview.queue.imageCount`。`npm test -- "src/components/agent/chat/components/CanvasSessionOverviewPanel.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、6 个用例;`npx eslint "src/components/agent/chat/components/CanvasSessionOverviewPanel.tsx" "src/components/agent/chat/components/CanvasSessionOverviewPanel.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0`、`npm run detect-translations -- --verbose`、5 个 `agent.json` 资源 JSON 解析、6 个关键 `agentChat.sessionOverview.*` key 的 5 locale 存在性检查、CanvasSessionOverviewPanel source `defaultValue` / 中文 fallback / `toLocale*` 扫描与定向 `git diff --check` 通过,当前资源口径为 5 个 locale、6 个 namespace、4967 个 source key。本条未启动 `npm run typecheck` / `npm run verify:local` / GUI smoke;当前工作区仍有大量并行 i18n / Rust / plugin 清理脏区,最终合并前仍需基于最新 diff 复跑统一入口;本条不触碰 OpenClaw、Settings automation / web-search / ProviderSetting、Skills、Artifact、Rust provider / model resources 或 Browser Runtime 长尾。 + +226. 2026-05-11 StartupLoadingScreen 启动加载屏 source fallback 二次收口续测:`src/i18n/StartupLoadingScreen.tsx` 接入 `useTranslation("common")`,新增 5 locale `common.startupLoading.*` 2 个正式资源,把启动加载屏标题与语言配置 / 工作台入口说明从硬编码中文改为 key-based 读取;品牌名与 logo alt 继续来自 `src/lib/branding.ts` 运行时品牌事实源,并通过 interpolation 注入标题。新增 `src/i18n/StartupLoadingScreen.test.tsx` 使用真实 en-US i18n resource 回归,验证 Starting Lime、Preparing language settings and workspace entry. Please wait. 来自 `common` namespace,logo alt 继续等于品牌名,并断言不泄露“正在启动 / 语言配置”等中文 fallback;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `common.startupLoading.title` 与 `common.startupLoading.description`。`npm test -- "src/i18n/StartupLoadingScreen.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、6 个用例;`npx eslint "src/i18n/StartupLoadingScreen.tsx" "src/i18n/StartupLoadingScreen.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0`、`npm run detect-translations -- --verbose`、5 个 `common.json` 资源 JSON 解析、2 个 `common.startupLoading.*` key 的 5 locale 存在性检查、StartupLoadingScreen source 硬编码中文 / defaultValue / fallback 扫描与定向 `git diff --check` 通过,当前资源口径为 5 个 locale、6 个 namespace、4969 个 source key(含并行资源增量)。本条未启动 `npm run typecheck` / `npm run verify:local` / GUI smoke;执行时仍有 Rust `cargo test` / `rustc`、headless Tauri、Vite 与多条 Codex owner 在活动,避免抢占并行资源;本条不触碰 App shell/sidebar/navigation、Settings 页面、Agent Chat、Artifact、ProjectSelector、Rust provider / model resources、Provider ImportExportDialog、Skills 或 Browser Runtime 长尾。 + +227. 2026-05-11 Agent Chat SearchResultPreviewList source fallback 二次收口续测:`src/components/agent/chat/components/SearchResultPreviewList.tsx` 接入 `useTranslation("agent")` 与 `formatNumber`,新增 5 locale `agentChat.searchResultPreview.*` 6 个正式资源,把搜索结果 hover 预览 aria、摘要空态、展开 / 收起 aria、展开 / 收起按钮文案与隐藏结果数量从硬编码中文和模板字符串改为 key-based + locale-aware number formatter;搜索结果 title / url / hostname / snippet 与打开 URL 回调继续作为 runtime / Web Search 数据保留。`src/components/agent/chat/components/SearchResultPreviewList.test.tsx` 改用真实 en-US i18n resource 回归,验证 Expand search results、Show 2 more results、Collapse results、Collapse search results、Preview search result: Result 1 来自 `agent` namespace,并保留 Result / Summary 作为 runtime 测试数据,同时断言不再泄露“展开其余 / 收起结果”等中文 fallback;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `agentChat.searchResultPreview.expandMore` 与 `agentChat.searchResultPreview.previewAria`。`npm test -- "src/components/agent/chat/components/CanvasSessionOverviewPanel.test.tsx" "src/components/agent/chat/components/SearchResultPreviewList.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 4 个文件、8 个用例;`npx eslint "src/components/agent/chat/components/CanvasSessionOverviewPanel.tsx" "src/components/agent/chat/components/CanvasSessionOverviewPanel.test.tsx" "src/components/agent/chat/components/SearchResultPreviewList.tsx" "src/components/agent/chat/components/SearchResultPreviewList.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0`、`npm run detect-translations -- --verbose`、5 个 `agent.json` 资源 JSON 解析、4 个关键 Agent Chat key 的 5 locale 存在性检查、CanvasSessionOverviewPanel / SearchResultPreviewList source `defaultValue` / 中文 fallback / `toLocale*` 扫描与定向 `git diff --check` 通过,当前资源口径为 5 个 locale、6 个 namespace、4977 个 source key。本条曾尝试 `npm run typecheck`,先发现本刀 `count` 保留字类型问题,已改为 `countLabel` 并复跑定向测试 / eslint / i18n / JSON / fallback / diff 检查;随后全量 typecheck 仍受并行 SkillsPage / SkillCard typed key 变更阻断,未作为本条绿色证据;本条未启动 `npm run verify:local` / GUI smoke;当前工作区仍有大量并行 i18n / Rust / plugin 清理脏区,最终合并前仍需基于最新 diff 复跑统一入口;本条不触碰 OpenClaw、Settings automation / web-search / ProviderSetting、Skills、Artifact、Rust provider / model resources 或 Browser Runtime 长尾。 + +228. 2026-05-11 ConfirmDialog 共享确认弹窗 source fallback 二次收口续测:`src/components/ConfirmDialog.tsx` 复用既有 `common.confirmDialog.title`、`common.confirm` 与 `common.cancel` 正式资源,把默认标题、确认按钮与取消按钮从 `t(key, { defaultValue: 中文 })` 改为纯 key-based 读取;调用方传入的 `title`、`message`、`confirmText`、`cancelText` 与变体样式继续作为 runtime / 调用方数据保留。`src/components/ConfirmDialog.test.tsx` 去掉 `react-i18next` mock,改用真实 en-US i18n resource 回归,验证 Confirm action、Cancel、Confirm 来自 `common` namespace,业务 message 继续作为调用方数据展示,并断言不泄露“确认操作 / 确定”等中文 fallback;`loadNamespace.test.ts` / `types.test.ts` 补充覆盖 `common.cancel` 与 `common.confirm`,`common.confirmDialog.title` 沿用既有覆盖。`npm test -- "src/components/ConfirmDialog.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、6 个用例;`npx eslint "src/components/ConfirmDialog.tsx" "src/components/ConfirmDialog.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0`、`npm run detect-translations -- --verbose`、5 个 `common.json` 资源 JSON 解析、3 个 ConfirmDialog 复用 key 的 5 locale 存在性检查、ConfirmDialog source 硬编码中文 / defaultValue / fallback 扫描与定向 `git diff --check` 通过,当前资源口径为 5 个 locale、6 个 namespace、4975 个 source key(含并行 Agent Chat 资源增量)。本条未新增资源 key,未启动 `npm run typecheck` / `npm run verify:local` / GUI smoke;执行时仍有 Rust / Vite / headless Tauri 与多条 Codex owner 在活动,避免抢占并行资源;本条不触碰 App shell/sidebar/navigation、Settings 页面、Agent Chat、Artifact、ProjectSelector、Rust provider / model resources、Provider ImportExportDialog、Skills 或 Browser Runtime 长尾。 + +229. 2026-05-11 SplashScreen 启动画面 source fallback 二次收口续测:`src/components/SplashScreen.tsx` 接入 `useTranslation("common")`,新增 5 locale `common.splashScreen.*` 2 个正式资源,把启动 Splash 的 slogan / subtitle 从硬编码中文改为 key-based 读取;品牌名、logo alt 与 logo asset 继续来自 `LIME_BRAND_NAME` / `LIME_BRAND_LOGO_SRC` runtime 品牌事实源。`src/components/SplashScreen.test.tsx` 使用真实 en-US i18n resource 回归,验证 Tap Lime, inspiration arrives、From one thought to polished copy, images, videos, and finished work 来自 `common` namespace,并断言不泄露“青柠一下 / 成稿、成图”等中文 fallback;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `common.splashScreen.slogan` 与 `common.splashScreen.subtitle`。`npm test -- "src/components/SplashScreen.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、8 个用例;`npx eslint "src/components/SplashScreen.tsx" "src/components/SplashScreen.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0`、`npm run detect-translations -- --verbose`、5 个 `common.json` 资源 JSON 解析、2 个 `common.splashScreen.*` key 的 5 locale 存在性检查、SplashScreen source 硬编码中文 / defaultValue / fallback 扫描与定向 `git diff --check` 通过,当前资源口径为 5 个 locale、6 个 namespace、4977 个 source key(含并行资源增量)。本条未启动 `npm run typecheck` / `npm run verify:local` / GUI smoke;执行时仍有 Rust `cargo test` / `rustc`、headless Tauri、Vite 与多条 Codex owner 在活动,避免抢占并行资源;本条不触碰 App shell/sidebar/navigation、Settings 页面、Agent Chat、Artifact、ProjectSelector、Rust provider / model resources、Provider ImportExportDialog、Skills 或 Browser Runtime 长尾。 + +230. 2026-05-11 Workspace CanvasBreadcrumbHeader 返回按钮 source fallback 收口续测:`src/components/workspace/canvas/shared/CanvasBreadcrumbHeader.tsx` 接入 `useTranslation("workspace")`,新增 5 locale `workspace.canvasBreadcrumb.backHome` 正式资源,把共享画布面包屑 Home 按钮默认 `title` / `aria-label` 从硬编码“返回新建任务”改为 key-based 读取;调用方传入的 `backTitle` 与当前 `label` 继续作为 runtime / 调用方数据保留。新增 `src/components/workspace/canvas/shared/CanvasBreadcrumbHeader.test.tsx` 使用真实 en-US i18n resource 回归,验证 Back to new task 来自 `workspace` namespace,并断言默认路径不泄露中文 fallback,同时保留自定义 `backTitle` 覆盖回归;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `workspace.canvasBreadcrumb.backHome`。`npm test -- "src/components/workspace/canvas/shared/CanvasBreadcrumbHeader.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、7 个用例;`npx eslint "src/components/workspace/canvas/shared/CanvasBreadcrumbHeader.tsx" "src/components/workspace/canvas/shared/CanvasBreadcrumbHeader.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0`、`npm run detect-translations -- --verbose`、5 个 `workspace.json` 资源 JSON 解析、`workspace.canvasBreadcrumb.backHome` 的 5 locale 存在性检查、CanvasBreadcrumbHeader source 硬编码中文 / defaultValue / fallback 扫描与定向 `git diff --check` 通过,当前资源口径为 5 个 locale、6 个 namespace、4998 个 source key(含并行资源增量)。本条未启动 `npm run typecheck` / `npm run verify:local` / GUI smoke;执行时仍有 Rust `cargo test` / `rustc`、headless Tauri、Vite 与多条 Codex owner 在活动,避免抢占并行资源;本条不触碰 App shell/sidebar/navigation、Settings 页面、Agent Chat、Artifact、ProjectSelector、Rust provider / model resources、Provider ImportExportDialog、Skills 或 Browser Runtime 长尾。 + +231. 2026-05-11 Resource Manager PDF 预览器 source fallback 收口续测:`src/features/resource-manager/PdfResourceRenderer.tsx` 接入 `useTranslation("workspace")`,新增 5 locale `workspace.resourceManager.pdf.*` 3 个正式资源,把 PDF 缺少可预览地址空态标题 / 说明与 iframe 默认 `title` 从硬编码中文改为 key-based 读取;`item.title`、`item.id`、`item.src` 与文件路径 / URL 继续作为 runtime / 资源数据保留。新增 `src/features/resource-manager/PdfResourceRenderer.test.tsx` 使用真实 en-US i18n resource 回归,验证 PDF preview address missing、This resource has no local path or loadable URL、PDF preview 来自 `workspace` namespace,并断言不泄露“PDF 缺少可预览地址 / 暂时无法在窗口内预览”等中文 fallback,同时保留 runtime title 覆盖回归;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `workspace.resourceManager.pdf.missingTitle` 与 `workspace.resourceManager.pdf.frameTitle`。`npm test -- "src/features/resource-manager/PdfResourceRenderer.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、7 个用例;`npx eslint "src/features/resource-manager/PdfResourceRenderer.tsx" "src/features/resource-manager/PdfResourceRenderer.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0`、`npm run detect-translations -- --verbose`、5 个 `workspace.json` 资源 JSON 解析、3 个 `workspace.resourceManager.pdf.*` key 的 5 locale 存在性检查、PdfResourceRenderer source 硬编码中文 / defaultValue / fallback 扫描与定向 `git diff --check` 通过,当前资源口径为 5 个 locale、6 个 namespace、5001 个 source key。本条未启动 `npm run typecheck` / `npm run verify:local` / GUI smoke;执行时仍有 `npm run typecheck` / `tsc --noEmit`、Rust `cargo test` / `rustc`、headless Tauri、Vite 与多条 Codex owner 在活动,避免抢占并行资源;本条不触碰 App shell/sidebar/navigation、Settings 页面、Agent Chat、Artifact、Skills、ProjectSelector、Provider ImportExportDialog、Rust provider / model resources 或 Browser Runtime 长尾。 + +232. 2026-05-11 Resource Manager 媒体播放预览器 source fallback 收口续测:`src/features/resource-manager/MediaPlaybackResourceRenderer.tsx` 接入 `useTranslation("workspace")`,新增 5 locale `workspace.resourceManager.media.*` 7 个正式资源,把媒体播放默认标题、视频 / 音频播放失败空态标题、播放失败说明、video / audio fallback 文案与 WebView 原生音频控制 label 从硬编码中文改为 key-based 读取;`item.title`、`metadata.slotLabel`、`description`、`metadata.prompt`、`item.src`、format label / preview target label 与文件 / MIME 运行时数据继续作为 runtime / 资源数据保留。新增 `src/features/resource-manager/MediaPlaybackResourceRenderer.test.tsx` 使用真实 en-US i18n resource 回归,验证 Media playback、WebView native audio controls、This environment does not support audio playback.、This environment does not support video playback.、Video cannot be played yet 与播放失败说明来自 `workspace` namespace,并断言不泄露“媒体播放 / WebView 原生音频控制 / 视频暂时无法播放”等中文 fallback,同时保留 runtime 标题在播放失败后不进入空态的行为边界;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `workspace.resourceManager.media.titleFallback` 与 `workspace.resourceManager.media.error.description`。`npm test -- "src/features/resource-manager/MediaPlaybackResourceRenderer.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、7 个用例;`npx eslint "src/features/resource-manager/MediaPlaybackResourceRenderer.tsx" "src/features/resource-manager/MediaPlaybackResourceRenderer.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0`、`npm run detect-translations -- --verbose`、5 个 `workspace.json` 资源 JSON 解析、7 个 `workspace.resourceManager.media.*` key 的 5 locale 存在性检查、MediaPlaybackResourceRenderer source 硬编码中文 / defaultValue / fallback 扫描与定向 `git diff --check` 通过,当前资源口径为 5 个 locale、6 个 namespace、5008 个 source key。本条未启动 `npm run typecheck` / `npm run verify:local` / GUI smoke;执行时仍有 `npm run typecheck` / `tsc --noEmit`、`cargo check` / `cargo test` / `rustc`、headless Tauri、Vite 与多条 Codex owner 在活动,避免抢占并行资源;本条不触碰 App shell/sidebar/navigation、Settings 页面、Agent Chat、Artifact、Skills、ProjectSelector、Provider ImportExportDialog、Rust provider / model resources 或 Browser Runtime 长尾。 + +233. 2026-05-11 Agent Chat HarnessVerificationSummarySection 验证结果展示 source fallback 与 locale-aware 数字收口续测:`src/components/agent/chat/components/HarnessVerificationSummarySection.tsx` 接入 `useTranslation("agent")`,`src/lib/agentRuntime/harnessVerificationPresentation.ts` 改为必须传入 agent namespace translator 与 locale,新增 5 locale `agentChat.harnessVerification.*` 20 个正式资源,把验证结果 section 标题、失败焦点 / 已恢复结果标题、outcome badge、Artifact 校验 / Browser 验证 / GUI Smoke 卡片标题、空态、统计描述与通过 / 失败 / 未知状态从硬编码中文与函数内英文 fallback 改为纯 key-based 读取,并用 `formatNumber` 处理 record / issue / repaired / fallback / success / failed / unknown / exit code;focus outcome 文本、GUI smoke status、底层 evidence summary 继续作为 runtime / API 数据保留。新增 `src/components/agent/chat/components/HarnessVerificationSummarySection.test.tsx` 使用真实 en-US i18n resource 回归,验证 Verification Results、Artifact Validation、Blocking failure、Records 1 · issues 2 · repaired 1 · fallback 0、Recovered、Status failed · exit Unknown · Failed、Verification failure focus 与 Recovered results 来自 `agent` namespace,并断言不泄露“验证结果 / 验证失败焦点”等中文 fallback;`src/lib/agentRuntime/harnessVerificationPresentation.test.ts` 改为真实 en-US resource 断言 outcome badge、三类卡片描述与缺失 / 不适用兜底;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `agentChat.harnessVerification.section.title` 与 `agentChat.harnessVerification.artifact.description`。`npm test -- "src/components/agent/chat/components/CanvasSessionOverviewPanel.test.tsx" "src/components/agent/chat/components/SearchResultPreviewList.test.tsx" "src/components/agent/chat/components/HarnessVerificationSummarySection.test.tsx" "src/lib/agentRuntime/harnessVerificationPresentation.test.ts" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 6 个文件、12 个用例;`npx eslint "src/components/agent/chat/components/HarnessVerificationSummarySection.tsx" "src/components/agent/chat/components/HarnessVerificationSummarySection.test.tsx" "src/lib/agentRuntime/harnessVerificationPresentation.ts" "src/lib/agentRuntime/harnessVerificationPresentation.test.ts" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0`、`npm run detect-translations -- --verbose`、5 个 `agent.json` 资源 JSON 解析、20 个 `agentChat.harnessVerification.*` key 的 5 locale 存在性检查、HarnessVerificationSummarySection / harnessVerificationPresentation source 硬编码中文 / defaultValue / `toLocale*` 扫描与定向 `git diff --check` 通过,当前资源口径为 5 个 locale、6 个 namespace、5008 个 source key(含并行资源增量)。本条曾尝试 `npm run typecheck`,先暴露本刀 `TS1016` 参数顺序问题,已改为显式 `AgentRuntimeEvidenceVerificationOutcome | undefined` 后复跑;最终 typecheck 不再报告 HarnessVerification 相关错误,但仍受并行 `src/components/skills/SkillCard.tsx` 与 `src/components/skills/SkillsPage.tsx` typed key / unknown 类型错误阻断,未作为本条绿色证据;本条未启动 `npm run verify:local` / GUI smoke,当前仍有 Rust `cargo test` / headless Tauri / Vite 与多条并行 owner 在活动,最终合并前需基于最新 diff 复跑统一入口;本条不触碰 OpenClaw、Settings automation / web-search / ProviderSetting、Skills、Artifact、Rust provider / model resources 或 Browser Runtime 长尾。 + +234. 2026-05-11 Agent Chat AgentIncidentPanel 活跃 incident 面板 source fallback 收口续测:`src/components/agent/chat/components/AgentIncidentPanel.tsx` 接入 `useTranslation("agent")`,新增 5 locale `agentChat.incidentPanel.*` 2 个正式资源,把空态“当前未发现活跃 incident”与 severity badge 的“优先级”后缀从硬编码中文改为 key-based 读取;incident title / detail / statusLabel / severityLabel 与 tone 继续作为 runtime / API 数据保留,并通过 interpolation 注入 severity。`src/components/agent/chat/components/AgentIncidentPanel.test.tsx` 改用真实 en-US i18n resource 回归,验证 No active incidents detected 与 High priority 来自 `agent` namespace,标题、说明和状态继续作为动态 incident 数据展示,并断言不泄露“当前未发现活跃 incident / 优先级”等中文 fallback;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `agentChat.incidentPanel.empty` 与 `agentChat.incidentPanel.priorityBadge`。`npm test -- "src/components/agent/chat/components/AgentIncidentPanel.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、7 个用例;`npx eslint "src/components/agent/chat/components/AgentIncidentPanel.tsx" "src/components/agent/chat/components/AgentIncidentPanel.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0`、`npm run detect-translations -- --verbose`、5 个 `agent.json` 资源 JSON 解析、2 个 `agentChat.incidentPanel.*` key 的 5 locale 存在性检查、AgentIncidentPanel source 硬编码中文 / defaultValue / `toLocale*` 扫描与定向 `git diff --check` 通过,当前资源口径为 5 个 locale、6 个 namespace、5010 个 source key。本条未启动 `npm run typecheck` / `npm run verify:local` / GUI smoke;执行时已有另一条 `npm run typecheck -- --pretty false` / `tsc --noEmit` 与 Rust `cargo test` owner 在活动,避免抢占并行资源;本条不触碰 OpenClaw、Settings automation / web-search / ProviderSetting、Skills、Artifact、Rust provider / model resources、Resource Manager 或 Browser Runtime 长尾。 + 仍未完成: -1. `common` namespace 已覆盖 App 根壳层、UpdateNotification 独立更新提醒窗口、SmartInput 独立悬浮输入窗、LatestRunStatusBadge 执行状态徽章、项目 / 工作区切换器、新建项目弹窗、useProjects 重命名空名称错误、Deep Link 邀请 / 登录 / 支付 toast、App Startup Windows 启动自检 toast,以及 OEM Cloud Access 登录 / OAuth / 支付反馈 / 会话刷新退出 / 邮箱验证码 / 密码登录 / Provider 默认服务 / API Key / 状态标签 / 浏览器预打开页与外部浏览器错误反馈,以及 OEM Lime Hub provider sync 托管 Key 名称;`navigation` namespace 已覆盖侧边栏主入口、搜索弹窗、会话列表起步路径、会话 meta 格式化、会话重命名 / 删除的 prompt / confirm / toast、账号菜单核心未登录 / 已登录操作文案、邀请入口 / 邀请弹窗 / 复制邀请 toast,以及外观快捷面板;账号套餐 usage 已确认属于云端运行时字段边界,不再作为侧栏静态 i18n 缺口;`workspace` namespace 已覆盖图片素材 `ImageGallery`、Runtime AGENTS 引导 toast、视频 PromptInput、视频 VideoCanvas 插图反馈 / 侧栏按钮、视频 VideoSidebar 参数栏概览 / 创作 Tips / 参考图上传区 / 基础控制区 / 模型面板、视频 VideoWorkspace 首屏 / 摘要卡 / 最近任务标题 / 预览操作 / 任务状态 / 同步提示 / 会话主预览 / 聚焦任务面板 / 生成入口反馈 / 参考图处理、Browser Runtime 工作台外壳、环境预设子面板、资料管理子面板、Debug 面板外壳 / 状态 / 最小控制起步路径、Debug 审计与附着 Chrome 子面板、Debug CDP 托管会话高级区、existing session hook feedback / presentation 与 Site Adapter 面板 chrome / 执行反馈,`agent` namespace 已覆盖 Skills / CuratedTask / 能力草案等起步路径,`errors` namespace 已覆盖 Crash Recovery 外壳与 crashDiagnostic 共享诊断摘要 / 复制失败 / 下载目录 fallback;Workspace 其他媒体 / Browser Runtime 其他子面板 / 工作台路径长尾与 Agent Chat 更深路径仍需 P1/P2 逐步迁移;Settings 共享壳层(侧边栏浮动导航 chrome 已在记录 144 收口)、home、developer-lab、system/developer 壳层与目录联调懒加载工具、system/shared Clipboard 权限指引、CrashRecoveryPanel 恢复模式外壳、system/environment 主体、system/experimental 主体(更新检查子面板已收口)、system/channels 工作台壳层、网关与隧道表单、Telegram / Feishu / 微信运行控制、微信兼容扫码排障与日志 Tail 面板、system/chrome-relay 独立连接引导窗口 / 主设置页核心首屏 / 高级工具起步区 / 高级工具连接方式卡 / 高级工具系统连接器卡 / 浏览器动作配置面板 / 后端策略详情面板 / 扩展桥接详情面板、system/shared Workspace 自愈记录共享卡片、system/automation 当前焦点条 / 概览焦点卡 / HealthPanel / 主页面壳层与调度器设置 / 开始模板与任务列表 / 运行历史区、general/memory 首屏与偏好画像 / 来源链状态与策略长尾 / memdir 写入与自动索引长尾、general/hotkeys、account/profile/stats/user-center-session、about(appearance 主体已在记录 143 收口)、agent/skills 高级入口、agent/image-gen、agent/video-gen、agent/media-services、agent/voice、agent/providers 的顶部切换器 / 云端反馈 / 左侧启用模型列表 / 导入导出弹窗 / 详情配置外壳入口态 / 详情配置主体静态表单 / 删除确认与连接测试反馈 / 接口获取模型动态反馈 / 添加流程静态 chrome / 添加流程校验错误与动态反馈 / 添加流程目录辅助文案 / 添加流程内置模板名称与描述 / 添加流程 provider type label / 添加流程接口获取 helper 调用面审计 / 添加流程 runtime 数据边界审计 / 能力草案面板 / Workspace 已注册能力面板与 Agent envelope / Managed Job presentation / 桌宠能力偏好卡 / Companion Bridge 诊断区已完成起步迁移,CuratedTask 模板定义与 Agent Chat / Skills / Home 结果模板主调用面已完成起步迁移;Agent Chat 输入能力区 section heading / banner / helper chrome、Skills 执行对话框 chrome、Skill 脚手架启动弹窗 chrome、Browser Runtime existing session hook feedback / presentation、Browser Runtime Debug 审计与附着 Chrome 子面板、Browser Runtime Debug CDP 托管会话高级区、Browser Site Adapter 面板 chrome / 执行反馈、SceneApp 最近判断建议横幅 runtime suggestion 与 General Workbench 最近判断建议横幅 runtime suggestion 已完成起步迁移;其他启动弹窗 runtime suggestion、其他模型执行提示正文和运行时 suggestion 长尾仍需继续;Workspace Skill runtime enable 首发提示正文已在条目 102 收口,Skills 执行对话框 chrome 已在条目 103 收口,Skill 脚手架启动弹窗 chrome 已在条目 105 收口,Browser Runtime existing session hook feedback 已在条目 106 收口,Browser Runtime existing session presentation 已在条目 112 收口,SceneApp 最近判断建议横幅 runtime suggestion 已在条目 107 收口,Browser Site Adapter 面板 chrome / 执行反馈已在条目 108 收口,General Workbench 最近判断建议横幅 runtime suggestion 已在条目 109 收口,Browser Runtime Debug 审计与附着 Chrome 子面板已在条目 110 收口,Browser Runtime Debug CDP 托管会话高级区已在条目 111 收口。 +1. `common` namespace 已覆盖 App 根壳层、SplashScreen 启动画面(source fallback 已在记录 229 二次收口)、StartupLoadingScreen 启动加载屏(source fallback 已在记录 226 二次收口)、ConfirmDialog 共享确认弹窗(source fallback 已在记录 228 二次收口)、UpdateNotification 独立更新提醒窗口(source fallback 已在记录 218 二次收口)、SmartInput 独立悬浮输入窗(source fallback 已在记录 219 二次收口)、ShortcutSettings 共享快捷键设置组件(source fallback 已在记录 224 二次收口)、LatestRunStatusBadge 执行状态徽章(source fallback 已在记录 218 二次收口)、项目 / 工作区切换器(ProjectSelector source fallback 已在记录 216 二次收口)、新建项目弹窗、useProjects 重命名空名称错误、Deep Link 邀请 / 登录 / 支付 toast、App Startup Windows 启动自检 toast,以及 OEM Cloud Access 登录 / OAuth / 支付反馈 / 会话刷新退出 / 邮箱验证码 / 密码登录 / Provider 默认服务 / API Key / 状态标签 / 浏览器预打开页与外部浏览器错误反馈,以及 OEM Lime Hub provider sync 托管 Key 名称;`navigation` namespace 已覆盖侧边栏主入口、搜索弹窗、会话列表起步路径、会话 meta 格式化、会话重命名 / 删除的 prompt / confirm / toast、账号菜单核心未登录 / 已登录操作文案、邀请入口 / 邀请弹窗 / 复制邀请 toast,以及外观快捷面板;账号套餐 usage 已确认属于云端运行时字段边界,不再作为侧栏静态 i18n 缺口;`workspace` namespace 已覆盖 ArtifactToolbar 工具栏 chrome、ArtifactRenderer 统一入口空内容态 / 流式状态 / 未知类型回退、CanvasAdapter 图层设计预览 chrome、CanvasBreadcrumbHeader 返回按钮(source fallback 已在记录 230 收口)、Resource Manager PDF 预览器(source fallback 已在记录 231 收口)、Resource Manager 媒体播放预览器(source fallback 已在记录 232 收口)、DocumentRenderer 文档工具栏 chrome、ArtifactDocumentRenderer 结构化阅读面 chrome / block fallback、BrowserAssistRenderer 状态说明 / browserActionIndex 复盘、A2UI 提交完成提示 / 待处理表单状态 chrome、图片素材 `ImageGallery`、Runtime AGENTS 引导 toast、视频 PromptInput(source fallback 已在记录 218 二次收口)、视频 VideoCanvas 插图反馈 / 侧栏按钮、视频 VideoSidebar 参数栏概览 / 创作 Tips / 参考图上传区 / 基础控制区 / 模型面板、视频 VideoWorkspace 首屏 / 摘要卡 / 最近任务标题 / 预览操作 / 任务状态 / 同步提示 / 会话主预览 / 聚焦任务面板 / 生成入口反馈 / 参考图处理、Browser Runtime 工作台外壳、环境预设子面板、资料管理子面板、Debug 面板外壳 / 状态 / 最小控制起步路径、Debug 审计与附着 Chrome 子面板、Debug CDP 托管会话高级区、existing session hook feedback / presentation 与 Site Adapter 面板 chrome / 执行反馈,`agent` namespace 已覆盖 Skills / CuratedTask / 能力草案等起步路径、Skills 仓库管理面板(已在记录 210 收口)、SkillCard 卡片 chrome(已在记录 212 收口)、内容检查弹窗(已在记录 213 收口)、WorkflowProgress 进度组件(已在记录 215 收口)与 SkillsPage 高级入口(已在记录 217 收口),`errors` namespace 已覆盖 Crash Recovery 外壳、Artifact ErrorFallbackRenderer 错误回退面板、Artifact SvgRenderer 预览控制 / 错误态、Artifact CodeRenderer 代码工具栏 / HTML 预览控制 / 错误态、Artifact HtmlRenderer 预览控制 / 错误态、Artifact ReactRenderer 预览 chrome / 编译错误态、Artifact MermaidRenderer 预览控制 / 导出控制 / 错误态与 crashDiagnostic 共享诊断摘要 / 复制失败 / 下载目录 fallback;Workspace 其他媒体 / Browser Runtime 其他子面板 / 工作台路径长尾与 Agent Chat 更深路径仍需 P1/P2 逐步迁移;Settings 共享壳层(侧边栏浮动导航 chrome 已在记录 144 收口,useSettingsCategory 导航分组 fallback 已在记录 187 收口,主布局 / Sidebar 壳层 fallback 已在记录 196 收口)、home(首屏总览 fallback 已在记录 197 收口)、developer-lab(合并入口 fallback 已在记录 188 收口)、system/developer(壳层与目录联调 fallback 已在记录 204 收口)、system/shared Clipboard 权限指引(ClipboardPermissionGuideCard fallback 已在记录 193 收口)、CrashRecoveryPanel 恢复模式外壳(已在记录 205 二次收口 UI fallback 与真实资源回归)、system/environment(主体 fallback 已在记录 202 收口)、system/experimental(主体 fallback 已在记录 201 收口,更新检查子面板二次 fallback 已在记录 208 收口)、system/channels 工作台壳层、网关与隧道表单、Telegram / Feishu / 微信运行控制、微信兼容扫码排障(日志 Tail 面板 defaultValue fallback 已在记录 186 收口)、system/chrome-relay 独立连接引导窗口(已在记录 200 收口) / 主设置页全量 fallback(已在记录 203 收口)、system/shared Workspace 自愈记录共享卡片(WorkspaceRepairHistoryCard fallback 已在记录 195 收口)、system/automation 当前焦点条(AutomationJobFocusStrip fallback 已在记录 189 收口) / 概览焦点卡(AutomationOverviewFocusCard fallback 已在记录 191 收口) / HealthPanel(已在记录 192 收口) / AutomationJobDialog 弹窗(已在记录 211 收口) / AutomationJobDetailsDialog 详情弹窗与内嵌运行历史(已在记录 214 收口) / 主页面壳层与调度器设置 / 开始模板与任务列表 / 主页面运行历史区、general/memory(记忆总页 fallback 已在记录 209 收口)、general/hotkeys(已审计快捷键页 fallback 已在记录 199 收口)、account/profile、account/stats(统计页 defaultValue fallback 已在记录 184 收口,仍待更广设置页统一门禁)、user-center-session(已在记录 194 收口)、about(版本与更新区 fallback 已在记录 198 收口)、agent/skills 高级入口(SkillsPage 已在记录 217 收口,SkillsWorkspacePage / service-skill 长尾仍待继续)、agent/image-gen(媒体生成默认模型 fallback 已在记录 190 收口)、agent/video-gen(媒体生成默认模型 fallback 已在记录 190 收口)、agent/media-services(服务模型总页 fallback 已在记录 206 收口)、agent/voice(语音设置页 fallback 已在记录 207 收口)、agent/providers 的顶部切换器 / 云端反馈 / 左侧启用模型列表 / 导入导出弹窗(ImportExportDialog source fallback 已在记录 223 二次收口) / 详情配置外壳入口态 / 详情配置主体静态表单 / 删除确认与连接测试反馈 / 接口获取模型动态反馈 / 添加流程静态 chrome / 添加流程校验错误与动态反馈 / 添加流程目录辅助文案 / 添加流程内置模板名称与描述 / 添加流程 provider type label / 添加流程接口获取 helper 调用面审计 / 添加流程 runtime 数据边界审计 / 能力草案面板(CapabilityDraftPanel source fallback 已在记录 221 二次收口) / Workspace 已注册能力面板与 Agent envelope / Managed Job presentation / 桌宠能力偏好卡 / Companion Bridge 诊断区已完成起步迁移,CuratedTask 模板定义与 Agent Chat / Skills / Home 结果模板主调用面已完成起步迁移;Agent Chat 会话过程索引面板(CanvasSessionOverviewPanel 已在记录 225 收口)、搜索结果预览列表(SearchResultPreviewList 已在记录 227 收口)、验证结果展示(HarnessVerificationSummarySection 已在记录 233 收口)、活跃 incident 面板(AgentIncidentPanel 已在记录 234 收口)、Agent Chat 输入能力区 section heading / banner / helper chrome、Skills 执行对话框 chrome / source fallback(已在记录 222 二次收口)、Skill 脚手架启动弹窗 chrome / source fallback(已在记录 220 二次收口)、Browser Runtime existing session hook feedback / presentation、Browser Runtime Debug 审计与附着 Chrome 子面板、Browser Runtime Debug CDP 托管会话高级区、Browser Site Adapter 面板 chrome / 执行反馈、SceneApp 最近判断建议横幅 runtime suggestion 与 General Workbench 最近判断建议横幅 runtime suggestion 已完成起步迁移;其他启动弹窗 runtime suggestion、其他模型执行提示正文和运行时 suggestion 长尾仍需继续;Workspace Skill runtime enable 首发提示正文已在条目 102 收口,Skills 执行对话框 chrome 已在条目 103 收口,source fallback 已在记录 222 二次收口,Skill 脚手架启动弹窗 chrome 已在条目 105 收口,source fallback 已在记录 220 二次收口,Browser Runtime existing session hook feedback 已在条目 106 收口,Browser Runtime existing session presentation 已在条目 112 收口,SceneApp 最近判断建议横幅 runtime suggestion 已在条目 107 收口,Browser Site Adapter 面板 chrome / 执行反馈已在条目 108 收口,General Workbench 最近判断建议横幅 runtime suggestion 已在条目 109 收口,Browser Runtime Debug 审计与附着 Chrome 子面板已在条目 110 收口,Browser Runtime Debug CDP 托管会话高级区已在条目 111 收口。 2. `legacy-patch/` 已完成物理隔离,并已有运行时命中量指标 API、GUI smoke metrics 导出链与离线 text / JSON 报告脚本;但 CI 尚未把 `i18n:patch-report --check` 接入删除门禁,也尚未把 `no-hit` 报告和 current 主路径依赖审计绑定为 DOM replacer 删除条件。退出条件仍是 P3 后按命中量和 current 主路径依赖清零逐步拆除。 3. 类型绑定已覆盖当前 source resource,`AGENTS.md` / 质量工作流也已封住新增功能必须走 current i18n 的人工规则;但尚未接入官方 `i18next-cli` 的抽取 / hardcoded string lint / type generation,也尚未评估 selector API 或 `@i18next-selector/codemod` 是否适合资源规模扩大后的 P3 阶段。 -4. 记录 105 已取得 `npm run verify:local` 与 GUI smoke 通过证据(日志 `.lime/qc/verify-local-2026-05-11-i18n-managed-job-initial-values.log`),并覆盖此前 Agent streaming runtime blocker;记录 125 已复跑 `npm run verify:local`,并通过 `verify:app-version`、`lint`、`typecheck`、全量 smart vitest、`test:contracts` 与 GUI smoke 的 workspace-ready / browser-runtime / site-adapters / agent-service-skill-entry / agent-runtime-tool-surface / agent-runtime-tool-surface-page 阶段;但 `smoke:claw-chat-ready-streaming` 仍以 `长 turn 在首个流式增量前结束: status=completed` 失败,单独复跑同样失败,因此记录 109 / 110 / 111 / 112 / 113 / 114 / 115 / 116 / 117 / 118 / 119 / 120 / 121 / 122 / 123 / 124 / 125 之后仍没有完整绿色统一入口;最新工作区涉及 CuratedTask 模板定义 / Agent Chat 输入能力区、Skills runtime enable 首发提示正文、Skills 执行对话框 chrome、Skill 脚手架启动弹窗 chrome、SceneApp / General Workbench 最近判断建议横幅 runtime suggestion / 执行摘要卡、Workspace 图片素材、Browser Runtime 工作台外壳、环境预设子面板、资料管理子面板、Debug 面板外壳、Debug 审计与附着 Chrome 子面板、Debug CDP 托管会话高级区、existing session hook feedback / presentation 与 Site Adapter 面板迁移,本轮主要取得定向测试、eslint、i18n、JSON 解析、typecheck、contracts 与大部分 GUI smoke 证据;记录 117 已补齐 `workspace.browserRuntimeDebug.*` typed resource 并重新取得 `npm run typecheck` 通过证据,记录 118 初次 typecheck 阻断已由记录 119 的 `workspace.browserExistingSession.feedback.*` 资源补齐与 `npm run typecheck` 通过证据覆盖,记录 120 的 `npm run typecheck` 被并行 Browser Site Adapter `workspace.browserSiteAdapter.*` typed key / defaultValue 错误阻断,该阻断已由记录 121 的 `workspace.browserSiteAdapter.*` 资源补齐与 `npm run typecheck` 通过证据覆盖;记录 122 的 `npm run typecheck` 被并行 `src/features/browser-runtime/BrowserRuntimeDebugPanel.tsx(582,33)` 的 `TS2554` 阻断,未作为 General Workbench 本刀通过证据;记录 123 已由 Browser Runtime Debug 审计与附着 Chrome 子面板迁移重新取得 `npm run typecheck` 通过证据;记录 124 继续取得 Browser Runtime Debug CDP 托管会话高级区的 `npm run typecheck` 通过证据;记录 131 提到的 `ExistingSessionAttachPanelCopy.presentation` 缺失已由记录 132 的 Browser Runtime existing session presentation 迁移与 `npm run typecheck` 通过证据覆盖;本轮存在隔壁 qcloop / P0 smoke 进程,未启动 `npm run verify:local` / GUI smoke,最终合并前应基于最新 diff 复跑统一入口。`ChannelLogTailPanel.test.tsx` 的 `Timeout` 类型不匹配已在记录 49 收口,当前不再作为 i18n PRD 的 typecheck blocker;记录 50 / 51 / 52 / 53 / 54 / 55 / 56 / 57 / 58 的 typecheck 因并行重验证负载或本轮未单独启动而未取得通过证据,后续收口时需复跑;记录 59 / 60 / 61 已重新取得 `npm run typecheck` 通过证据;记录 65 / 66 因隔壁 `verify:local` 长跑占用未重复启动 typecheck;记录 67 / 68 已重新取得 `npm run typecheck` 通过证据;记录 69 / 71 / 72 / 73 / 74 未单独启动 typecheck / verify:local;记录 75 已尝试 `npm run typecheck` 但当时被 AutomationJobDialog 并行脏改阻塞,且隔壁 `verify:local` 在 `smoke:claw-chat-ready-streaming` 超时失败;记录 76 已重新取得 `npm run typecheck` 通过证据;记录 77 已尝试 `npm run typecheck` 但被 Memory 来源链并行脏改阻塞;记录 78 / 79 / 80 / 81 / 82 / 83 / 84 / 85 / 86 / 87 / 88 / 89 / 90 / 91 已重新取得 `npm run typecheck` 通过证据;记录 92 / 93 因并行改动下的 `MemoryPage.test.tsx` / `WorkspaceRegisteredSkillsPanel.tsx` 类型 blocker 未取得本轮 typecheck 通过证据;记录 94 / 95 / 96 / 97 / 98 / 99 / 101 / 103 / 105 已重新取得 `npm run typecheck` 通过证据;记录 70 已重新取得 `npm run typecheck` 通过证据。 +4. 记录 105 已取得 `npm run verify:local` 与 GUI smoke 通过证据(日志 `.lime/qc/verify-local-2026-05-11-i18n-managed-job-initial-values.log`),并覆盖此前 Agent streaming runtime blocker;记录 125 已复跑 `npm run verify:local`,并通过 `verify:app-version`、`lint`、`typecheck`、全量 smart vitest、`test:contracts` 与 GUI smoke 的 workspace-ready / browser-runtime / site-adapters / agent-service-skill-entry / agent-runtime-tool-surface / agent-runtime-tool-surface-page 阶段;但 `smoke:claw-chat-ready-streaming` 仍以 `长 turn 在首个流式增量前结束: status=completed` 失败,单独复跑同样失败,因此记录 109 / 110 / 111 / 112 / 113 / 114 / 115 / 116 / 117 / 118 / 119 / 120 / 121 / 122 / 123 / 124 / 125 之后仍没有完整绿色统一入口;最新工作区涉及 CuratedTask 模板定义 / Agent Chat 输入能力区、Skills runtime enable 首发提示正文、Skills 执行对话框 chrome、Skill 脚手架启动弹窗 chrome、SceneApp / General Workbench 最近判断建议横幅 runtime suggestion / 执行摘要卡、Workspace 图片素材、Browser Runtime 工作台外壳、环境预设子面板、资料管理子面板、Debug 面板外壳、Debug 审计与附着 Chrome 子面板、Debug CDP 托管会话高级区、existing session hook feedback / presentation 与 Site Adapter 面板迁移,本轮主要取得定向测试、eslint、i18n、JSON 解析、typecheck、contracts 与大部分 GUI smoke 证据;记录 117 已补齐 `workspace.browserRuntimeDebug.*` typed resource 并重新取得 `npm run typecheck` 通过证据,记录 118 初次 typecheck 阻断已由记录 119 的 `workspace.browserExistingSession.feedback.*` 资源补齐与 `npm run typecheck` 通过证据覆盖,记录 120 的 `npm run typecheck` 被并行 Browser Site Adapter `workspace.browserSiteAdapter.*` typed key / defaultValue 错误阻断,该阻断已由记录 121 的 `workspace.browserSiteAdapter.*` 资源补齐与 `npm run typecheck` 通过证据覆盖;记录 122 的 `npm run typecheck` 被并行 `src/features/browser-runtime/BrowserRuntimeDebugPanel.tsx(582,33)` 的 `TS2554` 阻断,未作为 General Workbench 本刀通过证据;记录 123 已由 Browser Runtime Debug 审计与附着 Chrome 子面板迁移重新取得 `npm run typecheck` 通过证据;记录 124 继续取得 Browser Runtime Debug CDP 托管会话高级区的 `npm run typecheck` 通过证据;记录 131 提到的 `ExistingSessionAttachPanelCopy.presentation` 缺失已由记录 132 的 Browser Runtime existing session presentation 迁移与 `npm run typecheck` 通过证据覆盖;本轮存在隔壁 qcloop / P0 smoke 进程,未启动 `npm run verify:local` / GUI smoke,最终合并前应基于最新 diff 复跑统一入口。`ChannelLogTailPanel.test.tsx` 的 `Timeout` 类型不匹配已在记录 49 收口,当前不再作为 i18n PRD 的 typecheck blocker;记录 50 / 51 / 52 / 53 / 54 / 55 / 56 / 57 / 58 的 typecheck 因并行重验证负载或本轮未单独启动而未取得通过证据,后续收口时需复跑;记录 59 / 60 / 61 已重新取得 `npm run typecheck` 通过证据;记录 65 / 66 因隔壁 `verify:local` 长跑占用未重复启动 typecheck;记录 67 / 68 已重新取得 `npm run typecheck` 通过证据;记录 69 / 71 / 72 / 73 / 74 未单独启动 typecheck / verify:local;记录 75 已尝试 `npm run typecheck` 但当时被 AutomationJobDialog 并行脏改阻塞,且隔壁 `verify:local` 在 `smoke:claw-chat-ready-streaming` 超时失败;记录 76 已重新取得 `npm run typecheck` 通过证据;记录 77 已尝试 `npm run typecheck` 但被 Memory 来源链并行脏改阻塞;记录 78 / 79 / 80 / 81 / 82 / 83 / 84 / 85 / 86 / 87 / 88 / 89 / 90 / 91 已重新取得 `npm run typecheck` 通过证据;记录 92 / 93 因并行改动下的 `MemoryPage.test.tsx` / `WorkspaceRegisteredSkillsPanel.tsx` 类型 blocker 未取得本轮 typecheck 通过证据;记录 94 / 95 / 96 / 97 / 98 / 99 / 101 / 103 / 105 已重新取得 `npm run typecheck` 通过证据;记录 70 已重新取得 `npm run typecheck` 通过证据;记录 173 已重新取得 `npm run typecheck` 通过证据,并覆盖记录 172 提到的 ArtifactToolbar typed key / children blocker;记录 174 已重新取得 `npm run typecheck` 通过证据;记录 176 / 177 / 178 已重新取得 `npm run typecheck` 通过证据;记录 179 的 `npm run typecheck` 被并行 `src/components/AppSidebar.test.tsx` / `src/components/settings-v2/account/profile/index.tsx` 脏改阻断,未作为本条通过证据;记录 180 / 181 / 182 已重新取得 `npm run typecheck` 通过证据。 diff --git a/eslint.config.js b/eslint.config.js index 5f77e32c6..538cdcae3 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -589,25 +589,6 @@ const fileSystemCommandSelectors = [ "文件打开/定位相关后端命令请统一通过 `src/lib/api/fileSystem.ts` 暴露的网关函数调用,避免在 Hook / 组件中继续直接拼接命令名。", })); -const pluginGatewayCommandSelectors = [ - "get_plugin_status", - "get_plugins", - "list_installed_plugins", - "list_plugin_tasks", - "get_plugin_queue_stats", - "get_plugin_task", - "enable_plugin", - "disable_plugin", - "reload_plugins", - "unload_plugin", - "uninstall_plugin", - "cancel_plugin_task", -].map((command) => ({ - selector: `CallExpression[callee.name='safeInvoke'][arguments.0.value='${command}'], CallExpression[callee.name='invoke'][arguments.0.value='${command}']`, - message: - "插件运行态/管理相关后端命令请统一通过 `src/lib/api/plugins.ts` 暴露的网关函数调用,避免在 Hook / 组件中继续直接拼接命令名。", -})); - const fileBrowserCommandSelectors = [ "list_dir", "read_file_preview_cmd", @@ -1121,7 +1102,6 @@ export default [ ...brandPersonaGatewayCommandSelectors, ...posterMaterialGatewayCommandSelectors, ...fileSystemCommandSelectors, - ...pluginGatewayCommandSelectors, ...fileBrowserCommandSelectors, ...appUpdateCommandSelectors, ...screenshotChatCommandSelectors, @@ -1203,8 +1183,6 @@ export default [ "src/lib/api/subAgentScheduler.ts", "src/lib/api/fileSystem.ts", "src/lib/api/memory.ts", - "src/lib/api/plugins.ts", - "src/lib/api/pluginUI.ts", "src/lib/api/fileBrowser.ts", "src/lib/api/a2uiForm.ts", "src/lib/api/appUpdate.ts", diff --git a/package-lock.json b/package-lock.json index 57df91766..f7add031a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "lime", - "version": "1.34.0", + "version": "1.35.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lime", - "version": "1.34.0", + "version": "1.35.0", "license": "GPL-3.0-only", "dependencies": { "@babel/standalone": "^7.29.0", diff --git a/package.json b/package.json index af4d656e4..fa637dbf8 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "lime", "private": true, - "version": "1.34.0", + "version": "1.35.0", "description": "AI content workspace for Chinese creators: desktop writing, research, prompt management, knowledge base, and multi-model workflows.", "keywords": [ "ai", diff --git a/packages/lime-cli-npm/package.json b/packages/lime-cli-npm/package.json index d4d6f2897..6b4c75f36 100644 --- a/packages/lime-cli-npm/package.json +++ b/packages/lime-cli-npm/package.json @@ -1,6 +1,6 @@ { "name": "@limecloud/lime-cli", - "version": "1.34.0", + "version": "1.35.0", "description": "Lime 官方任务 CLI", "bin": { "lime": "scripts/run.js" diff --git a/plugins/README.md b/plugins/README.md deleted file mode 100644 index eee16a2fd..000000000 --- a/plugins/README.md +++ /dev/null @@ -1,74 +0,0 @@ -# Lime 插件目录 - -此目录包含 Lime 的官方插件源代码。 - -## 目录结构 - -``` -plugins/ -├── README.md # 本文件 -├── machine-id-tool/ # Machine ID 管理工具插件 -│ └── plugin.json # 插件清单文件 -└── ... # 其他插件 -``` - -## 插件清单格式 (plugin.json) - -每个插件必须包含一个 `plugin.json` 清单文件,定义插件的元数据: - -```json -{ - "name": "plugin-name", - "version": "0.1.0", - "description": "插件描述", - "author": "作者名", - "homepage": "https://github.com/...", - "license": "MIT", - "plugin_type": "binary", - "entry": "plugin-name", - "hooks": [], - "min_lime_version": "1.0.0", - "binary": { - "binary_name": "plugin-name", - "github_owner": "owner", - "github_repo": "repo", - "platform_binaries": { - "macos-arm64": "binary-aarch64-apple-darwin", - "macos-x64": "binary-x86_64-apple-darwin", - "linux-x64": "binary-x86_64-unknown-linux-gnu", - "linux-arm64": "binary-aarch64-unknown-linux-gnu", - "windows-x64": "binary-x86_64-pc-windows-msvc.exe" - } - }, - "ui": { - "surfaces": ["main"], - "icon": "icon-name", - "title": "插件标题" - } -} -``` - -## 构建插件包 - -使用构建脚本将插件打包为 zip 格式: - -```bash -# 打包指定插件 -./scripts/build-plugin.sh machine-id-tool - -# 指定版本号 -./scripts/build-plugin.sh machine-id-tool 0.2.0 -``` - -输出文件将保存在 `dist/plugins/` 目录下。 - -## 插件类型 - -- `script`: 脚本插件(JSON 配置驱动) -- `native`: 原生 Rust 插件(预留) -- `binary`: 二进制可执行文件插件 - -## 相关文档 - -- [插件安装机制设计文档](.kiro/specs/plugin-installation/design.md) -- [插件系统 README](src-tauri/src/plugin/README.md) diff --git a/scripts/build-plugin.sh b/scripts/build-plugin.sh deleted file mode 100755 index f6891858b..000000000 --- a/scripts/build-plugin.sh +++ /dev/null @@ -1,147 +0,0 @@ -#!/bin/bash -# 插件包构建脚本 -# 用法: ./scripts/build-plugin.sh [version] -# 示例: ./scripts/build-plugin.sh machine-id-tool 0.1.0 -# -# 此脚本将插件目录打包为 zip 格式,用于分发和安装 -# _需求: 6.1_ - -set -e - -# 颜色定义 -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -NC='\033[0m' # No Color - -# 打印带颜色的消息 -info() { - echo -e "${GREEN}[INFO]${NC} $1" -} - -warn() { - echo -e "${YELLOW}[WARN]${NC} $1" -} - -error() { - echo -e "${RED}[ERROR]${NC} $1" - exit 1 -} - -# 检查参数 -if [ -z "$1" ]; then - echo "用法: $0 [version]" - echo "示例: $0 machine-id-tool 0.1.0" - echo "" - echo "参数:" - echo " plugin_name 插件名称(对应 plugins/ 目录下的文件夹名)" - echo " version 可选,覆盖 plugin.json 中的版本号" - exit 1 -fi - -PLUGIN_NAME="$1" -VERSION_OVERRIDE="$2" - -# 获取脚本和项目目录 -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" -PLUGINS_DIR="$PROJECT_ROOT/plugins" -PLUGIN_DIR="$PLUGINS_DIR/$PLUGIN_NAME" -OUTPUT_DIR="$PROJECT_ROOT/dist/plugins" - -# 检查插件目录是否存在 -if [ ! -d "$PLUGIN_DIR" ]; then - error "插件目录不存在: $PLUGIN_DIR" -fi - -# 检查 plugin.json 是否存在 -MANIFEST_FILE="$PLUGIN_DIR/plugin.json" -if [ ! -f "$MANIFEST_FILE" ]; then - error "插件清单文件不存在: $MANIFEST_FILE" -fi - -# 读取版本号 -if [ -n "$VERSION_OVERRIDE" ]; then - VERSION="$VERSION_OVERRIDE" - info "使用覆盖版本: $VERSION" -else - # 从 plugin.json 读取版本 - VERSION=$(grep -o '"version"[[:space:]]*:[[:space:]]*"[^"]*"' "$MANIFEST_FILE" | head -1 | sed 's/.*"\([^"]*\)"$/\1/') - if [ -z "$VERSION" ]; then - error "无法从 plugin.json 读取版本号" - fi - info "从 plugin.json 读取版本: $VERSION" -fi - -# 创建输出目录 -mkdir -p "$OUTPUT_DIR" - -# 定义输出文件名 -OUTPUT_FILE="$OUTPUT_DIR/${PLUGIN_NAME}-${VERSION}.zip" - -info "开始打包插件: $PLUGIN_NAME v$VERSION" -info "源目录: $PLUGIN_DIR" -info "输出文件: $OUTPUT_FILE" - -# 如果输出文件已存在,先删除 -if [ -f "$OUTPUT_FILE" ]; then - warn "输出文件已存在,将被覆盖" - rm -f "$OUTPUT_FILE" -fi - -# 创建临时目录用于打包 -TEMP_DIR=$(mktemp -d) -TEMP_PLUGIN_DIR="$TEMP_DIR/$PLUGIN_NAME" - -# 复制插件文件到临时目录 -info "复制插件文件..." -mkdir -p "$TEMP_PLUGIN_DIR" -cp -r "$PLUGIN_DIR"/* "$TEMP_PLUGIN_DIR/" - -# 如果指定了版本覆盖,更新 plugin.json -if [ -n "$VERSION_OVERRIDE" ]; then - info "更新 plugin.json 版本号..." - if [[ "$OSTYPE" == "darwin"* ]]; then - # macOS - sed -i '' "s/\"version\"[[:space:]]*:[[:space:]]*\"[^\"]*\"/\"version\": \"$VERSION_OVERRIDE\"/" "$TEMP_PLUGIN_DIR/plugin.json" - else - # Linux - sed -i "s/\"version\"[[:space:]]*:[[:space:]]*\"[^\"]*\"/\"version\": \"$VERSION_OVERRIDE\"/" "$TEMP_PLUGIN_DIR/plugin.json" - fi -fi - -# 创建 zip 包 -info "创建 zip 包..." -cd "$TEMP_DIR" -zip -r "$OUTPUT_FILE" "$PLUGIN_NAME" -x "*.DS_Store" -x "*__MACOSX*" - -# 清理临时目录 -rm -rf "$TEMP_DIR" - -# 计算校验和 -info "计算校验和..." -if command -v sha256sum &> /dev/null; then - CHECKSUM=$(sha256sum "$OUTPUT_FILE" | awk '{print $1}') -elif command -v shasum &> /dev/null; then - CHECKSUM=$(shasum -a 256 "$OUTPUT_FILE" | awk '{print $1}') -else - warn "无法计算校验和:未找到 sha256sum 或 shasum 命令" - CHECKSUM="N/A" -fi - -# 输出结果 -echo "" -echo -e "${GREEN}========================================${NC}" -echo -e "${GREEN}插件打包完成!${NC}" -echo -e "${GREEN}========================================${NC}" -echo "" -echo "插件名称: $PLUGIN_NAME" -echo "版本: $VERSION" -echo "输出文件: $OUTPUT_FILE" -echo "文件大小: $(du -h "$OUTPUT_FILE" | cut -f1)" -echo "SHA256: $CHECKSUM" -echo "" -echo "安装方式:" -echo " 1. 在 Lime 中打开插件管理器" -echo " 2. 点击「从文件安装」" -echo " 3. 选择 $OUTPUT_FILE" diff --git a/scripts/run-vitest-smart.mjs b/scripts/run-vitest-smart.mjs index 5c9cad0f9..39958131f 100644 --- a/scripts/run-vitest-smart.mjs +++ b/scripts/run-vitest-smart.mjs @@ -23,7 +23,6 @@ const serialTestFiles = new Set([ "src/components/agent/chat/index.test.tsx", "src/components/agent/chat/hooks/useAsterAgentChat.test.tsx", "src/components/workspace/WorkbenchPage.test.tsx", - "src/components/plugins/PluginManager.test.tsx", "src/components/agent/chat/components/HarnessStatusPanel.test.tsx", "src/components/agent/chat/components/ThemeWorkbenchSidebar.test.tsx", "src/components/agent/chat/components/TeamWorkspaceBoard.test.tsx", diff --git a/scripts/translate_all.py b/scripts/translate_all.py index b288fa5e8..98d08ffcb 100644 --- a/scripts/translate_all.py +++ b/scripts/translate_all.py @@ -1090,27 +1090,19 @@ TRANSLATIONS = { "开始安装": "Start installation", "跳过安装": "Skip installation", "设置完成": "Setup complete", - "所有插件已成功安装": "All plugins installed successfully", "您可以开始使用": "You can start using", "个安装失败": "installation failed", - "您可以稍后在插件中心重试": "You can retry later in Plugin Center", - "您已跳过插件安装": "You skipped plugin installation", - "可以稍后在插件中心安装需要的插件": "Can install needed plugins later in Plugin Center", "您可以在左侧导航栏的": "You can find in the left navigation bar", - "随时安装插件": "install plugins anytime", "开始使用": "Get started", "等待安装": "Waiting to install", "准备下载": "Preparing download", "请稍候": "Please wait", - "正在为您安装选中的插件": "Installing selected plugins for you", "已为程序员推荐配置管理和": "Configuration management recommended for programmers and", "您可以根据需要选择插件": "You can select plugins as needed", - "或稍后在插件中心安装": "or install later in Plugin Center", "您是哪类用户": "What type of user are you", "欢迎使用": "Welcome to", "聚合代理": "Aggregation proxy", "让我们花一分钟时间": "Let's take a minute", - "根据您的使用场景推荐合适的插件": "to recommend suitable plugins based on your use case", "提升您的使用体验": "to enhance your experience", } diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 657fa8ba8..3f802fa5f 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -5131,7 +5131,7 @@ dependencies = [ [[package]] name = "lime" -version = "1.34.0" +version = "1.35.0" dependencies = [ "anyhow", "arboard", @@ -5238,7 +5238,7 @@ dependencies = [ [[package]] name = "lime-agent" -version = "1.34.0" +version = "1.35.0" dependencies = [ "anyhow", "aster-core", @@ -5267,7 +5267,7 @@ dependencies = [ [[package]] name = "lime-browser-runtime" -version = "1.34.0" +version = "1.35.0" dependencies = [ "chrono", "futures", @@ -5284,7 +5284,7 @@ dependencies = [ [[package]] name = "lime-cli" -version = "1.34.0" +version = "1.35.0" dependencies = [ "clap", "lime-core", @@ -5296,7 +5296,7 @@ dependencies = [ [[package]] name = "lime-config" -version = "1.34.0" +version = "1.35.0" dependencies = [ "async-trait", "lime-core", @@ -5312,7 +5312,7 @@ dependencies = [ [[package]] name = "lime-core" -version = "1.34.0" +version = "1.35.0" dependencies = [ "aster-models", "async-trait", @@ -5365,7 +5365,7 @@ dependencies = [ [[package]] name = "lime-gateway" -version = "1.34.0" +version = "1.35.0" dependencies = [ "aes", "axum 0.7.9", @@ -5395,7 +5395,7 @@ dependencies = [ [[package]] name = "lime-infra" -version = "1.34.0" +version = "1.35.0" dependencies = [ "chrono", "dashmap 5.5.3", @@ -5415,7 +5415,7 @@ dependencies = [ [[package]] name = "lime-knowledge" -version = "1.34.0" +version = "1.35.0" dependencies = [ "chrono", "hex", @@ -5428,7 +5428,7 @@ dependencies = [ [[package]] name = "lime-mcp" -version = "1.34.0" +version = "1.35.0" dependencies = [ "async-trait", "dirs 5.0.1", @@ -5444,7 +5444,7 @@ dependencies = [ [[package]] name = "lime-media-runtime" -version = "1.34.0" +version = "1.35.0" dependencies = [ "axum 0.7.9", "base64 0.22.1", @@ -5477,7 +5477,7 @@ dependencies = [ [[package]] name = "lime-processor" -version = "1.34.0" +version = "1.35.0" dependencies = [ "async-trait", "lime-core", @@ -5496,7 +5496,7 @@ dependencies = [ [[package]] name = "lime-providers" -version = "1.34.0" +version = "1.35.0" dependencies = [ "anyhow", "async-stream", @@ -5551,7 +5551,7 @@ dependencies = [ [[package]] name = "lime-server" -version = "1.34.0" +version = "1.35.0" dependencies = [ "aster-core", "async-stream", @@ -5595,7 +5595,7 @@ dependencies = [ [[package]] name = "lime-server-utils" -version = "1.34.0" +version = "1.35.0" dependencies = [ "axum 0.7.9", "futures", @@ -5610,7 +5610,7 @@ dependencies = [ [[package]] name = "lime-services" -version = "1.34.0" +version = "1.35.0" dependencies = [ "anyhow", "aster-core", @@ -5655,7 +5655,7 @@ dependencies = [ [[package]] name = "lime-skills" -version = "1.34.0" +version = "1.35.0" dependencies = [ "async-trait", "dirs 5.0.1", @@ -5673,7 +5673,7 @@ dependencies = [ [[package]] name = "lime-websocket" -version = "1.34.0" +version = "1.35.0" dependencies = [ "axum 0.7.9", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 4536e8ecf..c1628802b 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -9,7 +9,7 @@ exclude = [ resolver = "2" [workspace.package] -version = "1.34.0" +version = "1.35.0" edition = "2021" authors = ["coso"] repository = "https://github.com/aiclientproxy/lime" @@ -198,7 +198,7 @@ version = "2.4.9" [package] name = "lime" -version = "1.34.0" +version = "1.35.0" description = "AI API Proxy Desktop App" authors = ["you"] edition = "2021" diff --git a/src-tauri/crates/agent/src/aster_state_support.rs b/src-tauri/crates/agent/src/aster_state_support.rs index 581f0f5d1..9e1040196 100644 --- a/src-tauri/crates/agent/src/aster_state_support.rs +++ b/src-tauri/crates/agent/src/aster_state_support.rs @@ -10,7 +10,7 @@ use aster::tools::ToolRegistrationConfig; use lime_core::app_paths; use lime_core::database::{lock_db, DbConnection}; use lime_services::project_context_builder::ProjectContextBuilder; -use std::path::Path; +use std::path::{Path, PathBuf}; /// 重新加载 Lime Skills pub fn reload_lime_skills() { @@ -56,21 +56,24 @@ pub fn create_lime_tool_config() -> ToolRegistrationConfig { /// 加载 Lime Skills 到 aster-rust 的 global_registry fn load_lime_skills() { - let skills_dir = match app_paths::resolve_skills_dir() { - Ok(path) => path, + let roots = match resolve_lime_skill_root_sources() { + Ok(roots) => roots, Err(error) => { tracing::warn!( - "[AsterAgent] 解析 Lime Skills 目录失败,跳过加载: {}", + "[AsterAgent] 解析 Lime Skills 根目录失败,跳过加载: {}", error ); return; } }; - let skill_count = register_lime_skills_from_dir(&skills_dir, SkillSource::User).len(); + let skill_count = roots + .iter() + .flat_map(|(skills_dir, source)| register_lime_skills_from_dir(skills_dir, *source)) + .count(); if skill_count == 0 { - tracing::info!("[AsterAgent] Lime Skills 目录为空,无 Skills 可加载"); + tracing::info!("[AsterAgent] Lime Skills 根目录为空,无 Skills 可加载"); } else { tracing::info!( "[AsterAgent] 成功加载 {} 个 Lime Skills 到 global_registry", @@ -79,6 +82,32 @@ fn load_lime_skills() { } } +fn resolve_lime_skill_root_sources() -> Result, String> { + let project_roots = app_paths::resolve_lime_project_skill_roots(); + app_paths::resolve_lime_skill_roots() + .map(|roots| assign_lime_skill_root_sources(roots, &project_roots)) +} + +fn assign_lime_skill_root_sources( + roots: Vec, + project_roots: &[PathBuf], +) -> Vec<(PathBuf, SkillSource)> { + roots + .into_iter() + .map(|root| { + let source = if project_roots + .iter() + .any(|project_root| project_root == &root) + { + SkillSource::Project + } else { + SkillSource::User + }; + (root, source) + }) + .collect() +} + fn register_lime_skills_from_dir(skills_dir: &Path, source: SkillSource) -> Vec { let skills = load_skills_from_directory(skills_dir, source); let skill_count = skills.len(); @@ -102,6 +131,71 @@ fn register_lime_skills_from_dir(skills_dir: &Path, source: SkillSource) -> Vec< registered_names } +#[cfg(test)] +mod tests { + use super::assign_lime_skill_root_sources; + use aster::skills::{parse_allowed_tools, SkillSource}; + use std::path::Path; + + #[test] + fn assign_lime_skill_root_sources_should_mark_project_root() { + let project_root = Path::new("/tmp/project/.agents/skills").to_path_buf(); + let user_root = Path::new("/tmp/home/.agents/skills").to_path_buf(); + let app_root = Path::new("/tmp/app/skills").to_path_buf(); + + let roots = assign_lime_skill_root_sources( + vec![project_root.clone(), user_root.clone(), app_root.clone()], + &[project_root.clone()], + ); + + assert_eq!( + roots, + vec![ + (project_root, SkillSource::Project), + (user_root, SkillSource::User), + (app_root, SkillSource::User), + ] + ); + } + + #[test] + fn assign_lime_skill_root_sources_should_mark_cross_provider_project_roots() { + let project_agents_root = Path::new("/tmp/project/.agents/skills").to_path_buf(); + let project_claude_root = Path::new("/tmp/project/.claude/skills").to_path_buf(); + let user_claude_root = Path::new("/tmp/home/.claude/skills").to_path_buf(); + + let roots = assign_lime_skill_root_sources( + vec![ + project_agents_root.clone(), + project_claude_root.clone(), + user_claude_root.clone(), + ], + &[project_agents_root.clone(), project_claude_root.clone()], + ); + + assert_eq!( + roots, + vec![ + (project_agents_root, SkillSource::Project), + (project_claude_root, SkillSource::Project), + (user_claude_root, SkillSource::User), + ] + ); + } + + #[test] + fn aster_allowed_tools_parser_should_accept_agent_skills_standard_spacing() { + assert_eq!( + parse_allowed_tools(Some("Bash(git:*) Bash(jq:*) Read")), + Some(vec![ + "Bash(git:*)".to_string(), + "Bash(jq:*)".to_string(), + "Read".to_string(), + ]) + ); + } +} + /// 构建带项目上下文的 System Prompt pub fn build_project_system_prompt(db: &DbConnection, project_id: &str) -> Result { let conn = lock_db(db).map_err(|e| format!("获取数据库连接失败: {e}"))?; diff --git a/src-tauri/crates/agent/src/event_converter.rs b/src-tauri/crates/agent/src/event_converter.rs index edec7824d..c6d9726b7 100644 --- a/src-tauri/crates/agent/src/event_converter.rs +++ b/src-tauri/crates/agent/src/event_converter.rs @@ -914,7 +914,7 @@ fn extract_team_memory_shadow_object( }) } -fn build_turn_context_summary( +pub(crate) fn build_turn_context_summary( turn_context: Option<&TurnContextOverride>, ) -> Option { let metadata = &turn_context?.metadata; @@ -1297,12 +1297,27 @@ fn convert_item_payload(payload: ItemRuntimePayload) -> AgentThreadItemPayload { } ItemRuntimePayload::Plan { text } => AgentThreadItemPayload::Plan { text }, ItemRuntimePayload::RuntimeStatus { - phase: _, + phase, title, detail, checkpoints, } => AgentThreadItemPayload::TurnSummary { text: format_runtime_status_text(&title, &detail, &checkpoints), + metadata: Some(serde_json::json!({ + "sourceType": "runtime_status", + "source": "runtime_status", + "surface": "runtime_status", + "visibility": "diagnostics", + "persistence": "transient", + "agentui": { + "eventClass": "run.status", + "surface": "runtime_status", + "visibility": "diagnostics", + }, + "runtimeStatus": { + "phase": phase, + }, + })), }, ItemRuntimePayload::FileArtifact { path, @@ -2360,11 +2375,24 @@ mod tests { assert_eq!(events.len(), 1); match &events[0] { TauriAgentEvent::ItemUpdated { item } => match &item.payload { - AgentThreadItemPayload::TurnSummary { text } => { + AgentThreadItemPayload::TurnSummary { text, metadata } => { assert!(text.contains("先规划再输出")); assert!(!text.contains("已决定:")); assert!(text.contains("当前请求更像计划拆解")); assert!(text.contains("• 检测到计划需求")); + let metadata = metadata.as_ref().expect("runtime status metadata"); + assert_eq!( + metadata + .get("sourceType") + .and_then(serde_json::Value::as_str), + Some("runtime_status") + ); + assert_eq!( + metadata + .get("visibility") + .and_then(serde_json::Value::as_str), + Some("diagnostics") + ); } other => panic!("Unexpected payload: {other:?}"), }, diff --git a/src-tauri/crates/agent/src/protocol.rs b/src-tauri/crates/agent/src/protocol.rs index 4eed3cadf..ddcc36fbc 100644 --- a/src-tauri/crates/agent/src/protocol.rs +++ b/src-tauri/crates/agent/src/protocol.rs @@ -71,7 +71,7 @@ pub struct AgentContextTraceStep { pub detail: String, } -#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] pub struct AgentContextBudget { #[serde(default, skip_serializing_if = "Option::is_none")] pub used_tokens: Option, @@ -85,7 +85,7 @@ pub struct AgentContextBudget { pub source: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct AgentMissingContextFact { #[serde(default, skip_serializing_if = "Option::is_none")] pub id: Option, @@ -98,7 +98,7 @@ pub struct AgentMissingContextFact { pub source: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct AgentRetrievalRef { pub source_id: String, pub kind: String, @@ -118,7 +118,7 @@ pub struct AgentRetrievalRef { pub source: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct AgentTeamMemoryRef { pub key: String, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -131,7 +131,7 @@ pub struct AgentTeamMemoryRef { pub source: Option, } -#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] pub struct AgentTurnContextSummary { #[serde(default, skip_serializing_if = "Option::is_none")] pub memory_budget: Option, diff --git a/src-tauri/crates/agent/src/protocol_projection.rs b/src-tauri/crates/agent/src/protocol_projection.rs index 1fe8bed91..63124b80e 100644 --- a/src-tauri/crates/agent/src/protocol_projection.rs +++ b/src-tauri/crates/agent/src/protocol_projection.rs @@ -4,10 +4,12 @@ //! 旧 `event_converter` 仅保留 compat converter 语义,不再作为生产调用入口暴露。 use aster::agents::AgentEvent as AsterAgentEvent; -use aster::session::{ItemRuntime, TurnRuntime}; +use aster::session::{ItemRuntime, TurnContextOverride, TurnRuntime}; use lime_core::database::dao::agent_timeline::{AgentThreadItem, AgentThreadTurn}; -use crate::protocol::AgentEvent as RuntimeAgentEvent; +use crate::protocol::{ + AgentEvent as RuntimeAgentEvent, AgentTurnContextSummary as RuntimeTurnContextSummary, +}; pub fn project_runtime_event(event: AsterAgentEvent) -> Vec { crate::event_converter::convert_agent_event(event) @@ -20,3 +22,9 @@ pub fn project_turn_runtime(turn: TurnRuntime) -> AgentThreadTurn { pub fn project_item_runtime(item: ItemRuntime) -> AgentThreadItem { crate::event_converter::convert_item_runtime(item) } + +pub fn project_turn_context_summary( + turn_context: Option<&TurnContextOverride>, +) -> Option { + crate::event_converter::build_turn_context_summary(turn_context) +} diff --git a/src-tauri/crates/agent/src/request_tool_policy.rs b/src-tauri/crates/agent/src/request_tool_policy.rs index f3f44d263..5d07ea703 100644 --- a/src-tauri/crates/agent/src/request_tool_policy.rs +++ b/src-tauri/crates/agent/src/request_tool_policy.rs @@ -383,6 +383,7 @@ fn update_stream_event_diagnostics( struct TextDeltaBatcher { chunks: Vec, text: String, + has_flushed_first_delta: bool, } impl TextDeltaBatcher { @@ -403,6 +404,11 @@ impl TextDeltaBatcher { self.text.push_str(&text); self.chunks.push(text); + if !self.has_flushed_first_delta { + self.has_flushed_first_delta = true; + return self.flush(TextDeltaBatchBoundary::Provider); + } + let boundary = boundary.or_else(|| { (self.text.chars().count() >= TEXT_DELTA_BATCH_BACKLOG_CHARS) .then_some(TextDeltaBatchBoundary::Backlog) @@ -3066,7 +3072,17 @@ mod tests { #[test] fn text_delta_batcher_should_flush_on_newline_backlog_and_final() { let mut newline_batcher = TextDeltaBatcher::default(); - assert!(newline_batcher.push("第一段".to_string()).is_none()); + let first_event = newline_batcher + .push("第一段".to_string()) + .expect("first text delta should flush immediately"); + assert!(matches!( + first_event, + RuntimeAgentEvent::TextDeltaBatch { + ref text, + ref chunks, + boundary: TextDeltaBatchBoundary::Provider, + } if text == "第一段" && chunks.len() == 1 + )); let newline_event = newline_batcher .push("\n".to_string()) .expect("newline should flush batch"); @@ -3076,10 +3092,21 @@ mod tests { ref text, ref chunks, boundary: TextDeltaBatchBoundary::Newline, - } if text == "第一段\n" && chunks.len() == 2 + } if text == "\n" && chunks.len() == 1 )); let mut backlog_batcher = TextDeltaBatcher::default(); + let first_event = backlog_batcher + .push("a".to_string()) + .expect("first text delta should flush before backlog batching"); + assert!(matches!( + first_event, + RuntimeAgentEvent::TextDeltaBatch { + ref text, + boundary: TextDeltaBatchBoundary::Provider, + .. + } if text == "a" + )); let backlog_event = backlog_batcher .push("a".repeat(TEXT_DELTA_BATCH_BACKLOG_CHARS)) .expect("backlog should flush batch"); @@ -3093,6 +3120,17 @@ mod tests { )); let mut final_batcher = TextDeltaBatcher::default(); + let first_event = final_batcher + .push("开头".to_string()) + .expect("first text delta should flush immediately"); + assert!(matches!( + first_event, + RuntimeAgentEvent::TextDeltaBatch { + ref text, + boundary: TextDeltaBatchBoundary::Provider, + .. + } if text == "开头" + )); assert!(final_batcher.push("尾巴".to_string()).is_none()); let final_event = final_batcher .flush(TextDeltaBatchBoundary::Final) diff --git a/src-tauri/crates/agent/src/session_execution_runtime.rs b/src-tauri/crates/agent/src/session_execution_runtime.rs index d7cd4cb3d..88011280a 100644 --- a/src-tauri/crates/agent/src/session_execution_runtime.rs +++ b/src-tauri/crates/agent/src/session_execution_runtime.rs @@ -525,6 +525,8 @@ pub struct SessionExecutionRuntime { #[serde(skip_serializing_if = "Option::is_none")] pub latest_turn_status: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub context_summary: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub recent_access_mode: Option, #[serde(skip_serializing_if = "Option::is_none")] pub recent_preferences: Option, @@ -1259,6 +1261,7 @@ pub fn build_session_execution_runtime( mode: None, latest_turn_id: None, latest_turn_status: None, + context_summary: None, recent_access_mode: None, recent_preferences: None, recent_team_selection: None, @@ -1289,6 +1292,9 @@ pub fn build_session_execution_runtime( if let Some(latest_turn) = resolve_latest_turn(snapshot) { runtime.latest_turn_id = Some(latest_turn.id.clone()); runtime.latest_turn_status = Some(map_turn_status(latest_turn.status)); + runtime.context_summary = crate::protocol_projection::project_turn_context_summary( + latest_turn.context_override.as_ref(), + ); runtime.output_schema_runtime = latest_turn.output_schema_runtime.clone(); runtime.model_name = latest_turn .output_schema_runtime @@ -1386,6 +1392,7 @@ pub fn build_session_execution_runtime( && runtime.recent_gate_key.is_none() && runtime.recent_run_title.is_none() && runtime.recent_content_id.is_none() + && runtime.context_summary.is_none() && runtime.task_profile.is_none() && runtime.routing_decision.is_none() && runtime.limit_state.is_none() @@ -1577,6 +1584,94 @@ mod tests { assert!(runtime.recent_preferences.is_none()); } + #[test] + fn projects_context_summary_from_latest_turn_metadata() { + let now = Utc::now(); + let snapshot = SessionRuntimeSnapshot { + session_id: "session-context".to_string(), + threads: vec![ThreadRuntimeSnapshot { + thread: ThreadRuntime::new( + "thread-context", + "session-context", + PathBuf::from("/tmp/workspace"), + ), + turns: vec![TurnRuntime { + id: "turn-context".to_string(), + session_id: "session-context".to_string(), + thread_id: "thread-context".to_string(), + status: TurnStatus::Running, + input_text: Some("使用项目资料".to_string()), + error_message: None, + context_override: Some(TurnContextOverride { + metadata: [( + "agentui_context".to_string(), + json!({ + "memory_budget": { + "used_tokens": 640, + "max_tokens": 1200, + "status": "ready", + "source": "knowledge_context_resolver" + }, + "retrieval_refs": [ + { + "source_id": "knowledge_pack:brand:compiled/splits/brief.md", + "kind": "knowledge_pack", + "title": "brand:brief", + "path": "compiled/splits/brief.md", + "scope": "workspace", + "status": "ready", + "source": "knowledge_context_resolver" + } + ], + "missing_context": [ + { + "id": "knowledge_warning:0", + "kind": "knowledge_warning", + "label": "sources/missing.md", + "status": "unknown", + "reason": "缺少来源", + "source": "knowledge_context_resolver" + } + ] + }), + )] + .into_iter() + .collect(), + ..TurnContextOverride::default() + }), + output_schema_runtime: None, + created_at: now, + started_at: Some(now), + completed_at: None, + updated_at: now, + }], + items: Vec::new(), + }], + }; + + let runtime = build_session_execution_runtime( + "session-context", + None, + Some("react".to_string()), + Some(&snapshot), + None, + ) + .expect("runtime"); + let summary = runtime.context_summary.expect("context summary"); + + assert_eq!( + summary.memory_budget.and_then(|budget| budget.used_tokens), + Some(640) + ); + assert_eq!(summary.retrieval_refs.len(), 1); + assert_eq!( + summary.retrieval_refs[0].source_id, + "knowledge_pack:brand:compiled/splits/brief.md" + ); + assert_eq!(summary.missing_context.len(), 1); + assert_eq!(summary.missing_context[0].label, "sources/missing.md"); + } + #[test] fn keeps_recent_preferences_from_latest_turn_metadata() { let now = Utc::now(); @@ -2426,6 +2521,7 @@ mod tests { mode: None, latest_turn_id: Some("turn-fallback".to_string()), latest_turn_status: Some("completed".to_string()), + context_summary: None, recent_access_mode: None, recent_preferences: None, recent_team_selection: None, diff --git a/src-tauri/crates/agent/src/skill_execution.rs b/src-tauri/crates/agent/src/skill_execution.rs index 970b5960e..11f633ca4 100644 --- a/src-tauri/crates/agent/src/skill_execution.rs +++ b/src-tauri/crates/agent/src/skill_execution.rs @@ -436,6 +436,7 @@ mod tests { description: "测试 skill".to_string(), markdown_content: "test".to_string(), license: None, + compatibility: None, metadata: HashMap::new(), allowed_tools: allowed_tools.map(|tools| { tools diff --git a/src-tauri/crates/aster-rust/crates/aster/src/agents/reply_parts.rs b/src-tauri/crates/aster-rust/crates/aster/src/agents/reply_parts.rs index 93c6f4e2f..7cfae16f0 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/agents/reply_parts.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/agents/reply_parts.rs @@ -32,7 +32,23 @@ const LIME_RUNTIME_TOOL_SURFACE_KEY: &str = "tool_surface"; const LIME_RUNTIME_IMAGE_INPUT_POLICY_KEY: &str = "image_input_policy"; const TURN_TOOL_SURFACE_DIRECT_ANSWER: &str = "direct_answer"; const TURN_TOOL_SURFACE_LOCAL_WORKSPACE: &str = "local_workspace"; +const TURN_TOOL_SURFACE_COMPACT_TOOLS: &str = "compact_tools"; const LOCAL_WORKSPACE_TOOL_NAMES: &[&str] = &["Bash", "Read", "Write", "Edit", "Glob", "Grep"]; +const COMPACT_TOOL_SURFACE_TOOL_NAMES: &[&str] = &[ + "ToolSearch", + "ListMcpResourcesTool", + "ReadMcpResourceTool", + "extensionmanager__search_available_extensions", + "extensionmanager__manage_extensions", + "Read", + "Glob", + "Grep", + "Bash", + "Edit", + "Write", + "Agent", + "StructuredOutput", +]; const DIRECT_ANSWER_TURN_GUIDANCE: &str = "【当前回合执行约束】本回合应优先直接回答。除非信息明显不足或用户明确要求,否则不要调用工具,也不要把简单回复扩展成多阶段流程。"; const LOCAL_WORKSPACE_TURN_GUIDANCE: &str = "【当前回合执行约束】本回合只允许使用本地工作区工具。先用最少的侦查动作定位关键文件,优先小范围目录/文件列表与精确搜索;通常先控制在 3 到 6 次工具调用内拿到关键证据,只有前一步明确暴露新线索时再继续深入。若需要连续侦查,请把相互独立的读取/搜索收敛成一批,并在同一条回复里一起发起 2 到 4 个彼此独立的只读工具调用,让运行时并行执行;先完成这一批,再直接输出 1 到 2 句用户可见的结论正文,说明已经确认了什么、还缺什么、为什么还要继续,不要额外输出“阶段结论”标题,再决定是否继续下一批。如果用户消息里已经点名绝对路径、仓库根或具体文件,就把这些显式路径当作本回合唯一优先入口;第一批只围绕这些路径展开,不要先扫描当前默认工作区或无关目录。读取文件时聚焦与问题直接相关的入口、注册表、配置和代码片段,避免重复枚举大目录、避免一次性展开超长目录或整文件全文,也不要把大段原文直接抄回最终回答,改用结论加文件路径。"; @@ -170,6 +186,12 @@ fn is_local_workspace_tool(tool_name: &str) -> bool { .any(|candidate| candidate.eq_ignore_ascii_case(tool_name)) } +fn is_compact_tool_surface_tool(tool_name: &str) -> bool { + COMPACT_TOOL_SURFACE_TOOL_NAMES + .iter() + .any(|candidate| candidate.eq_ignore_ascii_case(tool_name)) +} + fn normalize_turn_metadata_tool_list(value: Option<&Value>) -> Vec { let Some(items) = value.and_then(Value::as_array) else { return Vec::new(); @@ -252,6 +274,10 @@ fn filter_tools_for_turn_surface( tools.retain(|tool| is_local_workspace_tool(&tool.name)); tools } + Some(TURN_TOOL_SURFACE_COMPACT_TOOLS) => { + tools.retain(|tool| is_compact_tool_surface_tool(&tool.name)); + tools + } _ => tools, } } @@ -556,11 +582,37 @@ impl Agent { }; Ok(Box::pin(try_stream! { - let mut first_provider_message_seen = false; + let mut first_provider_content_seen = false; while let Some(next) = stream.next().await { - let (mut message, usage) = next?; - if !first_provider_message_seen { - first_provider_message_seen = true; + let (mut message, usage) = match next { + Ok(next) => next, + Err(error) + if !first_provider_content_seen + && error.to_string().contains( + "Anthropic stream ended without assistant content or tool call", + ) => + { + tracing::warn!( + "[AsterAgent][TTFT] empty provider stream before first message, retrying non-stream fallback: provider={}, model={}, elapsed_ms={}, error={}", + provider.get_name(), + model_config.model_name, + started_at.elapsed().as_millis(), + error + ); + let (message, usage) = provider + .complete_with_model( + &model_config, + system_prompt.as_str(), + messages_for_provider.messages(), + &tools, + ) + .await?; + (Some(message), Some(usage)) + } + Err(error) => Err(error)?, + }; + if message.is_some() && !first_provider_content_seen { + first_provider_content_seen = true; tracing::info!( "[AsterAgent][TTFT] first provider stream message decoded: provider={}, model={}, elapsed_ms={}", provider.get_name(), @@ -779,6 +831,7 @@ impl Agent { #[cfg(test)] mod tests { use super::*; + use crate::agents::mcp_client::{Error as McpClientError, McpClientTrait}; use crate::conversation::message::{Message, MessageContent, ToolRequest}; use crate::model::ModelConfig; use crate::providers::base::{Provider, ProviderUsage, Usage}; @@ -788,9 +841,19 @@ mod tests { use crate::session::{Session, TurnContextOverride}; use async_trait::async_trait; use chrono::{DateTime, Utc}; + use rmcp::model::{ + CallToolResult, GetPromptResult, Implementation, InitializeResult, JsonObject, + ListPromptsResult, ListResourcesResult, ListToolsResult, ProtocolVersion, + ReadResourceResult, ResourcesCapability, ServerCapabilities, ServerInfo, + ServerNotification, + }; use rmcp::object; use std::collections::HashMap; use std::path::PathBuf; + use tokio::sync::{mpsc, Mutex}; + use tokio_util::sync::CancellationToken; + + const MCP_CONTEXT_SENTINEL: &str = "MCP_CONTEXT_SENTINEL_SHOULD_STAY"; #[derive(Clone)] struct MockProvider { @@ -870,6 +933,93 @@ mod tests { } } + struct PromptContextMockClient; + + #[async_trait] + impl McpClientTrait for PromptContextMockClient { + fn get_info(&self) -> Option<&InitializeResult> { + None + } + + async fn list_resources( + &self, + _next_cursor: Option, + _cancellation_token: CancellationToken, + ) -> Result { + Err(McpClientError::TransportClosed) + } + + async fn read_resource( + &self, + _uri: &str, + _cancellation_token: CancellationToken, + ) -> Result { + Err(McpClientError::TransportClosed) + } + + async fn list_tools( + &self, + _next_cursor: Option, + _cancellation_token: CancellationToken, + ) -> Result { + Ok(ListToolsResult { + tools: vec![Tool::new( + "heavy_tool".to_string(), + "Heavy MCP tool that should stay out of compact provider payload".to_string(), + object!({ "type": "object", "properties": {} }), + )], + next_cursor: None, + meta: None, + }) + } + + async fn call_tool( + &self, + _name: &str, + _arguments: Option, + _cancellation_token: CancellationToken, + ) -> Result { + Err(McpClientError::TransportClosed) + } + + async fn list_prompts( + &self, + _next_cursor: Option, + _cancellation_token: CancellationToken, + ) -> Result { + Err(McpClientError::TransportClosed) + } + + async fn get_prompt( + &self, + _name: &str, + _arguments: Value, + _cancellation_token: CancellationToken, + ) -> Result { + Err(McpClientError::TransportClosed) + } + + async fn subscribe(&self) -> mpsc::Receiver { + let (_tx, rx) = mpsc::channel(1); + rx + } + } + + fn prompt_context_server_info() -> ServerInfo { + ServerInfo { + protocol_version: ProtocolVersion::V_2025_03_26, + capabilities: ServerCapabilities { + resources: Some(ResourcesCapability::default()), + ..Default::default() + }, + server_info: Implementation { + name: "latency-probe".to_string(), + ..Default::default() + }, + instructions: Some(MCP_CONTEXT_SENTINEL.to_string()), + } + } + /// Mock scheduler for testing struct MockScheduler; @@ -1203,6 +1353,134 @@ mod tests { Ok(()) } + #[tokio::test] + async fn prepare_tools_and_prompt_keeps_only_compact_broker_tools_for_compact_turn_surface( + ) -> anyhow::Result<()> { + let agent = crate::agents::Agent::new(); + agent + .set_scheduler(std::sync::Arc::new(MockScheduler)) + .await; + + let session = SessionManager::create_session( + std::path::PathBuf::default(), + "test-compact-tool-surface".to_string(), + SessionType::User, + ) + .await?; + + let model_config = ModelConfig::new("claude-sonnet-4-5").unwrap(); + let provider = std::sync::Arc::new(MockProvider { + model_config, + observed_models: None, + }); + agent.update_provider(provider, &session.id).await?; + + let working_dir = std::env::current_dir()?; + let (tools, _toolshim_tools, _system_prompt) = crate::session_context::with_turn_context( + Some(build_turn_context_with_tool_surface( + TURN_TOOL_SURFACE_COMPACT_TOOLS, + )), + async { + agent + .prepare_tools_and_prompt( + &working_dir, + None, + false, + &ModelConfig::new("claude-sonnet-4-5").unwrap(), + ) + .await + }, + ) + .await?; + + let names: Vec = tools.iter().map(|tool| tool.name.to_string()).collect(); + assert!(!names.is_empty()); + assert!(names.len() <= COMPACT_TOOL_SURFACE_TOOL_NAMES.len()); + assert!(names.iter().all(|name| is_compact_tool_surface_tool(name))); + assert!(names.iter().any(|name| name == "ToolSearch")); + assert!(names.iter().any(|name| name == "Read")); + assert!(!names.iter().any(|name| name == "TeamCreate")); + assert!(!names.iter().any(|name| name == "TeamDelete")); + + Ok(()) + } + + #[tokio::test] + async fn compact_turn_surface_preserves_extension_prompt_context() -> anyhow::Result<()> { + let agent = crate::agents::Agent::new(); + agent + .set_scheduler(std::sync::Arc::new(MockScheduler)) + .await; + + let session = SessionManager::create_session( + std::path::PathBuf::default(), + "test-compact-extension-prompt-context".to_string(), + SessionType::User, + ) + .await?; + + let model_config = ModelConfig::new("claude-sonnet-4-5").unwrap(); + let provider = std::sync::Arc::new(MockProvider { + model_config, + observed_models: None, + }); + agent.update_provider(provider, &session.id).await?; + + let client: std::sync::Arc>> = + std::sync::Arc::new(Mutex::new(Box::new(PromptContextMockClient))); + agent + .extension_manager + .add_client( + "mcp__latency_probe".to_string(), + crate::agents::extension::ExtensionConfig::Builtin { + name: "mcp__latency_probe".to_string(), + display_name: Some("latency-probe".to_string()), + description: "Latency probe MCP".to_string(), + timeout: None, + bundled: Some(false), + available_tools: Vec::new(), + deferred_loading: false, + always_expose_tools: Vec::new(), + allowed_caller: None, + }, + client, + Some(prompt_context_server_info()), + None, + ) + .await; + + let working_dir = std::env::current_dir()?; + let (tools, _toolshim_tools, system_prompt) = crate::session_context::with_turn_context( + Some(build_turn_context_with_tool_surface( + TURN_TOOL_SURFACE_COMPACT_TOOLS, + )), + async { + agent + .prepare_tools_and_prompt( + &working_dir, + None, + false, + &ModelConfig::new("claude-sonnet-4-5").unwrap(), + ) + .await + }, + ) + .await?; + + let names: Vec = tools.iter().map(|tool| tool.name.to_string()).collect(); + assert!(names.iter().all(|name| is_compact_tool_surface_tool(name))); + assert!(names.iter().any(|name| name == "ListMcpResourcesTool")); + assert!(names.iter().any(|name| name == "ReadMcpResourceTool")); + assert!(!names + .iter() + .any(|name| name == "mcp__latency_probe__heavy_tool")); + assert!(system_prompt.contains(MCP_CONTEXT_SENTINEL)); + assert!(system_prompt.contains("ListMcpResourcesTool")); + assert!(!system_prompt.contains("【当前回合执行约束】")); + + Ok(()) + } + #[tokio::test] async fn prepare_tools_and_prompt_filters_turn_scoped_allowed_tools() -> anyhow::Result<()> { let agent = crate::agents::Agent::new(); @@ -1246,6 +1524,55 @@ mod tests { Ok(()) } + #[tokio::test] + async fn prepare_tools_and_prompt_applies_tool_scope_after_compact_surface( + ) -> anyhow::Result<()> { + let agent = crate::agents::Agent::new(); + + let session = SessionManager::create_session( + std::path::PathBuf::default(), + "test-compact-tool-scope".to_string(), + SessionType::Hidden, + ) + .await?; + + let model_config = ModelConfig::new("claude-sonnet-4-5").unwrap(); + let provider = std::sync::Arc::new(MockProvider { + model_config, + observed_models: None, + }); + agent.update_provider(provider, &session.id).await?; + + let mut turn_context = + build_turn_context_with_tool_surface(TURN_TOOL_SURFACE_COMPACT_TOOLS); + turn_context.metadata.insert( + "subagent".to_string(), + json!({ + "allowed_tools": ["Read", "Grep", "ListMcpResourcesTool"], + "disallowed_tools": ["Grep"], + }), + ); + + let working_dir = std::env::current_dir()?; + let (tools, _toolshim_tools, _system_prompt) = + crate::session_context::with_turn_context(Some(turn_context), async { + agent + .prepare_tools_and_prompt( + &working_dir, + None, + false, + &ModelConfig::new("claude-sonnet-4-5").unwrap(), + ) + .await + }) + .await?; + + let names: Vec = tools.iter().map(|tool| tool.name.to_string()).collect(); + assert_eq!(names, vec!["Read".to_string()]); + + Ok(()) + } + #[tokio::test] async fn prepare_tools_and_prompt_filters_turn_scoped_disallowed_tools() -> anyhow::Result<()> { let agent = crate::agents::Agent::new(); diff --git a/src-tauri/crates/aster-rust/crates/aster/src/providers/anthropic.rs b/src-tauri/crates/aster-rust/crates/aster/src/providers/anthropic.rs index 0c2c09cd6..d171e8a1d 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/providers/anthropic.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/providers/anthropic.rs @@ -55,6 +55,7 @@ fn should_use_bearer_auth_for_anthropic_host(host: &str) -> bool { "api.z.ai/api/anthropic", "moonshot.cn/anthropic", "moonshot.ai/anthropic", + "api.kimi.com/coding", "minimaxi.com/anthropic", "minimax.io/anthropic", "coding.dashscope.aliyuncs.com/apps/anthropic", @@ -65,6 +66,10 @@ fn should_use_bearer_auth_for_anthropic_host(host: &str) -> bool { .any(|needle| normalized_host.contains(needle)) } +fn is_official_anthropic_host(host: &str) -> bool { + normalize_anthropic_host(host).contains("api.anthropic.com") +} + fn parse_config_url(host: &str) -> Option { let trimmed = host.trim(); if trimmed.is_empty() { @@ -131,11 +136,50 @@ fn resolve_anthropic_auth(host: &str, secret: String) -> AuthMethod { } } +fn resolve_anthropic_auth_from_env(host: &str) -> Result<(String, AuthMethod)> { + let config = crate::config::Config::global(); + let sanitized_host = strip_host_url_metadata(host); + + if !is_official_anthropic_host(&sanitized_host) { + if let Ok(api_key) = config.get_secret::("ANTHROPIC_AUTH_TOKEN") { + return Ok((api_key.clone(), AuthMethod::BearerToken(api_key))); + } + } + + if should_use_bearer_auth_for_anthropic_host(&sanitized_host) { + let api_key: String = config + .get_secret::("ANTHROPIC_AUTH_TOKEN") + .or_else(|_| config.get_secret::("ANTHROPIC_API_KEY"))?; + Ok((api_key.clone(), AuthMethod::BearerToken(api_key))) + } else { + let api_key: String = config + .get_secret::("ANTHROPIC_API_KEY") + .or_else(|_| config.get_secret::("ANTHROPIC_AUTH_TOKEN"))?; + Ok(( + api_key.clone(), + AuthMethod::ApiKey { + header_name: "x-api-key".to_string(), + key: api_key, + }, + )) + } +} + fn build_anthropic_api_client(host: &str, auth_secret: String) -> Result { let sanitized_host = strip_host_url_metadata(host); let should_add_x_api_key_header = should_use_bearer_auth_for_anthropic_host(&sanitized_host); let auth = resolve_anthropic_auth(&sanitized_host, auth_secret.clone()); + build_anthropic_api_client_with_auth(host, auth_secret, auth, should_add_x_api_key_header) +} + +fn build_anthropic_api_client_with_auth( + host: &str, + auth_secret: String, + auth: AuthMethod, + should_add_x_api_key_header: bool, +) -> Result { + let sanitized_host = strip_host_url_metadata(host); let mut api_client = ApiClient::new(sanitized_host, auth)? .with_header("anthropic-version", ANTHROPIC_API_VERSION)?; @@ -170,18 +214,17 @@ impl AnthropicProvider { .or_else(|_| config.get_param("ANTHROPIC_BASE_URL")) .unwrap_or_else(|_| "https://api.anthropic.com".to_string()); let sanitized_host = strip_host_url_metadata(&host); - let api_key: String = if should_use_bearer_auth_for_anthropic_host(&sanitized_host) { - config - .get_secret("ANTHROPIC_AUTH_TOKEN") - .or_else(|_| config.get_secret("ANTHROPIC_API_KEY"))? - } else { - config - .get_secret("ANTHROPIC_API_KEY") - .or_else(|_| config.get_secret("ANTHROPIC_AUTH_TOKEN"))? - }; + let (api_key, auth) = resolve_anthropic_auth_from_env(&host)?; + let should_add_x_api_key_header = + should_use_bearer_auth_for_anthropic_host(&sanitized_host); let automatic_prompt_cache = supports_automatic_prompt_cache_for_host(&sanitized_host); - let api_client = build_anthropic_api_client(&host, api_key)?; + let api_client = build_anthropic_api_client_with_auth( + &host, + api_key, + auth, + should_add_x_api_key_header, + )?; Ok(Self { api_client, @@ -483,6 +526,9 @@ mod tests { assert!(should_use_bearer_auth_for_anthropic_host( "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic" )); + assert!(should_use_bearer_auth_for_anthropic_host( + "https://api.kimi.com/coding/" + )); } #[test] @@ -490,9 +536,6 @@ mod tests { assert!(!should_use_bearer_auth_for_anthropic_host( "https://api.anthropic.com" )); - assert!(!should_use_bearer_auth_for_anthropic_host( - "https://api.kimi.com/coding/" - )); } #[test] @@ -502,6 +545,11 @@ mod tests { _ => panic!("expected bearer token auth"), } + match resolve_anthropic_auth("https://api.kimi.com/coding/", "k1".to_string()) { + AuthMethod::BearerToken(token) => assert_eq!(token, "k1"), + _ => panic!("expected bearer token auth"), + } + match resolve_anthropic_auth("https://api.anthropic.com", "k2".to_string()) { AuthMethod::ApiKey { header_name, key } => { assert_eq!(header_name, "x-api-key"); @@ -534,6 +582,27 @@ mod tests { ); } + #[test] + fn test_kimi_code_host_adds_dual_auth_headers_to_api_client() { + let api_client = + build_anthropic_api_client("https://api.kimi.com/coding/", "k1".to_string()) + .expect("api client should build"); + let headers = api_client.default_headers_for_test(); + + assert_eq!( + headers + .get("anthropic-version") + .and_then(|value| value.to_str().ok()), + Some("2023-06-01") + ); + assert_eq!( + headers + .get("x-api-key") + .and_then(|value| value.to_str().ok()), + Some("k1") + ); + } + #[test] fn test_lime_tenant_fragment_adds_custom_header_to_api_client() { let api_client = build_anthropic_api_client( diff --git a/src-tauri/crates/aster-rust/crates/aster/src/providers/formats/anthropic.rs b/src-tauri/crates/aster-rust/crates/aster/src/providers/formats/anthropic.rs index ab4e691ca..90eaa77a5 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/providers/formats/anthropic.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/providers/formats/anthropic.rs @@ -8,7 +8,7 @@ use anyhow::{anyhow, Result}; use rmcp::model::{object, CallToolRequestParam, ErrorCode, ErrorData, JsonObject, Role, Tool}; use rmcp::object as json_object; use serde_json::{json, Value}; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; // Constants for frequently used strings in Anthropic API format @@ -525,6 +525,7 @@ where #[derive(Serialize, Deserialize, Debug)] struct StreamingEvent { + #[serde(default)] #[serde(rename = "type")] event_type: String, #[serde(flatten)] @@ -537,11 +538,26 @@ where let mut current_tool_id: Option = None; let mut final_usage: Option = None; let mut message_id: Option = None; + let mut pending_sse_event_type: Option = None; + let mut observed_event_counts: HashMap = HashMap::new(); + let mut yielded_content_or_tool = false; while let Some(line_result) = stream.next().await { let line = line_result?; + let trimmed_line = line.trim(); // Skip empty lines and non-data lines - if line.trim().is_empty() || !line.starts_with("data: ") { + if trimmed_line.is_empty() { + pending_sse_event_type = None; + continue; + } + if let Some(event_type) = trimmed_line.strip_prefix("event:") { + let event_type = event_type.trim(); + if !event_type.is_empty() { + pending_sse_event_type = Some(event_type.to_string()); + } + continue; + } + if !line.starts_with("data: ") { continue; } @@ -553,13 +569,26 @@ where } // Parse the JSON event - let event: StreamingEvent = match serde_json::from_str(data_part) { + let mut event: StreamingEvent = match serde_json::from_str(data_part) { Ok(event) => event, Err(e) => { tracing::debug!("Failed to parse streaming event: {} - Line: {}", e, data_part); continue; } }; + if event.event_type.is_empty() { + if let Some(event_type) = pending_sse_event_type.take() { + event.event_type = event_type; + } + } else { + pending_sse_event_type = None; + } + if event.event_type.is_empty() { + event.event_type = "unknown".to_string(); + } + *observed_event_counts + .entry(event.event_type.clone()) + .or_insert(0) += 1; match event.event_type.as_str() { "message_start" => { @@ -585,6 +614,31 @@ where } continue; } + "error" => { + let error_message = event + .data + .get("error") + .and_then(|error| error.get("message")) + .and_then(|message| message.as_str()) + .or_else(|| event.data.get("message").and_then(|message| message.as_str())) + .map(str::trim) + .filter(|message| !message.is_empty()) + .map(ToString::to_string) + .unwrap_or_else(|| event.data.to_string()); + let error_type = event + .data + .get("error") + .and_then(|error| error.get("type")) + .and_then(|error_type| error_type.as_str()) + .map(str::trim) + .filter(|error_type| !error_type.is_empty()); + + if let Some(error_type) = error_type { + Err(anyhow!("Anthropic stream error ({error_type}): {error_message}"))?; + } else { + Err(anyhow!("Anthropic stream error: {error_message}"))?; + } + } "content_block_start" => { // A new content block started if let Some(content_block) = event.data.get("content_block") { @@ -627,6 +681,7 @@ where vec![MessageContent::text(text)], ); message.id = message_id.clone(); + yielded_content_or_tool = true; yield (Some(message), None); } } else if delta.get("type") == Some(&json!("thinking_delta")) { @@ -704,6 +759,7 @@ where vec![MessageContent::tool_request(tool_id, Ok(tool_call))], ); message.id = message_id.clone(); + yielded_content_or_tool = true; yield (Some(message), None); } } @@ -794,6 +850,22 @@ where } else { tracing::debug!("🔍 Anthropic no final usage to yield"); } + + if !yielded_content_or_tool { + let mut observed_events = observed_event_counts + .iter() + .map(|(event_type, count)| format!("{event_type}={count}")) + .collect::>(); + observed_events.sort(); + let observed_events = if observed_events.is_empty() { + "none".to_string() + } else { + observed_events.join(", ") + }; + Err(anyhow!( + "Anthropic stream ended without assistant content or tool call; observed_events={observed_events}" + ))?; + } } } @@ -1256,4 +1328,83 @@ mod tests { Some(json!("README.md")) ); } + + #[tokio::test] + async fn test_streaming_error_event_returns_error() { + let stream = futures::stream::iter([ + Ok(String::from( + "data: {\"type\":\"error\",\"error\":{\"type\":\"authentication_error\",\"message\":\"invalid api key\"}}", + )), + Ok(String::from("data: [DONE]")), + ]); + + let outputs = response_to_streaming_message(stream) + .collect::>() + .await; + + assert_eq!(outputs.len(), 1); + let error = outputs + .into_iter() + .next() + .expect("stream should yield the error") + .expect_err("error event should fail the stream"); + assert!(error + .to_string() + .contains("Anthropic stream error (authentication_error): invalid api key")); + } + + #[tokio::test] + async fn test_streaming_sse_event_line_supplies_missing_json_type() { + let stream = futures::stream::iter([ + Ok(String::from("event: message_start")), + Ok(String::from( + "data: {\"message\":{\"id\":\"msg_123\",\"model\":\"kimi-for-coding\"}}", + )), + Ok(String::from("")), + Ok(String::from("event: content_block_delta")), + Ok(String::from( + "data: {\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"你好\"}}", + )), + Ok(String::from("")), + Ok(String::from("event: message_stop")), + Ok(String::from("data: {}")), + ]); + + let outputs = response_to_streaming_message(stream) + .collect::>() + .await; + + assert!(outputs.iter().any(|item| { + item.as_ref() + .ok() + .and_then(|(message, _)| message.as_ref()) + .map(|message| message.as_concat_text() == "你好") + .unwrap_or(false) + })); + assert!(outputs.iter().all(Result::is_ok)); + } + + #[tokio::test] + async fn test_empty_stream_returns_diagnostic_error() { + let stream = futures::stream::iter([ + Ok(String::from( + "data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_123\",\"model\":\"kimi-for-coding\"}}", + )), + Ok(String::from("data: {\"type\":\"message_stop\"}")), + ]); + + let outputs = response_to_streaming_message(stream) + .collect::>() + .await; + + let error = outputs + .into_iter() + .find_map(Result::err) + .expect("empty stream should produce a diagnostic error"); + assert!(error + .to_string() + .contains("Anthropic stream ended without assistant content or tool call")); + assert!(error.to_string().contains("message_start=1")); + assert!(error.to_string().contains("message_stop=1")); + } } diff --git a/src-tauri/crates/aster-rust/crates/aster/src/skills/loader.rs b/src-tauri/crates/aster-rust/crates/aster/src/skills/loader.rs index a060c14d9..1a5be7453 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/skills/loader.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/skills/loader.rs @@ -90,6 +90,7 @@ pub fn parse_frontmatter(content: &str) -> (SkillFrontmatter, String) { /// Parse allowed-tools field into a list pub fn parse_allowed_tools(value: Option<&str>) -> Option> { value.and_then(|v| { + let v = v.trim(); if v.is_empty() { return None; } @@ -101,7 +102,12 @@ pub fn parse_allowed_tools(value: Option<&str>) -> Option> { .collect(), ) } else { - Some(vec![v.trim().to_string()]) + Some( + v.split_whitespace() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(), + ) } }) } @@ -542,6 +548,14 @@ Body parse_allowed_tools(Some("tool1")), Some(vec!["tool1".to_string()]) ); + assert_eq!( + parse_allowed_tools(Some("Bash(git:*) Bash(jq:*) Read")), + Some(vec![ + "Bash(git:*)".to_string(), + "Bash(jq:*)".to_string(), + "Read".to_string() + ]) + ); assert_eq!( parse_allowed_tools(Some("tool1, tool2, tool3")), Some(vec![ diff --git a/src-tauri/crates/core/src/app_paths.rs b/src-tauri/crates/core/src/app_paths.rs index fdc4d4a86..3fb03de71 100644 --- a/src-tauri/crates/core/src/app_paths.rs +++ b/src-tauri/crates/core/src/app_paths.rs @@ -15,6 +15,18 @@ const USER_MEMORY_FILE_NAME: &str = "AGENTS.md"; const LEGACY_USER_MEMORY_FILE_NAMES: &[&str] = &["AGENTS.md", "AGENT.md", "instructions.md"]; const WORKSPACE_RUNTIME_DIR_NAME: &str = ".lime"; const WORKSPACE_LOCAL_RUNTIME_AGENTS_FILE_NAME: &str = "AGENTS.local.md"; +const SKILL_PROVIDER_DIRS: &[&str] = &[ + ".agents", + ".warp", + ".claude", + ".codex", + ".cursor", + ".gemini", + ".copilot", + ".factory", + ".github", + ".opencode", +]; const USER_SIGNAL_TABLES: &[&str] = &[ "contents", "agent_sessions", @@ -94,12 +106,32 @@ pub fn resolve_project_skills_dir() -> Option { .map(|cwd| resolve_project_skills_dir_from_cwd(&cwd)) } +pub fn resolve_lime_project_skill_roots() -> Vec { + std::env::current_dir() + .ok() + .map(|cwd| resolve_project_skill_roots_from_cwd(&cwd)) + .unwrap_or_default() +} + +pub fn resolve_user_agents_skills_dir() -> Option { + dirs::home_dir().map(|home| resolve_user_agents_skills_dir_from_home(&home)) +} + +pub fn resolve_lime_user_skill_roots() -> Vec { + dirs::home_dir() + .map(|home| resolve_user_skill_roots_from_home(&home)) + .unwrap_or_default() +} + pub fn resolve_lime_skill_roots() -> Result, String> { let mut roots = Vec::new(); - if let Some(project_dir) = resolve_project_skills_dir() { - roots.push(project_dir); + for project_dir in resolve_lime_project_skill_roots() { + push_unique_root(&mut roots, project_dir); } - roots.push(resolve_skills_dir()?); + for user_dir in resolve_lime_user_skill_roots() { + push_unique_root(&mut roots, user_dir); + } + push_unique_root(&mut roots, resolve_skills_dir()?); Ok(roots) } @@ -253,6 +285,25 @@ fn resolve_project_skills_dir_from_cwd(cwd: &Path) -> PathBuf { cwd.join(".agents").join("skills") } +fn resolve_project_skill_roots_from_cwd(cwd: &Path) -> Vec { + resolve_provider_skill_roots_from_base(cwd) +} + +fn resolve_user_agents_skills_dir_from_home(home: &Path) -> PathBuf { + home.join(".agents").join("skills") +} + +fn resolve_user_skill_roots_from_home(home: &Path) -> Vec { + resolve_provider_skill_roots_from_base(home) +} + +fn resolve_provider_skill_roots_from_base(base: &Path) -> Vec { + SKILL_PROVIDER_DIRS + .iter() + .map(|provider_dir| base.join(provider_dir).join("skills")) + .collect() +} + fn fallback_app_data_dir() -> PathBuf { std::env::temp_dir().join(APP_DATA_DIR_NAME) } @@ -910,6 +961,35 @@ mod tests { assert_eq!(resolved, cwd.join(".agents").join("skills")); } + #[test] + fn resolve_user_agents_skills_dir_from_home_builds_standard_user_path() { + let home = Path::new("/tmp/home"); + let resolved = resolve_user_agents_skills_dir_from_home(home); + assert_eq!(resolved, home.join(".agents").join("skills")); + } + + #[test] + fn resolve_project_skill_roots_from_cwd_builds_cross_provider_roots_in_precedence_order() { + let cwd = Path::new("/tmp/workspace"); + let resolved = resolve_project_skill_roots_from_cwd(cwd); + + assert_eq!(resolved.first(), Some(&cwd.join(".agents").join("skills"))); + assert!(resolved.contains(&cwd.join(".claude").join("skills"))); + assert!(resolved.contains(&cwd.join(".codex").join("skills"))); + assert!(resolved.contains(&cwd.join(".gemini").join("skills"))); + } + + #[test] + fn resolve_user_skill_roots_from_home_builds_cross_provider_roots_in_precedence_order() { + let home = Path::new("/tmp/home"); + let resolved = resolve_user_skill_roots_from_home(home); + + assert_eq!(resolved.first(), Some(&home.join(".agents").join("skills"))); + assert!(resolved.contains(&home.join(".claude").join("skills"))); + assert!(resolved.contains(&home.join(".codex").join("skills"))); + assert!(resolved.contains(&home.join(".gemini").join("skills"))); + } + #[test] fn resolve_workspace_runtime_agents_path_builds_workspace_file_path() { let workspace_root = Path::new("/tmp/workspace"); diff --git a/src-tauri/crates/core/src/config/types.rs b/src-tauri/crates/core/src/config/types.rs index fbfe82e67..eda1a29be 100644 --- a/src-tauri/crates/core/src/config/types.rs +++ b/src-tauri/crates/core/src/config/types.rs @@ -793,6 +793,11 @@ impl ServiceModelPreferenceConfig { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] pub struct ServiceModelsConfig { + #[serde( + default, + skip_serializing_if = "ServiceModelPreferenceConfig::is_default" + )] + pub responsive_chat: ServiceModelPreferenceConfig, #[serde( default, skip_serializing_if = "ServiceModelPreferenceConfig::is_default" @@ -2985,6 +2990,16 @@ mod unit_tests { #[test] fn test_workspace_preferences_supports_service_models_roundtrip() { let mut config = Config::default(); + config + .workspace_preferences + .service_models + .responsive_chat + .preferred_provider_id = Some("gemini".to_string()); + config + .workspace_preferences + .service_models + .responsive_chat + .preferred_model_id = Some("gemini-2.5-flash".to_string()); config .workspace_preferences .service_models @@ -3011,6 +3026,24 @@ mod unit_tests { let parsed: Config = serde_json::from_value(value) .expect("config should deserialize service model preferences"); + assert_eq!( + parsed + .workspace_preferences + .service_models + .responsive_chat + .preferred_provider_id + .as_deref(), + Some("gemini") + ); + assert_eq!( + parsed + .workspace_preferences + .service_models + .responsive_chat + .preferred_model_id + .as_deref(), + Some("gemini-2.5-flash") + ); assert_eq!( parsed .workspace_preferences diff --git a/src-tauri/crates/core/src/database/dao/agent_timeline.rs b/src-tauri/crates/core/src/database/dao/agent_timeline.rs index 9083cabe5..201926471 100644 --- a/src-tauri/crates/core/src/database/dao/agent_timeline.rs +++ b/src-tauri/crates/core/src/database/dao/agent_timeline.rs @@ -243,6 +243,8 @@ pub enum AgentThreadItemPayload { }, TurnSummary { text: String, + #[serde(skip_serializing_if = "Option::is_none")] + metadata: Option, }, } diff --git a/src-tauri/crates/core/src/models/skill_model.rs b/src-tauri/crates/core/src/models/skill_model.rs index 63686de3b..194c1e1c7 100644 --- a/src-tauri/crates/core/src/models/skill_model.rs +++ b/src-tauri/crates/core/src/models/skill_model.rs @@ -7,6 +7,7 @@ use std::path::Path; const SKILL_FRONTMATTER_NAME: &str = "name"; const SKILL_FRONTMATTER_DESCRIPTION: &str = "description"; const SKILL_FRONTMATTER_LICENSE: &str = "license"; +const SKILL_FRONTMATTER_COMPATIBILITY: &str = "compatibility"; const SKILL_FRONTMATTER_METADATA: &str = "metadata"; const SKILL_FRONTMATTER_ALLOWED_TOOLS: &str = "allowed-tools"; const SKILL_FRONTMATTER_ALLOWED_TOOLS_ALIAS: &str = "allowed_tools"; @@ -127,6 +128,8 @@ pub struct Skill { pub repo_branch: Option, #[serde(skip_serializing_if = "Option::is_none")] pub license: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub compatibility: Option, #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub metadata: HashMap, #[serde( @@ -160,6 +163,7 @@ pub struct SkillMetadata { pub name: Option, pub description: Option, pub license: Option, + pub compatibility: Option, #[serde(default)] pub metadata: HashMap, #[serde(default)] @@ -199,6 +203,8 @@ pub struct SkillPackageInspection { pub content: String, #[serde(skip_serializing_if = "Option::is_none")] pub license: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub compatibility: Option, #[serde(default)] pub metadata: HashMap, #[serde(rename = "allowedTools", default)] @@ -249,6 +255,7 @@ pub fn parse_skill_manifest_from_content(content: &str) -> Result Result Result split_allowed_tools_csv(single), + serde_yaml::Value::String(single) => split_allowed_tools_string(single), serde_yaml::Value::Sequence(values) => { let mut tools = Vec::new(); for item in values { @@ -394,9 +407,14 @@ fn parse_allowed_tools_field( } } -fn split_allowed_tools_csv(value: &str) -> Vec { +fn split_allowed_tools_string(value: &str) -> Vec { + let separator: &[_] = if value.contains(',') { + &[','] + } else { + &[' ', '\t', '\n', '\r'] + }; value - .split(',') + .split(separator) .map(|item| item.trim()) .filter(|item| !item.is_empty()) .map(ToString::to_string) @@ -648,4 +666,59 @@ mod tests { SkillSourceKind::Other ); } + + #[test] + fn parse_manifest_supports_agent_skills_standard_optional_fields() { + let manifest = parse_skill_manifest_from_content( + r#"--- +name: docx +description: Use when working with Word .docx files. +license: Proprietary. LICENSE.txt has complete terms +compatibility: Requires Node.js and Python. +metadata: + author: anthropic +allowed-tools: Bash(git:*) Bash(jq:*) Read +--- + +# DOCX +"#, + ) + .unwrap(); + + assert_eq!(manifest.metadata.name.as_deref(), Some("docx")); + assert_eq!( + manifest.metadata.compatibility.as_deref(), + Some("Requires Node.js and Python.") + ); + assert_eq!( + manifest.metadata.allowed_tools, + vec![ + "Bash(git:*)".to_string(), + "Bash(jq:*)".to_string(), + "Read".to_string() + ] + ); + assert!(manifest.compliance.validation_errors.is_empty()); + } + + #[test] + fn parse_manifest_keeps_legacy_comma_allowed_tools_compatibility() { + let manifest = parse_skill_manifest_from_content( + r#"--- +name: image-generate +description: Generate images. +allowed-tools: search_query, lime_create_image_generation_task +--- +"#, + ) + .unwrap(); + + assert_eq!( + manifest.metadata.allowed_tools, + vec![ + "search_query".to_string(), + "lime_create_image_generation_task".to_string() + ] + ); + } } diff --git a/src-tauri/crates/services/src/skill_service.rs b/src-tauri/crates/services/src/skill_service.rs index d9d0cfa3e..da2758f0d 100644 --- a/src-tauri/crates/services/src/skill_service.rs +++ b/src-tauri/crates/services/src/skill_service.rs @@ -1,6 +1,7 @@ use anyhow::{anyhow, Context, Result}; use parking_lot::{Mutex, RwLock}; -use reqwest::Client; +use reqwest::{header, Client}; +use serde::Deserialize; use std::collections::HashMap; use std::fs; use std::path::{Component, Path, PathBuf}; @@ -20,6 +21,7 @@ const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(60); const REMOTE_SKILLS_LIST_TIMEOUT: Duration = Duration::from_secs(8); const REMOTE_SKILLS_CACHE_TTL: Duration = Duration::from_secs(300); const REMOTE_SKILLS_ERROR_CACHE_TTL: Duration = Duration::from_secs(120); +const GITHUB_CONTENTS_USER_AGENT: &str = "lime-skill-service"; #[derive(Debug, Clone, PartialEq, Eq, Hash)] struct RepoCacheKey { @@ -96,6 +98,14 @@ struct RemoteSkillArchiveEntry { resource_summary: SkillResourceSummary, } +#[derive(Debug, Clone, Deserialize)] +struct GithubContentItem { + path: String, + #[serde(rename = "type")] + kind: String, + download_url: Option, +} + #[derive(Debug, Clone)] struct CatalogSkillRoot { source: SkillCatalogSource, @@ -146,12 +156,18 @@ impl SkillService { match app_type { AppType::Lime => { let mut roots = Vec::new(); - if let Some(project_dir) = app_paths::resolve_project_skills_dir() { + for project_dir in app_paths::resolve_lime_project_skill_roots() { roots.push(CatalogSkillRoot { source: SkillCatalogSource::Project, path: project_dir, }); } + for user_dir in app_paths::resolve_lime_user_skill_roots() { + roots.push(CatalogSkillRoot { + source: SkillCatalogSource::User, + path: user_dir, + }); + } roots.push(CatalogSkillRoot { source: SkillCatalogSource::User, path: app_paths::resolve_skills_dir().map_err(|e| anyhow!(e))?, @@ -402,7 +418,20 @@ impl SkillService { } async fn fetch_skills_from_branch(&self, repo: &SkillRepo, branch: &str) -> Result> { - let manifests = self.load_remote_skill_archive_entries(repo, branch).await?; + let manifests = match self.load_remote_skill_contents_entries(repo, branch).await { + Ok(entries) if !entries.is_empty() => entries, + Ok(_) => self.load_remote_skill_archive_entries(repo, branch).await?, + Err(error) => { + tracing::debug!( + "[SkillService] GitHub contents API 读取技能仓库 {}/{}@{} 失败,回退 ZIP: {}", + repo.owner, + repo.name, + branch, + error + ); + self.load_remote_skill_archive_entries(repo, branch).await? + } + }; let mut skills = Vec::new(); let repo_key_prefix = format!("{}/{}:", repo.owner, repo.name); for entry in manifests.into_values() { @@ -428,6 +457,203 @@ impl SkillService { Ok(skills) } + async fn load_remote_skill_contents_entries( + &self, + repo: &SkillRepo, + branch: &str, + ) -> Result> { + let mut manifests = HashMap::new(); + + if let Ok(skill_roots) = self.list_github_contents(repo, branch, "skills").await { + for item in skill_roots.into_iter().filter(|item| item.kind == "dir") { + if let Ok(entry) = self + .load_remote_skill_contents_entry(repo, branch, &item.path) + .await + { + manifests.insert(entry.readme_parent.clone(), entry); + } + } + if !manifests.is_empty() { + return Ok(manifests); + } + } + + let root_items = self.list_github_contents(repo, branch, "").await?; + for item in root_items.into_iter().filter(|item| item.kind == "dir") { + if let Ok(entry) = self + .load_remote_skill_contents_entry(repo, branch, &item.path) + .await + { + manifests.insert(entry.readme_parent.clone(), entry); + } + } + + Ok(manifests) + } + + async fn load_remote_skill_contents_entry( + &self, + repo: &SkillRepo, + branch: &str, + skill_path: &str, + ) -> Result { + let mut entry = RemoteSkillArchiveEntry { + directory: skill_path + .rsplit('/') + .next() + .filter(|value| !value.is_empty()) + .ok_or_else(|| anyhow!("Skill directory not found in GitHub contents path"))? + .to_string(), + content: String::new(), + readme_parent: skill_path.trim_matches('/').to_string(), + files: HashMap::new(), + resource_summary: SkillResourceSummary::default(), + }; + let mut pending_dirs = vec![entry.readme_parent.clone()]; + + while let Some(current_path) = pending_dirs.pop() { + let items = self + .list_github_contents(repo, branch, ¤t_path) + .await + .with_context(|| format!("Failed to list GitHub contents path {current_path}"))?; + + for item in items { + match item.kind.as_str() { + "dir" => pending_dirs.push(item.path), + "file" => { + let Some(relative_path) = + Self::github_skill_relative_path(&entry.readme_parent, &item.path) + else { + continue; + }; + let bytes = self.download_github_content_file(&item).await?; + + if relative_path == Path::new("SKILL.md") { + entry.content = + String::from_utf8(bytes).context("Failed to read SKILL.md")?; + continue; + } + + Self::mark_resource_summary(&mut entry.resource_summary, &relative_path); + entry.files.insert(relative_path, bytes); + } + _ => {} + } + } + } + + if entry.content.is_empty() { + return Err(anyhow!("Skill directory not found in GitHub contents")); + } + + Ok(entry) + } + + async fn list_github_contents( + &self, + repo: &SkillRepo, + branch: &str, + path: &str, + ) -> Result> { + let encoded_ref = urlencoding::encode(branch); + let url = if path.trim().is_empty() { + format!( + "https://api.github.com/repos/{}/{}/contents?ref={}", + repo.owner, repo.name, encoded_ref + ) + } else { + format!( + "https://api.github.com/repos/{}/{}/contents/{}?ref={}", + repo.owner, + repo.name, + path.trim_matches('/'), + encoded_ref + ) + }; + + let response = self + .client + .get(&url) + .header(header::USER_AGENT, GITHUB_CONTENTS_USER_AGENT) + .header(header::ACCEPT, "application/vnd.github+json") + .send() + .await + .with_context(|| format!("Failed to request GitHub contents: {url}"))?; + + if !response.status().is_success() { + return Err(anyhow!("HTTP {}: {}", response.status(), url)); + } + + response + .json::>() + .await + .with_context(|| format!("Failed to parse GitHub contents: {url}")) + } + + async fn download_github_content_file(&self, item: &GithubContentItem) -> Result> { + let download_url = item + .download_url + .as_deref() + .ok_or_else(|| anyhow!("GitHub content file missing download_url: {}", item.path))?; + let response = self + .client + .get(download_url) + .header(header::USER_AGENT, GITHUB_CONTENTS_USER_AGENT) + .send() + .await + .with_context(|| format!("Failed to download GitHub content: {}", item.path))?; + + if !response.status().is_success() { + return Err(anyhow!("HTTP {}: {}", response.status(), item.path)); + } + + Ok(response + .bytes() + .await + .with_context(|| format!("Failed to read GitHub content: {}", item.path))? + .to_vec()) + } + + fn github_skill_relative_path(skill_root: &str, path: &str) -> Option { + let relative = path + .strip_prefix(skill_root.trim_matches('/'))? + .trim_start_matches('/'); + if relative.is_empty() { + return None; + } + + let relative_path = PathBuf::from(relative); + if relative_path + .components() + .all(|component| matches!(component, Component::Normal(_))) + { + Some(relative_path) + } else { + None + } + } + + fn mark_resource_summary(summary: &mut SkillResourceSummary, relative_path: &Path) { + let Some(resource_dir) = + relative_path + .components() + .next() + .and_then(|component| match component { + Component::Normal(value) => Some(value.to_string_lossy().to_string()), + _ => None, + }) + else { + return; + }; + + match resource_dir.as_str() { + "scripts" => summary.has_scripts = true, + "references" => summary.has_references = true, + "assets" => summary.has_assets = true, + _ => {} + } + } + async fn load_remote_skill_archive_entries( &self, repo: &SkillRepo, @@ -458,20 +684,18 @@ impl SkillService { let mut file = archive .by_index(index) .context("Failed to read ZIP entry")?; - let archive_path = Path::new(file.name()); + let archive_path = PathBuf::from(file.name()); let Some((directory, relative_path)) = - Self::skill_file_marker_from_archive_path(archive_path) + Self::skill_file_marker_from_archive_path(&archive_path) else { continue; }; - let readme_parent = archive_path - .parent() - .map(|parent| parent.to_string_lossy().to_string()) - .unwrap_or_default(); + let readme_parent = + Self::skill_readme_parent_from_archive_path(&archive_path).unwrap_or_default(); let entry = manifests - .entry(directory.clone()) + .entry(readme_parent.clone()) .or_insert_with(|| RemoteSkillArchiveEntry { directory: directory.clone(), content: String::new(), @@ -532,9 +756,13 @@ impl SkillService { return None; } - let directory = components.get(1)?.clone(); + let is_nested_skills_repo = + components.get(1).is_some_and(|value| value == "skills") && components.len() >= 4; + let directory_index = if is_nested_skills_repo { 2 } else { 1 }; + let relative_path_start = directory_index + 1; + let directory = components.get(directory_index)?.clone(); let mut relative_path = PathBuf::new(); - for component in components.iter().skip(2) { + for component in components.iter().skip(relative_path_start) { relative_path.push(component); } if relative_path.as_os_str().is_empty() { @@ -544,6 +772,31 @@ impl SkillService { Some((directory, relative_path)) } + fn skill_readme_parent_from_archive_path(path: &Path) -> Option { + let components: Vec = path + .components() + .filter_map(|component| match component { + Component::Normal(value) => Some(value.to_string_lossy().to_string()), + _ => None, + }) + .collect(); + + if components.len() < 3 { + return None; + } + + let is_nested_skills_repo = + components.get(1).is_some_and(|value| value == "skills") && components.len() >= 4; + let directory_index = if is_nested_skills_repo { 2 } else { 1 }; + let relative_path_start = directory_index + 1; + let parent_components = components.get(1..relative_path_start)?; + if parent_components.is_empty() { + return None; + } + + Some(parent_components.join("/")) + } + #[cfg(test)] fn skill_resource_marker_from_archive_path(path: &Path) -> Option<(String, String)> { let (directory, relative_path) = Self::skill_file_marker_from_archive_path(path)?; @@ -597,6 +850,35 @@ impl SkillService { let mut last_error = None; for branch in branches { + let repo = SkillRepo { + owner: repo_owner.to_string(), + name: repo_name.to_string(), + branch: branch.to_string(), + enabled: true, + }; + match self + .download_github_skill_contents(&repo, branch, &target_dir, directory) + .await + { + Ok(()) => match self.validate_installed_skill_dir(&target_dir) { + Ok(()) => return Ok(()), + Err(error) => { + let _ = fs::remove_dir_all(&target_dir); + tracing::debug!( + "[SkillService] GitHub contents 安装后校验失败,回退 ZIP: {}", + error + ); + } + }, + Err(error) => { + let _ = fs::remove_dir_all(&target_dir); + tracing::debug!( + "[SkillService] GitHub contents 安装失败,回退 ZIP: {}", + error + ); + } + } + let zip_url = format!( "https://github.com/{repo_owner}/{repo_name}/archive/refs/heads/{branch}.zip" ); @@ -634,11 +916,32 @@ impl SkillService { enabled: true, }; + match self + .load_remote_skill_contents_entry_for_directory(&repo, &branch, directory) + .await + { + Ok(entry) => { + return Ok(Self::inspect_remote_skill_package( + &entry.content, + entry.resource_summary, + &entry.files, + )); + } + Err(error) => { + tracing::debug!( + "[SkillService] inspect remote skill via GitHub contents failed: {}", + error + ); + } + } + match self.load_remote_skill_archive_entries(&repo, &branch).await { Ok(entries) => { let entry = entries .into_values() - .find(|entry| entry.directory == directory) + .find(|entry| { + entry.directory == directory || entry.readme_parent == directory + }) .ok_or_else(|| anyhow!("Skill directory not found in archive"))?; return Ok(Self::inspect_remote_skill_package( @@ -670,6 +973,68 @@ impl SkillService { )) } + async fn load_remote_skill_contents_entry_for_directory( + &self, + repo: &SkillRepo, + branch: &str, + directory: &str, + ) -> Result { + let mut last_error = None; + for candidate in Self::remote_skill_path_candidates(directory) { + match self + .load_remote_skill_contents_entry(repo, branch, &candidate) + .await + { + Ok(entry) => return Ok(entry), + Err(error) => last_error = Some(error), + } + } + + Err(last_error.unwrap_or_else(|| anyhow!("Skill directory not found in GitHub contents"))) + } + + async fn download_github_skill_contents( + &self, + repo: &SkillRepo, + branch: &str, + target_dir: &Path, + directory: &str, + ) -> Result<()> { + let entry = self + .load_remote_skill_contents_entry_for_directory(repo, branch, directory) + .await?; + Self::write_remote_skill_entry_to_dir(&entry, target_dir) + } + + fn write_remote_skill_entry_to_dir( + entry: &RemoteSkillArchiveEntry, + target_dir: &Path, + ) -> Result<()> { + fs::create_dir_all(target_dir).context("Failed to create skill directory")?; + fs::write(target_dir.join("SKILL.md"), entry.content.as_bytes()) + .context("Failed to write SKILL.md")?; + + for (relative_path, bytes) in &entry.files { + let output_path = target_dir.join(relative_path); + if let Some(parent) = output_path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(&output_path, bytes) + .with_context(|| format!("Failed to write {}", output_path.display()))?; + } + + Ok(()) + } + + fn remote_skill_path_candidates(directory: &str) -> Vec { + let directory = directory.trim_matches('/').to_string(); + if directory.contains('/') { + return vec![directory]; + } + + vec![directory.clone(), format!("skills/{directory}")] + } + async fn download_and_extract( &self, zip_url: &str, @@ -691,28 +1056,27 @@ impl SkillService { let cursor = std::io::Cursor::new(bytes); let mut archive = zip::ZipArchive::new(cursor).context("Failed to open ZIP")?; - let skill_prefix = format!("/{directory}/"); + let selected_root = Self::select_remote_skill_root(&mut archive, directory)?; let mut found = false; for index in 0..archive.len() { let mut file = archive.by_index(index)?; - let file_path = file.name().to_string(); + let archive_path = PathBuf::from(file.name()); + let Some((entry_directory, relative_path)) = + Self::skill_file_marker_from_archive_path(&archive_path) + else { + continue; + }; - if !file_path.contains(&skill_prefix) { + let readme_parent = + Self::skill_readme_parent_from_archive_path(&archive_path).unwrap_or_default(); + if readme_parent != selected_root + && !(selected_root.is_empty() && entry_directory == directory) + { continue; } found = true; - let relative_path = file_path - .split(&skill_prefix) - .nth(1) - .unwrap_or("") - .to_string(); - - if relative_path.is_empty() { - continue; - } - let output_path = target_dir.join(&relative_path); if file.is_dir() { fs::create_dir_all(&output_path)?; @@ -733,6 +1097,55 @@ impl SkillService { Ok(()) } + fn select_remote_skill_root( + archive: &mut zip::ZipArchive, + directory: &str, + ) -> Result { + let mut candidates = Vec::new(); + for index in 0..archive.len() { + let file = archive.by_index(index)?; + let archive_path = PathBuf::from(file.name()); + let Some((entry_directory, relative_path)) = + Self::skill_file_marker_from_archive_path(&archive_path) + else { + continue; + }; + if relative_path != Path::new("SKILL.md") { + continue; + } + let readme_parent = + Self::skill_readme_parent_from_archive_path(&archive_path).unwrap_or_default(); + if readme_parent == directory || entry_directory == directory { + candidates.push(readme_parent); + } + } + + if candidates.is_empty() { + return Err(anyhow!("Skill directory not found in archive")); + } + + candidates.sort(); + if let Some(exact) = candidates + .iter() + .find(|candidate| candidate.as_str() == directory) + { + return Ok(exact.clone()); + } + + let nested_root = format!("skills/{directory}"); + if let Some(nested) = candidates + .iter() + .find(|candidate| candidate.as_str() == nested_root) + { + return Ok(nested.clone()); + } + + candidates + .into_iter() + .next() + .ok_or_else(|| anyhow!("Skill directory not found in archive")) + } + pub fn uninstall_skill(app_type: &AppType, directory: &str) -> Result<()> { let skills_dir = Self::get_skills_dir(app_type)?; let target_dir = skills_dir.join(directory); @@ -793,15 +1206,17 @@ impl SkillService { where F: FnMut(&str) -> Result, { - let (license, metadata, allowed_tools, mut standard_compliance) = + let (license, compatibility, metadata, allowed_tools, mut standard_compliance) = match parse_manifest_content(content) { Ok(manifest) => ( manifest.metadata.license, + manifest.metadata.compatibility, manifest.metadata.metadata, manifest.metadata.allowed_tools, manifest.compliance, ), Err(error) => ( + None, None, HashMap::new(), Vec::new(), @@ -825,6 +1240,7 @@ impl SkillService { SkillPackageInspection { content: content.to_string(), license, + compatibility, metadata, allowed_tools, resource_summary, @@ -1044,6 +1460,7 @@ impl SkillService { repo_name, repo_branch, license: inspection.license, + compatibility: inspection.compatibility, metadata: inspection.metadata, allowed_tools: inspection.allowed_tools, resource_summary: Some(inspection.resource_summary), @@ -1061,9 +1478,25 @@ mod tests { use super::{CatalogSkillRoot, SkillService}; use lime_core::models::{AppType, SkillCatalogSource}; use std::collections::HashMap; + use std::io::{Cursor, Write}; use std::path::{Path, PathBuf}; use tempfile::TempDir; + fn build_test_zip_archive(entries: &[(&str, &str)]) -> zip::ZipArchive>> { + let mut buffer = Cursor::new(Vec::new()); + { + let mut writer = zip::ZipWriter::new(&mut buffer); + let options = zip::write::FileOptions::default(); + for (path, content) in entries { + writer.start_file(*path, options).unwrap(); + writer.write_all(content.as_bytes()).unwrap(); + } + writer.finish().unwrap(); + } + buffer.set_position(0); + zip::ZipArchive::new(buffer).unwrap() + } + #[test] fn build_branch_candidates_should_include_main_master_fallback() { assert_eq!( @@ -1093,6 +1526,110 @@ mod tests { ); } + #[test] + fn archive_marker_should_support_anthropic_skills_nested_root() { + let path = Path::new("skills-main/skills/docx/scripts/create_docx.py"); + + let marker = SkillService::skill_file_marker_from_archive_path(path); + assert_eq!( + marker, + Some(( + "docx".to_string(), + PathBuf::from("scripts").join("create_docx.py") + )) + ); + + let readme_parent = SkillService::skill_readme_parent_from_archive_path(path); + assert_eq!(readme_parent.as_deref(), Some("skills/docx")); + } + + #[test] + fn archive_marker_should_keep_root_level_skill_compatibility() { + let path = Path::new("repo-main/docx/scripts/create_docx.py"); + + let marker = SkillService::skill_file_marker_from_archive_path(path); + assert_eq!( + marker, + Some(( + "docx".to_string(), + PathBuf::from("scripts").join("create_docx.py") + )) + ); + + let readme_parent = SkillService::skill_readme_parent_from_archive_path(path); + assert_eq!(readme_parent.as_deref(), Some("docx")); + } + + #[test] + fn select_remote_skill_root_should_match_anthropic_nested_skill_by_local_directory() { + let mut archive = build_test_zip_archive(&[ + ("skills-main/skills/docx/SKILL.md", "---\nname: docx\n---\n"), + ( + "skills-main/skills/docx/scripts/create_docx.py", + "print('docx')", + ), + ]); + + let selected = SkillService::select_remote_skill_root(&mut archive, "docx").unwrap(); + + assert_eq!(selected, "skills/docx"); + } + + #[test] + fn select_remote_skill_root_should_match_explicit_remote_path() { + let mut archive = build_test_zip_archive(&[ + ("skills-main/skills/docx/SKILL.md", "---\nname: docx\n---\n"), + ("skills-main/skills/pptx/SKILL.md", "---\nname: pptx\n---\n"), + ]); + + let selected = SkillService::select_remote_skill_root(&mut archive, "skills/docx").unwrap(); + + assert_eq!(selected, "skills/docx"); + } + + #[test] + fn remote_skill_path_candidates_should_prefer_root_then_standard_skills_path() { + assert_eq!( + SkillService::remote_skill_path_candidates("docx"), + vec!["docx".to_string(), "skills/docx".to_string()] + ); + assert_eq!( + SkillService::remote_skill_path_candidates("skills/docx"), + vec!["skills/docx".to_string()] + ); + } + + #[test] + fn github_skill_relative_path_should_strip_selected_skill_root_safely() { + assert_eq!( + SkillService::github_skill_relative_path( + "skills/docx", + "skills/docx/scripts/create_docx.py" + ), + Some(PathBuf::from("scripts").join("create_docx.py")) + ); + assert_eq!( + SkillService::github_skill_relative_path("skills/docx", "skills/docx"), + None + ); + } + + #[tokio::test] + #[ignore = "live GitHub smoke for the official anthropics/skills docx package"] + async fn inspect_remote_anthropic_docx_live_smoke() { + let service = SkillService::new().unwrap(); + + let inspection = service + .inspect_remote_skill("anthropics", "skills", "main", "skills/docx") + .await + .unwrap(); + + assert!(inspection.content.contains("name: docx")); + assert!(inspection.content.contains("Word documents")); + assert!(inspection.resource_summary.has_scripts); + assert!(inspection.standard_compliance.validation_errors.is_empty()); + } + #[test] fn inspect_skill_dir_should_collect_standard_metadata_and_workflow_state() { let temp_dir = TempDir::new().unwrap(); @@ -1105,11 +1642,11 @@ mod tests { name: Social Post description: Generate social posts license: MIT +compatibility: Requires network access for publishing. metadata: lime_workflow_ref: references/workflow.json lime_category: social -allowed-tools: - - web.search +allowed-tools: Bash(git:*) Bash(jq:*) Read --- # Social Post @@ -1125,11 +1662,22 @@ allowed-tools: let inspection = SkillService::inspect_skill_dir(&skill_dir).unwrap(); assert_eq!(inspection.license.as_deref(), Some("MIT")); + assert_eq!( + inspection.compatibility.as_deref(), + Some("Requires network access for publishing.") + ); assert_eq!( inspection.metadata.get("lime_category").map(String::as_str), Some("social") ); - assert_eq!(inspection.allowed_tools, vec!["web.search".to_string()]); + assert_eq!( + inspection.allowed_tools, + vec![ + "Bash(git:*)".to_string(), + "Bash(jq:*)".to_string(), + "Read".to_string() + ] + ); assert!(inspection.resource_summary.has_references); assert!(inspection.standard_compliance.is_standard); assert!(inspection.standard_compliance.validation_errors.is_empty()); diff --git a/src-tauri/crates/skills/src/skill_loader.rs b/src-tauri/crates/skills/src/skill_loader.rs index d85a87ae1..0f92eab65 100644 --- a/src-tauri/crates/skills/src/skill_loader.rs +++ b/src-tauri/crates/skills/src/skill_loader.rs @@ -52,6 +52,7 @@ pub struct SkillFrontmatter { pub name: Option, pub description: Option, pub license: Option, + pub compatibility: Option, #[serde(default)] pub metadata: HashMap, pub allowed_tools: Option>, @@ -78,6 +79,7 @@ pub struct LoadedSkillDefinition { pub description: String, pub markdown_content: String, pub license: Option, + pub compatibility: Option, pub metadata: HashMap, pub allowed_tools: Option>, pub argument_hint: Option, @@ -167,6 +169,7 @@ fn build_skill_frontmatter_from_manifest(parsed: &ParsedSkillManifest) -> SkillF name: parsed.metadata.name.clone(), description: parsed.metadata.description.clone(), license: parsed.metadata.license.clone(), + compatibility: parsed.metadata.compatibility.clone(), metadata, allowed_tools: (!parsed.metadata.allowed_tools.is_empty()) .then(|| parsed.metadata.allowed_tools.clone()), @@ -197,7 +200,12 @@ pub fn parse_allowed_tools(value: Option<&str>) -> Option> { .collect(), ) } else { - Some(vec![v.trim().to_string()]) + Some( + v.split_whitespace() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(), + ) } }) } @@ -288,6 +296,7 @@ pub fn load_skill_from_file( .map_err(|e| format!("检查 Skill 包失败: {}", e))?; frontmatter.license = inspection.license.clone(); + frontmatter.compatibility = inspection.compatibility.clone(); frontmatter.metadata = inspection.metadata.clone(); if !inspection.allowed_tools.is_empty() { frontmatter.allowed_tools = Some(inspection.allowed_tools.clone()); @@ -340,6 +349,7 @@ pub fn load_skill_from_file( description, markdown_content, license: inspection.license, + compatibility: inspection.compatibility, metadata: frontmatter.metadata, allowed_tools, argument_hint: frontmatter.argument_hint, @@ -432,9 +442,25 @@ pub fn find_skill_by_name(skill_name: &str) -> Result Result { .map_err(|e| format!("MachineIdService 初始化失败: {e}"))?; let machine_id_service_state: MachineIdState = Arc::new(RwLock::new(machine_id_service)); - // 插件管理器 - let plugin_manager = plugin::PluginManager::with_defaults(); - let plugin_manager_state = PluginManagerState(Arc::new(RwLock::new(plugin_manager))); - - // 插件安装器 - let plugin_installer_state = init_plugin_installer()?; - - // 插件 RPC 管理器 - let plugin_rpc_manager_state = crate::commands::plugin_rpc_cmd::PluginRpcManagerState::new(); - // 遥测系统 let (telemetry_state, shared_stats, shared_tokens, shared_logger) = init_telemetry(config)?; @@ -255,9 +239,6 @@ pub fn init_states(config: &Config) -> Result { skill_service: skill_service_state, api_key_provider_service: api_key_provider_service_state, machine_id_service: machine_id_service_state, - plugin_manager: plugin_manager_state, - plugin_installer: plugin_installer_state, - plugin_rpc_manager: plugin_rpc_manager_state, telemetry: telemetry_state, aster_agent: aster_agent_state, connect_state, @@ -296,42 +277,6 @@ fn build_context_memory_config(config: &Config) -> ContextMemoryConfig { context_config } -/// 初始化插件安装器 -fn init_plugin_installer() -> Result { - let db_path = database::get_db_path().map_err(|e| format!("获取数据库路径失败: {e}"))?; - let plugins_dir = lime_core::app_paths::best_effort_runtime_subdir("plugins"); - let temp_dir = std::env::temp_dir().join("lime_plugin_install"); - - let _ = std::fs::create_dir_all(&plugins_dir); - let _ = std::fs::create_dir_all(&temp_dir); - - match plugin::installer::PluginInstaller::from_paths( - plugins_dir.clone(), - temp_dir.clone(), - &db_path, - ) { - Ok(installer) => { - tracing::info!("[启动] 插件安装器初始化成功"); - Ok(PluginInstallerState(Arc::new(RwLock::new(installer)))) - } - Err(e) => { - tracing::error!("[启动] 插件安装器初始化失败: {}", e); - // 使用临时目录作为后备 - let fallback_plugins_dir = std::env::temp_dir().join("lime_plugins_fallback"); - let fallback_temp_dir = std::env::temp_dir().join("lime_plugin_install_fallback"); - let _ = std::fs::create_dir_all(&fallback_plugins_dir); - let _ = std::fs::create_dir_all(&fallback_temp_dir); - let installer = plugin::installer::PluginInstaller::from_paths( - fallback_plugins_dir, - fallback_temp_dir, - &db_path, - ) - .map_err(|e| format!("后备插件安装器初始化失败: {e}"))?; - Ok(PluginInstallerState(Arc::new(RwLock::new(installer)))) - } - } -} - /// 初始化遥测系统 fn init_telemetry(config: &Config) -> Result { let shared_stats = Arc::new(parking_lot::RwLock::new( diff --git a/src-tauri/src/app/runner.rs b/src-tauri/src/app/runner.rs index 613acbaef..208be0b59 100644 --- a/src-tauri/src/app/runner.rs +++ b/src-tauri/src/app/runner.rs @@ -133,9 +133,6 @@ pub fn run() { skill_service: skill_service_state, api_key_provider_service: api_key_provider_service_state, machine_id_service: machine_id_service_state, - plugin_manager: plugin_manager_state, - plugin_installer: plugin_installer_state, - plugin_rpc_manager: plugin_rpc_manager_state, telemetry: telemetry_state, aster_agent: aster_agent_state, connect_state, @@ -242,9 +239,6 @@ pub fn run() { .manage(api_key_provider_service_state) .manage(machine_id_service_state) .manage(telemetry_state) - .manage(plugin_manager_state) - .manage(plugin_installer_state) - .manage(plugin_rpc_manager_state) .manage(aster_agent_state) .manage(connect_state) .manage(model_registry_state) @@ -404,21 +398,6 @@ pub fn run() { tracing::info!("[启动] MCP Manager 事件发射器已设置"); } - // 设置 PluginManager 的任务事件发射器(用于发送 plugin-task-event) - if let Some(plugin_manager) = - app.try_state::() - { - let app_handle = app.handle().clone(); - let emitter = lime_core::DynEmitter::new( - crate::app::TauriEventEmitter(app_handle), - ); - tauri::async_runtime::block_on(async { - let manager = plugin_manager.0.read().await; - manager.set_task_emitter(emitter).await; - }); - tracing::info!("[启动] PluginManager 任务事件发射器已设置"); - } - let startup_runtime_resume = { let aster_agent_state = app.try_state::(); let db_state = app.try_state::(); @@ -1200,39 +1179,6 @@ pub fn run() { commands::security_perf_cmd::get_hint_routes, // Tray commands commands::tray_cmd::sync_tray_model_shortcuts, - // Plugin commands - commands::plugin_cmd::get_plugin_status, - commands::plugin_cmd::get_plugins, - commands::plugin_cmd::get_plugin_info, - commands::plugin_cmd::enable_plugin, - commands::plugin_cmd::disable_plugin, - commands::plugin_cmd::update_plugin_config, - commands::plugin_cmd::get_plugin_config, - commands::plugin_cmd::reload_plugins, - commands::plugin_cmd::unload_plugin, - commands::plugin_cmd::get_plugins_dir, - commands::plugin_cmd::list_plugin_tasks, - commands::plugin_cmd::get_plugin_task, - commands::plugin_cmd::cancel_plugin_task, - commands::plugin_cmd::get_plugin_queue_stats, - // Plugin Install commands - commands::plugin_install_cmd::install_plugin_from_file, - commands::plugin_install_cmd::install_plugin_from_url, - commands::plugin_install_cmd::uninstall_plugin, - commands::plugin_install_cmd::list_installed_plugins, - commands::plugin_install_cmd::get_installed_plugin, - commands::plugin_install_cmd::is_plugin_installed, - // Plugin UI commands - commands::plugin_cmd::get_plugins_with_ui, - commands::plugin_cmd::get_plugin_ui, - commands::plugin_cmd::handle_plugin_action, - commands::plugin_cmd::read_plugin_manifest_cmd, - commands::plugin_cmd::launch_plugin_ui, - commands::plugin_cmd::frontend_debug_log, - // Plugin RPC commands - commands::plugin_rpc_cmd::plugin_rpc_connect, - commands::plugin_rpc_cmd::plugin_rpc_disconnect, - commands::plugin_rpc_cmd::plugin_rpc_call, // Window control commands commands::window_cmd::get_window_size, commands::window_cmd::set_window_size, diff --git a/src-tauri/src/app/state.rs b/src-tauri/src/app/state.rs index 2bde9e5d3..779e561a9 100644 --- a/src-tauri/src/app/state.rs +++ b/src-tauri/src/app/state.rs @@ -8,14 +8,10 @@ use tokio::sync::RwLock; use crate::commands::api_key_provider_cmd::ApiKeyProviderServiceState; use crate::commands::context_memory::ContextMemoryServiceState; use crate::commands::machine_id_cmd::MachineIdState; -use crate::commands::plugin_cmd::PluginManagerState; -use crate::commands::plugin_install_cmd::PluginInstallerState; use crate::commands::skill_cmd::SkillServiceState; use crate::config::{GlobalConfigManager, GlobalConfigManagerState}; -use crate::database; -use crate::plugin; use crate::telemetry; -use lime_core::config::{Config, ConfigManager}; +use lime_core::config::{self, Config, ConfigManager}; use lime_services::api_key_provider_service::ApiKeyProviderService; use lime_services::context_memory_service::{ContextMemoryConfig, ContextMemoryService}; use lime_services::skill_service::SkillService; @@ -45,8 +41,6 @@ pub struct ServiceStates { pub skill_service: SkillServiceState, pub api_key_provider_service: ApiKeyProviderServiceState, pub machine_id_service: MachineIdState, - pub plugin_manager: PluginManagerState, - pub plugin_installer: PluginInstallerState, pub context_memory_service: ContextMemoryServiceState, } @@ -66,15 +60,8 @@ pub fn init_service_states() -> ServiceStates { .expect("Failed to initialize MachineIdService"); let machine_id_service_state: MachineIdState = Arc::new(RwLock::new(machine_id_service)); - // Initialize PluginManager - let plugin_manager = plugin::PluginManager::with_defaults(); - let plugin_manager_state = PluginManagerState(Arc::new(RwLock::new(plugin_manager))); - - // Initialize PluginInstaller - let plugin_installer_state = init_plugin_installer(); - // Initialize ContextMemoryService - let app_config = lime_core::config::load_config().unwrap_or_default(); + let app_config = config::load_config().unwrap_or_default(); let context_memory_config = build_context_memory_config(&app_config); let context_memory_service = ContextMemoryService::new(context_memory_config) .expect("Failed to initialize ContextMemoryService"); @@ -84,8 +71,6 @@ pub fn init_service_states() -> ServiceStates { skill_service: skill_service_state, api_key_provider_service: api_key_provider_service_state, machine_id_service: machine_id_service_state, - plugin_manager: plugin_manager_state, - plugin_installer: plugin_installer_state, context_memory_service: context_memory_service_state, } } @@ -109,46 +94,6 @@ fn build_context_memory_config(config: &Config) -> ContextMemoryConfig { context_config } -/// 初始化插件安装器 -fn init_plugin_installer() -> PluginInstallerState { - let db_path = database::get_db_path().expect("Failed to get database path for PluginInstaller"); - let plugins_dir = dirs::data_dir() - .unwrap_or_else(|| std::path::PathBuf::from(".")) - .join("lime") - .join("plugins"); - let temp_dir = std::env::temp_dir().join("lime_plugin_install"); - - // 创建目录(如果不存在) - if let Err(e) = std::fs::create_dir_all(&plugins_dir) { - tracing::warn!("无法创建插件目录: {}", e); - } - if let Err(e) = std::fs::create_dir_all(&temp_dir) { - tracing::warn!("无法创建插件临时目录: {}", e); - } - - match plugin::installer::PluginInstaller::from_paths(plugins_dir, temp_dir, &db_path) { - Ok(installer) => { - tracing::info!("[启动] 插件安装器初始化成功"); - PluginInstallerState(Arc::new(RwLock::new(installer))) - } - Err(e) => { - tracing::error!("[启动] 插件安装器初始化失败: {}", e); - // 创建一个默认的安装器(使用临时目录) - let fallback_plugins_dir = std::env::temp_dir().join("lime_plugins_fallback"); - let fallback_temp_dir = std::env::temp_dir().join("lime_plugin_install_fallback"); - let _ = std::fs::create_dir_all(&fallback_plugins_dir); - let _ = std::fs::create_dir_all(&fallback_temp_dir); - let installer = plugin::installer::PluginInstaller::from_paths( - fallback_plugins_dir, - fallback_temp_dir, - &db_path, - ) - .expect("Failed to create fallback PluginInstaller"); - PluginInstallerState(Arc::new(RwLock::new(installer))) - } - } -} - /// 遥测状态 pub struct TelemetryStates { pub stats: Arc>, diff --git a/src-tauri/src/commands/aster_agent_cmd/action_runtime.rs b/src-tauri/src/commands/aster_agent_cmd/action_runtime.rs index c97139570..ecaef874e 100644 --- a/src-tauri/src/commands/aster_agent_cmd/action_runtime.rs +++ b/src-tauri/src/commands/aster_agent_cmd/action_runtime.rs @@ -1,3 +1,5 @@ +use super::agentruntime_profile::AgentRuntimeProfileStream; +use super::runtime_turn::emit_agent_runtime_profile_event; use super::session_runtime::delete_runtime_session_internal_with_runtime; use super::*; use lime_agent::AgentEvent as RuntimeAgentEvent; @@ -131,6 +133,52 @@ fn build_permission_confirmation_response( }) } +fn action_response_profile_result(request: &AgentRuntimeRespondActionRequest) -> &'static str { + if request.confirmed { + "allow" + } else { + "deny" + } +} + +fn action_response_profile_turn_id(request: &AgentRuntimeRespondActionRequest) -> Option { + request + .action_scope + .as_ref() + .and_then(|scope| scope.turn_id.clone()) +} + +fn maybe_emit_action_resolved_profile_event( + app: &AppHandle, + request: &AgentRuntimeRespondActionRequest, +) { + let Some(event_name) = normalize_optional_text(request.event_name.clone()) else { + return; + }; + let Some(scope) = request.action_scope.as_ref() else { + return; + }; + let Some(thread_id) = normalize_optional_text(scope.thread_id.clone()) else { + return; + }; + let turn_id = action_response_profile_turn_id(request) + .unwrap_or_else(|| format!("action_{}", request.request_id)); + let Ok(stream) = AgentRuntimeProfileStream::new(request.session_id.clone(), thread_id, turn_id) + else { + return; + }; + + emit_agent_runtime_profile_event( + app, + event_name.as_str(), + stream.action_resolved( + request.request_id.as_str(), + action_response_profile_result(request), + request.confirmed, + ), + ); +} + fn build_user_lock_capability_response( request: &AgentRuntimeRespondActionRequest, ) -> serde_json::Value { @@ -402,23 +450,31 @@ pub async fn agent_runtime_respond_action( request: AgentRuntimeRespondActionRequest, ) -> Result<(), String> { if is_runtime_permission_confirmation_request_id(&request.request_id) { - return complete_runtime_permission_confirmation_request( + let result = complete_runtime_permission_confirmation_request( &app, normalize_optional_text(request.event_name.clone()).as_deref(), db.inner(), &request, ); + if result.is_ok() { + maybe_emit_action_resolved_profile_event(&app, &request); + } + return result; } if is_runtime_user_lock_capability_request_id(&request.request_id) { - return complete_runtime_user_lock_capability_request( + let result = complete_runtime_user_lock_capability_request( &app, normalize_optional_text(request.event_name.clone()).as_deref(), db.inner(), &request, ); + if result.is_ok() { + maybe_emit_action_resolved_profile_event(&app, &request); + } + return result; } - match request.action_type { + let result = match request.action_type { AgentRuntimeActionType::ToolConfirmation => { confirm_runtime_action_internal( state.inner(), @@ -456,7 +512,11 @@ pub async fn agent_runtime_respond_action( submitted_user_data, ) } + }; + if result.is_ok() { + maybe_emit_action_resolved_profile_event(&app, &request); } + result } async fn submit_runtime_elicitation_response_internal( diff --git a/src-tauri/src/commands/aster_agent_cmd/agentruntime_profile.rs b/src-tauri/src/commands/aster_agent_cmd/agentruntime_profile.rs new file mode 100644 index 000000000..31bf6dd9c --- /dev/null +++ b/src-tauri/src/commands/aster_agent_cmd/agentruntime_profile.rs @@ -0,0 +1,804 @@ +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use uuid::Uuid; + +pub(crate) const LIME_AGENT_RUNTIME_PROFILE_SCHEMA_VERSION: &str = "lime-profile-0.4.0"; +pub(crate) const LIME_AGENT_RUNTIME_ID: &str = "lime_runtime_local"; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AgentRuntimeProfileEvent { + #[serde(rename = "type")] + pub event_type: String, + pub event_id: String, + pub timestamp: String, + pub schema_version: String, + pub runtime_id: String, + pub session_id: String, + pub thread_id: String, + pub turn_id: String, + pub sequence: u64, + pub trace_id: String, + pub payload: Value, +} + +#[derive(Debug, Clone)] +pub(crate) struct AgentRuntimeProfileStream { + session_id: String, + thread_id: String, + turn_id: String, + trace_id: String, + sequence: Arc, +} + +impl AgentRuntimeProfileStream { + pub(crate) fn new( + session_id: impl Into, + thread_id: impl Into, + turn_id: impl Into, + ) -> Result { + let session_id = normalize_required_id(session_id.into(), "sessionId")?; + let thread_id = normalize_required_id(thread_id.into(), "threadId")?; + let turn_id = normalize_required_id(turn_id.into(), "turnId")?; + + Ok(Self { + trace_id: format!("trace_{turn_id}"), + session_id, + thread_id, + turn_id, + sequence: Arc::new(AtomicU64::new(0)), + }) + } + + pub(crate) fn turn_submitted(&self, source: &str) -> AgentRuntimeProfileEvent { + self.next_event( + "turn.submitted", + json!({ + "inputRef": format!("input://{}/user", self.turn_id), + "source": normalize_optional_label(source, "workspace"), + "status": "accepted", + }), + ) + } + + pub(crate) fn turn_started(&self) -> AgentRuntimeProfileEvent { + self.next_event( + "turn.started", + json!({ + "status": "running", + }), + ) + } + + pub(crate) fn model_requested( + &self, + provider_selector: &str, + provider_name: &str, + model_name: &str, + ) -> AgentRuntimeProfileEvent { + self.next_event( + "model.requested", + json!({ + "providerSelector": normalize_optional_label(provider_selector, "unconfigured"), + "providerName": normalize_optional_label(provider_name, "unconfigured"), + "modelName": normalize_optional_label(model_name, "unconfigured"), + "status": "started", + }), + ) + } + + pub(crate) fn model_completed( + &self, + provider_selector: &str, + provider_name: &str, + model_name: &str, + output_chars: usize, + ) -> AgentRuntimeProfileEvent { + self.next_event( + "model.completed", + json!({ + "providerSelector": normalize_optional_label(provider_selector, "unconfigured"), + "providerName": normalize_optional_label(provider_name, "unconfigured"), + "modelName": normalize_optional_label(model_name, "unconfigured"), + "outputChars": output_chars, + "status": "completed", + }), + ) + } + + pub(crate) fn model_failed( + &self, + provider_selector: &str, + provider_name: &str, + model_name: &str, + failure_category: &str, + message: &str, + retryable: bool, + ) -> AgentRuntimeProfileEvent { + self.next_event( + "model.failed", + json!({ + "providerSelector": normalize_optional_label(provider_selector, "unconfigured"), + "providerName": normalize_optional_label(provider_name, "unconfigured"), + "modelName": normalize_optional_label(model_name, "unconfigured"), + "failureCategory": normalize_optional_label(failure_category, "unknown"), + "message": message, + "retryable": retryable, + "status": "failed", + }), + ) + } + + pub(crate) fn turn_completed(&self) -> AgentRuntimeProfileEvent { + self.next_event( + "turn.completed", + json!({ + "status": "completed", + }), + ) + } + + pub(crate) fn turn_failed( + &self, + failure_category: &str, + message: &str, + ) -> AgentRuntimeProfileEvent { + self.next_event( + "turn.failed", + json!({ + "failureCategory": normalize_optional_label(failure_category, "unknown"), + "message": message, + "status": "failed", + }), + ) + } + + pub(crate) fn snapshot_updated(&self, status: &str) -> AgentRuntimeProfileEvent { + self.next_event( + "snapshot.updated", + json!({ + "source": "thread_read_model", + "status": normalize_optional_label(status, "updated"), + }), + ) + } + + pub(crate) fn permission_evaluated( + &self, + decision_id: &str, + result: &str, + scope: Value, + ) -> AgentRuntimeProfileEvent { + self.next_event( + "permission.evaluated", + json!({ + "decisionId": normalize_optional_label(decision_id, "policy_decision_unavailable"), + "result": normalize_policy_result(result), + "scope": scope, + "owner": "AgentPolicy", + }), + ) + } + + pub(crate) fn action_required( + &self, + action_id: &str, + tool_call_id: Option<&str>, + action_type: &str, + decision_kind: &str, + scope: Value, + ) -> AgentRuntimeProfileEvent { + self.next_event( + "action.required", + json!({ + "actionId": normalize_optional_label(action_id, "action_unavailable"), + "toolCallId": normalize_optional_label_option(tool_call_id), + "actionType": normalize_optional_label(action_type, "approval"), + "decisionKind": normalize_optional_label(decision_kind, "ask"), + "scope": scope, + "status": "pending", + "owner": "AgentPolicy", + }), + ) + } + + pub(crate) fn action_resolved( + &self, + action_id: &str, + result: &str, + confirmed: bool, + ) -> AgentRuntimeProfileEvent { + self.next_event( + "action.resolved", + json!({ + "actionId": normalize_optional_label(action_id, "action_unavailable"), + "result": normalize_policy_result(result), + "confirmed": confirmed, + "status": "resolved", + "owner": "AgentPolicy", + }), + ) + } + + pub(crate) fn tool_started( + &self, + tool_call_id: &str, + tool_name: &str, + ) -> AgentRuntimeProfileEvent { + self.next_event( + "tool.started", + json!({ + "toolCallId": normalize_optional_label(tool_call_id, "tool_call_unavailable"), + "toolName": normalize_optional_label(tool_name, "unknown_tool"), + "status": "running", + }), + ) + } + + pub(crate) fn tool_result( + &self, + tool_call_id: &str, + tool_name: &str, + success: bool, + ) -> AgentRuntimeProfileEvent { + self.next_event( + "tool.result", + json!({ + "toolCallId": normalize_optional_label(tool_call_id, "tool_call_unavailable"), + "toolName": normalize_optional_label(tool_name, "unknown_tool"), + "success": success, + "status": "completed", + }), + ) + } + + pub(crate) fn tool_failed( + &self, + tool_call_id: &str, + tool_name: &str, + failure_category: &str, + message: &str, + ) -> AgentRuntimeProfileEvent { + self.next_event( + "tool.failed", + json!({ + "toolCallId": normalize_optional_label(tool_call_id, "tool_call_unavailable"), + "toolName": normalize_optional_label(tool_name, "unknown_tool"), + "failureCategory": normalize_optional_label(failure_category, "tool_error"), + "message": message, + "status": "failed", + }), + ) + } + + pub(crate) fn task_profile_resolved( + &self, + task_kind: Option<&str>, + service_model_slot: Option<&str>, + routing_mode: Option<&str>, + ) -> AgentRuntimeProfileEvent { + self.next_event( + "task.profile.resolved", + json!({ + "taskKind": normalize_optional_label_option(task_kind), + "serviceModelSlot": normalize_optional_label_option(service_model_slot), + "routingMode": normalize_optional_label_option(routing_mode), + "status": "resolved", + }), + ) + } + + pub(crate) fn routing_single_candidate( + &self, + task_kind: Option<&str>, + candidate_count: u32, + selected_model: Option<&str>, + decision_source: Option<&str>, + ) -> AgentRuntimeProfileEvent { + self.next_event( + "routing.single_candidate", + json!({ + "taskKind": normalize_optional_label_option(task_kind), + "candidateCount": candidate_count, + "selectedModel": normalize_optional_label_option(selected_model), + "decisionSource": normalize_optional_label_option(decision_source), + "status": "selected", + }), + ) + } + + pub(crate) fn cost_estimated( + &self, + estimated_cost_class: Option<&str>, + ) -> AgentRuntimeProfileEvent { + self.next_event( + "cost.estimated", + json!({ + "estimatedCostClass": normalize_optional_label_option(estimated_cost_class), + "status": "recorded", + }), + ) + } + + pub(crate) fn limit_changed( + &self, + limit_status: Option<&str>, + single_candidate_only: Option, + ) -> AgentRuntimeProfileEvent { + self.next_event( + "limit.changed", + json!({ + "limitStatus": normalize_optional_label_option(limit_status), + "singleCandidateOnly": single_candidate_only, + "status": "changed", + }), + ) + } + + pub(crate) fn task_created( + &self, + task_id: &str, + task_kind: Option<&str>, + source: Option<&str>, + ) -> AgentRuntimeProfileEvent { + self.next_event( + "task.created", + json!({ + "taskId": normalize_optional_label(task_id, "task_unavailable"), + "taskKind": normalize_optional_label_option(task_kind), + "source": normalize_optional_label_option(source), + "status": "created", + }), + ) + } + + pub(crate) fn task_attempt_started( + &self, + task_id: &str, + run_id: &str, + attempt_id: &str, + attempt_index: usize, + ) -> AgentRuntimeProfileEvent { + self.next_event( + "task.attempt.started", + json!({ + "taskId": normalize_optional_label(task_id, "task_unavailable"), + "runId": normalize_optional_label(run_id, "run_unavailable"), + "attemptId": normalize_optional_label(attempt_id, "attempt_unavailable"), + "attemptIndex": attempt_index, + "status": "running", + }), + ) + } + + pub(crate) fn task_attempt_failed( + &self, + task_id: &str, + run_id: &str, + attempt_id: &str, + attempt_index: usize, + failure_category: &str, + message: Option<&str>, + retryable: bool, + ) -> AgentRuntimeProfileEvent { + self.next_event( + "task.attempt.failed", + json!({ + "taskId": normalize_optional_label(task_id, "task_unavailable"), + "runId": normalize_optional_label(run_id, "run_unavailable"), + "attemptId": normalize_optional_label(attempt_id, "attempt_unavailable"), + "attemptIndex": attempt_index, + "failureCategory": normalize_optional_label(failure_category, "unknown"), + "message": normalize_optional_label_option(message), + "retryable": retryable, + "status": "failed", + }), + ) + } + + pub(crate) fn task_retrying( + &self, + task_id: &str, + failed_attempt_id: Option<&str>, + queued_turn_id: &str, + next_attempt_index: usize, + reason: Option<&str>, + ) -> AgentRuntimeProfileEvent { + self.next_event( + "task.retrying", + json!({ + "taskId": normalize_optional_label(task_id, "task_unavailable"), + "failedAttemptId": normalize_optional_label_option(failed_attempt_id), + "queuedTurnId": normalize_optional_label(queued_turn_id, "queued_turn_unavailable"), + "nextAttemptIndex": next_attempt_index, + "reason": normalize_optional_label_option(reason), + "status": "queued", + }), + ) + } + + pub(crate) fn task_completed( + &self, + task_id: &str, + run_id: &str, + attempt_id: &str, + attempt_index: usize, + ) -> AgentRuntimeProfileEvent { + self.next_event( + "task.completed", + json!({ + "taskId": normalize_optional_label(task_id, "task_unavailable"), + "runId": normalize_optional_label(run_id, "run_unavailable"), + "attemptId": normalize_optional_label(attempt_id, "attempt_unavailable"), + "attemptIndex": attempt_index, + "status": "completed", + }), + ) + } + + pub(crate) fn task_failed( + &self, + task_id: &str, + run_id: &str, + attempt_id: &str, + attempt_index: usize, + failure_category: &str, + message: Option<&str>, + retryable: bool, + ) -> AgentRuntimeProfileEvent { + self.next_event( + "task.failed", + json!({ + "taskId": normalize_optional_label(task_id, "task_unavailable"), + "runId": normalize_optional_label(run_id, "run_unavailable"), + "attemptId": normalize_optional_label(attempt_id, "attempt_unavailable"), + "attemptIndex": attempt_index, + "failureCategory": normalize_optional_label(failure_category, "unknown"), + "message": normalize_optional_label_option(message), + "retryable": retryable, + "status": "failed", + }), + ) + } + + fn next_event(&self, event_type: &str, payload: Value) -> AgentRuntimeProfileEvent { + let sequence = self.sequence.fetch_add(1, Ordering::SeqCst) + 1; + AgentRuntimeProfileEvent { + event_type: event_type.to_string(), + event_id: build_profile_event_id(event_type, sequence), + timestamp: Utc::now().to_rfc3339(), + schema_version: LIME_AGENT_RUNTIME_PROFILE_SCHEMA_VERSION.to_string(), + runtime_id: LIME_AGENT_RUNTIME_ID.to_string(), + session_id: self.session_id.clone(), + thread_id: self.thread_id.clone(), + turn_id: self.turn_id.clone(), + sequence, + trace_id: self.trace_id.clone(), + payload, + } + } +} + +fn normalize_required_id(value: String, field_name: &str) -> Result { + let trimmed = value.trim().to_string(); + if trimmed.is_empty() { + Err(format!("AgentRuntime Profile {field_name} 不能为空")) + } else { + Ok(trimmed) + } +} + +fn normalize_optional_label(value: &str, fallback: &str) -> String { + let trimmed = value.trim(); + if trimmed.is_empty() { + fallback.to_string() + } else { + trimmed.to_string() + } +} + +fn normalize_optional_label_option(value: Option<&str>) -> Option { + value.and_then(|value| { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }) +} + +fn normalize_policy_result(value: &str) -> String { + match value.trim() { + "allow" | "deny" | "ask" | "defer" | "escalate" | "waive" | "not_applicable" + | "indeterminate" => value.trim().to_string(), + "" => "indeterminate".to_string(), + _ => "indeterminate".to_string(), + } +} + +fn build_profile_event_id(event_type: &str, sequence: u64) -> String { + let normalized_type = event_type.replace('.', "_"); + format!("evt_{normalized_type}_{sequence}_{}", Uuid::new_v4()) +} + +pub(crate) fn profile_failure_category(message: &str) -> &'static str { + let normalized = message.to_ascii_lowercase(); + if normalized.contains("cancel") || normalized.contains("停止") || normalized.contains("中断") + { + "cancelled" + } else if normalized.contains("quota") || normalized.contains("rate limit") { + "limit" + } else if normalized.contains("permission") || normalized.contains("权限") { + "permission" + } else { + "provider_error" + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn submit_turn_event_matches_lime_profile_fixture_shape() { + let stream = + AgentRuntimeProfileStream::new("sess_lime_001", "thread_lime_001", "turn_lime_001") + .expect("profile stream"); + + let event = stream.turn_submitted("workspace"); + let value = serde_json::to_value(event).expect("serialize event"); + + assert_eq!(value["type"], "turn.submitted"); + assert_eq!( + value["schemaVersion"], + LIME_AGENT_RUNTIME_PROFILE_SCHEMA_VERSION + ); + assert_eq!(value["runtimeId"], LIME_AGENT_RUNTIME_ID); + assert_eq!(value["sessionId"], "sess_lime_001"); + assert_eq!(value["threadId"], "thread_lime_001"); + assert_eq!(value["turnId"], "turn_lime_001"); + assert_eq!(value["sequence"], 1); + assert_eq!(value["payload"]["inputRef"], "input://turn_lime_001/user"); + assert_eq!(value["payload"]["source"], "workspace"); + assert_eq!(value["payload"]["status"], "accepted"); + assert!(value["eventId"] + .as_str() + .is_some_and(|value| !value.is_empty())); + assert!(value["timestamp"] + .as_str() + .is_some_and(|value| !value.is_empty())); + assert!(value["traceId"] + .as_str() + .is_some_and(|value| !value.is_empty())); + } + + #[test] + fn minimal_submit_turn_events_keep_monotonic_sequence() { + let stream = AgentRuntimeProfileStream::new("session-1", "thread-1", "turn-1") + .expect("profile stream"); + + let events = vec![ + stream.turn_submitted("workspace"), + stream.turn_started(), + stream.model_requested("openai", "openai", "gpt-5.1"), + stream.model_completed("openai", "openai", "gpt-5.1", 42), + stream.turn_completed(), + stream.snapshot_updated("completed"), + ]; + + let types = events + .iter() + .map(|event| event.event_type.as_str()) + .collect::>(); + assert_eq!( + types, + vec![ + "turn.submitted", + "turn.started", + "model.requested", + "model.completed", + "turn.completed", + "snapshot.updated", + ] + ); + + for (index, event) in events.iter().enumerate() { + assert_eq!(event.sequence, (index + 1) as u64); + assert_eq!(event.session_id, "session-1"); + assert_eq!(event.thread_id, "thread-1"); + assert_eq!(event.turn_id, "turn-1"); + } + } + + #[test] + fn profile_stream_rejects_missing_core_ids() { + let result = AgentRuntimeProfileStream::new("session-1", "", "turn-1"); + + assert!(result.is_err()); + } + + #[test] + fn policy_action_events_match_agentpolicy_linkage_shape() { + let stream = AgentRuntimeProfileStream::new("session-1", "thread-1", "turn-1") + .expect("profile stream"); + let scope = json!({ + "threadId": "thread-1", + "turnId": "turn-1", + "toolCallId": "tool-call-1" + }); + + let permission = stream.permission_evaluated("decision-1", "ask", scope.clone()); + let required = stream.action_required( + "action-1", + Some("tool-call-1"), + "tool_confirmation", + "ask", + scope, + ); + let resolved = stream.action_resolved("action-1", "allow", true); + + let permission_value = serde_json::to_value(permission).expect("permission event"); + assert_eq!(permission_value["type"], "permission.evaluated"); + assert_eq!(permission_value["payload"]["decisionId"], "decision-1"); + assert_eq!(permission_value["payload"]["result"], "ask"); + assert_eq!(permission_value["payload"]["owner"], "AgentPolicy"); + + let required_value = serde_json::to_value(required).expect("required event"); + assert_eq!(required_value["type"], "action.required"); + assert_eq!(required_value["payload"]["actionId"], "action-1"); + assert_eq!(required_value["payload"]["toolCallId"], "tool-call-1"); + assert_eq!(required_value["payload"]["decisionKind"], "ask"); + assert_eq!(required_value["payload"]["status"], "pending"); + + let resolved_value = serde_json::to_value(resolved).expect("resolved event"); + assert_eq!(resolved_value["type"], "action.resolved"); + assert_eq!(resolved_value["payload"]["actionId"], "action-1"); + assert_eq!(resolved_value["payload"]["result"], "allow"); + assert_eq!(resolved_value["payload"]["confirmed"], true); + assert_eq!(resolved_value["payload"]["status"], "resolved"); + } + + #[test] + fn policy_result_fails_closed_for_unknown_values() { + let stream = AgentRuntimeProfileStream::new("session-1", "thread-1", "turn-1") + .expect("profile stream"); + let event = stream.permission_evaluated("decision-1", "maybe", Value::Null); + let value = serde_json::to_value(event).expect("permission event"); + + assert_eq!(value["payload"]["result"], "indeterminate"); + } + + #[test] + fn tool_events_match_runtime_tool_fact_shape() { + let stream = AgentRuntimeProfileStream::new("session-1", "thread-1", "turn-1") + .expect("profile stream"); + + let started = stream.tool_started("tool-1", "Read"); + let result = stream.tool_result("tool-1", "Read", true); + let failed = stream.tool_failed("tool-2", "Write", "permission", "denied"); + + let started_value = serde_json::to_value(started).expect("started event"); + assert_eq!(started_value["type"], "tool.started"); + assert_eq!(started_value["payload"]["toolCallId"], "tool-1"); + assert_eq!(started_value["payload"]["toolName"], "Read"); + assert_eq!(started_value["payload"]["status"], "running"); + + let result_value = serde_json::to_value(result).expect("result event"); + assert_eq!(result_value["type"], "tool.result"); + assert_eq!(result_value["payload"]["toolCallId"], "tool-1"); + assert_eq!(result_value["payload"]["success"], true); + assert_eq!(result_value["payload"]["status"], "completed"); + + let failed_value = serde_json::to_value(failed).expect("failed event"); + assert_eq!(failed_value["type"], "tool.failed"); + assert_eq!(failed_value["payload"]["toolCallId"], "tool-2"); + assert_eq!(failed_value["payload"]["failureCategory"], "permission"); + assert_eq!(failed_value["payload"]["message"], "denied"); + assert_eq!(failed_value["payload"]["status"], "failed"); + } + + #[test] + fn routing_events_match_single_candidate_fact_shape() { + let stream = AgentRuntimeProfileStream::new("session-1", "thread-1", "turn-1") + .expect("profile stream"); + + let profile = stream.task_profile_resolved( + Some("translation"), + Some("translation"), + Some("single_candidate"), + ); + let routing = stream.routing_single_candidate( + Some("translation"), + 1, + Some("gpt-5.4-mini"), + Some("service_model_setting"), + ); + let cost = stream.cost_estimated(Some("low")); + let limit = stream.limit_changed(Some("single_candidate_only"), Some(true)); + + let profile_value = serde_json::to_value(profile).expect("profile event"); + assert_eq!(profile_value["type"], "task.profile.resolved"); + assert_eq!(profile_value["payload"]["taskKind"], "translation"); + assert_eq!(profile_value["payload"]["serviceModelSlot"], "translation"); + assert_eq!(profile_value["payload"]["routingMode"], "single_candidate"); + + let routing_value = serde_json::to_value(routing).expect("routing event"); + assert_eq!(routing_value["type"], "routing.single_candidate"); + assert_eq!(routing_value["payload"]["candidateCount"], 1); + assert_eq!(routing_value["payload"]["selectedModel"], "gpt-5.4-mini"); + assert_eq!( + routing_value["payload"]["decisionSource"], + "service_model_setting" + ); + + let cost_value = serde_json::to_value(cost).expect("cost event"); + assert_eq!(cost_value["type"], "cost.estimated"); + assert_eq!(cost_value["payload"]["estimatedCostClass"], "low"); + + let limit_value = serde_json::to_value(limit).expect("limit event"); + assert_eq!(limit_value["type"], "limit.changed"); + assert_eq!( + limit_value["payload"]["limitStatus"], + "single_candidate_only" + ); + assert_eq!(limit_value["payload"]["singleCandidateOnly"], true); + } + + #[test] + fn task_retry_events_match_attempt_fact_shape() { + let stream = AgentRuntimeProfileStream::new("session-1", "thread-1", "turn-1") + .expect("profile stream"); + + let created = stream.task_created( + "task-thread-1", + Some("managed_task"), + Some("thread_read_model"), + ); + let attempt_started = + stream.task_attempt_started("task-thread-1", "run-turn-1", "attempt-turn-1", 1); + let attempt_failed = stream.task_attempt_failed( + "task-thread-1", + "run-turn-1", + "attempt-turn-1", + 1, + "provider_error", + Some("rate limit"), + true, + ); + let retrying = stream.task_retrying( + "task-thread-1", + Some("attempt-turn-1"), + "queued-1", + 2, + Some("rate limit"), + ); + + let created_value = serde_json::to_value(created).expect("created event"); + assert_eq!(created_value["type"], "task.created"); + assert_eq!(created_value["payload"]["taskId"], "task-thread-1"); + assert_eq!(created_value["payload"]["taskKind"], "managed_task"); + + let started_value = serde_json::to_value(attempt_started).expect("started event"); + assert_eq!(started_value["type"], "task.attempt.started"); + assert_eq!(started_value["payload"]["runId"], "run-turn-1"); + assert_eq!(started_value["payload"]["attemptIndex"], 1); + + let failed_value = serde_json::to_value(attempt_failed).expect("failed event"); + assert_eq!(failed_value["type"], "task.attempt.failed"); + assert_eq!(failed_value["payload"]["failureCategory"], "provider_error"); + assert_eq!(failed_value["payload"]["retryable"], true); + + let retrying_value = serde_json::to_value(retrying).expect("retrying event"); + assert_eq!(retrying_value["type"], "task.retrying"); + assert_eq!( + retrying_value["payload"]["failedAttemptId"], + "attempt-turn-1" + ); + assert_eq!(retrying_value["payload"]["queuedTurnId"], "queued-1"); + assert_eq!(retrying_value["payload"]["nextAttemptIndex"], 2); + } +} diff --git a/src-tauri/src/commands/aster_agent_cmd/dto.rs b/src-tauri/src/commands/aster_agent_cmd/dto.rs index ece54caf7..72ef88594 100644 --- a/src-tauri/src/commands/aster_agent_cmd/dto.rs +++ b/src-tauri/src/commands/aster_agent_cmd/dto.rs @@ -579,13 +579,63 @@ pub struct AgentRuntimeThreadDiagnostics { pub latest_pending_request: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentRuntimeThreadTurnProfileView { + pub turn_id: String, + pub status: String, + pub native_status: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentRuntimeThreadToolCallView { + pub tool_call_id: String, + pub turn_id: String, + pub tool_name: String, + pub status: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub success: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentRuntimeThreadEvidenceSummary { + #[serde(default)] + pub evidence_refs: Vec, + #[serde(default)] + pub verification_outcomes: Vec, +} + +impl Default for AgentRuntimeThreadEvidenceSummary { + fn default() -> Self { + build_thread_evidence_summary() + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentRuntimeThreadTelemetrySummary { + #[serde(default)] + pub trace_ids: Vec, + pub join_status: String, +} + +impl Default for AgentRuntimeThreadTelemetrySummary { + fn default() -> Self { + build_thread_telemetry_summary() + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AgentRuntimeThreadReadModel { pub thread_id: String, pub status: String, + #[serde(default = "default_thread_profile_status")] + pub profile_status: String, #[serde(skip_serializing_if = "Option::is_none")] pub active_turn_id: Option, #[serde(default)] + pub turns: Vec, + #[serde(default)] pub pending_requests: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub last_outcome: Option, @@ -593,6 +643,16 @@ pub struct AgentRuntimeThreadReadModel { pub incidents: Vec, #[serde(default)] pub queued_turns: Vec, + #[serde(default)] + pub tool_calls: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub model_routing: Option, + #[serde(default)] + pub evidence_summary: AgentRuntimeThreadEvidenceSummary, + #[serde(default)] + pub telemetry_summary: AgentRuntimeThreadTelemetrySummary, + #[serde(skip_serializing_if = "Option::is_none")] + pub context_summary: Option, #[serde(skip_serializing_if = "Option::is_none")] pub interrupt_state: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -1465,6 +1525,181 @@ fn read_auxiliary_runtime_string_vec( }) } +fn normalize_profile_status(native_status: &str) -> String { + match native_status { + "idle" | "queued" | "running" | "completed" | "failed" | "stale" | "unknown" => { + native_status.to_string() + } + "aborted" | "cancelled" => "cancelled".to_string(), + "waiting_request" | "waiting_input" | "waiting_permission" | "interrupting" | "blocked" => { + "blocked".to_string() + } + _ => "unknown".to_string(), + } +} + +fn default_thread_profile_status() -> String { + "unknown".to_string() +} + +fn build_thread_profile_turns(detail: &SessionDetail) -> Vec { + detail + .turns + .iter() + .map(|turn| { + let native_status = turn.status.as_str().to_string(); + AgentRuntimeThreadTurnProfileView { + turn_id: turn.id.clone(), + status: normalize_profile_status(&native_status), + native_status, + } + }) + .collect() +} + +fn build_thread_tool_calls(detail: &SessionDetail) -> Vec { + detail + .items + .iter() + .filter_map(|item| { + let lime_core::database::dao::agent_timeline::AgentThreadItemPayload::ToolCall { + tool_name, + success, + error, + .. + } = &item.payload + else { + return None; + }; + + let status = if matches!( + item.status, + lime_core::database::dao::agent_timeline::AgentThreadItemStatus::InProgress + ) { + "running" + } else if matches!( + item.status, + lime_core::database::dao::agent_timeline::AgentThreadItemStatus::Failed + ) || success == &Some(false) + { + "failed" + } else { + "completed" + }; + + Some(AgentRuntimeThreadToolCallView { + tool_call_id: item.id.clone(), + turn_id: item.turn_id.clone(), + tool_name: tool_name.clone(), + status: status.to_string(), + success: *success, + error: error.clone(), + }) + }) + .collect() +} + +fn build_thread_model_routing_summary( + task_kind: Option<&str>, + service_model_slot: Option<&str>, + routing_mode: Option<&str>, + decision_source: Option<&str>, + decision_reason: Option<&str>, + selected_provider: Option<&str>, + selected_model: Option<&str>, + requested_provider: Option<&str>, + requested_model: Option<&str>, + candidate_count: Option, + fallback_chain: Option<&Vec>, + capability_gap: Option<&str>, + estimated_cost_class: Option<&str>, + single_candidate_only: Option, +) -> Option { + if task_kind.is_none() + && service_model_slot.is_none() + && routing_mode.is_none() + && decision_source.is_none() + && decision_reason.is_none() + && selected_provider.is_none() + && selected_model.is_none() + && requested_provider.is_none() + && requested_model.is_none() + && candidate_count.is_none() + && fallback_chain.is_none() + && capability_gap.is_none() + && estimated_cost_class.is_none() + && single_candidate_only.is_none() + { + return None; + } + + Some(serde_json::json!({ + "taskKind": task_kind, + "serviceModelSlot": service_model_slot, + "routingMode": routing_mode, + "decisionSource": decision_source, + "decisionReason": decision_reason, + "selectedProvider": selected_provider, + "selectedModel": selected_model, + "requestedProvider": requested_provider, + "requestedModel": requested_model, + "candidateCount": candidate_count, + "fallbackChain": fallback_chain, + "capabilityGap": capability_gap, + "estimatedCostClass": estimated_cost_class, + "singleCandidateOnly": single_candidate_only, + })) +} + +fn build_thread_evidence_summary() -> AgentRuntimeThreadEvidenceSummary { + AgentRuntimeThreadEvidenceSummary { + evidence_refs: Vec::new(), + verification_outcomes: Vec::new(), + } +} + +fn build_thread_telemetry_summary() -> AgentRuntimeThreadTelemetrySummary { + AgentRuntimeThreadTelemetrySummary { + trace_ids: Vec::new(), + join_status: "unavailable".to_string(), + } +} + +fn build_thread_context_summary( + turn_context_summary: Option<&lime_agent::protocol::AgentTurnContextSummary>, + latest_compaction_boundary: Option<&AgentRuntimeCompactionBoundarySnapshot>, +) -> Option { + if turn_context_summary.is_none() && latest_compaction_boundary.is_none() { + return None; + } + + let mut summary = serde_json::Map::new(); + summary.insert("owner".to_string(), serde_json::json!("AgentContext")); + + let mut sources = Vec::new(); + if let Some(context) = turn_context_summary { + if let Ok(serde_json::Value::Object(context_object)) = serde_json::to_value(context) { + summary.extend(context_object); + } + sources.push("turn_context"); + } + + if let Some(boundary) = latest_compaction_boundary { + summary.insert("latestCompaction".to_string(), serde_json::json!(boundary)); + sources.push("context_compaction"); + } + + let source = if sources.len() == 1 { + sources[0] + } else { + "mixed" + }; + summary.insert("source".to_string(), serde_json::json!(source)); + summary.insert("sources".to_string(), serde_json::json!(sources)); + + Some(serde_json::Value::Object(summary)) +} + impl AgentRuntimeThreadReadModel { #[cfg_attr(not(test), allow(dead_code))] pub(crate) fn from_session_detail( @@ -1646,15 +1881,103 @@ impl AgentRuntimeThreadReadModel { &["routing_decision", "estimated_cost_class"], ) }); + let task_kind = task_profile + .map(|profile| profile.kind.clone()) + .or(auxiliary_task_kind); + let service_model_slot = task_profile + .and_then(|profile| profile.service_model_slot.clone()) + .or(auxiliary_service_model_slot); + let routing_mode = routing_decision + .map(|decision| decision.routing_mode.clone()) + .or(auxiliary_routing_mode); + let decision_source = routing_decision + .map(|decision| decision.decision_source.clone()) + .or(auxiliary_decision_source); + let decision_reason = routing_decision + .map(|decision| decision.decision_reason.clone()) + .or(auxiliary_decision_reason); + let selected_provider = + routing_decision.and_then(|decision| decision.selected_provider.clone()); + let selected_model = routing_decision.and_then(|decision| decision.selected_model.clone()); + let requested_provider = + routing_decision.and_then(|decision| decision.requested_provider.clone()); + let requested_model = + routing_decision.and_then(|decision| decision.requested_model.clone()); + let candidate_count = routing_decision + .map(|decision| decision.candidate_count) + .or(auxiliary_candidate_count); + let fallback_chain = routing_decision + .and_then(|decision| { + if decision.fallback_chain.is_empty() { + None + } else { + Some(decision.fallback_chain.clone()) + } + }) + .or(auxiliary_fallback_chain); + let capability_gap = routing_decision + .and_then(|decision| decision.capability_gap.clone()) + .or_else(|| { + limit_state + .as_ref() + .and_then(|state| state.capability_gap.clone()) + }) + .or(auxiliary_capability_gap); + let estimated_cost_class = routing_decision + .and_then(|decision| decision.estimated_cost_class.clone()) + .or_else(|| { + cost_state + .as_ref() + .and_then(|state| state.estimated_cost_class.clone()) + }) + .or(auxiliary_estimated_cost_class); + let single_candidate_only = limit_state + .as_ref() + .map(|state| state.single_candidate_only); + let profile_status = normalize_profile_status(&status); + let turns = build_thread_profile_turns(detail); + let tool_calls = build_thread_tool_calls(detail); + let model_routing = build_thread_model_routing_summary( + task_kind.as_deref(), + service_model_slot.as_deref(), + routing_mode.as_deref(), + decision_source.as_deref(), + decision_reason.as_deref(), + selected_provider.as_deref(), + selected_model.as_deref(), + requested_provider.as_deref(), + requested_model.as_deref(), + candidate_count, + fallback_chain.as_ref(), + capability_gap.as_deref(), + estimated_cost_class.as_deref(), + single_candidate_only, + ); + let evidence_summary = build_thread_evidence_summary(); + let telemetry_summary = build_thread_telemetry_summary(); + let context_summary = build_thread_context_summary( + detail + .execution_runtime + .as_ref() + .and_then(|runtime| runtime.context_summary.as_ref()), + latest_compaction_boundary.as_ref(), + ); Self { thread_id: detail.thread_id.clone(), status, + profile_status, active_turn_id: active_running_turn.map(|turn| turn.id.clone()), + turns, pending_requests, last_outcome, incidents, queued_turns: queued_turns.to_vec(), + tool_calls, + model_routing, + evidence_summary, + telemetry_summary, + context_summary, interrupt_state, updated_at: latest_turn .map(|turn| turn.updated_at.clone()) @@ -1662,52 +1985,16 @@ impl AgentRuntimeThreadReadModel { latest_compaction_boundary, file_checkpoint_summary, diagnostics, - task_kind: task_profile - .map(|profile| profile.kind.clone()) - .or(auxiliary_task_kind), - service_model_slot: task_profile - .and_then(|profile| profile.service_model_slot.clone()) - .or(auxiliary_service_model_slot), - routing_mode: routing_decision - .map(|decision| decision.routing_mode.clone()) - .or(auxiliary_routing_mode), - decision_source: routing_decision - .map(|decision| decision.decision_source.clone()) - .or(auxiliary_decision_source), - decision_reason: routing_decision - .map(|decision| decision.decision_reason.clone()) - .or(auxiliary_decision_reason), - candidate_count: routing_decision - .map(|decision| decision.candidate_count) - .or(auxiliary_candidate_count), - fallback_chain: routing_decision - .and_then(|decision| { - if decision.fallback_chain.is_empty() { - None - } else { - Some(decision.fallback_chain.clone()) - } - }) - .or(auxiliary_fallback_chain), - capability_gap: routing_decision - .and_then(|decision| decision.capability_gap.clone()) - .or_else(|| { - limit_state - .as_ref() - .and_then(|state| state.capability_gap.clone()) - }) - .or(auxiliary_capability_gap), - estimated_cost_class: routing_decision - .and_then(|decision| decision.estimated_cost_class.clone()) - .or_else(|| { - cost_state - .as_ref() - .and_then(|state| state.estimated_cost_class.clone()) - }) - .or(auxiliary_estimated_cost_class), - single_candidate_only: limit_state - .as_ref() - .map(|state| state.single_candidate_only), + task_kind, + service_model_slot, + routing_mode, + decision_source, + decision_reason, + candidate_count, + fallback_chain, + capability_gap, + estimated_cost_class, + single_candidate_only, oem_policy, runtime_summary, auxiliary_task_runtime, @@ -2297,6 +2584,7 @@ pub(crate) fn build_last_outcome(detail: &SessionDetail) -> Option Some(text.clone()), _ => None, }); @@ -2917,7 +3205,10 @@ mod tests { let thread_read = AgentRuntimeThreadReadModel::from_session_detail(&detail, &[]); assert_eq!(thread_read.status, "waiting_request"); + assert_eq!(thread_read.profile_status, "blocked"); assert_eq!(thread_read.active_turn_id.as_deref(), Some("turn-1")); + assert_eq!(thread_read.turns.len(), 1); + assert_eq!(thread_read.turns[0].status, "running"); assert_eq!(thread_read.pending_requests.len(), 1); assert_eq!(thread_read.pending_requests[0].id, "req-1"); assert_eq!(thread_read.incidents.len(), 1); @@ -3008,7 +3299,10 @@ mod tests { let thread_read = AgentRuntimeThreadReadModel::from_session_detail(&detail, &queued_turns); assert_eq!(thread_read.status, "failed"); + assert_eq!(thread_read.profile_status, "failed"); assert_eq!(thread_read.queued_turns.len(), 1); + assert_eq!(thread_read.telemetry_summary.join_status, "unavailable"); + assert!(thread_read.evidence_summary.evidence_refs.is_empty()); assert_eq!( thread_read .last_outcome @@ -3090,6 +3384,7 @@ mod tests { mode: None, latest_turn_id: Some("turn-1".to_string()), latest_turn_status: Some("failed".to_string()), + context_summary: None, recent_access_mode: None, recent_preferences: None, recent_team_selection: None, @@ -3261,6 +3556,232 @@ mod tests { .map(|value| value.event_kind.as_str()), Some("rate_limit_hit") ); + assert_eq!( + thread_read + .model_routing + .as_ref() + .and_then(|value| value.get("candidateCount")) + .and_then(serde_json::Value::as_u64), + Some(1) + ); + assert_eq!( + thread_read + .model_routing + .as_ref() + .and_then(|value| value.get("singleCandidateOnly")) + .and_then(serde_json::Value::as_bool), + Some(true) + ); + assert_eq!( + thread_read + .model_routing + .as_ref() + .and_then(|value| value.get("selectedModel")) + .and_then(serde_json::Value::as_str), + Some("gpt-5.4-mini") + ); + assert_eq!( + thread_read + .model_routing + .as_ref() + .and_then(|value| value.get("decisionSource")) + .and_then(serde_json::Value::as_str), + Some("service_model_setting") + ); + } + + #[test] + fn thread_read_should_project_tool_calls_for_profile_consumers() { + let detail = build_session_detail( + vec![AgentThreadTurn { + id: "turn-tool".to_string(), + thread_id: "thread-1".to_string(), + prompt_text: "读取文件".to_string(), + status: AgentThreadTurnStatus::Completed, + started_at: "2026-03-23T09:10:00Z".to_string(), + completed_at: Some("2026-03-23T09:10:05Z".to_string()), + error_message: None, + created_at: "2026-03-23T09:10:00Z".to_string(), + updated_at: "2026-03-23T09:10:05Z".to_string(), + }], + vec![AgentThreadItem { + id: "tool-item-1".to_string(), + thread_id: "thread-1".to_string(), + turn_id: "turn-tool".to_string(), + sequence: 1, + status: AgentThreadItemStatus::Completed, + started_at: "2026-03-23T09:10:01Z".to_string(), + completed_at: Some("2026-03-23T09:10:02Z".to_string()), + updated_at: "2026-03-23T09:10:02Z".to_string(), + payload: AgentThreadItemPayload::ToolCall { + tool_name: "Read".to_string(), + arguments: None, + output: Some("hello".to_string()), + success: Some(true), + error: None, + metadata: None, + }, + }], + ); + + let thread_read = AgentRuntimeThreadReadModel::from_session_detail(&detail, &[]); + + assert_eq!(thread_read.profile_status, "completed"); + assert_eq!(thread_read.tool_calls.len(), 1); + assert_eq!(thread_read.tool_calls[0].tool_call_id, "tool-item-1"); + assert_eq!(thread_read.tool_calls[0].turn_id, "turn-tool"); + assert_eq!(thread_read.tool_calls[0].tool_name, "Read"); + assert_eq!(thread_read.tool_calls[0].status, "completed"); + assert_eq!(thread_read.tool_calls[0].success, Some(true)); + } + + #[test] + fn thread_read_should_project_context_summary_for_profile_consumers() { + let mut detail = build_session_detail( + vec![AgentThreadTurn { + id: "turn-context".to_string(), + thread_id: "thread-1".to_string(), + prompt_text: "使用项目知识库".to_string(), + status: AgentThreadTurnStatus::Running, + started_at: "2026-03-23T09:10:00Z".to_string(), + completed_at: None, + error_message: None, + created_at: "2026-03-23T09:10:00Z".to_string(), + updated_at: "2026-03-23T09:10:05Z".to_string(), + }], + Vec::new(), + ); + detail.execution_runtime = Some( + serde_json::from_value(serde_json::json!({ + "session_id": "session-1", + "source": "runtime_snapshot", + "latest_turn_id": "turn-context", + "latest_turn_status": "running", + "context_summary": { + "memory_budget": { + "used_tokens": 640, + "max_tokens": 1200, + "status": "ready", + "source": "knowledge_context_resolver" + }, + "retrieval_refs": [ + { + "source_id": "knowledge_pack:brand:compiled/splits/brief.md", + "kind": "knowledge_pack", + "title": "brand:brief", + "path": "compiled/splits/brief.md", + "scope": "workspace", + "status": "ready", + "source": "knowledge_context_resolver" + } + ], + "missing_context": [ + { + "id": "knowledge_warning:0", + "kind": "knowledge_warning", + "label": "sources/missing.md", + "status": "unknown", + "reason": "缺少来源", + "source": "knowledge_context_resolver" + } + ], + "team_memory_refs": [ + { + "key": "team.selection", + "repo_scope": "/repo/lime", + "updated_at": 1710000000, + "source": "team_memory_shadow" + } + ] + } + })) + .expect("execution runtime"), + ); + + let thread_read = AgentRuntimeThreadReadModel::from_session_detail(&detail, &[]); + let context_summary = thread_read.context_summary.expect("context summary"); + + assert_eq!( + context_summary + .get("owner") + .and_then(serde_json::Value::as_str), + Some("AgentContext") + ); + assert_eq!( + context_summary + .get("source") + .and_then(serde_json::Value::as_str), + Some("turn_context") + ); + assert_eq!( + context_summary + .pointer("/memory_budget/used_tokens") + .and_then(serde_json::Value::as_u64), + Some(640) + ); + assert_eq!( + context_summary + .pointer("/retrieval_refs/0/source_id") + .and_then(serde_json::Value::as_str), + Some("knowledge_pack:brand:compiled/splits/brief.md") + ); + assert_eq!( + context_summary + .pointer("/missing_context/0/status") + .and_then(serde_json::Value::as_str), + Some("unknown") + ); + assert_eq!( + context_summary + .pointer("/team_memory_refs/0/key") + .and_then(serde_json::Value::as_str), + Some("team.selection") + ); + assert!(context_summary + .pointer("/team_memory_refs/0/content") + .is_none()); + } + + #[test] + fn thread_context_summary_should_merge_compaction_boundary_refs() { + let runtime_summary: lime_agent::protocol::AgentTurnContextSummary = + serde_json::from_value(serde_json::json!({ + "memory_budget": { + "used_tokens": 320, + "status": "trimmed", + "source": "knowledge_context_resolver" + } + })) + .expect("context summary"); + let boundary = AgentRuntimeCompactionBoundarySnapshot { + session_id: "session-1".to_string(), + summary_preview: "保留研究目标与来源摘要".to_string(), + turn_count: Some(4), + created_at: "2026-03-23T09:10:20Z".to_string(), + trigger: Some("token_budget".to_string()), + detail: Some("保留研究目标与来源摘要".to_string()), + }; + + let summary = build_thread_context_summary(Some(&runtime_summary), Some(&boundary)) + .expect("context summary"); + + assert_eq!( + summary.get("source").and_then(serde_json::Value::as_str), + Some("mixed") + ); + assert_eq!( + summary + .pointer("/latestCompaction/trigger") + .and_then(serde_json::Value::as_str), + Some("token_budget") + ); + assert_eq!( + summary + .get("sources") + .and_then(serde_json::Value::as_array) + .map(Vec::len), + Some(2) + ); } #[test] @@ -3392,6 +3913,7 @@ mod tests { mode: None, latest_turn_id: Some("turn-approval".to_string()), latest_turn_status: Some("running".to_string()), + context_summary: None, recent_access_mode: None, recent_preferences: None, recent_team_selection: None, @@ -3566,6 +4088,7 @@ mod tests { mode: None, latest_turn_id: Some("turn-approval".to_string()), latest_turn_status: Some("completed".to_string()), + context_summary: None, recent_access_mode: None, recent_preferences: None, recent_team_selection: None, @@ -3636,6 +4159,7 @@ mod tests { mode: None, latest_turn_id: None, latest_turn_status: None, + context_summary: None, recent_access_mode: None, recent_preferences: None, recent_team_selection: None, diff --git a/src-tauri/src/commands/aster_agent_cmd/mod.rs b/src-tauri/src/commands/aster_agent_cmd/mod.rs index 3f223cf60..ee162629b 100644 --- a/src-tauri/src/commands/aster_agent_cmd/mod.rs +++ b/src-tauri/src/commands/aster_agent_cmd/mod.rs @@ -337,6 +337,7 @@ fn normalize_optional_text(value: Option) -> Option { } pub(crate) mod action_runtime; +mod agentruntime_profile; mod analysis_skill_launch; mod broadcast_skill_launch; mod browser_assist; @@ -360,6 +361,7 @@ mod resource_search_skill_launch; mod run_metadata; mod runtime_plugin_agents; mod runtime_project_hooks; +mod runtime_task_profile; mod runtime_turn; mod service_skill_launch; mod session_runtime; @@ -395,6 +397,10 @@ pub(crate) use action_runtime::{ build_action_resume_runtime_status, build_runtime_action_user_data, validate_elicitation_submission, }; +pub(crate) use agentruntime_profile::{ + AgentRuntimeProfileEvent, AgentRuntimeProfileStream, LIME_AGENT_RUNTIME_ID, + LIME_AGENT_RUNTIME_PROFILE_SCHEMA_VERSION, +}; pub(crate) use analysis_skill_launch::{ append_analysis_skill_launch_session_permissions, merge_system_prompt_with_analysis_skill_launch, prepare_analysis_skill_launch_request_metadata, @@ -464,10 +470,13 @@ pub(crate) use dto::{ AgentRuntimeSaveReviewDecisionRequest, AgentRuntimeSendSubagentInputRequest, AgentRuntimeSendSubagentInputResponse, AgentRuntimeSessionDetail, AgentRuntimeSpawnSubagentRequest, AgentRuntimeSpawnSubagentResponse, - AgentRuntimeSubmitTurnRequest, AgentRuntimeThreadDiagnostics, AgentRuntimeThreadReadModel, - AgentRuntimeToolInventoryRequest, AgentRuntimeUpdateSessionRequest, - AgentRuntimeWaitSubagentsRequest, AgentRuntimeWaitSubagentsResponse, AsterAgentStatus, - AsterChatRequest, AutoContinuePayload, ConfigureProviderRequest, + AgentRuntimeSubmitTurnRequest, AgentRuntimeThreadDiagnostics, + AgentRuntimeThreadEvidenceSummary, AgentRuntimeThreadReadModel, + AgentRuntimeThreadTelemetrySummary, AgentRuntimeThreadToolCallView, + AgentRuntimeThreadTurnProfileView, AgentRuntimeToolInventoryRequest, + AgentRuntimeUpdateSessionRequest, AgentRuntimeWaitSubagentsRequest, + AgentRuntimeWaitSubagentsResponse, AsterAgentStatus, AsterChatRequest, AutoContinuePayload, + ConfigureProviderRequest, }; pub(crate) use form_skill_launch::{ append_form_skill_launch_session_permissions, merge_system_prompt_with_form_skill_launch, @@ -505,7 +514,8 @@ pub(crate) use provider_runtime_strategy::{ use reply_runtime::{ build_runtime_user_message, build_turn_runtime_statuses, complete_runtime_status_projection, emit_runtime_status_with_projection, ensure_code_execution_extension_enabled, - should_fallback_to_react_from_code_orchestrated, stream_reply_once, + should_fallback_to_react_from_code_orchestrated, should_project_runtime_status_to_timeline, + stream_reply_once, }; pub(crate) use report_skill_launch::{ append_report_skill_launch_session_permissions, merge_system_prompt_with_report_skill_launch, @@ -538,6 +548,9 @@ pub(crate) use runtime_plugin_agents::{ merge_system_prompt_with_runtime_plugin_agents, resolve_requested_runtime_plugin_agent_definition, RuntimePluginAgentDefinition, }; +pub(crate) use runtime_task_profile::{ + attempt_id_from_turn_id, run_id_from_turn_id, task_id_from_thread_id, +}; pub(crate) use runtime_turn::{build_queued_turn_task, build_runtime_queue_executor}; #[cfg(test)] pub(crate) use runtime_turn::{ diff --git a/src-tauri/src/commands/aster_agent_cmd/reply_runtime.rs b/src-tauri/src/commands/aster_agent_cmd/reply_runtime.rs index 741c87f9e..8df3f94d6 100644 --- a/src-tauri/src/commands/aster_agent_cmd/reply_runtime.rs +++ b/src-tauri/src/commands/aster_agent_cmd/reply_runtime.rs @@ -289,6 +289,32 @@ pub(super) async fn build_turn_runtime_statuses( )) } +pub(super) fn should_project_runtime_status_to_timeline( + request_metadata: Option<&serde_json::Value>, +) -> bool { + let Some(fast_response_routing) = extract_harness_nested_object( + request_metadata, + &["fast_response_routing", "fastResponseRouting"], + ) else { + return true; + }; + + let presentation = fast_response_routing + .get("runtime_status_presentation") + .or_else(|| fast_response_routing.get("runtimeStatusPresentation")) + .and_then(serde_json::Value::as_str) + .map(str::trim); + + !matches!(presentation, Some("transient")) +} + +fn emit_transient_runtime_status(app: &AppHandle, event_name: &str, status: AgentRuntimeStatus) { + let runtime_event = RuntimeAgentEvent::RuntimeStatus { status }; + if let Err(error) = app.emit(event_name, &runtime_event) { + tracing::warn!("[AsterAgent] 发送 runtime_status 失败: {}", error); + } +} + fn emit_projected_runtime_item_event( app: &AppHandle, event_name: &str, @@ -320,40 +346,40 @@ pub(super) async fn emit_runtime_status_with_projection( workspace_root: &str, session_config: &aster::agents::SessionConfig, status: AgentRuntimeStatus, + project_to_timeline: bool, ) { - match agent - .upsert_runtime_status_item( - session_config, - status.phase.clone(), - status.title.clone(), - status.detail.clone(), - status.checkpoints.clone(), - ) - .await - { - Ok(agent_event) => { - for event in project_runtime_event(agent_event) { - emit_projected_runtime_item_event( - app, - event_name, - timeline_recorder, - workspace_root, - event, + if project_to_timeline { + match agent + .upsert_runtime_status_item( + session_config, + status.phase.clone(), + status.title.clone(), + status.detail.clone(), + status.checkpoints.clone(), + ) + .await + { + Ok(agent_event) => { + for event in project_runtime_event(agent_event) { + emit_projected_runtime_item_event( + app, + event_name, + timeline_recorder, + workspace_root, + event, + ); + } + } + Err(error) => { + tracing::warn!( + "[AsterAgent] 写入 runtime_status item 失败,降级仅发送 transient 事件: {}", + error ); } } - Err(error) => { - tracing::warn!( - "[AsterAgent] 写入 runtime_status item 失败,降级仅发送 transient 事件: {}", - error - ); - } } - let runtime_event = RuntimeAgentEvent::RuntimeStatus { status }; - if let Err(error) = app.emit(event_name, &runtime_event) { - tracing::warn!("[AsterAgent] 发送 runtime_status 失败: {}", error); - } + emit_transient_runtime_status(app, event_name, status); } pub(super) async fn complete_runtime_status_projection( diff --git a/src-tauri/src/commands/aster_agent_cmd/request_model_resolution.rs b/src-tauri/src/commands/aster_agent_cmd/request_model_resolution.rs index ddb7fcb3c..aaaacc263 100644 --- a/src-tauri/src/commands/aster_agent_cmd/request_model_resolution.rs +++ b/src-tauri/src/commands/aster_agent_cmd/request_model_resolution.rs @@ -2,12 +2,17 @@ use super::*; use crate::commands::aster_agent_cmd::service_skill_launch::extract_service_scene_launch_context; use crate::commands::model_registry_cmd::ModelRegistryState; use crate::config::GlobalConfigManagerState; +mod responsive_chat; use lime_core::database::dao::api_key_provider::{ApiProviderType, ProviderGroup}; use lime_core::models::model_registry::{ EnhancedModelMetadata, ModelCapabilities, ModelDeploymentSource, ModelManagementPlane, ModelModality, ModelPricing, ModelRuntimeFeature, ModelSource, ModelTaskFamily, ModelTier, ProviderAliasConfig, }; +use responsive_chat::{ + resolve_responsive_chat_auto_preference, responsive_chat_model_sort, targets_responsive_chat, + RESPONSIVE_CHAT_ROUTING_SLOT, RESPONSIVE_CHAT_SERVICE_MODEL_SLOT, +}; use std::collections::HashSet; use tauri::Manager; @@ -73,6 +78,7 @@ enum RuntimeModelCapabilityRequirement { BrowserReasoning, StructuredDocumentGeneration, CheapSummary, + ResponsiveChat, } impl RuntimeModelCapabilityRequirement { @@ -86,6 +92,7 @@ impl RuntimeModelCapabilityRequirement { Self::BrowserReasoning => "browser_reasoning_candidate_missing", Self::StructuredDocumentGeneration => "structured_document_candidate_missing", Self::CheapSummary => "cheap_summary_candidate_missing", + Self::ResponsiveChat => "responsive_chat_candidate_missing", } } } @@ -1068,6 +1075,15 @@ fn resolve_catalog_fallback_model_id( }) .collect::>(); + if targets_responsive_chat(runtime_requirements) { + candidates.sort_by(|left, right| responsive_chat_model_sort(left, right)); + return candidates + .into_iter() + .next() + .map(|candidate| candidate.id.clone()) + .unwrap_or_else(|| current_model_id.to_string()); + } + candidates.sort_by(|left, right| { let left_same_base = normalize_base_model_key(&left.id) == current_base_key; let right_same_base = normalize_base_model_key(&right.id) == current_base_key; @@ -1225,6 +1241,11 @@ fn choose_best_multi_candidate_model( return None; } + if targets_responsive_chat(runtime_requirements) { + candidates.sort_by(|left, right| responsive_chat_model_sort(left, right)); + return candidates.first().map(|model| model.id.clone()); + } + candidates.sort_by(|left, right| { let left_same_family = !current_family.is_empty() && left.family.as_deref().map(normalize_identifier) == Some(current_family.clone()); @@ -1345,7 +1366,8 @@ fn configured_custom_model_matches_runtime_request( .iter() .all(|requirement| match requirement { RuntimeModelCapabilityRequirement::TextGeneration - | RuntimeModelCapabilityRequirement::CheapSummary => true, + | RuntimeModelCapabilityRequirement::CheapSummary + | RuntimeModelCapabilityRequirement::ResponsiveChat => true, RuntimeModelCapabilityRequirement::VisionInput => supports_vision(None, model_id), RuntimeModelCapabilityRequirement::BrowserReasoning => { model_has_reasoning_capability(None, model_id) @@ -1949,6 +1971,33 @@ fn build_request_oem_limit_event( }) } +fn extract_fast_response_routing( + request_metadata: Option<&serde_json::Value>, +) -> Option<&serde_json::Map> { + extract_harness_nested_object( + request_metadata, + &["fast_response_routing", "fastResponseRouting"], + ) +} + +fn extract_fast_response_service_model_slot( + request_metadata: Option<&serde_json::Value>, +) -> Option { + let routing = extract_fast_response_routing(request_metadata)?; + extract_metadata_text(routing, &["service_model_slot", "serviceModelSlot"]) + .filter(|slot| normalize_identifier(slot) == RESPONSIVE_CHAT_SERVICE_MODEL_SLOT) + .or_else(|| Some(RESPONSIVE_CHAT_SERVICE_MODEL_SLOT.to_string())) +} + +fn extract_fast_response_routing_slot( + request_metadata: Option<&serde_json::Value>, +) -> Option { + let routing = extract_fast_response_routing(request_metadata)?; + extract_metadata_text(routing, &["routing_slot", "routingSlot"]) + .filter(|slot| normalize_identifier(slot) == RESPONSIVE_CHAT_ROUTING_SLOT) + .or_else(|| Some(RESPONSIVE_CHAT_ROUTING_SLOT.to_string())) +} + fn resolve_request_service_model_slot( request_metadata: Option<&serde_json::Value>, ) -> Option { @@ -1958,6 +2007,10 @@ fn resolve_request_service_model_slot( return Some(slot); } + if let Some(slot) = extract_fast_response_service_model_slot(request_metadata) { + return Some(slot); + } + if extract_harness_nested_object( request_metadata, &["translation_skill_launch", "translationSkillLaunch"], @@ -2027,7 +2080,10 @@ fn build_runtime_task_profile( let service_model_slot = resolve_request_service_model_slot(request_metadata); let service_scene_context = extract_service_scene_launch_context(request_metadata); let request_oem_routing = resolve_request_oem_routing_context(request_metadata); - let profile_binding = runtime_task_profile_binding_from_metadata(request_metadata); + let mut profile_binding = runtime_task_profile_binding_from_metadata(request_metadata); + if profile_binding.routing_slot.is_none() { + profile_binding.routing_slot = extract_fast_response_routing_slot(request_metadata); + } let mut traits = Vec::new(); if request @@ -2065,6 +2121,9 @@ fn build_runtime_task_profile( if profile_binding.modality_contract_key.is_some() { push_unique_profile_trait(&mut traits, "modality_runtime_contract"); } + if extract_fast_response_routing(request_metadata).is_some() { + push_unique_profile_trait(&mut traits, "fast_response_routing"); + } if profile_binding.execution_profile_key.is_some() { push_unique_profile_trait(&mut traits, "execution_profile"); } @@ -2279,6 +2338,19 @@ fn build_runtime_task_profile( ); } + if extract_fast_response_routing(request_metadata).is_some() { + return build_runtime_task_profile_with_binding( + "chat", + "fast_response_routing", + traits, + service_model_slot, + None, + None, + None, + &profile_binding, + ); + } + build_runtime_task_profile_with_binding( "chat", "default_chat", @@ -2374,6 +2446,7 @@ fn routing_slot_model_capability_requirements( vec![RuntimeModelCapabilityRequirement::StructuredDocumentGeneration] } Some("cheap_summary_model") => vec![RuntimeModelCapabilityRequirement::CheapSummary], + Some("responsive_chat_model") => vec![RuntimeModelCapabilityRequirement::ResponsiveChat], Some("base_model") | None => vec![RuntimeModelCapabilityRequirement::TextGeneration], Some(_) => Vec::new(), } @@ -2416,6 +2489,9 @@ fn model_satisfies_runtime_capability_requirement( || model.task_families.contains(&ModelTaskFamily::Chat) || model.task_families.contains(&ModelTaskFamily::Reasoning) } + RuntimeModelCapabilityRequirement::ResponsiveChat => { + model.task_families.is_empty() || model.task_families.contains(&ModelTaskFamily::Chat) + } } } @@ -2844,6 +2920,7 @@ enum RequestPreferenceSource { ServiceSceneLaunch, Session, ServiceModelSetting, + ResponsiveChatAuto, } #[derive(Debug, Clone, Default, PartialEq, Eq)] @@ -2911,6 +2988,7 @@ fn resolve_service_model_setting_preference( .service_models .resource_prompt_rewrite } + "responsive_chat" => &config.workspace_preferences.service_models.responsive_chat, "translation" => &config.workspace_preferences.service_models.translation, _ => return None, }; @@ -3015,6 +3093,7 @@ async fn build_runtime_request_provider_config_from_preference( .unwrap_or(false); let runtime_requirements = routing_slot_model_capability_requirements(task_profile.routing_slot.as_deref()); + let responsive_chat_routing = targets_responsive_chat(&runtime_requirements); let compatible_candidate_count = count_compatible_candidate_models( &catalog, thinking_enabled, @@ -3071,7 +3150,7 @@ async fn build_runtime_request_provider_config_from_preference( honor_explicit_model_lock, ); if !honor_explicit_model_lock - && should_auto_reselect_multi_candidate_model(&context) + && (should_auto_reselect_multi_candidate_model(&context) || responsive_chat_routing) && (compatible_candidate_count > 1 || should_reselect_runtime_gap) { if let Some(best_candidate) = choose_best_multi_candidate_model( @@ -3659,6 +3738,81 @@ pub(super) async fn resolve_runtime_request_provider_resolution( } } + if !oem_locked { + if let Some(auto_preference) = resolve_responsive_chat_auto_preference( + app, + db, + api_key_provider_service, + request, + &task_profile, + ) + .await? + { + let selection = build_runtime_request_provider_config_from_preference( + app, + db, + api_key_provider_service, + request, + &task_profile, + &auto_preference.provider_selector, + &auto_preference.model_name, + RequestPreferenceSource::ResponsiveChatAuto, + true, + ) + .await?; + let mut notes = vec![format!( + "responsive_chat 未配置显式服务模型,已从已启用 Provider 的模型元数据中自动选择低延迟候选;当前 Provider 兼容候选数为 {}。", + auto_preference.compatible_candidate_count + )]; + 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, + false, + false, + oem_locked, + selection.capability_gap.clone(), + notes, + ); + let routing_decision = build_routing_decision( + &task_profile, + "responsive_chat_auto", + compose_routing_decision_reason( + "当前回合命中 responsive_chat_model,且未配置服务模型;运行时按已启用 Provider 的模型元数据自动选择低延迟对话候选。", + Some(&selection), + oem_locked, + fallback_note.as_deref(), + ), + Some(&selection), + Some(auto_preference.provider_selector), + Some(auto_preference.model_name), + Some("service_models.responsive_chat:auto".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, + }); + } + } + let Some((provider_selector, provider_preference_source)) = resolve_provider_preference_with_session_fallback(None, session_context.as_ref()) else { @@ -3766,1463 +3920,4 @@ pub(super) async fn resolve_runtime_request_provider_resolution( } #[cfg(test)] -mod tests { - use super::*; - use lime_core::database::dao::api_key_provider::ProviderGroup; - - fn build_model( - id: &str, - family: Option<&str>, - reasoning: bool, - vision: bool, - is_latest: bool, - tier: ModelTier, - release_date: Option<&str>, - ) -> EnhancedModelMetadata { - EnhancedModelMetadata { - id: id.to_string(), - display_name: id.to_string(), - provider_id: "openai".to_string(), - provider_name: "openai".to_string(), - family: family.map(ToString::to_string), - tier, - capabilities: ModelCapabilities { - vision, - tools: true, - streaming: true, - json_mode: true, - function_calling: true, - reasoning, - }, - task_families: { - let mut families = vec![ModelTaskFamily::Chat]; - if reasoning { - families.push(ModelTaskFamily::Reasoning); - } - if vision { - families.push(ModelTaskFamily::VisionUnderstanding); - } - families - }, - input_modalities: if vision { - vec![ModelModality::Text, ModelModality::Image] - } else { - vec![ModelModality::Text] - }, - output_modalities: vec![ModelModality::Text, ModelModality::Json], - runtime_features: vec![ - ModelRuntimeFeature::Streaming, - ModelRuntimeFeature::ToolCalling, - ModelRuntimeFeature::JsonSchema, - ], - deployment_source: ModelDeploymentSource::UserCloud, - management_plane: ModelManagementPlane::LocalSettings, - canonical_model_id: None, - provider_model_id: Some(id.to_string()), - alias_source: None, - pricing: None, - limits: Default::default(), - status: Default::default(), - release_date: release_date.map(ToString::to_string), - is_latest, - description: None, - source: ModelSource::Embedded, - created_at: 0, - updated_at: 0, - } - } - - #[test] - fn thinking_on_prefers_reasoning_variant() { - let models = vec![ - build_model( - "gpt-5.4-mini", - Some("gpt-5.4"), - false, - false, - true, - ModelTier::Mini, - Some("2026-01-01"), - ), - build_model( - "gpt-5.4-mini-thinking", - Some("gpt-5.4"), - true, - false, - true, - ModelTier::Mini, - Some("2026-01-02"), - ), - ]; - - assert_eq!( - resolve_thinking_model_id("gpt-5.4-mini", &models), - "gpt-5.4-mini-thinking" - ); - } - - #[test] - fn thinking_off_restores_base_variant() { - let models = vec![ - build_model( - "gpt-5.4-mini", - Some("gpt-5.4"), - false, - false, - true, - ModelTier::Mini, - Some("2026-01-01"), - ), - build_model( - "gpt-5.4-mini-thinking", - Some("gpt-5.4"), - true, - false, - true, - ModelTier::Mini, - Some("2026-01-02"), - ), - ]; - - assert_eq!( - resolve_base_model_on_thinking_off("gpt-5.4-mini-thinking", &models), - "gpt-5.4-mini" - ); - } - - #[test] - fn vision_resolution_prefers_same_family_candidate() { - let models = vec![ - build_model( - "gpt-5.4-mini", - Some("gpt-5.4"), - false, - false, - true, - ModelTier::Mini, - Some("2026-01-01"), - ), - build_model( - "gpt-5.4", - Some("gpt-5.4"), - true, - true, - true, - ModelTier::Pro, - Some("2026-01-03"), - ), - build_model( - "gemini-2.5-pro", - Some("gemini-2.5"), - true, - true, - true, - ModelTier::Pro, - Some("2026-01-02"), - ), - ]; - - assert_eq!( - resolve_vision_model_id("gpt-5.4-mini", &models).unwrap(), - "gpt-5.4" - ); - } - - #[test] - fn vision_resolution_keeps_unknown_model_when_name_implies_vision() { - let models = vec![build_model( - "gpt-5.4", - Some("gpt-5.4"), - true, - true, - true, - ModelTier::Pro, - Some("2026-01-03"), - )]; - - assert_eq!( - resolve_vision_model_id("gpt-5.4-mini", &models).unwrap(), - "gpt-5.4-mini" - ); - } - - #[test] - fn codex_compatibility_falls_back_to_supported_model() { - let context = ProviderResolutionContext { - provider_selector: "codex".to_string(), - aster_provider_name: "openai".to_string(), - compatibility_provider_key: "codex".to_string(), - registry_provider_ids: vec!["codex".to_string()], - alias_key: "codex".to_string(), - custom_models: vec![], - is_custom_provider: false, - provider_type: Some(ApiProviderType::Codex), - provider_group: None, - configured_api_host: Some("https://api.openai.com/v1".to_string()), - has_credentials: true, - }; - - assert_eq!( - resolve_provider_model_compatibility(&context, "gpt-5.3-codex"), - "gpt-5.2-codex" - ); - } - - #[test] - fn xiaomi_compatibility_canonicalizes_display_name_and_legacy_alias() { - let context = ProviderResolutionContext { - provider_selector: "custom-mimo".to_string(), - aster_provider_name: "anthropic".to_string(), - compatibility_provider_key: "anthropic-compatible".to_string(), - registry_provider_ids: vec!["xiaomi".to_string()], - alias_key: "custom-mimo".to_string(), - custom_models: vec![], - is_custom_provider: true, - provider_type: Some(ApiProviderType::AnthropicCompatible), - provider_group: None, - configured_api_host: Some("https://token-plan-cn.xiaomimimo.com/anthropic".to_string()), - has_credentials: true, - }; - - assert_eq!( - resolve_provider_model_compatibility(&context, "MiMo-V2.5-Pro"), - "mimo-v2.5-pro" - ); - assert_eq!( - resolve_provider_model_compatibility(&context, "mimo-v2-pro"), - "mimo-v2.5-pro" - ); - assert_eq!( - canonicalize_provider_custom_models( - &context, - &[ - "MiMo-V2.5-Pro".to_string(), - "mimo-v2-pro".to_string(), - "mimo-v2-flash".to_string(), - ], - ), - vec!["mimo-v2.5-pro".to_string(), "mimo-v2-flash".to_string()] - ); - } - - #[test] - fn catalog_fallback_prefers_latest_same_lineage_chat_model_for_unknown_session_model() { - let models = vec![ - build_model( - "embedding-3", - Some("embedding"), - false, - false, - true, - ModelTier::Pro, - Some("2026-01-05"), - ), - build_model( - "glm-4v", - Some("glm"), - false, - true, - true, - ModelTier::Pro, - Some("2026-01-04"), - ), - build_model( - "glm-4.6", - Some("glm"), - false, - false, - false, - ModelTier::Pro, - Some("2026-01-03"), - ), - build_model( - "glm-4.7", - Some("glm"), - false, - false, - true, - ModelTier::Pro, - Some("2026-01-04"), - ), - ]; - - assert_eq!( - resolve_catalog_fallback_model_id("glm-5.1", &models, false, false, &[]), - "glm-4.7" - ); - } - - #[test] - fn multi_candidate_selection_prefers_same_family_lower_cost_model() { - let models = vec![ - build_model( - "gpt-5.4", - Some("gpt-5.4"), - false, - false, - true, - ModelTier::Pro, - Some("2026-01-03"), - ), - build_model( - "gpt-5.4-mini", - Some("gpt-5.4"), - false, - false, - true, - ModelTier::Mini, - Some("2026-01-04"), - ), - build_model( - "gemini-2.5-pro", - Some("gemini-2.5"), - false, - false, - true, - ModelTier::Pro, - Some("2026-01-05"), - ), - ]; - - assert_eq!( - choose_best_multi_candidate_model("gpt-5.4", &models, false, false, &[]).as_deref(), - Some("gpt-5.4-mini") - ); - } - - #[test] - fn multi_candidate_selection_prefers_reasoning_model_when_thinking_enabled() { - let mut models = vec![ - build_model( - "gpt-5.4-mini", - Some("gpt-5.4"), - false, - false, - true, - ModelTier::Mini, - Some("2026-01-03"), - ), - build_model( - "gpt-5.4-mini-thinking", - Some("gpt-5.4"), - false, - false, - true, - ModelTier::Mini, - Some("2026-01-04"), - ), - ]; - models[1].capabilities.reasoning = true; - models[1].task_families.push(ModelTaskFamily::Reasoning); - - assert_eq!( - choose_best_multi_candidate_model("gpt-5.4-mini", &models, true, false, &[]).as_deref(), - Some("gpt-5.4-mini-thinking") - ); - } - - #[test] - fn multi_candidate_selection_prefers_vision_candidate_when_images_present() { - let models = vec![ - build_model( - "gpt-5.4-mini", - Some("gpt-5.4"), - false, - false, - true, - ModelTier::Mini, - Some("2026-01-03"), - ), - build_model( - "gpt-5.4", - Some("gpt-5.4"), - true, - true, - true, - ModelTier::Pro, - Some("2026-01-04"), - ), - ]; - - assert_eq!( - choose_best_multi_candidate_model("gpt-5.4-mini", &models, false, true, &[]).as_deref(), - Some("gpt-5.4") - ); - } - - #[test] - fn runtime_routing_slot_filters_candidate_count_by_model_role() { - let mut models = vec![ - build_model( - "gpt-5.4-mini", - Some("gpt-5.4"), - false, - false, - true, - ModelTier::Mini, - Some("2026-01-03"), - ), - build_model( - "gpt-5.4-mini-thinking", - Some("gpt-5.4"), - true, - false, - true, - ModelTier::Mini, - Some("2026-01-04"), - ), - ]; - models[1].capabilities.reasoning = true; - models[1].task_families.push(ModelTaskFamily::Reasoning); - let requirements = - routing_slot_model_capability_requirements(Some("browser_reasoning_model")); - - assert_eq!( - count_compatible_candidate_models(&models, false, false, &requirements), - 1 - ); - assert_eq!( - choose_best_multi_candidate_model("gpt-5.4-mini", &models, false, false, &requirements) - .as_deref(), - Some("gpt-5.4-mini-thinking") - ); - } - - #[test] - fn runtime_routing_slot_keeps_specialized_image_candidate_in_pool() { - let mut image_model = build_model( - "gpt-image-1", - Some("gpt-image"), - false, - true, - true, - ModelTier::Pro, - Some("2026-01-04"), - ); - image_model.task_families = vec![ModelTaskFamily::ImageGeneration]; - image_model.output_modalities = vec![ModelModality::Image]; - image_model.input_modalities = vec![ModelModality::Text, ModelModality::Image]; - let models = vec![ - build_model( - "gpt-5.4-mini", - Some("gpt-5.4"), - false, - false, - true, - ModelTier::Mini, - Some("2026-01-03"), - ), - image_model, - ]; - let requirements = - routing_slot_model_capability_requirements(Some("image_generation_model")); - - assert_eq!( - count_compatible_candidate_models(&models, false, false, &requirements), - 1 - ); - assert_eq!( - choose_best_multi_candidate_model("gpt-5.4-mini", &models, false, false, &requirements) - .as_deref(), - Some("gpt-image-1") - ); - } - - #[test] - fn runtime_routing_slot_reselects_only_when_user_lock_allows_it() { - let models = vec![build_model( - "gpt-5.4-mini", - Some("gpt-5.4"), - false, - false, - true, - ModelTier::Mini, - Some("2026-01-03"), - )]; - let requirements = - routing_slot_model_capability_requirements(Some("browser_reasoning_model")); - let model = find_model_meta("gpt-5.4-mini", &models); - - assert!(should_reselect_for_runtime_capability_gap( - model, - 1, - &requirements, - false - )); - assert!(!should_reselect_for_runtime_capability_gap( - model, - 1, - &requirements, - true - )); - } - - #[test] - fn user_locked_model_reports_execution_profile_capability_gap() { - let models = vec![build_model( - "gpt-5.4-mini", - Some("gpt-5.4"), - false, - false, - true, - ModelTier::Mini, - Some("2026-01-03"), - )]; - let requirements = - routing_slot_model_capability_requirements(Some("browser_reasoning_model")); - let model = find_model_meta("gpt-5.4-mini", &models).expect("model"); - let locked_model_capability_gap = - missing_runtime_model_capability_requirements(model, &requirements) - .first() - .map(|requirement| requirement.gap_code().to_string()); - - assert_eq!( - locked_model_capability_gap.as_deref(), - Some("browser_reasoning_candidate_missing") - ); - assert_eq!( - runtime_model_capability_gap_for_catalog(&models, &requirements).as_deref(), - Some("browser_reasoning_candidate_missing") - ); - } - - #[test] - fn user_locked_capability_gap_should_use_blocking_limit_status() { - let selection = ResolvedRuntimeProviderSelection { - provider_config: ConfigureProviderRequest { - provider_id: Some("openai".to_string()), - provider_name: "openai".to_string(), - model_name: "gpt-5.4-mini".to_string(), - api_key: None, - base_url: None, - model_capabilities: None, - tool_call_strategy: None, - toolshim_model: None, - }, - provider_selector: "openai".to_string(), - requested_model: "gpt-5.4-mini".to_string(), - resolved_model: "gpt-5.4-mini".to_string(), - candidate_count: 1, - estimated_cost_class: Some("low".to_string()), - pricing: None, - capability_gap: Some("browser_reasoning_candidate_missing".to_string()), - capability_gap_source: Some("explicit_model_lock".to_string()), - fallback_chain: Vec::new(), - }; - - assert_eq!( - runtime_limit_status_for_selection(&selection), - "user_locked_capability_gap" - ); - } - - #[test] - fn custom_provider_multi_candidate_reselection_is_disabled() { - let context = ProviderResolutionContext { - provider_selector: "custom-mimo".to_string(), - aster_provider_name: "anthropic".to_string(), - compatibility_provider_key: "anthropic-compatible".to_string(), - registry_provider_ids: vec!["xiaomi".to_string()], - alias_key: "custom-mimo".to_string(), - custom_models: vec!["mimo-v2.5-pro".to_string(), "mimo-v2-flash".to_string()], - is_custom_provider: true, - provider_type: Some(ApiProviderType::AnthropicCompatible), - provider_group: None, - configured_api_host: Some("https://token-plan-cn.xiaomimimo.com/anthropic".to_string()), - has_credentials: true, - }; - - assert!(!should_auto_reselect_multi_candidate_model(&context)); - } - - #[test] - fn configured_model_allowlist_disables_system_provider_auto_reselection() { - let context = ProviderResolutionContext { - provider_selector: "deepseek".to_string(), - aster_provider_name: "openai".to_string(), - compatibility_provider_key: "deepseek".to_string(), - registry_provider_ids: vec!["deepseek".to_string()], - alias_key: "deepseek".to_string(), - custom_models: vec!["deepseek-chat".to_string()], - is_custom_provider: false, - provider_type: Some(ApiProviderType::Openai), - provider_group: None, - configured_api_host: Some("https://api.deepseek.com".to_string()), - has_credentials: true, - }; - - assert!(!should_auto_reselect_multi_candidate_model(&context)); - } - - #[test] - fn xiaomi_permission_recovery_prefers_non_flash_candidate() { - let context = ProviderResolutionContext { - provider_selector: "custom-mimo".to_string(), - aster_provider_name: "anthropic".to_string(), - compatibility_provider_key: "anthropic-compatible".to_string(), - registry_provider_ids: vec!["xiaomi".to_string()], - alias_key: "custom-mimo".to_string(), - custom_models: vec!["mimo-v2.5-pro".to_string(), "mimo-v2-flash".to_string()], - is_custom_provider: true, - provider_type: Some(ApiProviderType::AnthropicCompatible), - provider_group: None, - configured_api_host: Some("https://token-plan-cn.xiaomimimo.com/anthropic".to_string()), - has_credentials: true, - }; - let models = vec![ - build_model( - "mimo-v2.5-pro", - Some("mimo-v2"), - false, - false, - true, - ModelTier::Pro, - Some("2026-04-01"), - ), - build_model( - "mimo-v2-flash", - Some("mimo-v2"), - false, - false, - true, - ModelTier::Mini, - Some("2026-04-02"), - ), - ]; - - assert_eq!( - choose_provider_permission_recovery_model( - &context, - "mimo-v2-flash", - &models, - false, - false - ) - .as_deref(), - Some("mimo-v2.5-pro") - ); - } - - #[test] - fn permission_recovery_prefers_configured_model_for_system_provider() { - let context = ProviderResolutionContext { - provider_selector: "deepseek".to_string(), - aster_provider_name: "openai".to_string(), - compatibility_provider_key: "deepseek".to_string(), - registry_provider_ids: vec!["deepseek".to_string()], - alias_key: "deepseek".to_string(), - custom_models: vec!["deepseek-chat".to_string(), "deepseek-reasoner".to_string()], - is_custom_provider: false, - provider_type: Some(ApiProviderType::Openai), - provider_group: None, - configured_api_host: Some("https://api.deepseek.com".to_string()), - has_credentials: true, - }; - let models = vec![ - build_model( - "deepseek-chat", - Some("deepseek"), - false, - false, - true, - ModelTier::Mini, - Some("2026-04-01"), - ), - build_model( - "deepseek-reasoner", - Some("deepseek"), - true, - false, - true, - ModelTier::Pro, - Some("2026-04-02"), - ), - build_model( - "gpt-5.5", - Some("gpt"), - true, - false, - true, - ModelTier::Max, - Some("2026-05-01"), - ), - ]; - - assert_eq!( - choose_provider_permission_recovery_model(&context, "gpt-5.5", &models, false, false) - .as_deref(), - Some("deepseek-chat") - ); - } - - #[test] - fn permission_recovery_falls_back_to_known_openai_fast_model() { - let context = ProviderResolutionContext { - provider_selector: "openai".to_string(), - aster_provider_name: "openai".to_string(), - compatibility_provider_key: "openai".to_string(), - registry_provider_ids: vec!["openai".to_string()], - alias_key: "openai".to_string(), - custom_models: vec![], - is_custom_provider: false, - provider_type: Some(ApiProviderType::Openai), - provider_group: None, - configured_api_host: None, - has_credentials: true, - }; - let models = vec![ - build_model( - "gpt-4o", - Some("gpt-4o"), - false, - true, - true, - ModelTier::Pro, - Some("2024-05-13"), - ), - build_model( - "gpt-4o-mini", - Some("gpt-4o"), - false, - true, - true, - ModelTier::Mini, - Some("2024-07-18"), - ), - ]; - - assert_eq!( - choose_provider_permission_recovery_model(&context, "gpt-4o", &models, false, false) - .as_deref(), - Some("gpt-4o-mini") - ); - } - - #[test] - fn permission_recovery_can_use_uncataloged_configured_model() { - let context = ProviderResolutionContext { - provider_selector: "siliconflow-cn".to_string(), - aster_provider_name: "openai".to_string(), - compatibility_provider_key: "openai".to_string(), - registry_provider_ids: vec!["siliconflow-cn".to_string()], - alias_key: "siliconflow-cn".to_string(), - custom_models: vec!["deepseek-ai/DeepSeek-V4-Flash".to_string()], - is_custom_provider: false, - provider_type: Some(ApiProviderType::Openai), - provider_group: None, - configured_api_host: Some("https://api.siliconflow.cn/v1".to_string()), - has_credentials: true, - }; - let models = vec![build_model( - "gpt-5.5", - Some("gpt"), - true, - false, - true, - ModelTier::Max, - Some("2026-05-01"), - )]; - - assert_eq!( - choose_provider_permission_recovery_model(&context, "gpt-5.5", &models, false, false) - .as_deref(), - Some("deepseek-ai/DeepSeek-V4-Flash") - ); - } - - #[test] - fn model_preference_falls_back_to_session_model_when_provider_matches() { - let resolved = resolve_model_preference_with_session_fallback( - None, - "openai", - Some(&SessionProviderModelContext { - provider_selector: Some("openai".to_string()), - provider_name: Some("OpenAI".to_string()), - model_name: Some("gpt-5.4-mini".to_string()), - }), - ) - .unwrap(); - - assert_eq!( - resolved, - ("gpt-5.4-mini".to_string(), RequestPreferenceSource::Session) - ); - } - - #[test] - fn model_preference_requires_explicit_value_when_provider_changes() { - let error = resolve_model_preference_with_session_fallback( - None, - "gemini", - Some(&SessionProviderModelContext { - provider_selector: Some("openai".to_string()), - provider_name: Some("OpenAI".to_string()), - model_name: Some("gpt-5.4-mini".to_string()), - }), - ) - .unwrap_err(); - - assert!( - error.contains("切换 provider"), - "unexpected error message: {error}" - ); - } - - #[test] - fn explicit_model_preference_wins_over_session_fallback() { - let resolved = resolve_model_preference_with_session_fallback( - Some("gpt-5.4".to_string()), - "openai", - Some(&SessionProviderModelContext { - provider_selector: Some("openai".to_string()), - provider_name: Some("OpenAI".to_string()), - model_name: Some("gpt-5.4-mini".to_string()), - }), - ) - .unwrap(); - - assert_eq!( - resolved, - ("gpt-5.4".to_string(), RequestPreferenceSource::Request) - ); - } - - #[test] - fn provider_preference_falls_back_to_session_provider_when_request_missing() { - let resolved = resolve_provider_preference_with_session_fallback( - None, - Some(&SessionProviderModelContext { - provider_selector: Some("openai".to_string()), - provider_name: Some("OpenAI".to_string()), - model_name: Some("gpt-5.4-mini".to_string()), - }), - ) - .unwrap(); - - assert_eq!( - resolved, - ("openai".to_string(), RequestPreferenceSource::Session) - ); - } - - #[test] - fn explicit_provider_preference_wins_over_session_fallback() { - let resolved = resolve_provider_preference_with_session_fallback( - Some("gemini".to_string()), - Some(&SessionProviderModelContext { - provider_selector: Some("openai".to_string()), - provider_name: Some("OpenAI".to_string()), - model_name: Some("gpt-5.4-mini".to_string()), - }), - ) - .unwrap(); - - assert_eq!( - resolved, - ("gemini".to_string(), RequestPreferenceSource::Request) - ); - } - - #[test] - fn service_scene_model_preference_reads_complete_preference() { - let metadata = serde_json::json!({ - "harness": { - "service_scene_launch": { - "kind": "local_service_skill", - "service_scene_run": { - "skill_id": "voice-runtime", - "preferred_provider_id": "openai-tts", - "preferred_model_id": "gpt-4o-mini-tts", - "allow_fallback": false, - } - } - } - }); - - let resolved = resolve_service_scene_model_preference(Some(&metadata)); - - assert_eq!( - resolved, - Some(ServiceSceneModelPreferenceContext { - provider_selector: "openai-tts".to_string(), - model_name: "gpt-4o-mini-tts".to_string(), - allow_fallback: false, - }) - ); - } - - #[test] - fn service_scene_model_preference_ignores_provider_only_selection() { - let metadata = serde_json::json!({ - "harness": { - "service_scene_launch": { - "kind": "local_service_skill", - "service_scene_run": { - "skill_id": "voice-runtime", - "preferred_provider_id": "openai-tts" - } - } - } - }); - - assert!(resolve_service_scene_model_preference(Some(&metadata)).is_none()); - } - - #[test] - fn runtime_task_profile_marks_translation_service_slot() { - let request = AsterChatRequest { - message: "翻译这段内容".to_string(), - session_id: "session-1".to_string(), - event_name: "agent-event".to_string(), - images: None, - provider_config: None, - provider_preference: None, - model_preference: None, - thinking_enabled: None, - approval_policy: None, - sandbox_policy: None, - project_id: None, - workspace_id: "workspace-1".to_string(), - web_search: None, - search_mode: None, - execution_strategy: None, - auto_continue: None, - system_prompt: None, - metadata: Some(serde_json::json!({ - "harness": { - "translation_skill_launch": { - "source_text": "hello" - } - } - })), - turn_id: None, - queue_if_busy: None, - queued_turn_id: None, - }; - - let profile = build_runtime_task_profile(&request); - - assert_eq!(profile.kind, "translation"); - assert_eq!(profile.source, "translation_skill_launch"); - assert_eq!(profile.service_model_slot.as_deref(), Some("translation")); - assert!(profile - .traits - .iter() - .any(|value| value == "service_model_slot")); - } - - #[test] - fn runtime_task_profile_maps_more_service_model_slots() { - let base_request = AsterChatRequest { - message: "继续处理".to_string(), - session_id: "session-1".to_string(), - event_name: "agent-event".to_string(), - images: None, - provider_config: None, - provider_preference: None, - model_preference: None, - thinking_enabled: None, - approval_policy: None, - sandbox_policy: None, - project_id: None, - workspace_id: "workspace-1".to_string(), - web_search: None, - search_mode: None, - execution_strategy: None, - auto_continue: None, - system_prompt: None, - metadata: None, - turn_id: None, - queue_if_busy: None, - queued_turn_id: None, - }; - - let mut resource_request = base_request.clone(); - resource_request.metadata = Some(serde_json::json!({ - "harness": { - "resource_search_skill_launch": { - "kind": "resource_search_task", - "resource_search_task": { - "query": "找几张产品图" - } - } - } - })); - let resource_profile = build_runtime_task_profile(&resource_request); - assert_eq!(resource_profile.kind, "resource_search"); - assert_eq!(resource_profile.source, "resource_search_skill_launch"); - assert_eq!( - resource_profile.service_model_slot.as_deref(), - Some("resource_prompt_rewrite") - ); - - let mut summary_request = base_request.clone(); - summary_request.metadata = Some(serde_json::json!({ - "harness": { - "summary_skill_launch": { - "kind": "summary_request", - "summary_request": { - "content": "总结这段内容" - } - } - } - })); - let summary_profile = build_runtime_task_profile(&summary_request); - assert_eq!(summary_profile.kind, "summary"); - assert_eq!(summary_profile.source, "summary_skill_launch"); - assert_eq!(summary_profile.service_model_slot, None); - - let mut topic_request = base_request.clone(); - topic_request.metadata = Some(serde_json::json!({ - "harness": { - "topic_skill_launch": { - "kind": "topic_request" - } - } - })); - let topic_profile = build_runtime_task_profile(&topic_request); - assert_eq!(topic_profile.kind, "topic"); - assert_eq!(topic_profile.source, "auxiliary_topic"); - assert_eq!(topic_profile.service_model_slot.as_deref(), Some("topic")); - - let mut generation_topic_request = base_request.clone(); - generation_topic_request.metadata = Some(serde_json::json!({ - "harness": { - "generation_topic_skill_launch": { - "kind": "generation_topic_request" - } - } - })); - let generation_topic_profile = build_runtime_task_profile(&generation_topic_request); - assert_eq!(generation_topic_profile.kind, "generation_topic"); - assert_eq!( - generation_topic_profile.source, - "auxiliary_generation_topic" - ); - assert_eq!( - generation_topic_profile.service_model_slot.as_deref(), - Some("generation_topic") - ); - - let mut agent_meta_request = base_request.clone(); - agent_meta_request.metadata = Some(serde_json::json!({ - "harness": { - "agent_meta_skill_launch": { - "kind": "agent_meta_request" - } - } - })); - let agent_meta_profile = build_runtime_task_profile(&agent_meta_request); - assert_eq!(agent_meta_profile.kind, "agent_meta"); - assert_eq!(agent_meta_profile.source, "auxiliary_agent_meta"); - assert_eq!( - agent_meta_profile.service_model_slot.as_deref(), - Some("agent_meta") - ); - - let mut rewrite_request = base_request; - rewrite_request.metadata = Some(serde_json::json!({ - "harness": { - "turn_purpose": "style_rewrite" - } - })); - let rewrite_profile = build_runtime_task_profile(&rewrite_request); - assert_eq!(rewrite_profile.kind, "prompt_rewrite"); - assert_eq!(rewrite_profile.source, "turn_purpose"); - assert_eq!( - rewrite_profile.service_model_slot.as_deref(), - Some("prompt_rewrite") - ); - } - - #[test] - fn runtime_task_profile_marks_oem_runtime_from_harness_oem_routing() { - let request = AsterChatRequest { - message: "继续处理".to_string(), - session_id: "session-1".to_string(), - event_name: "agent-event".to_string(), - images: None, - provider_config: None, - provider_preference: None, - model_preference: None, - thinking_enabled: None, - approval_policy: None, - sandbox_policy: None, - project_id: None, - workspace_id: "workspace-1".to_string(), - web_search: None, - search_mode: None, - execution_strategy: None, - auto_continue: None, - system_prompt: None, - metadata: Some(serde_json::json!({ - "harness": { - "oem_routing": { - "tenant_id": "tenant-1", - "provider_source": "oem_cloud", - "provider_key": "lime-hub", - "config_mode": "managed", - "offer_state": "available_quota_low", - "quota_status": "low", - "fallback_to_local_allowed": false, - } - } - })), - turn_id: None, - queue_if_busy: None, - queued_turn_id: None, - }; - - let profile = build_runtime_task_profile(&request); - let oem_routing = resolve_request_oem_routing_context(request.metadata.as_ref()); - - assert!(profile.traits.iter().any(|value| value == "oem_runtime")); - assert!(request_oem_routing_is_locked(oem_routing.as_ref())); - assert_eq!( - build_request_oem_limit_event(oem_routing.as_ref()), - Some(lime_agent::SessionExecutionRuntimeLimitEvent { - event_kind: "quota_low".to_string(), - message: "OEM 云端 provider lime-hub 当前额度偏低,后续请求可能触发配额风险。" - .to_string(), - retryable: true, - }) - ); - } - - #[test] - fn runtime_task_profile_merges_modality_execution_profile_from_runtime_contract() { - let request = AsterChatRequest { - message: "打开这个页面并检查登录态".to_string(), - session_id: "session-1".to_string(), - event_name: "agent-event".to_string(), - images: None, - provider_config: None, - provider_preference: None, - model_preference: None, - thinking_enabled: None, - approval_policy: None, - sandbox_policy: None, - project_id: None, - workspace_id: "workspace-1".to_string(), - web_search: None, - search_mode: None, - execution_strategy: None, - auto_continue: None, - system_prompt: None, - metadata: Some(serde_json::json!({ - "harness": { - "browser_assist": { - "runtime_contract": { - "contract_key": "browser_control", - "routing_slot": "browser_reasoning_model", - "execution_profile": { - "profile_key": "browser_control_profile" - }, - "executor_adapter": { - "adapter_key": "browser:browser_assist" - }, - "executor_binding": { - "executor_kind": "browser", - "binding_key": "browser_assist" - } - } - } - } - })), - turn_id: None, - queue_if_busy: None, - queued_turn_id: None, - }; - - let profile = build_runtime_task_profile(&request); - - assert_eq!(profile.kind, "chat"); - assert_eq!( - profile.modality_contract_key.as_deref(), - Some("browser_control") - ); - assert_eq!( - profile.routing_slot.as_deref(), - Some("browser_reasoning_model") - ); - assert_eq!( - profile.execution_profile_key.as_deref(), - Some("browser_control_profile") - ); - assert_eq!( - profile.executor_adapter_key.as_deref(), - Some("browser:browser_assist") - ); - assert_eq!(profile.executor_kind.as_deref(), Some("browser")); - assert_eq!( - profile.executor_binding_key.as_deref(), - Some("browser_assist") - ); - assert_eq!( - profile.permission_profile_keys, - vec![ - "browser_control".to_string(), - "web_search".to_string(), - "ask_user_question".to_string() - ] - ); - assert_eq!( - profile.user_lock_policy.as_deref(), - Some("honor_explicit_model_lock_with_capability_check") - ); - assert!(profile - .traits - .iter() - .any(|value| value == "modality_runtime_contract")); - assert!(profile - .traits - .iter() - .any(|value| value == "execution_profile")); - assert!(profile - .traits - .iter() - .any(|value| value == "executor_adapter")); - } - - #[test] - fn runtime_permission_state_summarizes_declared_profile_keys_without_blocking() { - let task_profile = lime_agent::SessionExecutionRuntimeTaskProfile { - kind: "browser_control".to_string(), - source: "browser_assist".to_string(), - traits: vec!["modality_runtime_contract".to_string()], - modality_contract_key: Some("browser_control".to_string()), - routing_slot: Some("browser_reasoning_model".to_string()), - execution_profile_key: Some("browser_control_profile".to_string()), - executor_adapter_key: Some("browser:browser_assist".to_string()), - executor_kind: Some("browser".to_string()), - executor_binding_key: Some("browser_assist".to_string()), - permission_profile_keys: vec![ - "browser_control".to_string(), - "web_search".to_string(), - "ask_user_question".to_string(), - "browser_control".to_string(), - ], - user_lock_policy: Some("honor_explicit_model_lock_with_capability_check".to_string()), - service_model_slot: None, - scene_kind: None, - scene_skill_id: None, - entry_source: None, - }; - - let permission_state = build_permission_state(&task_profile, None); - - assert_eq!(permission_state.status, "requires_confirmation"); - assert_eq!( - permission_state.required_profile_keys, - vec![ - "browser_control".to_string(), - "web_search".to_string(), - "ask_user_question".to_string() - ] - ); - assert_eq!( - permission_state.ask_profile_keys, - vec!["browser_control".to_string(), "web_search".to_string()] - ); - assert!(permission_state.blocking_profile_keys.is_empty()); - assert_eq!( - permission_state.decision_scope, - "declared_permission_profiles_only" - ); - assert_eq!( - permission_state.confirmation_status.as_deref(), - Some("not_requested") - ); - assert!(permission_state.confirmation_request_id.is_none()); - assert_eq!( - permission_state.confirmation_source.as_deref(), - Some("declared_profile_only") - ); - } - - #[test] - fn runtime_permission_state_full_access_resolves_declared_profiles_without_extra_confirmation() - { - let task_profile = lime_agent::SessionExecutionRuntimeTaskProfile { - kind: "image_generation".to_string(), - source: "image_skill_launch".to_string(), - traits: vec!["modality_runtime_contract".to_string()], - modality_contract_key: Some("image_generation".to_string()), - routing_slot: Some("image_generation_model".to_string()), - execution_profile_key: Some("image_generation_profile".to_string()), - executor_adapter_key: Some("skill:image_generate".to_string()), - executor_kind: Some("skill".to_string()), - executor_binding_key: Some("image_generate".to_string()), - permission_profile_keys: vec![ - "write_artifacts".to_string(), - "media_upload".to_string(), - "ask_user_question".to_string(), - ], - user_lock_policy: None, - service_model_slot: None, - scene_kind: None, - scene_skill_id: None, - entry_source: None, - }; - - let permission_state = build_permission_state( - &task_profile, - Some(lime_agent::SessionExecutionRuntimeAccessMode::FullAccess), - ); - - assert_eq!(permission_state.status, "declared_only"); - assert_eq!( - permission_state.required_profile_keys, - vec![ - "write_artifacts".to_string(), - "media_upload".to_string(), - "ask_user_question".to_string(), - ] - ); - assert!(permission_state.ask_profile_keys.is_empty()); - assert_eq!( - permission_state.decision_scope, - "declared_permission_profiles_resolved_by_full_access" - ); - assert_eq!( - permission_state.confirmation_status.as_deref(), - Some("resolved") - ); - assert_eq!( - permission_state.confirmation_source.as_deref(), - Some("access_mode_full_access") - ); - assert!(permission_state - .notes - .iter() - .any(|note| note.contains("full-access"))); - } - - #[test] - fn explicit_provider_config_resolution_reports_single_candidate_routing() { - let task_profile = lime_agent::SessionExecutionRuntimeTaskProfile { - kind: "chat".to_string(), - source: "default_chat".to_string(), - traits: Vec::new(), - modality_contract_key: None, - routing_slot: None, - execution_profile_key: None, - executor_adapter_key: None, - executor_kind: None, - executor_binding_key: None, - permission_profile_keys: Vec::new(), - user_lock_policy: None, - service_model_slot: None, - scene_kind: None, - scene_skill_id: None, - entry_source: None, - }; - let base_decision = build_routing_decision( - &task_profile, - "provider_config", - "请求已显式传入 provider_config,运行时仅补齐能力与工具策略。".to_string(), - None, - Some("openai".to_string()), - Some("gpt-5.4-mini".to_string()), - None, - ); - - let resolved = lime_agent::SessionExecutionRuntimeRoutingDecision { - routing_mode: "single_candidate".to_string(), - selected_provider: Some("openai".to_string()), - selected_model: Some("gpt-5.4-mini".to_string()), - candidate_count: 1, - estimated_cost_class: Some("low".to_string()), - ..base_decision - }; - - assert_eq!(resolved.routing_mode, "single_candidate"); - assert_eq!(resolved.candidate_count, 1); - assert_eq!(resolved.selected_provider.as_deref(), Some("openai")); - assert_eq!(resolved.selected_model.as_deref(), Some("gpt-5.4-mini")); - } - - #[test] - fn build_cost_state_should_capture_pricing_snapshot() { - let selection = ResolvedRuntimeProviderSelection { - provider_config: ConfigureProviderRequest { - provider_id: Some("openai".to_string()), - provider_name: "openai".to_string(), - model_name: "gpt-5.4-mini".to_string(), - api_key: None, - base_url: None, - model_capabilities: None, - tool_call_strategy: None, - toolshim_model: None, - }, - provider_selector: "openai".to_string(), - requested_model: "gpt-5.4-mini".to_string(), - resolved_model: "gpt-5.4-mini".to_string(), - candidate_count: 1, - estimated_cost_class: Some("low".to_string()), - pricing: Some(ModelPricing { - input_per_million: Some(0.8), - output_per_million: Some(3.2), - cache_read_per_million: Some(0.08), - cache_write_per_million: Some(1.0), - currency: "USD".to_string(), - }), - capability_gap: None, - capability_gap_source: None, - fallback_chain: Vec::new(), - }; - - let cost_state = build_cost_state(Some(&selection), None, "estimated"); - - assert_eq!(cost_state.status, "estimated"); - assert_eq!(cost_state.estimated_cost_class.as_deref(), Some("low")); - assert_eq!(cost_state.input_per_million, Some(0.8)); - assert_eq!(cost_state.output_per_million, Some(3.2)); - assert_eq!(cost_state.cache_read_per_million, Some(0.08)); - assert_eq!(cost_state.cache_write_per_million, Some(1.0)); - assert_eq!(cost_state.currency.as_deref(), Some("USD")); - assert!(cost_state.estimated_total_cost.is_none()); - } - - #[test] - fn runtime_provider_strategy_prefers_manual_mode_for_credentialless_local_provider() { - let context = ProviderResolutionContext { - provider_selector: "ollama".to_string(), - aster_provider_name: "ollama".to_string(), - compatibility_provider_key: "ollama".to_string(), - registry_provider_ids: vec!["ollama".to_string()], - alias_key: "ollama".to_string(), - custom_models: vec![], - is_custom_provider: false, - provider_type: Some(ApiProviderType::Ollama), - provider_group: Some(ProviderGroup::Local), - configured_api_host: Some("http://127.0.0.1:11434".to_string()), - has_credentials: false, - }; - - assert_eq!( - resolve_runtime_provider_configuration_strategy(&context), - RuntimeProviderConfigurationStrategy::Manual { - base_url: Some("http://127.0.0.1:11434".to_string()), - } - ); - } - - #[test] - fn normalize_runtime_provider_base_url_strips_ollama_v1_suffix() { - assert_eq!( - normalize_runtime_provider_base_url( - Some(ApiProviderType::Ollama), - Some("http://127.0.0.1:11434/v1/".to_string()), - ), - Some("http://127.0.0.1:11434".to_string()) - ); - } - - #[test] - fn canonical_provider_selector_maps_legacy_mimo_ids() { - assert_eq!(canonical_provider_selector("mimo"), "xiaomi"); - assert_eq!(canonical_provider_selector("xiaomimimo"), "xiaomi"); - assert_eq!(canonical_provider_selector("xiaomi"), "xiaomi"); - } -} +mod tests; diff --git a/src-tauri/src/commands/aster_agent_cmd/request_model_resolution/responsive_chat.rs b/src-tauri/src/commands/aster_agent_cmd/request_model_resolution/responsive_chat.rs new file mode 100644 index 000000000..0f0808ba5 --- /dev/null +++ b/src-tauri/src/commands/aster_agent_cmd/request_model_resolution/responsive_chat.rs @@ -0,0 +1,547 @@ +use super::{ + build_provider_resolution_context, compare_release_date_desc, estimate_cost_class, + estimated_cost_rank, is_runtime_candidate_model, load_model_registry_catalog, + model_has_reasoning_capability, normalize_identifier, normalize_optional_text, + resolve_request_thinking_enabled, routing_slot_model_capability_requirements, + text_contains_any, RuntimeModelCapabilityRequirement, +}; +use crate::commands::api_key_provider_cmd::ApiKeyProviderServiceState; +use crate::commands::aster_agent_cmd::AsterChatRequest; +use lime_core::database::dao::api_key_provider::{ + ApiProviderType, ProviderGroup, ProviderWithKeys, +}; +use lime_core::database::DbConnection; +use lime_core::models::model_registry::EnhancedModelMetadata; +use std::collections::HashMap; +use tauri::AppHandle; + +pub(super) const RESPONSIVE_CHAT_SERVICE_MODEL_SLOT: &str = "responsive_chat"; +pub(super) const RESPONSIVE_CHAT_ROUTING_SLOT: &str = "responsive_chat_model"; + +#[derive(Debug, Clone)] +pub(super) struct ResponsiveChatAutoPreferenceContext { + pub(super) provider_selector: String, + pub(super) model_name: String, + pub(super) compatible_candidate_count: u32, +} + +#[derive(Debug, Clone)] +pub(super) struct ResponsiveChatAutoCandidate { + pub(super) provider_selector: String, + pub(super) model_name: String, + pub(super) model: EnhancedModelMetadata, + pub(super) compatible_candidate_count: u32, + pub(super) provider_order: usize, + pub(super) provider_group: Option, + pub(super) latency_hint: ResponsiveChatAutoLatencyHint, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(super) struct ResponsiveChatAutoLatencyHint { + pub(super) durations_ms: Vec, + pub(super) reasoning_sample_count: u32, + pub(super) error_sample_count: u32, + pub(super) unsupported_model_sample_count: u32, +} + +impl ResponsiveChatAutoLatencyHint { + fn p50_duration_ms(&self) -> Option { + if self.durations_ms.is_empty() { + return None; + } + + let mut durations = self.durations_ms.clone(); + durations.sort_unstable(); + durations.get(durations.len() / 2).copied() + } +} + +fn responsive_chat_speed_hint_rank(model: &EnhancedModelMetadata) -> u8 { + let text = [ + normalize_identifier(&model.id), + normalize_identifier(&model.display_name), + model + .family + .as_deref() + .map(normalize_identifier) + .unwrap_or_default(), + model + .description + .as_deref() + .map(normalize_identifier) + .unwrap_or_default(), + ] + .into_iter() + .filter(|part| !part.is_empty()) + .collect::>() + .join(" "); + + if text_contains_any(&text, &["highspeed", "high-speed", "high speed"]) { + return 6; + } + if text_contains_any(&text, &["flash", "instant"]) { + return 5; + } + if text_contains_any(&text, &["turbo", "fast"]) { + return 4; + } + if text_contains_any(&text, &["mini", "nano", "small", "lite", "haiku"]) { + return 3; + } + if text_contains_any(&text, &["pro", "max", "opus", "ultra"]) { + return 1; + } + + 2 +} + +pub(super) fn targets_responsive_chat(requirements: &[RuntimeModelCapabilityRequirement]) -> bool { + requirements.iter().any(|requirement| { + matches!( + requirement, + RuntimeModelCapabilityRequirement::ResponsiveChat + ) + }) +} + +pub(super) fn responsive_chat_model_sort( + left: &EnhancedModelMetadata, + right: &EnhancedModelMetadata, +) -> std::cmp::Ordering { + responsive_chat_model_quality_sort(left, right).then(left.id.cmp(&right.id)) +} + +pub(super) fn choose_responsive_chat_model_from_catalog( + catalog: &[EnhancedModelMetadata], + thinking_enabled: bool, + has_images: bool, + runtime_requirements: &[RuntimeModelCapabilityRequirement], +) -> Option<(String, EnhancedModelMetadata, u32)> { + let mut candidates = catalog + .iter() + .filter(|candidate| { + is_runtime_candidate_model( + candidate, + thinking_enabled, + has_images, + runtime_requirements, + ) && !responsive_chat_model_looks_specialized_for_code_agent(candidate) + }) + .collect::>(); + + if candidates.is_empty() { + return None; + } + + candidates.sort_by(|left, right| responsive_chat_model_sort(left, right)); + let best = candidates.first()?; + Some((best.id.clone(), (*best).clone(), candidates.len() as u32)) +} + +fn responsive_chat_model_quality_sort( + left: &EnhancedModelMetadata, + right: &EnhancedModelMetadata, +) -> std::cmp::Ordering { + let left_reasoning = model_has_reasoning_capability(Some(left), &left.id); + let right_reasoning = model_has_reasoning_capability(Some(right), &right.id); + let left_speed = responsive_chat_speed_hint_rank(left); + let right_speed = responsive_chat_speed_hint_rank(right); + let left_cost = estimated_cost_rank(estimate_cost_class(&left.id, Some(left)).as_deref()); + let right_cost = estimated_cost_rank(estimate_cost_class(&right.id, Some(right)).as_deref()); + + (!left_reasoning) + .cmp(&(!right_reasoning)) + .reverse() + .then(left_speed.cmp(&right_speed).reverse()) + .then(left_cost.cmp(&right_cost).reverse()) + .then( + left.capabilities + .streaming + .cmp(&right.capabilities.streaming) + .reverse(), + ) + .then(left.is_latest.cmp(&right.is_latest).reverse()) + .then(compare_release_date_desc(left, right).cmp(&0)) +} + +fn responsive_chat_model_looks_specialized_for_code_agent(model: &EnhancedModelMetadata) -> bool { + let text = [ + normalize_identifier(&model.id), + normalize_identifier(&model.display_name), + model + .family + .as_deref() + .map(normalize_identifier) + .unwrap_or_default(), + model + .description + .as_deref() + .map(normalize_identifier) + .unwrap_or_default(), + ] + .into_iter() + .filter(|part| !part.is_empty()) + .collect::>() + .join(" "); + + text_contains_any( + &text, + &[ + "code", + "coding", + "coder", + "codex", + "cli agent", + "cli-agent", + "agentic coding", + ], + ) +} + +fn responsive_chat_latency_rank(hint: &ResponsiveChatAutoLatencyHint) -> (u8, u64) { + let Some(p50_duration_ms) = hint.p50_duration_ms() else { + if hint.unsupported_model_sample_count > 0 { + return (7, u64::MAX); + } + if hint.error_sample_count > 0 { + return (6, u64::MAX); + } + return (4, u64::MAX); + }; + + if hint.unsupported_model_sample_count > 0 { + return (7, p50_duration_ms); + } + if hint.reasoning_sample_count > 0 { + return (5, p50_duration_ms); + } + if p50_duration_ms > 6_000 { + return (5, p50_duration_ms); + } + if p50_duration_ms > 3_000 { + return (2, p50_duration_ms); + } + if p50_duration_ms <= 1_500 { + return (0, p50_duration_ms); + } + + (1, p50_duration_ms) +} + +fn responsive_chat_provider_trust_rank(candidate: &ResponsiveChatAutoCandidate) -> u8 { + match candidate.provider_group { + Some(ProviderGroup::Mainstream | ProviderGroup::Cloud) => 0, + Some(ProviderGroup::Chinese | ProviderGroup::Aggregator) => 1, + Some(ProviderGroup::Local) => 2, + Some(ProviderGroup::Specialized) => 3, + Some(ProviderGroup::Custom) => 4, + None if lime_core::models::provider_type::is_custom_provider_id( + &candidate.provider_selector, + ) => + { + 4 + } + None => 2, + } +} + +pub(super) fn responsive_chat_auto_candidate_sort( + left: &ResponsiveChatAutoCandidate, + right: &ResponsiveChatAutoCandidate, +) -> std::cmp::Ordering { + responsive_chat_latency_rank(&left.latency_hint) + .cmp(&responsive_chat_latency_rank(&right.latency_hint)) + .then( + responsive_chat_provider_trust_rank(left) + .cmp(&responsive_chat_provider_trust_rank(right)), + ) + .then(responsive_chat_model_quality_sort( + &left.model, + &right.model, + )) + .then(left.provider_order.cmp(&right.provider_order)) + .then(left.provider_selector.cmp(&right.provider_selector)) + .then(left.model_name.cmp(&right.model_name)) +} + +fn responsive_chat_provider_type_supports_text_chat(provider_type: ApiProviderType) -> bool { + matches!( + provider_type, + ApiProviderType::Openai + | ApiProviderType::OpenaiResponse + | ApiProviderType::Anthropic + | ApiProviderType::AnthropicCompatible + | ApiProviderType::Gemini + | ApiProviderType::AzureOpenai + | ApiProviderType::Vertexai + | ApiProviderType::AwsBedrock + | ApiProviderType::Ollama + | ApiProviderType::NewApi + | ApiProviderType::Gateway + ) +} + +fn responsive_chat_provider_looks_non_chat_runtime(provider: &ProviderWithKeys) -> bool { + let id = normalize_identifier(&provider.provider.id); + let name = normalize_identifier(&provider.provider.name); + let api_host = normalize_identifier(&provider.provider.api_host); + + provider.provider.effective_provider_type() == ApiProviderType::Fal + || id == "fal" + || name == "fal" + || api_host.contains("fal.run") + || api_host.contains("/fal-ai") + || id.contains("codex") + || name.contains("codex") + || api_host.contains("codex") + || id.contains("coding") + || name.contains("coding") + || api_host.contains("/coding") +} + +pub(super) fn can_use_responsive_chat_provider(provider: &ProviderWithKeys) -> bool { + if !provider.provider.enabled { + return false; + } + + if !responsive_chat_provider_type_supports_text_chat( + provider.provider.effective_provider_type(), + ) { + return false; + } + if responsive_chat_provider_looks_non_chat_runtime(provider) { + return false; + } + + let has_enabled_key = provider.api_keys.iter().any(|key| key.enabled); + if has_enabled_key { + return true; + } + + let has_local_endpoint = matches!(provider.provider.group, ProviderGroup::Local) + && !provider.provider.api_host.trim().is_empty(); + has_local_endpoint || provider.provider.provider_type == ApiProviderType::Ollama +} + +pub(super) async fn resolve_responsive_chat_auto_preference( + app: &AppHandle, + db: &DbConnection, + api_key_provider_service: &ApiKeyProviderServiceState, + request: &AsterChatRequest, + task_profile: &lime_agent::SessionExecutionRuntimeTaskProfile, +) -> Result, String> { + let runtime_requirements = + routing_slot_model_capability_requirements(task_profile.routing_slot.as_deref()); + if !targets_responsive_chat(&runtime_requirements) { + return Ok(None); + } + + let thinking_enabled = resolve_request_thinking_enabled(request).await?; + let has_images = request + .images + .as_ref() + .map(|images| !images.is_empty()) + .unwrap_or(false); + let providers = api_key_provider_service.0.get_all_providers(db)?; + let mut candidates = Vec::new(); + let latency_hints = load_responsive_chat_auto_latency_hints(db); + + for (provider_order, provider) in providers.iter().enumerate() { + if !can_use_responsive_chat_provider(provider) { + continue; + } + + let context = + build_provider_resolution_context(db, api_key_provider_service, &provider.provider.id)?; + let (catalog, _alias_config) = load_model_registry_catalog(app, &context).await; + let Some((model_name, model, compatible_candidate_count)) = + choose_responsive_chat_model_from_catalog( + &catalog, + thinking_enabled, + has_images, + &runtime_requirements, + ) + else { + continue; + }; + + let latency_hint = latency_hints + .get(&(context.provider_selector.clone(), model_name.clone())) + .cloned() + .unwrap_or_default(); + + candidates.push(ResponsiveChatAutoCandidate { + provider_selector: context.provider_selector, + model_name, + model, + compatible_candidate_count, + provider_order, + provider_group: context.provider_group, + latency_hint, + }); + } + + candidates.sort_by(responsive_chat_auto_candidate_sort); + + Ok(candidates + .into_iter() + .next() + .map(|candidate| ResponsiveChatAutoPreferenceContext { + provider_selector: candidate.provider_selector, + model_name: candidate.model_name, + compatible_candidate_count: candidate.compatible_candidate_count, + })) +} + +fn parse_responsive_chat_auto_run_metadata(metadata: &str) -> Option<(String, String)> { + let value: serde_json::Value = serde_json::from_str(metadata).ok()?; + let routing_decision = value + .pointer("/request_metadata/lime_runtime/routing_decision") + .and_then(serde_json::Value::as_object)?; + let decision_source = routing_decision + .get("decisionSource") + .and_then(serde_json::Value::as_str) + .or_else(|| { + routing_decision + .get("decision_source") + .and_then(serde_json::Value::as_str) + })?; + if decision_source != "responsive_chat_auto" { + return None; + } + + let provider_selector = routing_decision + .get("selectedProvider") + .and_then(serde_json::Value::as_str) + .or_else(|| { + routing_decision + .get("selected_provider") + .and_then(serde_json::Value::as_str) + }) + .and_then(|value| normalize_optional_text(Some(value.to_string())))?; + let model_name = routing_decision + .get("selectedModel") + .and_then(serde_json::Value::as_str) + .or_else(|| { + routing_decision + .get("selected_model") + .and_then(serde_json::Value::as_str) + }) + .and_then(|value| normalize_optional_text(Some(value.to_string())))?; + + Some((provider_selector, model_name)) +} + +fn session_has_reasoning_item(conn: &rusqlite::Connection, session_id: &str) -> bool { + conn.query_row( + "SELECT EXISTS( + SELECT 1 FROM agent_thread_items + WHERE session_id = ?1 AND item_type = 'reasoning' + LIMIT 1 + )", + [session_id], + |row| row.get::<_, i64>(0), + ) + .map(|value| value > 0) + .unwrap_or(false) +} + +pub(super) fn is_responsive_chat_unsupported_model_error(message: Option<&str>) -> bool { + let Some(message) = message else { + return false; + }; + let normalized = normalize_identifier(message); + text_contains_any( + &normalized, + &[ + "not supported model", + "unsupported model", + "model not supported", + "model_not_found", + "model not found", + "invalid model", + ], + ) +} + +pub(super) fn responsive_chat_running_sample_is_stale( + status: &str, + started_at: Option<&str>, +) -> bool { + if status != "running" { + return false; + } + let Some(started_at) = + started_at.and_then(|value| normalize_optional_text(Some(value.to_string()))) + else { + return false; + }; + let Ok(started_at) = chrono::DateTime::parse_from_rfc3339(&started_at) else { + return false; + }; + chrono::Utc::now() + .signed_duration_since(started_at.with_timezone(&chrono::Utc)) + .num_milliseconds() + > 120_000 +} + +pub(super) fn load_responsive_chat_auto_latency_hints( + db: &DbConnection, +) -> HashMap<(String, String), ResponsiveChatAutoLatencyHint> { + let Ok(conn) = db.lock() else { + return HashMap::new(); + }; + let Ok(mut stmt) = conn.prepare( + "SELECT session_id, status, duration_ms, error_message, metadata, started_at + FROM agent_runs + WHERE source = 'chat' + AND source_ref = 'agent_runtime_submit_turn' + AND metadata IS NOT NULL + ORDER BY started_at DESC + LIMIT 80", + ) else { + return HashMap::new(); + }; + let Ok(rows) = stmt.query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, Option>(2)?, + row.get::<_, Option>(3)?, + row.get::<_, String>(4)?, + row.get::<_, Option>(5)?, + )) + }) else { + return HashMap::new(); + }; + + let mut hints: HashMap<(String, String), ResponsiveChatAutoLatencyHint> = HashMap::new(); + for row in rows.flatten() { + let (session_id, status, duration_ms, error_message, metadata, started_at) = row; + let Some((provider_selector, model_name)) = + parse_responsive_chat_auto_run_metadata(&metadata) + else { + continue; + }; + + let entry = hints.entry((provider_selector, model_name)).or_default(); + if status == "success" { + if let Some(duration_ms) = duration_ms.and_then(|value| u64::try_from(value).ok()) { + entry.durations_ms.push(duration_ms); + } + } + if status != "success" + || responsive_chat_running_sample_is_stale(&status, started_at.as_deref()) + { + entry.error_sample_count += 1; + } + if is_responsive_chat_unsupported_model_error(error_message.as_deref()) { + entry.unsupported_model_sample_count += 1; + } + if session_has_reasoning_item(&conn, &session_id) { + entry.reasoning_sample_count += 1; + } + } + + hints +} diff --git a/src-tauri/src/commands/aster_agent_cmd/request_model_resolution/tests.rs b/src-tauri/src/commands/aster_agent_cmd/request_model_resolution/tests.rs new file mode 100644 index 000000000..8d2dde1bb --- /dev/null +++ b/src-tauri/src/commands/aster_agent_cmd/request_model_resolution/tests.rs @@ -0,0 +1,2020 @@ +use super::responsive_chat::{ + can_use_responsive_chat_provider, choose_responsive_chat_model_from_catalog, + is_responsive_chat_unsupported_model_error, responsive_chat_auto_candidate_sort, + responsive_chat_running_sample_is_stale, ResponsiveChatAutoCandidate, + ResponsiveChatAutoLatencyHint, +}; +use super::*; +use lime_core::database::dao::api_key_provider::{ + ApiKeyEntry, ApiKeyProvider, ApiProviderType, ProviderGroup, ProviderWithKeys, +}; + +fn build_model( + id: &str, + family: Option<&str>, + reasoning: bool, + vision: bool, + is_latest: bool, + tier: ModelTier, + release_date: Option<&str>, +) -> EnhancedModelMetadata { + EnhancedModelMetadata { + id: id.to_string(), + display_name: id.to_string(), + provider_id: "openai".to_string(), + provider_name: "openai".to_string(), + family: family.map(ToString::to_string), + tier, + capabilities: ModelCapabilities { + vision, + tools: true, + streaming: true, + json_mode: true, + function_calling: true, + reasoning, + }, + task_families: { + let mut families = vec![ModelTaskFamily::Chat]; + if reasoning { + families.push(ModelTaskFamily::Reasoning); + } + if vision { + families.push(ModelTaskFamily::VisionUnderstanding); + } + families + }, + input_modalities: if vision { + vec![ModelModality::Text, ModelModality::Image] + } else { + vec![ModelModality::Text] + }, + output_modalities: vec![ModelModality::Text, ModelModality::Json], + runtime_features: vec![ + ModelRuntimeFeature::Streaming, + ModelRuntimeFeature::ToolCalling, + ModelRuntimeFeature::JsonSchema, + ], + deployment_source: ModelDeploymentSource::UserCloud, + management_plane: ModelManagementPlane::LocalSettings, + canonical_model_id: None, + provider_model_id: Some(id.to_string()), + alias_source: None, + pricing: None, + limits: Default::default(), + status: Default::default(), + release_date: release_date.map(ToString::to_string), + is_latest, + description: None, + source: ModelSource::Embedded, + created_at: 0, + updated_at: 0, + } +} + +fn build_provider_with_key( + id: &str, + provider_type: ApiProviderType, + api_host: &str, +) -> ProviderWithKeys { + let now = chrono::Utc::now(); + ProviderWithKeys { + provider: ApiKeyProvider { + id: id.to_string(), + name: id.to_string(), + provider_type, + api_host: api_host.to_string(), + is_system: false, + group: ProviderGroup::Aggregator, + enabled: true, + sort_order: 0, + api_version: None, + project: None, + location: None, + region: None, + custom_models: Vec::new(), + prompt_cache_mode: None, + created_at: now, + updated_at: now, + }, + api_keys: vec![ApiKeyEntry { + id: format!("{id}-key"), + provider_id: id.to_string(), + api_key_encrypted: "encrypted".to_string(), + alias: None, + enabled: true, + usage_count: 0, + error_count: 0, + last_used_at: None, + created_at: now, + }], + } +} + +#[test] +fn thinking_on_prefers_reasoning_variant() { + let models = vec![ + build_model( + "gpt-5.4-mini", + Some("gpt-5.4"), + false, + false, + true, + ModelTier::Mini, + Some("2026-01-01"), + ), + build_model( + "gpt-5.4-mini-thinking", + Some("gpt-5.4"), + true, + false, + true, + ModelTier::Mini, + Some("2026-01-02"), + ), + ]; + + assert_eq!( + resolve_thinking_model_id("gpt-5.4-mini", &models), + "gpt-5.4-mini-thinking" + ); +} + +#[test] +fn thinking_off_restores_base_variant() { + let models = vec![ + build_model( + "gpt-5.4-mini", + Some("gpt-5.4"), + false, + false, + true, + ModelTier::Mini, + Some("2026-01-01"), + ), + build_model( + "gpt-5.4-mini-thinking", + Some("gpt-5.4"), + true, + false, + true, + ModelTier::Mini, + Some("2026-01-02"), + ), + ]; + + assert_eq!( + resolve_base_model_on_thinking_off("gpt-5.4-mini-thinking", &models), + "gpt-5.4-mini" + ); +} + +#[test] +fn vision_resolution_prefers_same_family_candidate() { + let models = vec![ + build_model( + "gpt-5.4-mini", + Some("gpt-5.4"), + false, + false, + true, + ModelTier::Mini, + Some("2026-01-01"), + ), + build_model( + "gpt-5.4", + Some("gpt-5.4"), + true, + true, + true, + ModelTier::Pro, + Some("2026-01-03"), + ), + build_model( + "gemini-2.5-pro", + Some("gemini-2.5"), + true, + true, + true, + ModelTier::Pro, + Some("2026-01-02"), + ), + ]; + + assert_eq!( + resolve_vision_model_id("gpt-5.4-mini", &models).unwrap(), + "gpt-5.4" + ); +} + +#[test] +fn vision_resolution_keeps_unknown_model_when_name_implies_vision() { + let models = vec![build_model( + "gpt-5.4", + Some("gpt-5.4"), + true, + true, + true, + ModelTier::Pro, + Some("2026-01-03"), + )]; + + assert_eq!( + resolve_vision_model_id("gpt-5.4-mini", &models).unwrap(), + "gpt-5.4-mini" + ); +} + +#[test] +fn codex_compatibility_falls_back_to_supported_model() { + let context = ProviderResolutionContext { + provider_selector: "codex".to_string(), + aster_provider_name: "openai".to_string(), + compatibility_provider_key: "codex".to_string(), + registry_provider_ids: vec!["codex".to_string()], + alias_key: "codex".to_string(), + custom_models: vec![], + is_custom_provider: false, + provider_type: Some(ApiProviderType::Codex), + provider_group: None, + configured_api_host: Some("https://api.openai.com/v1".to_string()), + has_credentials: true, + }; + + assert_eq!( + resolve_provider_model_compatibility(&context, "gpt-5.3-codex"), + "gpt-5.2-codex" + ); +} + +#[test] +fn xiaomi_compatibility_canonicalizes_display_name_and_legacy_alias() { + let context = ProviderResolutionContext { + provider_selector: "custom-mimo".to_string(), + aster_provider_name: "anthropic".to_string(), + compatibility_provider_key: "anthropic-compatible".to_string(), + registry_provider_ids: vec!["xiaomi".to_string()], + alias_key: "custom-mimo".to_string(), + custom_models: vec![], + is_custom_provider: true, + provider_type: Some(ApiProviderType::AnthropicCompatible), + provider_group: None, + configured_api_host: Some("https://token-plan-cn.xiaomimimo.com/anthropic".to_string()), + has_credentials: true, + }; + + assert_eq!( + resolve_provider_model_compatibility(&context, "MiMo-V2.5-Pro"), + "mimo-v2.5-pro" + ); + assert_eq!( + resolve_provider_model_compatibility(&context, "mimo-v2-pro"), + "mimo-v2.5-pro" + ); + assert_eq!( + canonicalize_provider_custom_models( + &context, + &[ + "MiMo-V2.5-Pro".to_string(), + "mimo-v2-pro".to_string(), + "mimo-v2-flash".to_string(), + ], + ), + vec!["mimo-v2.5-pro".to_string(), "mimo-v2-flash".to_string()] + ); +} + +#[test] +fn catalog_fallback_prefers_latest_same_lineage_chat_model_for_unknown_session_model() { + let models = vec![ + build_model( + "embedding-3", + Some("embedding"), + false, + false, + true, + ModelTier::Pro, + Some("2026-01-05"), + ), + build_model( + "glm-4v", + Some("glm"), + false, + true, + true, + ModelTier::Pro, + Some("2026-01-04"), + ), + build_model( + "glm-4.6", + Some("glm"), + false, + false, + false, + ModelTier::Pro, + Some("2026-01-03"), + ), + build_model( + "glm-4.7", + Some("glm"), + false, + false, + true, + ModelTier::Pro, + Some("2026-01-04"), + ), + ]; + + assert_eq!( + resolve_catalog_fallback_model_id("glm-5.1", &models, false, false, &[]), + "glm-4.7" + ); +} + +#[test] +fn multi_candidate_selection_prefers_same_family_lower_cost_model() { + let models = vec![ + build_model( + "gpt-5.4", + Some("gpt-5.4"), + false, + false, + true, + ModelTier::Pro, + Some("2026-01-03"), + ), + build_model( + "gpt-5.4-mini", + Some("gpt-5.4"), + false, + false, + true, + ModelTier::Mini, + Some("2026-01-04"), + ), + build_model( + "gemini-2.5-pro", + Some("gemini-2.5"), + false, + false, + true, + ModelTier::Pro, + Some("2026-01-05"), + ), + ]; + + assert_eq!( + choose_best_multi_candidate_model("gpt-5.4", &models, false, false, &[]).as_deref(), + Some("gpt-5.4-mini") + ); +} + +#[test] +fn responsive_chat_routing_prefers_low_cost_non_reasoning_model() { + let mut models = vec![ + build_model( + "gpt-5.4", + Some("gpt-5.4"), + false, + false, + true, + ModelTier::Pro, + Some("2026-01-05"), + ), + build_model( + "gpt-5.4-mini-thinking", + Some("gpt-5.4"), + false, + false, + true, + ModelTier::Mini, + Some("2026-01-06"), + ), + build_model( + "gpt-5.4-mini", + Some("gpt-5.4"), + false, + false, + true, + ModelTier::Mini, + Some("2026-01-04"), + ), + ]; + models[1].capabilities.reasoning = true; + models[1].task_families.push(ModelTaskFamily::Reasoning); + + let requirements = routing_slot_model_capability_requirements(Some("responsive_chat_model")); + + assert_eq!( + choose_best_multi_candidate_model("gpt-5.4", &models, false, false, &requirements,) + .as_deref(), + Some("gpt-5.4-mini") + ); +} + +#[test] +fn responsive_chat_catalog_auto_selection_uses_metadata_not_provider_list() { + let mut models = vec![ + build_model( + "provider-a-pro", + Some("provider-a"), + false, + false, + true, + ModelTier::Pro, + Some("2026-01-05"), + ), + build_model( + "provider-b-flash", + Some("provider-b"), + false, + false, + true, + ModelTier::Mini, + Some("2026-01-04"), + ), + build_model( + "provider-c-mini-thinking", + Some("provider-c"), + true, + false, + true, + ModelTier::Mini, + Some("2026-01-06"), + ), + ]; + models[2].capabilities.reasoning = true; + models[2].task_families.push(ModelTaskFamily::Reasoning); + + let requirements = routing_slot_model_capability_requirements(Some("responsive_chat_model")); + let selected = choose_responsive_chat_model_from_catalog(&models, false, false, &requirements) + .expect("responsive chat candidate"); + + assert_eq!(selected.0, "provider-b-flash"); + assert_eq!(selected.2, 3); +} + +#[test] +fn responsive_chat_catalog_prefers_speed_hints_before_lexicographic_order() { + let models = vec![ + build_model( + "MiniMax-M2.7", + Some("MiniMax"), + false, + false, + true, + ModelTier::Pro, + None, + ), + build_model( + "sensenova-6.7-flash-lite", + Some("sensenova"), + false, + false, + true, + ModelTier::Pro, + None, + ), + build_model( + "deepseek-v4-flash", + Some("deepseek"), + false, + false, + true, + ModelTier::Pro, + None, + ), + ]; + + let requirements = routing_slot_model_capability_requirements(Some("responsive_chat_model")); + let selected = choose_responsive_chat_model_from_catalog(&models, false, false, &requirements) + .expect("responsive chat candidate"); + + assert_eq!(selected.0, "deepseek-v4-flash"); + assert_eq!(selected.2, 3); +} + +#[test] +fn responsive_chat_catalog_excludes_code_agent_models() { + let models = vec![ + build_model( + "astron-code-latest", + Some("astron-code"), + false, + false, + true, + ModelTier::Pro, + None, + ), + build_model( + "general-chat-mini", + Some("general-chat"), + false, + false, + true, + ModelTier::Mini, + None, + ), + ]; + let requirements = routing_slot_model_capability_requirements(Some("responsive_chat_model")); + let selected = choose_responsive_chat_model_from_catalog(&models, false, false, &requirements) + .expect("responsive chat candidate"); + + assert_eq!(selected.0, "general-chat-mini"); + assert_eq!(selected.2, 1); +} + +#[test] +fn responsive_chat_auto_candidate_uses_provider_order_before_model_name_tiebreaker() { + let mut candidates = vec![ + ResponsiveChatAutoCandidate { + provider_selector: "late-provider".to_string(), + model_name: "aaa-flash".to_string(), + model: build_model( + "aaa-flash", + Some("aaa"), + false, + false, + true, + ModelTier::Pro, + None, + ), + compatible_candidate_count: 1, + provider_order: 10, + provider_group: Some(ProviderGroup::Mainstream), + latency_hint: ResponsiveChatAutoLatencyHint::default(), + }, + ResponsiveChatAutoCandidate { + provider_selector: "early-provider".to_string(), + model_name: "zzz-flash".to_string(), + model: build_model( + "zzz-flash", + Some("zzz"), + false, + false, + true, + ModelTier::Pro, + None, + ), + compatible_candidate_count: 1, + provider_order: 1, + provider_group: Some(ProviderGroup::Mainstream), + latency_hint: ResponsiveChatAutoLatencyHint::default(), + }, + ]; + + candidates.sort_by(responsive_chat_auto_candidate_sort); + + assert_eq!(candidates[0].provider_selector, "early-provider"); + assert_eq!(candidates[0].model_name, "zzz-flash"); +} + +#[test] +fn responsive_chat_auto_candidate_demotes_slow_or_reasoning_history() { + let mut candidates = vec![ + ResponsiveChatAutoCandidate { + provider_selector: "known-slow-reasoning".to_string(), + model_name: "fast-looking-flash".to_string(), + model: build_model( + "fast-looking-flash", + Some("fast-looking"), + false, + false, + true, + ModelTier::Pro, + None, + ), + compatible_candidate_count: 1, + provider_order: 1, + provider_group: Some(ProviderGroup::Mainstream), + latency_hint: ResponsiveChatAutoLatencyHint { + durations_ms: vec![5_500], + reasoning_sample_count: 1, + ..Default::default() + }, + }, + ResponsiveChatAutoCandidate { + provider_selector: "unknown-flash-lite".to_string(), + model_name: "flash-lite".to_string(), + model: build_model( + "flash-lite", + Some("flash-lite"), + false, + false, + true, + ModelTier::Pro, + None, + ), + compatible_candidate_count: 1, + provider_order: 99, + provider_group: Some(ProviderGroup::Custom), + latency_hint: ResponsiveChatAutoLatencyHint::default(), + }, + ]; + + candidates.sort_by(responsive_chat_auto_candidate_sort); + + assert_eq!(candidates[0].provider_selector, "unknown-flash-lite"); + assert_eq!(candidates[0].model_name, "flash-lite"); +} + +#[test] +fn responsive_chat_auto_candidate_demotes_known_unsupported_model() { + let mut candidates = vec![ + ResponsiveChatAutoCandidate { + provider_selector: "unsupported-provider".to_string(), + model_name: "fast-looking-flash".to_string(), + model: build_model( + "fast-looking-flash", + Some("fast-looking"), + false, + false, + true, + ModelTier::Pro, + None, + ), + compatible_candidate_count: 1, + provider_order: 1, + provider_group: Some(ProviderGroup::Mainstream), + latency_hint: ResponsiveChatAutoLatencyHint { + durations_ms: vec![800], + error_sample_count: 1, + unsupported_model_sample_count: 1, + ..Default::default() + }, + }, + ResponsiveChatAutoCandidate { + provider_selector: "unknown-provider".to_string(), + model_name: "flash-lite".to_string(), + model: build_model( + "flash-lite", + Some("flash-lite"), + false, + false, + true, + ModelTier::Pro, + None, + ), + compatible_candidate_count: 1, + provider_order: 99, + provider_group: Some(ProviderGroup::Custom), + latency_hint: ResponsiveChatAutoLatencyHint::default(), + }, + ]; + + candidates.sort_by(responsive_chat_auto_candidate_sort); + + assert_eq!(candidates[0].provider_selector, "unknown-provider"); + assert_eq!(candidates[0].model_name, "flash-lite"); + assert!(is_responsive_chat_unsupported_model_error(Some( + "Request failed: Bad request (400): Not supported model mimo-v2-flash" + ))); +} + +#[test] +fn responsive_chat_auto_candidate_prefers_known_success_before_unproven_custom_flash() { + let mut candidates = vec![ + ResponsiveChatAutoCandidate { + provider_selector: "custom-new-fast-looking".to_string(), + model_name: "new-flash-lite".to_string(), + model: build_model( + "new-flash-lite", + Some("new"), + false, + false, + true, + ModelTier::Mini, + None, + ), + compatible_candidate_count: 1, + provider_order: 1, + provider_group: Some(ProviderGroup::Custom), + latency_hint: ResponsiveChatAutoLatencyHint::default(), + }, + ResponsiveChatAutoCandidate { + provider_selector: "known-streaming".to_string(), + model_name: "known-mini".to_string(), + model: build_model( + "known-mini", + Some("known"), + false, + false, + true, + ModelTier::Mini, + None, + ), + compatible_candidate_count: 1, + provider_order: 99, + provider_group: Some(ProviderGroup::Mainstream), + latency_hint: ResponsiveChatAutoLatencyHint { + durations_ms: vec![3_300], + ..Default::default() + }, + }, + ]; + + candidates.sort_by(responsive_chat_auto_candidate_sort); + + assert_eq!(candidates[0].provider_selector, "known-streaming"); + assert_eq!(candidates[0].model_name, "known-mini"); +} + +#[test] +fn responsive_chat_auto_candidate_keeps_known_success_ahead_of_unknown_after_stale_error() { + let mut candidates = vec![ + ResponsiveChatAutoCandidate { + provider_selector: "custom-unknown".to_string(), + model_name: "unknown-fast".to_string(), + model: build_model( + "unknown-fast", + Some("unknown"), + false, + false, + true, + ModelTier::Mini, + None, + ), + compatible_candidate_count: 1, + provider_order: 1, + provider_group: Some(ProviderGroup::Custom), + latency_hint: ResponsiveChatAutoLatencyHint::default(), + }, + ResponsiveChatAutoCandidate { + provider_selector: "known-aggregator".to_string(), + model_name: "known-flash".to_string(), + model: build_model( + "known-flash", + Some("known"), + false, + false, + true, + ModelTier::Mini, + None, + ), + compatible_candidate_count: 1, + provider_order: 99, + provider_group: Some(ProviderGroup::Aggregator), + latency_hint: ResponsiveChatAutoLatencyHint { + durations_ms: vec![4_800], + error_sample_count: 1, + ..Default::default() + }, + }, + ]; + + candidates.sort_by(responsive_chat_auto_candidate_sort); + + assert_eq!(candidates[0].provider_selector, "known-aggregator"); + assert_eq!(candidates[0].model_name, "known-flash"); +} + +#[test] +fn responsive_chat_provider_filter_rejects_media_only_fal_provider() { + let provider = + build_provider_with_key("fal", ApiProviderType::Openai, "https://fal.run/fal-ai"); + + assert!(!can_use_responsive_chat_provider(&provider)); + + let fal_typed_provider = + build_provider_with_key("custom-media", ApiProviderType::Fal, "https://fal.run"); + assert!(!can_use_responsive_chat_provider(&fal_typed_provider)); + + let codex_provider = build_provider_with_key( + "custom-codex", + ApiProviderType::Codex, + "https://api.example.com", + ); + assert!(!can_use_responsive_chat_provider(&codex_provider)); + + let coding_provider = build_provider_with_key( + "kimi-coding", + ApiProviderType::AnthropicCompatible, + "https://api.kimi.com/coding", + ); + assert!(!can_use_responsive_chat_provider(&coding_provider)); +} + +#[test] +fn responsive_chat_provider_filter_keeps_text_protocols() { + for provider_type in [ + ApiProviderType::Openai, + ApiProviderType::OpenaiResponse, + ApiProviderType::Anthropic, + ApiProviderType::AnthropicCompatible, + ApiProviderType::Gemini, + ApiProviderType::AzureOpenai, + ApiProviderType::Vertexai, + ApiProviderType::AwsBedrock, + ApiProviderType::Ollama, + ApiProviderType::NewApi, + ApiProviderType::Gateway, + ] { + let provider = build_provider_with_key( + &format!("text-{provider_type}"), + provider_type, + "https://api.example.com/v1", + ); + + assert!( + can_use_responsive_chat_provider(&provider), + "provider type {provider_type} should remain eligible" + ); + } +} + +#[test] +fn responsive_chat_running_sample_is_stale_after_two_minutes() { + let stale_started_at = (chrono::Utc::now() - chrono::Duration::seconds(180)) + .to_rfc3339_opts(chrono::SecondsFormat::Micros, true); + let fresh_started_at = (chrono::Utc::now() - chrono::Duration::seconds(30)) + .to_rfc3339_opts(chrono::SecondsFormat::Micros, true); + + assert!(responsive_chat_running_sample_is_stale( + "running", + Some(&stale_started_at) + )); + assert!(!responsive_chat_running_sample_is_stale( + "running", + Some(&fresh_started_at) + )); + assert!(!responsive_chat_running_sample_is_stale( + "success", + Some(&stale_started_at) + )); +} + +#[test] +fn multi_candidate_selection_prefers_reasoning_model_when_thinking_enabled() { + let mut models = vec![ + build_model( + "gpt-5.4-mini", + Some("gpt-5.4"), + false, + false, + true, + ModelTier::Mini, + Some("2026-01-03"), + ), + build_model( + "gpt-5.4-mini-thinking", + Some("gpt-5.4"), + false, + false, + true, + ModelTier::Mini, + Some("2026-01-04"), + ), + ]; + models[1].capabilities.reasoning = true; + models[1].task_families.push(ModelTaskFamily::Reasoning); + + assert_eq!( + choose_best_multi_candidate_model("gpt-5.4-mini", &models, true, false, &[]).as_deref(), + Some("gpt-5.4-mini-thinking") + ); +} + +#[test] +fn multi_candidate_selection_prefers_vision_candidate_when_images_present() { + let models = vec![ + build_model( + "gpt-5.4-mini", + Some("gpt-5.4"), + false, + false, + true, + ModelTier::Mini, + Some("2026-01-03"), + ), + build_model( + "gpt-5.4", + Some("gpt-5.4"), + true, + true, + true, + ModelTier::Pro, + Some("2026-01-04"), + ), + ]; + + assert_eq!( + choose_best_multi_candidate_model("gpt-5.4-mini", &models, false, true, &[]).as_deref(), + Some("gpt-5.4") + ); +} + +#[test] +fn runtime_routing_slot_filters_candidate_count_by_model_role() { + let mut models = vec![ + build_model( + "gpt-5.4-mini", + Some("gpt-5.4"), + false, + false, + true, + ModelTier::Mini, + Some("2026-01-03"), + ), + build_model( + "gpt-5.4-mini-thinking", + Some("gpt-5.4"), + true, + false, + true, + ModelTier::Mini, + Some("2026-01-04"), + ), + ]; + models[1].capabilities.reasoning = true; + models[1].task_families.push(ModelTaskFamily::Reasoning); + let requirements = routing_slot_model_capability_requirements(Some("browser_reasoning_model")); + + assert_eq!( + count_compatible_candidate_models(&models, false, false, &requirements), + 1 + ); + assert_eq!( + choose_best_multi_candidate_model("gpt-5.4-mini", &models, false, false, &requirements) + .as_deref(), + Some("gpt-5.4-mini-thinking") + ); +} + +#[test] +fn runtime_routing_slot_keeps_specialized_image_candidate_in_pool() { + let mut image_model = build_model( + "gpt-image-1", + Some("gpt-image"), + false, + true, + true, + ModelTier::Pro, + Some("2026-01-04"), + ); + image_model.task_families = vec![ModelTaskFamily::ImageGeneration]; + image_model.output_modalities = vec![ModelModality::Image]; + image_model.input_modalities = vec![ModelModality::Text, ModelModality::Image]; + let models = vec![ + build_model( + "gpt-5.4-mini", + Some("gpt-5.4"), + false, + false, + true, + ModelTier::Mini, + Some("2026-01-03"), + ), + image_model, + ]; + let requirements = routing_slot_model_capability_requirements(Some("image_generation_model")); + + assert_eq!( + count_compatible_candidate_models(&models, false, false, &requirements), + 1 + ); + assert_eq!( + choose_best_multi_candidate_model("gpt-5.4-mini", &models, false, false, &requirements) + .as_deref(), + Some("gpt-image-1") + ); +} + +#[test] +fn runtime_routing_slot_reselects_only_when_user_lock_allows_it() { + let models = vec![build_model( + "gpt-5.4-mini", + Some("gpt-5.4"), + false, + false, + true, + ModelTier::Mini, + Some("2026-01-03"), + )]; + let requirements = routing_slot_model_capability_requirements(Some("browser_reasoning_model")); + let model = find_model_meta("gpt-5.4-mini", &models); + + assert!(should_reselect_for_runtime_capability_gap( + model, + 1, + &requirements, + false + )); + assert!(!should_reselect_for_runtime_capability_gap( + model, + 1, + &requirements, + true + )); +} + +#[test] +fn user_locked_model_reports_execution_profile_capability_gap() { + let models = vec![build_model( + "gpt-5.4-mini", + Some("gpt-5.4"), + false, + false, + true, + ModelTier::Mini, + Some("2026-01-03"), + )]; + let requirements = routing_slot_model_capability_requirements(Some("browser_reasoning_model")); + let model = find_model_meta("gpt-5.4-mini", &models).expect("model"); + let locked_model_capability_gap = + missing_runtime_model_capability_requirements(model, &requirements) + .first() + .map(|requirement| requirement.gap_code().to_string()); + + assert_eq!( + locked_model_capability_gap.as_deref(), + Some("browser_reasoning_candidate_missing") + ); + assert_eq!( + runtime_model_capability_gap_for_catalog(&models, &requirements).as_deref(), + Some("browser_reasoning_candidate_missing") + ); +} + +#[test] +fn user_locked_capability_gap_should_use_blocking_limit_status() { + let selection = ResolvedRuntimeProviderSelection { + provider_config: ConfigureProviderRequest { + provider_id: Some("openai".to_string()), + provider_name: "openai".to_string(), + model_name: "gpt-5.4-mini".to_string(), + api_key: None, + base_url: None, + model_capabilities: None, + tool_call_strategy: None, + toolshim_model: None, + }, + provider_selector: "openai".to_string(), + requested_model: "gpt-5.4-mini".to_string(), + resolved_model: "gpt-5.4-mini".to_string(), + candidate_count: 1, + estimated_cost_class: Some("low".to_string()), + pricing: None, + capability_gap: Some("browser_reasoning_candidate_missing".to_string()), + capability_gap_source: Some("explicit_model_lock".to_string()), + fallback_chain: Vec::new(), + }; + + assert_eq!( + runtime_limit_status_for_selection(&selection), + "user_locked_capability_gap" + ); +} + +#[test] +fn custom_provider_multi_candidate_reselection_is_disabled() { + let context = ProviderResolutionContext { + provider_selector: "custom-mimo".to_string(), + aster_provider_name: "anthropic".to_string(), + compatibility_provider_key: "anthropic-compatible".to_string(), + registry_provider_ids: vec!["xiaomi".to_string()], + alias_key: "custom-mimo".to_string(), + custom_models: vec!["mimo-v2.5-pro".to_string(), "mimo-v2-flash".to_string()], + is_custom_provider: true, + provider_type: Some(ApiProviderType::AnthropicCompatible), + provider_group: None, + configured_api_host: Some("https://token-plan-cn.xiaomimimo.com/anthropic".to_string()), + has_credentials: true, + }; + + assert!(!should_auto_reselect_multi_candidate_model(&context)); +} + +#[test] +fn configured_model_allowlist_disables_system_provider_auto_reselection() { + let context = ProviderResolutionContext { + provider_selector: "deepseek".to_string(), + aster_provider_name: "openai".to_string(), + compatibility_provider_key: "deepseek".to_string(), + registry_provider_ids: vec!["deepseek".to_string()], + alias_key: "deepseek".to_string(), + custom_models: vec!["deepseek-chat".to_string()], + is_custom_provider: false, + provider_type: Some(ApiProviderType::Openai), + provider_group: None, + configured_api_host: Some("https://api.deepseek.com".to_string()), + has_credentials: true, + }; + + assert!(!should_auto_reselect_multi_candidate_model(&context)); +} + +#[test] +fn xiaomi_permission_recovery_prefers_non_flash_candidate() { + let context = ProviderResolutionContext { + provider_selector: "custom-mimo".to_string(), + aster_provider_name: "anthropic".to_string(), + compatibility_provider_key: "anthropic-compatible".to_string(), + registry_provider_ids: vec!["xiaomi".to_string()], + alias_key: "custom-mimo".to_string(), + custom_models: vec!["mimo-v2.5-pro".to_string(), "mimo-v2-flash".to_string()], + is_custom_provider: true, + provider_type: Some(ApiProviderType::AnthropicCompatible), + provider_group: None, + configured_api_host: Some("https://token-plan-cn.xiaomimimo.com/anthropic".to_string()), + has_credentials: true, + }; + let models = vec![ + build_model( + "mimo-v2.5-pro", + Some("mimo-v2"), + false, + false, + true, + ModelTier::Pro, + Some("2026-04-01"), + ), + build_model( + "mimo-v2-flash", + Some("mimo-v2"), + false, + false, + true, + ModelTier::Mini, + Some("2026-04-02"), + ), + ]; + + assert_eq!( + choose_provider_permission_recovery_model(&context, "mimo-v2-flash", &models, false, false) + .as_deref(), + Some("mimo-v2.5-pro") + ); +} + +#[test] +fn permission_recovery_prefers_configured_model_for_system_provider() { + let context = ProviderResolutionContext { + provider_selector: "deepseek".to_string(), + aster_provider_name: "openai".to_string(), + compatibility_provider_key: "deepseek".to_string(), + registry_provider_ids: vec!["deepseek".to_string()], + alias_key: "deepseek".to_string(), + custom_models: vec!["deepseek-chat".to_string(), "deepseek-reasoner".to_string()], + is_custom_provider: false, + provider_type: Some(ApiProviderType::Openai), + provider_group: None, + configured_api_host: Some("https://api.deepseek.com".to_string()), + has_credentials: true, + }; + let models = vec![ + build_model( + "deepseek-chat", + Some("deepseek"), + false, + false, + true, + ModelTier::Mini, + Some("2026-04-01"), + ), + build_model( + "deepseek-reasoner", + Some("deepseek"), + true, + false, + true, + ModelTier::Pro, + Some("2026-04-02"), + ), + build_model( + "gpt-5.5", + Some("gpt"), + true, + false, + true, + ModelTier::Max, + Some("2026-05-01"), + ), + ]; + + assert_eq!( + choose_provider_permission_recovery_model(&context, "gpt-5.5", &models, false, false) + .as_deref(), + Some("deepseek-chat") + ); +} + +#[test] +fn permission_recovery_falls_back_to_known_openai_fast_model() { + let context = ProviderResolutionContext { + provider_selector: "openai".to_string(), + aster_provider_name: "openai".to_string(), + compatibility_provider_key: "openai".to_string(), + registry_provider_ids: vec!["openai".to_string()], + alias_key: "openai".to_string(), + custom_models: vec![], + is_custom_provider: false, + provider_type: Some(ApiProviderType::Openai), + provider_group: None, + configured_api_host: None, + has_credentials: true, + }; + let models = vec![ + build_model( + "gpt-4o", + Some("gpt-4o"), + false, + true, + true, + ModelTier::Pro, + Some("2024-05-13"), + ), + build_model( + "gpt-4o-mini", + Some("gpt-4o"), + false, + true, + true, + ModelTier::Mini, + Some("2024-07-18"), + ), + ]; + + assert_eq!( + choose_provider_permission_recovery_model(&context, "gpt-4o", &models, false, false) + .as_deref(), + Some("gpt-4o-mini") + ); +} + +#[test] +fn permission_recovery_can_use_uncataloged_configured_model() { + let context = ProviderResolutionContext { + provider_selector: "siliconflow-cn".to_string(), + aster_provider_name: "openai".to_string(), + compatibility_provider_key: "openai".to_string(), + registry_provider_ids: vec!["siliconflow-cn".to_string()], + alias_key: "siliconflow-cn".to_string(), + custom_models: vec!["deepseek-ai/DeepSeek-V4-Flash".to_string()], + is_custom_provider: false, + provider_type: Some(ApiProviderType::Openai), + provider_group: None, + configured_api_host: Some("https://api.siliconflow.cn/v1".to_string()), + has_credentials: true, + }; + let models = vec![build_model( + "gpt-5.5", + Some("gpt"), + true, + false, + true, + ModelTier::Max, + Some("2026-05-01"), + )]; + + assert_eq!( + choose_provider_permission_recovery_model(&context, "gpt-5.5", &models, false, false) + .as_deref(), + Some("deepseek-ai/DeepSeek-V4-Flash") + ); +} + +#[test] +fn model_preference_falls_back_to_session_model_when_provider_matches() { + let resolved = resolve_model_preference_with_session_fallback( + None, + "openai", + Some(&SessionProviderModelContext { + provider_selector: Some("openai".to_string()), + provider_name: Some("OpenAI".to_string()), + model_name: Some("gpt-5.4-mini".to_string()), + }), + ) + .unwrap(); + + assert_eq!( + resolved, + ("gpt-5.4-mini".to_string(), RequestPreferenceSource::Session) + ); +} + +#[test] +fn model_preference_requires_explicit_value_when_provider_changes() { + let error = resolve_model_preference_with_session_fallback( + None, + "gemini", + Some(&SessionProviderModelContext { + provider_selector: Some("openai".to_string()), + provider_name: Some("OpenAI".to_string()), + model_name: Some("gpt-5.4-mini".to_string()), + }), + ) + .unwrap_err(); + + assert!( + error.contains("切换 provider"), + "unexpected error message: {error}" + ); +} + +#[test] +fn explicit_model_preference_wins_over_session_fallback() { + let resolved = resolve_model_preference_with_session_fallback( + Some("gpt-5.4".to_string()), + "openai", + Some(&SessionProviderModelContext { + provider_selector: Some("openai".to_string()), + provider_name: Some("OpenAI".to_string()), + model_name: Some("gpt-5.4-mini".to_string()), + }), + ) + .unwrap(); + + assert_eq!( + resolved, + ("gpt-5.4".to_string(), RequestPreferenceSource::Request) + ); +} + +#[test] +fn provider_preference_falls_back_to_session_provider_when_request_missing() { + let resolved = resolve_provider_preference_with_session_fallback( + None, + Some(&SessionProviderModelContext { + provider_selector: Some("openai".to_string()), + provider_name: Some("OpenAI".to_string()), + model_name: Some("gpt-5.4-mini".to_string()), + }), + ) + .unwrap(); + + assert_eq!( + resolved, + ("openai".to_string(), RequestPreferenceSource::Session) + ); +} + +#[test] +fn explicit_provider_preference_wins_over_session_fallback() { + let resolved = resolve_provider_preference_with_session_fallback( + Some("gemini".to_string()), + Some(&SessionProviderModelContext { + provider_selector: Some("openai".to_string()), + provider_name: Some("OpenAI".to_string()), + model_name: Some("gpt-5.4-mini".to_string()), + }), + ) + .unwrap(); + + assert_eq!( + resolved, + ("gemini".to_string(), RequestPreferenceSource::Request) + ); +} + +#[test] +fn service_scene_model_preference_reads_complete_preference() { + let metadata = serde_json::json!({ + "harness": { + "service_scene_launch": { + "kind": "local_service_skill", + "service_scene_run": { + "skill_id": "voice-runtime", + "preferred_provider_id": "openai-tts", + "preferred_model_id": "gpt-4o-mini-tts", + "allow_fallback": false, + } + } + } + }); + + let resolved = resolve_service_scene_model_preference(Some(&metadata)); + + assert_eq!( + resolved, + Some(ServiceSceneModelPreferenceContext { + provider_selector: "openai-tts".to_string(), + model_name: "gpt-4o-mini-tts".to_string(), + allow_fallback: false, + }) + ); +} + +#[test] +fn service_scene_model_preference_ignores_provider_only_selection() { + let metadata = serde_json::json!({ + "harness": { + "service_scene_launch": { + "kind": "local_service_skill", + "service_scene_run": { + "skill_id": "voice-runtime", + "preferred_provider_id": "openai-tts" + } + } + } + }); + + assert!(resolve_service_scene_model_preference(Some(&metadata)).is_none()); +} + +#[test] +fn runtime_task_profile_marks_translation_service_slot() { + let request = AsterChatRequest { + message: "翻译这段内容".to_string(), + session_id: "session-1".to_string(), + event_name: "agent-event".to_string(), + images: None, + provider_config: None, + provider_preference: None, + model_preference: None, + thinking_enabled: None, + approval_policy: None, + sandbox_policy: None, + project_id: None, + workspace_id: "workspace-1".to_string(), + web_search: None, + search_mode: None, + execution_strategy: None, + auto_continue: None, + system_prompt: None, + metadata: Some(serde_json::json!({ + "harness": { + "translation_skill_launch": { + "source_text": "hello" + } + } + })), + turn_id: None, + queue_if_busy: None, + queued_turn_id: None, + }; + + let profile = build_runtime_task_profile(&request); + + assert_eq!(profile.kind, "translation"); + assert_eq!(profile.source, "translation_skill_launch"); + assert_eq!(profile.service_model_slot.as_deref(), Some("translation")); + assert!(profile + .traits + .iter() + .any(|value| value == "service_model_slot")); +} + +#[test] +fn runtime_task_profile_maps_more_service_model_slots() { + let base_request = AsterChatRequest { + message: "继续处理".to_string(), + session_id: "session-1".to_string(), + event_name: "agent-event".to_string(), + images: None, + provider_config: None, + provider_preference: None, + model_preference: None, + thinking_enabled: None, + approval_policy: None, + sandbox_policy: None, + project_id: None, + workspace_id: "workspace-1".to_string(), + web_search: None, + search_mode: None, + execution_strategy: None, + auto_continue: None, + system_prompt: None, + metadata: None, + turn_id: None, + queue_if_busy: None, + queued_turn_id: None, + }; + + let mut resource_request = base_request.clone(); + resource_request.metadata = Some(serde_json::json!({ + "harness": { + "resource_search_skill_launch": { + "kind": "resource_search_task", + "resource_search_task": { + "query": "找几张产品图" + } + } + } + })); + let resource_profile = build_runtime_task_profile(&resource_request); + assert_eq!(resource_profile.kind, "resource_search"); + assert_eq!(resource_profile.source, "resource_search_skill_launch"); + assert_eq!( + resource_profile.service_model_slot.as_deref(), + Some("resource_prompt_rewrite") + ); + + let mut summary_request = base_request.clone(); + summary_request.metadata = Some(serde_json::json!({ + "harness": { + "summary_skill_launch": { + "kind": "summary_request", + "summary_request": { + "content": "总结这段内容" + } + } + } + })); + let summary_profile = build_runtime_task_profile(&summary_request); + assert_eq!(summary_profile.kind, "summary"); + assert_eq!(summary_profile.source, "summary_skill_launch"); + assert_eq!(summary_profile.service_model_slot, None); + + let mut topic_request = base_request.clone(); + topic_request.metadata = Some(serde_json::json!({ + "harness": { + "topic_skill_launch": { + "kind": "topic_request" + } + } + })); + let topic_profile = build_runtime_task_profile(&topic_request); + assert_eq!(topic_profile.kind, "topic"); + assert_eq!(topic_profile.source, "auxiliary_topic"); + assert_eq!(topic_profile.service_model_slot.as_deref(), Some("topic")); + + let mut generation_topic_request = base_request.clone(); + generation_topic_request.metadata = Some(serde_json::json!({ + "harness": { + "generation_topic_skill_launch": { + "kind": "generation_topic_request" + } + } + })); + let generation_topic_profile = build_runtime_task_profile(&generation_topic_request); + assert_eq!(generation_topic_profile.kind, "generation_topic"); + assert_eq!( + generation_topic_profile.source, + "auxiliary_generation_topic" + ); + assert_eq!( + generation_topic_profile.service_model_slot.as_deref(), + Some("generation_topic") + ); + + let mut agent_meta_request = base_request.clone(); + agent_meta_request.metadata = Some(serde_json::json!({ + "harness": { + "agent_meta_skill_launch": { + "kind": "agent_meta_request" + } + } + })); + let agent_meta_profile = build_runtime_task_profile(&agent_meta_request); + assert_eq!(agent_meta_profile.kind, "agent_meta"); + assert_eq!(agent_meta_profile.source, "auxiliary_agent_meta"); + assert_eq!( + agent_meta_profile.service_model_slot.as_deref(), + Some("agent_meta") + ); + + let mut rewrite_request = base_request; + rewrite_request.metadata = Some(serde_json::json!({ + "harness": { + "turn_purpose": "style_rewrite" + } + })); + let rewrite_profile = build_runtime_task_profile(&rewrite_request); + assert_eq!(rewrite_profile.kind, "prompt_rewrite"); + assert_eq!(rewrite_profile.source, "turn_purpose"); + assert_eq!( + rewrite_profile.service_model_slot.as_deref(), + Some("prompt_rewrite") + ); +} + +#[test] +fn runtime_task_profile_maps_fast_response_to_responsive_chat_slot() { + let request = AsterChatRequest { + message: "只回答一个字:好".to_string(), + session_id: "session-1".to_string(), + event_name: "agent-event".to_string(), + images: None, + provider_config: None, + provider_preference: None, + model_preference: None, + thinking_enabled: None, + approval_policy: None, + sandbox_policy: None, + project_id: None, + workspace_id: "workspace-1".to_string(), + web_search: None, + search_mode: None, + execution_strategy: None, + auto_continue: None, + system_prompt: None, + metadata: Some(serde_json::json!({ + "harness": { + "fast_response_routing": { + "mode": "auto", + "service_model_slot": "responsive_chat", + "routing_slot": "responsive_chat_model" + } + } + })), + turn_id: None, + queue_if_busy: None, + queued_turn_id: None, + }; + + let profile = build_runtime_task_profile(&request); + + assert_eq!(profile.kind, "chat"); + assert_eq!(profile.source, "fast_response_routing"); + assert_eq!( + profile.service_model_slot.as_deref(), + Some("responsive_chat") + ); + assert_eq!( + profile.routing_slot.as_deref(), + Some("responsive_chat_model") + ); + assert!(profile + .traits + .iter() + .any(|value| value == "fast_response_routing")); +} + +#[test] +fn runtime_task_profile_marks_oem_runtime_from_harness_oem_routing() { + let request = AsterChatRequest { + message: "继续处理".to_string(), + session_id: "session-1".to_string(), + event_name: "agent-event".to_string(), + images: None, + provider_config: None, + provider_preference: None, + model_preference: None, + thinking_enabled: None, + approval_policy: None, + sandbox_policy: None, + project_id: None, + workspace_id: "workspace-1".to_string(), + web_search: None, + search_mode: None, + execution_strategy: None, + auto_continue: None, + system_prompt: None, + metadata: Some(serde_json::json!({ + "harness": { + "oem_routing": { + "tenant_id": "tenant-1", + "provider_source": "oem_cloud", + "provider_key": "lime-hub", + "config_mode": "managed", + "offer_state": "available_quota_low", + "quota_status": "low", + "fallback_to_local_allowed": false, + } + } + })), + turn_id: None, + queue_if_busy: None, + queued_turn_id: None, + }; + + let profile = build_runtime_task_profile(&request); + let oem_routing = resolve_request_oem_routing_context(request.metadata.as_ref()); + + assert!(profile.traits.iter().any(|value| value == "oem_runtime")); + assert!(request_oem_routing_is_locked(oem_routing.as_ref())); + assert_eq!( + build_request_oem_limit_event(oem_routing.as_ref()), + Some(lime_agent::SessionExecutionRuntimeLimitEvent { + event_kind: "quota_low".to_string(), + message: "OEM 云端 provider lime-hub 当前额度偏低,后续请求可能触发配额风险。" + .to_string(), + retryable: true, + }) + ); +} + +#[test] +fn runtime_task_profile_merges_modality_execution_profile_from_runtime_contract() { + let request = AsterChatRequest { + message: "打开这个页面并检查登录态".to_string(), + session_id: "session-1".to_string(), + event_name: "agent-event".to_string(), + images: None, + provider_config: None, + provider_preference: None, + model_preference: None, + thinking_enabled: None, + approval_policy: None, + sandbox_policy: None, + project_id: None, + workspace_id: "workspace-1".to_string(), + web_search: None, + search_mode: None, + execution_strategy: None, + auto_continue: None, + system_prompt: None, + metadata: Some(serde_json::json!({ + "harness": { + "browser_assist": { + "runtime_contract": { + "contract_key": "browser_control", + "routing_slot": "browser_reasoning_model", + "execution_profile": { + "profile_key": "browser_control_profile" + }, + "executor_adapter": { + "adapter_key": "browser:browser_assist" + }, + "executor_binding": { + "executor_kind": "browser", + "binding_key": "browser_assist" + } + } + } + } + })), + turn_id: None, + queue_if_busy: None, + queued_turn_id: None, + }; + + let profile = build_runtime_task_profile(&request); + + assert_eq!(profile.kind, "chat"); + assert_eq!( + profile.modality_contract_key.as_deref(), + Some("browser_control") + ); + assert_eq!( + profile.routing_slot.as_deref(), + Some("browser_reasoning_model") + ); + assert_eq!( + profile.execution_profile_key.as_deref(), + Some("browser_control_profile") + ); + assert_eq!( + profile.executor_adapter_key.as_deref(), + Some("browser:browser_assist") + ); + assert_eq!(profile.executor_kind.as_deref(), Some("browser")); + assert_eq!( + profile.executor_binding_key.as_deref(), + Some("browser_assist") + ); + assert_eq!( + profile.permission_profile_keys, + vec![ + "browser_control".to_string(), + "web_search".to_string(), + "ask_user_question".to_string() + ] + ); + assert_eq!( + profile.user_lock_policy.as_deref(), + Some("honor_explicit_model_lock_with_capability_check") + ); + assert!(profile + .traits + .iter() + .any(|value| value == "modality_runtime_contract")); + assert!(profile + .traits + .iter() + .any(|value| value == "execution_profile")); + assert!(profile + .traits + .iter() + .any(|value| value == "executor_adapter")); +} + +#[test] +fn runtime_permission_state_summarizes_declared_profile_keys_without_blocking() { + let task_profile = lime_agent::SessionExecutionRuntimeTaskProfile { + kind: "browser_control".to_string(), + source: "browser_assist".to_string(), + traits: vec!["modality_runtime_contract".to_string()], + modality_contract_key: Some("browser_control".to_string()), + routing_slot: Some("browser_reasoning_model".to_string()), + execution_profile_key: Some("browser_control_profile".to_string()), + executor_adapter_key: Some("browser:browser_assist".to_string()), + executor_kind: Some("browser".to_string()), + executor_binding_key: Some("browser_assist".to_string()), + permission_profile_keys: vec![ + "browser_control".to_string(), + "web_search".to_string(), + "ask_user_question".to_string(), + "browser_control".to_string(), + ], + user_lock_policy: Some("honor_explicit_model_lock_with_capability_check".to_string()), + service_model_slot: None, + scene_kind: None, + scene_skill_id: None, + entry_source: None, + }; + + let permission_state = build_permission_state(&task_profile, None); + + assert_eq!(permission_state.status, "requires_confirmation"); + assert_eq!( + permission_state.required_profile_keys, + vec![ + "browser_control".to_string(), + "web_search".to_string(), + "ask_user_question".to_string() + ] + ); + assert_eq!( + permission_state.ask_profile_keys, + vec!["browser_control".to_string(), "web_search".to_string()] + ); + assert!(permission_state.blocking_profile_keys.is_empty()); + assert_eq!( + permission_state.decision_scope, + "declared_permission_profiles_only" + ); + assert_eq!( + permission_state.confirmation_status.as_deref(), + Some("not_requested") + ); + assert!(permission_state.confirmation_request_id.is_none()); + assert_eq!( + permission_state.confirmation_source.as_deref(), + Some("declared_profile_only") + ); +} + +#[test] +fn runtime_permission_state_full_access_resolves_declared_profiles_without_extra_confirmation() { + let task_profile = lime_agent::SessionExecutionRuntimeTaskProfile { + kind: "image_generation".to_string(), + source: "image_skill_launch".to_string(), + traits: vec!["modality_runtime_contract".to_string()], + modality_contract_key: Some("image_generation".to_string()), + routing_slot: Some("image_generation_model".to_string()), + execution_profile_key: Some("image_generation_profile".to_string()), + executor_adapter_key: Some("skill:image_generate".to_string()), + executor_kind: Some("skill".to_string()), + executor_binding_key: Some("image_generate".to_string()), + permission_profile_keys: vec![ + "write_artifacts".to_string(), + "media_upload".to_string(), + "ask_user_question".to_string(), + ], + user_lock_policy: None, + service_model_slot: None, + scene_kind: None, + scene_skill_id: None, + entry_source: None, + }; + + let permission_state = build_permission_state( + &task_profile, + Some(lime_agent::SessionExecutionRuntimeAccessMode::FullAccess), + ); + + assert_eq!(permission_state.status, "declared_only"); + assert_eq!( + permission_state.required_profile_keys, + vec![ + "write_artifacts".to_string(), + "media_upload".to_string(), + "ask_user_question".to_string(), + ] + ); + assert!(permission_state.ask_profile_keys.is_empty()); + assert_eq!( + permission_state.decision_scope, + "declared_permission_profiles_resolved_by_full_access" + ); + assert_eq!( + permission_state.confirmation_status.as_deref(), + Some("resolved") + ); + assert_eq!( + permission_state.confirmation_source.as_deref(), + Some("access_mode_full_access") + ); + assert!(permission_state + .notes + .iter() + .any(|note| note.contains("full-access"))); +} + +#[test] +fn explicit_provider_config_resolution_reports_single_candidate_routing() { + let task_profile = lime_agent::SessionExecutionRuntimeTaskProfile { + kind: "chat".to_string(), + source: "default_chat".to_string(), + traits: Vec::new(), + modality_contract_key: None, + routing_slot: None, + execution_profile_key: None, + executor_adapter_key: None, + executor_kind: None, + executor_binding_key: None, + permission_profile_keys: Vec::new(), + user_lock_policy: None, + service_model_slot: None, + scene_kind: None, + scene_skill_id: None, + entry_source: None, + }; + let base_decision = build_routing_decision( + &task_profile, + "provider_config", + "请求已显式传入 provider_config,运行时仅补齐能力与工具策略。".to_string(), + None, + Some("openai".to_string()), + Some("gpt-5.4-mini".to_string()), + None, + ); + + let resolved = lime_agent::SessionExecutionRuntimeRoutingDecision { + routing_mode: "single_candidate".to_string(), + selected_provider: Some("openai".to_string()), + selected_model: Some("gpt-5.4-mini".to_string()), + candidate_count: 1, + estimated_cost_class: Some("low".to_string()), + ..base_decision + }; + + assert_eq!(resolved.routing_mode, "single_candidate"); + assert_eq!(resolved.candidate_count, 1); + assert_eq!(resolved.selected_provider.as_deref(), Some("openai")); + assert_eq!(resolved.selected_model.as_deref(), Some("gpt-5.4-mini")); +} + +#[test] +fn build_cost_state_should_capture_pricing_snapshot() { + let selection = ResolvedRuntimeProviderSelection { + provider_config: ConfigureProviderRequest { + provider_id: Some("openai".to_string()), + provider_name: "openai".to_string(), + model_name: "gpt-5.4-mini".to_string(), + api_key: None, + base_url: None, + model_capabilities: None, + tool_call_strategy: None, + toolshim_model: None, + }, + provider_selector: "openai".to_string(), + requested_model: "gpt-5.4-mini".to_string(), + resolved_model: "gpt-5.4-mini".to_string(), + candidate_count: 1, + estimated_cost_class: Some("low".to_string()), + pricing: Some(ModelPricing { + input_per_million: Some(0.8), + output_per_million: Some(3.2), + cache_read_per_million: Some(0.08), + cache_write_per_million: Some(1.0), + currency: "USD".to_string(), + }), + capability_gap: None, + capability_gap_source: None, + fallback_chain: Vec::new(), + }; + + let cost_state = build_cost_state(Some(&selection), None, "estimated"); + + assert_eq!(cost_state.status, "estimated"); + assert_eq!(cost_state.estimated_cost_class.as_deref(), Some("low")); + assert_eq!(cost_state.input_per_million, Some(0.8)); + assert_eq!(cost_state.output_per_million, Some(3.2)); + assert_eq!(cost_state.cache_read_per_million, Some(0.08)); + assert_eq!(cost_state.cache_write_per_million, Some(1.0)); + assert_eq!(cost_state.currency.as_deref(), Some("USD")); + assert!(cost_state.estimated_total_cost.is_none()); +} + +#[test] +fn runtime_provider_strategy_prefers_manual_mode_for_credentialless_local_provider() { + let context = ProviderResolutionContext { + provider_selector: "ollama".to_string(), + aster_provider_name: "ollama".to_string(), + compatibility_provider_key: "ollama".to_string(), + registry_provider_ids: vec!["ollama".to_string()], + alias_key: "ollama".to_string(), + custom_models: vec![], + is_custom_provider: false, + provider_type: Some(ApiProviderType::Ollama), + provider_group: Some(ProviderGroup::Local), + configured_api_host: Some("http://127.0.0.1:11434".to_string()), + has_credentials: false, + }; + + assert_eq!( + resolve_runtime_provider_configuration_strategy(&context), + RuntimeProviderConfigurationStrategy::Manual { + base_url: Some("http://127.0.0.1:11434".to_string()), + } + ); +} + +#[test] +fn normalize_runtime_provider_base_url_strips_ollama_v1_suffix() { + assert_eq!( + normalize_runtime_provider_base_url( + Some(ApiProviderType::Ollama), + Some("http://127.0.0.1:11434/v1/".to_string()), + ), + Some("http://127.0.0.1:11434".to_string()) + ); +} + +#[test] +fn canonical_provider_selector_maps_legacy_mimo_ids() { + assert_eq!(canonical_provider_selector("mimo"), "xiaomi"); + assert_eq!(canonical_provider_selector("xiaomimimo"), "xiaomi"); + assert_eq!(canonical_provider_selector("xiaomi"), "xiaomi"); +} diff --git a/src-tauri/src/commands/aster_agent_cmd/runtime_task_profile.rs b/src-tauri/src/commands/aster_agent_cmd/runtime_task_profile.rs new file mode 100644 index 000000000..ad0f9b329 --- /dev/null +++ b/src-tauri/src/commands/aster_agent_cmd/runtime_task_profile.rs @@ -0,0 +1,256 @@ +use super::agentruntime_profile::{AgentRuntimeProfileEvent, AgentRuntimeProfileStream}; +use lime_agent::{SessionExecutionRuntimeTaskProfile, TurnExecutionProfile}; + +const DEFAULT_ATTEMPT_INDEX: usize = 1; +const PROFILE_EVENT_SOURCE: &str = "agent_runtime_submit_turn"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct RuntimeTurnTaskProfileRefs { + pub(crate) task_id: String, + pub(crate) task_kind: String, + pub(crate) run_id: String, + pub(crate) attempt_id: String, + pub(crate) attempt_index: usize, +} + +pub(crate) fn task_id_from_thread_id(thread_id: &str) -> String { + format!( + "task_{}", + normalize_runtime_task_id_fragment(thread_id, "thread_unavailable") + ) +} + +pub(crate) fn run_id_from_turn_id(turn_id: &str) -> String { + format!( + "run_{}", + normalize_runtime_task_id_fragment(turn_id, "turn_unavailable") + ) +} + +pub(crate) fn attempt_id_from_turn_id(turn_id: &str) -> String { + format!( + "attempt_{}", + normalize_runtime_task_id_fragment(turn_id, "turn_unavailable") + ) +} + +pub(crate) fn build_runtime_turn_task_profile_refs( + thread_id: &str, + turn_id: &str, + execution_profile: TurnExecutionProfile, + task_profile: Option<&SessionExecutionRuntimeTaskProfile>, +) -> RuntimeTurnTaskProfileRefs { + RuntimeTurnTaskProfileRefs { + task_id: task_id_from_thread_id(thread_id), + task_kind: runtime_task_kind_for_execution_profile(execution_profile, task_profile), + run_id: run_id_from_turn_id(turn_id), + attempt_id: attempt_id_from_turn_id(turn_id), + attempt_index: DEFAULT_ATTEMPT_INDEX, + } +} + +pub(crate) fn build_runtime_task_start_profile_events( + profile_stream: &AgentRuntimeProfileStream, + refs: &RuntimeTurnTaskProfileRefs, +) -> Vec { + vec![ + profile_stream.task_created( + refs.task_id.as_str(), + Some(refs.task_kind.as_str()), + Some(PROFILE_EVENT_SOURCE), + ), + profile_stream.task_attempt_started( + refs.task_id.as_str(), + refs.run_id.as_str(), + refs.attempt_id.as_str(), + refs.attempt_index, + ), + ] +} + +pub(crate) fn build_runtime_task_completed_profile_event( + profile_stream: &AgentRuntimeProfileStream, + refs: &RuntimeTurnTaskProfileRefs, +) -> AgentRuntimeProfileEvent { + profile_stream.task_completed( + refs.task_id.as_str(), + refs.run_id.as_str(), + refs.attempt_id.as_str(), + refs.attempt_index, + ) +} + +pub(crate) fn build_runtime_task_failed_profile_events( + profile_stream: &AgentRuntimeProfileStream, + refs: &RuntimeTurnTaskProfileRefs, + failure_category: &str, + message: &str, + retryable: bool, +) -> Vec { + vec![ + profile_stream.task_attempt_failed( + refs.task_id.as_str(), + refs.run_id.as_str(), + refs.attempt_id.as_str(), + refs.attempt_index, + failure_category, + Some(message), + retryable, + ), + profile_stream.task_failed( + refs.task_id.as_str(), + refs.run_id.as_str(), + refs.attempt_id.as_str(), + refs.attempt_index, + failure_category, + Some(message), + retryable, + ), + ] +} + +fn runtime_task_kind_for_execution_profile( + execution_profile: TurnExecutionProfile, + task_profile: Option<&SessionExecutionRuntimeTaskProfile>, +) -> String { + task_profile + .and_then(|profile| normalize_optional_runtime_task_text(profile.kind.as_str())) + .unwrap_or_else(|| match execution_profile { + TurnExecutionProfile::FastChat => "chat".to_string(), + TurnExecutionProfile::FullRuntime => "conversation_turn".to_string(), + }) +} + +fn normalize_runtime_task_id_fragment(value: &str, fallback: &str) -> String { + normalize_optional_runtime_task_text(value).unwrap_or_else(|| fallback.to_string()) +} + +fn normalize_optional_runtime_task_text(value: &str) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::Value; + + fn task_profile(kind: &str) -> SessionExecutionRuntimeTaskProfile { + SessionExecutionRuntimeTaskProfile { + kind: kind.to_string(), + source: "test".to_string(), + traits: Vec::new(), + modality_contract_key: None, + routing_slot: None, + execution_profile_key: None, + executor_adapter_key: None, + executor_kind: None, + executor_binding_key: None, + permission_profile_keys: Vec::new(), + user_lock_policy: None, + service_model_slot: None, + scene_kind: None, + scene_skill_id: None, + entry_source: None, + } + } + + #[test] + fn runtime_turn_task_profile_refs_should_keep_joinable_ids() { + let refs = build_runtime_turn_task_profile_refs( + "thread-1", + "turn-1", + TurnExecutionProfile::FullRuntime, + Some(&task_profile("translation")), + ); + + assert_eq!(refs.task_id, "task_thread-1"); + assert_eq!(refs.task_kind, "translation"); + assert_eq!(refs.run_id, "run_turn-1"); + assert_eq!(refs.attempt_id, "attempt_turn-1"); + assert_eq!(refs.attempt_index, 1); + } + + #[test] + fn runtime_turn_task_profile_refs_should_fallback_to_execution_profile_kind() { + let fast_chat = build_runtime_turn_task_profile_refs( + "thread-1", + "turn-1", + TurnExecutionProfile::FastChat, + None, + ); + let full_runtime = build_runtime_turn_task_profile_refs( + "thread-1", + "turn-1", + TurnExecutionProfile::FullRuntime, + None, + ); + + assert_eq!(fast_chat.task_kind, "chat"); + assert_eq!(full_runtime.task_kind, "conversation_turn"); + } + + #[test] + fn runtime_task_start_profile_events_should_emit_created_then_attempt_started() { + let stream = AgentRuntimeProfileStream::new("session-1", "thread-1", "turn-1") + .expect("profile stream"); + let refs = build_runtime_turn_task_profile_refs( + "thread-1", + "turn-1", + TurnExecutionProfile::FullRuntime, + Some(&task_profile("translation")), + ); + + let events = build_runtime_task_start_profile_events(&stream, &refs); + let values = events + .into_iter() + .map(|event| serde_json::to_value(event).expect("event")) + .collect::>(); + + assert_eq!(values[0]["type"], "task.created"); + assert_eq!(values[0]["payload"]["taskId"], "task_thread-1"); + assert_eq!(values[0]["payload"]["taskKind"], "translation"); + assert_eq!(values[0]["payload"]["source"], "agent_runtime_submit_turn"); + assert_eq!(values[1]["type"], "task.attempt.started"); + assert_eq!(values[1]["payload"]["runId"], "run_turn-1"); + assert_eq!(values[1]["payload"]["attemptId"], "attempt_turn-1"); + assert_eq!(values[1]["payload"]["attemptIndex"], 1); + assert_eq!(values[0]["sequence"], 1); + assert_eq!(values[1]["sequence"], 2); + } + + #[test] + fn runtime_task_failed_profile_events_should_emit_attempt_and_task_terminal() { + let stream = AgentRuntimeProfileStream::new("session-1", "thread-1", "turn-1") + .expect("profile stream"); + let refs = build_runtime_turn_task_profile_refs( + "thread-1", + "turn-1", + TurnExecutionProfile::FullRuntime, + None, + ); + + let events = build_runtime_task_failed_profile_events( + &stream, + &refs, + "provider_error", + "rate limit", + false, + ); + let values = events + .into_iter() + .map(|event| serde_json::to_value(event).expect("event")) + .collect::>(); + + assert_eq!(values[0]["type"], "task.attempt.failed"); + assert_eq!(values[0]["payload"]["failureCategory"], "provider_error"); + assert_eq!(values[0]["payload"]["message"], "rate limit"); + assert_eq!(values[0]["payload"]["retryable"], false); + assert_eq!(values[1]["type"], "task.failed"); + assert_eq!(values[1]["payload"]["attemptId"], "attempt_turn-1"); + } +} diff --git a/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs b/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs index 3bc51b53c..dfe1a69db 100644 --- a/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs +++ b/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs @@ -1,3 +1,6 @@ +use super::agentruntime_profile::{ + profile_failure_category, AgentRuntimeProfileEvent, AgentRuntimeProfileStream, +}; use super::request_model_resolution::resolve_runtime_provider_auth_recovery_config; #[cfg(test)] use super::runtime_project_hooks::enforce_runtime_turn_user_prompt_submit_hooks; @@ -8,6 +11,11 @@ use super::runtime_project_hooks::{ run_runtime_session_start_project_hooks_for_session_with_runtime, run_runtime_stop_project_hooks_for_session_with_runtime, }; +use super::runtime_task_profile::{ + build_runtime_task_completed_profile_event, build_runtime_task_failed_profile_events, + build_runtime_task_start_profile_events, build_runtime_turn_task_profile_refs, + RuntimeTurnTaskProfileRefs, +}; use super::service_skill_launch::build_service_skill_preload_tool_projection; use super::*; use crate::commands::auxiliary_model_selection::{ @@ -47,6 +55,7 @@ const LIME_RUNTIME_TOOL_SURFACE_KEY: &str = "tool_surface"; const LIME_RUNTIME_IMAGE_INPUT_POLICY_KEY: &str = "image_input_policy"; const FAST_CHAT_TOOL_SURFACE_DIRECT_ANSWER: &str = "direct_answer"; const FAST_CHAT_TOOL_SURFACE_LOCAL_WORKSPACE: &str = "local_workspace"; +const DEFAULT_NATIVE_TOOL_SURFACE_COMPACT: &str = "compact_tools"; const RUNTIME_IMAGE_INPUT_UNSUPPORTED_WARNING_CODE: &str = "runtime_image_input_unsupported"; const RUNTIME_TURN_CANCELLED_MESSAGE: &str = "用户已停止当前执行"; const AUTO_RUNTIME_MEMORY_MIN_USER_CHARS: usize = 12; @@ -71,6 +80,21 @@ fn emit_runtime_events(app: &AppHandle, event_name: &str, events: Vec bool { let normalized = message.trim().to_ascii_lowercase(); (normalized.contains("authentication failed") @@ -683,6 +707,21 @@ fn normalize_provider_identity(value: &str) -> String { value.trim().to_ascii_lowercase() } +fn should_use_compact_native_tool_surface(provider_config: &ConfigureProviderRequest) -> bool { + if matches!( + provider_config.tool_call_strategy, + Some(RuntimeToolCallStrategy::ToolShim) + ) { + return false; + } + + let Some(capabilities) = provider_config.model_capabilities.as_ref() else { + return true; + }; + + capabilities.tools || capabilities.function_calling +} + fn resolve_provider_config_apply_mode( provider_config: &ConfigureProviderRequest, ) -> ProviderConfigApplyMode { @@ -1057,6 +1096,8 @@ struct RuntimeTurnExecutionContext { run_start_metadata: serde_json::Map, run_observation: Arc>, timeline_recorder: Arc>, + profile_stream: AgentRuntimeProfileStream, + task_profile_refs: RuntimeTurnTaskProfileRefs, runtime_status_session_config: aster::agents::types::SessionConfig, stream_session_config_state: RuntimeTurnStreamSessionConfigState, } @@ -1090,6 +1131,8 @@ impl RuntimeTurnExecutionContext { let run_start_metadata = self.run_start_metadata.clone(); let run_observation = self.run_observation.clone(); let timeline_recorder = self.timeline_recorder.clone(); + let profile_stream = self.profile_stream.clone(); + let task_profile_refs = self.task_profile_refs.clone(); let runtime_status_session_config = self.runtime_status_session_config.clone(); let stream_session_config_state = self.stream_session_config_state.clone(); @@ -1117,6 +1160,7 @@ impl RuntimeTurnExecutionContext { execution_profile, request_metadata, provider_continuation_capability, + profile_stream.clone(), cancel_token, request_tool_policy, effective_strategy, @@ -1137,6 +1181,8 @@ impl RuntimeTurnExecutionContext { &self.timeline_recorder, workspace_root, &runtime_status_session_config, + &self.profile_stream, + &task_profile_refs, session_id, request_metadata, final_result, @@ -1294,6 +1340,35 @@ impl RuntimeTurnPreparedExecution { self.runtime_turn_artifacts.turn_state.turn_id.as_str() } + fn emit_profile_turn_submitted(&self, app: &AppHandle, event_name: &str) { + emit_agent_runtime_profile_event( + app, + event_name, + self.runtime_turn_execution_context + .profile_stream + .turn_submitted("workspace"), + ); + } + + fn emit_profile_task_started(&self, app: &AppHandle, event_name: &str) { + for event in build_runtime_task_start_profile_events( + &self.runtime_turn_execution_context.profile_stream, + &self.runtime_turn_execution_context.task_profile_refs, + ) { + emit_agent_runtime_profile_event(app, event_name, event); + } + } + + fn emit_profile_turn_started(&self, app: &AppHandle, event_name: &str) { + emit_agent_runtime_profile_event( + app, + event_name, + self.runtime_turn_execution_context + .profile_stream + .turn_started(), + ); + } + async fn emit_prelude( &self, agent: &Agent, @@ -1305,6 +1380,7 @@ impl RuntimeTurnPreparedExecution { model_name: Option<&str>, session_recent_preferences: Option<&lime_agent::SessionExecutionRuntimePreferences>, ) -> Result<(), String> { + self.emit_profile_turn_started(app, &request.event_name); prepare_runtime_turn_prelude( agent, app, @@ -1387,6 +1463,8 @@ impl RuntimeTurnPreparedExecution { app, &request.event_name, &self.runtime_turn_execution_context.timeline_recorder, + &self.runtime_turn_execution_context.profile_stream, + &self.runtime_turn_execution_context.task_profile_refs, &error, ); return Err(error); @@ -1434,6 +1512,8 @@ impl RuntimeTurnPreparedExecution { app, &request.event_name, &self.runtime_turn_execution_context.timeline_recorder, + &self.runtime_turn_execution_context.profile_stream, + &self.runtime_turn_execution_context.task_profile_refs, &error, ); return Err(error); @@ -2451,11 +2531,27 @@ fn build_runtime_turn_execution_context( None, turn_input_turn_context_override.clone(), ); + let profile_stream = AgentRuntimeProfileStream::new( + session_id, + turn_state.thread_id.clone(), + turn_state.turn_id.clone(), + )?; + let task_profile = extract_runtime_resolution_payload::< + lime_agent::SessionExecutionRuntimeTaskProfile, + >(request.metadata.as_ref(), "task_profile"); + let task_profile_refs = build_runtime_turn_task_profile_refs( + &turn_state.thread_id, + &turn_state.turn_id, + turn_state.execution_profile, + task_profile.as_ref(), + ); Ok(RuntimeTurnExecutionContext { run_start_metadata, run_observation: Arc::new(Mutex::new(ChatRunObservation::default())), timeline_recorder, + profile_stream, + task_profile_refs, runtime_status_session_config, stream_session_config_state: RuntimeTurnStreamSessionConfigState { session_id: session_id.to_string(), @@ -2651,9 +2747,28 @@ async fn execute_runtime_turn_submit( runtime_turn_prepared_execution.turn_id(), started_at.elapsed().as_millis() ); + runtime_turn_prepared_execution.emit_profile_turn_submitted(app, &request.event_name); + runtime_turn_prepared_execution.emit_profile_task_started(app, &request.event_name); let guard = agent_arc.read().await; - let agent = guard.as_ref().ok_or("Agent not initialized")?; + let Some(agent) = guard.as_ref() else { + let error = "Agent not initialized".to_string(); + fail_runtime_turn_before_model_execution( + app, + &request.event_name, + &runtime_turn_prepared_execution + .runtime_turn_execution_context + .timeline_recorder, + &runtime_turn_prepared_execution + .runtime_turn_execution_context + .profile_stream, + &runtime_turn_prepared_execution + .runtime_turn_execution_context + .task_profile_refs, + &error, + ); + return Err(error); + }; if let Err(error) = sync_runtime_skill_source_agent(session_id, agent).await { tracing::warn!( "[AsterAgent] 同步 runtime skill source agent 失败,已降级继续执行: {}", @@ -2917,6 +3032,12 @@ async fn prepare_runtime_turn_ingress_context( ensure_provider_runtime_ready(provider_config).await?; let runtime_tool_call_decision = enrich_provider_config_with_runtime_tool_strategy(provider_config).await; + if should_use_compact_native_tool_surface(provider_config) { + request.metadata = merge_runtime_turn_default_tool_surface_metadata( + request.metadata.take(), + DEFAULT_NATIVE_TOOL_SURFACE_COMPACT, + ); + } tracing::info!( "[AsterAgent] provider_config 运行时工具策略: provider_id={:?}, provider_name={}, model_name={}, strategy={:?}, toolshim_model={:?}, tools={}, function_calling={}, reasoning={}", provider_config.provider_id, @@ -3842,6 +3963,41 @@ fn merge_runtime_turn_tool_surface_metadata( Some(serde_json::Value::Object(root)) } +fn merge_runtime_turn_default_tool_surface_metadata( + request_metadata: Option, + tool_surface_mode: &str, +) -> Option { + let tool_surface_mode = tool_surface_mode.trim(); + if tool_surface_mode.is_empty() { + return request_metadata; + } + + let mut root = match request_metadata { + Some(serde_json::Value::Object(object)) => object, + Some(_) | None => serde_json::Map::new(), + }; + let runtime_entry = root + .entry(LIME_RUNTIME_METADATA_KEY.to_string()) + .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new())); + let runtime_object = runtime_entry + .as_object_mut() + .expect("lime_runtime metadata should be an object"); + + let existing_tool_surface = runtime_object + .get(LIME_RUNTIME_TOOL_SURFACE_KEY) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()); + if existing_tool_surface.is_none() { + runtime_object.insert( + LIME_RUNTIME_TOOL_SURFACE_KEY.to_string(), + serde_json::Value::String(tool_surface_mode.to_string()), + ); + } + + Some(serde_json::Value::Object(root)) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct RuntimeImageInputPolicy { submitted_image_count: usize, @@ -4983,6 +5139,8 @@ fn fail_runtime_turn_before_model_execution( app: &AppHandle, event_name: &str, timeline_recorder: &Arc>, + profile_stream: &AgentRuntimeProfileStream, + task_profile_refs: &RuntimeTurnTaskProfileRefs, message: &str, ) { let terminal_events = { @@ -5002,6 +5160,23 @@ fn fail_runtime_turn_before_model_execution( emit_runtime_events(app, event_name, events); } + let failure_category = profile_failure_category(message); + for event in build_runtime_task_failed_profile_events( + profile_stream, + task_profile_refs, + failure_category, + message, + false, + ) { + emit_agent_runtime_profile_event(app, event_name, event); + } + emit_agent_runtime_profile_event( + app, + event_name, + profile_stream.turn_failed(failure_category, message), + ); + emit_agent_runtime_profile_event(app, event_name, profile_stream.snapshot_updated("failed")); + let error_event = RuntimeAgentEvent::Error { message: message.to_string(), }; @@ -5482,6 +5657,7 @@ async fn execute_runtime_stream_attempt( execution_profile: TurnExecutionProfile, request_metadata: Option<&serde_json::Value>, provider_continuation_capability: ProviderContinuationCapability, + profile_stream: AgentRuntimeProfileStream, mut session_config: aster::agents::types::SessionConfig, cancel_token: CancellationToken, request_tool_policy: &RequestToolPolicy, @@ -5501,8 +5677,14 @@ async fn execute_runtime_stream_attempt( ); let images_for_provider = resolve_runtime_forwarded_images(request); let stream_timing = RuntimeStreamTiming::new(); + let (provider_selector, provider_name, model_name) = describe_provider_request_attempt(request); + emit_agent_runtime_profile_event( + app, + &request.event_name, + profile_stream.model_requested(&provider_selector, &provider_name, &model_name), + ); - let execution = stream_reply_once( + let execution = match stream_reply_once( agent, app, &request.event_name, @@ -5532,9 +5714,39 @@ async fn execute_runtime_stream_attempt( } }, ) - .await?; + .await + { + Ok(execution) => execution, + Err(error) => { + emit_agent_runtime_profile_event( + app, + &request.event_name, + profile_stream.model_failed( + &provider_selector, + &provider_name, + &model_name, + profile_failure_category(&error.message), + &error.message, + true, + ), + ); + return Err(error); + } + }; if execution.cancelled { + emit_agent_runtime_profile_event( + app, + &request.event_name, + profile_stream.model_failed( + &provider_selector, + &provider_name, + &model_name, + "cancelled", + RUNTIME_TURN_CANCELLED_MESSAGE, + false, + ), + ); tracing::info!( "[AsterAgent] runtime turn cancelled before success finalization: session_id={}, event_name={}, emitted_any={}", session_id, @@ -5565,6 +5777,17 @@ async fn execute_runtime_stream_attempt( &execution, ); + emit_agent_runtime_profile_event( + app, + &request.event_name, + profile_stream.model_completed( + &provider_selector, + &provider_name, + &model_name, + execution.text_output.chars().count(), + ), + ); + Ok(execution.text_output) } @@ -5697,6 +5920,7 @@ async fn execute_runtime_stream_with_strategy( execution_profile: TurnExecutionProfile, request_metadata: Option<&serde_json::Value>, provider_continuation_capability: ProviderContinuationCapability, + profile_stream: AgentRuntimeProfileStream, cancel_token: CancellationToken, request_tool_policy: &RequestToolPolicy, effective_strategy: AsterExecutionStrategy, @@ -5737,6 +5961,7 @@ where execution_profile, request_metadata, provider_continuation_capability, + profile_stream.clone(), build_session_config(), cancel_token.clone(), request_tool_policy, @@ -5789,6 +6014,7 @@ where execution_profile, request_metadata, provider_continuation_capability, + profile_stream.clone(), build_session_config(), cancel_token, request_tool_policy, @@ -5884,6 +6110,7 @@ where execution_profile, request_metadata, provider_continuation_capability, + profile_stream.clone(), build_session_config(), cancel_token.clone(), request_tool_policy, @@ -5993,6 +6220,8 @@ async fn prepare_runtime_turn_prelude( session_recent_preferences, ) .await?; + let project_runtime_status_to_timeline = + should_project_runtime_status_to_timeline(request.metadata.as_ref()); for status in [initial_runtime_status, decided_runtime_status] { emit_runtime_status_with_projection( agent, @@ -6002,6 +6231,7 @@ async fn prepare_runtime_turn_prelude( workspace_root, runtime_status_session_config, status, + project_runtime_status_to_timeline, ) .await; } @@ -6146,6 +6376,8 @@ async fn finalize_runtime_turn_result( timeline_recorder: &Arc>, workspace_root: &str, runtime_status_session_config: &aster::agents::types::SessionConfig, + profile_stream: &AgentRuntimeProfileStream, + task_profile_refs: &RuntimeTurnTaskProfileRefs, session_id: &str, request_metadata: Option<&serde_json::Value>, result: Result, @@ -6181,6 +6413,43 @@ async fn finalize_runtime_turn_result( if let Ok(events) = terminal_events { emit_runtime_events(app, event_name, events); } + match result.as_ref() { + Ok(_) => { + emit_agent_runtime_profile_event( + app, + event_name, + build_runtime_task_completed_profile_event(profile_stream, task_profile_refs), + ); + emit_agent_runtime_profile_event(app, event_name, profile_stream.turn_completed()); + emit_agent_runtime_profile_event( + app, + event_name, + profile_stream.snapshot_updated("completed"), + ); + } + Err(error) => { + let failure_category = profile_failure_category(error); + for event in build_runtime_task_failed_profile_events( + profile_stream, + task_profile_refs, + failure_category, + error, + false, + ) { + emit_agent_runtime_profile_event(app, event_name, event); + } + emit_agent_runtime_profile_event( + app, + event_name, + profile_stream.turn_failed(failure_category, error), + ); + emit_agent_runtime_profile_event( + app, + event_name, + profile_stream.snapshot_updated("failed"), + ); + } + } match result { Ok(assistant_output) => { @@ -7367,6 +7636,15 @@ async fn execute_queued_request_with_team_runtime_governor( pub(crate) fn build_queued_turn_task( mut request: AsterChatRequest, ) -> Result, String> { + let resolved_turn_id = request + .turn_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| Uuid::new_v4().to_string()); + request.turn_id = Some(resolved_turn_id); + let queued_turn_id = request .queued_turn_id .clone() @@ -7454,6 +7732,41 @@ mod tests { .await; } + fn build_compact_tool_surface_turn_context() -> TurnContextOverride { + let mut runtime_metadata = serde_json::Map::new(); + runtime_metadata.insert( + LIME_RUNTIME_TOOL_SURFACE_KEY.to_string(), + Value::String(DEFAULT_NATIVE_TOOL_SURFACE_COMPACT.to_string()), + ); + + let mut metadata = HashMap::new(); + metadata.insert( + LIME_RUNTIME_METADATA_KEY.to_string(), + Value::Object(runtime_metadata), + ); + + TurnContextOverride { + metadata, + ..TurnContextOverride::default() + } + } + + const COMPACT_PROVIDER_BROKER_TOOL_NAMES: &[&str] = &[ + TOOL_SEARCH_TOOL_NAME, + LIST_MCP_RESOURCES_TOOL_NAME, + READ_MCP_RESOURCE_TOOL_NAME, + "extensionmanager__search_available_extensions", + "extensionmanager__manage_extensions", + "Read", + "Glob", + "Grep", + "Bash", + "Edit", + "Write", + "Agent", + "StructuredOutput", + ]; + #[test] fn model_permission_detection_should_include_tenant_whitelist_400() { assert!(is_runtime_model_permission_denied_error( @@ -7585,6 +7898,23 @@ mod tests { assert!(!should_record_runtime_stream_event_on_timeline(&event)); } + #[test] + fn queued_turn_task_should_materialize_turn_id_before_runtime_execution() { + let request = build_runtime_turn_test_request("hello", None); + + let queued_task = build_queued_turn_task(request).expect("build queued task"); + let payload: AsterChatRequest = + serde_json::from_value(queued_task.payload).expect("deserialize queued payload"); + + assert!( + payload + .turn_id + .as_deref() + .is_some_and(|turn_id| !turn_id.trim().is_empty()), + "queued runtime payload must carry a stable turn id" + ); + } + #[derive(Clone)] struct AutoCompactThresholdTestProvider { context_limit: Option, @@ -7639,6 +7969,64 @@ mod tests { } } + #[tokio::test] + async fn compact_tool_surface_should_bound_provider_tools_in_runtime_crate() { + ensure_runtime_turn_test_session_manager().await; + + let agent = Agent::new(); + + let session = SessionManager::create_session( + PathBuf::from("."), + "compact tool surface 集成测试".to_string(), + SessionType::User, + ) + .await + .expect("创建测试会话失败"); + + let model_config = ModelConfig::new("gpt-4.1").expect("测试模型应有效"); + let provider = Arc::new(AutoCompactThresholdTestProvider::new(Some(200_000))); + agent + .update_provider(provider, &session.id) + .await + .expect("设置测试 provider 失败"); + + let working_dir = std::env::current_dir().expect("读取当前目录失败"); + let (tools, _toolshim_tools, _system_prompt) = aster::session_context::with_turn_context( + Some(build_compact_tool_surface_turn_context()), + async { + agent + .prepare_tools_and_prompt(&working_dir, None, false, &model_config) + .await + }, + ) + .await + .expect("准备工具面失败"); + + let names: Vec = tools.iter().map(|tool| tool.name.to_string()).collect(); + assert!(!names.is_empty(), "compact 工具面不应为空"); + assert!( + names.len() <= COMPACT_PROVIDER_BROKER_TOOL_NAMES.len(), + "compact broker 工具面最多应有 {} 个工具,实际为 {}: {:?}", + COMPACT_PROVIDER_BROKER_TOOL_NAMES.len(), + names.len(), + names + ); + assert!( + names.iter().all(|name| COMPACT_PROVIDER_BROKER_TOOL_NAMES + .iter() + .any(|candidate| candidate.eq_ignore_ascii_case(name))), + "compact provider tools 只能包含 broker / deferred / 本地核心工具: {names:?}" + ); + assert!(names.contains(&TOOL_SEARCH_TOOL_NAME.to_string())); + assert!(names.contains(&"Read".to_string())); + assert!(!names.contains(&"TeamCreate".to_string())); + assert!(!names.contains(&"TeamDelete".to_string())); + + delete_managed_session(&session.id) + .await + .expect("清理测试会话失败"); + } + fn build_auto_compaction_test_session(total_tokens: Option) -> aster::session::Session { let conversation = aster::conversation::Conversation::new_unvalidated(vec![ Message::user().with_text("第一条用户消息"), @@ -8282,6 +8670,96 @@ mod tests { ); } + #[test] + fn merge_runtime_turn_default_tool_surface_metadata_should_preserve_explicit_surface() { + let metadata = merge_runtime_turn_default_tool_surface_metadata( + Some(json!({ + LIME_RUNTIME_METADATA_KEY: { + LIME_RUNTIME_TOOL_SURFACE_KEY: FAST_CHAT_TOOL_SURFACE_DIRECT_ANSWER + } + })), + DEFAULT_NATIVE_TOOL_SURFACE_COMPACT, + ) + .expect("should preserve metadata"); + + assert_eq!( + metadata + .get(LIME_RUNTIME_METADATA_KEY) + .and_then(|value| value.get(LIME_RUNTIME_TOOL_SURFACE_KEY)) + .and_then(serde_json::Value::as_str), + Some(FAST_CHAT_TOOL_SURFACE_DIRECT_ANSWER) + ); + } + + #[test] + fn compact_native_tool_surface_should_apply_to_native_tool_capable_providers() { + let provider_config = ConfigureProviderRequest { + provider_id: Some("openai".to_string()), + provider_name: "openai".to_string(), + model_name: "gpt-4.1".to_string(), + api_key: None, + base_url: None, + model_capabilities: Some(lime_core::models::model_registry::ModelCapabilities { + vision: false, + tools: true, + streaming: true, + json_mode: true, + function_calling: false, + reasoning: false, + }), + tool_call_strategy: Some(RuntimeToolCallStrategy::Native), + toolshim_model: None, + }; + + assert!(should_use_compact_native_tool_surface(&provider_config)); + } + + #[test] + fn compact_native_tool_surface_should_apply_to_function_calling_providers() { + let provider_config = ConfigureProviderRequest { + provider_id: Some("google".to_string()), + provider_name: "google".to_string(), + model_name: "gemini-2.5-pro".to_string(), + api_key: None, + base_url: None, + model_capabilities: Some(lime_core::models::model_registry::ModelCapabilities { + vision: true, + tools: false, + streaming: true, + json_mode: true, + function_calling: true, + reasoning: true, + }), + tool_call_strategy: Some(RuntimeToolCallStrategy::Native), + toolshim_model: None, + }; + + assert!(should_use_compact_native_tool_surface(&provider_config)); + } + + #[test] + fn compact_native_tool_surface_should_skip_toolshim_strategy() { + let provider_config = ConfigureProviderRequest { + provider_id: Some("ollama".to_string()), + provider_name: "ollama".to_string(), + model_name: "llama".to_string(), + api_key: None, + base_url: None, + model_capabilities: Some(lime_core::models::model_registry::ModelCapabilities { + vision: false, + tools: false, + streaming: true, + json_mode: false, + function_calling: false, + reasoning: false, + }), + tool_call_strategy: Some(RuntimeToolCallStrategy::ToolShim), + toolshim_model: Some("gpt-4o-mini".to_string()), + }; + + assert!(!should_use_compact_native_tool_surface(&provider_config)); + } + fn runtime_test_model_capabilities( vision: bool, ) -> lime_core::models::model_registry::ModelCapabilities { diff --git a/src-tauri/src/commands/aster_agent_cmd/tests.rs b/src-tauri/src/commands/aster_agent_cmd/tests.rs index 75ddfe786..6d5f86112 100644 --- a/src-tauri/src/commands/aster_agent_cmd/tests.rs +++ b/src-tauri/src/commands/aster_agent_cmd/tests.rs @@ -3859,6 +3859,21 @@ mod tests { ); } + #[test] + fn test_fast_response_runtime_status_uses_transient_projection() { + let metadata = serde_json::json!({ + "harness": { + "fast_response_routing": { + "mode": "auto", + "runtime_status_presentation": "transient" + } + } + }); + + assert!(!should_project_runtime_status_to_timeline(Some(&metadata))); + assert!(should_project_runtime_status_to_timeline(None)); + } + #[test] fn test_resolve_request_web_search_preference_from_sources_prefers_request_flag() { let metadata = serde_json::json!({ @@ -6889,6 +6904,52 @@ mod tests { ); } + #[test] + fn test_runtime_turn_source_emits_task_profile_events_on_current_runtime_chain() { + let source = runtime_turn_source(); + let submit_slice = source_slice( + &source, + "runtime_turn_prepared_execution.emit_profile_turn_submitted(app, &request.event_name);", + "let guard = agent_arc.read().await;", + ); + assert_markers_in_order( + submit_slice, + &[ + "emit_profile_turn_submitted", + "emit_profile_task_started", + ], + ); + + let finalize_slice = source_slice( + &source, + "async fn finalize_runtime_turn_result(", + "match result {", + ); + assert_markers_in_order( + finalize_slice, + &[ + "build_runtime_task_completed_profile_event", + "profile_stream.turn_completed", + "build_runtime_task_failed_profile_events", + "profile_stream.turn_failed", + ], + ); + + let pre_model_failure_slice = source_slice( + &source, + "fn fail_runtime_turn_before_model_execution(", + "let error_event = RuntimeAgentEvent::Error", + ); + assert_markers_in_order( + pre_model_failure_slice, + &[ + "build_runtime_task_failed_profile_events", + "profile_stream.turn_failed", + "profile_stream.snapshot_updated(\"failed\")", + ], + ); + } + #[test] fn test_merge_system_prompt_with_service_skill_launch_preload_appends_result_context() { let execution = sample_service_skill_launch_preload_execution(); diff --git a/src-tauri/src/commands/aster_agent_cmd/tool_runtime/subagent_tools.rs b/src-tauri/src/commands/aster_agent_cmd/tool_runtime/subagent_tools.rs index 6ab64d2d1..c708324af 100644 --- a/src-tauri/src/commands/aster_agent_cmd/tool_runtime/subagent_tools.rs +++ b/src-tauri/src/commands/aster_agent_cmd/tool_runtime/subagent_tools.rs @@ -40,6 +40,7 @@ pub(crate) fn extract_runtime_subagent_result_text(detail: &SessionDetail) -> Op match &item.payload { lime_core::database::dao::agent_timeline::AgentThreadItemPayload::TurnSummary { text, + .. } | lime_core::database::dao::agent_timeline::AgentThreadItemPayload::Plan { text } | lime_core::database::dao::agent_timeline::AgentThreadItemPayload::AgentMessage { diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 204ea6595..b54deed7b 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -40,9 +40,6 @@ pub(crate) mod modality_runtime_contracts; pub mod model_registry_cmd; pub mod models_cmd; pub mod persona_cmd; -pub mod plugin_cmd; -pub mod plugin_install_cmd; -pub mod plugin_rpc_cmd; pub mod prompt_cmd; pub mod sceneapp_cmd; pub mod screenshot_cmd; diff --git a/src-tauri/src/commands/plugin_cmd.rs b/src-tauri/src/commands/plugin_cmd.rs deleted file mode 100644 index 73cff2acc..000000000 --- a/src-tauri/src/commands/plugin_cmd.rs +++ /dev/null @@ -1,569 +0,0 @@ -//! 插件系统相关命令 -//! -//! 提供插件管理和 UI 相关的 Tauri 命令: -//! - get_plugin_status: 获取插件服务状态 -//! - get_plugins: 获取所有插件列表 -//! - get_plugins_with_ui: 获取带有 UI 配置的已安装插件列表 -//! - get_plugin_ui: 获取插件 UI 定义 -//! - handle_plugin_action: 处理插件 UI 操作 -//! -//! _需求: 3.1, 3.2, 3.3_ - -#![allow(dead_code)] - -use lime_core::plugin::{ - PluginConfig, PluginInfo, PluginManager, PluginManifest, PluginQueueStats, PluginTaskRecord, - PluginTaskState, PluginType, -}; -use serde::{Deserialize, Serialize}; -use std::path::Path; -use std::sync::Arc; -use tokio::sync::RwLock; - -use super::plugin_install_cmd::PluginInstallerState; - -/// 前端调试日志命令 -#[tauri::command] -pub fn frontend_debug_log(message: String) { - println!("[Frontend] {message}"); -} - -/// 插件管理器状态 -pub struct PluginManagerState(pub Arc>); - -/// 插件状态响应 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PluginServiceStatus { - pub enabled: bool, - pub plugin_count: usize, - pub plugins_dir: String, -} - -/// 插件配置请求 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PluginConfigRequest { - pub enabled: bool, - pub timeout_ms: u64, - pub settings: serde_json::Value, -} - -/// 获取插件服务状态 -#[tauri::command] -pub async fn get_plugin_status( - state: tauri::State<'_, PluginManagerState>, -) -> Result { - let manager = state.0.read().await; - Ok(PluginServiceStatus { - enabled: true, - plugin_count: manager.count(), - plugins_dir: manager.plugins_dir().to_string_lossy().to_string(), - }) -} - -/// 获取所有插件列表 -#[tauri::command] -pub async fn get_plugins( - state: tauri::State<'_, PluginManagerState>, -) -> Result, String> { - let manager = state.0.read().await; - Ok(manager.list().await) -} - -/// 获取单个插件信息 -#[tauri::command] -pub async fn get_plugin_info( - state: tauri::State<'_, PluginManagerState>, - name: String, -) -> Result, String> { - let manager = state.0.read().await; - Ok(manager.get_info(&name).await) -} - -/// 启用插件 -#[tauri::command] -pub async fn enable_plugin( - state: tauri::State<'_, PluginManagerState>, - name: String, -) -> Result<(), String> { - let manager = state.0.read().await; - manager.enable(&name).await.map_err(|e| e.to_string()) -} - -/// 禁用插件 -#[tauri::command] -pub async fn disable_plugin( - state: tauri::State<'_, PluginManagerState>, - name: String, -) -> Result<(), String> { - let manager = state.0.read().await; - manager.disable(&name).await.map_err(|e| e.to_string()) -} - -/// 更新插件配置 -#[tauri::command] -pub async fn update_plugin_config( - state: tauri::State<'_, PluginManagerState>, - name: String, - config: PluginConfigRequest, -) -> Result<(), String> { - let manager = state.0.read().await; - let plugin_config = PluginConfig { - enabled: config.enabled, - timeout_ms: config.timeout_ms, - settings: config.settings, - }; - manager - .update_config(&name, plugin_config) - .await - .map_err(|e| e.to_string()) -} - -/// 获取插件配置 -#[tauri::command] -pub async fn get_plugin_config( - state: tauri::State<'_, PluginManagerState>, - name: String, -) -> Result, String> { - let manager = state.0.read().await; - Ok(manager.get_config(&name)) -} - -/// 重新加载所有插件 -#[tauri::command] -pub async fn reload_plugins( - state: tauri::State<'_, PluginManagerState>, -) -> Result, String> { - let manager = state.0.read().await; - manager.load_all().await.map_err(|e| e.to_string()) -} - -/// 卸载插件 -#[tauri::command] -pub async fn unload_plugin( - state: tauri::State<'_, PluginManagerState>, - name: String, -) -> Result<(), String> { - let manager = state.0.read().await; - manager.unload(&name).await.map_err(|e| e.to_string()) -} - -/// 获取插件目录路径 -#[tauri::command] -pub async fn get_plugins_dir( - state: tauri::State<'_, PluginManagerState>, -) -> Result { - let manager = state.0.read().await; - let dir = manager.plugins_dir().to_string_lossy().to_string(); - println!("[get_plugins_dir] 返回: {dir}"); - Ok(dir) -} - -fn parse_task_state(state: Option) -> Result, String> { - let Some(state) = state else { - return Ok(None); - }; - state - .parse::() - .map(Some) - .map_err(|e| format!("解析任务状态失败: {e}")) -} - -/// 查询插件任务列表 -#[tauri::command] -pub async fn list_plugin_tasks( - state: tauri::State<'_, PluginManagerState>, - plugin_id: Option, - task_state: Option, - limit: Option, -) -> Result, String> { - let manager = state.0.read().await; - let parsed_state = parse_task_state(task_state)?; - Ok(manager.list_tasks(plugin_id.as_deref(), parsed_state, limit.unwrap_or(100))) -} - -/// 获取单个插件任务 -#[tauri::command] -pub async fn get_plugin_task( - state: tauri::State<'_, PluginManagerState>, - task_id: String, -) -> Result, String> { - let manager = state.0.read().await; - Ok(manager.get_task(&task_id)) -} - -/// 取消插件任务 -#[tauri::command] -pub async fn cancel_plugin_task( - state: tauri::State<'_, PluginManagerState>, - task_id: String, -) -> Result { - let manager = state.0.read().await; - Ok(manager.cancel_task(&task_id)) -} - -/// 获取插件队列统计 -#[tauri::command] -pub async fn get_plugin_queue_stats( - state: tauri::State<'_, PluginManagerState>, - plugin_id: Option, -) -> Result, String> { - let manager = state.0.read().await; - Ok(manager.get_queue_stats(plugin_id.as_deref())) -} - -// ============================================================================ -// 插件 UI 注册系统 -// ============================================================================ - -/// 插件 UI 信息 -/// -/// 用于前端显示带有 UI 的插件列表 -/// _需求: 3.1, 3.3_ -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct PluginUIInfo { - /// 插件 ID - pub plugin_id: String, - /// 插件名称 - pub name: String, - /// 插件描述 - pub description: String, - /// 图标名称 (Lucide 图标) - pub icon: String, - /// UI 展示位置列表 (如 "tools", "sidebar", "main") - pub surfaces: Vec, -} - -/// 从插件目录读取 manifest 文件 -/// -/// 尝试读取 plugin.json 文件并解析为 PluginManifest -fn read_plugin_manifest(install_path: &Path) -> Option { - let manifest_path = install_path.join("plugin.json"); - tracing::debug!( - "read_plugin_manifest: install_path={:?}, manifest_path={:?}, exists={}", - install_path, - manifest_path, - manifest_path.exists() - ); - - if !manifest_path.exists() { - tracing::debug!("read_plugin_manifest: manifest file does not exist"); - return None; - } - - match std::fs::read_to_string(&manifest_path) { - Ok(content) => { - tracing::debug!( - "read_plugin_manifest: file content length={}", - content.len() - ); - match serde_json::from_str(&content) { - Ok(manifest) => { - tracing::debug!("read_plugin_manifest: parsed successfully"); - Some(manifest) - } - Err(e) => { - tracing::error!("read_plugin_manifest: JSON parse error: {}", e); - None - } - } - } - Err(e) => { - tracing::error!("read_plugin_manifest: read file error: {}", e); - None - } - } -} - -/// 获取带有 UI 配置的已安装插件列表 -/// -/// 从已安装插件中筛选带有 UI 配置的插件,返回 PluginUIInfo 列表 -/// 同时扫描插件目录中未注册但存在的插件 -/// _需求: 3.1, 3.3_ -#[tauri::command] -pub async fn get_plugins_with_ui( - installer_state: tauri::State<'_, PluginInstallerState>, - plugin_manager_state: tauri::State<'_, PluginManagerState>, -) -> Result, String> { - let installer = installer_state.0.read().await; - let manager = plugin_manager_state.0.read().await; - - // 获取所有已安装插件(从数据库) - let installed_plugins = installer.list_installed().map_err(|e| e.to_string())?; - tracing::info!( - "[get_plugins_with_ui] 从数据库获取到 {} 个已安装插件", - installed_plugins.len() - ); - for p in &installed_plugins { - tracing::info!( - "[get_plugins_with_ui] 数据库插件: id={}, install_path={:?}", - p.id, - p.install_path - ); - } - - let mut registered_ids: std::collections::HashSet = - installed_plugins.iter().map(|p| p.id.clone()).collect(); - - // 筛选带有 UI 配置的已注册插件 - let mut ui_plugins: Vec = installed_plugins - .into_iter() - .filter_map(|plugin| { - // 读取插件的 manifest 文件 - let manifest = read_plugin_manifest(&plugin.install_path)?; - - // 检查是否有 UI 配置 - let ui_config = manifest.ui?; - - // 只返回有 surfaces 配置的插件 - if ui_config.surfaces.is_empty() { - return None; - } - - Some(PluginUIInfo { - plugin_id: plugin.id, - name: plugin.name, - description: plugin.description, - icon: ui_config.icon.unwrap_or_else(|| "puzzle".to_string()), - surfaces: ui_config.surfaces, - }) - }) - .collect(); - - tracing::info!( - "[get_plugins_with_ui] 从数据库筛选出 {} 个带 UI 的插件", - ui_plugins.len() - ); - - // 扫描插件目录中未注册的插件 - let plugins_dir = manager.plugins_dir(); - tracing::info!( - "[get_plugins_with_ui] PluginManager plugins_dir={:?}, exists={}", - plugins_dir, - plugins_dir.exists() - ); - - if let Ok(entries) = std::fs::read_dir(plugins_dir) { - for entry in entries.flatten() { - let path = entry.path(); - tracing::debug!("[get_plugins_with_ui] 扫描目录: {:?}", path); - if path.is_dir() { - let plugin_id = path - .file_name() - .and_then(|n| n.to_str()) - .map(|s| s.to_string()); - - if let Some(id) = plugin_id { - tracing::info!( - "[get_plugins_with_ui] 发现目录: id={}, already_registered={}", - id, - registered_ids.contains(&id) - ); - - // 跳过已注册的插件 - if registered_ids.contains(&id) { - continue; - } - - // 尝试读取 manifest - let manifest_result = read_plugin_manifest(&path); - tracing::info!( - "[get_plugins_with_ui] 读取 manifest: id={}, success={}", - id, - manifest_result.is_some() - ); - - if let Some(manifest) = manifest_result { - tracing::info!( - "[get_plugins_with_ui] manifest: name={}, has_ui={}", - manifest.name, - manifest.ui.is_some() - ); - if let Some(ui_config) = manifest.ui { - tracing::info!( - "[get_plugins_with_ui] ui_config: surfaces={:?}", - ui_config.surfaces - ); - if !ui_config.surfaces.is_empty() { - ui_plugins.push(PluginUIInfo { - plugin_id: id.clone(), - name: manifest.name, - description: manifest.description, - icon: ui_config.icon.unwrap_or_else(|| "puzzle".to_string()), - surfaces: ui_config.surfaces, - }); - registered_ids.insert(id); - } - } - } - } - } - } - } else { - tracing::error!("[get_plugins_with_ui] 无法读取插件目录: {:?}", plugins_dir); - } - - tracing::info!( - "[get_plugins_with_ui] 最终返回 {} 个带 UI 的插件: {:?}", - ui_plugins.len(), - ui_plugins.iter().map(|p| &p.plugin_id).collect::>() - ); - - Ok(ui_plugins) -} - -// ============================================================================ -// 插件 UI 相关命令 -// ============================================================================ - -use lime_core::plugin::{UIMessage, UserAction}; - -/// 获取插件 UI 定义 -/// 返回插件的初始 UI 消息列表 -#[tauri::command] -pub async fn get_plugin_ui( - state: tauri::State<'_, PluginManagerState>, - plugin_id: String, -) -> Result, String> { - let manager = state.0.read().await; - - // 获取插件的 Surface 定义 - let surfaces = manager - .get_plugin_surfaces(&plugin_id) - .await - .map_err(|e| e.to_string())?; - - // 转换为 UI 消息 - let messages: Vec = surfaces.into_iter().flat_map(|s| s.to_messages()).collect(); - - Ok(messages) -} - -/// 处理插件 UI 操作 -/// 将用户操作转发给插件并返回响应消息 -#[tauri::command] -pub async fn handle_plugin_action( - state: tauri::State<'_, PluginManagerState>, - plugin_id: String, - action: UserAction, -) -> Result, String> { - let mut manager = state.0.write().await; - - manager - .handle_plugin_action(&plugin_id, action) - .await - .map_err(|e| e.to_string()) -} - -/// 读取插件清单文件 -/// -/// 从插件目录读取 plugin.json 文件 -/// 用于检查插件是否存在于文件系统中(即使未在数据库中注册) -/// 首先尝试从 PluginManager 的 plugins_dir 读取,如果失败则尝试从数据库中的 install_path 读取 -#[tauri::command] -pub async fn read_plugin_manifest_cmd( - state: tauri::State<'_, PluginManagerState>, - installer_state: tauri::State<'_, PluginInstallerState>, - plugin_id: String, -) -> Result, String> { - let manager = state.0.read().await; - let plugins_dir = manager.plugins_dir(); - let plugin_path = plugins_dir.join(&plugin_id); - - // 使用 println! 确保在 release 版本中也能看到日志 - println!( - "[read_plugin_manifest_cmd] plugin_id={}, plugins_dir={:?}, plugin_path={:?}, exists={}", - plugin_id, - plugins_dir, - plugin_path, - plugin_path.exists() - ); - - // 首先尝试从 PluginManager 的 plugins_dir 读取 - if let Some(manifest) = read_plugin_manifest(&plugin_path) { - println!("[read_plugin_manifest_cmd] found in plugins_dir"); - println!( - "[read_plugin_manifest_cmd] manifest: name={}, version={}, plugin_type={:?}, has_ui={}", - manifest.name, - manifest.version, - manifest.plugin_type, - manifest.ui.is_some() - ); - // 输出序列化后的 JSON - if let Ok(json) = serde_json::to_string(&manifest) { - println!("[read_plugin_manifest_cmd] JSON: {json}"); - } - return Ok(Some(manifest)); - } - - // 如果失败,尝试从数据库中的 install_path 读取 - let installer = installer_state.0.read().await; - if let Ok(Some(installed)) = installer.get_plugin(&plugin_id) { - println!( - "[read_plugin_manifest_cmd] trying install_path={:?}", - installed.install_path - ); - if let Some(manifest) = read_plugin_manifest(&installed.install_path) { - println!("[read_plugin_manifest_cmd] found in install_path"); - return Ok(Some(manifest)); - } - } - - println!("[read_plugin_manifest_cmd] not found"); - Ok(None) -} - -/// 启动插件 UI(用于 binary 类型插件) -/// -/// 启动插件的独立 UI 窗口 -#[tauri::command] -pub async fn launch_plugin_ui( - state: tauri::State<'_, PluginManagerState>, - installer_state: tauri::State<'_, PluginInstallerState>, - plugin_id: String, -) -> Result<(), String> { - let manager = state.0.read().await; - let plugins_dir = manager.plugins_dir(); - let plugin_path = plugins_dir.join(&plugin_id); - - // 首先尝试从 PluginManager 的 plugins_dir 读取 - let (manifest, actual_plugin_path) = if let Some(m) = read_plugin_manifest(&plugin_path) { - (m, plugin_path) - } else { - // 如果失败,尝试从数据库中的 install_path 读取 - let installer = installer_state.0.read().await; - if let Ok(Some(installed)) = installer.get_plugin(&plugin_id) { - if let Some(m) = read_plugin_manifest(&installed.install_path) { - (m, installed.install_path.clone()) - } else { - return Err(format!("插件 {plugin_id} 不存在")); - } - } else { - return Err(format!("插件 {plugin_id} 不存在")); - } - }; - - // 检查是否是 binary 类型 - if manifest.plugin_type != PluginType::Binary { - return Err("只有 binary 类型的插件支持独立启动".to_string()); - } - - // 获取二进制文件路径 - let binary_config = manifest - .binary - .ok_or_else(|| "插件缺少 binary 配置".to_string())?; - - let binary_name = &binary_config.binary_name; - let binary_path = actual_plugin_path.join(binary_name); - - if !binary_path.exists() { - return Err(format!("插件二进制文件不存在: {}", binary_path.display())); - } - - // 启动二进制文件 - std::process::Command::new(&binary_path) - .spawn() - .map_err(|e| format!("启动插件失败: {e}"))?; - - Ok(()) -} diff --git a/src-tauri/src/commands/plugin_install_cmd.rs b/src-tauri/src/commands/plugin_install_cmd.rs deleted file mode 100644 index 92568d98d..000000000 --- a/src-tauri/src/commands/plugin_install_cmd.rs +++ /dev/null @@ -1,189 +0,0 @@ -//! 插件安装相关命令 -//! -//! 提供插件安装、卸载和管理的 Tauri 命令: -//! - install_plugin_from_file: 从本地文件安装插件 -//! - install_plugin_from_url: 从 URL 安装插件 -//! - uninstall_plugin: 卸载插件 -//! - list_installed_plugins: 列出已安装插件 -//! -//! _需求: 1.1, 2.1, 2.2, 2.4, 3.1, 3.2, 3.3, 4.2, 6.1_ - -use lime_core::plugin::installer::{ - InstallProgress, InstalledPlugin, PluginInstaller, ProgressCallback, -}; -use serde::{Deserialize, Serialize}; -use std::path::PathBuf; -use std::sync::Arc; -use tauri::{AppHandle, Emitter, Runtime}; -use tokio::sync::RwLock; - -/// 插件安装器状态 -pub struct PluginInstallerState(pub Arc>); - -/// 安装结果响应 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct InstallResult { - pub success: bool, - pub plugin: Option, - pub error: Option, -} - -/// 进度事件名称 -const INSTALL_PROGRESS_EVENT: &str = "plugin-install-progress"; - -/// Tauri 进度回调实现 -/// -/// 将安装进度通过 Tauri 事件发送到前端 -struct TauriProgressCallback { - app_handle: AppHandle, -} - -impl TauriProgressCallback { - fn new(app_handle: AppHandle) -> Self { - Self { app_handle } - } -} - -impl lime_core::plugin::installer::ProgressCallback for TauriProgressCallback { - fn on_progress(&self, progress: InstallProgress) { - // 发送进度事件到前端 - let _ = self.app_handle.emit(INSTALL_PROGRESS_EVENT, &progress); - } -} - -/// 从本地文件安装插件 -/// -/// 流程: 验证 → 解压 → 注册 → 复制文件 -/// _需求: 1.1, 3.1, 3.2, 3.3_ -#[tauri::command] -pub async fn install_plugin_from_file( - app_handle: AppHandle, - state: tauri::State<'_, PluginInstallerState>, - file_path: String, -) -> Result { - let installer = state.0.read().await; - let path = PathBuf::from(&file_path); - - // 验证文件存在 - if !path.exists() { - return Ok(InstallResult { - success: false, - plugin: None, - error: Some(format!("文件不存在: {file_path}")), - }); - } - - // 创建进度回调 - let progress_callback = TauriProgressCallback::new(app_handle); - - // 执行安装 - match installer.install_from_file(&path, &progress_callback).await { - Ok(plugin) => Ok(InstallResult { - success: true, - plugin: Some(plugin), - error: None, - }), - Err(e) => { - // 发送失败进度 - progress_callback.on_progress(InstallProgress::failed(e.to_string())); - Ok(InstallResult { - success: false, - plugin: None, - error: Some(e.to_string()), - }) - } - } -} - -/// 从 URL 安装插件 -/// -/// 流程: 下载 → 验证 → 解压 → 注册 → 复制文件 -/// _需求: 2.1, 2.2, 2.4_ -#[tauri::command] -pub async fn install_plugin_from_url( - app_handle: AppHandle, - state: tauri::State<'_, PluginInstallerState>, - url: String, -) -> Result { - let installer = state.0.read().await; - - // 验证 URL 格式 - if !url.starts_with("http://") && !url.starts_with("https://") { - return Ok(InstallResult { - success: false, - plugin: None, - error: Some("无效的 URL 格式,必须以 http:// 或 https:// 开头".to_string()), - }); - } - - // 创建进度回调 - let progress_callback = TauriProgressCallback::new(app_handle); - - // 执行安装 - match installer.install_from_url(&url, &progress_callback).await { - Ok(plugin) => Ok(InstallResult { - success: true, - plugin: Some(plugin), - error: None, - }), - Err(e) => { - // 发送失败进度 - progress_callback.on_progress(InstallProgress::failed(e.to_string())); - Ok(InstallResult { - success: false, - plugin: None, - error: Some(e.to_string()), - }) - } - } -} - -/// 卸载插件 -/// -/// 流程: 删除文件 → 注销注册表 -/// _需求: 4.2_ -#[tauri::command] -pub async fn uninstall_plugin( - state: tauri::State<'_, PluginInstallerState>, - plugin_id: String, -) -> Result { - let installer = state.0.read().await; - - match installer.uninstall(&plugin_id).await { - Ok(()) => Ok(true), - Err(e) => Err(e.to_string()), - } -} - -/// 列出已安装插件 -/// -/// _需求: 6.1_ -#[tauri::command] -pub async fn list_installed_plugins( - state: tauri::State<'_, PluginInstallerState>, -) -> Result, String> { - let installer = state.0.read().await; - installer.list_installed().map_err(|e| e.to_string()) -} - -/// 获取已安装插件信息 -#[tauri::command] -pub async fn get_installed_plugin( - state: tauri::State<'_, PluginInstallerState>, - plugin_id: String, -) -> Result, String> { - let installer = state.0.read().await; - installer.get_plugin(&plugin_id).map_err(|e| e.to_string()) -} - -/// 检查插件是否已安装 -#[tauri::command] -pub async fn is_plugin_installed( - state: tauri::State<'_, PluginInstallerState>, - plugin_id: String, -) -> Result { - let installer = state.0.read().await; - installer - .is_installed(&plugin_id) - .map_err(|e| e.to_string()) -} diff --git a/src-tauri/src/commands/plugin_rpc_cmd.rs b/src-tauri/src/commands/plugin_rpc_cmd.rs deleted file mode 100644 index 74b2edeee..000000000 --- a/src-tauri/src/commands/plugin_rpc_cmd.rs +++ /dev/null @@ -1,384 +0,0 @@ -//! 插件 RPC 通信命令 -//! -//! 提供插件与其 Binary 后端进程的 JSON-RPC 通信功能: -//! - plugin_rpc_connect: 启动插件进程并建立连接 -//! - plugin_rpc_disconnect: 关闭插件进程 -//! - plugin_rpc_call: 发送 RPC 请求并等待响应 -//! -//! 支持异步通知:后端进程可以发送 JSON-RPC 通知,通过 Tauri 事件转发到前端。 -//! -//! _需求: 插件 RPC 通信_ - -use crate::commands::plugin_install_cmd::PluginInstallerState; -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use std::collections::HashMap; -use std::process::Stdio; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::Arc; -use tauri::Emitter; -use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; -use tokio::process::{Child, ChildStdin, Command}; -use tokio::sync::{mpsc, oneshot, Mutex, RwLock}; - -/// RPC 请求 ID 生成器 -static REQUEST_ID: AtomicU64 = AtomicU64::new(1); - -/// JSON-RPC 请求 -#[derive(Debug, Serialize)] -struct JsonRpcRequest { - jsonrpc: &'static str, - method: String, - params: Option, - id: u64, -} - -/// JSON-RPC 响应 -#[derive(Debug, Deserialize, Clone)] -struct JsonRpcResponse { - #[allow(dead_code)] - jsonrpc: String, - result: Option, - error: Option, - id: Option, -} - -/// JSON-RPC 通知(无 id 字段) -#[derive(Debug, Deserialize, Clone)] -struct JsonRpcNotification { - #[allow(dead_code)] - jsonrpc: String, - method: String, - params: Option, -} - -/// JSON-RPC 消息(可能是响应或通知) -#[derive(Debug, Deserialize)] -#[serde(untagged)] -enum JsonRpcMessage { - Response(JsonRpcResponse), - Notification(JsonRpcNotification), -} - -/// JSON-RPC 错误 -#[derive(Debug, Deserialize, Clone)] -struct JsonRpcError { - code: i32, - message: String, - #[allow(dead_code)] - data: Option, -} - -/// RPC 通知事件 payload -#[derive(Debug, Clone, Serialize)] -struct RpcNotificationPayload { - plugin_id: String, - method: String, - params: Option, -} - -/// 待处理的 RPC 请求 -struct PendingRequest { - response_tx: oneshot::Sender>, -} - -/// 插件进程信息 -struct PluginProcess { - #[allow(dead_code)] - child: Child, - stdin: Arc>, - pending_requests: Arc>>, - shutdown_tx: Option>, -} - -/// 插件 RPC 管理器状态 -pub struct PluginRpcManagerState { - /// 运行中的插件进程 - processes: RwLock>>>, -} - -impl PluginRpcManagerState { - pub fn new() -> Self { - Self { - processes: RwLock::new(HashMap::new()), - } - } -} - -impl Default for PluginRpcManagerState { - fn default() -> Self { - Self::new() - } -} - -/// 启动插件进程并建立 RPC 连接 -#[tauri::command] -pub async fn plugin_rpc_connect( - plugin_id: String, - app_handle: tauri::AppHandle, - installer_state: tauri::State<'_, PluginInstallerState>, - rpc_state: tauri::State<'_, PluginRpcManagerState>, -) -> Result<(), String> { - // 检查是否已连接 - { - let processes = rpc_state.processes.read().await; - if processes.contains_key(&plugin_id) { - return Ok(()); // 已连接 - } - } - - // 获取插件信息 - let installer = installer_state.0.read().await; - let plugins = installer.list_installed().map_err(|e| e.to_string())?; - let plugin = plugins - .iter() - .find(|p| p.id == plugin_id) - .ok_or_else(|| format!("插件 {plugin_id} 未安装"))?; - - // 读取插件 manifest - let manifest_path = plugin.install_path.join("plugin.json"); - let manifest_content = - std::fs::read_to_string(&manifest_path).map_err(|e| format!("读取 manifest 失败: {e}"))?; - let manifest: Value = - serde_json::from_str(&manifest_content).map_err(|e| format!("解析 manifest 失败: {e}"))?; - - // 获取二进制文件路径 - let _binary_name = manifest["binary"]["binary_name"] - .as_str() - .ok_or("manifest 中缺少 binary.binary_name")?; - - // 根据平台选择二进制文件 - let platform_key = match (std::env::consts::ARCH, std::env::consts::OS) { - ("aarch64", "macos") => "macos-arm64", - ("x86_64", "macos") => "macos-x64", - ("x86_64", "linux") => "linux-x64", - ("aarch64", "linux") => "linux-arm64", - ("x86_64", "windows") => "windows-x64", - _ => return Err("不支持的平台".to_string()), - }; - - let binary_filename = manifest["binary"]["platform_binaries"][platform_key] - .as_str() - .ok_or_else(|| format!("manifest 中缺少 {platform_key} 平台的二进制文件"))?; - - let binary_path = plugin.install_path.join(binary_filename); - if !binary_path.exists() { - return Err(format!("二进制文件不存在: {binary_path:?}")); - } - - // 启动进程(使用 tokio::process::Command) - let mut child = Command::new(&binary_path) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .map_err(|e| format!("启动插件进程失败: {e}"))?; - - tracing::info!("插件 {} 进程已启动, PID: {:?}", plugin_id, child.id()); - - // 获取 stdin 和 stdout - let stdin = child.stdin.take().ok_or("无法获取进程 stdin")?; - let stdout = child.stdout.take().ok_or("无法获取进程 stdout")?; - - let stdin = Arc::new(Mutex::new(stdin)); - let pending_requests: Arc>> = - Arc::new(Mutex::new(HashMap::new())); - - // 创建 shutdown channel - let (shutdown_tx, mut shutdown_rx) = mpsc::channel::<()>(1); - - // 启动 stdout 读取任务 - let plugin_id_clone = plugin_id.clone(); - let pending_requests_clone = pending_requests.clone(); - let app_handle_clone = app_handle.clone(); - - tokio::spawn(async move { - let mut reader = BufReader::new(stdout); - let mut line = String::new(); - - loop { - line.clear(); - - tokio::select! { - // 检查 shutdown 信号 - _ = shutdown_rx.recv() => { - tracing::info!("插件 {} stdout 读取任务收到关闭信号", plugin_id_clone); - break; - } - // 读取一行 - result = reader.read_line(&mut line) => { - match result { - Ok(0) => { - // EOF - tracing::info!("插件 {} stdout EOF", plugin_id_clone); - break; - } - Ok(_) => { - let line_trimmed = line.trim(); - if line_trimmed.is_empty() { - continue; - } - - // 尝试解析为 JSON-RPC 消息 - match serde_json::from_str::(line_trimmed) { - Ok(JsonRpcMessage::Response(response)) => { - // 这是一个响应,找到对应的 pending request - if let Some(id) = response.id { - let mut pending = pending_requests_clone.lock().await; - if let Some(req) = pending.remove(&id) { - let result = if let Some(error) = response.error { - Err(format!("RPC 错误 [{}]: {}", error.code, error.message)) - } else { - Ok(response.result.unwrap_or(Value::Null)) - }; - let _ = req.response_tx.send(result); - } - } - } - Ok(JsonRpcMessage::Notification(notification)) => { - // 这是一个通知,发送 Tauri 事件 - let payload = RpcNotificationPayload { - plugin_id: plugin_id_clone.clone(), - method: notification.method.clone(), - params: notification.params, - }; - tracing::debug!( - "插件 {} 发送通知: {}", - plugin_id_clone, - notification.method - ); - if let Err(e) = app_handle_clone.emit("plugin-rpc-notification", &payload) { - tracing::error!("发送 Tauri 事件失败: {}", e); - } - } - Err(e) => { - tracing::warn!( - "插件 {} 收到无效 JSON: {} - {}", - plugin_id_clone, - e, - line_trimmed - ); - } - } - } - Err(e) => { - tracing::error!("插件 {} 读取 stdout 失败: {}", plugin_id_clone, e); - break; - } - } - } - } - } - }); - - let process = PluginProcess { - child, - stdin, - pending_requests, - shutdown_tx: Some(shutdown_tx), - }; - - // 保存进程 - let mut processes = rpc_state.processes.write().await; - processes.insert(plugin_id, Arc::new(Mutex::new(process))); - - Ok(()) -} - -/// 关闭插件 RPC 连接 -#[tauri::command] -pub async fn plugin_rpc_disconnect( - plugin_id: String, - rpc_state: tauri::State<'_, PluginRpcManagerState>, -) -> Result<(), String> { - let mut processes = rpc_state.processes.write().await; - - if let Some(process_arc) = processes.remove(&plugin_id) { - let mut process = process_arc.lock().await; - - // 发送 shutdown 信号 - if let Some(tx) = process.shutdown_tx.take() { - let _ = tx.send(()).await; - } - - // 终止进程 - if let Err(e) = process.child.kill().await { - tracing::warn!("关闭插件 {} 进程失败: {}", plugin_id, e); - } - tracing::info!("插件 {} 进程已关闭", plugin_id); - } - - Ok(()) -} - -/// 发送 RPC 请求 -#[tauri::command] -pub async fn plugin_rpc_call( - plugin_id: String, - method: String, - params: Option, - rpc_state: tauri::State<'_, PluginRpcManagerState>, -) -> Result { - let processes = rpc_state.processes.read().await; - let process_arc = processes - .get(&plugin_id) - .ok_or_else(|| format!("插件 {plugin_id} 未连接"))? - .clone(); - drop(processes); - - let process = process_arc.lock().await; - - // 构建请求 - let request_id = REQUEST_ID.fetch_add(1, Ordering::SeqCst); - let request = JsonRpcRequest { - jsonrpc: "2.0", - method: method.clone(), - params, - id: request_id, - }; - - let request_json = - serde_json::to_string(&request).map_err(|e| format!("序列化请求失败: {e}"))?; - - // 创建响应 channel - let (response_tx, response_rx) = oneshot::channel(); - - // 注册 pending request - { - let mut pending = process.pending_requests.lock().await; - pending.insert(request_id, PendingRequest { response_tx }); - } - - // 发送请求 - { - let mut stdin = process.stdin.lock().await; - stdin - .write_all(request_json.as_bytes()) - .await - .map_err(|e| format!("发送请求失败: {e}"))?; - stdin - .write_all(b"\n") - .await - .map_err(|e| format!("发送换行失败: {e}"))?; - stdin - .flush() - .await - .map_err(|e| format!("刷新 stdin 失败: {e}"))?; - } - - // 释放 process lock,让 stdout 读取任务可以处理响应 - drop(process); - - // 等待响应(带超时) - match tokio::time::timeout(std::time::Duration::from_secs(30), response_rx).await { - Ok(Ok(result)) => result, - Ok(Err(_)) => Err("响应 channel 已关闭".to_string()), - Err(_) => { - // 超时,清理 pending request - let process = process_arc.lock().await; - let mut pending = process.pending_requests.lock().await; - pending.remove(&request_id); - Err(format!("RPC 调用 {method} 超时")) - } - } -} diff --git a/src-tauri/src/commands/skill_cmd.rs b/src-tauri/src/commands/skill_cmd.rs index 2e36b063f..ce9fd34bf 100644 --- a/src-tauri/src/commands/skill_cmd.rs +++ b/src-tauri/src/commands/skill_cmd.rs @@ -47,6 +47,18 @@ pub fn scan_installed_skills(skills_dir: &Path) -> Vec { skills } +fn scan_installed_skills_from_roots(skill_roots: &[PathBuf]) -> Vec { + let mut skills = Vec::new(); + for root in skill_roots { + for directory in scan_installed_skills(root) { + if !skills.contains(&directory) { + skills.push(directory); + } + } + } + skills +} + fn get_skills_dir(app_type: &AppType) -> Result { match app_type { AppType::Lime => app_paths::resolve_skills_dir(), @@ -93,6 +105,38 @@ fn validate_skill_directory(directory: &str) -> Result<(), String> { } } +fn validate_remote_skill_directory(directory: &str) -> Result<(), String> { + let directory = directory.trim(); + if directory.is_empty() { + return Err("Skill directory is required".to_string()); + } + + if directory.contains("..") + || directory.contains('\\') + || directory.starts_with('/') + || directory.starts_with("./") + || directory.ends_with("/.") + || directory.contains("/./") + || directory.split('/').any(str::is_empty) + { + return Err("Invalid skill directory".to_string()); + } + + let mut has_component = false; + for component in Path::new(directory).components() { + match component { + Component::Normal(_) => has_component = true, + _ => return Err("Invalid skill directory".to_string()), + } + } + + if has_component { + Ok(()) + } else { + Err("Skill directory is required".to_string()) + } +} + fn try_resolve_local_skill_dir( skills_dir: &Path, directory: &str, @@ -493,7 +537,7 @@ pub struct ImportedSkillResult { /// 获取已安装的 Lime Skills 目录列表 /// -/// 扫描 Lime Skills 目录,返回包含 SKILL.md 的子目录名列表。 +/// 扫描 Lime 可发现的 provider Skills 根目录,返回包含 SKILL.md 的子目录名列表。 /// 这些 Skills 将被传递给 aster 用于 AI Agent 功能。 /// /// # Returns @@ -501,8 +545,8 @@ pub struct ImportedSkillResult { /// - `Err(String)`: 错误信息 #[tauri::command] pub async fn get_installed_lime_skills() -> Result, String> { - let skills_dir = get_skills_dir(&AppType::Lime)?; - Ok(scan_installed_skills(&skills_dir)) + let skill_roots = get_skill_lookup_roots(&AppType::Lime)?; + Ok(scan_installed_skills_from_roots(&skill_roots)) } /// 获取本地已安装 Skill 的标准检查结果 @@ -579,7 +623,7 @@ pub async fn inspect_remote_skill( branch: String, directory: String, ) -> Result { - validate_skill_directory(&directory)?; + validate_remote_skill_directory(&directory)?; skill_service .0 .inspect_remote_skill(&owner, &name, &branch, &directory) @@ -953,6 +997,30 @@ mod tests { assert!(discovered.contains(&"valid-skill".to_string())); } + #[test] + fn test_scan_installed_skills_from_roots_deduplicates_provider_roots() { + let temp_dir = TempDir::new().unwrap(); + let agents_root = temp_dir.path().join(".agents").join("skills"); + let claude_root = temp_dir.path().join(".claude").join("skills"); + + for skill_dir in [ + agents_root.join("shared-skill"), + claude_root.join("shared-skill"), + claude_root.join("claude-only"), + ] { + std::fs::create_dir_all(&skill_dir).unwrap(); + std::fs::write(skill_dir.join("SKILL.md"), "# Test Skill").unwrap(); + } + + let discovered = + scan_installed_skills_from_roots(&[agents_root.clone(), claude_root.clone()]); + let discovered_set: HashSet<_> = discovered.into_iter().collect(); + + assert_eq!(discovered_set.len(), 2); + assert!(discovered_set.contains("shared-skill")); + assert!(discovered_set.contains("claude-only")); + } + #[test] fn test_inspect_local_skill_success() { let temp_dir = TempDir::new().unwrap(); @@ -995,6 +1063,22 @@ content"#, assert!(err.contains("Invalid skill directory")); } + #[test] + fn test_validate_remote_skill_directory_accepts_anthropic_nested_path() { + assert!(validate_remote_skill_directory("skills/docx").is_ok()); + assert!(validate_remote_skill_directory("docx").is_ok()); + } + + #[test] + fn test_validate_remote_skill_directory_rejects_traversal() { + assert!(validate_remote_skill_directory("../docx").is_err()); + assert!(validate_remote_skill_directory("skills/../docx").is_err()); + assert!(validate_remote_skill_directory("skills\\docx").is_err()); + assert!(validate_remote_skill_directory("/skills/docx").is_err()); + assert!(validate_remote_skill_directory("skills//docx").is_err()); + assert!(validate_remote_skill_directory("skills/./docx").is_err()); + } + #[test] fn test_inspect_local_skill_missing_skill_md() { let temp_dir = TempDir::new().unwrap(); diff --git a/src-tauri/src/dev_bridge/dispatcher.rs b/src-tauri/src/dev_bridge/dispatcher.rs index 9fde55c69..80a93d15b 100644 --- a/src-tauri/src/dev_bridge/dispatcher.rs +++ b/src-tauri/src/dev_bridge/dispatcher.rs @@ -18,7 +18,6 @@ mod media_tasks; mod memory; mod memory_runtime; mod models; -mod plugins; mod project_resources; mod providers; mod runtime_queries; @@ -158,10 +157,6 @@ pub async fn handle_command( return Ok(result); } - if let Some(result) = plugins::try_handle(state, cmd, args.as_ref()).await? { - return Ok(result); - } - if let Some(result) = agent_sessions::try_handle(state, cmd, args.as_ref()).await? { return Ok(result); } diff --git a/src-tauri/src/dev_bridge/dispatcher/plugins.rs b/src-tauri/src/dev_bridge/dispatcher/plugins.rs deleted file mode 100644 index 0d98b9d35..000000000 --- a/src-tauri/src/dev_bridge/dispatcher/plugins.rs +++ /dev/null @@ -1,37 +0,0 @@ -use super::require_app_handle; -use crate::dev_bridge::DevBridgeState; -use serde_json::Value as JsonValue; -use tauri::Manager; - -type DynError = Box; - -pub(super) async fn try_handle( - state: &DevBridgeState, - cmd: &str, - _args: Option<&JsonValue>, -) -> Result, DynError> { - if cmd != "get_plugins_with_ui" { - return Ok(None); - } - - let app_handle = require_app_handle(state)?; - let result = match cmd { - "get_plugins_with_ui" => { - let installer_state = - app_handle.state::(); - let plugin_manager_state = - app_handle.state::(); - - serde_json::to_value( - crate::commands::plugin_cmd::get_plugins_with_ui( - installer_state, - plugin_manager_state, - ) - .await?, - )? - } - _ => unreachable!("已通过前置判断过滤插件命令"), - }; - - Ok(Some(result)) -} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 11a7e6902..1cdba397a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -41,7 +41,6 @@ pub use lime_mcp as mcp; pub mod agent; pub mod agent_tools; pub mod app; -pub mod plugin; pub mod sceneapp; pub mod screenshot; pub mod services; diff --git a/src-tauri/src/plugin/README.md b/src-tauri/src/plugin/README.md deleted file mode 100644 index 5bc4f7a45..000000000 --- a/src-tauri/src/plugin/README.md +++ /dev/null @@ -1,66 +0,0 @@ -# plugin - - - -## 架构说明 - -插件系统模块,提供插件扩展功能: -- 插件加载和初始化 -- 请求前/响应后钩子 -- 插件隔离和错误处理 -- 插件配置管理 -- 二进制组件下载和管理 -- 声明式插件 UI 系统(基于 A2UI 设计理念) - -## 文件索引 - -- `mod.rs` - 模块入口和导出 -- `types.rs` - 核心类型定义(Plugin trait、PluginContext 等) -- `loader.rs` - 插件加载器 -- `manager.rs` - 插件管理器(生命周期、钩子执行) -- `binary_downloader.rs` - 二进制组件下载管理 -- `ui_types.rs` - 插件 UI 类型定义(组件、消息、数据绑定) -- `ui_trait.rs` - 插件 UI Trait 定义 -- `ui_builder.rs` - UI 构建器辅助 API -- `ui_events.rs` - UI 事件推送(Tauri 事件) -- `examples/` - 示例插件占位;旧凭证池状态示例已随凭证池退役删除 -- `tests.rs` - 单元测试 - -## 插件 UI 系统 - -基于 A2UI 设计理念的声明式 UI 系统: - -- **安全如数据**:插件只能声明 UI 结构,不能执行任意代码 -- **组件白名单**:预定义可用组件集(Row, Column, Card, Text, Button 等) -- **数据绑定分离**:UI 结构与数据模型分离,支持增量更新 - -### 使用示例 - -```rust -use crate::plugin::{ - SurfaceDefinition, ComponentDef, ChildrenDef, - BoundValue, Action, ComponentType, ColumnProps, -}; -use serde_json::json; - -// 创建 Surface 定义 -let surface = SurfaceDefinition { - surface_id: "my-plugin-ui".into(), - root_id: "root".into(), - initial_components: vec![ - ComponentDef::column("root", ChildrenDef::explicit(vec!["title", "content"])), - ComponentDef::text_literal("title", "插件标题"), - ComponentDef::text_bound("content", "/data/message"), - ], - initial_data: json!({ - "data": { - "message": "Hello from plugin!" - } - }), - styles: None, -}; -``` - -## 更新提醒 - -任何文件变更后,请更新此文档和相关的上级文档。 diff --git a/src-tauri/src/plugin/mod.rs b/src-tauri/src/plugin/mod.rs deleted file mode 100644 index 7d9b8e39e..000000000 --- a/src-tauri/src/plugin/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! 插件系统模块 -//! -//! 核心逻辑从 lime-core 重新导出, -//! ui_events 依赖 Tauri 保留在主 crate。 - -// 核心插件能力从 core crate 导出 -pub use lime_core::plugin::*; - -// Tauri 依赖的 UI 事件模块保留在主 crate -pub mod ui_events; -pub use ui_events::{PluginUIEmitter, PluginUIEmitterState, PluginUIEventPayload}; diff --git a/src-tauri/src/plugin/ui_events.rs b/src-tauri/src/plugin/ui_events.rs deleted file mode 100644 index c06480a70..000000000 --- a/src-tauri/src/plugin/ui_events.rs +++ /dev/null @@ -1,104 +0,0 @@ -//! 插件 UI 事件系统 -//! -//! 提供从 Rust 向前端推送 UI 更新的能力 - -use serde::Serialize; -use tauri::{AppHandle, Emitter}; - -use super::ui_types::UIMessage; - -/// 插件 UI 事件载荷 -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct PluginUIEventPayload { - /// 插件 ID - pub plugin_id: String, - /// UI 消息 - pub message: UIMessage, -} - -/// 插件 UI 事件发射器 -pub struct PluginUIEmitter { - app_handle: AppHandle, -} - -impl PluginUIEmitter { - /// 创建新的事件发射器 - pub fn new(app_handle: AppHandle) -> Self { - Self { app_handle } - } - - /// 发送单个 UI 消息 - pub fn emit(&self, plugin_id: &str, message: UIMessage) -> Result<(), String> { - let payload = PluginUIEventPayload { - plugin_id: plugin_id.to_string(), - message, - }; - - self.app_handle - .emit("plugin-ui-message", payload) - .map_err(|e| e.to_string()) - } - - /// 发送多个 UI 消息 - pub fn emit_all(&self, plugin_id: &str, messages: Vec) -> Result<(), String> { - for message in messages { - self.emit(plugin_id, message)?; - } - Ok(()) - } - - /// 发送 Surface 更新 - pub fn emit_surface_update( - &self, - plugin_id: &str, - update: super::ui_types::SurfaceUpdate, - ) -> Result<(), String> { - self.emit(plugin_id, UIMessage::SurfaceUpdate(update)) - } - - /// 发送数据模型更新 - pub fn emit_data_update( - &self, - plugin_id: &str, - update: super::ui_types::DataModelUpdate, - ) -> Result<(), String> { - self.emit(plugin_id, UIMessage::DataModelUpdate(update)) - } - - /// 发送删除 Surface - pub fn emit_delete_surface(&self, plugin_id: &str, surface_id: &str) -> Result<(), String> { - self.emit( - plugin_id, - UIMessage::DeleteSurface(super::ui_types::DeleteSurface { - surface_id: surface_id.to_string(), - }), - ) - } -} - -/// 全局事件发射器状态 -pub struct PluginUIEmitterState(pub Option); - -impl PluginUIEmitterState { - /// 创建空状态 - pub fn new() -> Self { - Self(None) - } - - /// 初始化发射器 - pub fn init(&mut self, app_handle: AppHandle) { - self.0 = Some(PluginUIEmitter::new(app_handle)); - } - - /// 获取发射器 - pub fn get(&self) -> Option<&PluginUIEmitter> { - self.0.as_ref() - } -} - -impl Default for PluginUIEmitterState { - fn default() -> Self { - Self::new() - } -} diff --git a/src-tauri/src/services/agent_timeline_service.rs b/src-tauri/src/services/agent_timeline_service.rs index cbcaec76f..01c684f8a 100644 --- a/src-tauri/src/services/agent_timeline_service.rs +++ b/src-tauri/src/services/agent_timeline_service.rs @@ -620,6 +620,7 @@ mod tests { updated_at: "2026-03-29T00:00:00.000Z".to_string(), payload: AgentThreadItemPayload::TurnSummary { text: "running".to_string(), + metadata: None, }, }, ) diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 4a7a9f3d7..0c517414a 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -30,6 +30,7 @@ pub mod memory_import_parser_service; pub mod memory_profile_prompt_service; pub mod memory_rules_loader_service; pub mod memory_source_resolver_service; +pub mod runtime_agent_profile_projection_service; pub mod runtime_agents_template_service; pub mod runtime_analysis_handoff_service; pub mod runtime_auxiliary_projection_service; diff --git a/src-tauri/src/services/runtime_agent_profile_projection_service.rs b/src-tauri/src/services/runtime_agent_profile_projection_service.rs new file mode 100644 index 000000000..b7484bbbc --- /dev/null +++ b/src-tauri/src/services/runtime_agent_profile_projection_service.rs @@ -0,0 +1,474 @@ +//! AgentRuntime Profile 投影服务 +//! +//! 将 current `SessionDetail + AgentRuntimeThreadReadModel` 投影为 +//! evidence/replay/review 可复用的 AgentRuntime Profile JSON。 + +use crate::agent::SessionDetail; +use crate::commands::aster_agent_cmd::{ + attempt_id_from_turn_id, run_id_from_turn_id, task_id_from_thread_id, AgentRuntimeProfileEvent, + AgentRuntimeProfileStream, AgentRuntimeThreadReadModel, LIME_AGENT_RUNTIME_ID, + LIME_AGENT_RUNTIME_PROFILE_SCHEMA_VERSION, +}; +use serde_json::{json, Value}; + +pub(crate) fn build_agent_runtime_profile_spine_json( + detail: &SessionDetail, + thread_read: &AgentRuntimeThreadReadModel, +) -> Value { + json!({ + "schemaVersion": LIME_AGENT_RUNTIME_PROFILE_SCHEMA_VERSION, + "runtimeId": LIME_AGENT_RUNTIME_ID, + "sessionId": detail.id, + "threadId": detail.thread_id, + "profileStatus": thread_read.profile_status, + "activeTurnId": thread_read.active_turn_id, + "turns": thread_read.turns, + "events": build_agent_runtime_profile_events_json(detail, thread_read), + "actions": build_agent_runtime_profile_actions_json(thread_read), + "toolCalls": thread_read.tool_calls, + "modelRouting": thread_read.model_routing, + "contextSummary": thread_read.context_summary, + "evidenceSummary": thread_read.evidence_summary, + "telemetrySummary": thread_read.telemetry_summary, + "correlationRefs": { + "turnIds": thread_read.turns.iter().map(|turn| turn.turn_id.clone()).collect::>(), + "toolCallIds": thread_read.tool_calls.iter().map(|tool| tool.tool_call_id.clone()).collect::>(), + "pendingRequestIds": thread_read.pending_requests.iter().map(|item| item.id.clone()).collect::>(), + "queuedTurnIds": thread_read.queued_turns.iter().map(|item| item.queued_turn_id.clone()).collect::>(), + "traceIds": thread_read.telemetry_summary.trace_ids, + "evidenceRefs": thread_read.evidence_summary.evidence_refs + }, + "source": "thread_read_model" + }) +} + +fn build_agent_runtime_profile_events_json( + detail: &SessionDetail, + thread_read: &AgentRuntimeThreadReadModel, +) -> Vec { + let mut events = build_agent_runtime_profile_policy_events_json(detail, thread_read); + events.extend(build_agent_runtime_profile_tool_events_json( + detail, + thread_read, + )); + events.extend(build_agent_runtime_profile_routing_events_json( + detail, + thread_read, + )); + events.extend(build_agent_runtime_profile_task_events_json( + detail, + thread_read, + )); + events +} + +fn build_agent_runtime_profile_policy_events_json( + detail: &SessionDetail, + thread_read: &AgentRuntimeThreadReadModel, +) -> Vec { + thread_read + .pending_requests + .iter() + .flat_map(|request| { + let turn_id = request + .turn_id + .as_deref() + .or(thread_read.active_turn_id.as_deref()) + .unwrap_or("turn_unavailable"); + let Ok(stream) = AgentRuntimeProfileStream::new( + detail.id.as_str(), + detail.thread_id.as_str(), + turn_id, + ) else { + return Vec::new(); + }; + let scope = request.scope.clone().unwrap_or_else(|| { + json!({ + "threadId": request.thread_id, + "turnId": request.turn_id, + "itemId": request.item_id, + }) + }); + let decision_kind = pending_request_decision_kind(request.request_type.as_str()); + let policy_decision_id = format!("policy_decision_{}", request.id); + let permission = stream.permission_evaluated( + policy_decision_id.as_str(), + decision_kind, + scope.clone(), + ); + let action = stream.action_required( + request.id.as_str(), + None, + request.request_type.as_str(), + decision_kind, + scope, + ); + vec![ + serialize_profile_event_value(permission), + serialize_profile_event_value(action), + ] + }) + .collect() +} + +fn build_agent_runtime_profile_tool_events_json( + detail: &SessionDetail, + thread_read: &AgentRuntimeThreadReadModel, +) -> Vec { + thread_read + .tool_calls + .iter() + .flat_map(|tool| { + let Ok(stream) = AgentRuntimeProfileStream::new( + detail.id.as_str(), + detail.thread_id.as_str(), + tool.turn_id.as_str(), + ) else { + return Vec::new(); + }; + let started = stream.tool_started(tool.tool_call_id.as_str(), tool.tool_name.as_str()); + let terminal = if tool.status == "failed" || tool.success == Some(false) { + stream.tool_failed( + tool.tool_call_id.as_str(), + tool.tool_name.as_str(), + "tool_error", + tool.error.as_deref().unwrap_or("tool failed"), + ) + } else { + stream.tool_result( + tool.tool_call_id.as_str(), + tool.tool_name.as_str(), + tool.success.unwrap_or(true), + ) + }; + + vec![ + serialize_profile_event_value(started), + serialize_profile_event_value(terminal), + ] + }) + .collect() +} + +fn build_agent_runtime_profile_routing_events_json( + detail: &SessionDetail, + thread_read: &AgentRuntimeThreadReadModel, +) -> Vec { + let Some(model_routing) = thread_read.model_routing.as_ref() else { + return Vec::new(); + }; + let turn_id = thread_read + .active_turn_id + .as_deref() + .or_else(|| thread_read.turns.last().map(|turn| turn.turn_id.as_str())) + .unwrap_or("turn_unavailable"); + let Ok(stream) = + AgentRuntimeProfileStream::new(detail.id.as_str(), detail.thread_id.as_str(), turn_id) + else { + return Vec::new(); + }; + let task_kind = json_string_field(model_routing, "taskKind"); + let service_model_slot = json_string_field(model_routing, "serviceModelSlot"); + let routing_mode = json_string_field(model_routing, "routingMode"); + let candidate_count = json_u32_field(model_routing, "candidateCount"); + let selected_model = json_string_field(model_routing, "selectedModel"); + let decision_source = json_string_field(model_routing, "decisionSource"); + let estimated_cost_class = json_string_field(model_routing, "estimatedCostClass"); + let limit_status = thread_read + .limit_state + .as_ref() + .map(|state| state.status.as_str()); + let single_candidate_only = json_bool_field(model_routing, "singleCandidateOnly"); + + let mut events = vec![serialize_profile_event_value(stream.task_profile_resolved( + task_kind.as_deref(), + service_model_slot.as_deref(), + routing_mode.as_deref(), + ))]; + if routing_mode.as_deref() == Some("single_candidate") + || candidate_count == Some(1) + || single_candidate_only == Some(true) + { + events.push(serialize_profile_event_value( + stream.routing_single_candidate( + task_kind.as_deref(), + candidate_count.unwrap_or(1), + selected_model.as_deref(), + decision_source.as_deref(), + ), + )); + } + if estimated_cost_class.is_some() { + events.push(serialize_profile_event_value( + stream.cost_estimated(estimated_cost_class.as_deref()), + )); + } + if limit_status.is_some() || single_candidate_only.is_some() { + events.push(serialize_profile_event_value( + stream.limit_changed(limit_status, single_candidate_only), + )); + } + events +} + +fn build_agent_runtime_profile_task_events_json( + detail: &SessionDetail, + thread_read: &AgentRuntimeThreadReadModel, +) -> Vec { + if thread_read.turns.is_empty() && thread_read.queued_turns.is_empty() { + return Vec::new(); + } + + let task_id = task_id_from_thread_id(&detail.thread_id); + let task_kind = thread_read + .task_kind + .as_deref() + .or(Some("conversation_turn")); + let first_turn_id = thread_read.turns.first().map(|turn| turn.turn_id.as_str()); + let stream_turn_id = thread_read + .active_turn_id + .as_deref() + .or_else(|| thread_read.turns.last().map(|turn| turn.turn_id.as_str())) + .or(first_turn_id) + .unwrap_or("turn_unavailable"); + let Ok(stream) = AgentRuntimeProfileStream::new( + detail.id.as_str(), + detail.thread_id.as_str(), + stream_turn_id, + ) else { + return Vec::new(); + }; + + let mut events = Vec::new(); + events.push(serialize_profile_event_value(stream.task_created( + task_id.as_str(), + task_kind, + Some("thread_read_model"), + ))); + + let mut latest_failed_attempt_id = None; + let mut latest_failure_message = None; + let mut latest_failure_category = None; + + for (index, turn) in thread_read.turns.iter().enumerate() { + let attempt_index = index + 1; + let run_id = run_id_from_turn_id(&turn.turn_id); + let attempt_id = attempt_id_from_turn_id(&turn.turn_id); + events.push(serialize_profile_event_value(stream.task_attempt_started( + task_id.as_str(), + run_id.as_str(), + attempt_id.as_str(), + attempt_index, + ))); + + match turn.status.as_str() { + "completed" => { + events.push(serialize_profile_event_value(stream.task_completed( + task_id.as_str(), + run_id.as_str(), + attempt_id.as_str(), + attempt_index, + ))); + } + "failed" | "cancelled" => { + let failure_message = + task_failure_message_for_turn(detail, thread_read, &turn.turn_id); + let failure_category = task_failure_category_for_status( + turn.status.as_str(), + failure_message.as_deref(), + ); + let retryable = task_attempt_retryable(turn.status.as_str(), thread_read); + events.push(serialize_profile_event_value(stream.task_attempt_failed( + task_id.as_str(), + run_id.as_str(), + attempt_id.as_str(), + attempt_index, + failure_category, + failure_message.as_deref(), + retryable, + ))); + latest_failed_attempt_id = Some(attempt_id); + latest_failure_message = failure_message; + latest_failure_category = Some(failure_category.to_string()); + } + _ => {} + } + } + + if !thread_read.queued_turns.is_empty() && latest_failed_attempt_id.is_some() { + let failed_attempt_id = latest_failed_attempt_id.as_deref(); + let retry_reason = latest_failure_message + .as_deref() + .or_else(|| { + thread_read + .diagnostics + .as_ref() + .and_then(|value| value.primary_blocking_summary.as_deref()) + }) + .or_else(|| latest_failure_category.as_deref()); + let next_attempt_index = thread_read.turns.len() + 1; + events.extend(thread_read.queued_turns.iter().map(|queued_turn| { + serialize_profile_event_value(stream.task_retrying( + task_id.as_str(), + failed_attempt_id, + queued_turn.queued_turn_id.as_str(), + next_attempt_index + queued_turn.position.saturating_sub(1), + retry_reason, + )) + })); + } else if let Some(last_turn) = thread_read.turns.last() { + if matches!(last_turn.status.as_str(), "failed" | "cancelled") { + let run_id = run_id_from_turn_id(&last_turn.turn_id); + let attempt_id = latest_failed_attempt_id + .clone() + .unwrap_or_else(|| attempt_id_from_turn_id(&last_turn.turn_id)); + let attempt_index = thread_read.turns.len(); + let failure_message = + task_failure_message_for_turn(detail, thread_read, &last_turn.turn_id); + let failure_category = latest_failure_category.unwrap_or_else(|| { + task_failure_category_for_status( + last_turn.status.as_str(), + failure_message.as_deref(), + ) + .to_string() + }); + events.push(serialize_profile_event_value(stream.task_failed( + task_id.as_str(), + run_id.as_str(), + attempt_id.as_str(), + attempt_index, + failure_category.as_str(), + failure_message.as_deref(), + false, + ))); + } + } + + events +} + +fn task_failure_message_for_turn( + detail: &SessionDetail, + thread_read: &AgentRuntimeThreadReadModel, + turn_id: &str, +) -> Option { + detail + .turns + .iter() + .rev() + .find(|turn| turn.id == turn_id) + .and_then(|turn| turn.error_message.clone()) + .or_else(|| { + thread_read + .last_outcome + .as_ref() + .filter(|outcome| outcome.turn_id.as_deref() == Some(turn_id)) + .and_then(|outcome| { + outcome + .primary_cause + .clone() + .or_else(|| outcome.summary.clone()) + }) + }) + .or_else(|| { + thread_read + .diagnostics + .as_ref() + .and_then(|diagnostics| diagnostics.primary_blocking_summary.clone()) + }) +} + +fn task_failure_category_for_status(status: &str, message: Option<&str>) -> &'static str { + if status == "cancelled" { + return "cancelled"; + } + + let normalized = message.map(str::to_ascii_lowercase).unwrap_or_default(); + if normalized.contains("rate limit") + || normalized.contains("quota") + || normalized.contains("provider") + || normalized.contains("network") + || normalized.contains("api") + { + "provider_error" + } else if normalized.contains("permission") || normalized.contains("权限") { + "permission" + } else if normalized.contains("tool") || normalized.contains("工具") { + "tool_error" + } else { + "runtime_error" + } +} + +fn task_attempt_retryable(status: &str, thread_read: &AgentRuntimeThreadReadModel) -> bool { + if !thread_read.queued_turns.is_empty() { + return true; + } + + thread_read + .last_outcome + .as_ref() + .map(|outcome| outcome.retryable) + .unwrap_or(status != "cancelled") +} + +fn json_string_field(value: &Value, key: &str) -> Option { + value + .get(key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +fn json_u32_field(value: &Value, key: &str) -> Option { + value + .get(key) + .and_then(Value::as_u64) + .and_then(|value| u32::try_from(value).ok()) +} + +fn json_bool_field(value: &Value, key: &str) -> Option { + value.get(key).and_then(Value::as_bool) +} + +fn serialize_profile_event_value(event: AgentRuntimeProfileEvent) -> Value { + serde_json::to_value(event).unwrap_or(Value::Null) +} + +fn build_agent_runtime_profile_actions_json( + thread_read: &AgentRuntimeThreadReadModel, +) -> Vec { + thread_read + .pending_requests + .iter() + .map(|request| { + json!({ + "actionId": request.id, + "requestType": request.request_type, + "status": request.status, + "turnId": request.turn_id, + "itemId": request.item_id, + "title": request.title, + "decision": request.decision, + "scope": request.scope, + "policyRefs": { + "owner": "AgentPolicy", + "decisionKind": pending_request_decision_kind(request.request_type.as_str()), + "approvalRequestId": request.id, + "policyDecisionId": format!("policy_decision_{}", request.id), + }, + "createdAt": request.created_at, + "resolvedAt": request.resolved_at, + }) + }) + .collect() +} + +fn pending_request_decision_kind(request_type: &str) -> &'static str { + match request_type { + "tool_confirmation" | "permission_confirmation" | "approval" => "ask", + "ask_user" | "elicitation" => "ask", + _ => "ask", + } +} diff --git a/src-tauri/src/services/runtime_analysis_handoff_service.rs b/src-tauri/src/services/runtime_analysis_handoff_service.rs index dfe79bf69..fe70543d4 100644 --- a/src-tauri/src/services/runtime_analysis_handoff_service.rs +++ b/src-tauri/src/services/runtime_analysis_handoff_service.rs @@ -164,6 +164,7 @@ struct AnalysisEvidenceSection { #[serde(rename_all = "camelCase")] struct AnalysisObservabilitySection { summary: Value, + agent_runtime_profile: Value, correlation_keys: Vec, gap_signals: Vec, verification_failure_outcomes: Vec, @@ -244,6 +245,10 @@ pub fn export_runtime_analysis_handoff( .pointer("/observabilitySummary") .cloned() .unwrap_or(Value::Null); + let agent_runtime_profile = runtime_payload + .pointer("/agentRuntimeProfile") + .cloned() + .unwrap_or(Value::Null); let observability_correlation_keys = collect_observability_correlation_keys(&observability_summary); let observability_gap_signals = collect_observability_gap_signals(&observability_summary); @@ -568,6 +573,7 @@ pub fn export_runtime_analysis_handoff( }, observability: AnalysisObservabilitySection { summary: sanitize_value(observability_summary, workspace_root.as_path()), + agent_runtime_profile: sanitize_value(agent_runtime_profile, workspace_root.as_path()), correlation_keys: observability_correlation_keys.clone(), gap_signals: observability_gap_signals.clone(), verification_failure_outcomes: observability_verification_failure_outcomes.clone(), @@ -583,6 +589,7 @@ pub fn export_runtime_analysis_handoff( &exported_at, &summary, &analysis_context.observability.summary, + &analysis_context.observability.agent_runtime_profile, &replay_refs, &handoff_refs, &evidence_refs, @@ -661,6 +668,7 @@ fn build_analysis_brief( exported_at: &str, summary: &AnalysisContextSummary, observability_summary: &Value, + agent_runtime_profile: &Value, replay_refs: &[AnalysisArtifactReference], handoff_refs: &[AnalysisArtifactReference], evidence_refs: &[AnalysisArtifactReference], @@ -739,6 +747,10 @@ fn build_analysis_brief( "- 关联键:{}", join_or_fallback(observability_correlation_keys, "无") ), + format!( + "- AgentRuntime Profile:{}", + format_agent_runtime_profile_summary(agent_runtime_profile) + ), format!( "- 当前缺口:{}", join_or_fallback(observability_gap_signals, "无") @@ -829,6 +841,37 @@ fn build_analysis_brief( format!("{}\n", lines.join("\n")) } +fn format_agent_runtime_profile_summary(profile: &Value) -> String { + let schema_version = + value_string(profile.pointer("/schemaVersion").unwrap_or(&Value::Null)).unwrap_or_default(); + let runtime_id = + value_string(profile.pointer("/runtimeId").unwrap_or(&Value::Null)).unwrap_or_default(); + let profile_status = + value_string(profile.pointer("/profileStatus").unwrap_or(&Value::Null)).unwrap_or_default(); + let active_turn_id = + value_string(profile.pointer("/activeTurnId").unwrap_or(&Value::Null)).unwrap_or_default(); + + let mut parts = Vec::new(); + if !schema_version.is_empty() { + parts.push(format!("schema={schema_version}")); + } + if !runtime_id.is_empty() { + parts.push(format!("runtime={runtime_id}")); + } + if !profile_status.is_empty() { + parts.push(format!("status={profile_status}")); + } + if !active_turn_id.is_empty() { + parts.push(format!("activeTurn={active_turn_id}")); + } + + if parts.is_empty() { + "未导出".to_string() + } else { + parts.join(" · ") + } +} + fn build_copy_prompt( title: &str, summary: &AnalysisContextSummary, @@ -1498,6 +1541,7 @@ mod tests { payload: AgentThreadItemPayload::TurnSummary { text: "已完成 handoff / evidence / replay,下一步把问题交给外部 AI 诊断并修复。" .to_string(), + metadata: None, }, }, ], @@ -1515,7 +1559,9 @@ mod tests { AgentRuntimeThreadReadModel { thread_id: "thread-1".to_string(), status: "waiting_request".to_string(), + profile_status: "blocked".to_string(), active_turn_id: Some("turn-1".to_string()), + turns: Vec::new(), pending_requests: vec![AgentRuntimeRequestView { id: "req-1".to_string(), thread_id: "thread-1".to_string(), @@ -1540,6 +1586,11 @@ mod tests { image_count: 0, position: 1, }], + tool_calls: Vec::new(), + model_routing: None, + evidence_summary: Default::default(), + telemetry_summary: Default::default(), + context_summary: None, interrupt_state: None, updated_at: Some("2026-03-27T10:01:20Z".to_string()), latest_compaction_boundary: None, @@ -1814,6 +1865,9 @@ mod tests { assert!(brief.contains("pending request:1")); assert!(brief.contains("证据关联与可观测覆盖")); assert!(brief.contains("requestTelemetry")); + assert!(brief.contains("AgentRuntime Profile")); + assert!(brief.contains("schema=lime-profile-0.4.0")); + assert!(brief.contains("runtime=lime_runtime_local")); assert!(brief.contains("结构化验证摘要")); assert!(brief.contains("当前没有结构化验证摘要")); assert!(brief.contains("验证失败焦点:无")); @@ -1826,6 +1880,9 @@ mod tests { assert!(context.contains("\"contractShape\": \"lime_external_analysis_handoff\"")); assert!(context.contains("\"pendingRequestCount\": 1")); assert!(context.contains("\"observability\"")); + assert!(context.contains("\"agentRuntimeProfile\"")); + assert!(context.contains("\"schemaVersion\": \"lime-profile-0.4.0\"")); + assert!(context.contains("\"runtimeId\": \"lime_runtime_local\"")); assert!(context.contains("\"correlationKeys\"")); assert!(context.contains("\"gapSignals\"")); assert!(context.contains("\"verificationFailureOutcomes\": []")); diff --git a/src-tauri/src/services/runtime_evidence_pack_service.rs b/src-tauri/src/services/runtime_evidence_pack_service.rs index e0a1622a9..6d3c2653b 100644 --- a/src-tauri/src/services/runtime_evidence_pack_service.rs +++ b/src-tauri/src/services/runtime_evidence_pack_service.rs @@ -4,7 +4,9 @@ //! 导出为最小可复盘的问题证据包。 use crate::agent::SessionDetail; -use crate::commands::aster_agent_cmd::AgentRuntimeThreadReadModel; +use crate::commands::aster_agent_cmd::{ + AgentRuntimeThreadReadModel, LIME_AGENT_RUNTIME_ID, LIME_AGENT_RUNTIME_PROFILE_SCHEMA_VERSION, +}; use crate::commands::modality_runtime_contracts::{ AUDIO_TRANSCRIPTION_CONTRACT_KEY, AUDIO_TRANSCRIPTION_LIMECORE_POLICY_REFS, AUDIO_TRANSCRIPTION_ROUTING_SLOT, BROWSER_CONTROL_CONTRACT_KEY, @@ -25,6 +27,7 @@ use crate::commands::modality_runtime_contracts::{ }; use crate::database::DbConnection; use crate::services::artifact_document_validator::ARTIFACT_DOCUMENT_SCHEMA_VERSION; +use crate::services::runtime_agent_profile_projection_service::build_agent_runtime_profile_spine_json; use crate::services::runtime_file_checkpoint_service::list_file_checkpoints; use crate::services::workspace_health_service::ensure_workspace_ready_with_auto_relocate; use crate::workspace::WorkspaceManager; @@ -698,6 +701,7 @@ fn build_runtime_json( known_gaps: &[String], exported_at: &str, ) -> Result { + let profile_spine = build_agent_runtime_profile_spine_json(detail, thread_read); let payload = json!({ "schemaVersion": "v1", "source": { @@ -705,6 +709,7 @@ fn build_runtime_json( "runtimeSubstrate": "aster_session_thread_runtime", "productSurface": "lime_workspace_evidence_pack" }, + "agentRuntimeProfile": profile_spine, "session": { "sessionId": detail.id, "threadId": detail.thread_id, @@ -2179,7 +2184,7 @@ fn collect_latest_turn_summary(detail: &SessionDetail) -> Option { .iter() .rev() .find_map(|item| match &item.payload { - AgentThreadItemPayload::TurnSummary { text } => { + AgentThreadItemPayload::TurnSummary { text, .. } => { normalize_optional_text(Some(text.clone())) } _ => None, @@ -2527,6 +2532,13 @@ fn request_log_matches_session( fn build_thread_runtime_facts_json(thread_read: &AgentRuntimeThreadReadModel) -> Value { json!({ + "profileStatus": thread_read.profile_status, + "turns": thread_read.turns, + "toolCalls": thread_read.tool_calls, + "modelRouting": thread_read.model_routing, + "contextSummary": thread_read.context_summary, + "evidenceSummary": thread_read.evidence_summary, + "telemetrySummary": thread_read.telemetry_summary, "taskKind": thread_read.task_kind, "serviceModelSlot": thread_read.service_model_slot, "routingMode": thread_read.routing_mode, @@ -2750,7 +2762,7 @@ fn summarize_item_payload(payload: &AgentThreadItemPayload) -> (&'static str, Op AgentThreadItemPayload::Plan { text } => { ("plan", normalize_optional_text(Some(truncate_text(text)))) } - AgentThreadItemPayload::TurnSummary { text } => ( + AgentThreadItemPayload::TurnSummary { text, .. } => ( "turn_summary", normalize_optional_text(Some(truncate_text(text))), ), @@ -2792,16 +2804,27 @@ fn build_runtime_observability_summary_json( "schemaVersion": "v1", "correlation": { "correlationKeys": [ + "runtime_id", + "profile_schema_version", "session_id", "thread_id", "turn_id", + "tool_call_id", + "trace_id", + "evidence_ref", "pending_request_id", "queued_turn_id", "subagent_session_id" ], + "runtimeId": LIME_AGENT_RUNTIME_ID, + "profileSchemaVersion": LIME_AGENT_RUNTIME_PROFILE_SCHEMA_VERSION, "sessionId": detail.id, "threadId": detail.thread_id, "activeTurnId": thread_read.active_turn_id, + "turnIds": thread_read.turns.iter().map(|turn| turn.turn_id.clone()).collect::>(), + "toolCallIds": thread_read.tool_calls.iter().map(|tool| tool.tool_call_id.clone()).collect::>(), + "traceIds": thread_read.telemetry_summary.trace_ids, + "evidenceRefs": thread_read.evidence_summary.evidence_refs, "pendingRequestIds": thread_read.pending_requests.iter().map(|item| item.id.clone()).collect::>(), "queuedTurnIds": thread_read.queued_turns.iter().map(|item| item.queued_turn_id.clone()).collect::>(), "subagentSessionIds": detail.child_subagent_sessions.iter().map(|item| item.id.clone()).collect::>() @@ -6352,6 +6375,7 @@ mod tests { updated_at: "2026-03-27T10:00:30Z".to_string(), payload: AgentThreadItemPayload::TurnSummary { text: "已拿到 handoff 四件套,下一步补问题证据包。".to_string(), + metadata: None, }, }, ], @@ -6365,7 +6389,15 @@ mod tests { AgentRuntimeThreadReadModel { thread_id: "thread-1".to_string(), status: "running".to_string(), + profile_status: "running".to_string(), active_turn_id: Some("turn-1".to_string()), + turns: vec![ + crate::commands::aster_agent_cmd::AgentRuntimeThreadTurnProfileView { + turn_id: "turn-1".to_string(), + status: "running".to_string(), + native_status: "running".to_string(), + }, + ], pending_requests: vec![crate::commands::aster_agent_cmd::AgentRuntimeRequestView { id: "req-1".to_string(), thread_id: "thread-1".to_string(), @@ -6390,6 +6422,45 @@ mod tests { image_count: 0, position: 1, }], + tool_calls: vec![ + crate::commands::aster_agent_cmd::AgentRuntimeThreadToolCallView { + tool_call_id: "tool-1".to_string(), + turn_id: "turn-1".to_string(), + tool_name: "Read".to_string(), + status: "completed".to_string(), + success: Some(true), + error: None, + }, + ], + model_routing: Some(json!({ + "taskKind": "translation", + "serviceModelSlot": "translation", + "routingMode": "single_candidate", + "decisionSource": "service_model_setting", + "selectedModel": "gpt-5.4-mini", + "candidateCount": 1, + "estimatedCostClass": "low", + "singleCandidateOnly": true + })), + evidence_summary: crate::commands::aster_agent_cmd::AgentRuntimeThreadEvidenceSummary { + evidence_refs: vec!["evidence://session-1/runtime".to_string()], + verification_outcomes: Vec::new(), + }, + telemetry_summary: + crate::commands::aster_agent_cmd::AgentRuntimeThreadTelemetrySummary { + trace_ids: vec!["trace-turn-1".to_string()], + join_status: "matched".to_string(), + }, + context_summary: Some(json!({ + "owner": "AgentContext", + "source": "turn_context", + "retrieval_refs": [ + { + "source_id": "knowledge_pack:brief", + "kind": "knowledge_pack" + } + ] + })), interrupt_state: None, updated_at: Some("2026-03-27T10:01:00Z".to_string()), latest_compaction_boundary: None, @@ -8113,6 +8184,316 @@ mod tests { assert!(!artifacts.contains("\"verification\"")); } + #[test] + fn evidence_runtime_should_export_agent_runtime_profile_spine() { + let temp_dir = TempDir::new().expect("temp dir"); + let detail = build_detail(); + let thread_read = build_thread_read(); + + let result = + export_runtime_evidence_pack(&detail, &thread_read, temp_dir.path()).expect("export"); + + assert_eq!( + result + .observability_summary + .pointer("/correlation/runtimeId") + .and_then(Value::as_str), + Some(LIME_AGENT_RUNTIME_ID) + ); + assert_eq!( + result + .observability_summary + .pointer("/correlation/profileSchemaVersion") + .and_then(Value::as_str), + Some(LIME_AGENT_RUNTIME_PROFILE_SCHEMA_VERSION) + ); + assert_eq!( + result + .observability_summary + .pointer("/correlation/turnIds/0") + .and_then(Value::as_str), + Some("turn-1") + ); + assert_eq!( + result + .observability_summary + .pointer("/correlation/toolCallIds/0") + .and_then(Value::as_str), + Some("tool-1") + ); + assert_eq!( + result + .observability_summary + .pointer("/correlation/traceIds/0") + .and_then(Value::as_str), + Some("trace-turn-1") + ); + + let runtime_path = temp_dir + .path() + .join(".lime/harness/sessions/session-1/evidence/runtime.json"); + let runtime_raw = fs::read_to_string(runtime_path).expect("runtime"); + let runtime = serde_json::from_str::(&runtime_raw).expect("parse runtime json"); + + assert_eq!( + runtime + .pointer("/agentRuntimeProfile/schemaVersion") + .and_then(Value::as_str), + Some(LIME_AGENT_RUNTIME_PROFILE_SCHEMA_VERSION) + ); + assert_eq!( + runtime + .pointer("/agentRuntimeProfile/runtimeId") + .and_then(Value::as_str), + Some(LIME_AGENT_RUNTIME_ID) + ); + assert_eq!( + runtime + .pointer("/agentRuntimeProfile/correlationRefs/evidenceRefs/0") + .and_then(Value::as_str), + Some("evidence://session-1/runtime") + ); + assert_eq!( + runtime + .pointer("/agentRuntimeProfile/actions/0/actionId") + .and_then(Value::as_str), + Some("req-1") + ); + assert_eq!( + runtime + .pointer("/agentRuntimeProfile/events/0/type") + .and_then(Value::as_str), + Some("permission.evaluated") + ); + assert_eq!( + runtime + .pointer("/agentRuntimeProfile/events/0/payload/owner") + .and_then(Value::as_str), + Some("AgentPolicy") + ); + assert_eq!( + runtime + .pointer("/agentRuntimeProfile/events/1/type") + .and_then(Value::as_str), + Some("action.required") + ); + assert_eq!( + runtime + .pointer("/agentRuntimeProfile/events/1/payload/actionId") + .and_then(Value::as_str), + Some("req-1") + ); + assert_eq!( + runtime + .pointer("/agentRuntimeProfile/events/2/type") + .and_then(Value::as_str), + Some("tool.started") + ); + assert_eq!( + runtime + .pointer("/agentRuntimeProfile/events/2/payload/toolCallId") + .and_then(Value::as_str), + Some("tool-1") + ); + assert_eq!( + runtime + .pointer("/agentRuntimeProfile/events/3/type") + .and_then(Value::as_str), + Some("tool.result") + ); + assert_eq!( + runtime + .pointer("/agentRuntimeProfile/events/3/payload/success") + .and_then(Value::as_bool), + Some(true) + ); + assert_eq!( + runtime + .pointer("/agentRuntimeProfile/events/4/type") + .and_then(Value::as_str), + Some("task.profile.resolved") + ); + assert_eq!( + runtime + .pointer("/agentRuntimeProfile/events/4/payload/taskKind") + .and_then(Value::as_str), + Some("translation") + ); + assert_eq!( + runtime + .pointer("/agentRuntimeProfile/events/5/type") + .and_then(Value::as_str), + Some("routing.single_candidate") + ); + assert_eq!( + runtime + .pointer("/agentRuntimeProfile/events/5/payload/selectedModel") + .and_then(Value::as_str), + Some("gpt-5.4-mini") + ); + assert_eq!( + runtime + .pointer("/agentRuntimeProfile/events/6/type") + .and_then(Value::as_str), + Some("cost.estimated") + ); + assert_eq!( + runtime + .pointer("/agentRuntimeProfile/events/6/payload/estimatedCostClass") + .and_then(Value::as_str), + Some("low") + ); + assert_eq!( + runtime + .pointer("/agentRuntimeProfile/events/7/type") + .and_then(Value::as_str), + Some("limit.changed") + ); + assert_eq!( + runtime + .pointer("/agentRuntimeProfile/events/7/payload/singleCandidateOnly") + .and_then(Value::as_bool), + Some(true) + ); + assert_eq!( + runtime + .pointer("/agentRuntimeProfile/events/8/type") + .and_then(Value::as_str), + Some("task.created") + ); + assert_eq!( + runtime + .pointer("/agentRuntimeProfile/events/8/payload/taskId") + .and_then(Value::as_str), + Some("task_thread-1") + ); + assert_eq!( + runtime + .pointer("/agentRuntimeProfile/events/9/type") + .and_then(Value::as_str), + Some("task.attempt.started") + ); + assert_eq!( + runtime + .pointer("/agentRuntimeProfile/events/9/payload/attemptId") + .and_then(Value::as_str), + Some("attempt_turn-1") + ); + assert_eq!( + runtime + .pointer("/agentRuntimeProfile/actions/0/policyRefs/owner") + .and_then(Value::as_str), + Some("AgentPolicy") + ); + assert_eq!( + runtime + .pointer("/agentRuntimeProfile/actions/0/policyRefs/decisionKind") + .and_then(Value::as_str), + Some("ask") + ); + assert_eq!( + runtime + .pointer("/agentRuntimeProfile/actions/0/policyRefs/approvalRequestId") + .and_then(Value::as_str), + Some("req-1") + ); + assert_eq!( + runtime + .pointer("/thread/runtimeFacts/contextSummary/owner") + .and_then(Value::as_str), + Some("AgentContext") + ); + } + + #[test] + fn evidence_runtime_should_export_task_retry_profile_events() { + let temp_dir = TempDir::new().expect("temp dir"); + let mut detail = build_detail(); + detail.turns = vec![AgentThreadTurn { + id: "turn-failed".to_string(), + thread_id: "thread-1".to_string(), + prompt_text: "继续重试 provider 请求".to_string(), + status: AgentThreadTurnStatus::Failed, + started_at: "2026-03-27T10:00:00Z".to_string(), + completed_at: Some("2026-03-27T10:00:05Z".to_string()), + error_message: Some("Provider 错误: rate limit".to_string()), + created_at: "2026-03-27T10:00:00Z".to_string(), + updated_at: "2026-03-27T10:00:05Z".to_string(), + }]; + detail.items = Vec::new(); + let queued_turns = vec![QueuedTurnSnapshot { + queued_turn_id: "queued-retry-1".to_string(), + message_preview: "继续重试".to_string(), + message_text: "继续重试 provider 请求".to_string(), + created_at: 1_774_607_210, + image_count: 0, + position: 1, + }]; + let thread_read = AgentRuntimeThreadReadModel::from_session_detail(&detail, &queued_turns); + + export_runtime_evidence_pack(&detail, &thread_read, temp_dir.path()).expect("export"); + + let runtime_path = temp_dir + .path() + .join(".lime/harness/sessions/session-1/evidence/runtime.json"); + let runtime_raw = fs::read_to_string(runtime_path).expect("runtime"); + let runtime = serde_json::from_str::(&runtime_raw).expect("parse runtime json"); + let events = runtime + .pointer("/agentRuntimeProfile/events") + .and_then(Value::as_array) + .expect("profile events"); + let event_types = events + .iter() + .filter_map(|event| event.get("type").and_then(Value::as_str)) + .collect::>(); + + assert!(event_types.contains(&"task.created")); + assert!(event_types.contains(&"task.attempt.started")); + assert!(event_types.contains(&"task.attempt.failed")); + assert!(event_types.contains(&"task.retrying")); + assert!(!event_types.contains(&"task.failed")); + + let attempt_failed = events + .iter() + .find(|event| event.get("type").and_then(Value::as_str) == Some("task.attempt.failed")) + .expect("attempt failed event"); + assert_eq!( + attempt_failed + .pointer("/payload/failureCategory") + .and_then(Value::as_str), + Some("provider_error") + ); + assert_eq!( + attempt_failed + .pointer("/payload/retryable") + .and_then(Value::as_bool), + Some(true) + ); + + let retrying = events + .iter() + .find(|event| event.get("type").and_then(Value::as_str) == Some("task.retrying")) + .expect("retrying event"); + assert_eq!( + retrying + .pointer("/payload/failedAttemptId") + .and_then(Value::as_str), + Some("attempt_turn-failed") + ); + assert_eq!( + retrying + .pointer("/payload/queuedTurnId") + .and_then(Value::as_str), + Some("queued-retry-1") + ); + assert_eq!( + retrying + .pointer("/payload/nextAttemptIndex") + .and_then(Value::as_u64), + Some(2) + ); + } + #[test] fn should_export_runtime_verification_when_signal_is_applicable() { let temp_dir = TempDir::new().expect("temp dir"); diff --git a/src-tauri/src/services/runtime_handoff_artifact_service.rs b/src-tauri/src/services/runtime_handoff_artifact_service.rs index d98ccc8bc..7ad00cb5e 100644 --- a/src-tauri/src/services/runtime_handoff_artifact_service.rs +++ b/src-tauri/src/services/runtime_handoff_artifact_service.rs @@ -1012,7 +1012,7 @@ fn collect_latest_turn_summary(detail: &SessionDetail) -> Option { .iter() .rev() .find_map(|item| match &item.payload { - AgentThreadItemPayload::TurnSummary { text } => { + AgentThreadItemPayload::TurnSummary { text, .. } => { normalize_optional_text(Some(text.clone())) } _ => None, @@ -1182,6 +1182,7 @@ mod tests { updated_at: "2026-03-27T10:00:30Z".to_string(), payload: AgentThreadItemPayload::TurnSummary { text: "已完成后端导出链路,下一步补前端入口。".to_string(), + metadata: None, }, }, ], @@ -1241,7 +1242,9 @@ mod tests { AgentRuntimeThreadReadModel { thread_id: "thread-1".to_string(), status: "running".to_string(), + profile_status: "running".to_string(), active_turn_id: Some("turn-1".to_string()), + turns: Vec::new(), pending_requests: vec![crate::commands::aster_agent_cmd::AgentRuntimeRequestView { id: "req-1".to_string(), thread_id: "thread-1".to_string(), @@ -1266,6 +1269,11 @@ mod tests { image_count: 0, position: 1, }], + tool_calls: Vec::new(), + model_routing: None, + evidence_summary: Default::default(), + telemetry_summary: Default::default(), + context_summary: None, interrupt_state: None, updated_at: Some("2026-03-27T10:01:00Z".to_string()), latest_compaction_boundary: None, diff --git a/src-tauri/src/services/runtime_replay_case_service.rs b/src-tauri/src/services/runtime_replay_case_service.rs index df9f0444b..65b8e6b49 100644 --- a/src-tauri/src/services/runtime_replay_case_service.rs +++ b/src-tauri/src/services/runtime_replay_case_service.rs @@ -155,6 +155,8 @@ pub fn export_runtime_replay_case( extract_observability_summary_from_runtime_payload(&evidence_runtime_payload); let modality_runtime_contracts = extract_modality_runtime_contracts_from_runtime_payload(&evidence_runtime_payload); + let agent_runtime_profile = + extract_agent_runtime_profile_from_runtime_payload(&evidence_runtime_payload); let success_criteria = build_success_criteria( detail, thread_read, @@ -189,6 +191,7 @@ pub fn export_runtime_replay_case( &pending_requests, &observability_summary, &modality_runtime_contracts, + &agent_runtime_profile, workspace_root.as_path(), exported_at.as_str(), )?, @@ -240,6 +243,7 @@ pub fn export_runtime_replay_case( &recent_artifacts, &observability_summary, &modality_runtime_contracts, + &agent_runtime_profile, exported_at.as_str(), )?, )?, @@ -336,6 +340,13 @@ fn extract_modality_runtime_contracts_from_runtime_payload(payload: &Value) -> V }) } +fn extract_agent_runtime_profile_from_runtime_payload(payload: &Value) -> Value { + payload + .pointer("/agentRuntimeProfile") + .cloned() + .unwrap_or(Value::Null) +} + #[allow(clippy::too_many_arguments)] fn build_input_json( detail: &SessionDetail, @@ -351,6 +362,7 @@ fn build_input_json( pending_requests: &[ReplayPendingRequestInput], observability_summary: &Value, modality_runtime_contracts: &Value, + agent_runtime_profile: &Value, workspace_root: &Path, exported_at: &str, ) -> Result { @@ -434,6 +446,7 @@ fn build_input_json( "incidents": &thread_read.incidents, "runtimeFacts": build_replay_runtime_facts(thread_read, modality_runtime_contracts), "modalityRuntimeContracts": modality_runtime_contracts, + "agentRuntimeProfile": agent_runtime_profile, }, "observability": observability_summary, "linkedArtifacts": { @@ -579,6 +592,7 @@ fn build_evidence_links_json( recent_artifacts: &[String], observability_summary: &Value, modality_runtime_contracts: &Value, + agent_runtime_profile: &Value, exported_at: &str, ) -> Result { let payload = json!({ @@ -597,6 +611,7 @@ fn build_evidence_links_json( }, "observabilitySummary": observability_summary, "modalityRuntimeContracts": modality_runtime_contracts, + "agentRuntimeProfile": agent_runtime_profile, "recentArtifacts": recent_artifacts, }); @@ -2567,7 +2582,7 @@ fn collect_latest_turn_summary(detail: &SessionDetail) -> Option { .iter() .rev() .find_map(|item| match &item.payload { - AgentThreadItemPayload::TurnSummary { text } => { + AgentThreadItemPayload::TurnSummary { text, .. } => { normalize_optional_text(Some(text.clone())) } _ => None, @@ -2676,7 +2691,7 @@ fn collect_pending_request_inputs( fn summarize_item_payload(payload: &AgentThreadItemPayload) -> Option { match payload { AgentThreadItemPayload::Plan { text } - | AgentThreadItemPayload::TurnSummary { text } + | AgentThreadItemPayload::TurnSummary { text, .. } | AgentThreadItemPayload::AgentMessage { text, .. } | AgentThreadItemPayload::Reasoning { text, .. } => { normalize_optional_text(Some(truncate_text(text, 160))) @@ -2887,6 +2902,7 @@ mod tests { payload: AgentThreadItemPayload::TurnSummary { text: "已完成 handoff 与 evidence,下一步把真实案例沉淀成 replay case。" .to_string(), + metadata: None, }, }, ], @@ -2939,7 +2955,9 @@ mod tests { AgentRuntimeThreadReadModel { thread_id: "thread-1".to_string(), status: "waiting_request".to_string(), + profile_status: "blocked".to_string(), active_turn_id: Some("turn-1".to_string()), + turns: Vec::new(), pending_requests: vec![crate::commands::aster_agent_cmd::AgentRuntimeRequestView { id: "req-1".to_string(), thread_id: "thread-1".to_string(), @@ -2964,6 +2982,11 @@ mod tests { image_count: 0, position: 1, }], + tool_calls: Vec::new(), + model_routing: None, + evidence_summary: Default::default(), + telemetry_summary: Default::default(), + context_summary: None, interrupt_state: None, updated_at: Some("2026-03-27T10:02:00Z".to_string()), latest_compaction_boundary: None, @@ -3394,6 +3417,9 @@ mod tests { assert!(input.contains("\"pending_request\"")); assert!(input.contains("\"observability\"")); assert!(input.contains("\"requestTelemetry\"")); + assert!(input.contains("\"agentRuntimeProfile\"")); + assert!(input.contains("\"schemaVersion\": \"lime-profile-0.4.0\"")); + assert!(input.contains("\"runtimeId\": \"lime_runtime_local\"")); assert!(input.contains("\"permissionState\"")); assert!(input.contains("\"requires_confirmation\"")); assert!(!input.contains("\"artifactValidator\"")); @@ -3412,6 +3438,8 @@ mod tests { assert!(links.contains("\"handoffBundle\"")); assert!(links.contains("\"evidencePack\"")); assert!(links.contains("\"observabilitySummary\"")); + assert!(links.contains("\"agentRuntimeProfile\"")); + assert!(links.contains("\"schemaVersion\": \"lime-profile-0.4.0\"")); assert!(!links.contains("\"artifactValidator\"")); } diff --git a/src-tauri/src/services/runtime_review_decision_service.rs b/src-tauri/src/services/runtime_review_decision_service.rs index 642f59264..2f8b128be 100644 --- a/src-tauri/src/services/runtime_review_decision_service.rs +++ b/src-tauri/src/services/runtime_review_decision_service.rs @@ -125,6 +125,7 @@ struct ReviewDecisionContext { verification_summary: Option, verification_failure_outcomes: Vec, verification_recovered_outcomes: Vec, + agent_runtime_profile: Option, #[serde(default)] limit_status: String, #[serde(default)] @@ -358,6 +359,7 @@ fn build_review_decision_document( verification_summary: verification_context.summary.clone(), verification_failure_outcomes: verification_context.failure_outcomes.clone(), verification_recovered_outcomes: verification_context.recovered_outcomes.clone(), + agent_runtime_profile: verification_context.agent_runtime_profile.clone(), limit_status: verification_context.limit_status.clone(), capability_gap: verification_context.capability_gap.clone(), user_locked_capability_summary: verification_context @@ -419,6 +421,9 @@ fn build_review_decision_markdown(document: &ReviewDecisionDocument) -> String { &document.review_context.verification_recovered_outcomes, "- 无", ); + let agent_runtime_profile = format_markdown_agent_runtime_profile( + document.review_context.agent_runtime_profile.as_ref(), + ); let permission_status = empty_fallback(&document.review_context.permission_status, "未导出"); let permission_confirmation = empty_fallback( &document.review_context.permission_confirmation_summary, @@ -464,6 +469,7 @@ fn build_review_decision_markdown(document: &ReviewDecisionDocument) -> String { - 模型锁定能力缺口:{user_locked_capability}\n\ - 权限状态:`{permission_status}`\n\ - 权限确认:{permission_confirmation}\n\ + - AgentRuntime Profile:{agent_runtime_profile}\n\ - analysis 目录:`{analysis_relative_root}`\n\ - handoff 目录:`{handoff_bundle_relative_root}`\n\ - evidence 目录:`{evidence_pack_relative_root}`\n\ @@ -520,6 +526,7 @@ fn build_review_decision_markdown(document: &ReviewDecisionDocument) -> String { user_locked_capability = user_locked_capability, permission_status = permission_status, permission_confirmation = permission_confirmation, + agent_runtime_profile = agent_runtime_profile, analysis_relative_root = document.review_context.analysis_relative_root, handoff_bundle_relative_root = document.review_context.handoff_bundle_relative_root, evidence_pack_relative_root = document.review_context.evidence_pack_relative_root, @@ -584,6 +591,36 @@ fn build_review_checklist(verification_context: &ReviewDecisionVerificationConte checklist } +fn format_markdown_agent_runtime_profile(profile: Option<&Value>) -> String { + let Some(profile) = profile else { + return "未导出".to_string(); + }; + let schema_version = value_string(profile.pointer("/schemaVersion")); + let runtime_id = value_string(profile.pointer("/runtimeId")); + let profile_status = value_string(profile.pointer("/profileStatus")); + let active_turn_id = value_string(profile.pointer("/activeTurnId")); + + let mut parts = Vec::new(); + if !schema_version.is_empty() { + parts.push(format!("schema=`{schema_version}`")); + } + if !runtime_id.is_empty() { + parts.push(format!("runtime=`{runtime_id}`")); + } + if !profile_status.is_empty() { + parts.push(format!("status=`{profile_status}`")); + } + if !active_turn_id.is_empty() { + parts.push(format!("activeTurn=`{active_turn_id}`")); + } + + if parts.is_empty() { + "未导出".to_string() + } else { + parts.join(" · ") + } +} + fn user_locked_capability_acceptance_block_message( verification_context: &ReviewDecisionVerificationContext, ) -> Option { @@ -677,6 +714,7 @@ struct ReviewDecisionVerificationContext { summary: Option, failure_outcomes: Vec, recovered_outcomes: Vec, + agent_runtime_profile: Option, limit_status: String, capability_gap: String, user_locked_capability_summary: String, @@ -750,6 +788,14 @@ fn load_analysis_verification_context( .pointer("/observability/verificationRecoveredOutcomes") .map(value_string_list) .unwrap_or_default(), + agent_runtime_profile: payload + .pointer("/observability/agentRuntimeProfile") + .cloned() + .or_else(|| { + payload + .pointer("/evidence/runtime/agentRuntimeProfile") + .cloned() + }), limit_status: value_string(payload.pointer("/summary/limitStatus")), capability_gap: value_string(payload.pointer("/summary/capabilityGap")), user_locked_capability_summary: value_string( @@ -1470,6 +1516,7 @@ mod tests { updated_at: "2026-03-27T10:01:00Z".to_string(), payload: AgentThreadItemPayload::TurnSummary { text: "外部分析已可导出,下一步需要固定人工审核记录。".to_string(), + metadata: None, }, }, ], @@ -1487,7 +1534,9 @@ mod tests { AgentRuntimeThreadReadModel { thread_id: "thread-1".to_string(), status: "waiting_request".to_string(), + profile_status: "blocked".to_string(), active_turn_id: Some("turn-1".to_string()), + turns: Vec::new(), pending_requests: vec![AgentRuntimeRequestView { id: "req-1".to_string(), thread_id: "thread-1".to_string(), @@ -1512,6 +1561,11 @@ mod tests { image_count: 0, position: 1, }], + tool_calls: Vec::new(), + model_routing: None, + evidence_summary: Default::default(), + telemetry_summary: Default::default(), + context_summary: None, interrupt_state: None, updated_at: Some("2026-03-27T10:01:20Z".to_string()), latest_compaction_boundary: None, @@ -1855,6 +1909,9 @@ mod tests { assert!(markdown.contains("aster-rust")); assert!(markdown.contains("pending_review")); assert!(markdown.contains("结构化验证摘要")); + assert!(markdown.contains("AgentRuntime Profile")); + assert!(markdown.contains("schema=`lime-profile-0.4.0`")); + assert!(markdown.contains("runtime=`lime_runtime_local`")); assert!(markdown.contains("当前没有结构化验证摘要")); assert!(markdown.contains("阻塞 / 提示失败")); assert!(markdown.contains("已恢复结果")); @@ -1867,6 +1924,9 @@ mod tests { assert!(json.contains("\"decisionStatus\": \"pending_review\"")); assert!(json.contains("\"executionEnvironmentReference\": \"codex\"")); assert!(json.contains("\"runtimeFactSource\": \"aster-rust\"")); + assert!(json.contains("\"agentRuntimeProfile\"")); + assert!(json.contains("\"schemaVersion\": \"lime-profile-0.4.0\"")); + assert!(json.contains("\"runtimeId\": \"lime_runtime_local\"")); assert!(json.contains("\"verificationSummary\": null")); assert!(json.contains("\"verificationFailureOutcomes\": []")); assert!(json.contains("\"verificationRecoveredOutcomes\": []")); diff --git a/src-tauri/src/skills/README.md b/src-tauri/src/skills/README.md index 0fc585b7c..82c05ff26 100644 --- a/src-tauri/src/skills/README.md +++ b/src-tauri/src/skills/README.md @@ -23,7 +23,7 @@ Lime 通过以下机制让 AI 能够自动发现和调用 Skills: 1. **Agent 初始化时加载 Skills** - `AsterAgentState::init_agent_with_db()` 调用 `load_lime_skills()` - 技能包以 Agent Skills 标准 `SKILL.md` 为主格式 - - 默认从应用级 Skills 目录加载,并支持项目级 `./.agents/skills` + - 默认从项目级 / 用户级 provider Skills 目录加载:`.agents/skills`、`.claude/skills`、`.codex/skills`、`.gemini/skills` 等,并保留应用级 Skills 目录兜底 - 注册到 aster-rust 的 `global_registry` 2. **SkillTool 自动注册** diff --git a/src-tauri/src/skills/runtime.rs b/src-tauri/src/skills/runtime.rs index 0635bab17..121445065 100644 --- a/src-tauri/src/skills/runtime.rs +++ b/src-tauri/src/skills/runtime.rs @@ -348,6 +348,7 @@ mod permission_tests { description: "测试 skill".to_string(), markdown_content: "test".to_string(), license: None, + compatibility: None, metadata: HashMap::new(), allowed_tools: allowed_tools.map(|tools| { tools diff --git a/src-tauri/tauri.conf.headless.json b/src-tauri/tauri.conf.headless.json index e3f62ab64..9096fbcbc 100644 --- a/src-tauri/tauri.conf.headless.json +++ b/src-tauri/tauri.conf.headless.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Lime", - "version": "1.34.0", + "version": "1.35.0", "identifier": "com.limecloud.lime.headless", "build": { "beforeDevCommand": "npm run dev:web-bridge", diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 0030eaa54..03e63aa62 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Lime", - "version": "1.34.0", + "version": "1.35.0", "identifier": "com.limecloud.lime", "build": { "beforeDevCommand": "node scripts/start-tauri-dev-server.mjs", @@ -62,7 +62,6 @@ ], "resources": [ "icons/tray/*", - "resources/models/**/*", "resources/site-adapters/**/*", "../extensions/lime-chrome/**/*" ], diff --git a/src-tauri/tests/provider_compact_tool_surface.rs b/src-tauri/tests/provider_compact_tool_surface.rs new file mode 100644 index 000000000..656705523 --- /dev/null +++ b/src-tauri/tests/provider_compact_tool_surface.rs @@ -0,0 +1,234 @@ +use aster::agents::extension::ExtensionConfig; +use aster::agents::Agent; +use aster::conversation::message::Message; +use aster::model::ModelConfig; +use aster::providers::formats::openai_responses::{ + create_responses_request, ResponsesRequestOptions, +}; +use aster::providers::formats::{anthropic, google, openai}; +use aster::providers::utils::ImageFormat; +use aster::session::TurnContextOverride; +use rmcp::model::Tool; +use rmcp::object; +use serde_json::{json, Value}; +use std::collections::HashMap; + +const LIME_RUNTIME_METADATA_KEY: &str = "lime_runtime"; +const LIME_RUNTIME_TOOL_SURFACE_KEY: &str = "tool_surface"; +const COMPACT_TOOL_SURFACE_MODE: &str = "compact_tools"; +const COMPACT_PROVIDER_BROKER_TOOL_NAMES: &[&str] = &[ + "ToolSearch", + "ListMcpResourcesTool", + "ReadMcpResourceTool", + "extensionmanager__search_available_extensions", + "extensionmanager__manage_extensions", + "Read", + "Glob", + "Grep", + "Bash", + "Edit", + "Write", + "Agent", + "StructuredOutput", +]; +const PROHIBITED_PROVIDER_TOOL_NAMES: &[&str] = &["TeamCreate", "TeamDelete"]; + +fn compact_turn_context() -> TurnContextOverride { + let mut metadata = HashMap::new(); + metadata.insert( + LIME_RUNTIME_METADATA_KEY.to_string(), + json!({ + LIME_RUNTIME_TOOL_SURFACE_KEY: COMPACT_TOOL_SURFACE_MODE, + }), + ); + + TurnContextOverride { + metadata, + ..TurnContextOverride::default() + } +} + +async fn prepare_compact_tools_and_prompt(agent: &Agent) -> (Vec, String) { + let working_dir = std::env::current_dir().expect("读取当前目录失败"); + let model_config = ModelConfig::new("gpt-4.1").expect("测试模型应有效"); + + let (tools, _toolshim_tools, system_prompt) = + aster::session_context::with_turn_context(Some(compact_turn_context()), async { + agent + .prepare_tools_and_prompt(&working_dir, None, false, &model_config) + .await + }) + .await + .expect("准备 compact provider 工具面失败"); + + (tools, system_prompt) +} + +fn tool_names(tools: &[Tool]) -> Vec { + tools.iter().map(|tool| tool.name.to_string()).collect() +} + +fn assert_compact_tool_names(names: &[String]) { + assert!(!names.is_empty(), "compact 工具面不应为空"); + assert!( + names.len() <= COMPACT_PROVIDER_BROKER_TOOL_NAMES.len(), + "compact broker 工具面最多应有 {} 个工具,实际为 {}: {:?}", + COMPACT_PROVIDER_BROKER_TOOL_NAMES.len(), + names.len(), + names + ); + assert!( + names.iter().all(|name| COMPACT_PROVIDER_BROKER_TOOL_NAMES + .iter() + .any(|candidate| candidate.eq_ignore_ascii_case(name))), + "compact provider tools 只能包含 broker / deferred / 本地核心工具: {names:?}" + ); + assert!(names.iter().any(|name| name == "ToolSearch")); + assert!(names.iter().any(|name| name == "Read")); + for prohibited_name in PROHIBITED_PROVIDER_TOOL_NAMES { + assert!( + !names.iter().any(|name| name == prohibited_name), + "compact provider tools 不应包含 {prohibited_name}: {names:?}" + ); + } +} + +fn anthropic_tool_names(payload: &Value) -> Vec { + payload + .get("tools") + .and_then(Value::as_array) + .expect("Anthropic payload should include tools") + .iter() + .filter_map(|tool| tool.get("name").and_then(Value::as_str)) + .map(str::to_string) + .collect() +} + +fn openai_tool_names(payload: &Value) -> Vec { + payload + .get("tools") + .and_then(Value::as_array) + .expect("OpenAI payload should include tools") + .iter() + .filter_map(|tool| { + tool.get("function") + .and_then(|function| function.get("name")) + .and_then(Value::as_str) + }) + .map(str::to_string) + .collect() +} + +fn openai_responses_tool_names(payload: &Value) -> Vec { + payload + .get("tools") + .and_then(Value::as_array) + .expect("OpenAI Responses payload should include tools") + .iter() + .filter_map(|tool| tool.get("name").and_then(Value::as_str)) + .map(str::to_string) + .collect() +} + +fn google_tool_names(payload: &Value) -> Vec { + payload + .get("tools") + .and_then(|tools| tools.get("functionDeclarations")) + .and_then(Value::as_array) + .expect("Google payload should include functionDeclarations") + .iter() + .filter_map(|tool| tool.get("name").and_then(Value::as_str)) + .map(str::to_string) + .collect() +} + +#[tokio::test] +async fn compact_tool_surface_bounds_anthropic_openai_and_google_payload_tools() { + let agent = Agent::new(); + let model_config = ModelConfig::new("gpt-4.1").expect("测试模型应有效"); + + let (tools, _system_prompt) = prepare_compact_tools_and_prompt(&agent).await; + + let prepared_names = tool_names(&tools); + assert_compact_tool_names(&prepared_names); + + let messages = vec![Message::user().with_text("请读取 README 并总结。")]; + let anthropic_payload = anthropic::create_request( + &ModelConfig::new("claude-sonnet-4-5").expect("Anthropic 测试模型应有效"), + "system", + &messages, + &tools, + ) + .expect("Anthropic payload 应可序列化"); + let openai_payload = openai::create_request( + &model_config, + "system", + &messages, + &tools, + &ImageFormat::OpenAi, + true, + ) + .expect("OpenAI payload 应可序列化"); + let openai_responses_payload = create_responses_request( + &ModelConfig::new("gpt-5-codex").expect("OpenAI Responses 测试模型应有效"), + "system", + &messages, + &tools, + &ResponsesRequestOptions::default(), + ) + .expect("OpenAI Responses payload 应可序列化"); + let google_payload = google::create_request( + &ModelConfig::new("gemini-2.5-pro").expect("Google 测试模型应有效"), + "system", + &messages, + &tools, + ) + .expect("Google payload 应可序列化"); + + for provider_names in [ + anthropic_tool_names(&anthropic_payload), + openai_tool_names(&openai_payload), + openai_responses_tool_names(&openai_responses_payload), + google_tool_names(&google_payload), + ] { + assert_eq!(provider_names, prepared_names); + assert_compact_tool_names(&provider_names); + } +} + +#[tokio::test] +async fn compact_tool_surface_preserves_extension_prompt_context() { + let agent = Agent::new(); + agent + .add_extension(ExtensionConfig::Frontend { + name: "latency_probe".to_string(), + description: "延迟测试扩展".to_string(), + tools: vec![Tool::new( + "latency_probe__heavy_tool".to_string(), + "Heavy tool that should not enter compact provider payload".to_string(), + object!({ "type": "object", "properties": {} }), + )], + instructions: Some("EXTENSION_PROMPT_CONTEXT_SHOULD_STAY".to_string()), + bundled: None, + available_tools: vec![], + deferred_loading: false, + always_expose_tools: vec![], + allowed_caller: None, + }) + .await + .expect("注册测试扩展失败"); + + let (tools, system_prompt) = prepare_compact_tools_and_prompt(&agent).await; + let names = tool_names(&tools); + + assert_compact_tool_names(&names); + assert!(!names.iter().any(|name| name == "latency_probe__heavy_tool")); + assert!( + system_prompt.contains("EXTENSION_PROMPT_CONTEXT_SHOULD_STAY"), + "compact 工具面只裁剪 provider tools schema,不应删除完整 extension prompt context" + ); + assert!( + !system_prompt.contains("【当前回合执行约束】"), + "compact 工具面不应通过额外硬编码 prompt guidance 优化首字" + ); +} diff --git a/src/App.tsx b/src/App.tsx index 0cdeeb05e..4f6391001 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -178,9 +178,7 @@ function AppContent() { ); const handleResourceManagerNavigationUnsupported = useCallback(() => { toast.info(t("common.app.resourceNavigation.unsupported.title"), { - description: t( - "common.app.resourceNavigation.unsupported.description", - ), + description: t("common.app.resourceNavigation.unsupported.description"), }); }, [t]); useResourceManagerNavigationIntents({ @@ -287,9 +285,7 @@ function AppContent() { if (!resolved) { toast.error(t("common.app.websiteDeepLink.unsupported.title"), { - description: t( - "common.app.websiteDeepLink.unsupported.description", - ), + description: t("common.app.websiteDeepLink.unsupported.description"), }); return; } diff --git a/src/components/AppPageContent.tsx b/src/components/AppPageContent.tsx index fdf837cd8..5425de77c 100644 --- a/src/components/AppPageContent.tsx +++ b/src/components/AppPageContent.tsx @@ -56,10 +56,6 @@ const loadMemoryPage = () => import("./memory").then((module) => ({ default: module.MemoryPage, })); -const loadPluginsPage = () => - import("./plugins/PluginsPage").then((module) => ({ - default: module.PluginsPage, - })); const loadSkillsWorkspacePage = () => import("./skills").then((module) => ({ default: module.SkillsWorkspacePage, @@ -124,7 +120,6 @@ function scheduleAgentChatPagePreload(): void { const ResourcesPage = lazy(loadResourcesPage); const MemoryPage = lazy(loadMemoryPage); -const PluginsPage = lazy(loadPluginsPage); const SkillsWorkspacePage = lazy(loadSkillsWorkspacePage); const KnowledgePage = lazy(loadKnowledgePage); const BrowserRuntimeWorkspace = lazy(loadBrowserRuntimeWorkspace); @@ -372,16 +367,6 @@ export function AppPageContent({ return wrapWithSceneApps(content); } - if (activePage === "plugins") { - const content = ( - - - - ); - - return wrapWithSceneApps(content); - } - if (activePage === "memory") { const content = (
diff --git a/src/components/AppSidebar.test.tsx b/src/components/AppSidebar.test.tsx index 79a94a40a..c1bedae4a 100644 --- a/src/components/AppSidebar.test.tsx +++ b/src/components/AppSidebar.test.tsx @@ -22,7 +22,6 @@ import { const { mockGetConfig, mockSaveConfig, - mockGetPluginsForSurface, mockSubscribeAppConfigChanged, mockListAgentRuntimeSessions, mockUpdateAgentRuntimeSession, @@ -44,7 +43,6 @@ const { } = vi.hoisted(() => ({ mockGetConfig: vi.fn(), mockSaveConfig: vi.fn(), - mockGetPluginsForSurface: vi.fn(), mockSubscribeAppConfigChanged: vi.fn(), mockListAgentRuntimeSessions: vi.fn(), mockUpdateAgentRuntimeSession: vi.fn(), @@ -83,10 +81,6 @@ vi.mock("@/i18n/legacy-patch/I18nPatchProvider", () => ({ }), })); -vi.mock("@/lib/api/pluginUI", () => ({ - getPluginsForSurface: mockGetPluginsForSurface, -})); - vi.mock("@/lib/api/agentRuntime", () => ({ deleteAgentRuntimeSession: mockDeleteAgentRuntimeSession, listAgentRuntimeSessions: mockListAgentRuntimeSessions, @@ -318,7 +312,6 @@ describe("AppSidebar", () => { document.documentElement.removeAttribute("style"); mockGetConfig.mockResolvedValue({}); mockSaveConfig.mockResolvedValue(undefined); - mockGetPluginsForSurface.mockResolvedValue([]); mockListAgentRuntimeSessions.mockResolvedValue([]); mockUpdateAgentRuntimeSession.mockResolvedValue(undefined); mockDeleteAgentRuntimeSession.mockResolvedValue(undefined); @@ -523,7 +516,6 @@ describe("AppSidebar", () => { expect(container.textContent).not.toContain("设置"); expect(container.textContent).not.toContain("持续流程"); expect(container.textContent).not.toContain("消息渠道"); - expect(container.textContent).not.toContain("插件中心"); expect(container.textContent).not.toContain("桌宠"); expect(container.textContent).not.toContain("支撑"); expect(container.textContent).not.toContain("技能"); @@ -2632,7 +2624,7 @@ describe("AppSidebar", () => { it("显式开启后应显示可选系统扩展入口", async () => { mockGetConfig.mockResolvedValue({ navigation: { - enabled_items: ["plugins", "companion"], + enabled_items: ["companion"], }, }); @@ -2643,7 +2635,6 @@ describe("AppSidebar", () => { }); await flushEffects(2); - expect(container.textContent).not.toContain("插件中心"); expect(container.textContent).not.toContain("桌宠"); await act(async () => { @@ -2661,7 +2652,6 @@ describe("AppSidebar", () => { expect(accountMenu?.textContent).toContain("设置"); expect(accountMenu?.textContent).toContain("持续流程"); expect(accountMenu?.textContent).toContain("消息渠道"); - expect(accountMenu?.textContent).toContain("插件中心"); expect(accountMenu?.textContent).toContain("桌宠"); }); @@ -2674,7 +2664,7 @@ describe("AppSidebar", () => { }) .mockResolvedValueOnce({ navigation: { - enabled_items: ["plugins", "companion"], + enabled_items: ["companion"], }, }); @@ -2685,7 +2675,6 @@ describe("AppSidebar", () => { }); await flushEffects(2); - expect(container.textContent).not.toContain("插件中心"); expect(container.textContent).not.toContain("桌宠"); await act(async () => { @@ -2703,7 +2692,6 @@ describe("AppSidebar", () => { expect(accountMenu?.textContent).toContain("设置"); expect(accountMenu?.textContent).toContain("持续流程"); expect(accountMenu?.textContent).toContain("消息渠道"); - expect(accountMenu?.textContent).not.toContain("插件中心"); expect(accountMenu?.textContent).not.toContain("桌宠"); await act(async () => { @@ -2719,7 +2707,6 @@ describe("AppSidebar", () => { accountMenu = container.querySelector( '[data-testid="app-sidebar-account-menu"]', ); - expect(accountMenu?.textContent).toContain("插件中心"); expect(accountMenu?.textContent).toContain("桌宠"); }); @@ -2950,53 +2937,4 @@ describe("AppSidebar", () => { container.querySelector('button[aria-label="设置"][aria-current="page"]'), ).toBeNull(); }); - - it("插件中心未开启时不应渲染插件扩展分组", async () => { - mockGetPluginsForSurface.mockResolvedValue([ - { - pluginId: "demo-sidebar", - name: "Demo Sidebar", - description: "demo", - icon: "Bot", - surfaces: ["sidebar"], - }, - ]); - - const container = mountSidebarContainer({ - currentPageParams: { - agentEntry: "new-task", - } as AgentPageParams, - }); - await flushEffects(3); - - expect(container.textContent).not.toContain("插件扩展"); - expect(container.textContent).not.toContain("Demo Sidebar"); - }); - - it("插件中心开启后才应显示插件扩展分组", async () => { - mockGetConfig.mockResolvedValue({ - navigation: { - enabled_items: ["plugins"], - }, - }); - mockGetPluginsForSurface.mockResolvedValue([ - { - pluginId: "demo-sidebar", - name: "Demo Sidebar", - description: "demo", - icon: "Bot", - surfaces: ["sidebar"], - }, - ]); - - const container = mountSidebarContainer({ - currentPageParams: { - agentEntry: "new-task", - } as AgentPageParams, - }); - await flushEffects(3); - - expect(container.textContent).toContain("插件扩展"); - expect(container.textContent).toContain("Demo Sidebar"); - }); }); diff --git a/src/components/AppSidebar.tsx b/src/components/AppSidebar.tsx index 519f501a9..ade32a8ae 100644 --- a/src/components/AppSidebar.tsx +++ b/src/components/AppSidebar.tsx @@ -39,14 +39,10 @@ import { Search, PanelLeftClose, PanelLeftOpen, - Activity, X, - LucideIcon, } from "lucide-react"; import { toast } from "sonner"; -import * as LucideIcons from "lucide-react"; import { useTranslation } from "react-i18next"; -import { getPluginsForSurface, PluginUIInfo } from "@/lib/api/pluginUI"; import { AgentPageParams, Page, PageParams } from "@/types/page"; import { SettingsTabs } from "@/types/settings"; import { @@ -85,7 +81,6 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { Modal } from "@/components/Modal"; -import { hasTauriInvokeCapability } from "@/lib/tauri-runtime"; import { LIME_BRAND_LOGO_SRC, LIME_BRAND_NAME } from "@/lib/branding"; import { scheduleMinimumDelayIdleTask } from "@/lib/utils/scheduleMinimumDelayIdleTask"; import { recordAgentUiPerformanceMetric } from "@/lib/agentUiPerformanceMetrics"; @@ -177,9 +172,6 @@ type SidebarNavItem = SidebarNavItemDefinition; const APP_SIDEBAR_COLLAPSED_STORAGE_KEY = "lime.app-sidebar.collapsed"; const APP_SIDEBAR_COLLAPSE_EVENT = "lime:app-sidebar-collapse"; -const SIDEBAR_PLUGIN_CENTER_NAV_ITEM_ID = "plugins"; -const SIDEBAR_PLUGIN_IDLE_TIMEOUT_MS = 1200; -const SIDEBAR_PLUGIN_BROWSER_IDLE_TIMEOUT_MS = 6000; const SIDEBAR_RECENT_SESSION_PAGE_SIZE = 10; const SIDEBAR_ARCHIVED_SESSION_PAGE_SIZE = 8; const SIDEBAR_SEARCH_RESULT_LIMIT = 8; @@ -196,7 +188,6 @@ const SIDEBAR_NAV_LABEL_KEYS: Record = { "home-general": "navigation.sidebar.items.homeGeneral", knowledge: "navigation.sidebar.items.knowledge", memory: "navigation.sidebar.items.memory", - plugins: "navigation.sidebar.items.plugins", settings: "navigation.sidebar.items.settings", skills: "navigation.sidebar.items.skills", }; @@ -231,17 +222,6 @@ function splitSidebarSessionResult(params: { }; } -function scheduleSidebarPluginLoad(task: () => void): () => void { - const minimumDelayMs = hasTauriInvokeCapability() - ? SIDEBAR_PLUGIN_IDLE_TIMEOUT_MS - : SIDEBAR_PLUGIN_BROWSER_IDLE_TIMEOUT_MS; - - return scheduleMinimumDelayIdleTask(task, { - minimumDelayMs, - idleTimeoutMs: SIDEBAR_PLUGIN_IDLE_TIMEOUT_MS, - }); -} - function hasCachedSidebarSessionEntry( sessions: AsterSessionInfo[], sessionId?: string | null, @@ -847,22 +827,6 @@ const MainNavList = styled.div` margin-bottom: 12px; `; -const Section = styled.div<{ $collapsed?: boolean }>` - display: flex; - flex-direction: column; - gap: 4px; - margin-bottom: 12px; -`; - -const SectionTitle = styled.div<{ $collapsed?: boolean }>` - padding: 0 10px; - font-size: 13px; - font-weight: 600; - color: var(--sidebar-muted); - opacity: 0.9; - display: ${({ $collapsed }) => ($collapsed ? "none" : "block")}; -`; - const NavButton = styled.button<{ $active?: boolean; $collapsed?: boolean }>` display: flex; align-items: center; @@ -2130,13 +2094,6 @@ const AccountMenuDivider = styled.div` background: var(--lime-divider-subtle, rgba(226, 240, 226, 0.82)); `; -function getIconByName(iconName: string): LucideIcon { - const IconComponent = ( - LucideIcons as unknown as Record - )[iconName]; - return IconComponent || Activity; -} - function sortSidebarSessions(sessions: AsterSessionInfo[]): AsterSessionInfo[] { return sessions .filter((session) => !isAuxiliaryAgentSessionId(session.id)) @@ -2525,8 +2482,6 @@ export function AppSidebar({ const [enabledNavItems, setEnabledNavItems] = useState( DEFAULT_ENABLED_SIDEBAR_NAV_ITEM_IDS, ); - const [sidebarPlugins, setSidebarPlugins] = useState([]); - const [refreshTrigger, setRefreshTrigger] = useState(0); const sidebarSessionsRef = useRef([]); const archivedSidebarSessionsRef = useRef([]); const [sidebarSessions, setSidebarSessions] = useState( @@ -2702,64 +2657,6 @@ export function AppSidebar({ ).map(localizeSidebarNavItem); }, [enabledNavItems, localizeSidebarNavItem]); - useEffect(() => { - if (!enabledNavItems.includes(SIDEBAR_PLUGIN_CENTER_NAV_ITEM_ID)) { - setSidebarPlugins([]); - return; - } - - let cancelled = false; - - const loadSidebarPlugins = async (forceRefresh = false) => { - try { - const plugins = await getPluginsForSurface("sidebar", { forceRefresh }); - if (!cancelled) { - setSidebarPlugins(plugins); - } - } catch (error) { - if (!cancelled) { - console.error("加载侧边栏插件失败:", error); - } - } - }; - - if (refreshTrigger > 0) { - void loadSidebarPlugins(true); - return () => { - cancelled = true; - }; - } - - const cancelScheduledLoad = scheduleSidebarPluginLoad(() => { - void loadSidebarPlugins(); - }); - - return () => { - cancelled = true; - cancelScheduledLoad(); - }; - }, [enabledNavItems, refreshTrigger]); - - useEffect(() => { - const handleStorageChange = (event: StorageEvent) => { - if (event.key === "plugin-changed") { - setRefreshTrigger((prev) => prev + 1); - } - }; - - const handlePluginChange = () => { - setRefreshTrigger((prev) => prev + 1); - }; - - window.addEventListener("storage", handleStorageChange); - window.addEventListener("plugin-changed", handlePluginChange); - - return () => { - window.removeEventListener("storage", handleStorageChange); - window.removeEventListener("plugin-changed", handlePluginChange); - }; - }, []); - useEffect(() => { if (typeof window === "undefined") { return; @@ -3474,20 +3371,6 @@ export function AppSidebar({ shouldLoadWorkspaceScopedConversations, ]); - const assistantItems = useMemo(() => { - return sidebarPlugins.map((plugin) => { - const pluginPageId = `plugin:${plugin.pluginId}` as Page; - return { - id: plugin.pluginId, - label: plugin.name, - icon: getIconByName(plugin.icon), - page: pluginPageId, - }; - }); - }, [sidebarPlugins]); - const shouldShowPluginExtensionsSection = - enabledNavItems.includes(SIDEBAR_PLUGIN_CENTER_NAV_ITEM_ID) && - assistantItems.length > 0; const recentSidebarSessions = useMemo(() => { if (!currentProjectId) { return []; @@ -4331,10 +4214,6 @@ export function AppSidebar({ "navigation.sidebar.search.moreRecent", "查看更多对话", ); - const pluginExtensionsTitle = t( - "navigation.sidebar.sections.pluginExtensions", - "插件扩展", - ); const languageMenuLabel = t("navigation.sidebar.account.language", "语言"); const interfaceLanguageLabel = t( "navigation.sidebar.account.interfaceLanguage", @@ -4449,7 +4328,8 @@ export function AppSidebar({ "navigation.sidebar.invite.dialog.close", "关闭邀请弹窗", ); - const inviteBrandName = inviteShare?.brandName ?? accountTenantLabel ?? "Lime"; + const inviteBrandName = + inviteShare?.brandName ?? accountTenantLabel ?? "Lime"; const inviteEyebrowLabel = t("navigation.sidebar.invite.dialog.eyebrow", { brand: inviteBrandName, defaultValue: "{{brand}} 邀请", @@ -4491,10 +4371,7 @@ export function AppSidebar({ "navigation.sidebar.invite.status.loading", "正在从云端同步邀请信息...", ); - const inviteRetryLabel = t( - "navigation.sidebar.invite.actions.retry", - "重试", - ); + const inviteRetryLabel = t("navigation.sidebar.invite.actions.retry", "重试"); const inviteCodeLabel = t("navigation.sidebar.invite.fields.code", "邀请码"); const inviteCopyLabel = t("navigation.sidebar.invite.actions.copy", "复制"); const inviteDownloadUrlLabel = t( @@ -4850,15 +4727,6 @@ export function AppSidebar({ } /> ) : null} - - {shouldShowPluginExtensionsSection && ( -
- - {pluginExtensionsTitle} - - {assistantItems.map((item) => renderNavItem(item))} -
- )} ({ - useTranslation: () => ({ - t: (key: string, options?: { defaultValue?: string }) => - ( - { - "common.cancel": "Cancel", - "common.confirm": "Confirm", - "common.confirmDialog.title": "Confirm action", - } as Record - )[key] ?? - options?.defaultValue ?? - key, - }), -})); - interface MountedDialog { container: HTMLDivElement; root: Root; @@ -44,15 +30,16 @@ function renderConfirmDialog() { mountedDialogs.push({ container, root }); } -beforeEach(() => { +beforeEach(async () => { ( globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean; } ).IS_REACT_ACT_ENVIRONMENT = true; + await changeLimeLocale("en-US"); }); -afterEach(() => { +afterEach(async () => { while (mountedDialogs.length > 0) { const mounted = mountedDialogs.pop(); if (!mounted) { @@ -64,6 +51,7 @@ afterEach(() => { mounted.container.remove(); } document.body.replaceChildren(); + await changeLimeLocale("zh-CN"); }); describe("ConfirmDialog", () => { @@ -74,5 +62,7 @@ describe("ConfirmDialog", () => { expect(document.body.textContent).toContain("Cancel"); expect(document.body.textContent).toContain("Confirm"); expect(document.body.textContent).toContain("This cannot be undone."); + expect(document.body.textContent).not.toContain("确认操作"); + expect(document.body.textContent).not.toContain("确定"); }); }); diff --git a/src/components/ConfirmDialog.tsx b/src/components/ConfirmDialog.tsx index 80f580730..c988963c5 100644 --- a/src/components/ConfirmDialog.tsx +++ b/src/components/ConfirmDialog.tsx @@ -40,21 +40,9 @@ export function ConfirmDialog({ }; const styles = variantStyles[variant]; - const resolvedTitle = - title ?? - t("common.confirmDialog.title", { - defaultValue: "确认操作", - }); - const resolvedConfirmText = - confirmText ?? - t("common.confirm", { - defaultValue: "确定", - }); - const resolvedCancelText = - cancelText ?? - t("common.cancel", { - defaultValue: "取消", - }); + const resolvedTitle = title ?? t("common.confirmDialog.title"); + const resolvedConfirmText = confirmText ?? t("common.confirm"); + const resolvedCancelText = cancelText ?? t("common.cancel"); return ( ({ @@ -31,17 +32,19 @@ function renderSplash() { } describe("SplashScreen", () => { - beforeEach(() => { + beforeEach(async () => { vi.useFakeTimers(); vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + await changeLimeLocale("en-US"); }); - afterEach(() => { + afterEach(async () => { for (const item of mountedSplashes.splice(0)) { act(() => item.root.unmount()); item.container.remove(); } startupWindowRevealMock.revealStartupWindowWhenReady.mockClear(); + await changeLimeLocale("zh-CN"); vi.useRealTimers(); vi.unstubAllGlobals(); }); @@ -71,6 +74,17 @@ describe("SplashScreen", () => { expect(logo?.style.objectFit).toBe("contain"); }); + it("启动画面文案应走 common namespace 英文资源", () => { + const { container } = renderSplash(); + + expect(container.textContent).toContain("Tap Lime, inspiration arrives"); + expect(container.textContent).toContain( + "From one thought to polished copy, images, videos, and finished work", + ); + expect(container.textContent).not.toContain("青柠一下"); + expect(container.textContent).not.toContain("成稿、成图"); + }); + it("首帧布局完成后应请求展示隐藏的 Tauri 主窗口", () => { renderSplash(); diff --git a/src/components/SplashScreen.tsx b/src/components/SplashScreen.tsx index d54a38e7a..139ef9b10 100644 --- a/src/components/SplashScreen.tsx +++ b/src/components/SplashScreen.tsx @@ -11,6 +11,7 @@ import { type CSSProperties, } from "react"; import styled, { keyframes } from "styled-components"; +import { useTranslation } from "react-i18next"; import { LIME_BRAND_LOGO_SRC, LIME_BRAND_NAME } from "@/lib/branding"; import { revealStartupWindowWhenReady } from "@/lib/startupWindowReveal"; @@ -320,6 +321,7 @@ export function SplashScreen({ duration = 420, exitDuration = 160, }: SplashScreenProps) { + const { t } = useTranslation("common"); const [isExiting, setIsExiting] = useState(false); useLayoutEffect(() => { @@ -380,8 +382,8 @@ export function SplashScreen({ - 青柠一下,灵感即来 - 从一句想法,到成稿、成图、成片、成事 + {t("common.splashScreen.slogan")} + {t("common.splashScreen.subtitle")} diff --git a/src/components/agent/chat/AgentChatWorkspace.tsx b/src/components/agent/chat/AgentChatWorkspace.tsx index ce9d71928..6dad38592 100644 --- a/src/components/agent/chat/AgentChatWorkspace.tsx +++ b/src/components/agent/chat/AgentChatWorkspace.tsx @@ -31,7 +31,6 @@ import { useSessionFiles } from "./hooks/useSessionFiles"; import { useContentSync } from "./hooks/useContentSync"; import { useDeveloperFeatureFlags } from "@/hooks/useDeveloperFeatureFlags"; import { useGlobalMediaGenerationDefaults } from "@/hooks/useGlobalMediaGenerationDefaults"; -import { useConfiguredProviders } from "@/hooks/useConfiguredProviders"; import { useServiceModelsConfig } from "@/hooks/useServiceModelsConfig"; import { useTrayModelShortcuts } from "./hooks/useTrayModelShortcuts"; import { type CanvasWorkbenchLayoutMode } from "./components/CanvasWorkbenchLayout"; @@ -141,7 +140,6 @@ import { buildGeneralAgentSystemPrompt, resolveAgentChatMode, } from "./utils/generalAgentPrompt"; -import { shouldUseAgentFastResponseSelection } from "./utils/fastResponseModel"; import { loadPersisted, savePersisted } from "./hooks/agentChatStorage"; import { loadPersistedProjectId } from "./hooks/agentProjectStorage"; import { loadPersistedSessionWorkspaceId } from "./hooks/agentProjectStorage"; @@ -4205,22 +4203,6 @@ export function AgentChatWorkspace({ }); const { handleImageWorkbenchCommand, resolveImageWorkbenchSkillRequest } = imageWorkbenchActionRuntime; - const shouldPrepareFastResponseProviders = - chatMode === "general" && - mappedTheme === "general" && - !isThemeWorkbench && - !contentId && - messages.length === 0 && - shouldUseAgentFastResponseSelection({ - providerType, - model, - }); - const { providers: fastResponseConfiguredProviders } = useConfiguredProviders( - { - autoLoad: shouldPrepareFastResponseProviders, - }, - ); - const { handleSend, handleRecommendationClick, @@ -4262,9 +4244,6 @@ export function AgentChatWorkspace({ browserAssistAutoLaunch: browserAssistRequestAutoLaunch, workspaceRequestMetadataBase: initialRequestMetadata, serviceModels, - currentProviderType: providerType, - currentModel: model, - configuredProviders: fastResponseConfiguredProviders, messages, bootstrapDispatchPreview, sendMessage, diff --git a/src/components/agent/chat/components/AgentIncidentPanel.test.tsx b/src/components/agent/chat/components/AgentIncidentPanel.test.tsx index cb73b5cb0..7df2fd9d7 100644 --- a/src/components/agent/chat/components/AgentIncidentPanel.test.tsx +++ b/src/components/agent/chat/components/AgentIncidentPanel.test.tsx @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { AgentIncidentPanel } from "./AgentIncidentPanel"; import type { ThreadReliabilityIncidentDisplay } from "../utils/threadReliabilityView"; +import { changeLimeLocale } from "@/i18n/createI18n"; interface MountedHarness { container: HTMLDivElement; @@ -13,15 +14,16 @@ interface MountedHarness { const mountedRoots: MountedHarness[] = []; -beforeEach(() => { +beforeEach(async () => { ( globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean; } ).IS_REACT_ACT_ENVIRONMENT = true; + await changeLimeLocale("en-US"); }); -afterEach(() => { +afterEach(async () => { while (mountedRoots.length > 0) { const mounted = mountedRoots.pop(); if (!mounted) break; @@ -30,6 +32,8 @@ afterEach(() => { }); mounted.container.remove(); } + document.body.innerHTML = ""; + await changeLimeLocale("zh-CN"); }); function renderPanel(incidents: ThreadReliabilityIncidentDisplay[]) { @@ -46,44 +50,50 @@ function renderPanel(incidents: ThreadReliabilityIncidentDisplay[]) { } describe("AgentIncidentPanel", () => { - it("无 active incident 时应展示空态", () => { + it("uses agent namespace resources for the empty state", () => { const container = renderPanel([]); + const text = container.textContent ?? ""; expect( container.querySelector('[data-testid="agent-incident-panel-empty"]'), ).not.toBeNull(); - expect(container.textContent).toContain("当前未发现活跃 incident"); + expect(text).toContain("No active incidents detected"); + expect(text).not.toContain("当前未发现活跃 incident"); }); - it("存在 active incident 时应展示标题、说明与状态", () => { + it("keeps incident data dynamic while localizing the priority badge chrome", () => { const container = renderPanel([ { id: "incident-1", incidentType: "approval_timeout", - title: "审批等待超过阈值", - detail: "当前线程等待工具确认时间过长", - statusLabel: "进行中", - severityLabel: "高", + title: "Approval wait exceeded threshold", + detail: "The thread has waited too long for tool confirmation", + statusLabel: "Running", + severityLabel: "High", tone: "failed", }, { id: "incident-2", incidentType: "waiting_user_input", - title: "线程正在等待人工处理", - detail: "等待你确认是否继续发布", - statusLabel: "进行中", - severityLabel: "中", + title: "Thread is waiting for manual handling", + detail: "Waiting for confirmation before publishing", + statusLabel: "Running", + severityLabel: "Medium", tone: "waiting", }, ]); + const text = container.textContent ?? ""; expect( container.querySelector('[data-testid="agent-incident-panel"]'), ).not.toBeNull(); - expect(container.textContent).toContain("审批等待超过阈值"); - expect(container.textContent).toContain("当前线程等待工具确认时间过长"); - expect(container.textContent).toContain("高优先级"); - expect(container.textContent).toContain("线程正在等待人工处理"); - expect(container.textContent).toContain("等待你确认是否继续发布"); + expect(text).toContain("Approval wait exceeded threshold"); + expect(text).toContain( + "The thread has waited too long for tool confirmation", + ); + expect(text).toContain("High priority"); + expect(text).toContain("Thread is waiting for manual handling"); + expect(text).toContain("Waiting for confirmation before publishing"); + expect(text).not.toContain("优先级"); }); }); diff --git a/src/components/agent/chat/components/AgentIncidentPanel.tsx b/src/components/agent/chat/components/AgentIncidentPanel.tsx index c55c4bf7b..43fc19166 100644 --- a/src/components/agent/chat/components/AgentIncidentPanel.tsx +++ b/src/components/agent/chat/components/AgentIncidentPanel.tsx @@ -1,5 +1,6 @@ import React from "react"; import { AlertTriangle, ShieldAlert } from "lucide-react"; +import { useTranslation } from "react-i18next"; import { Badge } from "@/components/ui/badge"; import { cn } from "@/lib/utils"; @@ -38,6 +39,8 @@ function resolveIncidentShellClassName( export const AgentIncidentPanel: React.FC = ({ incidents, }) => { + const { t } = useTranslation("agent"); + if (incidents.length === 0) { return (
= ({ >
- 当前未发现活跃 incident + {t("agentChat.incidentPanel.empty")}
); @@ -71,7 +74,9 @@ export const AgentIncidentPanel: React.FC = ({ variant="outline" className={resolveIncidentBadgeClassName(incident.tone)} > - {incident.severityLabel}优先级 + {t("agentChat.incidentPanel.priorityBadge", { + severity: incident.severityLabel, + })} {incident.statusLabel} diff --git a/src/components/agent/chat/components/AgentThreadTimeline.test.tsx b/src/components/agent/chat/components/AgentThreadTimeline.test.tsx index 68d81c95c..9538ec71f 100644 --- a/src/components/agent/chat/components/AgentThreadTimeline.test.tsx +++ b/src/components/agent/chat/components/AgentThreadTimeline.test.tsx @@ -1049,12 +1049,17 @@ describe("AgentThreadTimeline", () => { expect(container.textContent).not.toContain("```a2ui"); }); - it("内部路由型 turn_summary 完成态应降级为中性进展提示", () => { + it("runtime status turn_summary 完成态应降级为中性进展提示", () => { const items: AgentThreadItem[] = [ { ...createBaseItem("summary-1", 1), type: "turn_summary", - text: "直接回答优先\n当前请求无需默认升级为搜索或任务,先直接给出结果,必要时再调用工具。", + text: "runtime status should not be shown as conversation prose", + metadata: { + sourceType: "runtime_status", + surface: "runtime_status", + visibility: "diagnostics", + }, }, ]; @@ -1065,7 +1070,7 @@ describe("AgentThreadTimeline", () => { }); expect(container.textContent).toContain("当前进展"); - expect(container.textContent).not.toContain("直接回答优先"); + expect(container.textContent).not.toContain("runtime status should not"); expect(container.textContent).not.toContain("已完成思考"); }); diff --git a/src/components/agent/chat/components/AgentThreadTimeline.tsx b/src/components/agent/chat/components/AgentThreadTimeline.tsx index cbcf9990c..9e831fab1 100644 --- a/src/components/agent/chat/components/AgentThreadTimeline.tsx +++ b/src/components/agent/chat/components/AgentThreadTimeline.tsx @@ -41,8 +41,8 @@ import { AgentThreadTimelineArtifactCard } from "./AgentThreadTimelineArtifactCa import type { ArtifactTimelineOpenTarget } from "../utils/artifactTimelineNavigation"; import { normalizeProcessDisplayText } from "../utils/processDisplayText"; import { - isInternalRoutingTurnSummaryText, normalizeTurnSummaryDisplayText, + shouldHideTurnSummaryFromConversation, } from "../utils/turnSummaryPresentation"; import { isPendingRuntimeActionConfirmation, @@ -430,7 +430,7 @@ function ThinkingItemCard({ const shouldHideTurnSummaryContent = item.type === "turn_summary" && !hasStructuredPreview && - isInternalRoutingTurnSummaryText(displayText); + shouldHideTurnSummaryFromConversation(item); const content = hasStructuredPreview ? (
@@ -700,7 +700,7 @@ function extractCompactThinkingParts( return { title: item.status === "in_progress" ? "处理中" : "当前进展", - detail: isInternalRoutingTurnSummaryText(displayText) ? "" : displayText, + detail: shouldHideTurnSummaryFromConversation(item) ? "" : displayText, }; } @@ -1271,10 +1271,6 @@ function isInternalThinkingPreviewLine(line: string): boolean { return false; } - if (isInternalRoutingTurnSummaryText(normalized)) { - return true; - } - return ( /^(我们被要求|我被要求|我需要|我们需要|我们先|我们要|需要理解用户|首先,?用户|We need to|The user asks?|The user wants|The prompt asks|I need to)/i.test( normalized, diff --git a/src/components/agent/chat/components/AgentThreadTimelineArtifactCard.test.tsx b/src/components/agent/chat/components/AgentThreadTimelineArtifactCard.test.tsx index 7749b98ed..e57b10937 100644 --- a/src/components/agent/chat/components/AgentThreadTimelineArtifactCard.test.tsx +++ b/src/components/agent/chat/components/AgentThreadTimelineArtifactCard.test.tsx @@ -232,9 +232,7 @@ describe("AgentThreadTimelineArtifactCard", () => { ) as HTMLElement | null; expect(badge).not.toBeNull(); expect(badge?.textContent).toContain("AgentUI Artifact 预览"); - expect(badge?.getAttribute("title")).toContain( - "Agent UI 标准投影", - ); + expect(badge?.getAttribute("title")).toContain("Agent UI 标准投影"); expect(badge?.getAttribute("title")).toContain("产物快照"); expect(badge?.getAttribute("title")).not.toContain("artifact_snapshot"); }); diff --git a/src/components/agent/chat/components/AgentUiTeamWorkbenchSurfaceView.test.tsx b/src/components/agent/chat/components/AgentUiTeamWorkbenchSurfaceView.test.tsx index 7d7ee332e..141a3fb05 100644 --- a/src/components/agent/chat/components/AgentUiTeamWorkbenchSurfaceView.test.tsx +++ b/src/components/agent/chat/components/AgentUiTeamWorkbenchSurfaceView.test.tsx @@ -1,8 +1,9 @@ import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { AgentUiProjectionEvent } from "../projection/agentUiEventProjection"; import { AgentUiTeamWorkbenchSurfaceView } from "./AgentUiTeamWorkbenchSurfaceView"; +import { changeLimeLocale } from "@/i18n/createI18n"; interface MountedHarness { container: HTMLDivElement; @@ -11,6 +12,15 @@ interface MountedHarness { const mountedRoots: MountedHarness[] = []; +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(); diff --git a/src/components/agent/chat/components/AgentUiTeamWorkbenchSurfaceView.tsx b/src/components/agent/chat/components/AgentUiTeamWorkbenchSurfaceView.tsx index 2a7104316..11fba8a4a 100644 --- a/src/components/agent/chat/components/AgentUiTeamWorkbenchSurfaceView.tsx +++ b/src/components/agent/chat/components/AgentUiTeamWorkbenchSurfaceView.tsx @@ -1,9 +1,11 @@ import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; import type { AgentUiProjectionEvent } from "../projection/agentUiEventProjection"; import { buildAgentUiTeamWorkbenchViewModel, type AgentUiTeamWorkbenchViewItem, } from "../projection/agentUiTeamWorkbenchViewModel"; +import { formatNumber } from "@/i18n/format"; import { cn } from "@/lib/utils"; interface AgentUiTeamWorkbenchSurfaceViewProps { @@ -15,6 +17,8 @@ interface AgentUiTeamWorkbenchSurfaceViewProps { function renderItemAction( item: AgentUiTeamWorkbenchViewItem, + label: string, + ariaLabel: string, onAction?: (item: AgentUiTeamWorkbenchViewItem) => void, ) { if (!item.action) { @@ -23,7 +27,6 @@ function renderItemAction( const className = "rounded-full border border-sky-200 bg-sky-50 px-2 py-0.5 text-[10px] font-medium text-sky-700"; - const label = `${item.action.label} · 目标 ${item.action.targetId}`; if (!onAction) { return {label}; @@ -36,7 +39,7 @@ function renderItemAction( className, "transition hover:border-sky-300 hover:bg-sky-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300", )} - aria-label={`定位工作台操作:${label}`} + aria-label={ariaLabel} data-agentui-action-target={item.action.targetId} onClick={() => onAction(item)} > @@ -51,6 +54,8 @@ export function AgentUiTeamWorkbenchSurfaceView({ className, onAction, }: AgentUiTeamWorkbenchSurfaceViewProps) { + const { i18n, t } = useTranslation("agent"); + const locale = i18n.language; const model = useMemo( () => buildAgentUiTeamWorkbenchViewModel(events, { latestLimit }), [events, latestLimit], @@ -70,19 +75,23 @@ export function AgentUiTeamWorkbenchSurfaceView({
- 工作台操作视图 + {t("agentChat.teamWorkbenchSurface.title")}
- 按 Agent UI v0.6 工作区展示可操作目标,避免在组件内重新推断队友状态。 + {t("agentChat.teamWorkbenchSurface.description")}
- {model.total} 项 + {t("agentChat.teamWorkbenchSurface.totalBadge", { + countLabel: formatNumber(model.total, { locale }), + })} {model.attentionCount > 0 ? ( - 注意 {model.attentionCount} + {t("agentChat.teamWorkbenchSurface.attentionBadge", { + countLabel: formatNumber(model.attentionCount, { locale }), + })} ) : null}
@@ -105,11 +114,15 @@ export function AgentUiTeamWorkbenchSurfaceView({
- {section.total} + {formatNumber(section.total, { locale })} {section.attentionCount > 0 ? ( - 注意 {section.attentionCount} + {t("agentChat.teamWorkbenchSurface.attentionBadge", { + countLabel: formatNumber(section.attentionCount, { + locale, + }), + })} ) : null}
@@ -131,7 +144,25 @@ export function AgentUiTeamWorkbenchSurfaceView({ {item.title} {item.phaseLabel} - {renderItemAction(item, onAction)} + {item.action + ? renderItemAction( + item, + t("agentChat.teamWorkbenchSurface.actionTarget", { + label: item.action.label, + targetId: item.action.targetId, + }), + t("agentChat.teamWorkbenchSurface.actionAria", { + label: t( + "agentChat.teamWorkbenchSurface.actionTarget", + { + label: item.action.label, + targetId: item.action.targetId, + }, + ), + }), + onAction, + ) + : null}
{item.subtitle} diff --git a/src/components/agent/chat/components/CanvasSessionOverviewPanel.test.tsx b/src/components/agent/chat/components/CanvasSessionOverviewPanel.test.tsx new file mode 100644 index 000000000..7cc04f1d6 --- /dev/null +++ b/src/components/agent/chat/components/CanvasSessionOverviewPanel.test.tsx @@ -0,0 +1,143 @@ +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { CanvasSessionOverviewPanel } from "./CanvasSessionOverviewPanel"; +import { changeLimeLocale } from "@/i18n/createI18n"; +import { formatDate } from "@/i18n/format"; +import type { QueuedTurnSnapshot } from "@/lib/api/agentRuntime"; +import type { + ActionRequired, + AgentThreadItem, + AgentThreadTurn, +} from "../types"; + +const mountedRoots: Array<{ container: HTMLDivElement; root: Root }> = []; + +function renderPanel(props: { + turns: AgentThreadTurn[]; + threadItems: AgentThreadItem[]; + pendingActions?: ActionRequired[]; + queuedTurns?: QueuedTurnSnapshot[]; + currentTurnId?: string; + focusedItemId?: string; +}) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render(); + }); + + mountedRoots.push({ container, root }); + return container; +} + +function buildTurn(overrides?: Partial): AgentThreadTurn { + return { + created_at: "2026-05-11T01:40:00.000Z", + id: "turn-1", + prompt_text: "Draft a global launch plan", + started_at: "2026-05-11T01:40:00.000Z", + status: "running", + thread_id: "thread-1", + updated_at: "2026-05-11T01:45:00.000Z", + ...overrides, + }; +} + +function buildCommandItem( + overrides?: Partial>, +): Extract { + return { + command: "", + cwd: "/tmp", + id: "item-command", + sequence: 1, + started_at: "2026-05-11T01:41:00.000Z", + status: "in_progress", + thread_id: "thread-1", + turn_id: "turn-1", + type: "command_execution", + updated_at: "2026-05-11T01:45:00.000Z", + ...overrides, + }; +} + +describe("CanvasSessionOverviewPanel", () => { + beforeEach(async () => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = true; + await changeLimeLocale("en-US"); + }); + + afterEach(async () => { + while (mountedRoots.length > 0) { + const mounted = mountedRoots.pop(); + if (!mounted) { + continue; + } + act(() => { + mounted.root.unmount(); + }); + mounted.container.remove(); + } + document.body.innerHTML = ""; + await changeLimeLocale("zh-CN"); + }); + + it("uses agent namespace resources and locale-aware time/count formatting", () => { + const expectedTime = formatDate("2026-05-11T01:45:00.000Z", { + hour: "2-digit", + locale: "en-US", + minute: "2-digit", + }); + const container = renderPanel({ + currentTurnId: "turn-1", + focusedItemId: "item-command", + pendingActions: [ + { + actionType: "tool_confirmation", + prompt: "Approve shell command?", + requestId: "request-1", + }, + ], + queuedTurns: [ + { + created_at: 1_762_000_000, + image_count: 2, + message_preview: "Generate another draft", + message_text: "Generate another draft", + position: 1, + queued_turn_id: "queue-1", + }, + ], + threadItems: [buildCommandItem()], + turns: [buildTurn()], + }); + + const text = container.textContent ?? ""; + + expect(text).toContain("Session Process Index"); + expect(text).toContain("Running"); + expect(text).toContain("Focused on exec_command"); + expect(text).toContain("Current turn: turn-1"); + expect(text).toContain("In progress 1"); + expect(text).toContain("Needs input 1"); + expect(text).toContain("Last updated"); + expect(text).toContain(expectedTime); + expect(text).toContain("Execution timeline"); + expect(text).toContain("Command is running"); + expect(text).toContain("Pending interactions"); + expect(text).toContain("Waiting for confirmation"); + expect(text).toContain("Queued messages"); + expect(text).toContain("Queued 1"); + expect(text).toContain("2 image(s)"); + expect(text).not.toContain("会话过程索引"); + expect(text).not.toContain("执行时间线"); + expect(text).not.toContain("排队消息"); + }); +}); diff --git a/src/components/agent/chat/components/CanvasSessionOverviewPanel.tsx b/src/components/agent/chat/components/CanvasSessionOverviewPanel.tsx index 52e006ad4..f79c8892a 100644 --- a/src/components/agent/chat/components/CanvasSessionOverviewPanel.tsx +++ b/src/components/agent/chat/components/CanvasSessionOverviewPanel.tsx @@ -9,7 +9,11 @@ import { ShieldAlert, Sparkles, } from "lucide-react"; +import type { TFunction } from "i18next"; +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; import { Badge } from "@/components/ui/badge"; +import { formatDate, formatNumber } from "@/i18n/format"; import { cn } from "@/lib/utils"; import type { QueuedTurnSnapshot } from "@/lib/api/agentRuntime"; import type { @@ -31,6 +35,7 @@ interface CanvasSessionOverviewPanelProps { } type SessionStatusTone = "default" | "accent" | "success"; +type AgentTranslate = TFunction<"agent", undefined>; interface SessionActivityView { id: string; @@ -54,52 +59,82 @@ function shortenText(value?: string | null, maxLength = 120): string { return `${normalized.slice(0, maxLength - 1).trimEnd()}…`; } -function formatTimeLabel(value?: string | null): string | null { +function formatTimeLabel( + value: string | null | undefined, + locale: string, +): string | null { if (!value) { return null; } - const timestamp = new Date(value); - if (Number.isNaN(timestamp.getTime())) { - return null; - } - - return timestamp.toLocaleTimeString("zh-CN", { + const label = formatDate(value, { hour: "2-digit", + locale, minute: "2-digit", }); + return label || null; } -function resolveTurnStatusLabel(status?: AgentThreadTurn["status"] | null): { +function resolveTurnStatusLabel( + status: AgentThreadTurn["status"] | null | undefined, + t: AgentTranslate, +): { label: string; tone: SessionStatusTone; } { if (status === "running") { - return { label: "执行中", tone: "accent" }; + return { + label: t("agentChat.sessionOverview.status.turn.running"), + tone: "accent", + }; } if (status === "completed") { - return { label: "已完成", tone: "success" }; + return { + label: t("agentChat.sessionOverview.status.turn.completed"), + tone: "success", + }; } if (status === "failed") { - return { label: "失败", tone: "default" }; + return { + label: t("agentChat.sessionOverview.status.turn.failed"), + tone: "default", + }; } if (status === "aborted") { - return { label: "已中断", tone: "default" }; + return { + label: t("agentChat.sessionOverview.status.turn.aborted"), + tone: "default", + }; } - return { label: "空闲", tone: "default" }; + return { + label: t("agentChat.sessionOverview.status.turn.idle"), + tone: "default", + }; } -function resolveItemStatusLabel(item: AgentThreadItem): { +function resolveItemStatusLabel( + item: AgentThreadItem, + t: AgentTranslate, +): { label: string; tone: SessionStatusTone; } { if (item.status === "in_progress") { - return { label: "进行中", tone: "accent" }; + return { + label: t("agentChat.sessionOverview.status.item.inProgress"), + tone: "accent", + }; } if (item.status === "failed") { - return { label: "失败", tone: "default" }; + return { + label: t("agentChat.sessionOverview.status.item.failed"), + tone: "default", + }; } - return { label: "已完成", tone: "success" }; + return { + label: t("agentChat.sessionOverview.status.item.completed"), + tone: "success", + }; } function resolvePendingActionPreview(action: ActionRequired): string { @@ -121,17 +156,24 @@ function resolvePendingActionPreview(action: ActionRequired): string { return action.requestId; } -function buildActivityView(item: AgentThreadItem): SessionActivityView | null { - const { label: statusLabel, tone } = resolveItemStatusLabel(item); +function buildActivityView( + item: AgentThreadItem, + t: AgentTranslate, + locale: string, +): SessionActivityView | null { + const { label: statusLabel, tone } = resolveItemStatusLabel(item, t); const timeLabel = formatTimeLabel( item.updated_at || item.completed_at || item.started_at, + locale, ); switch (item.type) { case "tool_call": return { id: item.id, - title: item.tool_name || "工具调用", + title: + item.tool_name || + t("agentChat.sessionOverview.activity.toolCall.title"), summary: shortenText(item.error, 100) || shortenText( @@ -140,7 +182,7 @@ function buildActivityView(item: AgentThreadItem): SessionActivityView | null { : JSON.stringify(item.arguments ?? {}, null, 2), 100, ) || - "工具已接入当前运行轨迹。", + t("agentChat.sessionOverview.activity.toolCall.summaryFallback"), timeLabel, statusLabel, tone, @@ -151,7 +193,9 @@ function buildActivityView(item: AgentThreadItem): SessionActivityView | null { return { id: item.id, title: "exec_command", - summary: shortenText(item.command, 100) || "命令执行中", + summary: + shortenText(item.command, 100) || + t("agentChat.sessionOverview.activity.command.summaryFallback"), timeLabel, statusLabel, tone, @@ -165,7 +209,7 @@ function buildActivityView(item: AgentThreadItem): SessionActivityView | null { summary: shortenText(item.query, 100) || shortenText(item.output, 100) || - "正在检索外部信息。", + t("agentChat.sessionOverview.activity.webSearch.summaryFallback"), timeLabel, statusLabel, tone, @@ -175,11 +219,11 @@ function buildActivityView(item: AgentThreadItem): SessionActivityView | null { case "request_user_input": return { id: item.id, - title: "等待补充信息", + title: t("agentChat.sessionOverview.activity.requestInput.title"), summary: shortenText(item.prompt, 100) || shortenText(item.questions?.[0]?.question, 100) || - "A2UI / 问答采集中", + t("agentChat.sessionOverview.activity.requestInput.summaryFallback"), timeLabel, statusLabel, tone, @@ -189,11 +233,11 @@ function buildActivityView(item: AgentThreadItem): SessionActivityView | null { case "approval_request": return { id: item.id, - title: "等待确认", + title: t("agentChat.sessionOverview.activity.approval.title"), summary: shortenText(item.prompt, 100) || shortenText(item.tool_name, 100) || - "需要用户确认后继续。", + t("agentChat.sessionOverview.activity.approval.summaryFallback"), timeLabel, statusLabel, tone, @@ -203,10 +247,10 @@ function buildActivityView(item: AgentThreadItem): SessionActivityView | null { case "file_artifact": return { id: item.id, - title: "产物已写入", + title: t("agentChat.sessionOverview.activity.fileArtifact.title"), summary: shortenText(extractFileNameFromPath(item.path) || item.path, 100) || - "已产生新的文件产物。", + t("agentChat.sessionOverview.activity.fileArtifact.summaryFallback"), timeLabel, statusLabel, tone, @@ -216,12 +260,14 @@ function buildActivityView(item: AgentThreadItem): SessionActivityView | null { case "subagent_activity": return { id: item.id, - title: item.title?.trim() || "子任务活动", + title: + item.title?.trim() || + t("agentChat.sessionOverview.activity.subagent.title"), summary: shortenText(item.summary, 100) || shortenText(item.role, 100) || shortenText(item.model, 100) || - "子任务正在推进当前任务。", + t("agentChat.sessionOverview.activity.subagent.summaryFallback"), timeLabel, statusLabel, tone, @@ -231,8 +277,10 @@ function buildActivityView(item: AgentThreadItem): SessionActivityView | null { case "warning": return { id: item.id, - title: "运行警告", - summary: shortenText(item.message, 100) || "运行过程中出现警告。", + title: t("agentChat.sessionOverview.activity.warning.title"), + summary: + shortenText(item.message, 100) || + t("agentChat.sessionOverview.activity.warning.summaryFallback"), timeLabel, statusLabel, tone: "default", @@ -242,8 +290,10 @@ function buildActivityView(item: AgentThreadItem): SessionActivityView | null { case "error": return { id: item.id, - title: "执行失败", - summary: shortenText(item.message, 100) || "当前回合执行失败。", + title: t("agentChat.sessionOverview.activity.error.title"), + summary: + shortenText(item.message, 100) || + t("agentChat.sessionOverview.activity.error.summaryFallback"), timeLabel, statusLabel, tone: "default", @@ -253,11 +303,14 @@ function buildActivityView(item: AgentThreadItem): SessionActivityView | null { case "context_compaction": return { id: item.id, - title: item.stage === "started" ? "上下文压缩中" : "上下文压缩完成", + title: + item.stage === "started" + ? t("agentChat.sessionOverview.activity.compaction.started") + : t("agentChat.sessionOverview.activity.compaction.completed"), summary: shortenText(item.detail, 100) || shortenText(item.trigger, 100) || - "会话上下文正在整理。", + t("agentChat.sessionOverview.activity.compaction.summaryFallback"), timeLabel, statusLabel, tone, @@ -267,11 +320,11 @@ function buildActivityView(item: AgentThreadItem): SessionActivityView | null { case "reasoning": return { id: item.id, - title: "思考过程", + title: t("agentChat.sessionOverview.activity.reasoning.title"), summary: shortenText(item.summary?.join(" "), 100) || shortenText(item.text, 100) || - "模型正在整理思路。", + t("agentChat.sessionOverview.activity.reasoning.summaryFallback"), timeLabel, statusLabel, tone, @@ -281,8 +334,10 @@ function buildActivityView(item: AgentThreadItem): SessionActivityView | null { case "plan": return { id: item.id, - title: "执行计划", - summary: shortenText(item.text, 100) || "已生成执行计划。", + title: t("agentChat.sessionOverview.activity.plan.title"), + summary: + shortenText(item.text, 100) || + t("agentChat.sessionOverview.activity.plan.summaryFallback"), timeLabel, statusLabel, tone, @@ -292,8 +347,10 @@ function buildActivityView(item: AgentThreadItem): SessionActivityView | null { case "turn_summary": return { id: item.id, - title: "回合总结", - summary: shortenText(item.text, 100) || "当前回合已生成总结。", + title: t("agentChat.sessionOverview.activity.turnSummary.title"), + summary: + shortenText(item.text, 100) || + t("agentChat.sessionOverview.activity.turnSummary.summaryFallback"), timeLabel, statusLabel, tone, @@ -314,6 +371,8 @@ export function CanvasSessionOverviewPanel({ isSending = false, focusedItemId = null, }: CanvasSessionOverviewPanelProps) { + const { i18n, t } = useTranslation("agent"); + const locale = i18n.language; const sortedItems = sortThreadItems(threadItems).filter( (item) => item.type !== "user_message" && item.type !== "agent_message", ); @@ -322,26 +381,65 @@ export function CanvasSessionOverviewPanel({ turns.find((turn) => turn.id === currentTurnId) || turns.at(-1) || null; const turnStatus = resolveTurnStatusLabel( isSending ? "running" : currentTurn?.status, + t, ); const inProgressCount = sortedItems.filter( (item) => item.status === "in_progress", ).length; const recentActivity = sortedItems - .map((item) => buildActivityView(item)) + .map((item) => buildActivityView(item, t, locale)) .filter((item): item is SessionActivityView => Boolean(item)) .slice(-8) .reverse(); const latestTurnPrompt = - shortenText(currentTurn?.prompt_text, 160) || "当前还没有新的运行输入。"; + shortenText(currentTurn?.prompt_text, 160) || + t("agentChat.sessionOverview.latestPromptFallback"); const latestTurnUpdatedAt = formatTimeLabel( currentTurn?.updated_at || currentTurn?.completed_at || currentTurn?.started_at, + locale, ); const focusedActivity = recentActivity.find((item) => item.id === focusedItemId) || recentActivity[0] || null; + const summaryMetrics = useMemo(() => { + const formattedInProgressCount = formatNumber(inProgressCount, { locale }); + const formattedTraceCount = formatNumber(sortedItems.length, { locale }); + const formattedPendingCount = formatNumber(pendingActions.length, { + locale, + }); + const formattedQueuedCount = formatNumber(queuedTurns.length, { locale }); + + return { + followUp: + pendingActions.length > 0 + ? t("agentChat.sessionOverview.metrics.pending", { + countLabel: formattedPendingCount, + }) + : queuedTurns.length > 0 + ? t("agentChat.sessionOverview.metrics.queued", { + countLabel: formattedQueuedCount, + }) + : t("agentChat.sessionOverview.metrics.noFollowUp"), + trace: + inProgressCount > 0 + ? t("agentChat.sessionOverview.metrics.inProgress", { + countLabel: formattedInProgressCount, + }) + : t("agentChat.sessionOverview.metrics.traces", { + countLabel: formattedTraceCount, + }), + }; + }, [ + inProgressCount, + locale, + pendingActions.length, + queuedTurns.length, + sortedItems.length, + t, + ]); return (
- 会话过程索引 + {t("agentChat.sessionOverview.panel.title")}
- 聚焦 {focusedActivity.title} + {t("agentChat.sessionOverview.panel.focusedBadge", { + title: focusedActivity.title, + })} ) : null}

- 这里只补充当前回合的执行过程、待补信息和排队状态。主稿、Markdown - 与图片请直接从对话里的“在画布中打开”入口进入。 + {t("agentChat.sessionOverview.panel.description")}

- 当前 turn:{currentTurn?.id || "尚未创建"} + {t("agentChat.sessionOverview.metrics.currentTurn", { + id: + currentTurn?.id || + t("agentChat.sessionOverview.metrics.currentTurnMissing"), + })} - {inProgressCount > 0 - ? `进行中 ${inProgressCount}` - : `轨迹 ${sortedItems.length}`} + {summaryMetrics.trace} - {pendingActions.length > 0 - ? `待补信息 ${pendingActions.length}` - : queuedTurns.length > 0 - ? `排队 ${queuedTurns.length}` - : "无需跟进"} + {summaryMetrics.followUp}
@@ -403,18 +500,20 @@ export function CanvasSessionOverviewPanel({
- 最近更新时间 + {t("agentChat.sessionOverview.panel.updatedLabel")}
- {latestTurnUpdatedAt || "--:--"} + {latestTurnUpdatedAt || + t("agentChat.sessionOverview.time.empty")}
- 当前焦点 + {t("agentChat.sessionOverview.panel.focusLabel")}
- {focusedActivity?.title || "等待新的执行事件"} + {focusedActivity?.title || + t("agentChat.sessionOverview.panel.focusFallback")}
{focusedActivity?.summary || latestTurnPrompt} @@ -428,10 +527,10 @@ export function CanvasSessionOverviewPanel({
- 执行时间线 + {t("agentChat.sessionOverview.timeline.title")}
- 这里展示当前会话的技能、工具、A2UI、文件产物与异常信号,方便直接判断下一步卡在哪里。 + {t("agentChat.sessionOverview.timeline.description")}
@@ -482,15 +581,15 @@ export function CanvasSessionOverviewPanel({
- {item.timeLabel || "--:--"} + {item.timeLabel || + t("agentChat.sessionOverview.time.empty")}
); }) ) : (
- 当前还没有可展示的执行轨迹。新的工具调用、skills、A2UI - 与排队变化会出现在这里。 + {t("agentChat.sessionOverview.timeline.empty")}
)}
@@ -500,11 +599,10 @@ export function CanvasSessionOverviewPanel({
- 待处理交互 + {t("agentChat.sessionOverview.pending.title")}
- 缺少信息时,这里会明确显示等待补充的信息入口,便于与 A2UI - 收集流程对齐。 + {t("agentChat.sessionOverview.pending.description")}
@@ -518,8 +616,8 @@ export function CanvasSessionOverviewPanel({
{action.actionType === "tool_confirmation" - ? "等待确认" - : "等待补充信息"} + ? t("agentChat.sessionOverview.pending.confirm") + : t("agentChat.sessionOverview.pending.input")} {action.requestId} @@ -532,7 +630,7 @@ export function CanvasSessionOverviewPanel({ )) ) : (
- 当前没有等待中的确认或补充信息请求。 + {t("agentChat.sessionOverview.pending.empty")}
)}
@@ -541,10 +639,10 @@ export function CanvasSessionOverviewPanel({
- 排队消息 + {t("agentChat.sessionOverview.queue.title")}
- 多轮追问或连续提交时,这里保留稍后执行的输入快照。 + {t("agentChat.sessionOverview.queue.description")}
@@ -557,12 +655,18 @@ export function CanvasSessionOverviewPanel({ >
- 排队 {index + 1} + {t("agentChat.sessionOverview.queue.itemLabel", { + index: formatNumber(index + 1, { locale }), + })} {item.image_count > 0 - ? `${item.image_count} 张图` - : "文本输入"} + ? t("agentChat.sessionOverview.queue.imageCount", { + countLabel: formatNumber(item.image_count, { + locale, + }), + }) + : t("agentChat.sessionOverview.queue.textInput")}
@@ -575,7 +679,7 @@ export function CanvasSessionOverviewPanel({ )) ) : (
- 当前没有排队中的消息。 + {t("agentChat.sessionOverview.queue.empty")}
)}
diff --git a/src/components/agent/chat/components/CuratedTaskLauncherDialog.tsx b/src/components/agent/chat/components/CuratedTaskLauncherDialog.tsx index bad4ff4ad..8f19c9fd9 100644 --- a/src/components/agent/chat/components/CuratedTaskLauncherDialog.tsx +++ b/src/components/agent/chat/components/CuratedTaskLauncherDialog.tsx @@ -383,7 +383,10 @@ export function CuratedTaskLauncherDialog({ return null; } - return findCuratedTaskTemplateById(suggestedTaskId, curatedTaskTemplateCopy); + return findCuratedTaskTemplateById( + suggestedTaskId, + curatedTaskTemplateCopy, + ); }, [curatedTaskTemplateCopy, reviewFeedbackProjection]); const activeReviewBaselineSnapshot = useMemo(() => { diff --git a/src/components/agent/chat/components/HarnessStatusPanel.test.tsx b/src/components/agent/chat/components/HarnessStatusPanel.test.tsx index 4159f7020..5299935ee 100644 --- a/src/components/agent/chat/components/HarnessStatusPanel.test.tsx +++ b/src/components/agent/chat/components/HarnessStatusPanel.test.tsx @@ -9,6 +9,7 @@ import { areLightweightRenderersRegistered, registerLightweightRenderers, } from "@/components/artifact/renderers"; +import { changeLimeLocale } from "@/i18n/createI18n"; import { HarnessStatusPanel } from "./HarnessStatusPanel"; import { RuntimeReviewDecisionDialog } from "./RuntimeReviewDecisionDialog"; import type { HarnessSessionState } from "../utils/harnessState"; @@ -639,12 +640,13 @@ function createAlignedRuntimeToolInventory(): AgentRuntimeToolInventory { }; } -beforeEach(() => { +beforeEach(async () => { ( globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean; } ).IS_REACT_ACT_ENVIRONMENT = true; + await changeLimeLocale("zh-CN"); if (!areLightweightRenderersRegistered()) { registerLightweightRenderers(); diff --git a/src/components/agent/chat/components/HarnessStatusPanel.tsx b/src/components/agent/chat/components/HarnessStatusPanel.tsx index 3a4e0876d..53afc4fbe 100644 --- a/src/components/agent/chat/components/HarnessStatusPanel.tsx +++ b/src/components/agent/chat/components/HarnessStatusPanel.tsx @@ -135,7 +135,7 @@ import { } from "../utils/workflowStepPresentation"; import { buildThreadReliabilityView } from "../utils/threadReliabilityView"; import { resolveTeamWorkspaceStableProcessingLabel } from "../utils/teamWorkspaceCopy"; -import { isInternalRoutingRuntimeStatus } from "../utils/turnSummaryPresentation"; +import { isRuntimeStatusDiagnosticsOnly } from "../utils/turnSummaryPresentation"; import type { TeamRoleDefinition } from "../utils/teamDefinitions"; import type { TeamMemorySnapshot } from "@/lib/teamMemorySync"; import { AgentThreadReliabilityPanel } from "./AgentThreadReliabilityPanel"; @@ -1162,7 +1162,7 @@ function formatRuntimeProgressLabel( function buildRuntimeTaskPresentation( runtimeStatus: HarnessSessionState["runtimeStatus"], ): RuntimeTaskPresentation | null { - if (!runtimeStatus || isInternalRoutingRuntimeStatus(runtimeStatus)) { + if (!runtimeStatus || isRuntimeStatusDiagnosticsOnly(runtimeStatus)) { return null; } diff --git a/src/components/agent/chat/components/HarnessVerificationSummarySection.test.tsx b/src/components/agent/chat/components/HarnessVerificationSummarySection.test.tsx new file mode 100644 index 000000000..30059ad0d --- /dev/null +++ b/src/components/agent/chat/components/HarnessVerificationSummarySection.test.tsx @@ -0,0 +1,85 @@ +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { HarnessVerificationSummarySection } from "./HarnessVerificationSummarySection"; +import { changeLimeLocale } from "@/i18n/createI18n"; + +const mountedRoots: Array<{ container: HTMLDivElement; root: Root }> = []; + +function renderSection() { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render( + , + ); + }); + + mountedRoots.push({ container, root }); + return container; +} + +describe("HarnessVerificationSummarySection", () => { + beforeEach(async () => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = true; + await changeLimeLocale("en-US"); + }); + + afterEach(async () => { + while (mountedRoots.length > 0) { + const mounted = mountedRoots.pop(); + if (!mounted) { + continue; + } + act(() => { + mounted.root.unmount(); + }); + mounted.container.remove(); + } + document.body.innerHTML = ""; + await changeLimeLocale("zh-CN"); + }); + + it("uses agent namespace resources for verification section chrome", () => { + const container = renderSection(); + const text = container.textContent ?? ""; + + expect(text).toContain("Verification Results"); + expect(text).toContain("Artifact Validation"); + expect(text).toContain("Blocking failure"); + expect(text).toContain("Records 1 · issues 2 · repaired 1 · fallback 0"); + expect(text).toContain("Recovered"); + expect(text).toContain("Status failed · exit Unknown · Failed"); + expect(text).toContain("Verification failure focus"); + expect(text).toContain("Recovered results"); + expect(text).toContain("missing artifact"); + expect(text).not.toContain("验证结果"); + expect(text).not.toContain("验证失败焦点"); + }); +}); diff --git a/src/components/agent/chat/components/HarnessVerificationSummarySection.tsx b/src/components/agent/chat/components/HarnessVerificationSummarySection.tsx index 5280f6c70..bdd1afe9c 100644 --- a/src/components/agent/chat/components/HarnessVerificationSummarySection.tsx +++ b/src/components/agent/chat/components/HarnessVerificationSummarySection.tsx @@ -2,42 +2,49 @@ import type { AgentRuntimeEvidenceVerificationSummary } from "@/lib/api/agentRun import { buildHarnessEvidenceVerificationCardPresentations } from "@/lib/agentRuntime/harnessVerificationPresentation"; import { Badge } from "@/components/ui/badge"; import { ShieldAlert } from "lucide-react"; +import { useTranslation } from "react-i18next"; export function HarnessVerificationSummarySection({ summary, }: { summary: AgentRuntimeEvidenceVerificationSummary; }) { + const { i18n, t } = useTranslation("agent"); + const locale = i18n.language; + return (
- 验证结果 + {t("agentChat.harnessVerification.section.title")}
- {buildHarnessEvidenceVerificationCardPresentations(summary).map( - (card) => ( -
-
- - {card.title} - - {card.badge.label} -
-
- {card.description} -
+ {buildHarnessEvidenceVerificationCardPresentations(summary, { + locale, + t, + }).map((card) => ( +
+
+ + {card.title} + + {card.badge.label}
- ), - )} +
+ {card.description} +
+
+ ))}
{summary.focus_verification_failure_outcomes.length > 0 ? (
-
验证失败焦点
+
+ {t("agentChat.harnessVerification.section.failureFocusTitle")} +
{summary.focus_verification_failure_outcomes.map( (outcome, index) => ( @@ -50,7 +57,9 @@ export function HarnessVerificationSummarySection({ {summary.focus_verification_recovered_outcomes.length > 0 ? (
-
已恢复结果
+
+ {t("agentChat.harnessVerification.section.recoveredTitle")} +
{summary.focus_verification_recovered_outcomes.map( (outcome, index) => ( diff --git a/src/components/agent/chat/components/InlineToolProcessStep.test.tsx b/src/components/agent/chat/components/InlineToolProcessStep.test.tsx index 07666205a..bcd805c55 100644 --- a/src/components/agent/chat/components/InlineToolProcessStep.test.tsx +++ b/src/components/agent/chat/components/InlineToolProcessStep.test.tsx @@ -3,6 +3,7 @@ import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { InlineToolProcessStep } from "./InlineToolProcessStep"; import type { AgentToolCallState as ToolCallState } from "@/lib/api/agentProtocol"; +import { changeLimeLocale } from "@/i18n/createI18n"; vi.mock("@tauri-apps/plugin-shell", () => ({ open: vi.fn().mockResolvedValue(undefined), @@ -49,12 +50,13 @@ function renderTool( return rendered; } -beforeEach(() => { +beforeEach(async () => { ( globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean; } ).IS_REACT_ACT_ENVIRONMENT = true; + await changeLimeLocale("zh-CN"); }); afterEach(() => { diff --git a/src/components/agent/chat/components/MessageList.test.tsx b/src/components/agent/chat/components/MessageList.test.tsx index b8ca7ef23..6d8b3877b 100644 --- a/src/components/agent/chat/components/MessageList.test.tsx +++ b/src/components/agent/chat/components/MessageList.test.tsx @@ -474,7 +474,7 @@ describe("MessageList", () => { expect(onLoadFullHistory).toHaveBeenCalledTimes(1); }); - it("旧会话首帧应先渲染消息文本并延后历史 timeline", () => { + it("旧会话首帧应先渲染消息文本并延后历史 timeline", async () => { vi.useFakeTimers(); const messages = createConversationMessages(60); const turns: AgentThreadTurn[] = Array.from({ length: 30 }, (_, index) => { @@ -500,8 +500,9 @@ describe("MessageList", () => { started_at: turn.started_at, completed_at: turn.completed_at, updated_at: turn.updated_at, - type: "reasoning", - text: `历史执行轨迹 ${index + 1}`, + type: "tool_call", + tool_name: "Read", + arguments: { file_path: `/repo/history-${index + 1}.ts` }, })); const container = render(messages, { @@ -514,7 +515,10 @@ describe("MessageList", () => { expect(mockAgentThreadTimeline).not.toHaveBeenCalled(); act(() => { - vi.advanceTimersByTime(920); + vi.advanceTimersByTime(1_000); + }); + await act(async () => { + await Promise.resolve(); }); expect(mockAgentThreadTimeline).toHaveBeenCalled(); @@ -1805,7 +1809,7 @@ describe("MessageList", () => { ).toBeNull(); expect( container.querySelector( - '[data-testid="assistant-first-token-placeholder"]', + '[data-testid="assistant-first-token-runtime-status"]', ), ).toBeNull(); }); @@ -2028,9 +2032,10 @@ describe("MessageList", () => { ).toBeNull(); expect( container.querySelector( - '[data-testid="assistant-first-token-placeholder"]', + '[data-testid="assistant-first-token-runtime-status"]', ), ).not.toBeNull(); + expect(container.textContent).not.toContain("思考中"); expect(container.textContent).toContain("正在启动处理流程"); expect(container.textContent).toContain( "已开始处理,正在准备环境并等待第一条进展。", @@ -3781,7 +3786,9 @@ describe("MessageList", () => { }); expect( - container.querySelector('[data-testid="assistant-primary-timeline-shell"]'), + container.querySelector( + '[data-testid="assistant-primary-timeline-shell"]', + ), ).not.toBeNull(); expect( container.querySelector('[data-testid="agent-thread-timeline:leading"]'), @@ -4579,7 +4586,7 @@ describe("MessageList", () => { ).toBeNull(); expect( container.querySelector( - '[data-testid="assistant-first-token-placeholder"]', + '[data-testid="assistant-first-token-runtime-status"]', ), ).not.toBeNull(); expect(container.textContent).toContain("正在打开 GitHub"); @@ -4634,7 +4641,7 @@ describe("MessageList", () => { expect( container.querySelector( - '[data-testid="assistant-first-token-placeholder"]', + '[data-testid="assistant-first-token-runtime-status"]', ), ).not.toBeNull(); expect( diff --git a/src/components/agent/chat/components/MessageList.tsx b/src/components/agent/chat/components/MessageList.tsx index e0f1def53..41761c6b0 100644 --- a/src/components/agent/chat/components/MessageList.tsx +++ b/src/components/agent/chat/components/MessageList.tsx @@ -18,6 +18,7 @@ import { BookmarkPlus, Square, } from "lucide-react"; +import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; import { toast } from "sonner"; import type { Artifact } from "@/lib/artifact/types"; @@ -56,7 +57,7 @@ import { import { isHiddenConversationArtifactPath } from "../utils/internalArtifactVisibility"; import { buildInputbarRuntimeStatusLineModel } from "../utils/inputbarRuntimeStatusLine"; import { resolvePromptCacheActivity } from "../utils/tokenUsageSummary"; -import { isInternalRoutingTurnSummaryText } from "../utils/turnSummaryPresentation"; +import { shouldHideTurnSummaryFromConversation } from "../utils/turnSummaryPresentation"; import { Message, type ActionRequired, @@ -561,32 +562,37 @@ function measureMessageListComputation( }; } -const AssistantFirstTokenPlaceholder: React.FC<{ +const AssistantFirstTokenRuntimeStatus: React.FC<{ status?: AgentRuntimeStatus | null; }> = ({ status }) => { - const rawTitle = truncateRuntimeStatusText(status?.title || "已提交请求", 48); - const title = - status?.phase === "failed" || status?.phase === "cancelled" - ? rawTitle - : "思考中"; + const { t } = useTranslation("agent"); + const fallbackTitle = t( + `agentChat.messageList.firstTokenStatus.${status?.phase || "submitted"}.title`, + t("agentChat.messageList.firstTokenStatus.submitted.title"), + ); + const fallbackDetail = t( + `agentChat.messageList.firstTokenStatus.${status?.phase || "submitted"}.detail`, + t("agentChat.messageList.firstTokenStatus.submitted.detail"), + ); + const title = truncateRuntimeStatusText(status?.title || fallbackTitle, 48); const detail = - [rawTitle !== title ? rawTitle : null, status?.detail] + [status?.detail] .map((part) => (part ? truncateRuntimeStatusText(part, 120) : null)) .filter(Boolean) - .join(" · ") || "已提交请求,等待首个响应。"; + .join(" · ") || fallbackDetail; return (
- + - {title} - - {detail} + + {title} + {detail}
); @@ -667,7 +673,7 @@ function shouldRenderConversationTimelineItem( return true; } - if (isInternalRoutingTurnSummaryText(item.text)) { + if (shouldHideTurnSummaryFromConversation(item)) { return false; } @@ -709,14 +715,6 @@ function hasInlineThinkingContent(message: Message): boolean { ); } -function hasInternalRoutingSummary(items: AgentThreadItem[]): boolean { - return items.some( - (item) => - item.type === "turn_summary" && - isInternalRoutingTurnSummaryText(item.text), - ); -} - function hasSubstantiveProcessTimelineItem(items: AgentThreadItem[]): boolean { return items.some((item) => { switch (item.type) { @@ -772,10 +770,7 @@ function shouldSuppressAmbientPlainAnswerReasoning(params: { return false; } - return ( - hasInternalRoutingSummary(params.timelineItems) && - !hasSubstantiveProcessTimelineItem(params.timelineItems) - ); + return !hasSubstantiveProcessTimelineItem(params.timelineItems); } interface InlineProcessCoverage { @@ -2257,7 +2252,7 @@ const MessageListInner: React.FC = ({ !msg.imageWorkbenchPreview && !msg.taskPreview && !hasStructuredHistoricalContentHint(actionContent); - const shouldRenderFirstTokenPlaceholder = + const shouldRenderFirstTokenRuntimeStatus = msg.role === "assistant" && msg.isThinking && !shouldRenderRuntimeStatusPill(msg.runtimeStatus) && @@ -2274,7 +2269,6 @@ const MessageListInner: React.FC = ({ !msg.taskPreview; const shouldCollapseAssistantShell = msg.role === "assistant" && - !shouldRenderFirstTokenPlaceholder && !hasVisibleAssistantText && !conversationContentParts?.length && !conversationThinkingContent?.trim() && @@ -2342,11 +2336,15 @@ const MessageListInner: React.FC = ({ ) : null}
) : null; + const firstTokenRuntimeStatusNode = shouldRenderFirstTokenRuntimeStatus ? ( + + ) : null; if ( msg.role === "assistant" && !hasAssistantBodyContent && - !assistantMetaFooter + !assistantMetaFooter && + !firstTokenRuntimeStatusNode ) { return null; } @@ -2423,6 +2421,14 @@ const MessageListInner: React.FC = ({ {primaryTimelineNode}
) : null} + {firstTokenRuntimeStatusNode ? ( +
+ {firstTokenRuntimeStatusNode} +
+ ) : null} {hasAssistantBodyContent ? ( = ({ ? null : primaryTimelineNode} - {shouldRenderFirstTokenPlaceholder ? ( - - ) : shouldPreviewHistoricalAssistantMessage ? ( + {shouldPreviewHistoricalAssistantMessage ? ( = ({ {msg.role === "assistant" && trailingTimeline && - !shouldRenderFirstTokenPlaceholder ? ( + !shouldRenderFirstTokenRuntimeStatus ? ( ({ id: `result-${index + 1}`, - title: `结果 ${index + 1}`, + title: `Result ${index + 1}`, url: `https://example.com/${index + 1}`, hostname: "example.com", - snippet: `摘要 ${index + 1}`, + snippet: `Summary ${index + 1}`, })); act(() => { @@ -38,15 +39,17 @@ function renderList() { return rendered; } -beforeEach(() => { +beforeEach(async () => { ( globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean; } ).IS_REACT_ACT_ENVIRONMENT = true; + await changeLimeLocale("en-US"); }); -afterEach(() => { +afterEach(async () => { + vi.useRealTimers(); while (mountedRoots.length > 0) { const mounted = mountedRoots.pop(); if (!mounted) break; @@ -55,47 +58,51 @@ afterEach(() => { }); mounted.container.remove(); } + document.body.innerHTML = ""; + await changeLimeLocale("zh-CN"); }); describe("SearchResultPreviewList", () => { - it("搜索结果应默认折叠,并支持展开与收起", () => { + it("uses agent namespace resources when collapsing and expanding results", () => { const { container } = renderList(); - expect(container.textContent).toContain("结果 1"); - expect(container.textContent).toContain("结果 4"); - expect(container.textContent).not.toContain("结果 5"); - expect(container.textContent).toContain("展开其余 2 条结果"); + expect(container.textContent).toContain("Result 1"); + expect(container.textContent).toContain("Result 4"); + expect(container.textContent).not.toContain("Result 5"); + expect(container.textContent).toContain("Show 2 more results"); const toggleButton = container.querySelector( - 'button[aria-label="展开搜索结果"]', + 'button[aria-label="Expand search results"]', ) as HTMLButtonElement | null; act(() => { toggleButton?.click(); }); - expect(container.textContent).toContain("结果 5"); - expect(container.textContent).toContain("结果 6"); - expect(container.textContent).toContain("收起结果"); + expect(container.textContent).toContain("Result 5"); + expect(container.textContent).toContain("Result 6"); + expect(container.textContent).toContain("Collapse results"); const collapseButton = container.querySelector( - 'button[aria-label="收起搜索结果"]', + 'button[aria-label="Collapse search results"]', ) as HTMLButtonElement | null; act(() => { collapseButton?.click(); }); - expect(container.textContent).not.toContain("结果 5"); - expect(container.textContent).not.toContain("结果 6"); - expect(container.textContent).toContain("展开其余 2 条结果"); + expect(container.textContent).not.toContain("Result 5"); + expect(container.textContent).not.toContain("Result 6"); + expect(container.textContent).toContain("Show 2 more results"); + expect(container.textContent).not.toContain("展开其余"); + expect(container.textContent).not.toContain("收起结果"); }); - it("悬浮预览在鼠标移出整体区域后应自动关闭", () => { + it("keeps the hover preview open only while the pointer stays in the region", () => { vi.useFakeTimers(); const { container } = renderList(); const trigger = container.querySelector( - 'button[aria-label="预览搜索结果:结果 1"]', + 'button[aria-label="Preview search result: Result 1"]', ) as HTMLButtonElement | null; act(() => { @@ -106,7 +113,7 @@ describe("SearchResultPreviewList", () => { ); }); - expect(document.body.textContent).toContain("摘要 1"); + expect(document.body.textContent).toContain("Summary 1"); act(() => { document.body.dispatchEvent( @@ -117,6 +124,6 @@ describe("SearchResultPreviewList", () => { vi.advanceTimersByTime(140); }); - expect(document.body.textContent).not.toContain("摘要 1"); + expect(document.body.textContent).not.toContain("Summary 1"); }); }); diff --git a/src/components/agent/chat/components/SearchResultPreviewList.tsx b/src/components/agent/chat/components/SearchResultPreviewList.tsx index 32949fbd8..952fe5243 100644 --- a/src/components/agent/chat/components/SearchResultPreviewList.tsx +++ b/src/components/agent/chat/components/SearchResultPreviewList.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; import { ChevronDown, ChevronRight, @@ -11,6 +12,7 @@ import { PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; +import { formatNumber } from "@/i18n/format"; import { cn } from "@/lib/utils"; import type { SearchResultPreviewItem } from "../utils/searchResultPreview"; @@ -25,6 +27,7 @@ function SearchResultHoverCard({ popoverSide?: "top" | "right" | "bottom" | "left"; popoverAlign?: "start" | "center" | "end"; }) { + const { t } = useTranslation("agent"); const [open, setOpen] = useState(false); const closeTimerRef = useRef(null); const triggerRef = useRef(null); @@ -111,7 +114,9 @@ function SearchResultHoverCard({
- {item.snippet || "暂无摘要,点击可直接打开来源。"} + {item.snippet || t("agentChat.searchResultPreview.emptySnippet")}
) : null} diff --git a/src/components/agent/chat/components/TeamWorkbenchSummaryPanel.test.tsx b/src/components/agent/chat/components/TeamWorkbenchSummaryPanel.test.tsx index 92a8d1202..2b08f9801 100644 --- a/src/components/agent/chat/components/TeamWorkbenchSummaryPanel.test.tsx +++ b/src/components/agent/chat/components/TeamWorkbenchSummaryPanel.test.tsx @@ -476,9 +476,7 @@ describe("TeamWorkbenchSummaryPanel", () => { }); expect(container.textContent).toContain("打开详情 · child-1:turn-child-1"); expect(container.textContent).toContain("队友记录详情"); - expect(container.textContent).toContain( - "记录引用:child-1:turn-child-1", - ); + expect(container.textContent).toContain("记录引用:child-1:turn-child-1"); expect(container.textContent).toContain("子会话:child-1"); expect(container.textContent).toContain("最新回合:turn-child-1"); expect(container.textContent).toContain("子会话概览"); diff --git a/src/components/agent/chat/components/TeamWorkbenchSummaryPanel.tsx b/src/components/agent/chat/components/TeamWorkbenchSummaryPanel.tsx index 19e6f1802..495deaf72 100644 --- a/src/components/agent/chat/components/TeamWorkbenchSummaryPanel.tsx +++ b/src/components/agent/chat/components/TeamWorkbenchSummaryPanel.tsx @@ -195,22 +195,19 @@ function buildWorkbenchActionRouteStatus( case "unsupported_remote": return { label: "远端未接入", - detail: - "远端队友需要接入真实远端入口或 A2A 任务记录后才能运行时控制。", + detail: "远端队友需要接入真实远端入口或 A2A 任务记录后才能运行时控制。", className: "border-amber-200 bg-amber-50 text-amber-700", }; case "unsupported_review": return { label: "审核未接入", - detail: - "评审记录已定位;真实审核回调与重指派写回仍需后续接入。", + detail: "评审记录已定位;真实审核回调与重指派写回仍需后续接入。", className: "border-amber-200 bg-amber-50 text-amber-700", }; case "unsupported_handoff": return { label: "交接未接入", - detail: - "交接记录已定位;真实接收、退回、恢复记录接入前不伪造状态。", + detail: "交接记录已定位;真实接收、退回、恢复记录接入前不伪造状态。", className: "border-amber-200 bg-amber-50 text-amber-700", }; case "unsupported_work_item": @@ -1422,7 +1419,8 @@ export function TeamWorkbenchSummaryPanel({ ) : null}

- 这里只定位 Agent UI 标准工作区中的目标,不从文本推断状态,也不伪造远端、评审或交接运行时调用。 + 这里只定位 Agent UI + 标准工作区中的目标,不从文本推断状态,也不伪造远端、评审或交接运行时调用。

{selectedWorkbenchActionRouteStatus ? (

- 选择目标负责人后只回填负责人更新指令;发送并执行后,Agent - UI 仍以运行时返回的负责人变化作为唯一事实。 + 选择目标负责人后只回填负责人更新指令;发送并执行后,Agent UI + 仍以运行时返回的负责人变化作为唯一事实。

= ({
- {t( - "settings.providers.importExport.import.pasteJson", - "或粘贴配置 JSON", - )} + {t("settings.providers.importExport.import.pasteJson")}