Add room skill setting mutations (#133)

* add gated codex goals support

* sync README SDK versions

* bump claude agent sdk

* add codex goals settings toggle

* reuse status dashboard message and simplify settings actions

* refine settings page UX

* fix settings nav hash routing

* add dashboard UX verification

* refine dashboard settings and inbox UX

* remove inbox top-level navigation

* remove dashboard health top-level navigation

* fix: allow runtime image attachment paths

* feat: configure attachment allowlist dirs

* fix: clean duplicate dashboard status messages

* fix: poll dashboard duplicate cleanup between status updates

* fix: delete dashboard duplicates on create

* Refine settings IA with tabbed sections

* Add read-only runtime inventory settings

* Fix runtime inventory MCP JSON parsing

* Add room skill settings inventory

* Add room skill setting mutations
This commit is contained in:
Eyejoker
2026-05-04 03:27:35 +09:00
committed by GitHub
parent e90703d124
commit 85da2b918c
11 changed files with 577 additions and 17 deletions

View File

@@ -171,6 +171,43 @@
font-size: 0.86rem;
}
.runtime-room-skill-toggles {
display: grid;
gap: 8px;
margin-top: 8px;
}
.runtime-room-skill-toggle {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 10px;
min-height: 44px;
border-radius: 12px;
background: rgba(15, 23, 42, 0.38);
padding: 8px 10px;
}
.runtime-room-skill-toggle input {
width: 18px;
height: 18px;
}
.runtime-room-skill-toggle > span {
display: grid;
gap: 2px;
min-width: 0;
}
.runtime-room-skill-toggle strong {
color: var(--text);
font-size: 0.84rem;
}
.runtime-room-skill-toggle small {
color: var(--muted);
}
@media (max-width: 980px) {
.runtime-summary-card,
.runtime-skill-grid,

View File

@@ -3,11 +3,13 @@ import { useEffect, useState } from 'react';
import {
fetchRoomSkillSettings,
fetchRuntimeInventory,
updateRoomSkillSetting,
type RuntimeAgentInventory,
type RuntimeInventorySnapshot,
type RuntimePathSnapshot,
type RuntimeSkillDirSnapshot,
type RoomSkillCatalogItem,
type RoomSkillSettingUpdateInput,
type RoomSkillSettingsSnapshot,
} from './api';
import { SettingsSectionHeading } from './SettingsPanelChrome';
@@ -106,8 +108,12 @@ function agentLabel(agentType: 'claude-code' | 'codex') {
}
function RoomSkillPolicyCard({
onToggle,
savingKey,
snapshot,
}: {
onToggle: (input: RoomSkillSettingUpdateInput) => void;
savingKey: string | null;
snapshot: RoomSkillSettingsSnapshot | null;
}) {
if (!snapshot) {
@@ -159,6 +165,7 @@ function RoomSkillPolicyCard({
const disabledNames = agent.disabledSkillIds
.map((id) => catalogById.get(id)?.displayName ?? id)
.slice(0, 3);
const enabledIds = new Set(agent.effectiveEnabledSkillIds);
return (
<div
className="runtime-room-agent-policy"
@@ -177,6 +184,39 @@ function RoomSkillPolicyCard({
{disabledNames.length > 0 ? (
<small>OFF: {disabledNames.join(', ')}</small>
) : null}
<div className="runtime-room-skill-toggles">
{agent.availableSkillIds.map((skillId) => {
const skill = catalogById.get(skillId);
const key = `${room.jid}:${agent.agentType}:${skillId}`;
return (
<label
className="runtime-room-skill-toggle"
key={skillId}
>
<input
checked={enabledIds.has(skillId)}
disabled={savingKey !== null}
onChange={(event) =>
onToggle({
roomJid: room.jid,
agentType: agent.agentType,
skillId,
enabled: event.currentTarget.checked,
})
}
type="checkbox"
/>
<span>
<strong>{skill?.displayName ?? skillId}</strong>
<small>
{skill?.scope ?? 'unknown'}
{savingKey === key ? ' · 저장 중' : ''}
</small>
</span>
</label>
);
})}
</div>
</div>
);
})}
@@ -201,6 +241,10 @@ export function RuntimeInventorySettings() {
const [roomSkillSnapshot, setRoomSkillSnapshot] =
useState<RoomSkillSettingsSnapshot | null>(null);
const [error, setError] = useState<string | null>(null);
const [roomSkillError, setRoomSkillError] = useState<string | null>(null);
const [savingRoomSkillKey, setSavingRoomSkillKey] = useState<string | null>(
null,
);
useEffect(() => {
let cancelled = false;
@@ -220,6 +264,20 @@ export function RuntimeInventorySettings() {
};
}, []);
async function handleRoomSkillToggle(input: RoomSkillSettingUpdateInput) {
const key = `${input.roomJid}:${input.agentType}:${input.skillId}`;
setSavingRoomSkillKey(key);
setRoomSkillError(null);
try {
const next = await updateRoomSkillSetting(input);
setRoomSkillSnapshot(next);
} catch (err) {
setRoomSkillError(err instanceof Error ? err.message : String(err));
} finally {
setSavingRoomSkillKey(null);
}
}
return (
<section
aria-labelledby="settings-runtime-tab"
@@ -255,7 +313,16 @@ export function RuntimeInventorySettings() {
<AgentInventoryCard title="Codex" inventory={snapshot.codex} />
<AgentInventoryCard title="Claude Code" inventory={snapshot.claude} />
<RoomSkillPolicyCard snapshot={roomSkillSnapshot} />
{roomSkillError ? (
<p className="settings-error">{roomSkillError}</p>
) : null}
<RoomSkillPolicyCard
onToggle={(input) => {
void handleRoomSkillToggle(input);
}}
savingKey={savingRoomSkillKey}
snapshot={roomSkillSnapshot}
/>
<article className="runtime-agent-card">
<header className="runtime-card-head">

View File

@@ -473,6 +473,13 @@ export interface RoomSkillSettingsSnapshot {
rooms: RoomSkillPolicyRoom[];
}
export interface RoomSkillSettingUpdateInput {
roomJid: string;
agentType: 'claude-code' | 'codex';
skillId: string;
enabled: boolean;
}
export interface MoaReferenceStatus {
model: string;
checkedAt: string;
@@ -635,6 +642,30 @@ export async function fetchRoomSkillSettings(): Promise<RoomSkillSettingsSnapsho
return fetchJson('/api/settings/room-skills');
}
export async function updateRoomSkillSetting(
input: RoomSkillSettingUpdateInput,
): Promise<RoomSkillSettingsSnapshot> {
const response = await fetch('/api/settings/room-skills', {
method: 'PATCH',
headers: {
accept: 'application/json',
'content-type': 'application/json',
},
body: JSON.stringify(input),
});
if (!response.ok) {
let msg = `update room skill 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 RoomSkillSettingsSnapshot;
}
export async function updateCodexFeatures(
input: Partial<CodexFeatureSnapshot>,
): Promise<CodexFeatureSnapshot> {

View File

@@ -11,6 +11,8 @@ interface MockApiState {
codexFeatureUpdates: number;
ciWatcherFailures: number;
restartRequests: number;
roomSkillUpdates: number;
roomSkillsDisableCodexBrowser: boolean;
}
const seriousImpacts = new Set(['serious', 'critical']);
@@ -27,7 +29,7 @@ async function main() {
'settings nav keeps hash route stable',
browser,
baseUrl,
async (page) => {
async (page, state) => {
await openSettings(page, baseUrl);
const originalUrl = page.url();
@@ -52,6 +54,17 @@ async function main() {
await assertVisible(page.locator('#settings-runtime'));
await page.getByText('Runtime inventory').waitFor();
await page.getByText('EJClaw bridge').waitFor();
const codexPolicy = page
.locator('.runtime-room-agent-policy')
.filter({ hasText: 'Codex' })
.first();
const codexBrowserToggle = codexPolicy
.locator('input[type="checkbox"]')
.first();
assert.equal(await codexBrowserToggle.isChecked(), true);
await codexBrowserToggle.click();
await codexPolicy.getByText('1개 OFF').waitFor();
assert.equal(state.roomSkillUpdates, 1);
await page
.locator(
@@ -225,6 +238,8 @@ function createMockApiState(): MockApiState {
codexFeatureUpdates: 0,
ciWatcherFailures: 0,
restartRequests: 0,
roomSkillUpdates: 0,
roomSkillsDisableCodexBrowser: false,
};
}
@@ -319,8 +334,7 @@ async function handleMockApi(route: Route, state: MockApiState) {
return;
}
if (method === 'GET' && url.pathname === '/api/settings/room-skills') {
await fulfillJson(route, roomSkillSettingsFixture());
if (await handleMockRoomSkillRoute(route, state, url, method)) {
return;
}
@@ -419,6 +433,33 @@ async function handleMockApi(route: Route, state: MockApiState) {
);
}
async function handleMockRoomSkillRoute(
route: Route,
state: MockApiState,
url: URL,
method: string,
): Promise<boolean> {
if (url.pathname !== '/api/settings/room-skills') return false;
if (method === 'GET') {
await fulfillJson(route, roomSkillSettingsFixture(state));
return true;
}
if (method !== 'PATCH' && method !== 'PUT') return false;
const body = parseJsonBody(route.request().postData());
if (
body.roomJid === 'room@example' &&
body.agentType === 'codex' &&
body.skillId === 'codex-user:agent-browser' &&
body.enabled === false
) {
state.roomSkillUpdates += 1;
state.roomSkillsDisableCodexBrowser = true;
}
await fulfillJson(route, roomSkillSettingsFixture(state));
return true;
}
function mockOverview(state: MockApiState) {
return {
generatedAt: MOCK_TIME,
@@ -588,7 +629,8 @@ function runtimeInventoryFixture() {
};
}
function roomSkillSettingsFixture() {
function roomSkillSettingsFixture(state?: MockApiState) {
const codexDisabled = state?.roomSkillsDisableCodexBrowser === true;
return {
generatedAt: '2026-05-04T00:00:00.000Z',
catalog: [
@@ -629,17 +671,16 @@ function roomSkillSettingsFixture() {
agents: [
{
agentType: 'codex',
mode: 'all-enabled',
mode: codexDisabled ? 'custom' : 'all-enabled',
availableSkillIds: [
'codex-user:agent-browser',
'runner:agent-browser',
],
disabledSkillIds: [],
disabledSkillIds: codexDisabled ? ['codex-user:agent-browser'] : [],
explicitEnabledSkillIds: [],
effectiveEnabledSkillIds: [
'codex-user:agent-browser',
'runner:agent-browser',
],
effectiveEnabledSkillIds: codexDisabled
? ['runner:agent-browser']
: ['codex-user:agent-browser', 'runner:agent-browser'],
},
{
agentType: 'claude-code',

View File

@@ -43,6 +43,7 @@ export {
createTask,
deleteAllSessionsForGroup,
deleteSession,
deleteStoredRoomSkillOverride,
deleteTask,
findDuplicateCiWatcher,
getAllRoomBindings,
@@ -72,6 +73,7 @@ export {
setRouterStateForService,
setSession,
setSessionForAgentType,
upsertStoredRoomSkillOverride,
updateRegisteredGroupName,
updateTask,
updateTaskAfterRun,

View File

@@ -38,6 +38,14 @@ export interface StoredRoomSkillOverride {
updatedAt: string;
}
export interface StoredRoomSkillOverrideInput {
chatJid: string;
agentType: AgentType;
skillScope: string;
skillName: string;
enabled: boolean;
}
export interface AssignRoomInput {
name: string;
roomMode?: RoomMode;
@@ -373,6 +381,56 @@ export function getStoredRoomSkillOverridesFromDatabase(
.filter((row): row is StoredRoomSkillOverride => Boolean(row));
}
export function upsertStoredRoomSkillOverrideInDatabase(
database: Database,
input: StoredRoomSkillOverrideInput,
): void {
const agentType = normalizeStoredAgentType(input.agentType);
if (!agentType) {
throw new Error(`Unsupported agent type: ${input.agentType}`);
}
const now = new Date().toISOString();
database
.prepare(
`INSERT INTO room_skill_overrides (
chat_jid, agent_type, skill_scope, skill_name, enabled,
created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(chat_jid, agent_type, skill_scope, skill_name)
DO UPDATE SET
enabled = excluded.enabled,
updated_at = excluded.updated_at`,
)
.run(
input.chatJid,
agentType,
input.skillScope,
input.skillName,
input.enabled ? 1 : 0,
now,
now,
);
}
export function deleteStoredRoomSkillOverrideFromDatabase(
database: Database,
input: Omit<StoredRoomSkillOverrideInput, 'enabled'>,
): void {
const agentType = normalizeStoredAgentType(input.agentType);
if (!agentType) {
throw new Error(`Unsupported agent type: ${input.agentType}`);
}
database
.prepare(
`DELETE FROM room_skill_overrides
WHERE chat_jid = ?
AND agent_type = ?
AND skill_scope = ?
AND skill_name = ?`,
)
.run(input.chatJid, agentType, input.skillScope, input.skillName);
}
function getStoredRoomModeRowFromDatabase(
database: Database,
chatJid: string,

View File

@@ -28,6 +28,7 @@ import {
assignRoomInDatabase,
clearExplicitRoomModeInDatabase,
deleteStoredRoomSettingsForTestsInDatabase,
deleteStoredRoomSkillOverrideFromDatabase,
getAllRoomBindingsFromDatabase,
getEffectiveRoomModeFromDatabase,
getEffectiveRuntimeRoomModeFromDatabase,
@@ -41,7 +42,9 @@ import {
setRegisteredGroupForTestsInDatabase,
setStoredRoomOwnerAgentTypeForTestsInDatabase,
type StoredRoomSkillOverride,
type StoredRoomSkillOverrideInput,
updateRegisteredGroupNameInDatabase,
upsertStoredRoomSkillOverrideInDatabase,
} from './rooms.js';
import { type StoredRoomSettings } from './room-registration.js';
import {
@@ -373,6 +376,18 @@ export function getStoredRoomSkillOverrides(
return getStoredRoomSkillOverridesFromDatabase(db, chatJid);
}
export function upsertStoredRoomSkillOverride(
input: StoredRoomSkillOverrideInput,
): void {
upsertStoredRoomSkillOverrideInDatabase(requireDatabase(), input);
}
export function deleteStoredRoomSkillOverride(
input: Omit<StoredRoomSkillOverrideInput, 'enabled'>,
): void {
deleteStoredRoomSkillOverrideFromDatabase(requireDatabase(), input);
}
export function getExplicitRoomMode(chatJid: string): RoomMode | undefined {
return getExplicitRoomModeFromDatabase(requireDatabase(), chatJid);
}

View File

@@ -3,7 +3,11 @@ import { Database } from 'bun:sqlite';
import { describe, expect, it } from 'vitest';
import { initializeDatabaseSchema } from './db/bootstrap.js';
import { getStoredRoomSkillOverridesFromDatabase } from './db/rooms.js';
import {
deleteStoredRoomSkillOverrideFromDatabase,
getStoredRoomSkillOverridesFromDatabase,
upsertStoredRoomSkillOverrideInDatabase,
} from './db/rooms.js';
import {
buildRoomSkillSettingsSnapshot,
type RoomSkillSettingsBuildInput,
@@ -146,6 +150,39 @@ describe('room skill settings', () => {
});
});
it('keeps default mode when explicit enable rows do not change effective skills', () => {
const snapshot = buildRoomSkillSettingsSnapshot({
generatedAt: '2026-05-04T00:00:00.000Z',
inventory: inventoryFixture(),
roomBindings: {
'room-1': {
name: 'Room One',
folder: 'room-one',
added_at: '2026-05-04T00:00:00.000Z',
agentType: 'codex',
},
},
registeredAgentTypesByJid: new Map([['room-1', ['codex']]]),
overrides: [
{
chatJid: 'room-1',
agentType: 'codex',
skillScope: 'runner',
skillName: 'runner-browser',
enabled: true,
createdAt: '2026-05-04T00:00:00.000Z',
updatedAt: '2026-05-04T00:00:00.000Z',
},
],
});
expect(snapshot.rooms[0]?.agents[0]).toMatchObject({
mode: 'all-enabled',
explicitEnabledSkillIds: ['runner:runner-browser'],
effectiveEnabledSkillIds: ['codex-user:browser', 'runner:runner-browser'],
});
});
it('reads normalized room skill overrides from the database', () => {
const database = new Database(':memory:');
try {
@@ -192,4 +229,58 @@ describe('room skill settings', () => {
database.close();
}
});
it('upserts and deletes room skill overrides in the database', () => {
const database = new Database(':memory:');
try {
initializeDatabaseSchema(database);
database
.prepare(
`INSERT INTO room_settings (
chat_jid, room_mode, mode_source, name, folder, updated_at
) VALUES (?, 'single', 'explicit', ?, ?, ?)`,
)
.run('room-1', 'Room One', 'room-one', '2026-05-04T00:00:00.000Z');
upsertStoredRoomSkillOverrideInDatabase(database, {
chatJid: 'room-1',
agentType: 'codex',
skillScope: 'runner',
skillName: 'agent-browser',
enabled: false,
});
expect(getStoredRoomSkillOverridesFromDatabase(database)).toMatchObject([
{
chatJid: 'room-1',
agentType: 'codex',
skillScope: 'runner',
skillName: 'agent-browser',
enabled: false,
},
]);
upsertStoredRoomSkillOverrideInDatabase(database, {
chatJid: 'room-1',
agentType: 'codex',
skillScope: 'runner',
skillName: 'agent-browser',
enabled: true,
});
expect(
getStoredRoomSkillOverridesFromDatabase(database)[0],
).toMatchObject({
enabled: true,
});
deleteStoredRoomSkillOverrideFromDatabase(database, {
chatJid: 'room-1',
agentType: 'codex',
skillScope: 'runner',
skillName: 'agent-browser',
});
expect(getStoredRoomSkillOverridesFromDatabase(database)).toEqual([]);
} finally {
database.close();
}
});
});

View File

@@ -1,9 +1,11 @@
import path from 'node:path';
import {
deleteStoredRoomSkillOverride,
getAllRoomBindings,
getRegisteredAgentTypesForJid,
getStoredRoomSkillOverrides,
upsertStoredRoomSkillOverride,
} from './db.js';
import {
getRuntimeInventory,
@@ -57,6 +59,23 @@ export interface RoomSkillSettingsBuildInput {
overrides?: StoredRoomSkillOverride[];
}
export interface RoomSkillSettingUpdateInput {
roomJid: string;
agentType: string;
skillId: string;
enabled: boolean;
}
export class RoomSkillSettingsError extends Error {
constructor(
message: string,
readonly status: number,
) {
super(message);
this.name = 'RoomSkillSettingsError';
}
}
function skillId(scope: RoomSkillScope, name: string): string {
return `${scope}:${name}`;
}
@@ -134,6 +153,26 @@ function overrideKey(
return `${jid}\u0000${agentType}\u0000${scope}\u0000${name}`;
}
function parseSkillId(id: string): { scope: RoomSkillScope; name: string } {
const separator = id.indexOf(':');
if (separator <= 0 || separator === id.length - 1) {
throw new RoomSkillSettingsError('skillId must be "<scope>:<name>"', 400);
}
const scope = id.slice(0, separator);
const name = id.slice(separator + 1);
if (scope !== 'codex-user' && scope !== 'claude-user' && scope !== 'runner') {
throw new RoomSkillSettingsError(`Unsupported skill scope: ${scope}`, 400);
}
return { scope, name };
}
function normalizeRoomSkillAgentType(agentType: string): AgentType {
if (agentType === 'codex' || agentType === 'claude-code') {
return agentType;
}
throw new RoomSkillSettingsError(`Unsupported agent type: ${agentType}`, 400);
}
function resolveRoomAgentTypes(
jid: string,
group: RegisteredGroup,
@@ -202,7 +241,8 @@ export function buildRoomSkillSettingsSnapshot({
return {
agentType,
mode:
disabledSkillIds.length > 0 || explicitEnabledSkillIds.length > 0
disabledSkillIds.length > 0 ||
effectiveEnabledSkillIds.length !== available.length
? 'custom'
: 'all-enabled',
availableSkillIds: available.map((skill) => skill.id),
@@ -237,3 +277,53 @@ export function getRoomSkillSettings(): RoomSkillSettingsSnapshot {
overrides: getStoredRoomSkillOverrides(),
});
}
export function updateRoomSkillSetting(
input: RoomSkillSettingUpdateInput,
): RoomSkillSettingsSnapshot {
const roomJid = input.roomJid.trim();
const agentType = normalizeRoomSkillAgentType(input.agentType);
const { scope, name } = parseSkillId(input.skillId.trim());
const before = getRoomSkillSettings();
const room = before.rooms.find((candidate) => candidate.jid === roomJid);
if (!room) {
throw new RoomSkillSettingsError('Room not found', 404);
}
const agent = room.agents.find(
(candidate) => candidate.agentType === agentType,
);
if (!agent) {
throw new RoomSkillSettingsError(
'Agent is not registered for this room',
400,
);
}
const skill = before.catalog.find(
(candidate) => candidate.scope === scope && candidate.name === name,
);
if (!skill || !agent.availableSkillIds.includes(skill.id)) {
throw new RoomSkillSettingsError(
'Skill is not available for this room agent',
400,
);
}
if (input.enabled) {
deleteStoredRoomSkillOverride({
chatJid: roomJid,
agentType,
skillScope: scope,
skillName: name,
});
} else {
upsertStoredRoomSkillOverride({
chatJid: roomJid,
agentType,
skillScope: scope,
skillName: name,
enabled: false,
});
}
return getRoomSkillSettings();
}

View File

@@ -241,6 +241,7 @@ function makeDeps(
updateFastMode: () => fastMode,
updateModelConfig: () => modelConfig,
updateMoaSettings: () => moaSettings,
updateRoomSkillSetting: () => roomSkillSettings,
...overrides,
};
}
@@ -336,6 +337,25 @@ describe('web dashboard settings routes', () => {
calls.push(`moa:${JSON.stringify(input)}`);
return moaSettings;
},
updateRoomSkillSetting: (input) => {
calls.push(`room-skill:${JSON.stringify(input)}`);
return {
...roomSkillSettings,
rooms: [
{
...roomSkillSettings.rooms[0]!,
agents: [
{
...roomSkillSettings.rooms[0]!.agents[0]!,
mode: 'custom',
disabledSkillIds: [input.skillId],
effectiveEnabledSkillIds: [],
},
],
},
],
};
},
});
const modelUpdate = await route(
@@ -404,6 +424,31 @@ describe('web dashboard settings routes', () => {
status: { model: 'glm', ok: true },
});
const roomSkill = await route(
'/api/settings/room-skills',
'PATCH',
{
roomJid: 'room@example',
agentType: 'codex',
skillId: 'runner:agent-browser',
enabled: false,
},
deps,
);
expect(roomSkill?.status).toBe(200);
await expect(roomSkill?.json()).resolves.toMatchObject({
rooms: [
{
agents: [
{
mode: 'custom',
disabledSkillIds: ['runner:agent-browser'],
},
],
},
],
});
expect(calls).toEqual([
'models:{"owner":{"model":"gpt-5.5"}}',
'add:claude-token',
@@ -411,6 +456,42 @@ describe('web dashboard settings routes', () => {
'current:4',
'codex-features:{"goals":true}',
'moa:{"enabled":false,"models":[{"name":"kimi","enabled":false}]}',
'room-skill:{"roomJid":"room@example","agentType":"codex","skillId":"runner:agent-browser","enabled":false}',
]);
});
it('rejects unsupported room skill methods with 405', async () => {
const response = await route('/api/settings/room-skills', 'DELETE');
expect(response?.status).toBe(405);
expect(response?.headers.get('allow')).toBe('GET, HEAD, PATCH, PUT');
await expect(response?.json()).resolves.toEqual({
error: 'Method not allowed',
});
});
it('validates room skill mutation payloads', async () => {
const missingEnabled = await route('/api/settings/room-skills', 'PATCH', {
roomJid: 'room@example',
agentType: 'codex',
skillId: 'runner:agent-browser',
});
expect(missingEnabled?.status).toBe(400);
const invalid = await route(
'/api/settings/room-skills',
'PATCH',
{
roomJid: 'room@example',
agentType: 'codex',
skillId: 'runner:agent-browser',
enabled: false,
},
makeDeps({
updateRoomSkillSetting: () => {
throw new Error('boom');
},
}),
);
expect(invalid?.status).toBe(500);
});
});

View File

@@ -25,7 +25,10 @@ import {
} from './runtime-inventory.js';
import {
getRoomSkillSettings,
RoomSkillSettingsError,
updateRoomSkillSetting,
type RoomSkillSettingsSnapshot,
type RoomSkillSettingUpdateInput,
} from './room-skill-settings.js';
import {
checkMoaModel,
@@ -60,6 +63,7 @@ export interface SettingsRouteDependencies {
updateFastMode: typeof updateFastMode;
updateModelConfig: typeof updateModelConfig;
updateMoaSettings: typeof updateMoaSettings;
updateRoomSkillSetting: typeof updateRoomSkillSetting;
}
interface SettingsRouteContext {
@@ -89,6 +93,7 @@ const defaultSettingsRouteDependencies: SettingsRouteDependencies = {
updateFastMode,
updateModelConfig,
updateMoaSettings,
updateRoomSkillSetting,
};
function readMethod(method: string): boolean {
@@ -118,6 +123,19 @@ function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
function methodNotAllowed(
jsonResponse: JsonResponse,
allowed: string[],
): Response {
return jsonResponse(
{ error: 'Method not allowed' },
{
status: 405,
headers: { Allow: allowed.join(', ') },
},
);
}
async function handleModelSettingsRoute(
request: Request,
jsonResponse: JsonResponse,
@@ -228,13 +246,42 @@ function handleRuntimeInventoryRoute(
return jsonResponse(deps.getRuntimeInventory());
}
function handleRoomSkillsRoute(
async function handleRoomSkillsRoute(
request: Request,
jsonResponse: JsonResponse,
deps: SettingsRouteDependencies,
): Response | null {
if (!readMethod(request.method)) return null;
): Promise<Response> {
if (readMethod(request.method)) {
return jsonResponse(deps.getRoomSkillSettings());
}
if (request.method !== 'PATCH' && request.method !== 'PUT') {
return methodNotAllowed(jsonResponse, ['GET', 'HEAD', 'PATCH', 'PUT']);
}
const body = await readJsonObject(request, jsonResponse);
if (body instanceof Response) return body;
const input: RoomSkillSettingUpdateInput = {
roomJid: typeof body.roomJid === 'string' ? body.roomJid : '',
agentType: typeof body.agentType === 'string' ? body.agentType : '',
skillId: typeof body.skillId === 'string' ? body.skillId : '',
enabled: body.enabled === true,
};
if (typeof body.enabled !== 'boolean') {
return jsonResponse(
{ error: 'enabled must be a boolean' },
{ status: 400 },
);
}
try {
return jsonResponse(deps.updateRoomSkillSetting(input));
} catch (err) {
if (err instanceof RoomSkillSettingsError) {
return jsonResponse({ error: err.message }, { status: err.status });
}
return jsonResponse({ error: errorMessage(err) }, { status: 500 });
}
}
async function handleClaudeAccountAddRoute(