import { useState, useEffect, useCallback } from 'react'; 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 { KnowledgeNamespacesForm } from './KnowledgeNamespacesForm'; 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 { ReflectionForm } from './ReflectionForm'; import { McpForm } from './McpForm'; import { SshForm } from './SshForm'; import { GatewayServerForm } from './GatewayServerForm'; import { useAuthState } from '../../App'; interface ConfigFormProps { section: string; isAdmin: boolean; } function PreferencesFormWrapper() { const auth = useAuthState(); if (auth.mode !== 'authenticated') { return
Log in to manage preferences.
; } return ( ); } /** 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 }: ConfigFormProps) { // 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
; } if (section === 'notifications') { return
; } if (section === 'memory-learning') { return
; } if (!isAdmin) { return
この設定は管理者のみ閲覧できます。
; } // 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 ; } function ConfigFormInner({ section }: ConfigFormProps) { const { data, isLoading, error, refetch } = useConfig(); const queryClient = useQueryClient(); const [draft, setDraft] = useState(null); const [etag, setEtag] = useState(''); const [overriddenByEnv, setOverriddenByEnv] = useState>({}); const [isDirty, setIsDirty] = useState(false); const [saving, setSaving] = useState(false); const [toast, setToast] = useState(null); // Sync fetched config into draft useEffect(() => { if (data) { setDraft(data.config); setEtag(data.etag); setOverriddenByEnv(data.overriddenByEnv); setIsDirty(false); } }, [data]); const handleChange = useCallback((path: string, value: any) => { setDraft((prev: any) => setNestedValue(prev, path, value)); setIsDirty(true); }, []); const handleDiscard = () => { if (data) { setDraft(data.config); setIsDirty(false); } }; const handleSave = async () => { if (!draft) return; setSaving(true); try { const result = await updateConfig(draft, etag); if (result.conflict) { if (confirm('設定が他で変更されました。再読み込みしますか?')) { await refetch(); } return; } await queryClient.invalidateQueries({ queryKey: ['config'] }); setIsDirty(false); setToast('保存しました'); setTimeout(() => setToast(null), 2000); } catch (e: any) { setToast(`エラー: ${e.message}`); setTimeout(() => setToast(null), 3000); } finally { setSaving(false); } }; const dirtyCount = isDirty && data ? countDiff(data.config, draft) : 0; // 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
Loading...
; if (error) return
設定の読み込みに失敗しました
; if (!draft) return null; const formProps = { config: draft, onChange: handleChange, overriddenByEnv }; const sectionForm = (() => { switch (section) { // ── System case 'branding': return ; case 'paths-storage': return ; case 'execution': return ; // ── 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 ; case 'gateway-server': return ; case 'llm-metrics': return ; // ── Agent Runtime case 'ask-subtasks': return ; case 'context': return ; case 'safety': return ; case 'reflection': return ; // ── 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 ; case 'tools-browser': // Browser runtime (page/action timeouts, channel, etc.) is its // own form — kept verbatim, just relocated. return ; case 'tools-media': return ; case 'tools-external': return ; case 'tools-legacy-knowledge': return ; // ── MCP & Connections case 'mcp': return ; // ── SSH (admin) case 'ssh': return { 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 ; case 'tools': return ; case 'search-filter': return ; case 'browser-settings': return ; default: return
Unknown section: {section}
; } })(); const dirty = dirtyCount > 0; return (
{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. */}
{toast ? ( {toast} ) : dirty ? ( 未保存: {dirtyCount} 項目 — 「Save & Apply」を押すまで反映されません 未保存 {dirtyCount} ) : null}
); }