Skip to content

Commit 44fd666

Browse files
authored
Merge pull request #5 from adao-max/feat/persist-imagegen-config
feat: persist image generation config
2 parents 7f69ea2 + 9f84bb0 commit 44fd666

20 files changed

Lines changed: 3139 additions & 85 deletions

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,3 +94,9 @@ coverage/
9494

9595
# Logs
9696
*.log.idea/
97+
apps/*/logs/
98+
nohup.out
99+
100+
# Docs (local planning & notes)
101+
docs/
102+
.worktrees/

apps/webuiapps/package.json

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,19 @@
1010
"build:test": "NODE_ENV=test vite build",
1111
"build": "NODE_ENV=production vite build",
1212
"build:analyze": "NODE_ENV=production ANALYZE=analyze vite build",
13-
"preview": "vite preview"
13+
"preview": "vite preview",
14+
"test": "vitest run",
15+
"test:watch": "vitest",
16+
"test:coverage": "vitest run --coverage"
1417
},
1518
"dependencies": {
1619
"framer-motion": "^12.34.0"
20+
},
21+
"devDependencies": {
22+
"@vitest/coverage-istanbul": "^1.6.1",
23+
"@vitest/coverage-v8": "^1.6.1",
24+
"happy-dom": "^14.0.0",
25+
"jsdom": "^25.0.0",
26+
"vitest": "^1.6.1"
1727
}
1828
}

apps/webuiapps/src/components/ChatPanel/index.tsx

Lines changed: 46 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Settings, X } from 'lucide-react';
33
import {
44
chat,
55
loadConfig,
6+
loadConfigSync,
67
saveConfig,
78
getDefaultConfig,
89
type LLMConfig,
@@ -11,6 +12,7 @@ import {
1112
} from '@/lib/llmClient';
1213
import {
1314
loadImageGenConfig,
15+
loadImageGenConfigSync,
1416
saveImageGenConfig,
1517
getDefaultImageGenConfig,
1618
type ImageGenConfig,
@@ -27,6 +29,7 @@ import {
2729
import { seedMetaFiles } from '@/lib/seedMeta';
2830
import { dispatchAgentAction, onUserAction } from '@/lib/vibeContainerMock';
2931
import { getFileToolDefinitions, isFileTool, executeFileTool } from '@/lib/fileTools';
32+
import { logger } from '@/lib/logger';
3033
import {
3134
getImageGenToolDefinitions,
3235
isImageGenTool,
@@ -69,10 +72,22 @@ const ChatPanel: React.FC<{ onClose: () => void; visible?: boolean }> = ({
6972
const [input, setInput] = useState('');
7073
const [loading, setLoading] = useState(false);
7174
const [showSettings, setShowSettings] = useState(false);
72-
const [config, setConfig] = useState<LLMConfig | null>(loadConfig);
73-
const [imageGenConfig, setImageGenConfig] = useState<ImageGenConfig | null>(loadImageGenConfig);
75+
// Init from localStorage immediately (sync), then override from local file if available
76+
const [config, setConfig] = useState<LLMConfig | null>(loadConfigSync);
77+
const [imageGenConfig, setImageGenConfig] = useState<ImageGenConfig | null>(
78+
loadImageGenConfigSync,
79+
);
7480
const messagesEndRef = useRef<HTMLDivElement>(null);
7581

82+
useEffect(() => {
83+
loadConfig().then((fileConfig) => {
84+
if (fileConfig) setConfig(fileConfig);
85+
});
86+
loadImageGenConfig().then((fileConfig) => {
87+
if (fileConfig) setImageGenConfig(fileConfig);
88+
});
89+
}, []);
90+
7691
useEffect(() => {
7792
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
7893
}, [messages, loading]);
@@ -111,7 +126,7 @@ const ChatPanel: React.FC<{ onClose: () => void; visible?: boolean }> = ({
111126
try {
112127
await runConversation(newHistory, cfg);
113128
} catch (err) {
114-
console.error('[ChatPanel] User action error:', err);
129+
logger.error('ChatPanel', 'User action error:', err);
115130
} finally {
116131
setLoading(false);
117132
}
@@ -134,20 +149,20 @@ const ChatPanel: React.FC<{ onClose: () => void; visible?: boolean }> = ({
134149
};
135150
action_result?: string;
136151
};
137-
console.info('[ChatPanel] onUserAction received:', evt);
152+
logger.info('ChatPanel', 'onUserAction received:', evt);
138153
// Ignore action_result callbacks (result callbacks triggered by Agent)
139154
if (evt.action_result !== undefined) {
140-
console.info('[ChatPanel] Ignored: action_result event');
155+
logger.info('ChatPanel', 'Ignored: action_result event');
141156
return;
142157
}
143158
const action = evt.app_action;
144159
if (!action) {
145-
console.info('[ChatPanel] Ignored: no app_action');
160+
logger.info('ChatPanel', 'Ignored: no app_action');
146161
return;
147162
}
148163
// Ignore actions triggered by Agent (trigger_by=2)
149164
if (action.trigger_by === 2) {
150-
console.info('[ChatPanel] Ignored: Agent triggered');
165+
logger.info('ChatPanel', 'Ignored: Agent triggered');
151166
return;
152167
}
153168

@@ -185,7 +200,7 @@ const ChatPanel: React.FC<{ onClose: () => void; visible?: boolean }> = ({
185200
try {
186201
await runConversation(newHistory, config);
187202
} catch (err) {
188-
console.error('[ChatPanel] Error:', err);
203+
logger.error('ChatPanel', 'Error:', err);
189204
addMessage({
190205
id: String(Date.now()),
191206
role: 'assistant',
@@ -197,8 +212,9 @@ const ChatPanel: React.FC<{ onClose: () => void; visible?: boolean }> = ({
197212
}, [input, loading, config, chatHistory, addMessage]);
198213

199214
const runConversation = async (history: ChatMessage[], cfg: LLMConfig) => {
200-
console.info(
201-
'[ChatPanel] runConversation called, history length:',
215+
logger.info(
216+
'ChatPanel',
217+
'runConversation called, history length:',
202218
history.length,
203219
'provider:',
204220
cfg.provider,
@@ -212,7 +228,7 @@ const ChatPanel: React.FC<{ onClose: () => void; visible?: boolean }> = ({
212228
...getFileToolDefinitions(),
213229
...(hasImageGen ? getImageGenToolDefinitions() : []),
214230
];
215-
console.info('[ToolLog] ChatPanel: tools passed to chat(), count=', tools.length);
231+
logger.info('ToolLog', 'ChatPanel: tools passed to chat(), count=', tools.length);
216232
const fullMessages: ChatMessage[] = [
217233
{ role: 'system', content: buildSystemPrompt(hasImageGen) },
218234
...history,
@@ -222,21 +238,22 @@ const ChatPanel: React.FC<{ onClose: () => void; visible?: boolean }> = ({
222238
let iterations = 0;
223239
const maxIterations = 10;
224240

225-
console.info('[ChatDebug] === START conversation ===');
226-
console.info('[ChatDebug] messages sent to LLM:', JSON.stringify(currentMessages, null, 2));
227-
console.info(
228-
'[ChatDebug] tools:',
241+
logger.info('ChatDebug', '=== START conversation ===');
242+
logger.info('ChatDebug', 'messages sent to LLM:', JSON.stringify(currentMessages, null, 2));
243+
logger.info(
244+
'ChatDebug',
245+
'tools:',
229246
tools.map((t) => (t as { function: { name: string } }).function.name),
230247
);
231248

232249
while (iterations < maxIterations) {
233250
iterations++;
234-
console.info(`[ChatDebug] --- iteration ${iterations} ---`);
235-
console.info('[ChatDebug] messages count:', currentMessages.length);
251+
logger.info('ChatDebug', `--- iteration ${iterations} ---`);
252+
logger.info('ChatDebug', 'messages count:', currentMessages.length);
236253
const response = await chat(currentMessages, tools, cfg);
237254

238-
console.info('[ChatDebug] LLM response content:', response.content);
239-
console.info('[ChatDebug] LLM toolCalls:', JSON.stringify(response.toolCalls, null, 2));
255+
logger.info('ChatDebug', 'LLM response content:', response.content);
256+
logger.info('ChatDebug', 'LLM toolCalls:', JSON.stringify(response.toolCalls, null, 2));
240257

241258
if (response.toolCalls.length === 0) {
242259
// No tool calls, just text response
@@ -268,14 +285,15 @@ const ChatPanel: React.FC<{ onClose: () => void; visible?: boolean }> = ({
268285
}
269286

270287
// Execute each tool call
271-
console.info(
272-
'[ToolLog] ChatPanel: executing toolCalls count=',
288+
logger.info(
289+
'ToolLog',
290+
'ChatPanel: executing toolCalls count=',
273291
response.toolCalls.length,
274292
'names=',
275293
response.toolCalls.map((tc) => tc.function.name),
276294
);
277295
for (const tc of response.toolCalls) {
278-
console.info('[ToolLog] ChatPanel: processing tool name=', tc.function.name);
296+
logger.info('ToolLog', 'ChatPanel: processing tool name=', tc.function.name);
279297
let params: Record<string, string> = {};
280298
try {
281299
params = JSON.parse(tc.function.arguments);
@@ -286,7 +304,7 @@ const ChatPanel: React.FC<{ onClose: () => void; visible?: boolean }> = ({
286304
// list_apps tool
287305
if (tc.function.name === 'list_apps') {
288306
const result = executeListApps();
289-
console.info('[ChatDebug] list_apps result:', result);
307+
logger.info('ChatDebug', 'list_apps result:', result);
290308
currentMessages = [
291309
...currentMessages,
292310
{ role: 'tool', content: result, tool_call_id: tc.id },
@@ -303,8 +321,9 @@ const ChatPanel: React.FC<{ onClose: () => void; visible?: boolean }> = ({
303321
});
304322
try {
305323
const result = await executeFileTool(tc.function.name, params);
306-
console.info(
307-
`[ChatDebug] ${tc.function.name}(${JSON.stringify(params)}) result:`,
324+
logger.info(
325+
'ChatDebug',
326+
`${tc.function.name}(${JSON.stringify(params)}) result:`,
308327
result.slice(0, 500),
309328
);
310329
currentMessages = [
@@ -503,8 +522,9 @@ const ChatPanel: React.FC<{ onClose: () => void; visible?: boolean }> = ({
503522
imageGenConfig={imageGenConfig}
504523
onSave={(c, igc) => {
505524
setConfig(c);
506-
saveConfig(c);
507525
setImageGenConfig(igc);
526+
// Persist both configs atomically to ~/.openroom/config.json
527+
saveConfig(c, igc);
508528
if (igc) {
509529
saveImageGenConfig(igc);
510530
}
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
/**
2+
* Unit tests for configPersistence.ts
3+
*
4+
* Covers: loadPersistedConfig, savePersistedConfig, legacy format migration
5+
*/
6+
7+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
8+
import {
9+
loadPersistedConfig,
10+
savePersistedConfig,
11+
type PersistedConfig,
12+
} from '../configPersistence';
13+
import type { LLMConfig } from '../llmClient';
14+
import type { ImageGenConfig } from '../imageGenClient';
15+
16+
// ─── Constants ──────────────────────────────────────────────────────────────────
17+
18+
const MOCK_LLM_CONFIG: LLMConfig = {
19+
provider: 'openai',
20+
apiKey: 'sk-test',
21+
baseUrl: 'https://api.openai.com',
22+
model: 'gpt-4',
23+
};
24+
25+
const MOCK_IMAGEGEN_CONFIG: ImageGenConfig = {
26+
provider: 'openai',
27+
apiKey: 'sk-img-test',
28+
baseUrl: 'https://api.openai.com',
29+
model: 'gpt-image-1.5',
30+
};
31+
32+
const MOCK_PERSISTED: PersistedConfig = {
33+
llm: MOCK_LLM_CONFIG,
34+
imageGen: MOCK_IMAGEGEN_CONFIG,
35+
};
36+
37+
// ─── Setup / Teardown ───────────────────────────────────────────────────────────
38+
39+
beforeEach(() => {
40+
vi.restoreAllMocks();
41+
});
42+
43+
afterEach(() => {
44+
vi.restoreAllMocks();
45+
});
46+
47+
// ─── loadPersistedConfig() ──────────────────────────────────────────────────────
48+
49+
describe('loadPersistedConfig()', () => {
50+
it('returns full config when file has new { llm, imageGen } format', async () => {
51+
globalThis.fetch = vi.fn().mockResolvedValueOnce({
52+
ok: true,
53+
json: () => Promise.resolve(MOCK_PERSISTED),
54+
} as unknown as Response);
55+
56+
const result = await loadPersistedConfig();
57+
58+
expect(result).toEqual(MOCK_PERSISTED);
59+
expect(result?.llm).toEqual(MOCK_LLM_CONFIG);
60+
expect(result?.imageGen).toEqual(MOCK_IMAGEGEN_CONFIG);
61+
});
62+
63+
it('returns { llm } only when imageGen is absent', async () => {
64+
const withoutImageGen = { llm: MOCK_LLM_CONFIG };
65+
globalThis.fetch = vi.fn().mockResolvedValueOnce({
66+
ok: true,
67+
json: () => Promise.resolve(withoutImageGen),
68+
} as unknown as Response);
69+
70+
const result = await loadPersistedConfig();
71+
72+
expect(result?.llm).toEqual(MOCK_LLM_CONFIG);
73+
expect(result?.imageGen).toBeUndefined();
74+
});
75+
76+
it('migrates legacy flat LLMConfig format to { llm } wrapper', async () => {
77+
// Legacy format: flat LLMConfig at top level (has "provider", no "llm" key)
78+
globalThis.fetch = vi.fn().mockResolvedValueOnce({
79+
ok: true,
80+
json: () => Promise.resolve(MOCK_LLM_CONFIG),
81+
} as unknown as Response);
82+
83+
const result = await loadPersistedConfig();
84+
85+
expect(result).toEqual({ llm: MOCK_LLM_CONFIG });
86+
expect(result?.llm.provider).toBe('openai');
87+
expect(result?.imageGen).toBeUndefined();
88+
});
89+
90+
it('returns null when API returns 404', async () => {
91+
globalThis.fetch = vi.fn().mockResolvedValueOnce({
92+
ok: false,
93+
status: 404,
94+
} as Response);
95+
96+
expect(await loadPersistedConfig()).toBeNull();
97+
});
98+
99+
it('returns null when fetch throws (network error)', async () => {
100+
globalThis.fetch = vi.fn().mockRejectedValueOnce(new Error('Network error'));
101+
102+
expect(await loadPersistedConfig()).toBeNull();
103+
});
104+
105+
it('returns null when response is not a recognized format', async () => {
106+
globalThis.fetch = vi.fn().mockResolvedValueOnce({
107+
ok: true,
108+
json: () => Promise.resolve({ unrelated: 'data' }),
109+
} as unknown as Response);
110+
111+
expect(await loadPersistedConfig()).toBeNull();
112+
});
113+
});
114+
115+
// ─── savePersistedConfig() ──────────────────────────────────────────────────────
116+
117+
describe('savePersistedConfig()', () => {
118+
it('POSTs the full config to /api/llm-config', async () => {
119+
const mockFetch = vi.fn().mockResolvedValueOnce({ ok: true } as Response);
120+
globalThis.fetch = mockFetch;
121+
122+
await savePersistedConfig(MOCK_PERSISTED);
123+
124+
expect(mockFetch).toHaveBeenCalledWith('/api/llm-config', {
125+
method: 'POST',
126+
headers: { 'Content-Type': 'application/json' },
127+
body: JSON.stringify(MOCK_PERSISTED),
128+
});
129+
});
130+
131+
it('includes imageGen in the persisted JSON', async () => {
132+
const mockFetch = vi.fn().mockResolvedValueOnce({ ok: true } as Response);
133+
globalThis.fetch = mockFetch;
134+
135+
await savePersistedConfig(MOCK_PERSISTED);
136+
137+
const body = JSON.parse(mockFetch.mock.calls[0][1].body as string);
138+
expect(body.llm).toEqual(MOCK_LLM_CONFIG);
139+
expect(body.imageGen).toEqual(MOCK_IMAGEGEN_CONFIG);
140+
});
141+
142+
it('omits imageGen when not provided', async () => {
143+
const mockFetch = vi.fn().mockResolvedValueOnce({ ok: true } as Response);
144+
globalThis.fetch = mockFetch;
145+
146+
await savePersistedConfig({ llm: MOCK_LLM_CONFIG });
147+
148+
const body = JSON.parse(mockFetch.mock.calls[0][1].body as string);
149+
expect(body.llm).toEqual(MOCK_LLM_CONFIG);
150+
expect(body.imageGen).toBeUndefined();
151+
});
152+
153+
it('does not throw when fetch fails', async () => {
154+
globalThis.fetch = vi.fn().mockRejectedValueOnce(new Error('Network error'));
155+
156+
await expect(savePersistedConfig(MOCK_PERSISTED)).resolves.toBeUndefined();
157+
});
158+
});

0 commit comments

Comments
 (0)