add dashboard MoA settings controls
This commit is contained in:
@@ -82,11 +82,13 @@ STATUS_CHANNEL_ID= # Discord channel ID for live status updat
|
||||
# The SDK arbiter (subscription-based, no extra cost) aggregates all perspectives.
|
||||
# MOA_ENABLED=true
|
||||
# MOA_REF_MODELS=kimi,glm # Comma-separated reference model names
|
||||
# MOA_KIMI_MODEL=kimi-k2.5 # Kimi model
|
||||
# MOA_KIMI_MODEL=kimi-k2.6 # Kimi model
|
||||
# MOA_KIMI_BASE_URL=https://api.kimi.com/coding # Kimi API endpoint
|
||||
# MOA_KIMI_API_FORMAT=anthropic # Kimi Code API format
|
||||
# MOA_KIMI_API_KEY=sk-kimi-xxx # Kimi API key
|
||||
# MOA_GLM_MODEL=glm-4-plus # GLM model
|
||||
# MOA_GLM_BASE_URL=https://open.bigmodel.cn/api/paas/v4 # GLM API endpoint
|
||||
# MOA_GLM_MODEL=glm-5.1 # GLM model
|
||||
# MOA_GLM_BASE_URL=https://open.bigmodel.cn/api/anthropic # GLM API endpoint
|
||||
# MOA_GLM_API_FORMAT=anthropic # GLM Anthropic-compatible API
|
||||
# MOA_GLM_API_KEY=xxx # GLM API key
|
||||
|
||||
# --- Advanced ---
|
||||
|
||||
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;
|
||||
|
||||
@@ -93,19 +93,21 @@ GROQ_API_KEY=
|
||||
MOA_ENABLED=true
|
||||
MOA_REF_MODELS=kimi,glm
|
||||
|
||||
MOA_KIMI_MODEL=kimi-k2.5
|
||||
MOA_KIMI_MODEL=kimi-k2.6
|
||||
MOA_KIMI_BASE_URL=https://api.kimi.com/coding
|
||||
MOA_KIMI_API_KEY=sk-kimi-xxx
|
||||
MOA_KIMI_API_FORMAT=anthropic
|
||||
|
||||
MOA_GLM_MODEL=glm-4-plus
|
||||
MOA_GLM_BASE_URL=https://open.bigmodel.cn/api/paas/v4
|
||||
MOA_GLM_MODEL=glm-5.1
|
||||
MOA_GLM_BASE_URL=https://open.bigmodel.cn/api/anthropic
|
||||
MOA_GLM_API_KEY=xxx
|
||||
MOA_GLM_API_FORMAT=anthropic
|
||||
```
|
||||
|
||||
MoA는 arbiter 판정 전에 외부 모델 의견을 수집해 prompt에 주입합니다.
|
||||
`API_FORMAT` 같은 항목은 `.env.example`의 최소 예시에 없는 운영 확장 키입니다.
|
||||
대시보드 설정 화면에서 `MOA_ENABLED`, `MOA_REF_MODELS`, 모델명, base URL,
|
||||
API format, API key 교체와 연결 테스트를 관리할 수 있습니다. 저장 후에는
|
||||
스택 재시작이 필요합니다.
|
||||
|
||||
## 운영 / 배포 관련 설정
|
||||
|
||||
@@ -122,18 +124,18 @@ MAX_CONCURRENT_AGENTS=5
|
||||
|
||||
## 디버깅 경로
|
||||
|
||||
| 항목 | 경로 / 명령 |
|
||||
| --- | --- |
|
||||
| DB | `store/messages.db` |
|
||||
| 서비스 로그 | `journalctl --user -u ejclaw -f` |
|
||||
| room 로그 | `groups/{folder}/logs/` |
|
||||
| owner/reviewer 세션 | `data/sessions/{folder}*` |
|
||||
| owner worktree | `data/workspaces/{folder}/owner` |
|
||||
| Claude 플랫폼 프롬프트 | `prompts/claude-platform.md` |
|
||||
| reviewer 프롬프트 | `prompts/claude-paired-room.md` |
|
||||
| arbiter 프롬프트 | `prompts/arbiter-paired-room.md` |
|
||||
| Codex 플랫폼 프롬프트 | `prompts/codex-platform.md` |
|
||||
| 글로벌 메모리 | `groups/global/CLAUDE.md` |
|
||||
| 항목 | 경로 / 명령 |
|
||||
| ---------------------- | -------------------------------- |
|
||||
| DB | `store/messages.db` |
|
||||
| 서비스 로그 | `journalctl --user -u ejclaw -f` |
|
||||
| room 로그 | `groups/{folder}/logs/` |
|
||||
| owner/reviewer 세션 | `data/sessions/{folder}*` |
|
||||
| owner worktree | `data/workspaces/{folder}/owner` |
|
||||
| Claude 플랫폼 프롬프트 | `prompts/claude-platform.md` |
|
||||
| reviewer 프롬프트 | `prompts/claude-paired-room.md` |
|
||||
| arbiter 프롬프트 | `prompts/arbiter-paired-room.md` |
|
||||
| Codex 플랫폼 프롬프트 | `prompts/codex-platform.md` |
|
||||
| 글로벌 메모리 | `groups/global/CLAUDE.md` |
|
||||
|
||||
## 문서와 실제 코드의 우선순위
|
||||
|
||||
|
||||
66
src/moa.ts
66
src/moa.ts
@@ -30,6 +30,35 @@ export interface MoaReferenceResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface MoaReferenceStatus {
|
||||
model: string;
|
||||
checkedAt: string;
|
||||
ok: boolean;
|
||||
error: string | null;
|
||||
responseLength?: number;
|
||||
}
|
||||
|
||||
const referenceStatuses = new Map<string, MoaReferenceStatus>();
|
||||
|
||||
function normalizeError(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
function recordReferenceStatus(
|
||||
status: Omit<MoaReferenceStatus, 'checkedAt'>,
|
||||
): MoaReferenceStatus {
|
||||
const next: MoaReferenceStatus = {
|
||||
...status,
|
||||
checkedAt: new Date().toISOString(),
|
||||
};
|
||||
referenceStatuses.set(status.model, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function getMoaReferenceStatuses(): MoaReferenceStatus[] {
|
||||
return [...referenceStatuses.values()];
|
||||
}
|
||||
|
||||
async function queryModel(
|
||||
model: MoaModelConfig,
|
||||
systemPrompt: string,
|
||||
@@ -138,21 +167,50 @@ export async function collectMoaReferences(args: {
|
||||
return results.map((result, i) => {
|
||||
const model = config.referenceModels[i].name;
|
||||
if (result.status === 'fulfilled') {
|
||||
recordReferenceStatus({
|
||||
model,
|
||||
ok: true,
|
||||
error: null,
|
||||
responseLength: result.value.length,
|
||||
});
|
||||
logger.info(
|
||||
{ model, responseLen: result.value.length },
|
||||
'MoA: reference model responded',
|
||||
);
|
||||
return { model, response: result.value };
|
||||
}
|
||||
const error =
|
||||
result.reason instanceof Error
|
||||
? result.reason.message
|
||||
: String(result.reason);
|
||||
const error = normalizeError(result.reason);
|
||||
recordReferenceStatus({ model, ok: false, error });
|
||||
logger.warn({ model, error }, 'MoA: reference model failed');
|
||||
return { model, response: '', error };
|
||||
});
|
||||
}
|
||||
|
||||
export async function probeMoaReferenceModel(
|
||||
model: MoaModelConfig,
|
||||
): Promise<MoaReferenceStatus> {
|
||||
try {
|
||||
const response = await queryModel(
|
||||
model,
|
||||
'You are a configuration health check. Reply with a short plain-text OK.',
|
||||
'Reply exactly: OK',
|
||||
20_000,
|
||||
);
|
||||
return recordReferenceStatus({
|
||||
model: model.name,
|
||||
ok: true,
|
||||
error: null,
|
||||
responseLength: response.length,
|
||||
});
|
||||
} catch (err) {
|
||||
return recordReferenceStatus({
|
||||
model: model.name,
|
||||
ok: false,
|
||||
error: normalizeError(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format reference opinions into a section that gets appended
|
||||
* to the arbiter's prompt.
|
||||
|
||||
106
src/settings-store-moa.test.ts
Normal file
106
src/settings-store-moa.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { getMoaSettings, updateMoaSettings } from './settings-store-moa.js';
|
||||
|
||||
const originalCwd = process.cwd();
|
||||
let tempDir: string | null = null;
|
||||
|
||||
function writeTempEnv(content: string): string {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-moa-settings-'));
|
||||
fs.writeFileSync(path.join(tempDir, '.env'), content, { mode: 0o600 });
|
||||
process.chdir(tempDir);
|
||||
return path.join(tempDir, '.env');
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(originalCwd);
|
||||
if (tempDir) fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
tempDir = null;
|
||||
});
|
||||
|
||||
describe('MoA settings store', () => {
|
||||
it('reads MoA config without exposing API keys', () => {
|
||||
writeTempEnv(
|
||||
[
|
||||
'MOA_ENABLED=true',
|
||||
'MOA_REF_MODELS=kimi,glm',
|
||||
'MOA_KIMI_MODEL=kimi-k2.6',
|
||||
'MOA_KIMI_BASE_URL=https://api.kimi.com/coding',
|
||||
'MOA_KIMI_API_FORMAT=anthropic',
|
||||
'MOA_KIMI_API_KEY=sk-kimi-secret',
|
||||
'MOA_GLM_MODEL=glm-5.1',
|
||||
'MOA_GLM_BASE_URL=https://open.bigmodel.cn/api/anthropic',
|
||||
'MOA_GLM_API_FORMAT=anthropic',
|
||||
'MOA_GLM_API_KEY=glm-secret',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
expect(getMoaSettings()).toMatchObject({
|
||||
enabled: true,
|
||||
referenceModels: ['kimi', 'glm'],
|
||||
models: [
|
||||
{
|
||||
name: 'kimi',
|
||||
enabled: true,
|
||||
model: 'kimi-k2.6',
|
||||
apiFormat: 'anthropic',
|
||||
apiKeyConfigured: true,
|
||||
},
|
||||
{
|
||||
name: 'glm',
|
||||
enabled: true,
|
||||
model: 'glm-5.1',
|
||||
apiFormat: 'anthropic',
|
||||
apiKeyConfigured: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(JSON.stringify(getMoaSettings())).not.toContain('sk-kimi-secret');
|
||||
});
|
||||
|
||||
it('updates the master toggle, active models, and replacement API keys', () => {
|
||||
const envFile = writeTempEnv(
|
||||
[
|
||||
'MOA_ENABLED=true',
|
||||
'MOA_REF_MODELS=kimi,glm',
|
||||
'MOA_KIMI_MODEL=kimi-k2.6',
|
||||
'MOA_KIMI_BASE_URL=https://api.kimi.com/coding',
|
||||
'MOA_KIMI_API_FORMAT=anthropic',
|
||||
'MOA_KIMI_API_KEY=old-secret',
|
||||
'MOA_GLM_MODEL=glm-5.1',
|
||||
'MOA_GLM_BASE_URL=https://open.bigmodel.cn/api/anthropic',
|
||||
'MOA_GLM_API_FORMAT=anthropic',
|
||||
'MOA_GLM_API_KEY=glm-secret',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
const updated = updateMoaSettings({
|
||||
enabled: false,
|
||||
models: [
|
||||
{
|
||||
name: 'kimi',
|
||||
enabled: false,
|
||||
model: 'kimi-k2.7',
|
||||
baseUrl: 'https://api.kimi.com/coding',
|
||||
apiFormat: 'anthropic',
|
||||
apiKey: 'new-secret',
|
||||
},
|
||||
{ name: 'glm', enabled: true },
|
||||
],
|
||||
});
|
||||
|
||||
expect(updated.enabled).toBe(false);
|
||||
expect(updated.referenceModels).toEqual(['glm']);
|
||||
const content = fs.readFileSync(envFile, 'utf-8');
|
||||
expect(content).toContain('MOA_ENABLED=false');
|
||||
expect(content).toContain('MOA_REF_MODELS=glm');
|
||||
expect(content).toContain('MOA_KIMI_MODEL=kimi-k2.7');
|
||||
expect(content).toContain('MOA_KIMI_API_KEY=new-secret');
|
||||
});
|
||||
});
|
||||
277
src/settings-store-moa.ts
Normal file
277
src/settings-store-moa.ts
Normal file
@@ -0,0 +1,277 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
getMoaReferenceStatuses,
|
||||
probeMoaReferenceModel,
|
||||
type MoaModelConfig,
|
||||
type MoaReferenceStatus,
|
||||
} from './moa.js';
|
||||
|
||||
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 interface MoaModelSettingsUpdate {
|
||||
name: string;
|
||||
enabled?: boolean;
|
||||
model?: string;
|
||||
baseUrl?: string;
|
||||
apiFormat?: 'openai' | 'anthropic';
|
||||
apiKey?: string;
|
||||
}
|
||||
|
||||
export interface MoaSettingsUpdateInput {
|
||||
enabled?: boolean;
|
||||
models?: MoaModelSettingsUpdate[];
|
||||
}
|
||||
|
||||
const DEFAULT_MOA_MODEL_NAMES = ['kimi', 'glm'] as const;
|
||||
|
||||
function envFilePath(): string {
|
||||
return path.join(process.cwd(), '.env');
|
||||
}
|
||||
|
||||
function pickEnvValue(content: string, key: string): string | undefined {
|
||||
const re = new RegExp(`^${key}=(.*)$`, 'm');
|
||||
const match = content.match(re);
|
||||
if (!match) return undefined;
|
||||
return match[1].trim().replace(/^['"]|['"]$/g, '');
|
||||
}
|
||||
|
||||
function readEnvFile(): string {
|
||||
const file = envFilePath();
|
||||
if (!fs.existsSync(file)) return '';
|
||||
return fs.readFileSync(file, 'utf-8');
|
||||
}
|
||||
|
||||
function readEnvOrProcess(key: string): string | undefined {
|
||||
const fromFile = pickEnvValue(readEnvFile(), key);
|
||||
if (fromFile !== undefined) return fromFile;
|
||||
return process.env[key];
|
||||
}
|
||||
|
||||
function listSettingsEnvKeys(): string[] {
|
||||
const keys = new Set<string>();
|
||||
for (const line of readEnvFile().split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const eqIdx = trimmed.indexOf('=');
|
||||
if (eqIdx > 0) keys.add(trimmed.slice(0, eqIdx).trim());
|
||||
}
|
||||
for (const key of Object.keys(process.env)) keys.add(key);
|
||||
return [...keys].sort();
|
||||
}
|
||||
|
||||
function parseCommaList(value: string | undefined): string[] {
|
||||
return (value ?? '')
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function normalizeMoaModelName(name: string): string {
|
||||
const normalized = name.trim().toLowerCase();
|
||||
if (!/^[a-z0-9_]+$/.test(normalized)) {
|
||||
throw new Error(`invalid MoA model name: ${name}`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function moaPrefix(name: string): string {
|
||||
return `MOA_${normalizeMoaModelName(name).toUpperCase()}`;
|
||||
}
|
||||
|
||||
function sanitizeEnvValue(value: string): string {
|
||||
if (/[\r\n]/.test(value)) {
|
||||
throw new Error('env values must not contain newlines');
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function setOrInsertEnvLine(
|
||||
content: string,
|
||||
key: string,
|
||||
value: string,
|
||||
): string {
|
||||
const re = new RegExp(`^${key}=.*$`, 'm');
|
||||
if (re.test(content)) return content.replace(re, `${key}=${value}`);
|
||||
const trimmed = content.replace(/\s*$/, '');
|
||||
return `${trimmed}\n${key}=${value}\n`;
|
||||
}
|
||||
|
||||
function configuredMoaModelNames(): string[] {
|
||||
const names = new Set<string>(DEFAULT_MOA_MODEL_NAMES);
|
||||
for (const name of parseCommaList(readEnvOrProcess('MOA_REF_MODELS'))) {
|
||||
names.add(normalizeMoaModelName(name));
|
||||
}
|
||||
for (const key of listSettingsEnvKeys()) {
|
||||
const match = key.match(
|
||||
/^MOA_([A-Z0-9_]+)_(MODEL|BASE_URL|API_KEY|API_FORMAT)$/,
|
||||
);
|
||||
if (match) names.add(match[1].toLowerCase());
|
||||
}
|
||||
return [...names].sort((a, b) => {
|
||||
const defaultA = DEFAULT_MOA_MODEL_NAMES.indexOf(
|
||||
a as (typeof DEFAULT_MOA_MODEL_NAMES)[number],
|
||||
);
|
||||
const defaultB = DEFAULT_MOA_MODEL_NAMES.indexOf(
|
||||
b as (typeof DEFAULT_MOA_MODEL_NAMES)[number],
|
||||
);
|
||||
if (defaultA !== -1 || defaultB !== -1) {
|
||||
if (defaultA === -1) return 1;
|
||||
if (defaultB === -1) return -1;
|
||||
return defaultA - defaultB;
|
||||
}
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
}
|
||||
|
||||
function readMoaModelConfig(name: string): MoaModelConfig | null {
|
||||
const normalized = normalizeMoaModelName(name);
|
||||
const prefix = moaPrefix(normalized);
|
||||
const model = readEnvOrProcess(`${prefix}_MODEL`) ?? '';
|
||||
const baseUrl = readEnvOrProcess(`${prefix}_BASE_URL`) ?? '';
|
||||
const apiKey = readEnvOrProcess(`${prefix}_API_KEY`) ?? '';
|
||||
const rawFormat = readEnvOrProcess(`${prefix}_API_FORMAT`) ?? '';
|
||||
const apiFormat: 'openai' | 'anthropic' =
|
||||
rawFormat === 'anthropic' ? 'anthropic' : 'openai';
|
||||
if (!model || !baseUrl || !apiKey) return null;
|
||||
return { name: normalized, model, baseUrl, apiKey, apiFormat };
|
||||
}
|
||||
|
||||
export function getMoaSettings(): MoaSettingsSnapshot {
|
||||
const referenceModels = parseCommaList(
|
||||
readEnvOrProcess('MOA_REF_MODELS'),
|
||||
).map(normalizeMoaModelName);
|
||||
const active = new Set(referenceModels);
|
||||
const statuses = new Map(
|
||||
getMoaReferenceStatuses().map((status) => [status.model, status]),
|
||||
);
|
||||
return {
|
||||
enabled: readEnvOrProcess('MOA_ENABLED') === 'true',
|
||||
referenceModels,
|
||||
models: configuredMoaModelNames().map((name) => {
|
||||
const prefix = moaPrefix(name);
|
||||
const rawFormat = readEnvOrProcess(`${prefix}_API_FORMAT`) ?? '';
|
||||
const apiFormat: 'openai' | 'anthropic' =
|
||||
rawFormat === 'anthropic' ? 'anthropic' : 'openai';
|
||||
return {
|
||||
name,
|
||||
enabled: active.has(name),
|
||||
model: readEnvOrProcess(`${prefix}_MODEL`) ?? '',
|
||||
baseUrl: readEnvOrProcess(`${prefix}_BASE_URL`) ?? '',
|
||||
apiFormat,
|
||||
apiKeyConfigured: Boolean(readEnvOrProcess(`${prefix}_API_KEY`)),
|
||||
lastStatus: statuses.get(name) ?? null,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function updateMoaSettings(
|
||||
input: MoaSettingsUpdateInput,
|
||||
): MoaSettingsSnapshot {
|
||||
const file = envFilePath();
|
||||
let content = fs.existsSync(file) ? fs.readFileSync(file, 'utf-8') : '';
|
||||
|
||||
if (typeof input.enabled === 'boolean') {
|
||||
content = setOrInsertEnvLine(
|
||||
content,
|
||||
'MOA_ENABLED',
|
||||
input.enabled ? 'true' : 'false',
|
||||
);
|
||||
}
|
||||
|
||||
if (Array.isArray(input.models)) {
|
||||
const byName = new Map<string, MoaModelSettingsUpdate>();
|
||||
for (const update of input.models) {
|
||||
byName.set(normalizeMoaModelName(update.name), update);
|
||||
}
|
||||
content = applyMoaModelUpdates(content, byName);
|
||||
}
|
||||
|
||||
const tempPath = `${file}.tmp`;
|
||||
fs.writeFileSync(tempPath, content, { mode: 0o600 });
|
||||
fs.renameSync(tempPath, file);
|
||||
|
||||
return getMoaSettings();
|
||||
}
|
||||
|
||||
function applyMoaModelUpdates(
|
||||
content: string,
|
||||
byName: Map<string, MoaModelSettingsUpdate>,
|
||||
): string {
|
||||
let next = content;
|
||||
const allNames = new Set(configuredMoaModelNames());
|
||||
for (const name of byName.keys()) allNames.add(name);
|
||||
|
||||
const enabledNames: string[] = [];
|
||||
for (const name of allNames) {
|
||||
const update = byName.get(name);
|
||||
const currentlyEnabled = parseCommaList(readEnvOrProcess('MOA_REF_MODELS'))
|
||||
.map(normalizeMoaModelName)
|
||||
.includes(name);
|
||||
if (update?.enabled ?? currentlyEnabled) enabledNames.push(name);
|
||||
if (update) next = applyMoaModelUpdate(next, name, update);
|
||||
}
|
||||
|
||||
return setOrInsertEnvLine(next, 'MOA_REF_MODELS', enabledNames.join(','));
|
||||
}
|
||||
|
||||
function applyMoaModelUpdate(
|
||||
content: string,
|
||||
name: string,
|
||||
update: MoaModelSettingsUpdate,
|
||||
): string {
|
||||
let next = content;
|
||||
const prefix = moaPrefix(name);
|
||||
if (update.model !== undefined) {
|
||||
next = setOrInsertEnvLine(
|
||||
next,
|
||||
`${prefix}_MODEL`,
|
||||
sanitizeEnvValue(update.model),
|
||||
);
|
||||
}
|
||||
if (update.baseUrl !== undefined) {
|
||||
next = setOrInsertEnvLine(
|
||||
next,
|
||||
`${prefix}_BASE_URL`,
|
||||
sanitizeEnvValue(update.baseUrl),
|
||||
);
|
||||
}
|
||||
if (update.apiFormat !== undefined) {
|
||||
if (update.apiFormat !== 'openai' && update.apiFormat !== 'anthropic') {
|
||||
throw new Error(`invalid MoA API format for ${name}`);
|
||||
}
|
||||
next = setOrInsertEnvLine(next, `${prefix}_API_FORMAT`, update.apiFormat);
|
||||
}
|
||||
if (update.apiKey !== undefined && update.apiKey.trim() !== '') {
|
||||
next = setOrInsertEnvLine(
|
||||
next,
|
||||
`${prefix}_API_KEY`,
|
||||
sanitizeEnvValue(update.apiKey),
|
||||
);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export async function checkMoaModel(name: string): Promise<MoaReferenceStatus> {
|
||||
const config = readMoaModelConfig(name);
|
||||
if (!config) {
|
||||
throw new Error(`MoA model ${name} is missing model/baseUrl/apiKey`);
|
||||
}
|
||||
return probeMoaReferenceModel(config);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
FastModeSnapshot,
|
||||
ModelConfigSnapshot,
|
||||
} from './settings-store.js';
|
||||
import type { MoaSettingsSnapshot } from './settings-store-moa.js';
|
||||
|
||||
function jsonResponse(value: unknown, init?: ResponseInit): Response {
|
||||
return new Response(JSON.stringify(value), {
|
||||
@@ -42,6 +43,36 @@ const modelConfig: ModelConfigSnapshot = {
|
||||
|
||||
const fastMode: FastModeSnapshot = { codex: true, claude: false };
|
||||
|
||||
const moaSettings: MoaSettingsSnapshot = {
|
||||
enabled: true,
|
||||
referenceModels: ['kimi', 'glm'],
|
||||
models: [
|
||||
{
|
||||
name: 'kimi',
|
||||
enabled: true,
|
||||
model: 'kimi-k2.6',
|
||||
baseUrl: 'https://api.kimi.com/coding',
|
||||
apiFormat: 'anthropic',
|
||||
apiKeyConfigured: true,
|
||||
lastStatus: {
|
||||
model: 'kimi',
|
||||
checkedAt: '2026-04-30T00:00:00.000Z',
|
||||
ok: false,
|
||||
error: '402 Payment Required',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'glm',
|
||||
enabled: true,
|
||||
model: 'glm-5.1',
|
||||
baseUrl: 'https://open.bigmodel.cn/api/anthropic',
|
||||
apiFormat: 'anthropic',
|
||||
apiKeyConfigured: true,
|
||||
lastStatus: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function makeClaudeAccount(): ClaudeAccountSummary {
|
||||
return {
|
||||
index: 0,
|
||||
@@ -68,9 +99,17 @@ function makeDeps(
|
||||
): SettingsRouteDependencies {
|
||||
return {
|
||||
addClaudeAccountFromToken: () => ({ index: 2, accountId: null }),
|
||||
checkMoaModel: async (name) => ({
|
||||
model: name,
|
||||
checkedAt: '2026-04-30T00:01:00.000Z',
|
||||
ok: true,
|
||||
error: null,
|
||||
responseLength: 2,
|
||||
}),
|
||||
getActiveCodexSettingsIndex: () => 1,
|
||||
getFastMode: () => fastMode,
|
||||
getModelConfig: () => modelConfig,
|
||||
getMoaSettings: () => moaSettings,
|
||||
listClaudeAccounts: () => [makeClaudeAccount()],
|
||||
listCodexAccounts: () => [makeCodexAccount()],
|
||||
refreshAllCodexAccounts: async () => ({ refreshed: [1], failed: [] }),
|
||||
@@ -79,6 +118,7 @@ function makeDeps(
|
||||
setActiveCodexSettingsIndex: () => undefined,
|
||||
updateFastMode: () => fastMode,
|
||||
updateModelConfig: () => modelConfig,
|
||||
updateMoaSettings: () => moaSettings,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -115,6 +155,17 @@ describe('web dashboard settings routes', () => {
|
||||
expect(mode?.status).toBe(200);
|
||||
await expect(mode?.json()).resolves.toEqual(fastMode);
|
||||
|
||||
const moa = await route('/api/settings/moa');
|
||||
expect(moa?.status).toBe(200);
|
||||
const moaJson = (await moa?.json()) as MoaSettingsSnapshot;
|
||||
expect(moaJson.enabled).toBe(true);
|
||||
expect(moaJson.models.find((model) => model.name === 'kimi')).toMatchObject(
|
||||
{
|
||||
enabled: true,
|
||||
lastStatus: { ok: false, error: '402 Payment Required' },
|
||||
},
|
||||
);
|
||||
|
||||
const unmatched = await route('/api/overview');
|
||||
expect(unmatched).toBeNull();
|
||||
});
|
||||
@@ -136,6 +187,10 @@ describe('web dashboard settings routes', () => {
|
||||
calls.push(`models:${JSON.stringify(input)}`);
|
||||
return modelConfig;
|
||||
},
|
||||
updateMoaSettings: (input) => {
|
||||
calls.push(`moa:${JSON.stringify(input)}`);
|
||||
return moaSettings;
|
||||
},
|
||||
});
|
||||
|
||||
const modelUpdate = await route(
|
||||
@@ -174,11 +229,33 @@ describe('web dashboard settings routes', () => {
|
||||
ok: true,
|
||||
codexCurrentIndex: 1,
|
||||
});
|
||||
|
||||
const moa = await route(
|
||||
'/api/settings/moa',
|
||||
'PATCH',
|
||||
{ enabled: false, models: [{ name: 'kimi', enabled: false }] },
|
||||
deps,
|
||||
);
|
||||
expect(moa?.status).toBe(200);
|
||||
|
||||
const check = await route(
|
||||
'/api/settings/moa/check',
|
||||
'POST',
|
||||
{ name: 'glm' },
|
||||
deps,
|
||||
);
|
||||
expect(check?.status).toBe(200);
|
||||
await expect(check?.json()).resolves.toMatchObject({
|
||||
ok: true,
|
||||
status: { model: 'glm', ok: true },
|
||||
});
|
||||
|
||||
expect(calls).toEqual([
|
||||
'models:{"owner":{"model":"gpt-5.5"}}',
|
||||
'add:claude-token',
|
||||
'delete:codex:4',
|
||||
'current:4',
|
||||
'moa:{"enabled":false,"models":[{"name":"kimi","enabled":false}]}',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,12 @@ import {
|
||||
type FastModeSnapshot,
|
||||
type ModelConfigSnapshot,
|
||||
} from './settings-store.js';
|
||||
import {
|
||||
checkMoaModel,
|
||||
getMoaSettings,
|
||||
updateMoaSettings,
|
||||
type MoaSettingsSnapshot,
|
||||
} from './settings-store-moa.js';
|
||||
|
||||
type JsonResponse = (
|
||||
value: unknown,
|
||||
@@ -25,9 +31,11 @@ type JsonResponse = (
|
||||
|
||||
export interface SettingsRouteDependencies {
|
||||
addClaudeAccountFromToken: typeof addClaudeAccountFromToken;
|
||||
checkMoaModel: typeof checkMoaModel;
|
||||
getActiveCodexSettingsIndex: typeof getActiveCodexSettingsIndex;
|
||||
getFastMode: typeof getFastMode;
|
||||
getModelConfig: typeof getModelConfig;
|
||||
getMoaSettings: typeof getMoaSettings;
|
||||
listClaudeAccounts: typeof listClaudeAccounts;
|
||||
listCodexAccounts: typeof listCodexAccounts;
|
||||
refreshAllCodexAccounts: typeof refreshAllCodexAccounts;
|
||||
@@ -36,6 +44,7 @@ export interface SettingsRouteDependencies {
|
||||
setActiveCodexSettingsIndex: typeof setActiveCodexSettingsIndex;
|
||||
updateFastMode: typeof updateFastMode;
|
||||
updateModelConfig: typeof updateModelConfig;
|
||||
updateMoaSettings: typeof updateMoaSettings;
|
||||
}
|
||||
|
||||
interface SettingsRouteContext {
|
||||
@@ -47,9 +56,11 @@ interface SettingsRouteContext {
|
||||
|
||||
const defaultSettingsRouteDependencies: SettingsRouteDependencies = {
|
||||
addClaudeAccountFromToken,
|
||||
checkMoaModel,
|
||||
getActiveCodexSettingsIndex,
|
||||
getFastMode,
|
||||
getModelConfig,
|
||||
getMoaSettings,
|
||||
listClaudeAccounts,
|
||||
listCodexAccounts,
|
||||
refreshAllCodexAccounts,
|
||||
@@ -58,6 +69,7 @@ const defaultSettingsRouteDependencies: SettingsRouteDependencies = {
|
||||
setActiveCodexSettingsIndex,
|
||||
updateFastMode,
|
||||
updateModelConfig,
|
||||
updateMoaSettings,
|
||||
};
|
||||
|
||||
function readMethod(method: string): boolean {
|
||||
@@ -121,6 +133,43 @@ async function handleFastModeRoute(
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMoaSettingsRoute(
|
||||
request: Request,
|
||||
jsonResponse: JsonResponse,
|
||||
deps: SettingsRouteDependencies,
|
||||
): Promise<Response | null> {
|
||||
if (readMethod(request.method)) return jsonResponse(deps.getMoaSettings());
|
||||
if (request.method !== 'PUT' && request.method !== 'PATCH') return null;
|
||||
|
||||
const body = await readJsonObject(request, jsonResponse);
|
||||
if (body instanceof Response) return body;
|
||||
try {
|
||||
return jsonResponse(deps.updateMoaSettings(body));
|
||||
} catch (err) {
|
||||
return jsonResponse({ error: errorMessage(err) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMoaCheckRoute(
|
||||
request: Request,
|
||||
jsonResponse: JsonResponse,
|
||||
deps: SettingsRouteDependencies,
|
||||
): Promise<Response | null> {
|
||||
if (request.method !== 'POST') return null;
|
||||
|
||||
const body = await readJsonObject(request, jsonResponse);
|
||||
if (body instanceof Response) return body;
|
||||
const name = typeof body.name === 'string' ? body.name.trim() : '';
|
||||
if (!name)
|
||||
return jsonResponse({ error: 'name is required' }, { status: 400 });
|
||||
|
||||
try {
|
||||
return jsonResponse({ ok: true, status: await deps.checkMoaModel(name) });
|
||||
} catch (err) {
|
||||
return jsonResponse({ error: errorMessage(err) }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
function handleAccountsRoute(
|
||||
request: Request,
|
||||
jsonResponse: JsonResponse,
|
||||
@@ -250,6 +299,12 @@ export async function handleSettingsRoute({
|
||||
if (url.pathname === '/api/settings/fast-mode') {
|
||||
return handleFastModeRoute(request, jsonResponse, deps);
|
||||
}
|
||||
if (url.pathname === '/api/settings/moa') {
|
||||
return handleMoaSettingsRoute(request, jsonResponse, deps);
|
||||
}
|
||||
if (url.pathname === '/api/settings/moa/check') {
|
||||
return handleMoaCheckRoute(request, jsonResponse, deps);
|
||||
}
|
||||
if (url.pathname === '/api/settings/accounts/claude') {
|
||||
return handleClaudeAccountAddRoute(request, jsonResponse, deps);
|
||||
}
|
||||
@@ -282,4 +337,5 @@ export type {
|
||||
CodexAccountSummary,
|
||||
FastModeSnapshot,
|
||||
ModelConfigSnapshot,
|
||||
MoaSettingsSnapshot,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user