add dashboard MoA settings controls

This commit is contained in:
ejclaw
2026-04-30 01:31:05 +09:00
parent d2ccb40cac
commit 731984207c
12 changed files with 1134 additions and 24 deletions

View File

@@ -0,0 +1,403 @@
import { useEffect, useState } from 'react';
import {
type MoaModelSettingsSnapshot,
type MoaReferenceStatus,
type MoaSettingsSnapshot,
checkMoaModel,
fetchMoaSettings,
updateMoaSettings,
} from './api';
export function MoaSettingsPanel({
onRestartStack,
}: {
onRestartStack: () => void;
}) {
const [config, setConfig] = useState<MoaSettingsSnapshot | null>(null);
const [draft, setDraft] = useState<MoaSettingsSnapshot | null>(null);
const [apiKeys, setApiKeys] = useState<Record<string, string>>({});
const [busy, setBusy] = useState(false);
const [checking, setChecking] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [savedAt, setSavedAt] = useState<number | null>(null);
useEffect(() => {
let cancelled = false;
setBusy(true);
fetchMoaSettings()
.then((next) => {
if (cancelled) return;
setConfig(next);
setDraft(next);
setError(null);
})
.catch((err) => {
if (cancelled) return;
setError(err instanceof Error ? err.message : String(err));
})
.finally(() => {
if (!cancelled) setBusy(false);
});
return () => {
cancelled = true;
};
}, []);
function setModel(name: string, patch: Partial<MoaModelSettingsSnapshot>) {
setDraft((prev) =>
prev
? {
...prev,
models: prev.models.map((model) =>
model.name === name ? { ...model, ...patch } : model,
),
}
: prev,
);
}
function updateStatus(name: string, status: MoaReferenceStatus) {
const update = (prev: MoaSettingsSnapshot | null) =>
prev
? {
...prev,
models: prev.models.map((model) =>
model.name === name ? { ...model, lastStatus: status } : model,
),
}
: prev;
setConfig(update);
setDraft(update);
}
async function save() {
if (!draft) return;
setBusy(true);
setError(null);
try {
const next = await updateMoaSettings({
enabled: draft.enabled,
models: draft.models.map((model) => ({
name: model.name,
enabled: model.enabled,
model: model.model,
baseUrl: model.baseUrl,
apiFormat: model.apiFormat,
apiKey: apiKeys[model.name]?.trim() || undefined,
})),
});
setConfig(next);
setDraft(next);
setApiKeys({});
setSavedAt(Date.now());
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}
async function testModel(name: string) {
setChecking(name);
setError(null);
try {
updateStatus(name, await checkMoaModel(name));
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setChecking(null);
}
}
const dirty =
draft !== null &&
config !== null &&
(JSON.stringify(draft) !== JSON.stringify(config) ||
Object.values(apiKeys).some((value) => value.trim()));
return (
<section className="settings-section">
<h3>MoA </h3>
{error ? <p className="settings-error">{error}</p> : null}
{!draft ? (
<p className="settings-hint">
{busy ? '불러오는 중…' : 'MoA 설정 없음'}
</p>
) : (
<MoaSettingsContent
apiKeys={apiKeys}
busy={busy}
checking={checking}
dirty={dirty}
draft={draft}
onApiKeyChange={(name, value) =>
setApiKeys((prev) => ({ ...prev, [name]: value }))
}
onModelChange={setModel}
onRestartStack={onRestartStack}
onSave={save}
onTest={testModel}
onToggle={() =>
setDraft((prev) =>
prev ? { ...prev, enabled: !prev.enabled } : prev,
)
}
savedAt={savedAt}
/>
)}
</section>
);
}
function MoaSettingsContent({
apiKeys,
busy,
checking,
dirty,
draft,
onApiKeyChange,
onModelChange,
onRestartStack,
onSave,
onTest,
onToggle,
savedAt,
}: {
apiKeys: Record<string, string>;
busy: boolean;
checking: string | null;
dirty: boolean;
draft: MoaSettingsSnapshot;
onApiKeyChange: (name: string, value: string) => void;
onModelChange: (
name: string,
patch: Partial<MoaModelSettingsSnapshot>,
) => void;
onRestartStack: () => void;
onSave: () => Promise<void>;
onTest: (name: string) => Promise<void>;
onToggle: () => void;
savedAt: number | null;
}) {
return (
<>
<MoaMasterToggle
busy={busy}
enabled={draft.enabled}
onToggle={onToggle}
/>
<ul className="settings-account-list">
{draft.models.map((model) => (
<MoaModelRow
apiKeyValue={apiKeys[model.name] ?? ''}
busy={busy}
checking={checking}
dirty={dirty}
key={model.name}
model={model}
onApiKeyChange={onApiKeyChange}
onModelChange={onModelChange}
onTest={onTest}
/>
))}
</ul>
<MoaSettingsActions
busy={busy}
checking={checking}
dirty={dirty}
onRestartStack={onRestartStack}
onSave={onSave}
savedAt={savedAt}
/>
</>
);
}
function formatMoaStatus(status: MoaReferenceStatus | null): string {
if (!status) return '연결 테스트 전';
const at = new Date(status.checkedAt);
const checkedAt = Number.isNaN(at.getTime())
? status.checkedAt
: at.toLocaleString('ko-KR');
if (status.ok) return `정상 · ${checkedAt}`;
return `실패 · ${checkedAt} · ${status.error ?? 'unknown error'}`;
}
function MoaMasterToggle({
busy,
enabled,
onToggle,
}: {
busy: boolean;
enabled: boolean;
onToggle: () => void;
}) {
return (
<label className="settings-toggle-row">
<span className="settings-toggle-label">
<span className="settings-toggle-title">MoA </span>
<small className="settings-hint">
Arbiter .
.
</small>
</span>
<input
checked={enabled}
disabled={busy}
onChange={onToggle}
type="checkbox"
/>
</label>
);
}
function MoaSettingsActions({
busy,
checking,
dirty,
onRestartStack,
onSave,
savedAt,
}: {
busy: boolean;
checking: string | null;
dirty: boolean;
onRestartStack: () => void;
onSave: () => Promise<void>;
savedAt: number | null;
}) {
return (
<div className="settings-actions">
<button
className="settings-save"
disabled={!dirty || busy || checking !== null}
onClick={() => void onSave()}
type="button"
>
{busy ? '저장 중…' : '저장'}
</button>
{savedAt && !dirty ? (
<small className="settings-hint">
. .
</small>
) : null}
<button
className="settings-restart"
disabled={busy || checking !== null}
onClick={() => {
if (
window.confirm(
'스택을 재시작하면 진행 중인 모든 에이전트 작업이 중단됩니다. 진행할까요?',
)
) {
onRestartStack();
}
}}
type="button"
>
</button>
</div>
);
}
function MoaModelRow({
apiKeyValue,
busy,
checking,
dirty,
model,
onApiKeyChange,
onModelChange,
onTest,
}: {
apiKeyValue: string;
busy: boolean;
checking: string | null;
dirty: boolean;
model: MoaModelSettingsSnapshot;
onApiKeyChange: (name: string, value: string) => void;
onModelChange: (
name: string,
patch: Partial<MoaModelSettingsSnapshot>,
) => void;
onTest: (name: string) => Promise<void>;
}) {
return (
<li className="settings-account-row settings-moa-row">
<div className="settings-moa-grid">
<label className="settings-moa-name">
<input
checked={model.enabled}
disabled={busy}
onChange={() =>
onModelChange(model.name, { enabled: !model.enabled })
}
type="checkbox"
/>
<span className="settings-account-tag">{model.name}</span>
</label>
<input
aria-label={`${model.name} MoA model`}
onChange={(e) => onModelChange(model.name, { model: e.target.value })}
placeholder="model"
type="text"
value={model.model}
/>
<input
aria-label={`${model.name} MoA base URL`}
onChange={(e) =>
onModelChange(model.name, { baseUrl: e.target.value })
}
placeholder="base URL"
type="text"
value={model.baseUrl}
/>
<select
aria-label={`${model.name} MoA API format`}
onChange={(e) =>
onModelChange(model.name, {
apiFormat: e.target.value as 'openai' | 'anthropic',
})
}
value={model.apiFormat}
>
<option value="anthropic">anthropic</option>
<option value="openai">openai</option>
</select>
<input
aria-label={`${model.name} MoA API key`}
onChange={(e) => onApiKeyChange(model.name, e.target.value)}
placeholder={model.apiKeyConfigured ? 'API key set' : 'new API key'}
type="password"
value={apiKeyValue}
/>
<span
className={`settings-account-badge ${
model.lastStatus?.ok === false ? 'is-expired' : 'is-active'
}`}
title={model.lastStatus?.error ?? undefined}
>
{formatMoaStatus(model.lastStatus)}
</span>
</div>
<div className="settings-account-actions">
<button
className="settings-secondary"
disabled={
busy || checking !== null || dirty || !model.apiKeyConfigured
}
onClick={() => void onTest(model.name)}
type="button"
>
{checking === model.name
? '테스트중…'
: dirty
? '저장 후 테스트'
: '연결 테스트'}
</button>
</div>
</li>
);
}

View File

@@ -28,10 +28,11 @@ describe('SettingsPanel', () => {
expect(html).toContain('English');
});
it('renders model, fast mode, and account controls', () => {
it('renders model, MoA, fast mode, and account controls', () => {
const html = renderToStaticMarkup(createElement(SettingsPanel, baseProps));
expect(html).toContain('모델');
expect(html).toContain('MoA 참조 모델');
expect(html).toContain('패스트 모드');
expect(html).toContain('불러오는 중');
expect(html).toContain('Claude');

View File

@@ -18,6 +18,7 @@ import {
updateModels,
} from './api';
import { LOCALES, languageNames, type Locale, type Messages } from './i18n';
import { MoaSettingsPanel } from './MoaSettingsPanel';
type AccountProvider = 'claude' | 'codex';
@@ -77,6 +78,8 @@ export function SettingsPanel({
<ModelSettings onRestartStack={onRestartStack} />
<MoaSettingsPanel onRestartStack={onRestartStack} />
<FastModeSettings />
<AccountSettings onRestartStack={onRestartStack} />

View File

@@ -388,6 +388,30 @@ export interface FastModeSnapshot {
claude: boolean;
}
export interface MoaReferenceStatus {
model: string;
checkedAt: string;
ok: boolean;
error: string | null;
responseLength?: number;
}
export interface MoaModelSettingsSnapshot {
name: string;
enabled: boolean;
model: string;
baseUrl: string;
apiFormat: 'openai' | 'anthropic';
apiKeyConfigured: boolean;
lastStatus: MoaReferenceStatus | null;
}
export interface MoaSettingsSnapshot {
enabled: boolean;
referenceModels: string[];
models: MoaModelSettingsSnapshot[];
}
export async function fetchAccounts(): Promise<{
claude: ClaudeAccountSummary[];
codex: CodexAccountSummary[];
@@ -514,6 +538,67 @@ export async function updateFastMode(
return (await response.json()) as FastModeSnapshot;
}
export async function fetchMoaSettings(): Promise<MoaSettingsSnapshot> {
return fetchJson('/api/settings/moa');
}
export async function updateMoaSettings(input: {
enabled?: boolean;
models?: Array<{
name: string;
enabled?: boolean;
model?: string;
baseUrl?: string;
apiFormat?: 'openai' | 'anthropic';
apiKey?: string;
}>;
}): Promise<MoaSettingsSnapshot> {
const response = await fetch('/api/settings/moa', {
method: 'PUT',
headers: {
accept: 'application/json',
'content-type': 'application/json',
},
body: JSON.stringify(input),
});
if (!response.ok) {
let msg = `update MoA settings failed: ${response.status}`;
try {
const payload = (await response.json()) as { error?: string };
if (payload.error) msg = payload.error;
} catch {
/* ignore */
}
throw new Error(msg);
}
return (await response.json()) as MoaSettingsSnapshot;
}
export async function checkMoaModel(name: string): Promise<MoaReferenceStatus> {
const response = await fetch('/api/settings/moa/check', {
method: 'POST',
headers: {
accept: 'application/json',
'content-type': 'application/json',
},
body: JSON.stringify({ name }),
});
if (!response.ok) {
let msg = `check MoA model failed: ${response.status}`;
try {
const payload = (await response.json()) as { error?: string };
if (payload.error) msg = payload.error;
} catch {
/* ignore */
}
throw new Error(msg);
}
const payload = (await response.json()) as {
status: MoaReferenceStatus;
};
return payload.status;
}
export async function deleteAccount(
provider: 'claude' | 'codex',
index: number,

View File

@@ -862,6 +862,46 @@ button:disabled {
min-width: 0;
}
.settings-moa-row {
grid-template-columns: minmax(0, 1fr) auto;
}
.settings-moa-grid {
display: grid;
grid-template-columns:
82px minmax(110px, 1fr) minmax(170px, 1.3fr) 104px
minmax(120px, 1fr) minmax(120px, 1fr);
gap: 8px;
align-items: center;
min-width: 0;
}
.settings-moa-grid input,
.settings-moa-grid select {
min-height: 32px;
min-width: 0;
padding: 0 10px;
border: 1px solid var(--panel-border);
border-radius: 7px;
background: var(--bg-card);
color: var(--bg-ink);
font: inherit;
font-size: 12px;
}
.settings-moa-name {
display: flex;
gap: 6px;
align-items: center;
min-width: 0;
}
.settings-moa-grid .settings-account-badge {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.settings-account-tag {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 11px;