mirror of
https://github.com/zadam/trilium.git
synced 2025-11-12 08:15:52 +01:00
Merge pull request #2110 from TriliumNext/feat/llm-integration-part3
LLM Integration, part 3
This commit is contained in:
@@ -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,35 +60,24 @@ 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
|
||||
let receivedAnyContent = false;
|
||||
let receivedPostToolContent = false; // Track if we've started receiving post-tool content
|
||||
let timeoutId: number | null = null;
|
||||
let initialTimeoutId: number | null = null;
|
||||
let cleanupTimeoutId: number | null = null;
|
||||
let receivedAnyMessage = false;
|
||||
let toolsExecuted = false; // Flag to track if tools were executed in this session
|
||||
let toolExecutionCompleted = false; // Flag to track if tool execution is completed
|
||||
let eventListener: ((event: Event) => void) | null = null;
|
||||
let lastMessageTimestamp = 0;
|
||||
|
||||
// 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,
|
||||
@@ -129,28 +114,14 @@ export async function setupStreamingResponse(
|
||||
resolve();
|
||||
};
|
||||
|
||||
// Function to schedule cleanup with ability to cancel
|
||||
const scheduleCleanup = (delay: number) => {
|
||||
// Clear any existing cleanup timeout
|
||||
if (cleanupTimeoutId) {
|
||||
window.clearTimeout(cleanupTimeoutId);
|
||||
// Set initial timeout to catch cases where no message is received at all
|
||||
initialTimeoutId = window.setTimeout(() => {
|
||||
if (!receivedAnyMessage) {
|
||||
console.error(`[${responseId}] No initial message received within timeout`);
|
||||
performCleanup();
|
||||
reject(new Error('No response received from server'));
|
||||
}
|
||||
|
||||
console.log(`[${responseId}] Scheduling listener cleanup in ${delay}ms`);
|
||||
|
||||
// Set new cleanup timeout
|
||||
cleanupTimeoutId = window.setTimeout(() => {
|
||||
// Only clean up if no messages received recently (in last 2 seconds)
|
||||
const timeSinceLastMessage = Date.now() - lastMessageTimestamp;
|
||||
if (timeSinceLastMessage > 2000) {
|
||||
performCleanup();
|
||||
} else {
|
||||
console.log(`[${responseId}] Received message recently, delaying cleanup`);
|
||||
// Reschedule cleanup
|
||||
scheduleCleanup(2000);
|
||||
}
|
||||
}, delay);
|
||||
};
|
||||
}, 10000);
|
||||
|
||||
// Create a message handler for CustomEvents
|
||||
eventListener = (event: Event) => {
|
||||
@@ -158,7 +129,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 +143,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: content=${!!message.content}, contentLength=${message.content?.length || 0}, thinking=${!!message.thinking}, toolExecution=${!!message.toolExecution}, done=${!!message.done}`);
|
||||
|
||||
// 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) {
|
||||
@@ -186,109 +157,33 @@ export async function setupStreamingResponse(
|
||||
}
|
||||
}
|
||||
|
||||
// Handle specific message types
|
||||
if (message.type === 'tool_execution_start') {
|
||||
toolsExecuted = true; // Mark that tools were executed
|
||||
onThinkingUpdate('Executing tools...');
|
||||
// Also trigger tool execution UI with a specific format
|
||||
onToolExecution({
|
||||
action: 'start',
|
||||
tool: 'tools',
|
||||
result: 'Executing tools...'
|
||||
});
|
||||
return; // Skip accumulating content from this message
|
||||
// Handle error
|
||||
if (message.error) {
|
||||
console.error(`[${responseId}] Stream error: ${message.error}`);
|
||||
performCleanup();
|
||||
reject(new Error(message.error));
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'tool_result' && message.toolExecution) {
|
||||
toolsExecuted = true; // Mark that tools were executed
|
||||
console.log(`[${responseId}] Processing tool result: ${JSON.stringify(message.toolExecution)}`);
|
||||
// Handle thinking updates - only show if showThinking is enabled
|
||||
if (message.thinking && messageParams.showThinking) {
|
||||
console.log(`[${responseId}] Received thinking: ${message.thinking.substring(0, 100)}...`);
|
||||
onThinkingUpdate(message.thinking);
|
||||
}
|
||||
|
||||
// If tool execution doesn't have an action, add 'result' as the default
|
||||
if (!message.toolExecution.action) {
|
||||
message.toolExecution.action = 'result';
|
||||
}
|
||||
|
||||
// First send a 'start' action to ensure the container is created
|
||||
onToolExecution({
|
||||
action: 'start',
|
||||
tool: 'tools',
|
||||
result: 'Tool execution initialized'
|
||||
});
|
||||
|
||||
// Then send the actual tool execution data
|
||||
// Handle tool execution updates
|
||||
if (message.toolExecution) {
|
||||
console.log(`[${responseId}] Tool execution update:`, message.toolExecution);
|
||||
onToolExecution(message.toolExecution);
|
||||
|
||||
// Mark tool execution as completed if this is a result or error
|
||||
if (message.toolExecution.action === 'result' || message.toolExecution.action === 'complete' || message.toolExecution.action === 'error') {
|
||||
toolExecutionCompleted = true;
|
||||
console.log(`[${responseId}] Tool execution completed`);
|
||||
}
|
||||
|
||||
return; // Skip accumulating content from this message
|
||||
}
|
||||
|
||||
if (message.type === 'tool_execution_error' && message.toolExecution) {
|
||||
toolsExecuted = true; // Mark that tools were executed
|
||||
toolExecutionCompleted = true; // Mark tool execution as completed
|
||||
onToolExecution({
|
||||
...message.toolExecution,
|
||||
action: 'error',
|
||||
error: message.toolExecution.error || 'Unknown error during tool execution'
|
||||
});
|
||||
return; // Skip accumulating content from this message
|
||||
}
|
||||
|
||||
if (message.type === 'tool_completion_processing') {
|
||||
toolsExecuted = true; // Mark that tools were executed
|
||||
toolExecutionCompleted = true; // Tools are done, now processing the result
|
||||
onThinkingUpdate('Generating response with tool results...');
|
||||
// Also trigger tool execution UI with a specific format
|
||||
onToolExecution({
|
||||
action: 'generating',
|
||||
tool: 'tools',
|
||||
result: 'Generating response with tool results...'
|
||||
});
|
||||
return; // Skip accumulating content from this message
|
||||
}
|
||||
|
||||
// Handle content updates
|
||||
if (message.content) {
|
||||
console.log(`[${responseId}] Received content chunk of length ${message.content.length}, preview: "${message.content.substring(0, 50)}${message.content.length > 50 ? '...' : ''}"`);
|
||||
|
||||
// If tools were executed and completed, and we're now getting new content,
|
||||
// this is likely the final response after tool execution from Anthropic
|
||||
if (toolsExecuted && toolExecutionCompleted && message.content) {
|
||||
console.log(`[${responseId}] Post-tool execution content detected`);
|
||||
|
||||
// If this is the first post-tool chunk, indicate we're starting a new response
|
||||
if (!receivedPostToolContent) {
|
||||
receivedPostToolContent = true;
|
||||
postToolResponse = ''; // Clear any previous post-tool response
|
||||
console.log(`[${responseId}] First post-tool content chunk, starting fresh accumulation`);
|
||||
}
|
||||
|
||||
// Accumulate post-tool execution content
|
||||
postToolResponse += message.content;
|
||||
console.log(`[${responseId}] Accumulated post-tool content, now ${postToolResponse.length} chars`);
|
||||
|
||||
// Update the UI with the accumulated post-tool content
|
||||
// This replaces the pre-tool content with our accumulated post-tool content
|
||||
onContentUpdate(postToolResponse, message.done || false);
|
||||
} else {
|
||||
// Standard content handling for non-tool cases or initial tool response
|
||||
|
||||
// Check if this is a duplicated message containing the same content we already have
|
||||
if (message.done && assistantResponse.includes(message.content)) {
|
||||
console.log(`[${responseId}] Ignoring duplicated content in done message`);
|
||||
} else {
|
||||
// Add to our accumulated response
|
||||
assistantResponse += message.content;
|
||||
}
|
||||
|
||||
// Update the UI immediately with each chunk
|
||||
onContentUpdate(assistantResponse, message.done || false);
|
||||
}
|
||||
// Simply append the new content - no complex deduplication
|
||||
assistantResponse += message.content;
|
||||
|
||||
// Update the UI immediately with each chunk
|
||||
onContentUpdate(assistantResponse, message.done || false);
|
||||
receivedAnyContent = true;
|
||||
|
||||
// Reset timeout since we got content
|
||||
@@ -298,151 +193,33 @@ export async function setupStreamingResponse(
|
||||
|
||||
// Set new timeout
|
||||
timeoutId = window.setTimeout(() => {
|
||||
console.warn(`[${responseId}] Stream timeout for chat note ${chatNoteId}`);
|
||||
|
||||
// Clean up
|
||||
console.warn(`[${responseId}] Stream timeout for chat note ${noteId}`);
|
||||
performCleanup();
|
||||
reject(new Error('Stream timeout'));
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
// Handle tool execution updates (legacy format and standard format with llm-stream type)
|
||||
if (message.toolExecution) {
|
||||
// Only process if we haven't already handled this message via specific message types
|
||||
if (message.type === 'llm-stream' || !message.type) {
|
||||
console.log(`[${responseId}] Received tool execution update: action=${message.toolExecution.action || 'unknown'}`);
|
||||
toolsExecuted = true; // Mark that tools were executed
|
||||
|
||||
// Mark tool execution as completed if this is a result or error
|
||||
if (message.toolExecution.action === 'result' ||
|
||||
message.toolExecution.action === 'complete' ||
|
||||
message.toolExecution.action === 'error') {
|
||||
toolExecutionCompleted = true;
|
||||
console.log(`[${responseId}] Tool execution completed via toolExecution message`);
|
||||
}
|
||||
|
||||
onToolExecution(message.toolExecution);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle tool calls from the raw data or direct in message (OpenAI format)
|
||||
const toolCalls = message.tool_calls || (message.raw && message.raw.tool_calls);
|
||||
if (toolCalls && Array.isArray(toolCalls)) {
|
||||
console.log(`[${responseId}] Received tool calls: ${toolCalls.length} tools`);
|
||||
toolsExecuted = true; // Mark that tools were executed
|
||||
|
||||
// First send a 'start' action to ensure the container is created
|
||||
onToolExecution({
|
||||
action: 'start',
|
||||
tool: 'tools',
|
||||
result: 'Tool execution initialized'
|
||||
});
|
||||
|
||||
// Then process each tool call
|
||||
for (const toolCall of toolCalls) {
|
||||
let args = toolCall.function?.arguments || {};
|
||||
|
||||
// Try to parse arguments if they're a string
|
||||
if (typeof args === 'string') {
|
||||
try {
|
||||
args = JSON.parse(args);
|
||||
} catch (e) {
|
||||
console.log(`[${responseId}] Could not parse tool arguments as JSON: ${e}`);
|
||||
args = { raw: args };
|
||||
}
|
||||
}
|
||||
|
||||
onToolExecution({
|
||||
action: 'executing',
|
||||
tool: toolCall.function?.name || 'unknown',
|
||||
toolCallId: toolCall.id,
|
||||
args: args
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Handle thinking state updates
|
||||
if (message.thinking) {
|
||||
console.log(`[${responseId}] Received thinking update: ${message.thinking.substring(0, 50)}...`);
|
||||
onThinkingUpdate(message.thinking);
|
||||
}
|
||||
|
||||
// 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}, final response: ${assistantResponse.length} chars`);
|
||||
|
||||
// Dump message content to console for debugging
|
||||
if (message.content) {
|
||||
console.log(`[${responseId}] CONTENT IN DONE MESSAGE (first 200 chars): "${message.content.substring(0, 200)}..."`);
|
||||
|
||||
// Check if the done message contains the exact same content as our accumulated response
|
||||
// We normalize by removing whitespace to avoid false negatives due to spacing differences
|
||||
const normalizedMessage = message.content.trim();
|
||||
const normalizedResponse = assistantResponse.trim();
|
||||
|
||||
if (normalizedMessage === normalizedResponse) {
|
||||
console.log(`[${responseId}] Final message is identical to accumulated response, no need to update`);
|
||||
}
|
||||
// If the done message is longer but contains our accumulated response, use the done message
|
||||
else if (normalizedMessage.includes(normalizedResponse) && normalizedMessage.length > normalizedResponse.length) {
|
||||
console.log(`[${responseId}] Final message is more complete than accumulated response, using it`);
|
||||
assistantResponse = message.content;
|
||||
}
|
||||
// If the done message is different and not already included, append it to avoid duplication
|
||||
else if (!normalizedResponse.includes(normalizedMessage) && normalizedMessage.length > 0) {
|
||||
console.log(`[${responseId}] Final message has unique content, using it`);
|
||||
assistantResponse = message.content;
|
||||
}
|
||||
// Otherwise, we already have the content accumulated, so no need to update
|
||||
else {
|
||||
console.log(`[${responseId}] Already have this content accumulated, not updating`);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear timeout if set
|
||||
// Clear all timeouts
|
||||
if (timeoutId !== null) {
|
||||
window.clearTimeout(timeoutId);
|
||||
timeoutId = null;
|
||||
}
|
||||
|
||||
// Always mark as done when we receive the done flag
|
||||
onContentUpdate(assistantResponse, true);
|
||||
|
||||
// Set a longer delay before cleanup to allow for post-tool execution messages
|
||||
// Especially important for Anthropic which may send final message after tool execution
|
||||
const cleanupDelay = toolsExecuted ? 15000 : 1000; // 15 seconds if tools were used, otherwise 1 second
|
||||
console.log(`[${responseId}] Setting cleanup delay of ${cleanupDelay}ms since toolsExecuted=${toolsExecuted}`);
|
||||
scheduleCleanup(cleanupDelay);
|
||||
// Schedule cleanup after a brief delay to ensure all processing is complete
|
||||
cleanupTimeoutId = window.setTimeout(() => {
|
||||
performCleanup();
|
||||
}, 100);
|
||||
}
|
||||
};
|
||||
|
||||
// Register event listener for the custom event
|
||||
try {
|
||||
window.addEventListener('llm-stream-message', eventListener);
|
||||
console.log(`[${responseId}] Event listener added for llm-stream-message events`);
|
||||
} catch (err) {
|
||||
console.error(`[${responseId}] Error setting up event listener:`, err);
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
// Register the event listener for WebSocket messages
|
||||
window.addEventListener('llm-stream-message', eventListener);
|
||||
|
||||
// Set initial timeout for receiving any message
|
||||
initialTimeoutId = window.setTimeout(() => {
|
||||
console.warn(`[${responseId}] No messages received for initial period in chat note ${chatNoteId}`);
|
||||
if (!receivedAnyMessage) {
|
||||
console.error(`[${responseId}] WebSocket connection not established for chat note ${chatNoteId}`);
|
||||
|
||||
if (timeoutId !== null) {
|
||||
window.clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
// Clean up
|
||||
cleanupEventListener(eventListener);
|
||||
|
||||
// Show error message to user
|
||||
reject(new Error('WebSocket connection not established'));
|
||||
}
|
||||
}, 10000);
|
||||
console.log(`[${responseId}] Event listener registered, waiting for messages...`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -463,15 +240,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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -68,7 +69,6 @@ export default class LlmChatPanel extends BasicWidget {
|
||||
totalTokens?: number;
|
||||
};
|
||||
} = {
|
||||
model: 'default',
|
||||
temperature: 0.7,
|
||||
toolExecutions: []
|
||||
};
|
||||
@@ -90,12 +90,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,16 +316,22 @@ 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 || [],
|
||||
// Add metadata
|
||||
metadata: {
|
||||
model: this.metadata?.model || 'default',
|
||||
model: this.metadata?.model || undefined,
|
||||
provider: this.metadata?.provider || undefined,
|
||||
temperature: this.metadata?.temperature || 0.7,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
@@ -325,7 +340,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
|
||||
@@ -347,16 +362,52 @@ export default class LlmChatPanel extends BasicWidget {
|
||||
const savedData = await this.onGetData() as ChatData;
|
||||
|
||||
if (savedData?.messages?.length > 0) {
|
||||
// Check if we actually have new content to avoid unnecessary UI rebuilds
|
||||
const currentMessageCount = this.messages.length;
|
||||
const savedMessageCount = savedData.messages.length;
|
||||
|
||||
// If message counts are the same, check if content is different
|
||||
const hasNewContent = savedMessageCount > currentMessageCount ||
|
||||
JSON.stringify(this.messages) !== JSON.stringify(savedData.messages);
|
||||
|
||||
if (!hasNewContent) {
|
||||
console.log("No new content detected, skipping UI rebuild");
|
||||
return true;
|
||||
}
|
||||
|
||||
console.log(`Loading saved data: ${currentMessageCount} -> ${savedMessageCount} messages`);
|
||||
|
||||
// Store current scroll position if we need to preserve it
|
||||
const shouldPreserveScroll = savedMessageCount > currentMessageCount && currentMessageCount > 0;
|
||||
const currentScrollTop = shouldPreserveScroll ? this.chatContainer.scrollTop : 0;
|
||||
const currentScrollHeight = shouldPreserveScroll ? this.chatContainer.scrollHeight : 0;
|
||||
|
||||
// Load messages
|
||||
const oldMessages = [...this.messages];
|
||||
this.messages = savedData.messages;
|
||||
|
||||
// Clear and rebuild the chat UI
|
||||
this.noteContextChatMessages.innerHTML = '';
|
||||
// Only rebuild UI if we have significantly different content
|
||||
if (savedMessageCount > currentMessageCount) {
|
||||
// We have new messages - just add the new ones instead of rebuilding everything
|
||||
const newMessages = savedData.messages.slice(currentMessageCount);
|
||||
console.log(`Adding ${newMessages.length} new messages to UI`);
|
||||
|
||||
this.messages.forEach(message => {
|
||||
const role = message.role as 'user' | 'assistant';
|
||||
this.addMessageToChat(role, message.content);
|
||||
});
|
||||
newMessages.forEach(message => {
|
||||
const role = message.role as 'user' | 'assistant';
|
||||
this.addMessageToChat(role, message.content);
|
||||
});
|
||||
} else {
|
||||
// Content changed but count is same - need to rebuild
|
||||
console.log("Message content changed, rebuilding UI");
|
||||
|
||||
// Clear and rebuild the chat UI
|
||||
this.noteContextChatMessages.innerHTML = '';
|
||||
|
||||
this.messages.forEach(message => {
|
||||
const role = message.role as 'user' | 'assistant';
|
||||
this.addMessageToChat(role, message.content);
|
||||
});
|
||||
}
|
||||
|
||||
// Restore tool execution steps if they exist
|
||||
if (savedData.toolSteps && Array.isArray(savedData.toolSteps) && savedData.toolSteps.length > 0) {
|
||||
@@ -400,13 +451,33 @@ 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`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Restore scroll position if we were preserving it
|
||||
if (shouldPreserveScroll) {
|
||||
// Calculate the new scroll position to maintain relative position
|
||||
const newScrollHeight = this.chatContainer.scrollHeight;
|
||||
const scrollDifference = newScrollHeight - currentScrollHeight;
|
||||
const newScrollTop = currentScrollTop + scrollDifference;
|
||||
|
||||
// Only scroll down if we're near the bottom, otherwise preserve exact position
|
||||
const wasNearBottom = (currentScrollTop + this.chatContainer.clientHeight) >= (currentScrollHeight - 50);
|
||||
|
||||
if (wasNearBottom) {
|
||||
// User was at bottom, scroll to new bottom
|
||||
this.chatContainer.scrollTop = newScrollHeight;
|
||||
console.log("User was at bottom, scrolling to new bottom");
|
||||
} else {
|
||||
// User was not at bottom, try to preserve their position
|
||||
this.chatContainer.scrollTop = newScrollTop;
|
||||
console.log(`Preserving scroll position: ${currentScrollTop} -> ${newScrollTop}`);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -550,6 +621,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 +637,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 +648,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 +659,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 +720,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 +770,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 +805,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 +842,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 +920,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 +937,7 @@ export default class LlmChatPanel extends BasicWidget {
|
||||
}> = [];
|
||||
|
||||
return setupStreamingResponse(
|
||||
this.chatNoteId,
|
||||
this.noteId,
|
||||
messageParams,
|
||||
// Content update handler
|
||||
(content: string, isDone: boolean = false) => {
|
||||
@@ -898,7 +973,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);
|
||||
|
||||
@@ -933,9 +1008,9 @@ export default class LlmChatPanel extends BasicWidget {
|
||||
}
|
||||
}
|
||||
|
||||
// Save the updated data to the note
|
||||
this.saveCurrentData()
|
||||
.catch(err => console.error("Failed to save data after streaming completed:", err));
|
||||
// DON'T save here - let the server handle saving the complete conversation
|
||||
// to avoid race conditions between client and server saves
|
||||
console.log("Updated metadata after streaming completion, server should save");
|
||||
})
|
||||
.catch(err => console.error("Error fetching session data after streaming:", err));
|
||||
}
|
||||
@@ -973,11 +1048,9 @@ export default class LlmChatPanel extends BasicWidget {
|
||||
|
||||
console.log(`Cached tool execution for ${toolData.tool} to be saved later`);
|
||||
|
||||
// Save immediately after receiving a tool execution
|
||||
// This ensures we don't lose tool execution data if streaming fails
|
||||
this.saveCurrentData().catch(err => {
|
||||
console.error("Failed to save tool execution data:", err);
|
||||
});
|
||||
// DON'T save immediately during streaming - let the server handle saving
|
||||
// to avoid race conditions between client and server saves
|
||||
console.log(`Tool execution cached, will be saved by server`);
|
||||
}
|
||||
},
|
||||
// Complete handler
|
||||
@@ -995,23 +1068,19 @@ export default class LlmChatPanel extends BasicWidget {
|
||||
* Update the UI with streaming content
|
||||
*/
|
||||
private updateStreamingUI(assistantResponse: string, isDone: boolean = false) {
|
||||
// Parse and handle thinking content if present
|
||||
if (!isDone) {
|
||||
const thinkingContent = this.parseThinkingContent(assistantResponse);
|
||||
if (thinkingContent) {
|
||||
this.updateThinkingText(thinkingContent);
|
||||
// Don't display the raw response with think tags in the chat
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Get the existing assistant message or create a new one
|
||||
let assistantMessageEl = this.noteContextChatMessages.querySelector('.assistant-message:last-child');
|
||||
|
||||
if (!assistantMessageEl) {
|
||||
// If no assistant message yet, create one
|
||||
// Track if we have a streaming message in progress
|
||||
const hasStreamingMessage = !!this.noteContextChatMessages.querySelector('.assistant-message.streaming');
|
||||
|
||||
// Create a new message element or use the existing streaming one
|
||||
let assistantMessageEl: HTMLElement;
|
||||
|
||||
if (hasStreamingMessage) {
|
||||
// Use the existing streaming message
|
||||
assistantMessageEl = this.noteContextChatMessages.querySelector('.assistant-message.streaming')!;
|
||||
} else {
|
||||
// Create a new message element
|
||||
assistantMessageEl = document.createElement('div');
|
||||
assistantMessageEl.className = 'assistant-message message mb-3';
|
||||
assistantMessageEl.className = 'assistant-message message mb-3 streaming';
|
||||
this.noteContextChatMessages.appendChild(assistantMessageEl);
|
||||
|
||||
// Add assistant profile icon
|
||||
@@ -1026,60 +1095,37 @@ export default class LlmChatPanel extends BasicWidget {
|
||||
assistantMessageEl.appendChild(messageContent);
|
||||
}
|
||||
|
||||
// Clean the response to remove thinking tags before displaying
|
||||
const cleanedResponse = this.removeThinkingTags(assistantResponse);
|
||||
|
||||
// Update the content
|
||||
// Update the content with the current response
|
||||
const messageContent = assistantMessageEl.querySelector('.message-content') as HTMLElement;
|
||||
messageContent.innerHTML = formatMarkdown(cleanedResponse);
|
||||
messageContent.innerHTML = formatMarkdown(assistantResponse);
|
||||
|
||||
// Apply syntax highlighting if this is the final update
|
||||
// When the response is complete
|
||||
if (isDone) {
|
||||
// Remove the streaming class to mark this message as complete
|
||||
assistantMessageEl.classList.remove('streaming');
|
||||
|
||||
// Apply syntax highlighting
|
||||
formatCodeBlocks($(assistantMessageEl as HTMLElement));
|
||||
|
||||
// Hide the thinking display when response is complete
|
||||
this.hideThinkingDisplay();
|
||||
|
||||
// Update message in the data model for storage
|
||||
// Find the last assistant message to update, or add a new one if none exists
|
||||
const assistantMessages = this.messages.filter(msg => msg.role === 'assistant');
|
||||
const lastAssistantMsgIndex = assistantMessages.length > 0 ?
|
||||
this.messages.lastIndexOf(assistantMessages[assistantMessages.length - 1]) : -1;
|
||||
|
||||
if (lastAssistantMsgIndex >= 0) {
|
||||
// Update existing message with cleaned content
|
||||
this.messages[lastAssistantMsgIndex].content = cleanedResponse;
|
||||
} else {
|
||||
// Add new message with cleaned content
|
||||
this.messages.push({
|
||||
role: 'assistant',
|
||||
content: cleanedResponse
|
||||
});
|
||||
}
|
||||
|
||||
// Hide loading indicator
|
||||
hideLoadingIndicator(this.loadingIndicator);
|
||||
|
||||
// Save the final state to the Chat Note
|
||||
this.saveCurrentData().catch(err => {
|
||||
console.error("Failed to save assistant response to note:", err);
|
||||
// Always add a new message to the data model
|
||||
// This ensures we preserve all distinct assistant messages
|
||||
this.messages.push({
|
||||
role: 'assistant',
|
||||
content: assistantResponse,
|
||||
timestamp: new Date()
|
||||
});
|
||||
|
||||
// Save the updated message list
|
||||
this.saveCurrentData();
|
||||
}
|
||||
|
||||
// Scroll to bottom
|
||||
this.chatContainer.scrollTop = this.chatContainer.scrollHeight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove thinking tags from response content
|
||||
*/
|
||||
private removeThinkingTags(content: string): string {
|
||||
if (!content) return content;
|
||||
|
||||
// Remove <think>...</think> blocks from the content
|
||||
return content.replace(/<think>[\s\S]*?<\/think>/gi, '').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle general errors in the send message flow
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user