add codex goals settings toggle

This commit is contained in:
ejclaw
2026-05-02 00:06:26 +09:00
parent 5b544b69c6
commit e6f417c343
7 changed files with 265 additions and 0 deletions

View File

@@ -34,6 +34,7 @@ describe('SettingsPanel', () => {
expect(html).toContain('모델'); expect(html).toContain('모델');
expect(html).toContain('MoA 참조 모델'); expect(html).toContain('MoA 참조 모델');
expect(html).toContain('패스트 모드'); expect(html).toContain('패스트 모드');
expect(html).toContain('Codex 실험 기능');
expect(html).toContain('불러오는 중'); expect(html).toContain('불러오는 중');
expect(html).toContain('Claude'); expect(html).toContain('Claude');
expect(html).toContain('계정'); expect(html).toContain('계정');

View File

@@ -3,17 +3,20 @@ import { useEffect, useState } from 'react';
import { import {
type ClaudeAccountSummary, type ClaudeAccountSummary,
type CodexAccountSummary, type CodexAccountSummary,
type CodexFeatureSnapshot,
type FastModeSnapshot, type FastModeSnapshot,
type ModelConfigSnapshot, type ModelConfigSnapshot,
type ModelRoleConfig, type ModelRoleConfig,
addClaudeAccount, addClaudeAccount,
deleteAccount, deleteAccount,
fetchAccounts, fetchAccounts,
fetchCodexFeatures,
fetchFastMode, fetchFastMode,
fetchModelConfig, fetchModelConfig,
refreshAllCodexAccounts as refreshAllCodexAccountsApi, refreshAllCodexAccounts as refreshAllCodexAccountsApi,
refreshCodexAccount as refreshCodexAccountApi, refreshCodexAccount as refreshCodexAccountApi,
setCurrentCodexAccount as setCurrentCodexAccountApi, setCurrentCodexAccount as setCurrentCodexAccountApi,
updateCodexFeatures,
updateFastMode, updateFastMode,
updateModels, updateModels,
} from './api'; } from './api';
@@ -82,6 +85,8 @@ export function SettingsPanel({
<FastModeSettings /> <FastModeSettings />
<CodexFeatureSettings onRestartStack={onRestartStack} />
<AccountSettings onRestartStack={onRestartStack} /> <AccountSettings onRestartStack={onRestartStack} />
</div> </div>
); );
@@ -302,6 +307,104 @@ function FastModeSettings() {
); );
} }
function CodexFeatureSettings({
onRestartStack,
}: {
onRestartStack: () => void;
}) {
const [state, setState] = useState<CodexFeatureSnapshot | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [savedAt, setSavedAt] = useState<number | null>(null);
useEffect(() => {
let cancelled = false;
fetchCodexFeatures()
.then((snapshot) => {
if (cancelled) return;
setState(snapshot);
setError(null);
})
.catch((err) => {
if (cancelled) return;
setError(err instanceof Error ? err.message : String(err));
});
return () => {
cancelled = true;
};
}, []);
async function toggleGoals() {
if (!state) return;
const previous = state;
const optimistic = { ...state, goals: !state.goals };
setState(optimistic);
setBusy(true);
setError(null);
try {
const fresh = await updateCodexFeatures({ goals: optimistic.goals });
setState(fresh);
setSavedAt(Date.now());
} catch (err) {
setState(previous);
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}
return (
<section className="settings-section">
<h3>Codex </h3>
{error ? <p className="settings-error">{error}</p> : null}
{!state ? (
<p className="settings-hint"> </p>
) : (
<>
<label className="settings-toggle-row">
<span className="settings-toggle-label">
<span className="settings-toggle-title">/goal</span>
<small className="settings-hint">
CODEX_GOALS=true Codex 0.128 under-development goals
. OFF이며, .
</small>
</span>
<input
checked={state.goals}
disabled={busy}
onChange={() => void toggleGoals()}
type="checkbox"
/>
</label>
<div className="settings-actions">
{savedAt ? (
<small className="settings-hint">
. .
</small>
) : null}
<button
className="settings-restart"
disabled={busy}
onClick={() => {
if (
window.confirm(
'스택을 재시작하면 진행 중인 모든 에이전트 작업이 중단됩니다. 진행할까요?',
)
) {
onRestartStack();
}
}}
type="button"
>
</button>
</div>
</>
)}
</section>
);
}
function formatExpiry( function formatExpiry(
iso: string | null, iso: string | null,
): { label: string; cls: string } | null { ): { label: string; cls: string } | null {

View File

@@ -388,6 +388,10 @@ export interface FastModeSnapshot {
claude: boolean; claude: boolean;
} }
export interface CodexFeatureSnapshot {
goals: boolean;
}
export interface MoaReferenceStatus { export interface MoaReferenceStatus {
model: string; model: string;
checkedAt: string; checkedAt: string;
@@ -538,6 +542,34 @@ export async function updateFastMode(
return (await response.json()) as FastModeSnapshot; return (await response.json()) as FastModeSnapshot;
} }
export async function fetchCodexFeatures(): Promise<CodexFeatureSnapshot> {
return fetchJson('/api/settings/codex-features');
}
export async function updateCodexFeatures(
input: Partial<CodexFeatureSnapshot>,
): Promise<CodexFeatureSnapshot> {
const response = await fetch('/api/settings/codex-features', {
method: 'PUT',
headers: {
accept: 'application/json',
'content-type': 'application/json',
},
body: JSON.stringify(input),
});
if (!response.ok) {
let msg = `update codex features 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 CodexFeatureSnapshot;
}
export async function fetchMoaSettings(): Promise<MoaSettingsSnapshot> { export async function fetchMoaSettings(): Promise<MoaSettingsSnapshot> {
return fetchJson('/api/settings/moa'); return fetchJson('/api/settings/moa');
} }

View File

@@ -0,0 +1,45 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { getCodexFeatures, updateCodexFeatures } from './settings-store.js';
describe('settings-store Codex features', () => {
let tempDir: string;
let previousCwd: string;
let previousCodexGoals: string | undefined;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-settings-'));
previousCwd = process.cwd();
previousCodexGoals = process.env.CODEX_GOALS;
delete process.env.CODEX_GOALS;
process.chdir(tempDir);
});
afterEach(() => {
process.chdir(previousCwd);
if (previousCodexGoals === undefined) {
delete process.env.CODEX_GOALS;
} else {
process.env.CODEX_GOALS = previousCodexGoals;
}
fs.rmSync(tempDir, { recursive: true, force: true });
});
it('stores the Codex goals opt-in in the EJClaw .env file', () => {
expect(getCodexFeatures()).toEqual({ goals: false });
expect(updateCodexFeatures({ goals: true })).toEqual({ goals: true });
expect(fs.readFileSync(path.join(tempDir, '.env'), 'utf-8')).toContain(
'CODEX_GOALS=true',
);
expect(updateCodexFeatures({ goals: false })).toEqual({ goals: false });
expect(fs.readFileSync(path.join(tempDir, '.env'), 'utf-8')).toContain(
'CODEX_GOALS=false',
);
});
});

View File

@@ -56,6 +56,10 @@ export interface FastModeSnapshot {
claude: boolean; claude: boolean;
} }
export interface CodexFeatureSnapshot {
goals: boolean;
}
const ROLE_KEYS = ['OWNER', 'REVIEWER', 'ARBITER'] as const; const ROLE_KEYS = ['OWNER', 'REVIEWER', 'ARBITER'] as const;
function envFilePath(): string { function envFilePath(): string {
@@ -634,6 +638,36 @@ export function updateFastMode(
return getFastMode(); return getFastMode();
} }
function readCodexGoals(): boolean {
return readEnvOrProcess('CODEX_GOALS') === 'true';
}
function writeCodexGoals(value: boolean): void {
const file = envFilePath();
const content = fs.existsSync(file) ? fs.readFileSync(file, 'utf-8') : '';
const updated = setOrInsertEnvLine(
content,
'CODEX_GOALS',
value ? 'true' : 'false',
);
const tempPath = `${file}.tmp`;
fs.writeFileSync(tempPath, updated, { mode: 0o600 });
fs.renameSync(tempPath, file);
}
export function getCodexFeatures(): CodexFeatureSnapshot {
return {
goals: readCodexGoals(),
};
}
export function updateCodexFeatures(
input: Partial<CodexFeatureSnapshot>,
): CodexFeatureSnapshot {
if (typeof input.goals === 'boolean') writeCodexGoals(input.goals);
return getCodexFeatures();
}
export function removeAccountDirectory( export function removeAccountDirectory(
provider: 'claude' | 'codex', provider: 'claude' | 'codex',
index: number, index: number,

View File

@@ -7,6 +7,7 @@ import {
import type { import type {
ClaudeAccountSummary, ClaudeAccountSummary,
CodexAccountSummary, CodexAccountSummary,
CodexFeatureSnapshot,
FastModeSnapshot, FastModeSnapshot,
ModelConfigSnapshot, ModelConfigSnapshot,
} from './settings-store.js'; } from './settings-store.js';
@@ -42,6 +43,7 @@ const modelConfig: ModelConfigSnapshot = {
}; };
const fastMode: FastModeSnapshot = { codex: true, claude: false }; const fastMode: FastModeSnapshot = { codex: true, claude: false };
const codexFeatures: CodexFeatureSnapshot = { goals: false };
const moaSettings: MoaSettingsSnapshot = { const moaSettings: MoaSettingsSnapshot = {
enabled: true, enabled: true,
@@ -107,6 +109,7 @@ function makeDeps(
responseLength: 2, responseLength: 2,
}), }),
getActiveCodexSettingsIndex: () => 1, getActiveCodexSettingsIndex: () => 1,
getCodexFeatures: () => codexFeatures,
getFastMode: () => fastMode, getFastMode: () => fastMode,
getModelConfig: () => modelConfig, getModelConfig: () => modelConfig,
getMoaSettings: () => moaSettings, getMoaSettings: () => moaSettings,
@@ -116,6 +119,7 @@ function makeDeps(
refreshCodexAccount: async () => makeCodexAccount(), refreshCodexAccount: async () => makeCodexAccount(),
removeAccountDirectory: () => undefined, removeAccountDirectory: () => undefined,
setActiveCodexSettingsIndex: () => undefined, setActiveCodexSettingsIndex: () => undefined,
updateCodexFeatures: () => codexFeatures,
updateFastMode: () => fastMode, updateFastMode: () => fastMode,
updateModelConfig: () => modelConfig, updateModelConfig: () => modelConfig,
updateMoaSettings: () => moaSettings, updateMoaSettings: () => moaSettings,
@@ -155,6 +159,10 @@ describe('web dashboard settings routes', () => {
expect(mode?.status).toBe(200); expect(mode?.status).toBe(200);
await expect(mode?.json()).resolves.toEqual(fastMode); await expect(mode?.json()).resolves.toEqual(fastMode);
const features = await route('/api/settings/codex-features');
expect(features?.status).toBe(200);
await expect(features?.json()).resolves.toEqual(codexFeatures);
const moa = await route('/api/settings/moa'); const moa = await route('/api/settings/moa');
expect(moa?.status).toBe(200); expect(moa?.status).toBe(200);
const moaJson = (await moa?.json()) as MoaSettingsSnapshot; const moaJson = (await moa?.json()) as MoaSettingsSnapshot;
@@ -187,6 +195,10 @@ describe('web dashboard settings routes', () => {
calls.push(`models:${JSON.stringify(input)}`); calls.push(`models:${JSON.stringify(input)}`);
return modelConfig; return modelConfig;
}, },
updateCodexFeatures: (input) => {
calls.push(`codex-features:${JSON.stringify(input)}`);
return { goals: input.goals === true };
},
updateMoaSettings: (input) => { updateMoaSettings: (input) => {
calls.push(`moa:${JSON.stringify(input)}`); calls.push(`moa:${JSON.stringify(input)}`);
return moaSettings; return moaSettings;
@@ -230,6 +242,15 @@ describe('web dashboard settings routes', () => {
codexCurrentIndex: 1, codexCurrentIndex: 1,
}); });
const goals = await route(
'/api/settings/codex-features',
'PATCH',
{ goals: true },
deps,
);
expect(goals?.status).toBe(200);
await expect(goals?.json()).resolves.toEqual({ goals: true });
const moa = await route( const moa = await route(
'/api/settings/moa', '/api/settings/moa',
'PATCH', 'PATCH',
@@ -255,6 +276,7 @@ describe('web dashboard settings routes', () => {
'add:claude-token', 'add:claude-token',
'delete:codex:4', 'delete:codex:4',
'current:4', 'current:4',
'codex-features:{"goals":true}',
'moa:{"enabled":false,"models":[{"name":"kimi","enabled":false}]}', 'moa:{"enabled":false,"models":[{"name":"kimi","enabled":false}]}',
]); ]);
}); });

View File

@@ -1,6 +1,7 @@
import { import {
addClaudeAccountFromToken, addClaudeAccountFromToken,
getActiveCodexSettingsIndex, getActiveCodexSettingsIndex,
getCodexFeatures,
getFastMode, getFastMode,
getModelConfig, getModelConfig,
listClaudeAccounts, listClaudeAccounts,
@@ -9,9 +10,11 @@ import {
refreshCodexAccount, refreshCodexAccount,
removeAccountDirectory, removeAccountDirectory,
setActiveCodexSettingsIndex, setActiveCodexSettingsIndex,
updateCodexFeatures,
updateFastMode, updateFastMode,
updateModelConfig, updateModelConfig,
type ClaudeAccountSummary, type ClaudeAccountSummary,
type CodexFeatureSnapshot,
type CodexAccountSummary, type CodexAccountSummary,
type FastModeSnapshot, type FastModeSnapshot,
type ModelConfigSnapshot, type ModelConfigSnapshot,
@@ -33,6 +36,7 @@ export interface SettingsRouteDependencies {
addClaudeAccountFromToken: typeof addClaudeAccountFromToken; addClaudeAccountFromToken: typeof addClaudeAccountFromToken;
checkMoaModel: typeof checkMoaModel; checkMoaModel: typeof checkMoaModel;
getActiveCodexSettingsIndex: typeof getActiveCodexSettingsIndex; getActiveCodexSettingsIndex: typeof getActiveCodexSettingsIndex;
getCodexFeatures: typeof getCodexFeatures;
getFastMode: typeof getFastMode; getFastMode: typeof getFastMode;
getModelConfig: typeof getModelConfig; getModelConfig: typeof getModelConfig;
getMoaSettings: typeof getMoaSettings; getMoaSettings: typeof getMoaSettings;
@@ -42,6 +46,7 @@ export interface SettingsRouteDependencies {
refreshCodexAccount: typeof refreshCodexAccount; refreshCodexAccount: typeof refreshCodexAccount;
removeAccountDirectory: typeof removeAccountDirectory; removeAccountDirectory: typeof removeAccountDirectory;
setActiveCodexSettingsIndex: typeof setActiveCodexSettingsIndex; setActiveCodexSettingsIndex: typeof setActiveCodexSettingsIndex;
updateCodexFeatures: typeof updateCodexFeatures;
updateFastMode: typeof updateFastMode; updateFastMode: typeof updateFastMode;
updateModelConfig: typeof updateModelConfig; updateModelConfig: typeof updateModelConfig;
updateMoaSettings: typeof updateMoaSettings; updateMoaSettings: typeof updateMoaSettings;
@@ -58,6 +63,7 @@ const defaultSettingsRouteDependencies: SettingsRouteDependencies = {
addClaudeAccountFromToken, addClaudeAccountFromToken,
checkMoaModel, checkMoaModel,
getActiveCodexSettingsIndex, getActiveCodexSettingsIndex,
getCodexFeatures,
getFastMode, getFastMode,
getModelConfig, getModelConfig,
getMoaSettings, getMoaSettings,
@@ -67,6 +73,7 @@ const defaultSettingsRouteDependencies: SettingsRouteDependencies = {
refreshCodexAccount, refreshCodexAccount,
removeAccountDirectory, removeAccountDirectory,
setActiveCodexSettingsIndex, setActiveCodexSettingsIndex,
updateCodexFeatures,
updateFastMode, updateFastMode,
updateModelConfig, updateModelConfig,
updateMoaSettings, updateMoaSettings,
@@ -133,6 +140,23 @@ async function handleFastModeRoute(
} }
} }
async function handleCodexFeaturesRoute(
request: Request,
jsonResponse: JsonResponse,
deps: SettingsRouteDependencies,
): Promise<Response | null> {
if (readMethod(request.method)) return jsonResponse(deps.getCodexFeatures());
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.updateCodexFeatures(body));
} catch (err) {
return jsonResponse({ error: errorMessage(err) }, { status: 500 });
}
}
async function handleMoaSettingsRoute( async function handleMoaSettingsRoute(
request: Request, request: Request,
jsonResponse: JsonResponse, jsonResponse: JsonResponse,
@@ -299,6 +323,9 @@ export async function handleSettingsRoute({
if (url.pathname === '/api/settings/fast-mode') { if (url.pathname === '/api/settings/fast-mode') {
return handleFastModeRoute(request, jsonResponse, deps); return handleFastModeRoute(request, jsonResponse, deps);
} }
if (url.pathname === '/api/settings/codex-features') {
return handleCodexFeaturesRoute(request, jsonResponse, deps);
}
if (url.pathname === '/api/settings/moa') { if (url.pathname === '/api/settings/moa') {
return handleMoaSettingsRoute(request, jsonResponse, deps); return handleMoaSettingsRoute(request, jsonResponse, deps);
} }
@@ -335,6 +362,7 @@ export async function handleSettingsRoute({
export type { export type {
ClaudeAccountSummary, ClaudeAccountSummary,
CodexAccountSummary, CodexAccountSummary,
CodexFeatureSnapshot,
FastModeSnapshot, FastModeSnapshot,
ModelConfigSnapshot, ModelConfigSnapshot,
MoaSettingsSnapshot, MoaSettingsSnapshot,