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:
2026-05-29 14:05:01 +08:00
parent 933c2133f0
commit 48c76e6180
23 changed files with 440 additions and 353 deletions

View File

@@ -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,

View File

@@ -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()}>

View File

@@ -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,
},

View File

@@ -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>
);

View File

@@ -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">

View File

@@ -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)}
/>
</>
)}