Files
bDS/src/main/engine/ChatEngine.ts
2026-02-26 11:01:17 +01:00

424 lines
12 KiB
TypeScript

/**
* ChatEngine - Manages AI chat sessions and message persistence
*
* Responsible for:
* - Creating, updating, and deleting chat conversations
* - Storing and retrieving chat messages
* - Managing conversation state (titles, models, etc.)
*/
import { v4 as uuidv4 } from 'uuid';
import { eq, desc, asc } from 'drizzle-orm';
import { DatabaseConnection } from '../database/connection';
import { chatConversations, chatMessages, settings } from '../database/schema';
export interface ChatConversationData {
id: string;
title: string;
model?: string;
createdAt: Date;
updatedAt: Date;
}
export interface ChatMessageData {
id?: number;
conversationId: string;
role: 'system' | 'user' | 'assistant' | 'tool';
content?: string;
toolCallId?: string;
toolCalls?: string; // JSON array of tool calls
createdAt: Date;
}
export interface CreateConversationInput {
title?: string;
model?: string;
systemPrompt?: string;
}
export class ChatEngine {
private db: DatabaseConnection;
constructor(database: DatabaseConnection) {
this.db = database;
}
/**
* Create a new chat conversation
*/
async createConversation(input: CreateConversationInput = {}): Promise<ChatConversationData> {
const drizzle = this.db.getLocal();
const id = `chat_${uuidv4()}`;
const title = input.title || 'New Chat';
const model = input.model || 'claude-sonnet-4-5';
const now = new Date();
await drizzle.insert(chatConversations).values({
id,
title,
model,
createdAt: now,
updatedAt: now,
});
// Add system prompt as first message if provided
if (input.systemPrompt) {
await this.addMessage({
conversationId: id,
role: 'system',
content: input.systemPrompt,
createdAt: now,
});
}
return {
id,
title,
model,
createdAt: now,
updatedAt: now,
};
}
/**
* Get a conversation by ID with all messages
*/
async getConversation(id: string): Promise<(ChatConversationData & { messages: ChatMessageData[] }) | null> {
const drizzle = this.db.getLocal();
const rows = await drizzle
.select()
.from(chatConversations)
.where(eq(chatConversations.id, id));
if (rows.length === 0) {
return null;
}
const row = rows[0];
const conversation: ChatConversationData = {
id: row.id,
title: row.title,
model: row.model || undefined,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
const messageRows = await drizzle
.select()
.from(chatMessages)
.where(eq(chatMessages.conversationId, id))
.orderBy(asc(chatMessages.createdAt));
const messages: ChatMessageData[] = messageRows.map(r => ({
id: r.id,
conversationId: r.conversationId,
role: r.role,
content: r.content || undefined,
toolCallId: r.toolCallId || undefined,
toolCalls: r.toolCalls || undefined,
createdAt: r.createdAt,
}));
return { ...conversation, messages };
}
/**
* Get all conversations, sorted by most recently updated
*/
async getRecentConversations(limit: number = 50): Promise<ChatConversationData[]> {
const drizzle = this.db.getLocal();
const rows = await drizzle
.select()
.from(chatConversations)
.orderBy(desc(chatConversations.updatedAt))
.limit(limit);
return rows.map(row => ({
id: row.id,
title: row.title,
model: row.model || undefined,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
}));
}
/**
* Update a conversation's metadata
*/
async updateConversation(id: string, updates: Partial<Pick<ChatConversationData, 'title' | 'model'>>): Promise<void> {
const drizzle = this.db.getLocal();
await drizzle
.update(chatConversations)
.set({
...updates,
updatedAt: new Date(),
})
.where(eq(chatConversations.id, id));
}
/**
* Delete a conversation and all its messages
*/
async deleteConversation(id: string): Promise<void> {
const drizzle = this.db.getLocal();
// Messages are deleted via CASCADE, but let's be explicit
await drizzle
.delete(chatMessages)
.where(eq(chatMessages.conversationId, id));
await drizzle
.delete(chatConversations)
.where(eq(chatConversations.id, id));
}
/**
* Add a message to a conversation
*/
async addMessage(message: Omit<ChatMessageData, 'id'>): Promise<ChatMessageData> {
const drizzle = this.db.getLocal();
const createdAt = message.createdAt || new Date();
const result = await drizzle
.insert(chatMessages)
.values({
conversationId: message.conversationId,
role: message.role,
content: message.content || null,
toolCallId: message.toolCallId || null,
toolCalls: message.toolCalls || null,
createdAt,
})
.returning({ id: chatMessages.id });
// Update conversation's updated_at timestamp
await drizzle
.update(chatConversations)
.set({ updatedAt: createdAt })
.where(eq(chatConversations.id, message.conversationId));
return {
id: result[0].id,
conversationId: message.conversationId,
role: message.role,
content: message.content,
toolCallId: message.toolCallId,
toolCalls: message.toolCalls,
createdAt,
};
}
/**
* Get messages for a conversation
*/
async getMessages(conversationId: string): Promise<ChatMessageData[]> {
const drizzle = this.db.getLocal();
const rows = await drizzle
.select()
.from(chatMessages)
.where(eq(chatMessages.conversationId, conversationId))
.orderBy(asc(chatMessages.createdAt));
return rows.map(r => ({
id: r.id,
conversationId: r.conversationId,
role: r.role,
content: r.content || undefined,
toolCallId: r.toolCallId || undefined,
toolCalls: r.toolCalls || undefined,
createdAt: r.createdAt,
}));
}
/**
* Clear all messages from a conversation (but keep the conversation)
*/
async clearMessages(conversationId: string): Promise<void> {
const drizzle = this.db.getLocal();
await drizzle
.delete(chatMessages)
.where(eq(chatMessages.conversationId, conversationId));
}
/**
* Get default system prompt for new conversations
*/
async getDefaultSystemPrompt(): Promise<string> {
const drizzle = this.db.getLocal();
const rows = await drizzle
.select()
.from(settings)
.where(eq(settings.key, 'chat_system_prompt'));
// Return saved prompt if it exists and is non-empty
if (rows.length > 0 && rows[0].value) {
return rows[0].value;
}
return this.getBuiltInSystemPrompt();
}
/**
* Set default system prompt for new conversations.
* Pass empty string to reset to built-in default.
*/
async setDefaultSystemPrompt(prompt: string): Promise<void> {
const drizzle = this.db.getLocal();
// If empty string, delete the setting to use built-in default
if (!prompt || prompt.trim() === '') {
await drizzle
.delete(settings)
.where(eq(settings.key, 'chat_system_prompt'));
return;
}
await drizzle
.insert(settings)
.values({
key: 'chat_system_prompt',
value: prompt,
updatedAt: new Date(),
})
.onConflictDoUpdate({
target: settings.key,
set: {
value: prompt,
updatedAt: new Date(),
},
});
}
/**
* Get the built-in default system prompt
*/
private getBuiltInSystemPrompt(): string {
return `You are an AI assistant integrated into the Blogging Desktop Server (bDS) application.
Your role is to help users manage their blog posts and media files using ONLY the tools provided to you.
IMPORTANT: You do NOT have access to the internet, real-time data, or any external services.
You can ONLY access information through the tools listed below. Do not claim otherwise.
Available Data Tools:
- search_posts: Search blog posts using full-text search. Supports category/tag filters.
- read_post: Read the full content and metadata of a specific post by ID.
- list_posts: List posts with optional filtering by status, category, or tags.
- get_media: Get information about a specific media file by ID.
- list_media: List media files with optional MIME type filtering.
- view_image: View an image to analyze its visual content. Use this when you need to describe or analyze what an image looks like.
- update_post_metadata: Update a post's title, excerpt, tags, or categories.
- update_media_metadata: Update a media file's title, alt text, caption, or tags.
- list_tags: List all tags with post counts.
- list_categories: List all categories with post counts.
- get_post_backlinks: Get posts that link TO a given post (backlinks). Use to discover what references a post.
- get_post_outlinks: Get posts that a given post links TO. Use to traverse outbound links.
- get_post_media: Get media files linked to a post (featured images, galleries).
- get_media_posts: Get posts that use a specific media file.
Available UI Render Tools (use these to show rich interactive elements):
- render_chart: Show data as a bar, line, or pie chart. Use when presenting statistics or comparisons.
- render_table: Show data in a structured table. Use for tabular comparisons and listings.
- render_form: Show an interactive form to collect user input (e.g., metadata edits, settings).
- render_card: Show an information card with title, body, and action buttons.
- render_metric: Show a single KPI or statistic prominently.
- render_list: Show a bulleted list of items.
- render_tabs: Organize information into switchable tabs. Tab content supports all content types: text, metrics, lists, charts, and tables.
When answering questions:
1. USE THE TOOLS to find information. Never make up data about posts or media.
2. If asked about something outside your tools (weather, news, websites), explain that you can only access the user's local blog content.
3. Be concise and helpful. Format post information clearly when displaying it.
4. If a search returns no results, suggest alternative queries or filters.
5. When asked to describe or analyze an image, use the view_image tool to see the actual image content.
6. When presenting data, statistics, or comparisons, prefer using render tools (render_chart, render_table, render_metric) to show rich interactive UI instead of plain text.
7. When you need user input for a multi-field operation, use render_form to present a structured form.
8. Use render_card with action buttons when presenting items the user might want to navigate to (e.g., posts, media).
9. When comparing data across multiple dimensions (e.g., statistics per year), use render_tabs with embedded charts or tables in each tab.`;
}
/**
* Get a setting by key
*/
async getSetting(key: string): Promise<string | null> {
const drizzle = this.db.getLocal();
const rows = await drizzle
.select()
.from(settings)
.where(eq(settings.key, key));
if (rows.length > 0) {
return rows[0].value;
}
return null;
}
/**
* Set a setting by key
*/
async setSetting(key: string, value: string): Promise<void> {
const drizzle = this.db.getLocal();
await drizzle
.insert(settings)
.values({
key,
value,
updatedAt: new Date(),
})
.onConflictDoUpdate({
target: settings.key,
set: {
value,
updatedAt: new Date(),
},
});
}
/**
* Get selected model for new conversations
*/
async getSelectedModel(): Promise<string> {
const drizzle = this.db.getLocal();
const rows = await drizzle
.select()
.from(settings)
.where(eq(settings.key, 'chat_model'));
if (rows.length > 0) {
return rows[0].value;
}
return 'claude-sonnet-4-5';
}
/**
* Set selected model for new conversations
*/
async setSelectedModel(model: string): Promise<void> {
const drizzle = this.db.getLocal();
await drizzle
.insert(settings)
.values({
key: 'chat_model',
value: model,
updatedAt: new Date(),
})
.onConflictDoUpdate({
target: settings.key,
set: {
value: model,
updatedAt: new Date(),
},
});
}
}