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:
47
src/server/routes/models/create.ts
Normal file
47
src/server/routes/models/create.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type Database from "bun:sqlite";
|
||||
|
||||
import type { CreateModelRequest, RuntimeMode } from "../../../shared/api";
|
||||
|
||||
import { MODEL_CAPABILITIES } from "../../../shared/api";
|
||||
import { createModel } from "../../db/models";
|
||||
import { createApiError, jsonResponse } from "../../helpers";
|
||||
|
||||
export async function handleCreateModel(req: Request, db: Database, mode: RuntimeMode): Promise<Response> {
|
||||
let body: CreateModelRequest;
|
||||
try {
|
||||
body = (await req.json()) as CreateModelRequest;
|
||||
} 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.modelId || typeof body.modelId !== "string") {
|
||||
return jsonResponse(createApiError("modelId is required", 400), { mode, status: 400 });
|
||||
}
|
||||
|
||||
if (!body.providerId || typeof body.providerId !== "string") {
|
||||
return jsonResponse(createApiError("providerId is required", 400), { mode, status: 400 });
|
||||
}
|
||||
|
||||
if (!Array.isArray(body.capabilities) || body.capabilities.length === 0) {
|
||||
return jsonResponse(createApiError("capabilities is required and must be a non-empty array", 400), {
|
||||
mode,
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const invalidCaps = body.capabilities.filter((c) => !MODEL_CAPABILITIES.includes(c));
|
||||
if (invalidCaps.length > 0) {
|
||||
return jsonResponse(createApiError(`Invalid capabilities: ${invalidCaps.join(", ")}`, 400), { mode, status: 400 });
|
||||
}
|
||||
|
||||
const result = createModel(db, body);
|
||||
if ("error" in result) {
|
||||
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
|
||||
}
|
||||
|
||||
return jsonResponse(result, { mode, status: 201 });
|
||||
}
|
||||
22
src/server/routes/models/delete.ts
Normal file
22
src/server/routes/models/delete.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type Database from "bun:sqlite";
|
||||
|
||||
import type { RuntimeMode } from "../../../shared/api";
|
||||
|
||||
import { deleteModel } from "../../db/models";
|
||||
import { createApiError, jsonResponse } from "../../helpers";
|
||||
import { validateIdParam } from "../../middleware";
|
||||
|
||||
export function handleDeleteModel(req: Request, db: Database, mode: RuntimeMode): Response {
|
||||
const url = new URL(req.url);
|
||||
const idStr = url.pathname.split("/")[3];
|
||||
|
||||
const validated = validateIdParam(idStr ?? "", mode);
|
||||
if (validated instanceof Response) return validated;
|
||||
|
||||
const result = deleteModel(db, validated.id);
|
||||
if ("error" in result) {
|
||||
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
|
||||
}
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
22
src/server/routes/models/disable.ts
Normal file
22
src/server/routes/models/disable.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type Database from "bun:sqlite";
|
||||
|
||||
import type { RuntimeMode } from "../../../shared/api";
|
||||
|
||||
import { disableModel } from "../../db/models";
|
||||
import { createApiError, jsonResponse } from "../../helpers";
|
||||
import { validateIdParam } from "../../middleware";
|
||||
|
||||
export function handleDisableModel(req: Request, db: Database, mode: RuntimeMode): Response {
|
||||
const url = new URL(req.url);
|
||||
const idStr = url.pathname.split("/")[3];
|
||||
|
||||
const validated = validateIdParam(idStr ?? "", mode);
|
||||
if (validated instanceof Response) return validated;
|
||||
|
||||
const result = disableModel(db, validated.id);
|
||||
if ("error" in result) {
|
||||
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
|
||||
}
|
||||
|
||||
return jsonResponse(result, { mode });
|
||||
}
|
||||
22
src/server/routes/models/enable.ts
Normal file
22
src/server/routes/models/enable.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type Database from "bun:sqlite";
|
||||
|
||||
import type { RuntimeMode } from "../../../shared/api";
|
||||
|
||||
import { enableModel } from "../../db/models";
|
||||
import { createApiError, jsonResponse } from "../../helpers";
|
||||
import { validateIdParam } from "../../middleware";
|
||||
|
||||
export function handleEnableModel(req: Request, db: Database, mode: RuntimeMode): Response {
|
||||
const url = new URL(req.url);
|
||||
const idStr = url.pathname.split("/")[3];
|
||||
|
||||
const validated = validateIdParam(idStr ?? "", mode);
|
||||
if (validated instanceof Response) return validated;
|
||||
|
||||
const result = enableModel(db, validated.id);
|
||||
if ("error" in result) {
|
||||
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
|
||||
}
|
||||
|
||||
return jsonResponse(result, { mode });
|
||||
}
|
||||
22
src/server/routes/models/get.ts
Normal file
22
src/server/routes/models/get.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type Database from "bun:sqlite";
|
||||
|
||||
import type { RuntimeMode } from "../../../shared/api";
|
||||
|
||||
import { getModel } from "../../db/models";
|
||||
import { createApiError, jsonResponse } from "../../helpers";
|
||||
import { validateIdParam } from "../../middleware";
|
||||
|
||||
export function handleGetModel(req: Request, db: Database, mode: RuntimeMode): Response {
|
||||
const url = new URL(req.url);
|
||||
const idStr = url.pathname.split("/")[3];
|
||||
|
||||
const validated = validateIdParam(idStr ?? "", mode);
|
||||
if (validated instanceof Response) return validated;
|
||||
|
||||
const result = getModel(db, validated.id);
|
||||
if ("error" in result) {
|
||||
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
|
||||
}
|
||||
|
||||
return jsonResponse(result, { mode });
|
||||
}
|
||||
27
src/server/routes/models/list.ts
Normal file
27
src/server/routes/models/list.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type Database from "bun:sqlite";
|
||||
|
||||
import type { RuntimeMode } from "../../../shared/api";
|
||||
|
||||
import { listModels } from "../../db/models";
|
||||
import { jsonResponse } from "../../helpers";
|
||||
import { validatePagination } from "../../middleware";
|
||||
|
||||
export function handleListModels(req: Request, db: Database, mode: RuntimeMode): Response {
|
||||
const url = new URL(req.url);
|
||||
const pageParam = url.searchParams.get("page");
|
||||
const pageSizeParam = url.searchParams.get("pageSize");
|
||||
const keyword = url.searchParams.get("keyword");
|
||||
const providerId = url.searchParams.get("providerId");
|
||||
|
||||
const pagination = validatePagination(pageParam, pageSizeParam, mode);
|
||||
if (pagination instanceof Response) return pagination;
|
||||
|
||||
const result = listModels(db, {
|
||||
keyword: keyword ?? undefined,
|
||||
page: pagination.page,
|
||||
pageSize: pagination.pageSize,
|
||||
providerId: providerId ?? undefined,
|
||||
});
|
||||
|
||||
return jsonResponse(result, { mode });
|
||||
}
|
||||
43
src/server/routes/models/update.ts
Normal file
43
src/server/routes/models/update.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type Database from "bun:sqlite";
|
||||
|
||||
import type { RuntimeMode, UpdateModelRequest } from "../../../shared/api";
|
||||
|
||||
import { MODEL_CAPABILITIES } from "../../../shared/api";
|
||||
import { updateModel } from "../../db/models";
|
||||
import { createApiError, jsonResponse } from "../../helpers";
|
||||
import { validateIdParam } from "../../middleware";
|
||||
|
||||
export async function handleUpdateModel(req: Request, db: Database, mode: RuntimeMode): Promise<Response> {
|
||||
const url = new URL(req.url);
|
||||
const idStr = url.pathname.split("/")[3];
|
||||
|
||||
const validated = validateIdParam(idStr ?? "", mode);
|
||||
if (validated instanceof Response) return validated;
|
||||
|
||||
let body: UpdateModelRequest;
|
||||
try {
|
||||
body = (await req.json()) as UpdateModelRequest;
|
||||
} catch {
|
||||
return jsonResponse(createApiError("Invalid JSON body", 400), { mode, status: 400 });
|
||||
}
|
||||
|
||||
if (body.capabilities !== undefined) {
|
||||
if (!Array.isArray(body.capabilities) || body.capabilities.length === 0) {
|
||||
return jsonResponse(createApiError("capabilities must be a non-empty array", 400), { mode, status: 400 });
|
||||
}
|
||||
const invalidCaps = body.capabilities.filter((c) => !MODEL_CAPABILITIES.includes(c));
|
||||
if (invalidCaps.length > 0) {
|
||||
return jsonResponse(createApiError(`Invalid capabilities: ${invalidCaps.join(", ")}`, 400), {
|
||||
mode,
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const result = updateModel(db, validated.id, body);
|
||||
if ("error" in result) {
|
||||
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
|
||||
}
|
||||
|
||||
return jsonResponse(result, { mode });
|
||||
}
|
||||
41
src/server/routes/providers/create.ts
Normal file
41
src/server/routes/providers/create.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type Database from "bun:sqlite";
|
||||
|
||||
import type { CreateProviderRequest, RuntimeMode } from "../../../shared/api";
|
||||
|
||||
import { createProvider } from "../../db/providers";
|
||||
import { createApiError, jsonResponse } from "../../helpers";
|
||||
|
||||
export async function handleCreateProvider(req: Request, db: Database, mode: RuntimeMode): Promise<Response> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
const result = createProvider(db, body);
|
||||
if ("error" in result) {
|
||||
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
|
||||
}
|
||||
|
||||
return jsonResponse(result, { mode, status: 201 });
|
||||
}
|
||||
28
src/server/routes/providers/delete.ts
Normal file
28
src/server/routes/providers/delete.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type Database from "bun:sqlite";
|
||||
|
||||
import type { RuntimeMode } from "../../../shared/api";
|
||||
|
||||
import { getModelsByProviderId } from "../../db/models";
|
||||
import { deleteProvider } from "../../db/providers";
|
||||
import { createApiError, jsonResponse } from "../../helpers";
|
||||
import { validateIdParam } from "../../middleware";
|
||||
|
||||
export function handleDeleteProvider(req: Request, db: Database, mode: RuntimeMode): Response {
|
||||
const url = new URL(req.url);
|
||||
const idStr = url.pathname.split("/")[3];
|
||||
|
||||
const validated = validateIdParam(idStr ?? "", mode);
|
||||
if (validated instanceof Response) return validated;
|
||||
|
||||
const modelCount = getModelsByProviderId(db, validated.id);
|
||||
if (modelCount > 0) {
|
||||
return jsonResponse(createApiError("该供应商下存在模型,无法删除", 409), { mode, status: 409 });
|
||||
}
|
||||
|
||||
const result = deleteProvider(db, validated.id);
|
||||
if ("error" in result) {
|
||||
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
|
||||
}
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
22
src/server/routes/providers/disable.ts
Normal file
22
src/server/routes/providers/disable.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type Database from "bun:sqlite";
|
||||
|
||||
import type { RuntimeMode } from "../../../shared/api";
|
||||
|
||||
import { disableProvider } from "../../db/providers";
|
||||
import { createApiError, jsonResponse } from "../../helpers";
|
||||
import { validateIdParam } from "../../middleware";
|
||||
|
||||
export function handleDisableProvider(req: Request, db: Database, mode: RuntimeMode): Response {
|
||||
const url = new URL(req.url);
|
||||
const idStr = url.pathname.split("/")[3];
|
||||
|
||||
const validated = validateIdParam(idStr ?? "", mode);
|
||||
if (validated instanceof Response) return validated;
|
||||
|
||||
const result = disableProvider(db, validated.id);
|
||||
if ("error" in result) {
|
||||
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
|
||||
}
|
||||
|
||||
return jsonResponse(result, { mode });
|
||||
}
|
||||
22
src/server/routes/providers/enable.ts
Normal file
22
src/server/routes/providers/enable.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type Database from "bun:sqlite";
|
||||
|
||||
import type { RuntimeMode } from "../../../shared/api";
|
||||
|
||||
import { enableProvider } from "../../db/providers";
|
||||
import { createApiError, jsonResponse } from "../../helpers";
|
||||
import { validateIdParam } from "../../middleware";
|
||||
|
||||
export function handleEnableProvider(req: Request, db: Database, mode: RuntimeMode): Response {
|
||||
const url = new URL(req.url);
|
||||
const idStr = url.pathname.split("/")[3];
|
||||
|
||||
const validated = validateIdParam(idStr ?? "", mode);
|
||||
if (validated instanceof Response) return validated;
|
||||
|
||||
const result = enableProvider(db, validated.id);
|
||||
if ("error" in result) {
|
||||
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
|
||||
}
|
||||
|
||||
return jsonResponse(result, { mode });
|
||||
}
|
||||
22
src/server/routes/providers/get.ts
Normal file
22
src/server/routes/providers/get.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type Database from "bun:sqlite";
|
||||
|
||||
import type { RuntimeMode } from "../../../shared/api";
|
||||
|
||||
import { getProvider } from "../../db/providers";
|
||||
import { createApiError, jsonResponse } from "../../helpers";
|
||||
import { validateIdParam } from "../../middleware";
|
||||
|
||||
export function handleGetProvider(req: Request, db: Database, mode: RuntimeMode): Response {
|
||||
const url = new URL(req.url);
|
||||
const idStr = url.pathname.split("/")[3];
|
||||
|
||||
const validated = validateIdParam(idStr ?? "", mode);
|
||||
if (validated instanceof Response) return validated;
|
||||
|
||||
const result = getProvider(db, validated.id);
|
||||
if ("error" in result) {
|
||||
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
|
||||
}
|
||||
|
||||
return jsonResponse(result, { mode });
|
||||
}
|
||||
25
src/server/routes/providers/list.ts
Normal file
25
src/server/routes/providers/list.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type Database from "bun:sqlite";
|
||||
|
||||
import type { RuntimeMode } from "../../../shared/api";
|
||||
|
||||
import { listProviders } from "../../db/providers";
|
||||
import { jsonResponse } from "../../helpers";
|
||||
import { validatePagination } from "../../middleware";
|
||||
|
||||
export function handleListProviders(req: Request, db: Database, mode: RuntimeMode): Response {
|
||||
const url = new URL(req.url);
|
||||
const pageParam = url.searchParams.get("page");
|
||||
const pageSizeParam = url.searchParams.get("pageSize");
|
||||
const keyword = url.searchParams.get("keyword");
|
||||
|
||||
const pagination = validatePagination(pageParam, pageSizeParam, mode);
|
||||
if (pagination instanceof Response) return pagination;
|
||||
|
||||
const result = listProviders(db, {
|
||||
keyword: keyword ?? undefined,
|
||||
page: pagination.page,
|
||||
pageSize: pagination.pageSize,
|
||||
});
|
||||
|
||||
return jsonResponse(result, { mode });
|
||||
}
|
||||
34
src/server/routes/providers/test.ts
Normal file
34
src/server/routes/providers/test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type Database from "bun:sqlite";
|
||||
|
||||
import type { RuntimeMode } from "../../../shared/api";
|
||||
|
||||
import { testProviderConnection } from "../../ai/registry";
|
||||
import { getProvider } from "../../db/providers";
|
||||
import { createApiError, jsonResponse } from "../../helpers";
|
||||
import { validateIdParam } from "../../middleware";
|
||||
|
||||
export async function handleTestProvider(req: Request, db: Database, mode: RuntimeMode): Promise<Response> {
|
||||
const url = new URL(req.url);
|
||||
const idStr = url.pathname.split("/")[3];
|
||||
|
||||
const validated = validateIdParam(idStr ?? "", mode);
|
||||
if (validated instanceof Response) return validated;
|
||||
|
||||
const providerResult = getProvider(db, validated.id);
|
||||
if ("error" in providerResult) {
|
||||
return jsonResponse(createApiError(providerResult.error, providerResult.status), {
|
||||
mode,
|
||||
status: providerResult.status,
|
||||
});
|
||||
}
|
||||
|
||||
const provider = providerResult.provider;
|
||||
const testResult = await testProviderConnection({
|
||||
apiKey: provider.apiKey,
|
||||
baseUrl: provider.baseUrl,
|
||||
name: provider.name,
|
||||
type: provider.type,
|
||||
});
|
||||
|
||||
return jsonResponse({ providerTestResponse: testResult }, { mode });
|
||||
}
|
||||
36
src/server/routes/providers/update.ts
Normal file
36
src/server/routes/providers/update.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type Database from "bun:sqlite";
|
||||
|
||||
import type { RuntimeMode, UpdateProviderRequest } from "../../../shared/api";
|
||||
|
||||
import { updateProvider } from "../../db/providers";
|
||||
import { createApiError, jsonResponse } from "../../helpers";
|
||||
import { validateIdParam } from "../../middleware";
|
||||
|
||||
export async function handleUpdateProvider(req: Request, db: Database, mode: RuntimeMode): Promise<Response> {
|
||||
const url = new URL(req.url);
|
||||
const idStr = url.pathname.split("/")[3];
|
||||
|
||||
const validated = validateIdParam(idStr ?? "", mode);
|
||||
if (validated instanceof Response) return validated;
|
||||
|
||||
let body: UpdateProviderRequest;
|
||||
try {
|
||||
body = (await req.json()) as UpdateProviderRequest;
|
||||
} catch {
|
||||
return jsonResponse(createApiError("Invalid JSON body", 400), { mode, status: 400 });
|
||||
}
|
||||
|
||||
if (body.type !== undefined && !["anthropic", "openai", "openai-compatible"].includes(body.type)) {
|
||||
return jsonResponse(createApiError("type must be one of: openai, anthropic, openai-compatible", 400), {
|
||||
mode,
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const result = updateProvider(db, validated.id, body);
|
||||
if ("error" in result) {
|
||||
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
|
||||
}
|
||||
|
||||
return jsonResponse(result, { mode });
|
||||
}
|
||||
Reference in New Issue
Block a user