feat: refine webui config workflows

This commit is contained in:
LPF
2026-03-11 23:12:11 +08:00
parent 045927f6d1
commit 5e0c371bb9
12 changed files with 474 additions and 181 deletions

View File

@@ -119,19 +119,47 @@ const ChannelSettings: React.FC = () => {
(nextCfg as any).channels = {};
}
(nextCfg as any).channels[definition.id] = cloneJSON(draft);
const res = await fetch(`/webui/api/config${q}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(nextCfg),
});
if (!res.ok) {
throw new Error(await res.text());
const submit = async (confirmRisky: boolean) => {
const body = confirmRisky ? { ...nextCfg, confirm_risky: true } : nextCfg;
return ui.withLoading(async () => {
const res = await fetch(`/webui/api/config${q}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const text = await res.text();
let data: any = null;
try {
data = text ? JSON.parse(text) : null;
} catch {
data = null;
}
return { ok: res.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');
}
setCfg(nextCfg);
await loadConfig(true);
await ui.notify(t('configSaved'));
await ui.notify({ title: t('saved'), message: t('configSaved') });
} catch (err: any) {
await ui.notify(String(err?.message || err || 'save failed'));
await ui.notify({ title: t('requestFailed'), message: `${t('saveConfigFailed')}: ${String(err?.message || err || 'save failed')}` });
} finally {
setSaving(false);
}

View File

@@ -226,6 +226,7 @@ const Providers: React.FC = () => {
onStartOAuthLogin={() => startOAuthLogin(activeProviderEntry[0], activeProviderEntry[1])}
onTriggerOAuthImport={() => triggerOAuthImport(activeProviderEntry[0], activeProviderEntry[1])}
proxy={activeProviderEntry[1]}
runtimeItem={providerRuntimeMap[activeProviderEntry[0]]}
runtimeSummary={providerRuntimeMap[activeProviderEntry[0]] ? (
<ProviderRuntimeSummary
item={providerRuntimeMap[activeProviderEntry[0]]}

View File

@@ -214,12 +214,24 @@ const Skills: React.FC = () => {
}
/>
<ToolbarRow className="w-full">
<TextField disabled={installingSkill} value={installName} onChange={(e) => setInstallName(e.target.value)} placeholder={t('skillsNamePlaceholder')} className="w-full sm:w-72 disabled:opacity-60" />
<FixedButton disabled={installingSkill} onClick={installSkill} variant="success" label={installingSkill ? t('loading') : t('install')}>
<Zap className="w-4 h-4" />
</FixedButton>
<label className="flex items-center gap-2 text-xs text-zinc-400">
<ToolbarRow className="w-full flex-nowrap items-center">
<TextField
disabled={installingSkill}
value={installName}
onChange={(e) => setInstallName(e.target.value)}
placeholder={t('skillsNamePlaceholder')}
className="min-w-0 flex-1 disabled:opacity-60"
/>
<Button
disabled={installingSkill}
onClick={installSkill}
variant="success"
size="md"
noShrink
>
{installingSkill ? t('loading') : t('install')}
</Button>
<label className="flex shrink-0 items-center gap-2 whitespace-nowrap text-xs text-zinc-400">
<CheckboxField
checked={ignoreSuspicious}
disabled={installingSkill}

View File

@@ -9,6 +9,26 @@ import ProfileEditorPanel from '../components/subagentProfiles/ProfileEditorPane
import ProfileListPanel from '../components/subagentProfiles/ProfileListPanel';
import { emptyDraft, toProfileDraft, type SubagentProfile, type ToolAllowlistGroup } from '../components/subagentProfiles/profileDraft';
function validatePromptFilePath(pathValue: string): string | null {
const path = String(pathValue || '').trim();
if (!path) return null;
const normalized = path.replace(/\\/g, '/');
if (/^[a-zA-Z]:\//.test(normalized) || normalized.startsWith('/')) {
return 'promptFileRelativePathOnly';
}
const segments = normalized.split('/').filter(Boolean);
if (segments.some((segment) => segment === '..')) {
return 'promptFileWorkspaceOnly';
}
return null;
}
const promptPathMessages: Record<string, string> = {
promptFileRelativePathHint: 'Use a workspace-relative path such as agents/coder/AGENT.md.',
promptFileRelativePathOnly: 'system_prompt_file must be a workspace-relative path, not an absolute path.',
promptFileWorkspaceOnly: 'system_prompt_file must stay within the workspace.',
};
const SubagentProfiles: React.FC = () => {
const { t } = useTranslation();
const { q } = useAppContext();
@@ -21,6 +41,11 @@ const SubagentProfiles: React.FC = () => {
const [groups, setGroups] = useState<ToolAllowlistGroup[]>([]);
const [promptFileContent, setPromptFileContent] = useState('');
const [promptFileFound, setPromptFileFound] = useState(false);
const promptPathErrorKey = validatePromptFilePath(String(draft.system_prompt_file || ''));
const promptPathHint = t('promptFileRelativePathHint', { defaultValue: promptPathMessages.promptFileRelativePathHint });
const promptPathError = promptPathErrorKey
? t(promptPathErrorKey, { defaultValue: promptPathMessages[promptPathErrorKey] || promptPathMessages.promptFileWorkspaceOnly })
: '';
const selected = useMemo(
() => items.find((p) => p.agent_id === selectedId) || null,
@@ -66,6 +91,11 @@ const SubagentProfiles: React.FC = () => {
setPromptFileFound(false);
return;
}
if (promptPathErrorKey) {
setPromptFileFound(false);
setPromptFileContent('');
return;
}
fetch(`/webui/api/subagents_runtime${q}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -84,7 +114,7 @@ const SubagentProfiles: React.FC = () => {
setPromptFileFound(false);
setPromptFileContent('');
});
}, [draft.system_prompt_file, q]);
}, [draft.system_prompt_file, promptPathErrorKey, q]);
const onSelect = (p: SubagentProfile) => {
setSelectedId(p.agent_id || '');
@@ -110,6 +140,10 @@ const SubagentProfiles: React.FC = () => {
await ui.notify({ title: t('requestFailed'), message: 'agent_id is required' });
return;
}
if (promptPathErrorKey) {
await ui.notify({ title: t('requestFailed'), message: promptPathError });
return;
}
setSaving(true);
try {
@@ -144,22 +178,6 @@ const SubagentProfiles: React.FC = () => {
}
};
const setStatus = async (status: 'active' | 'disabled') => {
const agentId = String(draft.agent_id || '').trim();
if (!agentId) return;
const action = status === 'active' ? 'enable' : 'disable';
const r = await fetch(`/webui/api/subagent_profiles${q}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action, agent_id: agentId }),
});
if (!r.ok) {
await ui.notify({ title: t('requestFailed'), message: await r.text() });
return;
}
await load();
};
const remove = async () => {
const agentId = String(draft.agent_id || '').trim();
if (!agentId) return;
@@ -185,6 +203,10 @@ const SubagentProfiles: React.FC = () => {
await ui.notify({ title: t('requestFailed'), message: 'system_prompt_file is required' });
return;
}
if (promptPathErrorKey) {
await ui.notify({ title: t('requestFailed'), message: promptPathError });
return;
}
const r = await fetch(`/webui/api/subagents_runtime${q}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -233,14 +255,14 @@ const SubagentProfiles: React.FC = () => {
onAddAllowlistToken={addAllowlistToken}
onChange={setDraft}
onDelete={remove}
onDisable={() => setStatus('disabled')}
onEnable={() => setStatus('active')}
onPromptContentChange={setPromptFileContent}
onSave={save}
onSavePromptFile={savePromptFile}
promptContent={promptFileContent}
promptMeta={promptFileFound ? t('promptFileReady') : t('promptFileMissing')}
promptMeta={promptPathErrorKey ? promptPathError : (promptFileFound ? t('promptFileReady') : t('promptFileMissing'))}
promptPlaceholder={t('agentPromptContentPlaceholder')}
promptPathHint={promptPathHint}
promptPathInvalid={!!promptPathErrorKey}
roleLabel="Role"
saving={saving}
statusLabel={t('status')}

View File

@@ -111,26 +111,26 @@ const TaskAudit: React.FC = () => {
title={t('taskAudit')}
titleClassName="text-xl md:text-2xl font-semibold"
actions={(
<ToolbarRow>
<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>
</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>
<option value="blocked">{t('statusBlocked')}</option>
<option value="success">{t('statusSuccess')}</option>
<option value="error">{t('statusError')}</option>
<option value="suppressed">{t('statusSuppressed')}</option>
</SelectField>
<FixedButton onClick={fetchData} variant="primary" label={loading ? t('loading') : t('refresh')}>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
</FixedButton>
<ToolbarRow className="flex-nowrap">
<SelectField dense className="w-[152px] shrink-0" 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>
</SelectField>
<SelectField dense className="w-[152px] shrink-0" 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>
<option value="blocked">{t('statusBlocked')}</option>
<option value="success">{t('statusSuccess')}</option>
<option value="error">{t('statusError')}</option>
<option value="suppressed">{t('statusSuppressed')}</option>
</SelectField>
<FixedButton onClick={fetchData} variant="primary" label={loading ? t('loading') : t('refresh')}>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
</FixedButton>
</ToolbarRow>
)}
/>