release: ship v1.1.0
@@ -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`
|
||||
|
||||
@@ -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,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. 准备一个长线程,确保能够稳定接近上下文上限
|
||||
|
||||
@@ -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,而不是回退到工作区默认值
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
{
|
||||
"name": "lime",
|
||||
"private": true,
|
||||
"version": "1.0.1",
|
||||
"version": "1.1.0",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
|
||||
@@ -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]]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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,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",
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
[
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
|
After Width: | Height: | Size: 5.4 KiB |
@@ -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 @@
|
||||
<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 @@
|
||||
<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 |
|
After Width: | Height: | Size: 15 KiB |
@@ -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 |
|
After Width: | Height: | Size: 21 KiB |
@@ -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 @@
|
||||
<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 |
@@ -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 |
|
After Width: | Height: | Size: 15 KiB |