382 lines
9.9 KiB
TypeScript
382 lines
9.9 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
|
|
import {
|
|
type MoaModelSettingsSnapshot,
|
|
type MoaReferenceStatus,
|
|
type MoaSettingsSnapshot,
|
|
checkMoaModel,
|
|
fetchMoaSettings,
|
|
updateMoaSettings,
|
|
} from './api';
|
|
|
|
export function MoaSettingsPanel() {
|
|
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" id="settings-moa">
|
|
<header className="settings-section-head">
|
|
<span>Arbiter references</span>
|
|
<h3>MoA 참조 모델</h3>
|
|
<p>Kimi, GLM 같은 외부 참조 모델을 켜고 연결 상태를 바로 확인합니다.</p>
|
|
</header>
|
|
{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}
|
|
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,
|
|
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;
|
|
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}
|
|
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,
|
|
onSave,
|
|
savedAt,
|
|
}: {
|
|
busy: boolean;
|
|
checking: string | null;
|
|
dirty: boolean;
|
|
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 ? '저장 중…' : 'MoA 저장'}
|
|
</button>
|
|
{savedAt && !dirty ? (
|
|
<small className="settings-hint">
|
|
저장됨. 적용하려면 상단의 스택 재시작을 눌러 주세요.
|
|
</small>
|
|
) : null}
|
|
</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>
|
|
);
|
|
}
|