refactor: 简化模型管理,移除启用/禁用,优化测试和布局

- 移除供应商/模型启用禁用能力,清理DB schema/migration/API/前端
- 供应商测试改为Base URL连通性+/models探测
- 新增POST /api/models/test模型连接测试
- 新增GET /api/providers/options专用供应商选项接口
- 统一工具栏为ModelsToolbar,参考项目管理布局
- 模型弹窗优化:默认能力、响应式3列标签、并排数值
- 前后端正整数校验、供应商下拉loading/error/empty状态
- 表格列宽统一,操作列/名称列固定宽度
This commit is contained in:
2026-05-29 18:03:33 +08:00
parent 9241c782e6
commit 34e915ccf4
39 changed files with 895 additions and 961 deletions

View File

@@ -8,10 +8,10 @@ import { createProviderRegistry, generateText } from "ai";
import type { AIProviderConfig } from "./types";
export function buildProviderRegistry(db: Database) {
const enabledProviders = getEnabledProviders(db);
const providers = getProviders(db);
const providerEntries: Record<string, ReturnType<typeof createProvider>> = {};
for (const p of enabledProviders) {
for (const p of providers) {
providerEntries[p.id] = createProvider({
apiKey: p.api_key,
baseUrl: p.base_url,
@@ -23,24 +23,105 @@ export function buildProviderRegistry(db: Database) {
return createProviderRegistry(providerEntries);
}
export async function testProviderConnection(config: AIProviderConfig): Promise<{ message: string; ok: boolean }> {
export async function testModelConnection(
config: AIProviderConfig & { modelId: string },
): Promise<{ message: string; ok: boolean }> {
try {
const provider = createProvider(config);
const model = provider.languageModel("test");
await generateText({
maxOutputTokens: 1,
model,
maxOutputTokens: 10,
model: provider.languageModel(config.modelId),
prompt: "Hi",
});
return { message: "连接成功", ok: true };
return { message: "模型连接成功", ok: true };
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
return { message: `连接失败: ${msg}`, ok: false };
return { message: `模型连接失败${msg}`, ok: false };
}
}
export async function testProviderConnection(config: AIProviderConfig): Promise<{ message: string; ok: boolean }> {
const baseUrlResult = await probeBaseUrl(config.baseUrl);
if (!baseUrlResult.ok) return baseUrlResult;
const modelsUrl = buildModelsUrl(config.baseUrl);
try {
const response = await fetch(modelsUrl, {
headers: buildModelsHeaders(config),
signal: AbortSignal.timeout(5000),
});
if (response.status === 401 || response.status === 403) {
return { message: "Base URL 可连接,但 API Key 无效或权限不足。", ok: false };
}
if ([404, 405, 501].includes(response.status)) {
return {
message: "Base URL 可连接,但可能不支持 /models 接口;可检查 URL 或忽略此提示。",
ok: true,
};
}
if (!response.ok) {
return {
message: `Base URL 可连接,但 /models 请求失败HTTP ${response.status});可检查 URL 或忽略此提示。`,
ok: true,
};
}
const body = (await response.json().catch(() => null)) as unknown;
const modelCount = countModels(body);
if (modelCount !== null) {
return { message: `连接成功,/models 返回 ${modelCount} 个模型。`, ok: true };
}
return {
message: "Base URL 可连接,但 /models 返回格式不兼容,可能不支持 /models可检查 URL 或忽略此提示。",
ok: true,
};
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
return { message: `Base URL 可连接,但 /models 请求异常:${msg};可检查 URL 或忽略此提示。`, ok: true };
}
}
function buildModelsHeaders(config: AIProviderConfig): HeadersInit {
if (config.type === "anthropic") {
return {
accept: "application/json",
"anthropic-version": "2023-06-01",
"x-api-key": config.apiKey,
};
}
return {
accept: "application/json",
authorization: `Bearer ${config.apiKey}`,
};
}
function buildModelsUrl(baseUrl: string): string {
const url = new URL(baseUrl);
url.pathname = `${url.pathname.replace(/\/$/, "")}/models`;
url.search = "";
url.hash = "";
return url.toString();
}
function countModels(body: unknown): null | number {
if (Array.isArray(body)) return body.length;
if (!body || typeof body !== "object") return null;
const data = (body as { data?: unknown }).data;
if (Array.isArray(data)) return data.length;
const models = (body as { models?: unknown }).models;
if (Array.isArray(models)) return models.length;
return null;
}
function createProvider(config: AIProviderConfig) {
switch (config.type) {
case "anthropic":
@@ -56,14 +137,14 @@ function createProvider(config: AIProviderConfig) {
}
}
function getEnabledProviders(db: Database): Array<{
function getProviders(db: Database): Array<{
api_key: string;
base_url: string;
id: string;
name: string;
type: "anthropic" | "openai" | "openai-compatible";
}> {
const stmt = db.prepare("SELECT id, name, type, base_url, api_key FROM providers WHERE enabled = 1");
const stmt = db.prepare("SELECT id, name, type, base_url, api_key FROM providers");
return stmt.all() as Array<{
api_key: string;
base_url: string;
@@ -72,3 +153,16 @@ function getEnabledProviders(db: Database): Array<{
type: "anthropic" | "openai" | "openai-compatible";
}>;
}
async function probeBaseUrl(baseUrl: string): Promise<{ message: string; ok: boolean }> {
try {
await fetch(baseUrl, {
method: "HEAD",
signal: AbortSignal.timeout(5000),
});
return { message: "Base URL 可连接", ok: true };
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
return { message: `Base URL 不可达:${msg}`, ok: false };
}
}

View File

@@ -36,7 +36,6 @@ export function createModel(
capabilities: JSON.stringify(capabilities),
contextLength: request.contextLength ?? null,
createdAt: now,
enabled: true,
id,
maxOutputTokens: request.maxOutputTokens ?? null,
modelId,
@@ -66,32 +65,6 @@ export function deleteModel(raw: Database, id: string): { error: string; status:
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();
@@ -222,7 +195,6 @@ function toModel(row: typeof models.$inferSelect): Model {
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,

View File

@@ -3,7 +3,7 @@ import type Database from "bun:sqlite";
import { and, desc, eq, like, sql } from "drizzle-orm";
import { drizzle } from "drizzle-orm/bun-sqlite";
import type { CreateProviderRequest, Provider, UpdateProviderRequest } from "../../shared/api";
import type { CreateProviderRequest, Provider, ProviderOption, UpdateProviderRequest } from "../../shared/api";
import { providers } from "./schema";
@@ -30,7 +30,6 @@ export function createProvider(
apiKey,
baseUrl,
createdAt: now,
enabled: true,
id,
name,
type: request.type,
@@ -58,32 +57,6 @@ export function deleteProvider(raw: Database, id: string): { error: string; stat
return { success: true };
}
export function disableProvider(raw: Database, id: string): { error: string; status: number } | { provider: Provider } {
const db = wrap(raw);
const existing = db.select().from(providers).where(eq(providers.id, id)).get();
if (!existing) return { error: "供应商不存在", status: 404 };
if (!existing.enabled) return { error: "供应商已禁用", status: 409 };
const now = new Date().toISOString();
db.update(providers).set({ enabled: false, updatedAt: now }).where(eq(providers.id, id)).run();
const updated = db.select().from(providers).where(eq(providers.id, id)).get();
return { provider: toProvider(updated!) };
}
export function enableProvider(raw: Database, id: string): { error: string; status: number } | { provider: Provider } {
const db = wrap(raw);
const existing = db.select().from(providers).where(eq(providers.id, id)).get();
if (!existing) return { error: "供应商不存在", status: 404 };
if (existing.enabled) return { error: "供应商已启用", status: 409 };
const now = new Date().toISOString();
db.update(providers).set({ enabled: true, updatedAt: now }).where(eq(providers.id, id)).run();
const updated = db.select().from(providers).where(eq(providers.id, id)).get();
return { provider: toProvider(updated!) };
}
export function getProvider(raw: Database, id: string): { error: string; status: number } | { provider: Provider } {
const db = wrap(raw);
const row = db.select().from(providers).where(eq(providers.id, id)).get();
@@ -92,6 +65,17 @@ export function getProvider(raw: Database, id: string): { error: string; status:
return { provider: toProvider(row) };
}
export function listProviderOptions(raw: Database): ProviderOption[] {
const db = wrap(raw);
const rows = db
.select({ id: providers.id, name: providers.name, type: providers.type })
.from(providers)
.orderBy(desc(providers.createdAt))
.all();
return rows;
}
export function listProviders(
raw: Database,
options: { keyword?: string; page: number; pageSize: number },
@@ -189,7 +173,6 @@ function toProvider(row: typeof providers.$inferSelect): Provider {
apiKey: row.apiKey,
baseUrl: row.baseUrl,
createdAt: row.createdAt,
enabled: row.enabled,
id: row.id,
name: row.name,
type: row.type,

View File

@@ -16,7 +16,6 @@ export const providers = sqliteTable("providers", {
apiKey: text("api_key").notNull(),
baseUrl: text("base_url").notNull(),
createdAt: text("created_at").notNull(),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
id: text("id").primaryKey(),
name: text("name").notNull().unique(),
type: text("type", { enum: ["anthropic", "openai", "openai-compatible"] })
@@ -31,7 +30,6 @@ export const models = sqliteTable(
capabilities: text("capabilities").notNull(),
contextLength: integer("context_length"),
createdAt: text("created_at").notNull(),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
id: text("id").primaryKey(),
maxOutputTokens: integer("max_output_tokens"),
modelId: text("model_id").notNull(),

View File

@@ -38,6 +38,12 @@ export async function handleCreateModel(req: Request, db: Database, mode: Runtim
return jsonResponse(createApiError(`Invalid capabilities: ${invalidCaps.join(", ")}`, 400), { mode, status: 400 });
}
const numberError = validateOptionalPositiveInteger("contextLength", body.contextLength);
if (numberError) return jsonResponse(createApiError(numberError, 400), { mode, status: 400 });
const tokenError = validateOptionalPositiveInteger("maxOutputTokens", body.maxOutputTokens);
if (tokenError) return jsonResponse(createApiError(tokenError, 400), { mode, status: 400 });
const result = createModel(db, body);
if ("error" in result) {
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
@@ -45,3 +51,9 @@ export async function handleCreateModel(req: Request, db: Database, mode: Runtim
return jsonResponse(result, { mode, status: 201 });
}
function validateOptionalPositiveInteger(field: string, value: null | number | undefined): null | string {
if (value === undefined || value === null) return null;
if (!Number.isInteger(value) || value <= 0) return `${field} must be a positive integer`;
return null;
}

View File

@@ -1,22 +0,0 @@
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 });
}

View File

@@ -1,22 +0,0 @@
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 });
}

View File

@@ -0,0 +1,42 @@
import type Database from "bun:sqlite";
import type { RuntimeMode, TestModelRequest } from "../../../shared/api";
import { testModelConnection } from "../../ai/registry";
import { getProvider } from "../../db/providers";
import { createApiError, jsonResponse } from "../../helpers";
export async function handleTestModelConfig(req: Request, db: Database, mode: RuntimeMode): Promise<Response> {
let body: TestModelRequest;
try {
body = (await req.json()) as TestModelRequest;
} catch {
return jsonResponse(createApiError("Invalid JSON body", 400), { mode, status: 400 });
}
if (!body.providerId || typeof body.providerId !== "string") {
return jsonResponse(createApiError("providerId is required", 400), { mode, status: 400 });
}
if (!body.modelId || typeof body.modelId !== "string") {
return jsonResponse(createApiError("modelId is required", 400), { mode, status: 400 });
}
const providerResult = getProvider(db, body.providerId);
if ("error" in providerResult) {
return jsonResponse(createApiError(providerResult.error, providerResult.status), {
mode,
status: providerResult.status,
});
}
const testResult = await testModelConnection({
apiKey: providerResult.provider.apiKey,
baseUrl: providerResult.provider.baseUrl,
modelId: body.modelId,
name: providerResult.provider.name,
type: providerResult.provider.type,
});
return jsonResponse({ modelTestResponse: testResult }, { mode });
}

View File

@@ -34,6 +34,12 @@ export async function handleUpdateModel(req: Request, db: Database, mode: Runtim
}
}
const numberError = validateOptionalPositiveInteger("contextLength", body.contextLength);
if (numberError) return jsonResponse(createApiError(numberError, 400), { mode, status: 400 });
const tokenError = validateOptionalPositiveInteger("maxOutputTokens", body.maxOutputTokens);
if (tokenError) return jsonResponse(createApiError(tokenError, 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 });
@@ -41,3 +47,9 @@ export async function handleUpdateModel(req: Request, db: Database, mode: Runtim
return jsonResponse(result, { mode });
}
function validateOptionalPositiveInteger(field: string, value: null | number | undefined): null | string {
if (value === undefined || value === null) return null;
if (!Number.isInteger(value) || value <= 0) return `${field} must be a positive integer`;
return null;
}

View File

@@ -1,22 +0,0 @@
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 });
}

View File

@@ -1,22 +0,0 @@
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 });
}

View File

@@ -0,0 +1,10 @@
import type Database from "bun:sqlite";
import type { RuntimeMode } from "../../../shared/api";
import { listProviderOptions } from "../../db/providers";
import { jsonResponse } from "../../helpers";
export function handleListProviderOptions(db: Database, mode: RuntimeMode): Response {
return jsonResponse({ items: listProviderOptions(db) }, { mode });
}

View File

@@ -3,35 +3,7 @@ import type Database from "bun:sqlite";
import type { CreateProviderRequest, 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 });
}
export async function handleTestProviderConfig(req: Request, _db: Database, mode: RuntimeMode): Promise<Response> {
const validated = await readProviderConfig(req, mode);

View File

@@ -67,16 +67,10 @@ export function startServer(options: StartServerOptions) {
return handleUpdateModel(req, db, mode);
},
},
"/api/models/:id/disable": {
"/api/models/test": {
POST: async (req) => {
const { handleDisableModel } = await import("./routes/models/disable");
return handleDisableModel(req, db, mode);
},
},
"/api/models/:id/enable": {
POST: async (req) => {
const { handleEnableModel } = await import("./routes/models/enable");
return handleEnableModel(req, db, mode);
const { handleTestModelConfig } = await import("./routes/models/test");
return handleTestModelConfig(req, db, mode);
},
},
"/api/projects": {
@@ -139,22 +133,10 @@ export function startServer(options: StartServerOptions) {
return handleUpdateProvider(req, db, mode);
},
},
"/api/providers/:id/disable": {
POST: async (req) => {
const { handleDisableProvider } = await import("./routes/providers/disable");
return handleDisableProvider(req, db, mode);
},
},
"/api/providers/:id/enable": {
POST: async (req) => {
const { handleEnableProvider } = await import("./routes/providers/enable");
return handleEnableProvider(req, db, mode);
},
},
"/api/providers/:id/test": {
POST: async (req) => {
const { handleTestProvider } = await import("./routes/providers/test");
return handleTestProvider(req, db, mode);
"/api/providers/options": {
GET: async () => {
const { handleListProviderOptions } = await import("./routes/providers/options");
return handleListProviderOptions(db, mode);
},
},
"/api/providers/test": {