refine oauth model selection and provider inputs

This commit is contained in:
lpf
2026-03-11 19:48:10 +08:00
parent b3c0f58998
commit 5d74dba0b8
8 changed files with 381 additions and 215 deletions

View File

@@ -2,7 +2,7 @@ import React from 'react';
import { AnimatePresence, motion } from 'motion/react';
import { useTranslation } from 'react-i18next';
import { Button } from './Button';
import { TextField } from './FormControls';
import { TextField, TextareaField } from './FormControls';
type DialogOptions = {
title?: string;
@@ -13,6 +13,9 @@ type DialogOptions = {
initialValue?: string;
inputLabel?: string;
inputPlaceholder?: string;
monospace?: boolean;
multiline?: boolean;
wide?: boolean;
};
export const GlobalDialog: React.FC<{
@@ -36,28 +39,40 @@ export const GlobalDialog: React.FC<{
{open && (
<motion.div className="ui-overlay-strong fixed inset-0 z-[130] backdrop-blur-sm flex items-center justify-center p-4"
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}>
<motion.div className="brand-card w-full max-w-md border border-zinc-700 shadow-2xl"
<motion.div className={`brand-card w-full border border-zinc-700 shadow-2xl ${options.wide ? 'max-w-2xl' : 'max-w-md'}`}
initial={{ scale: 0.95, y: 8 }} animate={{ scale: 1, y: 0 }} exit={{ scale: 0.95, y: 8 }}>
<div className="px-5 py-4 border-b border-zinc-800 relative z-[1]">
<h3 className="text-sm font-semibold text-zinc-100">{options.title || (kind === 'confirm' ? t('dialogPleaseConfirm') : kind === 'prompt' ? t('dialogInputTitle') : t('dialogNotice'))}</h3>
</div>
<div className="px-5 py-4 space-y-3 relative z-[1]">
<div className="text-sm text-zinc-300 whitespace-pre-wrap">{options.message}</div>
<div className="text-sm text-zinc-300 whitespace-pre-wrap break-all">{options.message}</div>
{kind === 'prompt' && (
<div className="space-y-2">
{options.inputLabel && <label className="text-xs text-zinc-400">{options.inputLabel}</label>}
<TextField
autoFocus
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
onConfirm(value);
}
}}
placeholder={options.inputPlaceholder || t('dialogInputPlaceholder')}
className="w-full text-zinc-100"
/>
{options.multiline ? (
<TextareaField
autoFocus
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={options.inputPlaceholder || t('dialogInputPlaceholder')}
monospace={options.monospace}
className="min-h-[96px] w-full text-zinc-100"
/>
) : (
<TextField
autoFocus
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
onConfirm(value);
}
}}
placeholder={options.inputPlaceholder || t('dialogInputPlaceholder')}
monospace={options.monospace}
className="w-full text-zinc-100"
/>
)}
</div>
)}
</div>

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { Download, FolderOpen, LogIn, Plus, RefreshCw, RotateCcw, Trash2, Upload } from 'lucide-react';
import { Download, FolderOpen, LogIn, Plus, RefreshCw, RotateCcw, Trash2, Upload, X } from 'lucide-react';
import { Button, FixedButton } from '../Button';
import { CheckboxField, PanelField, SelectField, TextField } from '../FormControls';
@@ -17,6 +17,71 @@ export function ProxySelectField({ className, ...props }: React.ComponentProps<t
return <SelectField dense {...props} className={joinClasses(DENSE_PROXY_FIELD_CLASS, className)} />;
}
type TagInputFieldProps = {
onChange: (values: string[]) => void;
placeholder?: string;
values: string[];
};
function TagInputField({ onChange, placeholder, values }: TagInputFieldProps) {
const [draft, setDraft] = React.useState('');
React.useEffect(() => {
setDraft('');
}, [values]);
function commit(raw: string) {
const value = String(raw || '').trim();
if (!value || values.includes(value)) {
setDraft('');
return;
}
onChange([...values, value]);
setDraft('');
}
function remove(value: string) {
onChange(values.filter((item) => item !== value));
}
return (
<div className="space-y-2">
{values.length > 0 ? (
<div className="flex flex-wrap gap-2">
{values.map((value) => (
<div key={value} className="flex items-center gap-1 rounded-full border border-zinc-700 bg-zinc-950/70 px-2 py-1 text-[11px] text-zinc-200">
<span className="font-mono">{value}</span>
<button type="button" onClick={() => remove(value)} className="text-zinc-400 transition hover:text-zinc-100" aria-label={`remove ${value}`}>
<X className="h-3 w-3" />
</button>
</div>
))}
</div>
) : null}
<ProxyTextField
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
commit(draft);
return;
}
if (e.key === 'Backspace' && !draft && values.length > 0) {
e.preventDefault();
remove(values[values.length - 1]);
}
}}
onBlur={() => {
if (draft.trim()) commit(draft);
}}
placeholder={placeholder}
className="w-full"
/>
</div>
);
}
type RuntimeSection = 'candidates' | 'hits' | 'errors' | 'changes';
type ProviderRuntimeToolbarProps = {
@@ -50,9 +115,10 @@ export function ProviderRuntimeToolbar({
<div className="flex items-center justify-between gap-2 flex-wrap">
<div className="text-sm font-semibold text-zinc-200">{t('configProxies')}</div>
<div className="flex items-center gap-2">
<FixedButton onClick={onRefreshRuntime} variant="neutral" radius="lg" label={t('providersRefreshRuntime')}>
<Button onClick={onRefreshRuntime} size="xs" radius="lg" variant="neutral" gap="2">
<RefreshCw className="w-4 h-4" />
</FixedButton>
{t('providersRefreshRuntime')}
</Button>
<label className="flex items-center gap-2 rounded-xl border border-zinc-800 bg-zinc-900/30 px-2 py-1.5 text-[11px] text-zinc-300">
<CheckboxField checked={runtimeAutoRefresh} onChange={(e) => onRuntimeAutoRefreshChange(e.target.checked)} />
{t('providersAutoRefresh')}
@@ -70,9 +136,10 @@ export function ProviderRuntimeToolbar({
<option value="all">{t('providersRuntimeAll')}</option>
</SelectField>
<TextField dense value={newProxyName} onChange={(e) => onNewProxyNameChange(e.target.value)} placeholder={t('configNewProviderName')} className="bg-zinc-900/70 border-zinc-700" />
<FixedButton onClick={onAddProxy} variant="primary" radius="lg" label={t('add')}>
<Button onClick={onAddProxy} variant="primary" size="xs" radius="lg" gap="2">
<Plus className="w-4 h-4" />
</FixedButton>
{t('add')}
</Button>
</div>
</div>
);
@@ -311,7 +378,10 @@ export function ProviderProxyCard({
runtimeSummary,
t,
}: ProviderProxyCardProps) {
const authMode = String(proxy?.auth || 'bearer');
const authMode = String(proxy?.auth || 'oauth');
const providerModels = Array.isArray(proxy?.models)
? proxy.models.map((value: any) => String(value || '').trim()).filter(Boolean)
: [];
const showOAuth = ['oauth', 'hybrid'].includes(authMode);
const oauthProvider = String(proxy?.oauth?.provider || '');
const [runtimeOpen, setRuntimeOpen] = React.useState(false);
@@ -369,12 +439,7 @@ export function ProviderProxyCard({
<ProxyTextField value={String(proxy?.api_base || '')} onChange={(e) => onFieldChange('api_base', e.target.value)} placeholder={t('configLabels.api_base')} className="w-full" />
</PanelField>
<PanelField label={t('providersModels')} help={t('providersModelsHelp')} dense>
<ProxyTextField
value={Array.isArray(proxy?.models) ? proxy.models.join(',') : ''}
onChange={(e) => onFieldChange('models', e.target.value.split(',').map((s) => s.trim()).filter(Boolean))}
placeholder={`${t('configLabels.models')}${t('configCommaSeparatedHint')}`}
className="w-full"
/>
<TagInputField values={providerModels} onChange={(values) => onFieldChange('models', values)} placeholder={t('providersModelsEnterHint')} />
</PanelField>
<PanelField label={t('providersApiKey')} dense>
<ProxyTextField value={String(proxy?.api_key || '')} onChange={(e) => onFieldChange('api_key', e.target.value)} placeholder={t('configLabels.api_key')} className="w-full" />
@@ -456,7 +521,7 @@ export function ProviderProxyCard({
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-sky-500/15 text-[11px] font-semibold text-sky-300">2</div>
<div className="min-w-0 flex items-center gap-2">
<div className="text-sm font-medium text-zinc-100">Authentication</div>
<div className="truncate text-[11px] text-zinc-500">Request auth and hybrid priority.</div>
<div className="truncate text-[11px] text-zinc-500">Choose how this provider authenticates requests.</div>
</div>
</div>
<PanelField label={t('providersAuthMode')} help={t('providersAuthModeHelp')} dense>
@@ -480,53 +545,51 @@ export function ProviderProxyCard({
</div>
</div>
<div className="rounded-2xl border border-zinc-800 bg-zinc-950/20 p-4 space-y-3">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-fuchsia-500/15 text-[11px] font-semibold text-fuchsia-300">4</div>
<div className="min-w-0 flex items-center gap-2">
<div className="text-sm font-medium text-zinc-100">{t('providersOAuthAccounts')}</div>
<div className="truncate text-[11px] text-zinc-500">Imported sessions.</div>
{showOAuth ? (
<div className="rounded-2xl border border-zinc-800 bg-zinc-950/20 p-4 space-y-3">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-fuchsia-500/15 text-[11px] font-semibold text-fuchsia-300">4</div>
<div className="min-w-0 flex items-center gap-2">
<div className="text-sm font-medium text-zinc-100">{t('providersOAuthAccounts')}</div>
<div className="truncate text-[11px] text-zinc-500">Imported sessions.</div>
</div>
</div>
</div>
{showOAuth ? (
<FixedButton onClick={onLoadOAuthAccounts} variant="neutral" radius="lg" label={t('providersRefreshList')}>
<RefreshCw className="w-4 h-4" />
</FixedButton>
) : null}
</div>
{!showOAuth ? (
<div className="text-zinc-500">Enable oauth or hybrid mode to manage OAuth accounts.</div>
) : oauthAccounts.length === 0 ? (
<div className="text-zinc-500">{t('providersNoOAuthAccounts')}</div>
) : (
<div className="space-y-2">
{oauthAccounts.map((account, idx) => (
<div key={`${account?.credential_file || idx}`} className="rounded-xl border border-zinc-800 bg-zinc-900/40 px-3 py-3 space-y-2">
<div className="min-w-0">
<div className="text-zinc-200 truncate">{account?.email || account?.account_id || account?.credential_file}</div>
<div className="text-zinc-500 text-[11px]">label: {account?.account_label || account?.email || account?.account_id || '-'}</div>
<div className="text-zinc-500 truncate text-[11px]">{account?.credential_file}</div>
<div className="text-zinc-500 text-[11px]">project: {account?.project_id || '-'} · device: {account?.device_id || '-'}</div>
<div className="text-zinc-500 truncate text-[11px]">proxy: {account?.network_proxy || '-'}</div>
<div className="text-zinc-500 text-[11px]">expire: {account?.expire || '-'} · cooldown: {account?.cooldown_until || '-'}</div>
</div>
<div className="flex items-center gap-2 flex-wrap">
<FixedButton onClick={() => onRefreshOAuthAccount(String(account?.credential_file || ''))} variant="neutral" radius="lg" label="Refresh">
<RefreshCw className="w-4 h-4" />
</FixedButton>
<FixedButton onClick={() => onClearOAuthCooldown(String(account?.credential_file || ''))} variant="neutral" radius="lg" label="Clear Cooldown">
<RotateCcw className="w-4 h-4" />
</FixedButton>
<FixedButton onClick={() => onDeleteOAuthAccount(String(account?.credential_file || ''))} variant="danger" radius="lg" label={t('delete')}>
<Trash2 className="w-4 h-4" />
</FixedButton>
</div>
</div>
))}
</div>
)}
</div>
{oauthAccounts.length === 0 ? (
<div className="text-zinc-500">{t('providersNoOAuthAccounts')}</div>
) : (
<div className="space-y-2">
{oauthAccounts.map((account, idx) => (
<div key={`${account?.credential_file || idx}`} className="rounded-xl border border-zinc-800 bg-zinc-900/40 px-3 py-3 space-y-2">
<div className="min-w-0">
<div className="text-zinc-200 truncate">{account?.email || account?.account_id || account?.credential_file}</div>
<div className="text-zinc-500 text-[11px]">label: {account?.account_label || account?.email || account?.account_id || '-'}</div>
<div className="text-zinc-500 truncate text-[11px]">{account?.credential_file}</div>
<div className="text-zinc-500 text-[11px]">project: {account?.project_id || '-'} · device: {account?.device_id || '-'}</div>
<div className="text-zinc-500 truncate text-[11px]">proxy: {account?.network_proxy || '-'}</div>
<div className="text-zinc-500 text-[11px]">expire: {account?.expire || '-'} · cooldown: {account?.cooldown_until || '-'}</div>
</div>
<div className="flex items-center gap-2 flex-wrap">
<FixedButton onClick={() => onRefreshOAuthAccount(String(account?.credential_file || ''))} variant="neutral" radius="lg" label="Refresh">
<RefreshCw className="w-4 h-4" />
</FixedButton>
<FixedButton onClick={() => onClearOAuthCooldown(String(account?.credential_file || ''))} variant="neutral" radius="lg" label="Clear Cooldown">
<RotateCcw className="w-4 h-4" />
</FixedButton>
<FixedButton onClick={() => onDeleteOAuthAccount(String(account?.credential_file || ''))} variant="danger" radius="lg" label={t('delete')}>
<Trash2 className="w-4 h-4" />
</FixedButton>
</div>
</div>
))}
</div>
)}
</div>
) : null}
<div className="rounded-2xl border border-zinc-800 bg-zinc-950/20 p-4 space-y-3">
<div className="flex items-center justify-between gap-3">

View File

@@ -0,0 +1,72 @@
import React, { useMemo, useState } from 'react';
import { Button } from '../Button';
import { TextField } from '../FormControls';
type ProviderModelPickerModalProps = {
initialValue?: string;
models: string[];
onCancel: () => void;
onConfirm: (model: string) => void;
t: (key: string, options?: any) => string;
};
export const ProviderModelPickerModal: React.FC<ProviderModelPickerModalProps> = ({
initialValue,
models,
onCancel,
onConfirm,
t,
}) => {
const [query, setQuery] = useState('');
const [selected, setSelected] = useState(initialValue || models[0] || '');
const filtered = useMemo(() => {
const keyword = String(query || '').trim().toLowerCase();
if (!keyword) return models;
return models.filter((model) => model.toLowerCase().includes(keyword));
}, [models, query]);
return (
<div className="space-y-4">
<div className="text-sm text-zinc-300">{t('providersSelectModelMessage')}</div>
<TextField
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t('providersSelectModelSearchPlaceholder')}
className="w-full"
/>
<div className="max-h-[360px] space-y-2 overflow-auto pr-1">
{filtered.map((model) => {
const active = model === selected;
return (
<button
key={model}
type="button"
onClick={() => setSelected(model)}
className={[
'w-full rounded-xl border px-3 py-2 text-left transition',
active ? 'border-amber-400 bg-amber-500/10 text-zinc-100' : 'border-zinc-800 bg-zinc-950/40 text-zinc-300 hover:border-zinc-700',
].join(' ')}
>
<div className="font-mono text-sm">{model}</div>
</button>
);
})}
{filtered.length === 0 && (
<div className="rounded-xl border border-dashed border-zinc-800 px-3 py-5 text-center text-sm text-zinc-500">
{t('providersSelectModelEmpty')}
</div>
)}
</div>
<div className="flex items-center justify-end gap-2">
<Button onClick={onCancel} size="sm">
{t('cancel')}
</Button>
<Button onClick={() => selected && onConfirm(selected)} variant="primary" size="sm" disabled={!selected}>
{t('providersSelectModelConfirm')}
</Button>
</div>
</div>
);
};

View File

@@ -98,7 +98,7 @@ export function createDefaultProxyConfig() {
stream_include_usage: false,
},
supports_responses_compact: false,
auth: 'bearer',
auth: 'oauth',
timeout_sec: 120,
};
}

View File

@@ -1,10 +1,13 @@
import React, { useRef } from 'react';
import { buildProviderRuntimeExportPayload, createDefaultProxyConfig, setPath } from './configUtils';
import { ProviderModelPickerModal } from './ProviderModelPickerModal';
import { cloneJSON } from '../../utils/object';
type UI = {
closeModal: () => void;
confirmDialog: (options: any) => Promise<boolean>;
notify: (options: any) => Promise<void>;
openModal: (node: React.ReactNode, title?: string, onClose?: () => void) => void;
promptDialog: (options: any) => Promise<string | null>;
withLoading: <T>(fn: () => Promise<T>, label: string) => Promise<T>;
};
@@ -51,6 +54,64 @@ export function useConfigProviderActions({
return `models.providers.${name}`;
}
function normalizeModels(models: any): string[] {
const out: string[] = [];
for (const item of Array.isArray(models) ? models : []) {
const value = String(item || '').trim();
if (!value || out.includes(value)) continue;
out.push(value);
}
return out;
}
async function chooseProviderModel(name: string, proxy: any, models: string[]) {
const options = normalizeModels(models);
if (options.length === 0) return '';
const current = Array.isArray(proxy?.models) ? options.find((item) => item === String(proxy.models[0] || '').trim()) || '' : '';
if (options.length === 1) return options[0];
return new Promise<string | null>((resolve) => {
let settled = false;
const finish = (value: string | null, closedExternally = false) => {
if (settled) return;
settled = true;
if (!closedExternally) ui.closeModal();
resolve(value);
};
ui.openModal(
React.createElement(ProviderModelPickerModal, {
initialValue: current || options[0],
models: options,
onCancel: () => finish(null),
onConfirm: (value: string) => finish(value),
t,
}),
t('providersSelectModelTitle', { name }),
() => finish(null, true),
);
});
}
async function saveProviderModels(name: string, model: string) {
const trimmed = String(model || '').trim();
if (!trimmed) return;
const res = await fetch(`/webui/api/provider/models${q}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ provider: name, model: trimmed }),
});
const { text, data } = await parseResponseBody(res);
if (!res.ok) throw new Error(data?.error || text || 'provider model save failed');
}
async function applyOAuthModels(name: string, proxy: any, models: any) {
const options = normalizeModels(models);
if (options.length === 0) return '';
const selected = await chooseProviderModel(name, proxy, options);
if (!selected) return '';
await ui.withLoading(() => saveProviderModels(name, selected), t('providersSavingSelectedModel'));
return selected;
}
async function removeProxy(name: string) {
const ok = await ui.confirmDialog({
title: t('configDeleteProviderConfirmTitle'),
@@ -126,6 +187,9 @@ export function useConfigProviderActions({
title: t('providersOAuthLoginTitle'),
message: `${started?.instructions || t('providersOAuthLoginMessage')}\n\n${started.auth_url}`,
inputPlaceholder: t('providersOAuthCallbackPlaceholder'),
monospace: true,
multiline: true,
wide: true,
});
if (pasted == null) return;
callbackURL = pasted;
@@ -138,8 +202,14 @@ export function useConfigProviderActions({
});
const { text, data } = await parseResponseBody(res);
if (!res.ok) throw new Error(data?.error || text || 'oauth complete failed');
const selectedModel = await applyOAuthModels(name, proxy || {}, data?.models);
await loadConfig(true);
await ui.notify({ title: t('providersOAuthAddedTitle'), message: data?.account ? t('providersOAuthAddedMessage', { account: data.account }) : t('providersOAuthAddedFallback') });
const message = selectedModel
? t('providersOAuthAddedWithModel', { account: data?.account || '-', model: selectedModel })
: data?.account
? t('providersOAuthAddedMessage', { account: data.account })
: t('providersOAuthAddedFallback');
await ui.notify({ title: t('providersOAuthAddedTitle'), message });
}, t('providersCompletingOAuthLogin'));
} catch (err: any) {
await ui.notify({ title: t('requestFailed'), message: String(err?.message || err) });
@@ -190,8 +260,14 @@ export function useConfigProviderActions({
const res = await fetch(`/webui/api/provider/oauth/import${q}`, { method: 'POST', body: form });
const { text, data } = await parseResponseBody(res);
if (!res.ok) throw new Error(data?.error || text || 'oauth import failed');
const selectedModel = await applyOAuthModels(providerName, providerConfig || {}, data?.models);
await loadConfig(true);
await ui.notify({ title: t('providersAuthJsonImportedTitle'), message: data?.account ? t('providersOAuthAddedMessage', { account: data.account }) : t('providersAuthJsonImportedMessage') });
const message = selectedModel
? t('providersOAuthAddedWithModel', { account: data?.account || '-', model: selectedModel })
: data?.account
? t('providersOAuthAddedMessage', { account: data.account })
: t('providersAuthJsonImportedMessage');
await ui.notify({ title: t('providersAuthJsonImportedTitle'), message });
}, t('providersImportingAuthJson'));
} catch (err: any) {
await ui.notify({ title: t('requestFailed'), message: String(err?.message || err) });

View File

@@ -16,7 +16,7 @@ type UIContextType = {
notify: (opts: DialogOptions | string) => Promise<void>;
confirmDialog: (opts: DialogOptions | string) => Promise<boolean>;
promptDialog: (opts: DialogOptions | string) => Promise<string | null>;
openModal: (node: React.ReactNode, title?: string) => void;
openModal: (node: React.ReactNode, title?: string, onClose?: () => void) => void;
closeModal: () => void;
};
@@ -33,7 +33,7 @@ export const UIProvider: React.FC<{ children: React.ReactNode }> = ({ children }
const [loadingText, setLoadingText] = useState(t('loading'));
const [theme, setTheme] = useState<ThemeMode>(getInitialTheme);
const [dialog, setDialog] = useState<null | { kind: 'notice' | 'confirm' | 'prompt'; options: DialogOptions; resolve: (v: any) => void }>(null);
const [customModal, setCustomModal] = useState<null | { title?: string; node: React.ReactNode }>(null);
const [customModal, setCustomModal] = useState<null | { title?: string; node: React.ReactNode; onClose?: () => void }>(null);
const loading = loadingCount > 0;
useEffect(() => {
@@ -92,8 +92,11 @@ export const UIProvider: React.FC<{ children: React.ReactNode }> = ({ children }
const options = typeof opts === 'string' ? { message: opts } : opts;
setDialog({ kind: 'prompt', options, resolve });
}),
openModal: (node, title) => setCustomModal({ node, title }),
closeModal: () => setCustomModal(null),
openModal: (node, title, onClose) => setCustomModal({ node, title, onClose }),
closeModal: () => setCustomModal((current) => {
current?.onClose?.();
return null;
}),
}), [loading, t, theme]);
const closeDialog = (result?: boolean | string | null) => {
@@ -136,11 +139,22 @@ export const UIProvider: React.FC<{ children: React.ReactNode }> = ({ children }
{customModal && (
<motion.div className="ui-overlay-strong fixed inset-0 z-[125] backdrop-blur-sm flex items-center justify-center p-4"
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}>
<motion.div className="w-full max-w-4xl rounded-2xl border border-zinc-700 bg-zinc-900 shadow-2xl overflow-hidden"
<button
className="absolute inset-0"
onClick={() => setCustomModal((current) => {
current?.onClose?.();
return null;
})}
aria-label={t('close')}
/>
<motion.div className="relative w-full max-w-4xl rounded-2xl border border-zinc-700 bg-zinc-900 shadow-2xl overflow-hidden"
initial={{ scale: 0.96 }} animate={{ scale: 1 }} exit={{ scale: 0.96 }}>
<div className="px-5 py-3 border-b border-zinc-800 flex items-center justify-between">
<h3 className="text-sm font-semibold text-zinc-100">{customModal.title || t('modal')}</h3>
<button onClick={() => setCustomModal(null)} className="text-zinc-400 hover:text-zinc-200"></button>
<button onClick={() => setCustomModal((current) => {
current?.onClose?.();
return null;
})} className="text-zinc-400 hover:text-zinc-200"></button>
</div>
<div className="p-4 max-h-[80vh] overflow-auto">{customModal.node}</div>
</motion.div>

View File

@@ -54,8 +54,6 @@ const resources = {
channelFieldMaixCamAllowFromHint: 'Only these device or sender IDs can invoke the channel when set.',
channelFieldEnableGroupsHint: 'Allow messages coming from group chats.',
channelFieldRequireMentionHint: 'When enabled, group messages must mention the bot before they are accepted.',
whatsappBridgeRunning: 'Bridge Running',
whatsappBridgeStopped: 'Bridge Stopped',
whatsappBridgeURL: 'Bridge URL',
whatsappBridgeAccount: 'Linked Account',
whatsappBridgeLastEvent: 'Last Event',
@@ -71,8 +69,6 @@ const resources = {
whatsappQRCodeHint: 'If you are already linked, no QR is shown.\nIf the QR code does not appear, confirm the gateway is running and the WhatsApp channel is enabled.',
whatsappStateAwaitingScan: 'Awaiting Scan',
whatsappStateDisconnected: 'Disconnected',
whatsappBridgeDevHintTitle: 'How to use',
whatsappBridgeDevHint: '1. Start the gateway and enable the WhatsApp channel.\n2. Scan the QR code here with WhatsApp.\n3. Keep ClawGo running to receive WhatsApp messages.',
whatsappFieldEnabledHint: 'Master switch for receiving WhatsApp messages through the bridge.',
whatsappFieldBridgeURLHint: 'Optional. Leave empty to use the gateway embedded WhatsApp bridge at /whatsapp/ws.',
whatsappFieldAllowFromHint: 'One sender JID per line. Only these senders can trigger ClawGo.',
@@ -108,14 +104,11 @@ const resources = {
nodesFilterPlaceholder: 'Filter by node id, name, or tag',
agentTree: 'Agent Tree',
noAgentTree: 'No agent tree available.',
readonlyMirror: 'Read-only mirror',
localControl: 'Local control',
logs: 'Real-time Logs',
logCodes: 'Log Codes',
skills: 'Skills',
memory: 'Memory',
taskAudit: 'Task Audit',
tasks: 'Tasks',
subagentProfiles: 'Subagent Profiles',
subagentsRuntime: 'Agents',
nodeP2P: 'Node P2P',
@@ -136,59 +129,23 @@ const resources = {
zoomIn: 'Zoom In',
zoomOut: 'Zoom Out',
fitView: 'Fit View',
childrenCount: 'children',
'topologyFilter.all': 'All',
'topologyFilter.running': 'Running',
'topologyFilter.failed': 'Failed',
'topologyFilter.local': 'Local',
'topologyFilter.remote': 'Remote',
noLiveTasks: 'No live tasks',
remoteTasksUnavailable: 'Remote task details are not mirrored yet.',
subagentDetail: 'Subagent Detail',
spawnSubagent: 'Spawn Subagent',
dispatchAndWait: 'Dispatch And Wait',
dispatchReply: 'Dispatch Reply',
mergedResult: 'Merged Result',
configSubagentDraft: 'Config Subagent',
agentRegistry: 'Agent Registry',
loadDraft: 'Load Draft',
enableAgent: 'Enable Agent',
disableAgent: 'Disable Agent',
deleteAgent: 'Delete Agent',
deleteAgentConfirm: 'Delete agent "{{id}}" from config.json permanently?',
noRegistryAgents: 'No configured agents.',
saveToConfig: 'Save To Config',
configSubagentSaved: 'Subagent config saved and runtime updated.',
promptFileEditor: 'Prompt File Editor',
promptFileEditorPlaceholder: 'Edit the AGENT.md content for this subagent.',
bootstrapPromptFile: 'Bootstrap AGENT.md',
savePromptFile: 'Save AGENT.md',
promptFileSaved: 'Prompt file saved.',
promptFileBootstrapped: 'Prompt file template created.',
promptFileReady: 'AGENT.md ready',
promptFileMissing: 'AGENT.md missing',
threadTrace: 'Thread Trace',
threadMessages: 'Thread Messages',
inbox: 'Inbox',
reply: 'Reply',
ack: 'Ack',
steerMessage: 'Steering message',
newProfile: 'New Profile',
spawn: 'Spawn',
kill: 'Kill',
send: 'Send',
dispatch: 'Dispatch',
toolAllowlist: 'Tool Allowlist',
memoryNamespace: 'Memory Namespace',
subagentDeleteConfirmTitle: 'Delete Subagent Profile',
subagentDeleteConfirmMessage: 'Delete subagent profile "{{id}}" permanently?',
sidebarCore: 'Core',
sidebarMain: 'Main',
sidebarAgents: 'Agents',
sidebarRuntime: 'Runtime',
sidebarConfig: 'Configuration',
sidebarKnowledge: 'Knowledge',
sidebarSystem: 'System',
sidebarOps: 'Operations',
sidebarInsights: 'Insights',
ekg: 'EKG',
@@ -198,9 +155,7 @@ const resources = {
ekgTopProvidersWorkload: 'Top Providers (workload)',
ekgTopProvidersAll: 'Top Providers (all)',
ekgTopErrsigWorkload: 'Top Error Signatures (workload)',
ekgTopErrsigHeartbeat: 'Top Error Signatures (heartbeat)',
ekgTopErrsigAll: 'Top Error Signatures (all)',
taskList: 'Task List',
taskDetail: 'Task Detail',
taskQueue: 'Task Queue',
taskLogs: 'Task Logs',
@@ -231,9 +186,6 @@ const resources = {
nodeActions: 'Node Actions',
nodeModels: 'Node Models',
nodeAgents: 'Node Agents',
nodesOnline: 'Nodes Online',
recentCron: 'Recent Cron Jobs',
nodesSnapshot: 'Nodes Snapshot',
refreshAll: 'Refresh All',
refresh: 'Refresh',
dashboardNodeP2PDetail: '{{transport}} · {{sessions}} active · {{retries}} retries',
@@ -292,7 +244,6 @@ const resources = {
noNodes: 'No nodes available',
allActions: 'All Actions',
allTransports: 'All Transports',
sessions: 'Sessions',
mainChat: 'Main Chat',
internalStream: 'Internal Stream',
enable: 'Enable',
@@ -330,18 +281,13 @@ const resources = {
rawJson: 'Raw JSON',
reload: 'Reload',
saveChanges: 'Save Changes',
gatewaySettings: 'Gateway Settings',
host: 'Host',
port: 'Port',
token: 'Token',
agentDefaults: 'Agent Defaults',
maxToolIterations: 'Max Tool Iterations',
maxTokens: 'Max Tokens',
providers: 'Providers',
providersIntroBefore: 'Select a provider tab, then set auth to ',
providersIntroMiddle: ' or ',
providersIntroAfter: '. The OAuth fields, login link flow, callback paste step, and account list appear in that provider card.',
providersBuiltinCannotDelete: 'The built-in provider "proxy" cannot be deleted.',
providersQwenLabelTitle: 'Qwen Account Label',
providersQwenLabelMessage: 'Qwen OAuth may not return an email. Enter an email or alias to identify this account.',
providersQwenImportLabelMessage: 'Enter an email or alias for this imported Qwen account.',
@@ -357,10 +303,17 @@ const resources = {
providersOAuthCallbackPlaceholder: 'http://localhost:1455/auth/callback?code=...&state=...',
providersOAuthAddedTitle: 'OAuth Added',
providersOAuthAddedMessage: 'Account: {{account}}',
providersOAuthAddedWithModel: 'Account: {{account}}\nModel: {{model}}',
providersOAuthAddedFallback: 'OAuth account added.',
providersAuthJsonImportedTitle: 'auth.json Imported',
providersAuthJsonImportedMessage: 'OAuth auth.json imported.',
providersImportingAuthJson: 'Importing auth.json',
providersSavingSelectedModel: 'Saving selected model',
providersSelectModelTitle: 'Choose Model',
providersSelectModelMessage: 'OAuth login fetched the provider models. Choose the model this provider should use.',
providersSelectModelSearchPlaceholder: 'Search model id',
providersSelectModelConfirm: 'Use this model',
providersSelectModelEmpty: 'No models match the current search.',
providersRefreshingOAuthAccount: 'Refreshing OAuth account',
providersDeleteOAuthAccountTitle: 'Delete OAuth Account',
providersDeletingOAuthAccount: 'Deleting OAuth account',
@@ -376,7 +329,8 @@ const resources = {
providersApiBase: 'API base',
providersApiKey: 'API key',
providersModels: 'models',
providersModelsHelp: 'Comma separated model ids used by this provider.',
providersModelsHelp: 'Press Enter to add a model id for this provider.',
providersModelsEnterHint: 'Type model id and press Enter',
providersAuthMode: 'auth mode',
providersAuthModeHelp: 'Choose bearer for API key only, oauth for OAuth only, hybrid to use both.',
providersRuntimePersist: 'runtime persist',
@@ -407,10 +361,7 @@ const resources = {
providersOAuthAccounts: 'OAuth Accounts',
providersRefreshList: 'Refresh List',
providersNoOAuthAccounts: 'No imported OAuth accounts yet.',
proxyTimeout: 'Proxy Timeout (sec)',
system: 'System',
enableShellTools: 'Enable Shell Tools',
enableLogging: 'Enable Logging',
pauseJob: 'Pause Job',
startJob: 'Start Job',
deleteJob: 'Delete Job',
@@ -420,7 +371,6 @@ const resources = {
editJob: 'Edit Job',
jobName: 'Job Name',
kind: 'Kind',
everyMs: 'Interval (ms)',
cronExpression: 'Cron Expression',
runAt: 'Run At',
message: 'Message',
@@ -445,15 +395,12 @@ const resources = {
level: 'Level',
code: 'Code',
template: 'Template',
content: 'Content',
id: 'ID',
files: 'Files',
close: 'Close',
path: 'Path',
before: 'Before',
after: 'After',
hide: 'Hide',
show: 'Show',
clear: 'Clear',
pause: 'Pause',
resume: 'Resume',
@@ -464,7 +411,6 @@ const resources = {
appName: 'ClawGo',
webui: 'WebUI',
node: 'Node',
unknownIp: 'Unknown IP',
memoryFiles: 'Memory Files',
memoryFileNamePrompt: 'Memory file name',
noFileSelected: 'No file selected',
@@ -580,8 +526,6 @@ const resources = {
cronDisableConfirmMessage: 'Pause this cron job now?',
memoryDeleteConfirmTitle: 'Delete Memory File',
memoryDeleteConfirmMessage: 'Delete memory file "{{path}}" permanently?',
taskDeleteConfirmTitle: 'Delete Task',
taskDeleteConfirmMessage: 'Delete task "{{id}}" permanently?',
logsClearConfirmTitle: 'Clear Logs',
logsClearConfirmMessage: 'Clear current log list from this page?',
configDeleteProviderConfirmTitle: 'Delete Provider',
@@ -827,8 +771,6 @@ const resources = {
channelFieldMaixCamAllowFromHint: '设置后,仅允许这些设备或发送者 ID 调用该通道。',
channelFieldEnableGroupsHint: '允许接收来自群聊的消息。',
channelFieldRequireMentionHint: '开启后,群聊消息必须先 @ 机器人才会被接收。',
whatsappBridgeRunning: 'Bridge 运行中',
whatsappBridgeStopped: 'Bridge 未运行',
whatsappBridgeURL: 'Bridge 地址',
whatsappBridgeAccount: '关联账号',
whatsappBridgeLastEvent: '最近事件',
@@ -844,8 +786,6 @@ const resources = {
whatsappQRCodeHint: '如果已经关联成功,就不会显示二维码。\n如果二维码没有出现请确认 gateway 已启动且 WhatsApp 通道已启用。',
whatsappStateAwaitingScan: '等待扫码',
whatsappStateDisconnected: '已断开',
whatsappBridgeDevHintTitle: '使用方式',
whatsappBridgeDevHint: '1. 先启动 gateway并启用 WhatsApp 通道。\n2. 在这里用 WhatsApp 扫描二维码。\n3. 保持 ClawGo 运行以接收 WhatsApp 消息。',
whatsappFieldEnabledHint: '总开关,控制是否通过 bridge 接收 WhatsApp 消息。',
whatsappFieldBridgeURLHint: '可选。留空时自动使用当前 Gateway 内嵌的 /whatsapp/ws 地址。',
whatsappFieldAllowFromHint: '每行一个发送者 JID只有这些发送者可以触发 ClawGo。',
@@ -881,14 +821,11 @@ const resources = {
nodesFilterPlaceholder: '按节点 ID、名称或标签筛选',
agentTree: '代理树',
noAgentTree: '当前没有可用的代理树。',
readonlyMirror: '只读镜像',
localControl: '本地控制',
logs: '实时日志',
logCodes: '日志编号',
skills: '技能管理',
memory: '记忆文件',
taskAudit: '任务审计',
tasks: '任务管理',
subagentProfiles: '子代理档案',
subagentsRuntime: 'Agents',
nodeP2P: '节点 P2P',
@@ -909,59 +846,23 @@ const resources = {
zoomIn: '放大',
zoomOut: '缩小',
fitView: '适应视图',
childrenCount: '子节点',
'topologyFilter.all': '全部',
'topologyFilter.running': '运行中',
'topologyFilter.failed': '失败',
'topologyFilter.local': '本地',
'topologyFilter.remote': '远端',
noLiveTasks: '当前没有活动任务',
remoteTasksUnavailable: '远端任务细节暂未镜像回来。',
subagentDetail: '子代理详情',
spawnSubagent: '创建子代理任务',
dispatchAndWait: '派发并等待',
dispatchReply: '派发回复',
mergedResult: '汇总结果',
configSubagentDraft: '配置子代理',
agentRegistry: '代理注册表',
loadDraft: '载入配置',
enableAgent: '启用代理',
disableAgent: '停用代理',
deleteAgent: '删除代理',
deleteAgentConfirm: '确认从 config.json 中永久删除代理 "{{id}}" 吗?',
noRegistryAgents: '当前没有已配置代理。',
saveToConfig: '写入配置',
configSubagentSaved: '子代理配置已写入并刷新运行态。',
promptFileEditor: '提示词文件编辑器',
promptFileEditorPlaceholder: '编辑该子代理对应的 AGENT.md 内容。',
bootstrapPromptFile: '生成 AGENT.md 模板',
savePromptFile: '保存 AGENT.md',
promptFileSaved: '提示词文件已保存。',
promptFileBootstrapped: '提示词模板已创建。',
promptFileReady: 'AGENT.md 已就绪',
promptFileMissing: 'AGENT.md 缺失',
threadTrace: '线程追踪',
threadMessages: '线程消息',
inbox: '收件箱',
reply: '回复',
ack: '确认',
steerMessage: '引导消息',
newProfile: '新建档案',
spawn: '创建',
kill: '终止',
send: '发送',
dispatch: '派发',
toolAllowlist: '工具白名单',
memoryNamespace: '记忆命名空间',
subagentDeleteConfirmTitle: '删除子代理档案',
subagentDeleteConfirmMessage: '确认永久删除子代理档案 "{{id}}"',
sidebarCore: '核心',
sidebarMain: '主入口',
sidebarAgents: 'Agents',
sidebarRuntime: '运行态',
sidebarConfig: '配置',
sidebarKnowledge: '知识与调试',
sidebarSystem: '系统',
sidebarOps: '运维',
sidebarInsights: '洞察',
ekg: 'EKG',
@@ -971,9 +872,7 @@ const resources = {
ekgTopProvidersWorkload: 'Top Providers业务负载',
ekgTopProvidersAll: 'Top Providers全量',
ekgTopErrsigWorkload: 'Top 错误签名(业务负载)',
ekgTopErrsigHeartbeat: 'Top 错误签名(心跳)',
ekgTopErrsigAll: 'Top 错误签名(全量)',
taskList: '任务列表',
taskDetail: '任务详情',
taskQueue: '任务队列',
taskLogs: '任务日志',
@@ -1004,9 +903,6 @@ const resources = {
nodeActions: '节点动作',
nodeModels: '节点模型',
nodeAgents: '节点 Agents',
nodesOnline: '在线节点',
recentCron: '最近定时任务',
nodesSnapshot: '节点快照',
refreshAll: '刷新全部',
refresh: '刷新',
dashboardNodeP2PDetail: '{{transport}} · {{sessions}} 个活跃会话 · {{retries}} 次重试',
@@ -1065,7 +961,6 @@ const resources = {
noNodes: '无可用节点',
allActions: '全部动作',
allTransports: '全部传输',
sessions: '会话',
mainChat: '主对话',
internalStream: '内部流',
enable: '启用',
@@ -1103,18 +998,13 @@ const resources = {
rawJson: '原始 JSON',
reload: '重新加载',
saveChanges: '保存更改',
gatewaySettings: '网关设置',
host: '主机',
port: '端口',
token: '令牌',
agentDefaults: '代理默认值',
maxToolIterations: '最大工具迭代次数',
maxTokens: '最大 Token 数',
providers: '提供商',
providersIntroBefore: '先选择一个 provider 标签,再把认证模式切到 ',
providersIntroMiddle: ' 或 ',
providersIntroAfter: '。对应 provider 卡片里会出现 OAuth 字段、登录链接流程、回调地址回填和账号列表。',
providersBuiltinCannotDelete: '内置 provider “proxy” 不能删除。',
providersQwenLabelTitle: 'Qwen 账号标识',
providersQwenLabelMessage: 'Qwen OAuth 可能不会返回邮箱,请输入一个邮箱或别名来标识该账号。',
providersQwenImportLabelMessage: '请为导入的 Qwen 账号输入邮箱或别名。',
@@ -1130,10 +1020,17 @@ const resources = {
providersOAuthCallbackPlaceholder: 'http://localhost:1455/auth/callback?code=...&state=...',
providersOAuthAddedTitle: 'OAuth 已添加',
providersOAuthAddedMessage: '账号:{{account}}',
providersOAuthAddedWithModel: '账号:{{account}}\n模型{{model}}',
providersOAuthAddedFallback: 'OAuth 账号已添加。',
providersAuthJsonImportedTitle: 'auth.json 已导入',
providersAuthJsonImportedMessage: 'OAuth auth.json 已导入。',
providersImportingAuthJson: '正在导入 auth.json',
providersSavingSelectedModel: '正在保存所选模型',
providersSelectModelTitle: '选择模型',
providersSelectModelMessage: 'OAuth 登录已经拉取到服务商模型,请选择这个 provider 要使用的模型。',
providersSelectModelSearchPlaceholder: '搜索模型 ID',
providersSelectModelConfirm: '使用这个模型',
providersSelectModelEmpty: '当前搜索没有匹配到模型。',
providersRefreshingOAuthAccount: '正在刷新 OAuth 账号',
providersDeleteOAuthAccountTitle: '删除 OAuth 账号',
providersDeletingOAuthAccount: '正在删除 OAuth 账号',
@@ -1149,7 +1046,8 @@ const resources = {
providersApiBase: 'API 基础地址',
providersApiKey: 'API 密钥',
providersModels: '模型列表',
providersModelsHelp: '用逗号分隔这个 provider 要使用的模型 ID。',
providersModelsHelp: '输入模型 ID 后按回车添加到这个 provider。',
providersModelsEnterHint: '输入模型 ID 后按回车',
providersAuthMode: '认证模式',
providersAuthModeHelp: 'bearer 表示只用 API keyoauth 表示只用 OAuthhybrid 表示两者混用。',
providersRuntimePersist: '运行态持久化',
@@ -1180,10 +1078,7 @@ const resources = {
providersOAuthAccounts: 'OAuth 账号',
providersRefreshList: '刷新列表',
providersNoOAuthAccounts: '当前还没有导入 OAuth 账号。',
proxyTimeout: '代理超时 (秒)',
system: '系统',
enableShellTools: '启用 Shell 工具',
enableLogging: '启用日志',
pauseJob: '暂停任务',
startJob: '启动任务',
deleteJob: '删除任务',
@@ -1193,7 +1088,6 @@ const resources = {
editJob: '编辑任务',
jobName: '任务名称',
kind: '类型',
everyMs: '间隔 (毫秒)',
cronExpression: 'Cron 表达式',
runAt: '执行时间',
message: '消息',
@@ -1218,15 +1112,12 @@ const resources = {
level: '级别',
code: '代码',
template: '模板',
content: '内容',
id: 'ID',
files: '文件',
close: '关闭',
path: '路径',
before: '变更前',
after: '变更后',
hide: '隐藏',
show: '显示',
clear: '清空',
pause: '暂停',
resume: '继续',
@@ -1237,7 +1128,6 @@ const resources = {
appName: 'ClawGo',
webui: 'WebUI',
node: '节点',
unknownIp: '未知 IP',
memoryFiles: '记忆文件',
memoryFileNamePrompt: '记忆文件名',
noFileSelected: '未选择文件',
@@ -1353,8 +1243,6 @@ const resources = {
cronDisableConfirmMessage: '确认暂停该定时任务吗?',
memoryDeleteConfirmTitle: '删除记忆文件',
memoryDeleteConfirmMessage: '确认永久删除记忆文件“{{path}}”吗?',
taskDeleteConfirmTitle: '删除任务',
taskDeleteConfirmMessage: '确认永久删除任务“{{id}}”吗?',
logsClearConfirmTitle: '清空日志',
logsClearConfirmMessage: '确认清空当前页面中的日志列表吗?',
configDeleteProviderConfirmTitle: '删除 Provider',