2025-03-02 19:39:10 -08:00
|
|
|
import notes from '../notes.js';
|
|
|
|
import sql from '../sql.js';
|
|
|
|
import attributes from '../attributes.js';
|
|
|
|
import type { Message } from './ai_interface.js';
|
2025-04-13 21:16:18 +00:00
|
|
|
import type { ToolCall } from './tools/tool_interfaces.js';
|
2025-03-30 21:28:34 +00:00
|
|
|
import { t } from 'i18next';
|
2025-04-13 21:16:18 +00:00
|
|
|
import log from '../log.js';
|
2025-03-02 19:39:10 -08:00
|
|
|
|
|
|
|
interface StoredChat {
|
|
|
|
id: string;
|
|
|
|
title: string;
|
|
|
|
messages: Message[];
|
|
|
|
noteId?: string;
|
|
|
|
createdAt: Date;
|
|
|
|
updatedAt: Date;
|
2025-04-13 21:16:18 +00:00
|
|
|
metadata?: ChatMetadata;
|
|
|
|
}
|
|
|
|
|
|
|
|
interface ChatMetadata {
|
|
|
|
sources?: Array<{
|
|
|
|
noteId: string;
|
|
|
|
title: string;
|
|
|
|
similarity?: number;
|
|
|
|
path?: string;
|
|
|
|
branchId?: string;
|
|
|
|
content?: string;
|
|
|
|
}>;
|
|
|
|
model?: string;
|
|
|
|
provider?: string;
|
|
|
|
contextNoteId?: string;
|
|
|
|
toolExecutions?: Array<ToolExecution>;
|
|
|
|
usage?: {
|
|
|
|
promptTokens?: number;
|
|
|
|
completionTokens?: number;
|
|
|
|
totalTokens?: number;
|
|
|
|
};
|
|
|
|
temperature?: number;
|
|
|
|
maxTokens?: number;
|
|
|
|
}
|
|
|
|
|
|
|
|
interface ToolExecution {
|
|
|
|
id: string;
|
|
|
|
name: string;
|
|
|
|
arguments: Record<string, any> | string;
|
|
|
|
result: string | Record<string, any>;
|
|
|
|
error?: string;
|
|
|
|
timestamp: Date;
|
|
|
|
executionTime?: number;
|
2025-03-02 19:39:10 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Service for storing and retrieving chat histories
|
|
|
|
* Chats are stored as a special type of note
|
|
|
|
*/
|
|
|
|
export class ChatStorageService {
|
|
|
|
private static readonly CHAT_LABEL = 'triliumChat';
|
|
|
|
private static readonly CHAT_ROOT_LABEL = 'triliumChatRoot';
|
|
|
|
private static readonly CHAT_TYPE = 'code';
|
|
|
|
private static readonly CHAT_MIME = 'application/json';
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Get or create the root note for all chats
|
|
|
|
*/
|
|
|
|
async getOrCreateChatRoot(): Promise<string> {
|
|
|
|
const existingRoot = await sql.getRow<{noteId: string}>(
|
|
|
|
`SELECT noteId FROM attributes WHERE name = ? AND value = ?`,
|
|
|
|
['label', ChatStorageService.CHAT_ROOT_LABEL]
|
|
|
|
);
|
|
|
|
|
|
|
|
if (existingRoot) {
|
|
|
|
return existingRoot.noteId;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Create root note for chats
|
|
|
|
const { note } = notes.createNewNote({
|
|
|
|
parentNoteId: 'root',
|
2025-03-30 21:28:34 +00:00
|
|
|
title: t('ai.chat.root_note_title'),
|
2025-03-02 19:39:10 -08:00
|
|
|
type: 'text',
|
2025-03-30 21:28:34 +00:00
|
|
|
content: t('ai.chat.root_note_content')
|
2025-03-02 19:39:10 -08:00
|
|
|
});
|
|
|
|
|
|
|
|
attributes.createLabel(
|
|
|
|
note.noteId,
|
|
|
|
ChatStorageService.CHAT_ROOT_LABEL,
|
|
|
|
''
|
|
|
|
);
|
|
|
|
|
|
|
|
return note.noteId;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Create a new chat
|
|
|
|
*/
|
2025-04-13 21:16:18 +00:00
|
|
|
async createChat(title: string, messages: Message[] = [], metadata?: ChatMetadata): Promise<StoredChat> {
|
2025-03-02 19:39:10 -08:00
|
|
|
const rootNoteId = await this.getOrCreateChatRoot();
|
|
|
|
const now = new Date();
|
|
|
|
|
|
|
|
const { note } = notes.createNewNote({
|
|
|
|
parentNoteId: rootNoteId,
|
2025-03-30 21:28:34 +00:00
|
|
|
title: title || t('ai.chat.new_chat_title') + ' ' + now.toLocaleString(),
|
2025-03-02 19:39:10 -08:00
|
|
|
type: ChatStorageService.CHAT_TYPE,
|
|
|
|
mime: ChatStorageService.CHAT_MIME,
|
|
|
|
content: JSON.stringify({
|
|
|
|
messages,
|
2025-04-13 21:16:18 +00:00
|
|
|
metadata: metadata || {},
|
2025-03-02 19:39:10 -08:00
|
|
|
createdAt: now,
|
|
|
|
updatedAt: now
|
|
|
|
}, null, 2)
|
|
|
|
});
|
|
|
|
|
|
|
|
attributes.createLabel(
|
|
|
|
note.noteId,
|
|
|
|
ChatStorageService.CHAT_LABEL,
|
|
|
|
''
|
|
|
|
);
|
|
|
|
|
|
|
|
return {
|
|
|
|
id: note.noteId,
|
2025-03-30 21:28:34 +00:00
|
|
|
title: title || t('ai.chat.new_chat_title') + ' ' + now.toLocaleString(),
|
2025-03-02 19:39:10 -08:00
|
|
|
messages,
|
|
|
|
noteId: note.noteId,
|
|
|
|
createdAt: now,
|
2025-04-13 21:16:18 +00:00
|
|
|
updatedAt: now,
|
|
|
|
metadata: metadata || {}
|
2025-03-02 19:39:10 -08:00
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Get all chats
|
|
|
|
*/
|
|
|
|
async getAllChats(): Promise<StoredChat[]> {
|
|
|
|
const chats = await sql.getRows<{noteId: string, title: string, dateCreated: string, dateModified: string, content: string}>(
|
2025-04-15 22:37:57 +00:00
|
|
|
`SELECT notes.noteId, notes.title, notes.dateCreated, notes.dateModified, blobs.content
|
2025-03-02 19:39:10 -08:00
|
|
|
FROM notes
|
2025-04-15 22:37:57 +00:00
|
|
|
JOIN blobs ON notes.blobId = blobs.blobId
|
2025-03-02 19:39:10 -08:00
|
|
|
JOIN attributes ON notes.noteId = attributes.noteId
|
|
|
|
WHERE attributes.name = ? AND attributes.value = ?
|
|
|
|
ORDER BY notes.dateModified DESC`,
|
|
|
|
['label', ChatStorageService.CHAT_LABEL]
|
|
|
|
);
|
|
|
|
|
|
|
|
return chats.map(chat => {
|
|
|
|
let messages: Message[] = [];
|
2025-04-13 21:16:18 +00:00
|
|
|
let metadata: ChatMetadata = {};
|
|
|
|
let createdAt = new Date(chat.dateCreated);
|
|
|
|
let updatedAt = new Date(chat.dateModified);
|
2025-04-15 22:37:57 +00:00
|
|
|
|
2025-03-02 19:39:10 -08:00
|
|
|
try {
|
|
|
|
const content = JSON.parse(chat.content);
|
|
|
|
messages = content.messages || [];
|
2025-04-13 21:16:18 +00:00
|
|
|
metadata = content.metadata || {};
|
2025-04-15 22:37:57 +00:00
|
|
|
|
2025-04-13 21:16:18 +00:00
|
|
|
// Use stored dates if available
|
|
|
|
if (content.createdAt) {
|
|
|
|
createdAt = new Date(content.createdAt);
|
|
|
|
}
|
|
|
|
if (content.updatedAt) {
|
|
|
|
updatedAt = new Date(content.updatedAt);
|
|
|
|
}
|
2025-03-02 19:39:10 -08:00
|
|
|
} catch (e) {
|
|
|
|
console.error('Failed to parse chat content:', e);
|
|
|
|
}
|
|
|
|
|
|
|
|
return {
|
|
|
|
id: chat.noteId,
|
|
|
|
title: chat.title,
|
|
|
|
messages,
|
|
|
|
noteId: chat.noteId,
|
2025-04-13 21:16:18 +00:00
|
|
|
createdAt,
|
|
|
|
updatedAt,
|
|
|
|
metadata
|
2025-03-02 19:39:10 -08:00
|
|
|
};
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Get a specific chat
|
|
|
|
*/
|
|
|
|
async getChat(chatId: string): Promise<StoredChat | null> {
|
|
|
|
const chat = await sql.getRow<{noteId: string, title: string, dateCreated: string, dateModified: string, content: string}>(
|
2025-04-15 22:37:57 +00:00
|
|
|
`SELECT notes.noteId, notes.title, notes.dateCreated, notes.dateModified, blobs.content
|
2025-03-02 19:39:10 -08:00
|
|
|
FROM notes
|
2025-04-15 22:37:57 +00:00
|
|
|
JOIN blobs ON notes.blobId = blobs.blobId
|
2025-03-02 19:39:10 -08:00
|
|
|
WHERE notes.noteId = ?`,
|
|
|
|
[chatId]
|
|
|
|
);
|
|
|
|
|
|
|
|
if (!chat) {
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
|
|
|
let messages: Message[] = [];
|
2025-04-13 21:16:18 +00:00
|
|
|
let metadata: ChatMetadata = {};
|
|
|
|
let createdAt = new Date(chat.dateCreated);
|
|
|
|
let updatedAt = new Date(chat.dateModified);
|
2025-04-15 22:37:57 +00:00
|
|
|
|
2025-03-02 19:39:10 -08:00
|
|
|
try {
|
|
|
|
const content = JSON.parse(chat.content);
|
|
|
|
messages = content.messages || [];
|
2025-04-13 21:16:18 +00:00
|
|
|
metadata = content.metadata || {};
|
2025-04-15 22:37:57 +00:00
|
|
|
|
2025-04-13 21:16:18 +00:00
|
|
|
// Use stored dates if available
|
|
|
|
if (content.createdAt) {
|
|
|
|
createdAt = new Date(content.createdAt);
|
|
|
|
}
|
|
|
|
if (content.updatedAt) {
|
|
|
|
updatedAt = new Date(content.updatedAt);
|
|
|
|
}
|
2025-03-02 19:39:10 -08:00
|
|
|
} catch (e) {
|
|
|
|
console.error('Failed to parse chat content:', e);
|
|
|
|
}
|
|
|
|
|
|
|
|
return {
|
|
|
|
id: chat.noteId,
|
|
|
|
title: chat.title,
|
|
|
|
messages,
|
|
|
|
noteId: chat.noteId,
|
2025-04-13 21:16:18 +00:00
|
|
|
createdAt,
|
|
|
|
updatedAt,
|
|
|
|
metadata
|
2025-03-02 19:39:10 -08:00
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Update messages in a chat
|
|
|
|
*/
|
2025-04-13 21:16:18 +00:00
|
|
|
async updateChat(
|
2025-04-15 22:37:57 +00:00
|
|
|
chatId: string,
|
|
|
|
messages: Message[],
|
|
|
|
title?: string,
|
2025-04-13 21:16:18 +00:00
|
|
|
metadata?: ChatMetadata
|
|
|
|
): Promise<StoredChat | null> {
|
2025-03-02 19:39:10 -08:00
|
|
|
const chat = await this.getChat(chatId);
|
|
|
|
|
|
|
|
if (!chat) {
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
|
|
|
const now = new Date();
|
2025-04-13 21:16:18 +00:00
|
|
|
const updatedMetadata = {...(chat.metadata || {}), ...(metadata || {})};
|
|
|
|
|
|
|
|
// Extract and store tool calls from the messages
|
|
|
|
const toolExecutions = this.extractToolExecutionsFromMessages(messages, updatedMetadata.toolExecutions || []);
|
|
|
|
if (toolExecutions.length > 0) {
|
|
|
|
updatedMetadata.toolExecutions = toolExecutions;
|
|
|
|
}
|
2025-03-02 19:39:10 -08:00
|
|
|
|
|
|
|
// Update content directly using SQL since we don't have a method for this in the notes service
|
|
|
|
await sql.execute(
|
2025-04-15 22:37:57 +00:00
|
|
|
`UPDATE blobs SET content = ? WHERE blobId = (SELECT blobId FROM notes WHERE noteId = ?)`,
|
2025-03-02 19:39:10 -08:00
|
|
|
[JSON.stringify({
|
|
|
|
messages,
|
2025-04-13 21:16:18 +00:00
|
|
|
metadata: updatedMetadata,
|
2025-03-02 19:39:10 -08:00
|
|
|
createdAt: chat.createdAt,
|
|
|
|
updatedAt: now
|
|
|
|
}, null, 2), chatId]
|
|
|
|
);
|
|
|
|
|
|
|
|
// Update title if provided
|
|
|
|
if (title && title !== chat.title) {
|
|
|
|
await sql.execute(
|
|
|
|
`UPDATE notes SET title = ? WHERE noteId = ?`,
|
|
|
|
[title, chatId]
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
return {
|
|
|
|
...chat,
|
|
|
|
title: title || chat.title,
|
|
|
|
messages,
|
2025-04-13 21:16:18 +00:00
|
|
|
updatedAt: now,
|
|
|
|
metadata: updatedMetadata
|
2025-03-02 19:39:10 -08:00
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Delete a chat
|
|
|
|
*/
|
|
|
|
async deleteChat(chatId: string): Promise<boolean> {
|
|
|
|
try {
|
|
|
|
// Mark note as deleted using SQL since we don't have deleteNote in the exports
|
|
|
|
await sql.execute(
|
|
|
|
`UPDATE notes SET isDeleted = 1 WHERE noteId = ?`,
|
|
|
|
[chatId]
|
|
|
|
);
|
|
|
|
|
|
|
|
return true;
|
|
|
|
} catch (e) {
|
|
|
|
console.error('Failed to delete chat:', e);
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
}
|
2025-04-13 21:16:18 +00:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Record a new tool execution
|
|
|
|
*/
|
|
|
|
async recordToolExecution(
|
|
|
|
chatId: string,
|
2025-04-15 22:37:57 +00:00
|
|
|
toolName: string,
|
2025-04-13 21:16:18 +00:00
|
|
|
toolId: string,
|
|
|
|
args: Record<string, any> | string,
|
|
|
|
result: string | Record<string, any>,
|
|
|
|
error?: string
|
|
|
|
): Promise<boolean> {
|
|
|
|
try {
|
|
|
|
const chat = await this.getChat(chatId);
|
|
|
|
if (!chat) return false;
|
|
|
|
|
|
|
|
const toolExecution: ToolExecution = {
|
|
|
|
id: toolId,
|
|
|
|
name: toolName,
|
|
|
|
arguments: args,
|
|
|
|
result,
|
|
|
|
error,
|
|
|
|
timestamp: new Date(),
|
|
|
|
executionTime: 0 // Could track this if we passed in a start time
|
|
|
|
};
|
|
|
|
|
|
|
|
const currentToolExecutions = chat.metadata?.toolExecutions || [];
|
|
|
|
currentToolExecutions.push(toolExecution);
|
|
|
|
|
|
|
|
await this.updateChat(
|
|
|
|
chatId,
|
|
|
|
chat.messages,
|
|
|
|
undefined, // Don't change title
|
|
|
|
{
|
|
|
|
...chat.metadata,
|
|
|
|
toolExecutions: currentToolExecutions
|
|
|
|
}
|
|
|
|
);
|
|
|
|
|
|
|
|
return true;
|
|
|
|
} catch (e) {
|
|
|
|
log.error(`Failed to record tool execution: ${e}`);
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Extract tool executions from messages
|
|
|
|
* This helps maintain a record of all tool calls even if messages are truncated
|
|
|
|
*/
|
|
|
|
private extractToolExecutionsFromMessages(
|
2025-04-15 22:37:57 +00:00
|
|
|
messages: Message[],
|
2025-04-13 21:16:18 +00:00
|
|
|
existingToolExecutions: ToolExecution[] = []
|
|
|
|
): ToolExecution[] {
|
|
|
|
const toolExecutions = [...existingToolExecutions];
|
|
|
|
const executedToolIds = new Set(existingToolExecutions.map(t => t.id));
|
|
|
|
|
|
|
|
// Process all messages to find tool calls and their results
|
|
|
|
const assistantMessages = messages.filter(msg => msg.role === 'assistant' && msg.tool_calls);
|
|
|
|
const toolMessages = messages.filter(msg => msg.role === 'tool');
|
|
|
|
|
|
|
|
// Create a map of tool responses by tool_call_id
|
|
|
|
const toolResponseMap = new Map<string, string>();
|
|
|
|
for (const toolMsg of toolMessages) {
|
|
|
|
if (toolMsg.tool_call_id) {
|
|
|
|
toolResponseMap.set(toolMsg.tool_call_id, toolMsg.content);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// Extract all tool calls and pair with responses
|
|
|
|
for (const assistantMsg of assistantMessages) {
|
|
|
|
if (!assistantMsg.tool_calls || !Array.isArray(assistantMsg.tool_calls)) continue;
|
|
|
|
|
|
|
|
for (const toolCall of assistantMsg.tool_calls as ToolCall[]) {
|
|
|
|
if (!toolCall.id || executedToolIds.has(toolCall.id)) continue;
|
|
|
|
|
|
|
|
const toolResponse = toolResponseMap.get(toolCall.id);
|
|
|
|
if (!toolResponse) continue; // Skip if no response found
|
|
|
|
|
|
|
|
// We found a tool call with a response, record it
|
|
|
|
let args: Record<string, any> | string;
|
|
|
|
if (typeof toolCall.function.arguments === 'string') {
|
|
|
|
try {
|
|
|
|
args = JSON.parse(toolCall.function.arguments);
|
|
|
|
} catch (e) {
|
|
|
|
args = toolCall.function.arguments;
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
args = toolCall.function.arguments;
|
|
|
|
}
|
|
|
|
|
|
|
|
let result: string | Record<string, any> = toolResponse;
|
|
|
|
try {
|
|
|
|
// Try to parse result as JSON if it starts with { or [
|
|
|
|
if (toolResponse.trim().startsWith('{') || toolResponse.trim().startsWith('[')) {
|
|
|
|
result = JSON.parse(toolResponse);
|
|
|
|
}
|
|
|
|
} catch (e) {
|
|
|
|
// Keep as string if parsing fails
|
|
|
|
result = toolResponse;
|
|
|
|
}
|
|
|
|
|
|
|
|
const isError = toolResponse.startsWith('Error:');
|
|
|
|
const toolExecution: ToolExecution = {
|
|
|
|
id: toolCall.id,
|
|
|
|
name: toolCall.function.name,
|
|
|
|
arguments: args,
|
|
|
|
result,
|
|
|
|
error: isError ? toolResponse.substring('Error:'.length).trim() : undefined,
|
|
|
|
timestamp: new Date()
|
|
|
|
};
|
|
|
|
|
|
|
|
toolExecutions.push(toolExecution);
|
|
|
|
executedToolIds.add(toolCall.id);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return toolExecutions;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Store sources used in a chat
|
|
|
|
*/
|
|
|
|
async recordSources(
|
|
|
|
chatId: string,
|
|
|
|
sources: Array<{
|
|
|
|
noteId: string;
|
|
|
|
title: string;
|
|
|
|
similarity?: number;
|
|
|
|
path?: string;
|
|
|
|
branchId?: string;
|
|
|
|
content?: string;
|
|
|
|
}>
|
|
|
|
): Promise<boolean> {
|
|
|
|
try {
|
|
|
|
const chat = await this.getChat(chatId);
|
|
|
|
if (!chat) return false;
|
|
|
|
|
|
|
|
await this.updateChat(
|
|
|
|
chatId,
|
|
|
|
chat.messages,
|
|
|
|
undefined, // Don't change title
|
|
|
|
{
|
|
|
|
...chat.metadata,
|
|
|
|
sources
|
|
|
|
}
|
|
|
|
);
|
|
|
|
|
|
|
|
return true;
|
|
|
|
} catch (e) {
|
|
|
|
log.error(`Failed to record sources: ${e}`);
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
}
|
2025-03-02 19:39:10 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
// Singleton instance
|
|
|
|
const chatStorageService = new ChatStorageService();
|
|
|
|
export default chatStorageService;
|