add dashboard MoA settings controls
This commit is contained in:
403
apps/dashboard/src/MoaSettingsPanel.tsx
Normal file
403
apps/dashboard/src/MoaSettingsPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user