imeeting/frontend/src/pages/business/AiModels.tsx

717 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import React, { useEffect, useMemo, useRef, useState } from "react";
import { AutoComplete, Button, Card, Col, Divider, Drawer, Form, Input, InputNumber, Popconfirm, Row, Select, Space, Switch, Table, Tabs, Tag, Tooltip, Typography, App } from 'antd';
import PageContainer from "@/components/shared/PageContainer";
import {
DeleteOutlined,
EditOutlined,
PlusOutlined,
SafetyCertificateOutlined,
SaveOutlined,
SearchOutlined,
SyncOutlined,
WifiOutlined,
} from "@ant-design/icons";
import { useDict } from "../../hooks/useDict";
import {
AiModelDTO,
AiLocalProfileVO,
AiModelVO,
deleteAiModelByType,
getAiModelPage,
getRemoteModelList,
saveAiModel,
testLlmModelConnectivity,
testLocalModelConnectivity,
updateAiModel,
} from "../../api/business/aimodel";
import AppPagination from "../../components/shared/AppPagination";
const { Option } = Select;
const { Title } = Typography;
type ModelType = "ASR" | "LLM";
const PROVIDER_BASE_URL_MAP: Record<string, string> = {
openai: "https://api.openai.com",
deepseek: "https://api.deepseek.com",
aliyun: "https://dashscope.aliyuncs.com/compatible-mode",
qwen: "https://dashscope.aliyuncs.com/compatible-mode",
dashscope: "https://dashscope.aliyuncs.com/compatible-mode",
moonshot: "https://api.moonshot.cn",
kimi: "https://api.moonshot.cn",
groq: "https://api.groq.com/openai",
};
const DEFAULT_LLM_TEST_MESSAGE = "请回复LLM 连通性测试成功。";
const AiModels: React.FC = () => {
const { message } = App.useApp();
const [form] = Form.useForm();
const { items: providers } = useDict("biz_ai_provider");
const [activeType, setActiveType] = useState<ModelType>("ASR");
const [loading, setLoading] = useState(false);
const [data, setData] = useState<AiModelVO[]>([]);
const [total, setTotal] = useState(0);
const [current, setCurrent] = useState(1);
const [size, setSize] = useState(10);
const [searchName, setSearchName] = useState("");
const [drawerVisible, setDrawerVisible] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [submitLoading, setSubmitLoading] = useState(false);
const [fetchLoading, setFetchLoading] = useState(false);
const [connectivityLoading, setConnectivityLoading] = useState(false);
const [remoteModels, setRemoteModels] = useState<string[]>([]);
const [speakerModels, setSpeakerModels] = useState<string[]>([]);
const modelNameAutoFilledRef = useRef(false);
const localProfileLoadedRef = useRef(false);
const provider = Form.useWatch("provider", form);
const isDefaultChecked = Form.useWatch("isDefaultChecked", form);
const isLocalProvider = String(provider || "").toLowerCase() === "custom";
const isTencentProvider = String(provider || "").toLowerCase() === "tencent";
const isPlatformAdmin = useMemo(() => {
const profileStr = sessionStorage.getItem("userProfile");
if (!profileStr) {
return false;
}
const profile = JSON.parse(profileStr);
return profile.isPlatformAdmin === true;
}, []);
useEffect(() => {
void fetchData();
}, [current, size, searchName, activeType]);
useEffect(() => {
if (!drawerVisible || !provider) {
return;
}
const providerItem = providers.find((item) => item.itemValue === provider);
const providerLabel = providerItem?.itemLabel || provider;
const currentDisplayName = form.getFieldValue("modelName");
if (!editingId && (!currentDisplayName || modelNameAutoFilledRef.current)) {
form.setFieldValue("modelName", providerLabel);
modelNameAutoFilledRef.current = true;
}
const baseUrl = form.getFieldValue("baseUrl");
const providerKey = String(provider).toLowerCase();
const defaultBaseUrl = PROVIDER_BASE_URL_MAP[providerKey];
if (!baseUrl && defaultBaseUrl) {
form.setFieldValue("baseUrl", defaultBaseUrl);
}
}, [provider, drawerVisible, editingId, providers, form]);
const fetchData = async () => {
setLoading(true);
try {
const res = await getAiModelPage({
current,
size,
name: searchName || undefined,
type: activeType,
});
const pageData = (res as any)?.data?.data ?? (res as any);
setData(pageData?.records || []);
setTotal(pageData?.total || 0);
} finally {
setLoading(false);
}
};
const openDrawer = (record?: AiModelVO) => {
setRemoteModels([]);
setSpeakerModels([]);
modelNameAutoFilledRef.current = false;
localProfileLoadedRef.current = false;
if (record) {
setEditingId(record.id);
const speakerModel = record.mediaConfig?.speakerModel;
const svThreshold = record.mediaConfig?.svThreshold;
const tencentAppId = record.mediaConfig?.tencentAppId;
const tencentSecretId = record.mediaConfig?.tencentSecretId;
const tencentSecretKey = record.mediaConfig?.tencentSecretKey;
form.setFieldsValue({
...record,
modelType: record.modelType,
speakerModel,
svThreshold,
tencentAppId,
tencentSecretId,
tencentSecretKey,
isDefaultChecked: record.isDefault === 1,
statusChecked: record.status === 1,
});
if (record.modelCode) {
setRemoteModels([record.modelCode]);
}
if (speakerModel) {
setSpeakerModels([String(speakerModel)]);
}
} else {
setEditingId(null);
form.resetFields();
form.setFieldsValue({
modelType: activeType,
isDefaultChecked: false,
statusChecked: true,
sortOrder: 0,
temperature: 0.2,
topP: 0.9,
apiPath: "/v1/chat/completions",
svThreshold: 0.45,
});
}
setDrawerVisible(true);
};
useEffect(() => {
if (!drawerVisible || !isLocalProvider || !editingId || localProfileLoadedRef.current) {
return;
}
const values = form.getFieldsValue(["baseUrl", "apiKey"]);
if (!values.baseUrl || !values.apiKey) {
return;
}
localProfileLoadedRef.current = true;
void handleTestConnectivity();
}, [drawerVisible, isLocalProvider, editingId, form]);
const handleFetchRemote = async () => {
if (isLocalProvider) {
await handleTestConnectivity();
return;
}
const values = form.getFieldsValue(["provider", "baseUrl", "apiKey"]);
if (!values.provider || !values.baseUrl) {
message.warning("请先填写提供商和 Base URL");
return;
}
setFetchLoading(true);
try {
const res = await getRemoteModelList(values);
const rawModels = (res as any)?.data?.data ?? (Array.isArray(res) ? res : []);
const models = Array.isArray(rawModels) ? rawModels : [];
setRemoteModels(models);
message.success(`获取到 ${models.length} 个模型`);
} finally {
setFetchLoading(false);
}
};
const resolveLocalWsUrl = (baseUrl: string, wsEndpoint?: string) => {
if (!wsEndpoint) {
return undefined;
}
try {
const base = new URL(baseUrl);
const endpoint = wsEndpoint.startsWith("/") ? wsEndpoint : `/${wsEndpoint}`;
const protocol = base.protocol === "https:" ? "wss:" : "ws:";
return `${protocol}//${base.host}${endpoint}`;
} catch {
return undefined;
}
};
const applyLocalProfile = (profile: AiLocalProfileVO, baseUrl: string) => {
const nextRemoteModels = Array.isArray(profile.asrModels) ? profile.asrModels : [];
const nextSpeakerModels = Array.isArray(profile.speakerModels) ? profile.speakerModels : [];
setRemoteModels(nextRemoteModels);
setSpeakerModels(nextSpeakerModels);
const nextValues: Record<string, unknown> = {};
if (profile.activeAsrModel) {
nextValues.modelCode = profile.activeAsrModel;
}
if (profile.activeSpeakerModel) {
nextValues.speakerModel = profile.activeSpeakerModel;
}
if (profile.svThreshold !== undefined) {
nextValues.svThreshold = profile.svThreshold;
}
const wsUrl = resolveLocalWsUrl(baseUrl, profile.wsEndpoint);
if (wsUrl) {
nextValues.wsUrl = wsUrl;
}
form.setFieldsValue(nextValues);
};
const handleSubmit = async () => {
const values = await form.validateFields();
if (values.isDefaultChecked && !values.statusChecked) {
message.warning("默认模型必须保持启用状态");
return;
}
const payload: AiModelDTO = {
id: editingId ?? undefined,
modelType: values.modelType,
modelName: values.modelName,
provider: values.provider,
baseUrl: values.baseUrl,
apiPath: values.apiPath,
apiKey: values.apiKey,
modelCode: values.modelCode,
wsUrl: values.wsUrl,
mediaConfig:
activeType === "ASR" && isLocalProvider
? {
speakerModel: values.speakerModel,
svThreshold: values.svThreshold,
}
: activeType === "ASR" && isTencentProvider
? {
tencentAppId: values.tencentAppId,
tencentSecretId: values.tencentSecretId,
tencentSecretKey: values.tencentSecretKey,
}
: undefined,
temperature: values.temperature,
topP: values.topP,
isDefault: values.isDefaultChecked ? 1 : 0,
status: values.statusChecked ? 1 : 0,
sortOrder: values.sortOrder ?? 0,
remark: values.remark,
};
setSubmitLoading(true);
try {
if (editingId) {
await updateAiModel(payload);
message.success("更新成功");
} else {
await saveAiModel(payload);
message.success("新增成功");
}
setDrawerVisible(false);
void fetchData();
} finally {
setSubmitLoading(false);
}
};
const handleTestConnectivity = async () => {
if (activeType === "LLM") {
const values = await form.validateFields(["provider", "baseUrl", "apiPath", "modelCode"]);
const extraValues = form.getFieldsValue(["apiKey", "temperature", "topP"]);
setConnectivityLoading(true);
try {
await testLlmModelConnectivity({
provider: values.provider,
baseUrl: values.baseUrl,
apiPath: values.apiPath,
apiKey: extraValues.apiKey,
modelCode: values.modelCode,
temperature: extraValues.temperature,
topP: extraValues.topP,
testMessage: DEFAULT_LLM_TEST_MESSAGE,
});
message.success("LLM 连通性测试成功");
} finally {
setConnectivityLoading(false);
}
return;
}
const values = await form.validateFields(["provider", "baseUrl"]);
if (String(values.provider || "").toLowerCase() !== "custom") {
message.warning("仅本地模型支持连通性测试");
return;
}
const { apiKey } = form.getFieldsValue(["apiKey"]);
if (!apiKey) {
message.warning("请先填写 API Key 后再测试连接");
return;
}
setConnectivityLoading(true);
try {
const res = await testLocalModelConnectivity({
baseUrl: values.baseUrl,
apiKey,
});
const profile = (res as any)?.data?.data ?? (res as any)?.data ?? (res as any);
applyLocalProfile(profile as AiLocalProfileVO, values.baseUrl);
message.success("本地模型连通性测试成功");
} finally {
setConnectivityLoading(false);
}
};
const handleDelete = async (record: AiModelVO) => {
await deleteAiModelByType(record.id, record.modelType);
message.success("删除成功");
void fetchData();
};
const columns = [
{
title: "模型名称",
dataIndex: "modelName",
key: "modelName",
render: (text: string, record: AiModelVO) => (
<Space>
{text}
{record.isDefault === 1 && <Tag color="gold"></Tag>}
{record.tenantId === 0 && (
<Tooltip title="系统预置">
<SafetyCertificateOutlined style={{ color: "#52c41a" }} />
</Tooltip>
)}
</Space>
),
},
{
title: "提供商",
dataIndex: "provider",
key: "provider",
render: (value: string) => {
const item = providers.find((providerItem) => providerItem.itemValue === value);
return item ? <Tag>{item.itemLabel}</Tag> : value;
},
},
{ title: "模型名称(code)", dataIndex: "modelCode", key: "modelCode" },
{
title: "排序",
dataIndex: "sortOrder",
key: "sortOrder",
render: (value: number | undefined) => value ?? 0,
},
{
title: "状态",
dataIndex: "status",
key: "status",
render: (status: number) =>
status === 1 ? <Tag color="green"></Tag> : <Tag></Tag>,
},
{
title: "操作",
key: "action",
render: (_: unknown, record: AiModelVO) => {
const canEdit = record.tenantId !== 0 || isPlatformAdmin;
return (
<Space>
{canEdit && (
<Button type="link" icon={<EditOutlined />} onClick={() => openDrawer(record)}>
</Button>
)}
{canEdit && (
<Popconfirm title="确定删除吗?" onConfirm={() => handleDelete(record)}>
<Button type="link" danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
)}
</Space>
);
},
},
];
return (
<PageContainer
title="AI 模型配置"
subtitle="管理ASR语音识别和LLM大语言模型"
headerExtra={
<Button type="primary" icon={<PlusOutlined />} onClick={() => openDrawer()}>
</Button>
}
toolbar={
<Input
placeholder="搜索模型名称"
prefix={<SearchOutlined />}
allowClear
onPressEnter={(event) => setSearchName((event.target as HTMLInputElement).value)}
style={{ width: 220 }}
/>
}
>
<Tabs
activeKey={activeType}
onChange={(key) => {
setActiveType(key as ModelType);
setCurrent(1);
}}
items={[
{ key: "ASR", label: "ASR 模型" },
{ key: "LLM", label: "LLM 模型" },
]}
style={{ marginBottom: 16 }}
/>
<Card className="app-page__content-card" style={{ flex: 1, minHeight: 0 }} styles={{ body: { padding: 0, flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' } }}>
<div className="app-page__table-wrap" style={{ flex: 1, minHeight: 0, overflow: "auto" }}>
<Table
rowKey="id"
columns={columns}
dataSource={data}
loading={loading}
scroll={{ x: "max-content", y: "100%" }}
pagination={false}
/>
</div>
<AppPagination
current={current}
pageSize={size}
total={total}
onChange={(page, pageSize) => {
setCurrent(page);
setSize(pageSize);
}}
/>
</Card>
<Drawer
width={600}
open={drawerVisible}
onClose={() => setDrawerVisible(false)}
title={<Title level={4} style={{ margin: 0 }}>{editingId ? "编辑模型" : "新增模型"}</Title>}
forceRender
extra={
<Space>
<Button onClick={() => setDrawerVisible(false)}></Button>
<Button type="primary" icon={<SaveOutlined />} loading={submitLoading} onClick={handleSubmit}>
</Button>
</Space>
}
>
<Form form={form} layout="vertical">
<Form.Item name="modelType" hidden>
<Input />
</Form.Item>
<Form.Item label="模型类型">
<Tag color={activeType === "ASR" ? "blue" : "purple"}>
{activeType === "ASR" ? "语音识别 (ASR)" : "总结模型 (LLM)"}
</Tag>
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="modelName"
label="显示名称"
rules={[{ required: true, message: "请输入显示名称" }]}
>
<Input
onChange={() => {
modelNameAutoFilledRef.current = false;
}}
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="provider"
label="提供商"
rules={[{ required: true, message: "请选择提供商" }]}
>
<Select allowClear placeholder="请选择">
{providers.map((item) => (
<Option key={item.itemValue} value={item.itemValue}>
{item.itemLabel}
</Option>
))}
</Select>
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="sortOrder" label="排序值">
<InputNumber min={0} style={{ width: "100%" }} />
</Form.Item>
</Col>
</Row>
{!isTencentProvider && (
<>
<Form.Item name="baseUrl" label="Base URL" rules={[{required: true, message: "请输入 Base URL"}]}>
<Input placeholder="https://api.example.com"/>
</Form.Item>
<Form.Item
name="apiKey"
label="API Key"
>
<Input.Password/>
</Form.Item>
</>
)}
{(activeType === "LLM" || isLocalProvider) && (
<Form.Item label="连通性测试">
<Button icon={<WifiOutlined />} loading={connectivityLoading} onClick={handleTestConnectivity}>
</Button>
</Form.Item>
)}
<Divider orientation="left" style={{ fontSize: 14, color: "#999" }}>
</Divider>
<Form.Item
label="模型名称"
required={activeType === "LLM"}
tooltip="可从远程列表选择,也可手动输入;值将作为模型 code 传给后端"
>
<Space.Compact style={{ width: "100%" }}>
<Form.Item
name="modelCode"
noStyle
rules={activeType === "LLM" || isTencentProvider ? [{
required: true,
message: "请输入或选择模型名称"
}] : []}
>
<AutoComplete
style={{ width: "calc(100% - 100px)" }}
onFocus={() => {
if (isLocalProvider && remoteModels.length === 0) {
void handleFetchRemote();
}
}}
options={remoteModels.map((model) => ({ value: model }))}
filterOption={(inputValue, option) =>
isLocalProvider ||
String(option?.value || "").toLowerCase().includes(inputValue.toLowerCase())
}
>
<Input allowClear placeholder="可选择或自定义输入模型名称" />
</AutoComplete>
</Form.Item>
{!isTencentProvider && (
<Button icon={<SyncOutlined spin={fetchLoading}/>} onClick={handleFetchRemote} style={{width: 100}}>
</Button>
)}
</Space.Compact>
</Form.Item>
<Form.Item name="wsUrl" label="WebSocket 地址" hidden>
<Input placeholder="wss://api.example.com/v1/ws" />
</Form.Item>
{activeType === "ASR" && isLocalProvider && (
<Row gutter={16} hidden>
{/*<Col span={12}>*/}
{/* <Form.Item*/}
{/* name="speakerModel"*/}
{/* label="声纹模型"*/}
{/* >*/}
{/* <Select*/}
{/* allowClear*/}
{/* placeholder="请先测试连接获取声纹模型"*/}
{/* options={speakerModels.map((model) => ({ label: model, value: model }))}*/}
{/* />*/}
{/* </Form.Item>*/}
{/*</Col>*/}
<Col span={12}>
<Form.Item
name="svThreshold"
label="声纹阈值"
>
<InputNumber min={0} max={1} step={0.01} style={{ width: "100%" }} />
</Form.Item>
</Col>
</Row>
)}
{activeType === "ASR" && isTencentProvider && (
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="tencentAppId"
label="App ID"
rules={[{required: true, message: "请输入 App ID"}]}
>
<Input/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="tencentSecretId"
label="Secret ID"
rules={[{required: true, message: "请输入 Secret ID"}]}
>
<Input/>
</Form.Item>
</Col>
<Col span={24}>
<Form.Item
name="tencentSecretKey"
label="Secret Key"
rules={[{required: true, message: "请输入 Secret Key"}]}
>
<Input.Password/>
</Form.Item>
</Col>
</Row>
)}
{activeType === "LLM" && (
<>
<Form.Item name="apiPath" label="API 路径" initialValue="/v1/chat/completions">
<Input />
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="temperature" label="Temperature">
<InputNumber min={0} max={2} step={0.1} style={{ width: "100%" }} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="topP" label="Top P">
<InputNumber min={0} max={1} step={0.1} style={{ width: "100%" }} />
</Form.Item>
</Col>
</Row>
</>
)}
<Row gutter={16}>
<Col span={8}>
<Form.Item name="isDefaultChecked" label="设为默认" valuePropName="checked">
<Switch
checkedChildren="是"
unCheckedChildren="否"
onChange={(checked) => {
if (checked) {
form.setFieldValue("statusChecked", true);
}
}}
/>
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="statusChecked" label="状态" valuePropName="checked">
<Switch checkedChildren="启用" unCheckedChildren="禁用" disabled={Boolean(isDefaultChecked)} />
</Form.Item>
</Col>
</Row>
<Form.Item name="remark" label="备注">
<Input.TextArea rows={2} />
</Form.Item>
</Form>
</Drawer>
</PageContainer>
);
};
export default AiModels;