feat: configure OpenAI-compatible AI services and discover models
douyin-release-gate / verify (push) Failing after 19m29s

This commit is contained in:
2026-10-07 17:17:33 +08:00
parent 51091cf0c5
commit f345e368df
19 changed files with 1135 additions and 408 deletions
+100 -38
View File
@@ -1,8 +1,7 @@
// 采集设置:语义对齐 web.archived CreatorSettingsPage.jsx(提交 d97cade)。
// 四类配置用 antd Tabs 组织;仅用 antd 默认组件。
import { useCallback, useEffect, useState } from 'react';
import { Alert, App, Button, Card, Checkbox, Form, Input, InputNumber, Tabs, Typography } from 'antd';
import { creatorGet, creatorUpdate } from '@/services/api';
import { ReloadOutlined } from '@ant-design/icons';
import { Alert, App, Button, Card, Flex, Form, Input, InputNumber, Select, Tabs, Typography } from 'antd';
import { creatorAction, creatorGet, creatorUpdate } from '@/services/api';
import { conflictMessage } from '@/utils/helpers';
const numericFields = new Set([
@@ -13,26 +12,35 @@ const numericFields = new Set([
'metric_max_interval_seconds',
'metric_age_seconds',
]);
const editableFields = [
'lookback_days',
'new_work_interval_seconds',
'metric_initial_interval_seconds',
'metric_multiplier',
'metric_max_interval_seconds',
'metric_age_seconds',
'ai_provider',
...numericFields,
'ai_base_url',
'ai_api_key',
'ai_model',
'ai_configured',
];
const editableSettings = (value: any) => Object.fromEntries(editableFields.map((field) => [field, value[field]]));
type ModelList = {
baseURL: string;
apiKey: string;
options: string[];
loading: boolean;
error: string | null;
};
export default function Page() {
const [form] = Form.useForm();
const [pending, setPending] = useState(true);
const [error, setError] = useState<any>(null);
const [busy, setBusy] = useState(false);
const [refreshToken, setRefreshToken] = useState(0);
const [models, setModels] = useState<ModelList>({ baseURL: '', apiKey: '', options: [], loading: false, error: null });
const baseURL = (Form.useWatch('ai_base_url', { form, preserve: true }) ?? '').trim().replace(/\/+$/, '');
const apiKey = (Form.useWatch('ai_api_key', { form, preserve: true }) ?? '').trim();
const connectionMatches = models.baseURL === baseURL && models.apiKey === apiKey;
const modelOptions = connectionMatches ? models.options : [];
const loadingModels = Boolean(baseURL && apiKey) && (!connectionMatches || models.loading);
const modelError = connectionMatches ? models.error : null;
const { message: messageApi } = App.useApp();
const load = useCallback(async () => {
@@ -52,11 +60,56 @@ export default function Page() {
load();
}, [load]);
useEffect(() => {
if (pending) return;
if (!baseURL || !apiKey) {
setModels({ baseURL, apiKey, options: [], loading: false, error: null });
return;
}
let active = true;
setModels({ baseURL, apiKey, options: [], loading: true, error: null });
const timer = setTimeout(async () => {
try {
const result = await creatorAction('/creator/settings/models', { base_url: baseURL, api_key: apiKey });
if (!active) return;
if (!Array.isArray(result.models) || result.models.length === 0) {
form.setFieldValue('ai_model', undefined);
throw new Error('服务未返回可选模型,请检查 BASEURL 和 APIKEY。');
}
const selected = form.getFieldValue('ai_model');
const missingSelected = selected && !result.models.includes(selected);
if (missingSelected) form.setFieldValue('ai_model', undefined);
setModels({
baseURL, apiKey, options: result.models, loading: false,
error: missingSelected ? '已保存的 MODEL 不在当前模型列表中,请重新选择。' : null,
});
} catch (modelLoadError) {
if (!active) return;
setModels({ baseURL, apiKey, options: [], loading: false, error: conflictMessage(modelLoadError, '模型列表获取失败') });
}
}, 500);
return () => { active = false; clearTimeout(timer); };
}, [baseURL, apiKey, pending, refreshToken, form]);
function connectionChanged(changed: Record<string, unknown>) {
if ('ai_base_url' in changed || 'ai_api_key' in changed) {
form.setFieldValue('ai_model', undefined);
setRefreshToken((value) => value + 1);
}
if ('ai_model' in changed && modelOptions.includes(String(changed.ai_model))) {
setModels((value) => ({ ...value, error: null }));
}
}
async function save(values: any) {
const hasAI = [values.ai_base_url, values.ai_api_key, values.ai_model].some((value) => String(value ?? '').trim());
if (hasAI && (!connectionMatches || loadingModels || !baseURL || !apiKey || !modelOptions.includes(values.ai_model))) {
messageApi.error('请填写 BASEURL 和 APIKEY,并从成功获取的模型列表中选择 MODEL。');
return;
}
setBusy(true);
try {
// 数值字段清空时归零,对齐归档版 Number('') 的提交语义;服务侧字段为必填数值。
const payload = Object.fromEntries(editableFields.map((field) => [field, numericFields.has(field) ? values[field] ?? 0 : values[field]]));
const payload = Object.fromEntries(editableFields.map((field) => [field, numericFields.has(field) ? values[field] ?? 0 : String(values[field] ?? '').trim()]));
const result = await creatorUpdate('/creator/settings', payload);
form.setFieldsValue(editableSettings(result));
messageApi.success('采集与 AI 配置已保存。');
@@ -72,14 +125,13 @@ export default function Page() {
return (
<div>
<Typography.Paragraph type="secondary" style={{ marginBottom: 16 }}>按配置类别管理 UTC 采集窗口、指标采集和已批准的服务。</Typography.Paragraph>
<Typography.Paragraph type="secondary" style={{ marginBottom: 16 }}>管理采集策略、指标采集和 AI 服务。仅修改 AI 服务不会改变采集进度。</Typography.Paragraph>
<Card variant="outlined">
<Form form={form} layout="vertical" onFinish={save} initialValues={{}}>
<Form form={form} layout="vertical" onFinish={save} onValuesChange={connectionChanged} disabled={busy}>
<Tabs
items={[
{
key: 'collection',
label: '采集策略',
key: 'collection', label: '采集策略', forceRender: true,
children: (
<div style={{ maxWidth: 640 }}>
<Form.Item name="lookback_days" label="竞品回溯天数" extra="首次回溯默认 30 天;不会猜测缺失发布时间。">
@@ -89,10 +141,10 @@ export default function Page() {
<InputNumber min={1} style={{ width: 200 }} />
</Form.Item>
</div>
) },
),
},
{
key: 'metrics',
label: '指标采集',
key: 'metrics', label: '指标采集', forceRender: true,
children: (
<div style={{ maxWidth: 640 }}>
<Form.Item name="metric_initial_interval_seconds" label="指标首次间隔(秒)">
@@ -108,30 +160,40 @@ export default function Page() {
<InputNumber min={1} style={{ width: 200 }} />
</Form.Item>
</div>
) },
),
},
{
key: 'ai',
label: 'AI 服务',
key: 'ai', label: 'AI 服务', forceRender: true,
children: (
<div style={{ maxWidth: 640 }}>
<Form.Item name="ai_provider" label="AI Provider">
<Input />
<Typography.Paragraph type="secondary">仅支持 OpenAI Compatible 格式;模型列表与实际调用使用同一地址和密钥。</Typography.Paragraph>
<Form.Item name="ai_base_url" label="BASEURL" extra="填写接口根地址,通常以 /v1 结尾,不包含 /models 或 /chat/completions。">
<Input placeholder="https://api.openai.com/v1" autoComplete="off" />
</Form.Item>
<Form.Item name="ai_model" label="AI Model">
<Input />
<Form.Item name="ai_api_key" label="APIKEY">
<Input.Password autoComplete="off" />
</Form.Item>
<Form.Item name="ai_configured" valuePropName="checked">
<Checkbox>已完成 AI 配置审批</Checkbox>
<Form.Item
name="ai_model"
label={<Flex align="center" gap="small">MODEL<Button type="link" size="small" icon={<ReloadOutlined />} disabled={!baseURL || !apiKey || loadingModels || busy} onClick={() => setRefreshToken((value) => value + 1)}>刷新模型</Button></Flex>}
>
<Select
showSearch={{ optionFilterProp: 'label' }}
options={modelOptions.map((model) => ({ label: model, value: model }))}
loading={loadingModels}
disabled={busy || loadingModels || modelOptions.length === 0}
placeholder={loadingModels ? '正在获取模型…' : '填写 BASEURL 和 APIKEY 后选择模型'}
/>
</Form.Item>
{modelError && <Alert type="error" showIcon title={modelError} />}
</div>
) },
),
},
]}
/>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button type="primary" htmlType="submit" loading={busy}>
{busy ? '保存中…' : '保存设置'}
</Button>
</div>
<Flex justify="flex-end">
<Button type="primary" htmlType="submit" loading={busy}>{busy ? '保存中…' : '保存设置'}</Button>
</Flex>
</Form>
</Card>
</div>