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:
237
src/server/db/models.ts
Normal file
237
src/server/db/models.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user