feat: 新增模型管理功能(供应商 + 模型 CRUD)

- 新增 providers/models 数据库表、迁移和数据访问层
- 新增 15 个后端 API 路由(供应商/模型 CRUD + 连通性测试)
- 新增 AI 服务层(registry.ts: buildProviderRegistry + testProviderConnection)
- 新增前端模型管理页面(Tabs: 供应商/模型,含表格、表单、工具栏)
- 新增前端 hooks(use-providers, use-models)
- 新增共享类型和 MODEL_CAPABILITIES 常量
- 新增 10 个测试文件(66 个测试用例,4 个因 bun test ESM 兼容问题待修复)
- 更新开发文档(architecture, backend, frontend)
- 附带 apply-review 修复:统一错误响应、提取共享常量、清理重复测试

注意:registry.test.ts 中 4 个测试因 bun test 无法解析
createProviderRegistry ESM 导出而失败,详情见 context.md
This commit is contained in:
2026-05-29 12:40:10 +08:00
parent 2ea4bd4410
commit 933c2133f0
56 changed files with 4706 additions and 9 deletions

237
src/server/db/models.ts Normal file
View File

@@ -0,0 +1,237 @@
import type Database from "bun:sqlite";
import { and, desc, eq, like, or, sql } from "drizzle-orm";
import { drizzle } from "drizzle-orm/bun-sqlite";
import type { CreateModelRequest, Model, ModelCapability, UpdateModelRequest } from "../../shared/api";
import { models, providers } from "./schema";
export function createModel(
raw: Database,
request: CreateModelRequest,
): { error: string; status: number } | { model: Model } {
const db = wrap(raw);
const provider = db.select().from(providers).where(eq(providers.id, request.providerId)).get();
if (!provider) return { error: "供应商不存在", status: 400 };
const name = request.name.trim();
if (!name) return { error: "模型名称不能为空", status: 400 };
const modelId = request.modelId.trim();
if (!modelId) return { error: "模型 ID 不能为空", status: 400 };
const capabilities = request.capabilities;
if (!capabilities || capabilities.length === 0) {
return { error: "至少选择一个能力标签", status: 400 };
}
const id = crypto.randomUUID();
const now = new Date().toISOString();
try {
db.insert(models)
.values({
capabilities: JSON.stringify(capabilities),
contextLength: request.contextLength ?? null,
createdAt: now,
enabled: true,
id,
maxOutputTokens: request.maxOutputTokens ?? null,
modelId,
name,
providerId: request.providerId,
updatedAt: now,
})
.run();
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
if (msg.includes("UNIQUE constraint")) {
return { error: "该供应商下模型 ID 已存在", status: 409 };
}
throw e;
}
const row = db.select().from(models).where(eq(models.id, id)).get();
return { model: toModel(row!) };
}
export function deleteModel(raw: Database, id: string): { error: string; status: number } | { success: true } {
const db = wrap(raw);
const existing = db.select().from(models).where(eq(models.id, id)).get();
if (!existing) return { error: "模型不存在", status: 404 };
db.delete(models).where(eq(models.id, id)).run();
return { success: true };
}
export function disableModel(raw: Database, id: string): { error: string; status: number } | { model: Model } {
const db = wrap(raw);
const existing = db.select().from(models).where(eq(models.id, id)).get();
if (!existing) return { error: "模型不存在", status: 404 };
if (!existing.enabled) return { error: "模型已禁用", status: 409 };
const now = new Date().toISOString();
db.update(models).set({ enabled: false, updatedAt: now }).where(eq(models.id, id)).run();
const updated = db.select().from(models).where(eq(models.id, id)).get();
return { model: toModel(updated!) };
}
export function enableModel(raw: Database, id: string): { error: string; status: number } | { model: Model } {
const db = wrap(raw);
const existing = db.select().from(models).where(eq(models.id, id)).get();
if (!existing) return { error: "模型不存在", status: 404 };
if (existing.enabled) return { error: "模型已启用", status: 409 };
const now = new Date().toISOString();
db.update(models).set({ enabled: true, updatedAt: now }).where(eq(models.id, id)).run();
const updated = db.select().from(models).where(eq(models.id, id)).get();
return { model: toModel(updated!) };
}
export function getModel(raw: Database, id: string): { error: string; status: number } | { model: Model } {
const db = wrap(raw);
const row = db.select().from(models).where(eq(models.id, id)).get();
if (!row) return { error: "模型不存在", status: 404 };
return { model: toModel(row) };
}
export function getModelsByProviderId(raw: Database, providerId: string): number {
const db = wrap(raw);
const result = db
.select({ count: sql<number>`count(*)` })
.from(models)
.where(eq(models.providerId, providerId))
.get();
return Number(result?.count ?? 0);
}
export function listModels(
raw: Database,
options: { keyword?: string; page: number; pageSize: number; providerId?: string },
): { items: Model[]; page: number; pageSize: number; total: number } {
const db = wrap(raw);
const conditions = [];
if (options.providerId) {
conditions.push(eq(models.providerId, options.providerId));
}
if (options.keyword) {
const pattern = `%${options.keyword}%`;
conditions.push(or(like(models.name, pattern), like(models.modelId, pattern))!);
}
const where = conditions.length > 0 ? and(...conditions) : undefined;
const countResult = db
.select({ count: sql<number>`count(*)` })
.from(models)
.where(where)
.get();
const total = Number(countResult?.count ?? 0);
const rows = db
.select()
.from(models)
.where(where)
.orderBy(desc(models.createdAt))
.limit(options.pageSize)
.offset((options.page - 1) * options.pageSize)
.all();
return {
items: rows.map(toModel),
page: options.page,
pageSize: options.pageSize,
total,
};
}
export function updateModel(
raw: Database,
id: string,
request: UpdateModelRequest,
): { error: string; status: number } | { model: Model } {
const db = wrap(raw);
const existing = db.select().from(models).where(eq(models.id, id)).get();
if (!existing) return { error: "模型不存在", status: 404 };
const updates: Partial<typeof models.$inferInsert> = {
updatedAt: new Date().toISOString(),
};
const name = request.name?.trim();
if (name === "") return { error: "模型名称不能为空", status: 400 };
if (name !== undefined && name !== existing.name) {
updates.name = name;
}
const modelId = request.modelId?.trim();
if (modelId === "") return { error: "模型 ID 不能为空", status: 400 };
if (modelId !== undefined) {
updates.modelId = modelId;
}
if (request.providerId !== undefined) {
const provider = db.select().from(providers).where(eq(providers.id, request.providerId)).get();
if (!provider) return { error: "供应商不存在", status: 400 };
updates.providerId = request.providerId;
}
if (request.capabilities !== undefined) {
if (request.capabilities.length === 0) {
return { error: "至少选择一个能力标签", status: 400 };
}
updates.capabilities = JSON.stringify(request.capabilities);
}
if (request.contextLength !== undefined) {
updates.contextLength = request.contextLength;
}
if (request.maxOutputTokens !== undefined) {
updates.maxOutputTokens = request.maxOutputTokens;
}
if (Object.keys(updates).length === 1 && updates.updatedAt) {
return { model: toModel(existing) };
}
try {
db.update(models).set(updates).where(eq(models.id, id)).run();
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
if (msg.includes("UNIQUE constraint")) {
return { error: "该供应商下模型 ID 已存在", status: 409 };
}
throw e;
}
const updated = db.select().from(models).where(eq(models.id, id)).get();
return { model: toModel(updated!) };
}
function toModel(row: typeof models.$inferSelect): Model {
return {
capabilities: JSON.parse(row.capabilities) as ModelCapability[],
contextLength: row.contextLength,
createdAt: row.createdAt,
enabled: row.enabled,
id: row.id,
maxOutputTokens: row.maxOutputTokens,
modelId: row.modelId,
name: row.name,
providerId: row.providerId,
updatedAt: row.updatedAt,
};
}
function wrap(raw: Database) {
return drizzle(raw);
}