2b7ef65cce
CI / verify (pull_request) Has been cancelled
Add owner-scoped model API settings, uploadable knowledge bases, and WeCom bot AI/KB wiring with OpenAI-compatible URL normalization. Co-authored-by: Cursor <cursoragent@cursor.com>
381 lines
11 KiB
TypeScript
381 lines
11 KiB
TypeScript
import { useEffect, useState } from 'react';
|
||
import {
|
||
Button,
|
||
Form,
|
||
Input,
|
||
InputNumber,
|
||
Modal,
|
||
Popconfirm,
|
||
Select,
|
||
Space,
|
||
Switch,
|
||
Table,
|
||
Tag,
|
||
Typography,
|
||
message,
|
||
} from 'antd';
|
||
import type { ColumnsType } from 'antd/es/table';
|
||
import {
|
||
LLM_PROVIDERS,
|
||
LLM_PROVIDER_LABELS,
|
||
LLM_PROVIDER_PRESETS,
|
||
type LlmApiConfigDto,
|
||
type LlmProvider,
|
||
} from '@dukang/shared-types';
|
||
import { request } from '../lib/api';
|
||
import { fmtTime } from '../lib/constants';
|
||
import { useAdminList } from '../lib/useAdminList';
|
||
|
||
type FormValues = {
|
||
name: string;
|
||
provider: LlmProvider;
|
||
baseUrl?: string;
|
||
apiKey?: string;
|
||
modelName?: string;
|
||
temperature?: number | null;
|
||
maxTokens?: number | null;
|
||
systemPrompt?: string;
|
||
enabled: boolean;
|
||
};
|
||
|
||
export default function LlmConfigsPage() {
|
||
const [filterForm] = Form.useForm();
|
||
const [form] = Form.useForm<FormValues>();
|
||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||
const [modalOpen, setModalOpen] = useState(false);
|
||
const [editing, setEditing] = useState<LlmApiConfigDto | null>(null);
|
||
const [saving, setSaving] = useState(false);
|
||
const providerWatch = Form.useWatch('provider', form);
|
||
|
||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<LlmApiConfigDto>(
|
||
'/admin/llm-configs',
|
||
() => {
|
||
const qs = new URLSearchParams();
|
||
if (filters.name) qs.set('name', filters.name);
|
||
if (filters.enabled) qs.set('enabled', filters.enabled);
|
||
return qs;
|
||
},
|
||
[filters],
|
||
);
|
||
|
||
useEffect(() => {
|
||
if (!providerWatch || editing) return;
|
||
const preset = LLM_PROVIDER_PRESETS[providerWatch as LlmProvider];
|
||
if (!preset) return;
|
||
form.setFieldsValue({
|
||
baseUrl: preset.defaultBaseUrl || undefined,
|
||
modelName: preset.defaultModel || undefined,
|
||
});
|
||
}, [providerWatch, editing, form]);
|
||
|
||
const openCreate = () => {
|
||
setEditing(null);
|
||
form.setFieldsValue({
|
||
name: '',
|
||
provider: 'DEEPSEEK',
|
||
baseUrl: LLM_PROVIDER_PRESETS.DEEPSEEK.defaultBaseUrl,
|
||
apiKey: '',
|
||
modelName: LLM_PROVIDER_PRESETS.DEEPSEEK.defaultModel,
|
||
temperature: 0.3,
|
||
maxTokens: 1024,
|
||
systemPrompt: '',
|
||
enabled: true,
|
||
});
|
||
setModalOpen(true);
|
||
};
|
||
|
||
const openEdit = (row: LlmApiConfigDto) => {
|
||
setEditing(row);
|
||
form.setFieldsValue({
|
||
name: row.name,
|
||
provider: row.provider,
|
||
baseUrl: row.baseUrl,
|
||
apiKey: '',
|
||
modelName: row.modelName,
|
||
temperature: row.temperature,
|
||
maxTokens: row.maxTokens,
|
||
systemPrompt: row.systemPrompt || '',
|
||
enabled: row.enabled,
|
||
});
|
||
setModalOpen(true);
|
||
};
|
||
|
||
const onSave = async () => {
|
||
const values = await form.validateFields();
|
||
setSaving(true);
|
||
try {
|
||
if (editing) {
|
||
if (!editing.canEditFull) {
|
||
await request(`/admin/llm-configs/${editing.id}`, {
|
||
method: 'PUT',
|
||
body: JSON.stringify({ enabled: values.enabled }),
|
||
});
|
||
} else {
|
||
await request(`/admin/llm-configs/${editing.id}`, {
|
||
method: 'PUT',
|
||
body: JSON.stringify({
|
||
name: values.name,
|
||
provider: values.provider,
|
||
baseUrl: values.baseUrl,
|
||
apiKey: values.apiKey || undefined,
|
||
modelName: values.modelName,
|
||
temperature: values.temperature ?? null,
|
||
maxTokens: values.maxTokens ?? null,
|
||
systemPrompt: values.systemPrompt || null,
|
||
enabled: values.enabled,
|
||
}),
|
||
});
|
||
}
|
||
message.success('已保存');
|
||
} else {
|
||
await request('/admin/llm-configs', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
name: values.name,
|
||
provider: values.provider,
|
||
baseUrl: values.baseUrl,
|
||
apiKey: values.apiKey,
|
||
modelName: values.modelName,
|
||
temperature: values.temperature ?? null,
|
||
maxTokens: values.maxTokens ?? null,
|
||
systemPrompt: values.systemPrompt || null,
|
||
enabled: values.enabled,
|
||
}),
|
||
});
|
||
message.success('已创建');
|
||
}
|
||
setModalOpen(false);
|
||
reload();
|
||
} catch (e) {
|
||
message.error(e instanceof Error ? e.message : String(e));
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
const columns: ColumnsType<LlmApiConfigDto> = [
|
||
{ title: '名称', dataIndex: 'name' },
|
||
{
|
||
title: '提供商',
|
||
dataIndex: 'provider',
|
||
width: 120,
|
||
render: (p: LlmProvider) => LLM_PROVIDER_LABELS[p] || p,
|
||
},
|
||
{ title: '模型', dataIndex: 'modelName', ellipsis: true },
|
||
{
|
||
title: 'Key',
|
||
dataIndex: 'apiKeyConfigured',
|
||
width: 80,
|
||
render: (v: boolean) => (v ? <Tag color="green">已配</Tag> : <Tag>无</Tag>),
|
||
},
|
||
{
|
||
title: '生效',
|
||
dataIndex: 'enabled',
|
||
width: 90,
|
||
render: (v: boolean, row) => (
|
||
<Switch
|
||
checked={v}
|
||
disabled={!row.isOwner && !row.canEditFull}
|
||
onChange={async (checked) => {
|
||
try {
|
||
await request(`/admin/llm-configs/${row.id}`, {
|
||
method: 'PUT',
|
||
body: JSON.stringify({ enabled: checked }),
|
||
});
|
||
message.success(checked ? '已启用' : '已停用');
|
||
reload();
|
||
} catch (e) {
|
||
message.error(e instanceof Error ? e.message : String(e));
|
||
}
|
||
}}
|
||
/>
|
||
),
|
||
},
|
||
{
|
||
title: '创建人',
|
||
dataIndex: 'createdByName',
|
||
width: 100,
|
||
render: (v: string | null, row) => v || row.createdByHqAccountId,
|
||
},
|
||
{
|
||
title: '更新时间',
|
||
dataIndex: 'updatedAt',
|
||
width: 170,
|
||
render: (t: string) => fmtTime(t),
|
||
},
|
||
{
|
||
title: '操作',
|
||
width: 220,
|
||
render: (_, row) => (
|
||
<Space>
|
||
<Button
|
||
type="link"
|
||
size="small"
|
||
onClick={() => openEdit(row)}
|
||
>
|
||
{row.canEditFull ? '编辑' : '开关'}
|
||
</Button>
|
||
<Button
|
||
type="link"
|
||
size="small"
|
||
onClick={async () => {
|
||
try {
|
||
const res = await request<{ reply: string }>(`/admin/llm-configs/${row.id}/test`, {
|
||
method: 'POST',
|
||
body: '{}',
|
||
});
|
||
message.success(res.reply || '测试成功');
|
||
} catch (e) {
|
||
message.error(e instanceof Error ? e.message : String(e));
|
||
}
|
||
}}
|
||
>
|
||
测试
|
||
</Button>
|
||
{row.canEditFull ? (
|
||
<Popconfirm
|
||
title="确认删除?"
|
||
onConfirm={async () => {
|
||
try {
|
||
await request(`/admin/llm-configs/${row.id}`, { method: 'DELETE' });
|
||
message.success('已删除');
|
||
reload();
|
||
} catch (e) {
|
||
message.error(e instanceof Error ? e.message : String(e));
|
||
}
|
||
}}
|
||
>
|
||
<Button type="link" size="small" danger>
|
||
删除
|
||
</Button>
|
||
</Popconfirm>
|
||
) : null}
|
||
</Space>
|
||
),
|
||
},
|
||
];
|
||
|
||
const fullEdit = !editing || editing.canEditFull;
|
||
|
||
return (
|
||
<div>
|
||
<Space style={{ marginBottom: 16 }} wrap>
|
||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||
语言模型配置
|
||
</Typography.Title>
|
||
<Typography.Text type="secondary">
|
||
非超管仅可见自己创建的配置,且创建后只能改是否生效
|
||
</Typography.Text>
|
||
</Space>
|
||
|
||
<Form
|
||
form={filterForm}
|
||
layout="inline"
|
||
style={{ marginBottom: 16 }}
|
||
onFinish={(v) =>
|
||
setFilters({
|
||
name: v.name || '',
|
||
enabled: v.enabled === undefined || v.enabled === null ? '' : String(v.enabled),
|
||
})
|
||
}
|
||
>
|
||
<Form.Item name="name" label="名称">
|
||
<Input allowClear placeholder="搜索" />
|
||
</Form.Item>
|
||
<Form.Item name="enabled" label="生效">
|
||
<Select
|
||
allowClear
|
||
style={{ width: 100 }}
|
||
options={[
|
||
{ value: true, label: '开' },
|
||
{ value: false, label: '关' },
|
||
]}
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item>
|
||
<Button type="primary" htmlType="submit">
|
||
查询
|
||
</Button>
|
||
</Form.Item>
|
||
<Form.Item>
|
||
<Button type="primary" onClick={openCreate}>
|
||
新建配置
|
||
</Button>
|
||
</Form.Item>
|
||
</Form>
|
||
|
||
<Table
|
||
rowKey="id"
|
||
loading={loading}
|
||
columns={columns}
|
||
dataSource={data?.items}
|
||
pagination={{
|
||
current: page,
|
||
pageSize,
|
||
total: data?.total,
|
||
showSizeChanger: true,
|
||
onChange: (p, ps) => {
|
||
setPage(p);
|
||
setPageSize(ps);
|
||
},
|
||
}}
|
||
/>
|
||
|
||
<Modal
|
||
title={editing ? (fullEdit ? '编辑语言模型' : '修改是否生效') : '新建语言模型'}
|
||
open={modalOpen}
|
||
onCancel={() => setModalOpen(false)}
|
||
onOk={() => void onSave()}
|
||
confirmLoading={saving}
|
||
destroyOnClose
|
||
width={640}
|
||
>
|
||
<Form form={form} layout="vertical">
|
||
<Form.Item name="name" label="名称" rules={[{ required: true, message: '必填' }]}>
|
||
<Input disabled={!fullEdit} />
|
||
</Form.Item>
|
||
<Form.Item name="provider" label="提供商" rules={[{ required: true }]}>
|
||
<Select
|
||
disabled={!fullEdit}
|
||
options={LLM_PROVIDERS.map((p) => ({ value: p, label: LLM_PROVIDER_LABELS[p] }))}
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="baseUrl"
|
||
label="Base URL"
|
||
rules={[{ required: fullEdit, message: '必填' }]}
|
||
extra="填主机根地址即可,如 https://api.deepseek.com(不要带 /v1)"
|
||
>
|
||
<Input disabled={!fullEdit} placeholder="https://api.deepseek.com" />
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="apiKey"
|
||
label="API Key"
|
||
rules={editing ? [] : [{ required: true, message: '必填' }]}
|
||
extra={editing ? '留空表示不修改' : undefined}
|
||
>
|
||
<Input.Password disabled={!fullEdit} />
|
||
</Form.Item>
|
||
<Form.Item name="modelName" label="模型名" rules={[{ required: fullEdit, message: '必填' }]}>
|
||
<Input disabled={!fullEdit} />
|
||
</Form.Item>
|
||
<Space style={{ display: 'flex' }} size="large">
|
||
<Form.Item name="temperature" label="Temperature">
|
||
<InputNumber disabled={!fullEdit} min={0} max={2} step={0.1} style={{ width: 120 }} />
|
||
</Form.Item>
|
||
<Form.Item name="maxTokens" label="Max Tokens">
|
||
<InputNumber disabled={!fullEdit} min={1} max={128000} style={{ width: 140 }} />
|
||
</Form.Item>
|
||
</Space>
|
||
<Form.Item name="systemPrompt" label="系统提示词">
|
||
<Input.TextArea disabled={!fullEdit} rows={3} />
|
||
</Form.Item>
|
||
<Form.Item name="enabled" label="生效" valuePropName="checked">
|
||
<Switch />
|
||
</Form.Item>
|
||
</Form>
|
||
</Modal>
|
||
</div>
|
||
);
|
||
}
|