diff --git a/docs/aiprompts/command-runtime.md b/docs/aiprompts/command-runtime.md index 95c244e3b..6c4d4a06a 100644 --- a/docs/aiprompts/command-runtime.md +++ b/docs/aiprompts/command-runtime.md @@ -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 / 恢复 / 重试 / 取消怎么做 diff --git a/docs/aiprompts/commands.md b/docs/aiprompts/commands.md index 79a04f91f..0fea64ff7 100644 --- a/docs/aiprompts/commands.md +++ b/docs/aiprompts/commands.md @@ -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` diff --git a/docs/aiprompts/lib.md b/docs/aiprompts/lib.md index f336a7a30..933893d56 100644 --- a/docs/aiprompts/lib.md +++ b/docs/aiprompts/lib.md @@ -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 # 流量事件管理 diff --git a/docs/aiprompts/limecore-collaboration-entry.md b/docs/aiprompts/limecore-collaboration-entry.md index dbc556423..2f8448454 100644 --- a/docs/aiprompts/limecore-collaboration-entry.md +++ b/docs/aiprompts/limecore-collaboration-entry.md @@ -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 主链 diff --git a/docs/aiprompts/playwright-e2e.md b/docs/aiprompts/playwright-e2e.md index 35b62fed0..d6d941e20 100644 --- a/docs/aiprompts/playwright-e2e.md +++ b/docs/aiprompts/playwright-e2e.md @@ -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` diff --git a/docs/aiprompts/quality-workflow.md b/docs/aiprompts/quality-workflow.md index 93e27a6ce..3522a42fe 100644 --- a/docs/aiprompts/quality-workflow.md +++ b/docs/aiprompts/quality-workflow.md @@ -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 读取边界。 diff --git a/docs/aiprompts/skill-standard.md b/docs/aiprompts/skill-standard.md index 6068331ad..58f8b244a 100644 --- a/docs/aiprompts/skill-standard.md +++ b/docs/aiprompts/skill-standard.md @@ -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` 目录协议 diff --git a/docs/plugin-ui-design.md b/docs/plugin-ui-design.md deleted file mode 100644 index 6c462d225..000000000 --- a/docs/plugin-ui-design.md +++ /dev/null @@ -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; // 组件缓冲区 - dataModel: Record; // 数据模型 - 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' }, - Tabs: { items: 'TabItem[]' }, - - // 展示组件 - Text: { text: 'BoundValue', variant?: 'TextVariant' }, - Icon: { name: 'IconName', size?: 'number', color?: 'string' }, - Badge: { text: 'BoundValue', variant?: 'BadgeVariant' }, - Progress: { value: 'BoundValue', max?: 'number' }, - - // 输入组件 - Button: { child: 'ComponentRef', action: 'Action', variant?: 'ButtonVariant' }, - TextField: { label: 'BoundValue', value: 'BoundValue' }, - Switch: { label: 'BoundValue', checked: 'BoundValue' }, - Select: { options: 'SelectOption[]', value: 'BoundValue' }, - - // 数据展示 - Table: { columns: 'TableColumn[]', data: 'BoundValue' }, - List: { children: 'ChildrenDef', direction?: 'Direction' }, - KeyValue: { items: 'KeyValueItem[]' }, - - // 反馈组件 - Alert: { message: 'BoundValue', type: 'AlertType' }, - Spinner: { size?: 'number' }, - Empty: { description?: 'BoundValue' }, -}; -``` - -### 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; // 解析后的上下文数据 - timestamp: string; -} -``` - -### 4. 数据绑定 - -支持字面值和路径绑定: - -```typescript -type BoundValue = - | { 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; - - /// 处理用户操作 - fn handle_action(&mut self, action: UserAction) -> Result>; -} - -/// UI 消息类型 -pub enum UIMessage { - SurfaceUpdate(SurfaceUpdate), - DataModelUpdate(DataModelUpdate), - BeginRendering(BeginRendering), - DeleteSurface(DeleteSurface), -} - -/// Surface 定义 -pub struct SurfaceDefinition { - pub surface_id: String, - pub initial_components: Vec, - pub initial_data: serde_json::Value, - pub root_id: String, -} -``` - -## 使用示例 - -### 插件端(Rust) - -```rust -impl PluginUI for CredentialMonitorPlugin { - fn get_surfaces(&self) -> Vec { - 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> { - 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 ( -
- - - {/* 插件 UI 渲染区域 */} - invoke('plugin_handle_action', { pluginId, action })} - /> -
- ); -} -``` - -## 安全考虑 - -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 { - 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, PluginError> { - match action.name.as_str() { - "refresh" => { - // 返回数据更新消息 - Ok(vec![UIMessage::DataModelUpdate(/* ... */)]) - } - _ => Ok(vec![]) - } - } -} -``` - -## 下一步计划 - -1. **更多组件**:Table、Tabs、Modal 等复杂组件 -2. **表单验证**:支持 TextField 的验证规则 -3. **主题系统**:更完善的样式定制能力 -4. **插件市场**:支持从远程加载插件 UI 定义 diff --git a/src-tauri/crates/server/src/handlers/image_api_provider.rs b/src-tauri/crates/server/src/handlers/image_api_provider.rs index 306f83684..a6073ed38 100644 --- a/src-tauri/crates/server/src/handlers/image_api_provider.rs +++ b/src-tauri/crates/server/src/handlers/image_api_provider.rs @@ -30,13 +30,15 @@ struct ImageProviderRoutingConfig { preferred_model_id: Option, allow_fallback: bool, default_size: Option, + is_explicit: bool, } pub(crate) async fn try_generate_with_configured_provider( state: &AppState, request: &ImageGenerationRequest, + explicit_provider_id: Option<&str>, ) -> Result, 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 { +fn load_image_provider_routing( + explicit_provider_id: Option<&str>, +) -> Option { + 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 { 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 { let (width_raw, height_raw) = size.split_once('x')?; let width = width_raw.parse::().ok()?; @@ -311,7 +343,25 @@ fn size_to_aspect_ratio(size: &str) -> Option { } 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 Vec { 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) { +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) { 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) { } 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) { } } - 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, 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::(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); + } } diff --git a/src-tauri/crates/server/src/handlers/image_handler.rs b/src-tauri/crates/server/src/handlers/image_handler.rs index 49e3dd0da..14a6535c5 100644 --- a/src-tauri/crates/server/src/handlers/image_handler.rs +++ b/src-tauri/crates/server/src/handlers/image_handler.rs @@ -33,6 +33,15 @@ use lime_providers::converter::openai_to_antigravity::{ }; use lime_providers::providers::AntigravityProvider; +fn read_explicit_provider_id(headers: &HeaderMap) -> Option { + 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!({ diff --git a/src-tauri/crates/services/src/lib.rs b/src-tauri/crates/services/src/lib.rs index c5f8e8630..b5aa9c70d 100644 --- a/src-tauri/crates/services/src/lib.rs +++ b/src-tauri/crates/services/src/lib.rs @@ -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; diff --git a/src-tauri/crates/services/src/live_sync.rs b/src-tauri/crates/services/src/live_sync.rs index 7b054913f..7312f0e88 100644 --- a/src-tauri/crates/services/src/live_sync.rs +++ b/src-tauri/crates/services/src/live_sync.rs @@ -148,10 +148,6 @@ fn get_shell_config_target( } } -fn get_shell_config_path() -> Result> { - 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, Box> { - 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 { @@ -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, - pub conflicts: Vec, -} - -/// 从外部配置文件解析当前生效的 provider -pub fn parse_current_provider_from_live( - app_type: &AppType, - live_settings: &Value, -) -> Result> { - 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> { - // 读取外部配置文件 - 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 { - 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> { - // 读取外部配置 - 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> { - 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"] diff --git a/src-tauri/crates/services/src/switch.rs b/src-tauri/crates/services/src/switch.rs deleted file mode 100644 index 08c4ddec8..000000000 --- a/src-tauri/crates/services/src/switch.rs +++ /dev/null @@ -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> = Lazy::new(|| Mutex::new(())); - -/// 用于在异步上下文中传递的切换数据 -struct SwitchContext { - target_provider: Provider, - current_provider: Option, - app_type_enum: AppType, -} - -impl SwitchService { - pub fn get_providers(db: &DbConnection, app_type: &str) -> Result, 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, 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::() { - 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::() { - 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::().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::().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 { - 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::().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 { - let app_type_enum = app_type.parse::().map_err(|e| e.to_string())?; - live_sync::read_live_settings_for_display(&app_type_enum).map_err(|e| e.to_string()) - } -} diff --git a/src-tauri/src/app/runner.rs b/src-tauri/src/app/runner.rs index 4ac38da1a..4fe95ca40 100644 --- a/src-tauri/src/app/runner.rs +++ b/src-tauri/src/app/runner.rs @@ -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, diff --git a/src-tauri/src/commands/media_task_cmd.rs b/src-tauri/src/commands/media_task_cmd.rs index b7f8ead92..9d814513b 100644 --- a/src-tauri/src/commands/media_task_cmd.rs +++ b/src-tauri/src/commands/media_task_cmd.rs @@ -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::)); + let captured_response_format = Arc::new(Mutex::new(None::)); 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| { + 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(); } diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index ba1602ddf..41b84485d 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -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; diff --git a/src-tauri/src/commands/switch_cmd.rs b/src-tauri/src/commands/switch_cmd.rs deleted file mode 100644 index cb38cacfd..000000000 --- a/src-tauri/src/commands/switch_cmd.rs +++ /dev/null @@ -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, String> { - SwitchService::get_providers(&db, &app_type) -} - -#[tauri::command] -pub fn get_current_switch_provider( - db: State<'_, DbConnection>, - app_type: String, -) -> Result, 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 { - SwitchService::import_default_config(&db, &app_type) -} - -#[tauri::command] -pub fn read_live_provider_settings(app_type: String) -> Result { - SwitchService::read_live_settings(&app_type) -} - -/// 检查配置同步状态 -#[tauri::command] -pub fn check_config_sync_status( - db: State<'_, DbConnection>, - app_type: String, -) -> Result { - // 解析 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 { - // 解析 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}")) -} diff --git a/src/components/Providers.tsx b/src/components/Providers.tsx deleted file mode 100644 index 0688ab6de..000000000 --- a/src/components/Providers.tsx +++ /dev/null @@ -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(defaultProviders); - const [activeProvider, setActiveProvider] = useState("kiro"); - - // 使用 useProviderState hook 管理三个 OAuth providers - const kiro = useProviderState("kiro", { - getCredentials: getKiroCredentials, - getEnvVars: getEnvVariables, - getHash: getTokenFileHash, - checkAndReload: checkAndReloadCredentials, - reloadCredentials: reloadCredentials, - refreshToken: refreshKiroToken, - }); - - const gemini = useProviderState("gemini", { - getCredentials: getGeminiCredentials, - getEnvVars: getGeminiEnvVariables, - getHash: getGeminiTokenFileHash, - checkAndReload: checkAndReloadGeminiCredentials, - reloadCredentials: reloadGeminiCredentials, - refreshToken: refreshGeminiToken, - }); - - const qwen = useProviderState("qwen", { - getCredentials: getQwenCredentials, - getEnvVars: getQwenEnvVariables, - getHash: getQwenTokenFileHash, - checkAndReload: checkAndReloadQwenCredentials, - reloadCredentials: reloadQwenCredentials, - refreshToken: refreshQwenToken, - }); - - // OpenAI Custom state - const [openaiStatus, setOpenaiStatus] = useState( - null, - ); - const [openaiApiKey, setOpenaiApiKey] = useState(""); - const [openaiBaseUrl, setOpenaiBaseUrl] = useState(""); - - // Claude Custom state - const [claudeStatus, setClaudeStatus] = useState( - null, - ); - const [claudeApiKey, setClaudeApiKey] = useState(""); - const [claudeBaseUrl, setClaudeBaseUrl] = useState(""); - - // Default provider state - const [defaultProvider, setDefaultProviderState] = useState("kiro"); - - // Common state - const [showEnv, setShowEnv] = useState(false); - const [showValues, setShowValues] = useState(false); - const [loading, setLoading] = useState(null); - const [message, setMessage] = useState<{ - type: "success" | "error"; - text: string; - } | null>(null); - const [copied, setCopied] = useState(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 ( -
-
-

Provider 管理

-

配置和管理 AI 模型提供商

-
- - {message && ( -
- {message.type === "success" ? ( - - ) : ( - - )} - {message.text} -
- )} - - {/* Provider Tabs */} -
- {["kiro", "gemini", "qwen", "openai", "claude"].map((id) => ( - - ))} -
- - {/* Kiro Panel */} - {activeProvider === "kiro" && ( -
-
-

Kiro 凭证状态

-
- - 最后同步:{" "} - - {formatTime(kiro.lastSync)} - - - - - 监测中 - -
-
-
-
- 凭证路径: - - {kiro.status?.creds_path || - "~/.aws/sso/cache/kiro-auth-token.json"} - -
-
- 区域: - {kiro.status?.region || "未设置"} -
-
- Access Token: - - {kiro.status?.has_access_token ? "✓ 已加载" : "✗ 未加载"} - -
-
- Refresh Token: - - {kiro.status?.has_refresh_token ? "✓ 已加载" : "✗ 未加载"} - -
-
-
- - - -
-
- )} - - {/* Gemini Panel */} - {activeProvider === "gemini" && ( -
-
-

Gemini CLI 凭证状态

-
- - 最后同步:{" "} - - {formatTime(gemini.lastSync)} - - - - - 监测中 - -
-
-
-
- 凭证路径: - - {gemini.status?.creds_path || "~/.gemini/oauth_creds.json"} - -
-
- Token 有效: - - {gemini.status?.is_valid ? "✓ 有效" : "✗ 无效/过期"} - -
-
- Access Token: - - {gemini.status?.has_access_token ? "✓ 已加载" : "✗ 未加载"} - -
-
- Refresh Token: - - {gemini.status?.has_refresh_token ? "✓ 已加载" : "✗ 未加载"} - -
-
-
- - - -
-
- )} - - {/* Qwen Panel */} - {activeProvider === "qwen" && ( -
-
-

通义千问凭证状态

-
- - 最后同步:{" "} - - {formatTime(qwen.lastSync)} - - - - - 监测中 - -
-
-
-
- 凭证路径: - - {qwen.status?.creds_path || "~/.qwen/oauth_creds.json"} - -
-
- Token 有效: - - {qwen.status?.is_valid ? "✓ 有效" : "✗ 无效/过期"} - -
-
- Access Token: - - {qwen.status?.has_access_token ? "✓ 已加载" : "✗ 未加载"} - -
-
- Refresh Token: - - {qwen.status?.has_refresh_token ? "✓ 已加载" : "✗ 未加载"} - -
-
-
- - - -
-
- )} - - {/* OpenAI Custom Panel */} - {activeProvider === "openai" && ( -
-

OpenAI 自定义配置

-
-
- - setOpenaiApiKey(e.target.value)} - placeholder="sk-..." - className="w-full rounded-lg border bg-background px-3 py-2 text-sm" - /> -
-
- - setOpenaiBaseUrl(e.target.value)} - placeholder="https://api.openai.com/v1" - className="w-full rounded-lg border bg-background px-3 py-2 text-sm" - /> -
-
- 状态: - - {openaiStatus?.has_api_key ? "✓ 已配置" : "✗ 未配置"} - -
-
- -
- )} - - {/* Claude Custom Panel */} - {activeProvider === "claude" && ( -
-

Claude 自定义配置

-
-
- - setClaudeApiKey(e.target.value)} - placeholder="sk-ant-..." - className="w-full rounded-lg border bg-background px-3 py-2 text-sm" - /> -
-
- - setClaudeBaseUrl(e.target.value)} - placeholder="https://api.anthropic.com" - className="w-full rounded-lg border bg-background px-3 py-2 text-sm" - /> -
-
- 状态: - - {claudeStatus?.has_api_key ? "✓ 已配置" : "✗ 未配置"} - -
-
- -
- )} - - {/* .env 变量展示 */} - {showEnv && ( -
-
-

.env 环境变量 ({activeProvider})

-
- - -
-
- {currentEnvVars.length === 0 ? ( -

- 暂无环境变量,请先加载凭证 -

- ) : ( -
- {currentEnvVars.map((v) => ( -
- {v.key} - = - - {showValues ? v.value : v.masked} - - -
- ))} -
- )} -
- )} - - {/* Provider 列表 */} -
-
-

Provider 列表

- - 当前默认:{" "} - - {getProviderName(defaultProvider)} - - -
- {providers.map((provider) => ( -
-
-
-
-
-

{provider.name}

- {defaultProvider === provider.id && ( - - 默认 - - )} -
-

- {provider.description} -

-
-
-
- {defaultProvider !== provider.id && ( - - )} - {(provider.id === "kiro" || - provider.id === "gemini" || - provider.id === "qwen") && ( - - )} - -
-
- ))} -
- -

- 💡 提示:系统每 5 - 秒自动检查凭证文件变化,如有更新会自动重新加载并记录日志 -

-
- ); -} diff --git a/src/components/README.md b/src/components/README.md index d0fe58bc2..286122bbf 100644 --- a/src/components/README.md +++ b/src/components/README.md @@ -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` - 启动画面组件 ## 更新提醒 diff --git a/src/components/WebModeWarning.tsx b/src/components/WebModeWarning.tsx deleted file mode 100644 index 83c188147..000000000 --- a/src/components/WebModeWarning.tsx +++ /dev/null @@ -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 ( - - - - - - - ⚠️ Web Mode - Limited Functionality - - Running in browser mode. Some features require Tauri backend. For - full functionality, run: npm run tauri dev - - - - setVisible(false)} title="Close"> - - - - ); -} diff --git a/src/components/agent/AgentChatPage.tsx b/src/components/agent/AgentChatPage.tsx deleted file mode 100644 index 3697fc652..000000000 --- a/src/components/agent/AgentChatPage.tsx +++ /dev/null @@ -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"; diff --git a/src/components/agent/AgentSkillsPanel.tsx b/src/components/agent/AgentSkillsPanel.tsx deleted file mode 100644 index 9f6e98573..000000000 --- a/src/components/agent/AgentSkillsPanel.tsx +++ /dev/null @@ -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 ( - - -
- - 加载 Skills... -
-
-
- ); - } - - return ( - - - - - - - - {skills.length > 0 ? ( - <> - {/* Skills 名称列表 - 紧凑格式 */} -
- {skills.join(" · ")} -
- - {/* 使用提示 */} -

- 💡 直接描述任务,Agent 会自动使用合适的 Skill -

- - {/* 管理按钮 */} - - - ) : ( - <> - {/* 无 Skills 提示 */} -

- 暂无已安装的技能, - -

- - )} -
-
-
-
- ); -} diff --git a/src/components/agent/README.md b/src/components/agent/README.md index 6ac6935d7..940947fe5 100644 --- a/src/components/agent/README.md +++ b/src/components/agent/README.md @@ -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) diff --git a/src/components/agent/chat/AgentChatWorkspace.tsx b/src/components/agent/chat/AgentChatWorkspace.tsx index de523a196..50cfd2a7c 100644 --- a/src/components/agent/chat/AgentChatWorkspace.tsx +++ b/src/components/agent/chat/AgentChatWorkspace.tsx @@ -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, }); diff --git a/src/components/agent/chat/components/ImageTaskViewer.test.tsx b/src/components/agent/chat/components/ImageTaskViewer.test.tsx index d09b02d51..6d5bcd235 100644 --- a/src/components/agent/chat/components/ImageTaskViewer.test.tsx +++ b/src/components/agent/chat/components/ImageTaskViewer.test.tsx @@ -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: [ diff --git a/src/components/agent/chat/components/ImageTaskViewer.tsx b/src/components/agent/chat/components/ImageTaskViewer.tsx index 96b0e79e9..5086aa40c 100644 --- a/src/components/agent/chat/components/ImageTaskViewer.tsx +++ b/src/components/agent/chat/components/ImageTaskViewer.tsx @@ -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({
{selectedOutput ? ( - + ( + + )} + renderFallback={(reason) => ( +
+
+ {reason === "empty" && + (selectedTask?.status === "running" || + selectedTask?.status === "routing" || + selectedTask?.status === "queued") ? ( + + ) : ( + + )} +
+ {reason === "error" + ? resolveImageUnavailableTitle(selectedTask?.status) + : statusLabel} +
+
+ {reason === "error" + ? resolveImageUnavailableDescription(selectedTask?.mode) + : resolveEmptyStateDescription( + selectedTask?.status, + selectedTask?.failureMessage, + selectedTask?.mode, + )} +
+
+
+ )} + /> ) : (
@@ -317,24 +386,22 @@ export function ImageTaskViewer({
- {sourceImageUrl ? ( - { - ) : ( - - {(selectedTask?.mode || "").trim().toLowerCase() === - "variation" - ? "参考图待同步" - : "来源图待同步"} - - )} + ( + + {resolveSourcePlaceholderLabel( + selectedTask?.mode, + reason, + )} + + )} + />
@@ -456,10 +523,15 @@ export function ImageTaskViewer({ : "border-slate-200 hover:border-slate-300", )} > - {output.prompt ( +
+ 预览失败 +
+ )} /> ); diff --git a/src/components/agent/chat/components/ImageWorkbenchMessagePreview.tsx b/src/components/agent/chat/components/ImageWorkbenchMessagePreview.tsx index 68292abe0..40854c6fc 100644 --- a/src/components/agent/chat/components/ImageWorkbenchMessagePreview.tsx +++ b/src/components/agent/chat/components/ImageWorkbenchMessagePreview.tsx @@ -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<
- {hasImage ? ( - {preview.prompt - ) : ( -
-
- {preview.status === "running" ? ( - - ) : ( - - )} -
- {resolvePlaceholderLabel(preview)} + ( +
+
+ {reason === "empty" && preview.status === "running" ? ( + + ) : ( + + )} +
+ {reason === "error" + ? resolveImageUnavailableLabel(preview) + : resolvePlaceholderLabel(preview)} +
-
- )} + )} + />
@@ -300,20 +309,21 @@ export const ImageWorkbenchMessagePreview: React.FC<
- {hasSourceImage ? ( - { - ) : ( - - {resolveSourcePlaceholderLabel(preview)} - - )} + ( + + {reason === "error" + ? `${resolveSourceLabel(preview.mode)}暂时无法显示` + : resolveSourcePlaceholderLabel(preview)} + + )} + />
diff --git a/src/components/agent/chat/components/RenderableTaskImage.tsx b/src/components/agent/chat/components/RenderableTaskImage.tsx new file mode 100644 index 000000000..7fa8833b2 --- /dev/null +++ b/src/components/agent/chat/components/RenderableTaskImage.tsx @@ -0,0 +1,59 @@ +import { + useEffect, + useState, + type ImgHTMLAttributes, + type ReactNode, +} from "react"; + +type TaskImageFallbackReason = "empty" | "error"; + +interface RenderableTaskImageProps extends Omit< + ImgHTMLAttributes, + "src" | "children" +> { + src?: string | null; + renderFallback: (reason: TaskImageFallbackReason) => ReactNode; + renderImage?: ( + props: ImgHTMLAttributes & { 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 & { + src: string; + } = { + ...imageProps, + src: normalizedSrc, + onError: (event) => { + setLoadFailed(true); + onError?.(event); + }, + }; + + if (renderImage) { + return <>{renderImage(resolvedImageProps)}; + } + + return ; +} diff --git a/src/components/agent/chat/components/StableProcessingNotice.tsx b/src/components/agent/chat/components/StableProcessingNotice.tsx deleted file mode 100644 index 24f62594b..000000000 --- a/src/components/agent/chat/components/StableProcessingNotice.tsx +++ /dev/null @@ -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 = ({ - scope = "request", - className, - testId = "stable-processing-notice", -}) => { - return ( -
-
- -
-
-
- {STABLE_PROCESSING_LABEL} -
-
- {getStableProcessingDescription(scope)} -
-
-
- ); -}; diff --git a/src/components/agent/chat/config.ts b/src/components/agent/chat/config.ts deleted file mode 100644 index a385f9750..000000000 --- a/src/components/agent/chat/config.ts +++ /dev/null @@ -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; -} diff --git a/src/components/agent/chat/hooks/useStableProcessingNotice.ts b/src/components/agent/chat/hooks/useStableProcessingNotice.ts deleted file mode 100644 index a085ce2d8..000000000 --- a/src/components/agent/chat/hooks/useStableProcessingNotice.ts +++ /dev/null @@ -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(); - -export function resetStableProcessingNoticeMemoryForTest() { - shownStableProcessingNoticeKeys.clear(); -} - -function getStableProcessingNoticeKey({ - providerType, - model, -}: Pick) { - 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(noticeKey); - const hideTimerRef = useRef(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; -} diff --git a/src/components/agent/chat/service-skills/useServiceSkills.test.tsx b/src/components/agent/chat/service-skills/useServiceSkills.test.tsx index 36d888245..009c05cd5 100644 --- a/src/components/agent/chat/service-skills/useServiceSkills.test.tsx +++ b/src/components/agent/chat/service-skills/useServiceSkills.test.tsx @@ -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, diff --git a/src/components/agent/chat/skill-selection/CharacterMention.test.tsx b/src/components/agent/chat/skill-selection/CharacterMention.test.tsx index bdcdcbd9e..5b5e851ca 100644 --- a/src/components/agent/chat/skill-selection/CharacterMention.test.tsx +++ b/src/components/agent/chat/skill-selection/CharacterMention.test.tsx @@ -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({ diff --git a/src/components/agent/chat/skill-selection/CharacterMention.tsx b/src/components/agent/chat/skill-selection/CharacterMention.tsx index 1add4bc0f..ca385f349 100644 --- a/src/components/agent/chat/skill-selection/CharacterMention.tsx +++ b/src/components/agent/chat/skill-selection/CharacterMention.tsx @@ -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("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({ 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 = ({ mentionQuery, builtinCommands, slashCommands, + sceneCommands, mentionServiceSkills, filteredCharacters, installedSkills, @@ -117,6 +123,7 @@ export const CharacterMentionPanel: React.FC = ({ onSelectBuiltinCommand, onSelectServiceSkill, onSelectSlashCommand, + onSelectSceneCommand, onSelectCharacter, onSelectInstalledSkill, onSelectAvailableSkill, @@ -130,8 +137,10 @@ export const CharacterMentionPanel: React.FC = ({ ); 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 = ({ ))} ) : null} + {visibleSceneCommands.length > 0 ? ( + + {visibleSceneCommands.map((command) => ( + onSelectSceneCommand(command)} + className="cursor-pointer" + > + +
+
{command.commandPrefix}
+
+ {command.description} +
+
+
+ ))} +
+ ) : null} {visibleBuiltinCommands.length > 0 ? ( {visibleBuiltinCommands.map((command) => ( diff --git a/src/components/agent/chat/skill-selection/SkillSelector.tsx b/src/components/agent/chat/skill-selection/SkillSelector.tsx index 1d5e33988..206820fb0 100644 --- a/src/components/agent/chat/skill-selection/SkillSelector.tsx +++ b/src/components/agent/chat/skill-selection/SkillSelector.tsx @@ -126,6 +126,7 @@ export const SkillSelectorContent: React.FC = ({ mode="mention" mentionQuery={query} builtinCommands={[] satisfies BuiltinInputCommand[]} + sceneCommands={[]} slashCommands={[]} mentionServiceSkills={mentionServiceSkills} filteredCharacters={[]} @@ -135,6 +136,7 @@ export const SkillSelectorContent: React.FC = ({ onQueryChange={onQueryChange} onSelectBuiltinCommand={() => undefined} onSelectServiceSkill={(skill) => onSelectServiceSkill?.(skill)} + onSelectSceneCommand={() => undefined} onSelectSlashCommand={() => undefined} onSelectCharacter={() => undefined} onSelectInstalledSkill={onSelectInstalledSkill} diff --git a/src/components/agent/chat/skill-selection/builtinCommands.ts b/src/components/agent/chat/skill-selection/builtinCommands.ts index 94778cc5d..ebaaa1015 100644 --- a/src/components/agent/chat/skill-selection/builtinCommands.ts +++ b/src/components/agent/chat/skill-selection/builtinCommands.ts @@ -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), + ), + ); } diff --git a/src/components/agent/chat/workspace/imageWorkbenchHelpers.ts b/src/components/agent/chat/workspace/imageWorkbenchHelpers.ts index 08b8b4e5e..81cb7bee0 100644 --- a/src/components/agent/chat/workspace/imageWorkbenchHelpers.ts +++ b/src/components/agent/chat/workspace/imageWorkbenchHelpers.ts @@ -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 { @@ -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, diff --git a/src/components/agent/chat/workspace/useWorkspaceImageTaskPreviewRuntime.test.tsx b/src/components/agent/chat/workspace/useWorkspaceImageTaskPreviewRuntime.test.tsx index 6e8bdb98c..733eb28be 100644 --- a/src/components/agent/chat/workspace/useWorkspaceImageTaskPreviewRuntime.test.tsx +++ b/src/components/agent/chat/workspace/useWorkspaceImageTaskPreviewRuntime.test.tsx @@ -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", diff --git a/src/components/agent/chat/workspace/useWorkspaceImageWorkbenchActionRuntime.test.tsx b/src/components/agent/chat/workspace/useWorkspaceImageWorkbenchActionRuntime.test.tsx index 70ae5de4a..363832c42 100644 --- a/src/components/agent/chat/workspace/useWorkspaceImageWorkbenchActionRuntime.test.tsx +++ b/src/components/agent/chat/workspace/useWorkspaceImageWorkbenchActionRuntime.test.tsx @@ -242,10 +242,6 @@ describe("useWorkspaceImageWorkbenchActionRuntime", () => { content: "图片任务已创建,正在准备执行。", isThinking: true, toolCalls: [ - expect.objectContaining({ - name: "skill", - status: "completed", - }), expect.objectContaining({ name: "limeCreateImageGenerationTask", status: "running", diff --git a/src/components/agent/chat/workspace/useWorkspaceSendActions.test.tsx b/src/components/agent/chat/workspace/useWorkspaceSendActions.test.tsx index c7fa97d37..7677f4b12 100644 --- a/src/components/agent/chat/workspace/useWorkspaceSendActions.test.tsx +++ b/src/components/agent/chat/workspace/useWorkspaceSendActions.test.tsx @@ -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): 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({ diff --git a/src/components/agent/chat/workspace/useWorkspaceSendActions.ts b/src/components/agent/chat/workspace/useWorkspaceSendActions.ts index fa41ef78f..20b9c2ecc 100644 --- a/src/components/agent/chat/workspace/useWorkspaceSendActions.ts +++ b/src/components/agent/chat/workspace/useWorkspaceSendActions.ts @@ -358,6 +358,7 @@ interface UseWorkspaceSendActionsParams { handleAutoLaunchMatchedSiteSkill: ( match: AutoMatchedSiteSkill, ) => Promise; + handleRuntimeSceneLaunch: (rawText: string) => Promise; 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, ], diff --git a/src/components/agent/chat/workspace/useWorkspaceServiceSkillEntryActions.test.tsx b/src/components/agent/chat/workspace/useWorkspaceServiceSkillEntryActions.test.tsx index 7834b281b..60fce7931 100644 --- a/src/components/agent/chat/workspace/useWorkspaceServiceSkillEntryActions.test.tsx +++ b/src/components/agent/chat/workspace/useWorkspaceServiceSkillEntryActions.test.tsx @@ -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) { 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({ diff --git a/src/components/agent/chat/workspace/useWorkspaceServiceSkillEntryActions.ts b/src/components/agent/chat/workspace/useWorkspaceServiceSkillEntryActions.ts index d18ecc8b8..603d6def4 100644 --- a/src/components/agent/chat/workspace/useWorkspaceServiceSkillEntryActions.ts +++ b/src/components/agent/chat/workspace/useWorkspaceServiceSkillEntryActions.ts @@ -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 => { + 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) => { await handleServiceSkillLaunch(match.skill, match.slotValues, { @@ -996,6 +1156,7 @@ export function useWorkspaceServiceSkillEntryActions({ handleServiceSkillSelect, handleServiceSkillDialogOpenChange, handleServiceSkillLaunch, + handleRuntimeSceneLaunch, handleAutoLaunchMatchedSiteSkill, handleServiceSkillBrowserRuntimeLaunch, handleServiceSkillAutomationSetup, diff --git a/src/components/agent/index.ts b/src/components/agent/index.ts deleted file mode 100644 index 8abceb326..000000000 --- a/src/components/agent/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { AgentChatPage } from "./AgentChatPage"; -export { AgentSkillsPanel } from "./AgentSkillsPanel"; diff --git a/src/components/image-gen/tabs/index.ts b/src/components/image-gen/tabs/index.ts deleted file mode 100644 index ca3d5381d..000000000 --- a/src/components/image-gen/tabs/index.ts +++ /dev/null @@ -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"; diff --git a/src/components/image-gen/useImageGen.test.ts b/src/components/image-gen/useImageGen.test.ts index db1139fbf..a1c6d6a07 100644 --- a/src/components/image-gen/useImageGen.test.ts +++ b/src/components/image-gen/useImageGen.test.ts @@ -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; + + 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; const basePayload = JSON.parse( - ((fetchMock.mock.calls[2]?.[1] as { body?: string })?.body ?? "{}"), + (fetchMock.mock.calls[2]?.[1] as { body?: string })?.body ?? "{}", ) as Record; expect(editPayload).toMatchObject({ diff --git a/src/components/image-gen/useImageGen.ts b/src/components/image-gen/useImageGen.ts index 7f3a19be0..867fca935 100644 --- a/src/components/image-gen/useImageGen.ts +++ b/src/components/image-gen/useImageGen.ts @@ -192,7 +192,10 @@ async function fetchWithManagedAbort( ): Promise { 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; diff --git a/src/components/layout/PanelLayout.tsx b/src/components/layout/PanelLayout.tsx deleted file mode 100644 index e96bcbd32..000000000 --- a/src/components/layout/PanelLayout.tsx +++ /dev/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; - /** 活动块 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, -}) => ( - -
- -); - -/** 块渲染器 */ -const BlockRenderer: React.FC<{ - block: Block; - viewModel: BlockViewModel; - visible: boolean; -}> = ({ block, viewModel, visible }) => { - const Component = blockRegistry.getComponent(block.type); - - if (!Component) { - return ( -
- 未知的块类型: {block.type} -
- ); - } - - return ; -}; - -/** 面板节点渲染器 */ -const PanelNodeRenderer: React.FC<{ - node: PanelNode; - blocks: Map; - 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 ( -
- 块不存在 -
- ); - } - - 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 ( - - ); - } - - // 组节点 - if (node.type === "group" && node.children && node.children.length > 0) { - const orientation = node.direction ?? "horizontal"; - - return ( - - {node.children.map((child, index) => ( - - {index > 0 && } - onPanelResize?.(child.id, size.asPercentage)} - > - - - - ))} - - ); - } - - // 空节点 - return ( -
- 空面板 -
- ); -}; - -/** - * 分屏布局组件 - */ -export const PanelLayout: React.FC = ({ - 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 ( -
- -
- ); - } - } - - return ( -
- -
- ); -}; - -export default PanelLayout; diff --git a/src/components/memory/FeedbackStats.tsx b/src/components/memory/FeedbackStats.tsx deleted file mode 100644 index 335f97383..000000000 --- a/src/components/memory/FeedbackStats.tsx +++ /dev/null @@ -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(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 ( -
- - - - -
- ); -} - -function StatCard({ label, value }: { label: string; value: number | string }) { - return ( -
-
{label}
-
{value}
-
- ); -} diff --git a/src/components/memory/MemoryFeedback.tsx b/src/components/memory/MemoryFeedback.tsx deleted file mode 100644 index a093b03c4..000000000 --- a/src/components/memory/MemoryFeedback.tsx +++ /dev/null @@ -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 ( -
- - -
- ); -} diff --git a/src/components/model-selector/EnhancedModelList.tsx b/src/components/model-selector/EnhancedModelList.tsx deleted file mode 100644 index dc9a9c596..000000000 --- a/src/components/model-selector/EnhancedModelList.tsx +++ /dev/null @@ -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; - /** 是否加载中 */ - 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>( - 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 = {}; - - // 先添加收藏组 - 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 ( -
- - 加载模型列表... -
- ); - } - - if (error) { - return ( -
- - {error} -
- ); - } - - if (models.length === 0) { - return ( -
-

暂无可用模型

-

请等待模型数据加载

-
- ); - } - - return ( -
- {/* 搜索框 */} - {showSearch && ( -
- - 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" - /> -
- )} - - {/* 模型列表 */} -
- {Object.entries(groupedModels).map(([groupId, groupModels]) => { - const isExpanded = expandedGroups.has(groupId); - const groupName = getGroupName(groupId, groupModels[0]); - - return ( -
- {/* 分组头部 */} - {groupByProvider && ( - - )} - - {/* 模型列表 */} - {(!groupByProvider || isExpanded) && ( -
- {groupModels.map((model) => ( - onSelectModel?.(model)} - onToggleFavorite={() => onToggleFavorite?.(model.id)} - showPricing={showPricing} - /> - ))} -
- )} -
- ); - })} -
- - {/* 无搜索结果 */} - {filteredModels.length === 0 && searchQuery && ( -
-

未找到匹配的模型

-

尝试其他搜索词

-
- )} -
- ); -} - -/** 单个模型项 */ -function ModelItem({ - model, - isSelected, - isFavorite, - onSelect, - onToggleFavorite, - showPricing, -}: { - model: EnhancedModelMetadata; - isSelected: boolean; - isFavorite: boolean; - onSelect: () => void; - onToggleFavorite: () => void; - showPricing: boolean; -}) { - return ( -
-
- {/* 选中指示器 */} -
- {isSelected && } -
- - {/* 模型信息 */} -
-
- - {model.display_name} - - {model.is_latest && ( - - 最新 - - )} - -
-
- {model.id} - {model.limits.context_length && ( - <> - · - {formatContextLength(model.limits.context_length)} - - )} -
-
-
- - {/* 能力标签和操作 */} -
- {/* 能力图标 */} -
- {model.capabilities.vision && ( - - - - )} - {model.capabilities.tools && ( - - - - )} - {model.capabilities.reasoning && ( - - - - )} -
- - {/* 定价 */} - {showPricing && model.pricing && ( -
- - {model.pricing.input_per_million?.toFixed(2) || "?"} -
- )} - - {/* 收藏按钮 */} - -
-
- ); -} - -/** 服务等级徽章 */ -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 ( - - {config.label} - - ); -} - -/** 获取分组名称 */ -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); -} diff --git a/src/components/model-selector/ModeToggle.tsx b/src/components/model-selector/ModeToggle.tsx deleted file mode 100644 index 9ae84c7ac..000000000 --- a/src/components/model-selector/ModeToggle.tsx +++ /dev/null @@ -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 ( -
- - -
- ); -} diff --git a/src/components/model-selector/ModelList.tsx b/src/components/model-selector/ModelList.tsx deleted file mode 100644 index 26d16a023..000000000 --- a/src/components/model-selector/ModelList.tsx +++ /dev/null @@ -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 ( -
- - 加载模型列表... -
- ); - } - - if (error) { - return ( -
- - {error} -
- ); - } - - if (models.length === 0) { - return ( -
-

暂无可用模型

-

请先添加凭证

-
- ); - } - - return ( -
- {models.map((model) => { - const isSelected = model.id === selectedModelId; - - return ( - - ); - })} -
- ); -} - -/** 负载指示器 */ -function LoadIndicator({ load }: { load: number }) { - const color = - load < 30 ? "bg-green-500" : load < 70 ? "bg-yellow-500" : "bg-red-500"; - - return ( -
-
-
-
- {load}% -
- ); -} - -/** 格式化上下文长度 */ -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); -} diff --git a/src/components/model-selector/ModelSelector.tsx b/src/components/model-selector/ModelSelector.tsx deleted file mode 100644 index 3032e5a3c..000000000 --- a/src/components/model-selector/ModelSelector.tsx +++ /dev/null @@ -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(initialMode); - const [selectedModel, setSelectedModel] = useState( - 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 ( -
- {/* 头部:模式切换和刷新 */} -
- {showModeToggle && ( - - )} - -
- {/* 统计信息 */} - {showStats && poolStats && ( -
- - - {poolStats.healthy_count}/{poolStats.total_count} 可用 - -
- )} - - {/* 刷新按钮 */} - -
-
- - {/* 等级选择器 */} - - - {/* 专家模式:显示模型列表 */} - {mode === "expert" && ( - - )} - - {/* 简单模式:显示当前选择 */} - {mode === "simple" && selectedModel && ( -
-
- {selectedModel.display_name} -
-
- {selectedModel.provider_type} -
-
- )} - - {/* 错误提示 */} - {error && ( -
- {error} -
- )} -
- ); -} - -// 导出子组件 -export { TierSelector } from "./TierSelector"; -export { ModeToggle } from "./ModeToggle"; -export { ModelList } from "./ModelList"; -export type { SelectionMode } from "./ModeToggle"; diff --git a/src/components/model-selector/ProviderModelSelector.test.tsx b/src/components/model-selector/ProviderModelSelector.test.tsx deleted file mode 100644 index be7bd6a8f..000000000 --- a/src/components/model-selector/ProviderModelSelector.test.tsx +++ /dev/null @@ -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> = {}, -) { - const container = document.createElement("div"); - document.body.appendChild(container); - const root = createRoot(container); - - const mergedProps: React.ComponentProps = { - onSelect: vi.fn(), - initialProviderId: "custom-codex", - ...props, - }; - - act(() => { - root.render(); - }); - - 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"); - }); -}); diff --git a/src/components/model-selector/ProviderModelSelector.tsx b/src/components/model-selector/ProviderModelSelector.tsx deleted file mode 100644 index 635c46bcb..000000000 --- a/src/components/model-selector/ProviderModelSelector.tsx +++ /dev/null @@ -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 = ({ - provider, - isSelected, - onClick, -}) => { - return ( - - ); -}; - -interface ModelItemProps { - model: EnhancedModelMetadata; - isSelected: boolean; - onClick: () => void; -} - -/** 模型列表项 */ -const ModelItem: React.FC = ({ - model, - isSelected, - onClick, -}) => { - return ( - - ); -}; - -// ============================================================================ -// 主组件 -// ============================================================================ - -/** - * 双栏模型选择器组件 - * - * 左侧显示已配置凭证的 Provider 列表(单选) - * 右侧显示选中 Provider 对应的模型列表(单选) - * - * @example - * ```tsx - * { - * console.log("选中模型:", model.display_name); - * }} - * /> - * ``` - */ -export const ProviderModelSelector: React.FC = ({ - onSelect, - initialProviderId, - initialModelId, - className, -}) => { - // 状态 - const [selectedProviderId, setSelectedProviderId] = useState( - initialProviderId || null, - ); - const [selectedModelId, setSelectedModelId] = useState( - 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 ( -
- -

暂无已配置的 Provider

-

请先在凭证池中添加凭证

-
- ); - } - - return ( -
- {/* 左侧:Provider 列表 */} -
-
-

Providers

-

已配置凭证的

-
-
- {providersLoading ? ( -
- -
- ) : ( - configuredProviders.map((provider) => ( - handleSelectProvider(provider.key)} - /> - )) - )} -
-
- - {/* 右侧:模型列表 */} -
-
-

Models

-

- {selectedProvider - ? `${getProviderLabel(selectedProvider.key)} 的模型` - : "请选择 Provider"} -

-
-
- {modelsLoading ? ( -
- -
- ) : modelsError ? ( -
- -

{modelsError}

-
- ) : compatibleModels.length === 0 ? ( -
-

暂无模型数据

-
- ) : ( - <> - {incompatibleModelCount > 0 ? ( -
- 已隐藏 {incompatibleModelCount} 个当前登录态不兼容的模型 -
- ) : null} - {compatibleModels.map((model) => ( - handleSelectModel(model)} - /> - ))} - - )} -
-
-
- ); -}; - -export default ProviderModelSelector; diff --git a/src/components/model-selector/TierSelector.tsx b/src/components/model-selector/TierSelector.tsx deleted file mode 100644 index 0c4cfc04d..000000000 --- a/src/components/model-selector/TierSelector.tsx +++ /dev/null @@ -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: , - 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: , - 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: , - 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 ( -
- {tierOptions.map((option) => { - const isSelected = value === option.id; - const count = modelCounts?.[option.id]; - const hasModels = count === undefined || count > 0; - - return ( - - ); - })} -
- ); -} - -// eslint-disable-next-line react-refresh/only-export-components -export { tierOptions }; -export type { TierOption }; diff --git a/src/components/model-selector/index.ts b/src/components/model-selector/index.ts deleted file mode 100644 index 239c6dcd7..000000000 --- a/src/components/model-selector/index.ts +++ /dev/null @@ -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"; diff --git a/src/components/onboarding/constants.test.ts b/src/components/onboarding/constants.test.ts index 41ee038a0..b5c901541 100644 --- a/src/components/onboarding/constants.test.ts +++ b/src/components/onboarding/constants.test.ts @@ -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"); }); }); diff --git a/src/components/onboarding/constants.ts b/src/components/onboarding/constants.ts index 21da7e526..a35a8ed06 100644 --- a/src/components/onboarding/constants.ts +++ b/src/components/onboarding/constants.ts @@ -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"; diff --git a/src/components/onboarding/steps/CompleteStep.tsx b/src/components/onboarding/steps/CompleteStep.tsx index 50db772ec..daf87b6bb 100644 --- a/src/components/onboarding/steps/CompleteStep.tsx +++ b/src/components/onboarding/steps/CompleteStep.tsx @@ -66,7 +66,7 @@ export function CompleteStep({ onFinish }: CompleteStepProps) { Lime 已准备就绪,您可以开始使用了。 - 提示:您可以在左侧导航栏的"插件中心"随时安装插件 + 提示:后续可在设置中继续调整语音输入和快捷键。 - -
-
- - {!running ? ( -
- -

Dashboard 暂不可用

-

- 请先完成配置同步并启动 Gateway,启动成功后会在这里直接显示 Dashboard - 页面。 -

-
- ) : !dashboardUrl ? ( -
- 正在准备 Dashboard 地址... -
- ) : ( -
-
- 当前地址 - {dashboardUrl} -
-
- {(loading || frameLoading) && !frameBlocked && ( -
-
- - Dashboard 加载中... -
-
- )} - {frameBlocked && ( -
-
-

内嵌模式加载失败

-

- Dashboard 很可能被目标页的鉴权、Cookie 或 iframe - 策略拦截,因此在当前页面内无法稳定显示。 -

-
- {onOpenWindow ? ( - - ) : null} - - -
-
-
- )} -