2026-06-03 05:08:00 +00:00

319 lines
15 KiB
JavaScript

// SettingsPage — sidebar groups + scrollable form (matches existing SettingsSidebar structure)
const SETTINGS_GROUPS = [
{
label: '基本設定',
sections: [
{ id: 'general', label: 'General', desc: 'タイムゾーン・言語' },
{ id: 'provider', label: 'Provider', desc: 'LLM API キー・デフォルトモデル' },
{ id: 'workers', label: 'Workers', desc: '並列数・タイムアウト・リトライ' },
{ id: 'workspace', label: 'Workspace', desc: '作業ディレクトリ・クリーンアップ' },
{ id: 'progress', label: 'Progress', desc: '進捗報告の頻度' },
],
},
{
label: 'セキュリティ・アクセス制御',
sections: [
{ id: 'repos', label: 'Repos', desc: 'Gitea 接続・許可リポジトリ' },
{ id: 'access-control', label: 'Access Control', desc: 'ロール・権限マトリクス' },
{ id: 'search-filter', label: 'Search Filter', desc: 'NGワード・ドメイン制限' },
],
},
{
label: 'ツール設定',
sections: [
{ id: 'tools', label: 'Tools', desc: '利用可能なツールの有効化' },
{ id: 'browser-settings', label: 'Browser', desc: 'noVNC・セッション' },
],
},
{
label: 'エージェント制御',
sections: [
{ id: 'ask-subtasks', label: 'Ask / Subtasks', desc: 'ASK・サブタスクの挙動' },
{ id: 'context', label: 'Context', desc: 'コンテキスト長・注入ルール' },
{ id: 'memory-safety', label: 'Memory / Safety', desc: 'メモリ制限・安全装置' },
],
},
];
const PIECES = ['auto', 'chat', 'research', 'general', 'x-ai-digest', 'brainstorming', 'data-process'];
function SettingsSidebar({ section, onSelect, piece, onSelectPiece }) {
const itemStyle = (active) => ({
display: 'block', width: '100%', textAlign: 'left',
padding: '6px 10px', borderRadius: 8, border: 'none', cursor: 'pointer',
fontSize: 12, fontFamily: 'inherit', marginBottom: 1,
background: active ? '#eff6ff' : 'transparent',
color: active ? '#1d4ed8' : '#475569',
fontWeight: active ? 700 : 500,
});
return (
<div style={{ height: '100%', overflowY: 'auto', padding: '12px 10px', background: '#fff', borderRight: '1px solid #e2e8f0' }}>
{SETTINGS_GROUPS.map(g => (
<div key={g.label} style={{ marginBottom: 12 }}>
<div style={{
fontSize: 10, fontWeight: 700, color: '#94a3b8', letterSpacing: '.08em',
textTransform: 'uppercase', padding: '4px 10px 4px', marginBottom: 2,
}}>{g.label}</div>
{g.sections.map(s => (
<button key={s.id} style={itemStyle(section === s.id && !piece)} onClick={() => onSelect(s.id)}>
{s.label}
</button>
))}
</div>
))}
<div style={{
fontSize: 10, fontWeight: 700, color: '#94a3b8', letterSpacing: '.08em',
textTransform: 'uppercase', padding: '4px 10px 4px', marginBottom: 2,
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
}}>
<span>Pieces</span>
<button title="Piece を追加" style={{
border: 'none', background: 'transparent', color: '#2563eb',
cursor: 'pointer', fontSize: 14, fontWeight: 700, padding: 0, lineHeight: 1,
}}>+</button>
</div>
{PIECES.map(p => (
<button key={p} style={itemStyle(piece === p)} onClick={() => onSelectPiece(p)}>
{p}
</button>
))}
</div>
);
}
function SaveBar({ onDiscard }) {
// 3 states: idle / saving / saved / error — demonstrated via toggle
const [state, setState] = React.useState('idle');
const [dirty, setDirty] = React.useState(false);
// mark dirty when any descendant input changes
const onInput = React.useCallback(() => setDirty(true), []);
React.useEffect(() => {
const h = () => setDirty(true);
document.addEventListener('input', h);
return () => document.removeEventListener('input', h);
}, []);
const save = async () => {
setState('saving');
// mock latency; in 1/5 odds surface an error to showcase failure UI
await new Promise(r => setTimeout(r, 700));
if (Math.random() < 0.2) {
setState('error');
return;
}
setState('saved'); setDirty(false);
setTimeout(() => setState('idle'), 1500);
};
const discard = () => { setDirty(false); setState('idle'); onDiscard?.(); };
return (
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
{state === 'saved' && (
<span style={{
fontSize: 11, color: '#166534', background: '#dcfce7', padding: '3px 8px',
borderRadius: 9999, fontWeight: 700, display: 'inline-flex', alignItems: 'center', gap: 4,
}}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><path d="M5 13l4 4L19 7"/></svg>
保存しました
</span>
)}
{state === 'error' && (
<span style={{
fontSize: 11, color: '#b91c1c', background: '#fee2e2', padding: '3px 8px',
borderRadius: 9999, fontWeight: 700, display: 'inline-flex', alignItems: 'center', gap: 4,
}}> 保存に失敗</span>
)}
<button onClick={discard} disabled={!dirty || state === 'saving'} style={{
padding: '6px 12px', background: '#fff', border: '1px solid #e2e8f0', color: '#475569',
borderRadius: 8, fontSize: 12, fontWeight: 700,
cursor: (!dirty || state === 'saving') ? 'not-allowed' : 'pointer',
opacity: (!dirty || state === 'saving') ? 0.5 : 1,
fontFamily: 'inherit',
}}>Discard</button>
<button onClick={save} disabled={state === 'saving'} style={{
padding: '6px 14px', background: '#2563eb', border: 'none', color: '#fff',
borderRadius: 8, fontSize: 12, fontWeight: 700,
cursor: state === 'saving' ? 'wait' : 'pointer',
opacity: state === 'saving' ? 0.7 : 1,
fontFamily: 'inherit', display: 'inline-flex', alignItems: 'center', gap: 6,
}}>
{state === 'saving' && <Spinner />}
{state === 'saving' ? '保存中…' : 'Save & Apply'}
</button>
</div>
);
}
// Simple mock form surface — shows that the detail pane follows the exact same "card with form" rhythm as Schedules/Users.
function SettingsForm({ section, piece }) {
const meta = (() => {
for (const g of SETTINGS_GROUPS) for (const s of g.sections) if (s.id === section) return s;
return null;
})();
const title = piece ? `Piece: ${piece}` : (meta?.label || section);
const desc = piece ? `${piece} ピースの定義・ムーブメント・ツール設定` : (meta?.desc || '');
// Sample fields per-section (placeholder — the real forms are in gitea-agent-orchestrator/ui)
const fields = piece ? [
{ label: 'Description', kind: 'text', value: piece === 'auto' ? '入力内容から最適なピースを自動選択' : `${piece} 用の定義` },
{ label: 'Max movements', kind: 'number', value: 25 },
{ label: 'Initial movement', kind: 'select', value: 'execute', options: ['execute', 'plan', 'research'] },
] : ({
provider: [
{ label: 'Default provider', kind: 'select', value: 'anthropic', options: ['anthropic', 'openai', 'google', 'bedrock'] },
{ label: 'Model', kind: 'select', value: 'claude-sonnet-4.5', options: ['claude-sonnet-4.5', 'claude-opus-4', 'gpt-4.1'] },
{ label: 'API key', kind: 'password', value: 'sk-ant-•••••••••••••••••••••••', env: true },
{ label: 'Max tokens', kind: 'number', value: 8192 },
{ label: 'Temperature', kind: 'number', value: 0.7, step: 0.1 },
],
workers: [
{ label: 'Parallel workers', kind: 'number', value: 6 },
{ label: 'Per-task timeout (sec)', kind: 'number', value: 900 },
{ label: 'Max retries', kind: 'number', value: 3 },
{ label: 'Retry backoff (sec)', kind: 'number', value: 60 },
],
general: [
{ label: 'System timezone', kind: 'select', value: 'Asia/Tokyo', options: ['Asia/Tokyo', 'UTC', 'America/Los_Angeles'] },
{ label: 'Language', kind: 'select', value: 'ja', options: ['ja', 'en'] },
{ label: 'Allow anonymous task creation', kind: 'toggle', value: false },
],
repos: [
{ label: 'Gitea URL', kind: 'text', value: 'https://gitea.internal' },
{ label: 'Access token', kind: 'password', value: 'gitea_•••••••••••', env: true },
{ label: 'Allowed repos', kind: 'text', value: 'daichi/*, corp/*', help: 'カンマ区切り・グロブ可' },
],
'access-control': [
{ label: 'Admin ロール allow', kind: 'text', value: '*' },
{ label: 'Operator ロール allow', kind: 'text', value: 'tasks.*, schedules.*' },
{ label: 'Viewer ロール allow', kind: 'text', value: 'tasks.read, schedules.read' },
],
tools: [
{ label: 'Read / Write / Edit', kind: 'toggle', value: true },
{ label: 'Bash', kind: 'toggle', value: true },
{ label: 'Browser (noVNC)', kind: 'toggle', value: true },
{ label: 'WebSearch', kind: 'toggle', value: false },
],
'browser-settings': [
{ label: 'noVNC endpoint', kind: 'text', value: 'https://novnc.internal' },
{ label: 'Session timeout (min)', kind: 'number', value: 30 },
{ label: 'Allow CAPTCHA fallback to human', kind: 'toggle', value: true },
],
'ask-subtasks': [
{ label: 'Max ASK depth', kind: 'number', value: 3 },
{ label: 'Auto-resume after ASK timeout (min)', kind: 'number', value: 60 },
{ label: 'Allow parallel subtasks', kind: 'toggle', value: true },
],
context: [
{ label: 'Context window (tokens)', kind: 'number', value: 200000 },
{ label: 'Auto-compact threshold', kind: 'number', value: 80, help: '% で指定' },
],
'memory-safety': [
{ label: 'Memory limit per worker (MB)', kind: 'number', value: 2048 },
{ label: 'Kill on OOM', kind: 'toggle', value: true },
{ label: 'Safe-mode Bash commands only', kind: 'toggle', value: false },
],
progress: [
{ label: 'Progress update interval (sec)', kind: 'number', value: 15 },
{ label: 'Show subtask progress', kind: 'toggle', value: true },
],
workspace: [
{ label: 'Workspace root', kind: 'text', value: '/var/lib/agent/workspace' },
{ label: 'Clean after task completion', kind: 'toggle', value: false },
],
'search-filter': [
{ label: 'Blocked domains', kind: 'text', value: 'example-bad.com, *.malicious.example' },
{ label: 'NG words', kind: 'text', value: '', help: 'カンマ区切り' },
],
}[section] || []);
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
{/* Header */}
<div style={{
flexShrink: 0, padding: '14px 20px', borderBottom: '1px solid #e2e8f0', background: '#fff',
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12,
}}>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 10, fontWeight: 700, color: '#64748b', letterSpacing: '.08em', textTransform: 'uppercase' }}>
{piece ? 'PIECE' : 'SETTINGS'}
</div>
<div style={{ fontSize: 15, fontWeight: 700, color: '#0f172a' }}>{title}</div>
{desc && <div style={{ fontSize: 12, color: '#64748b', marginTop: 2 }}>{desc}</div>}
</div>
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
<SaveBar />
</div>
</div>
{/* Body */}
<div style={{ flex: 1, overflowY: 'auto', padding: '20px 24px', background: '#f8fafc' }}>
<div style={{ maxWidth: 640, margin: '0 auto' }}>
<div style={{
background: '#fff', border: '1px solid #e2e8f0', borderRadius: 16, padding: 20,
boxShadow: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
}}>
{fields.map((f, i) => (
<FormRow key={i} label={
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
{f.label}
{f.env && <span style={{ fontSize: 9, fontWeight: 700, color: '#92400e', background: '#fef3c7', padding: '1px 5px', borderRadius: 3 }}>ENV</span>}
</span>
} help={f.help}>
{f.kind === 'toggle' ? (
<div style={{
display: 'inline-flex', alignItems: 'center', width: 44, height: 24, borderRadius: 9999,
background: f.value ? '#2563eb' : '#cbd5e1', padding: 2,
justifyContent: f.value ? 'flex-end' : 'flex-start', cursor: 'pointer',
}}>
<div style={{ width: 20, height: 20, borderRadius: 9999, background: '#fff', boxShadow: '0 1px 2px rgb(0 0 0 / .2)' }} />
</div>
) : f.kind === 'select' ? (
<SelectInput value={f.value} onChange={() => {}}>
{f.options.map(o => <option key={o} value={o}>{o}</option>)}
</SelectInput>
) : f.kind === 'password' ? (
<TextInput type="password" value={f.value} readOnly={!!f.env}
style={{ fontFamily: 'IBM Plex Mono, monospace', background: f.env ? '#f8fafc' : '#fff' }} />
) : f.kind === 'number' ? (
<TextInput type="number" value={f.value} step={f.step || 1} onChange={() => {}} />
) : (
<TextInput value={f.value} onChange={() => {}} />
)}
</FormRow>
))}
{fields.length === 0 && (
<EmptyState
compact
icon={<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 8v4M12 16h.01"/></svg>}
title="このセクションは未実装です"
hint="`gitea-agent-orchestrator/ui` 側で設定項目を追加するとここに表示されます。"
/>
)}
</div>
<div style={{ height: 40 }} />
</div>
</div>
</div>
);
}
function SettingsPage({ section, setSection, piece, setPiece }) {
return (
<div style={{
flex: 1, minHeight: 0, display: 'grid',
gridTemplateColumns: '240px 1fr',
background: '#f1f5f9', gap: 1,
}}>
<SettingsSidebar
section={section} onSelect={(s) => { setSection(s); setPiece(null); }}
piece={piece} onSelectPiece={(p) => setPiece(p)}
/>
<div style={{ background: '#fff', minWidth: 0 }}>
<SettingsForm section={section} piece={piece} />
</div>
</div>
);
}
window.SettingsPage = SettingsPage;