import { useEffect, useRef, useState } from 'react'; import { Button } from '../../components/ui'; import { DecisionClient } from './DecisionClient'; interface Props { onClient(client: DecisionClient | undefined): void; onInvalidate(): void; } interface Configuration { llm: { provider: string; model: string; hasKey: boolean }; jev: { hasKey: boolean }; } interface Status { ready: boolean; configuration: Configuration; } interface Model { provider: string; id: string; name: string; } const inputClass = 'w-full rounded border border-border bg-surface px-2 py-1.5 text-xs'; const errors: Record = { session_expired: '本次会话已过期,请重新打开模型设置并填写凭据。', configuration_changed: '配置已在其他页面变更,请重新打开设置,不会使用旧配置继续任务。', api_key_required: '请填写对应的 API_KEY。切换提供方后需要重新填写。', server_busy: '服务器当前繁忙,请稍后手动重试。', ip_rate_limit: '请求次数已达上限,请稍后再试。', subscription_capacity: '订阅登录名额暂满,请稍后再试;不会自动切换付费 API。', codex_chatgpt_login_required: '请先完成官方订阅登录。', codex_rpc_failed: '服务器未能获取官方登录码,当前没有验证链接可打开。可能是服务器网络或账号限制;不会切换付费 API。', codex_rpc_timeout: '官方订阅服务连接超时,未获取登录码或验证链接;API 模式仍可手动使用。', codex_not_installed: '当前服务器未启用订阅服务。', model_unavailable: '当前模型不可用,请重新选择;不会自动替换。', csrf_required: '会话验证失效,请重新打开模型设置。', }; function message(error: unknown) { const code = error instanceof Error ? error.message : '请求失败'; return errors[code] ?? `请求未完成:${code}`; } export function WebsiteModelSettings({ onClient, onInvalidate }: Props) { const callbacks = useRef({ onClient, onInvalidate }); useEffect(() => { callbacks.current = { onClient, onInvalidate }; }, [onClient, onInvalidate]); const [client, setClient] = useState(); const [config, setConfig] = useState(); const [mode, setMode] = useState<'api' | 'codex'>('api'); const [selection, setSelection] = useState('deepseek:deepseek-flash'); const [apiKey, setApiKey] = useState(''); const [jevKey, setJevKey] = useState(''); const [models, setModels] = useState([]); const [search, setSearch] = useState(''); const [codexModels, setCodexModels] = useState<{ id: string; name: string }[]>([]); const [codexModel, setCodexModel] = useState(''); const [loggedIn, setLoggedIn] = useState(false); const [device, setDevice] = useState<{ verificationUrl: string; userCode: string }>(); const [busy, setBusy] = useState(true); const [dirty, setDirty] = useState(false); const [notice, setNotice] = useState('正在连接网站模型服务…'); const generation = useRef(0); const pending = useRef(undefined); useEffect(() => { const abort = new AbortController(); pending.current = abort; let alive = true; void (async () => { try { const c = await DecisionClient.website(abort.signal); const status = (await c.call('/status', 'GET', undefined, abort.signal)) as Status; if (!alive) return; setClient(c); setConfig(status.configuration); const llm = status.configuration.llm; setMode(llm.provider === 'codex' ? 'codex' : 'api'); if (llm.provider === 'codex') setCodexModel(llm.model); else setSelection(`${llm.provider}:${llm.model}`); callbacks.current.onClient(status.ready ? c : undefined); setNotice('会话已就绪。保存不会调用模型;开始真实任务或测试连接可能计费。'); const list = (await c.call('/models', 'GET', undefined, abort.signal)) as { models: Model[]; openrouterAvailable: boolean; }; if (!alive) return; setModels(list.models); if (!list.openrouterAvailable) setNotice('OpenRouter 模型目录暂不可用;已保存的型号不会自动替换。'); } catch (error) { if (alive) setNotice(message(error)); } finally { if (alive) setBusy(false); } })(); const cancelPending = () => { generation.current++; pending.current?.abort(); }; return () => { alive = false; cancelPending(); abort.abort(); }; }, []); useEffect(() => { if (!device || !client) return; const abort = new AbortController(); const deadline = Date.now() + 600_000; let timer: ReturnType; const poll = async () => { if (Date.now() >= deadline) { setDevice(undefined); setNotice('设备码已过期,请重新发起登录。'); return; } try { const status = (await client.call('/codex/status', 'GET', undefined, abort.signal)) as { loggedIn?: boolean; }; if (abort.signal.aborted) return; if (status.loggedIn) { const list = (await client.call('/codex/models', 'GET', undefined, abort.signal)) as { models: { id: string; name: string }[]; }; if (abort.signal.aborted) return; setLoggedIn(true); setCodexModels(list.models); setDevice(undefined); setNotice('官方登录完成;请选择订阅模型并保存。'); return; } } catch (error) { if (abort.signal.aborted) return; setDevice(undefined); setNotice(message(error)); return; } timer = setTimeout(() => void poll(), 3000); }; timer = setTimeout(() => void poll(), 3000); return () => { clearTimeout(timer); abort.abort(); }; }, [device, client]); function invalidate() { callbacks.current.onInvalidate(); callbacks.current.onClient(undefined); setDirty(true); } async function run(operation: (c: DecisionClient, signal: AbortSignal) => Promise) { if (!client || busy) return; const current = ++generation.current; const abort = new AbortController(); pending.current = abort; setBusy(true); try { await operation(client, abort.signal); } catch (error) { if (current === generation.current) { setNotice(message(error)); callbacks.current.onClient(undefined); } } finally { if (current === generation.current) setBusy(false); } } async function save() { await run(async (c, signal) => { invalidate(); const split = selection.indexOf(':'); const provider = mode === 'codex' ? 'codex' : selection.slice(0, split); const model = mode === 'codex' ? codexModel : selection.slice(split + 1); const result = (await c.call( '/configuration', 'PUT', { llm: { provider, model, ...(mode === 'api' && apiKey ? { apiKey } : {}) }, jev: { ...(jevKey ? { apiKey: jevKey } : {}) }, }, signal, )) as Status; if (signal.aborted) return; setConfig(result.configuration); setApiKey(''); setJevKey(''); setDirty(false); callbacks.current.onClient(result.ready ? c : undefined); setNotice('设置已保存;输入框已清空。密钥仅在本次服务会话内存中。'); }); } async function account(operation: 'login' | 'status' | 'cancel' | 'logout') { await run(async (c, signal) => { const post = operation !== 'status'; if (post) invalidate(); const result = (await c.call( `/codex/${operation}`, post ? 'POST' : 'GET', post ? {} : undefined, signal, )) as { loggedIn?: boolean; verificationUrl?: string; userCode?: string; }; if (signal.aborted) return; if (operation === 'login') { const url = new URL(result.verificationUrl ?? ''); if ( url.protocol !== 'https:' || url.hostname !== 'auth.openai.com' || url.username || url.password || (url.port && url.port !== '443') ) throw new Error('官方登录地址校验失败'); setDevice({ verificationUrl: url.href, userCode: result.userCode ?? '' }); setNotice('请在官方页面输入一次性码。账户可能需要先开启设备码登录;完成后刷新登录状态。'); } else { setLoggedIn(Boolean(result.loggedIn)); if (operation !== 'status' || result.loggedIn) setDevice(undefined); if (result.loggedIn) { const list = (await c.call('/codex/models', 'GET', undefined, signal)) as { models: { id: string; name: string }[]; }; if (signal.aborted) return; setCodexModels(list.models); setNotice('官方登录已完成;选择模型并保存。登录不代表已通过真实推理。'); } else { setCodexModels([]); setNotice( operation === 'status' ? '尚未完成官方登录;当前网络可能不可用。' : '订阅凭据已清除。', ); } } }); } const statusNotice = (

{busy ? '正在连接…' : notice}

); const matching = models.filter((m) => `${m.provider} ${m.id} ${m.name}`.toLowerCase().includes(search.toLowerCase()), ); return (

凭据经 HTTPS 发送到网站后端,仅存会话内存;空闲 30 分钟过期。模型文件仍在浏览器内。

{(['api', 'codex'] as const).map((value) => ( ))}
{mode === 'api' ? ( <> ) : (

官方订阅登录(实验性)。当前服务器可能无法连接官方服务;不会自动转付费 API。

点击登录先由服务器申请设备码;成功后请点击下方的官方验证链接,不会自动弹窗。仅浏览器能够打开 ChatGPT,并不代表服务器也能连接。

{statusNotice} {device && (
打开 OpenAI 官方验证页

一次性码:{device.userCode}(10 分钟内完成,过期请重新登录)

)}

{loggedIn ? '订阅已登录' : '订阅尚未确认登录'}

)}
连接测试与凭据管理
{(['llm', 'jev'] as const).map((role) => ( ))}
{mode === 'api' && statusNotice} {dirty &&

设置未保存,真实任务暂不可用。

}
); }