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>
+121
View File
@@ -0,0 +1,121 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const {readFileSync} = require('node:fs');
const {resolve} = require('node:path');
const Module = require('node:module');
const ts = require('typescript');
const React = require('react');
const defaults = {lookback_days:30, new_work_interval_seconds:1800, metric_initial_interval_seconds:3600, metric_multiplier:2, metric_max_interval_seconds:86400, metric_age_seconds:2592000, ai_base_url:'https://example.com/v1', ai_api_key:'saved-key', ai_model:'model-a', updated_at:'server-only'};
function deferred() { let resolve, reject; const promise = new Promise((yes, no) => {resolve=yes; reject=no;}); return {promise, resolve, reject}; }
function harness(options={}) {
const states=[], effects=[], queued=[], timers=new Map(), calls=[], saves=[], notices=[], logs=[];
let cursor=0, timerID=0;
const values={};
const form={setFieldsValue(value){Object.assign(values,value);}, getFieldValue(name){return values[name];}, setFieldValue(name,value){values[name]=value;}};
const antd={};
for(const name of ['Alert','Button','Card','Flex','Input','InputNumber','Select','Tabs']) antd[name]=()=>null;
antd.Input.Password=()=>null;
antd.Typography={Paragraph:()=>null,Text:()=>null};
antd.Form=()=>null; antd.Form.Item=()=>null;
antd.Form.useForm=()=>[form];
antd.Form.useWatch=(name)=>values[name];
antd.App={useApp:()=>({message:{success:v=>notices.push(v),error:v=>notices.push(v)}})};
const file=resolve(__dirname,'../src/pages/creator/settings/index.tsx');
const loaded=new Module(file,module); loaded.filename=file;
loaded.require=id=>{
if(id==='@test/timers') return {setTimeout(fn){const id=++timerID; timers.set(id,fn); return id;},clearTimeout(id){timers.delete(id);}};
if(id==='react') return {
useState(initial){const i=cursor++; if(!(i in states)) states[i]=typeof initial==='function'?initial():initial; return [states[i],value=>{states[i]=typeof value==='function'?value(states[i]):value;}];},
useRef(initial){const i=cursor++; if(!(i in states)) states[i]={current:initial}; return states[i];},
useCallback(callback,deps){const i=cursor++; const prior=states[i]; if(!prior||deps.some((v,j)=>!Object.is(v,prior.deps[j]))) states[i]={callback,deps}; return states[i].callback;},
useEffect(callback,deps){const i=cursor++; const prior=effects[i]; if(!prior||deps.some((v,j)=>!Object.is(v,prior.deps[j]))) {prior?.cleanup?.(); effects[i]={deps}; queued.push(()=>{effects[i].cleanup=callback();});}},
};
if(id==='antd') return antd;
if(id==='@ant-design/icons') return {ReloadOutlined:()=>null};
if(id==='@/utils/helpers') return {conflictMessage:(e,f)=>e?.message||f};
if(id==='@/services/api') return {
creatorGet:async()=>{if(options.loadError) throw new Error(options.loadError); return options.settings||defaults;},
creatorAction:async(path,payload)=>{calls.push({path,payload}); return options.models ? options.models(payload,calls.length) : {models:['model-a','model-b']};},
creatorUpdate:async(path,payload)=>{saves.push({path,payload}); if(options.saveError) throw new Error(options.saveError); return payload;},
};
return require(id);
};
const code=ts.transpileModule(readFileSync(file,'utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS,jsx:ts.JsxEmit.ReactJSX,target:ts.ScriptTarget.ES2020}}).outputText;
loaded._compile(`const {setTimeout,clearTimeout}=require('@test/timers');\n${code}`,file);
const h={antd,form,values,calls,saves,notices,logs,
render(){cursor=0; return loaded.exports.default();},
async flush(){for(let i=0;i<5;i++){h.render(); while(queued.length) queued.shift()(); for(let j=0;j<12;j++) await Promise.resolve();} return h.render();},
async tick(){const pending=[...timers.values()]; timers.clear(); pending.forEach(fn=>fn()); return h.flush();},
async change(changed){Object.assign(values,changed); const tree=h.render(); find(tree,antd.Form)?.props.onValuesChange?.(changed,values); return h.flush();},
dispose(){for(const effect of effects) effect?.cleanup?.();},
};
return h;
}
function all(node,predicate){const result=[]; function visit(value){if(Array.isArray(value)) return value.forEach(visit); if(!React.isValidElement(value)) return; if(predicate(value)) result.push(value); visit(value.props.children); visit(value.props.label); if(Array.isArray(value.props.items)) value.props.items.forEach(item=>visit(item.children));} visit(node); return result;}
function text(node){if(node==null) return ''; if(typeof node==='string'||typeof node==='number') return String(node); if(Array.isArray(node)) return node.map(text).join(''); return React.isValidElement(node)?text(node.props.children):'';}
const find=(tree,type)=>all(tree,n=>n.type===type)[0];
const alerts=(h,tree)=>all(tree,n=>n.type===h.antd.Alert).map(n=>`${text(n.props.title)} ${text(n.props.description)}`).join('\n');
const refresh=(h,tree)=>all(tree,n=>n.type===h.antd.Button).find(n=>text(n.props.children)==='刷新模型');
test('AI service uses password input, searchable discovered models and persists all settings',async t=>{
const h=harness(); t.after(h.dispose); await h.flush(); let tree=await h.tick();
assert.deepEqual(h.calls,[{path:'/creator/settings/models',payload:{base_url:defaults.ai_base_url,api_key:defaults.ai_api_key}}]);
assert.ok(find(tree,h.antd.Input.Password));
const select=find(tree,h.antd.Select); assert.ok(select.props.showSearch); assert.equal(select.props.mode,undefined);
assert.deepEqual(select.props.options.map(o=>o.value),['model-a','model-b']);
const fields=all(tree,n=>n.type===h.antd.Form.Item).map(n=>n.props.name);
assert.ok(fields.includes('ai_base_url')); assert.ok(fields.includes('ai_api_key')); assert.ok(fields.includes('ai_model'));
assert.ok(!fields.includes('ai_provider')); assert.ok(!fields.includes('ai_configured'));
assert.ok(find(tree,h.antd.Tabs).props.items.every(item=>item.forceRender));
await find(tree,h.antd.Form).props.onFinish({...h.values}); await h.flush();
assert.equal(h.saves[0].payload.ai_api_key,'saved-key'); assert.equal(h.saves[0].payload.ai_model,'model-a');
assert.equal(h.saves[0].payload.lookback_days,30); assert.equal(h.saves[0].payload.updated_at,undefined);
assert.equal(h.notices[0],'采集与 AI 配置已保存。');
});
test('connection edits clear selection and stale replies cannot override new models',async t=>{
const old=deferred(); const h=harness({models:(_,n)=>n===1?old.promise:{models:['new-model']}}); t.after(h.dispose);
await h.flush(); await h.tick();
await h.change({ai_base_url:'https://other.example/v1',ai_api_key:'new-key'});
assert.equal(h.values.ai_model,undefined);
let tree=await h.tick(); assert.deepEqual(find(tree,h.antd.Select).props.options.map(o=>o.value),['new-model']);
old.resolve({models:['stale-model']}); tree=await h.flush();
assert.deepEqual(find(tree,h.antd.Select).props.options.map(o=>o.value),['new-model']);
assert.equal(h.calls[1].payload.api_key,'new-key');
});
test('failed discovery is explicit and refresh recovers without a manual model fallback',async t=>{
const h=harness({models:(_,n)=>n===1?Promise.reject(new Error('HTTP 401: invalid key')):{models:['model-a']}}); t.after(h.dispose);
await h.flush(); let tree=await h.tick(); assert.match(alerts(h,tree),/401/);
assert.equal(find(tree,h.antd.Select).props.disabled,true); assert.deepEqual(find(tree,h.antd.Select).props.options,[]);
refresh(h,tree).props.onClick(); await h.flush(); tree=await h.tick();
assert.equal(alerts(h,tree),''); assert.equal(find(tree,h.antd.Select).props.disabled,false);
});
test('empty discovery and unavailable saved model are explicit',async t=>{
for(const models of [[],['different-model']]){
const h=harness({models:()=>({models})}); t.after(h.dispose); await h.flush(); const tree=await h.tick();
assert.ok(alerts(h,tree)); assert.equal(h.values.ai_model,undefined);
}
});
test('unconfigured AI does not fetch and collection settings remain saveable',async t=>{
const h=harness({settings:{...defaults,ai_base_url:'',ai_api_key:'',ai_model:''}}); t.after(h.dispose);
await h.flush(); const tree=await h.tick(); assert.equal(h.calls.length,0);
assert.equal(find(tree,h.antd.Select).props.disabled,true);
await find(tree,h.antd.Form).props.onFinish({...h.values}); assert.equal(h.saves.length,1);
});
test('partial connection or a model not from discovery cannot be saved',async t=>{
const h=harness(); t.after(h.dispose); await h.flush(); await h.tick();
await h.change({ai_api_key:'changed'}); let tree=h.render();
await find(tree,h.antd.Form).props.onFinish({...h.values,ai_model:'model-a'}); assert.equal(h.saves.length,0);
await h.tick(); tree=h.render(); await find(tree,h.antd.Form).props.onFinish({...h.values,ai_model:'invented-model'}); assert.equal(h.saves.length,0);
});
test('load and save failures remain visible',async t=>{
const broken=harness({loadError:'settings unavailable'}); t.after(broken.dispose);
assert.match(alerts(broken,await broken.flush()),/settings unavailable/);
const h=harness({saveError:'database unavailable'}); t.after(h.dispose); await h.flush(); const tree=await h.tick();
await find(tree,h.antd.Form).props.onFinish({...h.values}); assert.match(h.notices.join(' '),/database unavailable/);
});