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. */}