mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
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:
co-authored by
Claude Sonnet 4.6
parent
e4b93c38d8
commit
c2261961c1
@@ -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 / 恢复 / 重试 / 取消怎么做
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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 主链
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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 读取边界。
|
||||
|
||||
@@ -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` 目录协议
|
||||
|
||||
@@ -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!({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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, ¤t) {
|
||||
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, ¤t_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())
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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, ¤t_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}"))
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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` - 启动画面组件
|
||||
|
||||
## 更新提醒
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export { AgentChatPage } from "./AgentChatPage";
|
||||
export { AgentSkillsPanel } from "./AgentSkillsPanel";
|
||||
@@ -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";
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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";
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
- 为已安装插件列表提供右键菜单
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
export { PluginManager } from "./PluginManager";
|
||||
export { PluginInstallDialog } from "./PluginInstallDialog";
|
||||
export { PluginUninstallDialog } from "./PluginUninstallDialog";
|
||||
export { default } from "./PluginManager";
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -1,13 +0,0 @@
|
||||
/**
|
||||
* SubAgent 组件索引
|
||||
*/
|
||||
|
||||
export { SubAgentProgress } from "./SubAgentProgress";
|
||||
export type {
|
||||
SubAgentTask,
|
||||
SubAgentResult,
|
||||
SchedulerProgress,
|
||||
SchedulerEvent,
|
||||
SchedulerExecutionResult,
|
||||
SchedulerConfig,
|
||||
} from "@/hooks/useSubAgentScheduler";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 +0,0 @@
|
||||
export { WebSocketStatus } from "./WebSocketStatus";
|
||||
+13
-111
@@ -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
@@ -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 ===": "",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user