0d986f60bd
web-platform-ci / Standalone decision service (no cloud credentials) (push) Has been cancelled
web-platform-ci / TypeScript, lint, unit, build (push) Has been cancelled
web-platform-ci / Playwright E2E (push) Has been cancelled
lekiwi-compatibility / cpu-compatibility (push) Has been cancelled
集成服务器托管模型、自然语言移动与有界抓放、内置 LeKiwi URL 导入和双摄像头;同步部署契约与指定域名 iframe 白名单,保留原有物理安全、会话及调用预算防护。 更新 npm 包及锁文件版本、CHANGELOG 与发布文档。提交前 typecheck、120 项定向前端测试和 44 项后端测试通过(3 项可选跳过);真实 v2 云模型抓放仍待单独验收,不包含运行密钥或构建产物。
450 lines
17 KiB
TypeScript
450 lines
17 KiB
TypeScript
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<string, string> = {
|
||
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<DecisionClient>();
|
||
const [config, setConfig] = useState<Configuration>();
|
||
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<Model[]>([]);
|
||
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<AbortController | undefined>(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<typeof setTimeout>;
|
||
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<void>) {
|
||
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 = (
|
||
<p
|
||
role="status"
|
||
aria-label={mode === 'codex' ? '订阅操作状态' : '模型设置状态'}
|
||
className="break-words"
|
||
>
|
||
{busy ? '正在连接…' : notice}
|
||
</p>
|
||
);
|
||
const matching = models.filter((m) =>
|
||
`${m.provider} ${m.id} ${m.name}`.toLowerCase().includes(search.toLowerCase()),
|
||
);
|
||
return (
|
||
<div className="space-y-3 text-xs" aria-label="网站模型设置">
|
||
<p className="text-text-secondary">
|
||
凭据经 HTTPS 发送到网站后端,仅存会话内存;空闲 30 分钟过期。模型文件仍在浏览器内。
|
||
</p>
|
||
<div className="flex gap-2" role="group" aria-label="LLM 连接方式">
|
||
{(['api', 'codex'] as const).map((value) => (
|
||
<Button
|
||
key={value}
|
||
disabled={busy}
|
||
aria-pressed={mode === value}
|
||
onClick={() => {
|
||
if (mode !== value) {
|
||
invalidate();
|
||
setMode(value);
|
||
setApiKey('');
|
||
setNotice('连接方式已修改,请完成设置并保存。');
|
||
}
|
||
}}
|
||
>
|
||
{value === 'api' ? 'API_KEY' : 'ChatGPT 订阅'}
|
||
</Button>
|
||
))}
|
||
</div>
|
||
<fieldset disabled={busy || !client} className="space-y-2">
|
||
{mode === 'api' ? (
|
||
<>
|
||
<label className="block">
|
||
LLM API_KEY
|
||
<input
|
||
className={inputClass}
|
||
type="password"
|
||
autoComplete="off"
|
||
value={apiKey}
|
||
placeholder={
|
||
config?.llm.hasKey ? '已设置;留空保持同提供方密钥' : '填写自己的 API_KEY'
|
||
}
|
||
onChange={(e) => {
|
||
invalidate();
|
||
setApiKey(e.target.value);
|
||
}}
|
||
/>
|
||
</label>
|
||
<label className="block">
|
||
搜索模型
|
||
<input
|
||
className={inputClass}
|
||
type="search"
|
||
value={search}
|
||
onChange={(e) => setSearch(e.target.value)}
|
||
placeholder="DeepSeek / OpenRouter"
|
||
/>
|
||
</label>
|
||
<label className="block">
|
||
模型选择
|
||
<select
|
||
className={inputClass}
|
||
value={selection}
|
||
onChange={(e) => {
|
||
invalidate();
|
||
if (e.target.value.split(':')[0] !== selection.split(':')[0]) setApiKey('');
|
||
setSelection(e.target.value);
|
||
}}
|
||
>
|
||
{!matching.some((m) => `${m.provider}:${m.id}` === selection) && (
|
||
<option value={selection}>{selection}</option>
|
||
)}
|
||
{['deepseek', 'openrouter'].map((provider) => (
|
||
<optgroup
|
||
key={provider}
|
||
label={provider === 'deepseek' ? 'DeepSeek' : 'OpenRouter'}
|
||
>
|
||
{matching
|
||
.filter((m) => m.provider === provider)
|
||
.map((m) => (
|
||
<option key={m.id} value={`${m.provider}:${m.id}`}>
|
||
{m.name} · {m.id}
|
||
</option>
|
||
))}
|
||
</optgroup>
|
||
))}
|
||
</select>
|
||
</label>
|
||
</>
|
||
) : (
|
||
<div className="space-y-2">
|
||
<p>官方订阅登录(实验性)。当前服务器可能无法连接官方服务;不会自动转付费 API。</p>
|
||
<p>
|
||
点击登录先由服务器申请设备码;成功后请点击下方的官方验证链接,不会自动弹窗。仅浏览器能够打开
|
||
ChatGPT,并不代表服务器也能连接。
|
||
</p>
|
||
<div className="flex flex-wrap gap-2">
|
||
<Button onClick={() => void account('login')}>登录 ChatGPT 订阅</Button>
|
||
<Button onClick={() => void account('status')}>刷新登录状态</Button>
|
||
<Button onClick={() => void account('cancel')}>取消登录</Button>
|
||
<Button onClick={() => void account('logout')}>退出订阅</Button>
|
||
</div>
|
||
{statusNotice}
|
||
{device && (
|
||
<div className="space-y-1">
|
||
<a
|
||
className="underline"
|
||
href={device.verificationUrl}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
>
|
||
打开 OpenAI 官方验证页
|
||
</a>
|
||
<p>
|
||
一次性码:<strong>{device.userCode}</strong>(10 分钟内完成,过期请重新登录)
|
||
</p>
|
||
</div>
|
||
)}
|
||
<p>{loggedIn ? '订阅已登录' : '订阅尚未确认登录'}</p>
|
||
<label className="block">
|
||
订阅模型
|
||
<select
|
||
className={inputClass}
|
||
value={codexModel}
|
||
onChange={(e) => {
|
||
invalidate();
|
||
setCodexModel(e.target.value);
|
||
}}
|
||
>
|
||
<option value="">登录后选择官方可用模型</option>
|
||
{codexModels.map((m) => (
|
||
<option key={m.id} value={m.id}>
|
||
{m.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
</div>
|
||
)}
|
||
<label className="block">
|
||
Jev API_KEY(OpenRouter)
|
||
<input
|
||
className={inputClass}
|
||
type="password"
|
||
autoComplete="off"
|
||
value={jevKey}
|
||
placeholder={config?.jev.hasKey ? '已设置;留空保持' : '填写 Jev 的 OpenRouter API_KEY'}
|
||
onChange={(e) => {
|
||
invalidate();
|
||
setJevKey(e.target.value);
|
||
}}
|
||
/>
|
||
</label>
|
||
<Button onClick={() => void save()}>保存设置</Button>
|
||
<details>
|
||
<summary className="cursor-pointer">连接测试与凭据管理</summary>
|
||
<div className="mt-2 flex flex-wrap gap-2">
|
||
{(['llm', 'jev'] as const).map((role) => (
|
||
<Button
|
||
key={role}
|
||
disabled={dirty || !config?.jev.hasKey}
|
||
onClick={() =>
|
||
void run(async (c, signal) => {
|
||
callbacks.current.onInvalidate();
|
||
await c.call('/test', 'POST', { role }, signal);
|
||
if (!signal.aborted)
|
||
setNotice(
|
||
`${role.toUpperCase()} 连接测试通过;真实 API 可能计费,订阅测试仅验证工具门禁。`,
|
||
);
|
||
})
|
||
}
|
||
>
|
||
测试 {role.toUpperCase()}(可能计费)
|
||
</Button>
|
||
))}
|
||
<Button
|
||
onClick={() =>
|
||
void run(async (c, signal) => {
|
||
invalidate();
|
||
await c.call('/session', 'DELETE', {}, signal);
|
||
if (signal.aborted) return;
|
||
setClient(undefined);
|
||
setConfig(undefined);
|
||
setDevice(undefined);
|
||
setApiKey('');
|
||
setJevKey('');
|
||
setLoggedIn(false);
|
||
setNotice('本次凭据已清除。关闭并重新打开模型设置可创建新会话。');
|
||
})
|
||
}
|
||
>
|
||
清除本次凭据
|
||
</Button>
|
||
</div>
|
||
</details>
|
||
</fieldset>
|
||
{mode === 'api' && statusNotice}
|
||
{dirty && <p className="text-warning">设置未保存,真实任务暂不可用。</p>}
|
||
</div>
|
||
);
|
||
}
|