Files
Mujoco_WASM/web_platform/src/mobile/agent/WebsiteModelSettings.tsx
T
chenlin 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
feat: release v1.0.2 LeKiwi 语言控制与网站嵌入
集成服务器托管模型、自然语言移动与有界抓放、内置 LeKiwi URL 导入和双摄像头;同步部署契约与指定域名 iframe 白名单,保留原有物理安全、会话及调用预算防护。

更新 npm 包及锁文件版本、CHANGELOG 与发布文档。提交前 typecheck、120 项定向前端测试和 44 项后端测试通过(3 项可选跳过);真实 v2 云模型抓放仍待单独验收,不包含运行密钥或构建产物。
2026-09-24 15:29:49 +08:00

450 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}