This commit is contained in:
108
packages/server/src/local/routes/chat-persistence.ts
Normal file
108
packages/server/src/local/routes/chat-persistence.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* chat-persistence.ts — Message persistence functions for the chat route.
|
||||
*
|
||||
* Extracted from chat.ts to keep files under 800 LOC.
|
||||
* These functions depend on `fs`, `path` — no server state.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { GENERATION_FAILED_PREFIX } from '@waggle/shared';
|
||||
|
||||
/**
|
||||
* Persist a chat message to the session's .jsonl file on disk.
|
||||
* This ensures messages survive server restarts.
|
||||
*/
|
||||
export function persistMessage(
|
||||
dataDir: string,
|
||||
workspaceId: string,
|
||||
sessionId: string,
|
||||
msg: { role: string; content: string },
|
||||
): void {
|
||||
const sessionsDir = path.join(dataDir, 'workspaces', workspaceId, 'sessions');
|
||||
if (!fs.existsSync(sessionsDir)) {
|
||||
fs.mkdirSync(sessionsDir, { recursive: true });
|
||||
}
|
||||
const filePath = path.join(sessionsDir, `${sessionId}.jsonl`);
|
||||
|
||||
// Create file with meta line if it doesn't exist
|
||||
if (!fs.existsSync(filePath)) {
|
||||
const meta = JSON.stringify({ type: 'meta', title: null, created: new Date().toISOString() });
|
||||
fs.writeFileSync(filePath, meta + '\n', 'utf-8');
|
||||
}
|
||||
|
||||
const line = JSON.stringify({ role: msg.role, content: msg.content, timestamp: new Date().toISOString() });
|
||||
fs.appendFileSync(filePath, line + '\n', 'utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip a trailing failed user+assistant pair from a session's .jsonl file.
|
||||
*
|
||||
* A chat error persists the user turn followed by an assistant turn whose
|
||||
* content begins with GENERATION_FAILED_PREFIX. A client Retry re-issues the
|
||||
* same user message, which would leave the old failed pair duplicated on
|
||||
* reload. Call this before persisting the retried turn to drop that pair.
|
||||
*
|
||||
* Only rewrites when the tail is exactly a failed pair (assistant-failure line
|
||||
* preceded by a user line). Idempotent and safe otherwise. Returns whether it
|
||||
* stripped anything.
|
||||
*/
|
||||
export function stripTrailingFailedPair(
|
||||
dataDir: string,
|
||||
workspaceId: string,
|
||||
sessionId: string,
|
||||
): boolean {
|
||||
const filePath = path.join(dataDir, 'workspaces', workspaceId, 'sessions', `${sessionId}.jsonl`);
|
||||
if (!fs.existsSync(filePath)) return false;
|
||||
|
||||
const lines = fs.readFileSync(filePath, 'utf-8').split('\n').filter((l) => l.trim() !== '');
|
||||
if (lines.length < 2) return false;
|
||||
|
||||
const parse = (line: string): { role?: unknown; content?: unknown; type?: unknown } | null => {
|
||||
try { return JSON.parse(line); } catch { return null; }
|
||||
};
|
||||
|
||||
const lastIdx = lines.length - 1;
|
||||
const last = parse(lines[lastIdx]);
|
||||
if (!last || last.type === 'meta' || last.role !== 'assistant'
|
||||
|| typeof last.content !== 'string' || !last.content.startsWith(GENERATION_FAILED_PREFIX)) {
|
||||
return false;
|
||||
}
|
||||
const prev = parse(lines[lastIdx - 1]);
|
||||
if (!prev || prev.type === 'meta' || prev.role !== 'user') return false;
|
||||
|
||||
const kept = lines.slice(0, lastIdx - 1);
|
||||
fs.writeFileSync(filePath, kept.length ? kept.join('\n') + '\n' : '', 'utf-8');
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load chat messages from a session's .jsonl file on disk.
|
||||
* Returns messages in the format expected by the agent loop.
|
||||
*/
|
||||
export function loadSessionMessages(
|
||||
dataDir: string,
|
||||
workspaceId: string,
|
||||
sessionId: string,
|
||||
): Array<{ role: string; content: string }> {
|
||||
const filePath = path.join(dataDir, 'workspaces', workspaceId, 'sessions', `${sessionId}.jsonl`);
|
||||
if (!fs.existsSync(filePath)) return [];
|
||||
|
||||
const content = fs.readFileSync(filePath, 'utf-8').trim();
|
||||
if (!content) return [];
|
||||
|
||||
const messages: Array<{ role: string; content: string }> = [];
|
||||
for (const line of content.split('\n')) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const parsed = JSON.parse(line);
|
||||
if (parsed.type === 'meta') continue; // skip metadata line
|
||||
if (parsed.role && parsed.content !== undefined) {
|
||||
messages.push({ role: parsed.role, content: parsed.content });
|
||||
}
|
||||
} catch {
|
||||
// skip malformed lines
|
||||
}
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
Reference in New Issue
Block a user