refactor: 深度清理旧 UI 表面与 plugin-ui 系统

## 清理范围

### 1. Plugin UI 系统(完整删除)
- 删除 `src/lib/plugin-ui/` 整个渲染运行时
- 保留 `src/lib/api/pluginUI.ts` 元数据 API
- 删除 `docs/plugin-ui-design.md` 设计文档
- 清理 i18n 中的 PluginUIRenderer 引用

### 2. Provider Pool 旧凭证表单链
- 删除 `AntigravityFormStandalone.tsx`
- 删除 `ClaudeFormStandalone.tsx`
- 删除 `GeminiFormStandalone.tsx`
- 删除 `KiroFormStandalone.tsx`
- 删除 `provider-pool/credential-forms/index.ts`

### 3. Agent Chat 旧兼容壳
- 删除 `StableProcessingNotice.tsx`
- 删除 `useStableProcessingNotice.ts`
- 删除固定的 `agent/chat/config.ts`
- 更新治理目录册与守卫

### 4. 更深层 dead UI 组件
- 删除 `ui/alert.tsx`
- 删除 `ui/radio-group.tsx`
- 删除 `ui/separator.tsx`
- 删除 `model-selector/` 整个目录
- 删除 `smart-input/` 整个目录
- 删除 `subagent/` 目录
- 删除 `websocket/` 目录
- 删除 `solutions/ecommerce-review-reply/` 整个目录

### 5. 其他清理
- 删除 `src-tauri/crates/services/src/switch.rs`
- 删除 `src-tauri/src/commands/switch_cmd.rs`
- 删除零入口 barrel 文件
- 同步更新文档与测试

## 验证通过

- ✅ 治理报告:零引用候选 0,分类漂移候选 0,边界违规 0
- ✅ 契约验证:619 frontend commands, 697 rust commands
- ✅ 治理测试:75 个测试全绿

## 影响统计

- 126 个文件变更
- +2566 行, -14468 行
- 净减少约 12000 行代码

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
coso
2026-04-05 22:19:15 +08:00
co-authored by Claude Sonnet 4.6
parent e4b93c38d8
commit c2261961c1
128 changed files with 2706 additions and 14468 deletions
+53
View File
@@ -6,6 +6,7 @@
- 什么时候一个需求已经属于“命令运行时改动”,而不是普通 UI 或普通 skill 改动
- `@`、`/`、`skill`、`ServiceSkill`、`task`、`viewer` 之间的固定关系是什么
- 服务端统一目录与客户端 seeded / fallback 应该如何配合
- 为什么命令能力不能“先写代码再补 PRD”
- 新增一个命令功能时,最少要先补哪些设计文档
- 公共设计包和单功能方案包分别放在哪里
@@ -76,6 +77,40 @@ Lime 的命令体系固定按以下关系理解:
- “某个工作台自己维护一套状态”
- “viewer 自己推断任务状态”
## 统一目录与兜底规则
命令运行时的可发现性必须统一收敛到同一份目录协议,而不是前端各处各写一份静态数组。
当前固定规则如下:
1. `SkillCatalog.entries` 是当前统一目录投影。
2. `entries.kind=command` 驱动 `@` 原子命令。
3. `entries.kind=scene` 驱动产品型 `/` 场景命令。
4. `entries.kind=skill` 驱动首页技能卡、技能中心、启动推荐和补参入口。
5. 在线主路径优先消费:
- `bootstrap.skillCatalog`
- `GET /v1/public/tenants/{tenantId}/client/skills`
6. 客户端必须保留本地 seeded catalog 作为韧性兜底:
- 未登录
- 服务端未升级
- 远端拉取失败
- 返回 legacy `items` 但未返回 `entries`
7. 如果服务端暂时只返回 legacy `items`,客户端允许在网关层兼容构造 `entries`,但这只是 compat 过渡,不是新的长期事实源。
8. 输入区、提及面板、slash 场景面板、首页技能入口都应消费同一份 catalog selector;不要继续在组件内维护第二套硬编码命令列表。
9. 如果服务端下发了 Lime 尚未支持的展示类型,优先由服务端回退到已有 `renderContract`;客户端也必须退化到通用 `tool_timeline` 或 `artifact` 展示,而不是直接失能。
当前 `scene` slash 的第一刀执行也固定如下:
- `useWorkspaceSendActions` 先识别 `/scene-key ...`
- 再通过 `useWorkspaceServiceSkillEntryActions.handleRuntimeSceneLaunch(...)` 从本地缓存 `SkillCatalog.entries` 里解析 `scene`
- 客户端按 `linkedSkillId -> ServiceSkillHomeItem` 复用已有 `ServiceSkill` 启动链,而不是新增一套 scene 执行器
- 若云端 `cloud_scene` 在创建 run 之前就失败,例如缺少会话、服务端暂不可达,客户端要自动回退到本地工作区 prompt 主链,不能让 `/scene-key` 直接失能
- 未命中统一目录的 slash 文本必须继续回到普通 slash 流程,不能被错误吞成“未找到本地 Skill”
一句话:
> 目录发现要服务端优先,但体验稳定性必须由客户端 seeded/fallback 托底。
## 四种产品分型
新增命令前,必须先判断它属于哪一种产品分型:
@@ -118,6 +153,13 @@ Lime 的命令体系固定按以下关系理解:
- 有 slot schema
- 有 run / delivery / managed 语义
当前客户端第一刀收口规则:
- `/scene-key` 不再直接落回本地 slash skill 预处理
- 先按统一目录找到 `scene` 与其 `linkedSkillId`
- 复用现有 `ServiceSkill` 启动主链
- 云端首提失败时自动回退本地工作区,保证 seeded/fallback 仍可推进
### 3. `Agent + Workflow`
适合:
@@ -212,12 +254,23 @@ Lime 的命令体系固定按以下关系理解:
- 如果涉及 `skill`,背后是 CLI、API 还是 hybrid
- 底层 truth source 是什么
### 2.5 先判目录来源与兜底策略
至少要明确:
- 这项能力是否需要出现在统一 `SkillCatalog.entries`
- 它是 `command`、`scene` 还是 `skill`
- 对应目录项由 `limecore client/skills` 下发,还是暂时由客户端 seeded
- 服务端未返回该目录项时,客户端如何回退
- 如果这项能力依赖新 render type,Lime 当前是否已经支持
### 3. 先补方案包
方案包至少要回答:
- Agent 如何判断
- 如何补参
- 目录项由谁下发,客户端如何兜底
- 轻卡长什么样
- viewer 看什么
- scope / 恢复 / 重试 / 取消怎么做
+38
View File
@@ -45,6 +45,8 @@
旧设置页里“安全与性能 / 容错配置”那组命令已经下线。`get_retry_config`、`update_retry_config`、`get_failover_config`、`update_failover_config`、`get_switch_log`、`clear_switch_log`、`get_rate_limit_config`、`update_rate_limit_config`、`get_conversation_config`、`update_conversation_config`、`update_hint_routes`、`get_pairing_config`、`update_pairing_config` 都应视为 `dead`,不允许重新接回前端网关、Rust 注册或 mock。提示路由当前只保留只读的 `get_hint_routes` 读取面;如果未来确实要恢复编辑入口,必须重新定义 `current` 主链,而不是直接复活旧设置页命令。
旧 onboarding 插件安装流与 Provider Switch 命令链也已经下线。`get_switch_providers`、`get_current_switch_provider`、`add_switch_provider`、`update_switch_provider`、`delete_switch_provider`、`switch_provider`、`import_default_config`、`read_live_provider_settings`、`check_config_sync_status`、`sync_from_external_config` 都应视为 `dead`;初装引导当前只保留语音体验流程,不再允许通过 `config-switch`、插件推荐或配置切换 UI 重新接回这条旧链。
图库素材链路也遵循同一原则。当前主入口为 `src/lib/api/galleryMaterials.ts`,统一承接:
- `create_gallery_material_metadata`
@@ -70,6 +72,42 @@
`Artifact Workbench`、文档工作台与其他导出入口如需把内容落到用户选择的本地路径,应继续复用这条主链,不要在业务组件里重新扩散 `Blob + a.download` 式浏览器旁路。
命令目录与输入补全链路同样需要单一事实源。当前前端主入口为 `src/lib/api/skillCatalog.ts`,统一承接:
- `bootstrap.skillCatalog`
- `GET /v1/public/tenants/{tenantId}/client/skills`
- 本地 seeded `SkillCatalog`
当前目录协议固定收敛到 `SkillCatalog.entries`:
- `entries.kind=command` 用于 `@` 原子命令
- `entries.kind=scene` 用于产品型 `/` 场景命令
- `entries.kind=skill` 用于首页与技能入口
固定约束:
- `CharacterMention`、`builtinCommands`、场景 slash 补全不得再各自维护一套业务命令静态常量
- 服务端尚未返回 `entries` 时,允许网关层从 legacy `items` 兼容投影出 `entries`
- 客户端必须保留 seeded fallback,不能因为服务端暂时不可用就让 `@配图`、`@转写` 这类主链入口失能
- `src/components/agent/chat/commands/catalog.ts` 只继续承接 Lime 本地 / Codex 原生命令;产品型 `/` 场景不应再长期硬编码在这里
- 若服务端下发的 `renderContract` 超出 Lime 当前支持范围,优先由服务端回退到已支持类型,客户端也必须退化到通用 timeline / artifact 展示
当前 `/scene-key` 的发送主链也已经固定:
- 发送前由 `src/components/agent/chat/workspace/useWorkspaceSendActions.ts` 统一拦截 slash 场景
- 再委托 `src/components/agent/chat/workspace/useWorkspaceServiceSkillEntryActions.ts` 的 `handleRuntimeSceneLaunch(...)`
- 运行时只从统一 catalog 解析 `scene -> linkedSkillId -> ServiceSkillHomeItem`
- 对 `cloud_scene`,优先复用现有 `createServiceSkillRun(...)` 云端运行链
- 若云端 run 在创建前就失败,客户端必须自动回退到本地工作区 prompt 主链,不能把 slash scene 直接判死
- 未命中统一 scene 目录的 slash 文本必须继续回到普通 slash / Codex 命令流,不能误报本地 Skill 不存在
如果这轮改动触达了 `client/skills` 协议,不仅要改 Lime 前端 selector,还要同步检查 `limecore` 的:
- OpenAPI source fragments
- `packages/types`
- `packages/api-client`
- `control-plane-svc` skill catalog service 与路由测试
媒体生成任务链路同样需要单一事实源。当前对外公开契约应优先收敛到 `lime media ... generate --json` 这条 CLI 主链,至少覆盖:
- `lime media image generate`
+1 -2
View File
@@ -10,14 +10,13 @@
src/lib/
├── api/ # API 封装
│ ├── apiKeyProvider.ts
│ └── pluginUI.ts
│ └── pluginUI.ts # 插件元数据 API
├── config/ # 配置
│ └── providers.ts
├── types/ # 类型定义
│ └── provider.ts
├── errors/ # 错误处理
│ └── playwrightErrors.ts
├── plugin-ui/ # 插件 UI 系统
├── tauri/ # Tauri 命令封装
├── utils/ # 工具函数
├── flowEventManager.ts # 流量事件管理
@@ -15,6 +15,7 @@
- 用户中心、个人资料、会话同步
- AI 服务商页、云端 Provider、默认来源、模型目录
- `client/bootstrap`、`client/session`、`client/profile`
- `client/skills`、`skillCatalog.entries`、`client/service-skills`
- Gateway、Scene、Service Skill 云配置同步
- 任何“客户端要不要本地维护一份服务端数据”的判断
@@ -24,6 +25,7 @@
- 认证与会话
- 客户端 bootstrap
- `client/skills` 统一命令目录
- 用户资料与账户能力
- Provider Offer / 服务目录 / Scene Catalog
- Gateway 与云端运行时策略
@@ -45,5 +47,8 @@
- 服务端已有接口时,优先补客户端接线
- 云事实源不要在客户端长期维护第二份
- `@` / 产品型 `/` 的统一目录优先看 `client/skills.entries`
- Lime 客户端必须保留 seeded / fallback 韧性兜底,不能只靠服务端在线返回
- 能走运行时配置和 `bootstrap.features` 的,不要写死在前端
- 用户界面不要直接暴露 “OEM” 技术概念
- `scene` 目录项要稳定提供 `sceneKey` 与 `linkedSkillId`。Lime 当前会用它把 `/scene-key` 解析到现有 `ServiceSkill` 启动链;若云端 run 创建前失败,客户端会自动回退到本地工作区 prompt 主链
+1
View File
@@ -30,6 +30,7 @@
- 如果 companion 协议新增了 provider 摘要、桌宠回跳设置、桌宠主动请求同步,或双击 / 三击 / 文本对话触发的桌宠 LLM 交互事件,Playwright 续测只覆盖 Lime 主仓内的“状态事件是否触发”“是否跳到 `设置 -> AI 服务商`”“是否重发脱敏摘要”“是否调用宿主侧 LLM 代理逻辑”和“主窗口是否被唤起”,不在 WebView 层尝试直接操控原生桌宠 UI
- 共享网关控制页已下线,托盘也不再展示网关状态或地址;共享网关 `/v1/routes` 与 selector HTTP 路由也已下线,不再对“启动/停止网关、复制网关地址、路由/curl 示例、selector 路由、托盘运行态文案”做 GUI 续测;server 验证只关注标准 `/v1/messages` 与 `/v1/chat/completions` 主链,如需看运行时状态,走开发者页或实验页的诊断面板
- 旧设置页里的“安全与性能 / 容错配置”已经下线,不再对这些页签、表单或命令写入路径做 GUI 续测;如果还要验证提示路由,只围绕当前输入框 `get_hint_routes` 读取链与提示展示,不再寻找旧设置页入口
- 初装引导里的旧插件选择 / 插件安装 / 配置切换链路已经下线,不再对 `config-switch` 推荐安装、Provider Switch 页面或相关命令做 GUI 续测;当前 onboarding 只围绕现役语音体验流程验证
- 项目排版模板与品牌人设扩展旧链路已下线,不再对相关弹窗、模板列表、默认模板、人设扩展表单做 GUI 续测;项目与工作台回归只围绕当前 `Claw` / `workspace` / 现役 `persona` 主链
- 如果只是模块级代码修改、并不需要真实页面交互,优先跑最小单测或 `verify:local`
+2
View File
@@ -78,6 +78,8 @@
如果本轮是在清退旧设置页的“安全与性能 / 容错配置”命令面,`get_retry_config`、`update_retry_config`、`get_failover_config`、`update_failover_config`、`get_switch_log`、`clear_switch_log`、`get_rate_limit_config`、`update_rate_limit_config`、`get_conversation_config`、`update_conversation_config`、`update_hint_routes`、`get_pairing_config`、`update_pairing_config` 也必须同步从前端网关、Rust 注册和默认 mock 中撤掉;若当前输入框提示仍依赖 `get_hint_routes`,则只保留该只读读取面。最低校验至少包含 `npm run test:contracts` 与 `npm run governance:legacy-report`。
如果本轮是在清退旧 onboarding 插件安装流或 Provider Switch 命令面,`get_switch_providers`、`get_current_switch_provider`、`add_switch_provider`、`update_switch_provider`、`delete_switch_provider`、`switch_provider`、`import_default_config`、`read_live_provider_settings`、`check_config_sync_status`、`sync_from_external_config` 也必须同步从前端常量、Rust 注册、services、默认 mock 与 GUI 入口中撤掉;当前 onboarding 只允许保留语音体验链,不再保留 `config-switch` 推荐安装面。最低校验至少包含 `npm run test:contracts` 与 `npm run governance:legacy-report`。
如果本轮涉及 `companion_*` 桌宠命令族,还要同步检查本地 companion `WebSocket` 入口、前端 `src/lib/api/companion.ts` 网关、Rust 注册、治理目录册以及浏览器模式 mock 返回形态;浏览器模式下这组命令默认也要保持可 mock,不要让桌宠接入把默认页面渲染链路卡死。
如果本轮涉及 team runtime 工具面或主线程用户消息工具,还要同步检查 Rust catalog / inventory、runtime 注册、浏览器 fallback mock 与前端 tool display;`Agent / TeamCreate / TeamDelete / SendMessage / ListPeers` 必须保持同一组 current surface,`SendUserMessage` 也必须继续停留在 current 主线程工具面,`SubAgentTask` 只能继续停留在 compat 读取边界。
+39 -10
View File
@@ -441,24 +441,44 @@ Lime 技能能力必须明确区分三个对象:
客户端现状:
- 本地 seeded skill catalog
- `bootstrap.serviceSkillCatalog`
- `client/service-skills`
- 本地 seeded `SkillCatalog`
- `bootstrap.skillCatalog`
- `client/skills`
- `client/service-skills` compat 投影
- `siteAdapterCatalog`
服务端现状:
- `control-plane-svc` 负责客户端技能目录聚合
- `control-plane-svc` 负责客户端统一技能目录聚合
- Tool Hub 方向负责 tool / adapter 工件真相源
### 长期收敛方向
长期收敛规则固定如下:
1. 统一 skill 目录收敛到 `client/skills`
2. 兼容期保留 `client/service-skills`
3. adapter / tool 工件目录继续独立,不与 skill 目录混用
4. bootstrap 与独立刷新必须消费同一份目录协议
1. 统一技能目录继续收敛到 `client/skills`
2. `bootstrap.skillCatalog` 与独立刷新必须消费同一份目录协议
3. `SkillCatalog.entries` 必须承载三类统一目录项:
- `skill`
- `command`
- `scene`
4. `client/service-skills` 只允许作为 compat 投影继续保留,不再承接新的目录标准定义
5. adapter / tool 工件目录继续独立,不与 skill 目录混用
### 统一目录补充分层
`SkillCatalog.entries` 是分发层的 current 投影,但它不改变 skill 的产品与执行边界:
- `skill` 目录项回答“这是一个什么业务能力”
- `command` 目录项回答“用户可以通过哪个 `@` 原子入口触发它”
- `scene` 目录项回答“用户可以通过哪个 `/` 场景把多个能力编排起来”
固定约束:
- `/` 场景不是新的客户端硬编码系统,而是统一目录中的 `scene`
- `@` 命令不是独立于 skill 的第二套协议,而是统一目录中的 `command`
- `command` / `scene` 可以绑定到 skill、CLI、服务端 API 或 hybrid executor,但绑定规则仍属于运行时层
- Lime 客户端必须保留 seeded catalog 作为 offline / degrade 兜底,不能只依赖服务端在线目录
## 外部 `SKILL.md` 参考边界
@@ -493,13 +513,22 @@ Lime 技能能力必须明确区分三个对象:
## 当前主链
在统一 `client/skills` 正式落地前,当前新增能力的主链固定如下:
当前新增能力的主链固定如下:
- 业务技能目录:继续收敛到 `ServiceSkillCatalog`
- 在线目录:继续收敛到 `client/skills` 与 `bootstrap.skillCatalog`
- 本地兜底:继续收敛到 seeded `SkillCatalog`
- compat 投影:仅在迁移期保留 `client/service-skills`
- 标准摘要层:继续收敛到 `skillBundle`
- 站点工件目录:继续收敛到 `siteAdapterCatalog`
- 业务 skill 引用站点能力:通过 `siteCapabilityBinding.adapterName`
对输入面板和启动入口再补一条固定约束:
- `entries.kind=command` 驱动 `@`
- `entries.kind=scene` 驱动产品型 `/`
- `entries.kind=skill` 驱动首页技能入口与技能中心
- 服务端未返回 `entries` 时,客户端允许由 compat `items` 投影构造,但不得继续在组件层手写平行常量
不要在这个阶段再引入:
- 平级 `skill.json` 目录协议
-462
View File
@@ -1,462 +0,0 @@
# Lime Plugin UI 系统设计
## 概述
借鉴 A2UI 的设计理念,为 Lime 设计一套声明式的插件 UI 系统。核心思想是:
- **安全如数据,表达如代码**:插件只能声明 UI 结构,不能执行任意代码
- **声明式 JSON 格式**:插件通过 JSON 描述 UI 意图,宿主应用负责渲染
- **组件目录(Catalog)机制**:预定义可用组件集,插件只能使用目录中的组件
- **数据绑定分离**:UI 结构与数据模型分离,支持增量更新
## 架构设计
```
┌─────────────────────────────────────────────────────────────────┐
│ Lime Host │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Plugin UI Renderer │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐ │ │
│ │ │ Component │ │ Data │ │ Event │ │ │
│ │ │ Registry │ │ Store │ │ Handler │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────────┘ │ │
│ └─────────────────────────────────────────────────────────┘ │
│ ▲ │
│ │ JSON Messages │
│ ┌───────────────────────────┼─────────────────────────────┐ │
│ │ Plugin Bridge │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐ │ │
│ │ │ Tauri │ │ Schema │ │ Message │ │ │
│ │ │ IPC │ │ Validator │ │ Router │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────────┘ │ │
│ └─────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
▲
│
┌───────────────┴───────────────┐
│ Plugin (Rust) │
│ ┌─────────────────────────┐ │
│ │ UI Declaration API │ │
│ │ - surface_update() │ │
│ │ - data_update() │ │
│ │ - begin_rendering() │ │
│ └─────────────────────────┘ │
└───────────────────────────────┘
```
## 核心概念
### 1. Surface(渲染表面)
每个插件可以拥有一个或多个 Surface,代表独立的 UI 区域:
```typescript
interface Surface {
surfaceId: string; // 唯一标识
pluginId: string; // 所属插件
rootComponentId: string; // 根组件 ID
components: Map<string, Component>; // 组件缓冲区
dataModel: Record<string, any>; // 数据模型
styles?: SurfaceStyles; // 样式配置
}
```
### 2. Component Catalog(组件目录)
预定义的安全组件集,插件只能使用这些组件:
```typescript
// 标准组件目录
const StandardCatalog = {
// 布局组件
Row: { children: 'ComponentRef[]', gap?: 'number', align?: 'Alignment' },
Column: { children: 'ComponentRef[]', gap?: 'number', align?: 'Alignment' },
Card: { child: 'ComponentRef', title?: 'BoundValue<string>' },
Tabs: { items: 'TabItem[]' },
// 展示组件
Text: { text: 'BoundValue<string>', variant?: 'TextVariant' },
Icon: { name: 'IconName', size?: 'number', color?: 'string' },
Badge: { text: 'BoundValue<string>', variant?: 'BadgeVariant' },
Progress: { value: 'BoundValue<number>', max?: 'number' },
// 输入组件
Button: { child: 'ComponentRef', action: 'Action', variant?: 'ButtonVariant' },
TextField: { label: 'BoundValue<string>', value: 'BoundValue<string>' },
Switch: { label: 'BoundValue<string>', checked: 'BoundValue<boolean>' },
Select: { options: 'SelectOption[]', value: 'BoundValue<string>' },
// 数据展示
Table: { columns: 'TableColumn[]', data: 'BoundValue<any[]>' },
List: { children: 'ChildrenDef', direction?: 'Direction' },
KeyValue: { items: 'KeyValueItem[]' },
// 反馈组件
Alert: { message: 'BoundValue<string>', type: 'AlertType' },
Spinner: { size?: 'number' },
Empty: { description?: 'BoundValue<string>' },
};
```
### 3. 消息协议
#### Server → Client 消息
```typescript
// 组件更新
interface SurfaceUpdate {
surfaceId: string;
components: ComponentDef[];
}
// 数据更新
interface DataModelUpdate {
surfaceId: string;
path?: string; // JSONPath,如 '/credentials/0/status'
contents: DataEntry[];
}
// 开始渲染
interface BeginRendering {
surfaceId: string;
root: string; // 根组件 ID
catalogId?: string;
styles?: SurfaceStyles;
}
// 删除 Surface
interface DeleteSurface {
surfaceId: string;
}
```
#### Client → Server 消息
```typescript
// 用户操作
interface UserAction {
name: string; // 操作名称
surfaceId: string;
sourceComponentId: string;
context: Record<string, any>; // 解析后的上下文数据
timestamp: string;
}
```
### 4. 数据绑定
支持字面值和路径绑定:
```typescript
type BoundValue<T> =
| { literal: T } // 字面值
| { path: string } // 数据路径
| { literal: T; path: string }; // 初始化 + 绑定
// 示例
const textComponent = {
id: 'status-text',
component: {
Text: {
text: { path: '/credential/status' }, // 绑定到数据模型
variant: 'body'
}
}
};
```
## 实现方案
### 前端:React Renderer
```
src/lib/plugin-ui/
├── index.ts # 导出入口
├── types.ts # 类型定义
├── PluginUIRenderer.tsx # 主渲染器组件
├── PluginSurface.tsx # Surface 容器
├── ComponentRegistry.ts # 组件注册表
├── DataStore.ts # 数据存储
├── MessageHandler.ts # 消息处理
└── components/ # 标准组件实现
├── layout/
│ ├── Row.tsx
│ ├── Column.tsx
│ ├── Card.tsx
│ └── Tabs.tsx
├── display/
│ ├── Text.tsx
│ ├── Icon.tsx
│ ├── Badge.tsx
│ └── Progress.tsx
├── input/
│ ├── Button.tsx
│ ├── TextField.tsx
│ ├── Switch.tsx
│ └── Select.tsx
└── data/
├── Table.tsx
├── List.tsx
└── KeyValue.tsx
```
### 后端:Rust Plugin API
```rust
// src-tauri/src/plugins/ui_api.rs
/// 插件 UI 声明 API
pub trait PluginUI {
/// 获取插件的 Surface 定义
fn get_surfaces(&self) -> Vec<SurfaceDefinition>;
/// 处理用户操作
fn handle_action(&mut self, action: UserAction) -> Result<Vec<UIMessage>>;
}
/// UI 消息类型
pub enum UIMessage {
SurfaceUpdate(SurfaceUpdate),
DataModelUpdate(DataModelUpdate),
BeginRendering(BeginRendering),
DeleteSurface(DeleteSurface),
}
/// Surface 定义
pub struct SurfaceDefinition {
pub surface_id: String,
pub initial_components: Vec<ComponentDef>,
pub initial_data: serde_json::Value,
pub root_id: String,
}
```
## 使用示例
### 插件端(Rust)
```rust
impl PluginUI for CredentialMonitorPlugin {
fn get_surfaces(&self) -> Vec<SurfaceDefinition> {
vec![SurfaceDefinition {
surface_id: "credential-monitor".into(),
root_id: "root".into(),
initial_components: vec![
component!("root", Column {
children: explicit_list!["header", "credential-list"],
gap: 16
}),
component!("header", Row {
children: explicit_list!["title", "refresh-btn"],
align: "spaceBetween"
}),
component!("title", Text {
text: literal!("凭证监控"),
variant: "h3"
}),
component!("refresh-btn", Button {
child: "refresh-icon",
action: action!("refresh")
}),
component!("refresh-icon", Icon { name: "refresh" }),
component!("credential-list", List {
children: template!("credential-item", "/credentials"),
direction: "vertical"
}),
// 模板组件
component!("credential-item", Card {
child: "item-content"
}),
component!("item-content", Row {
children: explicit_list!["item-name", "item-status"]
}),
component!("item-name", Text {
text: path!("name") // 相对路径,从列表项数据解析
}),
component!("item-status", Badge {
text: path!("status"),
variant: path!("statusVariant")
}),
],
initial_data: json!({
"credentials": []
}),
}]
}
fn handle_action(&mut self, action: UserAction) -> Result<Vec<UIMessage>> {
match action.name.as_str() {
"refresh" => {
let credentials = self.fetch_credentials()?;
Ok(vec![UIMessage::DataModelUpdate(DataModelUpdate {
surface_id: "credential-monitor".into(),
path: Some("/credentials".into()),
contents: credentials.into_data_entries(),
})])
}
_ => Ok(vec![])
}
}
}
```
### 宿主端(React)
```tsx
// 在插件详情页使用
function PluginDetailPage({ pluginId }: { pluginId: string }) {
return (
<div className="plugin-detail">
<PluginInfo pluginId={pluginId} />
{/* 插件 UI 渲染区域 */}
<PluginUIRenderer
pluginId={pluginId}
onAction={(action) => invoke('plugin_handle_action', { pluginId, action })}
/>
</div>
);
}
```
## 安全考虑
1. **组件白名单**:只允许使用预定义的组件类型
2. **Schema 验证**:所有消息必须通过 JSON Schema 验证
3. **沙箱隔离**:每个插件的 Surface 相互隔离
4. **Action 审计**:记录所有用户操作,支持权限控制
5. **资源限制**:限制组件数量、数据大小等
## 扩展机制
### 自定义组件注册
允许宿主应用注册额外的组件:
```typescript
// 注册自定义组件
componentRegistry.register('CredentialCard', CredentialCardComponent, {
schema: {
credential: { type: 'object', required: true },
onRefresh: { type: 'action' }
}
});
```
### 主题支持
通过 Surface styles 支持主题定制:
```typescript
interface SurfaceStyles {
primaryColor?: string;
font?: string;
borderRadius?: number;
// ... 更多样式属性
}
```
## 迁移路径
1. **Phase 1**:实现核心渲染器和基础组件
2. **Phase 2**:添加数据绑定和事件处理
3. **Phase 3**:迁移现有插件 UI 到新系统
4. **Phase 4**:支持自定义组件扩展
## 与 A2UI 的差异
| 特性 | A2UI | Lime Plugin UI |
|------|------|---------------------|
| 传输方式 | SSE/JSONL 流 | Tauri IPC |
| 渲染框架 | Lit/Angular/Flutter | React |
| 组件风格 | Material Design | TailwindCSS/shadcn |
| 数据更新 | 增量流式 | 批量更新 |
| 使用场景 | 跨平台 Agent UI | 桌面应用插件 |
## 实时更新:Tauri 事件推送
插件可以通过 Tauri 事件系统向前端推送 UI 更新,实现实时数据刷新。
### 事件发射器
```rust
use crate::plugin::{PluginUIEmitter, UIMessage, DataModelUpdate, DataEntry};
// 在 Tauri 命令或服务中使用
fn update_plugin_ui(emitter: &PluginUIEmitter, plugin_id: &str) {
// 发送数据更新
let update = DataModelUpdate {
surface_id: "my-surface".into(),
path: Some("/stats".into()),
contents: vec![
DataEntry::number("count", 42.0),
DataEntry::string("status", "healthy"),
],
};
emitter.emit_data_update(plugin_id, update).unwrap();
}
```
### 前端监听
前端通过 `usePluginUI` Hook 自动监听 `plugin-ui-message` 事件:
```typescript
// 自动处理,无需手动监听
const { surfaces, handleAction } = usePluginUI({ pluginId: 'my-plugin' });
```
### 事件载荷格式
```typescript
interface PluginUIEventPayload {
pluginId: string;
message: UIMessage; // SurfaceUpdate | DataModelUpdate | BeginRendering | DeleteSurface
}
```
## 示例插件:凭证监控
完整示例见 `src-tauri/src/plugin/examples/credential_monitor.rs`:
```rust
use crate::plugin::{PluginUI, SurfaceDefinition, ComponentDef, ChildrenDef, BoundValue};
struct CredentialMonitorPlugin { /* ... */ }
impl PluginUI for CredentialMonitorPlugin {
fn get_surfaces(&self) -> Vec<SurfaceDefinition> {
vec![SurfaceDefinition {
surface_id: "credential-monitor".into(),
root_id: "root".into(),
initial_components: vec![
ComponentDef::column("root", ChildrenDef::explicit(vec!["header", "list"])),
ComponentDef::text_literal("header", "凭证监控"),
ComponentDef::list("list", ChildrenDef::template("item", "/credentials")),
// ... 更多组件
],
initial_data: json!({ "credentials": [] }),
styles: None,
}]
}
async fn handle_action(&mut self, action: UserAction) -> Result<Vec<UIMessage>, PluginError> {
match action.name.as_str() {
"refresh" => {
// 返回数据更新消息
Ok(vec![UIMessage::DataModelUpdate(/* ... */)])
}
_ => Ok(vec![])
}
}
}
```
## 下一步计划
1. **更多组件**:Table、Tabs、Modal 等复杂组件
2. **表单验证**:支持 TextField 的验证规则
3. **主题系统**:更完善的样式定制能力
4. **插件市场**:支持从远程加载插件 UI 定义
@@ -30,13 +30,15 @@ struct ImageProviderRoutingConfig {
preferred_model_id: Option<String>,
allow_fallback: bool,
default_size: Option<String>,
is_explicit: bool,
}
pub(crate) async fn try_generate_with_configured_provider(
state: &AppState,
request: &ImageGenerationRequest,
explicit_provider_id: Option<&str>,
) -> Result<Option<ImageGenerationResponse>, ConfiguredImageProviderError> {
let Some(routing) = load_image_provider_routing() else {
let Some(routing) = load_image_provider_routing(explicit_provider_id) else {
return Ok(None);
};
@@ -115,8 +117,8 @@ pub(crate) async fn try_generate_with_configured_provider(
state.logs.write().await.add(
"info",
&format!(
"[IMAGE] 默认图片服务命中 API Provider: provider_id={}, model={}, size={}",
provider.id, request_model, request_size
"[IMAGE] 图片服务命中 API Provider: provider_id={}, model={}, size={}, explicit={}",
provider.id, request_model, request_size, routing.is_explicit
),
);
@@ -173,7 +175,23 @@ fn handle_routing_failure(
})
}
fn load_image_provider_routing() -> Option<ImageProviderRoutingConfig> {
fn load_image_provider_routing(
explicit_provider_id: Option<&str>,
) -> Option<ImageProviderRoutingConfig> {
if let Some(provider_id) = explicit_provider_id
.map(str::trim)
.filter(|item| !item.is_empty())
.map(|item| item.to_string())
{
return Some(ImageProviderRoutingConfig {
provider_id,
preferred_model_id: None,
allow_fallback: false,
default_size: None,
is_explicit: true,
});
}
let config_path = ConfigManager::default_config_path();
let manager = ConfigManager::load(&config_path).ok()?;
let image_preference = manager
@@ -193,6 +211,7 @@ fn load_image_provider_routing() -> Option<ImageProviderRoutingConfig> {
preferred_model_id: normalize_optional_string(image_preference.preferred_model_id),
allow_fallback: image_preference.allow_fallback,
default_size: normalize_optional_string(manager.config().image_gen.default_size.clone()),
is_explicit: false,
})
}
@@ -302,6 +321,19 @@ fn resolve_fal_queue_host(api_host: &str) -> String {
}
}
const FAL_SUPPORTED_ASPECT_RATIOS: [(&str, f64); 10] = [
("21:9", 21.0 / 9.0),
("16:9", 16.0 / 9.0),
("3:2", 3.0 / 2.0),
("4:3", 4.0 / 3.0),
("5:4", 5.0 / 4.0),
("1:1", 1.0),
("4:5", 4.0 / 5.0),
("3:4", 3.0 / 4.0),
("2:3", 2.0 / 3.0),
("9:16", 9.0 / 16.0),
];
fn size_to_aspect_ratio(size: &str) -> Option<String> {
let (width_raw, height_raw) = size.split_once('x')?;
let width = width_raw.parse::<u32>().ok()?;
@@ -311,7 +343,25 @@ fn size_to_aspect_ratio(size: &str) -> Option<String> {
}
let gcd = greatest_common_divisor(width, height);
Some(format!("{}:{}", width / gcd, height / gcd))
let exact_ratio = format!("{}:{}", width / gcd, height / gcd);
if FAL_SUPPORTED_ASPECT_RATIOS
.iter()
.any(|(label, _)| *label == exact_ratio)
{
return Some(exact_ratio);
}
let numeric_ratio = width as f64 / height as f64;
let nearest = FAL_SUPPORTED_ASPECT_RATIOS
.iter()
.map(|(label, ratio)| (*label, (numeric_ratio - ratio).abs()))
.min_by(|left, right| left.1.total_cmp(&right.1));
match nearest {
Some((label, diff)) if diff <= 0.08 => Some(label.to_string()),
Some(_) => Some("auto".to_string()),
None => None,
}
}
fn greatest_common_divisor(mut left: u32, mut right: u32) -> u32 {
@@ -397,7 +447,7 @@ async fn post_fal_json(
return Err(format!(
"Fal HTTP {}: {}",
status.as_u16(),
preview_text(&body, 240)
summarize_fal_error_body(&body)
));
}
@@ -523,7 +573,7 @@ async fn get_fal_json(client: &Client, endpoint: &str, api_key: &str) -> Result<
return Err(format!(
"Fal GET HTTP {}: {}",
status.as_u16(),
preview_text(&body, 240)
summarize_fal_error_body(&body)
));
}
@@ -547,12 +597,25 @@ async fn build_openai_response(
let mut data = Vec::with_capacity(image_urls.len());
for image_url in image_urls {
if response_format == "b64_json" {
let b64_json = download_image_as_base64(client, image_url).await?;
data.push(ImageData {
b64_json: Some(b64_json),
url: None,
revised_prompt: None,
});
match download_image_as_base64(client, image_url).await {
Ok(b64_json) => data.push(ImageData {
b64_json: Some(b64_json),
url: None,
revised_prompt: None,
}),
Err(error) => {
tracing::warn!(
"[IMAGE] 图片二次下载失败,回退原始 URL: url={}, error={}",
image_url,
error
);
data.push(ImageData {
b64_json: None,
url: Some(image_url.clone()),
revised_prompt: None,
});
}
}
} else {
data.push(ImageData {
b64_json: None,
@@ -600,13 +663,23 @@ async fn download_image_as_base64(client: &Client, image_url: &str) -> Result<St
fn collect_image_urls(value: &Value) -> Vec<String> {
let mut urls = Vec::new();
collect_image_urls_inner(value, &mut urls);
collect_image_urls_inner(value, None, &mut urls);
urls
}
fn collect_image_urls_inner(value: &Value, urls: &mut Vec<String>) {
fn should_skip_control_url_key(key: &str) -> bool {
matches!(
key,
"status_url" | "statusUrl" | "response_url" | "responseUrl" | "cancel_url" | "cancelUrl"
)
}
fn collect_image_urls_inner(value: &Value, parent_key: Option<&str>, urls: &mut Vec<String>) {
match value {
Value::String(text) => {
if parent_key.is_some_and(should_skip_control_url_key) {
return;
}
let trimmed = text.trim();
if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
push_unique(urls, trimmed.to_string());
@@ -614,7 +687,7 @@ fn collect_image_urls_inner(value: &Value, urls: &mut Vec<String>) {
}
Value::Array(items) => {
for item in items {
collect_image_urls_inner(item, urls);
collect_image_urls_inner(item, None, urls);
}
}
Value::Object(map) => {
@@ -640,8 +713,8 @@ fn collect_image_urls_inner(value: &Value, urls: &mut Vec<String>) {
}
}
for nested in map.values() {
collect_image_urls_inner(nested, urls);
for (key, nested) in map {
collect_image_urls_inner(nested, Some(key.as_str()), urls);
}
}
_ => {}
@@ -654,6 +727,54 @@ fn push_unique(urls: &mut Vec<String>, candidate: String) {
}
}
fn summarize_fal_error_body(body: &str) -> String {
let normalized = body.trim();
if normalized.is_empty() {
return "Fal 返回了空响应。".to_string();
}
if normalized.contains("aspect_ratio") {
return "当前图片服务不支持这个画幅比例,请改用 21:9、16:9、3:2、4:3、5:4、1:1、4:5、3:4、2:3 或 9:16。".to_string();
}
if let Ok(parsed) = serde_json::from_str::<Value>(normalized) {
if let Some(message) = parsed
.get("message")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
return message.to_string();
}
if let Some(message) = parsed
.get("error")
.and_then(Value::as_object)
.and_then(|error| error.get("message"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
return message.to_string();
}
if let Some(message) = parsed
.get("detail")
.and_then(Value::as_array)
.and_then(|items| items.first())
.and_then(Value::as_object)
.and_then(|record| record.get("msg"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
return message.to_string();
}
}
preview_text(normalized, 240)
}
fn preview_text(text: &str, max_len: usize) -> String {
let normalized = text.trim();
let normalized_chars = normalized.chars().count();
@@ -667,7 +788,8 @@ fn preview_text(text: &str, max_len: usize) -> String {
#[cfg(test)]
mod tests {
use super::{
collect_image_urls, normalize_fal_api_host, resolve_fal_model, size_to_aspect_ratio,
collect_image_urls, load_image_provider_routing, normalize_fal_api_host, resolve_fal_model,
size_to_aspect_ratio,
};
use serde_json::json;
@@ -696,9 +818,11 @@ mod tests {
}
#[test]
fn size_to_aspect_ratio_reduces_fraction() {
fn size_to_aspect_ratio_maps_to_supported_fal_values() {
assert_eq!(size_to_aspect_ratio("1024x1024"), Some("1:1".to_string()));
assert_eq!(size_to_aspect_ratio("1792x1024"), Some("7:4".to_string()));
assert_eq!(size_to_aspect_ratio("1792x1024"), Some("16:9".to_string()));
assert_eq!(size_to_aspect_ratio("1024x1792"), Some("9:16".to_string()));
assert_eq!(size_to_aspect_ratio("1000x100"), Some("auto".to_string()));
assert_eq!(size_to_aspect_ratio("invalid"), None);
}
@@ -723,4 +847,34 @@ mod tests {
]
);
}
#[test]
fn collect_image_urls_ignores_fal_queue_control_urls() {
let payload = json!({
"status_url": "https://queue.fal.run/fal-ai/nano-banana/requests/req-1/status",
"response_url": "https://queue.fal.run/fal-ai/nano-banana/requests/req-1/response",
"cancel_url": "https://queue.fal.run/fal-ai/nano-banana/requests/req-1/cancel",
"data": [
{
"url": "https://cdn.example.com/final-image.png"
}
]
});
assert_eq!(
collect_image_urls(&payload),
vec!["https://cdn.example.com/final-image.png".to_string()]
);
}
#[test]
fn load_image_provider_routing_prefers_explicit_provider_without_fallback() {
let routing = load_image_provider_routing(Some("fal")).expect("explicit provider routing");
assert_eq!(routing.provider_id, "fal");
assert_eq!(routing.preferred_model_id, None);
assert_eq!(routing.default_size, None);
assert!(!routing.allow_fallback);
assert!(routing.is_explicit);
}
}
@@ -33,6 +33,15 @@ use lime_providers::converter::openai_to_antigravity::{
};
use lime_providers::providers::AntigravityProvider;
fn read_explicit_provider_id(headers: &HeaderMap) -> Option<String> {
headers
.get("x-provider-id")
.and_then(|value| value.to_str().ok())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| value.to_string())
}
/// 处理图像生成请求
///
/// # 端点
@@ -102,28 +111,34 @@ pub async fn handle_image_generation(
),
);
match image_api_provider::try_generate_with_configured_provider(&state, &request).await {
let explicit_provider_id = read_explicit_provider_id(&headers);
match image_api_provider::try_generate_with_configured_provider(
&state,
&request,
explicit_provider_id.as_deref(),
)
.await
{
Ok(Some(response)) => {
state.logs.write().await.add(
"info",
&format!(
"[IMAGE] 默认图片服务生成成功: {} 张图片",
response.data.len()
),
&format!("[IMAGE] 图片服务生成成功: {} 张图片", response.data.len()),
);
return (StatusCode::OK, Json(response)).into_response();
}
Ok(None) => {
state.logs.write().await.add(
"debug",
"[IMAGE] 默认图片服务未命中,继续回退到 Antigravity 兼容链路",
"[IMAGE] 图片服务未命中当前路由,继续回退到 Antigravity 兼容链路",
);
}
Err(error) => {
state.logs.write().await.add(
"error",
&format!("[IMAGE] 默认图片服务失败: {}", error.message),
);
state
.logs
.write()
.await
.add("error", &format!("[IMAGE] 图片服务失败: {}", error.message));
return (
error.status,
Json(serde_json::json!({
-3
View File
@@ -36,7 +36,6 @@
//! - `model_service` - 模型服务
//! - `prompt_service` - Prompt 服务
//! - `mcp_service` - MCP 服务
//! - `switch` - Provider 切换
//! - `aster_session_store` - Aster 会话存储
//! - `session_context_service` - 会话上下文服务
//! - `ai_summary_service` - AI 摘要服务
@@ -77,8 +76,6 @@ pub mod model_registry_service;
pub mod model_service;
pub mod persona_service;
pub mod prompt_service;
pub mod switch;
// 依赖其他 services 的服务
pub mod ai_summary_service;
pub mod project_context_builder;
-316
View File
@@ -148,10 +148,6 @@ fn get_shell_config_target(
}
}
fn get_shell_config_path() -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
Ok(get_shell_config_target()?.0)
}
fn escape_shell_env_value(value: &str, syntax: ShellConfigSyntax) -> String {
match syntax {
ShellConfigSyntax::Posix => value.replace('\\', "\\\\").replace('"', "\\\""),
@@ -167,57 +163,6 @@ fn format_shell_env_line(key: &str, value: &str, syntax: ShellConfigSyntax) -> S
}
}
fn parse_key_value(expr: &str) -> Option<(String, String)> {
let eq_pos = expr.find('=')?;
let key = expr[..eq_pos].trim();
if key.is_empty() {
return None;
}
let value = expr[eq_pos + 1..].trim().to_string();
Some((key.to_string(), value))
}
fn unquote_shell_value(raw: &str) -> String {
let value = if (raw.starts_with('"') && raw.ends_with('"'))
|| (raw.starts_with('\'') && raw.ends_with('\''))
{
raw[1..raw.len() - 1].to_string()
} else {
raw.to_string()
};
value.replace("\\\"", "\"").replace("\\\\", "\\")
}
fn unquote_powershell_value(raw: &str) -> String {
let value = if (raw.starts_with('"') && raw.ends_with('"'))
|| (raw.starts_with('\'') && raw.ends_with('\''))
{
raw[1..raw.len() - 1].to_string()
} else {
raw.to_string()
};
value.replace("`\"", "\"").replace("``", "`")
}
fn parse_shell_env_line(line: &str) -> Option<(String, String)> {
let trimmed = line.trim();
if let Some(export_line) = trimmed.strip_prefix("export ") {
let (key, value) = parse_key_value(export_line)?;
return Some((key, unquote_shell_value(&value)));
}
if let Some(env_line) = trimmed
.strip_prefix("$env:")
.or_else(|| trimmed.strip_prefix("$Env:"))
{
let (key, value) = parse_key_value(env_line)?;
return Some((key, unquote_powershell_value(&value)));
}
None
}
/// 将环境变量写入 shell 配置文件
/// 使用标记块管理,避免重复添加
///
@@ -302,41 +247,6 @@ pub fn write_env_to_shell_config(
Ok(())
}
/// 从 shell 配置文件读取 Lime 管理的环境变量
fn read_env_from_shell_config(
) -> Result<Vec<(String, String)>, Box<dyn std::error::Error + Send + Sync>> {
let config_path = get_shell_config_path()?;
if !config_path.exists() {
return Ok(Vec::new());
}
let content = fs::read_to_string(&config_path)?;
let mut env_vars = Vec::new();
let mut in_lime_block = false;
for line in content.lines() {
let trimmed = line.trim();
if trimmed == ENV_BLOCK_START {
in_lime_block = true;
continue;
}
if trimmed == ENV_BLOCK_END {
in_lime_block = false;
continue;
}
if in_lime_block {
if let Some((key, value)) = parse_shell_env_line(trimmed) {
env_vars.push((key, value));
}
}
}
Ok(env_vars)
}
/// Get the configuration file path for an app type
#[allow(dead_code)]
pub fn get_app_config_path(app_type: &AppType) -> Option<PathBuf> {
@@ -648,232 +558,6 @@ pub fn read_live_settings(
}
}
/// 同步状态枚举
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub enum SyncStatus {
InSync, // 完全同步
OutOfSync, // 有差异但无冲突
Conflict, // 有冲突需要用户选择
}
/// 配置冲突信息
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ConfigConflict {
pub field: String,
pub local_value: String,
pub external_value: String,
}
/// 同步检查结果
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct SyncCheckResult {
pub status: SyncStatus,
pub current_provider: String,
pub external_provider: String,
pub last_modified: Option<String>,
pub conflicts: Vec<ConfigConflict>,
}
/// 从外部配置文件解析当前生效的 provider
pub fn parse_current_provider_from_live(
app_type: &AppType,
live_settings: &Value,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
match app_type {
AppType::Claude => {
// 检查 Claude 配置中的认证信息来判断当前 provider
if let Some(env) = live_settings.get("env").and_then(|v| v.as_object()) {
// 优先检查 ANTHROPIC_AUTH_TOKEN (OAuth)
if let Some(token) = env.get("ANTHROPIC_AUTH_TOKEN").and_then(|v| v.as_str()) {
if !token.is_empty() {
return Ok("claude_oauth".to_string());
}
}
// 检查 ANTHROPIC_API_KEY (API Key)
if let Some(api_key) = env.get("ANTHROPIC_API_KEY").and_then(|v| v.as_str()) {
if !api_key.is_empty() {
return Ok("claude".to_string());
}
}
}
Ok("unknown".to_string())
}
AppType::Codex => {
// 检查 Codex 认证信息
if let Some(auth) = live_settings.get("auth").and_then(|v| v.as_object()) {
if auth
.get("access_token")
.and_then(|v| v.as_str())
.map(|s| !s.is_empty())
.unwrap_or(false)
{
return Ok("codex".to_string());
}
}
Ok("unknown".to_string())
}
AppType::Gemini => {
// 检查 Gemini 环境变量
if let Some(env) = live_settings.get("env").and_then(|v| v.as_object()) {
if let Some(api_key) = env.get("GOOGLE_API_KEY").and_then(|v| v.as_str()) {
if !api_key.is_empty() {
return Ok("gemini".to_string());
}
}
}
Ok("unknown".to_string())
}
AppType::Lime => Ok("lime".to_string()),
}
}
/// 检查配置同步状态
pub fn check_config_sync(
app_type: &AppType,
current_provider: &str,
) -> Result<SyncCheckResult, Box<dyn std::error::Error + Send + Sync>> {
// 读取外部配置文件
let live_settings = read_live_settings(app_type)?;
// 解析外部配置中的当前 provider
let external_provider = parse_current_provider_from_live(app_type, &live_settings)?;
// 获取配置文件的修改时间
let last_modified = get_config_last_modified(app_type);
// 比较配置 - 重要:需要更智能的比对逻辑
let status = if current_provider == external_provider {
SyncStatus::InSync
} else if external_provider == "unknown" {
// 外部配置无法识别,可能是配置文件不存在或损坏
SyncStatus::OutOfSync
} else {
// 这里需要更智能的判断:
// 如果外部配置是通过Lime设置的,应该检查是否已有匹配的provider
// 只有确实是来自其他外部软件的配置才标记为冲突
// 对于简化,先标记为冲突,让前端的更详细的比对逻辑来处理
// 实际上前端的 configsMatch 函数会做更精确的比对
SyncStatus::Conflict
};
// 检测具体的冲突字段
let conflicts = if matches!(status, SyncStatus::Conflict) {
vec![ConfigConflict {
field: "provider".to_string(),
local_value: current_provider.to_string(),
external_value: external_provider.clone(),
}]
} else {
vec![]
};
Ok(SyncCheckResult {
status,
current_provider: current_provider.to_string(),
external_provider,
last_modified,
conflicts,
})
}
/// 获取配置文件的最后修改时间
fn get_config_last_modified(app_type: &AppType) -> Option<String> {
let home = dirs::home_dir()?;
let path = match app_type {
AppType::Claude => home.join(".claude").join("settings.json"),
AppType::Codex => home.join(".codex").join("auth.json"),
AppType::Gemini => home.join(".gemini").join(".env"),
AppType::Lime => return None,
};
if let Ok(metadata) = std::fs::metadata(&path) {
if let Ok(modified) = metadata.modified() {
if let Ok(datetime) = modified.duration_since(std::time::UNIX_EPOCH) {
return Some(datetime.as_secs().to_string());
}
}
}
None
}
/// 从外部配置同步到 Lime 数据库
/// 这个函数需要与 switch service 集成来更新数据库中的 provider 记录
pub fn sync_from_external(
app_type: &AppType,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
// 读取外部配置
let live_settings = read_live_settings(app_type)?;
// 解析当前生效的 provider
let external_provider = parse_current_provider_from_live(app_type, &live_settings)?;
if external_provider == "unknown" {
return Err("无法识别外部配置中的 provider".into());
}
// 返回检测到的 provider,由调用方负责更新数据库
Ok(external_provider)
}
/// 读取配置用于前端显示(包含配置文件和环境变量)
pub fn read_live_settings_for_display(
app_type: &AppType,
) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
let home = dirs::home_dir().ok_or("Cannot find home directory")?;
match app_type {
AppType::Claude => {
let path = home.join(".claude").join("settings.json");
// 读取配置文件
let config_file: Value = if path.exists() {
let content = std::fs::read_to_string(&path)?;
serde_json::from_str(&content)?
} else {
json!({})
};
// 读取 shell 环境变量
let shell_env_vars = read_env_from_shell_config().unwrap_or_default();
let mut shell_env_obj = serde_json::Map::new();
for (key, value) in shell_env_vars {
shell_env_obj.insert(key, json!(value));
}
// 获取 shell 配置文件路径
let shell_config_path = get_shell_config_path()
.map(|p| p.display().to_string())
.unwrap_or_else(|_| {
#[cfg(target_os = "windows")]
{
"Documents/PowerShell/Microsoft.PowerShell_profile.ps1".to_string()
}
#[cfg(not(target_os = "windows"))]
{
"~/.zshrc or ~/.bashrc".to_string()
}
});
// 返回包含两部分的结构
Ok(json!({
"configFile": config_file,
"shellEnv": shell_env_obj,
"shellConfigPath": shell_config_path
}))
}
_ => {
// 其他类型直接返回原始配置
read_live_settings(app_type)
}
}
}
// 包含测试模块
#[cfg(test)]
#[path = "live_sync_tests.rs"]
-403
View File
@@ -1,403 +0,0 @@
use crate::live_sync;
use lime_core::database::dao::providers::ProviderDao;
use lime_core::database::DbConnection;
use lime_core::models::{AppType, Provider};
use once_cell::sync::Lazy;
use tokio::sync::Mutex;
pub struct SwitchService;
static SWITCH_PROVIDER_LOCK: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
/// 用于在异步上下文中传递的切换数据
struct SwitchContext {
target_provider: Provider,
current_provider: Option<Provider>,
app_type_enum: AppType,
}
impl SwitchService {
pub fn get_providers(db: &DbConnection, app_type: &str) -> Result<Vec<Provider>, String> {
let conn = db.lock().map_err(|e| e.to_string())?;
ProviderDao::get_all(&conn, app_type).map_err(|e| e.to_string())
}
pub fn get_current_provider(
db: &DbConnection,
app_type: &str,
) -> Result<Option<Provider>, String> {
let conn = db.lock().map_err(|e| e.to_string())?;
ProviderDao::get_current(&conn, app_type).map_err(|e| e.to_string())
}
pub fn add_provider(db: &DbConnection, provider: Provider) -> Result<(), String> {
let conn = db.lock().map_err(|e| e.to_string())?;
// Check if this is the first provider for this app type
let existing =
ProviderDao::get_all(&conn, &provider.app_type).map_err(|e| e.to_string())?;
let is_first = existing.is_empty();
ProviderDao::insert(&conn, &provider).map_err(|e| e.to_string())?;
// If this is the first provider, automatically set it as current and sync
if is_first {
ProviderDao::set_current(&conn, &provider.app_type, &provider.id)
.map_err(|e| e.to_string())?;
if let Ok(app_type_enum) = provider.app_type.parse::<AppType>() {
if app_type_enum != AppType::Lime {
live_sync::sync_to_live(&app_type_enum, &provider)
.map_err(|e| format!("Failed to sync: {e}"))?;
}
}
}
Ok(())
}
pub fn update_provider(db: &DbConnection, provider: Provider) -> Result<(), String> {
let conn = db.lock().map_err(|e| e.to_string())?;
// Check if this is the current provider
let current =
ProviderDao::get_current(&conn, &provider.app_type).map_err(|e| e.to_string())?;
let is_current = current
.as_ref()
.map(|p| p.id == provider.id)
.unwrap_or(false);
ProviderDao::update(&conn, &provider).map_err(|e| e.to_string())?;
// If this is the current provider, sync to live
if is_current {
if let Ok(app_type_enum) = provider.app_type.parse::<AppType>() {
if app_type_enum != AppType::Lime {
live_sync::sync_to_live(&app_type_enum, &provider)
.map_err(|e| format!("Failed to sync: {e}"))?;
}
}
}
Ok(())
}
pub fn delete_provider(db: &DbConnection, app_type: &str, id: &str) -> Result<(), String> {
let conn = db.lock().map_err(|e| e.to_string())?;
// Check if trying to delete the current provider
let current = ProviderDao::get_current(&conn, app_type).map_err(|e| e.to_string())?;
if let Some(ref current_provider) = current {
if current_provider.id == id {
return Err("Cannot delete the currently active provider".to_string());
}
}
ProviderDao::delete(&conn, app_type, id).map_err(|e| e.to_string())
}
pub fn switch_provider(db: &DbConnection, app_type: &str, id: &str) -> Result<(), String> {
use tracing::{error, info, warn};
info!("开始切换 {} 配置到 provider: {}", app_type, id);
let conn = db.lock().map_err(|e| e.to_string())?;
// Get target provider
let target_provider = ProviderDao::get_by_id(&conn, app_type, id)
.map_err(|e| {
error!("查找目标 provider 失败: {}", e);
e.to_string()
})?
.ok_or_else(|| {
error!("目标 provider 不存在: {}", id);
format!("Provider not found: {id}")
})?;
let app_type_enum = app_type.parse::<AppType>().map_err(|e| {
error!("无效的 app_type: {} - {}", app_type, e);
e.to_string()
})?;
// 获取当前 provider(用于回填和回滚)
let current_provider = if app_type_enum != AppType::Lime {
ProviderDao::get_current(&conn, app_type).map_err(|e| {
error!("获取当前 provider 失败: {}", e);
e.to_string()
})?
} else {
None
};
// 实施事务保护:先尝试同步,再更新数据库
if app_type_enum != AppType::Lime {
// Step 1: Backfill - 回填当前配置
if let Some(ref current) = current_provider {
if current.id != id {
info!("回填当前配置: {}", current.name);
match live_sync::read_live_settings(&app_type_enum) {
Ok(live_settings) => {
let mut updated_provider = current.clone();
updated_provider.settings_config = live_settings;
if let Err(e) = ProviderDao::update(&conn, &updated_provider) {
warn!("回填配置失败,但继续执行: {}", e);
} else {
info!("回填配置完成");
}
}
Err(e) => {
warn!("读取当前配置失败,跳过回填: {}", e);
}
}
}
}
// Step 2: 尝试同步新配置(在更新数据库前验证)
info!("验证目标配置可同步性");
if let Err(sync_error) = live_sync::sync_to_live(&app_type_enum, &target_provider) {
error!("配置同步失败: {}", sync_error);
// 尝试恢复原配置(如果有)
if let Some(ref current) = current_provider {
warn!("尝试恢复原配置: {}", current.name);
if let Err(restore_error) = live_sync::sync_to_live(&app_type_enum, current) {
error!("恢复原配置失败: {}", restore_error);
return Err(format!("切换失败且无法恢复原配置: {sync_error}"));
}
}
return Err(format!("配置同步失败: {sync_error}"));
}
}
// Step 3: 更新数据库(同步成功后)
info!("更新数据库中的当前 provider");
if let Err(db_error) = ProviderDao::set_current(&conn, app_type, id) {
error!("数据库更新失败: {}", db_error);
// 如果数据库更新失败,尝试恢复原配置文件
if app_type_enum != AppType::Lime {
if let Some(ref current) = current_provider {
warn!("数据库更新失败,尝试恢复原配置文件");
if let Err(restore_error) = live_sync::sync_to_live(&app_type_enum, current) {
error!("恢复配置文件失败: {}", restore_error);
}
}
}
return Err(db_error.to_string());
}
info!("配置切换成功: {} -> {}", app_type, target_provider.name);
Ok(())
}
/// 异步版本的 switch_provider,优化 Windows 性能
///
/// 优化策略:
/// 1. 减少数据库锁持有时间 - 先获取数据,释放锁,执行 I/O,再获取锁更新
/// 2. 使用 spawn_blocking 将文件 I/O 移出主线程
/// 3. 使用全局互斥锁确保切换流程串行化,避免并发写入
pub async fn switch_provider_async(
db: &DbConnection,
app_type: &str,
id: &str,
) -> Result<(), String> {
use tracing::{error, info, warn};
info!("开始切换 {} 配置到 provider: {} (异步)", app_type, id);
let _switch_guard = SWITCH_PROVIDER_LOCK.lock().await;
// Step 1: 获取数据(短暂持有锁)
let ctx = {
let conn = db.lock().map_err(|e| e.to_string())?;
// Get target provider
let target_provider = ProviderDao::get_by_id(&conn, app_type, id)
.map_err(|e| {
error!("查找目标 provider 失败: {}", e);
e.to_string()
})?
.ok_or_else(|| {
error!("目标 provider 不存在: {}", id);
format!("Provider not found: {id}")
})?;
let app_type_enum = app_type.parse::<AppType>().map_err(|e| {
error!("无效的 app_type: {} - {}", app_type, e);
e.to_string()
})?;
// 获取当前 provider(用于回填和回滚)
let current_provider = if app_type_enum != AppType::Lime {
ProviderDao::get_current(&conn, app_type).map_err(|e| {
error!("获取当前 provider 失败: {}", e);
e.to_string()
})?
} else {
None
};
// 锁在这里释放
SwitchContext {
target_provider,
current_provider,
app_type_enum,
}
};
// Step 2: 执行文件 I/O(在后台线程,不持有锁)
if ctx.app_type_enum != AppType::Lime {
let current_for_backfill = ctx.current_provider.clone();
let app_type_for_sync = ctx.app_type_enum;
let target_id = id.to_string();
// 使用 spawn_blocking 将文件 I/O 移到后台线程
let sync_result = tokio::task::spawn_blocking(move || {
// Step 2a: Backfill - 回填当前配置
if let Some(ref current) = current_for_backfill {
if current.id != target_id {
info!("回填当前配置: {}", current.name);
match live_sync::read_live_settings(&app_type_for_sync) {
Ok(live_settings) => {
// 返回需要更新的 provider 数据
Some((current.clone(), live_settings))
}
Err(e) => {
warn!("读取当前配置失败,跳过回填: {}", e);
None
}
}
} else {
None
}
} else {
None
}
})
.await
.map_err(|e| format!("后台任务失败: {e}"))?;
// 如果需要回填,更新数据库(短暂持有锁)
if let Some((mut current, live_settings)) = sync_result {
let conn = db.lock().map_err(|e| e.to_string())?;
current.settings_config = live_settings;
if let Err(e) = ProviderDao::update(&conn, &current) {
warn!("回填配置失败,但继续执行: {}", e);
} else {
info!("回填配置完成");
}
// 锁在这里释放
}
// Step 2b: 同步新配置(在后台线程)
let target_for_sync = ctx.target_provider.clone();
let current_for_restore = ctx.current_provider.clone();
let app_type_for_sync = ctx.app_type_enum;
tokio::task::spawn_blocking(move || {
info!("验证目标配置可同步性");
if let Err(sync_error) =
live_sync::sync_to_live(&app_type_for_sync, &target_for_sync)
{
error!("配置同步失败: {}", sync_error);
// 尝试恢复原配置(如果有)
if let Some(ref current) = current_for_restore {
warn!("尝试恢复原配置: {}", current.name);
if let Err(restore_error) =
live_sync::sync_to_live(&app_type_for_sync, current)
{
error!("恢复原配置失败: {}", restore_error);
return Err(format!("切换失败且无法恢复原配置: {sync_error}"));
}
}
return Err(format!("配置同步失败: {sync_error}"));
}
Ok(())
})
.await
.map_err(|e| format!("后台任务失败: {e}"))??;
}
// Step 3: 更新数据库(短暂持有锁)
{
let conn = db.lock().map_err(|e| e.to_string())?;
info!("更新数据库中的当前 provider");
if let Err(db_error) = ProviderDao::set_current(&conn, app_type, id) {
error!("数据库更新失败: {}", db_error);
// 如果数据库更新失败,尝试恢复原配置文件
if ctx.app_type_enum != AppType::Lime {
if let Some(ref current) = ctx.current_provider {
warn!("数据库更新失败,尝试恢复原配置文件");
let current_clone = current.clone();
let app_type_clone = ctx.app_type_enum;
// 在后台线程恢复
let _ = tokio::task::spawn_blocking(move || {
if let Err(restore_error) =
live_sync::sync_to_live(&app_type_clone, &current_clone)
{
error!("恢复配置文件失败: {}", restore_error);
}
});
}
}
return Err(db_error.to_string());
}
// 锁在这里释放
}
info!("配置切换成功: {} -> {}", app_type, ctx.target_provider.name);
Ok(())
}
/// Import current live config as a default provider
pub fn import_default_config(db: &DbConnection, app_type: &str) -> Result<bool, String> {
let conn = db.lock().map_err(|e| e.to_string())?;
// Check if providers already exist
let existing = ProviderDao::get_all(&conn, app_type).map_err(|e| e.to_string())?;
if !existing.is_empty() {
return Ok(false); // Already has providers, skip import
}
let app_type_enum = app_type.parse::<AppType>().map_err(|e| e.to_string())?;
// Skip for Lime
if app_type_enum == AppType::Lime {
return Ok(false);
}
// Read live settings
let live_settings = live_sync::read_live_settings(&app_type_enum)
.map_err(|e| format!("Failed to read live settings: {e}"))?;
// Create default provider
let provider = Provider {
id: "default".to_string(),
app_type: app_type.to_string(),
name: "Default (Imported)".to_string(),
settings_config: live_settings,
category: Some("custom".to_string()),
icon: None,
icon_color: Some("#6366f1".to_string()),
notes: Some("Imported from existing configuration".to_string()),
is_current: true,
sort_index: Some(0),
created_at: Some(chrono::Utc::now().timestamp()),
};
ProviderDao::insert(&conn, &provider).map_err(|e| e.to_string())?;
Ok(true)
}
/// Read current live settings for an app type
pub fn read_live_settings(app_type: &str) -> Result<serde_json::Value, String> {
let app_type_enum = app_type.parse::<AppType>().map_err(|e| e.to_string())?;
live_sync::read_live_settings_for_display(&app_type_enum).map_err(|e| e.to_string())
}
}
-11
View File
@@ -1114,17 +1114,6 @@ pub fn run() {
// API test commands (from app::commands)
app_commands::get_available_models,
app_commands::check_api_compatibility,
// Switch commands
commands::switch_cmd::get_switch_providers,
commands::switch_cmd::get_current_switch_provider,
commands::switch_cmd::add_switch_provider,
commands::switch_cmd::update_switch_provider,
commands::switch_cmd::delete_switch_provider,
commands::switch_cmd::switch_provider,
commands::switch_cmd::import_default_config,
commands::switch_cmd::read_live_provider_settings,
commands::switch_cmd::check_config_sync_status,
commands::switch_cmd::sync_from_external_config,
// Config commands
commands::config_cmd::get_config_status,
commands::config_cmd::get_config_dir_path,
+90 -17
View File
@@ -513,15 +513,25 @@ async fn execute_image_generation_task(
model: prepared_input.model.clone(),
n: prepared_input.count.max(1),
size: prepared_input.size.clone(),
response_format: "url".to_string(),
response_format: "b64_json".to_string(),
quality: None,
style: prepared_input.style.clone(),
user: Some(task_id.clone()),
};
let response = client
let mut request_builder = client
.post(&runner_config.endpoint)
.header("Authorization", format!("Bearer {}", runner_config.api_key))
.header("Authorization", format!("Bearer {}", runner_config.api_key));
if let Some(provider_id) = prepared_input
.provider_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
request_builder = request_builder.header("X-Provider-Id", provider_id);
}
let response = request_builder
.json(&request_body)
.send()
.await
@@ -890,7 +900,12 @@ pub fn cancel_media_task_artifact(
#[cfg(test)]
mod tests {
use super::*;
use axum::{http::StatusCode, routing::post, Json, Router};
use axum::{
http::{HeaderMap, StatusCode},
routing::post,
Json, Router,
};
use std::sync::{Arc, Mutex};
use tokio::net::TcpListener;
#[test]
@@ -1132,6 +1147,8 @@ mod tests {
#[tokio::test]
async fn execute_image_generation_task_should_advance_task_file_to_succeeded() {
let temp_dir = tempfile::tempdir().expect("create temp dir");
let captured_provider_id = Arc::new(Mutex::new(None::<String>));
let captured_response_format = Arc::new(Mutex::new(None::<String>));
let created =
create_image_generation_task_artifact_inner(CreateImageGenerationTaskArtifactRequest {
project_root_path: temp_dir.path().to_string_lossy().to_string(),
@@ -1165,22 +1182,40 @@ mod tests {
.await
.expect("bind image api");
let address = listener.local_addr().expect("resolve address");
let captured_provider_id_for_server = Arc::clone(&captured_provider_id);
let captured_response_format_for_server = Arc::clone(&captured_response_format);
let server = tokio::spawn(async move {
let app = Router::new().route(
"/v1/images/generations",
post(|| async move {
(
StatusCode::OK,
Json(json!({
"created": 1_717_200_000i64,
"data": [
{
"url": "https://example.com/generated-lime.png",
"revised_prompt": "未来感青柠实验室主视觉"
}
]
})),
)
post(move |headers: HeaderMap, Json(body): Json<Value>| {
let captured_provider_id = Arc::clone(&captured_provider_id_for_server);
let captured_response_format = Arc::clone(&captured_response_format_for_server);
async move {
let provider_id = headers
.get("x-provider-id")
.and_then(|value| value.to_str().ok())
.map(|value| value.to_string());
*captured_provider_id.lock().expect("lock provider id") = provider_id;
let response_format = body
.get("response_format")
.and_then(Value::as_str)
.map(|value| value.to_string());
*captured_response_format
.lock()
.expect("lock response format") = response_format;
(
StatusCode::OK,
Json(json!({
"created": 1_717_200_000i64,
"data": [
{
"b64_json": "ZmFrZS1saW1lLWltYWdl",
"revised_prompt": "未来感青柠实验室主视觉"
}
]
})),
)
}
}),
);
axum::serve(listener, app).await.expect("serve image api");
@@ -1209,6 +1244,18 @@ mod tests {
.map(Vec::len),
Some(1)
);
assert_eq!(
result
.record
.result
.as_ref()
.and_then(|value| value.get("images"))
.and_then(|value| value.as_array())
.and_then(|images| images.first())
.and_then(|value| value.get("url"))
.and_then(Value::as_str),
Some("data:image/png;base64,ZmFrZS1saW1lLWltYWdl")
);
assert_eq!(
result
.record
@@ -1234,6 +1281,32 @@ mod tests {
.map(Vec::len),
Some(1)
);
assert_eq!(
loaded
.record
.result
.as_ref()
.and_then(|value| value.get("images"))
.and_then(|value| value.as_array())
.and_then(|images| images.first())
.and_then(|value| value.get("url"))
.and_then(Value::as_str),
Some("data:image/png;base64,ZmFrZS1saW1lLWltYWdl")
);
assert_eq!(
captured_provider_id
.lock()
.expect("lock provider id")
.clone(),
Some("fal".to_string())
);
assert_eq!(
captured_response_format
.lock()
.expect("lock response format")
.clone(),
Some("b64_json".to_string())
);
server.abort();
}
-1
View File
@@ -56,7 +56,6 @@ pub mod skill_cmd;
pub mod skill_error;
pub mod skill_exec_cmd;
pub mod subagent_cmd;
pub mod switch_cmd;
pub mod telegram_remote_cmd;
pub mod telemetry_cmd;
pub mod terminal_cmd;
-110
View File
@@ -1,110 +0,0 @@
use crate::database::DbConnection;
use crate::models::app_type::AppType;
use crate::models::provider_model::Provider;
use lime_services::live_sync::{check_config_sync, sync_from_external, SyncCheckResult};
use lime_services::switch::SwitchService;
use serde_json::Value;
use tauri::State;
#[tauri::command]
pub fn get_switch_providers(
db: State<'_, DbConnection>,
app_type: String,
) -> Result<Vec<Provider>, String> {
SwitchService::get_providers(&db, &app_type)
}
#[tauri::command]
pub fn get_current_switch_provider(
db: State<'_, DbConnection>,
app_type: String,
) -> Result<Option<Provider>, String> {
SwitchService::get_current_provider(&db, &app_type)
}
#[tauri::command]
pub fn add_switch_provider(db: State<'_, DbConnection>, provider: Provider) -> Result<(), String> {
SwitchService::add_provider(&db, provider)
}
#[tauri::command]
pub fn update_switch_provider(
db: State<'_, DbConnection>,
provider: Provider,
) -> Result<(), String> {
SwitchService::update_provider(&db, provider)
}
#[tauri::command]
pub fn delete_switch_provider(
db: State<'_, DbConnection>,
app_type: String,
id: String,
) -> Result<(), String> {
SwitchService::delete_provider(&db, &app_type, &id)
}
/// 切换 Provider(异步版本,优化 Windows 性能)
#[tauri::command]
pub async fn switch_provider(
db: State<'_, DbConnection>,
app_type: String,
id: String,
) -> Result<(), String> {
SwitchService::switch_provider_async(&db, &app_type, &id).await
}
#[tauri::command]
pub fn import_default_config(
db: State<'_, DbConnection>,
app_type: String,
) -> Result<bool, String> {
SwitchService::import_default_config(&db, &app_type)
}
#[tauri::command]
pub fn read_live_provider_settings(app_type: String) -> Result<Value, String> {
SwitchService::read_live_settings(&app_type)
}
/// 检查配置同步状态
#[tauri::command]
pub fn check_config_sync_status(
db: State<'_, DbConnection>,
app_type: String,
) -> Result<SyncCheckResult, String> {
// 解析 app_type
let app_type_enum: AppType = app_type
.parse()
.map_err(|e| format!("Invalid app type: {e}"))?;
// 获取当前 Lime 中设置的 provider
let current_provider = SwitchService::get_current_provider(&db, &app_type)?
.map(|p| p.id)
.unwrap_or_else(|| "unknown".to_string());
// 检查同步状态
check_config_sync(&app_type_enum, &current_provider)
.map_err(|e| format!("Failed to check config sync: {e}"))
}
/// 从外部配置同步到 Lime
#[tauri::command]
pub async fn sync_from_external_config(
db: State<'_, DbConnection>,
app_type: String,
) -> Result<String, String> {
// 解析 app_type
let app_type_enum: AppType = app_type
.parse()
.map_err(|e| format!("Invalid app type: {e}"))?;
// 从外部配置获取 provider
let external_provider = sync_from_external(&app_type_enum)
.map_err(|e| format!("Failed to sync from external: {e}"))?;
// 切换到外部检测到的 provider
SwitchService::switch_provider_async(&db, &app_type, &external_provider).await?;
Ok(format!("已同步到外部配置的 provider: {external_provider}"))
}
-969
View File
@@ -1,969 +0,0 @@
import { useState, useEffect } from "react";
import {
Check,
X,
RefreshCw,
FolderOpen,
AlertCircle,
CheckCircle2,
Eye,
EyeOff,
Copy,
FileText,
} from "lucide-react";
import {
reloadCredentials,
refreshKiroToken,
getKiroCredentials,
getEnvVariables,
getTokenFileHash,
checkAndReloadCredentials,
// Gemini
getGeminiCredentials,
reloadGeminiCredentials,
refreshGeminiToken,
getGeminiEnvVariables,
getGeminiTokenFileHash,
checkAndReloadGeminiCredentials,
// Qwen
getQwenCredentials,
reloadQwenCredentials,
refreshQwenToken,
getQwenEnvVariables,
getQwenTokenFileHash,
checkAndReloadQwenCredentials,
// OpenAI/Claude Custom
getOpenAICustomStatus,
setOpenAICustomConfig,
getClaudeCustomStatus,
setClaudeCustomConfig,
type ClaudeCustomStatus,
type EnvVariable,
type GeminiCredentialStatus,
type KiroCredentialStatus,
type OpenAICustomStatus,
type QwenCredentialStatus,
} from "@/lib/api/providerRuntime";
import { getDefaultProvider, setDefaultProvider } from "@/lib/api/appConfig";
import { useProviderState } from "@/hooks/useProviderState";
import { useFileMonitoring } from "@/hooks/useFileMonitoring";
interface Provider {
id: string;
name: string;
enabled: boolean;
status: "connected" | "disconnected" | "error" | "loading";
description: string;
}
const defaultProviders: Provider[] = [
{
id: "kiro",
name: "Kiro Claude",
enabled: true,
status: "disconnected",
description: "通过 Kiro OAuth 访问 Claude Sonnet 4.5",
},
{
id: "gemini",
name: "Gemini CLI",
enabled: true,
status: "disconnected",
description: "通过 Gemini CLI OAuth 访问 Gemini 模型",
},
{
id: "qwen",
name: "通义千问",
enabled: true,
status: "disconnected",
description: "通过 Qwen OAuth 访问通义千问",
},
{
id: "openai",
name: "OpenAI 自定义",
enabled: false,
status: "disconnected",
description: "自定义 OpenAI 兼容 API",
},
{
id: "claude",
name: "Claude 自定义",
enabled: false,
status: "disconnected",
description: "自定义 Claude API",
},
];
export function Providers() {
const [providers, setProviders] = useState<Provider[]>(defaultProviders);
const [activeProvider, setActiveProvider] = useState<string>("kiro");
// 使用 useProviderState hook 管理三个 OAuth providers
const kiro = useProviderState<KiroCredentialStatus>("kiro", {
getCredentials: getKiroCredentials,
getEnvVars: getEnvVariables,
getHash: getTokenFileHash,
checkAndReload: checkAndReloadCredentials,
reloadCredentials: reloadCredentials,
refreshToken: refreshKiroToken,
});
const gemini = useProviderState<GeminiCredentialStatus>("gemini", {
getCredentials: getGeminiCredentials,
getEnvVars: getGeminiEnvVariables,
getHash: getGeminiTokenFileHash,
checkAndReload: checkAndReloadGeminiCredentials,
reloadCredentials: reloadGeminiCredentials,
refreshToken: refreshGeminiToken,
});
const qwen = useProviderState<QwenCredentialStatus>("qwen", {
getCredentials: getQwenCredentials,
getEnvVars: getQwenEnvVariables,
getHash: getQwenTokenFileHash,
checkAndReload: checkAndReloadQwenCredentials,
reloadCredentials: reloadQwenCredentials,
refreshToken: refreshQwenToken,
});
// OpenAI Custom state
const [openaiStatus, setOpenaiStatus] = useState<OpenAICustomStatus | null>(
null,
);
const [openaiApiKey, setOpenaiApiKey] = useState("");
const [openaiBaseUrl, setOpenaiBaseUrl] = useState("");
// Claude Custom state
const [claudeStatus, setClaudeStatus] = useState<ClaudeCustomStatus | null>(
null,
);
const [claudeApiKey, setClaudeApiKey] = useState("");
const [claudeBaseUrl, setClaudeBaseUrl] = useState("");
// Default provider state
const [defaultProvider, setDefaultProviderState] = useState<string>("kiro");
// Common state
const [showEnv, setShowEnv] = useState(false);
const [showValues, setShowValues] = useState(false);
const [loading, setLoading] = useState<string | null>(null);
const [message, setMessage] = useState<{
type: "success" | "error";
text: string;
} | null>(null);
const [copied, setCopied] = useState<string | null>(null);
// 使用 useFileMonitoring hook 自动监控文件变化
useFileMonitoring({
kiro: { checkFn: kiro.checkForChanges, interval: 5000 },
gemini: { checkFn: gemini.checkForChanges, interval: 5000 },
qwen: { checkFn: qwen.checkForChanges, interval: 5000 },
});
useEffect(() => {
const init = async () => {
// Load default provider
try {
const dp = await getDefaultProvider();
setDefaultProviderState(dp);
} catch (e) {
console.error("Failed to get default provider:", e);
}
// 初始化加载所有 provider 状态
await kiro.load();
await gemini.load();
await qwen.load();
await loadOpenAICustomStatus();
await loadClaudeCustomStatus();
};
init();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// 更新 provider 列表状态
useEffect(() => {
if (kiro.status) {
setProviders((prev) =>
prev.map((p) =>
p.id === "kiro"
? {
...p,
status: kiro.status?.loaded ? "connected" : "disconnected",
}
: p,
),
);
}
}, [kiro.status]);
useEffect(() => {
if (gemini.status) {
setProviders((prev) =>
prev.map((p) =>
p.id === "gemini"
? {
...p,
status: gemini.status?.loaded ? "connected" : "disconnected",
}
: p,
),
);
}
}, [gemini.status]);
useEffect(() => {
if (qwen.status) {
setProviders((prev) =>
prev.map((p) =>
p.id === "qwen"
? {
...p,
status: qwen.status?.loaded ? "connected" : "disconnected",
}
: p,
),
);
}
}, [qwen.status]);
const loadOpenAICustomStatus = async () => {
try {
const status = await getOpenAICustomStatus();
setOpenaiStatus(status);
setOpenaiBaseUrl(status.base_url);
setProviders((prev) =>
prev.map((p) =>
p.id === "openai"
? {
...p,
status:
status.enabled && status.has_api_key
? "connected"
: "disconnected",
enabled: status.enabled,
}
: p,
),
);
} catch (e) {
console.error("Failed to load OpenAI Custom status:", e);
}
};
const loadClaudeCustomStatus = async () => {
try {
const status = await getClaudeCustomStatus();
setClaudeStatus(status);
setClaudeBaseUrl(status.base_url);
setProviders((prev) =>
prev.map((p) =>
p.id === "claude"
? {
...p,
status:
status.enabled && status.has_api_key
? "connected"
: "disconnected",
enabled: status.enabled,
}
: p,
),
);
} catch (e) {
console.error("Failed to load Claude Custom status:", e);
}
};
const handleLoadCredentials = async (provider: string) => {
setMessage(null);
try {
if (provider === "kiro") {
await kiro.reload();
setMessage({ type: "success", text: "[Kiro] 凭证加载成功!" });
} else if (provider === "gemini") {
await gemini.reload();
setMessage({ type: "success", text: "[Gemini] 凭证加载成功!" });
} else if (provider === "qwen") {
await qwen.reload();
setMessage({ type: "success", text: "[Qwen] 凭证加载成功!" });
}
} catch (e: any) {
setMessage({ type: "error", text: `加载失败: ${e.toString()}` });
}
};
const handleRefreshToken = async (provider: string) => {
setMessage(null);
try {
if (provider === "kiro") {
await kiro.refresh();
setMessage({ type: "success", text: "[Kiro] Token 刷新成功!" });
} else if (provider === "gemini") {
await gemini.refresh();
setMessage({ type: "success", text: "[Gemini] Token 刷新成功!" });
} else if (provider === "qwen") {
await qwen.refresh();
setMessage({ type: "success", text: "[Qwen] Token 刷新成功!" });
}
} catch (e: any) {
setMessage({ type: "error", text: `刷新失败: ${e.toString()}` });
}
};
const handleSaveOpenAIConfig = async () => {
setLoading("save-openai");
try {
await setOpenAICustomConfig(
openaiApiKey || null,
openaiBaseUrl || null,
true,
);
await loadOpenAICustomStatus();
setMessage({ type: "success", text: "[OpenAI] 配置保存成功!" });
} catch (e: any) {
setMessage({ type: "error", text: `保存失败: ${e.toString()}` });
}
setLoading(null);
};
const handleSaveClaudeConfig = async () => {
setLoading("save-claude");
try {
await setClaudeCustomConfig(
claudeApiKey || null,
claudeBaseUrl || null,
true,
);
await loadClaudeCustomStatus();
setMessage({ type: "success", text: "[Claude] 配置保存成功!" });
} catch (e: any) {
setMessage({ type: "error", text: `保存失败: ${e.toString()}` });
}
setLoading(null);
};
const toggleProvider = (id: string) => {
setProviders((prev) =>
prev.map((p) => (p.id === id ? { ...p, enabled: !p.enabled } : p)),
);
};
const handleSetDefaultProvider = async (providerId: string) => {
setLoading(`default-${providerId}`);
try {
await setDefaultProvider(providerId);
setDefaultProviderState(providerId);
setMessage({
type: "success",
text: `默认 Provider 已切换为: ${getProviderName(providerId)}`,
});
} catch (e: any) {
setMessage({ type: "error", text: `切换失败: ${e.toString()}` });
}
setLoading(null);
};
const getProviderName = (id: string) => {
switch (id) {
case "kiro":
return "Kiro Claude";
case "gemini":
return "Gemini CLI";
case "qwen":
return "通义千问";
case "openai":
return "OpenAI 自定义";
case "claude":
return "Claude 自定义";
default:
return id;
}
};
const copyValue = (key: string, value: string) => {
navigator.clipboard.writeText(value);
setCopied(key);
setTimeout(() => setCopied(null), 2000);
};
const copyAllEnv = (vars: EnvVariable[]) => {
navigator.clipboard.writeText(
vars.map((v) => `${v.key}=${v.value}`).join("\n"),
);
setCopied("all");
setTimeout(() => setCopied(null), 2000);
};
const getStatusColor = (status: Provider["status"]) => {
switch (status) {
case "connected":
return "bg-green-500";
case "error":
return "bg-red-500";
case "loading":
return "bg-yellow-500 animate-pulse";
default:
return "bg-gray-400";
}
};
const formatTime = (date: Date | null) => {
if (!date) return "从未同步";
return date.toLocaleTimeString();
};
const currentEnvVars =
activeProvider === "kiro"
? kiro.envVars
: activeProvider === "gemini"
? gemini.envVars
: qwen.envVars;
const isAnyLoading = Boolean(
kiro.loading || gemini.loading || qwen.loading || loading,
);
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold">Provider 管理</h2>
<p className="text-muted-foreground">配置和管理 AI 模型提供商</p>
</div>
{message && (
<div
className={`flex items-center gap-2 rounded-lg border p-3 text-sm ${
message.type === "success"
? "border-green-500 bg-green-50 text-green-700"
: "border-red-500 bg-red-50 text-red-700"
}`}
>
{message.type === "success" ? (
<CheckCircle2 className="h-4 w-4" />
) : (
<AlertCircle className="h-4 w-4" />
)}
{message.text}
</div>
)}
{/* Provider Tabs */}
<div className="flex gap-2 border-b overflow-x-auto">
{["kiro", "gemini", "qwen", "openai", "claude"].map((id) => (
<button
key={id}
onClick={() => setActiveProvider(id)}
className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px whitespace-nowrap ${
activeProvider === id
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
{id === "kiro"
? "Kiro Claude"
: id === "gemini"
? "Gemini CLI"
: id === "qwen"
? "通义千问"
: id === "openai"
? "OpenAI 自定义"
: "Claude 自定义"}
</button>
))}
</div>
{/* Kiro Panel */}
{activeProvider === "kiro" && (
<div className="rounded-lg border bg-card p-4">
<div className="mb-3 flex items-center justify-between">
<h3 className="font-semibold">Kiro 凭证状态</h3>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span>
最后同步:{" "}
<span className="text-foreground">
{formatTime(kiro.lastSync)}
</span>
</span>
<span className="flex items-center gap-1">
<span className="h-2 w-2 rounded-full bg-green-500 animate-pulse" />
监测中
</span>
</div>
</div>
<div className="mb-4 grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">凭证路径:</span>
<code className="ml-2 rounded bg-muted px-2 py-0.5 text-xs break-all">
{kiro.status?.creds_path ||
"~/.aws/sso/cache/kiro-auth-token.json"}
</code>
</div>
<div>
<span className="text-muted-foreground">区域:</span>
<span className="ml-2">{kiro.status?.region || "未设置"}</span>
</div>
<div>
<span className="text-muted-foreground">Access Token:</span>
<span
className={`ml-2 ${kiro.status?.has_access_token ? "text-green-600" : "text-red-500"}`}
>
{kiro.status?.has_access_token ? "✓ 已加载" : "✗ 未加载"}
</span>
</div>
<div>
<span className="text-muted-foreground">Refresh Token:</span>
<span
className={`ml-2 ${kiro.status?.has_refresh_token ? "text-green-600" : "text-red-500"}`}
>
{kiro.status?.has_refresh_token ? "✓ 已加载" : "✗ 未加载"}
</span>
</div>
</div>
<div className="flex flex-wrap gap-2">
<button
onClick={() => handleLoadCredentials("kiro")}
disabled={isAnyLoading}
className="flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
<FolderOpen className="h-4 w-4" />
{kiro.loading === "reload" ? "加载中..." : "一键读取凭证"}
</button>
<button
onClick={() => handleRefreshToken("kiro")}
disabled={isAnyLoading || !kiro.status?.has_refresh_token}
className="flex items-center gap-2 rounded-lg border px-4 py-2 text-sm font-medium hover:bg-muted disabled:opacity-50"
>
<RefreshCw
className={`h-4 w-4 ${kiro.loading === "refresh" ? "animate-spin" : ""}`}
/>
刷新 Token
</button>
<button
onClick={() => setShowEnv(!showEnv)}
className="flex items-center gap-2 rounded-lg border px-4 py-2 text-sm font-medium hover:bg-muted"
>
<FileText className="h-4 w-4" />
{showEnv ? "隐藏" : "查看"} .env 变量
</button>
</div>
</div>
)}
{/* Gemini Panel */}
{activeProvider === "gemini" && (
<div className="rounded-lg border bg-card p-4">
<div className="mb-3 flex items-center justify-between">
<h3 className="font-semibold">Gemini CLI 凭证状态</h3>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span>
最后同步:{" "}
<span className="text-foreground">
{formatTime(gemini.lastSync)}
</span>
</span>
<span className="flex items-center gap-1">
<span className="h-2 w-2 rounded-full bg-green-500 animate-pulse" />
监测中
</span>
</div>
</div>
<div className="mb-4 grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">凭证路径:</span>
<code className="ml-2 rounded bg-muted px-2 py-0.5 text-xs break-all">
{gemini.status?.creds_path || "~/.gemini/oauth_creds.json"}
</code>
</div>
<div>
<span className="text-muted-foreground">Token 有效:</span>
<span
className={`ml-2 ${gemini.status?.is_valid ? "text-green-600" : "text-red-500"}`}
>
{gemini.status?.is_valid ? "✓ 有效" : "✗ 无效/过期"}
</span>
</div>
<div>
<span className="text-muted-foreground">Access Token:</span>
<span
className={`ml-2 ${gemini.status?.has_access_token ? "text-green-600" : "text-red-500"}`}
>
{gemini.status?.has_access_token ? "✓ 已加载" : "✗ 未加载"}
</span>
</div>
<div>
<span className="text-muted-foreground">Refresh Token:</span>
<span
className={`ml-2 ${gemini.status?.has_refresh_token ? "text-green-600" : "text-red-500"}`}
>
{gemini.status?.has_refresh_token ? "✓ 已加载" : "✗ 未加载"}
</span>
</div>
</div>
<div className="flex flex-wrap gap-2">
<button
onClick={() => handleLoadCredentials("gemini")}
disabled={isAnyLoading}
className="flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
<FolderOpen className="h-4 w-4" />
{gemini.loading === "reload" ? "加载中..." : "一键读取凭证"}
</button>
<button
onClick={() => handleRefreshToken("gemini")}
disabled={isAnyLoading || !gemini.status?.has_refresh_token}
className="flex items-center gap-2 rounded-lg border px-4 py-2 text-sm font-medium hover:bg-muted disabled:opacity-50"
>
<RefreshCw
className={`h-4 w-4 ${gemini.loading === "refresh" ? "animate-spin" : ""}`}
/>
刷新 Token
</button>
<button
onClick={() => setShowEnv(!showEnv)}
className="flex items-center gap-2 rounded-lg border px-4 py-2 text-sm font-medium hover:bg-muted"
>
<FileText className="h-4 w-4" />
{showEnv ? "隐藏" : "查看"} .env 变量
</button>
</div>
</div>
)}
{/* Qwen Panel */}
{activeProvider === "qwen" && (
<div className="rounded-lg border bg-card p-4">
<div className="mb-3 flex items-center justify-between">
<h3 className="font-semibold">通义千问凭证状态</h3>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span>
最后同步:{" "}
<span className="text-foreground">
{formatTime(qwen.lastSync)}
</span>
</span>
<span className="flex items-center gap-1">
<span className="h-2 w-2 rounded-full bg-green-500 animate-pulse" />
监测中
</span>
</div>
</div>
<div className="mb-4 grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">凭证路径:</span>
<code className="ml-2 rounded bg-muted px-2 py-0.5 text-xs break-all">
{qwen.status?.creds_path || "~/.qwen/oauth_creds.json"}
</code>
</div>
<div>
<span className="text-muted-foreground">Token 有效:</span>
<span
className={`ml-2 ${qwen.status?.is_valid ? "text-green-600" : "text-red-500"}`}
>
{qwen.status?.is_valid ? "✓ 有效" : "✗ 无效/过期"}
</span>
</div>
<div>
<span className="text-muted-foreground">Access Token:</span>
<span
className={`ml-2 ${qwen.status?.has_access_token ? "text-green-600" : "text-red-500"}`}
>
{qwen.status?.has_access_token ? "✓ 已加载" : "✗ 未加载"}
</span>
</div>
<div>
<span className="text-muted-foreground">Refresh Token:</span>
<span
className={`ml-2 ${qwen.status?.has_refresh_token ? "text-green-600" : "text-red-500"}`}
>
{qwen.status?.has_refresh_token ? "✓ 已加载" : "✗ 未加载"}
</span>
</div>
</div>
<div className="flex flex-wrap gap-2">
<button
onClick={() => handleLoadCredentials("qwen")}
disabled={isAnyLoading}
className="flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
<FolderOpen className="h-4 w-4" />
{qwen.loading === "reload" ? "加载中..." : "一键读取凭证"}
</button>
<button
onClick={() => handleRefreshToken("qwen")}
disabled={isAnyLoading || !qwen.status?.has_refresh_token}
className="flex items-center gap-2 rounded-lg border px-4 py-2 text-sm font-medium hover:bg-muted disabled:opacity-50"
>
<RefreshCw
className={`h-4 w-4 ${qwen.loading === "refresh" ? "animate-spin" : ""}`}
/>
刷新 Token
</button>
<button
onClick={() => setShowEnv(!showEnv)}
className="flex items-center gap-2 rounded-lg border px-4 py-2 text-sm font-medium hover:bg-muted"
>
<FileText className="h-4 w-4" />
{showEnv ? "隐藏" : "查看"} .env 变量
</button>
</div>
</div>
)}
{/* OpenAI Custom Panel */}
{activeProvider === "openai" && (
<div className="rounded-lg border bg-card p-4">
<h3 className="mb-3 font-semibold">OpenAI 自定义配置</h3>
<div className="mb-4 space-y-4">
<div>
<label className="block text-sm text-muted-foreground mb-1">
API Key
</label>
<input
type="password"
value={openaiApiKey}
onChange={(e) => setOpenaiApiKey(e.target.value)}
placeholder="sk-..."
className="w-full rounded-lg border bg-background px-3 py-2 text-sm"
/>
</div>
<div>
<label className="block text-sm text-muted-foreground mb-1">
Base URL
</label>
<input
type="text"
value={openaiBaseUrl}
onChange={(e) => setOpenaiBaseUrl(e.target.value)}
placeholder="https://api.openai.com/v1"
className="w-full rounded-lg border bg-background px-3 py-2 text-sm"
/>
</div>
<div className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">状态:</span>
<span
className={
openaiStatus?.has_api_key ? "text-green-600" : "text-red-500"
}
>
{openaiStatus?.has_api_key ? "✓ 已配置" : "✗ 未配置"}
</span>
</div>
</div>
<button
onClick={handleSaveOpenAIConfig}
disabled={loading !== null}
className="flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{loading === "save-openai" ? "保存中..." : "保存配置"}
</button>
</div>
)}
{/* Claude Custom Panel */}
{activeProvider === "claude" && (
<div className="rounded-lg border bg-card p-4">
<h3 className="mb-3 font-semibold">Claude 自定义配置</h3>
<div className="mb-4 space-y-4">
<div>
<label className="block text-sm text-muted-foreground mb-1">
API Key
</label>
<input
type="password"
value={claudeApiKey}
onChange={(e) => setClaudeApiKey(e.target.value)}
placeholder="sk-ant-..."
className="w-full rounded-lg border bg-background px-3 py-2 text-sm"
/>
</div>
<div>
<label className="block text-sm text-muted-foreground mb-1">
Base URL
</label>
<input
type="text"
value={claudeBaseUrl}
onChange={(e) => setClaudeBaseUrl(e.target.value)}
placeholder="https://api.anthropic.com"
className="w-full rounded-lg border bg-background px-3 py-2 text-sm"
/>
</div>
<div className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">状态:</span>
<span
className={
claudeStatus?.has_api_key ? "text-green-600" : "text-red-500"
}
>
{claudeStatus?.has_api_key ? "✓ 已配置" : "✗ 未配置"}
</span>
</div>
</div>
<button
onClick={handleSaveClaudeConfig}
disabled={loading !== null}
className="flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{loading === "save-claude" ? "保存中..." : "保存配置"}
</button>
</div>
)}
{/* .env 变量展示 */}
{showEnv && (
<div className="rounded-lg border bg-card p-4">
<div className="mb-3 flex items-center justify-between">
<h3 className="font-semibold">.env 环境变量 ({activeProvider})</h3>
<div className="flex items-center gap-2">
<button
onClick={() => setShowValues(!showValues)}
className="flex items-center gap-1 rounded px-2 py-1 text-xs hover:bg-muted"
>
{showValues ? (
<EyeOff className="h-3 w-3" />
) : (
<Eye className="h-3 w-3" />
)}
{showValues ? "隐藏值" : "显示值"}
</button>
<button
onClick={() => copyAllEnv(currentEnvVars)}
className="flex items-center gap-1 rounded px-2 py-1 text-xs hover:bg-muted"
>
{copied === "all" ? (
<CheckCircle2 className="h-3 w-3 text-green-500" />
) : (
<Copy className="h-3 w-3" />
)}
复制全部
</button>
</div>
</div>
{currentEnvVars.length === 0 ? (
<p className="text-sm text-muted-foreground">
暂无环境变量,请先加载凭证
</p>
) : (
<div className="space-y-2 font-mono text-sm">
{currentEnvVars.map((v) => (
<div
key={v.key}
className="flex items-center gap-2 rounded bg-muted p-2"
>
<span className="text-blue-600 shrink-0">{v.key}</span>
<span>=</span>
<span className="flex-1 truncate text-muted-foreground">
{showValues ? v.value : v.masked}
</span>
<button
onClick={() => copyValue(v.key, v.value)}
className="rounded p-1 hover:bg-background shrink-0"
>
{copied === v.key ? (
<CheckCircle2 className="h-3 w-3 text-green-500" />
) : (
<Copy className="h-3 w-3" />
)}
</button>
</div>
))}
</div>
)}
</div>
)}
{/* Provider 列表 */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-semibold">Provider 列表</h3>
<span className="text-sm text-muted-foreground">
当前默认:{" "}
<span className="font-medium text-primary">
{getProviderName(defaultProvider)}
</span>
</span>
</div>
{providers.map((provider) => (
<div
key={provider.id}
className={`flex items-center justify-between rounded-lg border bg-card p-4 transition-all ${
defaultProvider === provider.id
? "border-primary ring-1 ring-primary"
: ""
}`}
>
<div className="flex items-center gap-4">
<div
className={`h-3 w-3 rounded-full ${getStatusColor(provider.status)}`}
/>
<div>
<div className="flex items-center gap-2">
<h3 className="font-medium">{provider.name}</h3>
{defaultProvider === provider.id && (
<span className="rounded bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
默认
</span>
)}
</div>
<p className="text-sm text-muted-foreground">
{provider.description}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{defaultProvider !== provider.id && (
<button
onClick={() => handleSetDefaultProvider(provider.id)}
disabled={isAnyLoading}
className="rounded-lg border px-3 py-1.5 text-xs font-medium hover:bg-muted disabled:opacity-50"
title="设为默认"
>
{loading === `default-${provider.id}`
? "切换中..."
: "设为默认"}
</button>
)}
{(provider.id === "kiro" ||
provider.id === "gemini" ||
provider.id === "qwen") && (
<button
onClick={() => handleRefreshToken(provider.id)}
disabled={isAnyLoading}
className="rounded p-2 hover:bg-muted"
title="刷新 Token"
>
<RefreshCw
className={`h-4 w-4 ${
(provider.id === "kiro" && kiro.loading === "refresh") ||
(provider.id === "gemini" &&
gemini.loading === "refresh") ||
(provider.id === "qwen" && qwen.loading === "refresh")
? "animate-spin"
: ""
}`}
/>
</button>
)}
<button
onClick={() => toggleProvider(provider.id)}
className={`rounded-full p-1 ${provider.enabled ? "bg-green-100 text-green-600" : "bg-gray-100 text-gray-400"}`}
>
{provider.enabled ? (
<Check className="h-4 w-4" />
) : (
<X className="h-4 w-4" />
)}
</button>
</div>
</div>
))}
</div>
<p className="text-xs text-muted-foreground">
💡 提示:系统每 5
秒自动检查凭证文件变化,如有更新会自动重新加载并记录日志
</p>
</div>
);
}
+2 -3
View File
@@ -9,14 +9,14 @@ React 组件层,包含 UI 组件和业务组件。
## 文件索引
- `agent/` - AI Agent 聊天页面组件
- `agent/` - AI Agent 工作台组件(当前主实现收口在 `agent/chat/`)
- `connect/` - Lime Connect 组件(中转商 API Key 添加)
- `flow-monitor/` - LLM 流量监控组件
- `mcp/` - MCP 服务器管理组件(配置管理、运行时控制、工具/提示词/资源浏览与调用)
- `plugins/` - 插件管理组件
- `provider-pool/` - Provider 凭证池管理组件
- `routing/` - 路由规则配置组件
- `screenshot-chat/` - 截图对话功能组件(实验室功能)
- `smart-input/` - 截图/语音浮窗共享组件(当前仅保留快捷键设置)
- `settings-v2/` - 设置页面组件(当前主实现)
- `skills/` - 技能管理组件
- `terminal/` - 内置终端组件(使用 Tauri Commands)
@@ -29,7 +29,6 @@ React 组件层,包含 UI 组件和业务组件。
- `ConfirmDialog.tsx` - 确认对话框
- `HelpTip.tsx` - 帮助提示组件
- `Modal.tsx` - 模态框组件
- `Providers.tsx` - Provider 管理页面
- `SplashScreen.tsx` - 启动画面组件
## 更新提醒
-112
View File
@@ -1,112 +0,0 @@
/**
* Web Mode Warning Component
*
* Displays a warning banner when running in web mode (npm run dev)
* to inform users that some features may not work without Tauri backend
*/
import { useState } from "react";
import { AlertTriangle, X } from "lucide-react";
import styled from "styled-components";
import { hasTauriRuntimeMarkers } from "@/lib/tauri-runtime";
const WarningBanner = styled.div`
position: fixed;
top: 0;
left: 0;
right: 0;
background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%);
color: #78350f;
padding: 12px 20px;
display: flex;
align-items: center;
justify-content: space-between;
z-index: 9999;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
font-size: 14px;
`;
const Content = styled.div`
display: flex;
align-items: center;
gap: 12px;
flex: 1;
`;
const IconWrapper = styled.div`
display: flex;
align-items: center;
justify-content: center;
`;
const Message = styled.div`
display: flex;
flex-direction: column;
gap: 4px;
`;
const Title = styled.div`
font-weight: 600;
`;
const Description = styled.div`
font-size: 12px;
opacity: 0.9;
`;
const Code = styled.code`
background: rgba(0, 0, 0, 0.1);
padding: 2px 6px;
border-radius: 4px;
font-family: "Courier New", monospace;
font-size: 12px;
`;
const CloseButton = styled.button`
background: none;
border: none;
color: #78350f;
cursor: pointer;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: background 0.2s;
&:hover {
background: rgba(0, 0, 0, 0.1);
}
`;
export function WebModeWarning() {
const [visible, setVisible] = useState(true);
// Check if running in Tauri
const isTauri = hasTauriRuntimeMarkers();
// Only show in web mode (not Tauri)
if (isTauri || !visible) {
return null;
}
return (
<WarningBanner>
<Content>
<IconWrapper>
<AlertTriangle size={20} />
</IconWrapper>
<Message>
<Title>⚠️ Web Mode - Limited Functionality</Title>
<Description>
Running in browser mode. Some features require Tauri backend. For
full functionality, run: <Code>npm run tauri dev</Code>
</Description>
</Message>
</Content>
<CloseButton onClick={() => setVisible(false)} title="Close">
<X size={18} />
</CloseButton>
</WarningBanner>
);
}
-4
View File
@@ -1,4 +0,0 @@
// eslint-disable-next-line react-refresh/only-export-components
export * from "./chat";
// eslint-disable-next-line react-refresh/only-export-components
export * from "./AgentSkillsPanel";
-126
View File
@@ -1,126 +0,0 @@
/**
* @file AgentSkillsPanel.tsx
* @description AI Agent 页面的 Skills 展示面板组件
* @module components/agent
*
* 显示已加载的 Skills 数量和名称列表,提供管理入口。
* 实现被动式设计:Skills 自动加载,用户无需手动选择。
*/
import { Package, Settings2, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { ChevronDown, ChevronUp } from "lucide-react";
import { useState } from "react";
interface AgentSkillsPanelProps {
/** 已加载的 Skills 名称列表 */
skills: string[];
/** 是否正在加载 */
loading: boolean;
/** 点击“打开技能中心”按钮的回调 */
onManageClick: () => void;
}
/**
* AI Agent Skills 展示面板
*
* 功能:
* - 显示已加载 Skills 数量
* - 以紧凑格式显示 Skill 名称列表(用 · 分隔)
* - 提供“打开技能中心”按钮导航到 Skills 主入口
* - 无 Skills 时显示提示文本和技能中心入口
* - 显示使用提示
*
* @param skills - 已加载的 Skills 名称列表
* @param loading - 是否正在加载
* @param onManageClick - 点击管理按钮的回调
*/
export function AgentSkillsPanel({
skills,
loading,
onManageClick,
}: AgentSkillsPanelProps) {
const [isOpen, setIsOpen] = useState(true);
if (loading) {
return (
<Card>
<CardContent className="py-3 px-4">
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm">加载 Skills...</span>
</div>
</CardContent>
</Card>
);
}
return (
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<Card>
<CollapsibleTrigger asChild>
<button className="w-full flex items-center justify-between p-3 hover:bg-muted/50 transition-colors">
<div className="flex items-center gap-2">
<Package className="h-4 w-4" />
<span className="text-sm font-medium">
📦 已加载 {skills.length} 个 Skills
</span>
</div>
{isOpen ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<CardContent className="pt-0 pb-3 px-4 space-y-3">
{skills.length > 0 ? (
<>
{/* Skills 名称列表 - 紧凑格式 */}
<div className="text-sm text-muted-foreground">
{skills.join(" · ")}
</div>
{/* 使用提示 */}
<p className="text-xs text-muted-foreground">
💡 直接描述任务,Agent 会自动使用合适的 Skill
</p>
{/* 管理按钮 */}
<Button
variant="outline"
size="sm"
onClick={onManageClick}
className="w-full"
>
<Settings2 className="h-4 w-4 mr-2" />
打开技能中心
</Button>
</>
) : (
<>
{/* 无 Skills 提示 */}
<p className="text-sm text-muted-foreground">
暂无已安装的技能,
<button
onClick={onManageClick}
className="text-primary underline hover:no-underline"
>
去技能中心
</button>
</p>
</>
)}
</CardContent>
</CollapsibleContent>
</Card>
</Collapsible>
);
}
+2 -8
View File
@@ -1,20 +1,14 @@
# Agent 模块
> 版本: 1.0.0
> 更新: 2026-01-10
> 更新: 2026-04-05
## 模块说明
AI Agent 相关组件,包括聊天页面和技能面板。
AI Agent 相关组件。当前现役页面与运行时实现统一收口在 `chat/` 目录,旧根级页面包装层与旧 Skills 面板已删除。
## 文件索引
| 文件 | 说明 |
| ---------------------- | -------------------------------------- |
| `index.ts` | 模块导出入口 |
| `AgentChatPage.tsx` | Agent 聊天页面(旧版,已迁移到 chat/) |
| `AgentSkillsPanel.tsx` | Agent 技能面板 |
### chat/
AI Agent 聊天模块,详见 [chat/README.md](./chat/README.md)
@@ -2160,6 +2160,7 @@ export function AgentChatWorkspace({
contentId,
input,
chatToolPreferences: effectiveChatToolPreferences,
serviceSkills: activeTheme === "general" ? serviceSkills : [],
preferredTeamPresetId,
selectedTeam,
selectedTeamLabel,
@@ -2216,6 +2217,8 @@ export function AgentChatWorkspace({
ensureBrowserAssistCanvas,
handleAutoLaunchMatchedSiteSkill:
workspaceServiceSkillEntryActions.handleAutoLaunchMatchedSiteSkill,
handleRuntimeSceneLaunch:
workspaceServiceSkillEntryActions.handleRuntimeSceneLaunch,
handleImageWorkbenchCommand,
resolveImageWorkbenchSkillRequest,
});
@@ -136,6 +136,27 @@ describe("ImageTaskViewer", () => {
expect(onOpenImage).toHaveBeenCalledWith("https://example.com/image-1.png");
});
it("结果图加载失败时应展示兜底文案并隐藏打开原图入口", () => {
const { container } = renderComponent();
const image = container.querySelector(
'img[src="https://example.com/image-1.png"]',
);
expect(image).toBeTruthy();
act(() => {
image?.dispatchEvent(new Event("error"));
});
expect(container.textContent).toContain("图片暂时无法显示");
expect(container.textContent).toContain(
"图片结果已经返回,但当前预览地址暂时无法加载。",
);
expect(
container.querySelector('[data-testid="image-task-viewer-open-image"]'),
).toBeNull();
});
it("点击缩略图应切换当前输出", () => {
const onSelectOutput = vi.fn();
const { container } = renderComponent({ onSelectOutput });
@@ -218,6 +239,68 @@ describe("ImageTaskViewer", () => {
).toBeTruthy();
});
it("来源图加载失败时应展示来源图兜底文案", () => {
const { container } = renderComponent({
tasks: [
{
id: "task-source-1",
mode: "generate",
status: "complete",
prompt: "原始海报",
rawText: "@配图 原始海报",
expectedCount: 1,
outputIds: ["output-source-1"],
createdAt: 1,
},
{
id: "task-edit-1",
mode: "edit",
status: "complete",
prompt: "去掉背景里的路人,保留主体人物",
rawText: "@修图 去掉背景里的路人,保留主体人物",
expectedCount: 1,
outputIds: ["output-edit-1"],
targetOutputId: "output-source-1",
targetOutputRefId: "img-source-1",
sourceImageRef: "img-source-1",
sourceImageCount: 1,
createdAt: 2,
},
],
outputs: [
{
id: "output-source-1",
refId: "img-source-1",
taskId: "task-source-1",
url: "https://example.com/source.png",
prompt: "原始海报",
createdAt: 1,
},
{
id: "output-edit-1",
refId: "img-edit-1",
taskId: "task-edit-1",
url: "https://example.com/edited.png",
prompt: "移除路人后的海报",
createdAt: 2,
parentOutputId: "output-source-1",
},
],
selectedOutputId: "output-edit-1",
});
const sourceImage = container.querySelector(
'[data-testid="image-task-viewer-source-image"]',
);
expect(sourceImage).toBeTruthy();
act(() => {
sourceImage?.dispatchEvent(new Event("error"));
});
expect(container.textContent).toContain("来源图暂时无法显示");
});
it("重绘任务应优先展示参考图输出与重绘语义", () => {
const { container } = renderComponent({
tasks: [
@@ -1,5 +1,6 @@
import { ArrowUpRight, LoaderCircle, Sparkles, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { RenderableTaskImage } from "./RenderableTaskImage";
import type { ImageTaskViewerProps } from "./imageWorkbenchTypes";
function resolveModeEyebrow(mode?: string): string {
@@ -151,6 +152,43 @@ function resolveEmptyStateDescription(
}
}
function resolveImageUnavailableTitle(status?: string): string {
switch ((status || "").trim().toLowerCase()) {
case "complete":
case "partial":
return "图片暂时无法显示";
default:
return resolveStatusLabel(status);
}
}
function resolveImageUnavailableDescription(mode?: string): string {
switch ((mode || "").trim().toLowerCase()) {
case "edit":
return "修图结果已经返回,但当前预览地址暂时无法加载。";
case "variation":
return "重绘结果已经返回,但当前预览地址暂时无法加载。";
case "generate":
default:
return "图片结果已经返回,但当前预览地址暂时无法加载。";
}
}
function resolveSourcePlaceholderLabel(
mode?: string,
reason?: "empty" | "error",
) {
if (reason === "error") {
return (mode || "").trim().toLowerCase() === "variation"
? "参考图暂时无法显示"
: "来源图暂时无法显示";
}
return (mode || "").trim().toLowerCase() === "variation"
? "参考图待同步"
: "来源图待同步";
}
export function ImageTaskViewer({
tasks,
outputs,
@@ -208,8 +246,8 @@ export function ImageTaskViewer({
);
const sourceSummary = sourceImagePrompt
? sourceImagePrompt
: sourceImageRef
? `已引用 ${sourceImageRef}`
: sourceImageRef
? `已引用 ${sourceImageRef}`
: selectedTask?.mode === "variation"
? "当前任务会基于参考图继续生成新的重绘结果。"
: "当前任务会基于已有图片结果继续完成修图。";
@@ -266,22 +304,53 @@ export function ImageTaskViewer({
<div className="flex min-h-0 flex-1 flex-col px-5 py-5">
<div className="flex-1 overflow-hidden rounded-[20px] border border-slate-200 bg-slate-50">
{selectedOutput ? (
<button
type="button"
className="group relative block h-full w-full overflow-hidden"
onClick={() => onOpenImage?.(selectedOutput.url)}
data-testid="image-task-viewer-open-image"
>
<img
src={selectedOutput.url}
alt={selectedOutput.prompt || "图片任务结果"}
className="h-full w-full object-contain bg-[radial-gradient(circle_at_top,rgba(56,189,248,0.12),transparent_42%),linear-gradient(180deg,rgba(248,250,252,0.96),rgba(241,245,249,0.98))]"
/>
<div className="pointer-events-none absolute right-4 top-4 inline-flex items-center gap-1 rounded-full border border-white/80 bg-white/92 px-2.5 py-1 text-xs font-medium text-slate-600 shadow-sm shadow-slate-950/5">
<span>打开原图</span>
<ArrowUpRight className="h-3.5 w-3.5 transition-transform group-hover:translate-x-0.5 group-hover:-translate-y-0.5" />
</div>
</button>
<RenderableTaskImage
src={selectedOutput.url}
alt={selectedOutput.prompt || "图片任务结果"}
className="h-full w-full object-contain bg-[radial-gradient(circle_at_top,rgba(56,189,248,0.12),transparent_42%),linear-gradient(180deg,rgba(248,250,252,0.96),rgba(241,245,249,0.98))]"
renderImage={(imageProps) => (
<button
type="button"
className="group relative block h-full w-full overflow-hidden"
onClick={() => onOpenImage?.(selectedOutput.url)}
data-testid="image-task-viewer-open-image"
>
<img {...imageProps} />
<div className="pointer-events-none absolute right-4 top-4 inline-flex items-center gap-1 rounded-full border border-white/80 bg-white/92 px-2.5 py-1 text-xs font-medium text-slate-600 shadow-sm shadow-slate-950/5">
<span>打开原图</span>
<ArrowUpRight className="h-3.5 w-3.5 transition-transform group-hover:translate-x-0.5 group-hover:-translate-y-0.5" />
</div>
</button>
)}
renderFallback={(reason) => (
<div className="flex h-full min-h-[320px] items-center justify-center px-6 text-center">
<div className="max-w-sm space-y-3">
{reason === "empty" &&
(selectedTask?.status === "running" ||
selectedTask?.status === "routing" ||
selectedTask?.status === "queued") ? (
<LoaderCircle className="mx-auto h-8 w-8 animate-spin text-sky-500" />
) : (
<Sparkles className="mx-auto h-8 w-8 text-slate-400" />
)}
<div className="text-sm font-semibold text-slate-900">
{reason === "error"
? resolveImageUnavailableTitle(selectedTask?.status)
: statusLabel}
</div>
<div className="text-sm leading-6 text-slate-500">
{reason === "error"
? resolveImageUnavailableDescription(selectedTask?.mode)
: resolveEmptyStateDescription(
selectedTask?.status,
selectedTask?.failureMessage,
selectedTask?.mode,
)}
</div>
</div>
</div>
)}
/>
) : (
<div className="flex h-full min-h-[320px] items-center justify-center px-6 text-center">
<div className="max-w-sm space-y-3">
@@ -317,24 +386,22 @@ export function ImageTaskViewer({
</div>
<div className="mt-3 flex items-center gap-3">
<div className="flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-[18px] border border-slate-200 bg-white">
{sourceImageUrl ? (
<img
data-testid="image-task-viewer-source-image"
src={sourceImageUrl}
alt={
sourceImagePrompt ||
resolveSourceLabel(selectedTask?.mode)
}
className="h-full w-full object-cover"
/>
) : (
<span className="px-2 text-center text-[11px] font-medium text-slate-400">
{(selectedTask?.mode || "").trim().toLowerCase() ===
"variation"
? "参考图待同步"
: "来源图待同步"}
</span>
)}
<RenderableTaskImage
src={sourceImageUrl}
data-testid="image-task-viewer-source-image"
alt={
sourceImagePrompt || resolveSourceLabel(selectedTask?.mode)
}
className="h-full w-full object-cover"
renderFallback={(reason) => (
<span className="px-2 text-center text-[11px] font-medium text-slate-400">
{resolveSourcePlaceholderLabel(
selectedTask?.mode,
reason,
)}
</span>
)}
/>
</div>
<div className="min-w-0">
<div className="line-clamp-2 text-sm font-medium leading-6 text-slate-800">
@@ -456,10 +523,15 @@ export function ImageTaskViewer({
: "border-slate-200 hover:border-slate-300",
)}
>
<img
<RenderableTaskImage
src={output.url}
alt={output.prompt || "图片结果缩略图"}
className="h-20 w-28 object-cover"
renderFallback={() => (
<div className="flex h-20 w-28 items-center justify-center bg-slate-50 px-3 text-center text-[11px] font-medium text-slate-400">
预览失败
</div>
)}
/>
</button>
);
@@ -3,6 +3,7 @@ import { ArrowUpRight, LoaderCircle, Sparkles } from "lucide-react";
import { emitImageWorkbenchFocus } from "@/lib/imageWorkbenchEvents";
import { cn } from "@/lib/utils";
import type { MessageImageWorkbenchPreview } from "../types";
import { RenderableTaskImage } from "./RenderableTaskImage";
interface ImageWorkbenchMessagePreviewProps {
preview: MessageImageWorkbenchPreview;
@@ -157,6 +158,15 @@ function resolvePlaceholderLabel(
return resolveStatusLabel(preview);
}
function resolveImageUnavailableLabel(
preview: MessageImageWorkbenchPreview,
): string {
if (preview.status === "complete" || preview.status === "partial") {
return "图片暂时无法显示";
}
return resolvePlaceholderLabel(preview);
}
function shouldShowSourcePanel(preview: MessageImageWorkbenchPreview): boolean {
return Boolean(
preview.mode === "edit" ||
@@ -199,8 +209,6 @@ function resolveSourcePlaceholderLabel(
export const ImageWorkbenchMessagePreview: React.FC<
ImageWorkbenchMessagePreviewProps
> = ({ preview }) => {
const hasImage = Boolean(preview.imageUrl?.trim());
const hasSourceImage = Boolean(preview.sourceImageUrl?.trim());
const showSourcePanel = shouldShowSourcePanel(preview);
return (
@@ -244,26 +252,27 @@ export const ImageWorkbenchMessagePreview: React.FC<
<div className="px-4 pb-4">
<div className="grid gap-3 sm:grid-cols-[220px_minmax(0,1fr)]">
<div className="overflow-hidden rounded-[18px] border border-slate-200 bg-slate-50">
{hasImage ? (
<img
src={preview.imageUrl ?? ""}
alt={preview.prompt || "图片任务结果"}
className="aspect-[16/10] h-full w-full object-cover"
/>
) : (
<div className="flex aspect-[16/10] items-center justify-center bg-[radial-gradient(circle_at_top,rgba(56,189,248,0.14),transparent_46%),linear-gradient(180deg,rgba(248,250,252,0.98),rgba(241,245,249,0.98))] px-6 text-center">
<div className="space-y-2">
{preview.status === "running" ? (
<LoaderCircle className="mx-auto h-7 w-7 animate-spin text-sky-500" />
) : (
<Sparkles className="mx-auto h-7 w-7 text-slate-400" />
)}
<div className="text-sm font-medium text-slate-700">
{resolvePlaceholderLabel(preview)}
<RenderableTaskImage
src={preview.imageUrl}
alt={preview.prompt || "图片任务结果"}
className="aspect-[16/10] h-full w-full object-cover"
renderFallback={(reason) => (
<div className="flex aspect-[16/10] items-center justify-center bg-[radial-gradient(circle_at_top,rgba(56,189,248,0.14),transparent_46%),linear-gradient(180deg,rgba(248,250,252,0.98),rgba(241,245,249,0.98))] px-6 text-center">
<div className="space-y-2">
{reason === "empty" && preview.status === "running" ? (
<LoaderCircle className="mx-auto h-7 w-7 animate-spin text-sky-500" />
) : (
<Sparkles className="mx-auto h-7 w-7 text-slate-400" />
)}
<div className="text-sm font-medium text-slate-700">
{reason === "error"
? resolveImageUnavailableLabel(preview)
: resolvePlaceholderLabel(preview)}
</div>
</div>
</div>
</div>
)}
)}
/>
</div>
<div className="min-w-0">
<div className="line-clamp-2 text-sm font-medium leading-6 text-slate-900">
@@ -300,20 +309,21 @@ export const ImageWorkbenchMessagePreview: React.FC<
</div>
<div className="mt-2 flex items-center gap-3">
<div className="flex h-14 w-14 shrink-0 items-center justify-center overflow-hidden rounded-2xl border border-slate-200 bg-white">
{hasSourceImage ? (
<img
src={preview.sourceImageUrl ?? ""}
alt={
preview.sourceImagePrompt ||
resolveSourceLabel(preview.mode)
}
className="h-full w-full object-cover"
/>
) : (
<span className="px-2 text-center text-[11px] font-medium text-slate-400">
{resolveSourcePlaceholderLabel(preview)}
</span>
)}
<RenderableTaskImage
src={preview.sourceImageUrl}
alt={
preview.sourceImagePrompt ||
resolveSourceLabel(preview.mode)
}
className="h-full w-full object-cover"
renderFallback={(reason) => (
<span className="px-2 text-center text-[11px] font-medium text-slate-400">
{reason === "error"
? `${resolveSourceLabel(preview.mode)}暂时无法显示`
: resolveSourcePlaceholderLabel(preview)}
</span>
)}
/>
</div>
<div className="min-w-0">
<div className="line-clamp-2 text-xs font-medium leading-5 text-slate-700">
@@ -0,0 +1,59 @@
import {
useEffect,
useState,
type ImgHTMLAttributes,
type ReactNode,
} from "react";
type TaskImageFallbackReason = "empty" | "error";
interface RenderableTaskImageProps extends Omit<
ImgHTMLAttributes<HTMLImageElement>,
"src" | "children"
> {
src?: string | null;
renderFallback: (reason: TaskImageFallbackReason) => ReactNode;
renderImage?: (
props: ImgHTMLAttributes<HTMLImageElement> & { src: string },
) => ReactNode;
}
export function RenderableTaskImage({
src,
renderFallback,
renderImage,
onError,
...imageProps
}: RenderableTaskImageProps) {
const normalizedSrc = src?.trim() ?? "";
const [loadFailed, setLoadFailed] = useState(false);
useEffect(() => {
setLoadFailed(false);
}, [normalizedSrc]);
if (!normalizedSrc) {
return <>{renderFallback("empty")}</>;
}
if (loadFailed) {
return <>{renderFallback("error")}</>;
}
const resolvedImageProps: ImgHTMLAttributes<HTMLImageElement> & {
src: string;
} = {
...imageProps,
src: normalizedSrc,
onError: (event) => {
setLoadFailed(true);
onError?.(event);
},
};
if (renderImage) {
return <>{renderImage(resolvedImageProps)}</>;
}
return <img {...resolvedImageProps} />;
}
@@ -1,42 +0,0 @@
import React from "react";
import { ShieldCheck } from "lucide-react";
import { cn } from "@/lib/utils";
import {
getStableProcessingDescription,
STABLE_PROCESSING_LABEL,
type StableProcessingScope,
} from "../utils/stableProcessingExperience";
interface StableProcessingNoticeProps {
scope?: StableProcessingScope;
className?: string;
testId?: string;
}
export const StableProcessingNotice: React.FC<StableProcessingNoticeProps> = ({
scope = "request",
className,
testId = "stable-processing-notice",
}) => {
return (
<div
data-testid={testId}
className={cn(
"flex items-start gap-2 rounded-2xl border border-amber-200/80 bg-amber-50/90 px-3 py-2 text-[11px] leading-5 text-amber-900",
className,
)}
>
<div className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-white/80 text-amber-700">
<ShieldCheck className="h-3.5 w-3.5" />
</div>
<div className="min-w-0">
<div className="font-semibold text-amber-800">
{STABLE_PROCESSING_LABEL}
</div>
<div className="text-amber-700">
{getStableProcessingDescription(scope)}
</div>
</div>
</div>
);
};
-34
View File
@@ -1,34 +0,0 @@
/**
* Agent 后端配置
*
* 现在只使用 Aster 后端,保留配置接口以便未来扩展
*/
export type AgentBackend = "aster";
// 默认使用 Aster 后端
const STORAGE_KEY = "lime_agent_backend";
/**
* 获取当前 Agent 后端
* 现在固定返回 aster
*/
export function getAgentBackend(): AgentBackend {
return "aster";
}
/**
* 设置 Agent 后端
* 保留接口但不再生效
*/
export function setAgentBackend(_backend: AgentBackend): void {
localStorage.setItem(STORAGE_KEY, "aster");
}
/**
* 是否使用 Aster 后端
* 现在固定返回 true
*/
export function useAsterBackend(): boolean {
return true;
}
@@ -1,86 +0,0 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { resolveStableProcessingProviderGroup } from "../utils/stableProcessingExperience";
interface UseStableProcessingNoticeParams {
providerType?: string | null;
model?: string | null;
autoHideMs?: number;
}
export const STABLE_PROCESSING_NOTICE_AUTO_HIDE_MS = 3000;
const shownStableProcessingNoticeKeys = new Set<string>();
export function resetStableProcessingNoticeMemoryForTest() {
shownStableProcessingNoticeKeys.clear();
}
function getStableProcessingNoticeKey({
providerType,
model,
}: Pick<UseStableProcessingNoticeParams, "providerType" | "model">) {
return resolveStableProcessingProviderGroup({ providerType, model });
}
export function useStableProcessingNotice({
providerType,
model,
autoHideMs = STABLE_PROCESSING_NOTICE_AUTO_HIDE_MS,
}: UseStableProcessingNoticeParams) {
const noticeKey = useMemo(
() => getStableProcessingNoticeKey({ providerType, model }),
[providerType, model],
);
const [visible, setVisible] = useState(
() =>
Boolean(noticeKey) &&
!(noticeKey ? shownStableProcessingNoticeKeys.has(noticeKey) : false),
);
const lastNoticeKeyRef = useRef<string | null>(noticeKey);
const hideTimerRef = useRef<number | null>(null);
useEffect(() => {
return () => {
if (hideTimerRef.current !== null) {
window.clearTimeout(hideTimerRef.current);
}
};
}, []);
useEffect(() => {
if (hideTimerRef.current !== null) {
window.clearTimeout(hideTimerRef.current);
hideTimerRef.current = null;
}
if (!noticeKey) {
lastNoticeKeyRef.current = null;
setVisible(false);
return;
}
if (lastNoticeKeyRef.current !== noticeKey) {
lastNoticeKeyRef.current = noticeKey;
setVisible(!shownStableProcessingNoticeKeys.has(noticeKey));
}
if (shownStableProcessingNoticeKeys.has(noticeKey)) {
return;
}
shownStableProcessingNoticeKeys.add(noticeKey);
hideTimerRef.current = window.setTimeout(() => {
setVisible(false);
hideTimerRef.current = null;
}, autoHideMs);
return () => {
if (hideTimerRef.current !== null) {
window.clearTimeout(hideTimerRef.current);
hideTimerRef.current = null;
}
};
}, [autoHideMs, noticeKey]);
return visible;
}
@@ -31,6 +31,7 @@ function buildRemoteCatalog(): SkillCatalog {
itemCount: 2,
},
],
entries: [],
items: [
{
...seeded.items[0]!,
@@ -68,6 +69,7 @@ function buildCloudCatalog(): SkillCatalog {
itemCount: 1,
},
],
entries: [],
items: [
{
...seeded.items[1]!,
@@ -104,6 +106,7 @@ function buildLegacySiteCatalog(): SkillCatalog {
itemCount: 1,
},
],
entries: [],
items: [
{
...baseSkill,
@@ -5,6 +5,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CharacterMention } from "./CharacterMention";
import type { Character } from "@/lib/api/memory";
import type { Skill } from "@/lib/api/skills";
import {
clearSkillCatalogCache,
getSeededSkillCatalog,
saveSkillCatalog,
type SkillCatalog,
} from "@/lib/api/skillCatalog";
import { filterMentionableServiceSkills } from "@/components/agent/chat/service-skills/entryAdapter";
import type { ServiceSkillHomeItem } from "@/components/agent/chat/service-skills/types";
import type { BuiltinInputCommand } from "./builtinCommands";
@@ -147,6 +153,8 @@ beforeEach(() => {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
).IS_REACT_ACT_ENVIRONMENT = true;
window.localStorage.clear();
clearSkillCatalogCache();
});
afterEach(() => {
@@ -158,6 +166,8 @@ afterEach(() => {
});
mounted.container.remove();
}
window.localStorage.clear();
clearSkillCatalogCache();
vi.clearAllMocks();
});
@@ -344,6 +354,36 @@ function createServiceSkill(
};
}
function buildCatalogWithSceneEntry(): SkillCatalog {
const seeded = getSeededSkillCatalog();
return {
...seeded,
tenantId: "tenant-scene-demo",
version: "tenant-scene-demo-2026-04-05",
syncedAt: "2026-04-05T12:00:00.000Z",
entries: [
...seeded.entries,
{
id: "scene:campaign-launch",
kind: "scene",
title: "新品发布场景",
summary: "把链接解析、配图和封面串成一条产品链路。",
sceneKey: "campaign-launch",
commandPrefix: "/campaign-launch",
aliases: ["launch", "campaign"],
executionKind: "scene",
renderContract: {
resultKind: "tool_timeline",
detailKind: "scene_detail",
supportsStreaming: true,
supportsTimeline: true,
},
},
],
};
}
describe("CharacterMention", () => {
it("输入 @ 当次应弹出提及面板(不依赖受控 value 同步)", async () => {
const container = renderHarness({
@@ -397,7 +437,7 @@ describe("CharacterMention", () => {
expect(onSelectBuiltinCommand).toHaveBeenCalledWith(
expect.objectContaining({
key: "image",
key: "image_generate",
commandPrefix: "@配图",
}),
);
@@ -679,6 +719,23 @@ describe("CharacterMention", () => {
expect(document.body.textContent).toContain("/review");
});
it("统一目录中的 scene 应出现在 slash 面板里", async () => {
act(() => {
saveSkillCatalog(buildCatalogWithSceneEntry(), "bootstrap_sync");
});
const container = renderHarness();
const textarea = getTextarea(container);
await typeSlashAndWait(textarea, "/camp");
expect(document.body.textContent).toContain("场景组合");
expect(document.body.textContent).toContain("/campaign-launch");
expect(document.body.textContent).toContain(
"把链接解析、配图和封面串成一条产品链路。",
);
});
it("slash 面板选择 Lime 命令时应回填到输入框", async () => {
const onChangeSpy = vi.fn<(value: string) => void>();
const container = renderHarness({
@@ -700,6 +757,31 @@ describe("CharacterMention", () => {
expect(onChangeSpy).toHaveBeenCalledWith("/compact ");
});
it("slash 面板选择服务端 scene 时应回填场景命令", async () => {
act(() => {
saveSkillCatalog(buildCatalogWithSceneEntry(), "bootstrap_sync");
});
const onChangeSpy = vi.fn<(value: string) => void>();
const container = renderHarness({
onChangeSpy,
});
const textarea = getTextarea(container);
await typeSlashAndWait(textarea, "/camp");
const sceneButton = Array.from(
document.body.querySelectorAll("button"),
).find((button) => button.textContent?.includes("/campaign-launch"));
expect(sceneButton).toBeTruthy();
act(() => {
sceneButton?.click();
});
expect(onChangeSpy).toHaveBeenCalledWith("/campaign-launch ");
});
it("slash 面板选择已安装技能时应直接回填 slash skill", async () => {
const onChangeSpy = vi.fn<(value: string) => void>();
const container = renderHarness({
@@ -15,6 +15,10 @@ import React, {
import { createPortal } from "react-dom";
import type { Character } from "@/lib/api/memory";
import type { Skill } from "@/lib/api/skills";
import {
getSkillCatalog,
subscribeSkillCatalogChanged,
} from "@/lib/api/skillCatalog";
import { filterMentionableServiceSkills } from "@/components/agent/chat/service-skills/entryAdapter";
import type { ServiceSkillHomeItem } from "@/components/agent/chat/service-skills/types";
import { toast } from "sonner";
@@ -23,8 +27,13 @@ import {
type CodexSlashCommandDefinition,
} from "../commands";
import {
INPUTBAR_BUILTIN_COMMANDS,
filterBuiltinCommands,
filterRuntimeSceneSlashCommands,
listBuiltinCommandsFromSkillCatalog,
listRuntimeSceneSlashCommandsFromSkillCatalog,
type BuiltinInputCommand,
type RuntimeSceneSlashCommand,
} from "./builtinCommands";
import {
LazyCharacterMentionPanel,
@@ -135,6 +144,12 @@ export function CharacterMention({
const [showMentions, setShowMentions] = useState(false);
const [mentionQuery, setMentionQuery] = useState("");
const [triggerMode, setTriggerMode] = useState<TriggerMode>("mention");
const [runtimeBuiltinCommands, setRuntimeBuiltinCommands] = useState<
BuiltinInputCommand[]
>(INPUTBAR_BUILTIN_COMMANDS);
const [runtimeSceneCommands, setRuntimeSceneCommands] = useState<
RuntimeSceneSlashCommand[]
>([]);
const [panelAnchor, setPanelAnchor] = useState({
top: 0,
left: 0,
@@ -150,8 +165,8 @@ export function CharacterMention({
});
const filteredBuiltinCommands = useMemo(
() => filterBuiltinCommands(mentionQuery),
[mentionQuery],
() => filterBuiltinCommands(mentionQuery, runtimeBuiltinCommands),
[mentionQuery, runtimeBuiltinCommands],
);
const filteredServiceSkills = useMemo(
() => filterMentionableServiceSkills(serviceSkills, mentionQuery),
@@ -161,6 +176,10 @@ export function CharacterMention({
() => filterCodexSlashCommands(mentionQuery),
[mentionQuery],
);
const filteredRuntimeSceneCommands = useMemo(
() => filterRuntimeSceneSlashCommands(mentionQuery, runtimeSceneCommands),
[mentionQuery, runtimeSceneCommands],
);
const filteredCharacters = useMemo(() => {
if (!mentionQuery) return characters;
@@ -222,6 +241,44 @@ export function CharacterMention({
});
}, [inputRef]);
useEffect(() => {
let cancelled = false;
const syncCatalog = async () => {
try {
const catalog = await getSkillCatalog();
if (cancelled) {
return;
}
const nextBuiltinCommands = listBuiltinCommandsFromSkillCatalog(catalog);
const nextRuntimeScenes =
listRuntimeSceneSlashCommandsFromSkillCatalog(catalog);
setRuntimeBuiltinCommands(
nextBuiltinCommands.length > 0
? nextBuiltinCommands
: INPUTBAR_BUILTIN_COMMANDS,
);
setRuntimeSceneCommands(nextRuntimeScenes);
} catch {
if (cancelled) {
return;
}
setRuntimeBuiltinCommands(INPUTBAR_BUILTIN_COMMANDS);
setRuntimeSceneCommands([]);
}
};
void syncCatalog();
const unsubscribe = subscribeSkillCatalogChanged(() => {
void syncCatalog();
});
return () => {
cancelled = true;
unsubscribe();
};
}, []);
useEffect(() => {
const textarea = inputRef.current;
if (!textarea) return;
@@ -492,6 +549,36 @@ export function CharacterMention({
}, 0);
};
const handleSelectRuntimeSceneCommand = (
command: RuntimeSceneSlashCommand,
) => {
const textarea = inputRef.current;
if (!textarea) return;
const currentValue = textarea.value || value;
const cursorPos = textarea.selectionStart ?? currentValue.length;
const textAfterCursor = currentValue.slice(cursorPos);
const activeTrigger = resolveActiveTrigger(currentValue, cursorPos);
if (!activeTrigger || activeTrigger.mode !== "slash") {
return;
}
const newValue =
currentValue.slice(0, activeTrigger.triggerIndex) +
`${command.commandPrefix} ` +
textAfterCursor;
onChange(newValue);
setShowMentions(false);
setTimeout(() => {
textarea.focus();
const newCursorPos =
activeTrigger.triggerIndex + command.commandPrefix.length + 1;
textarea.setSelectionRange(newCursorPos, newCursorPos);
}, 0);
};
useEffect(() => {
const textarea = inputRef.current;
if (!textarea || !showMentions) return;
@@ -575,18 +662,20 @@ export function CharacterMention({
<LazyCharacterMentionPanel
mode={triggerMode}
mentionQuery={mentionQuery}
builtinCommands={filteredBuiltinCommands}
slashCommands={filteredSlashCommands}
mentionServiceSkills={filteredServiceSkills}
filteredCharacters={filteredCharacters}
builtinCommands={filteredBuiltinCommands}
slashCommands={filteredSlashCommands}
sceneCommands={filteredRuntimeSceneCommands}
mentionServiceSkills={filteredServiceSkills}
filteredCharacters={filteredCharacters}
installedSkills={installedSkills}
availableSkills={availableSkills}
commandRef={commandRef}
onQueryChange={setMentionQuery}
onSelectBuiltinCommand={handleSelectBuiltinCommand}
onSelectServiceSkill={handleSelectServiceSkill}
onSelectSlashCommand={handleSelectSlashCommand}
onSelectCharacter={handleSelectCharacter}
onSelectBuiltinCommand={handleSelectBuiltinCommand}
onSelectServiceSkill={handleSelectServiceSkill}
onSelectSlashCommand={handleSelectSlashCommand}
onSelectSceneCommand={handleSelectRuntimeSceneCommand}
onSelectCharacter={handleSelectCharacter}
onSelectInstalledSkill={handleSelectInstalledSkill}
onSelectAvailableSkill={handleSelectAvailableSkill}
onNavigateToSettings={
@@ -18,7 +18,10 @@ import type { Skill } from "@/lib/api/skills";
import { resolveServiceSkillEntryDescription } from "@/components/agent/chat/service-skills/entryAdapter";
import type { ServiceSkillHomeItem } from "@/components/agent/chat/service-skills/types";
import type { CodexSlashCommandDefinition } from "../commands";
import type { BuiltinInputCommand } from "./builtinCommands";
import type {
BuiltinInputCommand,
RuntimeSceneSlashCommand,
} from "./builtinCommands";
interface MentionServiceSkillGroup {
key: string;
@@ -88,6 +91,7 @@ interface CharacterMentionPanelProps {
mentionQuery: string;
builtinCommands: BuiltinInputCommand[];
slashCommands: CodexSlashCommandDefinition[];
sceneCommands: RuntimeSceneSlashCommand[];
mentionServiceSkills: ServiceSkillHomeItem[];
filteredCharacters: Character[];
installedSkills: Skill[];
@@ -97,6 +101,7 @@ interface CharacterMentionPanelProps {
onSelectBuiltinCommand: (command: BuiltinInputCommand) => void;
onSelectServiceSkill: (skill: ServiceSkillHomeItem) => void;
onSelectSlashCommand: (command: CodexSlashCommandDefinition) => void;
onSelectSceneCommand: (command: RuntimeSceneSlashCommand) => void;
onSelectCharacter: (character: Character) => void;
onSelectInstalledSkill: (skill: Skill) => void;
onSelectAvailableSkill: (skill: Skill) => void;
@@ -108,6 +113,7 @@ export const CharacterMentionPanel: React.FC<CharacterMentionPanelProps> = ({
mentionQuery,
builtinCommands,
slashCommands,
sceneCommands,
mentionServiceSkills,
filteredCharacters,
installedSkills,
@@ -117,6 +123,7 @@ export const CharacterMentionPanel: React.FC<CharacterMentionPanelProps> = ({
onSelectBuiltinCommand,
onSelectServiceSkill,
onSelectSlashCommand,
onSelectSceneCommand,
onSelectCharacter,
onSelectInstalledSkill,
onSelectAvailableSkill,
@@ -130,8 +137,10 @@ export const CharacterMentionPanel: React.FC<CharacterMentionPanelProps> = ({
);
const visibleCharacters = mode === "mention" ? filteredCharacters : [];
const visibleSlashCommands = mode === "slash" ? slashCommands : [];
const visibleSceneCommands = mode === "slash" ? sceneCommands : [];
const hasFilteredResults =
visibleSlashCommands.length > 0 ||
visibleSceneCommands.length > 0 ||
visibleBuiltinCommands.length > 0 ||
visibleServiceSkillGroups.length > 0 ||
visibleCharacters.length > 0 ||
@@ -191,6 +200,25 @@ export const CharacterMentionPanel: React.FC<CharacterMentionPanelProps> = ({
))}
</CommandGroup>
) : null}
{visibleSceneCommands.length > 0 ? (
<CommandGroup heading="场景组合">
{visibleSceneCommands.map((command) => (
<CommandItem
key={command.entryId ?? command.key}
onSelect={() => onSelectSceneCommand(command)}
className="cursor-pointer"
>
<Zap className="mr-2 h-4 w-4 text-sky-600" />
<div className="flex-1">
<div className="font-medium">{command.commandPrefix}</div>
<div className="text-xs text-muted-foreground line-clamp-1">
{command.description}
</div>
</div>
</CommandItem>
))}
</CommandGroup>
) : null}
{visibleBuiltinCommands.length > 0 ? (
<CommandGroup heading="内建命令">
{visibleBuiltinCommands.map((command) => (
@@ -126,6 +126,7 @@ export const SkillSelectorContent: React.FC<SkillSelectorContentProps> = ({
mode="mention"
mentionQuery={query}
builtinCommands={[] satisfies BuiltinInputCommand[]}
sceneCommands={[]}
slashCommands={[]}
mentionServiceSkills={mentionServiceSkills}
filteredCharacters={[]}
@@ -135,6 +136,7 @@ export const SkillSelectorContent: React.FC<SkillSelectorContentProps> = ({
onQueryChange={onQueryChange}
onSelectBuiltinCommand={() => undefined}
onSelectServiceSkill={(skill) => onSelectServiceSkill?.(skill)}
onSelectSceneCommand={() => undefined}
onSelectSlashCommand={() => undefined}
onSelectCharacter={() => undefined}
onSelectInstalledSkill={onSelectInstalledSkill}
@@ -1,101 +1,145 @@
import {
getSeededSkillCatalog,
listSkillCatalogCommandEntries,
listSkillCatalogSceneEntries,
type SkillCatalog,
type SkillCatalogCommandEntry,
type SkillCatalogSceneEntry,
} from "@/lib/api/skillCatalog";
export interface BuiltinInputCommand {
key:
| "image"
| "cover_generate"
| "image_edit"
| "image_variation"
| "video_generate"
| "transcription_generate"
| "url_parse";
key: string;
label: string;
mentionLabel: string;
commandPrefix: string;
description: string;
aliases: string[];
entryId?: string;
}
export const INPUTBAR_BUILTIN_COMMANDS: BuiltinInputCommand[] = [
{
key: "image",
label: "配图",
mentionLabel: "配图",
commandPrefix: "@配图",
description: "根据文字描述生成新的图片结果",
aliases: ["image", "img", "图片", "生图"],
},
{
key: "cover_generate",
label: "封面",
mentionLabel: "封面",
commandPrefix: "@封面",
description: "根据主题生成平台封面图任务",
aliases: ["cover", "fengmian", "封面", "封面图", "头图"],
},
{
key: "image_edit",
label: "修图",
mentionLabel: "修图",
commandPrefix: "@修图",
description: "编辑已有图片并生成新的结果图",
aliases: ["edit", "xiutu", "修图", "改图", "图片编辑"],
},
{
key: "image_variation",
label: "重绘",
mentionLabel: "重绘",
commandPrefix: "@重绘",
description: "基于已有图片或参考图继续重绘新的结果图",
aliases: ["variation", "variant", "zhonghui", "重绘", "图片重绘", "变体"],
},
{
key: "video_generate",
label: "视频",
mentionLabel: "视频",
commandPrefix: "@视频",
description: "根据文字描述提交视频生成任务",
aliases: ["video", "shipin", "视频", "短视频", "生成视频"],
},
{
key: "transcription_generate",
label: "转写",
mentionLabel: "转写",
commandPrefix: "@转写",
description: "把音频或视频来源提交为转写任务",
aliases: ["transcribe", "zhuanxie", "转写", "逐字稿", "字幕", "语音转文字"],
},
{
key: "url_parse",
label: "链接解析",
mentionLabel: "链接解析",
commandPrefix: "@链接解析",
description: "解析网页链接并提交为可追踪的文本任务",
aliases: [
"url",
"url_parse",
"链接",
"链接解析",
"网页读取",
"网页解析",
],
},
];
export interface RuntimeSceneSlashCommand {
key: string;
label: string;
commandPrefix: string;
description: string;
aliases: string[];
entryId?: string;
}
export function filterBuiltinCommands(query: string): BuiltinInputCommand[] {
const normalizedQuery = query.trim().toLowerCase();
if (!normalizedQuery) {
return INPUTBAR_BUILTIN_COMMANDS;
function normalizeSearchText(value: string): string {
return value.trim().toLowerCase();
}
function collectBuiltinCommandHaystacks(command: BuiltinInputCommand): string[] {
return [
command.label,
command.mentionLabel,
command.commandPrefix,
command.description,
...command.aliases,
];
}
function collectSceneCommandHaystacks(command: RuntimeSceneSlashCommand): string[] {
return [
command.label,
command.commandPrefix,
command.description,
...command.aliases,
];
}
function resolveMentionTriggerPrefix(
entry: SkillCatalogCommandEntry,
): string | null {
const mentionTrigger = entry.triggers.find((trigger) => trigger.mode === "mention");
return mentionTrigger?.prefix?.trim() || null;
}
function toBuiltinInputCommand(
entry: SkillCatalogCommandEntry,
): BuiltinInputCommand | null {
const commandPrefix = resolveMentionTriggerPrefix(entry);
if (!commandPrefix) {
return null;
}
return INPUTBAR_BUILTIN_COMMANDS.filter((command) => {
const haystacks = [
command.label,
command.mentionLabel,
command.commandPrefix,
command.description,
...command.aliases,
];
return haystacks.some((value) =>
return {
key: entry.commandKey,
label: entry.title,
mentionLabel: entry.title,
commandPrefix,
description: entry.summary,
aliases: entry.aliases ?? [],
entryId: entry.id,
};
}
function toRuntimeSceneSlashCommand(
entry: SkillCatalogSceneEntry,
): RuntimeSceneSlashCommand | null {
const commandPrefix = entry.commandPrefix.trim();
if (!commandPrefix.startsWith("/")) {
return null;
}
return {
key: entry.sceneKey,
label: entry.title,
commandPrefix,
description: entry.summary,
aliases: entry.aliases ?? [],
entryId: entry.id,
};
}
export function listBuiltinCommandsFromSkillCatalog(
catalog: SkillCatalog,
): BuiltinInputCommand[] {
return listSkillCatalogCommandEntries(catalog)
.map((entry) => toBuiltinInputCommand(entry))
.filter((entry): entry is BuiltinInputCommand => Boolean(entry));
}
export function listRuntimeSceneSlashCommandsFromSkillCatalog(
catalog: SkillCatalog,
): RuntimeSceneSlashCommand[] {
return listSkillCatalogSceneEntries(catalog)
.map((entry) => toRuntimeSceneSlashCommand(entry))
.filter((entry): entry is RuntimeSceneSlashCommand => Boolean(entry));
}
export const INPUTBAR_BUILTIN_COMMANDS: BuiltinInputCommand[] =
listBuiltinCommandsFromSkillCatalog(getSeededSkillCatalog());
export function filterBuiltinCommands(
query: string,
commands: BuiltinInputCommand[] = INPUTBAR_BUILTIN_COMMANDS,
): BuiltinInputCommand[] {
const normalizedQuery = normalizeSearchText(query);
if (!normalizedQuery) {
return commands;
}
return commands.filter((command) =>
collectBuiltinCommandHaystacks(command).some((value) =>
value.toLowerCase().includes(normalizedQuery),
);
});
),
);
}
export function filterRuntimeSceneSlashCommands(
query: string,
commands: RuntimeSceneSlashCommand[],
): RuntimeSceneSlashCommand[] {
const normalizedQuery = normalizeSearchText(query);
if (!normalizedQuery) {
return commands;
}
return commands.filter((command) =>
collectSceneCommandHaystacks(command).some((value) =>
value.toLowerCase().includes(normalizedQuery),
),
);
}
@@ -108,27 +108,9 @@ function resolveImageWorkbenchModeLabel(mode: ImageWorkbenchTaskMode): string {
return "图片生成";
}
function resolveImageWorkbenchSkillLabel(mode: ImageWorkbenchTaskMode): string {
if (mode === "edit") {
return "图片编辑技能";
}
if (mode === "variation") {
return "图片重绘技能";
}
return "配图技能";
}
function resolveImageWorkbenchCommandLabel(mode: ImageWorkbenchTaskMode): string {
if (mode === "edit") {
return "@修图";
}
if (mode === "variation") {
return "@重绘";
}
return "@配图";
}
function resolveImageWorkbenchProgressLabel(mode: ImageWorkbenchTaskMode): string {
function resolveImageWorkbenchProgressLabel(
mode: ImageWorkbenchTaskMode,
): string {
if (mode === "edit") {
return "修图";
}
@@ -140,12 +122,12 @@ function resolveImageWorkbenchProgressLabel(mode: ImageWorkbenchTaskMode): strin
function resolveImageWorkbenchActionVerb(mode: ImageWorkbenchTaskMode): string {
if (mode === "edit") {
return "梳理来源图与编辑要求,再通过图片编辑技能提交异步修图任务";
return "整理来源图与编辑要求,并创建异步修图任务";
}
if (mode === "variation") {
return "梳理参考图与重绘要求,再通过图片重绘技能提交异步图片任务";
return "整理参考图与重绘要求,并创建异步图片任务";
}
return "梳理画面主题、尺寸和出图数量,再通过配图技能提交异步图片任务";
return "整理画面主题、尺寸和出图数量,并创建异步图片任务";
}
function formatToolArguments(value: Record<string, unknown>): string {
@@ -172,38 +154,6 @@ function resolveImageWorkbenchAnalysisText(
}
}
function buildImageWorkbenchSkillToolCall(
params: BuildImageWorkbenchProcessDescriptorParams,
): AgentToolCallState {
const endedAt = params.endedAt ?? params.startedAt;
const prompt = collapseWhitespace(params.prompt) || "当前图片任务";
const rawText =
collapseWhitespace(params.rawText || "") ||
`${resolveImageWorkbenchCommandLabel(params.mode)} ${
params.mode === "variation" ? "重绘 " : params.mode === "generate" ? "生成 " : ""
}${prompt}`.trim();
return {
id: `${params.taskId}:skill`,
name: "skill",
arguments: formatToolArguments({
name: resolveImageWorkbenchSkillLabel(params.mode),
command: rawText,
prompt,
}),
status: "completed",
result: {
success: true,
output:
params.status === "running"
? `已完成${resolveImageWorkbenchProgressLabel(params.mode)}需求解析,正在提交异步图片任务。`
: `已完成${resolveImageWorkbenchProgressLabel(params.mode)}需求解析,并保留本轮执行上下文。`,
},
startTime: params.startedAt,
endTime: endedAt,
};
}
function resolveImageWorkbenchTaskToolStatus(
status: ImageWorkbenchMessageStatus,
): AgentToolCallState["status"] {
@@ -227,7 +177,8 @@ function buildImageWorkbenchTaskToolResult(
params.successCount ??
(params.status === "complete" ? params.count || 1 : undefined);
const images =
params.imageUrl && (params.status === "complete" || params.status === "partial")
params.imageUrl &&
(params.status === "complete" || params.status === "partial")
? [{ src: params.imageUrl, origin: "tool_payload" as const }]
: undefined;
@@ -253,7 +204,8 @@ function buildImageWorkbenchTaskToolResult(
default:
return {
success: false,
output: params.failureMessage?.trim() || "图片任务执行失败,未返回可用结果。",
output:
params.failureMessage?.trim() || "图片任务执行失败,未返回可用结果。",
error: params.failureMessage?.trim() || "图片任务执行失败",
};
}
@@ -278,7 +230,8 @@ function buildImageWorkbenchTaskToolCall(
status,
result: buildImageWorkbenchTaskToolResult(params),
startTime: params.startedAt,
endTime: status === "running" ? undefined : params.endedAt ?? params.startedAt,
endTime:
status === "running" ? undefined : (params.endedAt ?? params.startedAt),
};
}
@@ -288,20 +241,15 @@ export function buildImageWorkbenchProcessDescriptor(
toolCalls: AgentToolCallState[];
contentParts: ContentPart[];
} {
const skillToolCall = buildImageWorkbenchSkillToolCall(params);
const taskToolCall = buildImageWorkbenchTaskToolCall(params);
return {
toolCalls: [skillToolCall, taskToolCall],
toolCalls: [taskToolCall],
contentParts: [
{
type: "text",
text: resolveImageWorkbenchAnalysisText(params),
},
{
type: "tool_use",
toolCall: skillToolCall,
},
{
type: "tool_use",
toolCall: taskToolCall,
@@ -309,10 +309,6 @@ describe("useWorkspaceImageTaskPreviewRuntime", () => {
content: "图片任务已创建,正在准备执行。",
isThinking: true,
toolCalls: [
expect.objectContaining({
name: "skill",
status: "completed",
}),
expect.objectContaining({
name: "limeCreateImageGenerationTask",
status: "running",
@@ -322,12 +318,6 @@ describe("useWorkspaceImageTaskPreviewRuntime", () => {
expect.objectContaining({
type: "text",
}),
expect.objectContaining({
type: "tool_use",
toolCall: expect.objectContaining({
name: "skill",
}),
}),
expect.objectContaining({
type: "tool_use",
toolCall: expect.objectContaining({
@@ -391,10 +381,6 @@ describe("useWorkspaceImageTaskPreviewRuntime", () => {
expect.objectContaining({
content: "图片任务正在生成中。",
toolCalls: [
expect.objectContaining({
name: "skill",
status: "completed",
}),
expect.objectContaining({
name: "limeCreateImageGenerationTask",
status: "running",
@@ -426,10 +412,6 @@ describe("useWorkspaceImageTaskPreviewRuntime", () => {
content: "图片任务已完成,共生成 1 张。",
isThinking: false,
toolCalls: [
expect.objectContaining({
name: "skill",
status: "completed",
}),
expect.objectContaining({
name: "limeCreateImageGenerationTask",
status: "completed",
@@ -242,10 +242,6 @@ describe("useWorkspaceImageWorkbenchActionRuntime", () => {
content: "图片任务已创建,正在准备执行。",
isThinking: true,
toolCalls: [
expect.objectContaining({
name: "skill",
status: "completed",
}),
expect.objectContaining({
name: "limeCreateImageGenerationTask",
status: "running",
@@ -31,6 +31,7 @@ const mockSetChatToolPreferences = vi.fn();
const mockSetRuntimeTeamDispatchPreview = vi.fn();
const mockEnsureBrowserAssistCanvas = vi.fn(async () => true);
const mockHandleAutoLaunchMatchedSiteSkill = vi.fn(async () => undefined);
const mockHandleRuntimeSceneLaunch = vi.fn(async () => false);
const mockHandleImageWorkbenchCommand = vi.fn<
HookProps["handleImageWorkbenchCommand"]
>(async () => true);
@@ -203,6 +204,8 @@ function mountHook(initialProps?: Partial<HookProps>): HookHarness {
mockEnsureBrowserAssistCanvas as HookProps["ensureBrowserAssistCanvas"],
handleAutoLaunchMatchedSiteSkill:
mockHandleAutoLaunchMatchedSiteSkill as HookProps["handleAutoLaunchMatchedSiteSkill"],
handleRuntimeSceneLaunch:
mockHandleRuntimeSceneLaunch as HookProps["handleRuntimeSceneLaunch"],
handleImageWorkbenchCommand:
mockHandleImageWorkbenchCommand as HookProps["handleImageWorkbenchCommand"],
resolveImageWorkbenchSkillRequest:
@@ -245,6 +248,7 @@ describe("useWorkspaceSendActions", () => {
vi.clearAllMocks();
mockHandleImageWorkbenchCommand.mockResolvedValue(true);
mockHandleRuntimeSceneLaunch.mockResolvedValue(false);
mockResolveImageWorkbenchSkillRequest.mockReturnValue(null);
});
@@ -766,6 +770,29 @@ describe("useWorkspaceSendActions", () => {
}
});
it("/scene-key 命中运行时场景时应走统一 scene 启动入口,而不是继续发送普通消息", async () => {
mockHandleRuntimeSceneLaunch.mockResolvedValueOnce(true);
const harness = mountHook({
input: "/campaign-launch 帮我做一版新品活动启动方案",
});
try {
await act(async () => {
const started = await harness.getValue().handleSend();
expect(started).toBe(true);
});
expect(mockHandleRuntimeSceneLaunch).toHaveBeenCalledTimes(1);
expect(mockHandleRuntimeSceneLaunch).toHaveBeenCalledWith(
"/campaign-launch 帮我做一版新品活动启动方案",
);
expect(mockSendMessage).not.toHaveBeenCalled();
expect(mockHandleAutoLaunchMatchedSiteSkill).not.toHaveBeenCalled();
} finally {
harness.unmount();
}
});
it("已携带 service_skill_launch metadata 时不应再被前端二次命中站点技能或浏览器前置引导", async () => {
const mockMaybeStartBrowserTaskPreflight = vi.fn(() => false);
const harness = mountHook({
@@ -358,6 +358,7 @@ interface UseWorkspaceSendActionsParams {
handleAutoLaunchMatchedSiteSkill: (
match: AutoMatchedSiteSkill<ServiceSkillHomeItem>,
) => Promise<void>;
handleRuntimeSceneLaunch: (rawText: string) => Promise<boolean>;
handleImageWorkbenchCommand: (input: {
rawText: string;
parsedCommand: ParsedImageWorkbenchCommand;
@@ -445,6 +446,7 @@ export function useWorkspaceSendActions({
setRuntimeTeamDispatchPreview,
ensureBrowserAssistCanvas,
handleAutoLaunchMatchedSiteSkill,
handleRuntimeSceneLaunch,
handleImageWorkbenchCommand,
resolveImageWorkbenchSkillRequest,
}: UseWorkspaceSendActionsParams) {
@@ -631,6 +633,14 @@ export function useWorkspaceSendActions({
};
}
if (
!sendOptions?.purpose &&
sourceText.trim().startsWith("/") &&
(await handleRuntimeSceneLaunch(sourceText))
) {
return { kind: "done", result: true };
}
const trimmedSourceText = sourceText.trim();
if (
activeTheme === "general" &&
@@ -735,6 +745,7 @@ export function useWorkspaceSendActions({
mentionedCharacters,
projectId,
resolveSendBoundary,
handleRuntimeSceneLaunch,
serviceSkills,
workspaceRequestMetadataBase,
],
@@ -13,6 +13,8 @@ const mockGetServiceSkillRun = vi.fn();
const mockIsTerminalServiceSkillRunStatus = vi.fn();
const mockCreateContent = vi.fn();
const mockListProjects = vi.fn();
const mockGetSkillCatalog = vi.fn();
const mockListSkillCatalogSceneEntries = vi.fn();
const mockRecordServiceSkillAutomationLink = vi.fn();
const mockSiteGetAdapterLaunchReadiness = vi.fn();
const mockToastSuccess = vi.fn();
@@ -55,6 +57,12 @@ vi.mock("@/lib/api/project", () => ({
},
}));
vi.mock("@/lib/api/skillCatalog", () => ({
getSkillCatalog: () => mockGetSkillCatalog(),
listSkillCatalogSceneEntries: (catalog: unknown) =>
mockListSkillCatalogSceneEntries(catalog),
}));
vi.mock("@/lib/webview-api", () => ({
siteGetAdapterLaunchReadiness: (...args: unknown[]) =>
mockSiteGetAdapterLaunchReadiness(...args),
@@ -217,6 +225,7 @@ function createScheduledServiceSkill(): ServiceSkillHomeItem {
function createCloudServiceSkill(): ServiceSkillHomeItem {
return {
id: "cloud-video-dubbing",
skillKey: "campaign-launch",
title: "云端视频配音",
summary: "把视频文案与素材提交到云端,生成一版可继续加工的配音结果。",
category: "视频创作",
@@ -264,6 +273,7 @@ function renderHook(props?: Partial<HookProps>) {
contentId: "content-current",
input: "请结合当前上下文继续",
chatToolPreferences: DEFAULT_CHAT_TOOL_PREFERENCES,
serviceSkills: [],
onNavigate: vi.fn(),
recordServiceSkillUsage: vi.fn(),
};
@@ -321,6 +331,15 @@ beforeEach(() => {
id: "content-created-by-service-skill",
});
mockListProjects.mockResolvedValue([createProject()]);
mockGetSkillCatalog.mockResolvedValue({
version: "test-catalog",
tenantId: "tenant-test",
syncedAt: "2026-04-05T00:00:00.000Z",
groups: [],
items: [],
entries: [],
});
mockListSkillCatalogSceneEntries.mockReturnValue([]);
mockRecordServiceSkillAutomationLink.mockReset();
mockToastSuccess.mockReset();
mockToastError.mockReset();
@@ -626,6 +645,117 @@ describe("useWorkspaceServiceSkillEntryActions", () => {
);
});
it("/scene-key 应从统一目录解析到 cloud scene 技能并复用现有云端启动链", async () => {
const onNavigate = vi.fn();
const recordServiceSkillUsage = vi.fn();
mockListSkillCatalogSceneEntries.mockReturnValue([
{
id: "scene:campaign-launch",
kind: "scene",
title: "活动启动场景",
summary: "围绕活动目标生成启动方案。",
sceneKey: "campaign-launch",
commandPrefix: "/campaign-launch",
linkedSkillId: "cloud-video-dubbing",
},
]);
mockCreateServiceSkillRun.mockResolvedValue({
id: "service-skill-run-scene-1",
status: "success",
outputSummary: "活动启动方案已生成",
outputText: "# 活动启动方案\n\n第一版方案",
finishedAt: "2026-04-05T10:00:00.000Z",
});
const { render, getValue } = renderHook({
onNavigate,
recordServiceSkillUsage,
serviceSkills: [createCloudServiceSkill()],
});
await render();
let handled = false;
await act(async () => {
handled = await getValue().handleRuntimeSceneLaunch(
"/campaign-launch 帮我做一版新品活动启动方案",
);
});
expect(handled).toBe(true);
expect(mockCreateServiceSkillRun).toHaveBeenCalledWith(
"cloud-video-dubbing",
expect.stringContaining("[技能任务] 云端视频配音"),
);
expect(mockCreateServiceSkillRun).toHaveBeenCalledWith(
"cloud-video-dubbing",
expect.stringContaining("[补充要求] 帮我做一版新品活动启动方案"),
);
expect(recordServiceSkillUsage).toHaveBeenCalledWith({
skillId: "cloud-video-dubbing",
runnerType: "instant",
});
expect(onNavigate).toHaveBeenCalledWith(
"agent",
expect.objectContaining({
contentId: "content-created-by-service-skill",
}),
);
});
it("/scene-key 云端提交失败时应自动回退到本地工作区", async () => {
const onNavigate = vi.fn();
const recordServiceSkillUsage = vi.fn();
mockListSkillCatalogSceneEntries.mockReturnValue([
{
id: "scene:campaign-launch",
kind: "scene",
title: "活动启动场景",
summary: "围绕活动目标生成启动方案。",
sceneKey: "campaign-launch",
commandPrefix: "/campaign-launch",
linkedSkillId: "cloud-video-dubbing",
},
]);
mockCreateServiceSkillRun.mockRejectedValue(
new Error("缺少 OEM 云端 Session Token,请先完成登录或注入会话。"),
);
const { render, getValue } = renderHook({
onNavigate,
recordServiceSkillUsage,
serviceSkills: [createCloudServiceSkill()],
});
await render();
let handled = false;
await act(async () => {
handled = await getValue().handleRuntimeSceneLaunch(
"/campaign-launch 帮我做一版新品活动启动方案",
);
});
expect(handled).toBe(true);
expect(onNavigate).toHaveBeenCalledWith(
"agent",
expect.objectContaining({
projectId: "project-1",
contentId: "content-current",
autoRunInitialPromptOnMount: true,
initialUserPrompt: expect.stringContaining("[技能任务] 云端视频配音"),
}),
);
expect(recordServiceSkillUsage).toHaveBeenCalledWith({
skillId: "cloud-video-dubbing",
runnerType: "instant",
});
expect(mockToastInfo).toHaveBeenCalledWith(
"云端视频配音 云端暂不可用,已切换到本地工作区继续。",
{
id: "toast-loading",
},
);
});
it("普通技能进入工作区时应在保留 seed metadata 的同时注入当前 Team", async () => {
const onNavigate = vi.fn();
const { render, getValue } = renderHook({
@@ -2,6 +2,11 @@ import { useCallback, useState } from "react";
import { toast } from "sonner";
import { siteGetAdapterLaunchReadiness } from "@/lib/webview-api";
import { createAutomationJob } from "@/lib/api/automation";
import {
getSkillCatalog,
listSkillCatalogSceneEntries,
type SkillCatalogSceneEntry,
} from "@/lib/api/skillCatalog";
import {
createServiceSkillRun,
getServiceSkillRun,
@@ -26,7 +31,10 @@ import {
resolveWorkspaceEntry,
type WorkspaceEntryPayload,
} from "../workspaceEntry";
import { composeServiceSkillPrompt } from "../service-skills/promptComposer";
import {
createDefaultServiceSkillSlotValues,
composeServiceSkillPrompt,
} from "../service-skills/promptComposer";
import {
buildServiceSkillAutomationAgentTurnPayloadContext,
buildServiceSkillAutomationInitialValues,
@@ -89,6 +97,7 @@ function normalizeOptionalText(value?: string | null): string | undefined {
interface ServiceSkillLaunchOptions {
launchUserInput?: string | null;
fallbackToWorkspaceOnCloudSubmitFailure?: boolean;
}
function resolveServiceSkillLaunchUserInput(
@@ -102,6 +111,77 @@ function resolveServiceSkillLaunchUserInput(
return normalizeOptionalText(currentInput);
}
interface ParsedRuntimeSceneCommand {
sceneKey: string;
userInput: string;
}
function normalizeCommandToken(value?: string | null): string {
if (typeof value !== "string") {
return "";
}
return value.trim().replace(/^\/+/, "").toLowerCase();
}
function parseRuntimeSceneCommand(
rawText: string,
): ParsedRuntimeSceneCommand | null {
const sceneMatch = rawText.trim().match(/^\/([a-zA-Z0-9_-]+)\s*([\s\S]*)$/);
if (!sceneMatch) {
return null;
}
const [, sceneKey, userInput] = sceneMatch;
return {
sceneKey,
userInput: userInput?.trim() || "",
};
}
function matchesRuntimeSceneEntry(
entry: SkillCatalogSceneEntry,
sceneKey: string,
): boolean {
const normalizedSceneKey = normalizeCommandToken(sceneKey);
if (!normalizedSceneKey) {
return false;
}
if (normalizeCommandToken(entry.sceneKey) === normalizedSceneKey) {
return true;
}
if (normalizeCommandToken(entry.commandPrefix) === normalizedSceneKey) {
return true;
}
return (entry.aliases ?? []).some(
(alias) => normalizeCommandToken(alias) === normalizedSceneKey,
);
}
function resolveRuntimeSceneSkill(
serviceSkills: ServiceSkillHomeItem[],
entry: SkillCatalogSceneEntry,
): ServiceSkillHomeItem | null {
const normalizedSceneKey = normalizeCommandToken(entry.sceneKey);
if (!normalizedSceneKey) {
return null;
}
return (
serviceSkills.find((skill) => skill.id === entry.linkedSkillId) ||
serviceSkills.find(
(skill) => normalizeCommandToken(skill.skillKey) === normalizedSceneKey,
) ||
serviceSkills.find(
(skill) => normalizeCommandToken(skill.id) === normalizedSceneKey,
) ||
null
);
}
function buildServiceSkillCloudResultBody(
skill: ServiceSkillHomeItem,
run: ServiceSkillRun,
@@ -209,6 +289,7 @@ interface UseWorkspaceServiceSkillEntryActionsParams {
contentId?: string | null;
input: string;
chatToolPreferences: ChatToolPreferences;
serviceSkills: ServiceSkillHomeItem[];
preferredTeamPresetId?: string | null;
selectedTeam?: TeamDefinition | null;
selectedTeamLabel?: string | null;
@@ -227,6 +308,7 @@ export function useWorkspaceServiceSkillEntryActions({
contentId,
input,
chatToolPreferences,
serviceSkills,
preferredTeamPresetId,
selectedTeam,
selectedTeamLabel,
@@ -685,12 +767,14 @@ export function useWorkspaceServiceSkillEntryActions({
if (skill.executionLocation === "cloud_required") {
const toastId = toast.loading(`正在提交 ${skill.title} 到云端...`);
let runCreated = false;
try {
setServiceSkillDialogOpen(false);
setSelectedServiceSkill(null);
let run = await createServiceSkillRun(skill.id, prompt);
runCreated = true;
recordServiceSkillCloudRun(skill.id, run);
recordServiceSkillUsage({
skillId: skill.id,
@@ -762,6 +846,47 @@ export function useWorkspaceServiceSkillEntryActions({
},
);
} catch (error) {
if (
options?.fallbackToWorkspaceOnCloudSubmitFailure &&
!runCreated
) {
let workspacePayload: WorkspaceEntryPayload;
try {
workspacePayload = await prepareServiceSkillWorkspacePayload(
skill,
prompt,
);
} catch (workspaceError) {
toast.error(
`提交云端运行失败:${getErrorMessage(error)};本地回退失败:${getErrorMessage(workspaceError)}`,
{
id: toastId,
},
);
return;
}
const entered = navigateToServiceSkillWorkspace(workspacePayload);
if (!entered) {
toast.error(
`提交云端运行失败:${getErrorMessage(error)};进入本地工作区失败,请稍后重试。`,
{
id: toastId,
},
);
return;
}
recordServiceSkillUsage({
skillId: skill.id,
runnerType: skill.runnerType,
});
toast.info(`${skill.title} 云端暂不可用,已切换到本地工作区继续。`, {
id: toastId,
});
return;
}
toast.error(`提交云端运行失败:${getErrorMessage(error)}`, {
id: toastId,
});
@@ -808,6 +933,41 @@ export function useWorkspaceServiceSkillEntryActions({
],
);
const handleRuntimeSceneLaunch = useCallback(
async (rawText: string): Promise<boolean> => {
const parsedSceneCommand = parseRuntimeSceneCommand(rawText);
if (!parsedSceneCommand) {
return false;
}
const catalog = await getSkillCatalog();
const sceneEntry = listSkillCatalogSceneEntries(catalog).find((entry) =>
matchesRuntimeSceneEntry(entry, parsedSceneCommand.sceneKey),
);
if (!sceneEntry) {
return false;
}
const matchedSkill = resolveRuntimeSceneSkill(serviceSkills, sceneEntry);
if (!matchedSkill) {
return false;
}
await handleServiceSkillLaunch(
matchedSkill,
createDefaultServiceSkillSlotValues(matchedSkill),
{
launchUserInput:
normalizeOptionalText(parsedSceneCommand.userInput) ?? undefined,
fallbackToWorkspaceOnCloudSubmitFailure: true,
},
);
return true;
},
[handleServiceSkillLaunch, serviceSkills],
);
const handleAutoLaunchMatchedSiteSkill = useCallback(
async (match: AutoMatchedSiteSkill<ServiceSkillHomeItem>) => {
await handleServiceSkillLaunch(match.skill, match.slotValues, {
@@ -996,6 +1156,7 @@ export function useWorkspaceServiceSkillEntryActions({
handleServiceSkillSelect,
handleServiceSkillDialogOpenChange,
handleServiceSkillLaunch,
handleRuntimeSceneLaunch,
handleAutoLaunchMatchedSiteSkill,
handleServiceSkillBrowserRuntimeLaunch,
handleServiceSkillAutomationSetup,
-2
View File
@@ -1,2 +0,0 @@
export { AgentChatPage } from "./AgentChatPage";
export { AgentSkillsPanel } from "./AgentSkillsPanel";
-16
View File
@@ -1,16 +0,0 @@
/**
* @file tabs 导出
* @module components/image-gen/tabs
*/
export { AiImageGenTab } from "./AiImageGenTab";
export type { AiImageGenTabProps } from "./AiImageGenTab";
export { ImageSearchTab } from "./ImageSearchTab";
export type { ImageSearchTabProps } from "./ImageSearchTab";
export { LocalImageTab } from "./LocalImageTab";
export type { LocalImageTabProps } from "./LocalImageTab";
export { MyGalleryTab } from "./MyGalleryTab";
export type { MyGalleryTabProps } from "./MyGalleryTab";
+19 -4
View File
@@ -8,8 +8,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { __imageGenFalTestUtils } from "./useImageGen";
import { silenceConsole } from "./test-utils";
const { buildFalInput, requestImageFromFal, resolveFalEndpointModelCandidates } =
__imageGenFalTestUtils;
const {
buildFalInput,
requestImageFromFal,
resolveFalEndpointModelCandidates,
} = __imageGenFalTestUtils;
function createJsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
@@ -85,6 +88,18 @@ describe("useImageGen Fal 调用链路", () => {
expect(payload).not.toHaveProperty("image_urls");
});
it("1792x1024 应映射为 Fal 支持的 16:9,而不是 7:4", () => {
const payload = buildFalInput(
"a spring cafe",
[],
"1792x1024",
"fal-ai/nano-banana-pro",
true,
) as Record<string, unknown>;
expect(payload.aspect_ratio).toBe("16:9");
});
it("Fal Host 带 /fal-ai 历史路径时应自动归一化,避免重复拼接", async () => {
fetchMock.mockResolvedValueOnce(
createJsonResponse({
@@ -189,10 +204,10 @@ describe("useImageGen Fal 调用链路", () => {
);
const editPayload = JSON.parse(
((fetchMock.mock.calls[0]?.[1] as { body?: string })?.body ?? "{}"),
(fetchMock.mock.calls[0]?.[1] as { body?: string })?.body ?? "{}",
) as Record<string, unknown>;
const basePayload = JSON.parse(
((fetchMock.mock.calls[2]?.[1] as { body?: string })?.body ?? "{}"),
(fetchMock.mock.calls[2]?.[1] as { body?: string })?.body ?? "{}",
) as Record<string, unknown>;
expect(editPayload).toMatchObject({
+49 -8
View File
@@ -192,7 +192,10 @@ async function fetchWithManagedAbort(
): Promise<Response> {
const timeoutMs = options?.timeoutMs ?? 0;
const abortController = new AbortController();
const cleanupExternalAbort = bindAbortSignal(abortController, options?.signal);
const cleanupExternalAbort = bindAbortSignal(
abortController,
options?.signal,
);
const timeoutHandle =
timeoutMs > 0
? setTimeout(() => {
@@ -241,7 +244,9 @@ async function attemptFalQueueCancellation(
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn(`[ImageGen][fal/queue-cancel] cancel request failed: ${message}`);
console.warn(
`[ImageGen][fal/queue-cancel] cancel request failed: ${message}`,
);
}
}
@@ -804,7 +809,42 @@ function sizeToAspectRatio(size: string): string | null {
}
const gcd = computeGreatestCommonDivisor(width, height);
return `${Math.round(width / gcd)}:${Math.round(height / gcd)}`;
const exactRatio = `${Math.round(width / gcd)}:${Math.round(height / gcd)}`;
const supportedAspectRatios = [
["21:9", 21 / 9],
["16:9", 16 / 9],
["3:2", 3 / 2],
["4:3", 4 / 3],
["5:4", 5 / 4],
["1:1", 1],
["4:5", 4 / 5],
["3:4", 3 / 4],
["2:3", 2 / 3],
["9:16", 9 / 16],
] as const;
if (supportedAspectRatios.some(([label]) => label === exactRatio)) {
return exactRatio;
}
const numericRatio = width / height;
const nearest = supportedAspectRatios.reduce<
readonly [string, number] | null
>((best, current) => {
if (!best) {
return current;
}
const bestDiff = Math.abs(numericRatio - best[1]);
const currentDiff = Math.abs(numericRatio - current[1]);
return currentDiff < bestDiff ? current : best;
}, null);
if (!nearest) {
return null;
}
return Math.abs(numericRatio - nearest[1]) <= 0.08 ? nearest[0] : "auto";
}
function collectTextFromUnknown(value: unknown): string[] {
@@ -1983,7 +2023,8 @@ function isFalProviderLike(provider: {
const normalizedHost = provider.api_host.trim().toLowerCase();
return (
normalizedHost.includes("fal.run") || normalizedHost.includes("queue.fal.run")
normalizedHost.includes("fal.run") ||
normalizedHost.includes("queue.fal.run")
);
}
@@ -2261,7 +2302,9 @@ export function useImageGen(options: UseImageGenOptions = {}) {
() =>
Boolean(preferredProviderId) &&
!providersLoading &&
!availableProviders.some((provider) => provider.id === preferredProviderId),
!availableProviders.some(
(provider) => provider.id === preferredProviderId,
),
[availableProviders, preferredProviderId, providersLoading],
);
@@ -2782,9 +2825,7 @@ export function useImageGen(options: UseImageGenOptions = {}) {
});
}
throw canceled
? new Error(IMAGE_GENERATION_CANCELED_MESSAGE)
: error;
throw canceled ? new Error(IMAGE_GENERATION_CANCELED_MESSAGE) : error;
} finally {
if (generationRunIdRef.current === generationRunId) {
generationAbortControllerRef.current = null;
-206
View File
@@ -1,206 +0,0 @@
/**
* @file PanelLayout.tsx
* @description 分屏布局组件
* @module components/layout/PanelLayout
*
* 使用 react-resizable-panels 实现可调整的分屏布局。
*/
import React from "react";
import {
Panel,
Group as ResizablePanelGroup,
Separator as ResizableHandle,
} from "react-resizable-panels";
import type { PanelNode, Block, BlockViewModel } from "@/lib/blocks/types";
import { blockRegistry } from "@/lib/blocks/registry";
/** 布局属性 */
export interface PanelLayoutProps {
/** 根节点 */
rootNode: PanelNode;
/** 块映射 */
blocks: Map<string, Block>;
/** 活动块 ID */
activeBlockId?: string;
/** 最大化块 ID */
magnifiedBlockId?: string;
/** 块聚焦回调 */
onBlockFocus?: (blockId: string) => void;
/** 块关闭回调 */
onBlockClose?: (blockId: string) => void;
/** 块最大化回调 */
onBlockMagnify?: (blockId: string) => void;
/** 面板大小变化回调 */
onPanelResize?: (nodeId: string, size: number) => void;
}
/** 调整手柄组件 */
const ResizeHandle: React.FC<{ orientation: "horizontal" | "vertical" }> = ({
orientation,
}) => (
<ResizableHandle
className={`panel-resize-handle ${orientation === "horizontal" ? "horizontal" : "vertical"}`}
>
<div className="panel-resize-handle-inner" />
</ResizableHandle>
);
/** 块渲染器 */
const BlockRenderer: React.FC<{
block: Block;
viewModel: BlockViewModel;
visible: boolean;
}> = ({ block, viewModel, visible }) => {
const Component = blockRegistry.getComponent(block.type);
if (!Component) {
return (
<div className="flex items-center justify-center h-full text-gray-500">
未知的块类型: {block.type}
</div>
);
}
return <Component block={block} viewModel={viewModel} visible={visible} />;
};
/** 面板节点渲染器 */
const PanelNodeRenderer: React.FC<{
node: PanelNode;
blocks: Map<string, Block>;
activeBlockId?: string;
magnifiedBlockId?: string;
onBlockFocus?: (blockId: string) => void;
onBlockClose?: (blockId: string) => void;
onBlockMagnify?: (blockId: string) => void;
onPanelResize?: (nodeId: string, size: number) => void;
}> = ({
node,
blocks,
activeBlockId,
magnifiedBlockId,
onBlockFocus,
onBlockClose,
onBlockMagnify,
onPanelResize,
}) => {
// 块节点
if (node.type === "block" && node.blockId) {
const block = blocks.get(node.blockId);
if (!block) {
return (
<div className="flex items-center justify-center h-full text-gray-500">
块不存在
</div>
);
}
const viewModel: BlockViewModel = {
blockId: block.id,
isFocused: activeBlockId === block.id,
isMagnified: magnifiedBlockId === block.id,
focus: () => onBlockFocus?.(block.id),
toggleMagnify: () => onBlockMagnify?.(block.id),
close: () => onBlockClose?.(block.id),
};
return (
<BlockRenderer
block={block}
viewModel={viewModel}
visible={!magnifiedBlockId || magnifiedBlockId === block.id}
/>
);
}
// 组节点
if (node.type === "group" && node.children && node.children.length > 0) {
const orientation = node.direction ?? "horizontal";
return (
<ResizablePanelGroup orientation={orientation} className="panel-group">
{node.children.map((child, index) => (
<React.Fragment key={child.id}>
{index > 0 && <ResizeHandle orientation={orientation} />}
<Panel
defaultSize={child.size ?? 100 / node.children!.length}
minSize={10}
onResize={(size) => onPanelResize?.(child.id, size.asPercentage)}
>
<PanelNodeRenderer
node={child}
blocks={blocks}
activeBlockId={activeBlockId}
magnifiedBlockId={magnifiedBlockId}
onBlockFocus={onBlockFocus}
onBlockClose={onBlockClose}
onBlockMagnify={onBlockMagnify}
onPanelResize={onPanelResize}
/>
</Panel>
</React.Fragment>
))}
</ResizablePanelGroup>
);
}
// 空节点
return (
<div className="flex items-center justify-center h-full text-gray-500">
空面板
</div>
);
};
/**
* 分屏布局组件
*/
export const PanelLayout: React.FC<PanelLayoutProps> = ({
rootNode,
blocks,
activeBlockId,
magnifiedBlockId,
onBlockFocus,
onBlockClose,
onBlockMagnify,
onPanelResize,
}) => {
// 如果有最大化的块,只显示该块
if (magnifiedBlockId) {
const block = blocks.get(magnifiedBlockId);
if (block) {
const viewModel: BlockViewModel = {
blockId: block.id,
isFocused: true,
isMagnified: true,
focus: () => onBlockFocus?.(block.id),
toggleMagnify: () => onBlockMagnify?.(block.id),
close: () => onBlockClose?.(block.id),
};
return (
<div className="panel-layout magnified">
<BlockRenderer block={block} viewModel={viewModel} visible={true} />
</div>
);
}
}
return (
<div className="panel-layout">
<PanelNodeRenderer
node={rootNode}
blocks={blocks}
activeBlockId={activeBlockId}
magnifiedBlockId={magnifiedBlockId}
onBlockFocus={onBlockFocus}
onBlockClose={onBlockClose}
onBlockMagnify={onBlockMagnify}
onPanelResize={onPanelResize}
/>
</div>
);
};
export default PanelLayout;
-55
View File
@@ -1,55 +0,0 @@
import { useEffect, useState } from 'react';
import { getFeedbackStats, type FeedbackStats as FeedbackStatsData } from '@/lib/api/memoryFeedback';
interface FeedbackStatsProps {
sessionId: string;
}
export function FeedbackStats({ sessionId }: FeedbackStatsProps) {
const [stats, setStats] = useState<FeedbackStatsData | null>(null);
useEffect(() => {
let cancelled = false;
if (!sessionId) {
setStats(null);
return;
}
getFeedbackStats(sessionId)
.then((result) => {
if (!cancelled) {
setStats(result);
}
})
.catch(() => {
if (!cancelled) {
setStats(null);
}
});
return () => {
cancelled = true;
};
}, [sessionId]);
if (!stats) return null;
return (
<div className="grid grid-cols-4 gap-4">
<StatCard label="总数" value={stats.total} />
<StatCard label="批准" value={stats.approve_count} />
<StatCard label="拒绝" value={stats.reject_count} />
<StatCard label="批准率" value={`${(stats.approval_rate * 100).toFixed(1)}%`} />
</div>
);
}
function StatCard({ label, value }: { label: string; value: number | string }) {
return (
<div className="rounded-lg border p-4">
<div className="text-sm text-muted-foreground">{label}</div>
<div className="text-2xl font-bold">{value}</div>
</div>
);
}
-30
View File
@@ -1,30 +0,0 @@
import { Button } from '@/components/ui/button';
import { recordFeedback } from '@/lib/api/memoryFeedback';
import { toast } from 'sonner';
interface MemoryFeedbackProps {
memoryId: string;
sessionId: string;
}
export function MemoryFeedback({ memoryId, sessionId }: MemoryFeedbackProps) {
const handleFeedback = async (action: 'approve' | 'reject') => {
try {
await recordFeedback(memoryId, action, sessionId);
toast.success(action === 'approve' ? '已批准' : '已拒绝');
} catch (error) {
toast.error('反馈失败: ' + error);
}
};
return (
<div className="flex gap-2">
<Button size="sm" variant="ghost" onClick={() => handleFeedback('approve')}>
✓
</Button>
<Button size="sm" variant="ghost" onClick={() => handleFeedback('reject')}>
✗
</Button>
</div>
);
}
@@ -1,391 +0,0 @@
/**
* 增强版模型列表组件
*
* 使用 model_registry 数据,支持搜索、收藏、分组等功能
*/
import { useState, useMemo } from "react";
import {
Check,
AlertCircle,
Loader2,
Star,
Search,
ChevronDown,
ChevronRight,
Eye,
Wrench,
Brain,
DollarSign,
} from "lucide-react";
import { cn } from "@/lib/utils";
import type { EnhancedModelMetadata } from "@/lib/types/modelRegistry";
interface EnhancedModelListProps {
/** 模型列表 */
models: EnhancedModelMetadata[];
/** 选中的模型 ID */
selectedModelId?: string;
/** 选择模型回调 */
onSelectModel?: (model: EnhancedModelMetadata) => void;
/** 收藏模型回调 */
onToggleFavorite?: (modelId: string) => void;
/** 收藏的模型 ID 集合 */
favorites?: Set<string>;
/** 是否加载中 */
loading?: boolean;
/** 错误信息 */
error?: string | null;
/** 是否按 Provider 分组 */
groupByProvider?: boolean;
/** 是否显示搜索框 */
showSearch?: boolean;
/** 是否显示定价信息 */
showPricing?: boolean;
/** 自定义类名 */
className?: string;
}
export function EnhancedModelList({
models,
selectedModelId,
onSelectModel,
onToggleFavorite,
favorites = new Set(),
loading = false,
error = null,
groupByProvider = true,
showSearch = true,
showPricing = false,
className,
}: EnhancedModelListProps) {
const [searchQuery, setSearchQuery] = useState("");
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(
new Set(["favorites"]),
);
// 过滤模型
const filteredModels = useMemo(() => {
if (!searchQuery.trim()) return models;
const query = searchQuery.toLowerCase();
return models.filter(
(m) =>
m.id.toLowerCase().includes(query) ||
m.display_name.toLowerCase().includes(query) ||
m.provider_name.toLowerCase().includes(query) ||
m.family?.toLowerCase().includes(query),
);
}, [models, searchQuery]);
// 按 Provider 分组
const groupedModels = useMemo(() => {
if (!groupByProvider) {
return { all: filteredModels };
}
const groups: Record<string, EnhancedModelMetadata[]> = {};
// 先添加收藏组
const favoriteModels = filteredModels.filter((m) => favorites.has(m.id));
if (favoriteModels.length > 0) {
groups["favorites"] = favoriteModels;
}
// 按 Provider 分组
for (const model of filteredModels) {
if (!groups[model.provider_id]) {
groups[model.provider_id] = [];
}
groups[model.provider_id].push(model);
}
return groups;
}, [filteredModels, groupByProvider, favorites]);
// 切换分组展开状态
const toggleGroup = (groupId: string) => {
setExpandedGroups((prev) => {
const next = new Set(prev);
if (next.has(groupId)) {
next.delete(groupId);
} else {
next.add(groupId);
}
return next;
});
};
if (loading) {
return (
<div className={cn("flex items-center justify-center py-8", className)}>
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-muted-foreground">加载模型列表...</span>
</div>
);
}
if (error) {
return (
<div
className={cn(
"flex items-center justify-center py-8 text-destructive",
className,
)}
>
<AlertCircle className="h-5 w-5 mr-2" />
<span>{error}</span>
</div>
);
}
if (models.length === 0) {
return (
<div className={cn("text-center py-8 text-muted-foreground", className)}>
<p>暂无可用模型</p>
<p className="text-sm mt-1">请等待模型数据加载</p>
</div>
);
}
return (
<div className={cn("space-y-3", className)}>
{/* 搜索框 */}
{showSearch && (
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<input
type="text"
placeholder="搜索模型..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full rounded-lg border bg-background pl-10 pr-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/20"
/>
</div>
)}
{/* 模型列表 */}
<div className="space-y-2">
{Object.entries(groupedModels).map(([groupId, groupModels]) => {
const isExpanded = expandedGroups.has(groupId);
const groupName = getGroupName(groupId, groupModels[0]);
return (
<div key={groupId} className="border rounded-lg overflow-hidden">
{/* 分组头部 */}
{groupByProvider && (
<button
type="button"
onClick={() => toggleGroup(groupId)}
className="w-full flex items-center justify-between px-3 py-2 bg-muted/50 hover:bg-muted transition-colors"
>
<div className="flex items-center gap-2">
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
{groupId === "favorites" && (
<Star className="h-4 w-4 text-yellow-500 fill-yellow-500" />
)}
<span className="font-medium text-sm">{groupName}</span>
</div>
<span className="text-xs text-muted-foreground">
{groupModels.length} 个模型
</span>
</button>
)}
{/* 模型列表 */}
{(!groupByProvider || isExpanded) && (
<div className="divide-y">
{groupModels.map((model) => (
<ModelItem
key={model.id}
model={model}
isSelected={model.id === selectedModelId}
isFavorite={favorites.has(model.id)}
onSelect={() => onSelectModel?.(model)}
onToggleFavorite={() => onToggleFavorite?.(model.id)}
showPricing={showPricing}
/>
))}
</div>
)}
</div>
);
})}
</div>
{/* 无搜索结果 */}
{filteredModels.length === 0 && searchQuery && (
<div className="text-center py-8 text-muted-foreground">
<p>未找到匹配的模型</p>
<p className="text-sm mt-1">尝试其他搜索词</p>
</div>
)}
</div>
);
}
/** 单个模型项 */
function ModelItem({
model,
isSelected,
isFavorite,
onSelect,
onToggleFavorite,
showPricing,
}: {
model: EnhancedModelMetadata;
isSelected: boolean;
isFavorite: boolean;
onSelect: () => void;
onToggleFavorite: () => void;
showPricing: boolean;
}) {
return (
<div
className={cn(
"flex items-center justify-between px-3 py-2.5 hover:bg-muted/30 transition-colors cursor-pointer",
isSelected && "bg-primary/5",
)}
onClick={onSelect}
>
<div className="flex items-center gap-3 flex-1 min-w-0">
{/* 选中指示器 */}
<div
className={cn(
"w-4 h-4 rounded-full border-2 flex-shrink-0 flex items-center justify-center",
isSelected
? "border-primary bg-primary"
: "border-muted-foreground",
)}
>
{isSelected && <Check className="h-3 w-3 text-primary-foreground" />}
</div>
{/* 模型信息 */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm truncate">
{model.display_name}
</span>
{model.is_latest && (
<span className="text-xs px-1.5 py-0.5 rounded bg-primary/10 text-primary">
最新
</span>
)}
<TierBadge tier={model.tier} />
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>{model.id}</span>
{model.limits.context_length && (
<>
<span>·</span>
<span>{formatContextLength(model.limits.context_length)}</span>
</>
)}
</div>
</div>
</div>
{/* 能力标签和操作 */}
<div className="flex items-center gap-2 flex-shrink-0">
{/* 能力图标 */}
<div className="flex items-center gap-1">
{model.capabilities.vision && (
<span title="支持视觉">
<Eye className="h-3.5 w-3.5 text-blue-500" />
</span>
)}
{model.capabilities.tools && (
<span title="支持工具">
<Wrench className="h-3.5 w-3.5 text-green-500" />
</span>
)}
{model.capabilities.reasoning && (
<span title="支持推理">
<Brain className="h-3.5 w-3.5 text-purple-500" />
</span>
)}
</div>
{/* 定价 */}
{showPricing && model.pricing && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<DollarSign className="h-3 w-3" />
<span>{model.pricing.input_per_million?.toFixed(2) || "?"}</span>
</div>
)}
{/* 收藏按钮 */}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onToggleFavorite();
}}
className="p-1 rounded hover:bg-muted transition-colors"
title={isFavorite ? "取消收藏" : "收藏"}
>
<Star
className={cn(
"h-4 w-4",
isFavorite
? "text-yellow-500 fill-yellow-500"
: "text-muted-foreground",
)}
/>
</button>
</div>
</div>
);
}
/** 服务等级徽章 */
function TierBadge({ tier }: { tier: string }) {
const config = {
mini: {
label: "Mini",
color:
"bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300",
},
pro: {
label: "Pro",
color: "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300",
},
max: {
label: "Max",
color:
"bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300",
},
}[tier] || { label: tier, color: "bg-gray-100 text-gray-700" };
return (
<span className={cn("text-xs px-1.5 py-0.5 rounded", config.color)}>
{config.label}
</span>
);
}
/** 获取分组名称 */
function getGroupName(
groupId: string,
firstModel: EnhancedModelMetadata,
): string {
if (groupId === "favorites") return "收藏";
if (groupId === "all") return "全部模型";
return firstModel?.provider_name || groupId;
}
/** 格式化上下文长度 */
function formatContextLength(length: number): string {
if (length >= 1000000) {
return `${(length / 1000000).toFixed(1)}M`;
}
if (length >= 1000) {
return `${(length / 1000).toFixed(0)}K`;
}
return String(length);
}
@@ -1,59 +0,0 @@
/**
* 模式切换组件 - 简单模式/专家模式
*/
import { Settings2, Wand2 } from "lucide-react";
import { cn } from "@/lib/utils";
export type SelectionMode = "simple" | "expert";
interface ModeToggleProps {
/** 当前模式 */
mode: SelectionMode;
/** 模式变化回调 */
onModeChange: (mode: SelectionMode) => void;
/** 是否禁用 */
disabled?: boolean;
/** 自定义类名 */
className?: string;
}
export function ModeToggle({
mode,
onModeChange,
disabled = false,
className,
}: ModeToggleProps) {
return (
<div className={cn("flex items-center gap-2", className)}>
<button
type="button"
onClick={() => onModeChange("simple")}
disabled={disabled}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm transition-colors",
mode === "simple"
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground hover:bg-muted",
)}
>
<Wand2 className="h-4 w-4" />
<span>简单</span>
</button>
<button
type="button"
onClick={() => onModeChange("expert")}
disabled={disabled}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm transition-colors",
mode === "expert"
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground hover:bg-muted",
)}
>
<Settings2 className="h-4 w-4" />
<span>专家</span>
</button>
</div>
);
}
-162
View File
@@ -1,162 +0,0 @@
/**
* 模型列表组件 - 显示可用模型
*/
import { Check, AlertCircle, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
import type { AvailableModel } from "@/lib/api/orchestrator";
interface ModelListProps {
/** 模型列表 */
models: AvailableModel[];
/** 选中的模型 ID */
selectedModelId?: string;
/** 选择模型回调 */
onSelectModel?: (model: AvailableModel) => void;
/** 是否加载中 */
loading?: boolean;
/** 错误信息 */
error?: string | null;
/** 自定义类名 */
className?: string;
}
export function ModelList({
models,
selectedModelId,
onSelectModel,
loading = false,
error = null,
className,
}: ModelListProps) {
if (loading) {
return (
<div className={cn("flex items-center justify-center py-8", className)}>
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-muted-foreground">加载模型列表...</span>
</div>
);
}
if (error) {
return (
<div
className={cn(
"flex items-center justify-center py-8 text-destructive",
className,
)}
>
<AlertCircle className="h-5 w-5 mr-2" />
<span>{error}</span>
</div>
);
}
if (models.length === 0) {
return (
<div className={cn("text-center py-8 text-muted-foreground", className)}>
<p>暂无可用模型</p>
<p className="text-sm mt-1">请先添加凭证</p>
</div>
);
}
return (
<div className={cn("space-y-2", className)}>
{models.map((model) => {
const isSelected = model.id === selectedModelId;
return (
<button
key={`${model.id}-${model.credential_id}`}
type="button"
onClick={() => onSelectModel?.(model)}
className={cn(
"w-full flex items-center justify-between p-3 rounded-lg border transition-colors",
"hover:bg-muted/50",
isSelected ? "border-primary bg-primary/5" : "border-border",
!model.is_healthy && "opacity-60",
)}
>
<div className="flex items-center gap-3">
{/* 选中指示器 */}
<div
className={cn(
"w-4 h-4 rounded-full border-2 flex items-center justify-center",
isSelected
? "border-primary bg-primary"
: "border-muted-foreground",
)}
>
{isSelected && (
<Check className="h-3 w-3 text-primary-foreground" />
)}
</div>
{/* 模型信息 */}
<div className="text-left">
<div className="font-medium text-sm">{model.display_name}</div>
<div className="text-xs text-muted-foreground">
{model.provider_type}
{model.context_length &&
` · ${formatContextLength(model.context_length)}`}
</div>
</div>
</div>
{/* 状态和能力标签 */}
<div className="flex items-center gap-2">
{model.supports_vision && (
<span className="text-xs px-1.5 py-0.5 rounded bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300">
视觉
</span>
)}
{model.supports_tools && (
<span className="text-xs px-1.5 py-0.5 rounded bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300">
工具
</span>
)}
{!model.is_healthy && (
<span className="text-xs px-1.5 py-0.5 rounded bg-red-100 dark:bg-red-900 text-red-700 dark:text-red-300">
不健康
</span>
)}
{model.current_load !== undefined && (
<LoadIndicator load={model.current_load} />
)}
</div>
</button>
);
})}
</div>
);
}
/** 负载指示器 */
function LoadIndicator({ load }: { load: number }) {
const color =
load < 30 ? "bg-green-500" : load < 70 ? "bg-yellow-500" : "bg-red-500";
return (
<div className="flex items-center gap-1">
<div className="w-8 h-1.5 bg-muted rounded-full overflow-hidden">
<div
className={cn("h-full rounded-full", color)}
style={{ width: `${load}%` }}
/>
</div>
<span className="text-xs text-muted-foreground">{load}%</span>
</div>
);
}
/** 格式化上下文长度 */
function formatContextLength(length: number): string {
if (length >= 1000000) {
return `${(length / 1000000).toFixed(1)}M`;
}
if (length >= 1000) {
return `${(length / 1000).toFixed(0)}K`;
}
return String(length);
}
@@ -1,201 +0,0 @@
/**
* 统一模型选择器组件
*
* 整合简单模式(Mini/Pro/Max)和专家模式(直接选择模型)
*/
import { useState } from "react";
import { RefreshCw, Activity } from "lucide-react";
import { cn } from "@/lib/utils";
import { TierSelector } from "./TierSelector";
import { ModeToggle, type SelectionMode } from "./ModeToggle";
import { ModelList } from "./ModelList";
import {
useOrchestrator,
useModelSelection,
type ServiceTier,
type AvailableModel,
type SelectionResult,
} from "@/lib/api/orchestrator";
interface ModelSelectorProps {
/** 初始模式 */
initialMode?: SelectionMode;
/** 初始等级 */
initialTier?: ServiceTier;
/** 选择模型回调 */
onSelect?: (result: SelectionResult) => void;
/** 是否显示模式切换 */
showModeToggle?: boolean;
/** 是否显示统计信息 */
showStats?: boolean;
/** 紧凑模式 */
compact?: boolean;
/** 自定义类名 */
className?: string;
}
export function ModelSelector({
initialMode = "simple",
initialTier = "pro",
onSelect,
showModeToggle = true,
showStats = true,
compact = false,
className,
}: ModelSelectorProps) {
const [mode, setMode] = useState<SelectionMode>(initialMode);
const [selectedModel, setSelectedModel] = useState<AvailableModel | null>(
null,
);
// 使用编排器状态
const {
initialized: _initialized,
loading: orchestratorLoading,
error: orchestratorError,
poolStats,
refreshStats,
} = useOrchestrator();
// 使用模型选择
const {
tier,
setTier,
models,
loading: modelsLoading,
error: modelsError,
selectModel,
refreshModels,
} = useModelSelection(initialTier);
// 简单模式下自动选择模型
const handleTierChange = async (newTier: ServiceTier) => {
setTier(newTier);
if (mode === "simple") {
try {
const result = await selectModel({ tier: newTier });
onSelect?.(result);
} catch (err) {
console.error("模型选择失败:", err);
}
}
};
// 专家模式下手动选择模型
const handleModelSelect = async (model: AvailableModel) => {
setSelectedModel(model);
try {
const result = await selectModel({
tier,
preferred_provider: model.provider_type,
});
onSelect?.(result);
} catch (err) {
console.error("模型选择失败:", err);
}
};
// 刷新数据
const handleRefresh = () => {
refreshStats();
refreshModels();
};
const loading = orchestratorLoading || modelsLoading;
const error = orchestratorError || modelsError;
return (
<div className={cn("space-y-4", className)}>
{/* 头部:模式切换和刷新 */}
<div className="flex items-center justify-between">
{showModeToggle && (
<ModeToggle mode={mode} onModeChange={setMode} disabled={loading} />
)}
<div className="flex items-center gap-2">
{/* 统计信息 */}
{showStats && poolStats && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Activity className="h-3 w-3" />
<span>
{poolStats.healthy_count}/{poolStats.total_count} 可用
</span>
</div>
)}
{/* 刷新按钮 */}
<button
type="button"
onClick={handleRefresh}
disabled={loading}
className="p-1.5 rounded-md hover:bg-muted transition-colors"
title="刷新"
>
<RefreshCw
className={cn(
"h-4 w-4 text-muted-foreground",
loading && "animate-spin",
)}
/>
</button>
</div>
</div>
{/* 等级选择器 */}
<TierSelector
value={tier}
onChange={handleTierChange}
disabled={loading}
modelCounts={
poolStats
? {
mini: poolStats.mini_count,
pro: poolStats.pro_count,
max: poolStats.max_count,
}
: undefined
}
compact={compact}
/>
{/* 专家模式:显示模型列表 */}
{mode === "expert" && (
<ModelList
models={models}
selectedModelId={selectedModel?.id}
onSelectModel={handleModelSelect}
loading={modelsLoading}
error={modelsError}
/>
)}
{/* 简单模式:显示当前选择 */}
{mode === "simple" && selectedModel && (
<div className="p-3 rounded-lg bg-muted/50 border">
<div className="text-sm font-medium">
{selectedModel.display_name}
</div>
<div className="text-xs text-muted-foreground">
{selectedModel.provider_type}
</div>
</div>
)}
{/* 错误提示 */}
{error && (
<div className="p-3 rounded-lg bg-destructive/10 border border-destructive/20 text-destructive text-sm">
{error}
</div>
)}
</div>
);
}
// 导出子组件
export { TierSelector } from "./TierSelector";
export { ModeToggle } from "./ModeToggle";
export { ModelList } from "./ModelList";
export type { SelectionMode } from "./ModeToggle";
@@ -1,144 +0,0 @@
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";
const {
mockUseConfiguredProviders,
mockUseProviderModels,
} = vi.hoisted(() => ({
mockUseConfiguredProviders: vi.fn(),
mockUseProviderModels: vi.fn(),
}));
vi.mock("@/hooks/useConfiguredProviders", () => ({
useConfiguredProviders: () => mockUseConfiguredProviders(),
}));
vi.mock("@/hooks/useProviderModels", () => ({
useProviderModels: (...args: unknown[]) => mockUseProviderModels(...args),
}));
import { ProviderModelSelector } from "./ProviderModelSelector";
interface MountedRoot {
root: Root;
container: HTMLDivElement;
}
const mountedRoots: MountedRoot[] = [];
function renderSelector(
props: Partial<React.ComponentProps<typeof ProviderModelSelector>> = {},
) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
const mergedProps: React.ComponentProps<typeof ProviderModelSelector> = {
onSelect: vi.fn(),
initialProviderId: "custom-codex",
...props,
};
act(() => {
root.render(<ProviderModelSelector {...mergedProps} />);
});
mountedRoots.push({ root, container });
return { container };
}
beforeEach(() => {
(
globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
).IS_REACT_ACT_ENVIRONMENT = true;
vi.clearAllMocks();
mockUseConfiguredProviders.mockReturnValue({
providers: [
{
key: "custom-codex",
label: "Codex Custom",
registryId: "custom-codex",
fallbackRegistryId: "codex",
type: "codex",
providerId: "custom-codex",
apiHost: "https://api.openai.com/v1",
},
],
loading: false,
});
mockUseProviderModels.mockReturnValue({
models: [
{
id: "gpt-5.3-codex",
display_name: "GPT-5.3 Codex",
capabilities: {
vision: true,
tools: true,
streaming: true,
json_mode: true,
function_calling: true,
reasoning: true,
},
is_latest: true,
},
{
id: "gpt-5.2-codex",
display_name: "GPT-5.2 Codex",
capabilities: {
vision: true,
tools: true,
streaming: true,
json_mode: true,
function_calling: true,
reasoning: true,
},
is_latest: false,
},
],
loading: false,
error: null,
});
});
afterEach(() => {
while (mountedRoots.length > 0) {
const mounted = mountedRoots.pop();
if (!mounted) break;
act(() => {
mounted.root.unmount();
});
mounted.container.remove();
}
});
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();
expect(container.textContent).toContain(
"已隐藏 1 个当前登录态不兼容的模型",
);
expect(container.textContent).not.toContain("GPT-5.3 Codex");
expect(container.textContent).toContain("GPT-5.2 Codex");
});
});
@@ -1,341 +0,0 @@
/**
* @file ProviderModelSelector 组件
* @description 双栏模型选择器:左侧 Provider 列表,右侧模型列表
* @module components/model-selector/ProviderModelSelector
*/
import React, { useState, useMemo, useCallback, useEffect } from "react";
import { cn } from "@/lib/utils";
import {
Check,
ChevronRight,
Eye,
Wrench,
Brain,
Loader2,
AlertCircle,
} from "lucide-react";
import {
useConfiguredProviders,
type ConfiguredProvider,
} from "@/hooks/useConfiguredProviders";
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";
// ============================================================================
// 类型定义
// ============================================================================
export interface ProviderModelSelectorProps {
/** 选择模型回调 */
onSelect?: (model: EnhancedModelMetadata, providerId: string) => void;
/** 初始选中的 Provider */
initialProviderId?: string;
/** 初始选中的模型 */
initialModelId?: string;
/** 自定义类名 */
className?: string;
}
// ============================================================================
// 子组件
// ============================================================================
interface ProviderItemProps {
provider: ConfiguredProvider;
isSelected: boolean;
onClick: () => void;
}
/** Provider 列表项 */
const ProviderItem: React.FC<ProviderItemProps> = ({
provider,
isSelected,
onClick,
}) => {
return (
<button
type="button"
onClick={onClick}
className={cn(
"w-full flex items-center justify-between px-3 py-2 text-sm rounded-md transition-colors",
isSelected
? "bg-primary text-primary-foreground"
: "hover:bg-muted text-foreground",
)}
data-testid={`provider-item-${provider.key}`}
>
<div className="flex items-center gap-2 min-w-0">
<ChevronRight
className={cn(
"h-4 w-4 flex-shrink-0 transition-transform",
isSelected && "rotate-90",
)}
/>
<span className="truncate">{provider.label}</span>
</div>
</button>
);
};
interface ModelItemProps {
model: EnhancedModelMetadata;
isSelected: boolean;
onClick: () => void;
}
/** 模型列表项 */
const ModelItem: React.FC<ModelItemProps> = ({
model,
isSelected,
onClick,
}) => {
return (
<button
type="button"
onClick={onClick}
className={cn(
"w-full flex items-center justify-between px-3 py-2 text-sm rounded-md transition-colors",
isSelected
? "bg-primary/10 border border-primary"
: "hover:bg-muted border border-transparent",
)}
data-testid={`model-item-${model.id}`}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium truncate">{model.display_name}</span>
{model.is_latest && (
<span className="text-[10px] bg-green-100 text-green-700 px-1 py-0.5 rounded">
最新
</span>
)}
</div>
<div className="text-xs text-muted-foreground truncate">{model.id}</div>
</div>
{/* 能力标签 */}
<div className="flex items-center gap-1.5 ml-2">
{model.capabilities.vision && (
<span title="支持视觉">
<Eye className="h-3.5 w-3.5 text-blue-500" />
</span>
)}
{model.capabilities.tools && (
<span title="支持工具">
<Wrench className="h-3.5 w-3.5 text-orange-500" />
</span>
)}
{model.capabilities.reasoning && (
<span title="支持推理">
<Brain className="h-3.5 w-3.5 text-purple-500" />
</span>
)}
{isSelected && <Check className="h-4 w-4 text-primary ml-1" />}
</div>
</button>
);
};
// ============================================================================
// 主组件
// ============================================================================
/**
* 双栏模型选择器组件
*
* 左侧显示已配置凭证的 Provider 列表(单选)
* 右侧显示选中 Provider 对应的模型列表(单选)
*
* @example
* ```tsx
* <ProviderModelSelector
* onSelect={(model, providerId) => {
* console.log("选中模型:", model.display_name);
* }}
* />
* ```
*/
export const ProviderModelSelector: React.FC<ProviderModelSelectorProps> = ({
onSelect,
initialProviderId,
initialModelId,
className,
}) => {
// 状态
const [selectedProviderId, setSelectedProviderId] = useState<string | null>(
initialProviderId || null,
);
const [selectedModelId, setSelectedModelId] = useState<string | null>(
initialModelId || null,
);
// 获取已配置的 Provider 列表(使用共享 hook)
const { providers: configuredProviders, loading: providersLoading } =
useConfiguredProviders();
// 获取当前选中的 Provider
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,
...providerModelLoadOptions,
});
const compatibleModels = useMemo(
() =>
filteredModels.filter(
(model) =>
!getProviderModelCompatibilityIssue({
providerType: selectedProvider?.key || "",
configuredProviderType: selectedProvider?.type,
model: model.id,
}),
),
[filteredModels, selectedProvider?.key, selectedProvider?.type],
);
const incompatibleModelCount = useMemo(
() => filteredModels.length - compatibleModels.length,
[compatibleModels.length, filteredModels.length],
);
// 默认选中第一个 Provider
useEffect(() => {
if (!selectedProviderId && configuredProviders.length > 0) {
setSelectedProviderId(configuredProviders[0].key);
}
}, [selectedProviderId, configuredProviders]);
// 选择 Provider
const handleSelectProvider = useCallback((providerId: string) => {
setSelectedProviderId(providerId);
setSelectedModelId(null); // 切换 Provider 时清除模型选择
}, []);
// 选择模型
const handleSelectModel = useCallback(
(model: EnhancedModelMetadata) => {
setSelectedModelId(model.id);
if (selectedProviderId) {
onSelect?.(model, selectedProviderId);
}
},
[selectedProviderId, onSelect],
);
const isLoading = providersLoading || modelsLoading;
// 空状态
if (!isLoading && configuredProviders.length === 0) {
return (
<div
className={cn(
"flex flex-col items-center justify-center py-12 text-muted-foreground",
className,
)}
data-testid="provider-model-selector-empty"
>
<AlertCircle className="h-12 w-12 mb-4 opacity-50" />
<p className="text-sm">暂无已配置的 Provider</p>
<p className="text-xs mt-1">请先在凭证池中添加凭证</p>
</div>
);
}
return (
<div
className={cn("flex border rounded-lg overflow-hidden", className)}
data-testid="provider-model-selector"
>
{/* 左侧:Provider 列表 */}
<div className="w-48 border-r bg-muted/30 flex flex-col">
<div className="px-3 py-2 border-b bg-muted/50">
<h4 className="text-sm font-medium">Providers</h4>
<p className="text-xs text-muted-foreground">已配置凭证的</p>
</div>
<div className="flex-1 overflow-y-auto p-2 space-y-1">
{providersLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : (
configuredProviders.map((provider) => (
<ProviderItem
key={provider.key}
provider={provider}
isSelected={selectedProviderId === provider.key}
onClick={() => handleSelectProvider(provider.key)}
/>
))
)}
</div>
</div>
{/* 右侧:模型列表 */}
<div className="flex-1 flex flex-col min-w-0">
<div className="px-3 py-2 border-b bg-muted/50">
<h4 className="text-sm font-medium">Models</h4>
<p className="text-xs text-muted-foreground">
{selectedProvider
? `${getProviderLabel(selectedProvider.key)} 的模型`
: "请选择 Provider"}
</p>
</div>
<div className="flex-1 overflow-y-auto p-2 space-y-1">
{modelsLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : modelsError ? (
<div className="flex flex-col items-center justify-center py-8 text-red-500">
<AlertCircle className="h-8 w-8 mb-2" />
<p className="text-sm">{modelsError}</p>
</div>
) : compatibleModels.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
<p className="text-sm">暂无模型数据</p>
</div>
) : (
<>
{incompatibleModelCount > 0 ? (
<div className="px-1 py-1 text-xs text-amber-600">
已隐藏 {incompatibleModelCount} 个当前登录态不兼容的模型
</div>
) : null}
{compatibleModels.map((model) => (
<ModelItem
key={model.id}
model={model}
isSelected={selectedModelId === model.id}
onClick={() => handleSelectModel(model)}
/>
))}
</>
)}
</div>
</div>
</div>
);
};
export default ProviderModelSelector;
@@ -1,159 +0,0 @@
/**
* 服务等级选择器 - Mini/Pro/Max 三档选择
*
* 提供类似 v0 的简洁模式选择体验
*/
import React from "react";
import { Zap, Sparkles, Crown } from "lucide-react";
import { cn } from "@/lib/utils";
import type { ServiceTier } from "@/lib/api/orchestrator";
interface TierOption {
id: ServiceTier;
label: string;
description: string;
icon: React.ReactNode;
color: string;
bgColor: string;
borderColor: string;
}
const tierOptions: TierOption[] = [
{
id: "mini",
label: "Mini",
description: "快速响应",
icon: <Zap className="h-4 w-4" />,
color: "text-green-600 dark:text-green-400",
bgColor: "bg-green-50 dark:bg-green-950",
borderColor: "border-green-200 dark:border-green-800",
},
{
id: "pro",
label: "Pro",
description: "均衡性能",
icon: <Sparkles className="h-4 w-4" />,
color: "text-blue-600 dark:text-blue-400",
bgColor: "bg-blue-50 dark:bg-blue-950",
borderColor: "border-blue-200 dark:border-blue-800",
},
{
id: "max",
label: "Max",
description: "最强能力",
icon: <Crown className="h-4 w-4" />,
color: "text-purple-600 dark:text-purple-400",
bgColor: "bg-purple-50 dark:bg-purple-950",
borderColor: "border-purple-200 dark:border-purple-800",
},
];
interface TierSelectorProps {
/** 当前选中的等级 */
value: ServiceTier;
/** 等级变化回调 */
onChange: (tier: ServiceTier) => void;
/** 是否禁用 */
disabled?: boolean;
/** 各等级的模型数量 */
modelCounts?: {
mini: number;
pro: number;
max: number;
};
/** 紧凑模式 */
compact?: boolean;
/** 自定义类名 */
className?: string;
}
export function TierSelector({
value,
onChange,
disabled = false,
modelCounts,
compact = false,
className,
}: TierSelectorProps) {
return (
<div className={cn("flex gap-2", compact ? "gap-1" : "gap-2", className)}>
{tierOptions.map((option) => {
const isSelected = value === option.id;
const count = modelCounts?.[option.id];
const hasModels = count === undefined || count > 0;
return (
<button
key={option.id}
type="button"
onClick={() => onChange(option.id)}
disabled={disabled || !hasModels}
className={cn(
"flex-1 rounded-lg border px-3 py-2 transition-all",
"focus:outline-none focus:ring-2 focus:ring-offset-2",
compact ? "px-2 py-1.5" : "px-3 py-2",
isSelected
? cn(
option.borderColor,
option.bgColor,
option.color,
"ring-2 ring-offset-1",
option.id === "mini" && "ring-green-500/50",
option.id === "pro" && "ring-blue-500/50",
option.id === "max" && "ring-purple-500/50",
)
: cn(
"border-border hover:bg-muted",
!hasModels && "opacity-50 cursor-not-allowed",
),
)}
>
<div className="flex items-center justify-center gap-1.5">
<span
className={cn(
isSelected ? option.color : "text-muted-foreground",
)}
>
{option.icon}
</span>
<span
className={cn(
"font-medium",
compact ? "text-xs" : "text-sm",
isSelected ? option.color : "text-foreground",
)}
>
{option.label}
</span>
{count !== undefined && (
<span
className={cn(
"text-xs",
isSelected ? option.color : "text-muted-foreground",
)}
>
({count})
</span>
)}
</div>
{!compact && (
<p
className={cn(
"text-xs mt-0.5",
isSelected ? option.color : "text-muted-foreground",
)}
>
{option.description}
</p>
)}
</button>
);
})}
</div>
);
}
// eslint-disable-next-line react-refresh/only-export-components
export { tierOptions };
export type { TierOption };
-13
View File
@@ -1,13 +0,0 @@
/**
* 模型选择器组件导出
*/
export { ModelSelector } from "./ModelSelector";
export { TierSelector, tierOptions } from "./TierSelector";
export { ModeToggle } from "./ModeToggle";
export { ModelList } from "./ModelList";
export { ProviderModelSelector } from "./ProviderModelSelector";
export type { SelectionMode } from "./ModeToggle";
export type { TierOption } from "./TierSelector";
export type { ProviderModelSelectorProps } from "./ProviderModelSelector";
+5 -11
View File
@@ -1,17 +1,11 @@
import { describe, expect, it } from "vitest";
import { onboardingPlugins, userProfiles } from "./constants";
import * as onboardingConstants from "./constants";
describe("onboarding constants", () => {
it("开发者引导与配置管理描述应使用 current 品牌表述", () => {
const developerProfile = userProfiles.find((item) => item.id === "developer");
const configSwitchPlugin = onboardingPlugins.find(
(item) => item.id === "config-switch",
);
expect(developerProfile?.description).toContain("Claude、Codex、Gemini");
expect(developerProfile?.description).not.toContain("Claude Code");
expect(configSwitchPlugin?.description).toContain("Claude、Codex、Gemini");
expect(configSwitchPlugin?.description).not.toContain("Claude Code");
it("不再暴露旧插件安装流常量", () => {
expect("userProfiles" in onboardingConstants).toBe(false);
expect("onboardingPlugins" in onboardingConstants).toBe(false);
expect(onboardingConstants.ONBOARDING_VERSION).toBe("1.1.0");
});
});
+1 -60
View File
@@ -2,73 +2,14 @@
* 初次安装引导 - 常量配置
*/
import { Code, User, FileCode } from "lucide-react";
import type { LucideIcon } from "lucide-react";
/**
* 用户群体类型
*/
export type UserProfile = "developer" | "general";
/**
* 用户群体配置
*/
export interface UserProfileConfig {
id: UserProfile;
name: string;
description: string;
icon: LucideIcon;
defaultPlugins: string[];
}
/**
* 引导插件配置
*/
export interface OnboardingPlugin {
id: string;
name: string;
description: string;
icon: LucideIcon;
downloadUrl: string;
}
/**
* 用户群体列表
*/
export const userProfiles: UserProfileConfig[] = [
{
id: "developer",
name: "程序员",
description: "使用 Claude、Codex、Gemini 等 AI 编程工具",
icon: Code,
defaultPlugins: ["config-switch"],
},
{
id: "general",
name: "普通用户",
description: "日常使用 AI 聊天和其他功能",
icon: User,
defaultPlugins: [],
},
];
/**
* 可安装插件列表
*/
export const onboardingPlugins: OnboardingPlugin[] = [
{
id: "config-switch",
name: "配置管理",
description: "一键切换 API 配置,支持 Claude、Codex、Gemini 等客户端",
icon: FileCode,
downloadUrl:
"https://github.com/aiclientproxy/config-switch/releases/latest/download/config-switch-plugin.zip",
},
];
/**
* 引导版本号 - 用于控制是否重新显示引导
* 更新此版本号会触发已完成引导的用户重新看到引导
* 当前引导只保留语音体验流程,不再包含旧插件安装链路
*/
export const ONBOARDING_VERSION = "1.1.0";
@@ -66,7 +66,7 @@ export function CompleteStep({ onFinish }: CompleteStepProps) {
<Subtitle>Lime 已准备就绪,您可以开始使用了。</Subtitle>
<TipsMessage>
提示:您可以在左侧导航栏的"插件中心"随时安装插件
提示:后续可在设置中继续调整语音输入和快捷键。
</TipsMessage>
<Button size="lg" onClick={onFinish}>
@@ -1,386 +0,0 @@
/**
* 初次安装引导 - 安装进度
*/
import { useState, useEffect, useCallback, useRef } from "react";
import styled from "styled-components";
import { safeInvoke } from "@/lib/dev-bridge";
import { safeListen } from "@/lib/dev-bridge";
import type { UnlistenFn } from "@tauri-apps/api/event";
import { Check, X, Loader2 } from "lucide-react";
import { Progress } from "@/components/ui/progress";
import { onboardingPlugins } from "../constants";
const Container = styled.div`
display: flex;
flex-direction: column;
align-items: center;
padding: 32px 24px;
`;
const Title = styled.h2`
font-size: 24px;
font-weight: 600;
color: hsl(var(--foreground));
margin-bottom: 8px;
text-align: center;
`;
const Subtitle = styled.p`
font-size: 14px;
color: hsl(var(--muted-foreground));
margin-bottom: 32px;
text-align: center;
`;
const PluginList = styled.div`
display: flex;
flex-direction: column;
gap: 16px;
width: 100%;
max-width: 500px;
`;
const PluginRow = styled.div`
display: flex;
align-items: center;
gap: 16px;
`;
const IconWrapper = styled.div<{ $status: string }>`
width: 40px;
height: 40px;
border-radius: 10px;
background: ${({ $status }) => {
switch ($status) {
case "complete":
return "hsl(142.1 76.2% 36.3%)";
case "failed":
return "hsl(0 84.2% 60.2%)";
default:
return "hsl(var(--muted))";
}
}};
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all 0.3s;
svg {
width: 20px;
height: 20px;
color: ${({ $status }) =>
$status === "complete" || $status === "failed"
? "white"
: "hsl(var(--foreground))"};
}
`;
const PluginInfo = styled.div`
flex: 1;
min-width: 0;
`;
const PluginName = styled.div`
font-size: 14px;
font-weight: 600;
color: hsl(var(--foreground));
margin-bottom: 4px;
`;
const PluginStatus = styled.div<{ $status: string }>`
font-size: 12px;
color: ${({ $status }) => {
switch ($status) {
case "complete":
return "hsl(142.1 76.2% 36.3%)";
case "failed":
return "hsl(0 84.2% 60.2%)";
default:
return "hsl(var(--muted-foreground))";
}
}};
`;
const ProgressWrapper = styled.div`
width: 100px;
flex-shrink: 0;
`;
const OverallProgress = styled.div`
width: 100%;
max-width: 500px;
margin-top: 32px;
padding-top: 24px;
border-top: 1px solid hsl(var(--border));
`;
const OverallLabel = styled.div`
display: flex;
justify-content: space-between;
margin-bottom: 8px;
font-size: 12px;
color: hsl(var(--muted-foreground));
`;
/**
* 插件安装状态
*/
export interface PluginInstallState {
pluginId: string;
status: "pending" | "downloading" | "installing" | "complete" | "failed";
progress: number;
message: string;
error?: string;
}
/**
* 安装进度事件
*/
interface InstallProgress {
stage: string;
percent: number;
message: string;
}
/**
* 安装结果
*/
interface InstallResult {
success: boolean;
plugin?: {
id: string;
name: string;
};
error?: string;
}
interface InstallProgressStepProps {
selectedPlugins: string[];
onComplete: (results: PluginInstallState[]) => void;
}
export function InstallProgressStep({
selectedPlugins,
onComplete,
}: InstallProgressStepProps) {
const [installStates, setInstallStates] = useState<PluginInstallState[]>([]);
const [isInstalling, setIsInstalling] = useState(false);
const hasStarted = useRef(false);
// 初始化安装状态
useEffect(() => {
if (selectedPlugins.length === 0) {
onComplete([]);
return;
}
setInstallStates(
selectedPlugins.map((id) => ({
pluginId: id,
status: "pending",
progress: 0,
message: "等待安装...",
})),
);
}, [selectedPlugins, onComplete]);
// 顺序安装插件
const installPlugins = useCallback(async () => {
if (isInstalling || selectedPlugins.length === 0) return;
setIsInstalling(true);
let unlisten: UnlistenFn | null = null;
for (let i = 0; i < selectedPlugins.length; i++) {
const pluginId = selectedPlugins[i];
const plugin = onboardingPlugins.find((p) => p.id === pluginId);
if (!plugin) continue;
try {
// 监听当前插件的进度
unlisten = await safeListen<InstallProgress>(
"plugin-install-progress",
(event) => {
setInstallStates((prev) =>
prev.map((state) =>
state.pluginId === pluginId
? {
...state,
status:
event.payload.stage === "complete"
? "complete"
: event.payload.stage === "failed"
? "failed"
: "downloading",
progress: event.payload.percent,
message: event.payload.message,
}
: state,
),
);
},
);
// 更新状态为下载中
setInstallStates((prev) =>
prev.map((state) =>
state.pluginId === pluginId
? { ...state, status: "downloading", message: "准备下载..." }
: state,
),
);
// 调用安装 API
const result = await safeInvoke<InstallResult>(
"install_plugin_from_url",
{
url: plugin.downloadUrl,
},
);
// 取消监听
if (unlisten) {
unlisten();
unlisten = null;
}
// 更新结果状态
setInstallStates((prev) =>
prev.map((state) =>
state.pluginId === pluginId
? {
...state,
status: result.success ? "complete" : "failed",
progress: 100,
message: result.success
? "安装成功"
: result.error || "安装失败",
error: result.error,
}
: state,
),
);
} catch (e) {
// 取消监听
if (unlisten) {
unlisten();
unlisten = null;
}
setInstallStates((prev) =>
prev.map((state) =>
state.pluginId === pluginId
? {
...state,
status: "failed",
progress: 100,
message: "安装出错",
error: e instanceof Error ? e.message : String(e),
}
: state,
),
);
}
}
setIsInstalling(false);
}, [selectedPlugins, isInstalling]);
// 开始安装
useEffect(() => {
if (
installStates.length > 0 &&
!isInstalling &&
!hasStarted.current &&
installStates.every((s) => s.status === "pending")
) {
hasStarted.current = true;
installPlugins();
}
}, [installStates, isInstalling, installPlugins]);
// 检查是否全部完成
useEffect(() => {
if (
installStates.length > 0 &&
installStates.every(
(s) => s.status === "complete" || s.status === "failed",
)
) {
// 延迟一点调用 onComplete,让用户看到最终状态
const timer = setTimeout(() => {
onComplete(installStates);
}, 1000);
return () => clearTimeout(timer);
}
}, [installStates, onComplete]);
// 计算总体进度
const completedCount = installStates.filter(
(s) => s.status === "complete" || s.status === "failed",
).length;
const overallProgress =
selectedPlugins.length > 0
? Math.round((completedCount / selectedPlugins.length) * 100)
: 0;
const getStatusIcon = (state: PluginInstallState) => {
const plugin = onboardingPlugins.find((p) => p.id === state.pluginId);
const Icon = plugin?.icon;
switch (state.status) {
case "complete":
return <Check />;
case "failed":
return <X />;
case "downloading":
case "installing":
return <Loader2 className="animate-spin" />;
default:
return Icon ? <Icon /> : null;
}
};
return (
<Container>
<Title>正在安装插件</Title>
<Subtitle>请稍候,正在为您安装选中的插件...</Subtitle>
<PluginList>
{installStates.map((state) => {
const plugin = onboardingPlugins.find((p) => p.id === state.pluginId);
return (
<PluginRow key={state.pluginId}>
<IconWrapper $status={state.status}>
{getStatusIcon(state)}
</IconWrapper>
<PluginInfo>
<PluginName>{plugin?.name || state.pluginId}</PluginName>
<PluginStatus $status={state.status}>
{state.message}
</PluginStatus>
</PluginInfo>
<ProgressWrapper>
<Progress value={state.progress} />
</ProgressWrapper>
</PluginRow>
);
})}
</PluginList>
<OverallProgress>
<OverallLabel>
<span>总体进度</span>
<span>
{completedCount} / {selectedPlugins.length}
</span>
</OverallLabel>
<Progress value={overallProgress} />
</OverallProgress>
</Container>
);
}
@@ -1,189 +0,0 @@
/**
* 初次安装引导 - 插件选择
*/
import styled from "styled-components";
import { Checkbox } from "@/components/ui/checkbox";
import { onboardingPlugins } from "../constants";
const Container = styled.div`
display: flex;
flex-direction: column;
align-items: center;
padding: 32px 24px;
`;
const Title = styled.h2`
font-size: 24px;
font-weight: 600;
color: hsl(var(--foreground));
margin-bottom: 8px;
text-align: center;
`;
const Subtitle = styled.p`
font-size: 14px;
color: hsl(var(--muted-foreground));
margin-bottom: 24px;
text-align: center;
`;
const PluginList = styled.div`
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
max-width: 500px;
`;
const PluginCard = styled.label<{ $selected?: boolean }>`
display: flex;
align-items: flex-start;
gap: 16px;
padding: 16px;
border-radius: 12px;
border: 1px solid
${({ $selected }) =>
$selected ? "hsl(var(--primary))" : "hsl(var(--border))"};
background: ${({ $selected }) =>
$selected ? "hsl(var(--primary) / 0.05)" : "hsl(var(--card))"};
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: hsl(var(--primary) / 0.5);
}
`;
const CheckboxWrapper = styled.div`
padding-top: 2px;
`;
const IconWrapper = styled.div<{ $selected?: boolean }>`
width: 40px;
height: 40px;
border-radius: 10px;
background: ${({ $selected }) =>
$selected ? "hsl(var(--primary))" : "hsl(var(--muted))"};
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all 0.2s;
svg {
width: 20px;
height: 20px;
color: ${({ $selected }) =>
$selected ? "hsl(var(--primary-foreground))" : "hsl(var(--foreground))"};
}
`;
const PluginInfo = styled.div`
flex: 1;
min-width: 0;
`;
const PluginName = styled.div`
font-size: 14px;
font-weight: 600;
color: hsl(var(--foreground));
margin-bottom: 4px;
`;
const PluginDescription = styled.div`
font-size: 12px;
color: hsl(var(--muted-foreground));
line-height: 1.5;
`;
const SelectAllRow = styled.div`
display: flex;
justify-content: flex-end;
width: 100%;
max-width: 500px;
margin-bottom: 8px;
`;
const SelectAllButton = styled.button`
font-size: 12px;
color: hsl(var(--primary));
background: none;
border: none;
cursor: pointer;
padding: 4px 8px;
&:hover {
text-decoration: underline;
}
`;
interface PluginSelectStepProps {
selectedPlugins: string[];
onSelectionChange: (plugins: string[]) => void;
}
export function PluginSelectStep({
selectedPlugins,
onSelectionChange,
}: PluginSelectStepProps) {
const handleToggle = (pluginId: string) => {
if (selectedPlugins.includes(pluginId)) {
onSelectionChange(selectedPlugins.filter((id) => id !== pluginId));
} else {
onSelectionChange([...selectedPlugins, pluginId]);
}
};
const handleSelectAll = () => {
if (selectedPlugins.length === onboardingPlugins.length) {
onSelectionChange([]);
} else {
onSelectionChange(onboardingPlugins.map((p) => p.id));
}
};
const isAllSelected = selectedPlugins.length === onboardingPlugins.length;
return (
<Container>
<Title>选择要安装的插件</Title>
<Subtitle>您可以根据需要选择插件,或稍后在插件中心安装</Subtitle>
<SelectAllRow>
<SelectAllButton onClick={handleSelectAll}>
{isAllSelected ? "取消全选" : "全选"}
</SelectAllButton>
</SelectAllRow>
<PluginList>
{onboardingPlugins.map((plugin) => {
const isSelected = selectedPlugins.includes(plugin.id);
const Icon = plugin.icon;
return (
<PluginCard
key={plugin.id}
$selected={isSelected}
onClick={() => handleToggle(plugin.id)}
>
<CheckboxWrapper>
<Checkbox
checked={isSelected}
onCheckedChange={() => handleToggle(plugin.id)}
/>
</CheckboxWrapper>
<IconWrapper $selected={isSelected}>
<Icon />
</IconWrapper>
<PluginInfo>
<PluginName>{plugin.name}</PluginName>
<PluginDescription>{plugin.description}</PluginDescription>
</PluginInfo>
</PluginCard>
);
})}
</PluginList>
</Container>
);
}
@@ -4,7 +4,7 @@
import styled from "styled-components";
import { Button } from "@/components/ui/button";
import { Cpu, Puzzle, Mic } from "lucide-react";
import { Cpu, LayoutDashboard, Mic } from "lucide-react";
const Container = styled.div`
display: flex;
@@ -150,11 +150,11 @@ export function WelcomeStep({ onNext, onSkip }: WelcomeStepProps) {
<FeatureCard>
<FeatureIcon>
<Puzzle />
<LayoutDashboard />
</FeatureIcon>
<FeatureTitle>插件扩展</FeatureTitle>
<FeatureTitle>持续工作区</FeatureTitle>
<FeatureDescription>
丰富的插件生态,按需安装扩展功能
围绕同一目标持续整理对话、素材和结果,减少来回切换。
</FeatureDescription>
</FeatureCard>
@@ -0,0 +1,81 @@
import { act, type ReactElement } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CompleteStep } from "./CompleteStep";
import { WelcomeStep } from "./WelcomeStep";
interface Mounted {
container: HTMLDivElement;
root: Root;
}
const mounted: Mounted[] = [];
function renderStep(element: ReactElement): HTMLDivElement {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(element);
});
mounted.push({ container, root });
return container;
}
function getText(container: HTMLElement): string {
return (container.textContent ?? "").replace(/\s+/g, " ").trim();
}
beforeEach(() => {
(
globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
).IS_REACT_ACT_ENVIRONMENT = true;
});
afterEach(() => {
while (mounted.length > 0) {
const target = mounted.pop();
if (!target) {
break;
}
act(() => {
target.root.unmount();
});
target.container.remove();
}
});
describe("onboarding steps copy", () => {
it("欢迎页应只展示现役能力文案", () => {
const container = renderStep(
<WelcomeStep onNext={vi.fn()} onSkip={vi.fn()} />,
);
const text = getText(container);
expect(text).toContain("欢迎使用 Lime");
expect(text).toContain("持续工作区");
expect(text).toContain(
"围绕同一目标持续整理对话、素材和结果,减少来回切换。",
);
expect(text).toContain("语音交互");
expect(text).not.toContain("插件扩展");
expect(text).not.toContain("按需安装");
expect(text).not.toContain("丰富的插件生态");
});
it("完成页不再引导去插件中心安装插件", () => {
const container = renderStep(<CompleteStep onFinish={vi.fn()} />);
const text = getText(container);
expect(text).toContain("设置完成!");
expect(text).toContain("提示:后续可在设置中继续调整语音输入和快捷键。");
expect(text).not.toContain("插件中心");
expect(text).not.toContain("安装插件");
});
});
@@ -1,182 +0,0 @@
import { useEffect, useState } from "react";
import {
ExternalLink,
Loader2,
MonitorSmartphone,
RefreshCw,
SquareTerminal,
} from "lucide-react";
interface OpenClawDashboardFrameProps {
dashboardUrl: string | null;
loading: boolean;
reloadToken: number;
running: boolean;
windowBusy?: boolean;
onOpenExternal: () => void;
onOpenWindow?: () => void;
onReload: () => void;
}
export function OpenClawDashboardFrame({
dashboardUrl,
loading,
reloadToken,
running,
windowBusy = false,
onOpenExternal,
onOpenWindow,
onReload,
}: OpenClawDashboardFrameProps) {
const [frameLoading, setFrameLoading] = useState(false);
const [frameBlocked, setFrameBlocked] = useState(false);
useEffect(() => {
setFrameLoading(running && Boolean(dashboardUrl));
setFrameBlocked(false);
}, [dashboardUrl, reloadToken, running]);
useEffect(() => {
if (!frameLoading) {
return;
}
const timer = window.setTimeout(() => {
setFrameBlocked(true);
setFrameLoading(false);
}, 8000);
return () => window.clearTimeout(timer);
}, [frameLoading]);
return (
<section className="rounded-2xl border bg-card p-6 shadow-sm space-y-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<h2 className="text-lg font-semibold">Dashboard</h2>
<p className="mt-1 text-sm text-muted-foreground">
默认在当前页面内嵌显示,同时支持单独打开。
</p>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={onReload}
disabled={!running || loading}
className="inline-flex items-center gap-2 rounded-lg border px-4 py-2 text-sm hover:bg-muted disabled:opacity-60"
>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
刷新内嵌页
</button>
<button
type="button"
onClick={onOpenExternal}
disabled={!running || !dashboardUrl}
className="inline-flex items-center gap-2 rounded-lg border px-4 py-2 text-sm hover:bg-muted disabled:opacity-60"
>
<ExternalLink className="h-4 w-4" />
单独打开
</button>
</div>
</div>
{!running ? (
<div className="flex min-h-[520px] flex-col items-center justify-center rounded-xl border border-dashed bg-background/50 px-6 py-10 text-center">
<SquareTerminal className="h-10 w-10 text-muted-foreground" />
<h3 className="mt-4 text-base font-medium">Dashboard 暂不可用</h3>
<p className="mt-2 max-w-2xl text-sm text-muted-foreground">
请先完成配置同步并启动 Gateway,启动成功后会在这里直接显示 Dashboard
页面。
</p>
</div>
) : !dashboardUrl ? (
<div className="flex min-h-[520px] items-center justify-center rounded-xl border border-dashed bg-background/50 text-sm text-muted-foreground">
正在准备 Dashboard 地址...
</div>
) : (
<div className="overflow-hidden rounded-xl border bg-background">
<div className="flex items-center justify-between border-b bg-muted/40 px-4 py-2 text-xs text-muted-foreground">
<span>当前地址</span>
<span className="max-w-[70%] truncate">{dashboardUrl}</span>
</div>
<div className="relative h-[720px] bg-white">
{(loading || frameLoading) && !frameBlocked && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-background/70 backdrop-blur-sm">
<div className="inline-flex items-center gap-2 rounded-full border bg-card px-4 py-2 text-sm text-muted-foreground shadow-sm">
<Loader2 className="h-4 w-4 animate-spin" />
Dashboard 加载中...
</div>
</div>
)}
{frameBlocked && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-background/90 px-6">
<div className="max-w-xl rounded-2xl border bg-card p-6 text-center shadow-sm">
<h3 className="text-base font-semibold">内嵌模式加载失败</h3>
<p className="mt-2 text-sm leading-7 text-muted-foreground">
Dashboard 很可能被目标页的鉴权、Cookie 或 iframe
策略拦截,因此在当前页面内无法稳定显示。
</p>
<div className="mt-4 flex flex-wrap items-center justify-center gap-3">
{onOpenWindow ? (
<button
type="button"
onClick={onOpenWindow}
disabled={windowBusy}
className="inline-flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm text-primary-foreground disabled:opacity-60"
>
{windowBusy ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<MonitorSmartphone className="h-4 w-4" />
)}
打开桌面面板
</button>
) : null}
<button
type="button"
onClick={onOpenExternal}
className="inline-flex items-center gap-2 rounded-lg border px-4 py-2 text-sm hover:bg-muted"
>
<ExternalLink className="h-4 w-4" />
系统浏览器打开
</button>
<button
type="button"
onClick={onReload}
className="inline-flex items-center gap-2 rounded-lg border px-4 py-2 text-sm hover:bg-muted"
>
<RefreshCw className="h-4 w-4" />
再试一次
</button>
</div>
</div>
</div>
)}
<iframe
key={`${dashboardUrl}-${reloadToken}`}
title="OpenClaw Dashboard"
src={dashboardUrl}
className="h-full w-full"
allow="clipboard-read; clipboard-write"
onLoad={() => {
setFrameBlocked(false);
setFrameLoading(false);
}}
onError={() => {
setFrameBlocked(true);
setFrameLoading(false);
}}
/>
</div>
</div>
)}
</section>
);
}
export default OpenClawDashboardFrame;
-8
View File
@@ -10,7 +10,6 @@
| `PluginManager.tsx` | 插件管理主组件,显示插件列表和状态 |
| `PluginInstallDialog.tsx` | 插件安装对话框,支持本地文件和 URL 安装 |
| `PluginUninstallDialog.tsx` | 插件卸载确认对话框 |
| `PluginUIRenderer.tsx` | 插件 UI 渲染器,根据 pluginId 渲染对应的插件 UI |
| `PluginItemContextMenu.tsx` | 插件项右键菜单,支持启用/禁用、打开目录、卸载等操作 |
| `index.ts` | 模块导出 |
@@ -56,13 +55,6 @@
- 调用后端卸载命令
- 刷新插件列表
### PluginUIRenderer
- 根据 pluginId 渲染对应的插件 UI 组件
- 支持内置插件组件映射 (machine-id-tool -> MachineIdTool)
- 显示友好的错误提示(插件未找到、加载失败)
- 导出 Page 类型定义,支持动态插件路由
### PluginItemContextMenu
- 为已安装插件列表提供右键菜单
-4
View File
@@ -1,4 +0,0 @@
export { PluginManager } from "./PluginManager";
export { PluginInstallDialog } from "./PluginInstallDialog";
export { PluginUninstallDialog } from "./PluginUninstallDialog";
export { default } from "./PluginManager";
-7
View File
@@ -1,7 +0,0 @@
/**
* 项目组件当前主链导出
*/
export { CreateProjectDialog } from "./CreateProjectDialog";
export { ProjectSelector } from "./ProjectSelector";
export type { ProjectSelectorProps } from "./ProjectSelector";
@@ -1,460 +0,0 @@
/**
* 增强版模型列表页面
*
* 使用 model_registry 数据,支持搜索、收藏、分组等功能
*/
import { useState, useMemo, useEffect, useRef, useCallback } from "react";
import {
Cpu,
RefreshCw,
Copy,
Check,
Search,
Star,
Clock,
Filter,
Eye,
Wrench,
Brain,
DollarSign,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useModelRegistry } from "@/hooks/useModelRegistry";
import type {
EnhancedModelMetadata,
ModelTier,
} from "@/lib/types/modelRegistry";
export function EnhancedModelsTab() {
const {
models,
preferences,
loading,
error,
lastSyncAt,
refresh,
search,
toggleFavorite,
groupedByProvider,
} = useModelRegistry();
const [searchQuery, setSearchQuery] = useState("");
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState("");
const [selectedProvider, setSelectedProvider] = useState<string | null>(null);
const [selectedTier, setSelectedTier] = useState<ModelTier | null>(null);
const [copied, setCopied] = useState<string | null>(null);
const [showFavoritesOnly, setShowFavoritesOnly] = useState(false);
const [displayLimit, setDisplayLimit] = useState(50); // 初始显示 50 个
// 防抖搜索
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleSearchChange = useCallback((value: string) => {
setSearchQuery(value);
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
debounceTimerRef.current = setTimeout(() => {
setDebouncedSearchQuery(value);
}, 150);
}, []);
// 当筛选条件变化时,重置显示数量
useEffect(() => {
setDisplayLimit(50);
}, [debouncedSearchQuery, selectedProvider, selectedTier, showFavoritesOnly]);
// 搜索和过滤
const filteredModels = useMemo(() => {
let result = debouncedSearchQuery ? search(debouncedSearchQuery) : models;
if (selectedProvider) {
result = result.filter((m) => m.provider_id === selectedProvider);
}
if (selectedTier) {
result = result.filter((m) => m.tier === selectedTier);
}
if (showFavoritesOnly) {
result = result.filter((m) => preferences.get(m.id)?.is_favorite);
}
return result;
}, [
debouncedSearchQuery,
models,
selectedProvider,
selectedTier,
showFavoritesOnly,
preferences,
search,
]);
// 分页显示的模型
const displayedModels = useMemo(() => {
return filteredModels.slice(0, displayLimit);
}, [filteredModels, displayLimit]);
const hasMore = filteredModels.length > displayLimit;
// 缓存 providers 列表,避免每次渲染都重新计算
const providers = useMemo(
() => Array.from(groupedByProvider.keys()),
[groupedByProvider],
);
const copyModelId = (id: string) => {
navigator.clipboard.writeText(id);
setCopied(id);
setTimeout(() => setCopied(null), 2000);
};
const formatSyncTime = (timestamp: number | null) => {
if (!timestamp) return "从未同步";
return new Date(timestamp * 1000).toLocaleString("zh-CN");
};
return (
<div className="space-y-6">
{error && (
<div className="rounded-lg border border-red-500 bg-red-50 dark:bg-red-950 p-4 text-red-700 dark:text-red-300">
{error}
</div>
)}
{/* 头部信息 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Clock className="h-4 w-4" />
<span>上次同步: {formatSyncTime(lastSyncAt)}</span>
</div>
<button
onClick={refresh}
disabled={loading}
className="flex items-center gap-2 rounded-lg border px-4 py-2 text-sm font-medium hover:bg-muted disabled:opacity-50"
>
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
刷新
</button>
</div>
{/* 搜索和过滤 */}
<div className="flex flex-col gap-4">
<div className="flex items-center gap-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<input
type="text"
placeholder="搜索模型名称、ID、Provider..."
value={searchQuery}
onChange={(e) => handleSearchChange(e.target.value)}
className="w-full rounded-lg border bg-background pl-10 pr-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/20"
/>
</div>
<button
onClick={() => setShowFavoritesOnly(!showFavoritesOnly)}
className={cn(
"flex items-center gap-2 rounded-lg border px-4 py-2 text-sm font-medium transition-colors",
showFavoritesOnly
? "bg-yellow-100 border-yellow-300 text-yellow-700 dark:bg-yellow-900 dark:border-yellow-700 dark:text-yellow-300"
: "hover:bg-muted",
)}
>
<Star
className={cn(
"h-4 w-4",
showFavoritesOnly && "fill-yellow-500 text-yellow-500",
)}
/>
收藏
</button>
</div>
{/* Provider 过滤 */}
<div className="flex flex-wrap gap-2">
<button
onClick={() => setSelectedProvider(null)}
className={cn(
"rounded-lg px-3 py-1.5 text-sm font-medium transition-colors",
!selectedProvider
? "bg-primary text-primary-foreground"
: "bg-muted hover:bg-muted/80",
)}
>
全部 ({models.length})
</button>
{providers.map((providerId) => {
const providerModels = groupedByProvider.get(providerId) || [];
const providerName = providerModels[0]?.provider_name || providerId;
return (
<button
key={providerId}
onClick={() =>
setSelectedProvider(
selectedProvider === providerId ? null : providerId,
)
}
className={cn(
"rounded-lg px-3 py-1.5 text-sm font-medium transition-colors",
selectedProvider === providerId
? "bg-primary text-primary-foreground"
: "bg-muted hover:bg-muted/80",
)}
>
{providerName} ({providerModels.length})
</button>
);
})}
</div>
{/* Tier 过滤 */}
<div className="flex items-center gap-2">
<Filter className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">等级:</span>
{(["mini", "pro", "max"] as ModelTier[]).map((tier) => (
<button
key={tier}
onClick={() =>
setSelectedTier(selectedTier === tier ? null : tier)
}
className={cn(
"rounded-lg px-3 py-1 text-xs font-medium transition-colors",
selectedTier === tier
? getTierButtonActiveClass(tier)
: "bg-muted hover:bg-muted/80",
)}
>
{tier.toUpperCase()}
</button>
))}
</div>
</div>
{/* 模型列表 */}
<div className="rounded-lg border bg-card">
<div className="border-b px-4 py-3">
<div className="flex items-center justify-between">
<span className="font-medium">模型列表</span>
<span className="text-sm text-muted-foreground">
{hasMore
? `显示 ${displayedModels.length} / ${filteredModels.length} 个模型`
: `${filteredModels.length} 个模型`}
</span>
</div>
</div>
{loading ? (
<div className="flex items-center justify-center py-12">
<RefreshCw className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : filteredModels.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<Cpu className="h-12 w-12 mb-2 opacity-50" />
<p>暂无模型数据</p>
</div>
) : (
<>
<div className="divide-y max-h-[600px] overflow-y-auto">
{displayedModels.map((model) => (
<ModelRow
key={model.id}
model={model}
isFavorite={preferences.get(model.id)?.is_favorite || false}
usageCount={preferences.get(model.id)?.usage_count || 0}
copied={copied === model.id}
onCopy={() => copyModelId(model.id)}
onToggleFavorite={() => toggleFavorite(model.id)}
/>
))}
</div>
{hasMore && (
<div className="border-t px-4 py-3 flex justify-center">
<button
onClick={() => setDisplayLimit((prev) => prev + 50)}
className="flex items-center gap-2 rounded-lg border px-4 py-2 text-sm font-medium hover:bg-muted"
>
加载更多 (还有 {filteredModels.length - displayLimit} 个)
</button>
</div>
)}
</>
)}
</div>
{/* 使用说明 */}
<div className="rounded-lg border bg-card p-4">
<h3 className="mb-2 font-semibold">使用说明</h3>
<div className="space-y-2 text-sm text-muted-foreground">
<p>• 模型数据来自 models.dev API 和本地配置</p>
<p>• 点击星标可收藏常用模型,收藏的模型会优先显示</p>
<p>• 支持按 Provider、服务等级筛选模型</p>
</div>
</div>
</div>
);
}
/** 单个模型行 */
function ModelRow({
model,
isFavorite,
usageCount,
copied,
onCopy,
onToggleFavorite,
}: {
model: EnhancedModelMetadata;
isFavorite: boolean;
usageCount: number;
copied: boolean;
onCopy: () => void;
onToggleFavorite: () => void;
}) {
return (
<div className="flex items-center justify-between px-4 py-3 hover:bg-muted/50">
<div className="flex items-center gap-3 flex-1 min-w-0">
<Cpu className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<code className="font-medium truncate">{model.id}</code>
<TierBadge tier={model.tier} />
{model.is_latest && (
<span className="text-xs px-1.5 py-0.5 rounded bg-primary/10 text-primary">
最新
</span>
)}
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-0.5">
<span>{model.provider_name}</span>
{model.limits.context_length && (
<>
<span>·</span>
<span>{formatContextLength(model.limits.context_length)}</span>
</>
)}
{usageCount > 0 && (
<>
<span>·</span>
<span>使用 {usageCount} 次</span>
</>
)}
</div>
</div>
</div>
{/* 能力图标 */}
<div className="flex items-center gap-1 mr-3">
{model.capabilities.vision && (
<span title="支持视觉">
<Eye className="h-3.5 w-3.5 text-blue-500" />
</span>
)}
{model.capabilities.tools && (
<span title="支持工具">
<Wrench className="h-3.5 w-3.5 text-green-500" />
</span>
)}
{model.capabilities.reasoning && (
<span title="支持推理">
<Brain className="h-3.5 w-3.5 text-purple-500" />
</span>
)}
</div>
{/* 定价 */}
{model.pricing && model.pricing.input_per_million && (
<div className="flex items-center gap-1 text-xs text-muted-foreground mr-3">
<DollarSign className="h-3 w-3" />
<span>{model.pricing.input_per_million.toFixed(2)}</span>
</div>
)}
{/* 操作按钮 */}
<div className="flex items-center gap-2">
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
console.log("[EnhancedModelsTab] Toggle favorite:", model.id);
onToggleFavorite();
}}
className="p-2 rounded-lg hover:bg-muted transition-colors cursor-pointer"
title={isFavorite ? "取消收藏" : "收藏"}
>
<Star
className={cn(
"h-5 w-5",
isFavorite
? "text-yellow-500 fill-yellow-500"
: "text-muted-foreground hover:text-yellow-400",
)}
/>
</button>
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onCopy();
}}
className="p-2 rounded-lg hover:bg-muted transition-colors cursor-pointer"
title="复制模型 ID"
>
{copied ? (
<Check className="h-5 w-5 text-green-500" />
) : (
<Copy className="h-5 w-5 text-muted-foreground hover:text-foreground" />
)}
</button>
</div>
</div>
);
}
/** 服务等级徽章 */
function TierBadge({ tier }: { tier: string }) {
const config = {
mini: {
label: "Mini",
color:
"bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300",
},
pro: {
label: "Pro",
color: "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300",
},
max: {
label: "Max",
color:
"bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300",
},
}[tier] || {
label: tier,
color: "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300",
};
return (
<span className={cn("text-xs px-1.5 py-0.5 rounded", config.color)}>
{config.label}
</span>
);
}
/** 获取 Tier 按钮激活状态的样式 */
function getTierButtonActiveClass(tier: ModelTier): string {
const classes = {
mini: "bg-green-100 text-green-700 border-green-300 dark:bg-green-900 dark:text-green-300 dark:border-green-700",
pro: "bg-blue-100 text-blue-700 border-blue-300 dark:bg-blue-900 dark:text-blue-300 dark:border-blue-700",
max: "bg-purple-100 text-purple-700 border-purple-300 dark:bg-purple-900 dark:text-purple-300 dark:border-purple-700",
};
return classes[tier] || "bg-primary text-primary-foreground";
}
/** 格式化上下文长度 */
function formatContextLength(length: number): string {
if (length >= 1000000) {
return `${(length / 1000000).toFixed(1)}M`;
}
if (length >= 1000) {
return `${(length / 1000).toFixed(0)}K`;
}
return String(length);
}
@@ -1,22 +0,0 @@
/**
* @file ModelRegistryTab 组件
* @description 模型库 Tab,显示所有可用模型
* @module components/provider-pool/ModelRegistryTab
*/
import { EnhancedModelsTab } from "./EnhancedModelsTab";
/**
* 模型库 Tab 组件
*
* 复用 Provider Pool 的增强模型列表组件
*/
export function ModelRegistryTab() {
return (
<div className="min-h-[400px]" data-testid="model-registry-section">
<EnhancedModelsTab />
</div>
);
}
export default ModelRegistryTab;
@@ -1,159 +0,0 @@
/**
* Antigravity 凭证添加表单(自包含版本)
*
* 这是 AntigravityForm 的包装组件,内部管理所有状态,
* 适合在插件中独立使用。
*
* @module components/provider-pool/credential-forms/AntigravityFormStandalone
*/
import { useState, useCallback } from "react";
import { open } from "@tauri-apps/plugin-dialog";
import { AntigravityForm } from "./AntigravityForm";
import { Button } from "@/components/ui/button";
import { Loader2 } from "lucide-react";
/** Antigravity 凭证文件默认路径 */
const ANTIGRAVITY_DEFAULT_CREDS_PATH = "~/.antigravity/oauth_creds.json";
interface AntigravityFormStandaloneProps {
/** 添加成功回调 */
onSuccess: () => void;
/** 取消回调 */
onCancel?: () => void;
/** 初始名称 */
initialName?: string;
}
/**
* 自包含的 Antigravity 凭证添加表单
*
* 内部管理所有状态,只需要提供 onSuccess 和 onCancel 回调
*/
export function AntigravityFormStandalone({
onSuccess,
onCancel,
initialName = "",
}: AntigravityFormStandaloneProps) {
const [name, setName] = useState(initialName);
const [credsFilePath, setCredsFilePath] = useState(
ANTIGRAVITY_DEFAULT_CREDS_PATH,
);
const [projectId, setProjectId] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSelectFile = useCallback(async () => {
try {
const selected = await open({
multiple: false,
filters: [{ name: "JSON", extensions: ["json"] }],
});
if (selected) {
setCredsFilePath(selected as string);
}
} catch (e) {
console.error("Failed to open file dialog:", e);
}
}, []);
const handleSuccess = useCallback(() => {
onSuccess();
}, [onSuccess]);
// 使用 AntigravityForm hook 获取渲染函数和提交方法
const antigravityForm = AntigravityForm({
name,
credsFilePath,
setCredsFilePath,
projectId,
setProjectId,
onSelectFile: handleSelectFile,
loading,
setLoading,
setError,
onSuccess: handleSuccess,
});
// 处理提交
const handleSubmit = useCallback(() => {
if (antigravityForm.mode === "file") {
antigravityForm.handleFileSubmit();
} else if (antigravityForm.mode === "login") {
antigravityForm.handleGetAuthUrl();
}
}, [antigravityForm]);
// 是否显示提交按钮
const showSubmitButton = antigravityForm.mode === "file";
// login 模式显示获取授权 URL 按钮
const showLoginButton =
antigravityForm.mode === "login" && !antigravityForm.waitingForCallback;
return (
<div className="space-y-4">
{/* 名称输入 */}
<div>
<label className="mb-1 block text-sm font-medium">名称 (可选)</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="给这个凭证起个名字..."
disabled={loading}
className="w-full rounded-lg border bg-background px-3 py-2 text-sm"
/>
</div>
{/* Antigravity 表单内容 */}
{antigravityForm.render()}
{/* 错误提示 */}
{error && (
<div className="rounded-lg border border-red-300 bg-red-50 dark:bg-red-900/20 p-3 text-sm text-red-700 dark:text-red-300">
{error}
</div>
)}
{/* 按钮区域 */}
<div className="flex justify-end gap-2 pt-2">
{onCancel && (
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={loading}
>
取消
</Button>
)}
{showLoginButton && (
<Button type="button" onClick={handleSubmit} disabled={loading}>
{loading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
获取中...
</>
) : (
"获取授权 URL"
)}
</Button>
)}
{showSubmitButton && (
<Button type="button" onClick={handleSubmit} disabled={loading}>
{loading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
添加中...
</>
) : (
"添加凭证"
)}
</Button>
)}
</div>
</div>
);
}
export default AntigravityFormStandalone;
@@ -1,374 +0,0 @@
/**
* Claude 凭证添加表单(自包含版本)
*
* 支持多种认证方式:
* 1. Cookie 授权 - 使用 sessionKey 自动完成 OAuth 流程
* 2. OAuth 登录 - 通过授权 URL 手动复制授权码
* 3. 文件导入 - 导入已有的凭证文件
*
* @module components/provider-pool/credential-forms/ClaudeFormStandalone
*/
import { useState, useCallback, useEffect } from "react";
import { open } from "@tauri-apps/plugin-dialog";
import { onClaudeOAuthAuthUrl } from "@/lib/api/providerAuthEvents";
import { providerPoolApi } from "@/lib/api/providerPool";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Loader2, Cookie, Key, FileJson, Upload } from "lucide-react";
import { OAuthUrlDisplay } from "./OAuthUrlDisplay";
type AuthMode = "cookie" | "login" | "file";
interface ClaudeFormStandaloneProps {
/** 添加成功回调 */
onSuccess: () => void;
/** 取消回调 */
onCancel?: () => void;
/** 初始名称 */
initialName?: string;
/** 认证类型(预留扩展) */
authType?: string;
}
/**
* 自包含的 Claude 凭证添加表单
*/
export function ClaudeFormStandalone({
onSuccess,
onCancel,
initialName = "",
authType: _authType,
}: ClaudeFormStandaloneProps) {
const [mode, setMode] = useState<AuthMode>("cookie");
const [name, setName] = useState(initialName);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// OAuth 状态
const [authUrl, setAuthUrl] = useState<string | null>(null);
const [waitingForCallback, setWaitingForCallback] = useState(false);
// Cookie 状态
const [sessionKey, setSessionKey] = useState("");
const [isSetupToken, setIsSetupToken] = useState(false);
// 文件导入状态
const [credsFilePath, setCredsFilePath] = useState("");
// 监听后端发送的授权 URL 事件
useEffect(() => {
let unlisten: (() => void) | undefined;
const setupListener = async () => {
unlisten = await onClaudeOAuthAuthUrl((payload) => {
setAuthUrl(payload.auth_url);
});
};
setupListener();
return () => {
if (unlisten) unlisten();
};
}, []);
// 获取授权 URL 并启动服务器等待回调
const handleGetAuthUrl = useCallback(async () => {
setLoading(true);
setError(null);
setAuthUrl(null);
setWaitingForCallback(true);
try {
await providerPoolApi.getClaudeOAuthAuthUrlAndWait(
name.trim() || undefined,
);
onSuccess();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
setWaitingForCallback(false);
} finally {
setLoading(false);
}
}, [name, onSuccess]);
// Cookie 自动授权
const handleCookieSubmit = useCallback(async () => {
if (!sessionKey.trim()) {
setError("请输入 sessionKey");
return;
}
setLoading(true);
setError(null);
try {
await providerPoolApi.claudeOAuthWithCookie(
sessionKey.trim(),
isSetupToken,
name.trim() || undefined,
);
onSuccess();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
}, [sessionKey, isSetupToken, name, onSuccess]);
// 选择文件
const handleSelectFile = useCallback(async () => {
try {
const selected = await open({
multiple: false,
filters: [{ name: "JSON", extensions: ["json"] }],
});
if (selected) {
setCredsFilePath(selected as string);
}
} catch (e) {
console.error("Failed to open file dialog:", e);
}
}, []);
// 文件导入提交
const handleFileSubmit = useCallback(async () => {
if (!credsFilePath) {
setError("请选择凭证文件");
return;
}
setLoading(true);
setError(null);
try {
await providerPoolApi.addClaudeOAuth(
credsFilePath,
name.trim() || undefined,
);
onSuccess();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
}, [credsFilePath, name, onSuccess]);
return (
<div className="space-y-4">
{/* 名称输入 */}
<div>
<label className="mb-1 block text-sm font-medium">名称 (可选)</label>
<Input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="给这个凭证起个名字..."
disabled={loading}
/>
</div>
{/* 模式选择器 */}
<div className="flex gap-2">
<button
type="button"
onClick={() => setMode("cookie")}
className={`flex flex-1 items-center justify-center gap-2 rounded-lg border px-3 py-2 text-sm transition-colors ${
mode === "cookie"
? "border-amber-500 bg-amber-50 text-amber-700 dark:bg-amber-950/30 dark:text-amber-300"
: "hover:bg-muted"
}`}
>
<Cookie className="h-4 w-4" />
Cookie 授权
</button>
<button
type="button"
onClick={() => setMode("login")}
className={`flex flex-1 items-center justify-center gap-2 rounded-lg border px-3 py-2 text-sm transition-colors ${
mode === "login"
? "border-amber-500 bg-amber-50 text-amber-700 dark:bg-amber-950/30 dark:text-amber-300"
: "hover:bg-muted"
}`}
>
<Key className="h-4 w-4" />
OAuth 登录
</button>
<button
type="button"
onClick={() => setMode("file")}
className={`flex flex-1 items-center justify-center gap-2 rounded-lg border px-3 py-2 text-sm transition-colors ${
mode === "file"
? "border-amber-500 bg-amber-50 text-amber-700 dark:bg-amber-950/30 dark:text-amber-300"
: "hover:bg-muted"
}`}
>
<FileJson className="h-4 w-4" />
导入文件
</button>
</div>
{/* Cookie 授权表单 */}
{mode === "cookie" && (
<div className="space-y-4">
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-950/30">
<p className="text-sm text-amber-700 dark:text-amber-300">
使用浏览器 Cookie 中的 sessionKey 自动完成 OAuth
授权,无需手动复制授权码。
</p>
<p className="mt-2 text-xs text-amber-600 dark:text-amber-400">
获取方式:在 claude.ai 登录后,打开开发者工具 → Application →
Cookies → 复制 sessionKey 的值
</p>
</div>
<div>
<label className="mb-1 block text-sm font-medium">
sessionKey <span className="text-red-500">*</span>
</label>
<Textarea
value={sessionKey}
onChange={(e) => setSessionKey(e.target.value)}
placeholder="粘贴从浏览器 Cookie 中获取的 sessionKey..."
className="font-mono"
rows={3}
/>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="isSetupToken"
checked={isSetupToken}
onChange={(e) => setIsSetupToken(e.target.checked)}
className="h-4 w-4 rounded border-gray-300"
/>
<label
htmlFor="isSetupToken"
className="text-sm text-muted-foreground"
>
Setup Token 模式(只需推理权限,无 refresh_token)
</label>
</div>
</div>
)}
{/* OAuth 登录表单 */}
{mode === "login" && (
<div className="space-y-4">
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-950/30">
<p className="text-sm text-amber-700 dark:text-amber-300">
点击下方按钮获取授权 URL,然后复制到浏览器(支持指纹浏览器)完成
Claude 登录。
</p>
<p className="mt-2 text-xs text-amber-600 dark:text-amber-400">
授权成功后会自动完成,无需手动复制授权码。
</p>
</div>
<OAuthUrlDisplay
authUrl={authUrl}
waitingForCallback={waitingForCallback}
colorScheme="amber"
/>
</div>
)}
{/* 文件导入表单 */}
{mode === "file" && (
<div className="space-y-4">
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-950/30">
<p className="text-sm text-amber-700 dark:text-amber-300">
导入已有的 Claude OAuth 凭证文件。
</p>
<p className="mt-2 text-xs text-amber-600 dark:text-amber-400">
默认路径: ~/.claude/oauth.json 或 Claude CLI 的凭证文件
</p>
</div>
<div className="flex gap-2">
<Input
type="text"
value={credsFilePath}
onChange={(e) => setCredsFilePath(e.target.value)}
placeholder="选择 oauth.json 或 oauth_creds.json..."
className="flex-1"
/>
<Button type="button" variant="outline" onClick={handleSelectFile}>
<Upload className="h-4 w-4" />
</Button>
</div>
</div>
)}
{/* 错误提示 */}
{error && (
<div className="rounded-lg border border-red-300 bg-red-50 dark:bg-red-900/20 p-3 text-sm text-red-700 dark:text-red-300">
{error}
</div>
)}
{/* 按钮区域 */}
<div className="flex justify-end gap-2 pt-2">
{onCancel && (
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={loading}
>
取消
</Button>
)}
{mode === "cookie" && (
<Button
type="button"
onClick={handleCookieSubmit}
disabled={loading || !sessionKey.trim()}
>
{loading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
授权中...
</>
) : (
"添加凭证"
)}
</Button>
)}
{mode === "login" && !authUrl && (
<Button type="button" onClick={handleGetAuthUrl} disabled={loading}>
{loading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
获取中...
</>
) : (
"获取授权 URL"
)}
</Button>
)}
{mode === "file" && (
<Button
type="button"
onClick={handleFileSubmit}
disabled={loading || !credsFilePath}
>
{loading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
导入中...
</>
) : (
"导入凭证"
)}
</Button>
)}
</div>
</div>
);
}
export default ClaudeFormStandalone;
@@ -1,471 +0,0 @@
/**
* Gemini 凭证添加表单(自包含版本)
*
* 支持两种认证方式:
* 1. Google OAuth - 使用 Google 账户授权
* 2. API Key - 使用 Google AI Studio API Key
*
* @module components/provider-pool/credential-forms/GeminiFormStandalone
*/
import { useState, useCallback, useEffect } from "react";
import { open } from "@tauri-apps/plugin-dialog";
import { onGeminiAuthUrl } from "@/lib/api/providerAuthEvents";
import { providerPoolApi } from "@/lib/api/providerPool";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Loader2,
Key,
KeyRound,
Copy,
Check,
ExternalLink,
Upload,
} from "lucide-react";
type AuthMethod = "oauth" | "api_key";
interface GeminiFormStandaloneProps {
/** 添加成功回调 */
onSuccess: () => void;
/** 取消回调 */
onCancel?: () => void;
/** 初始名称 */
initialName?: string;
/** 初始认证方式 */
initialAuthMethod?: AuthMethod;
}
/**
* 自包含的 Gemini 凭证添加表单
*
* 内部管理所有状态,只需要提供 onSuccess 和 onCancel 回调
*/
export function GeminiFormStandalone({
onSuccess,
onCancel,
initialName = "",
initialAuthMethod = "oauth",
}: GeminiFormStandaloneProps) {
const [authMethod, setAuthMethod] = useState<AuthMethod>(initialAuthMethod);
const [name, setName] = useState(initialName);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// OAuth 状态
const [authUrl, setAuthUrl] = useState<string | null>(null);
const [sessionId, setSessionId] = useState<string | null>(null);
const [authCode, setAuthCode] = useState("");
const [copied, setCopied] = useState(false);
const [exchanging, setExchanging] = useState(false);
// 文件导入状态
const [credsFilePath, setCredsFilePath] = useState("");
const [projectId, setProjectId] = useState("");
// API Key 状态
const [apiKey, setApiKey] = useState("");
const [baseUrl, setBaseUrl] = useState("");
// 监听后端发送的授权 URL 事件
useEffect(() => {
let unlisten: (() => void) | undefined;
const setupListener = async () => {
unlisten = await onGeminiAuthUrl((payload) => {
console.log("[Gemini OAuth] 收到授权 URL 事件:", payload);
setAuthUrl(payload.auth_url);
setSessionId(payload.session_id);
});
};
setupListener();
return () => {
if (unlisten) unlisten();
};
}, []);
// 获取授权 URL
const handleGetAuthUrl = useCallback(async () => {
setLoading(true);
setError(null);
setAuthUrl(null);
setSessionId(null);
setAuthCode("");
try {
await providerPoolApi.getGeminiAuthUrlAndWait(name.trim() || undefined);
} catch (e) {
const errorMsg = e instanceof Error ? e.message : String(e);
if (errorMsg.includes("AUTH_URL:")) {
const urlMatch = errorMsg.match(/AUTH_URL:(.+?)(?:\s|$)/);
if (urlMatch) {
setAuthUrl(urlMatch[1]);
}
} else {
setError(errorMsg);
}
} finally {
setLoading(false);
}
}, [name]);
// 用 code 交换 token
const handleExchangeCode = useCallback(async () => {
if (!authCode.trim()) {
setError("请输入授权码");
return;
}
setExchanging(true);
setError(null);
try {
await providerPoolApi.exchangeGeminiCode(
authCode.trim(),
sessionId || undefined,
name.trim() || undefined,
);
onSuccess();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setExchanging(false);
}
}, [authCode, sessionId, name, onSuccess]);
// 复制 URL
const handleCopyUrl = useCallback(async () => {
if (authUrl) {
await navigator.clipboard.writeText(authUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
}, [authUrl]);
// 选择文件
const handleSelectFile = useCallback(async () => {
try {
const selected = await open({
multiple: false,
filters: [{ name: "JSON", extensions: ["json"] }],
});
if (selected) {
setCredsFilePath(selected as string);
}
} catch (e) {
console.error("Failed to open file dialog:", e);
}
}, []);
// 文件导入提交
const handleFileSubmit = useCallback(async () => {
if (!credsFilePath) {
setError("请选择凭证文件");
return;
}
setLoading(true);
setError(null);
try {
await providerPoolApi.addGeminiOAuth(
credsFilePath,
projectId.trim() || undefined,
name.trim() || undefined,
);
onSuccess();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
}, [credsFilePath, projectId, name, onSuccess]);
// API Key 提交
const handleApiKeySubmit = useCallback(async () => {
if (!apiKey.trim()) {
setError("请输入 API Key");
return;
}
setLoading(true);
setError(null);
try {
await providerPoolApi.addGeminiApiKey(
apiKey.trim(),
baseUrl.trim() || undefined,
undefined,
name.trim() || undefined,
);
onSuccess();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
}, [apiKey, baseUrl, name, onSuccess]);
return (
<div className="space-y-4">
{/* 名称输入 */}
<div>
<label className="mb-1 block text-sm font-medium">名称 (可选)</label>
<Input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="给这个凭证起个名字..."
disabled={loading || exchanging}
/>
</div>
{/* 认证方式选择 */}
<Tabs
value={authMethod}
onValueChange={(v) => setAuthMethod(v as AuthMethod)}
>
<TabsList className="grid grid-cols-2">
<TabsTrigger value="oauth" className="flex items-center gap-2">
<Key className="h-4 w-4" />
Google OAuth
</TabsTrigger>
<TabsTrigger value="api_key" className="flex items-center gap-2">
<KeyRound className="h-4 w-4" />
API Key
</TabsTrigger>
</TabsList>
{/* OAuth 认证 */}
<TabsContent value="oauth" className="space-y-4 mt-4">
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4 dark:border-blue-800 dark:bg-blue-950/30">
<p className="text-sm text-blue-700 dark:text-blue-300">
点击下方按钮获取授权 URL,然后复制到浏览器完成 Google 登录。
</p>
<p className="mt-2 text-xs text-blue-600 dark:text-blue-400">
授权成功后,复制页面显示的授权码粘贴到下方输入框。
</p>
</div>
{!authUrl ? (
<Button
onClick={handleGetAuthUrl}
disabled={loading}
className="w-full"
>
{loading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
获取授权 URL...
</>
) : (
<>
<ExternalLink className="h-4 w-4 mr-2" />
获取授权 URL
</>
)}
</Button>
) : (
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">授权 URL</span>
<button
onClick={handleCopyUrl}
className="flex items-center gap-1 rounded px-2 py-1 text-xs text-blue-600 hover:bg-blue-100 dark:text-blue-400 dark:hover:bg-blue-900/30"
>
{copied ? (
<>
<Check className="h-3 w-3" />
已复制
</>
) : (
<>
<Copy className="h-3 w-3" />
复制
</>
)}
</button>
</div>
<div className="rounded-lg border bg-muted/50 p-3">
<p className="break-all text-xs text-muted-foreground">
{authUrl.length > 100
? `${authUrl.slice(0, 100)}...`
: authUrl}
</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">
授权码 <span className="text-red-500">*</span>
</label>
<Input
type="text"
value={authCode}
onChange={(e) => setAuthCode(e.target.value)}
placeholder="粘贴浏览器页面显示的授权码..."
/>
<p className="text-xs text-muted-foreground">
在浏览器中完成授权后,复制页面显示的授权码
</p>
</div>
<Button
onClick={handleExchangeCode}
disabled={exchanging || !authCode.trim()}
className="w-full"
>
{exchanging ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
验证中...
</>
) : (
"验证授权码"
)}
</Button>
</div>
)}
{/* 文件导入选项 */}
<div className="border-t pt-4">
<p className="text-sm text-muted-foreground mb-3">
或者导入已有的凭证文件:
</p>
<div className="space-y-3">
<div className="flex gap-2">
<Input
type="text"
value={credsFilePath}
onChange={(e) => setCredsFilePath(e.target.value)}
placeholder="选择 oauth_creds.json..."
className="flex-1"
/>
<Button
type="button"
variant="outline"
onClick={handleSelectFile}
>
<Upload className="h-4 w-4" />
</Button>
</div>
<Input
type="text"
value={projectId}
onChange={(e) => setProjectId(e.target.value)}
placeholder="Project ID (可选)"
/>
<Button
onClick={handleFileSubmit}
disabled={loading || !credsFilePath}
variant="outline"
className="w-full"
>
{loading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
导入中...
</>
) : (
"导入凭证文件"
)}
</Button>
</div>
</div>
</TabsContent>
{/* API Key 认证 */}
<TabsContent value="api_key" className="space-y-4 mt-4">
<div className="rounded-lg border border-green-200 bg-green-50 p-4 dark:border-green-800 dark:bg-green-950/30">
<p className="text-sm text-green-700 dark:text-green-300">
使用 Google AI Studio 的 API Key 进行认证。
</p>
<p className="mt-2 text-xs text-green-600 dark:text-green-400">
从{" "}
<a
href="https://aistudio.google.com/app/apikey"
target="_blank"
rel="noopener noreferrer"
className="underline hover:no-underline"
>
Google AI Studio
</a>{" "}
获取 API Key。
</p>
</div>
<div className="space-y-3">
<div>
<label className="mb-1 block text-sm font-medium">
API Key <span className="text-red-500">*</span>
</label>
<Input
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="AIzaSy..."
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium">
Base URL (可选)
</label>
<Input
type="text"
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
placeholder="https://generativelanguage.googleapis.com"
/>
<p className="mt-1 text-xs text-muted-foreground">
留空使用官方 API
</p>
</div>
</div>
</TabsContent>
</Tabs>
{/* 错误提示 */}
{error && (
<div className="rounded-lg border border-red-300 bg-red-50 dark:bg-red-900/20 p-3 text-sm text-red-700 dark:text-red-300">
{error}
</div>
)}
{/* 按钮区域 */}
<div className="flex justify-end gap-2 pt-2">
{onCancel && (
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={loading || exchanging}
>
取消
</Button>
)}
{authMethod === "api_key" && (
<Button
type="button"
onClick={handleApiKeySubmit}
disabled={loading || !apiKey.trim()}
>
{loading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
添加中...
</>
) : (
"添加凭证"
)}
</Button>
)}
</div>
</div>
);
}
export default GeminiFormStandalone;
@@ -1,135 +0,0 @@
/**
* Kiro 凭证添加表单(自包含版本)
*
* 这是 KiroForm 的包装组件,内部管理所有状态,
* 适合在插件中独立使用。
*
* @module components/provider-pool/credential-forms/KiroFormStandalone
*/
import { useState, useCallback } from "react";
import { open } from "@tauri-apps/plugin-dialog";
import { KiroForm } from "./KiroForm";
import { Button } from "@/components/ui/button";
import { Loader2 } from "lucide-react";
/** Kiro 凭证文件默认路径 */
const KIRO_DEFAULT_CREDS_PATH = "~/.aws/sso/cache/kiro-auth-token.json";
interface KiroFormStandaloneProps {
/** 添加成功回调 */
onSuccess: () => void;
/** 取消回调 */
onCancel?: () => void;
/** 初始名称 */
initialName?: string;
}
/**
* 自包含的 Kiro 凭证添加表单
*
* 内部管理所有状态,只需要提供 onSuccess 和 onCancel 回调
*/
export function KiroFormStandalone({
onSuccess,
onCancel,
initialName = "",
}: KiroFormStandaloneProps) {
const [name, setName] = useState(initialName);
// 设置默认凭证文件路径
const [credsFilePath, setCredsFilePath] = useState(KIRO_DEFAULT_CREDS_PATH);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSelectFile = useCallback(async () => {
try {
const selected = await open({
multiple: false,
filters: [{ name: "JSON", extensions: ["json"] }],
});
if (selected) {
setCredsFilePath(selected as string);
}
} catch (e) {
console.error("Failed to open file dialog:", e);
}
}, []);
const handleSuccess = useCallback(() => {
onSuccess();
}, [onSuccess]);
// 使用 KiroForm hook 获取渲染函数和提交方法
const kiroForm = KiroForm({
name,
credsFilePath,
setCredsFilePath,
onSelectFile: handleSelectFile,
loading,
setLoading,
setError,
onSuccess: handleSuccess,
});
// 处理提交
const handleSubmit = useCallback(() => {
if (kiroForm.mode === "json") {
kiroForm.handleJsonSubmit();
} else if (kiroForm.mode === "file") {
kiroForm.handleFileSubmit();
}
}, [kiroForm]);
return (
<div className="space-y-4">
{/* 名称输入 */}
<div>
<label className="mb-1 block text-sm font-medium">名称 (可选)</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="给这个凭证起个名字..."
disabled={loading}
className="w-full rounded-lg border bg-background px-3 py-2 text-sm"
/>
</div>
{/* Kiro 表单内容 */}
{kiroForm.render()}
{/* 错误提示 */}
{error && (
<div className="rounded-lg border border-red-300 bg-red-50 dark:bg-red-900/20 p-3 text-sm text-red-700 dark:text-red-300">
{error}
</div>
)}
{/* 按钮区域 */}
<div className="flex justify-end gap-2 pt-2">
{onCancel && (
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={loading}
>
取消
</Button>
)}
<Button type="button" onClick={handleSubmit} disabled={loading}>
{loading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
添加中...
</>
) : (
"添加凭证"
)}
</Button>
</div>
</div>
);
}
export default KiroFormStandalone;
@@ -1,12 +0,0 @@
/**
* 凭证表单组件导出
*/
export * from "./types";
export * from "./ModeSelector";
export * from "./OAuthUrlDisplay";
export * from "./FileImportForm";
export * from "./AntigravityForm";
export * from "./CodexForm";
export * from "./ClaudeOAuthForm";
export * from "./GeminiForm";
-106
View File
@@ -1,106 +0,0 @@
/**
* @file ChatInput.tsx
* @description 聊天输入框组件,支持文本输入和发送
* @module components/smart-input/ChatInput
*/
import React, { useRef } from "react";
import { BaseComposer } from "@/components/input-kit";
import { CharacterMention } from "@/components/agent/chat/skill-selection/CharacterMention";
import { SkillBadge } from "@/components/agent/chat/skill-selection/SkillBadge";
import { useActiveSkill } from "@/components/agent/chat/skill-selection/useActiveSkill";
import type { ChatInputProps } from "./types";
/**
* 聊天输入框组件
*
* 提供文本输入框和发送按钮,支持 Enter 键发送
*
* 需求:
* - 4.3: 悬浮窗口应提供文本输入框供用户输入问题
* - 4.4: 当用户按下 Enter 或点击发送时,悬浮窗口应将图片和文本发送给 AI
*/
export const ChatInput: React.FC<ChatInputProps> = ({
value,
onChange,
onSend,
disabled = false,
isLoading = false,
placeholder = "输入问题...",
skills = [],
}) => {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const { activeSkill, setActiveSkill, wrapTextWithSkill, clearActiveSkill } =
useActiveSkill();
const handleSend = () => {
const text = activeSkill ? wrapTextWithSkill(value) : undefined;
onSend(text);
clearActiveSkill();
};
return (
<BaseComposer
text={value}
setText={onChange}
onSend={handleSend}
disabled={disabled || isLoading}
placeholder={placeholder}
textareaRef={textareaRef}
autoFocus
maxAutoHeight={80}
rows={1}
>
{({ textareaProps, onPrimaryAction, isPrimaryDisabled }) => (
<div className="smart-input-input-area">
{/* CharacterMention */}
{skills.length > 0 && (
<CharacterMention
characters={[]}
skills={skills}
inputRef={textareaRef}
value={value}
onChange={onChange}
onSelectSkill={setActiveSkill}
/>
)}
{/* Skill Badge */}
{activeSkill && (
<SkillBadge skill={activeSkill} onClear={clearActiveSkill} />
)}
<textarea
ref={textareaRef}
{...textareaProps}
className="smart-input-input resize-none"
/>
<button
className="smart-input-send-btn"
onClick={onPrimaryAction}
disabled={isPrimaryDisabled}
title="发送 (Enter)"
>
{isLoading ? (
<span
className="smart-input-loading-spinner"
style={{ width: 16, height: 16 }}
/>
) : (
<svg
className="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="22" y1="2" x2="11" y2="13" />
<polygon points="22 2 15 22 11 13 2 9 22 2" />
</svg>
)}
</button>
</div>
)}
</BaseComposer>
);
};
export default ChatInput;
-104
View File
@@ -1,104 +0,0 @@
/**
* @file ChatMessages.tsx
* @description 消息列表组件,显示用户和 AI 的对话消息
* @module components/smart-input/ChatMessages
*/
import React, { useRef, useEffect } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type { ChatMessagesProps, ChatMessage } from "./types";
import "./smart-input.css";
/**
* 单条消息组件
*/
const MessageItem: React.FC<{ message: ChatMessage }> = ({ message }) => {
const isUser = message.role === "user";
return (
<div
className={`smart-input-message ${isUser ? "smart-input-message-user" : "smart-input-message-assistant"}`}
>
{/* 用户消息显示图片 */}
{isUser && message.image && (
<div className="smart-input-message-image">
<img
src={`data:${message.image.mediaType};base64,${message.image.data}`}
alt="截图"
className="smart-input-message-thumbnail"
/>
</div>
)}
{/* 消息内容 */}
<div className="smart-input-message-content">
{message.isThinking ? (
<div className="smart-input-thinking">
<span className="smart-input-loading-spinner" />
<span>{message.thinkingContent || "思考中..."}</span>
</div>
) : isUser ? (
<p>{message.content}</p>
) : (
<div className="smart-input-markdown">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{message.content}
</ReactMarkdown>
</div>
)}
</div>
{/* 时间戳 */}
<div className="smart-input-message-time">
{new Date(message.timestamp).toLocaleTimeString("zh-CN", {
hour: "2-digit",
minute: "2-digit",
})}
</div>
</div>
);
};
/**
* 消息列表组件
*
* 显示用户消息和 AI 回复,支持 Markdown 渲染和自动滚动
*
* 需求:
* - 4.5: 悬浮窗口应在可滚动区域显示 AI 回复
* - 5.4: 当 AI 回复时,悬浮窗口应以 Markdown 格式渲染回复内容
*/
export const ChatMessages: React.FC<ChatMessagesProps> = ({
messages,
className = "",
}) => {
const containerRef = useRef<HTMLDivElement>(null);
// 自动滚动到底部
useEffect(() => {
if (containerRef.current) {
containerRef.current.scrollTop = containerRef.current.scrollHeight;
}
}, [messages]);
if (messages.length === 0) {
return (
<div className={`smart-input-messages ${className}`}>
<div className="smart-input-placeholder">
输入问题,开始与 AI 讨论截图内容
</div>
</div>
);
}
return (
<div ref={containerRef} className={`smart-input-messages ${className}`}>
{messages.map((message) => (
<MessageItem key={message.id} message={message} />
))}
</div>
);
};
export default ChatMessages;
-152
View File
@@ -1,152 +0,0 @@
# 截图对话组件 (screenshot-chat)
截图对话功能的前端组件模块,提供截图预览、对话输入、消息展示等功能。
## 文件索引
| 文件 | 描述 |
| -------------------------- | ----------------------------------- |
| `index.ts` | 模块导出入口 |
| `types.ts` | 类型定义(配置、消息、组件 Props) |
| `useScreenshotChat.ts` | 核心 Hook,管理消息、图片和 AI 通信 |
| `ScreenshotPreview.tsx` | 截图预览组件,支持缩放和拖拽查看 |
| `ChatInput.tsx` | 聊天输入框组件,支持 Enter 发送 |
| `ChatMessages.tsx` | 消息列表组件,支持 Markdown 渲染 |
| `ScreenshotChatWindow.tsx` | 悬浮窗主组件,组合所有子组件 |
| `ShortcutSettings.tsx` | 快捷键设置组件,支持录制模式 |
| `screenshot-chat.css` | 截图对话组件样式 |
## 组件说明
### ScreenshotChatWindow
悬浮窗主组件,组合截图预览、消息列表和输入框。
**功能特性:**
- 组合 ScreenshotPreview, ChatInput, ChatMessages
- 支持 ESC 键关闭窗口
- 支持窗口拖动(通过 header 区域)
- 显示错误信息和重试按钮
**Props:**
- `imagePath`: 截图文件路径
- `onClose`: 关闭窗口回调(可选)
### ScreenshotPreview
截图预览组件,用于在悬浮对话窗口中显示截图。
**功能特性:**
- 显示截图图片
- 支持滚轮缩放 (50% - 300%)
- 支持拖拽平移(放大后)
- 工具栏提供缩放和重置按钮
**Props:**
- `src`: 图片路径或 Base64 编码
- `alt`: 图片 alt 文本(可选)
- `className`: 自定义类名(可选)
- `maxHeight`: 最大高度,默认 300px(可选)
### ChatInput
聊天输入框组件。
**功能特性:**
- 文本输入框
- 发送按钮
- Enter 键发送支持
- 加载状态显示
**Props:**
- `value`: 输入框值
- `onChange`: 值变化回调
- `onSend`: 发送消息回调
- `disabled`: 是否禁用(可选)
- `isLoading`: 是否正在加载(可选)
- `placeholder`: 占位符文本(可选)
### ChatMessages
消息列表组件。
**功能特性:**
- 显示用户消息和 AI 回复
- Markdown 渲染支持
- 自动滚动到最新消息
- 显示消息时间戳
**Props:**
- `messages`: 消息列表
- `className`: 自定义类名(可选)
### ShortcutSettings
快捷键设置组件,用于在设置页面中配置截图快捷键。
**功能特性:**
- 显示当前快捷键(用户友好格式)
- 快捷键录制模式
- 保存/取消按钮
- 错误提示
**Props:**
- `currentShortcut`: 当前快捷键
- `onShortcutChange`: 快捷键变更回调
- `onValidate`: 验证快捷键回调(可选)
- `disabled`: 是否禁用(可选)
## Hook 说明
### useScreenshotChat
核心 Hook,管理截图对话的状态和 AI 通信。
**返回值:**
- `messages`: 消息列表
- `isLoading`: 是否正在加载
- `error`: 错误信息
- `imagePath`: 当前截图路径
- `imageBase64`: 当前截图的 Base64 编码
- `sendMessage(message)`: 发送消息到 AI
- `setImagePath(path)`: 设置截图路径
- `clearMessages()`: 清空消息历史
- `clearError()`: 清除错误
- `retry()`: 重试上一条消息
## 依赖关系
- 使用项目统一的 CSS 变量(terminal 主题)
- 使用 `@tauri-apps/api/core` 进行 Tauri 通信
- 使用 `react-markdown` 和 `remark-gfm` 进行 Markdown 渲染
- 使用 `@/lib/api/agentRuntime` 发起 Agent/Aster 请求
- 使用 `@/lib/api/agentProtocol` 解析 AI 流式事件
- 使用 `@/lib/api/project` 获取默认工作区
## 相关需求
- 需求 4.1: 悬浮窗口以无边框、置顶的方式打开
- 需求 4.2: 悬浮窗口应显示截图预览
- 需求 4.3: 悬浮窗口应提供文本输入框
- 需求 4.4: 支持 Enter 键发送
- 需求 4.5: 在可滚动区域显示 AI 回复
- 需求 4.6: 支持 ESC 关闭
- 需求 4.7: 支持窗口拖动
- 需求 5.1: 将图片编码为 base64
- 需求 5.2: 使用现有的 Agent API 进行 AI 通信
- 需求 5.3: 显示加载指示器
- 需求 5.4: 以 Markdown 格式渲染回复内容
- 需求 5.5: 显示错误信息并提供重试选项
- 需求 6.3: 显示当前快捷键和修改按钮
- 需求 6.4: 支持快捷键录制模式
@@ -1,174 +0,0 @@
/**
* @file SmartInputPreview.tsx
* @description 截图预览组件,用于悬浮对话窗口中显示截图
* @module components/smart-input/SmartInputPreview
*/
import React, { useState, useCallback } from "react";
/** 截图预览属性 */
export interface SmartInputPreviewProps {
/** 图片路径或 Base64 编码 */
src: string;
/** 图片 alt 文本 */
alt?: string;
/** 自定义类名 */
className?: string;
/** 最大高度 */
maxHeight?: number;
}
/**
* 截图预览组件
* 支持缩放和拖拽查看截图
*/
export const SmartInputPreview: React.FC<SmartInputPreviewProps> = ({
src,
alt = "截图预览",
className = "",
maxHeight = 300,
}) => {
const [scale, setScale] = useState(1);
const [position, setPosition] = useState({ x: 0, y: 0 });
const [isDragging, setIsDragging] = useState(false);
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
// 滚轮缩放
const handleWheel = useCallback((e: React.WheelEvent) => {
e.preventDefault();
const delta = e.deltaY > 0 ? -0.1 : 0.1;
setScale((prev) => Math.max(0.5, Math.min(3, prev + delta)));
}, []);
// 拖拽开始
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
if (scale > 1) {
setIsDragging(true);
setDragStart({ x: e.clientX - position.x, y: e.clientY - position.y });
}
},
[scale, position],
);
// 拖拽中
const handleMouseMove = useCallback(
(e: React.MouseEvent) => {
if (isDragging) {
setPosition({
x: e.clientX - dragStart.x,
y: e.clientY - dragStart.y,
});
}
},
[isDragging, dragStart],
);
// 拖拽结束
const handleMouseUp = useCallback(() => {
setIsDragging(false);
}, []);
// 重置缩放和位置
const handleReset = useCallback(() => {
setScale(1);
setPosition({ x: 0, y: 0 });
}, []);
// 放大
const handleZoomIn = useCallback(() => {
setScale((prev) => Math.min(3, prev + 0.25));
}, []);
// 缩小
const handleZoomOut = useCallback(() => {
setScale((prev) => Math.max(0.5, prev - 0.25));
}, []);
return (
<div className={`screenshot-preview ${className}`}>
{/* 工具栏 */}
<div className="screenshot-preview-toolbar">
<button
onClick={handleZoomOut}
title="缩小"
className="screenshot-preview-btn"
>
<svg
className="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
<line x1="8" y1="11" x2="14" y2="11" />
</svg>
</button>
<span className="screenshot-preview-scale">
{Math.round(scale * 100)}%
</span>
<button
onClick={handleZoomIn}
title="放大"
className="screenshot-preview-btn"
>
<svg
className="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
<line x1="11" y1="8" x2="11" y2="14" />
<line x1="8" y1="11" x2="14" y2="11" />
</svg>
</button>
<button
onClick={handleReset}
title="重置"
className="screenshot-preview-btn"
>
<svg
className="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="1 4 1 10 7 10" />
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" />
</svg>
</button>
</div>
{/* 图片容器 */}
<div
className="screenshot-preview-container"
style={{ maxHeight: `${maxHeight}px` }}
onWheel={handleWheel}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
>
<img
src={src}
alt={alt}
className="screenshot-preview-image"
style={{
transform: `translate(${position.x}px, ${position.y}px) scale(${scale})`,
transition: isDragging ? "none" : "transform 0.1s ease",
cursor: scale > 1 ? (isDragging ? "grabbing" : "grab") : "default",
}}
draggable={false}
/>
</div>
</div>
);
};
export default SmartInputPreview;
@@ -1,184 +0,0 @@
/**
* @file SmartInputWindow.tsx
* @description 截图对话悬浮窗主组件
* @module components/smart-input/SmartInputWindow
*/
import React, { useState, useEffect, useCallback } from "react";
import { closeScreenshotChatWindow } from "@/lib/api/screenshotChat";
import { SmartInputPreview } from "./SmartInputPreview";
import { ChatInput } from "./ChatInput";
import { ChatMessages } from "./ChatMessages";
import { useSmartInput } from "./useSmartInput";
import type { SmartInputWindowProps } from "./types";
import { skillsApi, type Skill } from "@/lib/api/skills";
import "./smart-input.css";
/**
* 截图对话悬浮窗主组件
*
* 组合截图预览、消息列表和输入框,提供完整的对话界面
*
* 需求:
* - 4.1: 当截图完成时,悬浮窗口应以无边框、置顶的方式打开
* - 4.6: 当用户按下 ESC 或点击窗口外部时,悬浮窗口应关闭
* - 4.7: 悬浮窗口应支持用户拖动
*/
export const SmartInputWindow: React.FC<SmartInputWindowProps> = ({
imagePath,
onClose,
}) => {
const [inputValue, setInputValue] = useState("");
const [skills, setSkills] = useState<Skill[]>([]);
const {
messages,
isLoading,
error,
imageBase64,
sendMessage,
setImagePath,
clearError,
retry,
} = useSmartInput();
// 加载图片
useEffect(() => {
if (imagePath) {
setImagePath(imagePath);
}
}, [imagePath, setImagePath]);
// 加载技能列表
useEffect(() => {
skillsApi
.getAll("lime")
.then(setSkills)
.catch((err) => console.error("加载技能列表失败:", err));
}, []);
// 处理关闭窗口
const handleClose = useCallback(async () => {
try {
await closeScreenshotChatWindow();
} catch (err) {
console.error("关闭窗口失败:", err);
}
onClose?.();
}, [onClose]);
// 处理 ESC 键关闭
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
handleClose();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [handleClose]);
// 处理发送消息
const handleSend = useCallback(
async (textOverride?: string) => {
const message = textOverride || inputValue;
if (!message.trim()) return;
setInputValue("");
await sendMessage(message);
},
[inputValue, sendMessage],
);
// 构建图片 src
const imageSrc = imageBase64
? `data:image/png;base64,${imageBase64}`
: imagePath;
return (
<div className="smart-input-page">
{/* 窗口头部 - 可拖动区域 */}
<div className="smart-input-header">
<span className="smart-input-title">截图对话</span>
<button
className="smart-input-close-btn"
onClick={handleClose}
title="关闭 (ESC)"
>
<svg
className="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
{/* 截图预览区域 */}
{imageSrc && (
<div className="smart-input-preview">
<SmartInputPreview src={imageSrc} maxHeight={200} />
</div>
)}
{/* 对话区域 */}
<div className="smart-input-conversation">
{/* 错误提示 */}
{error && (
<div className="smart-input-error">
<div className="smart-input-error-content">
<p style={{ color: "#f43f5e", marginBottom: 8 }}>{error}</p>
<button className="smart-input-retry-btn" onClick={retry}>
<svg
className="w-3 h-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="1 4 1 10 7 10" />
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" />
</svg>
重试
</button>
<button
className="smart-input-retry-btn"
onClick={clearError}
style={{ marginLeft: 8 }}
>
关闭
</button>
</div>
</div>
)}
{/* 消息列表 */}
<ChatMessages messages={messages} />
{/* 输入区域 */}
<ChatInput
value={inputValue}
onChange={setInputValue}
onSend={handleSend}
isLoading={isLoading}
disabled={!imageBase64}
placeholder={imageBase64 ? "输入问题..." : "正在加载图片..."}
skills={skills}
/>
</div>
{/* 调试信息(开发模式) */}
{import.meta.env.DEV && (
<div className="smart-input-debug">
路径: {imagePath} | Base64: {imageBase64 ? "已加载" : "未加载"} |
消息数: {messages.length}
</div>
)}
</div>
);
};
export default SmartInputWindow;
-33
View File
@@ -1,33 +0,0 @@
/**
* @file index.ts
* @description 截图对话模块导出入口
* @module components/smart-input
*/
// 类型导出
export type {
SmartInputConfig,
MessageImage,
ChatMessage,
SmartInputState,
UseSmartInputReturn,
SmartInputPreviewProps,
ChatInputProps,
ChatMessagesProps,
SmartInputWindowProps,
} from "./types";
export type { ShortcutSettingsProps } from "./ShortcutSettings";
// 组件导出
export { SmartInputPreview } from "./SmartInputPreview";
export { ChatInput } from "./ChatInput";
export { ChatMessages } from "./ChatMessages";
export { SmartInputWindow } from "./SmartInputWindow";
export { ShortcutSettings } from "./ShortcutSettings";
// Hook 导出
export { useSmartInput, readImageAsBase64 } from "./useSmartInput";
// 默认导出主组件
export { SmartInputWindow as default } from "./SmartInputWindow";
-643
View File
@@ -1,643 +0,0 @@
/**
* @file smart-input.css
* @description 截图对话组件样式 - 参考 Claude 的简洁设计
* @module components/smart-input
*/
/* ============================================================================
* 悬浮输入框容器
* ============================================================================ */
.screenshot-floating-container {
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
padding-bottom: 80px;
background: transparent;
}
/* 背景遮罩 - 半透明模糊效果 */
.screenshot-floating-backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.3);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}
/* ============================================================================
* 悬浮输入框
* ============================================================================ */
.screenshot-floating-input-wrapper {
position: relative;
z-index: 10;
display: flex;
align-items: center;
gap: 8px;
width: 600px;
max-width: calc(100vw - 40px);
padding: 8px 12px;
background: white;
border-radius: 24px;
box-shadow:
0 4px 24px rgba(0, 0, 0, 0.15),
0 0 0 1px rgba(0, 0, 0, 0.05);
}
/* 图片标签按钮 */
.screenshot-image-tag {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 10px;
background: #f3f4f6;
border: none;
border-radius: 16px;
color: #6b7280;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
}
.screenshot-image-tag:hover {
background: #e5e7eb;
color: #374151;
}
.screenshot-image-tag svg {
color: #9ca3af;
}
/* 输入框 */
.screenshot-floating-input {
flex: 1;
padding: 8px 4px;
border: none;
background: transparent;
font-size: 15px;
color: #1f2937;
outline: none;
}
.screenshot-floating-input::placeholder {
color: #9ca3af;
}
.screenshot-floating-input:disabled {
opacity: 0.6;
}
/* 右侧按钮组 */
.screenshot-floating-actions {
display: flex;
align-items: center;
gap: 8px;
}
/* 下拉按钮 */
.screenshot-action-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 10px;
background: transparent;
border: none;
border-radius: 8px;
color: #6b7280;
font-size: 13px;
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
}
.screenshot-action-btn:hover {
background: #f3f4f6;
color: #374151;
}
/* 发送按钮 */
.screenshot-send-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: #d1d5db;
border: none;
border-radius: 50%;
color: white;
cursor: not-allowed;
transition: all 0.15s ease;
}
.screenshot-send-btn.active {
background: #f97316;
cursor: pointer;
}
.screenshot-send-btn.active:hover {
background: #ea580c;
}
/* ============================================================================
* 图片预览弹窗
* ============================================================================ */
.screenshot-preview-modal {
position: fixed;
z-index: 100;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
max-width: 90vw;
max-height: 80vh;
background: white;
border-radius: 12px;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
overflow: hidden;
}
.screenshot-preview-close {
position: absolute;
top: 8px;
right: 8px;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: rgba(0, 0, 0, 0.5);
border: none;
border-radius: 50%;
color: white;
cursor: pointer;
transition: all 0.15s ease;
}
.screenshot-preview-close:hover {
background: rgba(0, 0, 0, 0.7);
}
.screenshot-preview-image {
display: block;
max-width: 90vw;
max-height: 80vh;
object-fit: contain;
}
/* ============================================================================
* 深色模式支持
* ============================================================================ */
@media (prefers-color-scheme: dark) {
.screenshot-floating-input-wrapper {
background: #1f2937;
box-shadow:
0 4px 24px rgba(0, 0, 0, 0.4),
0 0 0 1px rgba(255, 255, 255, 0.1);
}
.screenshot-image-tag {
background: #374151;
color: #d1d5db;
}
.screenshot-image-tag:hover {
background: #4b5563;
color: #f3f4f6;
}
.screenshot-image-tag svg {
color: #9ca3af;
}
.screenshot-floating-input {
color: #f3f4f6;
}
.screenshot-floating-input::placeholder {
color: #6b7280;
}
.screenshot-action-btn {
color: #9ca3af;
}
.screenshot-action-btn:hover {
background: #374151;
color: #f3f4f6;
}
.screenshot-send-btn {
background: #4b5563;
}
.screenshot-preview-modal {
background: #1f2937;
}
}
/* ============================================================================
* 旧版样式保留(兼容)
* ============================================================================ */
.screenshot-preview {
display: flex;
flex-direction: column;
border-radius: 8px;
overflow: hidden;
background-color: var(--terminal-bg, #1a1b26);
border: 1px solid var(--terminal-border, #3b4261);
}
.screenshot-preview-toolbar {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 6px 8px;
background-color: var(--terminal-tab-bg, #24283b);
border-bottom: 1px solid var(--terminal-border, #3b4261);
}
.screenshot-preview-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 4px;
color: var(--terminal-muted, #565f89);
background: transparent;
border: none;
cursor: pointer;
transition: all 0.15s ease;
}
.screenshot-preview-btn:hover {
background-color: var(--terminal-tab-hover-bg, #414868);
color: var(--terminal-fg, #c0caf5);
}
.screenshot-preview-scale {
min-width: 50px;
text-align: center;
font-size: 12px;
color: var(--terminal-muted, #565f89);
font-variant-numeric: tabular-nums;
}
.screenshot-preview-container {
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
background-color: var(--terminal-bg, #1a1b26);
}
/* ============================================================================
* 截图对话页面
* ============================================================================ */
.smart-input-page {
display: flex;
flex-direction: column;
height: 100vh;
width: 100vw;
background-color: var(--terminal-bg, #1a1b26);
color: var(--terminal-fg, #c0caf5);
overflow: hidden;
}
/* 窗口头部 - 拖动区域 */
.smart-input-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background-color: var(--terminal-tab-bg, #24283b);
border-bottom: 1px solid var(--terminal-border, #3b4261);
-webkit-app-region: drag;
user-select: none;
}
.smart-input-title {
font-size: 13px;
font-weight: 500;
color: var(--terminal-fg, #c0caf5);
}
.smart-input-close-btn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 4px;
color: var(--terminal-muted, #565f89);
background: transparent;
border: none;
cursor: pointer;
transition: all 0.15s ease;
-webkit-app-region: no-drag;
}
.smart-input-close-btn:hover {
background-color: #f43f5e;
color: white;
}
/* 截图预览区域 */
.smart-input-preview {
padding: 12px;
border-bottom: 1px solid var(--terminal-border, #3b4261);
}
/* 对话区域 */
.smart-input-conversation {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.smart-input-messages {
flex: 1;
overflow-y: auto;
padding: 12px;
}
.smart-input-placeholder {
text-align: center;
color: var(--terminal-muted, #565f89);
font-size: 13px;
padding: 24px;
}
/* 输入区域 */
.smart-input-input-area {
display: flex;
gap: 8px;
padding: 12px;
border-top: 1px solid var(--terminal-border, #3b4261);
background-color: var(--terminal-tab-bg, #24283b);
}
.smart-input-input {
flex: 1;
padding: 8px 12px;
border-radius: 6px;
border: 1px solid var(--terminal-border, #3b4261);
background-color: var(--terminal-bg, #1a1b26);
color: var(--terminal-fg, #c0caf5);
font-size: 13px;
outline: none;
transition: border-color 0.15s ease;
}
.smart-input-input:focus {
border-color: var(--terminal-accent, #7aa2f7);
}
.smart-input-input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.smart-input-send-btn {
padding: 8px 16px;
border-radius: 6px;
border: none;
background-color: var(--terminal-accent, #7aa2f7);
color: white;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
}
.smart-input-send-btn:hover:not(:disabled) {
background-color: var(--terminal-accent-hover, #5d8bea);
}
.smart-input-send-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* 错误状态 */
.smart-input-error {
display: flex;
align-items: center;
justify-content: center;
}
.smart-input-error-content {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: 24px;
}
/* 加载状态 */
.smart-input-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
color: var(--terminal-muted, #565f89);
}
.smart-input-loading-spinner {
width: 32px;
height: 32px;
border: 3px solid var(--terminal-border, #3b4261);
border-top-color: var(--terminal-accent, #7aa2f7);
border-radius: 50%;
animation: smart-input-spin 0.8s linear infinite;
}
@keyframes smart-input-spin {
to {
transform: rotate(360deg);
}
}
/* 调试信息 */
.smart-input-debug {
padding: 4px 12px;
background-color: var(--terminal-tab-bg, #24283b);
border-top: 1px solid var(--terminal-border, #3b4261);
font-size: 10px;
color: var(--terminal-muted, #565f89);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ============================================================================
* 消息列表组件
* ============================================================================ */
.smart-input-message {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 16px;
padding: 8px 12px;
border-radius: 8px;
max-width: 85%;
}
.smart-input-message-user {
align-self: flex-end;
background-color: var(--terminal-accent, #7aa2f7);
color: white;
}
.smart-input-message-assistant {
align-self: flex-start;
background-color: var(--terminal-tab-bg, #24283b);
border: 1px solid var(--terminal-border, #3b4261);
}
.smart-input-message-image {
margin-bottom: 8px;
}
.smart-input-message-thumbnail {
max-width: 120px;
max-height: 80px;
border-radius: 4px;
object-fit: cover;
border: 1px solid rgba(255, 255, 255, 0.2);
}
.smart-input-message-content {
font-size: 13px;
line-height: 1.5;
}
.smart-input-message-content p {
margin: 0;
}
.smart-input-message-time {
font-size: 10px;
opacity: 0.6;
align-self: flex-end;
}
/* 思考中状态 */
.smart-input-thinking {
display: flex;
align-items: center;
gap: 8px;
color: var(--terminal-muted, #565f89);
font-style: italic;
}
.smart-input-thinking .smart-input-loading-spinner {
width: 14px;
height: 14px;
border-width: 2px;
}
/* Markdown 渲染样式 */
.smart-input-markdown {
font-size: 13px;
line-height: 1.6;
}
.smart-input-markdown p {
margin: 0 0 8px 0;
}
.smart-input-markdown p:last-child {
margin-bottom: 0;
}
.smart-input-markdown code {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
padding: 2px 4px;
border-radius: 3px;
background-color: rgba(0, 0, 0, 0.2);
}
.smart-input-markdown pre {
margin: 8px 0;
padding: 8px;
border-radius: 4px;
background-color: rgba(0, 0, 0, 0.3);
overflow-x: auto;
}
.smart-input-markdown pre code {
padding: 0;
background: transparent;
}
.smart-input-markdown ul,
.smart-input-markdown ol {
margin: 8px 0;
padding-left: 20px;
}
.smart-input-markdown li {
margin-bottom: 4px;
}
.smart-input-markdown strong {
font-weight: 600;
}
.smart-input-markdown em {
font-style: italic;
}
.smart-input-markdown a {
color: var(--terminal-accent, #7aa2f7);
text-decoration: none;
}
.smart-input-markdown a:hover {
text-decoration: underline;
}
.smart-input-markdown blockquote {
margin: 8px 0;
padding-left: 12px;
border-left: 3px solid var(--terminal-accent, #7aa2f7);
color: var(--terminal-muted, #565f89);
}
/* 重试按钮 */
.smart-input-retry-btn {
display: inline-flex;
align-items: center;
gap: 4px;
margin-top: 8px;
padding: 4px 8px;
border-radius: 4px;
border: 1px solid var(--terminal-border, #3b4261);
background: transparent;
color: var(--terminal-muted, #565f89);
font-size: 12px;
cursor: pointer;
transition: all 0.15s ease;
}
.smart-input-retry-btn:hover {
background-color: var(--terminal-tab-hover-bg, #414868);
color: var(--terminal-fg, #c0caf5);
}
-156
View File
@@ -1,156 +0,0 @@
import type { Skill } from "@/lib/api/skills";
/**
* @file types.ts
* @description 截图对话模块类型定义
* @module components/smart-input/types
*/
// ============================================================
// 配置类型
// ============================================================
/**
* 截图对话功能配置
* 需求: 1.1 - 实验室功能应提供 screenshot_chat.enabled 布尔开关
*/
export interface SmartInputConfig {
/** 是否启用截图对话功能 */
enabled: boolean;
/** 触发截图的全局快捷键 */
shortcut: string;
}
// ============================================================
// 消息类型
// ============================================================
/**
* 消息图片
* 需求: 5.1 - 截图对话模块应将图片编码为 base64
*/
export interface MessageImage {
/** Base64 编码的图片数据 */
data: string;
/** 媒体类型,如 "image/png" */
mediaType: string;
}
/**
* 聊天消息
* 需求: 4.5 - 悬浮窗口应在可滚动区域显示 AI 回复
*/
export interface ChatMessage {
/** 消息唯一标识 */
id: string;
/** 消息角色:用户或助手 */
role: "user" | "assistant";
/** 消息文本内容 */
content: string;
/** 附带的图片(用户消息可能包含截图) */
image?: MessageImage;
/** 消息时间戳 */
timestamp: number;
/** 是否正在思考中(助手消息) */
isThinking?: boolean;
/** 思考中的提示文本 */
thinkingContent?: string;
}
// ============================================================
// Hook 状态类型
// ============================================================
/**
* 截图对话 Hook 状态
*/
export interface SmartInputState {
/** 消息列表 */
messages: ChatMessage[];
/** 是否正在加载 */
isLoading: boolean;
/** 错误信息 */
error: string | null;
/** 当前截图路径 */
imagePath: string | null;
/** 当前截图的 Base64 编码 */
imageBase64: string | null;
}
/**
* 截图对话 Hook 返回值
*/
export interface UseSmartInputReturn extends SmartInputState {
/** 发送消息到 AI */
sendMessage: (message: string) => Promise<void>;
/** 设置截图路径 */
setImagePath: (path: string) => void;
/** 清空消息历史 */
clearMessages: () => void;
/** 清除错误 */
clearError: () => void;
/** 重试上一条消息 */
retry: () => Promise<void>;
}
// ============================================================
// 组件 Props 类型
// ============================================================
/**
* 截图预览组件属性
* 需求: 4.2 - 悬浮窗口应显示截图预览
*/
export interface SmartInputPreviewProps {
/** 图片路径或 Base64 编码 */
src: string;
/** 图片 alt 文本 */
alt?: string;
/** 自定义类名 */
className?: string;
/** 最大高度 */
maxHeight?: number;
}
/**
* 聊天输入框组件属性
* 需求: 4.3, 4.4 - 悬浮窗口应提供文本输入框,支持 Enter 发送
*/
export interface ChatInputProps {
/** 输入框值 */
value: string;
/** 值变化回调 */
onChange: (value: string) => void;
/** 发送消息回调(可接受 textOverride) */
onSend: (textOverride?: string) => void;
/** 是否禁用 */
disabled?: boolean;
/** 是否正在加载 */
isLoading?: boolean;
/** 占位符文本 */
placeholder?: string;
/** 技能列表 */
skills?: Skill[];
}
/**
* 消息列表组件属性
* 需求: 4.5, 5.4 - 显示 AI 回复,支持 Markdown 渲染
*/
export interface ChatMessagesProps {
/** 消息列表 */
messages: ChatMessage[];
/** 自定义类名 */
className?: string;
}
/**
* 悬浮窗主组件属性
* 需求: 4.1, 4.6, 4.7 - 无边框置顶窗口,支持 ESC 关闭和拖动
*/
export interface SmartInputWindowProps {
/** 截图路径 */
imagePath: string;
/** 关闭窗口回调 */
onClose?: () => void;
}
-336
View File
@@ -1,336 +0,0 @@
/**
* @file useSmartInput.ts
* @description 截图对话核心 Hook,管理消息、图片和 AI 通信
* @module components/smart-input/useSmartInput
*/
import { useState, useCallback, useRef } from "react";
import { safeInvoke, safeListen } from "@/lib/dev-bridge";
import type { UnlistenFn } from "@tauri-apps/api/event";
import { toast } from "sonner";
import { requireDefaultProjectId } from "@/lib/api/project";
import {
createAgentRuntimeSession,
submitAgentRuntimeTurn,
} from "@/lib/api/agentRuntime";
import type { ChatMessage, MessageImage, UseSmartInputReturn } from "./types";
import {
createSubmitTurnRequestFromAgentOp,
parseAgentEvent,
type AgentEvent,
} from "@/lib/api/agentProtocol";
const DEFAULT_SMART_INPUT_PROVIDER = "claude";
const DEFAULT_SMART_INPUT_MODEL = "claude-sonnet-4-5";
/**
* 读取图片文件并转换为 Base64
* 需求: 5.1 - 截图对话模块应将图片编码为 base64
*
* @param imagePath - 图片文件路径
* @returns Base64 编码的图片数据
*/
export async function readImageAsBase64(imagePath: string): Promise<string> {
try {
const base64 = await safeInvoke<string>("read_image_as_base64", {
path: imagePath,
});
return base64;
} catch (error) {
console.error("读取图片失败:", error);
throw new Error(`读取图片失败: ${error}`);
}
}
/**
* 截图对话 Hook
*
* 提供截图对话功能的核心状态管理和 AI 通信能力
*
* 需求:
* - 5.1: 将图片编码为 base64
* - 5.2: 使用现有的 Agent API 进行 AI 通信
* - 5.3: 显示加载指示器
* - 5.5: 显示错误信息并提供重试选项
*/
export function useSmartInput(): UseSmartInputReturn {
// 状态
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [imagePath, setImagePathState] = useState<string | null>(null);
const [imageBase64, setImageBase64] = useState<string | null>(null);
// 用于重试的最后一条消息
const lastMessageRef = useRef<string | null>(null);
// 会话 ID
const sessionIdRef = useRef<string | null>(null);
const workspaceIdRef = useRef<string | null>(null);
/**
* 设置截图路径并加载图片
*/
const setImagePath = useCallback(async (path: string) => {
setImagePathState(path);
setError(null);
try {
const base64 = await readImageAsBase64(path);
setImageBase64(base64);
} catch (err) {
const errorMsg = err instanceof Error ? err.message : "加载图片失败";
setError(errorMsg);
toast.error(errorMsg);
}
}, []);
/**
* 创建或获取会话
*/
const ensureSession = useCallback(async (): Promise<string | null> => {
if (sessionIdRef.current) {
return sessionIdRef.current;
}
try {
let workspaceId = workspaceIdRef.current;
if (!workspaceId) {
workspaceId =
await requireDefaultProjectId("未找到默认工作区,请先创建或选择项目");
workspaceIdRef.current = workspaceId;
}
const createdSessionId = await createAgentRuntimeSession(
workspaceId,
);
sessionIdRef.current = createdSessionId;
return createdSessionId;
} catch (err) {
console.error("创建会话失败:", err);
return null;
}
}, []);
/**
* 发送消息到 AI
* 需求: 5.2 - 使用现有的 Agent API 进行 AI 通信
*/
const sendMessage = useCallback(
async (message: string) => {
if (!message.trim()) return;
if (!imageBase64) {
setError("请先加载截图");
return;
}
// 保存消息用于重试
lastMessageRef.current = message;
setError(null);
setIsLoading(true);
// 创建用户消息
const userMsg: ChatMessage = {
id: crypto.randomUUID(),
role: "user",
content: message,
image:
messages.length === 0
? { data: imageBase64, mediaType: "image/png" }
: undefined,
timestamp: Date.now(),
};
// 创建助手消息占位符
const assistantMsgId = crypto.randomUUID();
const assistantMsg: ChatMessage = {
id: assistantMsgId,
role: "assistant",
content: "",
timestamp: Date.now(),
isThinking: true,
thinkingContent: "思考中...",
};
setMessages((prev) => [...prev, userMsg, assistantMsg]);
let accumulatedContent = "";
let unlisten: UnlistenFn | null = null;
try {
// 确保有会话
const sessionId = await ensureSession();
if (!sessionId) {
throw new Error("无法创建会话");
}
// 创建唯一事件名称
const eventName = `screenshot_chat_stream_${assistantMsgId}`;
// 设置事件监听器
unlisten = await safeListen<AgentEvent>(eventName, (event) => {
const data = parseAgentEvent(event.payload);
if (!data) return;
switch (data.type) {
case "text_delta":
accumulatedContent += data.text;
setMessages((prev) =>
prev.map((msg) =>
msg.id === assistantMsgId
? {
...msg,
content: accumulatedContent,
isThinking: false,
thinkingContent: undefined,
}
: msg,
),
);
break;
case "done":
case "final_done":
setMessages((prev) =>
prev.map((msg) =>
msg.id === assistantMsgId
? {
...msg,
isThinking: false,
content: accumulatedContent || "(无响应)",
}
: msg,
),
);
setIsLoading(false);
if (unlisten) {
unlisten();
unlisten = null;
}
break;
case "error":
setError(data.message);
setMessages((prev) =>
prev.map((msg) =>
msg.id === assistantMsgId
? {
...msg,
isThinking: false,
content: `错误: ${data.message}`,
}
: msg,
),
);
setIsLoading(false);
if (unlisten) {
unlisten();
unlisten = null;
}
break;
}
});
// 准备图片数据(只在第一条消息时发送图片)
const images: MessageImage[] =
messages.length === 0
? [{ data: imageBase64, mediaType: "image/png" }]
: [];
const workspaceId = workspaceIdRef.current;
if (!workspaceId) {
throw new Error("缺少默认工作区,无法发送截图对话请求");
}
// 发送流式请求(使用 Aster Agent)
await submitAgentRuntimeTurn(
createSubmitTurnRequestFromAgentOp({
type: "user_input",
text: message,
sessionId,
eventName,
workspaceId,
images:
images.length > 0
? images.map((img) => ({
data: img.data,
media_type: img.mediaType,
}))
: undefined,
preferences: {
providerPreference: DEFAULT_SMART_INPUT_PROVIDER,
modelPreference: DEFAULT_SMART_INPUT_MODEL,
},
}),
);
} catch (err) {
console.error("发送消息失败:", err);
const errorMsg = err instanceof Error ? err.message : "发送失败";
setError(errorMsg);
toast.error(errorMsg);
// 移除失败的助手消息
setMessages((prev) => prev.filter((msg) => msg.id !== assistantMsgId));
setIsLoading(false);
if (unlisten) {
unlisten();
}
}
},
[imageBase64, messages.length, ensureSession],
);
/**
* 清空消息历史
*/
const clearMessages = useCallback(() => {
setMessages([]);
sessionIdRef.current = null;
lastMessageRef.current = null;
}, []);
/**
* 清除错误
*/
const clearError = useCallback(() => {
setError(null);
}, []);
/**
* 重试上一条消息
* 需求: 5.5 - 显示错误信息并提供重试选项
*/
const retry = useCallback(async () => {
if (lastMessageRef.current) {
// 移除最后一条失败的助手消息
setMessages((prev) => {
const lastMsg = prev[prev.length - 1];
if (
lastMsg?.role === "assistant" &&
lastMsg.content.startsWith("错误:")
) {
return prev.slice(0, -1);
}
return prev;
});
await sendMessage(lastMessageRef.current);
}
}, [sendMessage]);
return {
messages,
isLoading,
error,
imagePath,
imageBase64,
sendMessage,
setImagePath,
clearMessages,
clearError,
retry,
};
}
export default useSmartInput;
@@ -1,176 +0,0 @@
/**
* SubAgent 执行进度组件
*
* 显示 SubAgent 调度器的执行进度
*/
import React from "react";
import {
SchedulerProgress,
SchedulerEvent,
} from "@/hooks/useSubAgentScheduler";
interface SubAgentProgressProps {
progress: SchedulerProgress | null;
events: SchedulerEvent[];
isRunning: boolean;
onCancel?: () => void;
}
/**
* 进度条组件
*/
const ProgressBar: React.FC<{ percentage: number; className?: string }> = ({
percentage,
className = "",
}) => (
<div className={`w-full bg-gray-200 rounded-full h-2.5 ${className}`}>
<div
className="bg-blue-600 h-2.5 rounded-full transition-all duration-300"
style={{ width: `${Math.min(100, percentage)}%` }}
/>
</div>
);
/**
* 状态徽章
*/
const StatusBadge: React.FC<{ status: string; count: number }> = ({
status,
count,
}) => {
const colors: Record<string, string> = {
completed: "bg-green-100 text-green-800",
failed: "bg-red-100 text-red-800",
running: "bg-blue-100 text-blue-800",
pending: "bg-gray-100 text-gray-800",
skipped: "bg-yellow-100 text-yellow-800",
};
return (
<span
className={`px-2 py-1 text-xs font-medium rounded ${colors[status] || colors.pending}`}
>
{status}: {count}
</span>
);
};
/**
* 事件日志项
*/
const EventLogItem: React.FC<{ event: SchedulerEvent }> = ({ event }) => {
const getEventContent = () => {
switch (event.type) {
case "started":
return `🚀 开始执行 ${event.totalTasks} 个任务`;
case "queueRejected":
return `🚫 任务被拒绝: 请求 ${event.requested},队列上限 ${event.limit}`;
case "taskStarted":
return `▶️ 任务 ${event.taskId} (${event.taskType}) 开始`;
case "taskCompleted":
return `✅ 任务 ${event.taskId} 完成 (${event.durationMs}ms)`;
case "taskTimedOut":
return `⏱️ 任务 ${event.taskId} 超时 (${event.timeoutMs}ms)`;
case "taskFailed":
return `❌ 任务 ${event.taskId} 失败: ${event.error}`;
case "taskRetry":
return `🔄 任务 ${event.taskId} 重试 #${event.retryCount}`;
case "taskSkipped":
return `⏭️ 任务 ${event.taskId} 跳过: ${event.reason}`;
case "completed":
return `🏁 执行${event.success ? "成功" : "失败"} (${event.durationMs}ms)`;
case "cancelled":
return `🛑 执行已取消`;
default:
return null;
}
};
const content = getEventContent();
if (!content) return null;
return <div className="text-sm text-gray-600 py-1">{content}</div>;
};
/**
* SubAgent 进度组件
*/
export const SubAgentProgress: React.FC<SubAgentProgressProps> = ({
progress,
events,
isRunning,
onCancel,
}) => {
if (!progress && events.length === 0) {
return null;
}
return (
<div className="bg-white rounded-lg shadow p-4 space-y-4">
{/* 标题和取消按钮 */}
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium text-gray-900">SubAgent 执行进度</h3>
{isRunning && onCancel && (
<button
onClick={onCancel}
className="px-3 py-1 text-sm text-red-600 hover:text-red-800 hover:bg-red-50 rounded"
>
取消
</button>
)}
</div>
{/* 进度条 */}
{progress && (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-gray-600">
{progress.completed + progress.failed + progress.skipped} /{" "}
{progress.total}
</span>
<span className="text-gray-600">
{progress.percentage.toFixed(1)}%
</span>
</div>
<ProgressBar percentage={progress.percentage} />
</div>
)}
{/* 状态统计 */}
{progress && (
<div className="flex flex-wrap gap-2">
<StatusBadge status="completed" count={progress.completed} />
<StatusBadge status="running" count={progress.running} />
<StatusBadge status="pending" count={progress.pending} />
<StatusBadge status="failed" count={progress.failed} />
<StatusBadge status="skipped" count={progress.skipped} />
</div>
)}
{/* 当前运行的任务 */}
{progress && progress.currentTasks.length > 0 && (
<div className="text-sm">
<span className="text-gray-500">正在执行: </span>
<span className="text-blue-600">
{progress.currentTasks.join(", ")}
</span>
</div>
)}
{/* 事件日志 */}
{events.length > 0 && (
<div className="border-t pt-3">
<h4 className="text-sm font-medium text-gray-700 mb-2">执行日志</h4>
<div className="max-h-40 overflow-y-auto space-y-1">
{events.slice(-10).map((event, index) => (
<EventLogItem key={index} event={event} />
))}
</div>
</div>
)}
</div>
);
};
export default SubAgentProgress;
-13
View File
@@ -1,13 +0,0 @@
/**
* SubAgent 组件索引
*/
export { SubAgentProgress } from "./SubAgentProgress";
export type {
SubAgentTask,
SubAgentResult,
SchedulerProgress,
SchedulerEvent,
SchedulerExecutionResult,
SchedulerConfig,
} from "@/hooks/useSubAgentScheduler";
-21
View File
@@ -1,21 +0,0 @@
import { AlertTriangle, ExternalLink } from "lucide-react";
export function ExperimentalBanner() {
return (
<div className="flex items-center gap-2 px-3 py-2 bg-yellow-50 border border-yellow-200 rounded-md text-xs text-yellow-800">
<AlertTriangle className="h-3 w-3 shrink-0" />
<span>
实验功能,不影响核心使用。问题反馈:
<a
href="https://github.com/aiclientproxy/lime/issues"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-0.5 text-yellow-700 hover:text-yellow-900 underline ml-1"
>
GitHub Issue
<ExternalLink className="h-2.5 w-2.5" />
</a>
</span>
</div>
);
}
-64
View File
@@ -1,64 +0,0 @@
/**
* Alert 组件
*
* 用于显示重要信息、警告或错误提示
*/
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
},
);
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
));
Alert.displayName = "Alert";
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
));
AlertTitle.displayName = "AlertTitle";
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
));
AlertDescription.displayName = "AlertDescription";
export { Alert, AlertTitle, AlertDescription };
-64
View File
@@ -1,64 +0,0 @@
import * as React from "react";
import { cn } from "@/lib/utils";
interface RadioGroupProps {
value?: string;
onValueChange?: (value: string) => void;
className?: string;
children?: React.ReactNode;
}
interface RadioGroupItemProps {
value: string;
id?: string;
className?: string;
children?: React.ReactNode;
}
const RadioGroupContext = React.createContext<{
value?: string;
onValueChange?: (value: string) => void;
}>({});
const RadioGroup = React.forwardRef<HTMLDivElement, RadioGroupProps>(
({ className, value, onValueChange, children, ...props }, ref) => {
return (
<RadioGroupContext.Provider value={{ value, onValueChange }}>
<div
ref={ref}
className={cn("grid gap-2", className)}
role="radiogroup"
{...props}
>
{children}
</div>
</RadioGroupContext.Provider>
);
},
);
RadioGroup.displayName = "RadioGroup";
const RadioGroupItem = React.forwardRef<HTMLInputElement, RadioGroupItemProps>(
({ className, value, id, ...props }, ref) => {
const context = React.useContext(RadioGroupContext);
return (
<input
ref={ref}
type="radio"
id={id}
value={value}
checked={context.value === value}
onChange={() => context.onValueChange?.(value)}
className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
);
},
);
RadioGroupItem.displayName = "RadioGroupItem";
export { RadioGroup, RadioGroupItem };
-31
View File
@@ -1,31 +0,0 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Separator = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div"> & {
orientation?: "horizontal" | "vertical";
decorative?: boolean;
}
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref,
) => (
<div
ref={ref}
role={decorative ? "none" : "separator"}
aria-orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className,
)}
{...props}
/>
),
);
Separator.displayName = "Separator";
export { Separator };
@@ -1,232 +0,0 @@
import React, { useEffect, useState } from "react";
import { safeInvoke } from "@/lib/dev-bridge";
import {
Wifi,
WifiOff,
Users,
MessageSquare,
AlertCircle,
RefreshCw,
ChevronDown,
ChevronUp,
} from "lucide-react";
interface WsServiceStatus {
enabled: boolean;
active_connections: number;
total_connections: number;
total_messages: number;
total_errors: number;
}
interface WsConnectionInfo {
id: string;
connected_at: string;
client_info: string | null;
request_count: number;
}
export function WebSocketStatus() {
const [status, setStatus] = useState<WsServiceStatus | null>(null);
const [connections, setConnections] = useState<WsConnectionInfo[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showConnections, setShowConnections] = useState(false);
const fetchStatus = async () => {
try {
const wsStatus = await safeInvoke<WsServiceStatus>(
"get_websocket_status",
);
setStatus(wsStatus);
if (wsStatus.active_connections > 0) {
const wsConnections = await safeInvoke<WsConnectionInfo[]>(
"get_websocket_connections",
);
setConnections(wsConnections);
} else {
setConnections([]);
}
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchStatus();
const interval = setInterval(fetchStatus, 5000);
return () => clearInterval(interval);
}, []);
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
return date.toLocaleString();
};
const truncateId = (id: string) => {
return id.length > 8 ? `${id.slice(0, 8)}...` : id;
};
if (loading) {
return (
<div className="rounded-lg border bg-card p-4">
<div className="flex items-center gap-2 text-muted-foreground">
<RefreshCw className="h-4 w-4 animate-spin" />
<span>加载中...</span>
</div>
</div>
);
}
if (error) {
return (
<div className="rounded-lg border bg-card p-4">
<div className="flex items-center gap-2 text-red-500">
<AlertCircle className="h-4 w-4" />
<span>加载失败: {error}</span>
</div>
</div>
);
}
if (!status) {
return null;
}
return (
<div className="space-y-4">
{/* 状态概览 */}
<div className="rounded-lg border bg-card p-4">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold flex items-center gap-2">
{status.enabled ? (
<Wifi className="h-4 w-4 text-green-500" />
) : (
<WifiOff className="h-4 w-4 text-muted-foreground" />
)}
WebSocket 服务
</h3>
<button
onClick={fetchStatus}
className="p-1 hover:bg-muted rounded"
title="刷新"
>
<RefreshCw className="h-4 w-4" />
</button>
</div>
<div className="grid grid-cols-4 gap-4">
<StatCard
icon={Users}
label="活跃连接"
value={status.active_connections}
highlight={status.active_connections > 0}
/>
<StatCard
icon={Users}
label="总连接数"
value={status.total_connections}
/>
<StatCard
icon={MessageSquare}
label="总消息数"
value={status.total_messages}
/>
<StatCard
icon={AlertCircle}
label="错误数"
value={status.total_errors}
highlight={status.total_errors > 0}
highlightColor="text-red-500"
/>
</div>
</div>
{/* 连接列表 */}
{status.active_connections > 0 && (
<div className="rounded-lg border bg-card">
<button
onClick={() => setShowConnections(!showConnections)}
className="w-full p-4 flex items-center justify-between hover:bg-muted/50"
>
<span className="font-semibold">
活跃连接 ({status.active_connections})
</span>
{showConnections ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</button>
{showConnections && (
<div className="border-t">
{connections.length === 0 ? (
<div className="p-4 text-center text-muted-foreground">
暂无连接数据
</div>
) : (
<div className="divide-y">
{connections.map((conn) => (
<div
key={conn.id}
className="p-4 flex items-center justify-between"
>
<div>
<div className="font-mono text-sm">
{truncateId(conn.id)}
</div>
<div className="text-xs text-muted-foreground">
{conn.client_info || "未知客户端"}
</div>
</div>
<div className="text-right">
<div className="text-sm">{conn.request_count} 请求</div>
<div className="text-xs text-muted-foreground">
{formatDate(conn.connected_at)}
</div>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
)}
</div>
);
}
function StatCard({
icon: Icon,
label,
value,
highlight = false,
highlightColor = "text-green-500",
}: {
icon: React.ElementType;
label: string;
value: number;
highlight?: boolean;
highlightColor?: string;
}) {
return (
<div className="text-center">
<div className="flex items-center justify-center gap-1 text-muted-foreground mb-1">
<Icon className="h-3 w-3" />
<span className="text-xs">{label}</span>
</div>
<div className={`text-xl font-bold ${highlight ? highlightColor : ""}`}>
{value}
</div>
</div>
);
}
export default WebSocketStatus;
-1
View File
@@ -1 +0,0 @@
export { WebSocketStatus } from "./WebSocketStatus";
+13 -111
View File
@@ -355,30 +355,17 @@
"// === Onboarding Wizard (src/components/onboarding/steps/*.tsx) ===": "",
"初次安装引导 - 欢迎页": "First-time Setup - Welcome",
"欢迎使用 Lime": "Welcome to Lime",
"AI API 聚合代理": "AI API Aggregation Proxy",
"让我们花一分钟时间,根据您的使用场景推荐合适的插件,提升您的使用体验。": "Let's take a minute to recommend suitable plugins based on your use case to enhance your experience.",
"多凭证池管理,自动轮换": "Multi-credential pool management with auto-rotation",
"支持 Claude、OpenAI、Gemini 等主流 API": "Supports mainstream APIs like Claude, OpenAI, Gemini",
"插件扩展,按需安装": "Plugin extensions, install on demand",
"Lime 是一款强大的 AI 客户端代理,帮助您轻松管理和使用多种 AI 服务。": "Lime is a powerful AI client proxy that helps you manage and use multiple AI services with ease.",
"支持 Claude、OpenAI、Gemini 等主流 AI 模型": "Supports mainstream AI models such as Claude, OpenAI, and Gemini",
"持续工作区": "Persistent workspace",
"围绕同一目标持续整理对话、素材和结果,减少来回切换。": "Keep conversations, materials, and results organized around the same goal to reduce context switching.",
"语音交互": "Voice interaction",
"便捷的语音输入,提升对话效率": "Use voice input to keep conversations moving faster.",
"跳过引导": "Skip Guide",
"开始设置": "Start Setup",
"初次安装引导 - 用户群体选择": "First-time Setup - User Profile",
"您是哪类用户?": "What type of user are you?",
"我们将根据您的选择推荐合适的插件": "We will recommend suitable plugins based on your selection",
"初次安装引导 - 插件选择": "First-time Setup - Plugin Selection",
"选择要安装的插件": "Select Plugins to Install",
"已为程序员推荐配置管理和 Flow Monitor 插件": "Configuration management and Flow Monitor plugins recommended for developers",
"您可以根据需要选择插件,或稍后在插件中心安装": "You can select plugins as needed, or install them later in the Plugin Center",
"取消全选": "Deselect All",
"全选": "Select All",
"推荐": "Recommended",
"初次安装引导 - 安装进度": "First-time Setup - Installation Progress",
"等待安装...": "Waiting to install...",
"准备下载...": "Preparing to download...",
"安装出错": "Installation error",
"正在安装插件": "Installing Plugins",
"请稍候,正在为您安装选中的插件...": "Please wait, installing selected plugins...",
"总体进度": "Overall Progress",
"开始使用": "Get started",
"// === Provider Pool Sub-components ===": "",
"配置 Google Vertex AI API Key 和模型别名": "Configure Google Vertex AI API Key and model aliases",
"暂无 Vertex AI 凭证": "No Vertex AI credentials",
@@ -497,31 +484,16 @@
"此中转商不在官方注册表中,请确认您信任此来源后再添加。": "This relay provider is not in the official registry, please confirm you trust this source before adding.",
"API Key 信息展示组件": "API Key Information Display Component",
"名称": "Name",
"// === Model Selector Components (src/components/model-selector/*.tsx) ===": "",
"服务等级选择器 - Mini/Pro/Max 三档选择": "Service Tier Selector - Mini/Pro/Max Three-tier Selection",
"提供类似 v0 的简洁模式选择体验": "Provides v0-like simple mode selection experience",
"快速响应": "Fast Response",
"均衡性能": "Balanced Performance",
"最强能力": "Maximum Capability",
"当前选中的等级": "Currently Selected Tier",
"等级变化回调": "Tier Change Callback",
"是否禁用": "Is Disabled",
"各等级的模型数量": "Model Count per Tier",
"紧凑模式": "Compact Mode",
"ProviderModelSelector 组件": "ProviderModelSelector Component",
"双栏模型选择器:左侧 Provider 列表,右侧模型列表": "Two-column model selector: Provider list on left, model list on right",
"选择模型回调": "Select Model Callback",
"初始选中的 Provider": "Initially Selected Provider",
"初始选中的模型": "Initially Selected Model",
"已配置的 Provider 信息": "Configured Provider Information",
"常量": "Constants",
"OAuth 凭证类型到 Provider ID 的映射": "OAuth Credential Type to Provider ID Mapping",
"API Key Provider 类型到 Registry ID 的映射": "API Key Provider Type to Registry ID Mapping",
"Provider 显示名称": "Provider Display Names",
"阿里云": "Alibaba Cloud",
"自定义": "Custom",
"别名 Provider 列表(使用别名配置而非标准模型注册表)": "Alias Provider List (uses alias configuration instead of standard model registry)",
"Provider ID 到模型注册表 Provider ID 的映射(用于过滤模型)": "Provider ID to Model Registry Provider ID Mapping (for filtering models)",
"子组件": "Sub-components",
"Provider 列表项": "Provider List Item",
"模型列表项": "Model List Item",
@@ -531,13 +503,11 @@
"支持工具": "Supports Tools",
"支持推理": "Supports Reasoning",
"双栏模型选择器组件": "Two-column Model Selector Component",
"左侧显示已配置凭证的 Provider 列表(单选)": "Left side shows Provider list with configured credentials (single selection)",
"// === App.tsx ===": "",
"保留以供后续错误处理": "Reserved for future error handling",
"加载失败": "Failed to load",
"前缀": "Prefix",
"确认弹窗": "Confirmation Dialog",
"// === components\\agent\\AgentSkillsPanel.tsx ===": "",
"加载": "Load",
"已加载": "Loaded",
"名称列表": "Name List",
@@ -1710,39 +1680,17 @@
"同步到哪些应用": "Sync to which apps",
"同步到": "Sync to",
"服务器吗": "server",
"// === components\\model-selector\\EnhancedModelList.tsx ===": "",
"加载模型列表": "Load ModelList",
"暂无可用模型": "No available models",
"请等待模型数据加载": "Please wait for model data to load",
"无搜索结果": "No search results",
"未找到匹配的模型": "No matching models found",
"尝试其他搜索词": "Try other search terms",
"选中指示器": "Selection indicator",
"模型信息": "Model information",
"能力标签和操作": "Capability tags and actions",
"全部模型": "All models",
"// === components\\model-selector\\ModelList.tsx ===": "",
"请先添加凭证": "Please add credentials first",
"状态和能力标签": "Status and capability tags",
"视觉": "Vision",
"// === components\\model-selector\\ModelSelector.tsx ===": "",
"模型选择失败": "Model selection failed",
"模式切换和刷新": "Mode toggle and refresh",
"统计信息": "Statistics",
"可用": "Available",
"刷新按钮": "Refresh button",
"等级选择器": "Tier selector",
"专家模式": "Expert mode",
"显示模型列表": "Show model list",
"简单模式": "Simple mode",
"显示当前选择": "Show current selection",
"// === components\\model-selector\\ModeToggle.tsx ===": "",
"简单": "Simple",
"专家": "Expert",
"// === components\\model-selector\\ProviderModelSelector.tsx ===": "",
"时清除模型选择": "Clear model selection when",
"左侧": "Left",
"已配置凭证的": "With configured credentials",
"右侧": "Right",
"的模型": "models",
"请选择": "Please select",
@@ -1768,38 +1716,14 @@
"下一步": "Next",
"// === components\\onboarding\\steps\\CompleteStep.tsx ===": "",
"设置完成": "Setup complete",
"所有插件已成功安装": "All plugins installed successfully",
"您可以开始使用": "You can start using",
"个插件": "plugins",
"个安装失败": "installation failed",
"您可以稍后在插件中心重试": "You can retry later in Plugin Center",
"您已跳过插件安装": "You skipped plugin installation",
"可以稍后在插件中心安装需要的插件": "Can install needed plugins later in Plugin Center",
"您可以在左侧导航栏的": "You can find in the left navigation bar",
"随时安装插件": "install plugins anytime",
"Lime 已准备就绪,您可以开始使用了。": "Lime is ready. You can start using it now.",
"提示:后续可在设置中继续调整语音输入和快捷键。": "Tip: You can continue adjusting voice input and shortcuts in Settings later.",
"开始使用": "Get started",
"// === components\\onboarding\\steps\\InstallProgressStep.tsx ===": "",
"等待安装": "Waiting to install",
"准备下载": "Preparing download",
"请稍候": "Please wait",
"正在为您安装选中的插件": "Installing selected plugins for you",
"// === components\\onboarding\\steps\\PluginSelectStep.tsx ===": "",
"已为程序员推荐配置管理和": "Configuration management recommended for programmers and",
"您可以根据需要选择插件": "You can select plugins as needed",
"或稍后在插件中心安装": "or install later in Plugin Center",
"// === components\\onboarding\\steps\\UserProfileStep.tsx ===": "",
"您是哪类用户": "What type of user are you",
"// === components\\onboarding\\steps\\WelcomeStep.tsx ===": "",
"欢迎使用": "Welcome to",
"聚合代理": "Aggregation proxy",
"让我们花一分钟时间": "Let's take a minute",
"根据您的使用场景推荐合适的插件": "to recommend suitable plugins based on your use case",
"提升您的使用体验": "to enhance your experience",
"多凭证池管理": "Multi-credential pool management",
"自动轮换": "Auto-rotation",
"等主流": "And other mainstream",
"插件扩展": "Plugin extensions",
"按需安装": "Install on demand",
"持续工作区": "Persistent workspace",
"围绕同一目标持续整理对话、素材和结果,减少来回切换。": "Keep conversations, materials, and results organized around the same goal to reduce context switching.",
"语音交互": "Voice interaction",
"便捷的语音输入,提升对话效率": "Use voice input to keep conversations moving faster.",
"// === components\\plugins\\OAuthPluginContainer.tsx ===": "",
"认证类型": "Authentication type",
"最后使用": "Last used",
@@ -1890,7 +1814,6 @@
"未知来源": "Unknown source",
"// === components\\plugins\\PluginsPage.tsx ===": "",
"管理和配置": "Manage and configure",
"// === components\\plugins\\PluginUIRenderer.test.tsx ===": "",
"内置插件组件渲染": "Built-in plugin component rendering",
"应该正确渲染": "Should render correctly",
"未知插件处理": "Unknown plugin handler",
@@ -1902,7 +1825,6 @@
"大小写敏感性": "Case sensitivity",
"应该区分大小写": "Should be case sensitive",
"应该显示未找到": "Should display not found",
"// === components\\plugins\\PluginUIRenderer.tsx ===": "",
"无法加载插件": "Cannot load plugin",
"的用户界面": "user interface",
"请检查插件是否已正确安装": "Please check if plugin is installed correctly",
@@ -2232,7 +2154,6 @@
"完成登录": "Complete login",
"授权成功后": "After successful authorization",
"凭证将自动保存并添加到凭证池": "Credentials will be automatically saved and added to credential pool",
"// === components\\provider-pool\\credential-forms\\AntigravityFormStandalone.tsx ===": "",
"名称输入": "Name input",
"表单内容": "Form content",
"按钮区域": "Button area",
@@ -2245,7 +2166,6 @@
"指纹": "Fingerprint",
"需安装": "Requires installation",
"不可用警告图标": "Unavailable warning icon",
"// === components\\provider-pool\\credential-forms\\ClaudeFormStandalone.tsx ===": "",
"授权表单": "Authorization form",
"使用浏览器": "Use browser",
"中的": "in",
@@ -2283,7 +2203,6 @@
"复制页面显示的授权码": "Copy authorization code displayed on page",
"提交按钮": "Submit button",
"验证授权码": "Verify authorization code",
"// === components\\provider-pool\\credential-forms\\GeminiFormStandalone.tsx ===": "",
"认证方式选择": "Authentication method selection",
"认证": "Authentication",
"文件导入选项": "File import option",
@@ -2570,7 +2489,6 @@
"// === components\\provider-pool\\VertexAISection.tsx ===": "",
"和模型别名": " and ModelAlias",
"添加模型别名": "Add model alias",
"// === components\\Providers.tsx ===": "",
"访问通义千问": "Access Tongyi Qianwen",
"凭证加载成功": "Credentials loaded successfully",
"配置保存成功": "Configuration saved successfully",
@@ -3179,14 +3097,6 @@
"个插件工具": "plugin tools",
"推荐插件区域": "Recommended plugins area",
"插件安装对话框": "Plugin installation dialog",
"// === components\\websocket\\WebSocketStatus.tsx ===": "",
"活跃连接": "Active connections",
"总连接数": "Total connections",
"总消息数": "Total messages",
"错误数": "Error count",
"连接列表": "Connection list",
"暂无连接数据": "No connection data",
"未知客户端": "Unknown client",
"// === hooks\\useConnectCallback.ts ===": "",
"回调发送": "Callback sent",
"发送回调失败": "Failed to send callback",
@@ -3412,28 +3322,22 @@
"插件导出": "Plugin exports",
"没有默认导出": "No default export",
"加载插件失败": "Failed to Loaded plugins",
"// === lib\\plugin-loader\\PluginUIRenderer.tsx ===": "",
"插件加载失败": "Failed to pluginLoad",
"没有找到有效的组件导出": "No valid component export found",
"读取插件": "Read plugin",
"文件失败": "Failed to file",
"请通过命令行或": "Please use via command line or",
"使用此插件": "Use this plugin",
"// === lib\\plugin-ui\\ComponentRegistry.ts ===": "",
"无效的组件名称": "Invalid component name",
"必须以字母开头且只包含字母数字": "Must start with a letter and contain only alphanumeric characters",
"组件": "Component",
"将被覆盖": "Will be overwritten",
"// === lib\\plugin-ui\\components\\display.tsx ===": "",
"未知图标": "Unknown icon",
"// === lib\\plugin-ui\\PluginUIContainer.tsx ===": "",
"该插件没有提供": "This plugin does not provide",
"// === lib\\plugin-ui\\PluginUIRenderer.tsx ===": "",
"模板数据绑定": "Template data binding",
"不是数组": "Is not an array",
"未注册的组件类型": "Unregistered component type",
"未知组件": "Unknown component",
"// === lib\\plugin-ui\\SurfaceManager.test.ts ===": "",
"属性": "Property",
"注册一致性": "Registration consistency",
"注册的": "Registered",
@@ -3444,9 +3348,7 @@
"后应该无法查询到": "Should not be queryable after",
"清理插件时应该删除该插件的所有": "Should delete all of this plugin when cleaning",
"多个插件可以注册不同的": "Multiple plugins can register different",
"// === lib\\plugin-ui\\types.ts ===": "",
"权重": "Weight",
"// === lib\\plugin-ui\\usePluginUI.ts ===": "",
"监听事件失败": "Failed to listen to event",
"处理操作失败": "Failed to HandleActions",
"// === lib\\utils\\apiKeyMask.test.ts ===": "",
+13 -111
View File
@@ -330,30 +330,17 @@
"// === Onboarding Wizard (src/components/onboarding/steps/*.tsx) ===": "",
"初次安装引导 - 欢迎页": "初次安装引导 - 欢迎页",
"欢迎使用 Lime": "欢迎使用 Lime",
"AI API 聚合代理": "AI API 聚合代理",
"让我们花一分钟时间,根据您的使用场景推荐合适的插件,提升您的使用体验。": "让我们花一分钟时间,根据您的使用场景推荐合适的插件,提升您的使用体验。",
"多凭证池管理,自动轮换": "多凭证池管理,自动轮换",
"支持 Claude、OpenAI、Gemini 等主流 API": "支持 Claude、OpenAI、Gemini 等主流 API",
"插件扩展,按需安装": "插件扩展,按需安装",
"Lime 是一款强大的 AI 客户端代理,帮助您轻松管理和使用多种 AI 服务。": "Lime 是一款强大的 AI 客户端代理,帮助您轻松管理和使用多种 AI 服务。",
"支持 Claude、OpenAI、Gemini 等主流 AI 模型": "支持 Claude、OpenAI、Gemini 等主流 AI 模型",
"持续工作区": "持续工作区",
"围绕同一目标持续整理对话、素材和结果,减少来回切换。": "围绕同一目标持续整理对话、素材和结果,减少来回切换。",
"语音交互": "语音交互",
"便捷的语音输入,提升对话效率": "便捷的语音输入,提升对话效率",
"跳过引导": "跳过引导",
"开始设置": "开始设置",
"初次安装引导 - 用户群体选择": "初次安装引导 - 用户群体选择",
"您是哪类用户?": "您是哪类用户?",
"我们将根据您的选择推荐合适的插件": "我们将根据您的选择推荐合适的插件",
"初次安装引导 - 插件选择": "初次安装引导 - 插件选择",
"选择要安装的插件": "选择要安装的插件",
"已为程序员推荐配置管理和 Flow Monitor 插件": "已为程序员推荐配置管理和 Flow Monitor 插件",
"您可以根据需要选择插件,或稍后在插件中心安装": "您可以根据需要选择插件,或稍后在插件中心安装",
"取消全选": "取消全选",
"全选": "全选",
"推荐": "推荐",
"初次安装引导 - 安装进度": "初次安装引导 - 安装进度",
"等待安装...": "等待安装...",
"准备下载...": "准备下载...",
"安装出错": "安装出错",
"正在安装插件": "正在安装插件",
"请稍候,正在为您安装选中的插件...": "请稍候,正在为您安装选中的插件...",
"总体进度": "总体进度",
"开始使用": "开始使用",
"// === Provider Pool Sub-components ===": "",
"配置 Google Vertex AI API Key 和模型别名": "配置 Google Vertex AI API Key 和模型别名",
"暂无 Vertex AI 凭证": "暂无 Vertex AI 凭证",
@@ -472,31 +459,16 @@
"此中转商不在官方注册表中,请确认您信任此来源后再添加。": "此中转商不在官方注册表中,请确认您信任此来源后再添加。",
"API Key 信息展示组件": "API Key 信息展示组件",
"名称": "名称",
"// === Model Selector Components (src/components/model-selector/*.tsx) ===": "",
"服务等级选择器 - Mini/Pro/Max 三档选择": "服务等级选择器 - Mini/Pro/Max 三档选择",
"提供类似 v0 的简洁模式选择体验": "提供类似 v0 的简洁模式选择体验",
"快速响应": "快速响应",
"均衡性能": "均衡性能",
"最强能力": "最强能力",
"当前选中的等级": "当前选中的等级",
"等级变化回调": "等级变化回调",
"是否禁用": "是否禁用",
"各等级的模型数量": "各等级的模型数量",
"紧凑模式": "紧凑模式",
"ProviderModelSelector 组件": "ProviderModelSelector 组件",
"双栏模型选择器:左侧 Provider 列表,右侧模型列表": "双栏模型选择器:左侧 Provider 列表,右侧模型列表",
"选择模型回调": "选择模型回调",
"初始选中的 Provider": "初始选中的 Provider",
"初始选中的模型": "初始选中的模型",
"已配置的 Provider 信息": "已配置的 Provider 信息",
"常量": "常量",
"OAuth 凭证类型到 Provider ID 的映射": "OAuth 凭证类型到 Provider ID 的映射",
"API Key Provider 类型到 Registry ID 的映射": "API Key Provider 类型到 Registry ID 的映射",
"Provider 显示名称": "Provider 显示名称",
"阿里云": "阿里云",
"自定义": "自定义",
"别名 Provider 列表(使用别名配置而非标准模型注册表)": "别名 Provider 列表(使用别名配置而非标准模型注册表)",
"Provider ID 到模型注册表 Provider ID 的映射(用于过滤模型)": "Provider ID 到模型注册表 Provider ID 的映射(用于过滤模型)",
"子组件": "子组件",
"Provider 列表项": "Provider 列表项",
"模型列表项": "模型列表项",
@@ -506,13 +478,11 @@
"支持工具": "支持工具",
"支持推理": "支持推理",
"双栏模型选择器组件": "双栏模型选择器组件",
"左侧显示已配置凭证的 Provider 列表(单选)": "左侧显示已配置凭证的 Provider 列表(单选)",
"// === App.tsx ===": "",
"保留以供后续错误处理": "保留以供后续错误处理",
"加载失败": "加载失败",
"前缀": "前缀",
"确认弹窗": "确认弹窗",
"// === components\\agent\\AgentSkillsPanel.tsx ===": "",
"加载": "加载",
"已加载": "已加载",
"名称列表": "名称列表",
@@ -1692,39 +1662,17 @@
"同步到哪些应用": "同步到哪些应用",
"同步到": "同步到",
"服务器吗": "服务器吗",
"// === components\\model-selector\\EnhancedModelList.tsx ===": "",
"加载模型列表": "加载模型列表",
"暂无可用模型": "暂无可用模型",
"请等待模型数据加载": "请等待模型数据加载",
"无搜索结果": "无搜索结果",
"未找到匹配的模型": "未找到匹配的模型",
"尝试其他搜索词": "尝试其他搜索词",
"选中指示器": "选中指示器",
"模型信息": "模型信息",
"能力标签和操作": "能力标签和操作",
"全部模型": "全部模型",
"// === components\\model-selector\\ModelList.tsx ===": "",
"请先添加凭证": "请先添加凭证",
"状态和能力标签": "状态和能力标签",
"视觉": "视觉",
"// === components\\model-selector\\ModelSelector.tsx ===": "",
"模型选择失败": "模型选择失败",
"模式切换和刷新": "模式切换和刷新",
"统计信息": "统计信息",
"可用": "可用",
"刷新按钮": "刷新按钮",
"等级选择器": "等级选择器",
"专家模式": "专家模式",
"显示模型列表": "显示模型列表",
"简单模式": "简单模式",
"显示当前选择": "显示当前选择",
"// === components\\model-selector\\ModeToggle.tsx ===": "",
"简单": "简单",
"专家": "专家",
"// === components\\model-selector\\ProviderModelSelector.tsx ===": "",
"时清除模型选择": "时清除模型选择",
"左侧": "左侧",
"已配置凭证的": "已配置凭证的",
"右侧": "右侧",
"的模型": "的模型",
"请选择": "请选择",
@@ -1750,38 +1698,14 @@
"下一步": "下一步",
"// === components\\onboarding\\steps\\CompleteStep.tsx ===": "",
"设置完成": "设置完成",
"所有插件已成功安装": "所有插件已成功安装",
"您可以开始使用": "您可以开始使用",
"个插件": "个插件",
"个安装失败": "个安装失败",
"您可以稍后在插件中心重试": "您可以稍后在插件中心重试",
"您已跳过插件安装": "您已跳过插件安装",
"可以稍后在插件中心安装需要的插件": "可以稍后在插件中心安装需要的插件",
"您可以在左侧导航栏的": "您可以在左侧导航栏的",
"随时安装插件": "随时安装插件",
"Lime 已准备就绪,您可以开始使用了。": "Lime 已准备就绪,您可以开始使用了。",
"提示:后续可在设置中继续调整语音输入和快捷键。": "提示:后续可在设置中继续调整语音输入和快捷键。",
"开始使用": "开始使用",
"// === components\\onboarding\\steps\\InstallProgressStep.tsx ===": "",
"等待安装": "等待安装",
"准备下载": "准备下载",
"请稍候": "请稍候",
"正在为您安装选中的插件": "正在为您安装选中的插件",
"// === components\\onboarding\\steps\\PluginSelectStep.tsx ===": "",
"已为程序员推荐配置管理和": "已为程序员推荐配置管理和",
"您可以根据需要选择插件": "您可以根据需要选择插件",
"或稍后在插件中心安装": "或稍后在插件中心安装",
"// === components\\onboarding\\steps\\UserProfileStep.tsx ===": "",
"您是哪类用户": "您是哪类用户",
"// === components\\onboarding\\steps\\WelcomeStep.tsx ===": "",
"欢迎使用": "欢迎使用",
"聚合代理": "聚合代理",
"让我们花一分钟时间": "让我们花一分钟时间",
"根据您的使用场景推荐合适的插件": "根据您的使用场景推荐合适的插件",
"提升您的使用体验": "提升您的使用体验",
"多凭证池管理": "多凭证池管理",
"自动轮换": "自动轮换",
"等主流": "等主流",
"插件扩展": "插件扩展",
"按需安装": "按需安装",
"持续工作区": "持续工作区",
"围绕同一目标持续整理对话、素材和结果,减少来回切换。": "围绕同一目标持续整理对话、素材和结果,减少来回切换。",
"语音交互": "语音交互",
"便捷的语音输入,提升对话效率": "便捷的语音输入,提升对话效率",
"// === components\\plugins\\OAuthPluginContainer.tsx ===": "",
"认证类型": "认证类型",
"最后使用": "最后使用",
@@ -1872,7 +1796,6 @@
"未知来源": "未知来源",
"// === components\\plugins\\PluginsPage.tsx ===": "",
"管理和配置": "管理和配置",
"// === components\\plugins\\PluginUIRenderer.test.tsx ===": "",
"内置插件组件渲染": "内置插件组件渲染",
"应该正确渲染": "应该正确渲染",
"未知插件处理": "未知插件处理",
@@ -1884,7 +1807,6 @@
"大小写敏感性": "大小写敏感性",
"应该区分大小写": "应该区分大小写",
"应该显示未找到": "应该显示未找到",
"// === components\\plugins\\PluginUIRenderer.tsx ===": "",
"无法加载插件": "无法加载插件",
"的用户界面": "的用户界面",
"请检查插件是否已正确安装": "请检查插件是否已正确安装",
@@ -2214,7 +2136,6 @@
"完成登录": "完成登录",
"授权成功后": "授权成功后",
"凭证将自动保存并添加到凭证池": "凭证将自动保存并添加到凭证池",
"// === components\\provider-pool\\credential-forms\\AntigravityFormStandalone.tsx ===": "",
"名称输入": "名称输入",
"表单内容": "表单内容",
"按钮区域": "按钮区域",
@@ -2227,7 +2148,6 @@
"指纹": "指纹",
"需安装": "需安装",
"不可用警告图标": "不可用警告图标",
"// === components\\provider-pool\\credential-forms\\ClaudeFormStandalone.tsx ===": "",
"授权表单": "授权表单",
"使用浏览器": "使用浏览器",
"中的": "中的",
@@ -2265,7 +2185,6 @@
"复制页面显示的授权码": "复制页面显示的授权码",
"提交按钮": "提交按钮",
"验证授权码": "验证授权码",
"// === components\\provider-pool\\credential-forms\\GeminiFormStandalone.tsx ===": "",
"认证方式选择": "认证方式选择",
"认证": "认证",
"文件导入选项": "文件导入选项",
@@ -2552,7 +2471,6 @@
"// === components\\provider-pool\\VertexAISection.tsx ===": "",
"和模型别名": "和模型别名",
"添加模型别名": "添加模型别名",
"// === components\\Providers.tsx ===": "",
"访问通义千问": "访问通义千问",
"凭证加载成功": "凭证加载成功",
"配置保存成功": "配置保存成功",
@@ -3161,14 +3079,6 @@
"个插件工具": "个插件工具",
"推荐插件区域": "推荐插件区域",
"插件安装对话框": "插件安装对话框",
"// === components\\websocket\\WebSocketStatus.tsx ===": "",
"活跃连接": "活跃连接",
"总连接数": "总连接数",
"总消息数": "总消息数",
"错误数": "错误数",
"连接列表": "连接列表",
"暂无连接数据": "暂无连接数据",
"未知客户端": "未知客户端",
"// === hooks\\useConnectCallback.ts ===": "",
"回调发送": "回调发送",
"发送回调失败": "发送回调失败",
@@ -3394,28 +3304,22 @@
"插件导出": "插件导出",
"没有默认导出": "没有默认导出",
"加载插件失败": "加载插件失败",
"// === lib\\plugin-loader\\PluginUIRenderer.tsx ===": "",
"插件加载失败": "插件加载失败",
"没有找到有效的组件导出": "没有找到有效的组件导出",
"读取插件": "读取插件",
"文件失败": "文件失败",
"请通过命令行或": "请通过命令行或",
"使用此插件": "使用此插件",
"// === lib\\plugin-ui\\ComponentRegistry.ts ===": "",
"无效的组件名称": "无效的组件名称",
"必须以字母开头且只包含字母数字": "必须以字母开头且只包含字母数字",
"组件": "组件",
"将被覆盖": "将被覆盖",
"// === lib\\plugin-ui\\components\\display.tsx ===": "",
"未知图标": "未知图标",
"// === lib\\plugin-ui\\PluginUIContainer.tsx ===": "",
"该插件没有提供": "该插件没有提供",
"// === lib\\plugin-ui\\PluginUIRenderer.tsx ===": "",
"模板数据绑定": "模板数据绑定",
"不是数组": "不是数组",
"未注册的组件类型": "未注册的组件类型",
"未知组件": "未知组件",
"// === lib\\plugin-ui\\SurfaceManager.test.ts ===": "",
"属性": "属性",
"注册一致性": "注册一致性",
"注册的": "注册的",
@@ -3426,9 +3330,7 @@
"后应该无法查询到": "后应该无法查询到",
"清理插件时应该删除该插件的所有": "清理插件时应该删除该插件的所有",
"多个插件可以注册不同的": "多个插件可以注册不同的",
"// === lib\\plugin-ui\\types.ts ===": "",
"权重": "权重",
"// === lib\\plugin-ui\\usePluginUI.ts ===": "",
"监听事件失败": "监听事件失败",
"处理操作失败": "处理操作失败",
"// === lib\\utils\\apiKeyMask.test.ts ===": "",
-9
View File
@@ -23,15 +23,6 @@
- `provider.ts` - API Key Provider 系统类型定义(Requirements 5.1)
- `errors/` - 错误处理模块
- `playwrightErrors.ts` - Playwright 登录错误处理(Requirements 5.1, 5.2, 5.3, 5.4)
- `plugin-ui/` - 插件 UI 系统(基于 A2UI 设计理念)
- `types.ts` - 类型定义
- `ComponentRegistry.ts` - 组件注册表
- `DataStore.ts` - 数据存储
- `SurfaceManager.ts` - Surface 管理器
- `PluginUIRenderer.tsx` - 核心渲染器
- `PluginUIContainer.tsx` - 容器组件
- `usePluginUI.ts` - React Hook
- `components/` - 标准组件实现
- `tauri/` - Tauri 命令封装
- `utils/` - 通用工具函数
- `apiKeyValidation.ts` - API Key 格式验证(Requirements 3.8)
-69
View File
@@ -1,69 +0,0 @@
/**
* 电商差评回复 API
*
* 封装电商差评回复相关的 Tauri 命令调用
*/
import { safeInvoke } from "@/lib/dev-bridge";
/**
* 电商差评回复请求参数
*/
export interface EcommerceReviewReplyRequest {
/** 电商平台 */
platform: "taobao" | "jd" | "pinduoduo";
/** 差评链接 */
reviewUrl: string;
/** 回复语气 */
tone: "polite" | "sincere" | "professional";
/** 回复长度 */
length: "short" | "medium" | "long";
/** 自定义模板 (可选) */
template?: string;
/** AI 模型 (可选) */
model?: string;
/** 执行 ID (可选) */
executionId?: string;
}
/**
* Skill 执行结果
*/
export interface SkillExecutionResult {
/** 是否成功 */
success: boolean;
/** 最终输出 */
output?: string;
/** 错误信息 */
error?: string;
/** 已完成的步骤结果 */
stepsCompleted: Array<{
stepId: string;
stepName: string;
success: boolean;
output?: string;
error?: string;
}>;
}
/**
* 电商差评回复 API
*/
export const ecommerceReviewReplyApi = {
/**
* 执行电商差评回复
*
* @param request - 请求参数
* @returns 执行结果
*/
async executeReviewReply(
request: EcommerceReviewReplyRequest,
): Promise<SkillExecutionResult> {
return safeInvoke(
"execute_ecommerce_review_reply",
request as unknown as Record<string, unknown>,
);
},
};
export default ecommerceReviewReplyApi;
-43
View File
@@ -1,43 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { safeInvoke } from "@/lib/dev-bridge";
import { getFeedbackStats, recordFeedback } from "./memoryFeedback";
vi.mock("@/lib/dev-bridge", () => ({
safeInvoke: vi.fn(),
}));
describe("memoryFeedback API", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("应代理反馈记录与统计查询", async () => {
vi.mocked(safeInvoke)
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce({
total: 10,
approve_count: 7,
reject_count: 2,
modify_count: 1,
approval_rate: 0.7,
});
await expect(
recordFeedback("memory-1", "approve", "session-1"),
).resolves.toBeUndefined();
await expect(getFeedbackStats("session-1")).resolves.toEqual(
expect.objectContaining({ total: 10, approval_rate: 0.7 }),
);
expect(safeInvoke).toHaveBeenNthCalledWith(1, "unified_memory_feedback", {
request: {
memory_id: "memory-1",
action: { type: "approve" },
session_id: "session-1",
},
});
expect(safeInvoke).toHaveBeenNthCalledWith(2, "get_memory_feedback_stats", {
session_id: "session-1",
});
});
});
-37
View File
@@ -1,37 +0,0 @@
import { safeInvoke } from "@/lib/dev-bridge";
export interface FeedbackRequest {
memory_id: string;
action: "approve" | "reject" | { type: "modify"; changes: string };
session_id: string;
}
export interface FeedbackStats {
total: number;
approve_count: number;
reject_count: number;
modify_count: number;
approval_rate: number;
}
export async function recordFeedback(
memoryId: string,
action: "approve" | "reject",
sessionId: string,
): Promise<void> {
return safeInvoke<void>("unified_memory_feedback", {
request: {
memory_id: memoryId,
action: { type: action },
session_id: sessionId,
},
});
}
export async function getFeedbackStats(
sessionId: string,
): Promise<FeedbackStats> {
return safeInvoke<FeedbackStats>("get_memory_feedback_stats", {
session_id: sessionId,
});
}

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