feat(admin): LLM config, knowledge base, and WeCom AI binding
CI / verify (pull_request) Has been cancelled
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>
This commit is contained in:
@@ -0,0 +1,380 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user