mirror of
https://github.com/YspCoder/clawgo.git
synced 2026-06-23 18:20:34 +08:00
Add OAuth provider runtime and providers UI
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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>
|
||||
|
||||
263
webui/src/pages/Providers.tsx
Normal file
263
webui/src/pages/Providers.tsx
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user