diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 63a955d3c..7f5f55219 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,39 +1,48 @@ -## Lime v1.0.1 +## Lime v1.1.0 ### ✨ 主要更新 -- **主工作台与 Agent Chat 持续收敛**:Home Shell、Workspace、Decision Panel、Markdown 渲染、Service Skill 入口与 Workbench 交互继续围绕当前主链统一,减少入口分叉与状态散落 -- **Artifact Document 与工作区运行时继续加固**:Artifact 文档渲染、校验、输出 schema、操作服务、Canvas / A2UI / Preview / Workflow 链路同步整理,让文档型产物、工作台画布和会话侧的连接更稳定 -- **Team / Subagent / MCP 协作链路补强**:Aster Agent 命令运行时、请求元数据、子代理工具、MCP 命令面板与相关测试继续补齐,围绕团队协作和工具编排做进一步收口 -- **历史创作分支继续清退**:`content-creator`、`novel`、`poster`、`music` 等旧主题、旧命令、旧资源与相关接口进一步删除或下沉,治理目录、命令文档与路线图同步更新,减少长期并行 surface +- **Lime 版本切换到 `v1.1.0` 稳定版**:应用版本与发布说明统一升级,发布产物、Tauri 配置和前端包版本保持一致 +- **Aster Agent Framework 对齐 `aster-rust v0.25.0`**:Lime 声明的远端 `aster-core` / `aster-models` 依赖 tag 已同步提升,和当前本地联调的 `aster-rust` 版本保持一致 +- **设置页与 Provider Pool 体验收口**:设置页重复标题移除,背景氛围层与小屏 Provider 排版整理,让设置主路径更接近当前设计语言 +- **运行时与桥接稳定性补强**:补了浏览器运行时审计测试隔离、图片资源入库测试边界和相关工作台发送/模型列表回归,减少统一校验里的不稳定因素 ### ⚠️ 发布与兼容性说明 -- 本次发布 tag 为 `v1.0.1`,应用内版本号保持为 `1.0.1`;`.github/workflows/release.yml` 会按稳定版语义创建 GitHub Release +- 本次发布 tag 为 `v1.1.0`,应用内版本号保持为 `1.1.0`;`.github/workflows/release.yml` 会按稳定版语义创建 GitHub Release - Homebrew Tap 更新工作流不会再把本次发布视为 prerelease;Release 发布后会继续走稳定通道同步 -- 当前仓库声明的 `aster-rust` 依赖已提升到 `v0.24.0` -- 本地如果启用了 `.cargo/config.toml` 的 Aster 覆盖,请确认它指向干净的 `v0.24.0` 仓库;GitHub Release runner 不会带本地绝对路径覆盖 +- 当前仓库声明的 `aster-rust` 依赖已提升到 `v0.25.0` +- 本地如果启用了 `.cargo/config.toml` 的 Aster 覆盖,请确认它指向干净的 `v0.25.0` 仓库;GitHub Release runner 不会带本地绝对路径覆盖 ### 🔗 依赖与版本同步 -- 应用版本已同步提升到 `1.0.1`,覆盖 `package.json`、`package-lock.json`、`src-tauri/Cargo.toml`、`src-tauri/tauri.conf.json`、`src-tauri/tauri.conf.headless.json` -- `src-tauri/Cargo.lock` 会随本次 Rust 校验刷新,确保工作区 crate 的版本快照与 `1.0.1` 对齐 -- `aster-core` / `aster-models` 的 git tag 已同步切换到 `v0.24.0` +- 应用版本已同步提升到 `1.1.0`,覆盖 `package.json`、`package-lock.json`、`src-tauri/Cargo.toml`、`src-tauri/tauri.conf.json`、`src-tauri/tauri.conf.headless.json` +- `src-tauri/Cargo.lock` 会随本次 Rust 校验刷新,确保工作区 crate 的版本快照与 `1.1.0` 对齐 +- `aster-core` / `aster-models` 的 git tag 已同步切换到 `v0.25.0` ### 🧪 发布前校验 - `cargo fmt --manifest-path src-tauri/Cargo.toml --all` - `cargo test --manifest-path src-tauri/Cargo.toml` -- `cargo clippy --manifest-path src-tauri/Cargo.toml` +- `cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings` - `npm run lint` - `npm run verify:app-version` -- 当前结果:`npm run verify:app-version` 与 `npm run lint` 已通过;`cargo test` 已通过(`739` 个单测通过,`2` 个集成测试通过,另有 `2` 个真实联网测试保持 `ignored`);`cargo clippy` 已完成,但仍有若干既有 warning(如 `lime-browser-runtime` 的 `unnecessary_map_or`、`lime-agent` 的 `large_enum_variant` / `too_many_arguments`、主 crate 的未使用常量) +- `npm run test:contracts` +- `npm run verify:gui-smoke -- --reuse-running` +- 当前结果: + - `cargo fmt --manifest-path src-tauri/Cargo.toml --all`:通过 + - `CARGO_TARGET_DIR=target-version-check cargo test --manifest-path src-tauri/Cargo.toml`:通过,`745 passed`,额外集成测试 `2 passed`,真实联网测试 `2 ignored` + - `CARGO_TARGET_DIR=target-version-check cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings`:通过 + - `npm run lint`:通过 + - `npm run verify:app-version`:通过 + - `npm run test:contracts`:通过 + - `npm run verify:gui-smoke -- --reuse-running`:通过 ### 📝 文档同步 -- 发布说明已切换到当前这次 `v1.0.1` 稳定版发布内容,供 GitHub Release 直接读取 +- 发布说明已切换到当前这次 `v1.1.0` 稳定版发布内容,供 GitHub Release 直接读取 - 命令边界、质量流程、路线图与工作区相关文档会随当前主线收敛继续同步 --- -**完整变更**: `v1.0.0-beta` -> `v1.0.1` +**完整变更**: `v1.0.1` -> `v1.1.0` diff --git a/docs/aiprompts/commands.md b/docs/aiprompts/commands.md index deb3f186c..69dcac35a 100644 --- a/docs/aiprompts/commands.md +++ b/docs/aiprompts/commands.md @@ -52,12 +52,47 @@ 旧 `poster_material_*` 命名只允许停留在 schema 迁移与治理守卫中,不应重新出现在前端网关、Rust 命令模块或运行时代码里。 +模型 Provider 真相集同样遵循单一事实源。当前前端入口为 `src/lib/api/modelRegistry.ts` 中的: + +- `get_model_registry_provider_ids` + +它只允许读取 `src-tauri/resources/models/index.json` 的 `providers` 列表。无论是正式 Tauri 命令还是 DevBridge 开发链路,都不应再回退数据库或其它运行态缓存去“猜” provider 集合;资源异常时必须直接暴露错误,避免把索引损坏伪装成“只是没有模型”。 + 文档导出链路同样遵循这条路径。当前主入口为 `src/lib/api/document-export.ts`,统一承接: - `save_exported_document` `Artifact Workbench`、文档工作台与其他导出入口如需把内容落到用户选择的本地路径,应继续复用这条主链,不要在业务组件里重新扩散 `Blob + a.download` 式浏览器旁路。 +自动化设置链路同样遵循这条路径。当前主入口为 `src/lib/api/automation.ts`,统一承接: + +- `get_automation_scheduler_config` +- `update_automation_scheduler_config` +- `get_automation_status` +- `get_automation_jobs` +- `get_automation_job` +- `create_automation_job` +- `update_automation_job` +- `delete_automation_job` +- `run_automation_job_now` +- `get_automation_health` +- `get_automation_run_history` +- `preview_automation_schedule` +- `validate_automation_schedule` + +这些命令属于当前 `设置 -> 系统 -> 自动化` 主路径。浏览器模式下如已接通 DevBridge,应优先走真实后端;不要因为 dispatcher 漏接而长期依赖 mock 掩盖设置页报错。 + +Companion 桌宠链路同样遵循这条路径。当前主入口为 `src/lib/api/companion.ts`,统一承接: + +- `companion_get_pet_status` +- `companion_launch_pet` +- `companion_send_pet_command` + +Lime 主应用会在本地维护 `ws://127.0.0.1:45554/companion/pet` 的桌宠 companion 入口。前端如需感知桌宠连接状态,应继续通过 `companion-pet-status` 事件监听统一状态,不要在页面或 Hook 里自行直连本地 `WebSocket`。 + +如果 companion 协议继续扩展,也应优先延续“Lime 做宿主、桌宠只收脱敏派生状态”的边界。例如 provider 凭证池相关能力,允许 Lime 通过 `companion_send_pet_command` 下发诸如 `pet.provider_overview` 这类脱敏摘要,并允许桌宠通过 `pet.open_provider_settings` 之类事件请求 Lime 聚焦主窗口并跳到 `设置 -> AI 服务商`;但不允许桌宠直接读取凭证文件、数据库或内部 `/v1/credentials/*` 完整凭证接口。 + + ## 命令契约的五个事实源 命令边界不是单文件事实,至少要同时看下面五处: @@ -183,6 +218,7 @@ npm run verify:local - `agent_runtime_submit_turn.turn_config` 新增或调整 `approval_policy / sandbox_policy` - `agent_runtime_submit_turn.request_metadata.harness.team_memory_shadow` 新增或调整 repo-scoped Team 协作记忆注入 +- `agent_runtime_spawn_subagent` 的 current request 字段新增或调整 `name / teamName / cwd`,或修改 spawn 后的 Team 成员写回、child `working_dir` 与父子会话上下文投影 - `agent_runtime_update_session` 新增或调整 `provider_name / model_name / execution_strategy / recent_access_mode / recent_preferences / recent_team_selection` - `getSession/listSessions` 的 `execution_runtime` 新增或调整 `recent_access_mode / recent_theme / recent_session_mode / recent_gate_key / recent_run_title / recent_content_id` - 话题切换时的 provider/model、权限 accessMode、工具偏好、Team 选择,或 `theme / session_mode / gate_key / run_title / content_id` 恢复从本地 fallback 向 `execution_runtime` 收敛 @@ -227,6 +263,7 @@ npm run verify:local 以下是仓库当前已经明确收敛的几个方向: - **Agent / Codex 主命令**:继续收敛到 `agent_runtime_*` +- **子代理运行时主链**:继续收敛到 `agent_runtime_spawn_subagent`;当前 request surface 使用 `name / teamName / cwd` 等字段,其中 `teamName` 需要与 `name` 搭配并依附现有 Team 上下文,`cwd` 必须是绝对目录,并稳定投影到 child session 的 `working_dir` 与 Team 成员展示 - **会话状态回写主链**:继续收敛到 `agent_runtime_update_session`,用于名称、执行策略、session provider/model、`recent_access_mode`、`recent_preferences` 以及 `recent_team_selection` 的轻量持久化回写 - **会话权限主链**:`agent_runtime_submit_turn.turn_config.approval_policy / sandbox_policy` 是正式 turn context 权限协议;`getSession` 返回的 `execution_runtime.recent_access_mode` 负责承接会话最近一次 accessMode。当前端已命中同一 steady-state 权限时,不应继续依赖 `harness.access_mode` 作为唯一事实源 - **运行时交接导出主链**:继续收敛到 `agent_runtime_export_handoff_bundle`;前端统一通过 `src/lib/api/agentRuntime.ts` 网关进入,当前 GUI 入口位于 `HarnessStatusPanel` diff --git a/docs/aiprompts/playwright-e2e.md b/docs/aiprompts/playwright-e2e.md index b899169a8..b9e8a8fab 100644 --- a/docs/aiprompts/playwright-e2e.md +++ b/docs/aiprompts/playwright-e2e.md @@ -26,6 +26,8 @@ - 浏览器模式默认首页从 `http://127.0.0.1:1420/` 进入 - 能走真实后端就走真实后端;浏览器模式暂不支持或尚未桥接的能力,允许走 mock - `verify:gui-smoke` 内部的 browser runtime 校验默认走无界面浏览器会话;它只证明主链可启动,不替代后续真实页面交互验证 +- `lime-pet` 原生桌宠属于独立仓库与原生窗口壳,不纳入当前 WebView Playwright 的直接操控范围;在 Lime 主仓里只验证 `companion_*` API、状态事件与主窗口唤起链路,桌宠窗口移动、点击命中与原生层动画仍需额外手工 smoke +- 如果 companion 协议新增了 provider 摘要或桌宠回跳设置等事件,Playwright 续测只覆盖 Lime 主仓内的“状态事件是否触发”“是否跳到 `设置 -> AI 服务商`”和“主窗口是否被唤起”,不在 WebView 层尝试直接操控原生桌宠 UI - 共享网关控制页已下线,托盘也不再展示网关状态或地址;共享网关 `/v1/routes` 与 selector HTTP 路由也已下线,不再对“启动/停止网关、复制网关地址、路由/curl 示例、selector 路由、托盘运行态文案”做 GUI 续测;server 验证只关注标准 `/v1/messages` 与 `/v1/chat/completions` 主链,如需看运行时状态,走开发者页或实验页的诊断面板 - 项目排版模板与品牌人设扩展旧链路已下线,不再对相关弹窗、模板列表、默认模板、人设扩展表单做 GUI 续测;项目与工作台回归只围绕当前 `Claw` / `workspace` / 现役 `persona` 主链 - 如果只是模块级代码修改、并不需要真实页面交互,优先跑最小单测或 `verify:local` @@ -122,6 +124,15 @@ npm run test:contracts 3. 验证主导航可见,例如“首页”“社媒内容”“设置” 4. 检查控制台 error 是否为 0 +### AI 服务商页拆分后验证 + +1. 进入 `设置 -> AI 服务商` +2. 确认默认落在 `服务商设置`,左侧能看到 Provider 列表,右侧是当前 Provider 配置 +3. 确认首屏不会默认混入 OEM Offer、套餐或云端模型目录 +4. 点击 `云端服务` +5. 确认 OEM 会话、Offer 卡片、默认来源和模型目录改为在该页单独展示 +6. 如当前环境故意破坏了 `models/index.json`,确认 Provider 模型区会提示“模型真相源异常”,而不是静默显示空态 + ### 社媒内容工作流 1. 点击 `社媒内容` @@ -178,6 +189,13 @@ npm run test:contracts 8. 如当前环境已有 observer 连接,再确认“断开已连接扩展”能把页面状态回退到等待连接 9. 如当前环境接通真实后端,再确认“打开 Chrome 扩展页”与“打开远程调试页”可成功唤起对应 Chrome 页面 +### 自动化设置页验证 + +1. 进入 `设置 -> 系统 -> 自动化` +2. 确认调度状态、任务列表、健康面板能正常加载 +3. 打开控制台,确认浏览器模式接通 DevBridge 时不再出现 `get_automation_jobs`、`get_automation_health` 或 `get_automation_run_history` 的 unknown command 报错 +4. 如当前环境允许创建或编辑任务,再确认提交后列表能刷新,而不是只靠 web mock 静态回显 + ### 话题模型恢复验证 1. 进入同一工作区中的两个话题 @@ -212,6 +230,14 @@ npm run test:contracts 5. 对 custom Team 额外确认:切回后 label / description / roles 没丢;如果本轮是从 fallback 回填,继续切换一次确认第二次开始已优先走 runtime 恢复 6. 如果当前项目已有子代理或父会话上下文,再发送一条新消息,确认 Team Workbench 的 shadow 卡片与当前 Team 恢复一致,不会退回到全局 theme fallback;本轮如涉及 `harness.team_memory_shadow`,这里就是最小 GUI 续测锚点 +### 子代理 current 字段验证 + +1. 准备一个带 Team 或父子会话上下文的工作区,并触发一次子代理创建 +2. 如果当前入口支持显式名称或工作目录,优先带上 `name` 与绝对 `cwd`;如果 UI 暂无显式入口,至少复用现有 flow 创建一个 child session,并在详情区观察其展示名与工作目录 +3. 验证 child session / Team Workbench 优先显示显式 `name`,而不是退回 `agent_type`、profile label 或 task summary fallback +4. 如果本轮涉及 `teamName`,确认 child 会回挂到当前 Team,上下文里能按该名字识别,不会出现重复成员或错挂到其它 Team +5. 验证 child 的 `working_dir` 与详情展示反映请求的绝对 `cwd`;如果请求非法相对路径,前端应看到明确失败,而不是静默回退父目录 + ### 上下文压缩链路验证 1. 准备一个长线程,确保能够稳定接近上下文上限 diff --git a/docs/aiprompts/quality-workflow.md b/docs/aiprompts/quality-workflow.md index 92a591a98..31f811051 100644 --- a/docs/aiprompts/quality-workflow.md +++ b/docs/aiprompts/quality-workflow.md @@ -72,6 +72,8 @@ 如果本轮是在清退旧图库素材命名,`create_poster_metadata` / `get_poster_metadata` / `get_poster_material` / `update_poster_metadata` / `delete_poster_metadata` / `list_by_*`,以及 `PosterMaterial*` / `poster_material_*` 表名与模块名也必须同步从前端网关、Rust 注册、DAO 与治理目录册中撤掉;如需保留历史数据,只允许在 schema 迁移中短暂停留旧表名。最低校验至少包含 `npm run test:contracts` 与 `npm run governance:legacy-report`。 +如果本轮涉及 `companion_*` 桌宠命令族,还要同步检查本地 companion `WebSocket` 入口、前端 `src/lib/api/companion.ts` 网关、Rust 注册、治理目录册以及浏览器模式 mock 返回形态;浏览器模式下这组命令默认也要保持可 mock,不要让桌宠接入把默认页面渲染链路卡死。 + ### 3. 用户可见 UI 改动必须补稳定回归 - 优先补现有 `*.test.tsx` 的关键文案、状态与交互断言 @@ -176,17 +178,26 @@ npm run bridge:health -- --timeout-ms 120000 - 修改 `src/lib/api/document-export.ts`、`save_exported_document`,或把新的 GUI 导出入口接到本地文件保存主链 - 修改 `agent_runtime_submit_turn.turn_config.approval_policy / sandbox_policy` - 修改 `agent_runtime_submit_turn.request_metadata.harness.team_memory_shadow` +- 修改 `agent_runtime_spawn_subagent` 的 `name / teamName / cwd`、spawn 后 Team 成员写回,或 child `working_dir` / 父子上下文投影语义 - 修改 `agent_runtime_update_session` 或会话 provider/model / recent_access_mode / recent_preferences / recent_team_selection 恢复语义 - 修改 `execution_runtime.recent_access_mode / recent_theme / recent_session_mode / recent_gate_key / recent_run_title / recent_content_id` 恢复语义,或前端 `harness.access_mode / harness.theme / harness.session_mode / harness.gate_key / harness.run_title / harness.content_id` steady-state 去重逻辑 - 修改首页 / 工作区进入 `Claw` 时的首条自动发送上下文,例如 `initialUserPrompt`、`initialAutoSendRequestMetadata`、`harness.service_skill_launch` - 修改 `site_*` 站点适配器命令族,例如 `site_recommend_adapters`、`site_get_adapter_launch_readiness`、`site_import_adapter_yaml_bundle`、`site_run_adapter` +- 修改 `companion_get_pet_status`、`companion_launch_pet`、`companion_send_pet_command`,或调整 Lime 与独立桌宠之间的本地 companion 协议 +- 修改自动化设置命令族,例如 `get_automation_jobs`、`create_automation_job`、`update_automation_job`、`get_automation_health` 或 `get_automation_run_history`,尤其是它们在浏览器模式 DevBridge 与 mock 间的分流 - 修改浏览器资料 / 环境预设命令族,或调整它们在 `mockPriorityCommands` 里的优先级 - 修改浏览器连接器命令族,例如安装目录、启用状态、系统连接器、浏览器动作配置、扩展安装状态、打开 Chrome 扩展 / 远程调试页,或主动断开扩展连接 +- 修改 `get_model_registry_provider_ids`、Provider 模型映射或 `src-tauri/resources/models/index.json` 真相源读取语义 - 修改 `src/lib/dev-bridge/` - 修改 `src/lib/tauri-mock/` - 修改 `src-tauri/src/app/runner.rs` - 修改 `src-tauri/src/dev_bridge/` +如果本轮修改了 Provider 模型真相源或设置页中的“支持的模型”展示逻辑,还应额外确认: + +- 资源索引损坏时,GUI 会明确提示“模型真相源异常” +- 不会再静默回退数据库或把错误伪装成空模型列表 + ### Layer 4:交互型 E2E 入口: @@ -233,6 +244,10 @@ npm run bridge:health -- --timeout-ms 120000 - 如果这次改动影响 `harness.team_memory_shadow` 这类 repo-scoped Team 协作上下文,除了契约检查之外,还应补: - 前端发送边界回归,确认 `team_memory_shadow` 能随当前请求进入 `agent_runtime_submit_turn` - Rust `prompt_context` 定向测试,确认 shadow 只作为低优先级协作参考,不覆盖显式 `selected_team_*` 或 `recent_team_selection` +- 如果这次改动影响 `agent_runtime_spawn_subagent` 的 current request surface,除了契约检查之外,还应补: + - Rust 定向测试,确认显式 `name` 会覆盖 child session 展示名 / role hint 的 fallback + - Rust 或前端回归,确认 `teamName` 必须与 `name` 搭配,并且只在现有 Team 上下文内写回成员关系 + - 定向验证,确认绝对 `cwd` 会投影到 child `working_dir`,相对路径会在边界被拒绝 - 如果这次改动把 `accessMode` steady-state 从“只写 harness metadata”收敛到正式 turn context 与 `session/runtime`,除了契约检查之外,还应补 Hook/UI 回归,证明: - turn 提交始终携带正式 `approval_policy / sandbox_policy` - session 已有 `execution_runtime.recent_access_mode` 时,切换话题会恢复对应 accessMode,而不是回退到工作区默认值 diff --git a/docs/aiprompts/skill-standard.md b/docs/aiprompts/skill-standard.md index fd94ff4a6..6068331ad 100644 --- a/docs/aiprompts/skill-standard.md +++ b/docs/aiprompts/skill-standard.md @@ -75,6 +75,79 @@ - 再造一个平级的 `site skill` 协议 - 再造一个平级的 `prompt package` 协议 +## 第二原则 + +**Skill 是 bundle,不是单一 Markdown。** + +Lime 在工程上必须明确接受这一点: + +- Skill 可以有主说明文件 +- 但 Skill 的价值不应只存在于主说明文件 +- references、examples、assets、templates、scripts、data、config、memory 都可以是 Skill 的组成部分 + +也就是说,Skill 不只是“告诉模型做什么”,还应当有能力承载: + +- 领域知识 +- 模板与示例 +- 可执行脚本 +- 验证步骤 +- gotchas +- setup 信息 +- 长期记忆 + +如果一个 Skill 只有一段说明文字,没有任何额外知识、资产、脚本、验证或渐进披露结构,那么它更接近提示词说明,而不是强 Skill。 + +## 设计原则补充 + +除了分层边界,Lime Skill 在设计上还应遵守下面几条补充原则。 + +### 1. 不要重复模型默认知道的常识 + +Skill 最有价值的内容,是把模型从默认思路里“推出来”。 + +应该优先写: + +- 组织独有规则 +- 项目独有约束 +- 常见踩坑点 +- 质量判断标准 + +### 2. 高信号内容优先写成 gotchas + +如果某个 Skill 经常失败,先补 gotchas,而不是先补更长的背景介绍。 + +### 3. 优先使用渐进披露 + +主文件负责: + +- 触发 +- 路由 +- 总规则 + +详细内容优先拆到: + +- `references/` +- `examples/` +- `templates/` +- `scripts/` + +不要把所有内容都塞进单一说明文件。 + +### 4. 能用脚本和模板解决的,不要全靠自然语言重复描述 + +给 Skill 提供脚本、模板、示例,通常比在说明中反复描述更稳。 + +### 5. 需要长期使用的 Skill,应考虑 setup、memory 与 hooks + +高价值 Skill 往往不是一次性调用。 + +设计时要考虑: + +- 是否需要用户配置 +- 是否需要项目级配置 +- 是否要记录历史执行结果 +- 是否存在只在调用期间启用的 hooks + ## 标准分层 Lime 的技能标准必须分成四层: diff --git a/package.json b/package.json index a7e3c0fe2..3a66e68d9 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "lime", "private": true, - "version": "1.0.1", + "version": "1.1.0", "type": "module", "engines": { "node": ">=22.0.0" diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 71203df14..ae8f4afeb 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -378,7 +378,7 @@ checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" [[package]] name = "aster-core" -version = "0.24.0" +version = "0.25.0" dependencies = [ "ahash", "anyhow", @@ -470,7 +470,7 @@ dependencies = [ [[package]] name = "aster-models" -version = "0.24.0" +version = "0.25.0" dependencies = [ "serde", "serde_json", @@ -2417,7 +2417,7 @@ dependencies = [ "dtoa-short", "itoa", "matches", - "phf 0.10.1", + "phf 0.8.0", "proc-macro2", "quote", "smallvec", @@ -2433,7 +2433,7 @@ dependencies = [ "cssparser-macros", "dtoa-short", "itoa", - "phf 0.11.3", + "phf 0.8.0", "smallvec", ] @@ -4374,7 +4374,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.57.0", + "windows-core 0.56.0", ] [[package]] @@ -5101,7 +5101,7 @@ dependencies = [ [[package]] name = "lime" -version = "1.0.1" +version = "1.1.0" dependencies = [ "anyhow", "arboard", @@ -5205,7 +5205,7 @@ dependencies = [ [[package]] name = "lime-agent" -version = "1.0.1" +version = "1.1.0" dependencies = [ "anyhow", "aster-core", @@ -5234,7 +5234,7 @@ dependencies = [ [[package]] name = "lime-browser-runtime" -version = "1.0.1" +version = "1.1.0" dependencies = [ "chrono", "futures", @@ -5251,7 +5251,7 @@ dependencies = [ [[package]] name = "lime-config" -version = "1.0.1" +version = "1.1.0" dependencies = [ "async-trait", "lime-core", @@ -5267,7 +5267,7 @@ dependencies = [ [[package]] name = "lime-core" -version = "1.0.1" +version = "1.1.0" dependencies = [ "aster-models", "async-trait", @@ -5307,7 +5307,7 @@ dependencies = [ [[package]] name = "lime-credential" -version = "1.0.1" +version = "1.1.0" dependencies = [ "axum 0.7.9", "base64 0.22.1", @@ -5342,7 +5342,7 @@ dependencies = [ [[package]] name = "lime-gateway" -version = "1.0.1" +version = "1.1.0" dependencies = [ "aes", "axum 0.7.9", @@ -5372,7 +5372,7 @@ dependencies = [ [[package]] name = "lime-infra" -version = "1.0.1" +version = "1.1.0" dependencies = [ "chrono", "dashmap 5.5.3", @@ -5392,7 +5392,7 @@ dependencies = [ [[package]] name = "lime-mcp" -version = "1.0.1" +version = "1.1.0" dependencies = [ "async-trait", "dirs 5.0.1", @@ -5424,7 +5424,7 @@ dependencies = [ [[package]] name = "lime-processor" -version = "1.0.1" +version = "1.1.0" dependencies = [ "async-trait", "lime-core", @@ -5443,7 +5443,7 @@ dependencies = [ [[package]] name = "lime-providers" -version = "1.0.1" +version = "1.1.0" dependencies = [ "anyhow", "async-stream", @@ -5498,7 +5498,7 @@ dependencies = [ [[package]] name = "lime-server" -version = "1.0.1" +version = "1.1.0" dependencies = [ "aster-core", "async-stream", @@ -5543,7 +5543,7 @@ dependencies = [ [[package]] name = "lime-server-utils" -version = "1.0.1" +version = "1.1.0" dependencies = [ "axum 0.7.9", "futures", @@ -5558,7 +5558,7 @@ dependencies = [ [[package]] name = "lime-services" -version = "1.0.1" +version = "1.1.0" dependencies = [ "anyhow", "aster-core", @@ -5600,7 +5600,7 @@ dependencies = [ [[package]] name = "lime-skills" -version = "1.0.1" +version = "1.1.0" dependencies = [ "async-trait", "dirs 5.0.1", @@ -5618,7 +5618,7 @@ dependencies = [ [[package]] name = "lime-terminal" -version = "1.0.1" +version = "1.1.0" dependencies = [ "async-trait", "base64 0.22.1", @@ -5645,7 +5645,7 @@ dependencies = [ [[package]] name = "lime-websocket" -version = "1.0.1" +version = "1.1.0" dependencies = [ "axum 0.7.9", "chrono", @@ -6324,7 +6324,7 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" dependencies = [ - "proc-macro-crate 2.0.2", + "proc-macro-crate 1.3.1", "proc-macro2", "quote", "syn 2.0.117", @@ -7077,7 +7077,9 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" dependencies = [ + "phf_macros 0.8.0", "phf_shared 0.8.0", + "proc-macro-hack", ] [[package]] @@ -7086,9 +7088,7 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" dependencies = [ - "phf_macros 0.10.0", "phf_shared 0.10.0", - "proc-macro-hack", ] [[package]] @@ -7192,12 +7192,12 @@ dependencies = [ [[package]] name = "phf_macros" -version = "0.10.0" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58fdf3184dd560f160dd73922bea2d5cd6e8f064bf4b13110abd81b03697b4e0" +checksum = "7f6fde18ff429ffc8fe78e2bf7f8b7a5a5a6e2a8b58bc5a9ac69198bbda9189c" dependencies = [ - "phf_generator 0.10.0", - "phf_shared 0.10.0", + "phf_generator 0.8.0", + "phf_shared 0.8.0", "proc-macro-hack", "proc-macro2", "quote", @@ -7609,7 +7609,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" dependencies = [ "anyhow", - "itertools 0.14.0", + "itertools 0.12.1", "proc-macro2", "quote", "syn 2.0.117", @@ -9108,7 +9108,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b1fdf65dd6331831494dd616b30351c38e96e45921a27745cf98490458b90bb" dependencies = [ - "dirs 6.0.0", + "dirs 4.0.0", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 365f84c2a..355bbac1a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -3,7 +3,7 @@ members = ["crates/*"] resolver = "2" [workspace.package] -version = "1.0.1" +version = "1.1.0" edition = "2021" authors = ["coso"] repository = "https://github.com/aiclientproxy/lime" @@ -127,8 +127,8 @@ enigo = "0.3" # 如需联调本地 aster-rust,请运行: # npm run setup:local-aster -- /path/to/aster-rust # 脚本会在仓库根 .cargo/config.toml 写入本地 patch 覆盖;该文件已被 .gitignore 忽略。 -aster = { package = "aster-core", git = "https://github.com/astercloud/aster-rust", tag = "v0.24.0" } -aster-models = { git = "https://github.com/astercloud/aster-rust", tag = "v0.24.0" } +aster = { package = "aster-core", git = "https://github.com/astercloud/aster-rust", tag = "v0.25.0" } +aster-models = { git = "https://github.com/astercloud/aster-rust", tag = "v0.25.0" } # MCP (Model Context Protocol) rmcp = { version = "0.12.0", features = ["client", "transport-io", "transport-child-process"] } @@ -192,7 +192,7 @@ version = "2.4" [package] name = "lime" -version = "1.0.1" +version = "1.1.0" description = "AI API Proxy Desktop App" authors = ["you"] edition = "2021" diff --git a/src-tauri/crates/agent/src/protocol.rs b/src-tauri/crates/agent/src/protocol.rs index 9ebb302e6..82c6ded4c 100644 --- a/src-tauri/crates/agent/src/protocol.rs +++ b/src-tauri/crates/agent/src/protocol.rs @@ -317,6 +317,7 @@ pub struct AgentUserInputOp { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(tag = "type", rename_all = "snake_case")] +#[allow(clippy::large_enum_variant)] pub enum AgentOp { UserInput(AgentUserInputOp), Interrupt { diff --git a/src-tauri/crates/agent/src/request_tool_policy.rs b/src-tauri/crates/agent/src/request_tool_policy.rs index 346dfef88..65b019da4 100644 --- a/src-tauri/crates/agent/src/request_tool_policy.rs +++ b/src-tauri/crates/agent/src/request_tool_policy.rs @@ -1179,7 +1179,7 @@ async fn stream_agent_reply_once( where F: FnMut(&RuntimeAgentEvent), { - let mut auto_compaction_projection = AutoCompactionProjectionState::default(); + let mut auto_compaction_projection = AutoCompactionProjectionState; let mut stream = agent .reply(user_message, session_config, cancel_token) .await diff --git a/src-tauri/crates/agent/src/session_execution_runtime.rs b/src-tauri/crates/agent/src/session_execution_runtime.rs index 18ad84fa1..d2dfa1d63 100644 --- a/src-tauri/crates/agent/src/session_execution_runtime.rs +++ b/src-tauri/crates/agent/src/session_execution_runtime.rs @@ -91,14 +91,14 @@ impl SessionExecutionRuntimeAccessMode { Self::from_extension_data(&session.extension_data) } - fn to_extension_data(&self, extension_data: &mut ExtensionData) -> Result<(), String> { - ::to_extension_data(self, extension_data) + fn write_extension_data(self, extension_data: &mut ExtensionData) -> Result<(), String> { + ::to_extension_data(&self, extension_data) .map_err(|error| error.to_string()) } fn into_updated_extension_data(self, session: &Session) -> Result { let mut extension_data = session.extension_data.clone(); - self.to_extension_data(&mut extension_data)?; + self.write_extension_data(&mut extension_data)?; Ok(extension_data) } diff --git a/src-tauri/crates/agent/src/session_store.rs b/src-tauri/crates/agent/src/session_store.rs index b38cb395a..420714f09 100644 --- a/src-tauri/crates/agent/src/session_store.rs +++ b/src-tauri/crates/agent/src/session_store.rs @@ -4,11 +4,9 @@ //! 数据事实源收敛到 lime_core::database::agent_session_repository + Lime 数据库。 use aster::model::ModelConfig; -use aster::session::extension_data::{ - resolve_todo_list_state, ExtensionState, TodoListItem, TodoListItemStatus, -}; use aster::session::{ - resolve_subagent_session_metadata, Session as AsterSession, SessionRuntimeSnapshot, + resolve_subagent_session_metadata, resolve_task_board_state, ExtensionState, + Session as AsterSession, SessionRuntimeSnapshot, TaskBoardItem, TaskBoardItemStatus, }; use chrono::Utc; use lime_core::agent::types::{AgentMessage, AgentSession, ContentPart, MessageContent}; @@ -150,6 +148,7 @@ pub struct ChildSubagentSession { } impl ChildSubagentSession { + #[allow(clippy::too_many_arguments)] fn new_base( id: String, name: String, @@ -329,21 +328,21 @@ fn resolve_session_provider_selector(session: &AsterSession) -> Option { .and_then(|state| normalize_optional_text(Some(state.provider_selector))) } -fn map_session_todo_status(status: TodoListItemStatus) -> SessionTodoStatus { +fn map_session_todo_status(status: TaskBoardItemStatus) -> SessionTodoStatus { match status { - TodoListItemStatus::Pending => SessionTodoStatus::Pending, - TodoListItemStatus::InProgress => SessionTodoStatus::InProgress, - TodoListItemStatus::Completed => SessionTodoStatus::Completed, + TaskBoardItemStatus::Pending => SessionTodoStatus::Pending, + TaskBoardItemStatus::InProgress => SessionTodoStatus::InProgress, + TaskBoardItemStatus::Completed => SessionTodoStatus::Completed, } } -fn map_session_todo_item(item: TodoListItem) -> Option { - let content = item.content.trim().to_string(); +fn map_session_todo_item(item: TaskBoardItem) -> Option { + let content = item.subject.trim().to_string(); if content.is_empty() { return None; } - let active_form = normalize_optional_nonempty_body(Some(item.active_form)); + let active_form = normalize_optional_nonempty_body(item.active_form); Some(SessionTodoItem { content, status: map_session_todo_status(item.status), @@ -367,9 +366,9 @@ fn load_session_todo_items_from_conn( } }; - resolve_todo_list_state(&extension_data) - .map(|todo_list| { - todo_list + resolve_task_board_state(&extension_data) + .map(|task_board| { + task_board .items .into_iter() .filter_map(map_session_todo_item) diff --git a/src-tauri/crates/agent/src/tool_permissions.rs b/src-tauri/crates/agent/src/tool_permissions.rs index d23ab2684..a37ab4c8c 100644 --- a/src-tauri/crates/agent/src/tool_permissions.rs +++ b/src-tauri/crates/agent/src/tool_permissions.rs @@ -17,7 +17,7 @@ pub enum ToolRiskLevel { Destructive, } -/// 权限检查结果(对标 Claude Code 的 allow/deny/ask) +/// 权限检查结果(统一到当前工具面的 allow/deny/ask 语义) #[derive(Debug, Clone, PartialEq)] pub enum PermissionBehavior { Allow, diff --git a/src-tauri/crates/browser-runtime/src/action.rs b/src-tauri/crates/browser-runtime/src/action.rs index fc62e217c..c7fb9589c 100644 --- a/src-tauri/crates/browser-runtime/src/action.rs +++ b/src-tauri/crates/browser-runtime/src/action.rs @@ -397,7 +397,7 @@ fn page_info_from_event(event: &BrowserEvent) -> Option { async fn capture_and_sync_page_info(session: &CdpSessionHandle) -> Option { let page_info = session.capture_page_info().await.ok()?; let current_page_info = session.state().await.last_page_info; - let should_update = current_page_info.as_ref().map_or(true, |current| { + let should_update = current_page_info.as_ref().is_none_or(|current| { current.url != page_info.url || current.title != page_info.title || current.markdown != page_info.markdown diff --git a/src-tauri/crates/services/src/model_registry_service.rs b/src-tauri/crates/services/src/model_registry_service.rs index c9f2d4364..3d2bb9c85 100644 --- a/src-tauri/crates/services/src/model_registry_service.rs +++ b/src-tauri/crates/services/src/model_registry_service.rs @@ -103,6 +103,22 @@ struct HostAliasRule { providers: Vec, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ModelFetchProtocol { + OpenAiCompatible, + Anthropic, + Gemini, + Ollama, + Unsupported, +} + +#[derive(Debug, Clone)] +struct PreparedModelFetchRequest { + protocol: ModelFetchProtocol, + url: String, + headers: Vec<(String, String)>, +} + /// 模型注册服务 pub struct ModelRegistryService { /// 数据库连接 @@ -860,6 +876,28 @@ impl ModelRegistryService { // ========== 从 Provider API 获取模型 ========== + pub fn requires_api_key_for_model_fetch( + provider_id: &str, + api_host: &str, + provider_type: ApiProviderType, + ) -> bool { + match provider_type { + ApiProviderType::Ollama => false, + ApiProviderType::Openai + | ApiProviderType::OpenaiResponse + | ApiProviderType::Codex + | ApiProviderType::NewApi + | ApiProviderType::Gateway + | ApiProviderType::Fal => !Self::is_keyless_openai_like_provider(provider_id, api_host), + ApiProviderType::Anthropic + | ApiProviderType::AnthropicCompatible + | ApiProviderType::Gemini + | ApiProviderType::AzureOpenai + | ApiProviderType::Vertexai + | ApiProviderType::AwsBedrock => true, + } + } + /// 从 Provider API 获取模型列表 /// /// 调用 Provider 的 /v1/models 端点获取模型列表, @@ -902,14 +940,20 @@ impl ModelRegistryService { api_host ); - // 构建 API URL - let api_url = Self::build_models_api_url(api_host); - tracing::info!("[ModelRegistry] API URL: {}", api_url); - let diagnostic_hint = Self::build_models_api_hint(provider_id, api_host, &api_url); + let api_url = Self::build_diagnostic_models_api_url(provider_id, api_host, provider_type); + if let Some(url) = api_url.as_ref() { + tracing::info!("[ModelRegistry] API URL: {}", url); + } + let diagnostic_hint = api_url + .as_ref() + .and_then(|url| Self::build_models_api_hint(provider_id, api_host, url)); // 尝试从 API 获取 - match self.call_models_api(&api_url, api_key).await { - Ok(api_models) => { + match self + .call_models_api(provider_id, api_host, api_key, provider_type) + .await + { + Ok((api_models, request_url)) => { tracing::info!("[ModelRegistry] 从 API 获取到 {} 个模型", api_models.len()); // 转换为内部格式 @@ -923,7 +967,7 @@ impl ModelRegistryService { models, source: ModelFetchSource::Api, error: None, - request_url: Some(api_url), + request_url: Some(request_url), diagnostic_hint: None, error_kind: None, should_prompt_error: false, @@ -950,7 +994,7 @@ impl ModelRegistryService { models: vec![], source: ModelFetchSource::LocalFallback, error: Some(format!("API 获取失败: {}, 本地也无数据", api_error.message)), - request_url: Some(api_url), + request_url: api_url.clone(), diagnostic_hint, error_kind: Some(api_error.kind.clone()), should_prompt_error: Self::should_prompt_model_fetch_error(&api_error.kind), @@ -963,7 +1007,7 @@ impl ModelRegistryService { "API 获取失败: {}, 已使用本地数据", api_error.message )), - request_url: Some(api_url), + request_url: api_url, diagnostic_hint, error_kind: Some(api_error.kind.clone()), should_prompt_error: Self::should_prompt_model_fetch_error(&api_error.kind), @@ -1299,7 +1343,74 @@ impl ModelRegistryService { } } - /// 构建 /v1/models API URL + fn is_keyless_openai_like_provider(provider_id: &str, api_host: &str) -> bool { + let normalized_provider_id = provider_id.trim().to_lowercase(); + if matches!( + normalized_provider_id.as_str(), + "ollama" | "lmstudio" | "gpustack" | "ovms" + ) { + return true; + } + + let normalized_host = api_host.trim().to_lowercase(); + matches!( + normalized_host.as_str(), + host if host.contains("://localhost") + || host.contains("://127.0.0.1") + || host.contains("://0.0.0.0") + || host.contains("://host.docker.internal") + ) + } + + fn resolve_model_fetch_protocol( + provider_id: &str, + api_host: &str, + provider_type: Option, + ) -> ModelFetchProtocol { + if let Some(provider_type) = provider_type { + return match provider_type { + ApiProviderType::Openai + | ApiProviderType::OpenaiResponse + | ApiProviderType::Codex + | ApiProviderType::NewApi + | ApiProviderType::Gateway + | ApiProviderType::Fal => ModelFetchProtocol::OpenAiCompatible, + ApiProviderType::Anthropic | ApiProviderType::AnthropicCompatible => { + ModelFetchProtocol::Anthropic + } + ApiProviderType::Gemini => ModelFetchProtocol::Gemini, + ApiProviderType::Ollama => ModelFetchProtocol::Ollama, + ApiProviderType::AzureOpenai + | ApiProviderType::Vertexai + | ApiProviderType::AwsBedrock => ModelFetchProtocol::Unsupported, + }; + } + + let normalized_provider = provider_id.trim().to_lowercase(); + let normalized_host = api_host.trim().to_lowercase(); + + if normalized_provider == "ollama" + || normalized_host.contains("ollama") + || normalized_host.contains("://localhost:11434") + { + return ModelFetchProtocol::Ollama; + } + + if normalized_provider.contains("gemini") + || normalized_provider == "google" + || normalized_host.contains("generativelanguage.googleapis.com") + { + return ModelFetchProtocol::Gemini; + } + + if normalized_provider.contains("anthropic") || normalized_host.contains("anthropic.com") { + return ModelFetchProtocol::Anthropic; + } + + ModelFetchProtocol::OpenAiCompatible + } + + /// 构建模型枚举 API URL fn build_models_api_url(api_host: &str) -> String { let host = api_host.trim_end_matches('/'); @@ -1320,6 +1431,60 @@ impl ModelRegistryService { } } + fn build_gemini_models_api_url(api_host: &str) -> String { + let host = api_host.trim_end_matches('/'); + + if host.ends_with("/models") { + return host.to_string(); + } + + if host.ends_with("/v1beta") || host.ends_with("/v1") { + return format!("{host}/models"); + } + + if host.contains("/v1beta/") || host.contains("/v1/") { + return format!("{}/models", host.trim_end_matches('/')); + } + + format!("{host}/v1beta/models") + } + + fn build_ollama_models_api_url(api_host: &str) -> String { + let host = api_host.trim_end_matches('/'); + + if host.ends_with("/api/tags") { + return host.to_string(); + } + + if host.ends_with("/api") { + return format!("{host}/tags"); + } + + format!("{host}/api/tags") + } + + fn build_diagnostic_models_api_url( + provider_id: &str, + api_host: &str, + provider_type: Option, + ) -> Option { + let host = api_host.trim(); + if host.is_empty() { + return None; + } + + let url = match Self::resolve_model_fetch_protocol(provider_id, api_host, provider_type) { + ModelFetchProtocol::Gemini => Self::build_gemini_models_api_url(host), + ModelFetchProtocol::Ollama => Self::build_ollama_models_api_url(host), + ModelFetchProtocol::Anthropic | ModelFetchProtocol::OpenAiCompatible => { + Self::build_models_api_url(host) + } + ModelFetchProtocol::Unsupported => host.trim_end_matches('/').to_string(), + }; + + Some(url) + } + fn has_versioned_api_suffix(api_host: &str) -> bool { let path = api_host .split_once("://") @@ -1333,14 +1498,12 @@ impl ModelRegistryService { .split('/') .filter(|segment| !segment.is_empty()) .collect(); - if segments.len() < 2 { + if segments.is_empty() { return false; } let version = segments[segments.len() - 1]; - let api_segment = segments[segments.len() - 2]; - api_segment.eq_ignore_ascii_case("api") - && version.starts_with('v') + version.starts_with('v') && version .strip_prefix('v') .map(|suffix| !suffix.is_empty() && suffix.chars().all(|ch| ch.is_ascii_digit())) @@ -1361,6 +1524,12 @@ impl ModelRegistryService { )); } + if provider.contains("zhipu") || host.contains("bigmodel.cn/api/paas") { + return Some(format!( + "智谱 GLM 官方 OpenAI 兼容 Base URL 通常应使用 `https://open.bigmodel.cn/api/paas/v4`。当前模型列表请求为 `{api_url}`,如果出现 404,请优先检查 Base URL 是否保留 `/api/paas/v4`,不要再额外改成 `/v1` 风格地址。" + )); + } + None } @@ -1373,12 +1542,78 @@ impl ModelRegistryService { ) } - /// 调用 /v1/models API + fn prepare_model_fetch_request( + provider_id: &str, + api_host: &str, + api_key: &str, + provider_type: Option, + ) -> Result { + let protocol = Self::resolve_model_fetch_protocol(provider_id, api_host, provider_type); + let normalized_host = api_host.trim(); + + if normalized_host.is_empty() { + return Err(ModelsApiError::new( + ModelFetchErrorKind::Other, + "Provider 没有配置 API Host".to_string(), + )); + } + + if protocol == ModelFetchProtocol::Unsupported { + return Err(ModelsApiError::new( + ModelFetchErrorKind::Other, + "当前协议暂不支持自动获取最新模型".to_string(), + )); + } + + let request_type = provider_type.unwrap_or(match protocol { + ModelFetchProtocol::Anthropic => ApiProviderType::Anthropic, + ModelFetchProtocol::Gemini => ApiProviderType::Gemini, + ModelFetchProtocol::Ollama => ApiProviderType::Ollama, + ModelFetchProtocol::OpenAiCompatible | ModelFetchProtocol::Unsupported => { + ApiProviderType::Openai + } + }); + + let url = match protocol { + ModelFetchProtocol::OpenAiCompatible | ModelFetchProtocol::Anthropic => { + Self::build_models_api_url(normalized_host) + } + ModelFetchProtocol::Gemini => Self::build_gemini_models_api_url(normalized_host), + ModelFetchProtocol::Ollama => Self::build_ollama_models_api_url(normalized_host), + ModelFetchProtocol::Unsupported => unreachable!(), + }; + + let mut headers = vec![("Content-Type".to_string(), "application/json".to_string())]; + let runtime_spec = request_type.runtime_spec(); + if !api_key.trim().is_empty() { + let auth_value = runtime_spec + .auth_prefix + .map(|prefix| format!("{prefix} {api_key}")) + .unwrap_or_else(|| api_key.to_string()); + headers.push((runtime_spec.auth_header.to_string(), auth_value)); + } + + for (name, value) in runtime_spec.extra_headers { + headers.push(((*name).to_string(), (*value).to_string())); + } + + Ok(PreparedModelFetchRequest { + protocol, + url, + headers, + }) + } + async fn call_models_api( &self, - url: &str, + provider_id: &str, + api_host: &str, api_key: &str, - ) -> Result, ModelsApiError> { + provider_type: Option, + ) -> Result<(Vec, String), ModelsApiError> { + let request = + Self::prepare_model_fetch_request(provider_id, api_host, api_key, provider_type)?; + let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(30)) .build() @@ -1389,15 +1624,42 @@ impl ModelRegistryService { ) })?; - let response = client - .get(url) - .header("Authorization", format!("Bearer {api_key}")) - .header("Content-Type", "application/json") - .send() - .await - .map_err(|e| { - ModelsApiError::new(ModelFetchErrorKind::Network, format!("请求失败: {e}")) - })?; + let models = match request.protocol { + ModelFetchProtocol::OpenAiCompatible => { + let body = + Self::send_models_api_request(&client, &request.url, &request.headers).await?; + Self::parse_openai_models_response(&body)? + } + ModelFetchProtocol::Anthropic => { + Self::call_anthropic_models_api(&client, &request.url, &request.headers).await? + } + ModelFetchProtocol::Gemini => { + Self::call_gemini_models_api(&client, &request.url, &request.headers).await? + } + ModelFetchProtocol::Ollama => { + let body = + Self::send_models_api_request(&client, &request.url, &request.headers).await?; + Self::parse_ollama_models_response(&body)? + } + ModelFetchProtocol::Unsupported => unreachable!(), + }; + + Ok((models, request.url)) + } + + async fn send_models_api_request( + client: &reqwest::Client, + url: &str, + headers: &[(String, String)], + ) -> Result { + let mut request_builder = client.get(url); + for (name, value) in headers { + request_builder = request_builder.header(name, value); + } + + let response = request_builder.send().await.map_err(|e| { + ModelsApiError::new(ModelFetchErrorKind::Network, format!("请求失败: {e}")) + })?; if !response.status().is_success() { let status = response.status(); @@ -1426,15 +1688,16 @@ impl ModelRegistryService { )); } - let body = response.text().await.map_err(|e| { + response.text().await.map_err(|e| { ModelsApiError::new( ModelFetchErrorKind::InvalidResponse, format!("读取响应失败: {e}"), ) - })?; + }) + } - // 解析 OpenAI 格式的响应 - let api_response: ApiModelsResponse = serde_json::from_str(&body).map_err(|e| { + fn parse_openai_models_response(body: &str) -> Result, ModelsApiError> { + let api_response: ApiModelsResponse = serde_json::from_str(body).map_err(|e| { ModelsApiError::new( ModelFetchErrorKind::InvalidResponse, format!("解析响应失败: {e}"), @@ -1444,6 +1707,172 @@ impl ModelRegistryService { Ok(api_response.data) } + async fn call_anthropic_models_api( + client: &reqwest::Client, + base_url: &str, + headers: &[(String, String)], + ) -> Result, ModelsApiError> { + let mut models = Vec::new(); + let mut after_id: Option = None; + + loop { + let mut request_url = reqwest::Url::parse(base_url).map_err(|e| { + ModelsApiError::new( + ModelFetchErrorKind::Other, + format!("无效的 Anthropic 模型地址: {e}"), + ) + })?; + request_url.query_pairs_mut().append_pair("limit", "1000"); + if let Some(after) = after_id.as_deref() { + request_url.query_pairs_mut().append_pair("after_id", after); + } + + let body = Self::send_models_api_request(client, request_url.as_ref(), headers).await?; + let response = Self::parse_anthropic_models_response(&body)?; + models.extend(response.models); + + if !response.has_more { + break; + } + + let Some(next_after_id) = response.last_id else { + break; + }; + if next_after_id.trim().is_empty() { + break; + } + after_id = Some(next_after_id); + } + + Ok(models) + } + + fn parse_anthropic_models_response( + body: &str, + ) -> Result { + let response: RawAnthropicModelsResponse = serde_json::from_str(body).map_err(|e| { + ModelsApiError::new( + ModelFetchErrorKind::InvalidResponse, + format!("解析 Anthropic 响应失败: {e}"), + ) + })?; + + let models = response + .data + .into_iter() + .map(|model| ApiModelResponse { + id: model.id.clone(), + display_name: model.display_name, + provider_name: None, + family: None, + context_length: None, + }) + .collect(); + + Ok(AnthropicModelsResponse { + models, + has_more: response.has_more, + last_id: response.last_id, + }) + } + + async fn call_gemini_models_api( + client: &reqwest::Client, + base_url: &str, + headers: &[(String, String)], + ) -> Result, ModelsApiError> { + let mut models = Vec::new(); + let mut next_page_token: Option = None; + + loop { + let mut request_url = reqwest::Url::parse(base_url).map_err(|e| { + ModelsApiError::new( + ModelFetchErrorKind::Other, + format!("无效的 Gemini 模型地址: {e}"), + ) + })?; + request_url + .query_pairs_mut() + .append_pair("pageSize", "1000"); + if let Some(page_token) = next_page_token.as_deref() { + request_url + .query_pairs_mut() + .append_pair("pageToken", page_token); + } + + let body = Self::send_models_api_request(client, request_url.as_ref(), headers).await?; + let response = Self::parse_gemini_models_response(&body)?; + models.extend(response.models); + + let Some(page_token) = response.next_page_token else { + break; + }; + if page_token.trim().is_empty() { + break; + } + next_page_token = Some(page_token); + } + + Ok(models) + } + + fn parse_gemini_models_response(body: &str) -> Result { + let response: RawGeminiModelsResponse = serde_json::from_str(body).map_err(|e| { + ModelsApiError::new( + ModelFetchErrorKind::InvalidResponse, + format!("解析 Gemini 响应失败: {e}"), + ) + })?; + + let models = response + .models + .into_iter() + .filter(|model| { + model + .supported_generation_methods + .as_ref() + .is_none_or(|methods| { + methods + .iter() + .any(|method| method.eq_ignore_ascii_case("generateContent")) + }) + }) + .map(|model| ApiModelResponse { + id: model.name.trim_start_matches("models/").to_string(), + display_name: model.display_name, + provider_name: None, + family: None, + context_length: model.input_token_limit, + }) + .collect(); + + Ok(GeminiModelsResponse { + models, + next_page_token: response.next_page_token, + }) + } + + fn parse_ollama_models_response(body: &str) -> Result, ModelsApiError> { + let response: OllamaModelsResponse = serde_json::from_str(body).map_err(|e| { + ModelsApiError::new( + ModelFetchErrorKind::InvalidResponse, + format!("解析 Ollama 响应失败: {e}"), + ) + })?; + + Ok(response + .models + .into_iter() + .map(|model| ApiModelResponse { + id: model.name.clone(), + display_name: Some(model.name), + provider_name: None, + family: model.details.and_then(|details| details.family), + context_length: None, + }) + .collect()) + } + /// 转换 API 模型格式为内部格式 fn convert_api_model( &self, @@ -1451,20 +1880,23 @@ impl ModelRegistryService { provider_id: &str, now: i64, ) -> EnhancedModelMetadata { - // 从 model id 推断显示名称 - let display_name = model - .id - .split('/') - .next_back() - .unwrap_or(&model.id) - .to_string(); + let display_name = model.display_name.unwrap_or_else(|| { + model + .id + .split('/') + .next_back() + .unwrap_or(&model.id) + .to_string() + }); EnhancedModelMetadata { id: model.id.clone(), display_name, provider_id: provider_id.to_string(), - provider_name: model.owned_by.unwrap_or_else(|| provider_id.to_string()), - family: None, + provider_name: model + .provider_name + .unwrap_or_else(|| provider_id.to_string()), + family: model.family, tier: ModelTier::Pro, capabilities: ModelCapabilities { vision: false, @@ -1519,11 +1951,85 @@ struct ApiModelsResponse { struct ApiModelResponse { id: String, #[serde(default)] - owned_by: Option, + display_name: Option, + #[serde(default, alias = "owned_by")] + provider_name: Option, + #[serde(default)] + family: Option, #[serde(default)] context_length: Option, } +#[derive(Debug)] +struct AnthropicModelsResponse { + models: Vec, + has_more: bool, + last_id: Option, +} + +#[derive(Debug, Deserialize)] +struct RawAnthropicModelsResponse { + #[serde(default)] + data: Vec, + #[serde(default)] + has_more: bool, + #[serde(default)] + last_id: Option, +} + +#[derive(Debug, Deserialize)] +struct RawAnthropicModelResponse { + id: String, + #[serde(default)] + display_name: Option, +} + +#[derive(Debug)] +struct GeminiModelsResponse { + models: Vec, + next_page_token: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RawGeminiModelsResponse { + #[serde(default)] + models: Vec, + #[serde(default)] + next_page_token: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RawGeminiModelResponse { + name: String, + #[serde(default)] + display_name: Option, + #[serde(default)] + input_token_limit: Option, + #[serde(default)] + supported_generation_methods: Option>, +} + +#[derive(Debug, Deserialize)] +struct OllamaModelsResponse { + #[serde(default)] + models: Vec, +} + +#[derive(Debug, Deserialize)] +struct OllamaModelResponse { + name: String, + #[serde(default)] + details: Option, +} + +#[derive(Debug, Deserialize)] +struct OllamaModelDetails { + #[serde(default)] + family: Option, +} + /// 模型获取来源 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub enum ModelFetchSource { @@ -1566,7 +2072,7 @@ pub struct FetchModelsResult { #[cfg(test)] mod tests { - use super::{HostAliasRule, ModelRegistryService}; + use super::{HostAliasRule, ModelFetchProtocol, ModelRegistryService}; use lime_core::database::dao::api_key_provider::ApiProviderType; use lime_core::database::DbConnection; use rusqlite::Connection; @@ -1587,6 +2093,10 @@ mod tests { ModelRegistryService::build_models_api_url("https://open.bigmodel.cn/api/anthropic"), "https://open.bigmodel.cn/api/anthropic/v1/models" ); + assert_eq!( + ModelRegistryService::build_models_api_url("https://open.bigmodel.cn/api/paas/v4/"), + "https://open.bigmodel.cn/api/paas/v4/models" + ); assert_eq!( ModelRegistryService::build_models_api_url("https://ark.cn-beijing.volces.com/api/v3/"), "https://ark.cn-beijing.volces.com/api/v3/models" @@ -1597,6 +2107,34 @@ mod tests { ); } + #[test] + fn test_build_gemini_models_api_url() { + assert_eq!( + ModelRegistryService::build_gemini_models_api_url( + "https://generativelanguage.googleapis.com" + ), + "https://generativelanguage.googleapis.com/v1beta/models" + ); + assert_eq!( + ModelRegistryService::build_gemini_models_api_url( + "https://generativelanguage.googleapis.com/v1beta" + ), + "https://generativelanguage.googleapis.com/v1beta/models" + ); + } + + #[test] + fn test_build_ollama_models_api_url() { + assert_eq!( + ModelRegistryService::build_ollama_models_api_url("http://localhost:11434"), + "http://localhost:11434/api/tags" + ); + assert_eq!( + ModelRegistryService::build_ollama_models_api_url("http://localhost:11434/api"), + "http://localhost:11434/api/tags" + ); + } + #[test] fn test_build_models_api_hint_for_doubao() { let hint = ModelRegistryService::build_models_api_hint( @@ -1611,6 +2149,20 @@ mod tests { .contains("https://ark.cn-beijing.volces.com/api/v3")); } + #[test] + fn test_build_models_api_hint_for_zhipu() { + let hint = ModelRegistryService::build_models_api_hint( + "zhipu", + "https://open.bigmodel.cn/api/paas/v4", + "https://open.bigmodel.cn/api/paas/v4/models", + ); + + assert!(hint.is_some()); + assert!(hint + .unwrap() + .contains("https://open.bigmodel.cn/api/paas/v4")); + } + fn create_service_with_resource_dir(resource_dir: std::path::PathBuf) -> ModelRegistryService { let conn = Connection::open_in_memory().expect("in-memory db"); let db: DbConnection = Arc::new(Mutex::new(conn)); @@ -1710,4 +2262,118 @@ mod tests { ["google"] ); } + + #[test] + fn test_requires_api_key_for_model_fetch() { + assert!(ModelRegistryService::requires_api_key_for_model_fetch( + "openai", + "https://api.openai.com", + ApiProviderType::Openai + )); + assert!(!ModelRegistryService::requires_api_key_for_model_fetch( + "ollama", + "http://localhost:11434", + ApiProviderType::Ollama + )); + assert!(!ModelRegistryService::requires_api_key_for_model_fetch( + "lmstudio", + "http://127.0.0.1:1234/v1", + ApiProviderType::Openai + )); + } + + #[test] + fn test_resolve_model_fetch_protocol() { + assert_eq!( + ModelRegistryService::resolve_model_fetch_protocol( + "google", + "https://generativelanguage.googleapis.com", + Some(ApiProviderType::Gemini) + ), + ModelFetchProtocol::Gemini + ); + assert_eq!( + ModelRegistryService::resolve_model_fetch_protocol( + "anthropic", + "https://api.anthropic.com", + Some(ApiProviderType::Anthropic) + ), + ModelFetchProtocol::Anthropic + ); + assert_eq!( + ModelRegistryService::resolve_model_fetch_protocol( + "azure-openai", + "https://example.openai.azure.com", + Some(ApiProviderType::AzureOpenai) + ), + ModelFetchProtocol::Unsupported + ); + } + + #[test] + fn test_parse_anthropic_models_response() { + let response = ModelRegistryService::parse_anthropic_models_response( + r#"{ + "data": [ + { "id": "claude-sonnet-4-5", "display_name": "Claude Sonnet 4.5" } + ], + "has_more": false, + "last_id": "claude-sonnet-4-5" + }"#, + ) + .expect("parse anthropic response"); + + assert_eq!(response.models.len(), 1); + assert_eq!(response.models[0].id, "claude-sonnet-4-5"); + assert_eq!( + response.models[0].display_name.as_deref(), + Some("Claude Sonnet 4.5") + ); + } + + #[test] + fn test_parse_gemini_models_response() { + let response = ModelRegistryService::parse_gemini_models_response( + r#"{ + "models": [ + { + "name": "models/gemini-2.5-pro", + "displayName": "Gemini 2.5 Pro", + "inputTokenLimit": 1048576, + "supportedGenerationMethods": ["generateContent"] + }, + { + "name": "models/text-embedding-004", + "displayName": "Embedding", + "supportedGenerationMethods": ["embedContent"] + } + ], + "nextPageToken": "next-page" + }"#, + ) + .expect("parse gemini response"); + + assert_eq!(response.models.len(), 1); + assert_eq!(response.models[0].id, "gemini-2.5-pro"); + assert_eq!(response.next_page_token.as_deref(), Some("next-page")); + } + + #[test] + fn test_parse_ollama_models_response() { + let models = ModelRegistryService::parse_ollama_models_response( + r#"{ + "models": [ + { + "name": "qwen3:14b", + "details": { "family": "qwen3" } + } + ] + }"#, + ) + .expect("parse ollama response"); + + assert_eq!(models.len(), 1); + assert_eq!(models[0].id, "qwen3:14b"); + assert_eq!(models[0].family.as_deref(), Some("qwen3")); + } } diff --git a/src-tauri/resources/models/aliases/codex.json b/src-tauri/resources/models/aliases/codex.json index a40ce2812..7ae999243 100644 --- a/src-tauri/resources/models/aliases/codex.json +++ b/src-tauri/resources/models/aliases/codex.json @@ -4,29 +4,20 @@ "description": "OpenAI Codex CLI 支持的模型", "models": [ "gpt-5.3-codex", - "gpt-5.2-codex", - "gpt-5.1-codex-max", - "gpt-5.1-codex-mini", - "gpt-5.2" + "gpt-5.4" ], "aliases": { + "gpt-5.4": { + "actual": "gpt-5.4", + "internal_name": "gpt-5.4", + "provider": "openai", + "description": "最新前沿模型,跨知识、推理和编码的全面提升" + }, "gpt-5.3-codex": { "actual": "gpt-5.3-codex", "internal_name": "gpt-5.3-codex", "provider": "openai", "description": "Codex 最新一代模型,编码与推理能力增强" - }, - "gpt-5.2-codex": { - "actual": "gpt-5.2-codex", - "internal_name": "gpt-5.2-codex", - "provider": "openai", - "description": "最新前沿模型,跨知识、推理和编码的全面提升" - }, - "gpt-5.2": { - "actual": "gpt-5.2", - "internal_name": "gpt-5.2", - "provider": "openai", - "description": "最新前沿模型,跨知识、推理和编码的全面提升" } }, "updated_at": "2026-02-11T00:00:00Z" diff --git a/src-tauri/src/app/runner.rs b/src-tauri/src/app/runner.rs index 2445e26c1..9db9cc27e 100644 --- a/src-tauri/src/app/runner.rs +++ b/src-tauri/src/app/runner.rs @@ -235,6 +235,7 @@ pub fn run() { .manage(automation_service_state) .manage(commands::subagent_cmd::SubAgentSchedulerState::default()) .manage(commands::websocket_cmd::WsServiceState::default()) + .manage(crate::services::companion_service::CompanionServiceState::default()) .manage(lime_gateway::telegram::TelegramGatewayState::default()) .manage(lime_gateway::discord::DiscordGatewayState::default()) .manage(lime_gateway::feishu::FeishuGatewayState::default()) @@ -444,6 +445,23 @@ pub fn run() { }); } + { + let app_handle = app.handle().clone(); + let companion_state = app + .state::() + .inner() + .clone(); + + tauri::async_runtime::spawn(async move { + match companion_state.start(app_handle).await { + Ok(()) => tracing::info!("[启动] Companion Pet 服务已启动"), + Err(error) => { + tracing::warn!("[启动] Companion Pet 服务启动失败: {}", error) + } + } + }); + } + #[cfg(debug_assertions)] { let app_handle = app.handle().clone(); @@ -1129,6 +1147,10 @@ pub fn run() { // Path utility commands commands::config_cmd::expand_path, commands::config_cmd::open_auth_dir, + // Companion commands + commands::companion_cmd::companion_get_pet_status, + commands::companion_cmd::companion_launch_pet, + commands::companion_cmd::companion_send_pet_command, // OpenClaw commands commands::openclaw_cmd::openclaw_check_installed, commands::openclaw_cmd::openclaw_get_environment_status, diff --git a/src-tauri/src/commands/aster_agent_cmd/dto.rs b/src-tauri/src/commands/aster_agent_cmd/dto.rs index c5e217655..76326a8dc 100644 --- a/src-tauri/src/commands/aster_agent_cmd/dto.rs +++ b/src-tauri/src/commands/aster_agent_cmd/dto.rs @@ -1469,6 +1469,10 @@ pub struct AgentRuntimeSpawnSubagentRequest { #[serde(alias = "parentSessionId")] pub parent_session_id: String, pub message: String, + #[serde(default)] + pub name: Option, + #[serde(default, alias = "teamName")] + pub team_name: Option, #[serde(default, alias = "agentType")] pub agent_type: Option, #[serde(default)] @@ -1499,6 +1503,8 @@ pub struct AgentRuntimeSpawnSubagentRequest { pub system_overlay: Option, #[serde(default, alias = "outputContract")] pub output_contract: Option, + #[serde(default)] + pub cwd: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -1731,6 +1737,26 @@ mod tests { (Utc::now() - Duration::seconds(seconds)).to_rfc3339() } + #[test] + fn spawn_subagent_request_should_parse_current_fields() { + let request: AgentRuntimeSpawnSubagentRequest = serde_json::from_value(serde_json::json!({ + "parentSessionId": "parent-1", + "message": "检查当前工具面对齐情况", + "name": "verifier", + "teamName": "delivery-team", + "agentType": "explorer", + "cwd": "/tmp/workspace" + })) + .expect("spawn subagent request should deserialize"); + + assert_eq!(request.parent_session_id, "parent-1"); + assert_eq!(request.message, "检查当前工具面对齐情况"); + assert_eq!(request.name.as_deref(), Some("verifier")); + assert_eq!(request.team_name.as_deref(), Some("delivery-team")); + assert_eq!(request.agent_type.as_deref(), Some("explorer")); + assert_eq!(request.cwd.as_deref(), Some("/tmp/workspace")); + } + #[test] fn thread_read_should_expose_pending_request_and_waiting_incident() { let detail = build_session_detail( diff --git a/src-tauri/src/commands/aster_agent_cmd/mod.rs b/src-tauri/src/commands/aster_agent_cmd/mod.rs index c403e65b4..d2f6f905e 100644 --- a/src-tauri/src/commands/aster_agent_cmd/mod.rs +++ b/src-tauri/src/commands/aster_agent_cmd/mod.rs @@ -377,6 +377,7 @@ pub(crate) use session_runtime::{ resolve_session_recent_runtime_context, SessionRecentHarnessContext, SessionRecentRuntimeContext, }; +#[allow(unused_imports)] pub(crate) use subagent_runtime::{ agent_runtime_close_subagent_internal, agent_runtime_resume_subagent_internal, agent_runtime_send_subagent_input_internal, agent_runtime_spawn_subagent_internal, diff --git a/src-tauri/src/commands/aster_agent_cmd/prompt_context.rs b/src-tauri/src/commands/aster_agent_cmd/prompt_context.rs index f6bae2f12..28e25d6a8 100644 --- a/src-tauri/src/commands/aster_agent_cmd/prompt_context.rs +++ b/src-tauri/src/commands/aster_agent_cmd/prompt_context.rs @@ -769,7 +769,7 @@ pub(crate) fn build_team_preference_system_prompt( } lines.push( - "- spawn_agent 支持这些结构化字段:blueprintRoleId、blueprintRoleLabel、teamPresetId、profileId、profileName、roleKey、skillIds、skillDirectories、theme、systemOverlay、outputContract。" + "- spawn_agent 支持这些结构化字段:name、teamName、cwd、blueprintRoleId、blueprintRoleLabel、teamPresetId、profileId、profileName、roleKey、skillIds、skillDirectories、theme、systemOverlay、outputContract。teamName 需要与 name 搭配,并依附现有 team 上下文。" .to_string(), ); lines.push( diff --git a/src-tauri/src/commands/aster_agent_cmd/subagent_runtime.rs b/src-tauri/src/commands/aster_agent_cmd/subagent_runtime.rs index 3e1dbb16f..93129619b 100644 --- a/src-tauri/src/commands/aster_agent_cmd/subagent_runtime.rs +++ b/src-tauri/src/commands/aster_agent_cmd/subagent_runtime.rs @@ -9,6 +9,25 @@ const DEFAULT_WAIT_AGENT_TIMEOUT_MS: i64 = 30_000; const MIN_WAIT_AGENT_TIMEOUT_MS: i64 = 1_000; const MAX_WAIT_AGENT_TIMEOUT_MS: i64 = 300_000; +fn resolve_spawn_working_dir( + parent_working_dir: &std::path::Path, + requested_cwd: Option, +) -> Result { + let Some(cwd) = normalize_optional_text(requested_cwd) else { + return Ok(parent_working_dir.to_path_buf()); + }; + + let path = std::path::PathBuf::from(&cwd); + if !path.is_absolute() { + return Err("cwd 必须是绝对路径".to_string()); + } + if !path.is_dir() { + return Err(format!("cwd 不是有效目录: {cwd}")); + } + + Ok(path) +} + #[derive(Debug, Clone, Serialize)] struct SubagentStatusChangedEvent { #[serde(rename = "type")] @@ -153,12 +172,14 @@ fn normalize_optional_vec(values: &[String]) -> Vec { } fn build_subagent_session_name( + explicit_name: Option<&str>, message: &str, agent_type: Option<&str>, blueprint_role_label: Option<&str>, profile_name: Option<&str>, ) -> String { - normalize_optional_text(agent_type.map(ToString::to_string)) + normalize_optional_text(explicit_name.map(ToString::to_string)) + .or_else(|| normalize_optional_text(agent_type.map(ToString::to_string))) .or_else(|| normalize_optional_text(blueprint_role_label.map(ToString::to_string))) .or_else(|| normalize_optional_text(profile_name.map(ToString::to_string))) .or_else(|| build_subagent_task_summary(message)) @@ -169,12 +190,60 @@ fn resolve_subagent_role_hint( request: &AgentRuntimeSpawnSubagentRequest, customization: Option<&SubagentCustomizationState>, ) -> Option { - normalize_optional_text(request.agent_type.clone()) + normalize_optional_text(request.name.clone()) + .or_else(|| normalize_optional_text(request.agent_type.clone())) .or_else(|| customization.and_then(|state| state.blueprint_role_label.clone())) .or_else(|| customization.and_then(|state| state.profile_name.clone())) .or_else(|| customization.and_then(|state| state.role_key.clone())) } +async fn register_spawned_teammate( + parent_session_id: &str, + child_session_id: &str, + team_name: String, + teammate_name: String, + agent_type: Option, +) -> Result<(), String> { + let parent_session = read_session(parent_session_id, false, "读取父会话失败").await?; + let Some(mut team_state) = aster::session::TeamSessionState::from_session(&parent_session) + else { + return Err("当前 session 还没有 team 上下文,请先建立 team".to_string()); + }; + + if team_state.team_name != team_name { + return Err(format!( + "team_name 不匹配:当前 team 为 {},但请求的是 {}", + team_state.team_name, team_name + )); + } + if team_state.find_member_by_name(&teammate_name).is_some() { + return Err(format!("team 中已存在名为 {teammate_name} 的成员")); + } + + team_state.add_or_update_member(aster::session::TeamMember::teammate( + child_session_id.to_string(), + teammate_name.clone(), + agent_type.clone(), + )); + aster::session::save_team_state(parent_session_id, Some(team_state)) + .await + .map_err(|error| format!("更新 team 状态失败: {error}"))?; + aster::session::save_team_membership( + child_session_id, + Some(aster::session::TeamMembershipState { + team_name, + lead_session_id: parent_session_id.to_string(), + agent_id: child_session_id.to_string(), + name: teammate_name, + agent_type, + }), + ) + .await + .map_err(|error| format!("保存 team 成员信息失败: {error}"))?; + + Ok(()) +} + fn build_local_subagent_skill_payload( directory: &str, ) -> Result<(SubagentSkillSummary, SubagentSkillPromptBlock), String> { @@ -558,6 +627,11 @@ async fn create_runtime_subagent_session( let parent_session_id = normalize_required_text(&request.parent_session_id, "parent_session_id")?; let message = normalize_required_text(&request.message, "message")?; + let teammate_name = normalize_optional_text(request.name.clone()); + let team_name = normalize_optional_text(request.team_name.clone()); + if team_name.is_some() && teammate_name.is_none() { + return Err("team_name 需要同时提供 name".to_string()); + } enforce_team_spawn_limits(&parent_session_id).await?; let parent_session = read_session(&parent_session_id, false, "读取父会话失败").await?; let customization = build_subagent_customization_state(request)?; @@ -566,10 +640,13 @@ async fn create_runtime_subagent_session( .as_ref() .and_then(|state| state.profile_name.as_deref()); let role_hint = resolve_subagent_role_hint(request, customization.as_ref()); + let working_dir = + resolve_spawn_working_dir(parent_session.working_dir.as_path(), request.cwd.clone())?; let session = create_subagent_session( - parent_session.working_dir.clone(), + working_dir, build_subagent_session_name( + teammate_name.as_deref(), &message, request.agent_type.as_deref(), customization @@ -614,6 +691,16 @@ async fn create_runtime_subagent_session( "写入 subagent session metadata", ) .await?; + if let (Some(team_name), Some(teammate_name)) = (team_name, teammate_name.clone()) { + register_spawned_teammate( + &parent_session_id, + &session.id, + team_name, + teammate_name, + normalize_optional_text(request.agent_type.clone()), + ) + .await?; + } inherit_subagent_provider( runtime, @@ -694,9 +781,12 @@ pub(crate) async fn agent_runtime_spawn_subagent_internal( metadata: Some(serde_json::json!({ "subagent": { "parent_session_id": request.parent_session_id, + "name": request.name, + "team_name": request.team_name, "agent_type": request.agent_type, "reasoning_effort": request.reasoning_effort, "fork_context": request.fork_context, + "cwd": request.cwd, "origin_tool": "spawn_agent", "blueprint_role_id": customization.as_ref().and_then(|state| state.blueprint_role_id.clone()), "blueprint_role_label": customization.as_ref().and_then(|state| state.blueprint_role_label.clone()), @@ -958,3 +1048,64 @@ pub(crate) async fn agent_runtime_close_subagent_internal( changed_session_ids: changed_ids, }) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_build_subagent_session_name_prefers_explicit_name() { + let name = build_subagent_session_name( + Some("verifier"), + "检查当前 team runtime 差异", + Some("explorer"), + Some("分析"), + Some("代码分析员"), + ); + + assert_eq!(name, "verifier"); + } + + #[test] + fn test_resolve_subagent_role_hint_prefers_explicit_name() { + let request = AgentRuntimeSpawnSubagentRequest { + parent_session_id: "parent-1".to_string(), + message: "定位当前 team runtime 差异".to_string(), + name: Some("verifier".to_string()), + team_name: Some("delivery-team".to_string()), + agent_type: Some("explorer".to_string()), + model: None, + reasoning_effort: None, + fork_context: false, + blueprint_role_id: None, + blueprint_role_label: Some("分析".to_string()), + profile_id: None, + profile_name: Some("代码分析员".to_string()), + role_key: Some("explorer".to_string()), + skill_ids: Vec::new(), + skill_directories: Vec::new(), + team_preset_id: None, + theme: None, + system_overlay: None, + output_contract: None, + cwd: None, + }; + + assert_eq!( + resolve_subagent_role_hint(&request, None).as_deref(), + Some("verifier") + ); + } + + #[test] + fn test_resolve_spawn_working_dir_uses_requested_absolute_directory() { + let parent = tempfile::tempdir().expect("parent tempdir"); + let child = tempfile::tempdir().expect("child tempdir"); + + let resolved = + resolve_spawn_working_dir(parent.path(), Some(child.path().display().to_string())) + .expect("cwd override should resolve"); + + assert_eq!(resolved, child.path()); + } +} diff --git a/src-tauri/src/commands/aster_agent_cmd/tests.rs b/src-tauri/src/commands/aster_agent_cmd/tests.rs index 0f551df77..d3bfb86bc 100644 --- a/src-tauri/src/commands/aster_agent_cmd/tests.rs +++ b/src-tauri/src/commands/aster_agent_cmd/tests.rs @@ -2604,6 +2604,8 @@ mod tests { let customization = build_subagent_customization_state(&AgentRuntimeSpawnSubagentRequest { parent_session_id: "parent-1".to_string(), message: "定位当前 team runtime 差异".to_string(), + name: None, + team_name: None, agent_type: Some("Image #1".to_string()), model: None, reasoning_effort: None, @@ -2619,6 +2621,7 @@ mod tests { theme: None, system_overlay: None, output_contract: None, + cwd: None, }) .expect("build customization state") .expect("customization should exist"); 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 8da766675..d9d69094a 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 @@ -408,6 +408,8 @@ impl Tool for SubAgentTaskTool { AgentRuntimeSpawnSubagentRequest { parent_session_id, message: build_subagent_task_runtime_message(&input, &task, role), + name: None, + team_name: None, agent_type: Some(role.to_string()), model: input.model.clone(), reasoning_effort: None, @@ -423,6 +425,7 @@ impl Tool for SubAgentTaskTool { theme: None, system_overlay: None, output_contract: None, + cwd: None, }, ) .await @@ -517,10 +520,7 @@ fn build_agent_control_tool_config( runtime: SubagentControlRuntime, ) -> aster::tools::AgentControlToolConfig { let spawn_runtime = runtime.clone(); - let send_runtime = runtime.clone(); - let wait_runtime = runtime.clone(); - let resume_runtime = runtime.clone(); - let close_runtime = runtime; + let send_runtime = runtime; aster::tools::AgentControlToolConfig::new() .with_spawn_agent_callback(Arc::new(move |request| { @@ -531,6 +531,8 @@ fn build_agent_control_tool_config( AgentRuntimeSpawnSubagentRequest { parent_session_id: request.parent_session_id, message: request.message, + name: request.name, + team_name: request.team_name, agent_type: request.agent_type, model: request.model, reasoning_effort: request.reasoning_effort, @@ -546,6 +548,7 @@ fn build_agent_control_tool_config( theme: request.theme, system_overlay: request.system_overlay, output_contract: request.output_contract, + cwd: request.cwd, }, ) .await?; @@ -576,80 +579,6 @@ fn build_agent_control_tool_config( }) }) })) - .with_wait_agent_callback(Arc::new(move |request| { - let runtime = wait_runtime.clone(); - Box::pin(async move { - let response = agent_runtime_wait_subagents_internal( - &runtime, - AgentRuntimeWaitSubagentsRequest { - ids: request.ids, - timeout_ms: request.timeout_ms, - }, - ) - .await?; - let status = response - .status - .into_iter() - .map(|(id, status)| { - serde_json::to_value(status) - .map(|value| (id, value)) - .map_err(|error| format!("wait_agent 状态序列化失败: {error}")) - }) - .collect::, _>>()?; - - Ok(aster::tools::WaitAgentResponse { - status, - timed_out: response.timed_out, - extra: std::collections::BTreeMap::new(), - }) - }) - })) - .with_resume_agent_callback(Arc::new(move |request| { - let runtime = resume_runtime.clone(); - Box::pin(async move { - let response = agent_runtime_resume_subagent_internal( - &runtime, - AgentRuntimeResumeSubagentRequest { id: request.id }, - ) - .await?; - let mut extra = std::collections::BTreeMap::new(); - extra.insert( - "cascade_session_ids".to_string(), - serde_json::to_value(response.cascade_session_ids) - .map_err(|error| format!("resume_agent 级联会话序列化失败: {error}"))?, - ); - - Ok(aster::tools::ResumeAgentResponse { - status: serde_json::to_value(response.status) - .map_err(|error| format!("resume_agent 状态序列化失败: {error}"))?, - changed_session_ids: response.changed_session_ids, - extra, - }) - }) - })) - .with_close_agent_callback(Arc::new(move |request| { - let runtime = close_runtime.clone(); - Box::pin(async move { - let response = agent_runtime_close_subagent_internal( - &runtime, - AgentRuntimeCloseSubagentRequest { id: request.id }, - ) - .await?; - let mut extra = std::collections::BTreeMap::new(); - extra.insert( - "cascade_session_ids".to_string(), - serde_json::to_value(response.cascade_session_ids) - .map_err(|error| format!("close_agent 级联会话序列化失败: {error}"))?, - ); - - Ok(aster::tools::CloseAgentResponse { - previous_status: serde_json::to_value(response.previous_status) - .map_err(|error| format!("close_agent 状态序列化失败: {error}"))?, - changed_session_ids: response.changed_session_ids, - extra, - }) - }) - })) } pub(super) fn register_subagent_runtime_tools( diff --git a/src-tauri/src/commands/companion_cmd.rs b/src-tauri/src/commands/companion_cmd.rs new file mode 100644 index 000000000..a321fa945 --- /dev/null +++ b/src-tauri/src/commands/companion_cmd.rs @@ -0,0 +1,28 @@ +use crate::services::companion_service::{ + self, CompanionLaunchPetRequest, CompanionLaunchPetResult, CompanionPetCommandRequest, + CompanionPetSendResult, CompanionPetStatus, CompanionServiceState, +}; +use tauri::State; + +#[tauri::command] +pub async fn companion_get_pet_status( + companion_state: State<'_, CompanionServiceState>, +) -> Result { + companion_service::get_pet_status_global(companion_state.inner()).await +} + +#[tauri::command] +pub async fn companion_launch_pet( + companion_state: State<'_, CompanionServiceState>, + request: Option, +) -> Result { + companion_service::launch_pet_global(companion_state.inner(), request.unwrap_or_default()).await +} + +#[tauri::command] +pub async fn companion_send_pet_command( + companion_state: State<'_, CompanionServiceState>, + request: CompanionPetCommandRequest, +) -> Result { + companion_service::send_pet_command_global(companion_state.inner(), request).await +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 391d632bf..d58893e04 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -11,6 +11,7 @@ pub mod browser_profile_cmd; pub mod browser_runtime_cmd; pub mod channels_cmd; pub mod claw_solution_cmd; +pub mod companion_cmd; pub mod config_cmd; pub mod connect_cmd; pub mod connection_cmd; diff --git a/src-tauri/src/commands/model_registry_cmd.rs b/src-tauri/src/commands/model_registry_cmd.rs index caeb1c360..27e1c7629 100644 --- a/src-tauri/src/commands/model_registry_cmd.rs +++ b/src-tauri/src/commands/model_registry_cmd.rs @@ -5,9 +5,9 @@ use crate::models::model_registry::{ EnhancedModelMetadata, ModelSyncState, ModelTier, ProviderAliasConfig, UserModelPreference, }; +use lime_server_utils::load_model_registry_provider_ids_from_resources; use lime_services::model_registry_service::{FetchModelsResult, ModelRegistryService}; use serde::Serialize; -use std::collections::BTreeSet; use std::sync::Arc; use tauri::State; use tokio::sync::RwLock; @@ -36,20 +36,12 @@ pub async fn get_model_registry( /// 获取模型注册表中所有 provider_id(去重且有序) /// -/// provider_id 来源于 `src-tauri/resources/models/providers/*.json` 加载结果。 +/// provider_id 以 `src-tauri/resources/models/index.json` 的 providers 列表为唯一真相源。 #[tauri::command] pub async fn get_model_registry_provider_ids( - state: State<'_, ModelRegistryState>, + _state: State<'_, ModelRegistryState>, ) -> Result, String> { - let guard = state.read().await; - let service = guard - .as_ref() - .ok_or_else(|| "模型注册服务未初始化".to_string())?; - - let models = service.get_all_models().await; - let provider_ids: BTreeSet = models.into_iter().map(|m| m.provider_id).collect(); - - Ok(provider_ids.into_iter().collect()) + load_model_registry_provider_ids_from_resources() } /// 搜索模型 @@ -272,18 +264,32 @@ pub async fn fetch_provider_models_auto( .get_provider(&db, &provider_id)? .ok_or_else(|| format!("Provider 不存在: {provider_id}"))?; - // 获取 API Key - let api_key = api_key_service - .0 - .get_next_api_key(&db, &provider_id)? - .ok_or_else(|| format!("Provider {provider_id} 没有可用的 API Key"))?; - // 获取 API Host let api_host = provider.provider.api_host.clone(); if api_host.is_empty() { return Err("Provider 没有配置 API Host".to_string()); } + let provider_type = provider.provider.provider_type; + let requires_api_key = ModelRegistryService::requires_api_key_for_model_fetch( + &provider_id, + &api_host, + provider_type, + ); + + // 获取 API Key(支持本地免 Key 渠道) + let api_key = if requires_api_key { + api_key_service + .0 + .get_next_api_key(&db, &provider_id)? + .ok_or_else(|| format!("Provider {provider_id} 没有可用的 API Key"))? + } else { + api_key_service + .0 + .get_next_api_key(&db, &provider_id)? + .unwrap_or_default() + }; + // 调用模型注册服务 let guard = state.read().await; let service = guard @@ -295,7 +301,7 @@ pub async fn fetch_provider_models_auto( &provider_id, &api_host, &api_key, - Some(provider.provider.provider_type), + Some(provider_type), &provider.provider.custom_models, ) .await diff --git a/src-tauri/src/commands/webview_cmd.rs b/src-tauri/src/commands/webview_cmd.rs index 4a368f8b8..7361142f8 100644 --- a/src-tauri/src/commands/webview_cmd.rs +++ b/src-tauri/src/commands/webview_cmd.rs @@ -3684,6 +3684,10 @@ mod tests { use crate::services::browser_profile_service::sanitize_browser_profile_key; use rusqlite::Connection; use std::sync::{Arc, Mutex}; + use tokio::sync::Mutex as AsyncMutex; + + static BROWSER_RUNTIME_AUDIT_TEST_LOCK: Lazy> = + Lazy::new(|| AsyncMutex::new(())); fn setup_db() -> DbConnection { let conn = Connection::open_in_memory().unwrap(); @@ -3995,6 +3999,7 @@ mod tests { #[tokio::test] async fn browser_runtime_audit_should_store_launch_metadata() { + let _guard = BROWSER_RUNTIME_AUDIT_TEST_LOCK.lock().await; BROWSER_RUNTIME_AUDIT_LOGS.lock().await.clear(); append_browser_runtime_launch_audit(BrowserRuntimeLaunchAuditInput { @@ -4018,7 +4023,10 @@ mod tests { let logs = get_browser_action_audit_logs(Some(5)) .await .expect("audit logs should be readable"); - let record = logs.first().expect("launch audit must exist"); + let record = logs + .iter() + .find(|record| matches!(record.kind, BrowserRuntimeAuditKind::Launch)) + .expect("launch audit must exist"); assert!(matches!(record.kind, BrowserRuntimeAuditKind::Launch)); assert_eq!( record.profile_key.as_deref(), @@ -4041,6 +4049,7 @@ mod tests { #[tokio::test] async fn browser_runtime_audit_should_store_action_session_keys() { + let _guard = BROWSER_RUNTIME_AUDIT_TEST_LOCK.lock().await; BROWSER_RUNTIME_AUDIT_LOGS.lock().await.clear(); append_browser_runtime_audit(BrowserRuntimeAuditRecord::action( @@ -4064,7 +4073,13 @@ mod tests { let logs = get_browser_action_audit_logs(Some(5)) .await .expect("audit logs should be readable"); - let record = logs.first().expect("action audit must exist"); + let record = logs + .iter() + .find(|record| { + matches!(record.kind, BrowserRuntimeAuditKind::Action) + && record.id == "browser-action-1" + }) + .expect("action audit must exist"); assert!(matches!(record.kind, BrowserRuntimeAuditKind::Action)); assert_eq!(record.id, "browser-action-1"); assert_eq!(record.session_id.as_deref(), Some("session-42")); diff --git a/src-tauri/src/dev_bridge/dispatcher.rs b/src-tauri/src/dev_bridge/dispatcher.rs index 77bd2f526..258c0f0de 100644 --- a/src-tauri/src/dev_bridge/dispatcher.rs +++ b/src-tauri/src/dev_bridge/dispatcher.rs @@ -4,7 +4,9 @@ mod agent_sessions; mod app_runtime; +mod automation; mod browser; +mod companion; mod content; mod logs; mod memory; @@ -90,6 +92,14 @@ pub async fn handle_command( return Ok(result); } + if let Some(result) = companion::try_handle(state, cmd, args.as_ref()).await? { + return Ok(result); + } + + if let Some(result) = automation::try_handle(state, cmd, args.as_ref()).await? { + return Ok(result); + } + if let Some(result) = logs::try_handle(state, cmd, args.as_ref()).await? { return Ok(result); } diff --git a/src-tauri/src/dev_bridge/dispatcher/automation.rs b/src-tauri/src/dev_bridge/dispatcher/automation.rs new file mode 100644 index 000000000..1de459833 --- /dev/null +++ b/src-tauri/src/dev_bridge/dispatcher/automation.rs @@ -0,0 +1,158 @@ +use super::{ + args_or_default, get_string_arg, parse_nested_arg, parse_optional_nested_arg, + 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 !matches!( + cmd, + "get_automation_scheduler_config" + | "update_automation_scheduler_config" + | "get_automation_status" + | "get_automation_jobs" + | "get_automation_job" + | "create_automation_job" + | "update_automation_job" + | "delete_automation_job" + | "run_automation_job_now" + | "get_automation_health" + | "get_automation_run_history" + | "preview_automation_schedule" + | "validate_automation_schedule" + ) { + return Ok(None); + } + + let app_handle = require_app_handle(state)?; + let app_state = app_handle.state::(); + let automation_state = + app_handle.state::(); + + let result = match cmd { + "get_automation_scheduler_config" => serde_json::to_value( + crate::commands::automation_cmd::get_automation_scheduler_config(app_state).await?, + )?, + "update_automation_scheduler_config" => { + let config = parse_nested_arg::< + crate::commands::automation_cmd::AutomationSchedulerConfigResponse, + >(&args_or_default(args), "config")?; + crate::commands::automation_cmd::update_automation_scheduler_config( + app_state, + automation_state, + config, + app_handle.clone(), + ) + .await?; + JsonValue::Null + } + "get_automation_status" => serde_json::to_value( + crate::commands::automation_cmd::get_automation_status(automation_state).await?, + )?, + "get_automation_jobs" => serde_json::to_value( + crate::commands::automation_cmd::get_automation_jobs(automation_state).await?, + )?, + "get_automation_job" => { + let args = args_or_default(args); + let id = get_string_arg(&args, "id", "id")?; + serde_json::to_value( + crate::commands::automation_cmd::get_automation_job(automation_state, id).await?, + )? + } + "create_automation_job" => { + let request = parse_nested_arg::( + &args_or_default(args), + "request", + )?; + serde_json::to_value( + crate::commands::automation_cmd::create_automation_job(automation_state, request) + .await?, + )? + } + "update_automation_job" => { + let args = args_or_default(args); + let id = get_string_arg(&args, "id", "id")?; + let request = parse_nested_arg::< + crate::commands::automation_cmd::UpdateAutomationJobRequest, + >(&args, "request")?; + serde_json::to_value( + crate::commands::automation_cmd::update_automation_job( + automation_state, + id, + request, + ) + .await?, + )? + } + "delete_automation_job" => { + let args = args_or_default(args); + let id = get_string_arg(&args, "id", "id")?; + serde_json::to_value( + crate::commands::automation_cmd::delete_automation_job(automation_state, id) + .await?, + )? + } + "run_automation_job_now" => { + let args = args_or_default(args); + let id = get_string_arg(&args, "id", "id")?; + serde_json::to_value( + crate::commands::automation_cmd::run_automation_job_now(automation_state, id) + .await?, + )? + } + "get_automation_health" => { + let query = parse_optional_nested_arg::< + crate::services::automation_service::health::AutomationHealthQuery, + >(&args_or_default(args), "query")?; + serde_json::to_value( + crate::commands::automation_cmd::get_automation_health(automation_state, query) + .await?, + )? + } + "get_automation_run_history" => { + let args = args_or_default(args); + let id = get_string_arg(&args, "id", "id")?; + let limit = args + .get("limit") + .and_then(|value| value.as_u64()) + .map(|value| value as usize); + serde_json::to_value( + crate::commands::automation_cmd::get_automation_run_history( + automation_state, + id, + limit, + ) + .await?, + )? + } + "preview_automation_schedule" => { + let schedule = parse_nested_arg::( + &args_or_default(args), + "schedule", + )?; + serde_json::to_value( + crate::commands::automation_cmd::preview_automation_schedule(schedule).await?, + )? + } + "validate_automation_schedule" => { + let schedule = parse_nested_arg::( + &args_or_default(args), + "schedule", + )?; + serde_json::to_value( + crate::commands::automation_cmd::validate_automation_schedule(schedule).await?, + )? + } + _ => unreachable!("已通过前置 matches! 过滤 automation 命令"), + }; + + Ok(Some(result)) +} diff --git a/src-tauri/src/dev_bridge/dispatcher/companion.rs b/src-tauri/src/dev_bridge/dispatcher/companion.rs new file mode 100644 index 000000000..f3ae94965 --- /dev/null +++ b/src-tauri/src/dev_bridge/dispatcher/companion.rs @@ -0,0 +1,51 @@ +use super::{args_or_default, parse_nested_arg, parse_optional_nested_arg, 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> { + let app_handle = match cmd { + "companion_get_pet_status" | "companion_launch_pet" | "companion_send_pet_command" => { + require_app_handle(state)? + } + _ => return Ok(None), + }; + + let companion_state = + app_handle.state::(); + let result = match cmd { + "companion_get_pet_status" => serde_json::to_value( + crate::services::companion_service::get_pet_status_global(&companion_state).await?, + )?, + "companion_launch_pet" => { + let request = parse_optional_nested_arg::< + crate::services::companion_service::CompanionLaunchPetRequest, + >(&args_or_default(args), "request")? + .unwrap_or_default(); + serde_json::to_value( + crate::services::companion_service::launch_pet_global(&companion_state, request) + .await?, + )? + } + "companion_send_pet_command" => { + let request: crate::services::companion_service::CompanionPetCommandRequest = + parse_nested_arg(&args_or_default(args), "request")?; + serde_json::to_value( + crate::services::companion_service::send_pet_command_global( + &companion_state, + request, + ) + .await?, + )? + } + _ => unreachable!("已通过前置判断过滤 companion 命令"), + }; + + Ok(Some(result)) +} diff --git a/src-tauri/src/dev_bridge/dispatcher/models.rs b/src-tauri/src/dev_bridge/dispatcher/models.rs index eb8395353..b6af514b4 100644 --- a/src-tauri/src/dev_bridge/dispatcher/models.rs +++ b/src-tauri/src/dev_bridge/dispatcher/models.rs @@ -4,32 +4,11 @@ use serde_json::Value as JsonValue; type DynError = Box; -fn load_model_registry_provider_ids_from_db( - state: &DevBridgeState, -) -> Result, DynError> { - let Some(db) = &state.db else { - return Ok(vec![]); - }; - - let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; - let mut stmt = conn.prepare( - "SELECT DISTINCT provider_id FROM model_registry WHERE provider_id IS NOT NULL ORDER BY provider_id", - )?; - - let rows = stmt.query_map([], |row| row.get::<_, String>(0))?; - let mut provider_ids = Vec::new(); - for row in rows { - provider_ids.push(row?); - } - - Ok(provider_ids) -} - pub(super) async fn try_handle( state: &DevBridgeState, cmd: &str, ) -> Result, DynError> { - let result = match cmd { + let result: JsonValue = match cmd { "get_models" => serde_json::json!({ "data": [ {"id": "claude-sonnet-4-20250514", "object": "model", "owned_by": "anthropic"}, @@ -75,19 +54,7 @@ pub(super) async fn try_handle( serde_json::json!(service.force_reload().await?) } "get_model_registry_provider_ids" => { - match load_model_registry_provider_ids_from_resources() { - Ok(provider_ids) => serde_json::to_value(provider_ids)?, - Err(resource_error) => { - let fallback = load_model_registry_provider_ids_from_db(state)?; - if fallback.is_empty() { - return Err(format!( - "获取模型 Provider ID 失败(resources 与数据库均不可用): {resource_error}" - ) - .into()); - } - serde_json::to_value(fallback)? - } - } + serde_json::to_value(load_model_registry_provider_ids_from_resources()?)? } _ => return Ok(None), }; diff --git a/src-tauri/src/services/companion_service.rs b/src-tauri/src/services/companion_service.rs new file mode 100644 index 000000000..dc3d471e4 --- /dev/null +++ b/src-tauri/src/services/companion_service.rs @@ -0,0 +1,782 @@ +use axum::{ + extract::{ + ws::{Message, WebSocket, WebSocketUpgrade}, + State, + }, + response::IntoResponse, + routing::get, + Router, +}; +use futures::{SinkExt, StreamExt}; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; +use std::{path::PathBuf, process::Command, sync::Arc}; +use tauri::{AppHandle, Emitter, Manager}; +use tokio::sync::{mpsc, Mutex, RwLock}; +use uuid::Uuid; + +const DEFAULT_COMPANION_HOST: &str = "127.0.0.1"; +const DEFAULT_COMPANION_PORT: u16 = 45554; +const DEFAULT_COMPANION_PATH: &str = "/companion/pet"; +const DEFAULT_CLIENT_ID: &str = "lime"; +const DEFAULT_PROTOCOL_VERSION: u32 = 1; +const COMPANION_ENV_APP_PATH: &str = "LIME_PET_APP_PATH"; +#[cfg(target_os = "macos")] +const MACOS_PET_APP_NAME: &str = "Lime Pet"; +#[cfg(target_os = "windows")] +const WINDOWS_PET_EXE_NAME: &str = "Lime Pet.exe"; + +pub const COMPANION_PET_STATUS_EVENT: &str = "companion-pet-status"; +pub const COMPANION_OPEN_PROVIDER_SETTINGS_EVENT: &str = "companion-open-provider-settings"; + +fn default_companion_endpoint() -> String { + format!("ws://{DEFAULT_COMPANION_HOST}:{DEFAULT_COMPANION_PORT}{DEFAULT_COMPANION_PATH}") +} + +fn empty_payload() -> Value { + Value::Object(Map::new()) +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum CompanionPetVisualState { + Hidden, + Idle, + Walking, + Thinking, + Done, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CompanionPetStatus { + pub endpoint: String, + pub server_listening: bool, + pub connected: bool, + pub client_id: Option, + pub platform: Option, + pub capabilities: Vec, + pub last_event: Option, + pub last_error: Option, + pub last_state: Option, +} + +impl Default for CompanionPetStatus { + fn default() -> Self { + Self { + endpoint: default_companion_endpoint(), + server_listening: false, + connected: false, + client_id: None, + platform: None, + capabilities: Vec::new(), + last_event: None, + last_error: None, + last_state: None, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct CompanionLaunchPetRequest { + pub app_path: Option, + pub endpoint: Option, + pub client_id: Option, + pub protocol_version: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CompanionLaunchPetResult { + pub launched: bool, + pub resolved_path: Option, + pub endpoint: String, + pub message: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CompanionPetCommandRequest { + pub event: String, + #[serde(default = "empty_payload")] + pub payload: Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CompanionPetSendResult { + pub delivered: bool, + pub connected: bool, +} + +#[derive(Debug, Clone, Serialize)] +struct CompanionEnvelope { + protocol_version: u32, + event: String, + payload: Value, +} + +#[derive(Debug, Clone, Deserialize)] +struct CompanionIncomingEnvelope { + protocol_version: u32, + event: String, + #[serde(default = "empty_payload")] + payload: Value, +} + +#[derive(Debug, Clone, Deserialize, Default)] +struct CompanionReadyPayload { + client_id: Option, + platform: Option, + #[serde(default)] + capabilities: Vec, +} + +#[derive(Debug, Clone)] +struct ActivePetSender { + connection_id: String, + tx: mpsc::UnboundedSender, +} + +#[derive(Debug, Clone, Default)] +struct CompanionRuntime { + status: CompanionPetStatus, + active_connection_id: Option, +} + +#[derive(Clone)] +struct CompanionRouterState { + app_handle: AppHandle, + service: CompanionServiceState, +} + +#[derive(Clone)] +pub struct CompanionServiceState { + app_handle: Arc>>, + runtime: Arc>, + sender: Arc>>, + start_lock: Arc>, +} + +impl Default for CompanionServiceState { + fn default() -> Self { + Self { + app_handle: Arc::new(RwLock::new(None)), + runtime: Arc::new(RwLock::new(CompanionRuntime { + status: CompanionPetStatus::default(), + active_connection_id: None, + })), + sender: Arc::new(Mutex::new(None)), + start_lock: Arc::new(Mutex::new(())), + } + } +} + +impl CompanionServiceState { + pub async fn start(&self, app_handle: AppHandle) -> Result<(), String> { + self.set_app_handle(app_handle.clone()).await; + + let _guard = self.start_lock.lock().await; + if self.snapshot().await.server_listening { + return Ok(()); + } + + let bind_addr = format!("{DEFAULT_COMPANION_HOST}:{DEFAULT_COMPANION_PORT}"); + let listener = match tokio::net::TcpListener::bind(&bind_addr).await { + Ok(listener) => listener, + Err(error) => { + self.update_runtime(|runtime| { + runtime.status.server_listening = false; + runtime.status.last_error = Some(format!("Companion 服务监听失败: {error}")); + }) + .await; + return Err(format!("Companion 服务监听失败: {error}")); + } + }; + + self.update_runtime(|runtime| { + runtime.status.server_listening = true; + runtime.status.last_error = None; + runtime.status.endpoint = default_companion_endpoint(); + }) + .await; + + let service = self.clone(); + let router = Router::new() + .route(DEFAULT_COMPANION_PATH, get(companion_pet_ws)) + .with_state(CompanionRouterState { + app_handle, + service: self.clone(), + }); + + tokio::spawn(async move { + if let Err(error) = axum::serve(listener, router).await { + tracing::error!("[Companion] 服务运行失败: {}", error); + service + .mark_server_stopped(Some(format!("Companion 服务运行失败: {error}"))) + .await; + } + }); + + tracing::info!( + "[Companion] 已监听桌宠入口: {}", + default_companion_endpoint() + ); + Ok(()) + } + + pub async fn snapshot(&self) -> CompanionPetStatus { + self.runtime.read().await.status.clone() + } + + pub async fn launch_pet( + &self, + request: CompanionLaunchPetRequest, + ) -> Result { + let endpoint = request + .endpoint + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(default_companion_endpoint); + let client_id = request + .client_id + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| DEFAULT_CLIENT_ID.to_string()); + let protocol_version = request.protocol_version.unwrap_or(DEFAULT_PROTOCOL_VERSION); + + let Some(target) = resolve_launch_target(request.app_path.as_deref()) else { + return Ok(CompanionLaunchPetResult { + launched: false, + resolved_path: None, + endpoint, + message: Some( + "未找到 Lime Pet 可执行产物,请先安装桌宠应用或通过 app_path 显式指定。" + .to_string(), + ), + }); + }; + + let mut command = Command::new(&target.exec_path); + command + .arg("--connect") + .arg(&endpoint) + .arg("--client-id") + .arg(&client_id) + .arg("--protocol-version") + .arg(protocol_version.to_string()); + + match command.spawn() { + Ok(_) => Ok(CompanionLaunchPetResult { + launched: true, + resolved_path: Some(target.exec_path.to_string_lossy().to_string()), + endpoint, + message: None, + }), + Err(error) => Ok(CompanionLaunchPetResult { + launched: false, + resolved_path: Some(target.exec_path.to_string_lossy().to_string()), + endpoint, + message: Some(format!("启动 Lime Pet 失败: {error}")), + }), + } + } + + pub async fn send_pet_command( + &self, + request: CompanionPetCommandRequest, + ) -> Result { + let payload = CompanionEnvelope { + protocol_version: DEFAULT_PROTOCOL_VERSION, + event: request.event.clone(), + payload: request.payload.clone(), + }; + let serialized = serde_json::to_string(&payload).map_err(|e| e.to_string())?; + + let active_sender = self.sender.lock().await.clone(); + let Some(active_sender) = active_sender else { + return Ok(CompanionPetSendResult { + delivered: false, + connected: false, + }); + }; + + if active_sender.tx.send(serialized).is_err() { + self.mark_connection_closed( + &active_sender.connection_id, + "桌宠连接不可用,命令未送达".to_string(), + ) + .await; + return Ok(CompanionPetSendResult { + delivered: false, + connected: false, + }); + } + + self.record_outbound_command(&request.event, &request.payload) + .await; + + Ok(CompanionPetSendResult { + delivered: true, + connected: true, + }) + } + + async fn set_app_handle(&self, app_handle: AppHandle) { + let mut guard = self.app_handle.write().await; + *guard = Some(app_handle); + } + + async fn emit_status(&self, status: CompanionPetStatus) { + let app_handle = self.app_handle.read().await.clone(); + if let Some(app_handle) = app_handle { + if let Err(error) = app_handle.emit(COMPANION_PET_STATUS_EVENT, &status) { + tracing::warn!("[Companion] 发送状态事件失败: {}", error); + } + } + } + + async fn update_runtime(&self, mutate: F) + where + F: FnOnce(&mut CompanionRuntime), + { + let snapshot = { + let mut runtime = self.runtime.write().await; + mutate(&mut runtime); + runtime.status.clone() + }; + self.emit_status(snapshot).await; + } + + async fn mark_server_stopped(&self, reason: Option) { + let mut sender = self.sender.lock().await; + sender.take(); + drop(sender); + + self.update_runtime(|runtime| { + runtime.status.server_listening = false; + runtime.status.connected = false; + runtime.status.client_id = None; + runtime.status.platform = None; + runtime.status.capabilities.clear(); + runtime.status.last_error = reason; + runtime.active_connection_id = None; + }) + .await; + } + + async fn attach_sender(&self, connection_id: String, tx: mpsc::UnboundedSender) { + let mut guard = self.sender.lock().await; + *guard = Some(ActivePetSender { connection_id, tx }); + } + + async fn mark_connection_open(&self, connection_id: String) { + self.update_runtime(|runtime| { + runtime.active_connection_id = Some(connection_id); + runtime.status.connected = true; + runtime.status.client_id = None; + runtime.status.platform = None; + runtime.status.capabilities.clear(); + runtime.status.last_event = Some("pet.connected".to_string()); + runtime.status.last_error = None; + }) + .await; + } + + async fn mark_connection_ready(&self, connection_id: &str, payload: CompanionReadyPayload) { + self.update_runtime(|runtime| { + if runtime.active_connection_id.as_deref() != Some(connection_id) { + return; + } + runtime.status.connected = true; + runtime.status.client_id = payload.client_id; + runtime.status.platform = payload.platform; + runtime.status.capabilities = payload.capabilities; + runtime.status.last_event = Some("pet.ready".to_string()); + runtime.status.last_error = None; + }) + .await; + } + + async fn mark_connection_closed(&self, connection_id: &str, reason: String) { + { + let mut sender = self.sender.lock().await; + if sender.as_ref().map(|active| active.connection_id.as_str()) == Some(connection_id) { + sender.take(); + } + } + + self.update_runtime(|runtime| { + if runtime.active_connection_id.as_deref() != Some(connection_id) { + return; + } + runtime.active_connection_id = None; + runtime.status.connected = false; + runtime.status.client_id = None; + runtime.status.platform = None; + runtime.status.capabilities.clear(); + runtime.status.last_event = Some("pet.disconnected".to_string()); + runtime.status.last_error = Some(reason); + }) + .await; + } + + async fn handle_incoming_message( + &self, + app_handle: &AppHandle, + connection_id: &str, + text: &str, + ) { + let envelope = match serde_json::from_str::(text) { + Ok(envelope) => envelope, + Err(error) => { + tracing::warn!("[Companion] 忽略无法解析的桌宠消息: {}", error); + return; + } + }; + + if envelope.protocol_version != DEFAULT_PROTOCOL_VERSION { + self.update_runtime(|runtime| { + if runtime.active_connection_id.as_deref() != Some(connection_id) { + return; + } + runtime.status.last_error = + Some(format!("桌宠协议版本不兼容: {}", envelope.protocol_version)); + }) + .await; + return; + } + + let should_focus_main_window = matches!( + envelope.event.as_str(), + "pet.clicked" | "pet.open_chat" | "pet.open_provider_settings" + ); + + self.update_runtime(|runtime| { + if runtime.active_connection_id.as_deref() != Some(connection_id) { + return; + } + runtime.status.last_event = Some(envelope.event.clone()); + runtime.status.last_error = None; + }) + .await; + + if envelope.event == "pet.ready" { + match serde_json::from_value::(envelope.payload.clone()) { + Ok(payload) => { + self.mark_connection_ready(connection_id, payload).await; + } + Err(error) => { + self.update_runtime(|runtime| { + if runtime.active_connection_id.as_deref() != Some(connection_id) { + return; + } + runtime.status.last_error = + Some(format!("桌宠 ready 负载解析失败: {error}")); + }) + .await; + } + } + } + + if should_focus_main_window { + reveal_main_window(app_handle); + } + + if envelope.event == "pet.open_provider_settings" { + if let Err(error) = app_handle.emit(COMPANION_OPEN_PROVIDER_SETTINGS_EVENT, ()) { + tracing::warn!("[Companion] 发送服务商设置跳转事件失败: {}", error); + } + } + } + + async fn record_outbound_command(&self, event: &str, payload: &Value) { + let event_name = event.to_string(); + let next_state = match event { + "pet.hide" => Some(CompanionPetVisualState::Hidden), + "pet.show" => Some(CompanionPetVisualState::Walking), + "pet.state_changed" => payload + .get("state") + .and_then(Value::as_str) + .and_then(parse_visual_state), + _ => None, + }; + + self.update_runtime(|runtime| { + runtime.status.last_event = Some(event_name); + runtime.status.last_error = None; + if let Some(next_state) = next_state { + runtime.status.last_state = Some(next_state); + } + }) + .await; + } +} + +fn parse_visual_state(value: &str) -> Option { + match value.trim() { + "hidden" => Some(CompanionPetVisualState::Hidden), + "idle" => Some(CompanionPetVisualState::Idle), + "walking" => Some(CompanionPetVisualState::Walking), + "thinking" => Some(CompanionPetVisualState::Thinking), + "done" => Some(CompanionPetVisualState::Done), + _ => None, + } +} + +fn reveal_main_window(app_handle: &AppHandle) { + let Some(window) = app_handle.get_webview_window("main") else { + tracing::warn!("[Companion] 未找到主窗口,无法响应桌宠点击"); + return; + }; + + if let Err(error) = window.unminimize() { + tracing::warn!("[Companion] 主窗口取消最小化失败: {}", error); + } + if let Err(error) = window.show() { + tracing::warn!("[Companion] 主窗口显示失败: {}", error); + } + if let Err(error) = window.set_focus() { + tracing::warn!("[Companion] 主窗口聚焦失败: {}", error); + } +} + +#[derive(Debug)] +struct LaunchTarget { + exec_path: PathBuf, +} + +fn resolve_launch_target(explicit_path: Option<&str>) -> Option { + let mut candidates = Vec::new(); + + if let Some(explicit_path) = explicit_path + .map(str::trim) + .filter(|value| !value.is_empty()) + { + candidates.push(expand_user_path(explicit_path)); + } + + if let Ok(env_path) = std::env::var(COMPANION_ENV_APP_PATH) { + let env_path = env_path.trim(); + if !env_path.is_empty() { + candidates.push(expand_user_path(env_path)); + } + } + + candidates.extend(default_launch_candidates()); + + candidates.into_iter().find_map(normalize_launch_target) +} + +fn expand_user_path(value: &str) -> PathBuf { + if value == "~" { + return dirs::home_dir().unwrap_or_else(|| PathBuf::from(value)); + } + + if let Some(suffix) = value.strip_prefix("~/") { + if let Some(home_dir) = dirs::home_dir() { + return home_dir.join(suffix); + } + } + + PathBuf::from(value) +} + +#[cfg(target_os = "macos")] +fn default_launch_candidates() -> Vec { + let mut candidates = Vec::new(); + if let Some(home_dir) = dirs::home_dir() { + candidates.push(home_dir.join("Applications/Lime Pet.app")); + } + candidates.push(PathBuf::from("/Applications/Lime Pet.app")); + candidates +} + +#[cfg(target_os = "windows")] +fn default_launch_candidates() -> Vec { + let mut candidates = Vec::new(); + if let Some(local_dir) = dirs::data_local_dir() { + candidates.push( + local_dir + .join("Programs") + .join("Lime Pet") + .join(WINDOWS_PET_EXE_NAME), + ); + } + candidates +} + +#[cfg(not(any(target_os = "macos", target_os = "windows")))] +fn default_launch_candidates() -> Vec { + Vec::new() +} + +#[cfg(target_os = "macos")] +fn normalize_launch_target(path: PathBuf) -> Option { + if path + .extension() + .and_then(|value| value.to_str()) + .is_some_and(|value| value.eq_ignore_ascii_case("app")) + { + let exec_path = path.join("Contents").join("MacOS").join(MACOS_PET_APP_NAME); + return exec_path.exists().then_some(LaunchTarget { exec_path }); + } + + path.exists().then_some(LaunchTarget { exec_path: path }) +} + +#[cfg(target_os = "windows")] +fn normalize_launch_target(path: PathBuf) -> Option { + path.exists().then_some(LaunchTarget { exec_path: path }) +} + +#[cfg(not(any(target_os = "macos", target_os = "windows")))] +fn normalize_launch_target(path: PathBuf) -> Option { + path.exists().then_some(LaunchTarget { exec_path: path }) +} + +async fn companion_pet_ws( + ws: WebSocketUpgrade, + State(router_state): State, +) -> impl IntoResponse { + ws.on_upgrade(move |socket| handle_companion_socket(router_state, socket)) +} + +async fn handle_companion_socket(router_state: CompanionRouterState, socket: WebSocket) { + let connection_id = Uuid::new_v4().to_string(); + let (mut writer, mut reader) = socket.split(); + let (tx, mut rx) = mpsc::unbounded_channel::(); + + router_state + .service + .attach_sender(connection_id.clone(), tx) + .await; + router_state + .service + .mark_connection_open(connection_id.clone()) + .await; + + let writer_service = router_state.service.clone(); + let writer_connection_id = connection_id.clone(); + let writer_task = tokio::spawn(async move { + while let Some(message) = rx.recv().await { + if writer.send(Message::Text(message.into())).await.is_err() { + break; + } + } + + writer_service + .mark_connection_closed(&writer_connection_id, "桌宠连接写入通道已关闭".to_string()) + .await; + }); + + while let Some(message) = reader.next().await { + match message { + Ok(Message::Text(text)) => { + router_state + .service + .handle_incoming_message(&router_state.app_handle, &connection_id, &text) + .await; + } + Ok(Message::Binary(_)) => {} + Ok(Message::Ping(_)) => {} + Ok(Message::Pong(_)) => {} + Ok(Message::Close(_)) => { + break; + } + Err(error) => { + router_state + .service + .mark_connection_closed(&connection_id, format!("桌宠连接读取失败: {error}")) + .await; + writer_task.abort(); + return; + } + } + } + + writer_task.abort(); + router_state + .service + .mark_connection_closed(&connection_id, "桌宠连接已关闭".to_string()) + .await; +} + +pub async fn get_pet_status_global( + state: &CompanionServiceState, +) -> Result { + Ok(state.snapshot().await) +} + +pub async fn launch_pet_global( + state: &CompanionServiceState, + request: CompanionLaunchPetRequest, +) -> Result { + state.launch_pet(request).await +} + +pub async fn send_pet_command_global( + state: &CompanionServiceState, + request: CompanionPetCommandRequest, +) -> Result { + state.send_pet_command(request).await +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use tokio::sync::mpsc; + + #[tokio::test] + async fn send_pet_command_without_active_sender_returns_not_delivered() { + let state = CompanionServiceState::default(); + + let result = state + .send_pet_command(CompanionPetCommandRequest { + event: "pet.provider_overview".to_string(), + payload: json!({ + "total_provider_count": 2 + }), + }) + .await + .unwrap(); + + assert!(!result.delivered); + assert!(!result.connected); + } + + #[tokio::test] + async fn send_pet_command_serializes_envelope_and_updates_visual_state() { + let state = CompanionServiceState::default(); + let (tx, mut rx) = mpsc::unbounded_channel::(); + let connection_id = "conn-1".to_string(); + + state.attach_sender(connection_id.clone(), tx).await; + state.mark_connection_open(connection_id).await; + + let result = state + .send_pet_command(CompanionPetCommandRequest { + event: "pet.state_changed".to_string(), + payload: json!({ + "state": "thinking", + "total_provider_count": 3 + }), + }) + .await + .unwrap(); + + assert!(result.delivered); + assert!(result.connected); + + let outbound = rx.recv().await.expect("应收到发送给桌宠的消息"); + let envelope: serde_json::Value = serde_json::from_str(&outbound).expect("应输出合法 JSON"); + + assert_eq!( + envelope["protocol_version"], + json!(DEFAULT_PROTOCOL_VERSION) + ); + assert_eq!(envelope["event"], json!("pet.state_changed")); + assert_eq!(envelope["payload"]["state"], json!("thinking")); + assert_eq!(envelope["payload"]["total_provider_count"], json!(3)); + + let snapshot = state.snapshot().await; + assert_eq!(snapshot.last_event.as_deref(), Some("pet.state_changed")); + assert_eq!(snapshot.last_state, Some(CompanionPetVisualState::Thinking)); + assert!(snapshot.connected); + } +} diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 7ded91dd7..264313057 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -20,6 +20,7 @@ pub mod browser_profile_service; pub mod browser_runtime_window; pub mod chat_history_service; pub mod claw_solution_service; +pub mod companion_service; pub mod conversation_statistics_service; pub mod environment_service; pub mod execution_tracker_service; diff --git a/src-tauri/src/skills/default_skills.rs b/src-tauri/src/skills/default_skills.rs index 514b55f88..24d8e1978 100644 --- a/src-tauri/src/skills/default_skills.rs +++ b/src-tauri/src/skills/default_skills.rs @@ -42,6 +42,7 @@ const SITE_SEARCH_SKILL_CONTENT: &str = const SITE_SEARCH_ADAPTER_CATALOG_CONTENT: &str = include_str!("../../resources/default-skills/site_search/references/adapter-catalog.md"); +#[cfg(test)] const BUNDLED_SITE_ADAPTER_INDEX_CONTENT: &str = include_str!("../../resources/site-adapters/bundled/index.json"); diff --git a/src-tauri/tauri.conf.headless.json b/src-tauri/tauri.conf.headless.json index 9694f1bb4..9b10320b4 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.0.1", + "version": "1.1.0", "identifier": "com.lime.app", "build": { "beforeDevCommand": "npm run dev:web-bridge", diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 532bac66d..e67064347 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.0.1", + "version": "1.1.0", "identifier": "com.lime.app", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/App.tsx b/src/App.tsx index de3f59a99..fe9b80250 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -34,6 +34,7 @@ import { useSiteAdapterCatalogBootstrap } from "./hooks/useSiteAdapterCatalogBoo import { useAppNavigation } from "./hooks/useAppNavigation"; import { useAppShellLayout } from "./hooks/useAppShellLayout"; import { useAppStartupEffects } from "./hooks/useAppStartupEffects"; +import { useCompanionProviderBridge } from "./hooks/useCompanionProviderBridge"; import { useGlobalTrayModelSync } from "./hooks/useGlobalTrayModelSync"; import { useOemLimeHubProviderSync } from "./hooks/useOemLimeHubProviderSync"; import { ComponentDebugProvider } from "./contexts/ComponentDebugContext"; @@ -121,6 +122,9 @@ function AppContent() { currentPage, pageParams, }); + useCompanionProviderBridge({ + onNavigate: handleNavigate, + }); const _handleRequestRecommendation = useCallback( (shortLabel: string, fullPrompt: string, currentTheme: string) => { diff --git a/src/components/agent/chat/AgentChatHomeShell.test.tsx b/src/components/agent/chat/AgentChatHomeShell.test.tsx index 6ec4a2acb..28ed4c110 100644 --- a/src/components/agent/chat/AgentChatHomeShell.test.tsx +++ b/src/components/agent/chat/AgentChatHomeShell.test.tsx @@ -48,6 +48,20 @@ const { mockServiceSkills, } = vi.hoisted(() => { const mockClawSolutions = [ + { + id: "web-research-brief", + title: "网页研究简报", + summary: "快速整理研究目标、关键来源与结论框架。", + outputHint: "研究提纲 + 结论简报", + recommendedCapabilities: ["模型", "研究"], + readiness: "ready", + readinessMessage: "可直接开始", + badge: "研究方案", + recentUsedAt: null, + isRecent: false, + readinessLabel: "可直接开始", + readinessTone: "emerald", + }, { id: "social-post-starter", title: "社媒主稿生成", @@ -878,7 +892,8 @@ beforeEach(() => { domain: "github.com", profile_key: "attached-github", target_id: "tab-github", - message: "已检测到 github.com 的真实浏览器页面,Claw 可以直接复用当前会话执行。", + message: + "已检测到 github.com 的真实浏览器页面,Claw 可以直接复用当前会话执行。", }); mockIsTerminalServiceSkillRunStatus.mockImplementation((status: string) => ["success", "failed", "canceled", "timeout"].includes(status), @@ -1110,6 +1125,61 @@ describe("AgentChatHomeShell", () => { ); }); + it("点击网页研究简报时应开启联网研究偏好并进入工作区", async () => { + const onEnterWorkspace = vi.fn(); + mockPrepareClawSolution.mockResolvedValueOnce({ + solutionId: "web-research-brief", + actionType: "fill_input", + prompt: "请围绕这个主题先给我做一版网页研究简报", + shouldLaunchBrowserAssist: false, + shouldEnableTeamMode: false, + readiness: "ready", + readinessMessage: "可直接开始", + }); + + const { container } = renderShell({ + onNavigate: undefined, + onEnterWorkspace, + }); + + await flushEffects(); + + const researchSolutionButton = container.querySelector( + '[data-testid="home-shell-solution-web-research-brief"]', + ) as HTMLButtonElement | null; + + expect(researchSolutionButton).toBeTruthy(); + + act(() => { + researchSolutionButton?.click(); + }); + + await flushEffects(); + + expect(mockSaveChatToolPreferences).toHaveBeenLastCalledWith( + expect.objectContaining({ + webSearch: true, + thinking: false, + task: false, + subagent: false, + }), + "general", + ); + expect(onEnterWorkspace).toHaveBeenCalledWith( + expect.objectContaining({ + projectId: "project-1", + theme: "general", + initialCreationMode: "guided", + initialUserPrompt: "请围绕这个主题先给我做一版网页研究简报", + }), + ); + expect(mockRecordClawSolutionUsage).toHaveBeenCalledWith({ + solutionId: "web-research-brief", + actionType: "fill_input", + themeTarget: null, + }); + }); + it("最近 session runtime 的工具偏好应先回灌首页壳,再参与 team 推荐", async () => { mockHomeShellRecentExecutionRuntime.current = { recent_preferences: { @@ -1228,7 +1298,9 @@ describe("AgentChatHomeShell", () => { const snapshot = readTeamMemorySnapshot(localStorage, "/tmp/project-1"); expect( - snapshot?.entries["team.selection"]?.content.includes("Team:首页协作团队"), + snapshot?.entries["team.selection"]?.content.includes( + "Team:首页协作团队", + ), ).toBe(true); expect( container.querySelector('[data-testid="home-shell-selected-team"]') @@ -1282,9 +1354,8 @@ describe("AgentChatHomeShell", () => { harness: expect.objectContaining({ selected_team_label: "研究协作团队", selected_team_source: "custom", - selected_team_summary: expect.stringContaining( - "负责调研、归纳与验证。", - ), + selected_team_summary: + expect.stringContaining("负责调研、归纳与验证。"), }), }, }), @@ -1602,8 +1673,7 @@ describe("AgentChatHomeShell", () => { workbench_surface: "right_panel", }, }, - initialUserPrompt: - expect.stringContaining("[技能任务] 复制短视频脚本"), + initialUserPrompt: expect.stringContaining("[技能任务] 复制短视频脚本"), }), ); expect(onEnterWorkspace).toHaveBeenCalledWith( @@ -1696,14 +1766,14 @@ describe("AgentChatHomeShell", () => { theme: "general", initialCreationMode: "guided", autoRunInitialPromptOnMount: true, - initialUserPrompt: "你帮我在 GitHub 找一下和“browser assist mcp”相关的项目。", + initialUserPrompt: + "你帮我在 GitHub 找一下和“browser assist mcp”相关的项目。", initialRequestMetadata: undefined, initialAutoSendRequestMetadata: { harness: { browser_requirement: "required", - browser_requirement_reason: expect.stringContaining( - "真实浏览器页面", - ), + browser_requirement_reason: + expect.stringContaining("真实浏览器页面"), browser_assist: { enabled: true, profile_key: "attached-github", @@ -1739,9 +1809,9 @@ describe("AgentChatHomeShell", () => { expect(firstSiteSkillLaunchPayload?.initialUserPrompt).not.toContain( "adapter_name", ); - expect(firstSiteSkillLaunchPayload?.initialAutoSendRequestMetadata).not.toHaveProperty( - "artifact", - ); + expect( + firstSiteSkillLaunchPayload?.initialAutoSendRequestMetadata, + ).not.toHaveProperty("artifact"); expect(onEnterWorkspace).not.toHaveBeenCalled(); expect(mockRecordServiceSkillUsage).toHaveBeenCalledWith({ skillId: "github-repo-radar", @@ -1835,10 +1905,7 @@ describe("AgentChatHomeShell", () => { await flushEffects(); - expect(onNavigate).not.toHaveBeenCalledWith( - "agent", - expect.anything(), - ); + expect(onNavigate).not.toHaveBeenCalledWith("agent", expect.anything()); expect(onEnterWorkspace).not.toHaveBeenCalled(); expect(mockToastInfo).toHaveBeenCalledWith( expect.stringContaining("请先去浏览器工作台连接真实浏览器"), diff --git a/src/components/agent/chat/AgentChatWorkspace.tsx b/src/components/agent/chat/AgentChatWorkspace.tsx index 5ff45cc11..8e6946856 100644 --- a/src/components/agent/chat/AgentChatWorkspace.tsx +++ b/src/components/agent/chat/AgentChatWorkspace.tsx @@ -1142,6 +1142,9 @@ export function AgentChatWorkspace({ }), [syncSessionRecentTeamSelection], ); + const shouldAllowPersistedTeamFallback = + !persistedTeamMemoryShadowSnapshot && + !executionRuntime?.recent_team_selection; const { selectedTeam, @@ -1154,7 +1157,7 @@ export function AgentChatWorkspace({ runtimeSelection: executionRuntime?.recent_team_selection ?? null, shadowSnapshot: persistedTeamMemoryShadowSnapshot, sessionSync: selectedTeamSessionSync, - allowPersistedThemeFallback: !projectId, + allowPersistedThemeFallback: shouldAllowPersistedTeamFallback, }); const teamMemoryShadowSnapshot = useTeamMemoryShadowSync({ repoScope: project?.rootPath || null, @@ -2130,6 +2133,7 @@ export function AgentChatWorkspace({ handleRecommendationClick, handleSendRef, webSearchPreferenceRef, + submissionPreview, } = useWorkspaceSendActions({ input, setInput, @@ -2276,6 +2280,7 @@ export function AgentChatWorkspace({ pendingActionCount: pendingActions.length, queuedTurnCount: queuedTurns.length, runtimeTeamDispatchPreview, + submissionPreview, sessionId, updateTopicSnapshot, workspaceError: Boolean(workspacePathMissing || workspaceHealthError), diff --git a/src/components/agent/chat/claw-solutions/actionDispatcher.test.ts b/src/components/agent/chat/claw-solutions/actionDispatcher.test.ts index f9ac59ca1..e3d6b1442 100644 --- a/src/components/agent/chat/claw-solutions/actionDispatcher.test.ts +++ b/src/components/agent/chat/claw-solutions/actionDispatcher.test.ts @@ -70,6 +70,45 @@ describe("claw solution action dispatcher", () => { }); }); + it("网页研究简报应在进入工作区前开启联网研究偏好", () => { + const preparation: ClawSolutionPreparation = { + solutionId: "web-research-brief", + actionType: "fill_input", + prompt: "请围绕这个主题先给我做一版网页研究简报", + shouldLaunchBrowserAssist: false, + shouldEnableTeamMode: false, + readiness: "ready", + readinessMessage: "可直接开始", + }; + + expect(resolveClawSolutionLaunch(preparation, defaultPreferences)).toEqual({ + nextToolPreferences: { + webSearch: true, + thinking: false, + task: false, + subagent: false, + }, + preferencesChanged: true, + shouldStartBrowserAssistLoading: false, + enterWorkspacePayload: { + prompt: "请围绕这个主题先给我做一版网页研究简报", + openBrowserAssistOnMount: false, + toolPreferences: { + webSearch: true, + thinking: false, + task: false, + subagent: false, + }, + themeOverride: undefined, + }, + usageRecord: { + solutionId: "web-research-brief", + actionType: "fill_input", + themeTarget: null, + }, + }); + }); + it("应把多代理方案转换为开启 team 模式的载荷", () => { const preparation: ClawSolutionPreparation = { solutionId: "team-breakdown", @@ -110,9 +149,9 @@ describe("claw solution action dispatcher", () => { }); it("应把未就绪方案映射到对应配置入口", () => { - expect( - resolveClawSolutionSetupTarget("needs_setup", "missing_model"), - ).toBe(SettingsTabs.Providers); + expect(resolveClawSolutionSetupTarget("needs_setup", "missing_model")).toBe( + SettingsTabs.Providers, + ); expect( resolveClawSolutionSetupTarget( "needs_capability", diff --git a/src/components/agent/chat/claw-solutions/actionDispatcher.ts b/src/components/agent/chat/claw-solutions/actionDispatcher.ts index 43ee7e5d5..478aa099c 100644 --- a/src/components/agent/chat/claw-solutions/actionDispatcher.ts +++ b/src/components/agent/chat/claw-solutions/actionDispatcher.ts @@ -65,16 +65,56 @@ export function enableSubagentPreference(preferences: ChatToolPreferences): { }; } +function applyClawSolutionPreferencePreset( + preferences: ChatToolPreferences, + preset: Partial, +): { + nextToolPreferences: ChatToolPreferences; + changed: boolean; +} { + let changed = false; + const nextPreferences = { ...preferences }; + + for (const [key, value] of Object.entries(preset) as Array< + [keyof ChatToolPreferences, boolean | undefined] + >) { + if (!value || nextPreferences[key]) { + continue; + } + nextPreferences[key] = true; + changed = true; + } + + return { + nextToolPreferences: changed ? nextPreferences : preferences, + changed, + }; +} + +function resolveClawSolutionPreferencePreset( + preparation: ClawSolutionPreparation, +): Partial { + const preset: Partial = {}; + + if (preparation.solutionId === "web-research-brief") { + preset.webSearch = true; + } + + if (preparation.shouldEnableTeamMode) { + preset.subagent = true; + } + + return preset; +} + export function resolveClawSolutionLaunch( preparation: ClawSolutionPreparation, currentToolPreferences: ChatToolPreferences, ): ResolvedClawSolutionLaunch { - const { nextToolPreferences, changed } = preparation.shouldEnableTeamMode - ? enableSubagentPreference(currentToolPreferences) - : { - nextToolPreferences: currentToolPreferences, - changed: false, - }; + const { nextToolPreferences, changed } = applyClawSolutionPreferencePreset( + currentToolPreferences, + resolveClawSolutionPreferencePreset(preparation), + ); return { nextToolPreferences, diff --git a/src/components/agent/chat/components/EmptyStateComposerPanel.test.tsx b/src/components/agent/chat/components/EmptyStateComposerPanel.test.tsx index f2b382d0d..eb450fc08 100644 --- a/src/components/agent/chat/components/EmptyStateComposerPanel.test.tsx +++ b/src/components/agent/chat/components/EmptyStateComposerPanel.test.tsx @@ -7,10 +7,6 @@ import { createSkillSelectionProps, type SkillSelectionProps, } from "./Inputbar/components/skillSelectionBindings"; -import { - resetStableProcessingNoticeMemoryForTest, - STABLE_PROCESSING_NOTICE_AUTO_HIDE_MS, -} from "../hooks/useStableProcessingNotice"; vi.mock("./ChatModelSelector", () => ({ ChatModelSelector: () =>
, @@ -115,7 +111,6 @@ afterEach(() => { mounted.container.remove(); } vi.useRealTimers(); - resetStableProcessingNoticeMemoryForTest(); vi.clearAllMocks(); }); @@ -482,41 +477,18 @@ describe("EmptyStateComposerPanel", () => { ).toBeTruthy(); }); - it("命中稳妥模式模型时应短暂提示后自动收起且不再重复提醒", () => { - vi.useFakeTimers(); + it("命中稳妥模式模型时不应再展示额外横幅", () => { const container = renderPanel({ providerType: "openai", model: "glm-4.7", }); - expect( - container.querySelector( - '[data-testid="empty-state-stable-processing-notice"]', - ), - ).toBeTruthy(); - expect(container.textContent).toContain("稳妥模式"); - expect(container.textContent).toContain("依次开始同类请求"); - - act(() => { - vi.advanceTimersByTime(STABLE_PROCESSING_NOTICE_AUTO_HIDE_MS + 1); - }); - expect( container.querySelector( '[data-testid="empty-state-stable-processing-notice"]', ), ).toBeNull(); - - const nextContainer = renderPanel({ - providerType: "openai", - model: "glm-4.7", - }); - - expect( - nextContainer.querySelector( - '[data-testid="empty-state-stable-processing-notice"]', - ), - ).toBeNull(); + expect(container.textContent).not.toContain("稳妥模式"); }); it("点击多代理图标后应自动透传 Team 配置面板打开令牌", async () => { diff --git a/src/components/agent/chat/components/EmptyStateComposerPanel.tsx b/src/components/agent/chat/components/EmptyStateComposerPanel.tsx index 2ad809ba6..74f4553ec 100644 --- a/src/components/agent/chat/components/EmptyStateComposerPanel.tsx +++ b/src/components/agent/chat/components/EmptyStateComposerPanel.tsx @@ -22,7 +22,6 @@ import { InputbarModelExtra } from "./Inputbar/components/InputbarModelExtra"; import { SkillBadge } from "./Inputbar/components/SkillBadge"; import { SkillSelector } from "./Inputbar/components/SkillSelector"; import { TeamSelector } from "./Inputbar/components/TeamSelector"; -import { StableProcessingNotice } from "./StableProcessingNotice"; import type { WorkspaceSettings } from "@/types/workspace"; import { CREATION_MODE_CONFIG } from "./constants"; import type { @@ -47,7 +46,6 @@ import { } from "./emptyStateSurfaceTokens"; import type { ModelSelectorProps } from "@/components/input-kit"; import { getTeamSuggestion } from "../utils/teamSuggestion"; -import { useStableProcessingNotice } from "../hooks/useStableProcessingNotice"; import { buildSkillSelectionBindings, type SkillSelectionProps, @@ -247,10 +245,6 @@ export function EmptyStateComposerPanel({ const [teamSelectorAutoOpenToken, setTeamSelectorAutoOpenToken] = useState< number | null >(null); - const shouldShowStableNotice = useStableProcessingNotice({ - providerType, - model, - }); const activeSkill = skillSelection.activeSkill ?? null; const clearActiveSkill = skillSelection.onClearSkill; const { mentionProps: mentionSkillProps, selectorProps: skillSelectorProps } = @@ -315,8 +309,7 @@ export function EmptyStateComposerPanel({ const topExtra = isEntryTheme || Boolean(activeSkill) || - shouldShowTeamSuggestion || - shouldShowStableNotice ? ( + shouldShowTeamSuggestion ? ( <> {isEntryTheme ? ( @@ -388,14 +381,6 @@ export function EmptyStateComposerPanel({ onContinueSingleAgent={handleContinueSingleAgent} /> ) : null} - - {shouldShowStableNotice ? ( - - ) : null} ) : undefined; diff --git a/src/components/agent/chat/components/Inputbar/components/InputbarComposerSection.tsx b/src/components/agent/chat/components/Inputbar/components/InputbarComposerSection.tsx index 2127cc35e..7a2125dda 100644 --- a/src/components/agent/chat/components/Inputbar/components/InputbarComposerSection.tsx +++ b/src/components/agent/chat/components/Inputbar/components/InputbarComposerSection.tsx @@ -16,11 +16,9 @@ import { InputbarModelExtra } from "./InputbarModelExtra"; import { InputbarVisionCapabilityNotice } from "./InputbarVisionCapabilityNotice"; import { InputbarExecutionStrategySelect } from "./InputbarExecutionStrategySelect"; import { InputbarAccessModeSelect } from "./InputbarAccessModeSelect"; -import { StableProcessingNotice } from "../../StableProcessingNotice"; import { isGeneralResearchTheme } from "../../../utils/generalAgentPrompt"; import type { TeamDefinition } from "../../../utils/teamDefinitions"; import type { WorkspaceSettings } from "@/types/workspace"; -import { useStableProcessingNotice } from "../../../hooks/useStableProcessingNotice"; import { buildSkillSelectionBindings, type SkillSelectionProps, @@ -130,27 +128,14 @@ export const InputbarComposerSection: React.FC< buildSkillSelectionBindings(skillSelection); const resolvedProviderType = inputAdapter.model?.providerType; const resolvedModel = inputAdapter.model?.model; - const shouldShowStableNotice = useStableProcessingNotice({ - providerType: resolvedProviderType, - model: resolvedModel, - }); - const showStableNotice = - !isThemeWorkbenchVariant && shouldShowStableNotice; const shouldShowVisionNotice = currentPendingImages.length > 0 && Boolean(resolvedProviderType?.trim()) && Boolean(resolvedModel?.trim()); const resolvedTopExtra = - topExtra || showStableNotice || shouldShowVisionNotice ? ( + topExtra || shouldShowVisionNotice ? ( <> {topExtra} - {showStableNotice ? ( - - ) : null} {shouldShowVisionNotice ? ( { label: "OpenAI", registryId: "openai", type: "openai", + providerId: "openai", + apiHost: "https://api.openai.com/v1", }, ], loading: false, @@ -87,6 +89,20 @@ function renderNotice( } describe("InputbarVisionCapabilityNotice", () => { + it("受管 API Key Provider 应按真实模型目录检查多模态能力", () => { + renderNotice(); + + expect(mockUseProviderModels).toHaveBeenCalledWith( + expect.objectContaining({ key: "openai" }), + expect.objectContaining({ + returnFullMetadata: true, + autoLoad: true, + liveFetchOnly: true, + hasApiKey: true, + }), + ); + }); + it("当前模型支持多模态时不应展示提示", () => { const container = renderNotice(); diff --git a/src/components/agent/chat/components/Inputbar/components/InputbarVisionCapabilityNotice.tsx b/src/components/agent/chat/components/Inputbar/components/InputbarVisionCapabilityNotice.tsx index 9fca52b68..8ab921ab5 100644 --- a/src/components/agent/chat/components/Inputbar/components/InputbarVisionCapabilityNotice.tsx +++ b/src/components/agent/chat/components/Inputbar/components/InputbarVisionCapabilityNotice.tsx @@ -3,6 +3,7 @@ import { AlertCircle } from "lucide-react"; import { useConfiguredProviders } from "@/hooks/useConfiguredProviders"; import { useProviderModels } from "@/hooks/useProviderModels"; import { resolveVisionModel } from "@/lib/model/visionModelResolver"; +import { resolveProviderModelLoadOptions } from "@/lib/model/providerModelLoadOptions"; interface InputbarVisionCapabilityNoticeProps { providerType?: string; @@ -26,12 +27,22 @@ export const InputbarVisionCapabilityNotice: React.FC< () => providers.find((item) => item.key === providerType), [providerType, providers], ); + const providerModelLoadOptions = useMemo( + () => + resolveProviderModelLoadOptions({ + providerId: selectedProvider?.providerId, + providerType: selectedProvider?.type, + apiHost: selectedProvider?.apiHost, + }), + [selectedProvider?.apiHost, selectedProvider?.providerId, selectedProvider?.type], + ); const { models, loading: modelsLoading } = useProviderModels( selectedProvider, { returnFullMetadata: true, autoLoad: shouldInspectCapability && Boolean(selectedProvider), + ...providerModelLoadOptions, }, ); diff --git a/src/components/agent/chat/components/Inputbar/components/TeamSelectorPanel.test.tsx b/src/components/agent/chat/components/Inputbar/components/TeamSelectorPanel.test.tsx index 424dccb0d..05a63161c 100644 --- a/src/components/agent/chat/components/Inputbar/components/TeamSelectorPanel.test.tsx +++ b/src/components/agent/chat/components/Inputbar/components/TeamSelectorPanel.test.tsx @@ -3,10 +3,6 @@ import { createRoot, type Root } from "react-dom/client"; import type { WorkspaceSettings } from "@/types/workspace"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { TeamSelectorPanel } from "./TeamSelectorPanel"; -import { - resetStableProcessingNoticeMemoryForTest, - STABLE_PROCESSING_NOTICE_AUTO_HIDE_MS, -} from "../../../hooks/useStableProcessingNotice"; import { createTeamDefinitionFromPreset, type TeamDefinition, @@ -96,7 +92,6 @@ describe("TeamSelectorPanel", () => { } localStorage.clear(); vi.useRealTimers(); - resetStableProcessingNoticeMemoryForTest(); vi.clearAllMocks(); }); @@ -222,8 +217,7 @@ describe("TeamSelectorPanel", () => { expect(mockToast.success).toHaveBeenCalled(); }); - it("命中稳妥模式模型时应短暂提示 Team 会依次开始后自动收起", async () => { - vi.useFakeTimers(); + it("命中稳妥模式模型时不应再展示 Team 横幅提示", async () => { const { container } = renderPanel({ providerType: "openai", model: "glm-4.7", @@ -231,34 +225,11 @@ describe("TeamSelectorPanel", () => { await flushEffects(); - expect( - container.querySelector( - '[data-testid="team-selector-stable-processing-notice"]', - ), - ).toBeTruthy(); - expect(container.textContent).toContain("稳妥模式"); - expect(container.textContent).toContain("协作成员"); - expect(container.textContent).toContain("依次开始"); - - act(() => { - vi.advanceTimersByTime(STABLE_PROCESSING_NOTICE_AUTO_HIDE_MS + 1); - }); - expect( container.querySelector( '[data-testid="team-selector-stable-processing-notice"]', ), ).toBeNull(); - - const nextRender = renderPanel({ - providerType: "openai", - model: "glm-4.7", - }); - - expect( - nextRender.container.querySelector( - '[data-testid="team-selector-stable-processing-notice"]', - ), - ).toBeNull(); + expect(container.textContent).not.toContain("稳妥模式"); }); }); diff --git a/src/components/agent/chat/components/Inputbar/components/TeamSelectorPanel.tsx b/src/components/agent/chat/components/Inputbar/components/TeamSelectorPanel.tsx index 3c6e9c71b..7af466efc 100644 --- a/src/components/agent/chat/components/Inputbar/components/TeamSelectorPanel.tsx +++ b/src/components/agent/chat/components/Inputbar/components/TeamSelectorPanel.tsx @@ -17,8 +17,6 @@ import { Textarea } from "@/components/ui/textarea"; import { cn } from "@/lib/utils"; import type { WorkspaceSettings } from "@/types/workspace"; import { toast } from "sonner"; -import { StableProcessingNotice } from "../../StableProcessingNotice"; -import { useStableProcessingNotice } from "../../../hooks/useStableProcessingNotice"; import { BUILTIN_TEAM_PROFILE_OPTIONS, BUILTIN_TEAM_SKILL_OPTIONS, @@ -301,8 +299,8 @@ export const TeamSelectorPanel: React.FC = ({ activeTheme, input, workspaceId: _workspaceId, - providerType, - model, + providerType: _providerType, + model: _model, executionStrategy: _executionStrategy, selectedTeam = null, onSelectTeam, @@ -317,10 +315,6 @@ export const TeamSelectorPanel: React.FC = ({ const isProjectScopedCustomTeam = Boolean( workspaceSettings && onPersistCustomTeams, ); - const shouldShowStableNotice = useStableProcessingNotice({ - providerType, - model, - }); useEffect(() => { setCustomTeams(resolveCustomTeams(workspaceSettings)); @@ -1032,13 +1026,6 @@ export const TeamSelectorPanel: React.FC = ({ ) : null}
- {shouldShowStableNotice ? ( - - ) : null} {selectedTeam ? (
{ it("本地注册表无模型时应回退到后端 provider API 结果", async () => { mockLoadConfiguredProviders.mockResolvedValueOnce([ - createProvider(), + createProvider({ + providerId: "managed-social-provider", + apiHost: "https://api.example.com/v1", + }), ]); mockFetchProviderModelsAuto.mockResolvedValueOnce({ models: [ @@ -157,7 +160,7 @@ describe("resolveClawWorkspaceProviderSelection", () => { model: "social-model-api", }); expect(mockFetchProviderModelsAuto).toHaveBeenCalledWith( - "custom-social-provider", + "managed-social-provider", ); }); }); diff --git a/src/components/agent/chat/utils/clawWorkspaceProviderSelection.ts b/src/components/agent/chat/utils/clawWorkspaceProviderSelection.ts index 83c6b9457..95da58270 100644 --- a/src/components/agent/chat/utils/clawWorkspaceProviderSelection.ts +++ b/src/components/agent/chat/utils/clawWorkspaceProviderSelection.ts @@ -2,12 +2,8 @@ import { loadConfiguredProviders, type ConfiguredProvider, } from "@/hooks/useConfiguredProviders"; -import { modelRegistryApi } from "@/lib/api/modelRegistry"; -import { - getAliasConfigKey, - isAliasProvider, -} from "@/lib/constants/providerMappings"; -import { buildProviderModelsFromRegistry } from "@/lib/model/providerModelsCatalog"; +import { loadProviderModels } from "@/hooks/useProviderModels"; +import { resolveProviderModelLoadOptions } from "@/lib/model/providerModelLoadOptions"; import { type EnhancedModelMetadata } from "@/lib/types/modelRegistry"; import { filterModelsByTheme } from "./modelThemePolicy"; import { resolveProviderModelCompatibility } from "./providerModelCompatibility"; @@ -88,52 +84,6 @@ function resolvePreferredModelId( return candidateModels[0]?.id ?? null; } -async function loadProviderModelsForSelection( - provider: ConfiguredProvider, -): Promise { - const aliasConfigPromise = isAliasProvider(provider.key) - ? modelRegistryApi.getProviderAliasConfig(getAliasConfigKey(provider.key)) - : Promise.resolve(null); - const [registryModels, aliasConfig] = await Promise.all([ - modelRegistryApi.getModelRegistry(), - aliasConfigPromise, - ]); - const localResult = buildProviderModelsFromRegistry( - provider, - registryModels, - aliasConfig, - ); - - if (localResult.models.length > 0 || isAliasProvider(provider.key)) { - return localResult.models; - } - - try { - const apiResult = await modelRegistryApi.fetchProviderModelsAuto(provider.key); - if (!apiResult.models.length) { - return localResult.models; - } - - const existingModelIds = new Set( - localResult.models.map((model) => normalizeValue(model.id)), - ); - - return [ - ...localResult.models, - ...apiResult.models.filter((model) => { - const normalizedModelId = normalizeValue(model.id); - if (existingModelIds.has(normalizedModelId)) { - return false; - } - existingModelIds.add(normalizedModelId); - return true; - }), - ]; - } catch { - return localResult.models; - } -} - export async function resolveClawWorkspaceProviderSelection( input: ResolveClawWorkspaceProviderSelectionInput, ): Promise { @@ -157,7 +107,14 @@ export async function resolveClawWorkspaceProviderSelection( : configuredProviders; for (const provider of orderedProviders) { - const providerModels = await loadProviderModelsForSelection(provider); + const providerModels = await loadProviderModels( + provider, + resolveProviderModelLoadOptions({ + providerId: provider.providerId, + providerType: provider.type, + apiHost: provider.apiHost, + }), + ); const preferredModel = resolvePreferredModelId( provider, providerModels, diff --git a/src/components/agent/chat/workspace/submissionPreview.ts b/src/components/agent/chat/workspace/submissionPreview.ts new file mode 100644 index 000000000..41e64e2c3 --- /dev/null +++ b/src/components/agent/chat/workspace/submissionPreview.ts @@ -0,0 +1,66 @@ +import type { AsterExecutionStrategy } from "@/lib/api/agentRuntime"; +import type { Message, MessageImage } from "../types"; +import { + buildWaitingAgentRuntimeStatus, + formatAgentRuntimeStatusSummary, +} from "../utils/agentRuntimeStatus"; + +export interface SubmissionPreviewSnapshot { + key: string; + prompt: string; + images: MessageImage[]; + createdAt: number; + runtimeStatus: NonNullable; +} + +interface CreateSubmissionPreviewSnapshotOptions { + key: string; + prompt: string; + images: MessageImage[]; + executionStrategy: AsterExecutionStrategy; + webSearch?: boolean; + thinking?: boolean; +} + +export function createSubmissionPreviewSnapshot( + options: CreateSubmissionPreviewSnapshotOptions, +): SubmissionPreviewSnapshot { + const { key, prompt, images, executionStrategy, webSearch, thinking } = + options; + + return { + key, + prompt, + images, + createdAt: Date.now(), + runtimeStatus: buildWaitingAgentRuntimeStatus({ + executionStrategy, + webSearch, + thinking, + }), + }; +} + +export function buildSubmissionPreviewMessages( + snapshot: SubmissionPreviewSnapshot, +): Message[] { + const timestamp = new Date(snapshot.createdAt); + + return [ + { + id: `submission-preview:${snapshot.key}:user`, + role: "user", + content: snapshot.prompt, + images: snapshot.images.length > 0 ? snapshot.images : undefined, + timestamp, + }, + { + id: `submission-preview:${snapshot.key}:assistant`, + role: "assistant", + content: formatAgentRuntimeStatusSummary(snapshot.runtimeStatus), + timestamp: new Date(timestamp.getTime() + 1), + isThinking: true, + runtimeStatus: snapshot.runtimeStatus, + }, + ]; +} diff --git a/src/components/agent/chat/workspace/useWorkspaceDisplayMessagesRuntime.ts b/src/components/agent/chat/workspace/useWorkspaceDisplayMessagesRuntime.ts index 2aae34954..a4cb5650f 100644 --- a/src/components/agent/chat/workspace/useWorkspaceDisplayMessagesRuntime.ts +++ b/src/components/agent/chat/workspace/useWorkspaceDisplayMessagesRuntime.ts @@ -4,6 +4,10 @@ import type { BrowserTaskPreflight } from "../hooks/handleSendTypes"; import { buildLegacyQuestionnaireA2UI } from "../utils/legacyQuestionnaireA2UI"; import { buildRuntimeTeamDispatchPreviewMessages } from "./runtimeTeamPreview"; import type { RuntimeTeamDispatchPreviewSnapshot } from "./runtimeTeamPreview"; +import { + buildSubmissionPreviewMessages, + type SubmissionPreviewSnapshot, +} from "./submissionPreview"; import type { Message } from "../types"; interface UseWorkspaceDisplayMessagesRuntimeParams { @@ -14,6 +18,7 @@ interface UseWorkspaceDisplayMessagesRuntimeParams { pendingActionCount: number; queuedTurnCount: number; runtimeTeamDispatchPreview: RuntimeTeamDispatchPreviewSnapshot | null; + submissionPreview: SubmissionPreviewSnapshot | null; sessionId?: string | null; updateTopicSnapshot: ( sessionId: string, @@ -98,6 +103,7 @@ export function useWorkspaceDisplayMessagesRuntime({ pendingActionCount, queuedTurnCount, runtimeTeamDispatchPreview, + submissionPreview, sessionId, updateTopicSnapshot, workspaceError, @@ -107,11 +113,19 @@ export function useWorkspaceDisplayMessagesRuntime({ const runtimeTeamDispatchPreviewMessages = runtimeTeamDispatchPreview ? buildRuntimeTeamDispatchPreviewMessages(runtimeTeamDispatchPreview) : []; + const submissionPreviewMessages = + collapsedMessages.length === 0 && submissionPreview + ? buildSubmissionPreviewMessages(submissionPreview) + : []; if (runtimeTeamDispatchPreviewMessages.length > 0) { return [...collapsedMessages, ...runtimeTeamDispatchPreviewMessages]; } + if (submissionPreviewMessages.length > 0) { + return submissionPreviewMessages; + } + if (collapsedMessages.length === 0 && browserTaskPreflight) { return buildBrowserTaskPreflightPreviewMessages(browserTaskPreflight); } @@ -129,6 +143,7 @@ export function useWorkspaceDisplayMessagesRuntime({ bootstrapDispatchPreviewMessages, messages, runtimeTeamDispatchPreview, + submissionPreview, ]); useEffect(() => { diff --git a/src/components/agent/chat/workspace/useWorkspaceSendActions.test.tsx b/src/components/agent/chat/workspace/useWorkspaceSendActions.test.tsx index fd942b709..4a24e3c95 100644 --- a/src/components/agent/chat/workspace/useWorkspaceSendActions.test.tsx +++ b/src/components/agent/chat/workspace/useWorkspaceSendActions.test.tsx @@ -33,6 +33,16 @@ const mockEnsureBrowserAssistCanvas = vi.fn(async () => true); const mockHandleAutoLaunchMatchedSiteSkill = vi.fn(async () => undefined); const mockHandleImageWorkbenchCommand = vi.fn(async () => false); +function createDeferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((nextResolve, nextReject) => { + resolve = nextResolve; + reject = nextReject; + }); + return { promise, resolve, reject }; +} + function createGithubSiteSkill(): ServiceSkillHomeItem { return { id: "github-repo-radar", @@ -315,6 +325,83 @@ describe("useWorkspaceSendActions", () => { } }); + it("发送准备阶段应立即暴露 submission preview,并在真实提交接管后清理", async () => { + const deferredSend = createDeferred(); + mockSendMessage.mockImplementationOnce(async () => deferredSend.promise); + const harness = mountHook({ + input: "帮我找一下今天的新闻", + }); + + let sendPromise: Promise | null = null; + + try { + await act(async () => { + sendPromise = harness.getValue().handleSend(); + await Promise.resolve(); + }); + + expect(harness.getValue().submissionPreview).toMatchObject({ + prompt: "帮我找一下今天的新闻", + runtimeStatus: expect.objectContaining({ + title: "正在启动处理流程", + }), + }); + + await act(async () => { + deferredSend.resolve(); + await sendPromise; + }); + + expect(harness.getValue().submissionPreview).toBeNull(); + } finally { + harness.unmount(); + } + }); + + it("准备活动上下文较慢时,也应先展示 submission preview", async () => { + const deferredContext = createDeferred(); + const deferredSend = createDeferred(); + mockSendMessage.mockImplementationOnce(async () => deferredSend.promise); + const harness = mountHook({ + input: "帮我整理一下今天的重要新闻", + contextWorkspace: { + enabled: true, + activeContextPrompt: "", + prepareActiveContextPrompt: async () => deferredContext.promise, + }, + }); + + let sendPromise: Promise | null = null; + + try { + await act(async () => { + sendPromise = harness.getValue().handleSend(); + await Promise.resolve(); + }); + + expect(harness.getValue().submissionPreview).toMatchObject({ + prompt: "帮我整理一下今天的重要新闻", + runtimeStatus: expect.objectContaining({ + title: "正在启动处理流程", + }), + }); + + await act(async () => { + deferredContext.resolve("[上下文]\n今天关注科技与国际新闻。"); + await Promise.resolve(); + }); + + await act(async () => { + deferredSend.resolve(); + await sendPromise; + }); + + expect(harness.getValue().submissionPreview).toBeNull(); + } finally { + harness.unmount(); + } + }); + it("普通自然句命中站点 service skill 时应直接走自动启动链", async () => { const harness = mountHook({ input: "请帮我使用 GitHub 查一下 AI Agent 项目", diff --git a/src/components/agent/chat/workspace/useWorkspaceSendActions.ts b/src/components/agent/chat/workspace/useWorkspaceSendActions.ts index 14cac65b1..1436f4fe3 100644 --- a/src/components/agent/chat/workspace/useWorkspaceSendActions.ts +++ b/src/components/agent/chat/workspace/useWorkspaceSendActions.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import type { Dispatch, SetStateAction } from "react"; import type { AutoContinueRequestPayload } from "@/lib/api/agentRuntime"; @@ -29,6 +29,10 @@ import { type ContextWorkspaceSummary, type EnsureBrowserAssistCanvasOptions, } from "./workspaceSendHelpers"; +import { + createSubmissionPreviewSnapshot, + type SubmissionPreviewSnapshot, +} from "./submissionPreview"; import type { Character } from "@/lib/api/memory"; import type { TeamMemorySnapshot } from "@/lib/teamMemorySync"; import type { ThemeType } from "@/lib/workspace/workbenchContract"; @@ -112,6 +116,7 @@ interface WorkspaceResolvedSendState { effectiveToolPreferences: ChatToolPreferences; effectiveWebSearch?: boolean; effectiveThinking?: boolean; + submissionPreviewKey: string; } interface WorkspaceSendPlan extends WorkspaceResolvedSendState { @@ -181,6 +186,9 @@ export function useWorkspaceSendActions({ handleAutoLaunchMatchedSiteSkill, handleImageWorkbenchCommand, }: UseWorkspaceSendActionsParams) { + const [submissionPreview, setSubmissionPreview] = + useState(null); + const resolveSendExecutionPlan = useCallback( async ( images?: MessageImage[], @@ -283,12 +291,32 @@ export function useWorkspaceSendActions({ }); } - const text = await buildWorkspaceSendText({ - sourceText, - contextWorkspace, - mentionedCharacters, - sendOptions, - }); + const submissionPreviewKey = crypto.randomUUID(); + setSubmissionPreview( + createSubmissionPreviewSnapshot({ + key: submissionPreviewKey, + prompt: sourceText, + images: images || [], + executionStrategy: sendExecutionStrategy ?? executionStrategy, + webSearch: effectiveWebSearch, + thinking: effectiveThinking, + }), + ); + + let text: string; + try { + text = await buildWorkspaceSendText({ + sourceText, + contextWorkspace, + mentionedCharacters, + sendOptions, + }); + } catch (error) { + setSubmissionPreview((current) => + current?.key === submissionPreviewKey ? null : current, + ); + throw error; + } return { kind: "ready", @@ -300,6 +328,7 @@ export function useWorkspaceSendActions({ effectiveToolPreferences, effectiveWebSearch, effectiveThinking, + submissionPreviewKey, sendExecutionStrategy, autoContinuePayload, sendOptions, @@ -311,6 +340,7 @@ export function useWorkspaceSendActions({ chatToolPreferences, contextWorkspace, ensureBrowserAssistCanvas, + executionStrategy, handleAutoLaunchMatchedSiteSkill, handleImageWorkbenchCommand, input, @@ -333,12 +363,15 @@ export function useWorkspaceSendActions({ effectiveToolPreferences, effectiveWebSearch, effectiveThinking, + submissionPreviewKey, sendExecutionStrategy, autoContinuePayload, sendOptions, } = plan; setRuntimeTeamDispatchPreview(null); + setInput(""); + setMentionedCharacters([]); try { const preparedRuntimeTeamState = await _prepareRuntimeTeamBeforeSend({ @@ -357,9 +390,6 @@ export function useWorkspaceSendActions({ ); } - setInput(""); - setMentionedCharacters([]); - const nextRequestMetadata = buildWorkspaceRequestMetadata({ workspaceRequestMetadataBase, sendOptions: { @@ -420,6 +450,10 @@ export function useWorkspaceSendActions({ toast.error(`发送失败: ${errorMessage}`); setInput(sourceText); return false; + } finally { + setSubmissionPreview((current) => + current?.key === submissionPreviewKey ? null : current, + ); } }, [ @@ -536,5 +570,6 @@ export function useWorkspaceSendActions({ handleRecommendationClick, handleSendRef, webSearchPreferenceRef, + submissionPreview, }; } diff --git a/src/components/image-gen/useImageGen.resource.test.tsx b/src/components/image-gen/useImageGen.resource.test.tsx index 486b4c613..38fc1c0cc 100644 --- a/src/components/image-gen/useImageGen.resource.test.tsx +++ b/src/components/image-gen/useImageGen.resource.test.tsx @@ -46,6 +46,10 @@ vi.mock("@/lib/api/apiKeyProvider", () => ({ }, })); +vi.mock("@/lib/dev-bridge", () => ({ + safeInvoke: mockInvoke, +})); + vi.mock("@tauri-apps/api/core", () => ({ invoke: mockInvoke, })); diff --git a/src/components/input-kit/ModelSelector.test.tsx b/src/components/input-kit/ModelSelector.test.tsx index 71480aa8e..5244a6b73 100644 --- a/src/components/input-kit/ModelSelector.test.tsx +++ b/src/components/input-kit/ModelSelector.test.tsx @@ -76,6 +76,8 @@ beforeEach(() => { registryId: "custom-codex", fallbackRegistryId: "codex", type: "codex", + providerId: "custom-codex", + apiHost: "https://api.openai.com/v1", }, ], loading: false, @@ -144,6 +146,8 @@ describe("ModelSelector", () => { expect.objectContaining({ returnFullMetadata: true, autoLoad: false, + liveFetchOnly: true, + hasApiKey: true, }), ); }); diff --git a/src/components/input-kit/ModelSelector.tsx b/src/components/input-kit/ModelSelector.tsx index 34b5dbb99..ad690a55b 100644 --- a/src/components/input-kit/ModelSelector.tsx +++ b/src/components/input-kit/ModelSelector.tsx @@ -24,6 +24,7 @@ import { getProviderLabel } from "@/lib/constants/providerMappings"; import { ModelCapabilityBadges } from "@/components/model/ModelCapabilityBadges"; import { resolveOemCloudRuntimeContext } from "@/lib/api/oemCloudRuntime"; import { resolveOemLimeHubProviderName } from "@/lib/oemLimeHubProvider"; +import { resolveProviderModelLoadOptions } from "@/lib/model/providerModelLoadOptions"; const compactTriggerClassName = "h-8 min-w-[104px] max-w-[168px] justify-start gap-1.5 rounded-full border-slate-200/80 bg-white/92 px-2.5 text-slate-600 shadow-none transition-colors hover:border-slate-300 hover:bg-white hover:text-slate-800"; @@ -135,10 +136,23 @@ export const ModelSelector: React.FC = ({ (provider) => provider.key === providerType, ); }, [configuredProviders, providerType]); + const providerModelLoadOptions = useMemo( + () => + resolveProviderModelLoadOptions({ + providerId: selectedProvider?.providerId, + providerType: selectedProvider?.type, + apiHost: selectedProvider?.apiHost, + }), + [selectedProvider?.apiHost, selectedProvider?.providerId, selectedProvider?.type], + ); const { models: providerModels, loading: modelsLoading } = useProviderModels( selectedProvider, - { returnFullMetadata: true, autoLoad: shouldLoadModels }, + { + returnFullMetadata: true, + autoLoad: shouldLoadModels, + ...providerModelLoadOptions, + }, ); const filteredResult = useMemo(() => { diff --git a/src/components/model-selector/ProviderModelSelector.test.tsx b/src/components/model-selector/ProviderModelSelector.test.tsx index b3a7b9ee6..be7bd6a8f 100644 --- a/src/components/model-selector/ProviderModelSelector.test.tsx +++ b/src/components/model-selector/ProviderModelSelector.test.tsx @@ -66,6 +66,8 @@ beforeEach(() => { registryId: "custom-codex", fallbackRegistryId: "codex", type: "codex", + providerId: "custom-codex", + apiHost: "https://api.openai.com/v1", }, ], loading: false, @@ -117,6 +119,19 @@ afterEach(() => { }); describe("ProviderModelSelector", () => { + it("支持实时拉取的 API Key Provider 应使用真实模型目录", () => { + renderSelector(); + + expect(mockUseProviderModels).toHaveBeenCalledWith( + expect.objectContaining({ key: "custom-codex" }), + expect.objectContaining({ + returnFullMetadata: true, + liveFetchOnly: true, + hasApiKey: true, + }), + ); + }); + it("应隐藏 codex 不兼容模型并展示兼容提示", () => { const { container } = renderSelector(); diff --git a/src/components/model-selector/ProviderModelSelector.tsx b/src/components/model-selector/ProviderModelSelector.tsx index 9636ece02..635c46bcb 100644 --- a/src/components/model-selector/ProviderModelSelector.tsx +++ b/src/components/model-selector/ProviderModelSelector.tsx @@ -23,6 +23,7 @@ import { useProviderModels } from "@/hooks/useProviderModels"; import { getProviderLabel } from "@/lib/constants/providerMappings"; import type { EnhancedModelMetadata } from "@/lib/types/modelRegistry"; import { getProviderModelCompatibilityIssue } from "@/components/agent/chat/utils/providerModelCompatibility"; +import { resolveProviderModelLoadOptions } from "@/lib/model/providerModelLoadOptions"; // ============================================================================ // 类型定义 @@ -180,13 +181,25 @@ export const ProviderModelSelector: React.FC = ({ const selectedProvider = useMemo(() => { return configuredProviders.find((p) => p.key === selectedProviderId); }, [configuredProviders, selectedProviderId]); + const providerModelLoadOptions = useMemo( + () => + resolveProviderModelLoadOptions({ + providerId: selectedProvider?.providerId, + providerType: selectedProvider?.type, + apiHost: selectedProvider?.apiHost, + }), + [selectedProvider?.apiHost, selectedProvider?.providerId, selectedProvider?.type], + ); // 获取模型列表(使用共享 hook,返回完整元数据) const { models: filteredModels, loading: modelsLoading, error: modelsError, - } = useProviderModels(selectedProvider, { returnFullMetadata: true }); + } = useProviderModels(selectedProvider, { + returnFullMetadata: true, + ...providerModelLoadOptions, + }); const compatibleModels = useMemo( () => diff --git a/src/components/provider-pool/ProviderPoolPage.tsx b/src/components/provider-pool/ProviderPoolPage.tsx index 670a24036..50d553b16 100644 --- a/src/components/provider-pool/ProviderPoolPage.tsx +++ b/src/components/provider-pool/ProviderPoolPage.tsx @@ -349,14 +349,15 @@ export const ProviderPoolPage = forwardRef< const currentCredentials = currentPool?.credentials || []; return ( -
+
{!hideHeader && ( -
+
-

凭证池

-

- 管理多个 AI 服务凭证,自动轮询负载均衡,并为本机默认 Provider - 提供稳定的凭证来源 +

+ Provider 与凭证 +

+

+ 默认先管理 API Key Provider。OAuth、语音和中转服务保留在同一入口,但不再和日常 Provider 配置抢同一视觉焦点。

@@ -369,42 +370,42 @@ export const ProviderPoolPage = forwardRef< )} {/* Category Tabs - 第一行:分类选择 */} -
+
+ )} +
+
+ +
+ {visibleProviders.length > 0 ? ( + visibleProviders.map((provider) => { + const isActive = selectedKnownProvider?.id === provider.id; + return ( + + ); + }) + ) : ( +
+

+ 没有匹配的模板 +

+

+ 继续在右侧手动填写,也可以换个关键词再搜。 +

+
+ )} +
+
+ + +
+
+
+
+
+
+ +

+ 当前接入方案 +

+
+

+ {formState.name || + selectedKnownProvider?.name || + "手动创建新 Provider"} +

+

+ {selectedKnownProvider + ? "已按模板预填协议类型与接口地址,你只需要确认鉴权和附加参数。" + : "适合接入未内置的厂商、私有部署网关,或需要兼容 OpenAI / Anthropic / Gemini 协议的第三方服务。"} +

+
+ +
+ + {typeOption?.label ?? + getProviderTypeLabel(formState.type)} + + {selectedKnownProvider ? ( + + 已套用模板 + + ) : ( + + 手动配置 + + )} +
+
+ +
+ {summaryCards.map((item) => { + const Icon = item.icon; + return ( +
+
+ + {item.title} +
+

+ {item.value} +

+

+ {item.hint} +

+
+ ); + })} +
+
+ +
+
+
+ +

基础接入信息

+
+

+ 这里决定 Provider 的名称、接口地址和首个 API + Key,是最关键的一步。 +

+
+ +
+
+ + updateField("name", e.target.value)} + placeholder="例如:My Custom API" + disabled={isSubmitting} + className={cn( + "border-slate-200 bg-white", + errors.name && "border-red-500", + )} + data-testid="provider-name-input" + /> + {errors.name ? ( +

+ {errors.name} +

+ ) : ( +

+ 建议用厂商或网关名称,便于后续在左侧列表快速识别。 +

+ )} +
+ +
+ + updateField("apiHost", e.target.value)} + placeholder="https://api.example.com" + disabled={isSubmitting} + className={cn( + "border-slate-200 bg-white", + errors.apiHost && "border-red-500", + )} + data-testid="api-host-input" + /> + {errors.apiHost ? ( +

+ {errors.apiHost} +

+ ) : ( +

+ 可以填写官方接口、代理网关或企业内部统一中转地址。 +

+ )} +
+
+ +
+ + updateField("apiKey", e.target.value)} + placeholder="sk-..." + disabled={isSubmitting} + className={cn( + "border-slate-200 bg-white", + errors.apiKey && "border-red-500", + )} + data-testid="api-key-input" + /> + {errors.apiKey ? ( +

+ {errors.apiKey} +

+ ) : ( +

+ 保存后会自动为新 Provider + 创建第一把密钥,后续可在详情页继续追加多把 Key。 +

+ )} +
+
+ +
+
+
+ +

协议与附加参数

+
+

+ 普通第三方 Provider 推荐保持与 DeepChat + 一致的兼容协议;Codex、Gemini、Anthropic 继续保留 Lime + 的专属兼容语义。 +

+
+ +
+
+ + +

+ 大多数第三方服务使用 OpenAI + 兼容格式;只有特例协议才需要切换。 +

+
+ +
+

+ 接入提示 +

+

+ 若你接入的是 OEM + 或商业化统一网关,优先保持外部配置简单,把品牌、套餐和模型目录放在独立云端控制面管理。 +

+
+
+ + {specialProtocolHint ? ( +
+

协议特例保留

+

{specialProtocolHint}

+
+ ) : null} + + {extraFieldConfigs.some(Boolean) ? ( +
+ {extraFieldConfigs.map((config) => { + if (!config) { + return null; + } + + return ( +
+ + + updateField(config.field, e.target.value) + } + placeholder={config.placeholder} + disabled={isSubmitting} + className="border-slate-200 bg-white" + data-testid={config.testId} + /> +
+ ); + })} +
+ ) : null} +
+ + {submitError && ( +
- - + {submitError} +
)}
- - {/* 下拉列表 */} - {showProviderDropdown && filteredProviders.length > 0 && ( -
- {filteredProviders.map((provider) => ( - - ))} -
- )}
-

- 选择已知厂商可自动填充配置,或直接手动填写下方表单 -

- -
- - {/* Provider 名称 */} -
- - updateField("name", e.target.value)} - placeholder="例如:My Custom API" - disabled={isSubmitting} - className={cn(errors.name && "border-red-500")} - data-testid="provider-name-input" - /> - {errors.name && ( -

- {errors.name} -

- )} -
- - {/* Provider 类型 */} -
- - -

- 大多数第三方 API 服务使用 OpenAI 兼容格式 -

-
- - {/* API Host */} -
- - updateField("apiHost", e.target.value)} - placeholder="https://api.example.com" - disabled={isSubmitting} - className={cn(errors.apiHost && "border-red-500")} - data-testid="api-host-input" - /> - {errors.apiHost && ( -

- {errors.apiHost} -

- )} -
- - {/* API Key */} -
- - updateField("apiKey", e.target.value)} - placeholder="sk-..." - disabled={isSubmitting} - className={cn(errors.apiKey && "border-red-500")} - data-testid="api-key-input" - /> - {errors.apiKey && ( -

- {errors.apiKey} -

- )} -
- - {/* Azure OpenAI: API Version */} - {extraFields.includes("apiVersion") && ( -
- - updateField("apiVersion", e.target.value)} - placeholder="2024-02-15-preview" - disabled={isSubmitting} - className={cn(errors.apiVersion && "border-red-500")} - data-testid="api-version-input" - /> - {errors.apiVersion && ( -

- {errors.apiVersion} -

- )} -
- )} - - {/* VertexAI: Project */} - {extraFields.includes("project") && ( -
- - updateField("project", e.target.value)} - placeholder="your-project-id" - disabled={isSubmitting} - data-testid="project-input" - /> -
- )} - - {/* VertexAI: Location */} - {extraFields.includes("location") && ( -
- - updateField("location", e.target.value)} - placeholder="us-central1" - disabled={isSubmitting} - data-testid="location-input" - /> -
- )} - - {/* AWS Bedrock: Region */} - {extraFields.includes("region") && ( -
- - updateField("region", e.target.value)} - placeholder="us-east-1" - disabled={isSubmitting} - data-testid="region-input" - /> -
- )} - - {/* 提交错误 */} - {submitError && ( -
- {submitError} -
- )} diff --git a/src/components/provider-pool/api-key/AddCustomProviderModal.ui.test.tsx b/src/components/provider-pool/api-key/AddCustomProviderModal.ui.test.tsx new file mode 100644 index 000000000..17ca4b825 --- /dev/null +++ b/src/components/provider-pool/api-key/AddCustomProviderModal.ui.test.tsx @@ -0,0 +1,222 @@ +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockUseModelRegistry } = vi.hoisted(() => ({ + mockUseModelRegistry: vi.fn(), +})); + +const { mockGetSystemProviderCatalog } = vi.hoisted(() => ({ + mockGetSystemProviderCatalog: vi.fn(), +})); + +vi.mock("@/hooks/useModelRegistry", () => ({ + useModelRegistry: () => mockUseModelRegistry(), +})); + +vi.mock("@/lib/api/apiKeyProvider", () => ({ + apiKeyProviderApi: { + getSystemProviderCatalog: mockGetSystemProviderCatalog, + }, +})); + +import { AddCustomProviderModal } from "./AddCustomProviderModal"; + +interface MountedRoot { + container: HTMLDivElement; + root: Root; +} + +const mountedRoots: MountedRoot[] = []; + +function renderModal() { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render( + , + ); + }); + + mountedRoots.push({ container, root }); +} + +async function settleModal() { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); +} + +function findByTestId(testId: string): T { + const element = document.querySelector(`[data-testid="${testId}"]`); + if (!element) { + throw new Error(`未找到节点: ${testId}`); + } + return element; +} + +beforeEach(() => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = true; + + mockUseModelRegistry.mockReturnValue({ + groupedByProvider: new Map(), + }); + + mockGetSystemProviderCatalog.mockResolvedValue([ + { + id: "google", + name: "Google (Gemini)", + type: "gemini", + api_host: "https://generativelanguage.googleapis.com", + group: "cloud", + sort_order: 1, + api_version: null, + legacy_ids: ["gemini"], + }, + { + id: "deepseek", + name: "DeepSeek", + type: "openai", + api_host: "https://api.deepseek.com", + group: "mainstream", + sort_order: 2, + api_version: null, + legacy_ids: [], + }, + { + id: "zhipu", + name: "智谱 AI", + type: "openai", + api_host: "https://open.bigmodel.cn/api/paas/v4/", + group: "chinese", + sort_order: 3, + api_version: null, + legacy_ids: ["zhipuai"], + }, + ]); +}); + +afterEach(() => { + vi.clearAllMocks(); + + while (mountedRoots.length > 0) { + const mounted = mountedRoots.pop(); + if (!mounted) { + break; + } + + act(() => { + mounted.root.unmount(); + }); + mounted.container.remove(); + } +}); + +describe("AddCustomProviderModal", () => { + it("应展示双栏模板选择布局", async () => { + renderModal(); + + await settleModal(); + + expect(document.body.textContent ?? "").toContain("新增服务商"); + expect(document.body.textContent ?? "").toContain("选择模板"); + expect(document.body.textContent ?? "").toContain("基础接入信息"); + expect(document.body.textContent ?? "").toContain("协议与附加参数"); + }); + + it("选择 Gemini 模板后应预填并展示特例协议提示", async () => { + renderModal(); + + await settleModal(); + + await act(async () => { + findByTestId("known-provider-item-google").click(); + }); + + const nameInput = findByTestId("provider-name-input"); + const hostInput = findByTestId("api-host-input"); + + expect(nameInput.value).toBe("Google (Gemini)"); + expect(hostInput.value).toBe("https://generativelanguage.googleapis.com"); + expect(document.body.textContent ?? "").toContain("协议特例保留"); + expect(document.body.textContent ?? "").toContain( + "Gemini 保留原生协议能力", + ); + }); + + it("选择智谱模板后应预填正确的 GLM Base URL", async () => { + renderModal(); + + await settleModal(); + + await act(async () => { + findByTestId("known-provider-item-zhipu").click(); + }); + + const nameInput = findByTestId("provider-name-input"); + const hostInput = findByTestId("api-host-input"); + + expect(nameInput.value).toBe("智谱 AI"); + expect(hostInput.value).toBe("https://open.bigmodel.cn/api/paas/v4/"); + }); + + it("应注入 Codex CLI、Gemini CLI 与 Claude Code 特例模板", async () => { + renderModal(); + + await settleModal(); + + expect( + document.querySelector('[data-testid="known-provider-item-codex-cli"]'), + ).not.toBeNull(); + expect( + document.querySelector('[data-testid="known-provider-item-gemini-cli"]'), + ).not.toBeNull(); + expect( + document.querySelector('[data-testid="known-provider-item-claude-code"]'), + ).not.toBeNull(); + }); + + it("选择 Codex CLI 模板后应预填专属协议与地址", async () => { + renderModal(); + + await settleModal(); + + await act(async () => { + findByTestId("known-provider-item-codex-cli").click(); + }); + + const nameInput = findByTestId("provider-name-input"); + const hostInput = findByTestId("api-host-input"); + const providerTypeSelect = findByTestId("provider-type-select"); + + expect(nameInput.value).toBe("Codex CLI"); + expect(hostInput.value).toBe("https://api.openai.com"); + expect(providerTypeSelect.textContent ?? "").toContain("Codex CLI"); + expect(document.body.textContent ?? "").toContain("Codex 保留 Lime 的专属协议"); + }); + + it("legacy 别名只应参与搜索,不应重复渲染模板卡片", async () => { + renderModal(); + + await settleModal(); + + expect( + document.querySelector('[data-testid="known-provider-item-gemini"]'), + ).toBeNull(); + expect( + document.querySelector('[data-testid="known-provider-item-zhipuai"]'), + ).toBeNull(); + }); +}); diff --git a/src/components/provider-pool/api-key/ApiKeyList.tsx b/src/components/provider-pool/api-key/ApiKeyList.tsx index b65020d67..27a015c3a 100644 --- a/src/components/provider-pool/api-key/ApiKeyList.tsx +++ b/src/components/provider-pool/api-key/ApiKeyList.tsx @@ -7,13 +7,15 @@ * **Validates: Requirements 7.1** */ -import React, { useState } from "react"; +import React, { useMemo, useState } from "react"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { ApiKeyItem } from "./ApiKeyItem"; import type { ApiKeyDisplay } from "@/lib/api/apiKeyProvider"; +import { getProviderAccessHelp } from "@/lib/provider/providerAccessHelp"; +import { SectionInfoButton } from "./SectionInfoButton"; // ============================================================================ // 图标组件 @@ -71,6 +73,10 @@ export interface ApiKeyListProps { apiKeys: ApiKeyDisplay[]; /** Provider ID */ providerId: string; + /** Provider 显示名称 */ + providerName?: string; + /** Provider API Host */ + apiHost?: string; /** 添加 API Key 回调 */ onAdd?: (providerId: string, apiKey: string, alias?: string) => Promise; /** 切换 API Key 启用状态回调 */ @@ -106,6 +112,8 @@ export interface ApiKeyListProps { export const ApiKeyList: React.FC = ({ apiKeys, providerId, + providerName, + apiHost, onAdd, onToggle, onDelete, @@ -119,22 +127,47 @@ export const ApiKeyList: React.FC = ({ const [isAdding, setIsAdding] = useState(false); const [error, setError] = useState(null); - // 监听 apiKeys 变化,用于调试 - React.useEffect(() => { - console.log( - `[ApiKeyList] 组件更新: providerId=${providerId}, apiKeys.length=${apiKeys.length}`, - ); - apiKeys.forEach((k, i) => { - console.log( - `[ApiKeyList] [${i}] id=${k.id}, masked=${k.api_key_masked}`, - ); - }); - }, [apiKeys, providerId]); + const enabledCount = useMemo( + () => apiKeys.filter((item) => item.enabled).length, + [apiKeys], + ); - // 监听 providerId 变化 - React.useEffect(() => { - console.log(`[ApiKeyList] providerId 变化: ${providerId}`); - }, [providerId]); + const errorCount = useMemo( + () => apiKeys.reduce((sum, item) => sum + item.error_count, 0), + [apiKeys], + ); + + const latestUsedLabel = useMemo(() => { + const latestUsedAt = apiKeys + .map((item) => item.last_used_at) + .filter((value): value is string => Boolean(value)) + .sort((a, b) => new Date(b).getTime() - new Date(a).getTime())[0]; + + if (!latestUsedAt) { + return "暂无调用"; + } + + const diffMs = Date.now() - new Date(latestUsedAt).getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + + if (diffMins < 1) return "刚刚"; + if (diffMins < 60) return `${diffMins} 分钟前`; + if (diffHours < 24) return `${diffHours} 小时前`; + if (diffDays < 30) return `${diffDays} 天前`; + return new Date(latestUsedAt).toLocaleDateString("zh-CN"); + }, [apiKeys]); + + const providerAccessHelp = useMemo( + () => + getProviderAccessHelp({ + providerId, + providerName, + apiHost, + }), + [apiHost, providerId, providerName], + ); const handleAdd = async () => { if (!newApiKey.trim()) { @@ -146,24 +179,12 @@ export const ApiKeyList: React.FC = ({ setError(null); try { - console.log("[ApiKeyList] 开始添加 API Key:", { - providerId, - alias: newAlias.trim() || undefined, - apiKeyLength: newApiKey.trim().length, - }); await onAdd?.(providerId, newApiKey.trim(), newAlias.trim() || undefined); - console.log( - "[ApiKeyList] API Key 添加成功,当前 apiKeys:", - apiKeys.length, - ); - - // 重置表单 setNewApiKey(""); setNewAlias(""); setShowAddForm(false); setShowApiKey(false); } catch (e) { - console.error("[ApiKeyList] API Key 添加失败:", e); setError(e instanceof Error ? e.message : "添加失败"); } finally { setIsAdding(false); @@ -179,130 +200,183 @@ export const ApiKeyList: React.FC = ({ }; return ( -
- {/* 标题和添加按钮 */} -
-

- API Keys ({apiKeys.length}) -

- {!showAddForm && ( - - )} +
+
+
+

API Key

+ {providerAccessHelp.helpText || providerAccessHelp.keylessHint ? ( + +

+ {providerAccessHelp.keylessHint ?? providerAccessHelp.helpText} +

+ {providerAccessHelp.url ? ( + + 前往获取 + + ) : null} +
+ ) : null} +
+ +
+ + 已启用 {enabledCount} + + {apiKeys.length > 0 ? ( + + 最近调用 {latestUsedLabel} + + ) : null} + {errorCount > 0 ? ( + + 错误 {errorCount} + + ) : null} + {!showAddForm && ( + + )} +
- {/* 添加表单 */} - {showAddForm && ( -
- {/* API Key 输入 */} -
- -
- setNewApiKey(e.target.value)} - placeholder="输入 API Key" - className="pr-10" + {showAddForm ? ( +
+
+
+ +
+ setNewApiKey(e.target.value)} + placeholder="输入 API Key" + className="border-slate-200 bg-white pr-10" + disabled={isAdding} + autoComplete="new-password" + data-testid="new-api-key-input" + /> + +
+
+ +
+ + setNewAlias(e.target.value)} + placeholder="例如:生产主账号" + disabled={isAdding} + className="border-slate-200 bg-white" + autoComplete="off" + data-testid="new-alias-input" + /> +
+
+ + {error ? ( +

+ {error} +

+ ) : null} + +
+ + 取消 + +
+ ) : null} - {/* 别名输入 */} -
- - setNewAlias(e.target.value)} - placeholder="例如:主账号、测试账号" - disabled={isAdding} - data-testid="new-alias-input" - /> + {apiKeys.length > 0 ? ( +
+ {apiKeys.map((apiKey) => ( + + ))}
- - {/* 错误提示 */} - {error && ( -

- {error} -

- )} - - {/* 操作按钮 */} -
- - -
-
- )} - - {/* API Key 列表 */} - {apiKeys.length > 0 ? ( -
- {apiKeys.map((apiKey) => ( - - ))} -
- ) : ( - !showAddForm && ( -
-

暂无 API Key

-

- 点击上方「添加」按钮添加第一个 API Key -

-
- ) - )} +

暂无 API Key

+

+ 先新增第一把 Key,之后再做连接测试与模型刷新 +

+
+ ) + )}
); }; diff --git a/src/components/provider-pool/api-key/ApiKeyList.ui.test.tsx b/src/components/provider-pool/api-key/ApiKeyList.ui.test.tsx new file mode 100644 index 000000000..9c2f2db32 --- /dev/null +++ b/src/components/provider-pool/api-key/ApiKeyList.ui.test.tsx @@ -0,0 +1,133 @@ +import { act, type ComponentProps } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import type { ApiKeyDisplay } from "@/lib/api/apiKeyProvider"; +import { ApiKeyList } from "./ApiKeyList"; + +interface MountedRoot { + container: HTMLDivElement; + root: Root; +} + +const mountedRoots: MountedRoot[] = []; + +function createApiKey(overrides: Partial = {}): ApiKeyDisplay { + return { + id: "key-001", + provider_id: "openai", + api_key_masked: "sk-****1234", + alias: "默认账号", + enabled: true, + usage_count: 3, + error_count: 0, + last_used_at: new Date("2026-03-30T08:00:00.000Z").toISOString(), + created_at: new Date("2026-03-29T00:00:00.000Z").toISOString(), + ...overrides, + }; +} + +function renderList( + props: Partial> = {}, +) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + const mergedProps: ComponentProps = { + apiKeys: [], + providerId: "zhipu", + providerName: "智谱 AI", + apiHost: "https://open.bigmodel.cn/api/paas/v4/", + ...props, + }; + + act(() => { + root.render(); + }); + + mountedRoots.push({ container, root }); + return container; +} + +beforeEach(() => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = true; +}); + +afterEach(() => { + while (mountedRoots.length > 0) { + const mounted = mountedRoots.pop(); + if (!mounted) { + break; + } + + act(() => { + mounted.root.unmount(); + }); + mounted.container.remove(); + } +}); + +describe("ApiKeyList", () => { + it("应通过说明按钮展示 API Key 获取入口", () => { + const container = renderList(); + + const infoButton = container.querySelector( + '[data-testid="provider-api-key-info-button"]', + ); + + expect(infoButton).not.toBeNull(); + + act(() => { + infoButton?.click(); + }); + + const helpLink = document.querySelector( + '[data-testid="provider-api-key-help-link"]', + ); + + expect(document.body.textContent ?? "").toContain("如何获取 API Key"); + expect(helpLink).not.toBeNull(); + expect(helpLink?.href).toContain("open.bigmodel.cn/usercenter/apikeys"); + }); + + it("本地渠道应在说明按钮内展示通常无需 API Key 的提示", () => { + const container = renderList({ + providerId: "ollama", + providerName: "Ollama", + apiHost: "http://localhost:11434", + }); + + const infoButton = container.querySelector( + '[data-testid="provider-api-key-info-button"]', + ); + expect(infoButton).not.toBeNull(); + + act(() => { + infoButton?.click(); + }); + + const hint = document.querySelector( + '[data-testid="provider-api-key-keyless-hint"]', + ); + + expect(hint?.textContent ?? "").toContain("通常无需 API Key"); + }); + + it("新增 API Key 按钮应保持单行显示", () => { + const container = renderList({ + apiKeys: [createApiKey()], + }); + + const button = container.querySelector( + '[data-testid="add-api-key-button"]', + ); + + expect(button).not.toBeNull(); + expect(button?.className).toContain("whitespace-nowrap"); + }); +}); diff --git a/src/components/provider-pool/api-key/ApiKeyProviderSection.tsx b/src/components/provider-pool/api-key/ApiKeyProviderSection.tsx index d5b1823d1..fb526f02c 100644 --- a/src/components/provider-pool/api-key/ApiKeyProviderSection.tsx +++ b/src/components/provider-pool/api-key/ApiKeyProviderSection.tsx @@ -247,14 +247,11 @@ export const ApiKeyProviderSection = forwardRef< return (
-
-
- {/* 左侧:Provider 列表 */} setShowImportExportDialog(true)} - className="flex-shrink-0 bg-card" + className="flex-shrink-0 bg-slate-50/80" /> {/* 右侧:Provider 设置面板 */} -
-
+
= { region: "us-east-1", }; -/** 字段帮助文本映射 */ -const FIELD_HELP_TEXT: Record = { - apiHost: "API 服务的基础 URL", - apiVersion: "Azure OpenAI API 版本", - project: "Google Cloud 项目 ID", - location: "VertexAI 服务位置", - region: "AWS Bedrock 区域", -}; - // ============================================================================ // 类型定义 // ============================================================================ @@ -108,6 +102,7 @@ export interface ProviderConfigFormRef { } interface FormState { + providerName: string; providerType: ProviderType; apiHost: string; apiVersion: string; @@ -162,6 +157,7 @@ export const ProviderConfigForm = forwardRef< ) => { // 表单状态 const [formState, setFormState] = useState({ + providerName: provider.name || "", providerType: (provider.type as ProviderType) || "openai", apiHost: provider.api_host || "", apiVersion: provider.api_version || "", @@ -184,6 +180,10 @@ export const ProviderConfigForm = forwardRef< () => parseCustomModelsValue(formState.customModels), [formState.customModels], ); + const enabledApiKeyCount = useMemo( + () => provider.api_keys.filter((apiKey) => apiKey.enabled).length, + [provider.api_keys], + ); const configuredProvider = useMemo( () => ({ @@ -195,10 +195,31 @@ export const ProviderConfigForm = forwardRef< }), type: formState.providerType, providerId: provider.id, + apiHost: formState.apiHost, customModels: selectedModels, }), - [formState.providerType, provider.id, provider.name, selectedModels], + [ + formState.apiHost, + formState.providerType, + provider.id, + provider.name, + selectedModels, + ], ); + const modelAutoFetchCapability = useMemo( + () => + getProviderModelAutoFetchCapability({ + providerId: provider.id, + providerType: formState.providerType, + apiHost: formState.apiHost, + }), + [formState.apiHost, formState.providerType, provider.id], + ); + const requiresLiveModelTruth = modelAutoFetchCapability.supported; + const canReadLiveModels = + !requiresLiveModelTruth || + !modelAutoFetchCapability.requiresApiKey || + enabledApiKeyCount > 0; const { models: localCandidateModels, @@ -206,7 +227,20 @@ export const ProviderConfigForm = forwardRef< error: localModelsError, } = useProviderModels(configuredProvider, { returnFullMetadata: true, + liveFetchOnly: requiresLiveModelTruth, + hasApiKey: enabledApiKeyCount > 0, }); + const hasResolvedLiveModelDirectory = + !requiresLiveModelTruth || localCandidateModels.length > 0; + const shouldHideSavedModels = + requiresLiveModelTruth && !hasResolvedLiveModelDirectory; + const hiddenSelectedModelCount = shouldHideSavedModels + ? selectedModels.length + : 0; + const visibleSelectedModels = shouldHideSavedModels ? [] : selectedModels; + const shouldLockModelEditor = + requiresLiveModelTruth && + (!canReadLiveModels || localModelsLoading || localCandidateModels.length === 0); const latestLocalModel = useMemo(() => { const localModelsWithMetadata = localCandidateModels.filter( @@ -216,6 +250,10 @@ export const ProviderConfigForm = forwardRef< }, [localCandidateModels]); const recommendedLatestModel = useMemo(() => { + if (!hasResolvedLiveModelDirectory) { + return null; + } + if (latestLocalModel) { return latestLocalModel; } @@ -225,11 +263,56 @@ export const ProviderConfigForm = forwardRef< } return getLatestSelectableModel(localCandidateModels); - }, [latestLocalModel, localCandidateModels, localModelsLoading]); + }, [ + hasResolvedLiveModelDirectory, + latestLocalModel, + localCandidateModels, + localModelsLoading, + ]); + const modelTruthNotice = useMemo(() => { + if (!requiresLiveModelTruth) { + return null; + } + + if (!canReadLiveModels) { + return { + tone: "amber" as const, + title: "先补一把可用 API Key", + description: + "当前渠道会直接读取真实模型目录。为了避免继续展示旧缓存或错误模型,未配置可用 API Key 前暂不展示已选模型,也不开放手动输入。", + }; + } + + if (localModelsLoading) { + return { + tone: "slate" as const, + title: "正在读取真实模型目录", + description: + "读取完成前先不展示旧模型配置与推荐最新模型,避免把历史缓存误认为当前可用模型。", + }; + } + + if (localCandidateModels.length === 0) { + return { + tone: "amber" as const, + title: "当前未读取到真实模型目录", + description: + "这次没有拿到最新模型列表。为避免错误模型输出,页面不会继续展示旧模型或推荐模型,请先检查鉴权、Base URL 或接口兼容性。", + }; + } + + return null; + }, [ + canReadLiveModels, + localCandidateModels.length, + localModelsLoading, + requiresLiveModelTruth, + ]); // 当 provider 变化时,重置表单状态 useEffect(() => { setFormState({ + providerName: provider.name || "", providerType: (provider.type as ProviderType) || "openai", apiHost: provider.api_host || "", apiVersion: provider.api_version || "", @@ -242,6 +325,7 @@ export const ProviderConfigForm = forwardRef< setModelDraft(""); }, [ provider.id, + provider.name, provider.type, provider.api_host, provider.api_version, @@ -268,14 +352,23 @@ export const ProviderConfigForm = forwardRef< const request: UpdateProviderRequest = { type: state.providerType, - api_host: state.apiHost || undefined, - api_version: state.apiVersion || undefined, - project: state.project || undefined, - location: state.location || undefined, - region: state.region || undefined, - custom_models: customModels.length > 0 ? customModels : undefined, + api_host: state.apiHost, + api_version: state.apiVersion, + project: state.project, + location: state.location, + region: state.region, + custom_models: customModels, }; + const trimmedName = state.providerName.trim(); + if ( + !provider.is_system && + trimmedName && + trimmedName !== provider.name + ) { + request.name = trimmedName; + } + await onUpdate(provider.id, request); setLastSaved(new Date()); } catch (e) { @@ -284,7 +377,7 @@ export const ProviderConfigForm = forwardRef< setIsSaving(false); } }, - [provider.id, onUpdate], + [onUpdate, provider.id, provider.is_system, provider.name], ); // 防抖保存 @@ -390,12 +483,21 @@ export const ProviderConfigForm = forwardRef< ); useEffect(() => { - if (selectedModels.length > 0 || !recommendedLatestModel) { + if ( + selectedModels.length > 0 || + !recommendedLatestModel || + shouldLockModelEditor + ) { return; } applyCustomModels([recommendedLatestModel.id]); - }, [applyCustomModels, recommendedLatestModel, selectedModels.length]); + }, [ + applyCustomModels, + recommendedLatestModel, + selectedModels.length, + shouldLockModelEditor, + ]); useEffect(() => { onModelsChange?.(selectedModels); @@ -405,11 +507,53 @@ export const ProviderConfigForm = forwardRef< onRecommendedLatestModelChange?.(recommendedLatestModel?.id ?? null); }, [onRecommendedLatestModelChange, recommendedLatestModel]); - // 获取当前 Provider 类型需要显示的额外字段 - // 使用 formState 中的 providerType,这样修改类型后会立即更新显示的字段 const extraFields = PROVIDER_TYPE_FIELDS[formState.providerType] || []; + const specialProtocolHint = getSpecialProtocolHint(formState.providerType); + const defaultModelId = + visibleSelectedModels[0] ?? recommendedLatestModel?.id ?? null; + const extraFieldConfigs = extraFields.map((field) => { + switch (field) { + case "apiVersion": + return { + field, + id: "api-version", + label: FIELD_LABELS.apiVersion, + placeholder: FIELD_PLACEHOLDERS.apiVersion, + value: formState.apiVersion, + testId: "api-version-input", + }; + case "project": + return { + field, + id: "project", + label: FIELD_LABELS.project, + placeholder: FIELD_PLACEHOLDERS.project, + value: formState.project, + testId: "project-input", + }; + case "location": + return { + field, + id: "location", + label: FIELD_LABELS.location, + placeholder: FIELD_PLACEHOLDERS.location, + value: formState.location, + testId: "location-input", + }; + case "region": + return { + field, + id: "region", + label: FIELD_LABELS.region, + placeholder: FIELD_PLACEHOLDERS.region, + value: formState.region, + testId: "region-input", + }; + default: + return null; + } + }); - // 格式化最后保存时间 const formatLastSaved = (date: Date | null): string => { if (!date) return ""; return `已保存于 ${date.toLocaleTimeString("zh-CN")}`; @@ -417,291 +561,332 @@ export const ProviderConfigForm = forwardRef< return (
-
- - -

- 选择 API 协议类型,不同类型使用不同的请求格式 -

-
- - {/* API Host 字段(所有 Provider 都有) */} -
- - handleFieldChange("apiHost", e.target.value)} - placeholder={FIELD_PLACEHOLDERS.apiHost} - disabled={loading || isSaving} - data-testid="api-host-input" - /> -

- {FIELD_HELP_TEXT.apiHost} -

-
- - {/* Azure OpenAI: API Version */} - {extraFields.includes("apiVersion") && ( -
- - handleFieldChange("apiVersion", e.target.value)} - placeholder={FIELD_PLACEHOLDERS.apiVersion} - disabled={loading || isSaving} - data-testid="api-version-input" - /> -

- {FIELD_HELP_TEXT.apiVersion} +

+
+

+ 协议与默认模型

-
- )} - - {/* VertexAI: Project */} - {extraFields.includes("project") && ( -
- - handleFieldChange("project", e.target.value)} - placeholder={FIELD_PLACEHOLDERS.project} - disabled={loading || isSaving} - data-testid="project-input" - /> -

- {FIELD_HELP_TEXT.project} -

-
- )} - - {/* VertexAI: Location */} - {extraFields.includes("location") && ( -
- - handleFieldChange("location", e.target.value)} - placeholder={FIELD_PLACEHOLDERS.location} - disabled={loading || isSaving} - data-testid="location-input" - /> -

- {FIELD_HELP_TEXT.location} -

-
- )} - - {/* AWS Bedrock: Region */} - {extraFields.includes("region") && ( -
- - handleFieldChange("region", e.target.value)} - placeholder={FIELD_PLACEHOLDERS.region} - disabled={loading || isSaving} - data-testid="region-input" - /> -

- {FIELD_HELP_TEXT.region} -

-
- )} - - {/* 自定义模型列表 */} -
-
-
-
- -

- 手动添加模型后会保留在这里;默认模型请在右侧“模型能力”列表中点击选择。 + +

+ 这里维护服务商名称、接口地址、协议类型和默认模型,保存会自动防抖提交。 +

+

+ {provider.is_system + ? "官方供应商固定使用原生协议。" + : "兼容协议主要用于自定义接入;官方供应商继续保持各自原生协议。"} +

+ {specialProtocolHint ? ( +

+ {specialProtocolHint}

-
-
+ ) : null} + +
+
+ + + {isSaving + ? "保存中..." + : saveError + ? "保存失败" + : lastSaved + ? formatLastSaved(lastSaved) + : "修改后自动保存"} + +
+
- - -
-
- {selectedModels.length > 0 ? ( - selectedModels.map((modelId, index) => { - const isLatest = recommendedLatestModel?.id === modelId; - return ( -
- - {modelId} - - {index === 0 ? ( - 默认 - ) : null} - {isLatest ? ( - 最新 - ) : null} - {index > 0 ? ( - - ) : null} - -
- ); - }) - ) : ( -

- 尚未选择模型。检测到可用模型后,系统会默认填入最新模型。 -

- )} -
-
- -
+
+ {!provider.is_system ? ( +
+ setModelDraft(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === ",") { - e.preventDefault(); - handleAddModelDraft(); - } - }} - placeholder="手动输入模型 ID,按 Enter 添加" - autoCapitalize="none" - autoCorrect="off" - spellCheck={false} + id="provider-name" + value={formState.providerName} + onChange={(e) => + handleFieldChange("providerName", e.target.value) + } + placeholder="输入服务商名称" disabled={loading || isSaving} - data-testid="custom-models-input" + className="border-slate-200 bg-white" + data-testid="provider-name-input" /> -
+ ) : null} -
- - 第一个模型会作为默认模型,用于测试与默认请求;若未显式选择,则自动使用最新模型。 - + {!provider.is_system ? ( +
+ + +
+ ) : ( +
+ +
+ {getProviderTypeLabel(formState.providerType)} +
+
+ )} + +
+ + handleFieldChange("apiHost", e.target.value)} + placeholder={FIELD_PLACEHOLDERS.apiHost} + disabled={loading || isSaving} + className="border-slate-200 bg-white" + data-testid="api-host-input" + /> +
+ + {extraFieldConfigs.map((config) => { + if (!config) { + return null; + } + + return ( +
+ + + handleFieldChange(config.field, e.target.value) + } + placeholder={config.placeholder} + disabled={loading || isSaving} + className="border-slate-200 bg-white" + data-testid={config.testId} + /> +
+ ); + })} +
+ +
+
+
+

默认模型

+ +

+ 第一个模型会被视为默认模型。支持实时拉取的渠道,只有在拿到真实模型目录后才会展示当前模型与推荐最新模型。 +

+
+
+
+ + 当前: + {defaultModelId ?? + (requiresLiveModelTruth ? "待读取真实目录" : "待指定")} + {recommendedLatestModel ? ( - - 当前推荐最新模型: - - {recommendedLatestModel.id} - - + + 推荐最新:{recommendedLatestModel.id} + ) : null}
- {localModelsError ? ( -

{localModelsError}

- ) : null} - {localModelsLoading ? ( -

- 正在加载模型列表... -

- ) : null}
-
- {/* 保存状态指示 */} -
+ + + {modelTruthNotice ? ( +
+

{modelTruthNotice.title}

+

{modelTruthNotice.description}

+
+ ) : null} + +
+ {visibleSelectedModels.length > 0 ? ( + visibleSelectedModels.map((modelId, index) => { + const isLatest = recommendedLatestModel?.id === modelId; + return ( +
+ + {modelId} + + {index === 0 ? ( + + 默认 + + ) : null} + {isLatest ? 最新 : null} + {index > 0 ? ( + + ) : null} + +
+ ); + }) + ) : ( +
+ {hiddenSelectedModelCount > 0 + ? `已保存 ${hiddenSelectedModelCount} 个模型配置,待读取真实模型目录后再展示。` + : requiresLiveModelTruth + ? "读取到真实模型目录后,才会展示当前模型与推荐最新模型。" + : "尚未指定模型。读取到模型目录后会自动填入推荐最新模型。"} +
+ )} +
+ +
+ setModelDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === ",") { + e.preventDefault(); + handleAddModelDraft(); + } + }} + placeholder={ + shouldLockModelEditor + ? "先读取真实模型目录,再补充模型 ID" + : "手动输入模型 ID,按 Enter 添加" + } + autoCapitalize="none" + autoCorrect="off" + spellCheck={false} + disabled={loading || isSaving || shouldLockModelEditor} + data-testid="custom-models-input" + /> + +
+ + {localModelsError ? ( +

{localModelsError}

+ ) : null} + {localModelsLoading ? ( +

正在加载模型列表...

+ ) : null} + {saveError ? ( +

+ {saveError} +

+ ) : null} + {lastSaved && !isSaving && !saveError ? ( +

+ {formatLastSaved(lastSaved)} +

+ ) : null} {isSaving ? ( - 保存中... - - ) : saveError ? ( - - {saveError} - - ) : lastSaved ? ( - - {formatLastSaved(lastSaved)} - - ) : ( - - )} +

+ ) : null}
); diff --git a/src/components/provider-pool/api-key/ProviderConfigForm.ui.test.tsx b/src/components/provider-pool/api-key/ProviderConfigForm.ui.test.tsx index f2e176d52..3629886e4 100644 --- a/src/components/provider-pool/api-key/ProviderConfigForm.ui.test.tsx +++ b/src/components/provider-pool/api-key/ProviderConfigForm.ui.test.tsx @@ -3,12 +3,12 @@ import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ProviderWithKeysDisplay } from "@/lib/api/apiKeyProvider"; +const { mockUseProviderModels } = vi.hoisted(() => ({ + mockUseProviderModels: vi.fn(), +})); + vi.mock("@/hooks/useProviderModels", () => ({ - useProviderModels: () => ({ - models: [], - loading: false, - error: null, - }), + useProviderModels: mockUseProviderModels, })); import { ProviderConfigForm } from "./ProviderConfigForm"; @@ -77,6 +77,11 @@ beforeEach(() => { } ).IS_REACT_ACT_ENVIRONMENT = true; vi.useFakeTimers(); + mockUseProviderModels.mockReturnValue({ + models: [], + loading: false, + error: null, + }); }); afterEach(() => { @@ -94,8 +99,41 @@ afterEach(() => { }); describe("ProviderConfigForm", () => { - it("系统 Provider 应显示协议选择并保存协议变更", async () => { + it("系统 Provider 应固定原生协议,不展示兼容协议切换", () => { const provider = createProvider(); + const { container } = renderForm(provider); + + const trigger = container.querySelector( + '[data-testid="provider-type-select"]', + ); + expect(trigger).toBeNull(); + expect(container.textContent ?? "").toContain("OpenAI Responses API"); + expect( + container.querySelector('[data-testid="provider-config-info-button"]'), + ).not.toBeNull(); + }); + + it("实时拉模型渠道在未读取到真实目录时不应继续展示旧模型", () => { + const provider = createProvider({ + custom_models: ["gpt-4.1"], + api_keys: [], + }); + const { container } = renderForm(provider); + + expect(container.textContent ?? "").toContain("先补一把可用 API Key"); + expect(container.textContent ?? "").toContain("已保存 1 个模型配置"); + expect(container.textContent ?? "").not.toContain("推荐最新模型:gpt-4.1"); + expect(container.textContent ?? "").not.toContain(">gpt-4.1<"); + expect(container.textContent ?? "").not.toContain("当前默认模型:gpt-4.1"); + }); + + it("自定义 Provider 应允许切换兼容协议并保存", async () => { + const provider = createProvider({ + id: "custom-openai", + name: "自定义 OpenAI", + is_system: false, + type: "openai-response", + }); const { container, onUpdate } = renderForm(provider); const trigger = container.querySelector( @@ -119,7 +157,7 @@ describe("ProviderConfigForm", () => { expect(onUpdate).toHaveBeenCalledTimes(1); expect(onUpdate).toHaveBeenCalledWith( - "openai", + "custom-openai", expect.objectContaining({ type: "openai", }), diff --git a/src/components/provider-pool/api-key/ProviderConfigForm.utils.ts b/src/components/provider-pool/api-key/ProviderConfigForm.utils.ts index bf805c772..fe89f4592 100644 --- a/src/components/provider-pool/api-key/ProviderConfigForm.utils.ts +++ b/src/components/provider-pool/api-key/ProviderConfigForm.utils.ts @@ -46,6 +46,17 @@ export const PROVIDER_TYPE_FIELDS: Record = { gateway: [], }; +const SPECIAL_PROVIDER_PROTOCOL_HINTS: Partial> = { + codex: + "Codex 保留 Lime 的专属协议与模型别名能力,会继续使用独立的 Codex 模型映射与鉴权链路。", + anthropic: + "Anthropic 继续使用原生协议,不会被收敛到普通 OpenAI 兼容请求格式。", + "anthropic-compatible": + "Anthropic 兼容用于接入实现 Anthropic wire format 的第三方服务,仍按 Anthropic 语义处理请求与模型映射。", + gemini: + "Gemini 保留原生协议能力与专属模型映射,不按普通 OpenAI 兼容 Provider 处理。", +}; + export function isSupportedProviderType( providerType: string, ): providerType is ProviderType { @@ -59,6 +70,16 @@ export function getProviderTypeLabel(providerType: string): string { ); } +export function isSpecialProtocolProviderType(type: ProviderType): boolean { + return getSpecialProtocolHint(type) !== null; +} + +export function getSpecialProtocolHint( + type: ProviderType, +): string | null { + return SPECIAL_PROVIDER_PROTOCOL_HINTS[type] ?? null; +} + export function dedupeModelIds(modelIds: string[]): string[] { const seen = new Set(); const result: string[] = []; diff --git a/src/components/provider-pool/api-key/ProviderList.tsx b/src/components/provider-pool/api-key/ProviderList.tsx index 1add1db64..6e16e88ee 100644 --- a/src/components/provider-pool/api-key/ProviderList.tsx +++ b/src/components/provider-pool/api-key/ProviderList.tsx @@ -108,25 +108,30 @@ export const ProviderList: React.FC = ({ return (
{/* 搜索框 */} -
+
+
+

服务商列表

+

+ 已加载 {totalProviders} 个 Provider +

+
- + onSearchChange?.(e.target.value)} className={cn( - "w-full pl-9 pr-3 py-2 text-sm rounded-lg", - "bg-muted/50 border border-transparent", - "placeholder:text-muted-foreground/60", - "focus:outline-none focus:border-primary/30 focus:bg-background", + "w-full rounded-xl border border-slate-200 bg-white py-2 pl-9 pr-3 text-sm", + "placeholder:text-slate-400", + "focus:border-slate-300 focus:outline-none", "transition-colors", )} data-testid="provider-search-input" @@ -136,11 +141,11 @@ export const ProviderList: React.FC = ({ {/* Provider 分组列表 */}
{totalProviders === 0 ? ( -
+

未找到 Provider

{searchQuery &&

尝试其他搜索词

}
@@ -166,17 +171,17 @@ export const ProviderList: React.FC = ({ {/* 添加自定义 Provider 按钮 */} {(onAddCustomProvider || onImportExport) && ( -
+
{onAddCustomProvider && ( - - 从 API 获取模型列表 - - - )} +
+
+ + setSearchQuery(event.target.value)} + placeholder="搜索模型" + className="h-8 border-slate-200 bg-white pl-8 text-xs" + /> +
+ {autoFetchCapability.supported ? ( + + + + + + {refreshTooltipText} + + + ) : null} +
- 暂无模型数据 - {hasApiKey && ( + {registryTruthError + ? "模型真相源异常" + : autoFetchCapability.supported + ? autoFetchCapability.requiresApiKey && !hasApiKey + ? "请先添加可用 API Key" + : apiError + ? "当前未读取到可用模型" + : "尚未获取模型目录" + : "暂无模型数据"} + {!registryTruthError && autoFetchCapability.supported && ( )}
+ {!registryTruthError && + !autoFetchCapability.supported && + autoFetchCapability.unsupportedReason ? ( +
+ {autoFetchCapability.unsupportedReason} +
+ ) : null} + {registryTruthError && ( +
+
+ + 请先修复内置模型索引 +
+ {registryDiagnosticLines.map((line) => ( +
+ {line} +
+ ))} +
+ )} {apiError && (
{apiShouldPromptError ? ( @@ -474,75 +635,96 @@ export const ProviderModelList: React.FC = ({ return (
- {/* 标题 */} -
-

- - 支持的模型 - - ({displayModelsSource.length}) - - {/* 数据来源标识 */} - {apiSource && ( +
+
+

+ + 支持的模型 + + ({filteredModelsSource.length}) + + {apiSource && ( + + + + + {apiSource === "Api" ? ( + <> + + API + + ) : ( + <> + + 本地 + + )} + + + + {apiSource === "Api" + ? "数据来自 Provider API" + : "API 获取失败,使用本地数据"} + + + + )} +

+

+ 支持搜索、刷新模型目录,并直接把任一模型设为默认模型。 +

+
+ +
+
+ + setSearchQuery(event.target.value)} + placeholder="搜索模型名称或 ID" + className="h-8 border-slate-200 bg-white pl-8 text-xs" + /> +
+ {autoFetchCapability.supported ? ( - - {apiSource === "Api" ? ( - <> - - API - + {refreshing ? ( + ) : ( - <> - - 本地 - + )} - + 获取最新模型 + - - {apiSource === "Api" - ? "数据来自 Provider API" - : "API 获取失败,使用本地数据"} - + {refreshTooltipText} - )} -

- {/* 刷新按钮 */} - {hasApiKey && ( - - - - - - 从 API 获取最新模型列表 - - - )} + ) : null} +
{/* API 错误提示 */} @@ -561,23 +743,52 @@ export const ProviderModelList: React.FC = ({
)} - {/* 模型列表 */} -
- {displayModels.map((model) => ( - - ))} -
+ {registryTruthError ? ( +
+
+ + 模型真相源异常 +
+ {registryDiagnosticLines.map((line) => ( +
+ {line} +
+ ))} +
+ ) : null} + + {!autoFetchCapability.supported && + autoFetchCapability.unsupportedReason ? ( +
+ {autoFetchCapability.unsupportedReason} +
+ ) : null} + + {filteredModelsSource.length === 0 ? ( +
+ {searchQuery ? "没有匹配的模型" : "暂无模型数据"} +
+ ) : ( +
+ {displayModels.map((model) => ( + + ))} +
+ )} {/* 显示更多提示 */} {hasMore && (

- 还有 {displayModelsSource.length - maxItems!} 个模型未显示 + 还有 {filteredModelsSource.length - maxItems!} 个模型未显示

)}
diff --git a/src/components/provider-pool/api-key/ProviderSetting.tsx b/src/components/provider-pool/api-key/ProviderSetting.tsx index 2ee43e303..01d6bc003 100644 --- a/src/components/provider-pool/api-key/ProviderSetting.tsx +++ b/src/components/provider-pool/api-key/ProviderSetting.tsx @@ -21,7 +21,12 @@ import { DialogFooter, } from "@/components/ui/dialog"; import { Textarea } from "@/components/ui/textarea"; -import { Trash2 } from "lucide-react"; +import { + Bot, + ShieldCheck, + Sparkles, + Trash2, +} from "lucide-react"; import { ProviderIcon } from "@/icons/providers"; import { ApiKeyList } from "./ApiKeyList"; import { @@ -34,6 +39,8 @@ import { } from "./ConnectionTestButton"; import { ProviderModelList } from "./ProviderModelList"; import { getProviderTypeLabel } from "./ProviderConfigForm.utils"; +import { getProviderModelAutoFetchCapability } from "@/lib/model/providerModelFetchSupport"; +import { SectionInfoButton } from "./SectionInfoButton"; import type { ChatTestResult, ProviderWithKeysDisplay, @@ -121,7 +128,6 @@ export const ProviderSetting: React.FC = ({ >(null); const enabledApiKeyCount = provider?.api_keys?.filter((apiKey) => apiKey.enabled).length ?? 0; - const defaultModel = draftCustomModels[0] ?? recommendedLatestModelId ?? null; useEffect(() => { setDraftCustomModels(provider?.custom_models ?? []); @@ -179,17 +185,18 @@ export const ProviderSetting: React.FC = ({ return (
-
-

- 请从左侧列表选择一个 Provider -

-

- 选择后可在此处集中管理 API Key、模型、连接测试与支持模型信息。 +

+
+ +

服务商配置工作台

+
+

+ 选择一个服务商后,这里只保留模型、密钥和必要配置,不再铺满整页说明。

@@ -204,35 +211,35 @@ export const ProviderSetting: React.FC = ({ return provider.api_host; } })(); - - const summaryItems = [ - { - label: "可用密钥", - value: `${enabledApiKeyCount}`, - hint: `共 ${provider.api_keys?.length ?? 0} 个 API Key`, - compact: false, - }, - { - label: "默认模型", - value: defaultModel ?? "未设置", - hint: defaultModel - ? "第一个模型用于默认请求与测试" - : "可在下方配置中指定", - compact: true, - }, - { - label: "协议类型", - value: getProviderTypeLabel(provider.type), - hint: `${provider.type} · ${provider.is_system ? "系统预设 Provider" : "自定义 Provider"}`, - compact: true, - }, - { - label: "接口地址", - value: providerHostLabel, - hint: provider.api_host, - compact: true, - }, - ]; + const modelAutoFetchCapability = getProviderModelAutoFetchCapability({ + providerId: provider.id, + providerType: provider.type, + apiHost: provider.api_host, + }); + const requiresLiveModelTruth = modelAutoFetchCapability.supported; + const hasRequiredApiAccess = + !modelAutoFetchCapability.requiresApiKey || enabledApiKeyCount > 0; + const hasResolvedLiveModelDirectory = + !requiresLiveModelTruth || Boolean(recommendedLatestModelId); + const showVerifiedModelState = + !requiresLiveModelTruth || hasResolvedLiveModelDirectory; + const defaultModel = showVerifiedModelState + ? draftCustomModels[0] ?? recommendedLatestModelId ?? null + : null; + const connectionReady = + provider.enabled && hasRequiredApiAccess && hasResolvedLiveModelDirectory; + const connectionBlockHint = !provider.enabled + ? "请先启用当前 Provider,再进行连通性验证。" + : !hasRequiredApiAccess + ? "先添加并启用至少一把 API Key,再进行连通性验证。" + : !hasResolvedLiveModelDirectory + ? "先读取真实模型目录,再进行连接测试与默认模型验证。" + : null; + const modelStatusNotice = !hasRequiredApiAccess + ? "先添加并启用 API Key,才能读取真实模型目录。" + : !hasResolvedLiveModelDirectory + ? "读取真实模型目录前,不展示旧模型,避免把历史缓存误认为当前可用模型。" + : null; // 处理启用/禁用切换 const handleToggleEnabled = async (enabled: boolean) => { @@ -248,55 +255,76 @@ export const ProviderSetting: React.FC = ({ data-provider-id={provider.id} >
-
- {/* Provider 头部 */} +
-
-
-
-
+
+
-
+

{provider.name}

- + {provider.is_system ? "系统预设" : "自定义 Provider"} - - {provider.enabled ? "已启用" : "已禁用"} + + {provider.enabled ? "运行中" : "已停用"}
-

- 类型: {getProviderTypeLabel(provider.type)} -

-

- {provider.api_host} -

+ {getProviderTypeLabel(provider.type)} + / + {providerHostLabel} +
-
- - {provider.enabled ? "已启用" : "已禁用"} + + 密钥 {enabledApiKeyCount} + + + 默认 + {defaultModel ?? + (requiresLiveModelTruth ? "模型待同步" : "未设置")} + +
+ + {provider.enabled ? "已启用" : "已停用"} = ({ size="sm" onClick={() => onDeleteProvider(provider.id)} disabled={loading} - className="border-red-200 text-red-600 hover:bg-red-50 hover:text-red-700" + className="border-red-200 bg-white text-red-600 hover:bg-red-50 hover:text-red-700" title="删除此 Provider" data-testid="delete-provider-button" > - 删除 + 删除 Provider )}
- -
- {summaryItems.map((item) => ( -
-

- {item.label} -

-

- {item.value} -

-

- {item.hint} -

-
- ))} -
-
-
-
+
+
-
-

- 访问凭证 -

-

- 管理当前 Provider 的 API Key、别名与启用状态。 -

+
+ +

模型设置

+ +

+ 模型区只展示当前真实可用的目录。支持自动拉取的渠道会优先读取最新模型;读取失败前不会继续展示旧模型。 +

+
+
+ + 默认: + {defaultModel ?? + (requiresLiveModelTruth ? "待读取" : "未设置")} + + {showVerifiedModelState && recommendedLatestModelId ? ( + + 推荐最新:{recommendedLatestModelId} + + ) : null} +
+
+ + {modelStatusNotice ? ( +
+ {modelStatusNotice} +
+ ) : null} + +
+ 0} + apiHost={provider.api_host} + /> +
+
+ +
+
= ({ />
-
-
-

- 请求配置 -

-

- 配置 API Host、协议类型与默认模型,表单会自动保存。 -

-
+
= ({ loading={loading} />
-
-
-
-

- 连接测试 -

-

- 使用当前默认模型检查连接、鉴权与基础对话可用性。 -

+
+
+ +

连接验证

+ +

+ 连接验证会用当前默认模型检查鉴权、路由和最小对话是否可用。未启用 Provider、缺少可用 Key 或模型目录尚未同步时不会放行测试。 +

+
+
+ + {connectionReady ? "可测试" : "待就绪"} +
-
- 当前默认模型: - - {defaultModel ?? "未设置"} +
+ 默认模型: + + {defaultModel ?? + (requiresLiveModelTruth ? "待读取真实目录" : "未设置")}
-
+
- {(provider.api_keys?.length ?? 0) === 0 && ( -

- 请先添加 API Key 后再进行连接测试。 -

- )} -
-
-
-

- 模型能力 -

-

- 展示当前 Provider 支持的模型,并支持从 API 主动刷新。 + {connectionBlockHint ? ( +

+ {connectionBlockHint}

-
- 0} - /> + ) : null}
diff --git a/src/components/provider-pool/api-key/ProviderSetting.ui.test.tsx b/src/components/provider-pool/api-key/ProviderSetting.ui.test.tsx new file mode 100644 index 000000000..561deeaf6 --- /dev/null +++ b/src/components/provider-pool/api-key/ProviderSetting.ui.test.tsx @@ -0,0 +1,150 @@ +import React from "react"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ProviderWithKeysDisplay } from "@/lib/api/apiKeyProvider"; + +vi.mock("./ProviderConfigForm", () => ({ + ProviderConfigForm: React.forwardRef((_props, _ref) => ( +
协议配置表单
+ )), +})); + +vi.mock("./ProviderModelList", () => ({ + ProviderModelList: () => ( +
模型列表
+ ), +})); + +vi.mock("./ConnectionTestButton", () => ({ + ConnectionTestButton: () => ( +
连接测试按钮
+ ), +})); + +import { ProviderSetting } from "./ProviderSetting"; + +interface MountedRoot { + container: HTMLDivElement; + root: Root; +} + +const mountedRoots: MountedRoot[] = []; + +function createProvider( + overrides: Partial = {}, +): ProviderWithKeysDisplay { + return { + id: "deepseek", + name: "DeepSeek", + type: "openai", + api_host: "https://api.deepseek.com", + is_system: false, + group: "mainstream", + enabled: true, + sort_order: 1, + api_key_count: 1, + custom_models: ["deepseek-chat"], + created_at: new Date("2026-03-15T00:00:00.000Z").toISOString(), + updated_at: new Date("2026-03-15T00:00:00.000Z").toISOString(), + api_keys: [ + { + id: "key-001", + provider_id: "deepseek", + api_key_masked: "sk-****1234", + alias: "生产账号", + enabled: true, + usage_count: 12, + error_count: 0, + last_used_at: new Date("2026-03-15T08:00:00.000Z").toISOString(), + created_at: new Date("2026-03-14T00:00:00.000Z").toISOString(), + }, + ], + ...overrides, + }; +} + +function renderSetting(provider: ProviderWithKeysDisplay | null) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render( + , + ); + }); + + mountedRoots.push({ container, root }); + return container; +} + +beforeEach(() => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = true; +}); + +afterEach(() => { + vi.clearAllMocks(); + + while (mountedRoots.length > 0) { + const mounted = mountedRoots.pop(); + if (!mounted) { + break; + } + + act(() => { + mounted.root.unmount(); + }); + mounted.container.remove(); + } +}); + +describe("ProviderSetting", () => { + it("空状态应提示进入服务商配置工作台", () => { + const container = renderSetting(null); + + expect(container.textContent ?? "").toContain("服务商配置工作台"); + expect(container.textContent ?? "").toContain("模型、密钥和必要配置"); + }); + + it("应展示新的分区式编辑工作台", () => { + const container = renderSetting(createProvider()); + const text = container.textContent ?? ""; + + expect(text).toContain("模型设置"); + expect(text).toContain("API Key"); + expect(text).toContain("协议配置表单"); + expect(text).toContain("连接验证"); + expect(text).toContain("读取真实模型目录前,不展示旧模型"); + expect(text).toContain("默认:待读取"); + expect( + container.querySelector('[data-testid="provider-models-info-button"]'), + ).not.toBeNull(); + expect( + container.querySelector('[data-testid="provider-connection-info-button"]'), + ).not.toBeNull(); + expect( + container.querySelector('[data-testid="delete-provider-button"]'), + ).not.toBeNull(); + }); + + it("服务商工作台应保留原分栏,并允许模型区头部自然换行", () => { + const container = renderSetting(createProvider()); + const workbenchGrid = container.querySelector( + '[data-testid="provider-setting-workbench-grid"]', + ); + const modelsHeader = container.querySelector( + '[data-testid="supported-models-header"]', + ); + + expect(workbenchGrid?.className).toContain( + "xl:grid-cols-[minmax(0,1.7fr)_minmax(300px,340px)]", + ); + expect(modelsHeader?.className).toContain("flex-wrap"); + expect(modelsHeader?.className).toContain("justify-between"); + }); +}); diff --git a/src/components/provider-pool/api-key/README.md b/src/components/provider-pool/api-key/README.md index 3f469af26..df2053363 100644 --- a/src/components/provider-pool/api-key/README.md +++ b/src/components/provider-pool/api-key/README.md @@ -108,7 +108,8 @@ function ApiKeySection() { 2. **应用后端层(Lime Tauri)** - `get_system_provider_catalog` 作为 Provider 元信息入口(含 `legacy_ids`); - - `get_model_registry_provider_ids` 从 `src-tauri/resources/models/index.json` 提供模型 Provider 真相集。 + - `get_model_registry_provider_ids` 从 `src-tauri/resources/models/index.json` 提供模型 Provider 真相集; + - 真相源读取失败时直接报错,不再静默回退数据库。 3. **应用前端层(UI)** - `ProviderModelList` 先用 Catalog 归一 provider,再用模型 Provider 真相集校验; diff --git a/src/components/provider-pool/api-key/SectionInfoButton.tsx b/src/components/provider-pool/api-key/SectionInfoButton.tsx new file mode 100644 index 000000000..4ed1a0de3 --- /dev/null +++ b/src/components/provider-pool/api-key/SectionInfoButton.tsx @@ -0,0 +1,52 @@ +import type { ReactNode } from "react"; +import { CircleHelp } from "lucide-react"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { cn } from "@/lib/utils"; + +interface SectionInfoButtonProps { + label: string; + children: ReactNode; + className?: string; + contentClassName?: string; + contentTestId?: string; + triggerTestId?: string; +} + +export function SectionInfoButton({ + label, + children, + className, + contentClassName, + contentTestId, + triggerTestId, +}: SectionInfoButtonProps) { + return ( + + + + + + {children} + + + ); +} + +export default SectionInfoButton; diff --git a/src/components/provider-pool/api-key/providerModelListCache.ts b/src/components/provider-pool/api-key/providerModelListCache.ts new file mode 100644 index 000000000..0d0080143 --- /dev/null +++ b/src/components/provider-pool/api-key/providerModelListCache.ts @@ -0,0 +1,24 @@ +export const PROVIDER_MODELS_CACHE_TTL_MS = 5 * 60 * 1000; + +export function isProviderModelsCacheExpired( + cachedAt: number, + now: number = Date.now(), +): boolean { + return now - cachedAt >= PROVIDER_MODELS_CACHE_TTL_MS; +} + +function normalizeCacheSegment(value?: string | null): string { + return (value || "").trim().toLowerCase(); +} + +export function buildProviderModelsCacheKey(input: { + providerId?: string | null; + providerType?: string | null; + apiHost?: string | null; +}): string { + return [ + normalizeCacheSegment(input.providerId), + normalizeCacheSegment(input.providerType), + normalizeCacheSegment(input.apiHost), + ].join(":"); +} diff --git a/src/components/settings-v2/_layout/index.test.tsx b/src/components/settings-v2/_layout/index.test.tsx index 20bc7c456..77df8473c 100644 --- a/src/components/settings-v2/_layout/index.test.tsx +++ b/src/components/settings-v2/_layout/index.test.tsx @@ -92,9 +92,6 @@ vi.mock("../system/web-search", () => ({ vi.mock("../system/chrome-relay", () => ({ ChromeRelaySettings: () =>
chrome-relay
, })); -vi.mock("../features/SettingHeader", () => ({ - SettingHeader: ({ title }: { title: string }) =>
{title}
, -})); vi.mock("../home", () => ({ SettingsHomePage: () =>
home
, })); @@ -171,26 +168,26 @@ afterEach(() => { }); describe("SettingsLayoutV2 Profile Tab", () => { - it("OEM 运行时下只展示统一账户页,不再渲染本地资料编辑器", async () => { + it("OEM 运行时下只展示统一账户页,不再渲染本地资料编辑器,也不再注入壳层标题", async () => { const container = renderComponent(SettingsTabs.Profile); await flushEffects(); const text = container.textContent ?? ""; - expect(text).toContain("账号与资料"); expect(text).toContain("USER_CENTER_SESSION"); expect(text).not.toContain("PROFILE_SETTINGS"); + expect(text).not.toContain("账号与资料"); }); - it("非 OEM 运行时下仍保留本地资料编辑器", async () => { + it("非 OEM 运行时下仍保留本地资料编辑器,但不再显示重复页头", async () => { mockResolveOemCloudRuntimeContext.mockReturnValue(null); const container = renderComponent(SettingsTabs.Profile); await flushEffects(); const text = container.textContent ?? ""; - expect(text).toContain("个人资料"); expect(text).toContain("USER_CENTER_SESSION"); expect(text).toContain("PROFILE_SETTINGS"); + expect(text).not.toContain("个人资料"); }); }); @@ -206,24 +203,33 @@ describe("SettingsLayoutV2 Channels Redirect", () => { }); describe("SettingsLayoutV2 Experimental Tab", () => { - it("实验功能页应复用标准设置页标题", async () => { + it("实验功能页应直接展示内容,不再复用壳层设置页标题", async () => { const container = renderComponent(SettingsTabs.Experimental); await flushEffects(); const text = container.textContent ?? ""; - expect(text).toContain("实验功能"); expect(text).toContain("experimental"); + expect(text).not.toContain("实验功能"); }); }); describe("SettingsLayoutV2 Developer Tab", () => { - it("开发者页应复用标准设置页标题", async () => { + it("开发者页应直接展示内容,不再复用壳层设置页标题", async () => { const container = renderComponent(SettingsTabs.Developer); await flushEffects(); const text = container.textContent ?? ""; - expect(text).toContain("开发者"); expect(text).toContain("developer"); + expect(text).not.toContain("开发者"); + }); + + it("设置内容区应挂载统一氛围层,避免页面背景过于单调", async () => { + const container = renderComponent(SettingsTabs.Providers); + await flushEffects(); + + expect( + container.querySelector('[data-testid="settings-content-atmosphere"]'), + ).not.toBeNull(); }); it("预取开发者页时应连同延迟区块一起预热", async () => { diff --git a/src/components/settings-v2/_layout/index.tsx b/src/components/settings-v2/_layout/index.tsx index 3ba7e443b..ea7c33631 100644 --- a/src/components/settings-v2/_layout/index.tsx +++ b/src/components/settings-v2/_layout/index.tsx @@ -20,8 +20,6 @@ import { SettingsTabs } from "@/types/settings"; import { Page, PageParams } from "@/types/page"; import { buildHomeAgentParams } from "@/lib/workspace/navigation"; import { CanvasBreadcrumbHeader } from "@/lib/workspace/workbenchUi"; - -import { SettingHeader } from "../features/SettingHeader"; import { SettingsHomePage } from "../home"; import { resolveOemCloudRuntimeContext } from "@/lib/api/oemCloudRuntime"; @@ -147,8 +145,15 @@ const HeaderBar = styled.div` const ContentContainer = styled.main` flex: 1; min-width: 0; + position: relative; + isolation: isolate; overflow-y: auto; padding: 24px 32px; + background: linear-gradient( + 180deg, + rgba(248, 250, 252, 0.96) 0%, + rgba(244, 249, 247, 0.92) 100% + ); &::-webkit-scrollbar { width: 6px; @@ -173,11 +178,45 @@ const ContentContainer = styled.main` `; const ContentWrapper = styled.div<{ $wide: boolean }>` + position: relative; + z-index: 1; width: 100%; min-width: 0; max-width: ${({ $wide }) => ($wide ? "1440px" : "800px")}; `; +const ContentAtmosphere = styled.div` + position: absolute; + inset: 0; + pointer-events: none; + z-index: 0; + background: + radial-gradient( + circle at 8% 0%, + rgba(16, 185, 129, 0.1) 0%, + rgba(16, 185, 129, 0) 34% + ), + radial-gradient( + circle at 92% 4%, + rgba(56, 189, 248, 0.1) 0%, + rgba(56, 189, 248, 0) 30% + ); + + @media (max-width: 640px) { + background: + radial-gradient( + circle at 10% 0%, + rgba(16, 185, 129, 0.08) 0%, + rgba(16, 185, 129, 0) 36% + ), + radial-gradient( + circle at 92% 2%, + rgba(56, 189, 248, 0.08) 0%, + rgba(56, 189, 248, 0) 32% + ); + } +`; + const PlaceholderPage = styled.div` display: flex; flex-direction: column; @@ -323,86 +362,53 @@ function renderSettingsContent( // 账号组 case SettingsTabs.Profile: return ( - <> - - {withSettingsContentFallback( - <> - - {!hasManagedAccountProfile ? : null} - , - "正在加载账号资料...", - )} - + withSettingsContentFallback( + <> + + {!hasManagedAccountProfile ? : null} + , + "正在加载账号资料...", + ) ); case SettingsTabs.Stats: - return ( - <> - - {withSettingsContentFallback( - , - "正在加载数据统计...", - )} - + return withSettingsContentFallback( + , + "正在加载数据统计...", ); // 通用组 case SettingsTabs.Appearance: - return ( - <> - - {withSettingsContentFallback( - , - "正在加载外观设置...", - )} - + return withSettingsContentFallback( + , + "正在加载外观设置...", ); case SettingsTabs.Hotkeys: - return ( - <> - - {withSettingsContentFallback( - , - "正在加载快捷键设置...", - )} - + return withSettingsContentFallback( + , + "正在加载快捷键设置...", ); case SettingsTabs.Memory: - return ( - <> - - {withSettingsContentFallback( - , - "正在加载记忆设置...", - )} - + return withSettingsContentFallback( + , + "正在加载记忆设置...", ); // 智能体组 case SettingsTabs.Providers: - return ( - <> - - {withSettingsContentFallback( - onTabChange(SettingsTabs.Profile)} - />, - "正在加载 AI 服务商设置...", - )} - + return withSettingsContentFallback( + onTabChange(SettingsTabs.Profile)} + />, + "正在加载 AI 服务商设置...", ); case SettingsTabs.Skills: - return ( - <> - - {withSettingsContentFallback( - , - "正在加载技能管理...", - )} - + return withSettingsContentFallback( + , + "正在加载技能管理...", ); case SettingsTabs.MediaServices: @@ -413,61 +419,36 @@ function renderSettingsContent( // 系统组 case SettingsTabs.McpServer: - return ( - <> - - {withSettingsContentFallback( - , - "正在加载 MCP 服务器...", - )} - + return withSettingsContentFallback( + , + "正在加载 MCP 服务器...", ); case SettingsTabs.Channels: return ; case SettingsTabs.WebSearch: - return ( - <> - - {withSettingsContentFallback( - , - "正在加载网络搜索设置...", - )} - + return withSettingsContentFallback( + , + "正在加载网络搜索设置...", ); case SettingsTabs.Environment: - return ( - <> - - {withSettingsContentFallback( - , - "正在加载环境变量...", - )} - + return withSettingsContentFallback( + , + "正在加载环境变量...", ); case SettingsTabs.ChromeRelay: - return ( - <> - - {withSettingsContentFallback( - , - "正在加载连接器设置...", - )} - + return withSettingsContentFallback( + , + "正在加载连接器设置...", ); case SettingsTabs.SecurityPerformance: - return ( - <> - - {withSettingsContentFallback( - , - "正在加载安全与性能设置...", - )} - + return withSettingsContentFallback( + , + "正在加载安全与性能设置...", ); case SettingsTabs.Automation: @@ -486,25 +467,15 @@ function renderSettingsContent( ); case SettingsTabs.Experimental: - return ( - <> - - {withSettingsContentFallback( - , - "正在加载实验功能...", - )} - + return withSettingsContentFallback( + , + "正在加载实验功能...", ); case SettingsTabs.Developer: - return ( - <> - - {withSettingsContentFallback( - , - "正在加载开发者工具...", - )} - + return withSettingsContentFallback( + , + "正在加载开发者工具...", ); case SettingsTabs.About: @@ -611,6 +582,7 @@ export function SettingsLayoutV2({ onTabPrefetch={handleTabPrefetch} /> + {renderSettingsContent( activeTab, diff --git a/src/components/settings-v2/agent/providers/index.test.tsx b/src/components/settings-v2/agent/providers/index.test.tsx index 7b10b3e40..5b978ec15 100644 --- a/src/components/settings-v2/agent/providers/index.test.tsx +++ b/src/components/settings-v2/agent/providers/index.test.tsx @@ -2,15 +2,50 @@ import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -const { mockUseOemCloudAccess, mockFormatOemCloudDateTime } = vi.hoisted(() => ({ +const { + mockUseOemCloudAccess, + mockFormatOemCloudDateTime, + mockGetCompanionPetStatus, + mockLaunchCompanionPet, + mockListenCompanionPetStatus, + mockSendCompanionPetCommand, + mockProviderPoolGetOverview, + mockSubscribeProviderDataChanged, +} = vi.hoisted(() => ({ mockUseOemCloudAccess: vi.fn(), mockFormatOemCloudDateTime: vi.fn((value?: string) => `fmt:${value ?? ""}`), + mockGetCompanionPetStatus: vi.fn(), + mockLaunchCompanionPet: vi.fn(), + mockListenCompanionPetStatus: vi.fn(), + mockSendCompanionPetCommand: vi.fn(), + mockProviderPoolGetOverview: vi.fn(), + mockSubscribeProviderDataChanged: vi.fn(), })); vi.mock("@/components/provider-pool", () => ({ ProviderPoolPage: () =>
凭证池占位
, })); +vi.mock("@/lib/api/companion", () => ({ + getCompanionPetStatus: () => mockGetCompanionPetStatus(), + launchCompanionPet: () => mockLaunchCompanionPet(), + listenCompanionPetStatus: (...args: unknown[]) => + mockListenCompanionPetStatus(...args), + sendCompanionPetCommand: (...args: unknown[]) => + mockSendCompanionPetCommand(...args), +})); + +vi.mock("@/lib/api/providerPool", () => ({ + providerPoolApi: { + getOverview: (...args: unknown[]) => mockProviderPoolGetOverview(...args), + }, +})); + +vi.mock("@/lib/providerDataEvents", () => ({ + subscribeProviderDataChanged: (...args: unknown[]) => + mockSubscribeProviderDataChanged(...args), +})); + vi.mock("@/hooks/useOemCloudAccess", () => ({ useOemCloudAccess: () => mockUseOemCloudAccess(), formatOemCloudDateTime: (value?: string) => @@ -107,13 +142,59 @@ function createAccessState(overrides: Record = {}) { }; } -function renderPage(props: { onOpenProfile?: () => void } = {}) { +function createPetStatus(overrides: Record = {}) { + return { + endpoint: "ws://127.0.0.1:45554/companion/pet", + server_listening: true, + connected: false, + client_id: null, + platform: null, + capabilities: [], + last_event: null, + last_error: null, + last_state: "idle", + ...overrides, + }; +} + +function createProviderOverview() { + return [ + { + provider_type: "openai", + stats: { + total: 2, + healthy: 1, + unhealthy: 1, + disabled: 0, + total_usage: 6, + total_errors: 1, + }, + credentials: [], + }, + { + provider_type: "codex", + stats: { + total: 1, + healthy: 1, + unhealthy: 0, + disabled: 0, + total_usage: 3, + total_errors: 0, + }, + credentials: [], + }, + ]; +} + +async function renderPage(props: { onOpenProfile?: () => void } = {}) { const container = document.createElement("div"); document.body.appendChild(container); const root = createRoot(container); - act(() => { + await act(async () => { root.render(); + await Promise.resolve(); + await Promise.resolve(); }); const page = { container, root }; @@ -140,6 +221,20 @@ beforeEach(() => { ).IS_REACT_ACT_ENVIRONMENT = true; mockUseOemCloudAccess.mockReturnValue(createAccessState()); + mockGetCompanionPetStatus.mockResolvedValue(createPetStatus()); + mockLaunchCompanionPet.mockResolvedValue({ + launched: true, + resolved_path: "/Applications/Lime Pet.app/Contents/MacOS/Lime Pet", + endpoint: "ws://127.0.0.1:45554/companion/pet", + message: null, + }); + mockListenCompanionPetStatus.mockResolvedValue(vi.fn()); + mockSendCompanionPetCommand.mockResolvedValue({ + delivered: true, + connected: true, + }); + mockProviderPoolGetOverview.mockResolvedValue(createProviderOverview()); + mockSubscribeProviderDataChanged.mockReturnValue(vi.fn()); }); afterEach(() => { @@ -159,7 +254,7 @@ afterEach(() => { }); describe("CloudProviderSettings", () => { - it("未配置运行时信息时应展示配置提示,并通过单独视图承接本地 Provider", async () => { + it("默认应直接进入本地 Provider 主区,并保持切换器激活态清晰", async () => { mockUseOemCloudAccess.mockReturnValue( createAccessState({ runtime: null, @@ -167,27 +262,50 @@ describe("CloudProviderSettings", () => { }), ); - const { container } = renderPage(); + const { container } = await renderPage(); const text = container.textContent ?? ""; + const settingsTab = container.querySelector( + '[data-testid="provider-workspace-tab-settings"]', + ); + const cloudTab = container.querySelector( + '[data-testid="provider-workspace-tab-cloud"]', + ); - expect(text).toContain("云端接入"); - expect(text).toContain("public/oem-runtime-config.js"); - expect(text).toContain("按任务阶段看 AI 服务商"); - expect(text).not.toContain("凭证池占位"); + expect( + container.querySelector('[data-testid="provider-workspace-switcher"]'), + ).not.toBeNull(); + expect( + container.querySelectorAll('[data-testid="provider-workspace-switcher"]') + .length, + ).toBe(1); + expect(text).toContain("凭证池占位"); + expect(text).not.toContain("把本地 Provider 配置和 OEM 云端服务拆开管理"); + expect(text).not.toContain("默认先进入“服务商设置”处理 Provider"); + expect(text).not.toContain("public/oem-runtime-config.js"); + expect(settingsTab?.getAttribute("data-state")).toBe("active"); + expect(cloudTab?.getAttribute("data-state")).toBe("inactive"); await act(async () => { - findButton(container, "本地 Provider").dispatchEvent( + findButton(container, "云端服务").dispatchEvent( new MouseEvent("click", { bubbles: true }), ); }); - expect(container.textContent ?? "").toContain("本地 / 其它开发者 Provider"); - expect(container.textContent ?? "").toContain("凭证池占位"); + expect(container.textContent ?? "").toContain("先配置 OEM 云端运行时"); + expect(container.textContent ?? "").toContain("public/oem-runtime-config.js"); + expect(settingsTab?.getAttribute("data-state")).toBe("inactive"); + expect(cloudTab?.getAttribute("data-state")).toBe("active"); }); it("未登录时应提示前往个人中心登录", async () => { const onOpenProfile = vi.fn(); - const { container } = renderPage({ onOpenProfile }); + const { container } = await renderPage({ onOpenProfile }); + + await act(async () => { + findButton(container, "云端服务").dispatchEvent( + new MouseEvent("click", { bubbles: true }), + ); + }); expect(container.textContent ?? "").toContain("去个人中心登录"); @@ -200,7 +318,7 @@ describe("CloudProviderSettings", () => { expect(onOpenProfile).toHaveBeenCalledTimes(1); }); - it("已登录时应通过分视图展示云端来源目录与模型详情", async () => { + it("已登录时应在云端页展示 OEM 来源目录与模型详情", async () => { const handleRefresh = vi.fn(); const handleSetDefault = vi.fn(); const selectedOffer = { @@ -250,10 +368,13 @@ describe("CloudProviderSettings", () => { }), ); - const { container } = renderPage(); - expect(container.textContent ?? "").toContain("Demo Operator"); - expect(container.textContent ?? "").toContain("治理摘要与下一步"); - expect(container.textContent ?? "").toContain("fmt:2026-03-25T08:00:00.000Z"); + const { container } = await renderPage(); + + await act(async () => { + findButton(container, "云端服务").dispatchEvent( + new MouseEvent("click", { bubbles: true }), + ); + }); await act(async () => { findButton(container, "刷新云端状态").dispatchEvent( @@ -261,13 +382,10 @@ describe("CloudProviderSettings", () => { ); }); - await act(async () => { - findButton(container, "云端目录").dispatchEvent( - new MouseEvent("click", { bubbles: true }), - ); - }); - const text = container.textContent ?? ""; + expect(text).toContain("Demo Operator"); + expect(text).toContain("当前云端摘要"); + expect(text).toContain("fmt:2026-03-25T08:00:00.000Z"); expect(text).toContain("Lime Hub 主服务"); expect(text).toContain("GPT-5.2 Pro"); @@ -334,10 +452,10 @@ describe("CloudProviderSettings", () => { }), ); - const { container } = renderPage(); + const { container } = await renderPage(); await act(async () => { - findButton(container, "云端目录").dispatchEvent( + findButton(container, "云端服务").dispatchEvent( new MouseEvent("click", { bubbles: true }), ); }); @@ -381,17 +499,150 @@ describe("CloudProviderSettings", () => { }), ); - const { container } = renderPage(); - act(() => { - findButton(container, "云端目录").dispatchEvent( - new MouseEvent("click", { bubbles: true }), + return renderPage().then(({ container }) => { + act(() => { + findButton(container, "云端服务").dispatchEvent( + new MouseEvent("click", { bubbles: true }), + ); + }); + const offerGrid = container.querySelector( + '[data-testid="oem-cloud-offer-grid"]', ); + + expect(offerGrid).not.toBeNull(); + expect(offerGrid?.className).not.toContain("lg:grid-cols-2"); }); - const offerGrid = container.querySelector( - '[data-testid="oem-cloud-offer-grid"]', + }); + + it("服务商设置页应展示桌宠桥接卡片和脱敏边界说明", async () => { + mockGetCompanionPetStatus.mockResolvedValue( + createPetStatus({ + connected: true, + client_id: "lime-pet", + platform: "macos", + capabilities: ["provider-overview"], + last_event: "pet.ready", + last_state: "walking", + }), ); - expect(offerGrid).not.toBeNull(); - expect(offerGrid?.className).not.toContain("lg:grid-cols-2"); + const { container } = await renderPage(); + const text = container.textContent ?? ""; + + expect( + container.querySelector('[data-testid="companion-provider-card"]'), + ).not.toBeNull(); + expect(text).toContain("Lime Pet Companion"); + expect(text).toContain("桌宠通过本地 Companion 通道复用 Lime 的 AI 服务商状态"); + expect(text).toContain("不会直接读取 API Key、OAuth 凭证或本地凭证文件"); + expect(text).toContain("桌宠已连接"); + expect(text).toContain("Provider 概览"); + expect(text).toContain("桌宠视角预览"); + expect(text).toContain("OpenAI"); + expect(text).toContain("Codex"); + expect(text).toContain("可用 2"); + expect(text).toContain("需关注 1"); + expect(text).toContain("接入检查"); + expect(text).toContain("当前链路已就绪,可以直接点击“立即同步到桌宠”。"); + }); + + it("点击启动桌宠后应调用 launch 接口并展示启动反馈", async () => { + mockGetCompanionPetStatus + .mockResolvedValueOnce(createPetStatus()) + .mockResolvedValueOnce( + createPetStatus({ + server_listening: true, + connected: false, + last_event: "pet.launch_requested", + }), + ); + + const { container } = await renderPage(); + + await act(async () => { + findButton(container, "启动 Lime Pet").dispatchEvent( + new MouseEvent("click", { bubbles: true }), + ); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(mockLaunchCompanionPet).toHaveBeenCalledTimes(1); + expect(container.textContent ?? "").toContain("已请求启动 Lime Pet"); + }); + + it("桌宠已连接且支持 provider 概览时,应允许手动同步摘要", async () => { + mockGetCompanionPetStatus.mockResolvedValue( + createPetStatus({ + connected: true, + capabilities: ["provider-overview"], + last_event: "pet.ready", + }), + ); + + const { container } = await renderPage(); + + await act(async () => { + findButton(container, "立即同步到桌宠").dispatchEvent( + new MouseEvent("click", { bubbles: true }), + ); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(mockSendCompanionPetCommand).toHaveBeenCalledWith({ + event: "pet.provider_overview", + payload: { + providers: [ + { + provider_type: "codex", + display_name: "Codex", + total_count: 1, + healthy_count: 1, + available: true, + needs_attention: false, + }, + { + provider_type: "openai", + display_name: "OpenAI", + total_count: 2, + healthy_count: 1, + available: true, + needs_attention: true, + }, + ], + total_provider_count: 2, + available_provider_count: 2, + needs_attention_provider_count: 1, + }, + }); + expect(container.textContent ?? "").toContain("已同步 2 个服务商摘要到桌宠"); + expect(container.textContent ?? "").toContain( + "当前链路已就绪,可以直接点击“立即同步到桌宠”。", + ); + }); + + it("桌宠已连接但未声明 provider 概览能力时,应展示诊断并禁用手动同步", async () => { + mockGetCompanionPetStatus.mockResolvedValue( + createPetStatus({ + connected: true, + client_id: "lime-pet", + platform: "windows", + capabilities: [], + last_event: "pet.ready", + }), + ); + + const { container } = await renderPage(); + const syncButton = container.querySelector( + '[data-testid="companion-sync-preview"]', + ) as HTMLButtonElement | null; + const text = container.textContent ?? ""; + + expect(syncButton).not.toBeNull(); + expect(syncButton?.disabled).toBe(true); + expect(text).toContain("能力未声明"); + expect(text).toContain("当前桌宠已连接,但尚未声明 Provider 概览能力"); + expect(text).toContain("平台:Windows"); }); }); diff --git a/src/components/settings-v2/agent/providers/index.tsx b/src/components/settings-v2/agent/providers/index.tsx index 46cd1db78..0b570e428 100644 --- a/src/components/settings-v2/agent/providers/index.tsx +++ b/src/components/settings-v2/agent/providers/index.tsx @@ -1,9 +1,9 @@ import { useEffect, useMemo, useState, type ReactNode } from "react"; import { AlertCircle, + Bot, CheckCircle2, Cloud, - Database, ExternalLink, KeyRound, Layers3, @@ -27,10 +27,24 @@ import type { OemCloudProviderOfferState, OemCloudProviderOfferSummary, } from "@/lib/api/oemCloudControlPlane"; +import { + getCompanionPetStatus, + launchCompanionPet, + listenCompanionPetStatus, + sendCompanionPetCommand, + type CompanionPetStatus, +} from "@/lib/api/companion"; +import { providerPoolApi } from "@/lib/api/providerPool"; +import { subscribeProviderDataChanged } from "@/lib/providerDataEvents"; +import { + buildCompanionProviderOverview, + type CompanionProviderOverviewPayload, +} from "@/lib/provider/companionProviderOverview"; import { cn } from "@/lib/utils"; const SURFACE_CLASS_NAME = - "rounded-[26px] border border-slate-200/80 bg-white p-5 shadow-sm shadow-slate-950/5"; + "rounded-[24px] border border-slate-200/80 bg-white p-5 shadow-sm shadow-slate-950/5"; +const DEFAULT_COMPANION_ENDPOINT = "ws://127.0.0.1:45554/companion/pet"; function SessionValueCard(props: { label: string; @@ -39,15 +53,15 @@ function SessionValueCard(props: { icon?: ReactNode; }) { return ( -
-
+
+
{props.icon ? {props.icon} : null} {props.label}

{props.value}

-

{props.hint}

+

{props.hint}

); } @@ -59,7 +73,7 @@ function NoticeBar(props: { return (
-

- {props.label} -

+
+

{props.label}

{props.value}

@@ -112,9 +127,704 @@ function RuntimeSummaryItem(props: { ); } -function resolveOfferTone( - state: string, -): "slate" | "emerald" | "amber" { +function formatCompanionError(error: unknown): string { + if (error instanceof Error && error.message) { + return error.message; + } + if (typeof error === "string" && error.trim()) { + return error; + } + return "未知错误"; +} + +function formatCompanionCapabilityLabel(capability: string): string { + switch (capability) { + case "provider-overview": + return "Provider 概览"; + default: + return capability; + } +} + +function formatCompanionVisualStateLabel( + state: CompanionPetStatus["last_state"], +): string { + switch (state) { + case "hidden": + return "隐藏"; + case "walking": + return "游走"; + case "thinking": + return "思考中"; + case "done": + return "完成"; + case "idle": + default: + return "待命"; + } +} + +function formatCompanionPlatformLabel(platform: string | null | undefined): string { + switch (platform) { + case "macos": + return "macOS"; + case "windows": + return "Windows"; + case "linux": + return "Linux"; + default: + return platform?.trim() || "未上报平台"; + } +} + +function formatCompanionDateTime(value: Date | null): string { + if (!value) { + return "尚未同步"; + } + + return new Intl.DateTimeFormat("zh-CN", { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }).format(value); +} + +function CompanionProviderBridgeCard() { + const [status, setStatus] = useState(null); + const [loadingStatus, setLoadingStatus] = useState(true); + const [refreshingStatus, setRefreshingStatus] = useState(false); + const [launchingPet, setLaunchingPet] = useState(false); + const [syncingPreview, setSyncingPreview] = useState(false); + const [previewLoading, setPreviewLoading] = useState(true); + const [previewError, setPreviewError] = useState(null); + const [providerOverviewPreview, setProviderOverviewPreview] = + useState(null); + const [lastManualSyncAt, setLastManualSyncAt] = useState(null); + const [actionFeedback, setActionFeedback] = useState<{ + tone: "error" | "success"; + message: string; + } | null>(null); + + useEffect(() => { + let cancelled = false; + let statusUnlisten: (() => void) | null = null; + let unsubscribeProviderData: (() => void) | null = null; + + const applyStatus = (nextStatus: CompanionPetStatus) => { + if (cancelled) { + return; + } + setStatus(nextStatus); + }; + + const loadProviderPreview = async (forceRefresh = false) => { + if (!cancelled) { + setPreviewLoading(true); + setPreviewError(null); + } + + try { + const overview = await providerPoolApi.getOverview( + forceRefresh ? { forceRefresh: true } : undefined, + ); + if (cancelled) { + return; + } + setProviderOverviewPreview(buildCompanionProviderOverview(overview)); + } catch (error) { + if (!cancelled) { + setPreviewError(`读取桌宠摘要预览失败:${formatCompanionError(error)}`); + } + } finally { + if (!cancelled) { + setPreviewLoading(false); + } + } + }; + + const loadStatus = async (initial = false) => { + if (initial) { + setLoadingStatus(true); + } else { + setRefreshingStatus(true); + } + + try { + const nextStatus = await getCompanionPetStatus(); + applyStatus(nextStatus); + } catch (error) { + if (!cancelled) { + setActionFeedback({ + tone: "error", + message: `读取桌宠状态失败:${formatCompanionError(error)}`, + }); + } + } finally { + if (!cancelled) { + setLoadingStatus(false); + setRefreshingStatus(false); + } + } + }; + + void loadStatus(true); + void loadProviderPreview(); + + void listenCompanionPetStatus((nextStatus) => { + applyStatus(nextStatus); + }) + .then((unlisten) => { + if (cancelled) { + void unlisten(); + return; + } + statusUnlisten = unlisten; + }) + .catch((error) => { + if (!cancelled) { + setActionFeedback({ + tone: "error", + message: `监听桌宠状态失败:${formatCompanionError(error)}`, + }); + } + }); + + unsubscribeProviderData = subscribeProviderDataChanged(() => { + void loadProviderPreview(true); + }); + + return () => { + cancelled = true; + if (statusUnlisten) { + statusUnlisten(); + } + if (unsubscribeProviderData) { + unsubscribeProviderData(); + } + }; + }, []); + + const refreshStatus = async () => { + setActionFeedback(null); + setRefreshingStatus(true); + setPreviewLoading(true); + try { + const [nextStatus] = await Promise.all([ + getCompanionPetStatus(), + providerPoolApi + .getOverview({ forceRefresh: true }) + .then((overview) => { + setProviderOverviewPreview(buildCompanionProviderOverview(overview)); + setPreviewError(null); + }) + .catch((error) => { + setPreviewError( + `读取桌宠摘要预览失败:${formatCompanionError(error)}`, + ); + }) + .finally(() => { + setPreviewLoading(false); + }), + ]); + setStatus(nextStatus); + } catch (error) { + setActionFeedback({ + tone: "error", + message: `刷新桌宠状态失败:${formatCompanionError(error)}`, + }); + } finally { + setRefreshingStatus(false); + } + }; + + const handleLaunchPet = async () => { + setActionFeedback(null); + setLaunchingPet(true); + try { + const result = await launchCompanionPet(); + if (result.launched) { + setActionFeedback({ + tone: "success", + message: result.message || "已请求启动 Lime Pet,请等待桌宠建立连接。", + }); + } else { + setActionFeedback({ + tone: "error", + message: + result.message || + "当前没有可用的 Lime Pet 可执行产物,请先安装桌宠应用。", + }); + } + + const nextStatus = await getCompanionPetStatus(); + setStatus(nextStatus); + } catch (error) { + setActionFeedback({ + tone: "error", + message: `启动桌宠失败:${formatCompanionError(error)}`, + }); + } finally { + setLaunchingPet(false); + } + }; + + const handleSyncPreview = async () => { + setActionFeedback(null); + + if (!providerOverviewPreview) { + setActionFeedback({ + tone: "error", + message: "桌宠摘要预览尚未准备完成,请稍后再试。", + }); + return; + } + + if (!connected) { + setActionFeedback({ + tone: "error", + message: "桌宠尚未连接,暂时无法同步摘要。", + }); + return; + } + + if (!supportsProviderOverview) { + setActionFeedback({ + tone: "error", + message: "当前桌宠未声明 Provider 概览能力,暂时无法接收摘要。", + }); + return; + } + + setSyncingPreview(true); + try { + const result = await sendCompanionPetCommand({ + event: "pet.provider_overview", + payload: providerOverviewPreview, + }); + + if (!result.delivered) { + setActionFeedback({ + tone: "error", + message: "桌宠连接存在但本次摘要未送达,请检查 Companion 连接状态。", + }); + return; + } + + setActionFeedback({ + tone: "success", + message: `已同步 ${providerOverviewPreview.total_provider_count} 个服务商摘要到桌宠。`, + }); + setLastManualSyncAt(new Date()); + } catch (error) { + setActionFeedback({ + tone: "error", + message: `同步桌宠摘要失败:${formatCompanionError(error)}`, + }); + } finally { + setSyncingPreview(false); + } + }; + + const connected = Boolean(status?.connected); + const serverListening = Boolean(status?.server_listening); + const supportsProviderOverview = Boolean( + status?.capabilities.includes("provider-overview"), + ); + const endpoint = status?.endpoint || DEFAULT_COMPANION_ENDPOINT; + const lastState = formatCompanionVisualStateLabel(status?.last_state || "idle"); + const capabilityText = + status && status.capabilities.length > 0 + ? status.capabilities.map(formatCompanionCapabilityLabel).join(" / ") + : "未声明"; + const previewProviders = providerOverviewPreview?.providers || []; + const petIdentity = status?.client_id?.trim() || "等待桌宠上报"; + const syncDiagnostic = (() => { + if (previewLoading) { + return { + label: "整理摘要中", + hint: "正在从本地 Provider 真相源整理桌宠预览。", + }; + } + if (previewError) { + return { + label: "预览异常", + hint: previewError, + }; + } + if (!serverListening) { + return { + label: "宿主未监听", + hint: "Companion 服务尚未监听,本地桌宠暂时无法接入。", + }; + } + if (!connected) { + return { + label: "等待桌宠连接", + hint: "Companion 已监听,可点击“启动 Lime Pet”或检查桌宠是否已连上本地入口。", + }; + } + if (!supportsProviderOverview) { + return { + label: "能力未声明", + hint: "当前桌宠已连接,但尚未声明 Provider 概览能力,Lime 不会强行下发摘要。", + }; + } + if (syncingPreview) { + return { + label: "同步中", + hint: "正在把当前脱敏摘要发送给桌宠。", + }; + } + return { + label: "可立即同步", + hint: "桌宠已连接且已声明 Provider 概览能力,可以手动下发当前摘要。", + }; + })(); + const readinessChecks = [ + { + key: "host", + label: "Companion 宿主已监听", + done: serverListening, + pending: false, + detail: serverListening + ? "Lime 已监听本地桌宠入口。" + : "当前还没有可用的本地桌宠入口。", + }, + { + key: "connection", + label: "桌宠已建立连接", + done: connected, + pending: false, + detail: connected + ? "桌宠已经接入 Lime Companion。" + : "需要启动桌宠,或检查它是否连到了本地入口。", + }, + { + key: "capability", + label: "桌宠声明 Provider 概览能力", + done: supportsProviderOverview, + pending: connected && !supportsProviderOverview, + detail: supportsProviderOverview + ? "桌宠已声明可接收 provider-overview 摘要。" + : connected + ? "当前桌宠已连接,但尚未声明 provider-overview 能力。" + : "桌宠连接建立后,Lime 会等待能力声明。", + }, + { + key: "preview", + label: "脱敏摘要已准备完成", + done: + !previewLoading && + !previewError && + providerOverviewPreview !== null && + previewProviders.length >= 0, + pending: previewLoading, + detail: previewLoading + ? "正在从本地 Provider 真相源整理脱敏摘要。" + : previewError + ? previewError + : `当前已准备 ${providerOverviewPreview?.total_provider_count ?? 0} 个服务商摘要。`, + }, + ] as const; + const nextAction = (() => { + if (!serverListening) { + return "先让 Lime 完整启动 Companion 宿主,再连接桌宠。"; + } + if (!connected) { + return "点击“启动 Lime Pet”,或检查桌宠是否已连接到本地 Companion 地址。"; + } + if (!supportsProviderOverview) { + return "先让桌宠在 ready 事件里声明 provider-overview 能力,再尝试同步摘要。"; + } + if (previewLoading) { + return "等待 Lime 整理完当前 Provider 脱敏摘要。"; + } + if (previewError) { + return "先修复摘要预览异常,再把脱敏摘要发送给桌宠。"; + } + return "当前链路已就绪,可以直接点击“立即同步到桌宠”。"; + })(); + + return ( +
+
+
+
+
+ +
+
+
+

+ Lime Pet Companion +

+ + +
+

+ 桌宠通过本地 Companion 通道复用 Lime 的 AI 服务商状态,只接收脱敏后的可用性摘要,不会直接读取 API Key、OAuth 凭证或本地凭证文件。 +

+
+
+ +
+ + + + + +
+ +
+
+
+

接入检查

+

+ 按“宿主监听、桌宠连接、能力声明、摘要准备”这四步排查桌宠接入状态。 +

+
+
+ 当前建议:{nextAction} +
+
+ +
+ {readinessChecks.map((item) => ( +
+
+ + {item.done ? ( + + ) : item.pending ? ( + + ) : ( + + )} + +
+

{item.label}

+

{item.detail}

+
+
+
+ ))} +
+
+ +
+
+
+

+ 桌宠视角预览 +

+

+ 这里展示 Lime 准备发给桌宠的 Provider 脱敏摘要,便于确认 Companion 边界没有把原始凭证带出去。 +

+
+
+ + + + + +
+
+ +
+ Companion 地址:{endpoint} +
+ + {previewLoading ? ( +
+ + 正在整理桌宠摘要预览... +
+ ) : previewProviders.length > 0 ? ( +
+ {previewProviders.map((provider) => ( +
+
+
+

+ {provider.display_name} +

+

+ {provider.provider_type} +

+
+ +
+
+ 配置 {provider.total_count} + 健康 {provider.healthy_count} + {provider.needs_attention ? ( + 需要关注 + ) : ( + 状态稳定 + )} +
+
+ ))} +
+ ) : ( +
+ 当前还没有可供桌宠消费的 Provider 摘要。配置任一服务商后,这里会显示脱敏后的可用性信息。 +
+ )} +
+ + {status?.last_error ? ( + + ) : null} + + {previewError ? ( + + ) : null} + + {actionFeedback ? ( + + ) : null} +
+ +
+ + +
+
+
+ ); +} + +function resolveOfferTone(state: string): "slate" | "emerald" | "amber" { switch (state) { case "available_ready": return "emerald"; @@ -174,45 +884,41 @@ function resolveDisplayOfferState( return "available_subscribe_required"; } -type ProviderWorkspaceView = "overview" | "cloud" | "providers"; +type ProviderWorkspaceView = "settings" | "cloud"; const PROVIDER_WORKSPACE_VIEW_META: Array<{ value: ProviderWorkspaceView; label: string; - caption: string; - description: string; - icon: typeof Cloud; + summary: string; + icon: typeof KeyRound; }> = [ { - value: "overview", - label: "概览", - caption: "默认来源 / 会话 / 治理", - description: "适合先确认当前租户到底在走哪条云端路径。", - icon: Database, + value: "settings", + label: "服务商设置", + summary: "Provider / API Key / 模型", + icon: KeyRound, }, { value: "cloud", - label: "云端目录", - caption: "Offer / 模型目录 / 消费态", - description: "云端服务目录单独展示,避免和开发者配置混在一起。", + label: "云端服务", + summary: "Offer / 目录 / 会话", icon: Cloud, }, - { - value: "providers", - label: "本地 Provider", - caption: "本地模型 / 第三方 / API Key", - description: "开发者配置独立出来,避免被云端消费信息淹没。", - icon: KeyRound, - }, ]; +function isLimeBrandedHub(hubProviderName: string | null | undefined): boolean { + if (typeof hubProviderName !== "string") { + return false; + } + + return hubProviderName.trim().toLowerCase().includes("lime"); +} + export interface CloudProviderSettingsProps { onOpenProfile?: () => void; } export function CloudProviderSettings(props: CloudProviderSettingsProps) { - const [activeView, setActiveView] = - useState("overview"); const { runtime, configuredTarget, @@ -236,13 +942,45 @@ export function CloudProviderSettings(props: CloudProviderSettingsProps) { activeConfigModeLabel, activeModelsSourceLabel, activeDeveloperAccessEnabled, - activeDeveloperAccessLabel, handleRefresh, openOfferDetail, handleSetDefault, openUserCenter, } = useOemCloudAccess(); + const isOemRuntime = Boolean(runtime); + const isLimeBrand = isLimeBrandedHub(hubProviderName); + const showProviderSettingsEntry = + !isOemRuntime || isLimeBrand || activeDeveloperAccessEnabled; + const workspaceViews = useMemo(() => { + const orderedViews: ProviderWorkspaceView[] = []; + + if (isOemRuntime && !isLimeBrand) { + orderedViews.push("cloud"); + } + + if (showProviderSettingsEntry) { + orderedViews.push("settings"); + } + + if (!orderedViews.includes("cloud")) { + orderedViews.push("cloud"); + } + + return orderedViews.map( + (view) => + PROVIDER_WORKSPACE_VIEW_META.find((item) => item.value === view)!, + ); + }, [isLimeBrand, isOemRuntime, showProviderSettingsEntry]); + const defaultView = workspaceViews[0]?.value ?? "cloud"; + const [activeView, setActiveView] = useState(defaultView); + + useEffect(() => { + if (!workspaceViews.some((item) => item.value === activeView)) { + setActiveView(defaultView); + } + }, [activeView, defaultView, workspaceViews]); + useEffect(() => { if (!session || selectedOffer || loadingDetail || offers.length === 0) { return; @@ -261,42 +999,35 @@ export function CloudProviderSettings(props: CloudProviderSettingsProps) { session, ]); - const selectedOfferKey = selectedOffer?.providerKey ?? defaultCloudOffer?.providerKey; + const selectedOfferKey = + selectedOffer?.providerKey ?? defaultCloudOffer?.providerKey; const displayedSelectedOfferState = resolveDisplayOfferState( session, selectedOffer, ); - const localProviderHint = useMemo(() => { - if (!activeCloudOffer) { - return "这里管理你自带的 API Key、第三方平台和本地模型。云端入口已经单独收口,不再和本地 Provider 混在一起。"; - } - - if (activeDeveloperAccessEnabled) { - return "当前云端服务已开放开发者入口。你可以继续在这里管理自带 API Key 或第三方 Provider,作为云端服务之外的补充能力。"; - } - - return "当前云端服务未开放开发者 API Key 模式,这不会影响你在这里管理其它第三方 Provider 或本地模型。"; - }, [activeCloudOffer, activeDeveloperAccessEnabled]); - const offerGridClassName = useMemo( () => cn("grid gap-4", offers.length > 1 && "lg:grid-cols-2"), [offers.length], ); - const overviewContent = !runtime ? ( -
-
-

先配置运行时入口

-

- 当前没有可用的运行时配置。请先在 - - public/oem-runtime-config.js - - 中配置域名、网关地址和租户信息,再继续接入云端服务。 -

-
-
+ const cloudDirectoryContent = !runtime ? ( +
+
+
+

+ 先配置 OEM 云端运行时 +

+

+ 当前没有可用的云端运行时配置。请先在 + + public/oem-runtime-config.js + + 中配置域名、网关地址和租户信息,再继续接入云端服务。 +

+
+
+
) : initializing ? (
@@ -304,26 +1035,74 @@ export function CloudProviderSettings(props: CloudProviderSettingsProps) { 正在恢复个人中心会话...
- ) : session ? ( -
+ ) : !session ? ( +
+
+
+
+

+ 登录后查看云端目录 +

+

+ 当前还没有可用的个人中心会话。登录后,云端默认来源、模型目录和服务技能目录会自动同步到本地。 +

+
+
+ + +
+
+
+ +
+
+

+ 云端页面承接什么 +

+

+ 这里保留 OEM 商业化相关的 Offer、套餐、模型目录、默认来源和会话状态,不再和本地 Provider 配置共用一个长页。 +

+
+
+
+ ) : ( +
-
-
-
+
+
+
-
+

{session.user.displayName || session.user.email || "已登录"}

{session.user.email || session.user.username || session.user.id}

+

+ 当前云端会话将驱动 OEM Offer、模型目录和默认来源。商业化套餐、可用模型和是否开放开发者入口都以服务端治理结果为准。 +

@@ -351,7 +1130,7 @@ export function CloudProviderSettings(props: CloudProviderSettingsProps) { type="button" onClick={() => void handleRefresh()} disabled={refreshing} - className="inline-flex items-center justify-center gap-2 rounded-[18px] border border-slate-200 bg-white px-4 py-2.5 text-sm font-medium text-slate-700 transition hover:border-slate-300 hover:bg-slate-50 disabled:opacity-60" + className="inline-flex items-center justify-center gap-2 rounded-[16px] border border-slate-200 bg-white px-4 py-2.5 text-sm font-medium text-slate-700 transition hover:border-slate-300 hover:bg-slate-50 disabled:opacity-60" data-testid="oem-cloud-refresh" > props.onOpenProfile?.()} - className="inline-flex items-center justify-center gap-2 rounded-[18px] border border-slate-200 bg-white px-4 py-2.5 text-sm font-medium text-slate-700 transition hover:border-slate-300 hover:bg-slate-50" + className="inline-flex items-center justify-center gap-2 rounded-[16px] border border-slate-200 bg-white px-4 py-2.5 text-sm font-medium text-slate-700 transition hover:border-slate-300 hover:bg-slate-50" > 前往个人中心管理会话 @@ -370,7 +1149,7 @@ export function CloudProviderSettings(props: CloudProviderSettingsProps) { - -
-
- ) : ( -
-
-
-
-

- 先在个人中心登录 -

-

- 当前还没有可用的个人中心会话。登录后,云端默认来源、模型目录和服务技能目录会自动同步到本地。 -

-
-
- - -
-
-
-
-
-

接入说明

-

- 云端服务默认通过登录会话消费。是否开放开发者 API Key 模式、是否允许租户覆盖,都由后台统一治理并在这里展示最终态。 -

-
-
-
- ); - - const cloudDirectoryContent = !runtime ? ( -
-

- 当前没有可用的运行时配置。请先在 - - public/oem-runtime-config.js - - 中配置域名、网关地址和租户信息,再继续接入云端服务。 -

-
- ) : initializing ? ( -
-
- - 正在恢复个人中心会话... -
-
- ) : !session ? ( -
-
-
-

- 登录后查看云端目录 -

-

- 当前没有可用的个人中心会话。登录后,云端默认来源、模型目录和服务技能目录会自动同步到本地。 -

-
- -
-
- ) : ( -
-
+

云端服务目录

- 卡片以服务端返回为主,并结合当前桌面会话做展示归一。是否允许 API Key 模式、当前模型来源、租户覆盖是否生效,都以服务端治理结果为准。 + 卡片以服务端返回为主,并结合当前桌面会话做状态归一。是否允许 API Key 模式、当前模型来源、租户覆盖是否生效,都以服务端治理结果为准。

@@ -566,7 +1227,9 @@ export function CloudProviderSettings(props: CloudProviderSettingsProps) { session, offer, ); - const stateTone = resolveOfferTone(displayedOfferState ?? offer.state); + const stateTone = resolveOfferTone( + displayedOfferState ?? offer.state, + ); return (
void openOfferDetail(offer.providerKey)} disabled={loadingDetail && isFocused} - className="inline-flex items-center justify-center gap-2 rounded-[18px] border border-slate-200 bg-white px-4 py-2.5 text-sm font-medium text-slate-700 transition hover:border-slate-300 hover:bg-slate-50 disabled:opacity-60" + className="inline-flex items-center justify-center gap-2 rounded-[16px] border border-slate-200 bg-white px-4 py-2.5 text-sm font-medium text-slate-700 transition hover:border-slate-300 hover:bg-slate-50 disabled:opacity-60" > {loadingDetail && isFocused ? ( @@ -660,7 +1323,7 @@ export function CloudProviderSettings(props: CloudProviderSettingsProps) { type="button" onClick={() => void handleSetDefault(offer)} disabled={savingDefault === offer.providerKey} - className="inline-flex items-center justify-center gap-2 rounded-[18px] bg-slate-900 px-4 py-2.5 text-sm font-medium text-white transition hover:bg-slate-800 disabled:opacity-60" + className="inline-flex items-center justify-center gap-2 rounded-[16px] bg-slate-900 px-4 py-2.5 text-sm font-medium text-white transition hover:bg-slate-800 disabled:opacity-60" > {savingDefault === offer.providerKey ? ( @@ -759,11 +1422,11 @@ export function CloudProviderSettings(props: CloudProviderSettingsProps) { 正在加载模型目录...
) : selectedModels.length > 0 ? ( -
+
{selectedModels.map((model) => (
@@ -794,7 +1457,7 @@ export function CloudProviderSettings(props: CloudProviderSettingsProps) {
) : ( -
+

选择一个云端来源查看详情 @@ -811,195 +1474,65 @@ export function CloudProviderSettings(props: CloudProviderSettingsProps) { ); const localProviderContent = ( -

-
-
-
-

- 本地 / 其它开发者 Provider -

-

- {localProviderHint} -

-
- - -
-
- +
+ -
+
); return ( -
-
-
-
+
+ {errorMessage ? : null} + {infoMessage ? : null} -
-
-
- - CLOUD ACCESS - -
-

- {hubProviderName} 云端接入 -

-

- 这页现在拆成概览、云端目录和本地 Provider 三个视图。云端消费态与开发者配置不再堆在一个长页里,而是按工作流分开展示。 -

-
-
- -
-
-
-

- 运行时摘要 -

-

- 当前接入快照 -

-
-
- - -
-
- -
- - - - -
-
-
- - {errorMessage ? : null} - {infoMessage ? : null} - - - setActiveView(value as ProviderWorkspaceView) - } - className="space-y-5" + + setActiveView(value as ProviderWorkspaceView) + } + className="space-y-4" + > + {workspaceViews.length > 1 ? ( + -
-
-
-

- 视图切换 -

-

- 按任务阶段看 AI 服务商 -

-

- 概览负责判断当前在用哪条云端路径,云端目录只看 Offer 和模型,本地 Provider 只做开发者配置,避免一个页面同时承担三类认知任务。 -

-
+ {workspaceViews.map((item) => { + const ItemIcon = item.icon; - - {PROVIDER_WORKSPACE_VIEW_META.map((item) => { - const ItemIcon = item.icon; - const active = item.value === activeView; + return ( + + + + + {item.label} + + ); + })} + + ) : null} - return ( - -
-
- - - - {item.label} -
+ {showProviderSettingsEntry ? ( + + {localProviderContent} + + ) : null} - {active ? ( - - 当前 - - ) : null} -
- -
- - {item.caption} - - - {item.description} - -
-
- ); - })} - -
-
- - - {overviewContent} - - - - {cloudDirectoryContent} - - - - {localProviderContent} - -
-
-
+ + {cloudDirectoryContent} + +
); } diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx index bf3afa7ac..8bb647a4d 100644 --- a/src/components/ui/tabs.tsx +++ b/src/components/ui/tabs.tsx @@ -37,17 +37,23 @@ const Tabs: React.FC = ({ ); }; -interface TabsListProps { +interface TabsListProps extends React.HTMLAttributes { className?: string; children: React.ReactNode; } -const TabsList: React.FC = ({ className, children }) => ( +const TabsList: React.FC = ({ + className, + children, + ...props +}) => (
{children}
@@ -77,6 +83,9 @@ const TabsTrigger: React.FC = ({ return (