461 lines
20 KiB
TypeScript
461 lines
20 KiB
TypeScript
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useQueryClient } from '@tanstack/react-query';
|
|
import { useConfig } from '../../hooks/useConfig';
|
|
import { useUnsavedGuard } from '../../lib/unsavedGuard';
|
|
import { updateConfig } from '../../api';
|
|
import { LlmWorkersForm } from './LlmWorkersForm';
|
|
import { WorkspaceForm } from './WorkspaceForm';
|
|
import { PathsStorageForm } from './PathsStorageForm';
|
|
import { ExecutionForm } from './ExecutionForm';
|
|
import { ToolsForm } from './ToolsForm';
|
|
import { ToolsWebForm } from './ToolsWebForm';
|
|
import { ToolsMediaForm } from './ToolsMediaForm';
|
|
import { ToolsExternalForm } from './ToolsExternalForm';
|
|
import { AskSubtasksForm } from './AskSubtasksForm';
|
|
import { SearchFilterForm } from './SearchFilterForm';
|
|
import { BrowserSettingsForm } from './BrowserSettingsForm';
|
|
import { ContextForm } from './ContextForm';
|
|
import { SafetyForm } from './SafetyForm';
|
|
import { PreferencesForm } from './PreferencesForm';
|
|
import { NotificationsForm } from './NotificationsForm';
|
|
import { BrandingForm } from './BrandingForm';
|
|
import { MemoryLearningForm } from './MemoryLearningForm';
|
|
import { MetricsForm } from './MetricsForm';
|
|
import { ServerTlsForm } from './ServerTlsForm';
|
|
import { ReflectionForm } from './ReflectionForm';
|
|
import { SkillsQuotaForm } from './SkillsQuotaForm';
|
|
import { McpForm } from './McpForm';
|
|
import { SshForm } from './SshForm';
|
|
import { GatewayServerForm } from './GatewayServerForm';
|
|
import { PushNotificationsForm } from './PushNotificationsForm';
|
|
import { AuthForm } from './AuthForm';
|
|
import { OrgsForm } from './OrgsForm';
|
|
import { PetsForm } from './PetsForm';
|
|
import { A2aDelegationsForm } from './A2aDelegationsForm';
|
|
import { ChatConnectorsForm } from './ChatConnectorsForm';
|
|
import { useToast } from '../../hooks/useToast';
|
|
import { settingsFieldId, type ConfigDraftStatus } from './types';
|
|
|
|
import { useAuthState } from '../../App';
|
|
|
|
interface ConfigFormProps {
|
|
section: string;
|
|
isAdmin: boolean;
|
|
focusFieldKey?: string;
|
|
focusFieldRequestId?: number;
|
|
onDraftStatusChange?: (status: ConfigDraftStatus) => void;
|
|
}
|
|
|
|
function PreferencesFormWrapper() {
|
|
const auth = useAuthState();
|
|
if (auth.mode !== 'authenticated') {
|
|
return <div className="text-sm text-slate-500">Log in to manage preferences.</div>;
|
|
}
|
|
return (
|
|
<PreferencesForm
|
|
user={{
|
|
defaultVisibility: auth.user.defaultVisibility ?? 'private',
|
|
defaultVisibilityOrgId: auth.user.defaultVisibilityOrgId ?? null,
|
|
hasLocalCredential: auth.user.hasLocalCredential,
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Pets is a per-user concept (chat mascot + worker/backend assignment), so it
|
|
* uses its own /api/users/me/pets endpoints — not the admin config draft. It
|
|
* renders stand-alone (no global save bar) with a local toast for import/save
|
|
* feedback, mirroring the other user-scoped sections.
|
|
*/
|
|
function PetsFormWrapper() {
|
|
const auth = useAuthState();
|
|
const { toast, showToast } = useToast();
|
|
// Pets work for the logged-in user OR the synthetic local user when auth is
|
|
// disabled (mode 'disabled'). Only block when auth is ON but nobody is logged
|
|
// in. ('loading' falls through to the panel, which shows its own spinner.)
|
|
if (auth.mode === 'unauthenticated') {
|
|
return <div className="text-sm text-slate-500">Log in to manage pets.</div>;
|
|
}
|
|
return (
|
|
<div className="relative h-full">
|
|
<PetsForm showToast={showToast} />
|
|
{toast && (
|
|
<div
|
|
className={`fixed bottom-6 right-6 z-50 rounded-md px-4 py-2 text-sm text-white shadow-lg ${
|
|
toast.variant === 'error' ? 'bg-red-600' : 'bg-slate-800'
|
|
}`}
|
|
>
|
|
{toast.message}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Set a value at a dot-separated path in an object (immutable). */
|
|
function setNestedValue(obj: any, path: string, value: any): any {
|
|
const keys = path.split('.');
|
|
if (keys.length === 1) {
|
|
return { ...obj, [keys[0]]: value };
|
|
}
|
|
const [first, ...rest] = keys;
|
|
return { ...obj, [first]: setNestedValue(obj[first] ?? {}, rest.join('.'), value) };
|
|
}
|
|
|
|
/** Count leaf-level differences between two values. Arrays are compared as a single leaf. */
|
|
function countDiff(a: any, b: any): number {
|
|
if (a === b) return 0;
|
|
if (Array.isArray(a) || Array.isArray(b)) {
|
|
return JSON.stringify(a) === JSON.stringify(b) ? 0 : 1;
|
|
}
|
|
if (a && b && typeof a === 'object' && typeof b === 'object') {
|
|
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
|
|
let total = 0;
|
|
for (const k of keys) total += countDiff(a[k], b[k]);
|
|
return total;
|
|
}
|
|
// Treat undefined/null/empty-string as equivalent to reduce noise from optional fields.
|
|
const norm = (v: any) => (v === undefined || v === null || v === '' ? null : v);
|
|
return norm(a) === norm(b) ? 0 : 1;
|
|
}
|
|
|
|
export function ConfigForm({ section, isAdmin, onDraftStatusChange, focusFieldKey, focusFieldRequestId }: ConfigFormProps) {
|
|
const { t } = useTranslation('settings');
|
|
const usesAdminDraft = isAdmin && !['preferences', 'notifications', 'memory-learning', 'pets', 'a2a-delegations', 'chat-connectors', 'organizations'].includes(section);
|
|
|
|
useEffect(() => {
|
|
if (!usesAdminDraft) onDraftStatusChange?.({ dirtySectionIds: new Set() });
|
|
}, [onDraftStatusChange, usesAdminDraft]);
|
|
|
|
// User-scoped sections use their own per-user APIs and should not load the
|
|
// admin /api/config draft. Render them stand-alone without the global save bar.
|
|
if (section === 'preferences') {
|
|
return <div className="max-w-2xl"><PreferencesFormWrapper /></div>;
|
|
}
|
|
if (section === 'notifications') {
|
|
return <div className="max-w-2xl"><NotificationsForm /></div>;
|
|
}
|
|
if (section === 'memory-learning') {
|
|
return <div className="max-w-2xl"><MemoryLearningForm /></div>;
|
|
}
|
|
if (section === 'pets') {
|
|
return <PetsFormWrapper />;
|
|
}
|
|
if (section === 'a2a-delegations') {
|
|
return <div className="max-w-2xl"><A2aDelegationsForm /></div>;
|
|
}
|
|
if (!isAdmin) {
|
|
return <div className="max-w-2xl text-sm text-slate-500">{t('configForm.adminOnly')}</div>;
|
|
}
|
|
// Local organizations: admin-managed via /api/admin/orgs (not config.yaml),
|
|
// so render stand-alone without the global save bar.
|
|
if (section === 'organizations') {
|
|
return <OrgsForm />;
|
|
}
|
|
// Chat connectors: admin-managed via /api/admin/chat/bindings (not
|
|
// config.yaml), so render stand-alone without the global save bar —
|
|
// mirrors a2a-delegations/organizations above.
|
|
if (section === 'chat-connectors') {
|
|
return <div className="max-w-2xl"><ChatConnectorsForm /></div>;
|
|
}
|
|
// Step 8: 'gateway-keys' bookmarks are redirected to 'gateway-server'
|
|
// by SettingsPage via LEGACY_SECTION_REDIRECT, so we no longer need a
|
|
// dedicated branch here. The keys UI lives inside GatewayServerForm
|
|
// as the Virtual Keys section.
|
|
return <ConfigFormInner section={section} isAdmin={isAdmin} onDraftStatusChange={onDraftStatusChange} focusFieldKey={focusFieldKey} focusFieldRequestId={focusFieldRequestId} />;
|
|
}
|
|
|
|
function ConfigFormInner({ section, onDraftStatusChange, focusFieldKey, focusFieldRequestId }: ConfigFormProps) {
|
|
const { t } = useTranslation('settings');
|
|
const { data, isLoading, error, refetch } = useConfig();
|
|
const queryClient = useQueryClient();
|
|
|
|
const [draft, setDraft] = useState<any>(null);
|
|
const [etag, setEtag] = useState('');
|
|
const [overriddenByEnv, setOverriddenByEnv] = useState<Record<string, boolean>>({});
|
|
const [isDirty, setIsDirty] = useState(false);
|
|
const [saving, setSaving] = useState(false);
|
|
const [toast, setToast] = useState<string | null>(null);
|
|
const [toastIsError, setToastIsError] = useState(false);
|
|
// Field keys currently flagged invalid via SectionFormProps.onValidityChange.
|
|
// While non-empty, Save & Apply is disabled: an invalid draft never reaches
|
|
// the config draft (the field withholds onChange), so allowing a save from
|
|
// another dirty field would silently discard the invalid field's edit while
|
|
// looking saved. Reporting components clear their key on unmount (see the
|
|
// contract on SectionFormProps), so section switches / row removals cannot
|
|
// leave Save bricked by a stale key.
|
|
const [invalidKeys, setInvalidKeys] = useState<ReadonlySet<string>>(new Set());
|
|
const [dirtySectionIds, setDirtySectionIds] = useState<ReadonlySet<string>>(new Set());
|
|
const completedFocusRequestRef = useRef<number | undefined>();
|
|
// Bumped whenever the draft is replaced wholesale out from under any
|
|
// in-progress local field state (Discard Changes, or a fresh `data`
|
|
// load/refetch below). Section forms with local "in-progress draft"
|
|
// state that doesn't purely derive from the `value` prop (see
|
|
// ExtraBodyField in LlmWorkersForm) fold this into their row keys to
|
|
// force a remount — see SectionFormProps.resetToken for the contract.
|
|
const [resetToken, setResetToken] = useState(0);
|
|
|
|
// Sync fetched config into draft. This effect only re-runs when the
|
|
// `data` object identity changes — i.e. the initial load and any
|
|
// subsequent refetch (e.g. after a save-conflict reload) — never on
|
|
// every render, so bumping resetToken here only fires on a genuine
|
|
// external replacement of the draft, matching the Discard Changes case
|
|
// below. Also clears invalidKeys: any field-local invalid draft that
|
|
// hasn't reached this fresh `data` (and therefore never reached the
|
|
// server) is being discarded along with the rest of the draft, so a
|
|
// phantom invalid key must not survive to permanently block Save.
|
|
useEffect(() => {
|
|
if (data) {
|
|
setDraft(data.config);
|
|
setEtag(data.etag);
|
|
setOverriddenByEnv(data.overriddenByEnv);
|
|
setIsDirty(false);
|
|
setInvalidKeys(new Set());
|
|
setDirtySectionIds(new Set());
|
|
setResetToken(t => t + 1);
|
|
}
|
|
}, [data]);
|
|
|
|
const handleChange = useCallback((path: string, value: any) => {
|
|
setDraft((prev: any) => setNestedValue(prev, path, value));
|
|
setIsDirty(true);
|
|
setDirtySectionIds(prev => prev.has(section) ? prev : new Set(prev).add(section));
|
|
}, [section]);
|
|
|
|
const handleValidityChange = useCallback((fieldKey: string, valid: boolean) => {
|
|
setInvalidKeys(prev => {
|
|
if (valid ? !prev.has(fieldKey) : prev.has(fieldKey)) return prev; // no-op → keep identity
|
|
const next = new Set(prev);
|
|
if (valid) next.delete(fieldKey);
|
|
else next.add(fieldKey);
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const handleNavigateToInvalid = () => {
|
|
const fieldKey = invalidKeys.values().next().value;
|
|
if (!fieldKey) return;
|
|
|
|
const field = document.getElementById(settingsFieldId(fieldKey));
|
|
if (!(field instanceof HTMLElement)) return;
|
|
|
|
const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
|
field.scrollIntoView({ block: 'center', behavior: reduceMotion ? 'auto' : 'smooth' });
|
|
field.focus({ preventScroll: true });
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!focusFieldKey) return;
|
|
if (completedFocusRequestRef.current === focusFieldRequestId) return;
|
|
const field = document.getElementById(settingsFieldId(focusFieldKey));
|
|
if (!(field instanceof HTMLElement)) return;
|
|
completedFocusRequestRef.current = focusFieldRequestId;
|
|
const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
|
field.scrollIntoView({ block: 'center', behavior: reduceMotion ? 'auto' : 'smooth' });
|
|
field.focus({ preventScroll: true });
|
|
field.classList.add('ring-2', 'ring-amber-400');
|
|
const timer = window.setTimeout(() => field.classList.remove('ring-2', 'ring-amber-400'), 1600);
|
|
return () => window.clearTimeout(timer);
|
|
}, [focusFieldKey, focusFieldRequestId, draft, section]);
|
|
|
|
const handleDiscard = () => {
|
|
if (data) {
|
|
setDraft(data.config);
|
|
setIsDirty(false);
|
|
// Discard doesn't change `data`, so the [data] sync effect above
|
|
// won't fire — clear any phantom invalid key and bump resetToken
|
|
// here directly so field-local draft state (e.g. ExtraBodyField's
|
|
// in-progress textarea + JSON error) remounts from the reverted
|
|
// value instead of getting stuck showing a stale error forever.
|
|
setInvalidKeys(new Set());
|
|
setDirtySectionIds(new Set());
|
|
setResetToken(t => t + 1);
|
|
}
|
|
};
|
|
|
|
const handleSave = async () => {
|
|
if (!draft) return;
|
|
setSaving(true);
|
|
try {
|
|
const result = await updateConfig(draft, etag);
|
|
if (result.conflict) {
|
|
if (confirm(t('configForm.conflict'))) {
|
|
await refetch();
|
|
}
|
|
return;
|
|
}
|
|
await queryClient.invalidateQueries({ queryKey: ['config'] });
|
|
setIsDirty(false);
|
|
setDirtySectionIds(new Set());
|
|
setToastIsError(false);
|
|
setToast(t('configForm.saved'));
|
|
setTimeout(() => setToast(null), 2000);
|
|
} catch (e: any) {
|
|
setToastIsError(true);
|
|
setToast(t('configForm.error', { msg: e.message }));
|
|
setTimeout(() => setToast(null), 3000);
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const dirtyCount = isDirty && data ? countDiff(data.config, draft) : 0;
|
|
|
|
useEffect(() => {
|
|
if (dirtyCount === 0 && dirtySectionIds.size > 0) setDirtySectionIds(new Set());
|
|
}, [dirtyCount, dirtySectionIds]);
|
|
|
|
useEffect(() => {
|
|
onDraftStatusChange?.({ dirtySectionIds: dirtyCount > 0 ? dirtySectionIds : new Set() });
|
|
}, [dirtyCount, dirtySectionIds, onDraftStatusChange]);
|
|
|
|
// Arm beforeunload + register an in-app guard so navigating elsewhere
|
|
// (e.g. clicking a TopBar tab) prompts when there are unsaved fields.
|
|
useUnsavedGuard(dirtyCount > 0);
|
|
|
|
if (isLoading) return <div className="text-sm text-slate-400">Loading...</div>;
|
|
if (error) return <div className="text-sm text-red-500">{t('configForm.loadError')}</div>;
|
|
if (!draft) return null;
|
|
|
|
const formProps = { config: draft, onChange: handleChange, overriddenByEnv, onValidityChange: handleValidityChange, resetToken };
|
|
|
|
const sectionForm = (() => {
|
|
switch (section) {
|
|
// ── System
|
|
case 'branding': return <BrandingForm {...formProps} />;
|
|
case 'paths-storage': return <PathsStorageForm {...formProps} />;
|
|
case 'execution': return <ExecutionForm {...formProps} />;
|
|
|
|
// ── LLM (Step 7: LlmWorkersForm replaces ProviderForm; reads llm.workers,
|
|
// not provider.workers. 'provider' alias kept for URL backwards compat.)
|
|
case 'provider':
|
|
case 'llm-workers':
|
|
return <LlmWorkersForm {...formProps} />;
|
|
case 'gateway-server': return <GatewayServerForm {...formProps} />;
|
|
case 'llm-metrics': return <MetricsForm {...formProps} />;
|
|
|
|
// ── Server / Network
|
|
case 'server-tls': return <ServerTlsForm {...formProps} />;
|
|
|
|
// ── Agent Runtime
|
|
case 'ask-subtasks': return <AskSubtasksForm {...formProps} />;
|
|
case 'context': return <ContextForm {...formProps} />;
|
|
case 'safety': return <SafetyForm {...formProps} />;
|
|
case 'reflection': return <ReflectionForm {...formProps} />;
|
|
case 'skills-quota': return <SkillsQuotaForm {...formProps} />;
|
|
case 'push-notifications': return <PushNotificationsForm {...formProps} />;
|
|
case 'auth': return <AuthForm {...formProps} />;
|
|
|
|
// ── Tools sub-sections — Step 9 split the legacy grab-bag
|
|
// ToolsForm into focused per-category forms. Each binds to the
|
|
// same `tools.*` config keys as before (functionally equivalent),
|
|
// just without the in-form sub-tab nav.
|
|
case 'tools-web':
|
|
// Folds SearchFilterForm in as a sub-section (Step 3
|
|
// INVESTIGATE #3 follow-up).
|
|
return <ToolsWebForm {...formProps} />;
|
|
case 'tools-browser':
|
|
// Browser runtime (page/action timeouts, channel, etc.) is its
|
|
// own form — kept verbatim, just relocated.
|
|
return <BrowserSettingsForm {...formProps} />;
|
|
case 'tools-media':
|
|
return <ToolsMediaForm {...formProps} />;
|
|
case 'tools-external':
|
|
return <ToolsExternalForm {...formProps} />;
|
|
|
|
// ── MCP & Connections
|
|
case 'mcp': return <McpForm {...formProps} />;
|
|
|
|
// ── SSH (admin)
|
|
case 'ssh': return <SshForm {...formProps} showToast={(msg) => {
|
|
setToast(msg);
|
|
setTimeout(() => setToast(null), 3000);
|
|
}} />;
|
|
|
|
// ── Legacy ids — kept here only so a direct URL hit still renders
|
|
// something during the transition window. The Settings page also
|
|
// rewrites the URL to the new id via `LEGACY_SECTION_REDIRECT`, so
|
|
// these branches are mostly defensive. ('provider' moved to the
|
|
// LLM-Workers case above — Step 7 — so it now lands on the new
|
|
// form. 'tools' bookmark still resolves to the legacy ToolsForm
|
|
// with all sub-tabs visible per Step 9 fallback design.)
|
|
case 'workspace': return <WorkspaceForm {...formProps} />;
|
|
case 'tools': return <ToolsForm {...formProps} />;
|
|
case 'search-filter': return <SearchFilterForm {...formProps} />;
|
|
case 'browser-settings': return <BrowserSettingsForm {...formProps} />;
|
|
|
|
default: return <div className="text-sm text-slate-400">Unknown section: {section}</div>;
|
|
}
|
|
})();
|
|
|
|
const dirty = dirtyCount > 0;
|
|
const blockedByInvalid = invalidKeys.size > 0;
|
|
|
|
return (
|
|
<div className="max-w-2xl pb-20">
|
|
{sectionForm}
|
|
|
|
{/* Sticky save bar: stays visible while scrolling, gets a strong amber
|
|
accent when dirty so it cannot be missed. The pb-20 on the parent
|
|
reserves space so the bar never overlaps the last form field. */}
|
|
<div
|
|
className={`sticky bottom-0 px-3 py-2.5 mt-6 border rounded-md flex items-center justify-end gap-2 transition-colors ${
|
|
dirty
|
|
? 'bg-amber-50 dark:bg-amber-500/15 border-amber-300 dark:border-amber-500/30 shadow-[0_2px_8px_rgba(180,83,9,0.08)]'
|
|
: 'bg-canvas border-hairline'
|
|
}`}
|
|
>
|
|
{toast ? (
|
|
<span className={`text-2xs mr-auto ${toastIsError ? 'text-red-600' : 'text-emerald-700 dark:text-emerald-300'}`}>
|
|
{toast}
|
|
</span>
|
|
) : blockedByInvalid ? (
|
|
<div className="mr-auto flex min-w-0 items-center gap-2">
|
|
<span className="text-xs text-red-600 dark:text-red-400 flex items-center gap-1.5 font-medium min-w-0" aria-live="polite">
|
|
<span className="inline-block w-1.5 h-1.5 rounded-full bg-red-500 flex-shrink-0" aria-hidden />
|
|
<span className="truncate">{t('configForm.invalidBlocked')}</span>
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={handleNavigateToInvalid}
|
|
className="h-8 flex-shrink-0 rounded-md border border-red-300 bg-canvas px-2 text-xs font-medium text-red-700 hover:bg-red-50 dark:border-red-500/40 dark:text-red-300 dark:hover:bg-red-500/10"
|
|
>
|
|
{t('configForm.showInvalidField')}
|
|
</button>
|
|
</div>
|
|
) : dirty ? (
|
|
<span className="text-xs mr-auto text-amber-800 dark:text-amber-300 flex items-center gap-1.5 font-medium min-w-0">
|
|
<span className="inline-block w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse flex-shrink-0" aria-hidden />
|
|
<span className="truncate">
|
|
<span className="hidden sm:inline">{t('configForm.unsavedAdmin', { count: dirtyCount })}</span>
|
|
<span className="sm:hidden">{t('configForm.unsavedShort', { count: dirtyCount })}</span>
|
|
</span>
|
|
</span>
|
|
) : null}
|
|
<button
|
|
onClick={handleDiscard}
|
|
disabled={!dirty}
|
|
className="px-3 h-8 text-xs text-slate-700 border border-hairline bg-canvas rounded-md hover:bg-surface disabled:opacity-50 transition-colors whitespace-nowrap flex-shrink-0"
|
|
>
|
|
<span className="hidden sm:inline">Discard Changes</span>
|
|
<span className="sm:hidden">Discard</span>
|
|
</button>
|
|
<button
|
|
onClick={handleSave}
|
|
disabled={!dirty || saving || blockedByInvalid}
|
|
className="px-3 h-8 text-xs font-semibold bg-accent text-accent-fg rounded-md hover:bg-accent-deep disabled:opacity-50 transition-colors whitespace-nowrap flex-shrink-0"
|
|
>
|
|
{saving ? 'Saving...' : (
|
|
<>
|
|
<span className="hidden sm:inline">Save & Apply</span>
|
|
<span className="sm:hidden">Save</span>
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|