feat(llm): redo chat storage, part 1

This commit is contained in:
perf3ct 2025-06-02 00:56:19 +00:00
parent 206905b278
commit 35f78aede9
No known key found for this signature in database
GPG Key ID: 569C4EEC436F5232
6 changed files with 142 additions and 104 deletions

View File

@ -6,8 +6,10 @@ import type { SessionResponse } from "./types.js";
/**
* Create a new chat session
* @param currentNoteId - Optional current note ID for context
* @returns The noteId of the created chat note
*/
export async function createChatSession(currentNoteId?: string): Promise<{chatNoteId: string | null, noteId: string | null}> {
export async function createChatSession(currentNoteId?: string): Promise<string | null> {
try {
const resp = await server.post<SessionResponse>('llm/chat', {
title: 'Note Chat',
@ -15,48 +17,42 @@ export async function createChatSession(currentNoteId?: string): Promise<{chatNo
});
if (resp && resp.id) {
// The backend might provide the noteId separately from the chatNoteId
// If noteId is provided, use it; otherwise, we'll need to query for it separately
return {
chatNoteId: resp.id,
noteId: resp.noteId || null
};
// Backend returns the chat note ID as 'id'
return resp.id;
}
} catch (error) {
console.error('Failed to create chat session:', error);
}
return {
chatNoteId: null,
noteId: null
};
return null;
}
/**
* Check if a session exists
* Check if a chat note exists
* @param noteId - The ID of the chat note
*/
export async function checkSessionExists(chatNoteId: string): Promise<boolean> {
export async function checkSessionExists(noteId: string): Promise<boolean> {
try {
// Validate that we have a proper note ID format, not a session ID
// Note IDs in Trilium are typically longer or in a different format
if (chatNoteId && chatNoteId.length === 16 && /^[A-Za-z0-9]+$/.test(chatNoteId)) {
console.warn(`Invalid note ID format detected: ${chatNoteId} appears to be a legacy session ID`);
return false;
}
const sessionCheck = await server.getWithSilentNotFound<any>(`llm/chat/${chatNoteId}`);
const sessionCheck = await server.getWithSilentNotFound<any>(`llm/chat/${noteId}`);
return !!(sessionCheck && sessionCheck.id);
} catch (error: any) {
console.log(`Error checking chat note ${chatNoteId}:`, error);
console.log(`Error checking chat note ${noteId}:`, error);
return false;
}
}
/**
* Set up streaming response via WebSocket
* @param noteId - The ID of the chat note
* @param messageParams - Message parameters
* @param onContentUpdate - Callback for content updates
* @param onThinkingUpdate - Callback for thinking updates
* @param onToolExecution - Callback for tool execution
* @param onComplete - Callback for completion
* @param onError - Callback for errors
*/
export async function setupStreamingResponse(
chatNoteId: string,
noteId: string,
messageParams: any,
onContentUpdate: (content: string, isDone?: boolean) => void,
onThinkingUpdate: (thinking: string) => void,
@ -64,13 +60,6 @@ export async function setupStreamingResponse(
onComplete: () => void,
onError: (error: Error) => void
): Promise<void> {
// Validate that we have a proper note ID format, not a session ID
if (chatNoteId && chatNoteId.length === 16 && /^[A-Za-z0-9]+$/.test(chatNoteId)) {
console.error(`Invalid note ID format: ${chatNoteId} appears to be a legacy session ID`);
onError(new Error("Invalid note ID format - using a legacy session ID"));
return;
}
return new Promise((resolve, reject) => {
let assistantResponse = '';
let postToolResponse = ''; // Separate accumulator for post-tool execution content
@ -87,12 +76,12 @@ export async function setupStreamingResponse(
// Create a unique identifier for this response process
const responseId = `llm-stream-${Date.now()}-${Math.floor(Math.random() * 1000)}`;
console.log(`[${responseId}] Setting up WebSocket streaming for chat note ${chatNoteId}`);
console.log(`[${responseId}] Setting up WebSocket streaming for chat note ${noteId}`);
// Send the initial request to initiate streaming
(async () => {
try {
const streamResponse = await server.post<any>(`llm/chat/${chatNoteId}/messages/stream`, {
const streamResponse = await server.post<any>(`llm/chat/${noteId}/messages/stream`, {
content: messageParams.content,
useAdvancedContext: messageParams.useAdvancedContext,
showThinking: messageParams.showThinking,
@ -158,7 +147,7 @@ export async function setupStreamingResponse(
const message = customEvent.detail;
// Only process messages for our chat note
if (!message || message.chatNoteId !== chatNoteId) {
if (!message || message.chatNoteId !== noteId) {
return;
}
@ -172,12 +161,12 @@ export async function setupStreamingResponse(
cleanupTimeoutId = null;
}
console.log(`[${responseId}] LLM Stream message received via CustomEvent: chatNoteId=${chatNoteId}, content=${!!message.content}, contentLength=${message.content?.length || 0}, thinking=${!!message.thinking}, toolExecution=${!!message.toolExecution}, done=${!!message.done}, type=${message.type || 'llm-stream'}`);
console.log(`[${responseId}] LLM Stream message received via CustomEvent: chatNoteId=${noteId}, content=${!!message.content}, contentLength=${message.content?.length || 0}, thinking=${!!message.thinking}, toolExecution=${!!message.toolExecution}, done=${!!message.done}, type=${message.type || 'llm-stream'}`);
// Mark first message received
if (!receivedAnyMessage) {
receivedAnyMessage = true;
console.log(`[${responseId}] First message received for chat note ${chatNoteId}`);
console.log(`[${responseId}] First message received for chat note ${noteId}`);
// Clear the initial timeout since we've received a message
if (initialTimeoutId !== null) {
@ -298,7 +287,7 @@ export async function setupStreamingResponse(
// Set new timeout
timeoutId = window.setTimeout(() => {
console.warn(`[${responseId}] Stream timeout for chat note ${chatNoteId}`);
console.warn(`[${responseId}] Stream timeout for chat note ${noteId}`);
// Clean up
performCleanup();
@ -369,7 +358,7 @@ export async function setupStreamingResponse(
// Handle completion
if (message.done) {
console.log(`[${responseId}] Stream completed for chat note ${chatNoteId}, has content: ${!!message.content}, content length: ${message.content?.length || 0}, current response: ${assistantResponse.length} chars`);
console.log(`[${responseId}] Stream completed for chat note ${noteId}, has content: ${!!message.content}, content length: ${message.content?.length || 0}, current response: ${assistantResponse.length} chars`);
// Dump message content to console for debugging
if (message.content) {
@ -428,9 +417,9 @@ export async function setupStreamingResponse(
// Set initial timeout for receiving any message
initialTimeoutId = window.setTimeout(() => {
console.warn(`[${responseId}] No messages received for initial period in chat note ${chatNoteId}`);
console.warn(`[${responseId}] No messages received for initial period in chat note ${noteId}`);
if (!receivedAnyMessage) {
console.error(`[${responseId}] WebSocket connection not established for chat note ${chatNoteId}`);
console.error(`[${responseId}] WebSocket connection not established for chat note ${noteId}`);
if (timeoutId !== null) {
window.clearTimeout(timeoutId);
@ -463,15 +452,9 @@ function cleanupEventListener(listener: ((event: Event) => void) | null): void {
/**
* Get a direct response from the server without streaming
*/
export async function getDirectResponse(chatNoteId: string, messageParams: any): Promise<any> {
export async function getDirectResponse(noteId: string, messageParams: any): Promise<any> {
try {
// Validate that we have a proper note ID format, not a session ID
if (chatNoteId && chatNoteId.length === 16 && /^[A-Za-z0-9]+$/.test(chatNoteId)) {
console.error(`Invalid note ID format: ${chatNoteId} appears to be a legacy session ID`);
throw new Error("Invalid note ID format - using a legacy session ID");
}
const postResponse = await server.post<any>(`llm/chat/${chatNoteId}/messages`, {
const postResponse = await server.post<any>(`llm/chat/${noteId}/messages`, {
message: messageParams.content,
includeContext: messageParams.useAdvancedContext,
options: {

View File

@ -37,9 +37,10 @@ export default class LlmChatPanel extends BasicWidget {
private thinkingBubble!: HTMLElement;
private thinkingText!: HTMLElement;
private thinkingToggle!: HTMLElement;
private chatNoteId: string | null = null;
private noteId: string | null = null; // The actual noteId for the Chat Note
private currentNoteId: string | null = null;
// Simplified to just use noteId - this represents the AI Chat note we're working with
private noteId: string | null = null;
private currentNoteId: string | null = null; // The note providing context (for regular notes)
private _messageHandlerId: number | null = null;
private _messageHandler: any = null;
@ -90,12 +91,21 @@ export default class LlmChatPanel extends BasicWidget {
this.messages = messages;
}
public getChatNoteId(): string | null {
return this.chatNoteId;
public getNoteId(): string | null {
return this.noteId;
}
public setChatNoteId(chatNoteId: string | null): void {
this.chatNoteId = chatNoteId;
public setNoteId(noteId: string | null): void {
this.noteId = noteId;
}
// Deprecated - keeping for backward compatibility but mapping to noteId
public getChatNoteId(): string | null {
return this.noteId;
}
public setChatNoteId(noteId: string | null): void {
this.noteId = noteId;
}
public getNoteContextChatMessages(): HTMLElement {
@ -307,10 +317,16 @@ export default class LlmChatPanel extends BasicWidget {
}
}
const dataToSave: ChatData = {
// Only save if we have a valid note ID
if (!this.noteId) {
console.warn('Cannot save chat data: no noteId available');
return;
}
const dataToSave = {
messages: this.messages,
chatNoteId: this.chatNoteId,
noteId: this.noteId,
chatNoteId: this.noteId, // For backward compatibility
toolSteps: toolSteps,
// Add sources if we have them
sources: this.sources || [],
@ -325,7 +341,7 @@ export default class LlmChatPanel extends BasicWidget {
}
};
console.log(`Saving chat data with chatNoteId: ${this.chatNoteId}, noteId: ${this.noteId}, ${toolSteps.length} tool steps, ${this.sources?.length || 0} sources, ${toolExecutions.length} tool executions`);
console.log(`Saving chat data with noteId: ${this.noteId}, ${toolSteps.length} tool steps, ${this.sources?.length || 0} sources, ${toolExecutions.length} tool executions`);
// Save the data to the note attribute via the callback
// This is the ONLY place we should save data, letting the container widget handle persistence
@ -400,7 +416,6 @@ export default class LlmChatPanel extends BasicWidget {
// Load Chat Note ID if available
if (savedData.noteId) {
console.log(`Using noteId as Chat Note ID: ${savedData.noteId}`);
this.chatNoteId = savedData.noteId;
this.noteId = savedData.noteId;
} else {
console.log(`No noteId found in saved data, cannot load chat session`);
@ -550,6 +565,15 @@ export default class LlmChatPanel extends BasicWidget {
// Get current note context if needed
const currentActiveNoteId = appContext.tabManager.getActiveContext()?.note?.noteId || null;
// For AI Chat notes, the note itself IS the chat session
// So currentNoteId and noteId should be the same
if (this.noteId && currentActiveNoteId === this.noteId) {
// We're in an AI Chat note - don't reset, just load saved data
console.log(`Refreshing AI Chat note ${this.noteId} - loading saved data`);
await this.loadSavedData();
return;
}
// If we're switching to a different note, we need to reset
if (this.currentNoteId !== currentActiveNoteId) {
console.log(`Note ID changed from ${this.currentNoteId} to ${currentActiveNoteId}, resetting chat panel`);
@ -557,7 +581,6 @@ export default class LlmChatPanel extends BasicWidget {
// Reset the UI and data
this.noteContextChatMessages.innerHTML = '';
this.messages = [];
this.chatNoteId = null;
this.noteId = null; // Also reset the chat note ID
this.hideSources(); // Hide any sources from previous note
@ -569,7 +592,7 @@ export default class LlmChatPanel extends BasicWidget {
const hasSavedData = await this.loadSavedData();
// Only create a new session if we don't have a session or saved data
if (!this.chatNoteId || !this.noteId || !hasSavedData) {
if (!this.noteId || !hasSavedData) {
// Create a new chat session
await this.createChatSession();
}
@ -580,19 +603,15 @@ export default class LlmChatPanel extends BasicWidget {
*/
private async createChatSession() {
try {
// Create a new chat session, passing the current note ID if it exists
const { chatNoteId, noteId } = await createChatSession(
this.currentNoteId ? this.currentNoteId : undefined
);
// If we already have a noteId (for AI Chat notes), use it
const contextNoteId = this.noteId || this.currentNoteId;
if (chatNoteId) {
// If we got back an ID from the API, use it
this.chatNoteId = chatNoteId;
// For new sessions, the noteId should equal the chatNoteId
// This ensures we're using the note ID consistently
this.noteId = noteId || chatNoteId;
// Create a new chat session, passing the context note ID
const noteId = await createChatSession(contextNoteId ? contextNoteId : undefined);
if (noteId) {
// Set the note ID for this chat
this.noteId = noteId;
console.log(`Created new chat session with noteId: ${this.noteId}`);
} else {
throw new Error("Failed to create chat session - no ID returned");
@ -645,7 +664,7 @@ export default class LlmChatPanel extends BasicWidget {
const showThinking = this.showThinkingCheckbox.checked;
// Add logging to verify parameters
console.log(`Sending message with: useAdvancedContext=${useAdvancedContext}, showThinking=${showThinking}, noteId=${this.currentNoteId}, sessionId=${this.chatNoteId}`);
console.log(`Sending message with: useAdvancedContext=${useAdvancedContext}, showThinking=${showThinking}, noteId=${this.currentNoteId}, sessionId=${this.noteId}`);
// Create the message parameters
const messageParams = {
@ -695,11 +714,11 @@ export default class LlmChatPanel extends BasicWidget {
await validateEmbeddingProviders(this.validationWarning);
// Make sure we have a valid session
if (!this.chatNoteId) {
if (!this.noteId) {
// If no session ID, create a new session
await this.createChatSession();
if (!this.chatNoteId) {
if (!this.noteId) {
// If still no session ID, show error and return
console.error("Failed to create chat session");
toastService.showError("Failed to create chat session");
@ -730,7 +749,7 @@ export default class LlmChatPanel extends BasicWidget {
await this.saveCurrentData();
// Add logging to verify parameters
console.log(`Sending message with: useAdvancedContext=${useAdvancedContext}, showThinking=${showThinking}, noteId=${this.currentNoteId}, sessionId=${this.chatNoteId}`);
console.log(`Sending message with: useAdvancedContext=${useAdvancedContext}, showThinking=${showThinking}, noteId=${this.currentNoteId}, sessionId=${this.noteId}`);
// Create the message parameters
const messageParams = {
@ -767,12 +786,12 @@ export default class LlmChatPanel extends BasicWidget {
*/
private async handleDirectResponse(messageParams: any): Promise<boolean> {
try {
if (!this.chatNoteId) return false;
if (!this.noteId) return false;
console.log(`Getting direct response using sessionId: ${this.chatNoteId} (noteId: ${this.noteId})`);
console.log(`Getting direct response using sessionId: ${this.noteId} (noteId: ${this.noteId})`);
// Get a direct response from the server
const postResponse = await getDirectResponse(this.chatNoteId, messageParams);
const postResponse = await getDirectResponse(this.noteId, messageParams);
// If the POST request returned content directly, display it
if (postResponse && postResponse.content) {
@ -845,11 +864,11 @@ export default class LlmChatPanel extends BasicWidget {
* Set up streaming response via WebSocket
*/
private async setupStreamingResponse(messageParams: any): Promise<void> {
if (!this.chatNoteId) {
if (!this.noteId) {
throw new Error("No session ID available");
}
console.log(`Setting up streaming response using sessionId: ${this.chatNoteId} (noteId: ${this.noteId})`);
console.log(`Setting up streaming response using sessionId: ${this.noteId} (noteId: ${this.noteId})`);
// Store tool executions captured during streaming
const toolExecutionsCache: Array<{
@ -862,7 +881,7 @@ export default class LlmChatPanel extends BasicWidget {
}> = [];
return setupStreamingResponse(
this.chatNoteId,
this.noteId,
messageParams,
// Content update handler
(content: string, isDone: boolean = false) => {
@ -898,7 +917,7 @@ export default class LlmChatPanel extends BasicWidget {
similarity?: number;
content?: string;
}>;
}>(`llm/chat/${this.chatNoteId}`)
}>(`llm/chat/${this.noteId}`)
.then((sessionData) => {
console.log("Got updated session data:", sessionData);

View File

@ -11,7 +11,7 @@ export interface ChatResponse {
export interface SessionResponse {
id: string;
title: string;
noteId?: string;
noteId: string; // The ID of the chat note
}
export interface ToolExecutionStep {
@ -33,8 +33,8 @@ export interface MessageData {
export interface ChatData {
messages: MessageData[];
chatNoteId: string | null;
noteId?: string | null;
noteId: string; // The ID of the chat note
chatNoteId?: string; // Deprecated - kept for backward compatibility, should equal noteId
toolSteps: ToolExecutionStep[];
sources?: Array<{
noteId: string;

View File

@ -94,6 +94,11 @@ export default class AiChatTypeWidget extends TypeWidget {
this.llmChatPanel.clearNoteContextChatMessages();
this.llmChatPanel.setMessages([]);
// Set the note ID for the chat panel
if (note) {
this.llmChatPanel.setNoteId(note.noteId);
}
// This will load saved data via the getData callback
await this.llmChatPanel.refresh();
this.isInitialized = true;
@ -130,7 +135,7 @@ export default class AiChatTypeWidget extends TypeWidget {
// Reset the chat panel UI
this.llmChatPanel.clearNoteContextChatMessages();
this.llmChatPanel.setMessages([]);
this.llmChatPanel.setChatNoteId(null);
this.llmChatPanel.setNoteId(this.note.noteId);
}
// Call the parent method to refresh
@ -152,6 +157,7 @@ export default class AiChatTypeWidget extends TypeWidget {
// Make sure the chat panel has the current note ID
if (this.note) {
this.llmChatPanel.setCurrentNoteId(this.note.noteId);
this.llmChatPanel.setNoteId(this.note.noteId);
}
this.initPromise = (async () => {
@ -186,7 +192,7 @@ export default class AiChatTypeWidget extends TypeWidget {
// Format the data properly - this is the canonical format of the data
const formattedData = {
messages: data.messages || [],
chatNoteId: data.chatNoteId || this.note.noteId,
noteId: this.note.noteId, // Always use the note's own ID
toolSteps: data.toolSteps || [],
sources: data.sources || [],
metadata: {

View File

@ -814,10 +814,11 @@ async function streamMessage(req: Request, res: Response) {
throw new Error('Content cannot be empty');
}
// Check if session exists
const session = restChatService.getSessions().get(chatNoteId);
// Get or create session from Chat Note
// This will check the sessions store first, and if not found, create from the Chat Note
const session = await restChatService.getOrCreateSessionFromChatNote(chatNoteId, true);
if (!session) {
throw new Error('Chat not found');
throw new Error('Chat not found and could not be created from note');
}
// Update last active timestamp

View File

@ -480,27 +480,56 @@ class RestChatService {
const options: any = req.body || {};
const title = options.title || 'Chat Session';
// Use the currentNoteId as the chatNoteId if provided
let chatNoteId = options.chatNoteId;
// Determine the note ID for the chat
let noteId = options.noteId || options.chatNoteId; // Accept either name for backward compatibility
// If currentNoteId is provided but chatNoteId is not, use currentNoteId
if (!chatNoteId && options.currentNoteId) {
chatNoteId = options.currentNoteId;
log.info(`Using provided currentNoteId ${chatNoteId} as chatNoteId`);
// If currentNoteId is provided, check if it's already an AI Chat note
if (!noteId && options.currentNoteId) {
// Import becca to check note type
const becca = (await import('../../../becca/becca.js')).default;
const note = becca.notes[options.currentNoteId];
// Check if this is an AI Chat note by looking at its content structure
if (note) {
try {
const content = note.getContent();
if (content) {
const contentStr = typeof content === 'string' ? content : content.toString();
const parsedContent = JSON.parse(contentStr);
// AI Chat notes have a messages array and noteId in their content
if (parsedContent.messages && Array.isArray(parsedContent.messages) && parsedContent.noteId) {
// This looks like an AI Chat note - use it directly
noteId = options.currentNoteId;
log.info(`Using existing AI Chat note ${noteId} as session`);
}
}
} catch (e) {
// Not JSON content, so not an AI Chat note
}
}
// If we still don't have a chatNoteId, create a new Chat Note
if (!chatNoteId) {
if (!noteId) {
log.info(`Creating new chat note from context of note ${options.currentNoteId}`);
// Don't use the currentNoteId as the chat note ID - create a new one
}
}
// If we don't have a noteId, create a new Chat Note
if (!noteId) {
// Create a new Chat Note via the storage service
const chatStorageService = (await import('../../llm/chat_storage_service.js')).default;
const newChat = await chatStorageService.createChat(title);
chatNoteId = newChat.id;
log.info(`Created new Chat Note with ID: ${chatNoteId}`);
noteId = newChat.id;
log.info(`Created new Chat Note with ID: ${noteId}`);
} else {
// We have a noteId - this means we're working with an existing aiChat note
// Don't create another note, just use the existing one
log.info(`Using existing Chat Note with ID: ${noteId}`);
}
// Create a new session through our session store
// Create a new session through our session store using the note ID
const session = SessionsStore.createSession({
chatNoteId,
chatNoteId: noteId, // This is really the noteId of the chat note
title,
systemPrompt: options.systemPrompt,
contextNoteId: options.contextNoteId,
@ -511,10 +540,10 @@ class RestChatService {
});
return {
id: session.id,
id: session.id, // This will be the same as noteId
title: session.title,
createdAt: session.createdAt,
noteId: chatNoteId // Return the note ID explicitly
noteId: noteId // Return the note ID for clarity
};
} catch (error: any) {
log.error(`Error creating LLM session: ${error.message || 'Unknown error'}`);