release: ship v1.1.0

This commit is contained in:
coso
2026-04-02 20:36:21 +08:00
parent 0398fc8c8e
commit 8ea5050aba
173 changed files with 9286 additions and 2275 deletions
+24 -15
View File
@@ -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`
+37
View File
@@ -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`
+26
View File
@@ -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. 准备一个长线程,确保能够稳定接近上下文上限
+15
View File
@@ -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,而不是回退到工作区默认值
+73
View File
@@ -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 的技能标准必须分成四层:
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "lime",
"private": true,
"version": "1.0.1",
"version": "1.1.0",
"type": "module",
"engines": {
"node": ">=22.0.0"
+31 -31
View File
@@ -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]]
+4 -4
View File
@@ -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"
+1
View File
@@ -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 {
@@ -1179,7 +1179,7 @@ async fn stream_agent_reply_once<F>(
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
@@ -91,14 +91,14 @@ impl SessionExecutionRuntimeAccessMode {
Self::from_extension_data(&session.extension_data)
}
fn to_extension_data(&self, extension_data: &mut ExtensionData) -> Result<(), String> {
<Self as ExtensionState>::to_extension_data(self, extension_data)
fn write_extension_data(self, extension_data: &mut ExtensionData) -> Result<(), String> {
<Self as ExtensionState>::to_extension_data(&self, extension_data)
.map_err(|error| error.to_string())
}
fn into_updated_extension_data(self, session: &Session) -> Result<ExtensionData, String> {
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)
}
+13 -14
View File
@@ -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<String> {
.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<SessionTodoItem> {
let content = item.content.trim().to_string();
fn map_session_todo_item(item: TaskBoardItem) -> Option<SessionTodoItem> {
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)
@@ -17,7 +17,7 @@ pub enum ToolRiskLevel {
Destructive,
}
/// 权限检查结果(对标 Claude Code 的 allow/deny/ask)
/// 权限检查结果(统一到当前工具面的 allow/deny/ask 语义)
#[derive(Debug, Clone, PartialEq)]
pub enum PermissionBehavior {
Allow,
@@ -397,7 +397,7 @@ fn page_info_from_event(event: &BrowserEvent) -> Option<BrowserPageInfo> {
async fn capture_and_sync_page_info(session: &CdpSessionHandle) -> Option<BrowserPageInfo> {
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
@@ -103,6 +103,22 @@ struct HostAliasRule {
providers: Vec<String>,
}
#[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<ApiProviderType>,
) -> 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<ApiProviderType>,
) -> Option<String> {
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<ApiProviderType>,
) -> Result<PreparedModelFetchRequest, ModelsApiError> {
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<Vec<ApiModelResponse>, ModelsApiError> {
provider_type: Option<ApiProviderType>,
) -> Result<(Vec<ApiModelResponse>, 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<String, ModelsApiError> {
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<Vec<ApiModelResponse>, 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<Vec<ApiModelResponse>, ModelsApiError> {
let mut models = Vec::new();
let mut after_id: Option<String> = 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<AnthropicModelsResponse, ModelsApiError> {
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<Vec<ApiModelResponse>, ModelsApiError> {
let mut models = Vec::new();
let mut next_page_token: Option<String> = 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<GeminiModelsResponse, ModelsApiError> {
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<Vec<ApiModelResponse>, 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<String>,
display_name: Option<String>,
#[serde(default, alias = "owned_by")]
provider_name: Option<String>,
#[serde(default)]
family: Option<String>,
#[serde(default)]
context_length: Option<u32>,
}
#[derive(Debug)]
struct AnthropicModelsResponse {
models: Vec<ApiModelResponse>,
has_more: bool,
last_id: Option<String>,
}
#[derive(Debug, Deserialize)]
struct RawAnthropicModelsResponse {
#[serde(default)]
data: Vec<RawAnthropicModelResponse>,
#[serde(default)]
has_more: bool,
#[serde(default)]
last_id: Option<String>,
}
#[derive(Debug, Deserialize)]
struct RawAnthropicModelResponse {
id: String,
#[serde(default)]
display_name: Option<String>,
}
#[derive(Debug)]
struct GeminiModelsResponse {
models: Vec<ApiModelResponse>,
next_page_token: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RawGeminiModelsResponse {
#[serde(default)]
models: Vec<RawGeminiModelResponse>,
#[serde(default)]
next_page_token: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RawGeminiModelResponse {
name: String,
#[serde(default)]
display_name: Option<String>,
#[serde(default)]
input_token_limit: Option<u32>,
#[serde(default)]
supported_generation_methods: Option<Vec<String>>,
}
#[derive(Debug, Deserialize)]
struct OllamaModelsResponse {
#[serde(default)]
models: Vec<OllamaModelResponse>,
}
#[derive(Debug, Deserialize)]
struct OllamaModelResponse {
name: String,
#[serde(default)]
details: Option<OllamaModelDetails>,
}
#[derive(Debug, Deserialize)]
struct OllamaModelDetails {
#[serde(default)]
family: Option<String>,
}
/// 模型获取来源
#[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"));
}
}
+7 -16
View File
@@ -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"
+22
View File
@@ -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::<crate::services::companion_service::CompanionServiceState>()
.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,
@@ -1469,6 +1469,10 @@ pub struct AgentRuntimeSpawnSubagentRequest {
#[serde(alias = "parentSessionId")]
pub parent_session_id: String,
pub message: String,
#[serde(default)]
pub name: Option<String>,
#[serde(default, alias = "teamName")]
pub team_name: Option<String>,
#[serde(default, alias = "agentType")]
pub agent_type: Option<String>,
#[serde(default)]
@@ -1499,6 +1503,8 @@ pub struct AgentRuntimeSpawnSubagentRequest {
pub system_overlay: Option<String>,
#[serde(default, alias = "outputContract")]
pub output_contract: Option<String>,
#[serde(default)]
pub cwd: Option<String>,
}
#[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(
@@ -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,
@@ -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(
@@ -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<String>,
) -> Result<std::path::PathBuf, String> {
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<String> {
}
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<String> {
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<String>,
) -> 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());
}
}
@@ -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");
@@ -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::<Result<std::collections::BTreeMap<_, _>, _>>()?;
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(
+28
View File
@@ -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<CompanionPetStatus, String> {
companion_service::get_pet_status_global(companion_state.inner()).await
}
#[tauri::command]
pub async fn companion_launch_pet(
companion_state: State<'_, CompanionServiceState>,
request: Option<CompanionLaunchPetRequest>,
) -> Result<CompanionLaunchPetResult, String> {
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<CompanionPetSendResult, String> {
companion_service::send_pet_command_global(companion_state.inner(), request).await
}
+1
View File
@@ -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;
+25 -19
View File
@@ -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<Vec<String>, 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<String> = 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
+17 -2
View File
@@ -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<AsyncMutex<()>> =
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"));
+10
View File
@@ -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);
}
@@ -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<dyn std::error::Error>;
pub(super) async fn try_handle(
state: &DevBridgeState,
cmd: &str,
args: Option<&JsonValue>,
) -> Result<Option<JsonValue>, 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::<crate::app::AppState>();
let automation_state =
app_handle.state::<crate::services::automation_service::AutomationServiceState>();
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::<crate::commands::automation_cmd::AutomationJobRequest>(
&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::<lime_core::config::TaskSchedule>(
&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::<lime_core::config::TaskSchedule>(
&args_or_default(args),
"schedule",
)?;
serde_json::to_value(
crate::commands::automation_cmd::validate_automation_schedule(schedule).await?,
)?
}
_ => unreachable!("已通过前置 matches! 过滤 automation 命令"),
};
Ok(Some(result))
}
@@ -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<dyn std::error::Error>;
pub(super) async fn try_handle(
state: &DevBridgeState,
cmd: &str,
args: Option<&JsonValue>,
) -> Result<Option<JsonValue>, 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::<crate::services::companion_service::CompanionServiceState>();
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))
}
+2 -35
View File
@@ -4,32 +4,11 @@ use serde_json::Value as JsonValue;
type DynError = Box<dyn std::error::Error>;
fn load_model_registry_provider_ids_from_db(
state: &DevBridgeState,
) -> Result<Vec<String>, 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<Option<JsonValue>, 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),
};
+782
View File
@@ -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<String>,
pub platform: Option<String>,
pub capabilities: Vec<String>,
pub last_event: Option<String>,
pub last_error: Option<String>,
pub last_state: Option<CompanionPetVisualState>,
}
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<String>,
pub endpoint: Option<String>,
pub client_id: Option<String>,
pub protocol_version: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CompanionLaunchPetResult {
pub launched: bool,
pub resolved_path: Option<String>,
pub endpoint: String,
pub message: Option<String>,
}
#[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<String>,
platform: Option<String>,
#[serde(default)]
capabilities: Vec<String>,
}
#[derive(Debug, Clone)]
struct ActivePetSender {
connection_id: String,
tx: mpsc::UnboundedSender<String>,
}
#[derive(Debug, Clone, Default)]
struct CompanionRuntime {
status: CompanionPetStatus,
active_connection_id: Option<String>,
}
#[derive(Clone)]
struct CompanionRouterState {
app_handle: AppHandle,
service: CompanionServiceState,
}
#[derive(Clone)]
pub struct CompanionServiceState {
app_handle: Arc<RwLock<Option<AppHandle>>>,
runtime: Arc<RwLock<CompanionRuntime>>,
sender: Arc<Mutex<Option<ActivePetSender>>>,
start_lock: Arc<Mutex<()>>,
}
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<CompanionLaunchPetResult, String> {
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<CompanionPetSendResult, String> {
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<F>(&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<String>) {
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<String>) {
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::<CompanionIncomingEnvelope>(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::<CompanionReadyPayload>(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<CompanionPetVisualState> {
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<LaunchTarget> {
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<PathBuf> {
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<PathBuf> {
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<PathBuf> {
Vec::new()
}
#[cfg(target_os = "macos")]
fn normalize_launch_target(path: PathBuf) -> Option<LaunchTarget> {
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<LaunchTarget> {
path.exists().then_some(LaunchTarget { exec_path: path })
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
fn normalize_launch_target(path: PathBuf) -> Option<LaunchTarget> {
path.exists().then_some(LaunchTarget { exec_path: path })
}
async fn companion_pet_ws(
ws: WebSocketUpgrade,
State(router_state): State<CompanionRouterState>,
) -> 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::<String>();
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<CompanionPetStatus, String> {
Ok(state.snapshot().await)
}
pub async fn launch_pet_global(
state: &CompanionServiceState,
request: CompanionLaunchPetRequest,
) -> Result<CompanionLaunchPetResult, String> {
state.launch_pet(request).await
}
pub async fn send_pet_command_global(
state: &CompanionServiceState,
request: CompanionPetCommandRequest,
) -> Result<CompanionPetSendResult, String> {
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::<String>();
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);
}
}
+1
View File
@@ -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;
+1
View File
@@ -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");
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+4
View File
@@ -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) => {
@@ -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("请先去浏览器工作台连接真实浏览器"),
@@ -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),
@@ -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",
@@ -65,16 +65,56 @@ export function enableSubagentPreference(preferences: ChatToolPreferences): {
};
}
function applyClawSolutionPreferencePreset(
preferences: ChatToolPreferences,
preset: Partial<ChatToolPreferences>,
): {
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<ChatToolPreferences> {
const preset: Partial<ChatToolPreferences> = {};
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,
@@ -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: () => <div data-testid="empty-state-model-selector" />,
@@ -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 () => {
@@ -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 ? (
<EntryTaskContainer>
@@ -388,14 +381,6 @@ export function EmptyStateComposerPanel({
onContinueSingleAgent={handleContinueSingleAgent}
/>
) : null}
{shouldShowStableNotice ? (
<StableProcessingNotice
scope={subagentEnabled ? "team" : "request"}
className="mx-3 mb-2"
testId="empty-state-stable-processing-notice"
/>
) : null}
</>
) : undefined;
@@ -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 ? (
<StableProcessingNotice
scope={activeTools["subagent_mode"] ? "team" : "request"}
className="mx-3 mb-2"
testId="inputbar-stable-processing-notice"
/>
) : null}
{shouldShowVisionNotice ? (
<InputbarVisionCapabilityNotice
providerType={resolvedProviderType}
@@ -36,6 +36,8 @@ beforeEach(() => {
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();
@@ -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,
},
);
@@ -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("稳妥模式");
});
});
@@ -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<TeamSelectorPanelProps> = ({
activeTheme,
input,
workspaceId: _workspaceId,
providerType,
model,
providerType: _providerType,
model: _model,
executionStrategy: _executionStrategy,
selectedTeam = null,
onSelectTeam,
@@ -317,10 +315,6 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
const isProjectScopedCustomTeam = Boolean(
workspaceSettings && onPersistCustomTeams,
);
const shouldShowStableNotice = useStableProcessingNotice({
providerType,
model,
});
useEffect(() => {
setCustomTeams(resolveCustomTeams(workspaceSettings));
@@ -1032,13 +1026,6 @@ export const TeamSelectorPanel: React.FC<TeamSelectorPanelProps> = ({
</button>
) : null}
</div>
{shouldShowStableNotice ? (
<StableProcessingNotice
scope="team"
className="mt-3"
testId="team-selector-stable-processing-notice"
/>
) : null}
{selectedTeam ? (
<div
className="mt-3 rounded-2xl border border-slate-200 bg-slate-50 px-3 py-2.5"
@@ -133,7 +133,10 @@ describe("resolveClawWorkspaceProviderSelection", () => {
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",
);
});
});
@@ -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<EnhancedModelMetadata[]> {
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<ClawWorkspaceProviderSelection | null> {
@@ -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,
@@ -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<Message["runtimeStatus"]>;
}
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,
},
];
}
@@ -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(() => {
@@ -33,6 +33,16 @@ const mockEnsureBrowserAssistCanvas = vi.fn(async () => true);
const mockHandleAutoLaunchMatchedSiteSkill = vi.fn(async () => undefined);
const mockHandleImageWorkbenchCommand = vi.fn(async () => false);
function createDeferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((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<void>();
mockSendMessage.mockImplementationOnce(async () => deferredSend.promise);
const harness = mountHook({
input: "帮我找一下今天的新闻",
});
let sendPromise: Promise<boolean> | 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<string>();
const deferredSend = createDeferred<void>();
mockSendMessage.mockImplementationOnce(async () => deferredSend.promise);
const harness = mountHook({
input: "帮我整理一下今天的重要新闻",
contextWorkspace: {
enabled: true,
activeContextPrompt: "",
prepareActiveContextPrompt: async () => deferredContext.promise,
},
});
let sendPromise: Promise<boolean> | 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 项目",
@@ -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<SubmissionPreviewSnapshot | null>(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,
};
}
@@ -46,6 +46,10 @@ vi.mock("@/lib/api/apiKeyProvider", () => ({
},
}));
vi.mock("@/lib/dev-bridge", () => ({
safeInvoke: mockInvoke,
}));
vi.mock("@tauri-apps/api/core", () => ({
invoke: mockInvoke,
}));
@@ -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,
}),
);
});
+15 -1
View File
@@ -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<ModelSelectorProps> = ({
(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(() => {
@@ -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();
@@ -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<ProviderModelSelectorProps> = ({
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(
() =>
@@ -349,14 +349,15 @@ export const ProviderPoolPage = forwardRef<
const currentCredentials = currentPool?.credentials || [];
return (
<div className="space-y-6">
<div className="space-y-4">
{!hideHeader && (
<div className="flex items-center justify-between">
<div className="rounded-[24px] border border-slate-200/80 bg-white p-5 shadow-sm shadow-slate-950/5">
<div>
<h2 className="text-2xl font-bold">凭证池</h2>
<p className="text-muted-foreground text-sm">
管理多个 AI 服务凭证,自动轮询负载均衡,并为本机默认 Provider
提供稳定的凭证来源
<h2 className="text-xl font-semibold text-slate-900">
Provider 与凭证
</h2>
<p className="mt-2 text-sm leading-6 text-slate-600">
默认先管理 API Key Provider。OAuth、语音和中转服务保留在同一入口,但不再和日常 Provider 配置抢同一视觉焦点。
</p>
</div>
</div>
@@ -369,42 +370,42 @@ export const ProviderPoolPage = forwardRef<
)}
{/* Category Tabs - 第一行:分类选择 */}
<div className="flex gap-1 mb-2">
<div className="flex flex-wrap gap-2">
<button
onClick={() => {
setActiveCategory("apikey");
}}
className={`px-4 py-2 text-sm font-medium rounded-lg border transition-colors ${
className={`rounded-xl border px-4 py-2 text-sm font-medium transition-colors ${
activeCategory === "apikey"
? "border-primary bg-primary/10 text-primary"
: "border-border bg-card text-muted-foreground hover:text-foreground hover:bg-muted"
? "border-slate-300 bg-white text-slate-950 shadow-sm"
: "border-slate-200 bg-slate-50 text-slate-600 hover:border-slate-300 hover:bg-white hover:text-slate-900"
}`}
data-testid="apikey-category-tab"
>
API Key
服务商
</button>
<button
onClick={() => {
setActiveCategory("connect");
setActiveTab("connect");
}}
className={`px-4 py-2 text-sm font-medium rounded-lg border transition-colors ${
className={`rounded-xl border px-4 py-2 text-sm font-medium transition-colors ${
activeCategory === "connect"
? "border-primary bg-primary/10 text-primary"
: "border-border bg-card text-muted-foreground hover:text-foreground hover:bg-muted"
? "border-slate-300 bg-white text-slate-950 shadow-sm"
: "border-slate-200 bg-slate-50 text-slate-600 hover:border-slate-300 hover:bg-white hover:text-slate-900"
}`}
data-testid="connect-category-tab"
>
Connect
中转服务
</button>
<button
onClick={() => {
setActiveCategory("voice");
}}
className={`px-4 py-2 text-sm font-medium rounded-lg border transition-colors ${
className={`rounded-xl border px-4 py-2 text-sm font-medium transition-colors ${
activeCategory === "voice"
? "border-primary bg-primary/10 text-primary"
: "border-border bg-card text-muted-foreground hover:text-foreground hover:bg-muted"
? "border-slate-300 bg-white text-slate-950 shadow-sm"
: "border-slate-200 bg-slate-50 text-slate-600 hover:border-slate-300 hover:bg-white hover:text-slate-900"
}`}
data-testid="voice-category-tab"
>
@@ -415,10 +416,10 @@ export const ProviderPoolPage = forwardRef<
setActiveCategory("oauth");
setActiveTab(oauthProviderTypes[0]);
}}
className={`px-4 py-2 text-sm font-medium rounded-lg border transition-colors ${
className={`rounded-xl border px-4 py-2 text-sm font-medium transition-colors ${
activeCategory === "oauth"
? "border-primary bg-primary/10 text-primary"
: "border-border bg-card text-muted-foreground hover:text-foreground hover:bg-muted"
? "border-slate-300 bg-white text-slate-950 shadow-sm"
: "border-slate-200 bg-slate-50 text-slate-600 hover:border-slate-300 hover:bg-white hover:text-slate-900"
}`}
data-testid="oauth-category-tab"
>
@@ -435,12 +436,12 @@ export const ProviderPoolPage = forwardRef<
return (
<button
key={providerType}
onClick={() => setActiveTab(providerType)}
title={providerLabels[providerType]}
className={`group relative flex items-center justify-center gap-2 min-w-[120px] px-3 py-2 rounded-lg border transition-all ${
onClick={() => setActiveTab(providerType)}
title={providerLabels[providerType]}
className={`group relative flex min-w-[120px] items-center justify-center gap-2 rounded-xl border px-3 py-2 transition-all ${
isActive
? "border-primary bg-primary/10 text-primary shadow-sm"
: "border-border bg-card hover:border-primary/50 hover:bg-muted text-muted-foreground hover:text-foreground"
? "border-slate-300 bg-white text-slate-950 shadow-sm"
: "border-slate-200 bg-slate-50 text-slate-600 hover:border-slate-300 hover:bg-white hover:text-slate-900"
}`}
data-testid={`oauth-provider-${providerType}`}
>
@@ -476,7 +477,7 @@ export const ProviderPoolPage = forwardRef<
{/* API Key 分类 - 左右分栏布局 */}
{activeCategory === "apikey" && (
<div
className="h-[calc(100vh-280px)] min-h-[400px]"
className="h-[calc(100vh-240px)] min-h-[620px]"
data-testid="apikey-section"
>
<ApiKeyProviderSection
File diff suppressed because it is too large Load Diff
@@ -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(
<AddCustomProviderModal
isOpen
onClose={vi.fn()}
onAdd={vi.fn().mockResolvedValue({ id: "provider-001" })}
onAddApiKey={vi.fn().mockResolvedValue(undefined)}
/>,
);
});
mountedRoots.push({ container, root });
}
async function settleModal() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
function findByTestId<T extends Element>(testId: string): T {
const element = document.querySelector<T>(`[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<HTMLButtonElement>("known-provider-item-google").click();
});
const nameInput = findByTestId<HTMLInputElement>("provider-name-input");
const hostInput = findByTestId<HTMLInputElement>("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<HTMLButtonElement>("known-provider-item-zhipu").click();
});
const nameInput = findByTestId<HTMLInputElement>("provider-name-input");
const hostInput = findByTestId<HTMLInputElement>("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<HTMLButtonElement>("known-provider-item-codex-cli").click();
});
const nameInput = findByTestId<HTMLInputElement>("provider-name-input");
const hostInput = findByTestId<HTMLInputElement>("api-host-input");
const providerTypeSelect = findByTestId<HTMLElement>("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();
});
});
@@ -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<void>;
/** 切换 API Key 启用状态回调 */
@@ -106,6 +112,8 @@ export interface ApiKeyListProps {
export const ApiKeyList: React.FC<ApiKeyListProps> = ({
apiKeys,
providerId,
providerName,
apiHost,
onAdd,
onToggle,
onDelete,
@@ -119,22 +127,47 @@ export const ApiKeyList: React.FC<ApiKeyListProps> = ({
const [isAdding, setIsAdding] = useState(false);
const [error, setError] = useState<string | null>(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<ApiKeyListProps> = ({
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<ApiKeyListProps> = ({
};
return (
<div className={cn("space-y-3", className)} data-testid="api-key-list">
{/* 标题和添加按钮 */}
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium text-foreground">
API Keys ({apiKeys.length})
</h4>
{!showAddForm && (
<Button
variant="outline"
size="sm"
onClick={() => setShowAddForm(true)}
disabled={loading}
data-testid="add-api-key-button"
>
<PlusIcon className="mr-1" />
添加
</Button>
)}
<div
className={cn(
"rounded-[24px] border border-slate-200/80 bg-white p-4 shadow-sm shadow-slate-950/5",
className,
)}
data-testid="api-key-list"
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex items-center gap-2">
<h4 className="text-base font-semibold text-slate-900">API Key</h4>
{providerAccessHelp.helpText || providerAccessHelp.keylessHint ? (
<SectionInfoButton
label="查看 API Key 获取说明"
triggerTestId="provider-api-key-info-button"
contentTestId="provider-api-key-info-content"
>
<p
data-testid={
providerAccessHelp.keylessHint
? "provider-api-key-keyless-hint"
: "provider-api-key-help-text"
}
>
{providerAccessHelp.keylessHint ?? providerAccessHelp.helpText}
</p>
{providerAccessHelp.url ? (
<a
href={providerAccessHelp.url}
target="_blank"
rel="noreferrer"
className="mt-2 inline-flex font-semibold text-slate-900 underline underline-offset-4"
data-testid="provider-api-key-help-link"
>
前往获取
</a>
) : null}
</SectionInfoButton>
) : null}
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="inline-flex rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-xs text-slate-600">
已启用 {enabledCount}
</span>
{apiKeys.length > 0 ? (
<span className="inline-flex rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-xs text-slate-600">
最近调用 {latestUsedLabel}
</span>
) : null}
{errorCount > 0 ? (
<span className="inline-flex rounded-full border border-rose-200 bg-rose-50 px-3 py-1 text-xs text-rose-700">
错误 {errorCount}
</span>
) : null}
{!showAddForm && (
<Button
variant="outline"
size="sm"
onClick={() => setShowAddForm(true)}
disabled={loading}
className="shrink-0 whitespace-nowrap border-slate-200 bg-white px-3"
data-testid="add-api-key-button"
>
<PlusIcon className="mr-1" />
新增 API Key
</Button>
)}
</div>
</div>
{/* 添加表单 */}
{showAddForm && (
<div
className="p-3 rounded-lg border border-border bg-muted/20 space-y-3"
data-testid="add-api-key-form"
>
{/* API Key 输入 */}
<div className="space-y-1.5">
<Label htmlFor="new-api-key" className="text-xs">
API Key <span className="text-red-500">*</span>
</Label>
<div className="relative">
<Input
id="new-api-key"
type={showApiKey ? "text" : "password"}
value={newApiKey}
onChange={(e) => setNewApiKey(e.target.value)}
placeholder="输入 API Key"
className="pr-10"
{showAddForm ? (
<div
className="mt-4 rounded-[20px] border border-slate-200 bg-slate-50/80 p-4"
data-testid="add-api-key-form"
>
<div
className="space-y-4"
data-testid="add-api-key-fields-stack"
>
<div className="min-w-0 space-y-1.5">
<Label htmlFor="new-api-key" className="text-xs font-medium">
API Key <span className="text-red-500">*</span>
</Label>
<div className="relative">
<Input
id="new-api-key"
type={showApiKey ? "text" : "password"}
value={newApiKey}
onChange={(e) => 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"
/>
<button
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-900"
onClick={() => setShowApiKey(!showApiKey)}
tabIndex={-1}
>
{showApiKey ? <EyeSlashIcon /> : <EyeIcon />}
</button>
</div>
</div>
<div className="min-w-0 space-y-1.5">
<Label htmlFor="new-alias" className="text-xs font-medium">
别名(可选)
</Label>
<Input
id="new-alias"
type="text"
value={newAlias}
onChange={(e) => setNewAlias(e.target.value)}
placeholder="例如:生产主账号"
disabled={isAdding}
className="border-slate-200 bg-white"
autoComplete="off"
data-testid="new-alias-input"
/>
</div>
</div>
{error ? (
<p className="mt-3 text-xs text-red-500" data-testid="add-error">
{error}
</p>
) : null}
<div className="mt-4 flex justify-end gap-2">
<Button
variant="ghost"
size="sm"
onClick={handleCancel}
disabled={isAdding}
data-testid="new-api-key-input"
/>
<button
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setShowApiKey(!showApiKey)}
tabIndex={-1}
>
{showApiKey ? <EyeSlashIcon /> : <EyeIcon />}
</button>
取消
</Button>
<Button
size="sm"
onClick={handleAdd}
disabled={isAdding || !newApiKey.trim()}
data-testid="confirm-add-button"
>
{isAdding ? "添加中..." : "确认添加"}
</Button>
</div>
</div>
) : null}
{/* 别名输入 */}
<div className="space-y-1.5">
<Label htmlFor="new-alias" className="text-xs">
别名(可选)
</Label>
<Input
id="new-alias"
type="text"
value={newAlias}
onChange={(e) => setNewAlias(e.target.value)}
placeholder="例如:主账号、测试账号"
disabled={isAdding}
data-testid="new-alias-input"
/>
{apiKeys.length > 0 ? (
<div className="mt-4 space-y-3" data-testid="api-key-items">
{apiKeys.map((apiKey) => (
<ApiKeyItem
key={apiKey.id}
apiKey={apiKey}
onToggle={onToggle}
onDelete={onDelete}
loading={loading}
className="rounded-[18px] border border-slate-200/80 bg-slate-50 px-4 py-3 hover:bg-slate-100/80"
/>
))}
</div>
{/* 错误提示 */}
{error && (
<p className="text-xs text-red-500" data-testid="add-error">
{error}
</p>
)}
{/* 操作按钮 */}
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="sm"
onClick={handleCancel}
disabled={isAdding}
) : (
!showAddForm && (
<div
className="mt-4 rounded-[20px] border border-dashed border-slate-200 bg-slate-50/60 px-4 py-10 text-center"
data-testid="empty-state"
>
取消
</Button>
<Button
size="sm"
onClick={handleAdd}
disabled={isAdding || !newApiKey.trim()}
data-testid="confirm-add-button"
>
{isAdding ? "添加中..." : "确认添加"}
</Button>
</div>
</div>
)}
{/* API Key 列表 */}
{apiKeys.length > 0 ? (
<div className="space-y-2" data-testid="api-key-items">
{apiKeys.map((apiKey) => (
<ApiKeyItem
key={apiKey.id}
apiKey={apiKey}
onToggle={onToggle}
onDelete={onDelete}
loading={loading}
/>
))}
</div>
) : (
!showAddForm && (
<div
className="py-8 text-center text-sm text-muted-foreground"
data-testid="empty-state"
>
<p>暂无 API Key</p>
<p className="mt-1 text-xs">
点击上方「添加」按钮添加第一个 API Key
</p>
</div>
)
)}
<p className="text-sm font-medium text-slate-900">暂无 API Key</p>
<p className="mt-1 text-xs text-slate-500">
先新增第一把 Key,之后再做连接测试与模型刷新
</p>
</div>
)
)}
</div>
);
};
@@ -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> = {}): 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<ComponentProps<typeof ApiKeyList>> = {},
) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
const mergedProps: ComponentProps<typeof ApiKeyList> = {
apiKeys: [],
providerId: "zhipu",
providerName: "智谱 AI",
apiHost: "https://open.bigmodel.cn/api/paas/v4/",
...props,
};
act(() => {
root.render(<ApiKeyList {...mergedProps} />);
});
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<HTMLButtonElement>(
'[data-testid="provider-api-key-info-button"]',
);
expect(infoButton).not.toBeNull();
act(() => {
infoButton?.click();
});
const helpLink = document.querySelector<HTMLAnchorElement>(
'[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<HTMLButtonElement>(
'[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<HTMLButtonElement>(
'[data-testid="add-api-key-button"]',
);
expect(button).not.toBeNull();
expect(button?.className).toContain("whitespace-nowrap");
});
});
@@ -247,14 +247,11 @@ export const ApiKeyProviderSection = forwardRef<
return (
<div
className={cn(
"relative flex h-full overflow-hidden rounded-[28px] border border-emerald-200/70 bg-[linear-gradient(135deg,rgba(244,251,248,0.96)_0%,rgba(248,250,252,0.98)_42%,rgba(241,246,255,0.96)_100%)] shadow-sm shadow-slate-950/5",
"relative flex h-full overflow-hidden rounded-[24px] border border-slate-200/80 bg-white shadow-sm shadow-slate-950/5",
className,
)}
data-testid="api-key-provider-section"
>
<div className="pointer-events-none absolute left-[240px] top-[-84px] h-64 w-64 rounded-full bg-emerald-200/25 blur-3xl" />
<div className="pointer-events-none absolute right-[-72px] top-[-32px] h-72 w-72 rounded-full bg-sky-200/22 blur-3xl" />
{/* 左侧:Provider 列表 */}
<ProviderList
providersByGroup={providersByGroup}
@@ -266,12 +263,11 @@ export const ApiKeyProviderSection = forwardRef<
onToggleGroup={toggleGroup}
onAddCustomProvider={onAddCustomProvider}
onImportExport={() => setShowImportExportDialog(true)}
className="flex-shrink-0 bg-card"
className="flex-shrink-0 bg-slate-50/80"
/>
{/* 右侧:Provider 设置面板 */}
<div className="relative flex-1 min-w-0 overflow-hidden bg-[linear-gradient(180deg,rgba(248,250,252,0.78),rgba(241,245,249,0.42))]">
<div className="pointer-events-none absolute inset-0 bg-[linear-gradient(to_right,rgba(148,163,184,0.06)_1px,transparent_1px),linear-gradient(to_bottom,rgba(148,163,184,0.06)_1px,transparent_1px)] bg-[size:26px_26px] opacity-70" />
<div className="relative flex-1 min-w-0 overflow-hidden bg-white">
<ProviderSetting
provider={selectedProvider}
onUpdate={handleUpdateProvider}
@@ -36,16 +36,19 @@ import type { EnhancedModelMetadata } from "@/lib/types/modelRegistry";
import type { ConfiguredProvider } from "@/hooks/useConfiguredProviders";
import { useProviderModels } from "@/hooks/useProviderModels";
import { resolveRegistryProviderId } from "./providerTypeMapping";
import { getProviderModelAutoFetchCapability } from "@/lib/model/providerModelFetchSupport";
import {
dedupeModelIds,
getProviderTypeLabel,
getSpecialProtocolHint,
getLatestSelectableModel,
getProviderTypeLabel,
parseCustomModelsValue,
PROVIDER_TYPE_FIELDS,
PROVIDER_TYPE_OPTIONS,
serializeCustomModels,
} from "./ProviderConfigForm.utils";
import { Plus, Star, X } from "lucide-react";
import { Plus, Save, Star, X } from "lucide-react";
import { SectionInfoButton } from "./SectionInfoButton";
// ============================================================================
// 常量
@@ -72,15 +75,6 @@ const FIELD_PLACEHOLDERS: Record<string, string> = {
region: "us-east-1",
};
/** 字段帮助文本映射 */
const FIELD_HELP_TEXT: Record<string, string> = {
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<FormState>({
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<ConfiguredProvider>(
() => ({
@@ -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 (
<div
className={cn("space-y-5", className)}
className={cn(
"rounded-[24px] border border-slate-200/80 bg-white p-4 shadow-sm shadow-slate-950/5",
className,
)}
data-testid="provider-config-form"
>
<div className="space-y-1.5">
<Label htmlFor="provider-type" className="text-sm font-medium">
Provider 类型
</Label>
<Select
value={formState.providerType}
onValueChange={(value) =>
handleFieldChange("providerType", value as ProviderType)
}
disabled={loading || isSaving}
>
<SelectTrigger id="provider-type" data-testid="provider-type-select">
<span>{getProviderTypeLabel(formState.providerType)}</span>
</SelectTrigger>
<SelectContent>
{PROVIDER_TYPE_OPTIONS.map((type) => (
<SelectItem key={type.value} value={type.value}>
{type.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
选择 API 协议类型,不同类型使用不同的请求格式
</p>
</div>
{/* API Host 字段(所有 Provider 都有) */}
<div className="space-y-1.5">
<Label htmlFor="api-host" className="text-sm font-medium">
{FIELD_LABELS.apiHost}
</Label>
<Input
id="api-host"
type="text"
value={formState.apiHost}
onChange={(e) => handleFieldChange("apiHost", e.target.value)}
placeholder={FIELD_PLACEHOLDERS.apiHost}
disabled={loading || isSaving}
data-testid="api-host-input"
/>
<p className="text-xs text-muted-foreground">
{FIELD_HELP_TEXT.apiHost}
</p>
</div>
{/* Azure OpenAI: API Version */}
{extraFields.includes("apiVersion") && (
<div className="space-y-1.5">
<Label htmlFor="api-version" className="text-sm font-medium">
{FIELD_LABELS.apiVersion}
</Label>
<Input
id="api-version"
type="text"
value={formState.apiVersion}
onChange={(e) => handleFieldChange("apiVersion", e.target.value)}
placeholder={FIELD_PLACEHOLDERS.apiVersion}
disabled={loading || isSaving}
data-testid="api-version-input"
/>
<p className="text-xs text-muted-foreground">
{FIELD_HELP_TEXT.apiVersion}
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex items-center gap-2">
<p className="text-base font-semibold text-slate-900">
协议与默认模型
</p>
</div>
)}
{/* VertexAI: Project */}
{extraFields.includes("project") && (
<div className="space-y-1.5">
<Label htmlFor="project" className="text-sm font-medium">
{FIELD_LABELS.project}
</Label>
<Input
id="project"
type="text"
value={formState.project}
onChange={(e) => handleFieldChange("project", e.target.value)}
placeholder={FIELD_PLACEHOLDERS.project}
disabled={loading || isSaving}
data-testid="project-input"
/>
<p className="text-xs text-muted-foreground">
{FIELD_HELP_TEXT.project}
</p>
</div>
)}
{/* VertexAI: Location */}
{extraFields.includes("location") && (
<div className="space-y-1.5">
<Label htmlFor="location" className="text-sm font-medium">
{FIELD_LABELS.location}
</Label>
<Input
id="location"
type="text"
value={formState.location}
onChange={(e) => handleFieldChange("location", e.target.value)}
placeholder={FIELD_PLACEHOLDERS.location}
disabled={loading || isSaving}
data-testid="location-input"
/>
<p className="text-xs text-muted-foreground">
{FIELD_HELP_TEXT.location}
</p>
</div>
)}
{/* AWS Bedrock: Region */}
{extraFields.includes("region") && (
<div className="space-y-1.5">
<Label htmlFor="region" className="text-sm font-medium">
{FIELD_LABELS.region}
</Label>
<Input
id="region"
type="text"
value={formState.region}
onChange={(e) => handleFieldChange("region", e.target.value)}
placeholder={FIELD_PLACEHOLDERS.region}
disabled={loading || isSaving}
data-testid="region-input"
/>
<p className="text-xs text-muted-foreground">
{FIELD_HELP_TEXT.region}
</p>
</div>
)}
{/* 自定义模型列表 */}
<div className="rounded-2xl border border-border/80 bg-card p-4 shadow-sm">
<div className="space-y-4">
<div className="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
<div className="space-y-1">
<Label
htmlFor="custom-model-draft"
className="text-sm font-medium"
>
自定义模型
</Label>
<p className="text-xs text-muted-foreground">
手动添加模型后会保留在这里;默认模型请在右侧“模型能力”列表中点击选择。
<SectionInfoButton
label="查看协议与默认模型说明"
triggerTestId="provider-config-info-button"
>
<p>
这里维护服务商名称、接口地址、协议类型和默认模型,保存会自动防抖提交。
</p>
<p className="mt-2">
{provider.is_system
? "官方供应商固定使用原生协议。"
: "兼容协议主要用于自定义接入;官方供应商继续保持各自原生协议。"}
</p>
{specialProtocolHint ? (
<p className="mt-2" data-testid="protocol-special-hint">
{specialProtocolHint}
</p>
</div>
</div>
) : null}
</SectionInfoButton>
</div>
<div className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-xs text-slate-600">
<Save className="h-3.5 w-3.5" />
<span>
{isSaving
? "保存中..."
: saveError
? "保存失败"
: lastSaved
? formatLastSaved(lastSaved)
: "修改后自动保存"}
</span>
</div>
</div>
<input
id="custom-models"
type="hidden"
value={formState.customModels}
readOnly
/>
<div className="rounded-xl border border-border/70 bg-muted/20 p-3">
<div className="flex min-h-[56px] flex-wrap gap-2">
{selectedModels.length > 0 ? (
selectedModels.map((modelId, index) => {
const isLatest = recommendedLatestModel?.id === modelId;
return (
<div
key={modelId}
className="inline-flex items-center gap-1.5 rounded-full border border-border/70 bg-background px-2 py-1 text-xs normal-case"
>
<span className="max-w-[220px] truncate normal-case">
{modelId}
</span>
{index === 0 ? (
<Badge variant="secondary">默认</Badge>
) : null}
{isLatest ? (
<Badge variant="outline">最新</Badge>
) : null}
{index > 0 ? (
<Button
type="button"
variant="ghost"
size="icon"
className="h-5 w-5 rounded-full"
onClick={() => setDefaultModel(modelId)}
title="设为默认模型"
>
<Star className="h-3 w-3" />
</Button>
) : null}
<Button
type="button"
variant="ghost"
size="icon"
className="h-5 w-5 rounded-full"
onClick={() => handleRemoveModel(modelId)}
title="移除模型"
>
<X className="h-3 w-3" />
</Button>
</div>
);
})
) : (
<p className="text-xs text-muted-foreground">
尚未选择模型。检测到可用模型后,系统会默认填入最新模型。
</p>
)}
</div>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<div className="mt-4 grid gap-3 lg:grid-cols-2">
{!provider.is_system ? (
<div className="space-y-1.5">
<Label htmlFor="provider-name" className="text-sm font-medium">
Provider 名称
</Label>
<Input
id="custom-model-draft"
type="text"
className="normal-case bg-background"
value={modelDraft}
onChange={(e) => 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"
/>
<Button
type="button"
variant="outline"
onClick={handleAddModelDraft}
disabled={loading || isSaving || !modelDraft.trim()}
className="sm:min-w-[88px]"
>
<Plus className="mr-1 h-4 w-4" />
添加
</Button>
</div>
) : null}
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span>
第一个模型会作为默认模型,用于测试与默认请求;若未显式选择,则自动使用最新模型。
</span>
{!provider.is_system ? (
<div className="space-y-1.5">
<Label htmlFor="provider-type" className="text-sm font-medium">
Provider 类型
</Label>
<Select
value={formState.providerType}
onValueChange={(value) =>
handleFieldChange("providerType", value as ProviderType)
}
disabled={loading || isSaving}
>
<SelectTrigger
id="provider-type"
className="border-slate-200 bg-white"
data-testid="provider-type-select"
>
<span>{getProviderTypeLabel(formState.providerType)}</span>
</SelectTrigger>
<SelectContent>
{PROVIDER_TYPE_OPTIONS.map((type) => (
<SelectItem key={type.value} value={type.value}>
{type.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : (
<div className="space-y-1.5">
<Label className="text-sm font-medium">Provider 类型</Label>
<div className="rounded-[16px] border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-700">
{getProviderTypeLabel(formState.providerType)}
</div>
</div>
)}
<div
className={cn(
"space-y-1.5",
provider.is_system ? "lg:col-span-2" : "",
)}
>
<Label htmlFor="api-host" className="text-sm font-medium">
{FIELD_LABELS.apiHost}
</Label>
<Input
id="api-host"
type="text"
value={formState.apiHost}
onChange={(e) => handleFieldChange("apiHost", e.target.value)}
placeholder={FIELD_PLACEHOLDERS.apiHost}
disabled={loading || isSaving}
className="border-slate-200 bg-white"
data-testid="api-host-input"
/>
</div>
{extraFieldConfigs.map((config) => {
if (!config) {
return null;
}
return (
<div key={config.field} className="space-y-1.5">
<Label htmlFor={config.id} className="text-sm font-medium">
{config.label}
</Label>
<Input
id={config.id}
type="text"
value={config.value}
onChange={(e) =>
handleFieldChange(config.field, e.target.value)
}
placeholder={config.placeholder}
disabled={loading || isSaving}
className="border-slate-200 bg-white"
data-testid={config.testId}
/>
</div>
);
})}
</div>
<div className="mt-5 border-t border-slate-200 pt-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex items-center gap-2">
<p className="text-sm font-semibold text-slate-900">默认模型</p>
<SectionInfoButton
label="查看默认模型说明"
triggerTestId="provider-default-model-info-button"
>
<p>
第一个模型会被视为默认模型。支持实时拉取的渠道,只有在拿到真实模型目录后才会展示当前模型与推荐最新模型。
</p>
</SectionInfoButton>
</div>
<div className="flex flex-wrap gap-2">
<Badge
variant="outline"
className="border-slate-200 bg-slate-50 text-slate-600"
>
当前:
{defaultModelId ??
(requiresLiveModelTruth ? "待读取真实目录" : "待指定")}
</Badge>
{recommendedLatestModel ? (
<span>
当前推荐最新模型:
<span className="font-medium normal-case">
{recommendedLatestModel.id}
</span>
</span>
<Badge
variant="outline"
className="border-sky-200 bg-sky-50 text-sky-700"
>
推荐最新:{recommendedLatestModel.id}
</Badge>
) : null}
</div>
{localModelsError ? (
<p className="text-xs text-amber-600">{localModelsError}</p>
) : null}
{localModelsLoading ? (
<p className="text-xs text-muted-foreground">
正在加载模型列表...
</p>
) : null}
</div>
</div>
{/* 保存状态指示 */}
<div className="flex items-center justify-between text-xs">
<input
id="custom-models"
type="hidden"
value={formState.customModels}
readOnly
/>
{modelTruthNotice ? (
<div
className={cn(
"mt-4 rounded-[18px] border px-4 py-3 text-sm leading-6",
modelTruthNotice.tone === "amber"
? "border-amber-200 bg-amber-50 text-amber-900"
: "border-slate-200 bg-slate-50 text-slate-700",
)}
data-testid="provider-model-truth-notice"
>
<p className="font-semibold">{modelTruthNotice.title}</p>
<p className="mt-1">{modelTruthNotice.description}</p>
</div>
) : null}
<div className="mt-4 flex min-h-[64px] flex-wrap gap-2">
{visibleSelectedModels.length > 0 ? (
visibleSelectedModels.map((modelId, index) => {
const isLatest = recommendedLatestModel?.id === modelId;
return (
<div
key={modelId}
className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-slate-50 px-3 py-1.5 text-xs"
>
<span className="max-w-[220px] truncate normal-case text-slate-900">
{modelId}
</span>
{index === 0 ? (
<Badge className="bg-slate-900 text-white hover:bg-slate-900">
默认
</Badge>
) : null}
{isLatest ? <Badge variant="outline">最新</Badge> : null}
{index > 0 ? (
<Button
type="button"
variant="ghost"
size="icon"
className="h-5 w-5 rounded-full text-slate-500 hover:text-slate-900"
onClick={() => setDefaultModel(modelId)}
title="设为默认模型"
>
<Star className="h-3 w-3" />
</Button>
) : null}
<Button
type="button"
variant="ghost"
size="icon"
className="h-5 w-5 rounded-full text-slate-500 hover:text-slate-900"
onClick={() => handleRemoveModel(modelId)}
title="移除模型"
>
<X className="h-3 w-3" />
</Button>
</div>
);
})
) : (
<div className="rounded-[18px] border border-dashed border-slate-200 bg-slate-50 px-4 py-4 text-sm text-slate-500">
{hiddenSelectedModelCount > 0
? `已保存 ${hiddenSelectedModelCount} 个模型配置,待读取真实模型目录后再展示。`
: requiresLiveModelTruth
? "读取到真实模型目录后,才会展示当前模型与推荐最新模型。"
: "尚未指定模型。读取到模型目录后会自动填入推荐最新模型。"}
</div>
)}
</div>
<div className="mt-4 flex flex-col gap-2 sm:flex-row">
<Input
id="custom-model-draft"
type="text"
className="normal-case border-slate-200 bg-white"
value={modelDraft}
onChange={(e) => 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"
/>
<Button
type="button"
variant="outline"
onClick={handleAddModelDraft}
disabled={
loading ||
isSaving ||
shouldLockModelEditor ||
!modelDraft.trim()
}
className="border-slate-200 bg-white sm:min-w-[112px]"
>
<Plus className="mr-1 h-4 w-4" />
添加模型
</Button>
</div>
{localModelsError ? (
<p className="mt-3 text-xs text-amber-600">{localModelsError}</p>
) : null}
{localModelsLoading ? (
<p className="mt-3 text-xs text-slate-500">正在加载模型列表...</p>
) : null}
{saveError ? (
<p className="mt-3 text-xs text-red-500" data-testid="save-error">
{saveError}
</p>
) : null}
{lastSaved && !isSaving && !saveError ? (
<p
className="mt-3 text-xs text-emerald-600"
data-testid="save-success"
>
{formatLastSaved(lastSaved)}
</p>
) : null}
{isSaving ? (
<span
className="text-muted-foreground"
<p
className="mt-3 text-xs text-slate-500"
data-testid="saving-indicator"
>
保存中...
</span>
) : saveError ? (
<span className="text-red-500" data-testid="save-error">
{saveError}
</span>
) : lastSaved ? (
<span className="text-green-600" data-testid="save-success">
{formatLastSaved(lastSaved)}
</span>
) : (
<span />
)}
</p>
) : null}
</div>
</div>
);
@@ -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<HTMLButtonElement>(
'[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<HTMLButtonElement>(
@@ -119,7 +157,7 @@ describe("ProviderConfigForm", () => {
expect(onUpdate).toHaveBeenCalledTimes(1);
expect(onUpdate).toHaveBeenCalledWith(
"openai",
"custom-openai",
expect.objectContaining({
type: "openai",
}),
@@ -46,6 +46,17 @@ export const PROVIDER_TYPE_FIELDS: Record<ProviderType, string[]> = {
gateway: [],
};
const SPECIAL_PROVIDER_PROTOCOL_HINTS: Partial<Record<ProviderType, string>> = {
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<string>();
const result: string[] = [];
@@ -108,25 +108,30 @@ export const ProviderList: React.FC<ProviderListProps> = ({
return (
<div
className={cn(
"flex flex-col h-full w-60 border-r border-border bg-background",
"flex h-full w-[280px] flex-col border-r border-slate-200/80 bg-slate-50/80",
className,
)}
data-testid="provider-list"
>
{/* 搜索框 */}
<div className="p-3 border-b border-border">
<div className="border-b border-slate-200/80 px-4 py-4">
<div className="mb-3">
<p className="text-sm font-semibold text-slate-900">服务商列表</p>
<p className="text-xs text-slate-500">
已加载 {totalProviders} 个 Provider
</p>
</div>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400" />
<input
type="text"
placeholder="搜索 Provider..."
placeholder="搜索服务商或协议..."
value={searchQuery}
onChange={(e) => 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<ProviderListProps> = ({
{/* Provider 分组列表 */}
<div
className="flex-1 overflow-y-auto p-2"
className="flex-1 overflow-y-auto px-3 py-3"
data-testid="provider-groups-container"
>
{totalProviders === 0 ? (
<div className="flex flex-col items-center justify-center h-32 text-muted-foreground text-sm">
<div className="flex h-32 flex-col items-center justify-center text-sm text-slate-500">
<p>未找到 Provider</p>
{searchQuery && <p className="text-xs mt-1">尝试其他搜索词</p>}
</div>
@@ -166,17 +171,17 @@ export const ProviderList: React.FC<ProviderListProps> = ({
{/* 添加自定义 Provider 按钮 */}
{(onAddCustomProvider || onImportExport) && (
<div className="p-3 border-t border-border space-y-2">
<div className="space-y-2 border-t border-slate-200/80 px-4 py-4">
{onAddCustomProvider && (
<button
type="button"
onClick={onAddCustomProvider}
className={cn(
"flex items-center justify-center gap-2 w-full px-3 py-2 rounded-lg",
"text-sm font-medium text-primary",
"bg-primary/10 hover:bg-primary/20",
"flex w-full items-center justify-center gap-2 rounded-xl px-3 py-2",
"text-sm font-medium text-slate-900",
"bg-white hover:bg-slate-100 border border-slate-200",
"transition-colors",
"focus:outline-none focus:ring-2 focus:ring-primary/20",
"focus:outline-none focus:ring-2 focus:ring-slate-200",
)}
data-testid="add-custom-provider-button"
>
@@ -189,11 +194,11 @@ export const ProviderList: React.FC<ProviderListProps> = ({
type="button"
onClick={onImportExport}
className={cn(
"flex items-center justify-center gap-2 w-full px-3 py-2 rounded-lg",
"text-sm font-medium text-muted-foreground",
"bg-muted/50 hover:bg-muted",
"flex w-full items-center justify-center gap-2 rounded-xl px-3 py-2",
"text-sm font-medium text-slate-600",
"bg-slate-100 hover:bg-slate-200",
"transition-colors",
"focus:outline-none focus:ring-2 focus:ring-muted/20",
"focus:outline-none focus:ring-2 focus:ring-slate-200",
)}
data-testid="import-export-button"
>
@@ -6,20 +6,29 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { mockUseModelRegistry } = vi.hoisted(() => ({
mockUseModelRegistry: vi.fn(),
}));
const { mockGetSystemProviderCatalog, mockGetModelRegistryProviderIds } =
vi.hoisted(() => ({
mockGetSystemProviderCatalog: vi.fn(async () => []),
mockGetModelRegistryProviderIds: vi.fn(async () => ["openai"]),
}));
const { mockFetchProviderModelsAuto } = vi.hoisted(() => ({
mockFetchProviderModelsAuto: vi.fn(),
}));
vi.mock("@/hooks/useModelRegistry", () => ({
useModelRegistry: (...args: unknown[]) => mockUseModelRegistry(...args),
useModelRegistry: mockUseModelRegistry,
}));
vi.mock("@/lib/api/apiKeyProvider", () => ({
apiKeyProviderApi: {
getSystemProviderCatalog: vi.fn(async () => []),
getSystemProviderCatalog: mockGetSystemProviderCatalog,
},
}));
vi.mock("@/lib/api/modelRegistry", () => ({
fetchProviderModelsAuto: mockFetchProviderModelsAuto,
modelRegistryApi: {
getModelRegistryProviderIds: vi.fn(async () => ["openai"]),
getModelRegistryProviderIds: mockGetModelRegistryProviderIds,
},
}));
@@ -27,7 +36,10 @@ vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn(),
}));
import { ProviderModelList } from "./ProviderModelList";
import {
ProviderModelList,
} from "./ProviderModelList";
import { isProviderModelsCacheExpired } from "./providerModelListCache";
interface MountedRoot {
root: Root;
@@ -100,6 +112,13 @@ beforeEach(() => {
loading: false,
error: null,
});
mockGetSystemProviderCatalog.mockResolvedValue([]);
mockGetModelRegistryProviderIds.mockResolvedValue(["openai"]);
mockFetchProviderModelsAuto.mockResolvedValue({
models: [],
source: "Api",
error: null,
});
});
afterEach(() => {
@@ -116,7 +135,10 @@ afterEach(() => {
describe("ProviderModelList", () => {
it("应展示思考与多模态能力标签", async () => {
const container = renderProviderModelList();
const container = renderProviderModelList({
providerId: "openai",
providerType: "azure-openai",
});
await act(async () => {
await Promise.resolve();
});
@@ -124,4 +146,147 @@ describe("ProviderModelList", () => {
expect(container.textContent).toContain("无思考");
expect(container.textContent).toContain("支持多模态");
});
it("官方供应商即使未配置 Key 也应显式展示获取最新模型按钮", async () => {
const container = renderProviderModelList({
providerId: "gemini",
providerType: "gemini",
hasApiKey: false,
});
await act(async () => {
await Promise.resolve();
});
const button = Array.from(container.querySelectorAll("button")).find(
(item) => item.textContent?.includes("获取最新模型"),
);
expect(button).not.toBeUndefined();
expect((button as HTMLButtonElement).disabled).toBe(true);
});
it("支持实时拉模型的渠道在无 Key 时不应展示 registry 旧模型", async () => {
const container = renderProviderModelList({
providerId: "openai",
providerType: "openai",
hasApiKey: false,
});
await act(async () => {
await Promise.resolve();
});
expect(container.textContent ?? "").toContain("请先添加可用 API Key");
expect(container.textContent ?? "").not.toContain("GPT-4.1");
});
it("Ollama 在未配置 Key 时也应允许直接获取最新模型", async () => {
const container = renderProviderModelList({
providerId: "ollama",
providerType: "ollama",
hasApiKey: false,
apiHost: "http://localhost:11434",
});
await act(async () => {
await Promise.resolve();
});
const button = Array.from(container.querySelectorAll("button")).find(
(item) => item.textContent?.includes("获取最新模型"),
);
expect(button).not.toBeUndefined();
expect((button as HTMLButtonElement).disabled).toBe(false);
});
it("获取最新模型按钮应保持单行,避免被工具栏挤压换行", async () => {
const container = renderProviderModelList({
providerId: "deepseek",
providerType: "openai",
hasApiKey: true,
});
await act(async () => {
await Promise.resolve();
});
const button = Array.from(container.querySelectorAll("button")).find(
(item) => item.textContent?.includes("获取最新模型"),
) as HTMLButtonElement | undefined;
expect(button).not.toBeUndefined();
expect(button?.className).toContain("whitespace-nowrap");
expect(button?.className).toContain("shrink-0");
});
it("窄容器时工具栏应默认纵向堆叠,避免标题被压成逐字换行", async () => {
const container = renderProviderModelList({
providerId: "openai",
providerType: "azure-openai",
hasApiKey: true,
});
await act(async () => {
await Promise.resolve();
});
const toolbar = container.querySelector<HTMLElement>(
'[data-testid="provider-model-list-toolbar"]',
);
const actions = container.querySelector<HTMLElement>(
'[data-testid="provider-model-list-actions"]',
);
expect(toolbar?.className).toContain("flex-col");
expect(toolbar?.className).toContain("2xl:flex-row");
expect(actions?.className).toContain("w-full");
expect(actions?.className).toContain("2xl:min-w-[360px]");
});
it("暂不支持自动获取的云端协议不应再展示获取最新模型按钮", async () => {
const container = renderProviderModelList({
providerId: "azure-openai",
providerType: "azure-openai",
hasApiKey: true,
});
await act(async () => {
await Promise.resolve();
});
const button = Array.from(container.querySelectorAll("button")).find(
(item) => item.textContent?.includes("获取最新模型"),
);
expect(button).toBeUndefined();
expect(container.textContent).toContain("当前不展示自动获取入口");
});
it("模型真相源异常时应显式展示错误,不再伪装成正常空态", async () => {
mockGetModelRegistryProviderIds.mockRejectedValueOnce(
new Error("未找到 models index.json"),
);
const container = renderProviderModelList();
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(container.textContent).toContain("模型真相源异常");
expect(container.textContent).toContain("未找到 models index.json");
});
it("Provider 模型缓存应在 TTL 后过期", () => {
expect(isProviderModelsCacheExpired(1_000, 1_000)).toBe(false);
expect(
isProviderModelsCacheExpired(1_000, 1_000 + 5 * 60 * 1000 - 1),
).toBe(false);
expect(
isProviderModelsCacheExpired(1_000, 1_000 + 5 * 60 * 1000),
).toBe(true);
});
});
@@ -10,13 +10,16 @@ import { useModelRegistry } from "@/hooks/useModelRegistry";
import {
Sparkles,
Check,
AlertCircle,
Loader2,
RefreshCw,
Cloud,
HardDrive,
Search,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import {
Tooltip,
TooltipContent,
@@ -36,6 +39,11 @@ import {
resolveRegistryProviderId,
} from "./providerTypeMapping";
import { getLatestSelectableModel } from "./ProviderConfigForm.utils";
import { getProviderModelAutoFetchCapability } from "@/lib/model/providerModelFetchSupport";
import {
buildProviderModelsCacheKey,
isProviderModelsCacheExpired,
} from "./providerModelListCache";
// ============================================================================
// 类型定义
@@ -56,6 +64,8 @@ export interface ProviderModelListProps {
onLatestModelResolved?: (modelId: string | null) => void;
/** 是否有可用的 API Key(用于显示刷新按钮) */
hasApiKey?: boolean;
/** 当前 Provider 的 API Host */
apiHost?: string;
/** 额外的 CSS 类名 */
className?: string;
/** 最大显示数量,默认显示全部 */
@@ -73,6 +83,7 @@ interface CachedProviderModels {
requestUrl: string | null;
diagnosticHint: string | null;
shouldPromptError: boolean;
cachedAt: number;
}
const providerModelsCache = new Map<string, CachedProviderModels>();
@@ -122,11 +133,13 @@ const ModelItem: React.FC<ModelItemProps> = ({
return (
<div
className={cn(
"flex items-start justify-between gap-3 rounded-lg border px-3 py-2 transition-colors",
onSelect ? "cursor-pointer hover:bg-muted/50" : "hover:bg-muted/50",
"group flex items-start justify-between gap-3 rounded-[18px] border px-4 py-3 transition-colors",
onSelect
? "cursor-pointer hover:border-slate-300 hover:bg-slate-50"
: "hover:bg-slate-50",
isDefault
? "border-primary/30 bg-primary/5"
: "border-transparent bg-transparent",
? "border-slate-900 bg-slate-900/[0.03]"
: "border-slate-200/80 bg-white",
)}
onClick={onSelect ? () => onSelect(model.id) : undefined}
role={onSelect ? "button" : undefined}
@@ -145,21 +158,33 @@ const ModelItem: React.FC<ModelItemProps> = ({
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium truncate">
<span className="truncate text-sm font-medium">
{model.display_name}
</span>
{isLatest ? <Badge variant="outline">最新</Badge> : null}
{isDefault ? <Badge variant="secondary">默认</Badge> : null}
</div>
<div className="text-xs text-muted-foreground truncate">{model.id}</div>
<div className="truncate text-xs text-muted-foreground">{model.id}</div>
<ModelCapabilityBadges
capabilities={model.capabilities}
className="mt-2"
/>
</div>
<div className="flex items-center gap-1.5 pt-1">
{isDefault ? <Check className="h-3.5 w-3.5 text-primary" /> : null}
<div className="flex shrink-0 items-center gap-2 pt-1">
{isDefault ? <Check className="h-3.5 w-3.5 text-slate-900" /> : null}
{onSelect ? (
<span
className={cn(
"rounded-full border px-2.5 py-1 text-[11px] font-medium transition-colors",
isDefault
? "border-slate-900 bg-slate-900 text-white"
: "border-slate-200 bg-white text-slate-600 group-hover:border-slate-300 group-hover:text-slate-900",
)}
>
{isDefault ? "当前默认" : "设为默认"}
</span>
) : null}
</div>
</div>
);
@@ -187,15 +212,38 @@ export const ProviderModelList: React.FC<ProviderModelListProps> = ({
onSelectModel,
onLatestModelResolved,
hasApiKey = false,
apiHost,
className,
maxItems,
}) => {
const autoFetchCapability = useMemo(
() =>
getProviderModelAutoFetchCapability({
providerId,
providerType,
apiHost,
}),
[apiHost, providerId, providerType],
);
const canRefreshFromApi =
autoFetchCapability.supported &&
(!autoFetchCapability.requiresApiKey || hasApiKey);
const refreshTooltipText = autoFetchCapability.supported
? autoFetchCapability.requiresApiKey && !hasApiKey
? "先配置可用 API Key 后才能获取最新模型"
: "自动获取最新模型列表"
: (autoFetchCapability.unsupportedReason ??
"当前协议暂不支持自动获取最新模型");
const [searchQuery, setSearchQuery] = useState("");
const [catalogAliasMap, setCatalogAliasMap] = useState<Record<
string,
string
> | null>(null);
const [validRegistryProviderIds, setValidRegistryProviderIds] =
useState<Set<string> | null>(null);
const [registryTruthError, setRegistryTruthError] = useState<string | null>(
null,
);
useEffect(() => {
let cancelled = false;
@@ -234,10 +282,15 @@ export const ProviderModelList: React.FC<ProviderModelListProps> = ({
}
setValidRegistryProviderIds(new Set(providerIds));
} catch {
setRegistryTruthError(null);
} catch (error) {
if (cancelled) {
return;
}
setRegistryTruthError(
error instanceof Error ? error.message : String(error),
);
setValidRegistryProviderIds(null);
}
};
@@ -278,7 +331,11 @@ export const ProviderModelList: React.FC<ProviderModelListProps> = ({
null,
);
const [apiShouldPromptError, setApiShouldPromptError] = useState(false);
const cacheKey = `${providerId}:${providerType}`;
const cacheKey = buildProviderModelsCacheKey({
providerId,
providerType,
apiHost,
});
useEffect(() => {
const cached = providerModelsCache.get(cacheKey);
@@ -292,16 +349,34 @@ export const ProviderModelList: React.FC<ProviderModelListProps> = ({
return;
}
if (
isProviderModelsCacheExpired(cached.cachedAt) ||
(autoFetchCapability.requiresApiKey && !hasApiKey)
) {
providerModelsCache.delete(cacheKey);
setApiModels(null);
setApiSource(null);
setApiError(null);
setApiRequestUrl(null);
setApiDiagnosticHint(null);
setApiShouldPromptError(false);
return;
}
setApiModels(cached.models);
setApiSource(cached.source);
setApiError(cached.error);
setApiRequestUrl(cached.requestUrl);
setApiDiagnosticHint(cached.diagnosticHint);
setApiShouldPromptError(cached.shouldPromptError);
}, [cacheKey]);
}, [autoFetchCapability.requiresApiKey, cacheKey, hasApiKey]);
// 从 API 获取模型列表(自动获取 API Key)
const handleRefreshFromApi = useCallback(async () => {
if (!autoFetchCapability.supported) {
return;
}
setRefreshing(true);
setApiError(null);
setApiRequestUrl(null);
@@ -313,8 +388,12 @@ export const ProviderModelList: React.FC<ProviderModelListProps> = ({
await fetchProviderModelsAuto(providerId);
if (result && result.models) {
setApiModels(result.models);
setApiSource(result.source);
const shouldDisplayFetchedModels =
result.source === "Api" && result.models.length > 0;
const nextModels = shouldDisplayFetchedModels ? result.models : [];
setApiModels(nextModels);
setApiSource(shouldDisplayFetchedModels ? "Api" : null);
setApiRequestUrl(result.request_url ?? null);
setApiDiagnosticHint(result.diagnostic_hint ?? null);
setApiShouldPromptError(Boolean(result.should_prompt_error));
@@ -324,41 +403,73 @@ export const ProviderModelList: React.FC<ProviderModelListProps> = ({
setApiError(null);
}
providerModelsCache.set(cacheKey, {
models: result.models,
source: result.source ?? null,
error: result.error ?? null,
requestUrl: result.request_url ?? null,
diagnosticHint: result.diagnostic_hint ?? null,
shouldPromptError: Boolean(result.should_prompt_error),
});
if (shouldDisplayFetchedModels) {
providerModelsCache.set(cacheKey, {
models: nextModels,
source: "Api",
error: result.error ?? null,
requestUrl: result.request_url ?? null,
diagnosticHint: result.diagnostic_hint ?? null,
shouldPromptError: Boolean(result.should_prompt_error),
cachedAt: Date.now(),
});
} else {
providerModelsCache.delete(cacheKey);
}
} else {
setApiError("返回结果格式错误");
}
} catch (err) {
setApiError(err instanceof Error ? err.message : String(err));
providerModelsCache.delete(cacheKey);
} finally {
setRefreshing(false);
}
}, [cacheKey, providerId]);
}, [autoFetchCapability.supported, cacheKey, providerId]);
// 使用 API 模型或本地模型
const displayModelsSource = apiModels ?? models;
const displayModelsSource = useMemo(
() =>
autoFetchCapability.supported
? (apiModels ?? [])
: registryTruthError
? []
: models,
[apiModels, autoFetchCapability.supported, models, registryTruthError],
);
const filteredModelsSource = useMemo(() => {
if (!searchQuery.trim()) {
return displayModelsSource;
}
const query = searchQuery.trim().toLowerCase();
return displayModelsSource.filter(
(model) =>
model.display_name.toLowerCase().includes(query) ||
model.id.toLowerCase().includes(query),
);
}, [displayModelsSource, searchQuery]);
const apiDiagnosticLines = buildApiDiagnosticLines({
error: apiError,
request_url: apiRequestUrl,
diagnostic_hint: apiDiagnosticHint,
});
const registryDiagnosticLines = registryTruthError
? [
"模型真相源异常:无法校验当前 Provider 是否存在于内置 registry。",
registryTruthError,
]
: [];
// 限制显示数量
const displayModels = useMemo(() => {
if (maxItems && maxItems > 0) {
return displayModelsSource.slice(0, maxItems);
return filteredModelsSource.slice(0, maxItems);
}
return displayModelsSource;
}, [displayModelsSource, maxItems]);
return filteredModelsSource;
}, [filteredModelsSource, maxItems]);
const hasMore = maxItems && displayModelsSource.length > maxItems;
const hasMore = maxItems && filteredModelsSource.length > maxItems;
const resolvedLatestModelId =
latestModelId ?? getLatestSelectableModel(displayModelsSource)?.id ?? null;
const effectiveDefaultModelId = selectedModelId ?? resolvedLatestModelId;
@@ -402,9 +513,12 @@ export const ProviderModelList: React.FC<ProviderModelListProps> = ({
if (displayModelsSource.length === 0) {
return (
<div className={cn("space-y-2", className)}>
<div className="flex items-center justify-between mb-2">
<div className="space-y-1">
<h4 className="text-sm font-medium text-foreground flex items-center gap-2">
<div
className="mb-2 flex flex-col gap-3 2xl:flex-row 2xl:items-center 2xl:justify-between"
data-testid="provider-model-list-empty-toolbar"
>
<div className="min-w-0 space-y-1">
<h4 className="flex flex-wrap items-center gap-2 text-sm font-medium text-foreground">
<Sparkles className="h-4 w-4 text-muted-foreground" />
支持的模型
</h4>
@@ -414,46 +528,93 @@ export const ProviderModelList: React.FC<ProviderModelListProps> = ({
</p>
) : null}
</div>
{hasApiKey && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={handleRefreshFromApi}
disabled={refreshing}
className="h-7 px-2"
>
{refreshing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>从 API 获取模型列表</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<div className="flex w-full min-w-0 flex-col gap-2 sm:flex-row sm:items-center 2xl:w-auto 2xl:min-w-[320px]">
<div className="relative min-w-0 flex-1 sm:min-w-[200px]">
<Search className="absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400" />
<Input
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
placeholder="搜索模型"
className="h-8 border-slate-200 bg-white pl-8 text-xs"
/>
</div>
{autoFetchCapability.supported ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={handleRefreshFromApi}
disabled={refreshing || !canRefreshFromApi}
className="h-8 shrink-0 whitespace-nowrap border border-slate-200 bg-white px-3"
>
{refreshing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="mr-1 h-3.5 w-3.5" />
)}
获取最新模型
</Button>
</TooltipTrigger>
<TooltipContent>{refreshTooltipText}</TooltipContent>
</Tooltip>
</TooltipProvider>
) : null}
</div>
</div>
<div
className="py-4 text-center text-sm text-muted-foreground"
data-testid="provider-model-list-empty"
>
暂无模型数据
{hasApiKey && (
{registryTruthError
? "模型真相源异常"
: autoFetchCapability.supported
? autoFetchCapability.requiresApiKey && !hasApiKey
? "请先添加可用 API Key"
: apiError
? "当前未读取到可用模型"
: "尚未获取模型目录"
: "暂无模型数据"}
{!registryTruthError && autoFetchCapability.supported && (
<Button
variant="ghost"
size="sm"
onClick={handleRefreshFromApi}
disabled={refreshing}
className="ml-1 h-auto p-0 text-primary underline-offset-4 hover:underline"
disabled={refreshing || !canRefreshFromApi}
className="ml-1 h-auto p-0 text-primary underline-offset-4 hover:underline disabled:pointer-events-auto disabled:cursor-not-allowed disabled:opacity-60"
>
点击从 API 获取
{canRefreshFromApi
? "点击获取最新模型"
: autoFetchCapability.requiresApiKey
? "配置 API Key 后可获取最新模型"
: "当前暂不可自动获取最新模型"}
</Button>
)}
</div>
{!registryTruthError &&
!autoFetchCapability.supported &&
autoFetchCapability.unsupportedReason ? (
<div className="rounded-md border border-slate-200 bg-slate-50 px-3 py-2 text-left text-xs text-slate-600">
{autoFetchCapability.unsupportedReason}
</div>
) : null}
{registryTruthError && (
<div
className="rounded-md border border-rose-200 bg-rose-50/90 px-3 py-2 text-left text-xs text-rose-700"
data-testid="provider-model-list-registry-error"
>
<div className="mb-1 flex items-center gap-1.5 font-semibold">
<AlertCircle className="h-3.5 w-3.5" />
<span>请先修复内置模型索引</span>
</div>
{registryDiagnosticLines.map((line) => (
<div key={line} className="break-all leading-5">
{line}
</div>
))}
</div>
)}
{apiError && (
<div className="rounded-md border border-amber-200 bg-amber-50/80 px-3 py-2 text-left text-xs text-amber-700 dark:border-amber-900/60 dark:bg-amber-950/20 dark:text-amber-300">
{apiShouldPromptError ? (
@@ -474,75 +635,96 @@ export const ProviderModelList: React.FC<ProviderModelListProps> = ({
return (
<div
className={cn("space-y-1", className)}
className={cn("space-y-3", className)}
data-testid="provider-model-list"
>
{/* 标题 */}
<div className="flex items-center justify-between mb-2">
<h4 className="text-sm font-medium text-foreground flex items-center gap-2">
<Sparkles className="h-4 w-4 text-muted-foreground" />
支持的模型
<span className="text-xs text-muted-foreground font-normal">
({displayModelsSource.length})
</span>
{/* 数据来源标识 */}
{apiSource && (
<div
className="flex flex-col gap-3 2xl:flex-row 2xl:items-start 2xl:justify-between"
data-testid="provider-model-list-toolbar"
>
<div className="min-w-0 space-y-1">
<h4 className="flex flex-wrap items-center gap-2 text-sm font-medium text-foreground">
<Sparkles className="h-4 w-4 text-muted-foreground" />
支持的模型
<span className="text-xs font-normal text-muted-foreground">
({filteredModelsSource.length})
</span>
{apiSource && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span
className={cn(
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs",
apiSource === "Api"
? "bg-emerald-100 text-emerald-700"
: "bg-amber-100 text-amber-700",
)}
>
{apiSource === "Api" ? (
<>
<Cloud className="h-3 w-3" />
API
</>
) : (
<>
<HardDrive className="h-3 w-3" />
本地
</>
)}
</span>
</TooltipTrigger>
<TooltipContent>
{apiSource === "Api"
? "数据来自 Provider API"
: "API 获取失败,使用本地数据"}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</h4>
<p className="text-xs text-muted-foreground">
支持搜索、刷新模型目录,并直接把任一模型设为默认模型。
</p>
</div>
<div
className="flex w-full min-w-0 flex-col gap-2 sm:flex-row sm:items-center 2xl:w-auto 2xl:min-w-[360px]"
data-testid="provider-model-list-actions"
>
<div className="relative min-w-0 flex-1 sm:min-w-[220px] 2xl:w-[280px]">
<Search className="absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400" />
<Input
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
placeholder="搜索模型名称或 ID"
className="h-8 border-slate-200 bg-white pl-8 text-xs"
/>
</div>
{autoFetchCapability.supported ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span
className={cn(
"inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded",
apiSource === "Api"
? "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400"
: "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400",
)}
<Button
variant="outline"
size="sm"
onClick={handleRefreshFromApi}
disabled={refreshing || !canRefreshFromApi}
className="h-8 shrink-0 whitespace-nowrap border-slate-200 bg-white px-3"
>
{apiSource === "Api" ? (
<>
<Cloud className="h-3 w-3" />
API
</>
{refreshing ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
) : (
<>
<HardDrive className="h-3 w-3" />
本地
</>
<RefreshCw className="mr-1 h-3.5 w-3.5" />
)}
</span>
获取最新模型
</Button>
</TooltipTrigger>
<TooltipContent>
{apiSource === "Api"
? "数据来自 Provider API"
: "API 获取失败,使用本地数据"}
</TooltipContent>
<TooltipContent>{refreshTooltipText}</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</h4>
{/* 刷新按钮 */}
{hasApiKey && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={handleRefreshFromApi}
disabled={refreshing}
className="h-7 px-2"
>
{refreshing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>从 API 获取最新模型列表</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
) : null}
</div>
</div>
{/* API 错误提示 */}
@@ -561,23 +743,52 @@ export const ProviderModelList: React.FC<ProviderModelListProps> = ({
</div>
)}
{/* 模型列表 */}
<div className="border rounded-md divide-y divide-border">
{displayModels.map((model) => (
<ModelItem
key={model.id}
model={model}
isDefault={effectiveDefaultModelKey === model.id.toLowerCase()}
isLatest={resolvedLatestModelKey === model.id.toLowerCase()}
onSelect={onSelectModel}
/>
))}
</div>
{registryTruthError ? (
<div
className="mb-2 rounded-md border border-rose-200 bg-rose-50/90 px-3 py-2 text-xs text-rose-700"
data-testid="provider-model-list-registry-error"
>
<div className="mb-1 flex items-center gap-1.5 font-semibold">
<AlertCircle className="h-3.5 w-3.5" />
<span>模型真相源异常</span>
</div>
{registryDiagnosticLines.map((line) => (
<div key={line} className="break-all leading-5">
{line}
</div>
))}
</div>
) : null}
{!autoFetchCapability.supported &&
autoFetchCapability.unsupportedReason ? (
<div className="rounded-md border border-slate-200 bg-slate-50 px-3 py-2 text-xs text-slate-600">
{autoFetchCapability.unsupportedReason}
</div>
) : null}
{filteredModelsSource.length === 0 ? (
<div className="rounded-[20px] border border-dashed border-slate-200 bg-slate-50 px-4 py-10 text-center text-sm text-slate-500">
{searchQuery ? "没有匹配的模型" : "暂无模型数据"}
</div>
) : (
<div className="space-y-3">
{displayModels.map((model) => (
<ModelItem
key={model.id}
model={model}
isDefault={effectiveDefaultModelKey === model.id.toLowerCase()}
isLatest={resolvedLatestModelKey === model.id.toLowerCase()}
onSelect={onSelectModel}
/>
))}
</div>
)}
{/* 显示更多提示 */}
{hasMore && (
<p className="text-xs text-muted-foreground text-center pt-2">
还有 {displayModelsSource.length - maxItems!} 个模型未显示
还有 {filteredModelsSource.length - maxItems!} 个模型未显示
</p>
)}
</div>
@@ -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<ProviderSettingProps> = ({
>(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<ProviderSettingProps> = ({
return (
<div
className={cn(
"flex h-full items-center justify-center bg-[linear-gradient(180deg,rgba(248,250,252,0.78),rgba(241,245,249,0.42))] px-6",
"flex h-full items-center justify-center bg-slate-50/60 px-6",
className,
)}
data-testid="provider-setting-empty"
>
<div className="w-full max-w-xl rounded-[28px] border border-emerald-200/70 bg-[linear-gradient(135deg,rgba(244,251,248,0.98)_0%,rgba(248,250,252,0.98)_48%,rgba(241,246,255,0.96)_100%)] p-8 text-center shadow-sm shadow-slate-950/5">
<p className="text-base font-semibold text-foreground">
请从左侧列表选择一个 Provider
</p>
<p className="mt-2 text-sm text-muted-foreground">
选择后可在此处集中管理 API Key、模型、连接测试与支持模型信息。
<div className="w-full max-w-2xl rounded-[28px] border border-slate-200/80 bg-white p-8 shadow-sm shadow-slate-950/5">
<div className="flex items-center gap-3 text-slate-900">
<Sparkles className="h-5 w-5" />
<p className="text-lg font-semibold">服务商配置工作台</p>
</div>
<p className="mt-3 text-sm leading-6 text-slate-500">
选择一个服务商后,这里只保留模型、密钥和必要配置,不再铺满整页说明。
</p>
</div>
</div>
@@ -204,35 +211,35 @@ export const ProviderSetting: React.FC<ProviderSettingProps> = ({
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<ProviderSettingProps> = ({
data-provider-id={provider.id}
>
<div className="flex-1 overflow-y-auto">
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6 p-5 lg:p-6">
{/* Provider 头部 */}
<div className="mx-auto flex w-full max-w-[1440px] flex-col gap-5 p-4 lg:p-6">
<section
className="relative overflow-hidden rounded-[28px] border border-emerald-200/70 bg-[linear-gradient(135deg,rgba(244,251,248,0.98)_0%,rgba(248,250,252,0.98)_45%,rgba(241,246,255,0.96)_100%)] p-5 shadow-sm shadow-slate-950/5"
className="rounded-[26px] border border-slate-200/80 bg-white px-5 py-5 shadow-sm shadow-slate-950/5"
data-testid="provider-header"
>
<div className="pointer-events-none absolute -left-16 top-[-64px] h-48 w-48 rounded-full bg-emerald-200/30 blur-3xl" />
<div className="pointer-events-none absolute right-[-52px] top-[-24px] h-48 w-48 rounded-full bg-sky-200/25 blur-3xl" />
<div className="flex flex-col gap-5 lg:flex-row lg:items-start lg:justify-between">
<div className="flex min-w-0 items-start gap-4">
<div className="flex flex-col gap-5 xl:flex-row xl:items-center xl:justify-between">
<div className="flex min-w-0 items-center gap-4">
<ProviderIcon
providerType={provider.id}
fallbackText={provider.name}
size={44}
size={52}
className="flex-shrink-0"
data-testid="provider-icon"
/>
<div className="min-w-0 flex-1 space-y-2">
<div className="min-w-0 space-y-2">
<div className="flex flex-wrap items-center gap-2">
<h3
className="min-w-0 truncate text-2xl font-semibold text-foreground"
className="min-w-0 truncate text-2xl font-semibold tracking-tight text-slate-900"
data-testid="provider-name"
>
{provider.name}
</h3>
<Badge variant="outline">
<Badge
variant="outline"
className="border-slate-200 bg-slate-50 text-slate-600"
>
{provider.is_system ? "系统预设" : "自定义 Provider"}
</Badge>
<Badge variant={provider.enabled ? "secondary" : "outline"}>
{provider.enabled ? "已启用" : "已禁用"}
<Badge
variant="outline"
className={cn(
"border",
provider.enabled
? "border-emerald-200 bg-emerald-50 text-emerald-700"
: "border-slate-200 bg-slate-50 text-slate-500",
)}
>
{provider.enabled ? "运行中" : "已停用"}
</Badge>
</div>
<p
className="text-sm text-muted-foreground"
<div
className="flex flex-wrap items-center gap-2 text-sm text-slate-500"
data-testid="provider-type"
>
类型: {getProviderTypeLabel(provider.type)}
</p>
<p className="break-all text-sm text-muted-foreground">
{provider.api_host}
</p>
<span>{getProviderTypeLabel(provider.type)}</span>
<span className="text-slate-300">/</span>
<span className="break-all">{providerHostLabel}</span>
</div>
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<div className="inline-flex items-center gap-3 rounded-xl border border-border/70 bg-muted/30 px-3 py-2">
<span className="text-sm text-muted-foreground">
{provider.enabled ? "已启用" : "已禁用"}
<Badge
variant="outline"
className="border-slate-200 bg-slate-50 text-slate-600"
>
密钥 {enabledApiKeyCount}
</Badge>
<Badge
variant="outline"
className="max-w-[240px] truncate border-slate-200 bg-slate-50 text-slate-600"
>
默认
{defaultModel ??
(requiresLiveModelTruth ? "模型待同步" : "未设置")}
</Badge>
<div className="inline-flex items-center gap-3 rounded-full border border-slate-200 bg-slate-50 px-4 py-2">
<span className="text-sm text-slate-600">
{provider.enabled ? "已启用" : "已停用"}
</span>
<Switch
checked={provider.enabled}
@@ -312,62 +340,90 @@ export const ProviderSetting: React.FC<ProviderSettingProps> = ({
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"
>
<Trash2 className="mr-1.5 h-4 w-4" />
删除
删除 Provider
</Button>
)}
</div>
</div>
<div className="mt-5 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
{summaryItems.map((item) => (
<div
key={item.label}
className="rounded-[22px] border border-white/90 bg-white/85 p-4 shadow-sm shadow-slate-950/5"
>
<p className="text-[11px] font-medium text-slate-500">
{item.label}
</p>
<p
className={cn(
"mt-2 font-semibold text-slate-900",
item.compact
? "break-all text-sm leading-5"
: "text-2xl leading-none",
)}
>
{item.value}
</p>
<p className="mt-2 line-clamp-2 text-xs text-slate-500">
{item.hint}
</p>
</div>
))}
</div>
</section>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.15fr)_minmax(320px,0.85fr)]">
<div className="space-y-6">
<section
className="rounded-2xl border border-border/80 bg-card p-5 shadow-sm"
data-testid="api-key-section"
<div
className="grid gap-5 xl:grid-cols-[minmax(0,1.7fr)_minmax(300px,340px)]"
data-testid="provider-setting-workbench-grid"
>
<section
className="rounded-[26px] border border-slate-200/80 bg-white p-5 shadow-sm shadow-slate-950/5"
data-testid="supported-models-section"
>
<div
className="mb-4 flex flex-wrap items-start justify-between gap-3"
data-testid="supported-models-header"
>
<div className="mb-4 space-y-1">
<h4 className="text-sm font-semibold text-foreground">
访问凭证
</h4>
<p className="text-xs text-muted-foreground">
管理当前 Provider 的 API Key、别名与启用状态。
</p>
<div className="flex min-w-0 items-center gap-2 text-slate-900">
<Bot className="h-4 w-4" />
<h4 className="text-base font-semibold">模型设置</h4>
<SectionInfoButton
label="查看模型设置说明"
triggerTestId="provider-models-info-button"
>
<p>
模型区只展示当前真实可用的目录。支持自动拉取的渠道会优先读取最新模型;读取失败前不会继续展示旧模型。
</p>
</SectionInfoButton>
</div>
<div className="flex flex-wrap gap-2">
<Badge
variant="outline"
className="border-slate-200 bg-slate-50 text-slate-600"
>
默认:
{defaultModel ??
(requiresLiveModelTruth ? "待读取" : "未设置")}
</Badge>
{showVerifiedModelState && recommendedLatestModelId ? (
<Badge
variant="outline"
className="border-sky-200 bg-sky-50 text-sky-700"
>
推荐最新:{recommendedLatestModelId}
</Badge>
) : null}
</div>
</div>
{modelStatusNotice ? (
<div className="mb-4 rounded-[18px] border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900">
{modelStatusNotice}
</div>
) : null}
<div className="rounded-[22px] border border-slate-200/80 bg-slate-50/60 p-4">
<ProviderModelList
providerId={provider.id}
providerType={provider.type}
selectedModelId={draftCustomModels[0] ?? null}
latestModelId={recommendedLatestModelId}
onSelectModel={handleSelectDefaultModel}
onLatestModelResolved={handleRecommendedLatestModelChange}
hasApiKey={enabledApiKeyCount > 0}
apiHost={provider.api_host}
/>
</div>
</section>
<div className="space-y-5">
<section data-testid="api-key-section">
<ApiKeyList
key={`${provider.id}-${provider.api_keys?.length || 0}`}
apiKeys={provider.api_keys || []}
providerId={provider.id}
providerName={provider.name}
apiHost={provider.api_host}
onAdd={onAddApiKey}
onToggle={onToggleApiKey}
onDelete={onDeleteApiKey}
@@ -375,18 +431,7 @@ export const ProviderSetting: React.FC<ProviderSettingProps> = ({
/>
</section>
<section
className="rounded-2xl border border-border/80 bg-card p-5 shadow-sm"
data-testid="config-section"
>
<div className="mb-4 space-y-1">
<h4 className="text-sm font-semibold text-foreground">
请求配置
</h4>
<p className="text-xs text-muted-foreground">
配置 API Host、协议类型与默认模型,表单会自动保存。
</p>
</div>
<section data-testid="config-section">
<ProviderConfigForm
ref={providerConfigFormRef}
provider={provider}
@@ -398,83 +443,68 @@ export const ProviderSetting: React.FC<ProviderSettingProps> = ({
loading={loading}
/>
</section>
</div>
<div className="space-y-6">
<section
className="rounded-2xl border border-border/80 bg-card p-5 shadow-sm"
className="rounded-[24px] border border-slate-200/80 bg-white p-4 shadow-sm shadow-slate-950/5"
data-testid="connection-test-section"
>
<div className="mb-4 space-y-1">
<h4 className="text-sm font-semibold text-foreground">
连接测试
</h4>
<p className="text-xs text-muted-foreground">
使用当前默认模型检查连接、鉴权与基础对话可用性。
</p>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2 text-slate-900">
<ShieldCheck className="h-4 w-4" />
<h4 className="text-base font-semibold">连接验证</h4>
<SectionInfoButton
label="查看连接验证说明"
triggerTestId="provider-connection-info-button"
>
<p>
连接验证会用当前默认模型检查鉴权、路由和最小对话是否可用。未启用 Provider、缺少可用 Key 或模型目录尚未同步时不会放行测试。
</p>
</SectionInfoButton>
</div>
<Badge
variant="outline"
className={cn(
"border",
connectionReady
? "border-emerald-200 bg-emerald-50 text-emerald-700"
: "border-slate-200 bg-slate-50 text-slate-500",
)}
>
{connectionReady ? "可测试" : "待就绪"}
</Badge>
</div>
<div className="rounded-xl border border-border/70 bg-muted/20 p-3 text-xs text-muted-foreground">
当前默认模型:
<span className="ml-1 font-medium text-foreground">
{defaultModel ?? "未设置"}
<div className="mt-4 rounded-[18px] border border-slate-200/80 bg-slate-50 px-4 py-3 text-sm text-slate-600">
默认模型:
<span className="ml-1 font-semibold text-slate-900">
{defaultModel ??
(requiresLiveModelTruth ? "待读取真实目录" : "未设置")}
</span>
</div>
<div className="mt-4 flex flex-col gap-3 sm:flex-row">
<div className="mt-4 flex flex-col gap-3">
<ConnectionTestButton
providerId={provider.id}
onTest={onTestConnection}
disabled={
loading ||
!provider.enabled ||
(provider.api_keys?.length ?? 0) === 0
}
className="flex-1"
disabled={loading || !connectionReady}
className="w-full"
/>
<Button
variant="outline"
size="sm"
className="sm:self-start"
disabled={
loading ||
!provider.enabled ||
(provider.api_keys?.length ?? 0) === 0 ||
!onTestChat
}
className="border-slate-200 bg-white"
disabled={loading || !connectionReady || !onTestChat}
onClick={() => setChatDialogOpen(true)}
>
对话测试
</Button>
</div>
{(provider.api_keys?.length ?? 0) === 0 && (
<p className="mt-3 text-xs text-muted-foreground">
请先添加 API Key 后再进行连接测试。
</p>
)}
</section>
<section
className="rounded-2xl border border-border/80 bg-card p-5 shadow-sm"
data-testid="supported-models-section"
>
<div className="mb-4 space-y-1">
<h4 className="text-sm font-semibold text-foreground">
模型能力
</h4>
<p className="text-xs text-muted-foreground">
展示当前 Provider 支持的模型,并支持从 API 主动刷新。
{connectionBlockHint ? (
<p className="mt-3 text-xs text-slate-500">
{connectionBlockHint}
</p>
</div>
<ProviderModelList
providerId={provider.id}
providerType={provider.type}
selectedModelId={draftCustomModels[0] ?? null}
latestModelId={recommendedLatestModelId}
onSelectModel={handleSelectDefaultModel}
onLatestModelResolved={handleRecommendedLatestModelChange}
hasApiKey={enabledApiKeyCount > 0}
/>
) : null}
</section>
</div>
</div>
@@ -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) => (
<div data-testid="provider-config-form-stub">协议配置表单</div>
)),
}));
vi.mock("./ProviderModelList", () => ({
ProviderModelList: () => (
<div data-testid="provider-model-list-stub">模型列表</div>
),
}));
vi.mock("./ConnectionTestButton", () => ({
ConnectionTestButton: () => (
<div data-testid="connection-test-button-stub">连接测试按钮</div>
),
}));
import { ProviderSetting } from "./ProviderSetting";
interface MountedRoot {
container: HTMLDivElement;
root: Root;
}
const mountedRoots: MountedRoot[] = [];
function createProvider(
overrides: Partial<ProviderWithKeysDisplay> = {},
): 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(
<ProviderSetting provider={provider} onDeleteProvider={vi.fn()} />,
);
});
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<HTMLElement>(
'[data-testid="provider-setting-workbench-grid"]',
);
const modelsHeader = container.querySelector<HTMLElement>(
'[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");
});
});
@@ -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 真相集校验;
@@ -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 (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
aria-label={label}
className={cn(
"inline-flex h-7 w-7 items-center justify-center rounded-full border border-slate-200 bg-white text-slate-400 transition hover:border-slate-300 hover:text-slate-700",
className,
)}
data-testid={triggerTestId}
>
<CircleHelp className="h-3.5 w-3.5" />
</button>
</PopoverTrigger>
<PopoverContent
align="end"
className={cn(
"w-[280px] rounded-[18px] border-slate-200 bg-white p-3 text-sm leading-6 text-slate-600 shadow-lg shadow-slate-950/8",
contentClassName,
)}
data-testid={contentTestId}
>
{children}
</PopoverContent>
</Popover>
);
}
export default SectionInfoButton;
@@ -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(":");
}
@@ -92,9 +92,6 @@ vi.mock("../system/web-search", () => ({
vi.mock("../system/chrome-relay", () => ({
ChromeRelaySettings: () => <div>chrome-relay</div>,
}));
vi.mock("../features/SettingHeader", () => ({
SettingHeader: ({ title }: { title: string }) => <div>{title}</div>,
}));
vi.mock("../home", () => ({
SettingsHomePage: () => <div>home</div>,
}));
@@ -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 () => {
+90 -118
View File
@@ -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 (
<>
<SettingHeader title={hasManagedAccountProfile ? "账号与资料" : "个人资料"} />
{withSettingsContentFallback(
<>
<UserCenterSessionSettings />
{!hasManagedAccountProfile ? <ProfileSettings /> : null}
</>,
"正在加载账号资料...",
)}
</>
withSettingsContentFallback(
<>
<UserCenterSessionSettings />
{!hasManagedAccountProfile ? <ProfileSettings /> : null}
</>,
"正在加载账号资料...",
)
);
case SettingsTabs.Stats:
return (
<>
<SettingHeader title="数据统计" />
{withSettingsContentFallback(
<StatsSettings />,
"正在加载数据统计...",
)}
</>
return withSettingsContentFallback(
<StatsSettings />,
"正在加载数据统计...",
);
// 通用组
case SettingsTabs.Appearance:
return (
<>
<SettingHeader title="外观" />
{withSettingsContentFallback(
<AppearanceSettings />,
"正在加载外观设置...",
)}
</>
return withSettingsContentFallback(
<AppearanceSettings />,
"正在加载外观设置...",
);
case SettingsTabs.Hotkeys:
return (
<>
<SettingHeader title="快捷键" />
{withSettingsContentFallback(
<HotkeysSettings />,
"正在加载快捷键设置...",
)}
</>
return withSettingsContentFallback(
<HotkeysSettings />,
"正在加载快捷键设置...",
);
case SettingsTabs.Memory:
return (
<>
<SettingHeader title="记忆" />
{withSettingsContentFallback(
<MemorySettings />,
"正在加载记忆设置...",
)}
</>
return withSettingsContentFallback(
<MemorySettings />,
"正在加载记忆设置...",
);
// 智能体组
case SettingsTabs.Providers:
return (
<>
<SettingHeader title="AI 服务商" />
{withSettingsContentFallback(
<CloudProviderSettings
onOpenProfile={() => onTabChange(SettingsTabs.Profile)}
/>,
"正在加载 AI 服务商设置...",
)}
</>
return withSettingsContentFallback(
<CloudProviderSettings
onOpenProfile={() => onTabChange(SettingsTabs.Profile)}
/>,
"正在加载 AI 服务商设置...",
);
case SettingsTabs.Skills:
return (
<>
<SettingHeader title="技能管理" />
{withSettingsContentFallback(
<ExtensionsSettings />,
"正在加载技能管理...",
)}
</>
return withSettingsContentFallback(
<ExtensionsSettings />,
"正在加载技能管理...",
);
case SettingsTabs.MediaServices:
@@ -413,61 +419,36 @@ function renderSettingsContent(
// 系统组
case SettingsTabs.McpServer:
return (
<>
<SettingHeader title="MCP 服务器" />
{withSettingsContentFallback(
<McpPanel hideHeader />,
"正在加载 MCP 服务器...",
)}
</>
return withSettingsContentFallback(
<McpPanel hideHeader />,
"正在加载 MCP 服务器...",
);
case SettingsTabs.Channels:
return <SettingsChannelsRedirect onNavigate={onNavigate} />;
case SettingsTabs.WebSearch:
return (
<>
<SettingHeader title="网络搜索" />
{withSettingsContentFallback(
<WebSearchSettings />,
"正在加载网络搜索设置...",
)}
</>
return withSettingsContentFallback(
<WebSearchSettings />,
"正在加载网络搜索设置...",
);
case SettingsTabs.Environment:
return (
<>
<SettingHeader title="环境变量" />
{withSettingsContentFallback(
<EnvironmentSettings />,
"正在加载环境变量...",
)}
</>
return withSettingsContentFallback(
<EnvironmentSettings />,
"正在加载环境变量...",
);
case SettingsTabs.ChromeRelay:
return (
<>
<SettingHeader title="连接器" />
{withSettingsContentFallback(
<ChromeRelaySettings />,
"正在加载连接器设置...",
)}
</>
return withSettingsContentFallback(
<ChromeRelaySettings />,
"正在加载连接器设置...",
);
case SettingsTabs.SecurityPerformance:
return (
<>
<SettingHeader title="安全与性能" />
{withSettingsContentFallback(
<SecurityPerformanceSettings />,
"正在加载安全与性能设置...",
)}
</>
return withSettingsContentFallback(
<SecurityPerformanceSettings />,
"正在加载安全与性能设置...",
);
case SettingsTabs.Automation:
@@ -486,25 +467,15 @@ function renderSettingsContent(
);
case SettingsTabs.Experimental:
return (
<>
<SettingHeader title="实验功能" />
{withSettingsContentFallback(
<ExperimentalSettings />,
"正在加载实验功能...",
)}
</>
return withSettingsContentFallback(
<ExperimentalSettings />,
"正在加载实验功能...",
);
case SettingsTabs.Developer:
return (
<>
<SettingHeader title="开发者" />
{withSettingsContentFallback(
<DeveloperSettings />,
"正在加载开发者工具...",
)}
</>
return withSettingsContentFallback(
<DeveloperSettings />,
"正在加载开发者工具...",
);
case SettingsTabs.About:
@@ -611,6 +582,7 @@ export function SettingsLayoutV2({
onTabPrefetch={handleTabPrefetch}
/>
<ContentContainer ref={contentContainerRef}>
<ContentAtmosphere data-testid="settings-content-atmosphere" />
<ContentWrapper $wide={WIDE_CONTENT_TABS.has(activeTab)}>
{renderSettingsContent(
activeTab,
@@ -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: () => <div data-testid="provider-pool-stub">凭证池占位</div>,
}));
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<string, unknown> = {}) {
};
}
function renderPage(props: { onOpenProfile?: () => void } = {}) {
function createPetStatus(overrides: Record<string, unknown> = {}) {
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(<CloudProviderSettings {...props} />);
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");
});
});
File diff suppressed because it is too large Load Diff
+13 -2
View File
@@ -37,17 +37,23 @@ const Tabs: React.FC<TabsProps> = ({
);
};
interface TabsListProps {
interface TabsListProps extends React.HTMLAttributes<HTMLDivElement> {
className?: string;
children: React.ReactNode;
}
const TabsList: React.FC<TabsListProps> = ({ className, children }) => (
const TabsList: React.FC<TabsListProps> = ({
className,
children,
...props
}) => (
<div
role="tablist"
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-gray-100 p-1 text-gray-500",
className,
)}
{...props}
>
{children}
</div>
@@ -77,6 +83,9 @@ const TabsTrigger: React.FC<TabsTriggerProps> = ({
return (
<button
type="button"
role="tab"
aria-selected={isActive}
data-state={isActive ? "active" : "inactive"}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
isActive
@@ -116,6 +125,8 @@ const TabsContent: React.FC<TabsContentProps> = ({
return (
<div
role="tabpanel"
data-state="active"
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
className,
-18
View File
@@ -41,12 +41,8 @@ describe("subAgentSummary", () => {
total: 3,
completed: 1,
failed: 0,
running: 1,
pending: 1,
skipped: 0,
cancelled: false,
currentTasks: ["task-b"],
percentage: 33,
}),
).toBe("正在执行:task-b");
});
@@ -56,16 +52,9 @@ describe("subAgentSummary", () => {
summarizeSubAgentResult(
{
success: true,
results: [],
totalDurationMs: 1200,
successfulCount: 2,
failedCount: 1,
skippedCount: 0,
totalTokenUsage: {
inputTokens: 0,
outputTokens: 0,
totalTokens: 0,
},
},
[
{
@@ -83,16 +72,9 @@ describe("subAgentSummary", () => {
summarizeSubAgentResult(
{
success: true,
results: [],
totalDurationMs: 1200,
successfulCount: 0,
failedCount: 0,
skippedCount: 0,
totalTokenUsage: {
inputTokens: 0,
outputTokens: 0,
totalTokens: 0,
},
},
[
{
+29 -10
View File
@@ -1,8 +1,25 @@
import type {
SchedulerExecutionResult,
SchedulerProgress,
SubAgentTask,
} from "@/lib/api/subAgentScheduler";
type SubAgentTaskSummary = {
id: string;
taskType?: string | null;
prompt?: string | null;
description?: string | null;
};
type SchedulerProgressSummary = {
total: number;
completed: number;
failed: number;
skipped: number;
currentTasks: string[];
};
type SchedulerExecutionResultSummary = {
success?: boolean;
mergedSummary?: string | null;
successfulCount: number;
failedCount: number;
skippedCount: number;
};
function normalizeText(value?: string | null): string | null {
if (typeof value !== "string") {
@@ -20,7 +37,7 @@ function truncateSummary(value: string, maxLength = 56): string {
return `${value.slice(0, maxLength - 1).trimEnd()}…`;
}
export function summarizeSubAgentTask(task: SubAgentTask): string {
export function summarizeSubAgentTask(task: SubAgentTaskSummary): string {
return truncateSummary(
normalizeText(task.description) ||
normalizeText(task.prompt) ||
@@ -29,7 +46,9 @@ export function summarizeSubAgentTask(task: SubAgentTask): string {
);
}
export function summarizeSubAgentTaskBatch(tasks: SubAgentTask[]): string | null {
export function summarizeSubAgentTaskBatch(
tasks: SubAgentTaskSummary[],
): string | null {
if (tasks.length === 0) {
return null;
}
@@ -48,7 +67,7 @@ export function summarizeSubAgentTaskBatch(tasks: SubAgentTask[]): string | null
}
export function summarizeSubAgentProgress(
progress: SchedulerProgress,
progress: SchedulerProgressSummary,
): string | null {
if (progress.currentTasks.length > 0) {
return `正在执行:${progress.currentTasks.join("、")}`;
@@ -59,8 +78,8 @@ export function summarizeSubAgentProgress(
}
export function summarizeSubAgentResult(
result: SchedulerExecutionResult,
tasks: SubAgentTask[],
result: SchedulerExecutionResultSummary,
tasks: SubAgentTaskSummary[],
): string | null {
const mergedSummary = normalizeText(result.mergedSummary);
if (mergedSummary) {
@@ -0,0 +1,202 @@
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 { useCompanionProviderBridge } from "./useCompanionProviderBridge";
import {
COMPANION_OPEN_PROVIDER_SETTINGS_EVENT,
type CompanionPetStatus,
getCompanionPetStatus,
listenCompanionPetStatus,
sendCompanionPetCommand,
} from "@/lib/api/companion";
import { safeListen } from "@/lib/dev-bridge";
import { providerPoolApi } from "@/lib/api/providerPool";
import { subscribeProviderDataChanged } from "@/lib/providerDataEvents";
import { SettingsTabs } from "@/types/settings";
vi.mock("@/lib/api/companion", () => ({
COMPANION_OPEN_PROVIDER_SETTINGS_EVENT: "companion-open-provider-settings",
COMPANION_PROVIDER_OVERVIEW_CAPABILITY: "provider-overview",
getCompanionPetStatus: vi.fn(),
listenCompanionPetStatus: vi.fn(),
sendCompanionPetCommand: vi.fn(),
}));
vi.mock("@/lib/dev-bridge", () => ({
safeListen: vi.fn(),
}));
vi.mock("@/lib/api/providerPool", () => ({
providerPoolApi: {
getOverview: vi.fn(),
},
}));
vi.mock("@/lib/providerDataEvents", () => ({
subscribeProviderDataChanged: vi.fn(),
}));
type HookProps = Parameters<typeof useCompanionProviderBridge>[0];
const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = [];
function createConnectedStatus(
overrides: Partial<CompanionPetStatus> = {},
): CompanionPetStatus {
return {
endpoint: "ws://127.0.0.1:45554/companion/pet",
server_listening: true,
connected: true,
client_id: "lime",
platform: "macos",
capabilities: ["provider-overview"],
last_event: "pet.ready",
last_error: null,
last_state: "walking",
...overrides,
};
}
function renderHook(props?: Partial<HookProps>) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
function Probe(currentProps: HookProps) {
useCompanionProviderBridge(currentProps);
return null;
}
const defaultProps: HookProps = {
onNavigate: vi.fn(),
};
const render = async (nextProps?: Partial<HookProps>) => {
await act(async () => {
root.render(<Probe {...defaultProps} {...props} {...nextProps} />);
await Promise.resolve();
await Promise.resolve();
});
};
mountedRoots.push({ root, container });
return {
render,
defaultProps: {
...defaultProps,
...props,
},
};
}
describe("useCompanionProviderBridge", () => {
beforeEach(() => {
(
globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
).IS_REACT_ACT_ENVIRONMENT = true;
vi.mocked(getCompanionPetStatus).mockResolvedValue(createConnectedStatus());
vi.mocked(listenCompanionPetStatus).mockResolvedValue(vi.fn());
vi.mocked(sendCompanionPetCommand).mockResolvedValue({
delivered: true,
connected: true,
});
vi.mocked(providerPoolApi.getOverview).mockResolvedValue([
{
provider_type: "openai",
stats: {
total: 1,
healthy: 1,
unhealthy: 0,
disabled: 0,
total_usage: 3,
total_errors: 0,
},
credentials: [],
},
]);
vi.mocked(subscribeProviderDataChanged).mockReturnValue(vi.fn());
vi.mocked(safeListen).mockResolvedValue(vi.fn());
});
afterEach(() => {
while (mountedRoots.length > 0) {
const mounted = mountedRoots.pop();
if (!mounted) {
break;
}
act(() => {
mounted.root.unmount();
});
mounted.container.remove();
}
vi.clearAllMocks();
});
it("桌宠已连接且支持 provider 概览时,应下发脱敏摘要", async () => {
const { render } = renderHook();
await render();
expect(sendCompanionPetCommand).toHaveBeenCalledWith({
event: "pet.provider_overview",
payload: {
providers: [
{
provider_type: "openai",
display_name: "OpenAI",
total_count: 1,
healthy_count: 1,
available: true,
needs_attention: false,
},
],
total_provider_count: 1,
available_provider_count: 1,
needs_attention_provider_count: 0,
},
});
});
it("收到打开服务商设置事件时,应导航到 Providers 标签页", async () => {
const onNavigate = vi.fn();
let openSettingsHandler: (() => void) | null = null;
vi.mocked(safeListen).mockImplementation(async (event, handler) => {
if (event === COMPANION_OPEN_PROVIDER_SETTINGS_EVENT) {
openSettingsHandler = handler as () => void;
}
return vi.fn();
});
const { render } = renderHook({ onNavigate });
await render();
await act(async () => {
openSettingsHandler?.();
await Promise.resolve();
});
expect(onNavigate).toHaveBeenCalledWith("settings", {
tab: SettingsTabs.Providers,
});
});
it("桌宠未声明 provider 能力时,不应下发摘要", async () => {
vi.mocked(getCompanionPetStatus).mockResolvedValue(
createConnectedStatus({
capabilities: [],
}),
);
const { render } = renderHook();
await render();
expect(sendCompanionPetCommand).not.toHaveBeenCalled();
});
});
+163
View File
@@ -0,0 +1,163 @@
import { useEffect, useRef } from "react";
import type { UnlistenFn } from "@tauri-apps/api/event";
import {
COMPANION_OPEN_PROVIDER_SETTINGS_EVENT,
COMPANION_PROVIDER_OVERVIEW_CAPABILITY,
getCompanionPetStatus,
listenCompanionPetStatus,
sendCompanionPetCommand,
type CompanionPetStatus,
} from "@/lib/api/companion";
import { safeListen } from "@/lib/dev-bridge";
import { providerPoolApi } from "@/lib/api/providerPool";
import { subscribeProviderDataChanged } from "@/lib/providerDataEvents";
import { buildCompanionProviderOverview } from "@/lib/provider/companionProviderOverview";
import type { Page, PageParams } from "@/types/page";
import { SettingsTabs } from "@/types/settings";
interface UseCompanionProviderBridgeOptions {
onNavigate: (page: Page, params?: PageParams) => void;
}
function supportsProviderOverview(
status: CompanionPetStatus | null | undefined,
): boolean {
return Boolean(
status?.connected &&
status.capabilities.includes(COMPANION_PROVIDER_OVERVIEW_CAPABILITY),
);
}
export function useCompanionProviderBridge({
onNavigate,
}: UseCompanionProviderBridgeOptions): void {
const statusRef = useRef<CompanionPetStatus | null>(null);
const lastFingerprintRef = useRef<string | null>(null);
const syncRequestIdRef = useRef(0);
useEffect(() => {
let cancelled = false;
let statusUnlisten: UnlistenFn | null = null;
let openSettingsUnlisten: UnlistenFn | null = null;
const syncProviderOverview = async (forceRefresh = false) => {
if (!supportsProviderOverview(statusRef.current)) {
return;
}
const requestId = ++syncRequestIdRef.current;
try {
const overview = await providerPoolApi.getOverview(
forceRefresh ? { forceRefresh: true } : undefined,
);
if (cancelled || requestId !== syncRequestIdRef.current) {
return;
}
const payload = buildCompanionProviderOverview(overview);
const fingerprint = JSON.stringify(payload);
if (fingerprint === lastFingerprintRef.current) {
return;
}
const result = await sendCompanionPetCommand({
event: "pet.provider_overview",
payload,
});
if (
!cancelled &&
requestId === syncRequestIdRef.current &&
result.delivered
) {
lastFingerprintRef.current = fingerprint;
}
} catch (error) {
if (!cancelled) {
console.warn("[Companion] 同步桌宠 provider 摘要失败:", error);
}
}
};
const applyStatus = (status: CompanionPetStatus) => {
const previousStatus = statusRef.current;
statusRef.current = status;
if (!status.connected) {
lastFingerprintRef.current = null;
return;
}
const becameProviderAware =
supportsProviderOverview(status) &&
(!previousStatus?.connected || !supportsProviderOverview(previousStatus));
if (becameProviderAware) {
void syncProviderOverview(true);
}
};
void getCompanionPetStatus()
.then((status) => {
if (!cancelled) {
applyStatus(status);
}
})
.catch((error) => {
if (!cancelled) {
console.warn("[Companion] 读取桌宠状态失败:", error);
}
});
void listenCompanionPetStatus(applyStatus)
.then((unlisten) => {
if (cancelled) {
void unlisten();
return;
}
statusUnlisten = unlisten;
})
.catch((error) => {
console.warn("[Companion] 监听桌宠状态失败:", error);
});
const unsubscribeProviderData = subscribeProviderDataChanged(() => {
void syncProviderOverview(true);
});
const handleWindowFocus = () => {
void syncProviderOverview(true);
};
window.addEventListener("focus", handleWindowFocus);
void safeListen(COMPANION_OPEN_PROVIDER_SETTINGS_EVENT, () => {
onNavigate("settings", {
tab: SettingsTabs.Providers,
});
})
.then((unlisten) => {
if (cancelled) {
void unlisten();
return;
}
openSettingsUnlisten = unlisten;
})
.catch((error) => {
console.warn("[Companion] 监听桌宠设置跳转失败:", error);
});
return () => {
cancelled = true;
unsubscribeProviderData();
window.removeEventListener("focus", handleWindowFocus);
if (statusUnlisten) {
statusUnlisten();
}
if (openSettingsUnlisten) {
openSettingsUnlisten();
}
};
}, [onNavigate]);
}
+118
View File
@@ -0,0 +1,118 @@
import { describe, expect, it } from "vitest";
import type { ProviderWithKeysDisplay } from "@/lib/api/apiKeyProvider";
import type { ProviderPoolOverview } from "@/lib/api/providerPool";
import { buildConfiguredProviders } from "./useConfiguredProviders";
function createProviderPoolOverview(
overrides: Partial<ProviderPoolOverview> = {},
): ProviderPoolOverview {
return {
provider_type: "openai",
stats: {
total: 0,
healthy: 0,
unhealthy: 0,
disabled: 0,
total_usage: 0,
total_errors: 0,
},
credentials: [],
...overrides,
};
}
function createApiKeyProvider(
overrides: Partial<ProviderWithKeysDisplay> = {},
): ProviderWithKeysDisplay {
return {
id: "openai",
name: "OpenAI",
type: "openai",
api_host: "https://api.openai.com/v1",
is_system: true,
group: "cloud",
enabled: true,
sort_order: 0,
custom_models: [],
api_key_count: 0,
api_keys: [],
created_at: "2026-04-01T00:00:00Z",
updated_at: "2026-04-01T00:00:00Z",
...overrides,
};
}
describe("buildConfiguredProviders", () => {
it("应将无 Key 但已启用且地址有效的 Ollama 视为已配置渠道", () => {
const providers = buildConfiguredProviders(
[createProviderPoolOverview()],
[
createApiKeyProvider({
id: "ollama",
name: "Ollama (本地)",
type: "ollama",
api_host: "http://localhost:11434",
api_key_count: 0,
}),
],
);
expect(providers).toEqual(
expect.arrayContaining([
expect.objectContaining({
key: "ollama",
providerId: "ollama",
type: "ollama",
apiHost: "http://localhost:11434",
}),
]),
);
});
it("不应误把其他无 Key 云渠道当成已配置", () => {
const providers = buildConfiguredProviders(
[],
[
createApiKeyProvider({
id: "openai",
name: "OpenAI",
type: "openai",
api_key_count: 0,
}),
],
);
expect(providers).toEqual([]);
});
it("无 Key 的 Ollama 缺少地址或被禁用时不应展示", () => {
const missingHostProviders = buildConfiguredProviders(
[],
[
createApiKeyProvider({
id: "ollama",
name: "Ollama (本地)",
type: "ollama",
api_host: " ",
api_key_count: 0,
}),
],
);
const disabledProviders = buildConfiguredProviders(
[],
[
createApiKeyProvider({
id: "ollama",
name: "Ollama (本地)",
type: "ollama",
api_host: "http://localhost:11434",
enabled: false,
api_key_count: 0,
}),
],
);
expect(missingHostProviders).toEqual([]);
expect(disabledProviders).toEqual([]);
});
});
+27 -1
View File
@@ -40,6 +40,8 @@ export interface ConfiguredProvider {
credentialType?: string;
/** Provider ID(用于 API Key Provider) */
providerId?: string;
/** Provider API Host */
apiHost?: string;
/** 自定义模型列表(用于 API Key Provider) */
customModels?: string[];
}
@@ -59,6 +61,29 @@ interface LoadConfiguredProvidersOptions {
forceRefresh?: boolean;
}
function normalizeProviderType(value?: string | null): string {
return (value || "").trim().toLowerCase();
}
function hasConfiguredKeylessAccess(
provider: ProviderWithKeysDisplay,
): boolean {
return (
normalizeProviderType(provider.type) === "ollama" &&
provider.enabled &&
provider.api_host.trim().length > 0
);
}
function isConfiguredApiKeyProvider(
provider: ProviderWithKeysDisplay,
): boolean {
return provider.enabled && (
provider.api_key_count > 0 ||
hasConfiguredKeylessAccess(provider)
);
}
export function buildConfiguredProviders(
oauthCredentials: ProviderPoolOverview[],
apiKeyProviders: ProviderWithKeysDisplay[],
@@ -90,7 +115,7 @@ export function buildConfiguredProviders(
});
safeApiKeyProviders
.filter((p) => p.api_key_count > 0 && p.enabled)
.filter(isConfiguredApiKeyProvider)
.forEach((provider) => {
let key = provider.id;
let label = provider.name;
@@ -109,6 +134,7 @@ export function buildConfiguredProviders(
type: provider.type,
credentialType: `${provider.type}_key`,
providerId: provider.id,
apiHost: provider.api_host,
customModels: provider.custom_models,
});
}
+142
View File
@@ -0,0 +1,142 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ConfiguredProvider } from "./useConfiguredProviders";
import { loadProviderModels } from "./useProviderModels";
const {
mockGetModelRegistry,
mockGetProviderAliasConfig,
mockFetchProviderModelsAuto,
} = vi.hoisted(() => ({
mockGetModelRegistry: vi.fn(),
mockGetProviderAliasConfig: vi.fn(),
mockFetchProviderModelsAuto: vi.fn(),
}));
vi.mock("@/lib/api/modelRegistry", () => ({
modelRegistryApi: {
getModelRegistry: mockGetModelRegistry,
getProviderAliasConfig: mockGetProviderAliasConfig,
fetchProviderModelsAuto: mockFetchProviderModelsAuto,
},
}));
function createProvider(
overrides: Partial<ConfiguredProvider> = {},
): ConfiguredProvider {
return {
key: "openai_api_key",
label: "OpenAI API Key",
registryId: "openai",
fallbackRegistryId: "openai",
type: "openai",
providerId: "openai",
apiHost: "https://api.openai.com/v1",
...overrides,
};
}
describe("loadProviderModels", () => {
beforeEach(() => {
vi.clearAllMocks();
mockGetModelRegistry.mockResolvedValue([]);
mockGetProviderAliasConfig.mockResolvedValue(null);
mockFetchProviderModelsAuto.mockResolvedValue({
models: [],
source: "Api",
error: null,
});
});
it("实时拉取时应优先使用真实 providerId,而不是前端去重后的 key", async () => {
mockFetchProviderModelsAuto.mockResolvedValueOnce({
models: [
{
id: "gpt-5.1",
display_name: "GPT-5.1",
provider_id: "openai",
provider_name: "OpenAI",
family: null,
tier: "pro",
capabilities: {
vision: true,
tools: true,
streaming: true,
json_mode: true,
function_calling: true,
reasoning: true,
},
pricing: null,
limits: {
context_length: null,
max_output_tokens: null,
requests_per_minute: null,
tokens_per_minute: null,
},
status: "active",
release_date: null,
is_latest: true,
description: null,
source: "custom",
created_at: 0,
updated_at: 0,
},
],
source: "Api",
error: null,
});
const models = await loadProviderModels(createProvider(), {
liveFetchOnly: true,
hasApiKey: true,
});
expect(models.map((model) => model.id)).toEqual(["gpt-5.1"]);
expect(mockFetchProviderModelsAuto).toHaveBeenCalledWith("openai");
});
it("实时目录读取失败并回退到 LocalFallback 时,不应继续把旧模型当成最新模型展示", async () => {
mockFetchProviderModelsAuto.mockResolvedValueOnce({
models: [
{
id: "gpt-4.1",
display_name: "GPT-4.1",
provider_id: "openai",
provider_name: "OpenAI",
family: null,
tier: "pro",
capabilities: {
vision: true,
tools: true,
streaming: true,
json_mode: true,
function_calling: true,
reasoning: true,
},
pricing: null,
limits: {
context_length: null,
max_output_tokens: null,
requests_per_minute: null,
tokens_per_minute: null,
},
status: "active",
release_date: null,
is_latest: true,
description: null,
source: "local",
created_at: 0,
updated_at: 0,
},
],
source: "LocalFallback",
error: "401 Unauthorized",
});
const models = await loadProviderModels(createProvider(), {
liveFetchOnly: true,
hasApiKey: true,
});
expect(models).toEqual([]);
});
});
+110 -10
View File
@@ -11,8 +11,12 @@ import {
} from "@/lib/api/modelRegistry";
import { useModelRegistry } from "./useModelRegistry";
import { useAliasConfig } from "./useAliasConfig";
import { getAliasConfigKey, isAliasProvider } from "@/lib/constants/providerMappings";
import {
getAliasConfigKey,
isAliasProvider,
} from "@/lib/constants/providerMappings";
import { buildProviderModelsFromRegistry } from "@/lib/model/providerModelsCatalog";
import { getProviderModelAutoFetchCapability } from "@/lib/model/providerModelFetchSupport";
import type { ConfiguredProvider } from "./useConfiguredProviders";
import type { EnhancedModelMetadata } from "@/lib/types/modelRegistry";
@@ -25,6 +29,10 @@ export interface UseProviderModelsOptions {
returnFullMetadata?: boolean;
/** 是否自动加载模型注册表 */
autoLoad?: boolean;
/** 对支持实时拉取模型的 Provider,仅接受实时模型目录 */
liveFetchOnly?: boolean;
/** 当前 Provider 是否有可用 API Key */
hasApiKey?: boolean;
}
export interface UseProviderModelsResult {
@@ -40,16 +48,39 @@ export interface UseProviderModelsResult {
interface LoadProviderModelsOptions {
forceRefresh?: boolean;
liveFetchOnly?: boolean;
hasApiKey?: boolean;
}
function getProviderAutoFetchCapability(
selectedProvider: ConfiguredProvider,
) {
return getProviderModelAutoFetchCapability({
providerId: selectedProvider.providerId ?? selectedProvider.key,
providerType: selectedProvider.type,
apiHost: selectedProvider.apiHost,
});
}
async function fetchProviderModelsFromApi(
selectedProvider: ConfiguredProvider,
): Promise<EnhancedModelMetadata[]> {
if (!getProviderAutoFetchCapability(selectedProvider).supported) {
return [];
}
try {
const result: FetchProviderModelsResult =
await modelRegistryApi.fetchProviderModelsAuto(selectedProvider.key);
await modelRegistryApi.fetchProviderModelsAuto(
selectedProvider.providerId ?? selectedProvider.key,
);
if (result && result.models && result.models.length > 0) {
if (
result &&
result.source === "Api" &&
result.models &&
result.models.length > 0
) {
return result.models;
}
} catch {
@@ -67,7 +98,9 @@ export async function loadProviderModels(
return [];
}
const sourceOptions = options.forceRefresh ? { forceRefresh: true } : undefined;
const sourceOptions = options.forceRefresh
? { forceRefresh: true }
: undefined;
const aliasConfigPromise = isAliasProvider(selectedProvider.key)
? modelRegistryApi.getProviderAliasConfig(
getAliasConfigKey(selectedProvider.key),
@@ -79,12 +112,27 @@ export async function loadProviderModels(
modelRegistryApi.getModelRegistry(sourceOptions),
aliasConfigPromise,
]);
const autoFetchCapability = getProviderAutoFetchCapability(selectedProvider);
const useLiveFetchTruthOnly =
options.liveFetchOnly && autoFetchCapability.supported;
if (
useLiveFetchTruthOnly &&
autoFetchCapability.requiresApiKey &&
!options.hasApiKey
) {
return [];
}
const localResult = buildProviderModelsFromRegistry(
selectedProvider,
registryModels,
aliasConfig,
);
if (useLiveFetchTruthOnly) {
return fetchProviderModelsFromApi(selectedProvider);
}
if (localResult.hasLocalModels || localResult.models.length > 0) {
return localResult.models;
}
@@ -93,6 +141,10 @@ export async function loadProviderModels(
return localResult.models;
}
if (!autoFetchCapability.supported) {
return localResult.models;
}
const apiModels = await fetchProviderModelsFromApi(selectedProvider);
if (apiModels.length === 0) {
return localResult.models;
@@ -144,7 +196,12 @@ export function useProviderModels(
selectedProvider: ConfiguredProvider | undefined | null,
options: UseProviderModelsOptions = {},
): UseProviderModelsResult {
const { returnFullMetadata = false, autoLoad = true } = options;
const {
returnFullMetadata = false,
autoLoad = true,
liveFetchOnly = false,
hasApiKey = false,
} = options;
// 获取模型注册表数据
const {
@@ -154,8 +211,10 @@ export function useProviderModels(
} = useModelRegistry({ autoLoad });
// 获取别名配置
const { aliasConfig, loading: aliasLoading } =
useAliasConfig(selectedProvider, { autoLoad });
const { aliasConfig, loading: aliasLoading } = useAliasConfig(
selectedProvider,
{ autoLoad },
);
// API 获取的模型缓存
const [apiModels, setApiModels] = useState<EnhancedModelMetadata[]>([]);
@@ -172,11 +231,26 @@ export function useProviderModels(
),
[selectedProvider, registryModels, aliasConfig],
);
const autoFetchCapability = useMemo(
() =>
selectedProvider ? getProviderAutoFetchCapability(selectedProvider) : null,
[selectedProvider],
);
const useLiveFetchTruthOnly = Boolean(
liveFetchOnly && autoFetchCapability?.supported,
);
const canReadLiveModels = Boolean(
!useLiveFetchTruthOnly ||
!autoFetchCapability?.requiresApiKey ||
hasApiKey,
);
// 当本地没有模型时,从 API 获取
useEffect(() => {
if (!selectedProvider) {
setApiModels([]);
setApiLoading(false);
setApiError(null);
return;
}
@@ -189,11 +263,28 @@ export function useProviderModels(
// 如果是别名 Provider,不从 API 获取
if (isAliasProvider(selectedProvider.key)) {
setApiModels([]);
setApiLoading(false);
setApiError(null);
return;
}
if (!autoFetchCapability?.supported) {
setApiModels([]);
setApiLoading(false);
setApiError(null);
return;
}
if (!canReadLiveModels) {
setApiModels([]);
setApiLoading(false);
setApiError(null);
return;
}
// 如果本地有模型,不需要从 API 获取
if (localResult.hasLocalModels) {
if (!useLiveFetchTruthOnly && localResult.hasLocalModels) {
setApiModels([]);
return;
}
@@ -222,14 +313,23 @@ export function useProviderModels(
}, [
selectedProvider,
autoLoad,
autoFetchCapability,
canReadLiveModels,
useLiveFetchTruthOnly,
localResult.hasLocalModels,
registryLoading,
aliasLoading,
registryModels,
]);
// 合并本地模型和 API 模型
const finalResult = useMemo(() => {
if (useLiveFetchTruthOnly) {
return {
modelIds: apiModels.map((model) => model.id),
models: returnFullMetadata ? apiModels : [],
};
}
// 如果有本地模型,使用本地模型
if (localResult.hasLocalModels || localResult.models.length > 0) {
return {
@@ -266,7 +366,7 @@ export function useProviderModels(
modelIds: localResult.modelIds,
models: returnFullMetadata ? localResult.models : [],
};
}, [localResult, apiModels, returnFullMetadata]);
}, [apiModels, localResult, returnFullMetadata, useLiveFetchTruthOnly]);
// 计算加载状态
const loading = registryLoading || aliasLoading || apiLoading;
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>AI21</title><path d="M6.47 17l-.367-1.189H2.718L2.35 17H0l3.398-9.789h2.026L8.864 17H6.47zm-2.052-6.993l-1.17 4.028H5.56l-1.142-4.028zm4.707-2.796h2.23V17h-2.23V7.211zM11.955 15c.1-.483.277-.946.524-1.37.214-.359.482-.68.795-.951.32-.273.658-.52 1.013-.741.28-.168.54-.33.781-.483.222-.14.433-.296.632-.468.172-.148.317-.325.428-.525.107-.199.16-.423.157-.65 0-.392-.104-.674-.313-.846a1.176 1.176 0 00-.775-.259 1.207 1.207 0 00-.863.329c-.231.219-.347.585-.347 1.098H11.8a3.387 3.387 0 01.224-1.245c.146-.377.371-.716.66-.993.306-.29.667-.514 1.06-.657A4.04 4.04 0 0115.183 7c.42-.002.84.057 1.244.175.376.107.73.287 1.04.531.305.246.55.562.714.923.185.419.275.875.265 1.335.005.39-.084.774-.259 1.12-.167.328-.38.63-.632.894-.246.259-.517.49-.808.693-.29.2-.554.37-.789.51-.326.224-.596.417-.809.58a3.872 3.872 0 00-.51.455 1.229 1.229 0 00-.265.434 1.633 1.633 0 00-.074.517h4.078V17h-6.606a9.24 9.24 0 01.183-2zM18.8 8.93a5.05 5.05 0 001.135-.105c.25-.049.484-.156.686-.314.163-.139.28-.324.34-.532.068-.25.1-.51.095-.77H23V17h-2.243v-6.475H18.8V8.93z"></path></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+1 -1
View File
@@ -1 +1 @@
<svg fill="currentColor" height="1em" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>百川 (Baichuan)</title><path d="M12 2L4 6v12l8 4 8-4V6l-8-4zm0 2.5L18 8v8l-6 3-6-3V8l6-3.5zm0 3.5l-4 2v4l4 2 4-2v-4l-4-2z"/></svg>
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Baichuan</title><defs><linearGradient id="lobe-icons-baichuan-fill" x1="17.764%" x2="100%" y1="8.678%" y2="91.322%"><stop offset="0%" stop-color="#FEC13E"></stop><stop offset="100%" stop-color="#FF6933"></stop></linearGradient></defs><path d="M7.333 2h-3.2l-2 4.333V17.8L0 22h5.2l2.028-4.2L7.333 2zm7.334 0h-5.2v20h5.2V2zM16.8 7.733H22V22h-5.2V7.733zM22 2h-5.2v4.133H22V2z" fill="url(#lobe-icons-baichuan-fill)" fill-rule="nonzero"></path></svg>

Before

Width:  |  Height:  |  Size: 244 B

After

Width:  |  Height:  |  Size: 569 B

+1 -1
View File
@@ -1 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>BaiduCloud</title><path d="M21.715 5.61l-3.983 2.31a.903.903 0 01-.896 0L12.44 5.384a.903.903 0 00-.897 0L7.156 7.92a.903.903 0 01-.896 0L2.276 5.617 12.002 0l9.713 5.61z" fill="#5BCA87"></path><path d="M18.641 9.467a.89.89 0 00-.438.77v5.072a.896.896 0 01-.445.77l-4.428 2.51a.884.884 0 00-.445.777v4.607l4.429-2.536 5.31-3.047V7.157l-3.983 2.31z" fill="#EC5D3E"></path><path d="M10.98 18.941a.936.936 0 00-.305-.352l-4.429-2.516a.903.903 0 01-.431-.764v-5.078a.89.89 0 00-.452-.757l-.451-.26L1.38 7.158V18.39l5.311 3.047L11.126 24v-4.608a.881.881 0 00-.146-.45z" fill="#2464F5"></path></svg>
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Baidu</title><path d="M8.859 11.735c1.017-1.71 4.059-3.083 6.202.286 1.579 2.284 4.284 4.397 4.284 4.397s2.027 1.601.73 4.684c-1.24 2.956-5.64 1.607-6.005 1.49l-.024-.009s-1.746-.568-3.776-.112c-2.026.458-3.773.286-3.773.286l-.045-.001c-.328-.01-2.38-.187-3.001-2.968-.675-3.028 2.365-4.687 2.592-4.968.226-.288 1.802-1.37 2.816-3.085zm.986 1.738v2.032h-1.64s-1.64.138-2.213 2.014c-.2 1.252.177 1.99.242 2.148.067.157.596 1.073 1.927 1.342h3.078v-7.514l-1.394-.022zm3.588 2.191l-1.44.024v3.956s.064.985 1.44 1.344h3.541v-5.3h-1.528v3.979h-1.46s-.466-.068-.553-.447v-3.556zM9.82 16.715v3.06H8.58s-.863-.045-1.126-1.049c-.136-.445.02-.959.088-1.16.063-.203.353-.671.951-.85H9.82zm9.525-9.036c2.086 0 2.646 2.06 2.646 2.742 0 .688.284 3.597-2.309 3.655-2.595.057-2.704-1.77-2.704-3.08 0-1.374.277-3.317 2.367-3.317zM4.24 6.08c1.523-.135 2.645 1.55 2.762 2.513.07.625.393 3.486-1.975 4-2.364.515-3.244-2.249-2.984-3.544 0 0 .28-2.797 2.197-2.969zm8.847-1.483c.14-1.31 1.69-3.316 2.931-3.028 1.236.285 2.367 1.944 2.137 3.37-.224 1.428-1.345 3.313-3.095 3.082-1.748-.226-2.143-1.823-1.973-3.424zM9.425 1c1.307 0 2.364 1.519 2.364 3.398 0 1.879-1.057 3.4-2.364 3.4s-2.367-1.521-2.367-3.4C7.058 2.518 8.118 1 9.425 1z"></path></svg>

Before

Width:  |  Height:  |  Size: 717 B

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+1 -1
View File
@@ -1 +1 @@
<svg fill="currentColor" height="1em" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>AWS Bedrock</title><path d="M12 2L2 7v10l10 5 10-5V7L12 2zm0 2.18l7.09 3.54L12 11.27 4.91 7.72 12 4.18zM4 9.27l7 3.5v7.05l-7-3.5V9.27zm9 10.55v-7.05l7-3.5v7.05l-7 3.5z"/></svg>
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Bedrock</title><path d="M13.05 15.513h3.08c.214 0 .389.177.389.394v1.82a1.704 1.704 0 011.296 1.661c0 .943-.755 1.708-1.685 1.708-.931 0-1.686-.765-1.686-1.708 0-.807.554-1.484 1.297-1.662v-1.425h-2.69v4.663a.395.395 0 01-.188.338l-2.69 1.641a.385.385 0 01-.405-.002l-4.926-3.086a.395.395 0 01-.185-.336V16.3L2.196 14.87A.395.395 0 012 14.555L2 14.528V9.406c0-.14.073-.27.192-.34l2.465-1.462V4.448c0-.129.062-.249.165-.322l.021-.014L9.77 1.058a.385.385 0 01.407 0l2.69 1.675a.395.395 0 01.185.336V7.6h3.856V5.683a1.704 1.704 0 01-1.296-1.662c0-.943.755-1.708 1.685-1.708.931 0 1.685.765 1.685 1.708 0 .807-.553 1.484-1.296 1.662v2.311a.391.391 0 01-.389.394h-4.245v1.806h6.624a1.69 1.69 0 011.64-1.313c.93 0 1.685.764 1.685 1.707 0 .943-.754 1.708-1.685 1.708a1.69 1.69 0 01-1.64-1.314H13.05v1.937h4.953l.915 1.18a1.66 1.66 0 01.84-.227c.931 0 1.685.764 1.685 1.707 0 .943-.754 1.708-1.685 1.708-.93 0-1.685-.765-1.685-1.708 0-.346.102-.668.276-.937l-.724-.935H13.05v1.806zM9.973 1.856L7.93 3.122V6.09h-.778V3.604L5.435 4.669v2.945l2.11 1.36L9.712 7.61V5.334h.778V7.83c0 .136-.07.263-.184.335L7.963 9.638v2.081l1.422 1.009-.446.646-1.406-.998-1.53 1.005-.423-.66 1.605-1.055v-1.99L5.038 8.29l-2.26 1.34v1.676l1.972-1.189.398.677-2.37 1.429V14.3l2.166 1.258 2.27-1.368.397.677-2.176 1.311V19.3l1.876 1.175 2.365-1.426.398.678-2.017 1.216 1.918 1.201 2.298-1.403v-5.78l-4.758 2.893-.4-.675 5.158-3.136V3.289L9.972 1.856zM16.13 18.47a.913.913 0 00-.908.92c0 .507.406.918.908.918a.913.913 0 00.907-.919.913.913 0 00-.907-.92zm3.63-3.81a.913.913 0 00-.908.92c0 .508.406.92.907.92a.913.913 0 00.908-.92.913.913 0 00-.908-.92zm1.555-4.99a.913.913 0 00-.908.92c0 .507.407.918.908.918a.913.913 0 00.907-.919.913.913 0 00-.907-.92zM17.296 3.1a.913.913 0 00-.907.92c0 .508.406.92.907.92a.913.913 0 00.908-.92.913.913 0 00-.908-.92z"></path></svg>

Before

Width:  |  Height:  |  Size: 288 B

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Cloudflare</title><g><path d="M16.493 17.4c.135-.52.08-.983-.161-1.338-.215-.328-.592-.519-1.05-.519l-8.663-.109a.148.148 0 01-.135-.082c-.027-.054-.027-.109-.027-.163.027-.082.108-.164.189-.164l8.744-.11c1.05-.054 2.153-.9 2.556-1.937l.511-1.31c.027-.055.027-.11.027-.164C17.92 8.91 15.66 7 12.942 7c-2.503 0-4.628 1.638-5.381 3.903a2.432 2.432 0 00-1.803-.491c-1.21.109-2.153 1.092-2.287 2.32-.027.328 0 .628.054.9C1.56 13.688 0 15.326 0 17.319c0 .19.027.355.027.545 0 .082.08.137.161.137h15.983c.08 0 .188-.055.215-.164l.107-.437"></path><path d="M19.238 11.75h-.242c-.054 0-.108.054-.135.109l-.35 1.2c-.134.52-.08.983.162 1.338.215.328.592.518 1.05.518l1.855.11c.054 0 .108.027.135.082.027.054.027.109.027.163-.027.082-.108.164-.188.164l-1.91.11c-1.05.054-2.153.9-2.557 1.937l-.134.355c-.027.055.026.137.107.137h6.592c.081 0 .162-.055.162-.137.107-.41.188-.846.188-1.31-.027-2.62-2.153-4.777-4.762-4.777"></path></g></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+1 -1
View File
@@ -1 +1 @@
<svg fill="currentColor" height="1em" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Cohere</title><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg>
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Cohere</title><path clip-rule="evenodd" d="M8.128 14.099c.592 0 1.77-.033 3.398-.703 1.897-.781 5.672-2.2 8.395-3.656 1.905-1.018 2.74-2.366 2.74-4.18A4.56 4.56 0 0018.1 1H7.549A6.55 6.55 0 001 7.55c0 3.617 2.745 6.549 7.128 6.549z" fill="#39594D" fill-rule="evenodd"></path><path clip-rule="evenodd" d="M9.912 18.61a4.387 4.387 0 012.705-4.052l3.323-1.38c3.361-1.394 7.06 1.076 7.06 4.715a5.104 5.104 0 01-5.105 5.104l-3.597-.001a4.386 4.386 0 01-4.386-4.387z" fill="#D18EE2" fill-rule="evenodd"></path><path d="M4.776 14.962A3.775 3.775 0 001 18.738v.489a3.776 3.776 0 007.551 0v-.49a3.775 3.775 0 00-3.775-3.775z" fill="#FF7759"></path></svg>

Before

Width:  |  Height:  |  Size: 261 B

After

Width:  |  Height:  |  Size: 769 B

+1
View File
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>ComfyUI</title><path d="M10.311 16.46L8.495 8.078a.398.398 0 01.575-.437l7.29 3.843a.375.375 0 01-.064.69l-3.346 1.033a.493.493 0 00-.29.24l-1.65 3.11a.375.375 0 01-.699-.097z"></path><path d="M17.087 1.153C13.825-.336 10.665-.148 8.72.414c1.008.13 2.602.375 3.692.868.905.408 4.102 2.12 4.876 2.484.773.363 3.035 1.196 3.867 1.64.666.357 1.442 1.173 1.746 1.536-.613-1.238-2.191-4.137-5.813-5.79z"></path><circle cx="8.438" cy="2.578" r="1.57"></circle><circle cx="21.939" cy="13.384" r=".633"></circle><circle cx="20.275" cy="11.532" r=".492"></circle><circle cx="18.47" cy="16.619" r="1.219"></circle><circle cx="14.579" cy="18.447" r=".797"></circle><circle cx="15.341" cy="21.506" r="1.113"></circle><circle cx="4.278" cy="14.966" r="1.254"></circle><circle cx="4.981" cy="8.895" r="1.629"></circle><path d="M16.795 16.82l-.974.604-.175.108-.18.112a1.195 1.195 0 11-1.996 1.247c-.268.17-.498.319-.672.435-1.389.93-2.817 1.858-4.676 1.945-2.42.114-5.91-2.203-6.446-7.676-.41-4.196 2.836-8.122 5.098-9.587l.112-.07a2.055 2.055 0 01-.51-1.36c0-.478.162-.918.435-1.267-.567.098-.952.362-1.615.843C3.239 3.572 2.126 4.45.613 8.27c-1.512 3.82.047 8.392 1.7 10.9 1.652 2.508 5.485 4.078 6.375 4.313.891.234 3.505.796 4.583.117.352-.222.641-.536.894-.901a1.676 1.676 0 011.443-2.849c.315-.589.672-1.09 1.155-1.372.247-.144.517-.291.797-.439a1.687 1.687 0 01-.765-1.22z"></path><path d="M10.468 2.56c0 .338-.081.657-.225.938.998.246 2.006.598 3.612 1.606.414.26 1.346.727 2.195 1.153.449.225.874.438 1.186.603.898.386 1.854.797 2.596 1.15 2.14 1.02 2.693 2.605 2.442 4.448a.985.985 0 11-.718 1.833c-.488.498-1.212.836-1.926 1.116a1.683 1.683 0 01.487 1.359l.118-.055.026-.012a29.518 29.518 0 001.08-.514l.015-.008c.21-.11.533-.281.882-.548-.14.354-.6 1.222-1.312 1.86-.89.796-1.57.972-2.485 1.16-.715.147-1.728 1.016-2.174 1.46a1.674 1.674 0 01.47 2.323c-.029.113-.064.22-.1.327a5.074 5.074 0 00-.094.308l.136-.04c.601-.177 1.772-.52 4.247-3.089 2.678-2.78 2.945-6.016 3.059-7.723.199-2.743-1.588-4.48-2.444-5.101-.445-.323-1.685-.866-2.905-1.4l-.2-.089-.775-.335c-.773-.332-1.473-.633-1.88-.869l-.041-.024c-1.197-.692-3.07-1.775-4.63-2.264a3.4 3.4 0 00-.376-.073 3.005 3.005 0 01-.345-.068c.051.18.079.371.079.569z"></path><path clip-rule="evenodd" d="M13.886 6.476c-1.426-.827-3.247-1.882-5.075-1.882-1.334 0-2.826.919-4.033 2.213a2.095 2.095 0 011.914.875c.23-.375.496-.762.773-1.025.554-.525 1.329-.75 1.594-.75 1.149 0 2.591.75 4.692 2.086.636.404 1.26.735 1.83 1.038 1.482.787 2.6 1.38 2.6 2.548 0 1.324-.473 1.711-1.105 2.074-.123.07-.336.199-.615.366-.887.533-2.433 1.461-3.78 2.166a17.9 17.9 0 00-1.076.628c-1.353.833-2.638 1.625-4.089.602-1.828-1.289-1.621-3.574-1.176-4.781.13-.35.274-.678.408-.981.328-.743.59-1.335.39-1.727a1.475 1.475 0 00-.197-.281 2.1 2.1 0 01-1.628 1.322c-.162.635-.299 1.252-.34 1.667-.023.23-.052.465-.084.696a1.747 1.747 0 11-1.643 3.045c.587 1.302 1.401 2.36 2.236 2.892 1.897 1.21 3.818.56 4.415.358h.001a8.28 8.28 0 01.087-.03c.404-.131 2.473-1.508 4.235-2.681l.814-.542c1.468-.974 3.315-1.747 4.156-2.099.22-.092.372-.156.429-.186l.014-.007c.399-.213 1.496-.798 1.612-2.466.102-1.467-1.478-2.244-2.325-2.66-.127-.063-.238-.117-.323-.164-.27-.15-.744-.363-1.326-.626-.653-.296-1.444-.653-2.237-1.05-.342-.171-.728-.395-1.148-.638zm6.39 5.783a.727.727 0 100-1.453.727.727 0 000 1.453z"></path><path d="M4.082 11.684a6.337 6.337 0 00-.046 1.552 1.748 1.748 0 00-1.431 1.228c-.316-1.383-.345-2.869.088-4.221.078-.245.172-.491.28-.736a2.103 2.103 0 001.314 1.368c-.09.248-.161.518-.205.81z"></path></svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

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