feat: configure OpenAI-compatible AI services and discover models
douyin-release-gate / verify (push) Failing after 19m29s
douyin-release-gate / verify (push) Failing after 19m29s
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user