From 5d74dba0b8e8d56e0a7b0678043da96ffe6edec0 Mon Sep 17 00:00:00 2001 From: lpf Date: Wed, 11 Mar 2026 19:48:10 +0800 Subject: [PATCH] refine oauth model selection and provider inputs --- pkg/api/server.go | 50 ++++- webui/src/components/GlobalDialog.tsx | 45 +++-- .../config/ProviderConfigSection.tsx | 173 ++++++++++++------ .../config/ProviderModelPickerModal.tsx | 72 ++++++++ webui/src/components/config/configUtils.ts | 2 +- .../config/useConfigProviderActions.ts | 80 +++++++- webui/src/context/UIContext.tsx | 26 ++- webui/src/i18n/index.ts | 148 ++------------- 8 files changed, 381 insertions(+), 215 deletions(-) create mode 100644 webui/src/components/config/ProviderModelPickerModal.tsx diff --git a/pkg/api/server.go b/pkg/api/server.go index a05470f..467638a 100644 --- a/pkg/api/server.go +++ b/pkg/api/server.go @@ -457,6 +457,7 @@ func (s *Server) Start(ctx context.Context) error { mux.HandleFunc("/webui/api/provider/oauth/complete", s.handleWebUIProviderOAuthComplete) mux.HandleFunc("/webui/api/provider/oauth/import", s.handleWebUIProviderOAuthImport) mux.HandleFunc("/webui/api/provider/oauth/accounts", s.handleWebUIProviderOAuthAccounts) + mux.HandleFunc("/webui/api/provider/models", s.handleWebUIProviderModels) mux.HandleFunc("/webui/api/provider/runtime", s.handleWebUIProviderRuntime) mux.HandleFunc("/webui/api/provider/runtime/summary", s.handleWebUIProviderRuntimeSummary) mux.HandleFunc("/webui/api/whatsapp/status", s.handleWebUIWhatsAppStatus) @@ -1104,9 +1105,6 @@ func (s *Server) handleWebUIProviderOAuthComplete(w http.ResponseWriter, r *http http.Error(w, err.Error(), http.StatusBadRequest) return } - if len(models) > 0 { - pc.Models = models - } if session.CredentialFile != "" { pc.OAuth.CredentialFile = session.CredentialFile pc.OAuth.CredentialFiles = appendUniqueStrings(pc.OAuth.CredentialFiles, session.CredentialFile) @@ -1181,9 +1179,6 @@ func (s *Server) handleWebUIProviderOAuthImport(w http.ResponseWriter, r *http.R http.Error(w, err.Error(), http.StatusBadRequest) return } - if len(models) > 0 { - pc.Models = models - } if session.CredentialFile != "" { pc.OAuth.CredentialFile = session.CredentialFile pc.OAuth.CredentialFiles = appendUniqueStrings(pc.OAuth.CredentialFiles, session.CredentialFile) @@ -1278,6 +1273,49 @@ func (s *Server) handleWebUIProviderOAuthAccounts(w http.ResponseWriter, r *http } } +func (s *Server) handleWebUIProviderModels(w http.ResponseWriter, r *http.Request) { + if !s.checkAuth(r) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var body struct { + Provider string `json:"provider"` + Model string `json:"model"` + Models []string `json:"models"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "invalid json", http.StatusBadRequest) + return + } + cfg, pc, err := s.loadProviderConfig(strings.TrimSpace(body.Provider)) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + models := make([]string, 0, len(body.Models)+1) + for _, model := range body.Models { + models = appendUniqueStrings(models, model) + } + models = appendUniqueStrings(models, body.Model) + if len(models) == 0 { + http.Error(w, "model required", http.StatusBadRequest) + return + } + pc.Models = models + if err := s.saveProviderConfig(cfg, body.Provider, pc); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "ok": true, + "models": pc.Models, + }) +} + func (s *Server) handleWebUIProviderRuntime(w http.ResponseWriter, r *http.Request) { if !s.checkAuth(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) diff --git a/webui/src/components/GlobalDialog.tsx b/webui/src/components/GlobalDialog.tsx index ade6f96..7e26723 100644 --- a/webui/src/components/GlobalDialog.tsx +++ b/webui/src/components/GlobalDialog.tsx @@ -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 && ( -

{options.title || (kind === 'confirm' ? t('dialogPleaseConfirm') : kind === 'prompt' ? t('dialogInputTitle') : t('dialogNotice'))}

-
{options.message}
+
{options.message}
{kind === 'prompt' && (
{options.inputLabel && } - 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 ? ( + setValue(e.target.value)} + placeholder={options.inputPlaceholder || t('dialogInputPlaceholder')} + monospace={options.monospace} + className="min-h-[96px] w-full text-zinc-100" + /> + ) : ( + 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" + /> + )}
)}
diff --git a/webui/src/components/config/ProviderConfigSection.tsx b/webui/src/components/config/ProviderConfigSection.tsx index a79fcf7..0154d1a 100644 --- a/webui/src/components/config/ProviderConfigSection.tsx +++ b/webui/src/components/config/ProviderConfigSection.tsx @@ -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; } +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 ( +
+ {values.length > 0 ? ( +
+ {values.map((value) => ( +
+ {value} + +
+ ))} +
+ ) : null} + 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" + /> +
+ ); +} + type RuntimeSection = 'candidates' | 'hits' | 'errors' | 'changes'; type ProviderRuntimeToolbarProps = { @@ -50,9 +115,10 @@ export function ProviderRuntimeToolbar({
{t('configProxies')}
- +
); @@ -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({ onFieldChange('api_base', e.target.value)} placeholder={t('configLabels.api_base')} className="w-full" /> - onFieldChange('models', e.target.value.split(',').map((s) => s.trim()).filter(Boolean))} - placeholder={`${t('configLabels.models')}${t('configCommaSeparatedHint')}`} - className="w-full" - /> + onFieldChange('models', values)} placeholder={t('providersModelsEnterHint')} /> onFieldChange('api_key', e.target.value)} placeholder={t('configLabels.api_key')} className="w-full" /> @@ -456,7 +521,7 @@ export function ProviderProxyCard({
2
Authentication
-
Request auth and hybrid priority.
+
Choose how this provider authenticates requests.
@@ -480,53 +545,51 @@ export function ProviderProxyCard({ -
-
-
-
4
-
-
{t('providersOAuthAccounts')}
-
Imported sessions.
+ {showOAuth ? ( +
+
+
+
4
+
+
{t('providersOAuthAccounts')}
+
Imported sessions.
+
-
- {showOAuth ? ( - ) : null} -
- {!showOAuth ? ( -
Enable oauth or hybrid mode to manage OAuth accounts.
- ) : oauthAccounts.length === 0 ? ( -
{t('providersNoOAuthAccounts')}
- ) : ( -
- {oauthAccounts.map((account, idx) => ( -
-
-
{account?.email || account?.account_id || account?.credential_file}
-
label: {account?.account_label || account?.email || account?.account_id || '-'}
-
{account?.credential_file}
-
project: {account?.project_id || '-'} · device: {account?.device_id || '-'}
-
proxy: {account?.network_proxy || '-'}
-
expire: {account?.expire || '-'} · cooldown: {account?.cooldown_until || '-'}
-
-
- onRefreshOAuthAccount(String(account?.credential_file || ''))} variant="neutral" radius="lg" label="Refresh"> - - - onClearOAuthCooldown(String(account?.credential_file || ''))} variant="neutral" radius="lg" label="Clear Cooldown"> - - - onDeleteOAuthAccount(String(account?.credential_file || ''))} variant="danger" radius="lg" label={t('delete')}> - - -
-
- ))}
- )} -
+ {oauthAccounts.length === 0 ? ( +
{t('providersNoOAuthAccounts')}
+ ) : ( +
+ {oauthAccounts.map((account, idx) => ( +
+
+
{account?.email || account?.account_id || account?.credential_file}
+
label: {account?.account_label || account?.email || account?.account_id || '-'}
+
{account?.credential_file}
+
project: {account?.project_id || '-'} · device: {account?.device_id || '-'}
+
proxy: {account?.network_proxy || '-'}
+
expire: {account?.expire || '-'} · cooldown: {account?.cooldown_until || '-'}
+
+
+ onRefreshOAuthAccount(String(account?.credential_file || ''))} variant="neutral" radius="lg" label="Refresh"> + + + onClearOAuthCooldown(String(account?.credential_file || ''))} variant="neutral" radius="lg" label="Clear Cooldown"> + + + onDeleteOAuthAccount(String(account?.credential_file || ''))} variant="danger" radius="lg" label={t('delete')}> + + +
+
+ ))} +
+ )} +
+ ) : null}
diff --git a/webui/src/components/config/ProviderModelPickerModal.tsx b/webui/src/components/config/ProviderModelPickerModal.tsx new file mode 100644 index 0000000..94752c7 --- /dev/null +++ b/webui/src/components/config/ProviderModelPickerModal.tsx @@ -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 = ({ + 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 ( +
+
{t('providersSelectModelMessage')}
+ setQuery(e.target.value)} + placeholder={t('providersSelectModelSearchPlaceholder')} + className="w-full" + /> +
+ {filtered.map((model) => { + const active = model === selected; + return ( + + ); + })} + {filtered.length === 0 && ( +
+ {t('providersSelectModelEmpty')} +
+ )} +
+
+ + +
+
+ ); +}; diff --git a/webui/src/components/config/configUtils.ts b/webui/src/components/config/configUtils.ts index 3a5c718..c83f7f2 100644 --- a/webui/src/components/config/configUtils.ts +++ b/webui/src/components/config/configUtils.ts @@ -98,7 +98,7 @@ export function createDefaultProxyConfig() { stream_include_usage: false, }, supports_responses_compact: false, - auth: 'bearer', + auth: 'oauth', timeout_sec: 120, }; } diff --git a/webui/src/components/config/useConfigProviderActions.ts b/webui/src/components/config/useConfigProviderActions.ts index ad478e2..647da9f 100644 --- a/webui/src/components/config/useConfigProviderActions.ts +++ b/webui/src/components/config/useConfigProviderActions.ts @@ -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; notify: (options: any) => Promise; + openModal: (node: React.ReactNode, title?: string, onClose?: () => void) => void; promptDialog: (options: any) => Promise; withLoading: (fn: () => Promise, label: string) => Promise; }; @@ -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((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) }); diff --git a/webui/src/context/UIContext.tsx b/webui/src/context/UIContext.tsx index 6ce758b..65b8164 100644 --- a/webui/src/context/UIContext.tsx +++ b/webui/src/context/UIContext.tsx @@ -16,7 +16,7 @@ type UIContextType = { notify: (opts: DialogOptions | string) => Promise; confirmDialog: (opts: DialogOptions | string) => Promise; promptDialog: (opts: DialogOptions | string) => Promise; - 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(getInitialTheme); const [dialog, setDialog] = useState void }>(null); - const [customModal, setCustomModal] = useState(null); + const [customModal, setCustomModal] = useState 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 && ( - setCustomModal((current) => { + current?.onClose?.(); + return null; + })} + aria-label={t('close')} + /> +

{customModal.title || t('modal')}

- +
{customModal.node}
diff --git a/webui/src/i18n/index.ts b/webui/src/i18n/index.ts index a6d78ff..8ed453e 100644 --- a/webui/src/i18n/index.ts +++ b/webui/src/i18n/index.ts @@ -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 key,oauth 表示只用 OAuth,hybrid 表示两者混用。', 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',