Open-source release of MAESTRO, an agent orchestration platform that runs LLM-driven tasks through sandboxed tools, with a web UI. Apache-2.0. See README.md and docs/ (getting-started, configuration, architecture).
319 lines
15 KiB
JavaScript
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;
|