Add OAuth provider runtime and providers UI

This commit is contained in:
lpf
2026-03-11 15:47:49 +08:00
parent d9872c3da7
commit 1c0e463d07
52 changed files with 9772 additions and 901 deletions

View File

@@ -5,10 +5,7 @@ import { useTranslation } from 'react-i18next';
import { useAppContext } from '../context/AppContext';
import { useUI } from '../context/UIContext';
import { Button, FixedButton } from '../components/Button';
import Checkbox from '../components/Checkbox';
import FormField from '../components/FormField';
import Input from '../components/Input';
import Textarea from '../components/Textarea';
import { CheckboxField, FieldBlock, TextField, TextareaField } from '../components/FormControls';
type ChannelKey = 'telegram' | 'whatsapp' | 'discord' | 'feishu' | 'qq' | 'dingtalk' | 'maixcam';
@@ -530,10 +527,10 @@ const ChannelSettings: React.FC = () => {
{t(value ? 'enabled_true' : 'enabled_false')}
</div>
</div>
<Checkbox
<CheckboxField
checked={!!value}
onChange={(e) => setDraft((prev) => ({ ...prev, [field.key]: e.target.checked }))}
className="mt-1"
className="ui-checkbox mt-1"
/>
</label>
);
@@ -546,56 +543,49 @@ const ChannelSettings: React.FC = () => {
{t(value ? 'enabled_true' : 'enabled_false')}
</div>
</div>
<Checkbox
<CheckboxField
checked={!!value}
onChange={(e) => setDraft((prev) => ({ ...prev, [field.key]: e.target.checked }))}
className="ui-checkbox"
/>
</label>
);
}
if (field.type === 'list') {
return (
<div key={field.key} className={`ui-form-field ${isWhatsApp ? 'lg:col-span-2' : ''}`}>
<div className="flex items-center justify-between gap-3">
<label className="ui-form-label">{label}</label>
{isWhatsApp && Array.isArray(value) && value.length > 0 && (
<span className="ui-pill ui-pill-neutral inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] font-medium">
{t('entries')}: {value.length}
</span>
)}
</div>
{helper && <div className="ui-form-help">{helper}</div>}
<Textarea
<FieldBlock
key={field.key}
className={`ui-form-field ${isWhatsApp ? 'lg:col-span-2' : ''}`}
label={label}
help={helper}
meta={isWhatsApp && Array.isArray(value) && value.length > 0 ? `${t('entries')}: ${value.length}` : undefined}
>
<TextareaField
value={formatList(value)}
onChange={(e) => setDraft((prev) => ({ ...prev, [field.key]: parseList(e.target.value) }))}
placeholder={field.placeholder || ''}
className={`px-4 py-3 text-sm ${isWhatsApp ? 'min-h-36 font-mono' : 'min-h-32'}`}
monospace={isWhatsApp}
className={`${isWhatsApp ? 'min-h-36 px-4 py-3' : 'min-h-32 px-4 py-3'}`}
/>
{isWhatsApp && <div className="ui-form-help text-[11px]">{t('whatsappFieldAllowFromFootnote')}</div>}
</div>
</FieldBlock>
);
}
return (
<FormField
key={field.key}
label={label}
help={helper}
className={`ui-form-field ${isWhatsApp && field.key === 'bridge_url' ? 'lg:col-span-2' : ''}`}
>
<Input
<FieldBlock key={field.key} className={`ui-form-field ${isWhatsApp && field.key === 'bridge_url' ? 'lg:col-span-2' : ''}`} label={label} help={helper}>
<TextField
type={field.type}
value={value === null || value === undefined ? '' : String(value)}
onChange={(e) => setDraft((prev) => ({ ...prev, [field.key]: field.type === 'number' ? Number(e.target.value || 0) : e.target.value }))}
placeholder={field.placeholder || ''}
className={`px-4 py-3 text-sm ${isWhatsApp && field.key === 'bridge_url' ? 'font-mono' : ''}`}
className={`${isWhatsApp && field.key === 'bridge_url' ? 'font-mono' : ''}`}
/>
</FormField>
</FieldBlock>
);
};
const wa = waStatus?.status;
const stateLabel = wa?.connected ? t('online') : wa?.logged_in ? t('whatsappStateDisconnected') : wa?.qr_available ? t('whatsappStateAwaitingScan') : t('offline');
const canLogout = !!(wa?.logged_in || wa?.connected || wa?.user_jid);
return (
<div className="space-y-6 px-5 py-5 md:px-7 md:py-6 xl:px-8">
@@ -652,12 +642,10 @@ const ChannelSettings: React.FC = () => {
<div className="ui-text-primary mt-1 text-2xl font-semibold">{stateLabel}</div>
</div>
</div>
{canLogout ? (
<Button onClick={handleLogout} variant="danger" gap="2">
<LogOut className="h-4 w-4" />
{t('logout')}
</Button>
) : null}
<Button onClick={handleLogout} variant="danger" gap="2">
<LogOut className="h-4 w-4" />
{t('logout')}
</Button>
</div>
<div className="grid gap-4 sm:grid-cols-2">

View File

@@ -5,9 +5,7 @@ import { useTranslation } from 'react-i18next';
import { useAppContext } from '../context/AppContext';
import { useUI } from '../context/UIContext';
import { Button, FixedButton } from '../components/Button';
import Input from '../components/Input';
import Select from '../components/Select';
import Textarea from '../components/Textarea';
import { SelectField, TextField, TextareaField } from '../components/FormControls';
import { ChatItem } from '../types';
type StreamItem = {
@@ -577,9 +575,9 @@ const Chat: React.FC = () => {
<Button onClick={() => setChatTab('subagents')} variant={chatTab === 'subagents' ? 'primary' : 'neutral'} size="xs">{t('subagentGroup')}</Button>
</div>
{chatTab === 'main' && (
<Select value={sessionKey} onChange={(e) => setSessionKey(e.target.value)} className="min-w-[220px] flex-1 rounded-xl px-2.5 py-1.5 text-xs">
<SelectField dense value={sessionKey} onChange={(e) => setSessionKey(e.target.value)} className="min-w-[220px] flex-1">
{userSessions.map((s: any) => <option key={s.key} value={s.key}>{s.title || s.key}</option>)}
</Select>
</SelectField>
)}
<FixedButton
onClick={() => {
@@ -621,28 +619,28 @@ const Chat: React.FC = () => {
<div className="ui-text-secondary text-sm">{t('subagentDispatchHint')}</div>
</div>
<div className="space-y-3">
<Select
<SelectField
value={dispatchAgentID}
onChange={(e) => setDispatchAgentID(e.target.value)}
className="ui-select w-full rounded-2xl px-3 py-2.5 text-sm"
className="w-full rounded-2xl py-2.5"
>
{registryAgents.map((agent) => (
<option key={agent.agent_id} value={agent.agent_id}>
{formatAgentName(agent.display_name || agent.agent_id, t)} · {agent.role || '-'}
</option>
))}
</Select>
<Textarea
</SelectField>
<TextareaField
value={dispatchTask}
onChange={(e) => setDispatchTask(e.target.value)}
placeholder={t('subagentTaskPlaceholder')}
className="w-full min-h-[180px] resize-none rounded-2xl px-3 py-3 text-sm"
className="w-full min-h-[180px] resize-none rounded-2xl px-3 py-3"
/>
<Input
<TextField
value={dispatchLabel}
onChange={(e) => setDispatchLabel(e.target.value)}
placeholder={t('subagentLabelPlaceholder')}
className="w-full rounded-2xl px-3 py-2.5 text-sm"
className="w-full rounded-2xl py-2.5"
/>
<Button onClick={dispatchSubagentTask} disabled={!dispatchAgentID.trim() || !dispatchTask.trim()} variant="primary" size="md_tall" fullWidth>
{t('dispatchToSubagent')}
@@ -762,7 +760,7 @@ const Chat: React.FC = () => {
>
<Paperclip className="w-5 h-5" />
</label>
<Input
<TextField
value={msg}
onChange={(e) => setMsg(e.target.value)}
onKeyDown={(e) => chatTab === 'main' && e.key === 'Enter' && send()}

View File

@@ -1,52 +1,15 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Plus, RefreshCw, Save } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useAppContext } from '../context/AppContext';
import { useUI } from '../context/UIContext';
import { Button, FixedButton } from '../components/Button';
import Checkbox from '../components/Checkbox';
import FieldCard from '../components/FieldCard';
import FormField from '../components/FormField';
import Input from '../components/Input';
import { TextareaField } from '../components/FormControls';
import { GatewayArtifactsSection, GatewayDispatchSection, GatewayP2PSection } from '../components/config/GatewayConfigSection';
import { ConfigDiffModal, ConfigHeader, ConfigSidebar, ConfigToolbar } from '../components/config/ConfigPageChrome';
import { buildDiffRows, formatTagRuleText, parseTagRuleText, setPath } from '../components/config/configUtils';
import { useConfigGatewayActions } from '../components/config/useConfigGatewayActions';
import { useConfigNavigation } from '../components/config/useConfigNavigation';
import { useConfigSaveAction } from '../components/config/useConfigSaveAction';
import RecursiveConfig from '../components/RecursiveConfig';
import Select from '../components/Select';
import Textarea from '../components/Textarea';
function setPath(obj: any, path: string, value: any) {
const keys = path.split('.');
const next = JSON.parse(JSON.stringify(obj || {}));
let cur = next;
for (let i = 0; i < keys.length - 1; i++) {
const k = keys[i];
if (typeof cur[k] !== 'object' || cur[k] === null) cur[k] = {};
cur = cur[k];
}
cur[keys[keys.length - 1]] = value;
return next;
}
function parseTagRuleText(raw: string) {
const out: Record<string, string[]> = {};
for (const line of String(raw || '').split('\n')) {
const trimmed = line.trim();
if (!trimmed) continue;
const idx = trimmed.indexOf('=');
if (idx <= 0) continue;
const key = trimmed.slice(0, idx).trim();
const tags = trimmed.slice(idx + 1).split(',').map((item) => item.trim()).filter(Boolean);
if (!key || tags.length === 0) continue;
out[key] = tags;
}
return out;
}
function formatTagRuleText(value: unknown) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return '';
return Object.entries(value as Record<string, any>)
.map(([key, tags]) => `${key}=${Array.isArray(tags) ? tags.join(',') : ''}`)
.filter((line) => line !== '=')
.join('\n');
}
const Config: React.FC = () => {
const { t } = useTranslation();
@@ -56,38 +19,37 @@ const Config: React.FC = () => {
const [basicMode, setBasicMode] = useState(true);
const [hotOnly, setHotOnly] = useState(false);
const [search, setSearch] = useState('');
const [newProxyName, setNewProxyName] = useState('');
const configLabels = useMemo(
() => t('configLabels', { returnObjects: true }) as Record<string, string>,
[t]
);
const hotPrefixes = useMemo(() => hotReloadFieldDetails.map((x) => String(x.path || '').replace(/\.\*$/, '')).filter(Boolean), [hotReloadFieldDetails]);
const hotReloadTabKey = '__hot_reload__';
const allTopKeys = useMemo(() => Object.keys(cfg || {}).filter(k => typeof (cfg as any)?.[k] === 'object' && (cfg as any)?.[k] !== null), [cfg]);
const basicTopKeys = useMemo(() => {
const preferred = ['gateway', 'providers', 'channels', 'tools', 'cron', 'agents', 'logging'];
return preferred.filter((k) => allTopKeys.includes(k));
}, [allTopKeys]);
const filteredTopKeys = useMemo(() => {
let keys = basicMode ? basicTopKeys : allTopKeys;
if (hotOnly) {
keys = keys.filter((k) => hotPrefixes.some((p) => p === k || p.startsWith(`${k}.`) || k.startsWith(`${p}.`)));
}
if (search.trim()) {
const s = search.trim().toLowerCase();
keys = keys.filter((k) => k.toLowerCase().includes(s));
}
return [hotReloadTabKey, ...keys];
}, [allTopKeys, basicTopKeys, basicMode, hotOnly, search, hotPrefixes]);
const [selectedTop, setSelectedTop] = useState<string>('');
const activeTop = filteredTopKeys.includes(selectedTop) ? selectedTop : (filteredTopKeys[0] || '');
const [baseline, setBaseline] = useState<any>(null);
const [showDiff, setShowDiff] = useState(false);
const { activeTop, configLabels, filteredTopKeys, hotReloadTabKey } = useConfigNavigation({
basicMode,
cfg,
hotOnly,
hotReloadFieldDetails,
search,
selectedTop,
t,
});
const {
addGatewayIceServer,
removeGatewayIceServer,
updateGatewayArtifactsField,
updateGatewayDispatchField,
updateGatewayIceServer,
updateGatewayP2PField,
} = useConfigGatewayActions({ setCfg });
const { saveConfig } = useConfigSaveAction({
cfg,
cfgRaw,
q,
setBaseline,
setConfigEditing,
setShowDiff,
showRaw,
t,
ui,
});
const currentPayload = useMemo(() => {
if (showRaw) {
@@ -97,24 +59,7 @@ const Config: React.FC = () => {
}, [showRaw, cfgRaw, cfg]);
const diffRows = useMemo(() => {
const out: Array<{ path: string; before: any; after: any }> = [];
const walk = (a: any, b: any, p: string) => {
const keys = new Set([...(a && typeof a === 'object' ? Object.keys(a) : []), ...(b && typeof b === 'object' ? Object.keys(b) : [])]);
if (keys.size === 0) {
if (JSON.stringify(a) !== JSON.stringify(b)) out.push({ path: p || t('configRoot'), before: a, after: b });
return;
}
keys.forEach((k) => {
const pa = p ? `${p}.${k}` : k;
const av = a ? a[k] : undefined;
const bv = b ? b[k] : undefined;
const bothObj = av && bv && typeof av === 'object' && typeof bv === 'object' && !Array.isArray(av) && !Array.isArray(bv);
if (bothObj) walk(av, bv, pa);
else if (JSON.stringify(av) !== JSON.stringify(bv)) out.push({ path: pa, before: av, after: bv });
});
};
walk(baseline || {}, currentPayload || {}, '');
return out;
return buildDiffRows(baseline, currentPayload, t('configRoot'));
}, [baseline, currentPayload]);
const isDirty = useMemo(() => {
@@ -133,209 +78,33 @@ const Config: React.FC = () => {
return () => setConfigEditing(false);
}, [isDirty, setConfigEditing]);
function updateProxyField(name: string, field: string, value: any) {
setCfg((v) => setPath(v, `providers.proxies.${name}.${field}`, value));
}
function updateGatewayP2PField(field: string, value: any) {
setCfg((v) => setPath(v, `gateway.nodes.p2p.${field}`, value));
}
function updateGatewayDispatchField(field: string, value: any) {
setCfg((v) => setPath(v, `gateway.nodes.dispatch.${field}`, value));
}
function updateGatewayArtifactsField(field: string, value: any) {
setCfg((v) => setPath(v, `gateway.nodes.artifacts.${field}`, value));
}
function updateGatewayIceServer(index: number, field: string, value: any) {
setCfg((v) => {
const next = JSON.parse(JSON.stringify(v || {}));
if (!next.gateway || typeof next.gateway !== 'object') next.gateway = {};
if (!next.gateway.nodes || typeof next.gateway.nodes !== 'object') next.gateway.nodes = {};
if (!next.gateway.nodes.p2p || typeof next.gateway.nodes.p2p !== 'object') next.gateway.nodes.p2p = {};
if (!Array.isArray(next.gateway.nodes.p2p.ice_servers)) next.gateway.nodes.p2p.ice_servers = [];
if (!next.gateway.nodes.p2p.ice_servers[index] || typeof next.gateway.nodes.p2p.ice_servers[index] !== 'object') {
next.gateway.nodes.p2p.ice_servers[index] = { urls: [], username: '', credential: '' };
}
next.gateway.nodes.p2p.ice_servers[index][field] = value;
return next;
});
}
function addGatewayIceServer() {
setCfg((v) => {
const next = JSON.parse(JSON.stringify(v || {}));
if (!next.gateway || typeof next.gateway !== 'object') next.gateway = {};
if (!next.gateway.nodes || typeof next.gateway.nodes !== 'object') next.gateway.nodes = {};
if (!next.gateway.nodes.p2p || typeof next.gateway.nodes.p2p !== 'object') next.gateway.nodes.p2p = {};
if (!Array.isArray(next.gateway.nodes.p2p.ice_servers)) next.gateway.nodes.p2p.ice_servers = [];
next.gateway.nodes.p2p.ice_servers.push({ urls: [], username: '', credential: '' });
return next;
});
}
function removeGatewayIceServer(index: number) {
setCfg((v) => {
const next = JSON.parse(JSON.stringify(v || {}));
const iceServers = next?.gateway?.nodes?.p2p?.ice_servers;
if (Array.isArray(iceServers)) {
iceServers.splice(index, 1);
}
return next;
});
}
async function removeProxy(name: string) {
const ok = await ui.confirmDialog({
title: t('configDeleteProviderConfirmTitle'),
message: t('configDeleteProviderConfirmMessage', { name }),
danger: true,
confirmText: t('delete'),
});
if (!ok) return;
setCfg((v) => {
const next = JSON.parse(JSON.stringify(v || {}));
if (next?.providers?.proxies && typeof next.providers.proxies === 'object') {
delete next.providers.proxies[name];
}
return next;
});
}
function addProxy() {
const name = newProxyName.trim();
if (!name) return;
setCfg((v) => {
const next = JSON.parse(JSON.stringify(v || {}));
if (!next.providers || typeof next.providers !== 'object') next.providers = {};
if (!next.providers.proxies || typeof next.providers.proxies !== 'object' || Array.isArray(next.providers.proxies)) {
next.providers.proxies = {};
}
if (!next.providers.proxies[name]) {
next.providers.proxies[name] = {
api_key: '',
api_base: '',
models: [],
responses: {
web_search_enabled: false,
web_search_context_size: '',
file_search_vector_store_ids: [],
file_search_max_num_results: 0,
include: [],
stream_include_usage: false,
},
supports_responses_compact: false,
auth: 'bearer',
timeout_sec: 120,
};
}
return next;
});
setNewProxyName('');
}
async function saveConfig() {
try {
const payload = showRaw ? JSON.parse(cfgRaw) : cfg;
const submit = async (confirmRisky: boolean) => {
const body = confirmRisky ? { ...payload, confirm_risky: true } : payload;
return ui.withLoading(async () => {
const r = await fetch(`/webui/api/config${q}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const text = await r.text();
let data: any = null;
try {
data = text ? JSON.parse(text) : null;
} catch {
data = null;
}
return { ok: r.ok, text, data };
}, t('saving'));
};
let result = await submit(false);
if (!result.ok && result.data?.requires_confirm) {
const changedFields = Array.isArray(result.data?.changed_fields) ? result.data.changed_fields.join(', ') : '';
const ok = await ui.confirmDialog({
title: t('configRiskyChangeConfirmTitle'),
message: t('configRiskyChangeConfirmMessage', { fields: changedFields || '-' }),
danger: true,
confirmText: t('saveChanges'),
});
if (!ok) return;
result = await submit(true);
}
if (!result.ok) {
throw new Error(result.data?.error || result.text || 'save failed');
}
await ui.notify({ title: t('saved'), message: t('configSaved') });
setBaseline(JSON.parse(JSON.stringify(payload)));
setConfigEditing(false);
setShowDiff(false);
} catch (e) {
await ui.notify({ title: t('requestFailed'), message: `${t('saveConfigFailed')}: ${e}` });
}
}
return (
<div className="p-4 md:p-6 xl:p-8 w-full space-y-4 flex flex-col min-h-full">
<div className="flex items-center justify-between gap-3 flex-wrap">
<h1 className="ui-text-primary text-2xl font-semibold tracking-tight">{t('configuration')}</h1>
<div className="flex items-center gap-2 flex-wrap justify-end">
<div className="ui-toolbar-chip flex items-center gap-1 p-1 rounded-xl">
<Button onClick={() => setShowRaw(false)} variant={!showRaw ? 'primary' : 'neutral'} size="sm" radius="lg">{t('form')}</Button>
<Button onClick={() => setShowRaw(true)} variant={showRaw ? 'primary' : 'neutral'} size="sm" radius="lg">{t('rawJson')}</Button>
</div>
<Button onClick={saveConfig} variant="primary" gap="2">
<Save className="w-4 h-4" /> {t('saveChanges')}
</Button>
</div>
</div>
<ConfigHeader onSave={saveConfig} onShowForm={() => setShowRaw(false)} onShowRaw={() => setShowRaw(true)} showRaw={showRaw} t={t} />
<div className="flex items-center justify-between gap-3 flex-wrap">
<div className="flex items-center gap-2 flex-wrap">
<FixedButton
onClick={async () => { await loadConfig(true); setTimeout(() => setBaseline(JSON.parse(JSON.stringify(cfg))), 0); }}
label={t('reload')}
>
<RefreshCw className="w-4 h-4" />
</FixedButton>
<Button onClick={() => setShowDiff(true)} size="sm">{t('configDiffPreview')}</Button>
<Button onClick={() => setBasicMode(v => !v)} size="sm">
{basicMode ? t('configBasicMode') : t('configAdvancedMode')}
</Button>
<label className="ui-text-primary flex items-center gap-2 text-sm">
<Checkbox checked={hotOnly} onChange={(e) => setHotOnly(e.target.checked)} />
{t('configHotOnly')}
</label>
<Input value={search} onChange={(e) => setSearch(e.target.value)} placeholder={t('configSearchPlaceholder')} className="min-w-[240px] flex-1 px-3 py-2 rounded-xl text-sm" />
</div>
</div>
<ConfigToolbar
basicMode={basicMode}
hotOnly={hotOnly}
onHotOnlyChange={setHotOnly}
onReload={async () => { await loadConfig(true); setTimeout(() => setBaseline(JSON.parse(JSON.stringify(cfg))), 0); }}
onSearchChange={setSearch}
onShowDiff={() => setShowDiff(true)}
onToggleBasicMode={() => setBasicMode((value) => !value)}
search={search}
t={t}
/>
<div className="flex-1 brand-card ui-border-subtle border rounded-[30px] overflow-hidden flex flex-col shadow-sm min-h-[420px]">
{!showRaw ? (
<div className="flex-1 flex min-h-0">
<aside className="sidebar-section ui-border-subtle w-44 md:w-56 border-r p-2 md:p-3 overflow-y-auto shrink-0">
<div className="ui-text-secondary text-xs uppercase tracking-widest mb-2 px-2">{t('configTopLevel')}</div>
<div className="space-y-1">
{filteredTopKeys.map((k) => (
<button
key={k}
onClick={() => setSelectedTop(k)}
className={`w-full text-left px-3 py-2 rounded-xl text-sm transition-colors ${activeTop === k ? 'nav-item-active ui-text-primary' : 'ui-text-primary ui-row-hover'}`}
>
{k === hotReloadTabKey ? t('configHotFieldsFull') : (configLabels[k] || k)}
</button>
))}
</div>
</aside>
<ConfigSidebar
activeTop={activeTop}
configLabels={configLabels}
filteredTopKeys={filteredTopKeys}
hotReloadTabKey={hotReloadTabKey}
onSelectTop={setSelectedTop}
t={t}
/>
<div className="flex-1 p-4 md:p-6 overflow-y-auto space-y-4">
{activeTop === hotReloadTabKey && (
@@ -351,235 +120,32 @@ const Config: React.FC = () => {
</div>
</div>
)}
{activeTop === 'providers' && !showRaw && (
<div className="brand-card-subtle rounded-2xl border border-zinc-800 p-3 space-y-3">
<div className="flex items-center justify-between gap-2 flex-wrap">
<div className="text-sm font-semibold text-zinc-200">{t('configProxies')}</div>
<FormField
label={t('configNewProviderName')}
labelClassName="text-xs text-zinc-400"
className="flex-1 min-w-[180px] space-y-1"
>
<Input value={newProxyName} onChange={(e)=>setNewProxyName(e.target.value)} placeholder={t('configNewProviderName')} className="px-2 py-1 rounded-lg bg-zinc-900/70 border border-zinc-700 text-xs" />
</FormField>
<div className="flex items-end gap-2">
<FixedButton onClick={addProxy} variant="primary" label={t('add')}>
<Plus className="w-4 h-4" />
</FixedButton>
</div>
</div>
<div className="space-y-2">
{Object.entries(((cfg as any)?.providers?.proxies || {}) as Record<string, any>).map(([name, p]) => (
<div key={name} className="grid grid-cols-1 md:grid-cols-7 gap-2 rounded-xl border border-zinc-800 bg-zinc-900/30 p-2 text-xs">
<div className="md:col-span-1 font-mono text-zinc-300 flex items-center">{name}</div>
<FormField label={t('configLabels.api_base')} labelClassName="text-[11px] text-zinc-500" className="md:col-span-2 space-y-1">
<Input value={String(p?.api_base || '')} onChange={(e)=>updateProxyField(name, 'api_base', e.target.value)} placeholder={t('configLabels.api_base')} className="px-2 py-1 rounded-lg bg-zinc-950/70 border border-zinc-800" />
</FormField>
<FormField label={t('configLabels.api_key')} labelClassName="text-[11px] text-zinc-500" className="md:col-span-2 space-y-1">
<Input value={String(p?.api_key || '')} onChange={(e)=>updateProxyField(name, 'api_key', e.target.value)} placeholder={t('configLabels.api_key')} className="px-2 py-1 rounded-lg bg-zinc-950/70 border border-zinc-800" />
</FormField>
<FormField label={t('configLabels.models')} labelClassName="text-[11px] text-zinc-500" className="md:col-span-1 space-y-1">
<Input value={Array.isArray(p?.models) ? p.models.join(',') : ''} onChange={(e)=>updateProxyField(name, 'models', e.target.value.split(',').map(s=>s.trim()).filter(Boolean))} placeholder={`${t('configLabels.models')}${t('configCommaSeparatedHint')}`} className="px-2 py-1 rounded-lg bg-zinc-950/70 border border-zinc-800" />
</FormField>
<Button onClick={()=>removeProxy(name)} variant="danger" size="xs" radius="lg">{t('delete')}</Button>
</div>
))}
{Object.keys(((cfg as any)?.providers?.proxies || {}) as Record<string, any>).length === 0 && (
<div className="text-xs text-zinc-500">{t('configNoCustomProviders')}</div>
)}
</div>
</div>
)}
{activeTop === 'gateway' && !showRaw && (
<div className="brand-card-subtle rounded-2xl border border-zinc-800 p-3 space-y-3">
<div className="flex items-center justify-between gap-2 flex-wrap">
<div className="text-sm font-semibold text-zinc-200">{t('configNodeP2P')}</div>
<div className="text-xs text-zinc-500">{t('configNodeP2PHint')}</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-2 text-xs">
<FieldCard title={t('enable')}>
<Checkbox
checked={Boolean((cfg as any)?.gateway?.nodes?.p2p?.enabled)}
onChange={(e) => updateGatewayP2PField('enabled', e.target.checked)}
/>
</FieldCard>
<FieldCard title={t('dashboardNodeP2PTransport')}>
<Select
value={String((cfg as any)?.gateway?.nodes?.p2p?.transport || 'websocket_tunnel')}
onChange={(e) => updateGatewayP2PField('transport', e.target.value)}
className="w-full min-h-0 rounded-lg px-2 py-1 text-xs"
>
<option value="websocket_tunnel">websocket_tunnel</option>
<option value="webrtc">webrtc</option>
</Select>
</FieldCard>
<FieldCard title={t('dashboardNodeP2PIce')}>
<Input
value={Array.isArray((cfg as any)?.gateway?.nodes?.p2p?.stun_servers) ? (cfg as any).gateway.nodes.p2p.stun_servers.join(', ') : ''}
onChange={(e) => updateGatewayP2PField('stun_servers', e.target.value.split(',').map((s) => s.trim()).filter(Boolean))}
placeholder={t('configNodeP2PStunPlaceholder')}
className="w-full px-2 py-1 rounded-lg bg-zinc-950/70 border border-zinc-800"
/>
</FieldCard>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between gap-2 flex-wrap">
<div className="text-sm font-medium text-zinc-200">{t('configNodeP2PIceServers')}</div>
<FixedButton onClick={addGatewayIceServer} variant="primary" label={t('add')}>
<Plus className="w-4 h-4" />
</FixedButton>
</div>
{Array.isArray((cfg as any)?.gateway?.nodes?.p2p?.ice_servers) && (cfg as any).gateway.nodes.p2p.ice_servers.length > 0 ? (
((cfg as any).gateway.nodes.p2p.ice_servers as Array<any>).map((server, index) => (
<div key={`ice-${index}`} className="grid grid-cols-1 md:grid-cols-7 gap-2 rounded-xl border border-zinc-800 bg-zinc-900/30 p-2 text-xs">
<Input
value={Array.isArray(server?.urls) ? server.urls.join(', ') : ''}
onChange={(e) => updateGatewayIceServer(index, 'urls', e.target.value.split(',').map((s) => s.trim()).filter(Boolean))}
placeholder={t('configNodeP2PIceUrlsPlaceholder')}
className="md:col-span-3 px-2 py-1 rounded-lg bg-zinc-950/70 border border-zinc-800"
/>
<Input
value={String(server?.username || '')}
onChange={(e) => updateGatewayIceServer(index, 'username', e.target.value)}
placeholder={t('configNodeP2PIceUsername')}
className="md:col-span-1 px-2 py-1 rounded-lg bg-zinc-950/70 border border-zinc-800"
/>
<Input
value={String(server?.credential || '')}
onChange={(e) => updateGatewayIceServer(index, 'credential', e.target.value)}
placeholder={t('configNodeP2PIceCredential')}
className="md:col-span-2 px-2 py-1 rounded-lg bg-zinc-950/70 border border-zinc-800"
/>
<Button onClick={() => removeGatewayIceServer(index)} variant="danger" size="xs" radius="lg">{t('delete')}</Button>
</div>
))
) : (
<div className="text-xs text-zinc-500">{t('configNodeP2PIceServersEmpty')}</div>
)}
</div>
<div className="border-t border-zinc-800/70 pt-3 space-y-3">
<div className="flex items-center justify-between gap-2 flex-wrap">
<div className="text-sm font-semibold text-zinc-200">{t('configNodeDispatch')}</div>
<div className="text-xs text-zinc-500">{t('configNodeDispatchHint')}</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-2 text-xs">
<FieldCard title={t('configNodeDispatchPreferLocal')}>
<Checkbox
checked={Boolean((cfg as any)?.gateway?.nodes?.dispatch?.prefer_local)}
onChange={(e) => updateGatewayDispatchField('prefer_local', e.target.checked)}
/>
</FieldCard>
<FieldCard title={t('configNodeDispatchPreferP2P')}>
<Checkbox
checked={Boolean((cfg as any)?.gateway?.nodes?.dispatch?.prefer_p2p ?? true)}
onChange={(e) => updateGatewayDispatchField('prefer_p2p', e.target.checked)}
/>
</FieldCard>
<FieldCard title={t('configNodeDispatchAllowRelay')}>
<Checkbox
checked={Boolean((cfg as any)?.gateway?.nodes?.dispatch?.allow_relay_fallback ?? true)}
onChange={(e) => updateGatewayDispatchField('allow_relay_fallback', e.target.checked)}
/>
</FieldCard>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-xs">
<FieldCard title={t('configNodeDispatchActionTags')}>
<Textarea
value={formatTagRuleText((cfg as any)?.gateway?.nodes?.dispatch?.action_tags)}
onChange={(e) => updateGatewayDispatchField('action_tags', parseTagRuleText(e.target.value))}
placeholder={t('configNodeDispatchActionTagsPlaceholder')}
className="min-h-28 w-full rounded-xl bg-zinc-950/70 border border-zinc-800 px-3 py-2"
/>
</FieldCard>
<FieldCard title={t('configNodeDispatchAgentTags')}>
<Textarea
value={formatTagRuleText((cfg as any)?.gateway?.nodes?.dispatch?.agent_tags)}
onChange={(e) => updateGatewayDispatchField('agent_tags', parseTagRuleText(e.target.value))}
placeholder={t('configNodeDispatchAgentTagsPlaceholder')}
className="min-h-28 w-full rounded-xl bg-zinc-950/70 border border-zinc-800 px-3 py-2"
/>
</FieldCard>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-xs">
<FieldCard title={t('configNodeDispatchAllowActions')}>
<Textarea
value={formatTagRuleText((cfg as any)?.gateway?.nodes?.dispatch?.allow_actions)}
onChange={(e) => updateGatewayDispatchField('allow_actions', parseTagRuleText(e.target.value))}
placeholder={t('configNodeDispatchAllowActionsPlaceholder')}
className="min-h-28 w-full rounded-xl bg-zinc-950/70 border border-zinc-800 px-3 py-2"
/>
</FieldCard>
<FieldCard title={t('configNodeDispatchDenyActions')}>
<Textarea
value={formatTagRuleText((cfg as any)?.gateway?.nodes?.dispatch?.deny_actions)}
onChange={(e) => updateGatewayDispatchField('deny_actions', parseTagRuleText(e.target.value))}
placeholder={t('configNodeDispatchDenyActionsPlaceholder')}
className="min-h-28 w-full rounded-xl bg-zinc-950/70 border border-zinc-800 px-3 py-2"
/>
</FieldCard>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-xs">
<FieldCard title={t('configNodeDispatchAllowAgents')}>
<Textarea
value={formatTagRuleText((cfg as any)?.gateway?.nodes?.dispatch?.allow_agents)}
onChange={(e) => updateGatewayDispatchField('allow_agents', parseTagRuleText(e.target.value))}
placeholder={t('configNodeDispatchAllowAgentsPlaceholder')}
className="min-h-28 w-full rounded-xl bg-zinc-950/70 border border-zinc-800 px-3 py-2"
/>
</FieldCard>
<FieldCard title={t('configNodeDispatchDenyAgents')}>
<Textarea
value={formatTagRuleText((cfg as any)?.gateway?.nodes?.dispatch?.deny_agents)}
onChange={(e) => updateGatewayDispatchField('deny_agents', parseTagRuleText(e.target.value))}
placeholder={t('configNodeDispatchDenyAgentsPlaceholder')}
className="min-h-28 w-full rounded-xl bg-zinc-950/70 border border-zinc-800 px-3 py-2"
/>
</FieldCard>
</div>
</div>
<div className="border-t border-zinc-800/70 pt-3 space-y-3">
<div className="flex items-center justify-between gap-2 flex-wrap">
<div className="text-sm font-semibold text-zinc-200">{t('configNodeArtifacts')}</div>
<div className="text-xs text-zinc-500">{t('configNodeArtifactsHint')}</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-2 text-xs">
<FieldCard title={t('enable')}>
<Checkbox
checked={Boolean((cfg as any)?.gateway?.nodes?.artifacts?.enabled)}
onChange={(e) => updateGatewayArtifactsField('enabled', e.target.checked)}
/>
</FieldCard>
<FieldCard title={t('configNodeArtifactsKeepLatest')}>
<Input
type="number"
min={1}
value={Number((cfg as any)?.gateway?.nodes?.artifacts?.keep_latest || 500)}
onChange={(e) => updateGatewayArtifactsField('keep_latest', Math.max(1, Number.parseInt(e.target.value || '0', 10) || 1))}
className="w-full px-2 py-1 rounded-lg bg-zinc-950/70 border border-zinc-800"
/>
</FieldCard>
<FieldCard title={t('configNodeArtifactsPruneOnRead')}>
<Checkbox
checked={Boolean((cfg as any)?.gateway?.nodes?.artifacts?.prune_on_read ?? true)}
onChange={(e) => updateGatewayArtifactsField('prune_on_read', e.target.checked)}
/>
</FieldCard>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 text-xs">
<FieldCard title={t('configNodeArtifactsRetainDays')}>
<Input
type="number"
min={0}
value={Number((cfg as any)?.gateway?.nodes?.artifacts?.retain_days ?? 7)}
onChange={(e) => updateGatewayArtifactsField('retain_days', Math.max(0, Number.parseInt(e.target.value || '0', 10) || 0))}
className="w-full px-2 py-1 rounded-lg bg-zinc-950/70 border border-zinc-800"
/>
</FieldCard>
</div>
</div>
<GatewayP2PSection
addGatewayIceServer={addGatewayIceServer}
iceServers={Array.isArray((cfg as any)?.gateway?.nodes?.p2p?.ice_servers) ? (cfg as any).gateway.nodes.p2p.ice_servers : []}
onP2PFieldChange={updateGatewayP2PField}
onRemoveGatewayIceServer={removeGatewayIceServer}
onUpdateGatewayIceServer={updateGatewayIceServer}
p2p={(cfg as any)?.gateway?.nodes?.p2p}
t={t}
/>
<GatewayDispatchSection
dispatch={(cfg as any)?.gateway?.nodes?.dispatch}
formatTagRuleText={formatTagRuleText}
onDispatchFieldChange={updateGatewayDispatchField}
parseTagRuleText={parseTagRuleText}
t={t}
/>
<GatewayArtifactsSection
artifacts={(cfg as any)?.gateway?.nodes?.artifacts}
onArtifactsFieldChange={updateGatewayArtifactsField}
t={t}
/>
</div>
)}
{activeTop && activeTop !== hotReloadTabKey ? (
{activeTop && activeTop !== hotReloadTabKey && activeTop !== 'providers' && activeTop !== 'gateway' ? (
<RecursiveConfig
data={(cfg as any)?.[activeTop] || {}}
labels={configLabels}
@@ -589,50 +155,22 @@ const Config: React.FC = () => {
onChange={(path, val) => setCfg(v => setPath(v, path, val))}
/>
) : (
<div className="text-zinc-500 text-sm">{t('configNoGroups')}</div>
activeTop !== 'providers' && activeTop !== 'gateway' && <div className="text-zinc-500 text-sm">{t('configNoGroups')}</div>
)}
</div>
</div>
) : (
<Textarea
<TextareaField
value={cfgRaw}
onChange={(e) => setCfgRaw(e.target.value)}
className="flex-1 w-full bg-zinc-950/35 p-6 font-mono text-sm text-zinc-300 focus:outline-none resize-none"
monospace
className="flex-1 w-full bg-zinc-950/35 p-6 text-zinc-300 focus:outline-none resize-none border-0 rounded-none"
spellCheck={false}
/>
)}
</div>
{showDiff && (
<div className="ui-overlay-strong fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="w-full max-w-4xl max-h-[85vh] brand-card border border-zinc-800 rounded-[30px] overflow-hidden flex flex-col">
<div className="px-4 py-3 border-b border-zinc-800 flex items-center justify-between">
<div className="font-semibold">{t('configDiffPreviewCount', { count: diffRows.length })}</div>
<Button onClick={() => setShowDiff(false)} size="xs" radius="xl">{t('close')}</Button>
</div>
<div className="overflow-auto text-xs">
<table className="w-full">
<thead className="sticky top-0 bg-zinc-900 text-zinc-300">
<tr>
<th className="text-left p-2">{t('path')}</th>
<th className="text-left p-2">{t('before')}</th>
<th className="text-left p-2">{t('after')}</th>
</tr>
</thead>
<tbody>
{diffRows.map((r, i) => (
<tr key={i} className="border-t border-zinc-900 align-top">
<td className="p-2 font-mono text-zinc-400">{r.path}</td>
<td className="p-2 text-zinc-300 break-all">{JSON.stringify(r.before)}</td>
<td className="p-2 text-emerald-300 break-all">{JSON.stringify(r.after)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)}
{showDiff && <ConfigDiffModal diffRows={diffRows} onClose={() => setShowDiff(false)} t={t} />}
</div>
);
};

View File

@@ -5,11 +5,7 @@ import { useTranslation } from 'react-i18next';
import { useAppContext } from '../context/AppContext';
import { useUI } from '../context/UIContext';
import { Button, FixedButton } from '../components/Button';
import Checkbox from '../components/Checkbox';
import FormField from '../components/FormField';
import Input from '../components/Input';
import Select from '../components/Select';
import Textarea from '../components/Textarea';
import { CheckboxField, FieldBlock, SelectField, TextField, TextareaField } from '../components/FormControls';
import { CronJob } from '../types';
import { formatLocalDateTime } from '../utils/time';
@@ -270,37 +266,35 @@ const Cron: React.FC = () => {
<div className="p-6 space-y-4 max-h-[70vh] overflow-y-auto relative z-[1]">
<div className="grid grid-cols-2 gap-4">
<FormField label={t('jobName')} labelClassName="text-sm font-medium text-zinc-400">
<Input
<FieldBlock label={t('jobName')}>
<TextField
type="text"
value={cronForm.name}
onChange={(e) => setCronForm({ ...cronForm, name: e.target.value })}
className="rounded-xl px-3 py-2 text-sm"
/>
</FormField>
<FormField label={t('cronExpression')} labelClassName="text-sm font-medium text-zinc-400">
<Input
</FieldBlock>
<FieldBlock label={t('cronExpression')}>
<TextField
type="text"
value={cronForm.expr}
onChange={(e) => setCronForm({ ...cronForm, expr: e.target.value })}
placeholder={t('cronExpressionPlaceholder')}
className="rounded-xl px-3 py-2 text-sm"
/>
</FormField>
</FieldBlock>
</div>
<FormField label={t('message')} labelClassName="text-sm font-medium text-zinc-400">
<Textarea
<FieldBlock label={t('message')}>
<TextareaField
value={cronForm.message}
onChange={(e) => setCronForm({ ...cronForm, message: e.target.value })}
rows={3}
className="rounded-xl px-3 py-2 text-sm resize-none"
className="resize-none"
/>
</FormField>
</FieldBlock>
<div className="grid grid-cols-2 gap-4">
<FormField label={t('channel')} labelClassName="text-sm font-medium text-zinc-400">
<Select
<FieldBlock label={t('channel')}>
<SelectField
value={cronForm.channel}
onChange={(e) => {
const nextChannel = e.target.value;
@@ -308,50 +302,47 @@ const Cron: React.FC = () => {
const nextTo = candidates.includes(cronForm.to) ? cronForm.to : (candidates[0] || '');
setCronForm({ ...cronForm, channel: nextChannel, to: nextTo });
}}
className="rounded-xl px-3 py-2 text-sm"
>
{(enabledChannels.length > 0 ? enabledChannels : [cronForm.channel]).map((ch) => (
<option key={ch} value={ch}>{ch}</option>
))}
</Select>
</FormField>
<FormField label={t('to')} labelClassName="text-sm font-medium text-zinc-400">
</SelectField>
</FieldBlock>
<FieldBlock label={t('to')}>
{((channelRecipients[cronForm.channel] || []).length > 0) ? (
<Select
<SelectField
value={cronForm.to}
onChange={(e) => setCronForm({ ...cronForm, to: e.target.value })}
className="rounded-xl px-3 py-2 text-sm"
>
{(channelRecipients[cronForm.channel] || []).map((id) => (
<option key={id} value={id}>{id}</option>
))}
</Select>
</SelectField>
) : (
<Input
<TextField
type="text"
value={cronForm.to}
onChange={(e) => setCronForm({ ...cronForm, to: e.target.value })}
placeholder={t('recipientId')}
className="rounded-xl px-3 py-2 text-sm"
/>
)}
</FormField>
</FieldBlock>
</div>
<div className="flex items-center gap-6 pt-2">
<label className="flex items-center gap-3 cursor-pointer group">
<Checkbox
<CheckboxField
checked={cronForm.deliver}
onChange={(e) => setCronForm({ ...cronForm, deliver: e.target.checked })}
className="w-4 h-4"
className="w-4 h-4 rounded border-zinc-700 text-indigo-500 focus:ring-indigo-500 focus:ring-offset-zinc-900 bg-zinc-950"
/>
<span className="text-sm font-medium text-zinc-400 group-hover:text-zinc-200 transition-colors">{t('deliver')}</span>
</label>
<label className="flex items-center gap-3 cursor-pointer group">
<Checkbox
<CheckboxField
checked={cronForm.enabled}
onChange={(e) => setCronForm({ ...cronForm, enabled: e.target.checked })}
className="w-4 h-4"
className="w-4 h-4 rounded border-zinc-700 text-indigo-500 focus:ring-indigo-500 focus:ring-offset-zinc-900 bg-zinc-950"
/>
<span className="text-sm font-medium text-zinc-400 group-hover:text-zinc-200 transition-colors">{t('active')}</span>
</label>

View File

@@ -3,7 +3,7 @@ import { AlertTriangle, RefreshCw, Route, ServerCrash, Workflow } from 'lucide-r
import { useTranslation } from 'react-i18next';
import { useAppContext } from '../context/AppContext';
import { FixedButton } from '../components/Button';
import Select from '../components/Select';
import { SelectField } from '../components/FormControls';
type EKGKV = { key?: string; score?: number; count?: number };
@@ -161,11 +161,11 @@ const EKG: React.FC = () => {
<div className="ui-text-muted mt-1 text-sm">{t('ekgOverviewHint')}</div>
</div>
<div className="flex items-center gap-2">
<Select value={ekgWindow} onChange={(e) => setEkgWindow(e.target.value as '6h' | '24h' | '7d')} className="h-12 min-w-[96px] rounded-xl px-3 text-sm">
<SelectField value={ekgWindow} onChange={(e) => setEkgWindow(e.target.value as '6h' | '24h' | '7d')} className="h-12 min-w-[96px]">
<option value="6h">6h</option>
<option value="24h">24h</option>
<option value="7d">7d</option>
</Select>
</SelectField>
<FixedButton
onClick={fetchData}
variant="primary"

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAppContext } from '../context/AppContext';
import Input from '../components/Input';
import { TextField } from '../components/FormControls';
type CodeItem = {
code: number;
@@ -44,11 +44,11 @@ const LogCodes: React.FC = () => {
<div className="p-4 md:p-6 xl:p-8 w-full space-y-6">
<div className="flex items-center justify-between gap-3 flex-wrap">
<h1 className="ui-text-secondary text-2xl font-semibold tracking-tight">{t('logCodes')}</h1>
<Input
<TextField
value={kw}
onChange={(e) => setKw(e.target.value)}
placeholder={t('logCodesSearchPlaceholder')}
className="w-full sm:w-80 rounded-xl px-3 py-2 text-sm"
className="w-full sm:w-80"
/>
</div>

View File

@@ -5,10 +5,7 @@ import { useTranslation } from 'react-i18next';
import { useAppContext } from '../context/AppContext';
import { useUI } from '../context/UIContext';
import { Button, FixedButton } from '../components/Button';
import Checkbox from '../components/Checkbox';
import FormField from '../components/FormField';
import Input from '../components/Input';
import Select from '../components/Select';
import { CheckboxField, SelectField, TextField } from '../components/FormControls';
type MCPDraftServer = {
enabled: boolean;
@@ -478,40 +475,46 @@ const MCP: React.FC = () => {
<div className="max-h-[80vh] overflow-y-auto px-6 py-5 space-y-5">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField label={t('configNewMCPServerName')} labelClassName="text-xs text-zinc-400" className="space-y-2">
<Input value={draftName} onChange={(e) => setDraftName(e.target.value)} className="h-11 px-3" />
</FormField>
<FormField label={t('configLabels.description')} labelClassName="text-xs text-zinc-400" className="space-y-2">
<Input value={draft.description} onChange={(e) => updateDraftField('description', e.target.value)} className="h-11 px-3" />
</FormField>
<label className="space-y-2">
<div className="text-xs text-zinc-400">{t('configNewMCPServerName')}</div>
<TextField value={draftName} onChange={(e) => setDraftName(e.target.value)} className="h-11" />
</label>
<label className="space-y-2">
<div className="text-xs text-zinc-400">{t('configLabels.description')}</div>
<TextField value={draft.description} onChange={(e) => updateDraftField('description', e.target.value)} className="h-11" />
</label>
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<FormField label="transport" labelClassName="text-xs text-zinc-400" className="space-y-2">
<Select value={draft.transport} onChange={(e) => updateDraftField('transport', e.target.value)} className="h-11 px-3">
<label className="space-y-2">
<div className="text-xs text-zinc-400">transport</div>
<SelectField value={draft.transport} onChange={(e) => updateDraftField('transport', e.target.value)} className="h-11">
<option value="stdio">stdio</option>
<option value="http">http</option>
<option value="streamable_http">streamable_http</option>
<option value="sse">sse</option>
</Select>
</FormField>
<FormField label="enabled" labelClassName="text-xs text-zinc-400" className="space-y-2">
</SelectField>
</label>
<label className="space-y-2">
<div className="text-xs text-zinc-400">enabled</div>
<div className="ui-toggle-card flex h-11 items-center rounded-xl px-3">
<Checkbox checked={draft.enabled} onChange={(e) => updateDraftField('enabled', e.target.checked)} />
<CheckboxField checked={draft.enabled} onChange={(e) => updateDraftField('enabled', e.target.checked)} />
</div>
</FormField>
</label>
{draft.transport === 'stdio' && (
<FormField label="permission" labelClassName="text-xs text-zinc-400" className="space-y-2">
<Select value={draft.permission} onChange={(e) => updateDraftField('permission', e.target.value)} className="h-11 px-3">
<label className="space-y-2">
<div className="text-xs text-zinc-400">permission</div>
<SelectField value={draft.permission} onChange={(e) => updateDraftField('permission', e.target.value)} className="h-11">
<option value="workspace">workspace</option>
<option value="full">full</option>
</Select>
</FormField>
</SelectField>
</label>
)}
{draft.transport === 'stdio' && (
<FormField label={t('configLabels.package')} labelClassName="text-xs text-zinc-400" className="space-y-2">
<Input value={draft.package} onChange={(e) => updateDraftField('package', e.target.value)} className="h-11 px-3" />
</FormField>
<label className="space-y-2">
<div className="text-xs text-zinc-400">{t('configLabels.package')}</div>
<TextField value={draft.package} onChange={(e) => updateDraftField('package', e.target.value)} className="h-11" />
</label>
)}
</div>
@@ -519,17 +522,17 @@ const MCP: React.FC = () => {
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<label className="space-y-2">
<div className="text-xs text-zinc-400">{t('configLabels.command')}</div>
<Input value={draft.command} onChange={(e) => updateDraftField('command', e.target.value)} className="h-11 px-3" />
<TextField value={draft.command} onChange={(e) => updateDraftField('command', e.target.value)} className="h-11" />
</label>
<label className="space-y-2">
<div className="text-xs text-zinc-400">{t('configLabels.working_dir')}</div>
<Input value={draft.working_dir} onChange={(e) => updateDraftField('working_dir', e.target.value)} className="h-11 px-3" />
<TextField value={draft.working_dir} onChange={(e) => updateDraftField('working_dir', e.target.value)} className="h-11" />
</label>
</div>
) : (
<label className="space-y-2">
<div className="text-xs text-zinc-400">{t('configLabels.url')}</div>
<Input value={draft.url} onChange={(e) => updateDraftField('url', e.target.value)} className="h-11 px-3" />
<TextField value={draft.url} onChange={(e) => updateDraftField('url', e.target.value)} className="h-11" />
</label>
)}
@@ -553,7 +556,7 @@ const MCP: React.FC = () => {
</span>
))}
</div>
<Input
<TextField
value={draftArgInput}
onChange={(e) => setDraftArgInput(e.target.value)}
onKeyDown={(e) => {
@@ -564,7 +567,7 @@ const MCP: React.FC = () => {
}}
onBlur={() => addDraftArg(draftArgInput)}
placeholder={t('configMCPArgsEnterHint')}
className="h-11 px-3"
className="h-11"
/>
</div>
)}

View File

@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
import { useAppContext } from '../context/AppContext';
import { useUI } from '../context/UIContext';
import { Button, FixedButton } from '../components/Button';
import Textarea from '../components/Textarea';
import { TextareaField } from '../components/FormControls';
const Memory: React.FC = () => {
const { t } = useTranslation();
@@ -147,7 +147,7 @@ const Memory: React.FC = () => {
<h2 className="ui-text-primary font-semibold">{active || t('noFileSelected')}</h2>
<Button onClick={saveFile} variant="primary" size="sm" radius="xl">{t('save')}</Button>
</div>
<Textarea value={content} onChange={(e) => setContent(e.target.value)} className="w-full h-[50vh] lg:h-[80vh] rounded-[24px] p-4" />
<TextareaField value={content} onChange={(e) => setContent(e.target.value)} className="w-full h-[50vh] lg:h-[80vh] rounded-[24px] p-4" />
</div>
</main>
</div>

View File

@@ -4,8 +4,7 @@ import { useTranslation } from 'react-i18next';
import { useSearchParams } from 'react-router-dom';
import { useAppContext } from '../context/AppContext';
import { Button, FixedButton, LinkButton } from '../components/Button';
import Input from '../components/Input';
import Select from '../components/Select';
import { SelectField, TextField } from '../components/FormControls';
import { formatLocalDateTime } from '../utils/time';
function dataUrlForArtifact(artifact: any) {
@@ -163,26 +162,26 @@ const NodeArtifacts: React.FC = () => {
<div>{t('nodeArtifactsRetentionRemaining')}: {Number(retentionSummary?.remaining || filteredItems.length || 0)}</div>
</div>
<div className="grid grid-cols-1 gap-2">
<Select value={nodeFilter} onChange={(e) => setNodeFilter(e.target.value)} className="rounded-xl px-2 py-2 text-xs">
<SelectField dense value={nodeFilter} onChange={(e) => setNodeFilter(e.target.value)}>
<option value="all">{t('allNodes')}</option>
{nodes.map((node) => <option key={node} value={node}>{node}</option>)}
</Select>
<Select value={actionFilter} onChange={(e) => setActionFilter(e.target.value)} className="rounded-xl px-2 py-2 text-xs">
</SelectField>
<SelectField dense value={actionFilter} onChange={(e) => setActionFilter(e.target.value)}>
<option value="all">{t('allActions')}</option>
{actions.map((action) => <option key={action} value={action}>{action}</option>)}
</Select>
<Select value={kindFilter} onChange={(e) => setKindFilter(e.target.value)} className="rounded-xl px-2 py-2 text-xs">
</SelectField>
<SelectField dense value={kindFilter} onChange={(e) => setKindFilter(e.target.value)}>
<option value="all">{t('allKinds')}</option>
{kinds.map((kind) => <option key={kind} value={kind}>{kind}</option>)}
</Select>
</SelectField>
</div>
<div className="grid grid-cols-[1fr_auto] gap-2">
<Input
<TextField
value={keepLatest}
onChange={(e) => setKeepLatest(e.target.value)}
inputMode="numeric"
placeholder={t('nodeArtifactsKeepLatest')}
className="rounded-xl px-3 py-2 text-xs"
dense
/>
<Button onClick={pruneArtifacts} disabled={prunePending} variant="warning" size="xs_tall">
{prunePending ? t('loading') : t('nodeArtifactsPrune')}

View File

@@ -5,9 +5,7 @@ import { Check, RefreshCw } from 'lucide-react';
import { useAppContext } from '../context/AppContext';
import { formatLocalDateTime } from '../utils/time';
import { Button, FixedButton, LinkButton } from '../components/Button';
import Input from '../components/Input';
import Select from '../components/Select';
import Textarea from '../components/Textarea';
import { SelectField, TextField, TextareaField } from '../components/FormControls';
function dataUrlForArtifact(artifact: any) {
const mime = String(artifact?.mime_type || '').trim() || 'application/octet-stream';
@@ -237,11 +235,10 @@ const Nodes: React.FC = () => {
<div className="grid grid-cols-1 xl:grid-cols-[300px_1fr_1.1fr] gap-4 flex-1 min-h-0">
<div className="brand-card ui-panel rounded-[28px] overflow-hidden flex flex-col min-h-0">
<div className="px-3 py-2 border-b border-zinc-800 dark:border-zinc-700 space-y-2">
<Input
<TextField
value={nodeFilter}
onChange={(e) => setNodeFilter(e.target.value)}
placeholder={t('nodesFilterPlaceholder')}
className="rounded-xl px-3 py-2 text-sm"
/>
</div>
<div className="overflow-y-auto min-h-0">
@@ -401,19 +398,19 @@ const Nodes: React.FC = () => {
<div className="px-3 py-2 border-b border-zinc-800 dark:border-zinc-700 space-y-2">
<div className="text-xs text-zinc-400 uppercase tracking-wider">{t('nodeDispatchDetail')}</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-2">
<Select value={dispatchActionFilter} onChange={(e) => setDispatchActionFilter(e.target.value)} className="rounded-xl px-2 py-2 text-xs">
<SelectField dense value={dispatchActionFilter} onChange={(e) => setDispatchActionFilter(e.target.value)}>
<option value="all">{t('allActions')}</option>
{dispatchActions.map((action) => <option key={action} value={action}>{action}</option>)}
</Select>
<Select value={dispatchTransportFilter} onChange={(e) => setDispatchTransportFilter(e.target.value)} className="rounded-xl px-2 py-2 text-xs">
</SelectField>
<SelectField dense value={dispatchTransportFilter} onChange={(e) => setDispatchTransportFilter(e.target.value)}>
<option value="all">{t('allTransports')}</option>
{dispatchTransports.map((transport) => <option key={transport} value={transport}>{transport}</option>)}
</Select>
<Select value={dispatchStatusFilter} onChange={(e) => setDispatchStatusFilter(e.target.value)} className="rounded-xl px-2 py-2 text-xs">
</SelectField>
<SelectField dense value={dispatchStatusFilter} onChange={(e) => setDispatchStatusFilter(e.target.value)}>
<option value="all">{t('allStatus')}</option>
<option value="ok">ok</option>
<option value="error">error</option>
</Select>
</SelectField>
</div>
</div>
<div className="grid grid-rows-[220px_1fr] min-h-0 flex-1">
@@ -479,24 +476,24 @@ const Nodes: React.FC = () => {
<div className="grid grid-cols-3 gap-2">
<label className="space-y-1">
<div className="text-zinc-500 text-[11px]">{t('mode')}</div>
<Select value={replayModeDraft} onChange={(e) => setReplayModeDraft(e.target.value)} className="w-full rounded-xl px-2 py-2 text-xs">
<SelectField dense value={replayModeDraft} onChange={(e) => setReplayModeDraft(e.target.value)} className="w-full">
<option value="auto">auto</option>
<option value="p2p">p2p</option>
<option value="relay">relay</option>
</Select>
</SelectField>
</label>
<label className="space-y-1 col-span-2">
<div className="text-zinc-500 text-[11px]">{t('model')}</div>
<Input value={replayModelDraft} onChange={(e) => setReplayModelDraft(e.target.value)} className="w-full rounded-xl px-3 py-2 text-xs" />
<TextField dense value={replayModelDraft} onChange={(e) => setReplayModelDraft(e.target.value)} className="w-full" />
</label>
</div>
<label className="space-y-1 block">
<div className="text-zinc-500 text-[11px]">{t('task')}</div>
<Textarea value={replayTaskDraft} onChange={(e) => setReplayTaskDraft(e.target.value)} className="min-h-24 w-full p-3 text-xs" />
<TextareaField dense value={replayTaskDraft} onChange={(e) => setReplayTaskDraft(e.target.value)} className="min-h-24 w-full p-3" />
</label>
<label className="space-y-1 block">
<div className="text-zinc-500 text-[11px]">{t('args')}</div>
<Textarea value={replayArgsDraft} onChange={(e) => setReplayArgsDraft(e.target.value)} className="min-h-40 w-full p-3 text-xs font-mono" />
<TextareaField dense monospace value={replayArgsDraft} onChange={(e) => setReplayArgsDraft(e.target.value)} className="min-h-40 w-full p-3" />
</label>
</div>
</div>

View File

@@ -0,0 +1,263 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { RefreshCw, Save } from 'lucide-react';
import { useAppContext } from '../context/AppContext';
import { useUI } from '../context/UIContext';
import { Button, FixedButton } from '../components/Button';
import { ConfigDiffModal } from '../components/config/ConfigPageChrome';
import { ProviderProxyCard, ProviderRuntimeDrawer, ProviderRuntimeSummary, ProviderRuntimeToolbar } from '../components/config/ProviderConfigSection';
import { buildDiffRows, RuntimeWindow } from '../components/config/configUtils';
import { useConfigProviderActions } from '../components/config/useConfigProviderActions';
import { useConfigRuntimeView } from '../components/config/useConfigRuntimeView';
import { useConfigSaveAction } from '../components/config/useConfigSaveAction';
const Providers: React.FC = () => {
const { t } = useTranslation();
const ui = useUI();
const { cfg, setCfg, cfgRaw, loadConfig, q, setConfigEditing, providerRuntimeItems } = useAppContext();
const [newProxyName, setNewProxyName] = useState('');
const [runtimeAutoRefresh, setRuntimeAutoRefresh] = useState(true);
const [runtimeRefreshSec, setRuntimeRefreshSec] = useState(10);
const [runtimeWindow, setRuntimeWindow] = useState<RuntimeWindow>('24h');
const [runtimeDrawerProvider, setRuntimeDrawerProvider] = useState('');
const [selectedProviderTab, setSelectedProviderTab] = useState('');
const [showDiff, setShowDiff] = useState(false);
const [baseline, setBaseline] = useState<any>(null);
const oauthImportInputRef = useRef<HTMLInputElement | null>(null);
const latestProviderRuntimeRef = useRef<any[]>([]);
const [displayedProviderRuntimeItems, setDisplayedProviderRuntimeItems] = useState<any[]>([]);
const [oauthAccounts, setOAuthAccounts] = useState<Record<string, Array<any>>>({});
const providerEntries = useMemo(() => {
const providers = ((cfg as any)?.providers || {}) as Record<string, any>;
const entries: Array<[string, any]> = [];
if (providers?.proxy && typeof providers.proxy === 'object') {
entries.push(['proxy', providers.proxy]);
}
const custom = providers?.proxies;
if (custom && typeof custom === 'object' && !Array.isArray(custom)) {
Object.entries(custom).forEach(([name, value]) => entries.push([name, value]));
}
return entries;
}, [cfg]);
const providerRuntimeMap = useMemo(() => {
const entries = Array.isArray(displayedProviderRuntimeItems) ? displayedProviderRuntimeItems : [];
return Object.fromEntries(entries.map((item: any) => [String(item?.name || ''), item]));
}, [displayedProviderRuntimeItems]);
const activeProviderName = useMemo(() => {
if (providerEntries.length === 0) return '';
if (providerEntries.some(([name]) => name === selectedProviderTab)) return selectedProviderTab;
return providerEntries[0]?.[0] || '';
}, [providerEntries, selectedProviderTab]);
const activeProviderEntry = useMemo(
() => providerEntries.find(([name]) => name === activeProviderName) || null,
[providerEntries, activeProviderName],
);
useEffect(() => {
latestProviderRuntimeRef.current = Array.isArray(providerRuntimeItems) ? providerRuntimeItems : [];
if (runtimeAutoRefresh && runtimeRefreshSec <= 1) {
setDisplayedProviderRuntimeItems(latestProviderRuntimeRef.current);
}
}, [providerRuntimeItems, runtimeAutoRefresh, runtimeRefreshSec]);
useEffect(() => {
if (!runtimeAutoRefresh) return;
setDisplayedProviderRuntimeItems(latestProviderRuntimeRef.current);
const timer = window.setInterval(() => {
setDisplayedProviderRuntimeItems(latestProviderRuntimeRef.current);
}, Math.max(1, runtimeRefreshSec) * 1000);
return () => window.clearInterval(timer);
}, [runtimeAutoRefresh, runtimeRefreshSec]);
useEffect(() => {
if (!activeProviderName && providerEntries.length > 0) {
setSelectedProviderTab(providerEntries[0][0]);
}
}, [activeProviderName, providerEntries]);
useEffect(() => {
providerEntries.forEach(([name, p]) => {
if (['oauth', 'hybrid'].includes(String(p?.auth || ''))) {
loadOAuthAccounts(name);
}
});
}, [providerEntries]);
useEffect(() => {
if (baseline == null && cfg && Object.keys(cfg).length > 0) {
setBaseline(JSON.parse(JSON.stringify(cfg)));
}
}, [cfg, baseline]);
const diffRows = useMemo(() => buildDiffRows(baseline, cfg, t('configRoot')), [baseline, cfg, t]);
const isDirty = useMemo(() => {
if (baseline == null) return false;
return JSON.stringify(baseline) !== JSON.stringify(cfg || {});
}, [baseline, cfg]);
useEffect(() => {
setConfigEditing(isDirty);
return () => setConfigEditing(false);
}, [isDirty, setConfigEditing]);
const { filterRuntimeEvents, renderRuntimeEventList, runtimeSectionOpen, toggleRuntimeSection } = useConfigRuntimeView(runtimeWindow);
const {
addProxy,
clearAPIKeyCooldown,
clearOAuthCooldown,
clearProviderHistory,
deleteOAuthAccount,
exportProviderHistory,
loadOAuthAccounts,
onOAuthImportChange,
refreshOAuthAccount,
refreshProviderRuntimeNow,
removeProxy,
startOAuthLogin,
triggerOAuthImport,
updateProxyField,
} = useConfigProviderActions({
inputRef: oauthImportInputRef,
loadConfig,
onProviderRuntimeRefreshed: () => setDisplayedProviderRuntimeItems(latestProviderRuntimeRef.current),
providerRuntimeMap,
q,
setCfg,
setNewProxyName,
setOAuthAccounts,
t,
ui,
});
const { saveConfig } = useConfigSaveAction({
cfg,
cfgRaw,
q,
setBaseline,
setConfigEditing,
setShowDiff,
showRaw: false,
t,
ui,
});
return (
<div className="p-4 md:p-6 xl:p-8 w-full space-y-4 flex flex-col min-h-full">
<input ref={oauthImportInputRef} type="file" accept=".json,application/json" className="hidden" onChange={onOAuthImportChange} />
<div className="flex items-center justify-between gap-3 flex-wrap">
<h1 className="ui-text-primary text-2xl font-semibold tracking-tight">{t('providers')}</h1>
<div className="flex items-center gap-2 flex-wrap justify-end">
<FixedButton onClick={async () => { await loadConfig(true); setTimeout(() => setBaseline(JSON.parse(JSON.stringify(cfg))), 0); }} label={t('reload')}>
<RefreshCw className="w-4 h-4" />
</FixedButton>
<Button onClick={() => setShowDiff(true)} size="sm">{t('configDiffPreview')}</Button>
<Button onClick={saveConfig} variant="primary" gap="2">
<Save className="w-4 h-4" /> {t('saveChanges')}
</Button>
</div>
</div>
<div className="brand-card ui-border-subtle border rounded-[30px] p-4 md:p-6 space-y-4">
<ProviderRuntimeToolbar
newProxyName={newProxyName}
onAddProxy={() => addProxy(newProxyName)}
onNewProxyNameChange={setNewProxyName}
onRefreshRuntime={refreshProviderRuntimeNow}
onRuntimeAutoRefreshChange={setRuntimeAutoRefresh}
onRuntimeRefreshSecChange={setRuntimeRefreshSec}
onRuntimeWindowChange={setRuntimeWindow}
runtimeAutoRefresh={runtimeAutoRefresh}
runtimeRefreshSec={runtimeRefreshSec}
runtimeWindow={runtimeWindow}
t={t}
/>
<div className="rounded-xl border border-zinc-800 bg-zinc-950/40 px-3 py-2 text-xs text-zinc-400">
{t('providersIntroBefore')}
<span className="font-mono text-zinc-200">oauth</span>
{t('providersIntroMiddle')}
<span className="font-mono text-zinc-200">hybrid</span>
{t('providersIntroAfter')}
</div>
{providerEntries.length > 0 ? (
<>
<div className="flex flex-wrap gap-2">
{providerEntries.map(([name, p]) => {
const auth = String(p?.auth || 'bearer');
const active = name === activeProviderName;
return (
<Button
key={`provider-tab-${name}`}
onClick={() => setSelectedProviderTab(name)}
variant={active ? 'primary' : 'neutral'}
size="sm"
radius="xl"
>
{name}
<span className="ml-1 text-[11px] opacity-80">{auth}</span>
</Button>
);
})}
</div>
{activeProviderEntry && (
<ProviderProxyCard
key={activeProviderEntry[0]}
name={activeProviderEntry[0]}
oauthAccounts={oauthAccounts[activeProviderEntry[0]] || []}
onClearOAuthCooldown={(credentialFile) => clearOAuthCooldown(activeProviderEntry[0], credentialFile)}
onDeleteOAuthAccount={(credentialFile) => deleteOAuthAccount(activeProviderEntry[0], credentialFile)}
onFieldChange={(field, value) => updateProxyField(activeProviderEntry[0], field, value)}
onLoadOAuthAccounts={() => loadOAuthAccounts(activeProviderEntry[0])}
onRefreshOAuthAccount={(credentialFile) => refreshOAuthAccount(activeProviderEntry[0], credentialFile)}
onRemove={() => removeProxy(activeProviderEntry[0])}
onStartOAuthLogin={() => startOAuthLogin(activeProviderEntry[0], activeProviderEntry[1])}
onTriggerOAuthImport={() => triggerOAuthImport(activeProviderEntry[0], activeProviderEntry[1])}
proxy={activeProviderEntry[1]}
runtimeSummary={providerRuntimeMap[activeProviderEntry[0]] ? (
<ProviderRuntimeSummary
item={providerRuntimeMap[activeProviderEntry[0]]}
name={activeProviderEntry[0]}
onClearApiCooldown={() => clearAPIKeyCooldown(activeProviderEntry[0])}
onClearHistory={() => clearProviderHistory(activeProviderEntry[0])}
onExportHistory={() => exportProviderHistory(activeProviderEntry[0])}
onOpenHistory={() => setRuntimeDrawerProvider(activeProviderEntry[0])}
renderRuntimeEventList={renderRuntimeEventList}
runtimeSectionOpen={(section) => runtimeSectionOpen(activeProviderEntry[0], section)}
toggleRuntimeSection={(section) => toggleRuntimeSection(activeProviderEntry[0], section)}
filterRuntimeEvents={filterRuntimeEvents}
/>
) : null}
t={t}
/>
)}
</>
) : (
<div className="text-xs text-zinc-500">{t('configNoCustomProviders')}</div>
)}
</div>
{runtimeDrawerProvider && providerRuntimeMap[runtimeDrawerProvider] && (
<ProviderRuntimeDrawer
filterRuntimeEvents={filterRuntimeEvents}
item={providerRuntimeMap[runtimeDrawerProvider]}
name={runtimeDrawerProvider}
onClearHistory={() => clearProviderHistory(runtimeDrawerProvider)}
onClose={() => setRuntimeDrawerProvider('')}
onExportHistory={() => exportProviderHistory(runtimeDrawerProvider)}
renderRuntimeEventList={renderRuntimeEventList}
/>
)}
{showDiff && <ConfigDiffModal diffRows={diffRows} onClose={() => setShowDiff(false)} t={t} />}
</div>
);
};
export default Providers;

View File

@@ -5,9 +5,7 @@ import { useTranslation } from 'react-i18next';
import { useAppContext } from '../context/AppContext';
import { useUI } from '../context/UIContext';
import { Button, FixedButton } from '../components/Button';
import Checkbox from '../components/Checkbox';
import Input from '../components/Input';
import Textarea from '../components/Textarea';
import { CheckboxField, TextField, TextareaField } from '../components/FormControls';
const Skills: React.FC = () => {
const { t } = useTranslation();
@@ -193,10 +191,10 @@ const Skills: React.FC = () => {
<div className="flex items-start justify-between gap-3 flex-wrap">
<h1 className="text-2xl font-semibold tracking-tight">{t('skills')}</h1>
<div className="flex items-center gap-2 flex-wrap w-full xl:w-auto">
<Input disabled={installingSkill} value={installName} onChange={(e) => setInstallName(e.target.value)} placeholder={t('skillsNamePlaceholder')} className="w-full sm:w-72 px-3 py-2 bg-zinc-950/70 border border-zinc-800 rounded-xl text-sm disabled:opacity-60" />
<TextField disabled={installingSkill} value={installName} onChange={(e) => setInstallName(e.target.value)} placeholder={t('skillsNamePlaceholder')} className="w-full sm:w-72 disabled:opacity-60" />
<Button disabled={installingSkill} onClick={installSkill} variant="success">{installingSkill ? t('loading') : t('install')}</Button>
<label className="flex items-center gap-2 text-xs text-zinc-400">
<Checkbox
<CheckboxField
checked={ignoreSuspicious}
disabled={installingSkill}
onChange={(e) => setIgnoreSuspicious(e.target.checked)}
@@ -300,7 +298,12 @@ const Skills: React.FC = () => {
</FixedButton>
</div>
</div>
<Textarea value={fileContent} onChange={(e)=>setFileContent(e.target.value)} className="flex-1 bg-zinc-950 text-zinc-200 font-mono text-sm p-4 resize-none outline-none" />
<TextareaField
value={fileContent}
onChange={(e)=>setFileContent(e.target.value)}
monospace
className="flex-1 rounded-none border-0 bg-zinc-950 text-zinc-200 p-4 resize-none outline-none"
/>
</main>
</motion.div>
</div>

View File

@@ -4,10 +4,7 @@ import { Check, Plus, RefreshCw } from 'lucide-react';
import { useAppContext } from '../context/AppContext';
import { useUI } from '../context/UIContext';
import { Button, FixedButton } from '../components/Button';
import FormField from '../components/FormField';
import Input from '../components/Input';
import Select from '../components/Select';
import Textarea from '../components/Textarea';
import { FieldBlock, SelectField, TextField, TextareaField } from '../components/FormControls';
type SubagentProfile = {
agent_id: string;
@@ -321,75 +318,83 @@ const SubagentProfiles: React.FC = () => {
<div className="brand-card ui-border-subtle rounded-[28px] border p-4 space-y-3">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<FormField label={t('id')} labelClassName="ui-text-subtle text-xs" className="space-y-1">
<Input
<FieldBlock label={t('id')}>
<TextField
value={draft.agent_id || ''}
disabled={!!selected}
onChange={(e) => setDraft({ ...draft, agent_id: e.target.value })}
className="w-full px-2 py-1.5 text-xs rounded-xl disabled:opacity-60"
dense
className="w-full disabled:opacity-60"
placeholder="coder"
/>
</FormField>
<FormField label={t('name')} labelClassName="ui-text-subtle text-xs" className="space-y-1">
<Input
</FieldBlock>
<FieldBlock label={t('name')}>
<TextField
value={draft.name || ''}
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
className="w-full px-2 py-1 text-xs rounded"
dense
className="w-full"
placeholder="Code Agent"
/>
</FormField>
<FormField label="Role" labelClassName="ui-text-subtle text-xs" className="space-y-1">
<Input
</FieldBlock>
<FieldBlock label="Role">
<TextField
value={draft.role || ''}
onChange={(e) => setDraft({ ...draft, role: e.target.value })}
className="w-full px-2 py-1 text-xs rounded"
dense
className="w-full"
placeholder="coding"
/>
</FormField>
<FormField label={t('status')} labelClassName="ui-text-subtle text-xs" className="space-y-1">
<Select
</FieldBlock>
<FieldBlock label={t('status')}>
<SelectField
value={draft.status || 'active'}
onChange={(e) => setDraft({ ...draft, status: e.target.value })}
className="w-full px-2 py-1 text-xs rounded"
dense
className="w-full"
>
<option value="active">active</option>
<option value="disabled">disabled</option>
</Select>
</FormField>
<FormField label="notify_main_policy" labelClassName="ui-text-subtle text-xs" className="space-y-1">
<Select
</SelectField>
</FieldBlock>
<FieldBlock label="notify_main_policy">
<SelectField
value={draft.notify_main_policy || 'final_only'}
onChange={(e) => setDraft({ ...draft, notify_main_policy: e.target.value })}
className="w-full px-2 py-1 text-xs rounded"
dense
className="w-full"
>
<option value="final_only">final_only</option>
<option value="internal_only">internal_only</option>
<option value="milestone">milestone</option>
<option value="on_blocked">on_blocked</option>
<option value="always">always</option>
</Select>
</FormField>
<FormField label="system_prompt_file" labelClassName="ui-text-subtle text-xs" className="space-y-1 md:col-span-2">
<Input
</SelectField>
</FieldBlock>
<FieldBlock className="md:col-span-2" label="system_prompt_file">
<TextField
value={draft.system_prompt_file || ''}
onChange={(e) => setDraft({ ...draft, system_prompt_file: e.target.value })}
className="w-full px-2 py-1 text-xs rounded"
dense
className="w-full"
placeholder="agents/coder/AGENT.md"
/>
</FormField>
<FormField label={t('memoryNamespace')} labelClassName="ui-text-subtle text-xs" className="space-y-1 md:col-span-2">
<Input
</FieldBlock>
<FieldBlock className="md:col-span-2" label={t('memoryNamespace')}>
<TextField
value={draft.memory_namespace || ''}
onChange={(e) => setDraft({ ...draft, memory_namespace: e.target.value })}
className="w-full px-2 py-1 text-xs rounded"
dense
className="w-full"
placeholder="coder"
/>
</FormField>
<FormField label={t('toolAllowlist')} labelClassName="ui-text-subtle text-xs" className="space-y-1 md:col-span-2">
<Input
</FieldBlock>
<FieldBlock className="md:col-span-2" label={t('toolAllowlist')}>
<TextField
value={allowlistText}
onChange={(e) => setDraft({ ...draft, tool_allowlist: parseAllowlist(e.target.value) })}
className="w-full px-2 py-1 text-xs rounded"
dense
className="w-full"
placeholder="read_file, list_files, memory_search"
/>
<div className="ui-text-muted mt-1 text-[11px]">
@@ -404,16 +409,13 @@ const SubagentProfiles: React.FC = () => {
))}
</div>
)}
</FormField>
<div className="md:col-span-2">
<div className="flex items-center justify-between mb-1 gap-3">
<div className="ui-text-subtle text-xs">system_prompt_file content</div>
<div className="ui-text-muted text-[11px]">{promptFileFound ? t('promptFileReady') : t('promptFileMissing')}</div>
</div>
<Textarea
</FieldBlock>
<FieldBlock className="md:col-span-2" label="system_prompt_file content" meta={promptFileFound ? t('promptFileReady') : t('promptFileMissing')}>
<TextareaField
value={promptFileContent}
onChange={(e) => setPromptFileContent(e.target.value)}
className="w-full px-2 py-1 text-xs rounded min-h-[220px]"
dense
className="w-full min-h-[220px]"
placeholder={t('agentPromptContentPlaceholder')}
/>
<div className="mt-2 flex items-center gap-2">
@@ -421,43 +423,47 @@ const SubagentProfiles: React.FC = () => {
{t('savePromptFile')}
</Button>
</div>
</div>
<FormField label={t('maxRetries')} labelClassName="ui-text-subtle text-xs" className="space-y-1">
<Input
</FieldBlock>
<FieldBlock label={t('maxRetries')}>
<TextField
type="number"
min={0}
value={Number(draft.max_retries || 0)}
onChange={(e) => setDraft({ ...draft, max_retries: Number(e.target.value) || 0 })}
className="w-full px-2 py-1 text-xs rounded"
dense
className="w-full"
/>
</FormField>
<FormField label={t('retryBackoffMs')} labelClassName="ui-text-subtle text-xs" className="space-y-1">
<Input
</FieldBlock>
<FieldBlock label={t('retryBackoffMs')}>
<TextField
type="number"
min={0}
value={Number(draft.retry_backoff_ms || 0)}
onChange={(e) => setDraft({ ...draft, retry_backoff_ms: Number(e.target.value) || 0 })}
className="w-full px-2 py-1 text-xs rounded"
dense
className="w-full"
/>
</FormField>
<FormField label="Max Task Chars" labelClassName="ui-text-subtle text-xs" className="space-y-1">
<Input
</FieldBlock>
<FieldBlock label="Max Task Chars">
<TextField
type="number"
min={0}
value={Number(draft.max_task_chars || 0)}
onChange={(e) => setDraft({ ...draft, max_task_chars: Number(e.target.value) || 0 })}
className="w-full px-2 py-1 text-xs rounded"
dense
className="w-full"
/>
</FormField>
<FormField label="Max Result Chars" labelClassName="ui-text-subtle text-xs" className="space-y-1 md:col-span-2">
<Input
</FieldBlock>
<FieldBlock className="md:col-span-2" label="Max Result Chars">
<TextField
type="number"
min={0}
value={Number(draft.max_result_chars || 0)}
onChange={(e) => setDraft({ ...draft, max_result_chars: Number(e.target.value) || 0 })}
className="w-full px-2 py-1 text-xs rounded"
dense
className="w-full"
/>
</FormField>
</FieldBlock>
</div>
<div className="flex items-center gap-2">

View File

@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import { Check, RefreshCw } from 'lucide-react';
import { useAppContext } from '../context/AppContext';
import { FixedButton } from '../components/Button';
import Select from '../components/Select';
import { SelectField } from '../components/FormControls';
import { formatLocalDateTime } from '../utils/time';
type TaskAuditItem = {
@@ -114,14 +114,14 @@ const TaskAudit: React.FC = () => {
<div className="flex items-center justify-between flex-wrap gap-3">
<h1 className="text-xl md:text-2xl font-semibold">{t('taskAudit')}</h1>
<div className="flex items-center gap-2">
<Select value={sourceFilter} onChange={(e)=>setSourceFilter(e.target.value)} className="rounded-xl px-2 py-1.5 text-xs">
<SelectField dense value={sourceFilter} onChange={(e)=>setSourceFilter(e.target.value)}>
<option value="all">{t('allSources')}</option>
<option value="direct">{t('sourceDirect')}</option>
<option value="memory_todo">{t('sourceMemoryTodo')}</option>
<option value="task_watchdog">task_watchdog</option>
<option value="-">-</option>
</Select>
<Select value={statusFilter} onChange={(e)=>setStatusFilter(e.target.value)} className="rounded-xl px-2 py-1.5 text-xs">
</SelectField>
<SelectField dense value={statusFilter} onChange={(e)=>setStatusFilter(e.target.value)}>
<option value="all">{t('allStatus')}</option>
<option value="running">{t('statusRunning')}</option>
<option value="waiting">{t('statusWaiting')}</option>
@@ -129,7 +129,7 @@ const TaskAudit: React.FC = () => {
<option value="success">{t('statusSuccess')}</option>
<option value="error">{t('statusError')}</option>
<option value="suppressed">{t('statusSuppressed')}</option>
</Select>
</SelectField>
<FixedButton onClick={fetchData} variant="primary" label={loading ? t('loading') : t('refresh')}>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
</FixedButton>