Skip to content

Commit 9f84bb0

Browse files
committed
feat: persist image generation config
1 parent bf4746c commit 9f84bb0

7 files changed

Lines changed: 428 additions & 33 deletions

File tree

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

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
} from '@/lib/llmClient';
1313
import {
1414
loadImageGenConfig,
15+
loadImageGenConfigSync,
1516
saveImageGenConfig,
1617
getDefaultImageGenConfig,
1718
type ImageGenConfig,
@@ -73,13 +74,18 @@ const ChatPanel: React.FC<{ onClose: () => void; visible?: boolean }> = ({
7374
const [showSettings, setShowSettings] = useState(false);
7475
// Init from localStorage immediately (sync), then override from local file if available
7576
const [config, setConfig] = useState<LLMConfig | null>(loadConfigSync);
76-
const [imageGenConfig, setImageGenConfig] = useState<ImageGenConfig | null>(loadImageGenConfig);
77+
const [imageGenConfig, setImageGenConfig] = useState<ImageGenConfig | null>(
78+
loadImageGenConfigSync,
79+
);
7780
const messagesEndRef = useRef<HTMLDivElement>(null);
7881

7982
useEffect(() => {
8083
loadConfig().then((fileConfig) => {
8184
if (fileConfig) setConfig(fileConfig);
8285
});
86+
loadImageGenConfig().then((fileConfig) => {
87+
if (fileConfig) setImageGenConfig(fileConfig);
88+
});
8389
}, []);
8490

8591
useEffect(() => {
@@ -516,8 +522,9 @@ const ChatPanel: React.FC<{ onClose: () => void; visible?: boolean }> = ({
516522
imageGenConfig={imageGenConfig}
517523
onSave={(c, igc) => {
518524
setConfig(c);
519-
saveConfig(c);
520525
setImageGenConfig(igc);
526+
// Persist both configs atomically to ~/.openroom/config.json
527+
saveConfig(c, igc);
521528
if (igc) {
522529
saveImageGenConfig(igc);
523530
}
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+
});
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
/**
2+
* Unit tests for imageGenClient.ts — config loading/saving
3+
*/
4+
5+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
6+
import {
7+
loadImageGenConfig,
8+
loadImageGenConfigSync,
9+
saveImageGenConfig,
10+
getDefaultImageGenConfig,
11+
type ImageGenConfig,
12+
} from '../imageGenClient';
13+
14+
const CONFIG_KEY = 'webuiapps-imagegen-config';
15+
16+
const MOCK_IG_CONFIG: ImageGenConfig = {
17+
provider: 'openai',
18+
apiKey: 'sk-img-test',
19+
baseUrl: 'https://api.openai.com',
20+
model: 'gpt-image-1.5',
21+
};
22+
23+
const MOCK_LLM_CONFIG = {
24+
provider: 'openai',
25+
apiKey: 'sk-llm',
26+
baseUrl: 'https://api.openai.com',
27+
model: 'gpt-4',
28+
};
29+
30+
beforeEach(() => {
31+
localStorage.clear();
32+
vi.restoreAllMocks();
33+
});
34+
35+
afterEach(() => {
36+
vi.restoreAllMocks();
37+
});
38+
39+
describe('getDefaultImageGenConfig()', () => {
40+
it('returns correct defaults for openai', () => {
41+
const cfg = getDefaultImageGenConfig('openai');
42+
expect(cfg.provider).toBe('openai');
43+
expect(cfg.model).toBe('gpt-image-1.5');
44+
});
45+
46+
it('returns correct defaults for gemini', () => {
47+
const cfg = getDefaultImageGenConfig('gemini');
48+
expect(cfg.provider).toBe('gemini');
49+
expect(cfg.baseUrl).toBe('https://generativelanguage.googleapis.com');
50+
});
51+
});
52+
53+
describe('loadImageGenConfigSync()', () => {
54+
it('returns null when localStorage is empty', () => {
55+
expect(loadImageGenConfigSync()).toBeNull();
56+
});
57+
58+
it('returns parsed config from localStorage', () => {
59+
localStorage.setItem(CONFIG_KEY, JSON.stringify(MOCK_IG_CONFIG));
60+
expect(loadImageGenConfigSync()).toEqual(MOCK_IG_CONFIG);
61+
});
62+
63+
it('returns null on invalid JSON', () => {
64+
localStorage.setItem(CONFIG_KEY, 'bad-json');
65+
expect(loadImageGenConfigSync()).toBeNull();
66+
});
67+
});
68+
69+
describe('loadImageGenConfig()', () => {
70+
it('loads imageGen from file API (new format) and syncs to localStorage', async () => {
71+
globalThis.fetch = vi.fn().mockResolvedValueOnce({
72+
ok: true,
73+
json: () => Promise.resolve({ llm: MOCK_LLM_CONFIG, imageGen: MOCK_IG_CONFIG }),
74+
} as unknown as Response);
75+
76+
const result = await loadImageGenConfig();
77+
78+
expect(result).toEqual(MOCK_IG_CONFIG);
79+
expect(localStorage.getItem(CONFIG_KEY)).toBe(JSON.stringify(MOCK_IG_CONFIG));
80+
});
81+
82+
it('returns null from file API when imageGen is absent (LLM-only config)', async () => {
83+
globalThis.fetch = vi.fn().mockResolvedValueOnce({
84+
ok: true,
85+
json: () => Promise.resolve({ llm: MOCK_LLM_CONFIG }),
86+
} as unknown as Response);
87+
88+
const result = await loadImageGenConfig();
89+
90+
// No imageGen in file → falls through to localStorage
91+
expect(result).toBeNull();
92+
});
93+
94+
it('falls back to localStorage when API is unavailable', async () => {
95+
globalThis.fetch = vi.fn().mockRejectedValueOnce(new Error('Network error'));
96+
localStorage.setItem(CONFIG_KEY, JSON.stringify(MOCK_IG_CONFIG));
97+
98+
const result = await loadImageGenConfig();
99+
100+
expect(result).toEqual(MOCK_IG_CONFIG);
101+
});
102+
103+
it('returns null when both API and localStorage have nothing', async () => {
104+
globalThis.fetch = vi.fn().mockResolvedValueOnce({ ok: false, status: 404 } as Response);
105+
106+
expect(await loadImageGenConfig()).toBeNull();
107+
});
108+
109+
it('handles legacy flat LLMConfig file (no imageGen) gracefully', async () => {
110+
// Legacy file has flat LLMConfig → no imageGen field
111+
globalThis.fetch = vi.fn().mockResolvedValueOnce({
112+
ok: true,
113+
json: () => Promise.resolve(MOCK_LLM_CONFIG),
114+
} as unknown as Response);
115+
116+
const result = await loadImageGenConfig();
117+
118+
// Legacy format has no imageGen → should fall through to localStorage
119+
expect(result).toBeNull();
120+
});
121+
});
122+
123+
describe('saveImageGenConfig()', () => {
124+
it('writes to localStorage', () => {
125+
saveImageGenConfig(MOCK_IG_CONFIG);
126+
expect(JSON.parse(localStorage.getItem(CONFIG_KEY)!)).toEqual(MOCK_IG_CONFIG);
127+
});
128+
});

0 commit comments

Comments
 (0)