Skip to content

Commit 49071e3

Browse files
committed
feat(webuiapps): persist chat history locally
1 parent 15b6988 commit 49071e3

3 files changed

Lines changed: 351 additions & 0 deletions

File tree

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import type { ChatHistoryData, DisplayMessage } from '../chatHistoryStorage';
3+
import type { ChatMessage } from '../llmClient';
4+
5+
const fetchMock = vi.fn();
6+
vi.stubGlobal('fetch', fetchMock);
7+
8+
const STORAGE_KEY = 'webuiapps-chat-history';
9+
10+
const sampleMessages: DisplayMessage[] = [
11+
{ id: '1', role: 'user', content: 'Hello' },
12+
{ id: '2', role: 'assistant', content: 'Hi there!' },
13+
];
14+
15+
const sampleChatHistory: ChatMessage[] = [
16+
{ role: 'user', content: 'Hello' },
17+
{ role: 'assistant', content: 'Hi there!' },
18+
];
19+
20+
function makeSavedData(msgs = sampleMessages, history = sampleChatHistory): ChatHistoryData {
21+
return { version: 1, savedAt: Date.now(), messages: msgs, chatHistory: history };
22+
}
23+
24+
describe('chatHistoryStorage', () => {
25+
beforeEach(() => {
26+
fetchMock.mockReset();
27+
localStorage.clear();
28+
vi.resetModules();
29+
});
30+
31+
// ============ loadChatHistorySync ============
32+
33+
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+
);
61+
const { loadChatHistorySync } = await import('../chatHistoryStorage');
62+
expect(loadChatHistorySync()).toBeNull();
63+
});
64+
});
65+
66+
// ============ loadChatHistory (async) ============
67+
68+
describe('loadChatHistory', () => {
69+
it('loads from API and syncs to localStorage', async () => {
70+
const data = makeSavedData();
71+
fetchMock.mockResolvedValueOnce({
72+
ok: true,
73+
json: () => Promise.resolve(data),
74+
});
75+
const { loadChatHistory } = await import('../chatHistoryStorage');
76+
77+
const result = await loadChatHistory();
78+
79+
expect(fetchMock).toHaveBeenCalledWith('/api/chat-history');
80+
expect(result).not.toBeNull();
81+
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);
85+
});
86+
87+
it('falls back to localStorage when API returns non-ok', async () => {
88+
const data = makeSavedData();
89+
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
90+
fetchMock.mockResolvedValueOnce({ ok: false, status: 404 });
91+
const { loadChatHistory } = await import('../chatHistoryStorage');
92+
93+
const result = await loadChatHistory();
94+
95+
expect(result).not.toBeNull();
96+
expect(result!.messages).toEqual(sampleMessages);
97+
});
98+
99+
it('falls back to localStorage when fetch throws', async () => {
100+
const data = makeSavedData();
101+
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
102+
fetchMock.mockRejectedValueOnce(new Error('network error'));
103+
const { loadChatHistory } = await import('../chatHistoryStorage');
104+
105+
const result = await loadChatHistory();
106+
107+
expect(result).not.toBeNull();
108+
expect(result!.messages).toEqual(sampleMessages);
109+
});
110+
111+
it('returns null when both API and localStorage are empty', async () => {
112+
fetchMock.mockResolvedValueOnce({ ok: false, status: 404 });
113+
const { loadChatHistory } = await import('../chatHistoryStorage');
114+
115+
const result = await loadChatHistory();
116+
expect(result).toBeNull();
117+
});
118+
});
119+
120+
// ============ saveChatHistory ============
121+
122+
describe('saveChatHistory', () => {
123+
it('saves to localStorage and POSTs to API', async () => {
124+
fetchMock.mockResolvedValueOnce({ ok: true });
125+
const { saveChatHistory } = await import('../chatHistoryStorage');
126+
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');
135+
136+
// Check fetch call
137+
expect(fetchMock).toHaveBeenCalledOnce();
138+
const [url, options] = fetchMock.mock.calls[0];
139+
expect(url).toBe('/api/chat-history');
140+
expect(options.method).toBe('POST');
141+
const body = JSON.parse(options.body);
142+
expect(body.version).toBe(1);
143+
});
144+
145+
it('saves to localStorage even when fetch fails', async () => {
146+
fetchMock.mockRejectedValueOnce(new Error('network error'));
147+
const { saveChatHistory } = await import('../chatHistoryStorage');
148+
149+
await saveChatHistory(sampleMessages, sampleChatHistory);
150+
151+
const stored = JSON.parse(localStorage.getItem(STORAGE_KEY)!);
152+
expect(stored.messages).toEqual(sampleMessages);
153+
});
154+
});
155+
156+
// ============ clearChatHistory ============
157+
158+
describe('clearChatHistory', () => {
159+
it('removes from localStorage and sends DELETE to API', async () => {
160+
localStorage.setItem(STORAGE_KEY, JSON.stringify(makeSavedData()));
161+
fetchMock.mockResolvedValueOnce({ ok: true });
162+
const { clearChatHistory } = await import('../chatHistoryStorage');
163+
164+
await clearChatHistory();
165+
166+
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
167+
expect(fetchMock).toHaveBeenCalledWith('/api/chat-history', { method: 'DELETE' });
168+
});
169+
170+
it('clears localStorage even when DELETE fetch fails', async () => {
171+
localStorage.setItem(STORAGE_KEY, JSON.stringify(makeSavedData()));
172+
fetchMock.mockRejectedValueOnce(new Error('network error'));
173+
const { clearChatHistory } = await import('../chatHistoryStorage');
174+
175+
await clearChatHistory();
176+
177+
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
178+
});
179+
});
180+
});
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
/**
2+
* Chat History Persistence
3+
*
4+
* Persists chat history (display messages + LLM conversation history) to
5+
* ~/.openroom/history/chat.json via the dev-server API, with localStorage fallback.
6+
*/
7+
8+
import type { ChatMessage } from './llmClient';
9+
10+
export interface DisplayMessage {
11+
id: string;
12+
role: 'user' | 'assistant' | 'tool';
13+
content: string;
14+
imageUrl?: string;
15+
}
16+
17+
export interface ChatHistoryData {
18+
version: 1;
19+
savedAt: number;
20+
messages: DisplayMessage[];
21+
chatHistory: ChatMessage[];
22+
}
23+
24+
const STORAGE_KEY = 'webuiapps-chat-history';
25+
const API_PATH = '/api/chat-history';
26+
27+
/**
28+
* Load chat history — priority: local file > localStorage.
29+
*/
30+
export async function loadChatHistory(): Promise<ChatHistoryData | null> {
31+
// 1. Try local file via dev-server API
32+
try {
33+
const res = await fetch(API_PATH);
34+
if (res.ok) {
35+
const data: ChatHistoryData = await res.json();
36+
if (data && data.version === 1) {
37+
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
38+
return data;
39+
}
40+
}
41+
} catch {
42+
// API not available — fall through
43+
}
44+
45+
// 2. Fall back to localStorage
46+
return loadChatHistorySync();
47+
}
48+
49+
/**
50+
* Synchronous read from localStorage cache.
51+
*/
52+
export function loadChatHistorySync(): ChatHistoryData | null {
53+
try {
54+
const raw = localStorage.getItem(STORAGE_KEY);
55+
if (!raw) return null;
56+
const data: ChatHistoryData = JSON.parse(raw);
57+
return data && data.version === 1 ? data : null;
58+
} catch {
59+
return null;
60+
}
61+
}
62+
63+
/**
64+
* Save chat history — writes to both localStorage and local file.
65+
*/
66+
export async function saveChatHistory(
67+
messages: DisplayMessage[],
68+
chatHistory: ChatMessage[],
69+
): Promise<void> {
70+
const data: ChatHistoryData = {
71+
version: 1,
72+
savedAt: Date.now(),
73+
messages,
74+
chatHistory,
75+
};
76+
77+
// Always write localStorage (sync, instant)
78+
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
79+
80+
// Best-effort write to local file
81+
try {
82+
await fetch(API_PATH, {
83+
method: 'POST',
84+
headers: { 'Content-Type': 'application/json' },
85+
body: JSON.stringify(data),
86+
});
87+
} catch {
88+
// Silently ignore if API is not available
89+
}
90+
}
91+
92+
/**
93+
* Clear chat history from both localStorage and local file.
94+
*/
95+
export async function clearChatHistory(): Promise<void> {
96+
localStorage.removeItem(STORAGE_KEY);
97+
98+
try {
99+
await fetch(API_PATH, { method: 'DELETE' });
100+
} catch {
101+
// Silently ignore
102+
}
103+
}

apps/webuiapps/vite.config.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { join } from 'path';
1212
import { generateLogFileName, createLogMiddleware } from './src/lib/logPlugin';
1313

1414
const LLM_CONFIG_FILE = resolve(os.homedir(), '.openroom', 'config.json');
15+
const CHAT_HISTORY_FILE = resolve(os.homedir(), '.openroom', 'history', 'chat.json');
1516

1617
/** LLM config persistence plugin — reads/writes config to ~/.openroom/config.json */
1718
function llmConfigPlugin(): Plugin {
@@ -65,6 +66,72 @@ function llmConfigPlugin(): Plugin {
6566
};
6667
}
6768

69+
/** Chat history persistence plugin — reads/writes to ~/.openroom/history/chat.json */
70+
function chatHistoryPlugin(): Plugin {
71+
return {
72+
name: 'chat-history',
73+
configureServer(server) {
74+
server.middlewares.use('/api/chat-history', (req, res) => {
75+
res.setHeader('Content-Type', 'application/json');
76+
77+
if (req.method === 'GET') {
78+
try {
79+
if (fs.existsSync(CHAT_HISTORY_FILE)) {
80+
const content = fs.readFileSync(CHAT_HISTORY_FILE, 'utf-8');
81+
res.writeHead(200);
82+
res.end(content);
83+
} else {
84+
res.writeHead(404);
85+
res.end(JSON.stringify({ error: 'No history file found' }));
86+
}
87+
} catch (err) {
88+
res.writeHead(500);
89+
res.end(JSON.stringify({ error: String(err) }));
90+
}
91+
return;
92+
}
93+
94+
if (req.method === 'POST') {
95+
const chunks: Buffer[] = [];
96+
req.on('data', (chunk: Buffer) => chunks.push(chunk));
97+
req.on('end', () => {
98+
try {
99+
const body = Buffer.concat(chunks).toString();
100+
JSON.parse(body);
101+
const dir = resolve(os.homedir(), '.openroom', 'history');
102+
fs.mkdirSync(dir, { recursive: true });
103+
fs.writeFileSync(CHAT_HISTORY_FILE, body, 'utf-8');
104+
res.writeHead(200);
105+
res.end(JSON.stringify({ ok: true }));
106+
} catch (err) {
107+
res.writeHead(500);
108+
res.end(JSON.stringify({ error: String(err) }));
109+
}
110+
});
111+
return;
112+
}
113+
114+
if (req.method === 'DELETE') {
115+
try {
116+
if (fs.existsSync(CHAT_HISTORY_FILE)) {
117+
fs.unlinkSync(CHAT_HISTORY_FILE);
118+
}
119+
res.writeHead(200);
120+
res.end(JSON.stringify({ ok: true }));
121+
} catch (err) {
122+
res.writeHead(500);
123+
res.end(JSON.stringify({ error: String(err) }));
124+
}
125+
return;
126+
}
127+
128+
res.writeHead(405);
129+
res.end(JSON.stringify({ error: 'Method not allowed' }));
130+
});
131+
},
132+
};
133+
}
134+
68135
/** Debug log plugin — writes browser logs to logs/debug-*.log */
69136
function logServerPlugin(): Plugin {
70137
return {
@@ -174,6 +241,7 @@ const config = ({ mode }: ConfigEnv): UserConfigExport => {
174241
const skipLegacy = env.VITE_SKIP_LEGACY === 'true';
175242
const plugins: PluginOption[] = [
176243
llmConfigPlugin(),
244+
chatHistoryPlugin(),
177245
logServerPlugin(),
178246
llmProxyPlugin(),
179247
react(),

0 commit comments

Comments
 (0)