Skip to content

Commit fc6c812

Browse files
committed
feat(chat): unify llm model config usage and stabilize tests
1 parent 02ced6d commit fc6c812

8 files changed

Lines changed: 139 additions & 235 deletions

File tree

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

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,13 @@ import {
1010
Pencil,
1111
List,
1212
} from 'lucide-react';
13+
import { chat, loadConfig, loadConfigSync, saveConfig, type ChatMessage } from '@/lib/llmClient';
1314
import {
14-
chat,
15-
loadConfig,
16-
loadConfigSync,
17-
saveConfig,
18-
getDefaultConfig,
15+
PROVIDER_MODELS,
16+
getDefaultProviderConfig,
1917
type LLMConfig,
2018
type LLMProvider,
21-
type ChatMessage,
22-
} from '@/lib/llmClient';
23-
import { PROVIDER_MODELS } from '@/lib/llmModels';
19+
} from '@/lib/llmModels';
2420
import {
2521
loadImageGenConfig,
2622
loadImageGenConfigSync,
@@ -1011,6 +1007,7 @@ const ChatPanel: React.FC<{ onClose: () => void; visible?: boolean }> = ({
10111007
{messages.map((msg) => (
10121008
<React.Fragment key={msg.id}>
10131009
<div
1010+
data-testid="chat-message"
10141011
className={`${styles.message} ${
10151012
msg.role === 'user'
10161013
? styles.user
@@ -1124,8 +1121,10 @@ const SettingsModal: React.FC<{
11241121
// LLM settings
11251122
const [provider, setProvider] = useState<LLMProvider>(config?.provider || 'minimax');
11261123
const [apiKey, setApiKey] = useState(config?.apiKey || '');
1127-
const [baseUrl, setBaseUrl] = useState(config?.baseUrl || getDefaultConfig('minimax').baseUrl);
1128-
const [model, setModel] = useState(config?.model || getDefaultConfig('minimax').model);
1124+
const [baseUrl, setBaseUrl] = useState(
1125+
config?.baseUrl || getDefaultProviderConfig('minimax').baseUrl,
1126+
);
1127+
const [model, setModel] = useState(config?.model || getDefaultProviderConfig('minimax').model);
11291128
const [customHeaders, setCustomHeaders] = useState(config?.customHeaders || '');
11301129
const [manualModelMode, setManualModelMode] = useState(false);
11311130

@@ -1147,7 +1146,7 @@ const SettingsModal: React.FC<{
11471146

11481147
const handleProviderChange = (p: LLMProvider) => {
11491148
setProvider(p);
1150-
const defaults = getDefaultConfig(p);
1149+
const defaults = getDefaultProviderConfig(p);
11511150
setBaseUrl(defaults.baseUrl);
11521151
setModel(defaults.model);
11531152
setManualModelMode(false);

apps/webuiapps/src/lib/__tests__/chatHistoryStorage.test.ts

Lines changed: 32 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@ import type { ChatMessage } from '../llmClient';
55
const fetchMock = vi.fn();
66
vi.stubGlobal('fetch', fetchMock);
77

8-
const STORAGE_KEY = 'webuiapps-chat-history';
8+
const SESSION_PATH = 'char-1/mod-1';
9+
10+
function expectedUrl(file: string): string {
11+
return `/api/session-data?path=${encodeURIComponent(`${SESSION_PATH}/chat/${file}`)}`;
12+
}
913

1014
const sampleMessages: DisplayMessage[] = [
1115
{ id: '1', role: 'user', content: 'Hello' },
@@ -24,157 +28,101 @@ function makeSavedData(msgs = sampleMessages, history = sampleChatHistory): Chat
2428
describe('chatHistoryStorage', () => {
2529
beforeEach(() => {
2630
fetchMock.mockReset();
27-
localStorage.clear();
2831
vi.resetModules();
2932
});
3033

31-
// ============ loadChatHistorySync ============
32-
3334
describe('loadChatHistorySync', () => {
34-
it('returns null when localStorage is empty', async () => {
35-
const { loadChatHistorySync } = await import('../chatHistoryStorage');
36-
expect(loadChatHistorySync()).toBeNull();
37-
});
38-
39-
it('returns data from localStorage', async () => {
40-
const data = makeSavedData();
41-
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
42-
const { loadChatHistorySync } = await import('../chatHistoryStorage');
43-
const result = loadChatHistorySync();
44-
expect(result).not.toBeNull();
45-
expect(result!.messages).toHaveLength(2);
46-
expect(result!.chatHistory).toHaveLength(2);
47-
expect(result!.version).toBe(1);
48-
});
49-
50-
it('returns null for invalid JSON', async () => {
51-
localStorage.setItem(STORAGE_KEY, 'not-json');
52-
const { loadChatHistorySync } = await import('../chatHistoryStorage');
53-
expect(loadChatHistorySync()).toBeNull();
54-
});
55-
56-
it('returns null for wrong version', async () => {
57-
localStorage.setItem(
58-
STORAGE_KEY,
59-
JSON.stringify({ version: 99, savedAt: 0, messages: [], chatHistory: [] }),
60-
);
35+
it('returns null', async () => {
6136
const { loadChatHistorySync } = await import('../chatHistoryStorage');
62-
expect(loadChatHistorySync()).toBeNull();
37+
expect(loadChatHistorySync(SESSION_PATH)).toBeNull();
6338
});
6439
});
6540

66-
// ============ loadChatHistory (async) ============
67-
6841
describe('loadChatHistory', () => {
69-
it('loads from API and syncs to localStorage', async () => {
42+
it('loads from API', async () => {
7043
const data = makeSavedData();
7144
fetchMock.mockResolvedValueOnce({
7245
ok: true,
7346
json: () => Promise.resolve(data),
7447
});
7548
const { loadChatHistory } = await import('../chatHistoryStorage');
7649

77-
const result = await loadChatHistory();
50+
const result = await loadChatHistory(SESSION_PATH);
7851

79-
expect(fetchMock).toHaveBeenCalledWith('/api/chat-history');
52+
expect(fetchMock).toHaveBeenCalledWith(expectedUrl('chat.json'));
8053
expect(result).not.toBeNull();
8154
expect(result!.messages).toEqual(sampleMessages);
82-
// Verify synced to localStorage
83-
const stored = JSON.parse(localStorage.getItem(STORAGE_KEY)!);
84-
expect(stored.version).toBe(1);
8555
});
8656

87-
it('falls back to localStorage when API returns non-ok', async () => {
88-
const data = makeSavedData();
89-
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
57+
it('returns null when API returns non-ok', async () => {
9058
fetchMock.mockResolvedValueOnce({ ok: false, status: 404 });
9159
const { loadChatHistory } = await import('../chatHistoryStorage');
9260

93-
const result = await loadChatHistory();
61+
const result = await loadChatHistory(SESSION_PATH);
9462

95-
expect(result).not.toBeNull();
96-
expect(result!.messages).toEqual(sampleMessages);
63+
expect(result).toBeNull();
9764
});
9865

99-
it('falls back to localStorage when fetch throws', async () => {
100-
const data = makeSavedData();
101-
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
66+
it('returns null when fetch throws', async () => {
10267
fetchMock.mockRejectedValueOnce(new Error('network error'));
10368
const { loadChatHistory } = await import('../chatHistoryStorage');
10469

105-
const result = await loadChatHistory();
70+
const result = await loadChatHistory(SESSION_PATH);
10671

107-
expect(result).not.toBeNull();
108-
expect(result!.messages).toEqual(sampleMessages);
72+
expect(result).toBeNull();
10973
});
11074

111-
it('returns null when both API and localStorage are empty', async () => {
75+
it('returns null when API is empty', async () => {
11276
fetchMock.mockResolvedValueOnce({ ok: false, status: 404 });
11377
const { loadChatHistory } = await import('../chatHistoryStorage');
11478

115-
const result = await loadChatHistory();
79+
const result = await loadChatHistory(SESSION_PATH);
11680
expect(result).toBeNull();
11781
});
11882
});
11983

120-
// ============ saveChatHistory ============
121-
12284
describe('saveChatHistory', () => {
123-
it('saves to localStorage and POSTs to API', async () => {
85+
it('POSTs to API with expected payload', async () => {
12486
fetchMock.mockResolvedValueOnce({ ok: true });
12587
const { saveChatHistory } = await import('../chatHistoryStorage');
12688

127-
await saveChatHistory(sampleMessages, sampleChatHistory);
128-
129-
// Check localStorage
130-
const stored = JSON.parse(localStorage.getItem(STORAGE_KEY)!);
131-
expect(stored.version).toBe(1);
132-
expect(stored.messages).toEqual(sampleMessages);
133-
expect(stored.chatHistory).toEqual(sampleChatHistory);
134-
expect(typeof stored.savedAt).toBe('number');
89+
await saveChatHistory(SESSION_PATH, sampleMessages, sampleChatHistory);
13590

136-
// Check fetch call
13791
expect(fetchMock).toHaveBeenCalledOnce();
13892
const [url, options] = fetchMock.mock.calls[0];
139-
expect(url).toBe('/api/chat-history');
93+
expect(url).toBe(expectedUrl('chat.json'));
14094
expect(options.method).toBe('POST');
14195
const body = JSON.parse(options.body);
14296
expect(body.version).toBe(1);
97+
expect(body.messages).toEqual(sampleMessages);
98+
expect(body.chatHistory).toEqual(sampleChatHistory);
14399
});
144100

145-
it('saves to localStorage even when fetch fails', async () => {
101+
it('does not throw when fetch fails', async () => {
146102
fetchMock.mockRejectedValueOnce(new Error('network error'));
147103
const { saveChatHistory } = await import('../chatHistoryStorage');
148104

149-
await saveChatHistory(sampleMessages, sampleChatHistory);
150-
151-
const stored = JSON.parse(localStorage.getItem(STORAGE_KEY)!);
152-
expect(stored.messages).toEqual(sampleMessages);
105+
await expect(
106+
saveChatHistory(SESSION_PATH, sampleMessages, sampleChatHistory),
107+
).resolves.toBeUndefined();
153108
});
154109
});
155110

156-
// ============ clearChatHistory ============
157-
158111
describe('clearChatHistory', () => {
159-
it('removes from localStorage and sends DELETE to API', async () => {
160-
localStorage.setItem(STORAGE_KEY, JSON.stringify(makeSavedData()));
112+
it('sends DELETE to API', async () => {
161113
fetchMock.mockResolvedValueOnce({ ok: true });
162114
const { clearChatHistory } = await import('../chatHistoryStorage');
163115

164-
await clearChatHistory();
116+
await clearChatHistory(SESSION_PATH);
165117

166-
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
167-
expect(fetchMock).toHaveBeenCalledWith('/api/chat-history', { method: 'DELETE' });
118+
expect(fetchMock).toHaveBeenCalledWith(expectedUrl('chat.json'), { method: 'DELETE' });
168119
});
169120

170-
it('clears localStorage even when DELETE fetch fails', async () => {
171-
localStorage.setItem(STORAGE_KEY, JSON.stringify(makeSavedData()));
121+
it('does not throw when DELETE fetch fails', async () => {
172122
fetchMock.mockRejectedValueOnce(new Error('network error'));
173123
const { clearChatHistory } = await import('../chatHistoryStorage');
174124

175-
await clearChatHistory();
176-
177-
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
125+
await expect(clearChatHistory(SESSION_PATH)).resolves.toBeUndefined();
178126
});
179127
});
180128
});

apps/webuiapps/src/lib/__tests__/configPersistence.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
savePersistedConfig,
1111
type PersistedConfig,
1212
} from '../configPersistence';
13-
import type { LLMConfig } from '../llmClient';
13+
import type { LLMConfig } from '../llmModels';
1414
import type { ImageGenConfig } from '../imageGenClient';
1515

1616
// ─── Constants ──────────────────────────────────────────────────────────────────

apps/webuiapps/src/lib/__tests__/llmClient.test.ts

Lines changed: 34 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,14 @@
99

1010
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
1111
import {
12-
getDefaultConfig,
1312
loadConfig,
1413
loadConfigSync,
1514
saveConfig,
1615
chat,
1716
type ChatMessage,
1817
type ToolDef,
1918
} from '../llmClient';
20-
import type { LLMConfig } from '../llmModels';
19+
import { getDefaultProviderConfig, type LLMConfig } from '../llmModels';
2120

2221
// ─── Constants ────────────────────────────────────────────────────────────────
2322

@@ -92,55 +91,53 @@ afterEach(() => {
9291
vi.restoreAllMocks();
9392
});
9493

95-
// ─── getDefaultConfig() ───────────────────────────────────────────────────────
96-
97-
describe('getDefaultConfig()', () => {
94+
describe('getDefaultProviderConfig()', () => {
9895
it('returns correct defaults for openai', () => {
99-
const cfg = getDefaultConfig('openai');
96+
const cfg = getDefaultProviderConfig('openai');
10097
expect(cfg.provider).toBe('openai');
10198
expect(cfg.baseUrl).toBe('https://api.openai.com/v1');
10299
expect(cfg.model).toBe('gpt-5.4');
103100
expect('apiKey' in cfg).toBe(false);
104101
});
105102

106103
it('returns correct defaults for anthropic', () => {
107-
const cfg = getDefaultConfig('anthropic');
104+
const cfg = getDefaultProviderConfig('anthropic');
108105
expect(cfg.provider).toBe('anthropic');
109106
expect(cfg.baseUrl).toBe('https://api.anthropic.com/v1');
110107
expect(cfg.model).toBe('claude-sonnet-4-6');
111108
});
112109

113110
it('returns correct defaults for deepseek', () => {
114-
const cfg = getDefaultConfig('deepseek');
111+
const cfg = getDefaultProviderConfig('deepseek');
115112
expect(cfg.provider).toBe('deepseek');
116113
expect(cfg.baseUrl).toBe('https://api.deepseek.com/v1');
117114
expect(cfg.model).toBe('deepseek-chat');
118115
});
119116

120117
it('returns correct defaults for minimax', () => {
121-
const cfg = getDefaultConfig('minimax');
118+
const cfg = getDefaultProviderConfig('minimax');
122119
expect(cfg.provider).toBe('minimax');
123120
expect(cfg.baseUrl).toBe('https://api.minimax.io/anthropic/v1');
124121
expect(cfg.model).toBe('MiniMax-M2.5');
125122
});
126123

127124
it('returns correct defaults for z.ai', () => {
128-
const cfg = getDefaultConfig('z.ai');
125+
const cfg = getDefaultProviderConfig('z.ai');
129126
expect(cfg.provider).toBe('z.ai');
130127
expect(cfg.baseUrl).toBe('https://api.z.ai/api/coding/paas/v4');
131128
expect(cfg.model).toBe('glm-5');
132129
});
133130

134131
it('returns correct defaults for kimi', () => {
135-
const cfg = getDefaultConfig('kimi');
132+
const cfg = getDefaultProviderConfig('kimi');
136133
expect(cfg.provider).toBe('kimi');
137134
expect(cfg.baseUrl).toBe('https://api.moonshot.cn/v1');
138135
expect(cfg.model).toBe('kimi-k2-5');
139136
});
140137

141138
it('returns consistent values for the same provider', () => {
142-
const a = getDefaultConfig('openai');
143-
const b = getDefaultConfig('openai');
139+
const a = getDefaultProviderConfig('openai');
140+
const b = getDefaultProviderConfig('openai');
144141
expect(a).toStrictEqual(b);
145142
});
146143
});
@@ -345,6 +342,16 @@ describe('chat()', () => {
345342
expect(headers['Authorization']).toBe('Bearer sk-test-key');
346343
});
347344

345+
it('uses v1/chat/completions when baseUrl has no version suffix', async () => {
346+
const mockFetch = vi.fn().mockResolvedValueOnce(makeOpenAIResponse('ok'));
347+
globalThis.fetch = mockFetch;
348+
349+
await chat(MOCK_MESSAGES, [], MOCK_OPENAI_CONFIG);
350+
351+
const headers = mockFetch.mock.calls[0][1].headers as Record<string, string>;
352+
expect(headers['X-LLM-Target-URL']).toBe('https://api.openai.com/v1/chat/completions');
353+
});
354+
348355
it('includes tools in body when tools array is non-empty', async () => {
349356
const mockFetch = vi.fn().mockResolvedValueOnce(makeOpenAIResponse('ok'));
350357
globalThis.fetch = mockFetch;
@@ -421,6 +428,20 @@ describe('chat()', () => {
421428
expect(headers['x-api-key']).toBe('ant-test-key');
422429
});
423430

431+
it('uses /messages when baseUrl already includes /v1', async () => {
432+
const mockFetch = vi.fn().mockResolvedValueOnce(makeAnthropicResponse('Anthropic response'));
433+
globalThis.fetch = mockFetch;
434+
435+
const configWithVersion: LLMConfig = {
436+
...MOCK_ANTHROPIC_CONFIG,
437+
baseUrl: 'https://api.anthropic.com/v1',
438+
};
439+
await chat(MOCK_MESSAGES, [], configWithVersion);
440+
441+
const headers = mockFetch.mock.calls[0][1].headers as Record<string, string>;
442+
expect(headers['X-LLM-Target-URL']).toBe('https://api.anthropic.com/v1/messages');
443+
});
444+
424445
it('extracts system message to top-level system field', async () => {
425446
const messages: ChatMessage[] = [
426447
{ role: 'system', content: 'You are helpful.' },

apps/webuiapps/src/lib/configPersistence.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* automatically migrated on read.
77
*/
88

9-
import type { LLMConfig } from './llmClient';
9+
import type { LLMConfig } from './llmModels';
1010
import type { ImageGenConfig } from './imageGenClient';
1111

1212
export interface PersistedConfig {

0 commit comments

Comments
 (0)