From 48c76e6180ea8da55d7988ddba141d6c76f74ba5 Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Fri, 29 May 2026 14:05:01 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=A8=A1=E5=9E=8B=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=AE=A1=E6=9F=A5=E4=BF=AE=E5=A4=8D=E4=B8=8E=E5=BD=92=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复 registry 测试 ai mock 缺失 createProviderRegistry 导出 - 新增 POST /api/providers/test 支持未保存供应商配置连通性测试 - 供应商表单新增测试连接按钮,新建默认 openai-compatible - 连通性测试按 ok 展示成功/失败,不再统一 success 样式 - 模型表单新建时也可测试供应商连接 - 模型页使用独立 provider 列表避免分页/搜索影响 - 移除模型管理组件内联 style - 新增 ProviderTestResultResponse 共享响应类型 - 新增 bun run format:check 脚本 - 补充关键测试覆盖(删除关联、连通性、默认类型、表单测试) - 更新 docs/user/usage.md、docs/development/*、design.md、tasks.md - 归档 change 至 openspec/changes/archive/2026-05-29-add-model-management --- docs/development/README.md | 1 + docs/development/backend.md | 11 +- docs/development/frontend.md | 4 + docs/user/usage.md | 10 + .../add-model-management/.openspec.yaml | 2 - .../changes/add-model-management/design.md | 225 ------------------ .../changes/add-model-management/tasks.md | 89 ------- package.json | 1 + src/server/routes/providers/test.ts | 46 +++- src/server/server.ts | 6 + src/shared/api.ts | 4 + src/web/hooks/use-providers.ts | 23 +- .../models/components/ModelFormModal.tsx | 23 +- .../pages/models/components/ModelTable.tsx | 12 +- .../models/components/ProviderFormModal.tsx | 47 +++- .../pages/models/components/ProviderTable.tsx | 18 +- src/web/pages/models/index.tsx | 19 +- tests/server/ai/registry.test.ts | 27 ++- tests/server/routes/providers.test.ts | 79 +++++- tests/web/components/ModelTable.test.tsx | 2 + tests/web/components/ProviderTable.test.tsx | 22 ++ tests/web/hooks/use-providers.test.ts | 22 ++ tests/web/routes/models.test.tsx | 100 ++++++++ 23 files changed, 440 insertions(+), 353 deletions(-) delete mode 100644 openspec/changes/add-model-management/.openspec.yaml delete mode 100644 openspec/changes/add-model-management/design.md delete mode 100644 openspec/changes/add-model-management/tasks.md diff --git a/docs/development/README.md b/docs/development/README.md index 3f900ee..10a87bf 100644 --- a/docs/development/README.md +++ b/docs/development/README.md @@ -27,6 +27,7 @@ | `bun run typecheck` | TypeScript 类型检查 | | `bun run lint` | ESLint 和 Prettier 格式检查 | | `bun run format` | Prettier 自动格式化 | +| `bun run format:check` | Prettier 格式检查 | | `bun test` | 运行全部测试 | | `bun run check` | schema:check + typecheck + lint + test | | `bun run build` | 构建生产可执行文件 | diff --git a/docs/development/backend.md b/docs/development/backend.md index 6ee60ff..807b3e0 100644 --- a/docs/development/backend.md +++ b/docs/development/backend.md @@ -74,7 +74,7 @@ middleware.ts 提供 API 参数校验函数: - `AIProviderConfig` — 供应商配置(name、type、baseUrl、apiKey) - `AIModelConfig` — 模型配置(providerId、modelId、capabilities) -- `AIRegistryConfig` — Registry 构建配置(providers、models) +- `AIRegistryConfig` — Registry 构建配置(providers、models),供后续 AI 调用层组合使用 ### Registry 构建 @@ -83,7 +83,14 @@ middleware.ts 提供 API 参数校验函数: - `buildProviderRegistry(db)` — 从 DB 查询启用的供应商,构建 Vercel AI SDK Provider Registry - `testProviderConnection(config)` — 使用 generateText 测试供应商连接 -每次 AI 调用时从 DB 查询 providers,构建 registry 后通过 `registry.languageModel('providerId:modelId')` 获取模型实例。不使用缓存层。 +每次 AI 调用时从 DB 查询 enabled providers,构建 registry 后通过 `registry.languageModel('providerId:modelId')` 获取模型实例。不使用缓存层。模型是否存在、是否启用以及业务能力标签由调用方基于 models 表先行校验,registry 只负责将 providerId/modelId 映射到 AI SDK 模型实例。 + +### 供应商连通性测试 + +供应商连通性测试返回 `{ providerTestResponse: { ok, message } }`,前端根据 `ok` 展示成功或失败提示。 + +- `POST /api/providers/:id/test` — 使用已保存供应商配置测试连接 +- `POST /api/providers/test` — 使用表单中尚未保存的供应商配置测试连接 ### 支持的供应商类型 diff --git a/docs/development/frontend.md b/docs/development/frontend.md index 56643b2..1495e3b 100644 --- a/docs/development/frontend.md +++ b/docs/development/frontend.md @@ -131,6 +131,10 @@ Sidebar(`src/web/components/Sidebar/index.tsx`)是纯展示/导航组件, Workbench 项目上下文通过 `ProjectContext` 提供,在 `WorkbenchProjectGate` 中从 URL path param 读取 `projectId`,通过 `useProject(projectId)` 加载项目,仅 active 项目渲染工作台布局,不存在或 archived 项目显示"项目不存在或不可访问"。 +模型管理页面(`src/web/pages/models/index.tsx`)属于 Admin 路由 `/models`,通过 antd `Tabs` 在同页组织供应商和模型两个视图。页面使用 `ProviderToolbar`、`ProviderTable`、`ProviderFormModal`、`ModelToolbar`、`ModelTable`、`ModelFormModal` 拆分筛选、表格和表单职责;模型表单和模型表格使用独立 provider 列表查询,不能复用供应商标签页当前分页或搜索结果作为全量选项。 + +供应商表单必须支持未保存配置的连通性测试,新建供应商时 type 默认 `openai-compatible`,baseURL 不设默认值。连通性测试返回 `ok: false` 时应展示失败反馈,不得使用成功提示样式。 + - 生产入口必须启用 `ErrorBoundary`,运行时渲染异常使用 antd `Result status="500"` 或等价组件展示。 - `ReactQueryDevtools` 仅在 `import.meta.env.DEV` 条件下渲染,不进入生产渲染路径。 - 主题切换统一通过 `ConfigProvider` 的 antd theme algorithm 控制,不使用硬编码主题色。 diff --git a/docs/user/usage.md b/docs/user/usage.md index 480b57f..37b5905 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -36,6 +36,7 @@ bun run dev config.yaml | ---------- | ----------------------- | ---------------------------------------- | | 总览 | `/` | Admin 管理台总览,展示运行时元信息 | | 项目管理 | `/projects` | 创建、编辑、归档、恢复和永久删除项目 | +| 模型管理 | `/models` | 配置 AI 供应商和模型,供后续 AI 功能使用 | | 工作台总览 | `/workbench/:projectId` | Workbench 工作台总览,按项目维度查看信息 | | 用户管理 | `/users` | 页面建设中 | | 系统设置 | `/settings` | 页面建设中 | @@ -46,3 +47,12 @@ bun run dev config.yaml - **Workbench(工作台)**:项目维度视角,通过 `/workbench/:projectId` 进入指定项目的工作台。URL 可保存为浏览器书签,下次直接进入。仅 active 状态的项目可进入工作台,archived 项目不可访问。 从项目管理页面的 active 项目行可点击"进入工作台"跳转到对应项目的工作台。 + +## 模型管理 + +在 Admin 侧栏进入 `/models` 后,页面通过两个标签页管理 AI 基础配置: + +- **供应商**:新增、编辑、启用、禁用、删除 OpenAI、Anthropic 或 OpenAI 兼容供应商。新建供应商时类型默认是 `openai-compatible`,baseURL 和 API Key 由用户填写。 +- **模型**:为已启用供应商新增模型,填写模型显示名称、实际调用用的 modelId、能力标签,以及可选的上下文长度和最大输出 token。 + +供应商表格和供应商表单都提供“测试连接”操作。测试连接只返回成功或失败提示,不会阻止保存供应商或模型。删除供应商前必须先删除或迁移其关联模型,否则系统会拒绝删除以避免误删模型配置。 diff --git a/openspec/changes/add-model-management/.openspec.yaml b/openspec/changes/add-model-management/.openspec.yaml deleted file mode 100644 index b12d93e..0000000 --- a/openspec/changes/add-model-management/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: fast-drive -created: 2026-05-29 diff --git a/openspec/changes/add-model-management/design.md b/openspec/changes/add-model-management/design.md deleted file mode 100644 index 7068676..0000000 --- a/openspec/changes/add-model-management/design.md +++ /dev/null @@ -1,225 +0,0 @@ -## 背景 - -Alfred·阿福定位为"基于 AI 的信息综合处理平台",但当前项目(v0.1.0)没有任何 AI/LLM 集成。项目已完成基础架构(Bun 全栈、SQLite + Drizzle ORM、React 19 + Ant Design 6 + React Router 7、TanStack React Query),并拥有管理控制台(Dashboard + 项目管理)和工作台控制台。 - -本次变更在管理控制台中新增"模型管理"功能,让用户能够配置 AI 供应商和模型,为后续所有 AI 功能提供基础设施。这是项目 AI 能力的第一个里程碑。 - -**当前状态:** -- 后端:`src/server/` 使用 Bun.serve(),路由在 `routes/` 目录按功能拆分,DB schema 在 `src/server/db/schema.ts`,数据访问层在 `src/server/db/projects.ts` -- 前端:`src/web/` 使用 console 模式,admin 菜单在 `consoles/admin/menu.tsx`,页面在 `pages/` 目录 -- 零 AI/LLM 相关代码 - -## 讨论记录 - -- 已确认结论: - - 使用 Vercel AI SDK(`ai` + `@ai-sdk/openai` + `@ai-sdk/anthropic` + `@ai-sdk/openai-compatible`)作为统一 AI 调用层 - - 三种供应商类型:`openai`、`anthropic`、`openai-compatible`(覆盖 DeepSeek、Qwen、Ollama 等) - - API Key 明文存储于 SQLite,可接受(本地/自部署单用户工具) - - AI 注册表不使用缓存层:每次 AI 调用时从 DB 查询供应商+模型 → 构建 `createProviderRegistry` → 调用 AI SDK(开销 ~1ms,远小于网络 I/O) - - 功能调用(function calling)视为基础能力,不列入 capability 标签 -- 用户偏好: - - 前端使用 antd `Tabs` 组件在同一页面内展示供应商和模型两个标签页 - - 供应商表单中 type 默认值为 `openai-compatible`,baseURL 不设默认值 - - 连通性测试为可选功能,不阻塞模型创建 -- 约束: - - 严格遵循现有代码模式(路由拆分、数据访问函数、hooks、页面组件结构) - - 不引入新的样式系统 -- 被否决方案: - - API Key 加密存储:对本地/自部署单用户场景过度设计 - - AI 注册表缓存:增加复杂度但收益极小(~1ms vs 数百毫秒网络 I/O) - - LangChain 等重量级框架:Vercel AI SDK 更轻量且 API 更统一 - -## 需求 - -| 需求 | 验收标准 | -| ---- | -------- | -| 供应商 CRUD | 管理员可新增、查看、编辑、删除供应商(名称、类型、baseURL、apiKey) | -| 供应商类型支持 | 支持 openai、anthropic、openai-compatible 三种类型,各自对应 AI SDK 不同 provider 工厂 | -| 模型 CRUD | 管理员可新增、查看、编辑、删除模型(名称、所属供应商、modelId、能力标签、可选参数) | -| 能力标签多选 | 模型表单中可多选能力标签(text / reasoning / image-generation / video-generation / audio-generation / image-recognition / video-recognition / audio-recognition) | -| 连通性测试 | 供应商编辑/创建时可测试 API 连通性,返回成功/失败提示 | -| 模型启用/禁用 | 供应商和模型均可启用/禁用,不影响数据 | -| 管理控制台菜单 | Admin 控制台侧栏新增"模型管理"菜单项 | -| AI 注册表服务 | 后端提供 AI 注册表构建服务,按需从 DB 查询构建,供后续 AI 功能调用 | -| 级联约束 | 删除供应商时,若存在关联模型则阻止删除,用户需先处理关联模型 | - -## 数据模型规格 - -### providers 表 - -| 字段 | 类型 | 约束 | 说明 | -| ---- | ---- | ---- | ---- | -| id | TEXT | PK, UUID | 自动生成 | -| name | TEXT | NOT NULL, UNIQUE | 供应商显示名称 | -| type | TEXT | NOT NULL | 枚举:`openai` \| `anthropic` \| `openai-compatible`,默认 `openai-compatible` | -| baseUrl | TEXT | NOT NULL | API 基础 URL,不设默认值,由用户填写 | -| apiKey | TEXT | NOT NULL | API 密钥,明文存储,GET 接口完整返回 | -| enabled | INTEGER | NOT NULL, DEFAULT 1 | 1=启用, 0=禁用 | -| createdAt | TEXT | NOT NULL | ISO 8601 时间戳 | -| updatedAt | TEXT | NOT NULL | ISO 8601 时间戳 | - -### models 表 - -| 字段 | 类型 | 约束 | 说明 | -| ---- | ---- | ---- | ---- | -| id | TEXT | PK, UUID | 自动生成 | -| name | TEXT | NOT NULL | 模型显示名称 | -| providerId | TEXT | NOT NULL, FK → providers.id | 所属供应商 | -| modelId | TEXT | NOT NULL | API 调用用的模型标识(如 `gpt-4o`) | -| capabilities | TEXT | NOT NULL | JSON 数组,能力标签(见下方定义) | -| contextLength | INTEGER | 可选 | 上下文窗口长度 | -| maxOutputTokens | INTEGER | 可选 | 最大输出 token 数 | -| enabled | INTEGER | NOT NULL, DEFAULT 1 | 1=启用, 0=禁用 | -| createdAt | TEXT | NOT NULL | ISO 8601 时间戳 | -| updatedAt | TEXT | NOT NULL | ISO 8601 时间戳 | - -**唯一约束:** `(providerId, modelId)` 联合唯一——同一供应商下 modelId 不可重复,不同供应商可以有相同 modelId。 - -**能力标签定义(ModelCapability):** - -``` -"text" | "reasoning" | "image-generation" | "video-generation" | "audio-generation" | "image-recognition" | "video-recognition" | "audio-recognition" -``` - -存储为 JSON 数组字符串,如 `["text","reasoning","image-recognition"]`。 - -## 目标 / 非目标 - -**目标:** -- 在管理控制台提供完整的供应商和模型管理界面 -- 建立后端 AI 服务层(注册表 + 类型定义),为后续 AI 功能提供可复用基础 -- 完成 Vercel AI SDK 集成的依赖安装和基础配置 -- 新增 DB migration 支持 providers 和 models 表 - -**非目标:** -- 不实现实际的 AI 调用功能(文本生成、图片生成等)——这是后续变更的内容 -- 不实现 API Key 加密存储 -- 不实现供应商/模型的导入导出功能 -- 不实现多用户权限控制 - -## 执行约束 - -- 依赖限制: - - 新增 npm 依赖仅限 `ai`、`@ai-sdk/openai`、`@ai-sdk/anthropic`、`@ai-sdk/openai-compatible` - - 使用 `bun add` 安装,严禁 npm/pnpm - - 优先使用项目已有依赖(Drizzle ORM、antd、TanStack React Query 等) -- 约束: - - 后端遵循 Bun 内置 API > es-toolkit > 三方库优先级 - - 前端遵循 antd 组件默认能力优先,禁止内联 style、覆盖 antd 内部类名 - - Git 提交格式:中文,"类型: 简短描述" -- 质量门禁: - - 新增代码必须编写完善的测试 - - 不允许跳过任何测试 - - 代码变更需执行文档影响分析 -- 相关方: - - 本变更为基础设施层,所有后续 AI 功能变更将依赖本变更的 AI 注册表服务 -- 文档 / 沟通: - - 每次代码变更执行文档影响分析:用户可见行为变更 → `docs/user/`,开发流程/架构变更 → `docs/development/` -- 兼容性 / 连续性: - - 无需考虑向前兼容性 - -## 影响范围 - -| 范围 | Artifacts / 参考资料 | 预期变更 | 备注 | -| ---- | -------------------- | -------- | ---- | -| DB Schema | `src/server/db/schema.ts` | 新增 providers 和 models 表定义 | 遵循现有 projects 表模式 | -| DB Migration | `drizzle/` | 新增 migration SQL 文件 | 自动生成 | -| 数据访问层 | `src/server/db/projects.ts`(参考模式) | 新增 `providers.ts` 和 `models.ts` | CRUD + 启用/禁用 | -| DB 导出 | `src/server/db/index.ts` | 新增 providers 和 models schema 导出 | 遵循现有导出模式 | -| 共享类型 | `src/shared/api.ts` | 新增供应商和模型相关类型定义 | 前后端共用 | -| 后端路由 | `src/server/routes/` | 新增 `providers/` 和 `models/` 目录 | CRUD + 连通性测试 | -| 服务器入口 | `src/server/server.ts` | 注册新路由 | 懒加载导入 | -| AI 服务层 | `src/server/ai/`(新建) | 新增 `registry.ts` 和 `types.ts` | AI 注册表构建服务 | -| 前端菜单 | `src/web/consoles/admin/menu.tsx` | 新增"模型管理"菜单项 | | -| 前端页面 | `src/web/pages/models/`(新建) | 新增模型管理页面(Tabs + 表格 + 表单) | 遵循 projects 页面模式 | -| 前端组件 | `src/web/pages/models/components/`(新建) | ProviderTable、ProviderFormModal、ModelTable、ModelFormModal | | -| 前端路由 | `src/web/routes.tsx` | 新增 `/models` 路由 | | -| 前端 Hooks | `src/web/hooks/` | 新增 `use-providers.ts`、`use-models.ts` | TanStack React Query | -| 依赖 | `package.json` | 新增 `ai`、`@ai-sdk/openai`、`@ai-sdk/anthropic`、`@ai-sdk/openai-compatible` | | -| 测试 | `tests/` | 新增后端路由、数据访问、前端 hooks 和组件测试 | | -| 文档 | `docs/development/backend.md` | 更新后端架构说明,补充 AI 服务层 | 开发文档 | -| 文档 | `docs/development/frontend.md` | 补充模型管理页面组件说明 | 开发文档 | - -## 决策 - -| 决策 | 理由 | 已否决替代方案 | -| ---- | ---- | ---------------- | -| 使用 Vercel AI SDK | 轻量、统一 API、支持多供应商、TypeScript 优先、与 Bun 兼容 | LangChain(过重,API 复杂)、自行封装 fetch(维护成本高,无法统一接口) | -| 三种供应商类型 | 覆盖主流场景:OpenAI(Responses API + Chat Completions API)、Anthropic、OpenAI 兼容协议(DeepSeek/Qwen/Ollama 等) | 仅区分"OpenAI 协议"和"其他"(丢失 Anthropic 特有能力) | -| API Key 明文存储 | 本地/自部署单用户场景,加密增加复杂度但安全性提升有限 | 加密存储(过度设计) | -| AI 注册表不缓存 | DB 查询 + 注册表构建开销 ~1ms,远小于网络 I/O;无缓存则无需处理失效/一致性问题 | 内存缓存(增加复杂度,收益极小) | -| capability 标签用 JSON 数组 | 灵活可扩展,SQLite 中 TEXT 字段存储 JSON 数组 | 关联表(过度设计,标签数量有限且固定) | -| 连通性测试不阻塞操作 | 测试是辅助功能,网络波动不应阻止用户保存配置 | 强制测试通过才能保存(用户体验差) | -| 删除供应商时阻止而非级联删除 | 防止误删导致模型数据丢失,用户需先处理关联模型 | CASCADE 删除(数据安全风险) | -| GET 接口完整返回 apiKey | 本地/自部署单用户场景,前端使用 antd Password 组件隐藏显示,无需脱敏 | apiKey 脱敏返回(增加复杂度,编辑时需额外处理) | -| providerId + modelId 联合唯一 | 同一供应商下 modelId 不可重复,不同供应商可有相同 modelId;符合 AI SDK 注册表 key 格式 `providerId:modelId` | modelId 全局唯一(限制过严)、无约束(可能导致注册表冲突) | -| 连通性测试使用 generateText 最小请求 | 调用 `generateText({ model, prompt: 'hi' })` 验证连通性和 apiKey 有效性,简单直接 | 仅验证 HTTP 连接(不验证 apiKey)、listModels(并非所有供应商支持) | -| 启用/禁用使用 enable/disable 双端点 | 语义明确,与 archive/restore 模式一致;供应商/模型的启用禁用是布尔切换,双端点更清晰 | toggle 单端点(语义含糊,前端需知道当前状态才能确定操作) | -| 供应商 type 默认 openai-compatible | 覆盖最广(DeepSeek/Qwen/Ollama 等),用户只需填写 baseURL 和 apiKey 即可使用 | 默认 openai(限制性强,不适合自部署场景) | -| baseURL 不设默认值 | 不同部署环境的 baseURL 差异大,自动填充可能误导用户填错地址 | 设默认值(看似方便,但可能掩盖配置错误) | - -## 执行计划 - -**阶段 1:基础层(DB + 类型 + 依赖)** -1. 安装 Vercel AI SDK 相关依赖 -2. 在 `src/server/db/schema.ts` 新增 providers 和 models 表定义 -3. 生成 DB migration -4. 在 `src/shared/api.ts` 新增供应商和模型相关类型定义 -5. 在 `src/server/ai/types.ts` 新增 AI 相关类型定义(ModelCapability 等) - -**阶段 2:后端(数据访问 + 路由 + AI 服务)** -6. 新增 `src/server/db/providers.ts` 数据访问函数 -7. 新增 `src/server/db/models.ts` 数据访问函数 -8. 新增 `src/server/routes/providers/` 目录下的 CRUD 路由处理器(含 enable/disable 双端点) -9. 新增 `src/server/routes/models/` 目录下的 CRUD 路由处理器(含 enable/disable 双端点) -10. 新增 `src/server/routes/providers/test.ts` 连通性测试路由 -11. 在 `src/server/server.ts` 注册所有新路由 -12. 新增 `src/server/ai/registry.ts`,包含 `buildProviderRegistry(db)`(从 DB 查询启用的供应商构建 AI SDK Provider Registry)和 `testProviderConnection(config)`(使用 generateText 测试连通性) - -**阶段 3:前端(页面 + 组件 + Hooks)** -13. 新增 `src/web/hooks/use-providers.ts` 和 `src/web/hooks/use-models.ts` -14. 在 `src/web/consoles/admin/menu.tsx` 新增"模型管理"菜单项 -15. 在 `src/web/routes.tsx` 新增 `/models` 路由 -16. 新增 `src/web/pages/models/index.tsx` 页面(Tabs 布局) -17. 新增 `src/web/pages/models/components/ProviderTable.tsx` -18. 新增 `src/web/pages/models/components/ProviderFormModal.tsx` -19. 新增 `src/web/pages/models/components/ModelTable.tsx` -20. 新增 `src/web/pages/models/components/ModelFormModal.tsx` - -**阶段 4:测试** -21. 后端数据访问层测试(providers.ts、models.ts) -22. 后端路由测试(providers/、models/) -23. AI 注册表测试 -24. 前端 hooks 测试 -25. 前端组件测试 - -**阶段 5:文档** -26. 执行文档影响分析,更新 `docs/development/` 相关文档 - -## 验证计划 - -| 需求 / 风险 | 验证方式 | -| ----------- | -------- | -| 供应商 CRUD API | 后端路由测试覆盖创建、查询、更新、删除、启用/禁用 | -| 模型 CRUD API | 后端路由测试覆盖创建、查询、更新、删除、启用/禁用 | -| 供应商删除约束 | 测试删除有关联模型的供应商时返回错误 | -| 连通性测试 API | 测试 mock 场景下的成功/失败响应 | -| AI 注册表构建 | 测试从 DB 数据正确构建 AI SDK provider 实例 | -| DB Migration | 测试 migration 正确创建表和索引 | -| 前端供应商管理 | 组件测试覆盖表格渲染、表单提交、启用/禁用操作 | -| 前端模型管理 | 组件测试覆盖表格渲染、表单提交、能力标签多选 | -| 前端菜单和路由 | 测试菜单项显示和路由跳转 | -| 文档完整性 | 检查 docs/development/ 和 docs/user/ 是否已更新 | - -## 风险 / 权衡 - -- [Vercel AI SDK 与 Bun 的兼容性] -> AI SDK 基于 Web 标准 API,Bun 对 Web API 支持良好;安装后需验证 import 正常 -- [OpenAI-compatible 供应商行为不一致] -> 注册表构建时使用 `createOpenAICompatible` 标准接口;连通性测试帮助用户提前发现问题 -- [API Key 明文存储安全风险] -> 文档中说明安全模型(单用户本地部署);未来可按需增加加密 -- [migration 与现有数据兼容性] -> 新增表不影响现有 projects 表;migration 为增量操作 - -## 待解决问题 - -| 状态 | 问题 | 所需决策 | -| ---- | ---- | -------- | -| 无 | 无待解决问题。 | 无需决策 | diff --git a/openspec/changes/add-model-management/tasks.md b/openspec/changes/add-model-management/tasks.md deleted file mode 100644 index b9ad430..0000000 --- a/openspec/changes/add-model-management/tasks.md +++ /dev/null @@ -1,89 +0,0 @@ -## 1. 上下文审查 - -- [x] 1.1 阅读 design.md,识别范围、需求、决策、执行约束和待解决问题 -- [x] 1.2 审查影响范围中列出的相关文件:`src/server/db/schema.ts`、`src/server/db/projects.ts`、`src/server/server.ts`、`src/shared/api.ts`、`src/web/consoles/admin/menu.tsx`、`src/web/routes.tsx`、`src/web/pages/projects/` -- [x] 1.3 审查现有测试模式:`tests/server/routes/projects.test.ts`、`tests/server/db/projects.test.ts`、`tests/web/hooks/use-projects.test.ts`、`tests/web/routes/projects.test.tsx` - -## 2. 基础层:依赖安装与类型定义 - -- [x] 2.1 使用 `bun add ai @ai-sdk/openai @ai-sdk/anthropic @ai-sdk/openai-compatible` 安装 Vercel AI SDK 依赖 -- [x] 2.2 在 `src/shared/api.ts` 新增供应商和模型相关类型定义(ProviderType、ModelCapability、Provider 相关请求/响应类型、Model 相关请求/响应类型) -- [x] 2.3 新建 `src/server/ai/types.ts`,定义 AI 层专用类型(ProviderType、ModelCapability、AIRegistryConfig 等) - -## 3. 数据库层:Schema 与 Migration - -- [x] 3.1 在 `src/server/db/schema.ts` 新增 providers 表定义(id、name、type、baseUrl、apiKey、enabled、createdAt、updatedAt) -- [x] 3.2 在 `src/server/db/schema.ts` 新增 models 表定义(id、name、providerId、modelId、capabilities、contextLength、maxOutputTokens、enabled、createdAt、updatedAt) -- [x] 3.3 使用 `bunx drizzle-kit generate` 生成 migration SQL 文件 -- [x] 3.4 验证 migration SQL 文件正确性(表结构、索引、外键约束) - -## 4. 数据访问层 - -- [x] 4.1 新建 `src/server/db/providers.ts`,实现供应商 CRUD 数据访问函数(create、list、get、update、delete、enable、disable),遵循 `projects.ts` 模式 -- [x] 4.2 新建 `src/server/db/models.ts`,实现模型 CRUD 数据访问函数(create、list、get、update、delete、enable、disable),包含按 providerId 查询和关联检查 - -## 5. 后端路由层 - -- [x] 5.1 新建 `src/server/routes/providers/create.ts`(POST /api/providers) -- [x] 5.2 新建 `src/server/routes/providers/list.ts`(GET /api/providers) -- [x] 5.3 新建 `src/server/routes/providers/get.ts`(GET /api/providers/:id) -- [x] 5.4 新建 `src/server/routes/providers/update.ts`(PATCH /api/providers/:id) -- [x] 5.5 新建 `src/server/routes/providers/delete.ts`(DELETE /api/providers/:id,有关联模型时阻止删除) -- [x] 5.6 新建 `src/server/routes/providers/enable.ts`(POST /api/providers/:id/enable) -- [x] 5.7 新建 `src/server/routes/providers/disable.ts`(POST /api/providers/:id/disable) -- [x] 5.8 新建 `src/server/routes/providers/test.ts`(POST /api/providers/:id/test,使用 AI SDK generateText 测试连通性) -- [x] 5.9 新建 `src/server/routes/models/create.ts`(POST /api/models) -- [x] 5.10 新建 `src/server/routes/models/list.ts`(GET /api/models,支持按 providerId 筛选) -- [x] 5.11 新建 `src/server/routes/models/get.ts`(GET /api/models/:id) -- [x] 5.12 新建 `src/server/routes/models/update.ts`(PATCH /api/models/:id) -- [x] 5.13 新建 `src/server/routes/models/delete.ts`(DELETE /api/models/:id) -- [x] 5.14 新建 `src/server/routes/models/enable.ts`(POST /api/models/:id/enable) -- [x] 5.15 新建 `src/server/routes/models/disable.ts`(POST /api/models/:id/disable) -- [x] 5.16 在 `src/server/server.ts` 中注册所有新路由(懒加载导入) - -## 6. AI 服务层 - -- [x] 6.1 新建 `src/server/ai/registry.ts`,实现 `buildProviderRegistry(db)`(从 DB 查询启用的供应商构建 AI SDK Provider Registry,模型通过 `registry.languageModel('providerId:modelId')` 获取)和 `testProviderConnection(config)`(使用 generateText 测试连通性) - -## 7. 前端:Hooks 与路由 - -- [x] 7.1 新建 `src/web/hooks/use-providers.ts`,实现供应商 CRUD 的 TanStack React Query hooks -- [x] 7.2 新建 `src/web/hooks/use-models.ts`,实现模型 CRUD 的 TanStack React Query hooks -- [x] 7.3 在 `src/web/consoles/admin/menu.tsx` 新增"模型管理"菜单项,图标使用 antd 的 RobotOutlined 或类似图标 -- [x] 7.4 在 `src/web/routes.tsx` 新增 `/models` 路由,指向 AdminConsoleLayout 下的模型管理页面 - -## 8. 前端:页面与组件 - -- [x] 8.1 新建 `src/web/pages/models/index.tsx`,使用 antd Tabs 实现供应商/模型双标签页布局 -- [x] 8.2 新建 `src/web/pages/models/components/ProviderTable.tsx`,实现供应商列表表格(名称、类型、baseURL、状态、操作) -- [x] 8.3 新建 `src/web/pages/models/components/ProviderFormModal.tsx`,实现供应商创建/编辑表单弹窗(name、type Select 默认 openai-compatible、apiKey Password 输入、baseURL 不设默认值) -- [x] 8.4 新建 `src/web/pages/models/components/ModelTable.tsx`,实现模型列表表格(名称、供应商、modelId、能力标签、状态、操作) -- [x] 8.5 新建 `src/web/pages/models/components/ModelFormModal.tsx`,实现模型创建/编辑表单弹窗(name、provider Select、modelId、capabilities Checkbox.Group、contextLength、maxOutputTokens 可选输入、连通性测试按钮) - -## 9. 测试:后端 - -- [x] 9.1 编写 `tests/server/db/providers.test.ts`,覆盖供应商 CRUD 和启用/禁用数据访问函数 -- [x] 9.2 编写 `tests/server/db/models.test.ts`,覆盖模型 CRUD、启用/禁用和关联检查数据访问函数 -- [x] 9.3 编写 `tests/server/routes/providers.test.ts`,覆盖供应商路由的请求验证、CRUD 操作和连通性测试 -- [x] 9.4 编写 `tests/server/routes/models.test.ts`,覆盖模型路由的请求验证和 CRUD 操作 -- [x] 9.5 编写 `tests/server/ai/registry.test.ts`,覆盖 AI 注册表构建逻辑 - -## 10. 测试:前端 - -- [x] 10.1 编写 `tests/web/hooks/use-providers.test.ts`,覆盖供应商 hooks 的 query 和 mutation -- [x] 10.2 编写 `tests/web/hooks/use-models.test.ts`,覆盖模型 hooks 的 query 和 mutation -- [x] 10.3 编写 `tests/web/routes/models.test.tsx`,覆盖模型管理页面路由和渲染 -- [x] 10.4 编写 `tests/web/components/ProviderTable.test.tsx`,覆盖供应商表格渲染和交互 -- [x] 10.5 编写 `tests/web/components/ModelTable.test.tsx`,覆盖模型表格渲染和交互 - -## 11. 质量保障 - -- [x] 11.1 运行全部测试(`bun test`),确保无失败 -- [x] 11.2 运行 lint 检查(`bun run lint`),确保无错误 -- [x] 11.3 运行格式检查(`bun run format:check`),确保代码格式正确 - -## 12. 文档 - -- [x] 12.1 执行文档影响分析:评估本次变更对 docs/ 下各文档的影响 -- [x] 12.2 更新 `docs/development/backend.md`,补充 AI 服务层架构说明(`src/server/ai/` 目录结构和职责) -- [x] 12.3 更新 `docs/development/frontend.md`,补充模型管理页面组件说明 diff --git a/package.json b/package.json index 28dae41..195b96e 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "build": "bun run scripts/build.ts", "lint": "eslint .", "format": "prettier . --write", + "format:check": "prettier . --check", "check": "bun run schema:check && bun run typecheck && bun run lint && bun test", "schema": "bun run scripts/generate-config-schema.ts", "schema:check": "bun run scripts/generate-config-schema.ts -- --check", diff --git a/src/server/routes/providers/test.ts b/src/server/routes/providers/test.ts index 90896a9..c1ab803 100644 --- a/src/server/routes/providers/test.ts +++ b/src/server/routes/providers/test.ts @@ -1,6 +1,6 @@ import type Database from "bun:sqlite"; -import type { RuntimeMode } from "../../../shared/api"; +import type { CreateProviderRequest, RuntimeMode } from "../../../shared/api"; import { testProviderConnection } from "../../ai/registry"; import { getProvider } from "../../db/providers"; @@ -32,3 +32,47 @@ export async function handleTestProvider(req: Request, db: Database, mode: Runti return jsonResponse({ providerTestResponse: testResult }, { mode }); } + +export async function handleTestProviderConfig(req: Request, _db: Database, mode: RuntimeMode): Promise { + const validated = await readProviderConfig(req, mode); + if (validated instanceof Response) return validated; + + const testResult = await testProviderConnection({ + apiKey: validated.apiKey, + baseUrl: validated.baseUrl, + name: validated.name, + type: validated.type, + }); + + return jsonResponse({ providerTestResponse: testResult }, { mode }); +} + +async function readProviderConfig(req: Request, mode: RuntimeMode): Promise { + let body: CreateProviderRequest; + try { + body = (await req.json()) as CreateProviderRequest; + } catch { + return jsonResponse(createApiError("Invalid JSON body", 400), { mode, status: 400 }); + } + + if (!body.name || typeof body.name !== "string") { + return jsonResponse(createApiError("name is required", 400), { mode, status: 400 }); + } + + if (!body.baseUrl || typeof body.baseUrl !== "string") { + return jsonResponse(createApiError("baseUrl is required", 400), { mode, status: 400 }); + } + + if (!body.apiKey || typeof body.apiKey !== "string") { + return jsonResponse(createApiError("apiKey is required", 400), { mode, status: 400 }); + } + + if (!body.type || !["anthropic", "openai", "openai-compatible"].includes(body.type)) { + return jsonResponse(createApiError("type must be one of: openai, anthropic, openai-compatible", 400), { + mode, + status: 400, + }); + } + + return body; +} diff --git a/src/server/server.ts b/src/server/server.ts index 4235ddf..c7c56f8 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -157,6 +157,12 @@ export function startServer(options: StartServerOptions) { return handleTestProvider(req, db, mode); }, }, + "/api/providers/test": { + POST: async (req) => { + const { handleTestProviderConfig } = await import("./routes/providers/test"); + return handleTestProviderConfig(req, db, mode); + }, + }, }, }); diff --git a/src/shared/api.ts b/src/shared/api.ts index 5cc16da..bbcc9e6 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -131,6 +131,10 @@ export interface ProviderTestResponse { ok: boolean; } +export interface ProviderTestResultResponse { + providerTestResponse: ProviderTestResponse; +} + export type ProviderType = "anthropic" | "openai" | "openai-compatible"; export type RuntimeMode = "development" | "production" | "test"; diff --git a/src/web/hooks/use-providers.ts b/src/web/hooks/use-providers.ts index 39423c4..7ec0a19 100644 --- a/src/web/hooks/use-providers.ts +++ b/src/web/hooks/use-providers.ts @@ -6,6 +6,7 @@ import type { ProviderListResponse, ProviderResponse, ProviderTestResponse, + ProviderTestResultResponse, UpdateProviderRequest, } from "../../shared/api"; @@ -63,13 +64,27 @@ export async function fetchProviderList(params: { return response.json() as Promise; } +export async function testProviderConfig(data: CreateProviderRequest): Promise { + const response = await fetch("/api/providers/test", { + body: JSON.stringify(data), + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + if (!response.ok) { + const body = (await response.json().catch(() => null)) as null | { error?: string }; + throw new Error(body?.error ?? `HTTP ${response.status}`); + } + const result = (await response.json()) as ProviderTestResultResponse; + return result.providerTestResponse; +} + export async function testProviderConnection(id: string): Promise { const response = await fetch(`/api/providers/${id}/test`, { method: "POST" }); if (!response.ok) { const body = (await response.json().catch(() => null)) as null | { error?: string }; throw new Error(body?.error ?? `HTTP ${response.status}`); } - const data = (await response.json()) as { providerTestResponse: ProviderTestResponse }; + const data = (await response.json()) as ProviderTestResultResponse; return data.providerTestResponse; } @@ -138,6 +153,12 @@ export function useProviderList(params: { keyword?: string; page?: number; pageS }); } +export function useTestProviderConfig() { + return useMutation({ + mutationFn: testProviderConfig, + }); +} + export function useTestProviderConnection() { return useMutation({ mutationFn: testProviderConnection, diff --git a/src/web/pages/models/components/ModelFormModal.tsx b/src/web/pages/models/components/ModelFormModal.tsx index 5db79fd..e1c367f 100644 --- a/src/web/pages/models/components/ModelFormModal.tsx +++ b/src/web/pages/models/components/ModelFormModal.tsx @@ -1,7 +1,14 @@ import { App as AntApp, Button, Checkbox, Col, Form, Input, InputNumber, Modal, Row, Select, Space } from "antd"; import { useEffect, useState } from "react"; -import type { CreateModelRequest, Model, ModelCapability, Provider, UpdateModelRequest } from "../../../../shared/api"; +import type { + CreateModelRequest, + Model, + ModelCapability, + Provider, + ProviderTestResponse, + UpdateModelRequest, +} from "../../../../shared/api"; interface FormValues { capabilities: ModelCapability[]; @@ -21,7 +28,7 @@ interface ModelFormModalProps { open: boolean; providers: Provider[]; submitting: boolean; - testConnection?: (providerId: string) => Promise; + testConnection?: (providerId: string) => Promise; } const CAPABILITY_OPTIONS: Array<{ label: string; value: ModelCapability }> = [ @@ -111,7 +118,11 @@ export function ModelFormModal({ setTesting(true); try { const result = await testConnection(providerId); - message.success((result as { message: string }).message); + if (result.ok) { + message.success(result.message); + } else { + message.error(result.message); + } } catch (err) { message.error((err as Error).message); } finally { @@ -162,12 +173,12 @@ export function ModelFormModal({ - + - + - {editingModel && testConnection && ( + {testConnection && ( + + ); diff --git a/src/web/pages/models/components/ProviderTable.tsx b/src/web/pages/models/components/ProviderTable.tsx index d69ea8a..6a80f74 100644 --- a/src/web/pages/models/components/ProviderTable.tsx +++ b/src/web/pages/models/components/ProviderTable.tsx @@ -9,7 +9,7 @@ import { } from "@ant-design/icons"; import { App as AntApp, Button, Popconfirm, Space, Table, Tag, Tooltip } from "antd"; -import type { Provider, ProviderListResponse } from "../../../../shared/api"; +import type { Provider, ProviderListResponse, ProviderTestResponse } from "../../../../shared/api"; interface ProviderTableProps { data: ProviderListResponse | undefined; @@ -19,7 +19,7 @@ interface ProviderTableProps { onEdit: (provider: Provider) => void; onEnable: (id: string) => Promise; onPageChange: (page: number, pageSize: number) => void; - onTest: (id: string) => Promise; + onTest: (id: string) => Promise; page: number; pageSize: number; } @@ -100,7 +100,11 @@ export function ProviderTable({ const handleTest = async (id: string) => { try { const result = await onTest(id); - message.success((result as { message: string }).message); + if (result.ok) { + message.success(result.message); + } else { + message.error(result.message); + } } catch (err) { message.error((err as Error).message); } @@ -112,7 +116,13 @@ export function ProviderTable({ render: (_value: unknown, record: Provider) => ( -