fix: 模型管理审查修复与归档
- 修复 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
This commit is contained in:
@@ -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<Response> {
|
||||
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<CreateProviderRequest | 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,
|
||||
});
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<ProviderListResponse>;
|
||||
}
|
||||
|
||||
export async function testProviderConfig(data: CreateProviderRequest): Promise<ProviderTestResponse> {
|
||||
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<ProviderTestResponse> {
|
||||
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,
|
||||
|
||||
@@ -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<unknown>;
|
||||
testConnection?: (providerId: string) => Promise<ProviderTestResponse>;
|
||||
}
|
||||
|
||||
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({
|
||||
</Checkbox.Group>
|
||||
</Form.Item>
|
||||
<Form.Item label="上下文长度" name="contextLength">
|
||||
<InputNumber placeholder="可选" style={{ width: "100%" }} />
|
||||
<InputNumber placeholder="可选" />
|
||||
</Form.Item>
|
||||
<Form.Item label="最大输出 Token" name="maxOutputTokens">
|
||||
<InputNumber placeholder="可选" style={{ width: "100%" }} />
|
||||
<InputNumber placeholder="可选" />
|
||||
</Form.Item>
|
||||
{editingModel && testConnection && (
|
||||
{testConnection && (
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button loading={testing} onClick={() => void handleTest()}>
|
||||
|
||||
@@ -45,11 +45,13 @@ const COLUMNS: ColumnsType<Model> = [
|
||||
{
|
||||
dataIndex: "capabilities",
|
||||
render: (value: string[]) =>
|
||||
value.map((c) => (
|
||||
<Tag key={c} style={{ marginBottom: 2 }}>
|
||||
{CAPABILITY_LABELS[c] ?? c}
|
||||
</Tag>
|
||||
)),
|
||||
value.length > 0 ? (
|
||||
<Space size={[0, 4]} wrap>
|
||||
{value.map((c) => (
|
||||
<Tag key={c}>{CAPABILITY_LABELS[c] ?? c}</Tag>
|
||||
))}
|
||||
</Space>
|
||||
) : null,
|
||||
title: "能力",
|
||||
width: 200,
|
||||
},
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { App as AntApp, Form, Input, Modal, Select } from "antd";
|
||||
import { useEffect } from "react";
|
||||
import { App as AntApp, Button, Form, Input, Modal, Select, Space } from "antd";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import type { CreateProviderRequest, Provider, ProviderType, UpdateProviderRequest } from "../../../../shared/api";
|
||||
import type {
|
||||
CreateProviderRequest,
|
||||
Provider,
|
||||
ProviderTestResponse,
|
||||
ProviderType,
|
||||
UpdateProviderRequest,
|
||||
} from "../../../../shared/api";
|
||||
|
||||
interface FormValues {
|
||||
apiKey: string;
|
||||
@@ -15,6 +21,7 @@ interface ProviderFormModalProps {
|
||||
onCancel: () => void;
|
||||
onCreate: (data: CreateProviderRequest) => Promise<unknown>;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onTest: (data: CreateProviderRequest) => Promise<ProviderTestResponse>;
|
||||
onUpdate: (args: { data: UpdateProviderRequest; id: string }) => Promise<unknown>;
|
||||
open: boolean;
|
||||
submitting: boolean;
|
||||
@@ -31,12 +38,14 @@ export function ProviderFormModal({
|
||||
onCancel,
|
||||
onCreate,
|
||||
onOpenChange,
|
||||
onTest,
|
||||
onUpdate,
|
||||
open,
|
||||
submitting,
|
||||
}: ProviderFormModalProps) {
|
||||
const { message } = AntApp.useApp();
|
||||
const [form] = Form.useForm<FormValues>();
|
||||
const [testing, setTesting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -49,6 +58,7 @@ export function ProviderFormModal({
|
||||
});
|
||||
} else {
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ type: "openai-compatible" });
|
||||
}
|
||||
}, [editingProvider, form, open]);
|
||||
|
||||
@@ -80,6 +90,30 @@ export function ProviderFormModal({
|
||||
}
|
||||
};
|
||||
|
||||
const handleTest = async () => {
|
||||
try {
|
||||
const values = await form.validateFields(["name", "type", "baseUrl", "apiKey"]);
|
||||
setTesting(true);
|
||||
const result = await onTest({
|
||||
apiKey: values.apiKey,
|
||||
baseUrl: values.baseUrl,
|
||||
name: values.name,
|
||||
type: values.type,
|
||||
});
|
||||
if (result.ok) {
|
||||
message.success(result.message);
|
||||
} else {
|
||||
message.error(result.message);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
message.error(err.message);
|
||||
}
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
confirmLoading={submitting}
|
||||
@@ -107,6 +141,13 @@ export function ProviderFormModal({
|
||||
<Form.Item label="API Key" name="apiKey" rules={[{ message: "请输入 API Key", required: true }]}>
|
||||
<Input.Password placeholder="请输入 API Key" />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button loading={testing} onClick={() => void handleTest()}>
|
||||
测试连接
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -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<unknown>;
|
||||
onPageChange: (page: number, pageSize: number) => void;
|
||||
onTest: (id: string) => Promise<unknown>;
|
||||
onTest: (id: string) => Promise<ProviderTestResponse>;
|
||||
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) => (
|
||||
<Space size="small">
|
||||
<Tooltip title="测试连接">
|
||||
<Button icon={<ThunderboltOutlined />} onClick={() => void handleTest(record.id)} size="small" type="link" />
|
||||
<Button
|
||||
aria-label="测试连接"
|
||||
icon={<ThunderboltOutlined />}
|
||||
onClick={() => void handleTest(record.id)}
|
||||
size="small"
|
||||
type="link"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Button icon={<EditOutlined />} onClick={() => onEdit(record)} size="small" type="link">
|
||||
编辑
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
useDisableProvider,
|
||||
useEnableProvider,
|
||||
useProviderList,
|
||||
useTestProviderConfig,
|
||||
useTestProviderConnection,
|
||||
useUpdateProvider,
|
||||
} from "../../hooks/use-providers";
|
||||
@@ -48,6 +49,11 @@ export function ModelsPage() {
|
||||
pageSize: providerPageSize,
|
||||
});
|
||||
|
||||
const { data: modelProviderData, isLoading: modelProviderLoading } = useProviderList({
|
||||
page: 1,
|
||||
pageSize: 1000,
|
||||
});
|
||||
|
||||
const { data: modelData, isLoading: modelLoading } = useModelList({
|
||||
keyword: modelKeyword || undefined,
|
||||
page: modelPage,
|
||||
@@ -60,6 +66,7 @@ export function ModelsPage() {
|
||||
const enableProviderMutation = useEnableProvider();
|
||||
const disableProviderMutation = useDisableProvider();
|
||||
const testProviderMutation = useTestProviderConnection();
|
||||
const testProviderConfigMutation = useTestProviderConfig();
|
||||
|
||||
const createModelMutation = useCreateModel();
|
||||
const updateModelMutation = useUpdateModel();
|
||||
@@ -74,6 +81,7 @@ export function ModelsPage() {
|
||||
const isModelSubmitting = createModelMutation.isPending || updateModelMutation.isPending;
|
||||
const isModelActionPending =
|
||||
deleteModelMutation.isPending || enableModelMutation.isPending || disableModelMutation.isPending;
|
||||
const modelProviders = modelProviderData?.items ?? [];
|
||||
|
||||
return (
|
||||
<Flex flex={1} gap="var(--ant-margin-lg)" vertical>
|
||||
@@ -126,9 +134,10 @@ export function ModelsPage() {
|
||||
onCancel={() => setProviderDialogOpen(false)}
|
||||
onCreate={(data) => createProviderMutation.mutateAsync(data)}
|
||||
onOpenChange={setProviderDialogOpen}
|
||||
onTest={(data) => testProviderConfigMutation.mutateAsync(data)}
|
||||
onUpdate={(args) => updateProviderMutation.mutateAsync(args)}
|
||||
open={providerDialogOpen}
|
||||
submitting={isProviderSubmitting}
|
||||
submitting={isProviderSubmitting || testProviderConfigMutation.isPending}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -152,7 +161,7 @@ export function ModelsPage() {
|
||||
/>
|
||||
<ModelTable
|
||||
data={modelData}
|
||||
loading={modelLoading || isModelActionPending}
|
||||
loading={modelLoading || modelProviderLoading || isModelActionPending}
|
||||
onDelete={(id) => deleteModelMutation.mutateAsync(id)}
|
||||
onDisable={(id) => disableModelMutation.mutateAsync(id)}
|
||||
onEdit={(model) => {
|
||||
@@ -166,7 +175,7 @@ export function ModelsPage() {
|
||||
}}
|
||||
page={modelPage}
|
||||
pageSize={modelPageSize}
|
||||
providers={providerData?.items ?? []}
|
||||
providers={modelProviders}
|
||||
/>
|
||||
<ModelFormModal
|
||||
editingModel={editingModel}
|
||||
@@ -175,9 +184,9 @@ export function ModelsPage() {
|
||||
onOpenChange={setModelDialogOpen}
|
||||
onUpdate={(args) => updateModelMutation.mutateAsync(args)}
|
||||
open={modelDialogOpen}
|
||||
providers={providerData?.items ?? []}
|
||||
providers={modelProviders}
|
||||
submitting={isModelSubmitting}
|
||||
testConnection={editingModel ? (id: string) => testProviderMutation.mutateAsync(id) : undefined}
|
||||
testConnection={(id: string) => testProviderMutation.mutateAsync(id)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user