Bonsai-Chat-WebGPU / src /lib /conversations.ts
WaveCut's picture
Persist chat tools and stream artifact drafts
179a355 verified
Raw
History Blame Contribute Delete
4.05 kB
import type { ChatMessage, ChatSession, ModelId, ToolEvent } from './contracts';
const MODEL_IDS: readonly ModelId[] = ['bonsai-1.7b', 'bonsai-4b', 'bonsai-8b', 'bonsai-27b'];
export function conversationTitle(messages: ChatMessage[]): string {
const firstPrompt = messages.find((message) => message.role === 'user' && message.content.trim())?.content.trim();
return firstPrompt ? firstPrompt.replace(/\s+/g, ' ').slice(0, 80) : 'New chat';
}
export function conversationPreview(messages: ChatMessage[]): string {
const latest = [...messages].reverse().find((message) => message.content.trim())?.content.trim();
return latest ? latest.replace(/\s+/g, ' ').slice(0, 140) : 'No messages yet';
}
export function normalizeConversationModelId(value: string): ModelId {
return MODEL_IDS.includes(value as ModelId) ? value as ModelId : 'bonsai-27b';
}
export function normalizeConversationToolsEnabled(value: unknown): boolean {
return value === true;
}
export function upsertConversation(sessions: ChatSession[], next: ChatSession): ChatSession[] {
return [next, ...sessions.filter((session) => session.id !== next.id)]
.sort((left, right) => right.updatedAt - left.updatedAt);
}
export function normalizeConversationToolEvents(
value: unknown,
messages: ChatMessage[],
): ToolEvent[] {
const events = new Map<string, ToolEvent>();
for (const message of messages) {
for (const tool of message.tools ?? []) {
const interrupted = tool.state === 'queued' || tool.state === 'running';
events.set(tool.id, {
id: tool.id,
timestamp: message.timestamp,
label: tool.name,
detail: interrupted
? 'interrupted before completion'
: tool.state === 'error'
? 'tool returned a bounded error'
: 'local result restored from chat history',
state: tool.state === 'complete' ? 'complete' : 'error',
});
}
}
if (Array.isArray(value)) {
for (const candidate of value) {
if (!candidate || typeof candidate !== 'object') continue;
const event = candidate as Partial<ToolEvent>;
if (typeof event.id !== 'string'
|| typeof event.timestamp !== 'string'
|| typeof event.label !== 'string'
|| typeof event.detail !== 'string'
|| !['running', 'complete', 'error'].includes(event.state ?? '')) continue;
events.set(event.id, event as ToolEvent);
}
}
return [...events.values()].slice(-20);
}
export interface MessageRegenerationPlan {
baseMessages: ChatMessage[];
userMessage: ChatMessage;
discardedMessageIds: string[];
}
export function planMessageRegeneration(
messages: ChatMessage[],
messageId: string,
): MessageRegenerationPlan | null {
const targetIndex = messages.findIndex((message) => message.id === messageId);
if (targetIndex < 0) return null;
const target = messages[targetIndex];
if (!target || target.role === 'system') return null;
let userIndex = target.role === 'user' ? targetIndex : -1;
for (let index = targetIndex - 1; userIndex < 0 && index >= 0; index -= 1) {
if (messages[index]?.role === 'user') userIndex = index;
}
const userMessage = messages[userIndex];
if (!userMessage || !userMessage.content.trim()) return null;
return {
baseMessages: messages.slice(0, userIndex),
userMessage,
discardedMessageIds: messages.slice(userIndex + 1).map((message) => message.id),
};
}
export function planUserMessageEdit(
messages: ChatMessage[],
messageId: string,
content: string,
editedAt = Date.now(),
): MessageRegenerationPlan | null {
const targetIndex = messages.findIndex((message) => message.id === messageId);
const target = messages[targetIndex];
const normalized = content.trim();
if (targetIndex < 0 || target?.role !== 'user' || !normalized) return null;
return {
baseMessages: messages.slice(0, targetIndex),
userMessage: { ...target, content: normalized, editedAt },
discardedMessageIds: messages.slice(targetIndex + 1).map((message) => message.id),
};
}