feat: initial public release (MAESTRO)
This commit is contained in:
@@ -0,0 +1,285 @@
|
||||
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 <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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 <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 (!isAdmin) {
|
||||
return <div className="max-w-2xl text-sm text-slate-500">この設定は管理者のみ閲覧できます。</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} />;
|
||||
}
|
||||
|
||||
function ConfigFormInner({ section }: ConfigFormProps) {
|
||||
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);
|
||||
|
||||
// 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 <div className="text-sm text-slate-400">Loading...</div>;
|
||||
if (error) return <div className="text-sm text-red-500">設定の読み込みに失敗しました</div>;
|
||||
if (!draft) return null;
|
||||
|
||||
const formProps = { config: draft, onChange: handleChange, overriddenByEnv };
|
||||
|
||||
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} />;
|
||||
|
||||
// ── Agent Runtime
|
||||
case 'ask-subtasks': return <AskSubtasksForm {...formProps} />;
|
||||
case 'context': return <ContextForm {...formProps} />;
|
||||
case 'safety': return <SafetyForm {...formProps} />;
|
||||
case 'reflection': return <ReflectionForm {...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} />;
|
||||
case 'tools-legacy-knowledge':
|
||||
return <KnowledgeNamespacesForm {...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;
|
||||
|
||||
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 border-amber-300 shadow-[0_2px_8px_rgba(180,83,9,0.08)]'
|
||||
: 'bg-white border-hairline'
|
||||
}`}
|
||||
>
|
||||
{toast ? (
|
||||
<span className={`text-2xs mr-auto ${toast.startsWith('エラー') ? 'text-red-600' : 'text-emerald-700'}`}>
|
||||
{toast}
|
||||
</span>
|
||||
) : dirty ? (
|
||||
<span className="text-xs mr-auto text-amber-800 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">未保存: {dirtyCount} 項目 — 「Save & Apply」を押すまで反映されません</span>
|
||||
<span className="sm:hidden">未保存 {dirtyCount}</span>
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
<button
|
||||
onClick={handleDiscard}
|
||||
disabled={!dirty}
|
||||
className="px-3 h-8 text-xs text-slate-700 border border-hairline bg-white 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}
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user