diff --git a/package-lock.json b/package-lock.json index cd3e7e4..ba6331f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "@floating-ui/dom": "^1.7.5", + "@github/copilot-sdk": "^0.1.23", "@libsql/client": "^0.4.0", "@milkdown/kit": "^7.18.0", "@milkdown/plugin-block": "^7.18.0", @@ -36,6 +37,7 @@ "sharp": "^0.34.5", "snowball-stemmers": "^0.6.0", "uuid": "^9.0.1", + "zod": "^3.25.76", "zustand": "^4.4.7" }, "devDependencies": { @@ -53,7 +55,7 @@ "concurrently": "^8.2.2", "cross-env": "^10.1.0", "drizzle-kit": "^0.20.0", - "electron": "^28.0.0", + "electron": "^39.5.2", "electron-builder": "^24.9.1", "jsdom": "^28.0.0", "memfs": "^4.6.0", @@ -2250,7 +2252,6 @@ "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.5.tgz", "integrity": "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==", "license": "MIT", - "peer": true, "dependencies": { "@floating-ui/core": "^1.7.4", "@floating-ui/utils": "^0.2.10" @@ -2262,6 +2263,142 @@ "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", "license": "MIT" }, + "node_modules/@github/copilot": { + "version": "0.0.403", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-0.0.403.tgz", + "integrity": "sha512-v5jUdtGJReLmE1rmff/LZf+50nzmYQYAaSRNtVNr9g0j0GkCd/noQExe31i1+PudvWU0ZJjltR0B8pUfDRdA9Q==", + "license": "SEE LICENSE IN LICENSE.md", + "bin": { + "copilot": "npm-loader.js" + }, + "optionalDependencies": { + "@github/copilot-darwin-arm64": "0.0.403", + "@github/copilot-darwin-x64": "0.0.403", + "@github/copilot-linux-arm64": "0.0.403", + "@github/copilot-linux-x64": "0.0.403", + "@github/copilot-win32-arm64": "0.0.403", + "@github/copilot-win32-x64": "0.0.403" + } + }, + "node_modules/@github/copilot-darwin-arm64": { + "version": "0.0.403", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-0.0.403.tgz", + "integrity": "sha512-dOw8IleA0d1soHnbr/6wc6vZiYWNTKMgfTe/NET1nCfMzyKDt/0F0I7PT5y+DLujJknTla/ZeEmmBUmliTW4Cg==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "darwin" + ], + "bin": { + "copilot-darwin-arm64": "copilot" + } + }, + "node_modules/@github/copilot-darwin-x64": { + "version": "0.0.403", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-0.0.403.tgz", + "integrity": "sha512-aK2jSNWgY8eiZ+TmrvGhssMCPDTKArc0ip6Ul5OaslpytKks8hyXoRbxGD0N9sKioSUSbvKUf+1AqavbDpJO+w==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "darwin" + ], + "bin": { + "copilot-darwin-x64": "copilot" + } + }, + "node_modules/@github/copilot-linux-arm64": { + "version": "0.0.403", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-0.0.403.tgz", + "integrity": "sha512-KhoR2iR70O6vCkzf0h8/K+p82qAgOvMTgAPm9bVEHvbdGFR7Py9qL5v03bMbPxsA45oNaZAkzDhfTAqWhIAZsQ==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ], + "bin": { + "copilot-linux-arm64": "copilot" + } + }, + "node_modules/@github/copilot-linux-x64": { + "version": "0.0.403", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-0.0.403.tgz", + "integrity": "sha512-eoswUc9vo4TB+/9PgFJLVtzI4dPjkpJXdCsAioVuoqPdNxHxlIHFe9HaVcqMRZxUNY1YHEBZozy+IpUEGjgdfQ==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ], + "bin": { + "copilot-linux-x64": "copilot" + } + }, + "node_modules/@github/copilot-sdk": { + "version": "0.1.23", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-0.1.23.tgz", + "integrity": "sha512-0by81bsBQlDKE5VbcegZfUMvPyPm1aXwSGS2rGaMAFxv3ps+dACf1Voruxik7hQTae0ziVFJjuVrlxZoRaXBLw==", + "license": "MIT", + "dependencies": { + "@github/copilot": "^0.0.403", + "vscode-jsonrpc": "^8.2.1", + "zod": "^4.3.6" + }, + "engines": { + "node": ">=24.0.0" + } + }, + "node_modules/@github/copilot-sdk/node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@github/copilot-win32-arm64": { + "version": "0.0.403", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-0.0.403.tgz", + "integrity": "sha512-djWjzCsp2xPNafMyOZ/ivU328/WvWhdroGie/DugiJBTgQL2SP0quWW1fhTlDwE81a3g9CxfJonaRgOpFTJTcg==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "win32" + ], + "bin": { + "copilot-win32-arm64": "copilot.exe" + } + }, + "node_modules/@github/copilot-win32-x64": { + "version": "0.0.403", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-0.0.403.tgz", + "integrity": "sha512-lju8cHy2E6Ux7R7tWyLZeksYC2MVZu9i9ocjiBX/qfG2/pNJs7S5OlkwKJ0BSXSbZEHQYq7iHfEWp201bVfk9A==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "win32" + ], + "bin": { + "copilot-win32-x64": "copilot.exe" + } + }, "node_modules/@hapi/address": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/@hapi/address/-/address-5.1.1.tgz", @@ -7615,15 +7752,15 @@ } }, "node_modules/electron": { - "version": "28.3.3", - "resolved": "https://registry.npmjs.org/electron/-/electron-28.3.3.tgz", - "integrity": "sha512-ObKMLSPNhomtCOBAxFS8P2DW/4umkh72ouZUlUKzXGtYuPzgr1SYhskhFWgzAsPtUzhL2CzyV2sfbHcEW4CXqw==", + "version": "39.5.2", + "resolved": "https://registry.npmjs.org/electron/-/electron-39.5.2.tgz", + "integrity": "sha512-EiGFKoTjCuJTsdNxSwOiKJvRbWOFHTmqnnVftpUUZf7rdMkvM6yn9i55uLNDKefvTE69M+vfMgGLa7HuY94WZg==", "dev": true, "hasInstallScript": true, "license": "MIT", "dependencies": { "@electron/get": "^2.0.0", - "@types/node": "^18.11.18", + "@types/node": "^22.7.7", "extract-zip": "^2.0.1" }, "bin": { @@ -7824,22 +7961,15 @@ "license": "ISC" }, "node_modules/electron/node_modules/@types/node": { - "version": "18.19.130", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", - "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "version": "22.19.11", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz", + "integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~5.26.4" + "undici-types": "~6.21.0" } }, - "node_modules/electron/node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true, - "license": "MIT" - }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -14557,6 +14687,15 @@ } } }, + "node_modules/vscode-jsonrpc": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.1.tgz", + "integrity": "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/vue": { "version": "3.5.28", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.28.tgz", @@ -14942,7 +15081,6 @@ "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "dev": true, "license": "MIT", "peer": true, "funding": { diff --git a/package.json b/package.json index f60e145..8b70b97 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "concurrently": "^8.2.2", "cross-env": "^10.1.0", "drizzle-kit": "^0.20.0", - "electron": "^28.0.0", + "electron": "^39.5.2", "electron-builder": "^24.9.1", "jsdom": "^28.0.0", "memfs": "^4.6.0", @@ -53,6 +53,7 @@ }, "dependencies": { "@floating-ui/dom": "^1.7.5", + "@github/copilot-sdk": "^0.1.23", "@libsql/client": "^0.4.0", "@milkdown/kit": "^7.18.0", "@milkdown/plugin-block": "^7.18.0", @@ -79,6 +80,7 @@ "sharp": "^0.34.5", "snowball-stemmers": "^0.6.0", "uuid": "^9.0.1", + "zod": "^3.25.76", "zustand": "^4.4.7" }, "build": { diff --git a/src/main/database/connection.ts b/src/main/database/connection.ts index 4c9f51f..6006cd3 100644 --- a/src/main/database/connection.ts +++ b/src/main/database/connection.ts @@ -410,6 +410,34 @@ export class DatabaseConnection { args: ['default', 'Default Project', 'default', 'Your first blog project', now, now, 1], }); } + + // Create chat_conversations table for AI chat persistence + await this.localClient.execute(` + CREATE TABLE IF NOT EXISTS chat_conversations ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + model TEXT, + copilot_session_id TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) + `); + await this.localClient.execute('CREATE INDEX IF NOT EXISTS idx_chat_conversations_updated_at ON chat_conversations(updated_at)'); + + // Create chat_messages table for storing conversation messages + await this.localClient.execute(` + CREATE TABLE IF NOT EXISTS chat_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + conversation_id TEXT NOT NULL, + role TEXT NOT NULL, + content TEXT, + tool_call_id TEXT, + tool_calls TEXT, + created_at INTEGER NOT NULL, + FOREIGN KEY (conversation_id) REFERENCES chat_conversations(id) ON DELETE CASCADE + ) + `); + await this.localClient.execute('CREATE INDEX IF NOT EXISTS idx_chat_messages_conversation_id ON chat_messages(conversation_id)'); } async close(): Promise { diff --git a/src/main/database/schema.ts b/src/main/database/schema.ts index 5e523ff..526003e 100644 --- a/src/main/database/schema.ts +++ b/src/main/database/schema.ts @@ -108,6 +108,27 @@ export const tags = sqliteTable('tags', { projectNameIdx: uniqueIndex('tags_project_name_idx').on(table.projectId, table.name), })); +// Chat conversations table - stores AI chat sessions +export const chatConversations = sqliteTable('chat_conversations', { + id: text('id').primaryKey(), + title: text('title').notNull(), + model: text('model'), // Model used for this conversation + copilotSessionId: text('copilot_session_id'), // Copilot SDK session ID for resuming + createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), + updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), +}); + +// Chat messages table - stores messages within conversations +export const chatMessages = sqliteTable('chat_messages', { + id: integer('id').primaryKey({ autoIncrement: true }), + conversationId: text('conversation_id').notNull(), + role: text('role', { enum: ['system', 'user', 'assistant', 'tool'] }).notNull(), + content: text('content'), + toolCallId: text('tool_call_id'), // For tool responses + toolCalls: text('tool_calls'), // JSON array of tool calls + createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), +}); + // Types for TypeScript export type Project = typeof projects.$inferSelect; export type NewProject = typeof projects.$inferInsert; @@ -123,3 +144,7 @@ export type PostLink = typeof postLinks.$inferSelect; export type NewPostLink = typeof postLinks.$inferInsert; export type Tag = typeof tags.$inferSelect; export type NewTag = typeof tags.$inferInsert; +export type ChatConversation = typeof chatConversations.$inferSelect; +export type NewChatConversation = typeof chatConversations.$inferInsert; +export type ChatMessage = typeof chatMessages.$inferSelect; +export type NewChatMessage = typeof chatMessages.$inferInsert; diff --git a/src/main/engine/ChatEngine.ts b/src/main/engine/ChatEngine.ts new file mode 100644 index 0000000..46134cb --- /dev/null +++ b/src/main/engine/ChatEngine.ts @@ -0,0 +1,382 @@ +/** + * 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 { DatabaseConnection } from '../database/connection'; + +export interface ChatConversationData { + id: string; + title: string; + model?: string; + copilotSessionId?: 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 { + const client = this.db.getLocalClient(); + if (!client) { + throw new Error('Database not initialized'); + } + + const id = `chat_${uuidv4()}`; + const title = input.title || 'New Chat'; + const model = input.model || 'gpt-4.1'; + const now = Date.now(); + + await client.execute({ + sql: `INSERT INTO chat_conversations (id, title, model, created_at, updated_at) VALUES (?, ?, ?, ?, ?)`, + args: [id, title, model, now, now], + }); + + // Add system prompt as first message if provided + if (input.systemPrompt) { + await this.addMessage({ + conversationId: id, + role: 'system', + content: input.systemPrompt, + createdAt: new Date(now), + }); + } + + return { + id, + title, + model, + createdAt: new Date(now), + updatedAt: new Date(now), + }; + } + + /** + * Get a conversation by ID with all messages + */ + async getConversation(id: string): Promise<(ChatConversationData & { messages: ChatMessageData[] }) | null> { + const client = this.db.getLocalClient(); + if (!client) { + throw new Error('Database not initialized'); + } + + const convResult = await client.execute({ + sql: `SELECT * FROM chat_conversations WHERE id = ?`, + args: [id], + }); + + if (convResult.rows.length === 0) { + return null; + } + + const row = convResult.rows[0]; + const conversation: ChatConversationData = { + id: row.id as string, + title: row.title as string, + model: row.model as string | undefined, + copilotSessionId: row.copilot_session_id as string | undefined, + createdAt: new Date(row.created_at as number), + updatedAt: new Date(row.updated_at as number), + }; + + const messagesResult = await client.execute({ + sql: `SELECT * FROM chat_messages WHERE conversation_id = ? ORDER BY created_at ASC`, + args: [id], + }); + + const messages: ChatMessageData[] = messagesResult.rows.map(r => ({ + id: r.id as number, + conversationId: r.conversation_id as string, + role: r.role as 'system' | 'user' | 'assistant' | 'tool', + content: r.content as string | undefined, + toolCallId: r.tool_call_id as string | undefined, + toolCalls: r.tool_calls as string | undefined, + createdAt: new Date(r.created_at as number), + })); + + return { ...conversation, messages }; + } + + /** + * Get all conversations, sorted by most recently updated + */ + async getRecentConversations(limit: number = 50): Promise { + const client = this.db.getLocalClient(); + if (!client) { + throw new Error('Database not initialized'); + } + + const result = await client.execute({ + sql: `SELECT * FROM chat_conversations ORDER BY updated_at DESC LIMIT ?`, + args: [limit], + }); + + return result.rows.map(row => ({ + id: row.id as string, + title: row.title as string, + model: row.model as string | undefined, + copilotSessionId: row.copilot_session_id as string | undefined, + createdAt: new Date(row.created_at as number), + updatedAt: new Date(row.updated_at as number), + })); + } + + /** + * Update a conversation's metadata + */ + async updateConversation(id: string, updates: Partial>): Promise { + const client = this.db.getLocalClient(); + if (!client) { + throw new Error('Database not initialized'); + } + + const setClauses: string[] = ['updated_at = ?']; + const args: (string | number | null)[] = [Date.now()]; + + if (updates.title !== undefined) { + setClauses.push('title = ?'); + args.push(updates.title); + } + if (updates.model !== undefined) { + setClauses.push('model = ?'); + args.push(updates.model); + } + if (updates.copilotSessionId !== undefined) { + setClauses.push('copilot_session_id = ?'); + args.push(updates.copilotSessionId); + } + + args.push(id); + + await client.execute({ + sql: `UPDATE chat_conversations SET ${setClauses.join(', ')} WHERE id = ?`, + args, + }); + } + + /** + * Delete a conversation and all its messages + */ + async deleteConversation(id: string): Promise { + const client = this.db.getLocalClient(); + if (!client) { + throw new Error('Database not initialized'); + } + + // Messages are deleted via CASCADE, but let's be explicit + await client.execute({ + sql: `DELETE FROM chat_messages WHERE conversation_id = ?`, + args: [id], + }); + + await client.execute({ + sql: `DELETE FROM chat_conversations WHERE id = ?`, + args: [id], + }); + } + + /** + * Add a message to a conversation + */ + async addMessage(message: Omit): Promise { + const client = this.db.getLocalClient(); + if (!client) { + throw new Error('Database not initialized'); + } + + const createdAt = message.createdAt?.getTime() || Date.now(); + + const result = await client.execute({ + sql: `INSERT INTO chat_messages (conversation_id, role, content, tool_call_id, tool_calls, created_at) + VALUES (?, ?, ?, ?, ?, ?)`, + args: [ + message.conversationId, + message.role, + message.content || null, + message.toolCallId || null, + message.toolCalls || null, + createdAt, + ], + }); + + // Update conversation's updated_at timestamp + await client.execute({ + sql: `UPDATE chat_conversations SET updated_at = ? WHERE id = ?`, + args: [createdAt, message.conversationId], + }); + + return { + id: Number(result.lastInsertRowid), + conversationId: message.conversationId, + role: message.role, + content: message.content, + toolCallId: message.toolCallId, + toolCalls: message.toolCalls, + createdAt: new Date(createdAt), + }; + } + + /** + * Get messages for a conversation + */ + async getMessages(conversationId: string): Promise { + const client = this.db.getLocalClient(); + if (!client) { + throw new Error('Database not initialized'); + } + + const result = await client.execute({ + sql: `SELECT * FROM chat_messages WHERE conversation_id = ? ORDER BY created_at ASC`, + args: [conversationId], + }); + + return result.rows.map(r => ({ + id: r.id as number, + conversationId: r.conversation_id as string, + role: r.role as 'system' | 'user' | 'assistant' | 'tool', + content: r.content as string | undefined, + toolCallId: r.tool_call_id as string | undefined, + toolCalls: r.tool_calls as string | undefined, + createdAt: new Date(r.created_at as number), + })); + } + + /** + * Clear all messages from a conversation (but keep the conversation) + */ + async clearMessages(conversationId: string): Promise { + const client = this.db.getLocalClient(); + if (!client) { + throw new Error('Database not initialized'); + } + + await client.execute({ + sql: `DELETE FROM chat_messages WHERE conversation_id = ?`, + args: [conversationId], + }); + } + + /** + * Get default system prompt for new conversations + */ + async getDefaultSystemPrompt(): Promise { + const client = this.db.getLocalClient(); + if (!client) { + return this.getBuiltInSystemPrompt(); + } + + const result = await client.execute({ + sql: `SELECT value FROM settings WHERE key = 'chat_system_prompt'`, + args: [], + }); + + if (result.rows.length > 0) { + return result.rows[0].value as string; + } + + return this.getBuiltInSystemPrompt(); + } + + /** + * Set default system prompt for new conversations + */ + async setDefaultSystemPrompt(prompt: string): Promise { + const client = this.db.getLocalClient(); + if (!client) { + throw new Error('Database not initialized'); + } + + const now = Date.now(); + await client.execute({ + sql: `INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, ?)`, + args: ['chat_system_prompt', prompt, now], + }); + } + + /** + * Get the built-in default system prompt + */ + private getBuiltInSystemPrompt(): string { + return `You are an AI assistant for the Blogging Desktop Server (bDS) application. +You help users manage their blog posts and media files. + +You have access to tools that allow you to: +- Search for posts using full-text search with optional category/tag filters +- Read individual post content and metadata +- View information about media files (images) +- Update metadata for posts and media files + +When answering questions about the user's blog content: +1. Use the search tool to find relevant posts +2. Read specific posts to get detailed content +3. Provide helpful summaries and suggestions + +Be concise but thorough in your responses. When displaying post information, format it clearly.`; + } + + /** + * Get selected model for new conversations + */ + async getSelectedModel(): Promise { + const client = this.db.getLocalClient(); + if (!client) { + return 'gpt-4.1'; + } + + const result = await client.execute({ + sql: `SELECT value FROM settings WHERE key = 'chat_model'`, + args: [], + }); + + if (result.rows.length > 0) { + return result.rows[0].value as string; + } + + return 'gpt-4.1'; + } + + /** + * Set selected model for new conversations + */ + async setSelectedModel(model: string): Promise { + const client = this.db.getLocalClient(); + if (!client) { + throw new Error('Database not initialized'); + } + + const now = Date.now(); + await client.execute({ + sql: `INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, ?)`, + args: ['chat_model', model, now], + }); + } +} diff --git a/src/main/engine/CopilotManager.ts b/src/main/engine/CopilotManager.ts new file mode 100644 index 0000000..6d2ad21 --- /dev/null +++ b/src/main/engine/CopilotManager.ts @@ -0,0 +1,986 @@ +/** + * CopilotManager - Handles AI chat using GitHub Copilot SDK + * + * Provides native Copilot integration with custom tool support for: + * - Searching posts with full-text search and filters + * - Reading post content and metadata + * - Viewing media file information + * - Updating post and media metadata + */ + +import { BrowserWindow, app } from 'electron'; +import { spawn } from 'child_process'; +import path from 'path'; +import { ChatEngine, ChatMessageData } from './ChatEngine'; +import { PostEngine } from './PostEngine'; +import { MediaEngine } from './MediaEngine'; + +// ESM modules - loaded dynamically +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let CopilotClient: any = null; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let defineTool: any = null; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let z: any = null; + +let modulesLoaded = false; + +// Dynamic import that bypasses TypeScript transformation to properly handle ESM +// TypeScript compiles import() to require() in CommonJS, which breaks ESM-only packages +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const dynamicImport = new Function('specifier', 'return import(specifier)') as (specifier: string) => Promise; + +/** + * Load ESM modules dynamically (required for CommonJS compatibility) + */ +async function loadModules(): Promise { + if (modulesLoaded) return true; + + try { + // Use dynamicImport to bypass TypeScript's transformation of import() to require() + const copilotSdk = await dynamicImport('@github/copilot-sdk'); + CopilotClient = copilotSdk.CopilotClient; + defineTool = copilotSdk.defineTool; + + const zod = await dynamicImport('zod'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + z = (zod as any).z || (zod as any).default?.z || zod; + + modulesLoaded = true; + console.log('[CopilotManager] ESM modules loaded successfully'); + return true; + } catch (error) { + console.error('[CopilotManager] Failed to load ESM modules:', error); + return false; + } +} + +interface CopilotSession { + sessionId: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + session: any; + conversationId: string; + isResumed?: boolean; + model?: string; +} + +export interface SendMessageOptions { + onDelta?: (delta: string) => void; + onToolCall?: (toolCall: { name: string; args: unknown }) => void; + onToolResult?: (result: { name: string; result: unknown }) => void; +} + +export interface SendMessageResult { + success: boolean; + message?: string; + error?: string; + toolCalls?: Array<{ name: string; args: unknown }>; +} + +export class CopilotManager { + private chatEngine: ChatEngine; + private postEngine: PostEngine; + private mediaEngine: MediaEngine; + private getMainWindow: () => BrowserWindow | null; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private client: any = null; + private sessions: Map = new Map(); + private isInitialized = false; + private initError: string | null = null; + private initPromise: Promise<{ success: boolean; error?: string }> | null = null; + private isStopping = false; + + constructor( + chatEngine: ChatEngine, + postEngine: PostEngine, + mediaEngine: MediaEngine, + getMainWindow: () => BrowserWindow | null + ) { + this.chatEngine = chatEngine; + this.postEngine = postEngine; + this.mediaEngine = mediaEngine; + this.getMainWindow = getMainWindow; + } + + /** + * Get tools for the Copilot SDK + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private getToolsForCopilot(): any[] { + if (!modulesLoaded || !defineTool || !z) { + console.warn('[CopilotManager] Modules not loaded, returning empty tools list'); + return []; + } + + const tools = [ + // Search posts with full-text search and filters + defineTool('search_posts', { + description: 'Search blog posts using full-text search. Can filter by category or tags. Returns matching posts with their metadata.', + parameters: z.object({ + query: z.string().describe('The search query text to find in posts'), + category: z.string().optional().describe('Optional category to filter by (e.g., "article", "picture", "aside", "page")'), + tags: z.array(z.string()).optional().describe('Optional array of tags to filter by'), + limit: z.number().optional().describe('Maximum number of results to return (default: 10)'), + }), + handler: async (args: { query: string; category?: string; tags?: string[]; limit?: number }) => { + try { + // Get search results (returns id, title, slug, excerpt) + const searchResults = await this.postEngine.searchPosts(args.query); + + // Fetch full post data for filtering + const fullPosts = await Promise.all( + searchResults.map(sr => this.postEngine.getPost(sr.id)) + ); + + // Filter by category and tags + let filteredPosts = fullPosts.filter(p => p !== null); + + if (args.category) { + filteredPosts = filteredPosts.filter(p => p!.categories.includes(args.category!)); + } + + if (args.tags && args.tags.length > 0) { + filteredPosts = filteredPosts.filter(p => + args.tags!.every(tag => p!.tags.includes(tag)) + ); + } + + // Apply limit + const limit = args.limit || 10; + filteredPosts = filteredPosts.slice(0, limit); + + return { + success: true, + count: filteredPosts.length, + posts: filteredPosts.map(p => ({ + id: p!.id, + title: p!.title, + slug: p!.slug, + excerpt: p!.excerpt, + status: p!.status, + categories: p!.categories, + tags: p!.tags, + createdAt: p!.createdAt, + updatedAt: p!.updatedAt, + })), + }; + } catch (error) { + return { success: false, error: (error as Error).message }; + } + }, + }), + + // Read a single post + defineTool('read_post', { + description: 'Read the full content and metadata of a specific blog post by its ID.', + parameters: z.object({ + postId: z.string().describe('The unique ID of the post to read'), + }), + handler: async (args: { postId: string }) => { + try { + const post = await this.postEngine.getPost(args.postId); + if (!post) { + return { success: false, error: 'Post not found' }; + } + return { + success: true, + post: { + id: post.id, + title: post.title, + slug: post.slug, + content: post.content, + excerpt: post.excerpt, + status: post.status, + author: post.author, + categories: post.categories, + tags: post.tags, + createdAt: post.createdAt, + updatedAt: post.updatedAt, + publishedAt: post.publishedAt, + }, + }; + } catch (error) { + return { success: false, error: (error as Error).message }; + } + }, + }), + + // Get media file information + defineTool('get_media', { + description: 'Get information about a specific media file (image) by its ID.', + parameters: z.object({ + mediaId: z.string().describe('The unique ID of the media file'), + }), + handler: async (args: { mediaId: string }) => { + try { + const media = await this.mediaEngine.getMedia(args.mediaId); + if (!media) { + return { success: false, error: 'Media not found' }; + } + return { + success: true, + media: { + id: media.id, + filename: media.filename, + originalName: media.originalName, + mimeType: media.mimeType, + size: media.size, + width: media.width, + height: media.height, + alt: media.alt, + caption: media.caption, + tags: media.tags, + createdAt: media.createdAt, + updatedAt: media.updatedAt, + }, + }; + } catch (error) { + return { success: false, error: (error as Error).message }; + } + }, + }), + + // List all media files + defineTool('list_media', { + description: 'List all media files in the current project with optional filtering.', + parameters: z.object({ + mimeTypeFilter: z.string().optional().describe('Filter by MIME type prefix (e.g., "image/")'), + limit: z.number().optional().describe('Maximum number of results (default: 20)'), + }), + handler: async (args: { mimeTypeFilter?: string; limit?: number }) => { + try { + let mediaList = await this.mediaEngine.getAllMedia(); + + if (args.mimeTypeFilter) { + mediaList = mediaList.filter(m => m.mimeType.startsWith(args.mimeTypeFilter!)); + } + + const limit = args.limit || 20; + mediaList = mediaList.slice(0, limit); + + return { + success: true, + count: mediaList.length, + media: mediaList.map(m => ({ + id: m.id, + filename: m.filename, + originalName: m.originalName, + mimeType: m.mimeType, + alt: m.alt, + tags: m.tags, + })), + }; + } catch (error) { + return { success: false, error: (error as Error).message }; + } + }, + }), + + // Update post metadata + defineTool('update_post_metadata', { + description: 'Update metadata for a blog post (title, excerpt, tags, categories). Does NOT update post content.', + parameters: z.object({ + postId: z.string().describe('The unique ID of the post to update'), + title: z.string().optional().describe('New title for the post'), + excerpt: z.string().optional().describe('New excerpt/summary for the post'), + tags: z.array(z.string()).optional().describe('New tags for the post'), + categories: z.array(z.string()).optional().describe('New categories for the post'), + }), + handler: async (args: { postId: string; title?: string; excerpt?: string; tags?: string[]; categories?: string[] }) => { + try { + const updates: Record = {}; + if (args.title !== undefined) updates.title = args.title; + if (args.excerpt !== undefined) updates.excerpt = args.excerpt; + if (args.tags !== undefined) updates.tags = args.tags; + if (args.categories !== undefined) updates.categories = args.categories; + + if (Object.keys(updates).length === 0) { + return { success: false, error: 'No updates provided' }; + } + + await this.postEngine.updatePost(args.postId, updates); + return { success: true, message: `Post ${args.postId} metadata updated successfully` }; + } catch (error) { + return { success: false, error: (error as Error).message }; + } + }, + }), + + // Update media metadata + defineTool('update_media_metadata', { + description: 'Update metadata for a media file (alt text, caption, tags).', + parameters: z.object({ + mediaId: z.string().describe('The unique ID of the media to update'), + alt: z.string().optional().describe('New alt text for the image'), + caption: z.string().optional().describe('New caption for the image'), + tags: z.array(z.string()).optional().describe('New tags for the media'), + }), + handler: async (args: { mediaId: string; alt?: string; caption?: string; tags?: string[] }) => { + try { + const updates: Record = {}; + if (args.alt !== undefined) updates.alt = args.alt; + if (args.caption !== undefined) updates.caption = args.caption; + if (args.tags !== undefined) updates.tags = args.tags; + + if (Object.keys(updates).length === 0) { + return { success: false, error: 'No updates provided' }; + } + + await this.mediaEngine.updateMedia(args.mediaId, updates); + return { success: true, message: `Media ${args.mediaId} metadata updated successfully` }; + } catch (error) { + return { success: false, error: (error as Error).message }; + } + }, + }), + + // List posts with filtering + defineTool('list_posts', { + description: 'List blog posts with optional filtering by status, category, or tags.', + parameters: z.object({ + status: z.enum(['draft', 'published', 'archived']).optional().describe('Filter by post status'), + category: z.string().optional().describe('Filter by category'), + tags: z.array(z.string()).optional().describe('Filter by tags (posts must have all specified tags)'), + limit: z.number().optional().describe('Maximum number of results (default: 20)'), + offset: z.number().optional().describe('Offset for pagination (default: 0)'), + }), + handler: async (args: { status?: 'draft' | 'published' | 'archived'; category?: string; tags?: string[]; limit?: number; offset?: number }) => { + try { + // Use getPostsFiltered for filtering + const filter: { status?: 'draft' | 'published' | 'archived'; tags?: string[]; categories?: string[] } = {}; + if (args.status) filter.status = args.status; + if (args.tags) filter.tags = args.tags; + if (args.category) filter.categories = [args.category]; + + let posts; + if (Object.keys(filter).length > 0) { + posts = await this.postEngine.getPostsFiltered(filter); + } else { + const result = await this.postEngine.getAllPosts({ + limit: args.limit || 20, + offset: args.offset || 0, + }); + posts = result.items; + } + + // Apply offset/limit for filtered results + const offset = args.offset || 0; + const limit = args.limit || 20; + const slicedPosts = posts.slice(offset, offset + limit); + + return { + success: true, + count: slicedPosts.length, + total: posts.length, + hasMore: offset + limit < posts.length, + posts: slicedPosts.map(p => ({ + id: p.id, + title: p.title, + slug: p.slug, + status: p.status, + categories: p.categories, + tags: p.tags, + createdAt: p.createdAt, + updatedAt: p.updatedAt, + })), + }; + } catch (error) { + return { success: false, error: (error as Error).message }; + } + }, + }), + ]; + + return tools; + } + + /** + * Initialize the Copilot client + * Uses a lock to prevent concurrent initialization attempts + */ + async initialize(): Promise<{ success: boolean; error?: string }> { + // Already initialized + if (this.isInitialized && this.client) { + return { success: true }; + } + + // If stopping, don't initialize + if (this.isStopping) { + return { success: false, error: 'Client is stopping' }; + } + + // If already initializing, wait for that to complete + if (this.initPromise) { + return this.initPromise; + } + + // Start initialization + this.initPromise = this.doInitialize(); + const result = await this.initPromise; + this.initPromise = null; + return result; + } + + private async doInitialize(): Promise<{ success: boolean; error?: string }> { + try { + console.log('[CopilotManager] Initializing Copilot client...'); + + const loaded = await loadModules(); + if (!loaded) { + return { success: false, error: 'Failed to load Copilot SDK modules' }; + } + + // Check again in case stop was called during module loading + if (this.isStopping) { + return { success: false, error: 'Client is stopping' }; + } + + console.log('[CopilotManager] Creating CopilotClient instance...'); + this.client = new CopilotClient({ + autoStart: true, + autoRestart: true, + useLoggedInUser: true, + logLevel: 'info', + }); + + console.log('[CopilotManager] Starting client...'); + await this.client.start(); + console.log('[CopilotManager] Client started successfully'); + + this.isInitialized = true; + this.initError = null; + + console.log('[CopilotManager] Copilot client initialized successfully'); + return { success: true }; + } catch (error) { + console.error('[CopilotManager] Failed to initialize Copilot client:', error); + this.initError = (error as Error).message; + this.isInitialized = false; + return { success: false, error: (error as Error).message }; + } + } + + /** + * Check if Copilot is ready + */ + async checkReady(): Promise<{ ready: boolean; error?: string; requiresCLI?: boolean }> { + try { + if (!this.isInitialized) { + const result = await this.initialize(); + if (!result.success) { + const isCLIError = + result.error?.includes('copilot') || + result.error?.includes('ENOENT') || + result.error?.includes('spawn'); + + return { + ready: false, + error: result.error, + requiresCLI: isCLIError, + }; + } + } + + await this.client.ping(); + return { ready: true }; + } catch (error) { + console.error('[CopilotManager] Ready check failed:', error); + return { + ready: false, + error: (error as Error).message, + requiresCLI: + (error as Error).message?.includes('copilot') || + (error as Error).message?.includes('ENOENT'), + }; + } + } + + /** + * Get or create a session for a conversation + */ + private async getOrCreateSession( + conversationId: string, + options: { model?: string; systemMessage?: string; copilotSessionId?: string; forceNewSession?: boolean } = {} + ): Promise { + console.log(`[CopilotManager] getOrCreateSession called with model: ${options.model || 'default (gpt-4.1)'}`); + + if (this.sessions.has(conversationId)) { + const existingSession = this.sessions.get(conversationId)!; + if (options.model && existingSession.model !== options.model) { + console.log(`[CopilotManager] Model changed, creating new session`); + this.sessions.delete(conversationId); + } else { + return existingSession; + } + } + + if (!this.isInitialized) { + await this.initialize(); + } + + const tools = this.getToolsForCopilot(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let session: any; + let isResumed = false; + + // Try to resume existing session + if (options.copilotSessionId && !options.forceNewSession) { + try { + console.log(`[CopilotManager] Resuming session: ${options.copilotSessionId}`); + session = await this.client.resumeSession(options.copilotSessionId, { + streaming: true, + tools, + }); + isResumed = true; + console.log(`[CopilotManager] Session resumed successfully`); + } catch (error) { + console.log(`[CopilotManager] Failed to resume session, creating new: ${(error as Error).message}`); + session = null; + } + } + + // Create new session if needed + if (!session) { + const modelToUse = options.model || 'gpt-4.1'; + console.log(`[CopilotManager] Creating new session for conversation: ${conversationId} with model: ${modelToUse}`); + session = await this.client.createSession({ + model: modelToUse, + streaming: true, + tools, + systemMessage: options.systemMessage + ? { + content: options.systemMessage, + } + : undefined, + }); + console.log(`[CopilotManager] New session created: ${session.sessionId}`); + + // Store session ID in database + try { + await this.chatEngine.updateConversation(conversationId, { + copilotSessionId: session.sessionId, + }); + } catch (error) { + console.error(`[CopilotManager] Failed to save session ID: ${(error as Error).message}`); + } + } + + const copilotSession: CopilotSession = { + sessionId: session.sessionId, + session, + conversationId, + isResumed, + model: options.model || 'gpt-4.1', + }; + + this.sessions.set(conversationId, copilotSession); + return copilotSession; + } + + /** + * Send a message to a conversation + */ + async sendMessage( + conversationId: string, + userMessage: string, + options: SendMessageOptions = {} + ): Promise { + const { onDelta, onToolCall, onToolResult } = options; + + try { + const readyCheck = await this.checkReady(); + if (!readyCheck.ready) { + return { success: false, error: readyCheck.error }; + } + + // Get conversation from database + const conversation = await this.chatEngine.getConversation(conversationId); + if (!conversation) { + return { success: false, error: 'Conversation not found' }; + } + + // Get or create session + const systemMessage = conversation.messages.find(m => m.role === 'system'); + const copilotSession = await this.getOrCreateSession(conversationId, { + model: conversation.model, + systemMessage: systemMessage?.content, + copilotSessionId: conversation.copilotSessionId, + }); + + // Add user message to database + await this.chatEngine.addMessage({ + conversationId, + role: 'user', + content: userMessage, + createdAt: new Date(), + }); + + // Collect response + let fullResponse = ''; + const toolCalls: Array<{ name: string; args: unknown }> = []; + + // Set up event handlers + const unsubscribeDelta = copilotSession.session.on('assistant.message_delta', (event: { data: { deltaContent?: string } }) => { + fullResponse += event.data.deltaContent || ''; + if (onDelta) { + onDelta(event.data.deltaContent || ''); + } + }); + + const unsubscribeToolStart = copilotSession.session.on('tool.execution_start', (event: { data?: { toolName?: string; args?: unknown } }) => { + if (onToolCall) { + onToolCall({ + name: event.data?.toolName || 'unknown', + args: event.data?.args, + }); + } + toolCalls.push({ + name: event.data?.toolName || 'unknown', + args: event.data?.args, + }); + }); + + const unsubscribeToolEnd = copilotSession.session.on('tool.execution_end', (event: { data?: { toolName?: string; result?: unknown } }) => { + if (onToolResult) { + onToolResult({ + name: event.data?.toolName || 'unknown', + result: event.data?.result, + }); + } + }); + + try { + // Send message and wait for completion (5 minute timeout) + const response = await copilotSession.session.sendAndWait( + { + prompt: userMessage, + }, + 300000 + ); + + // Use final content if available + if (response?.data?.content) { + fullResponse = response.data.content; + } + + // Save assistant response + if (fullResponse) { + await this.chatEngine.addMessage({ + conversationId, + role: 'assistant', + content: fullResponse, + toolCalls: toolCalls.length > 0 ? JSON.stringify(toolCalls) : undefined, + createdAt: new Date(), + }); + } + + // Generate title after first exchange + const userMsgCount = conversation.messages.filter(m => m.role === 'user').length; + if (userMsgCount === 0 && fullResponse) { + this.generateConversationTitle(conversationId, userMessage, fullResponse).catch(err => + console.error('[CopilotManager] Error generating title:', err) + ); + } + + return { + success: true, + message: fullResponse, + toolCalls: toolCalls.length > 0 ? toolCalls : undefined, + }; + } finally { + unsubscribeDelta(); + unsubscribeToolStart(); + unsubscribeToolEnd(); + } + } catch (error) { + console.error('[CopilotManager] Error sending message:', error); + return { success: false, error: (error as Error).message }; + } + } + + /** + * Generate a title for a conversation based on first exchange + */ + private async generateConversationTitle(conversationId: string, userMessage: string, assistantResponse: string): Promise { + try { + // Create a quick session just for title generation + const titleSession = await this.client.createSession({ + model: 'gpt-4.1-mini', + streaming: false, + }); + + const response = await titleSession.sendAndWait({ + prompt: `Generate a short, concise title (max 6 words) for this conversation. Only output the title, nothing else. + +User: ${userMessage.substring(0, 200)} +Assistant: ${assistantResponse.substring(0, 200)}`, + }); + + if (response?.data?.content) { + const title = response.data.content.trim().replace(/^["']|["']$/g, ''); + await this.chatEngine.updateConversation(conversationId, { title }); + + // Notify UI of title update + const mainWindow = this.getMainWindow(); + if (mainWindow) { + mainWindow.webContents.send('chat-title-updated', { conversationId, title }); + } + } + + await titleSession.destroy(); + } catch (error) { + console.error('[CopilotManager] Error generating title:', error); + } + } + + /** + * Abort a currently running message + */ + async abortMessage(conversationId: string): Promise<{ success: boolean; error?: string }> { + const copilotSession = this.sessions.get(conversationId); + if (!copilotSession) { + return { success: false, error: 'No active session for this conversation' }; + } + + try { + await copilotSession.session.abort(); + console.log('[CopilotManager] Aborted message for conversation:', conversationId); + return { success: true }; + } catch (error) { + console.error('[CopilotManager] Error aborting message:', error); + return { success: false, error: (error as Error).message }; + } + } + + /** + * Destroy a session + */ + async destroySession(conversationId: string): Promise { + const copilotSession = this.sessions.get(conversationId); + if (copilotSession) { + try { + await copilotSession.session.destroy(); + } catch (error) { + console.error('[CopilotManager] Error destroying session:', error); + } + this.sessions.delete(conversationId); + } + } + + /** + * Get authentication status + * Does not auto-initialize - returns unauthenticated if client not ready + */ + async getAuthStatus(): Promise<{ isAuthenticated: boolean; authType?: string; login?: string; error?: string }> { + try { + // Don't auto-initialize on auth check - let frontend trigger login explicitly + if (!this.isInitialized || !this.client) { + return { isAuthenticated: false }; + } + + const authStatus = await this.client.getAuthStatus(); + return { + isAuthenticated: authStatus.isAuthenticated, + authType: authStatus.authType, + login: authStatus.login, + }; + } catch (error) { + console.error('[CopilotManager] Error getting auth status:', error); + return { isAuthenticated: false, error: (error as Error).message }; + } + } + + /** + * Get CLI path for the native Copilot binary + */ + private getCLIPath(): string { + const platform = process.platform; + const arch = process.arch; + const packageName = `@github/copilot-${platform}-${arch}`; + const exeName = platform === 'win32' ? 'copilot.exe' : 'copilot'; + + // In production (packaged app), node_modules is in resources/app.asar.unpacked + // In development, it's in the project root + const isPackaged = app.isPackaged; + + let cliPath: string; + if (isPackaged) { + // In packaged app, asarUnpack puts these in app.asar.unpacked + const resourcesPath = process.resourcesPath; + cliPath = path.join(resourcesPath, 'app.asar.unpacked', 'node_modules', packageName, exeName); + } else { + // In development + cliPath = path.join(__dirname, '..', '..', '..', 'node_modules', packageName, exeName); + } + + console.log('[CopilotManager] CLI path:', cliPath); + return cliPath; + } + + /** + * Trigger interactive login using the CLI + */ + async triggerLogin(options: { + onDeviceCode?: (deviceCode: { verificationUri: string; userCode: string }) => void; + onMessage?: (message: string) => void; + } = {}): Promise<{ success: boolean; error?: string; login?: string }> { + const { onDeviceCode, onMessage } = options; + + return new Promise((resolve) => { + const cliPath = this.getCLIPath(); + + console.log('[CopilotManager] Triggering interactive login...'); + if (onMessage) onMessage('Starting authentication...'); + + // Spawn the CLI with 'login' command + const cliProcess = spawn(cliPath, ['login'], { + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env } + }); + + let stdout = ''; + let stderr = ''; + let deviceCodeSent = false; + + cliProcess.stdout.on('data', (data: Buffer) => { + const text = data.toString(); + stdout += text; + console.log('[CopilotManager] CLI stdout:', text); + + // Parse for device code information + if (!deviceCodeSent) { + const urlMatch = text.match(/https:\/\/github\.com\/login\/device/); + const codeMatch = text.match(/code[:\s]+([A-Z0-9]{4}-[A-Z0-9]{4})/i); + + if (urlMatch && codeMatch) { + deviceCodeSent = true; + if (onDeviceCode) { + onDeviceCode({ + verificationUri: 'https://github.com/login/device', + userCode: codeMatch[1] + }); + } + } + } + + if (onMessage) { + onMessage(text.trim()); + } + }); + + cliProcess.stderr.on('data', (data: Buffer) => { + const text = data.toString(); + stderr += text; + console.log('[CopilotManager] CLI stderr:', text); + + // Some CLIs output to stderr for prompts + if (!deviceCodeSent) { + const urlMatch = text.match(/https:\/\/github\.com\/login\/device/); + const codeMatch = text.match(/code[:\s]+([A-Z0-9]{4}-[A-Z0-9]{4})/i); + + if (urlMatch && codeMatch) { + deviceCodeSent = true; + if (onDeviceCode) { + onDeviceCode({ + verificationUri: 'https://github.com/login/device', + userCode: codeMatch[1] + }); + } + } + } + + if (onMessage) { + onMessage(text.trim()); + } + }); + + cliProcess.on('close', (code: number) => { + console.log('[CopilotManager] CLI login process exited with code:', code); + + if (code === 0) { + // Re-initialize client to pick up new credentials + this.isInitialized = false; + this.client = null; + resolve({ success: true }); + } else { + resolve({ success: false, error: stderr || `Login failed with code ${code}` }); + } + }); + + cliProcess.on('error', (error: Error) => { + console.error('[CopilotManager] CLI spawn error:', error); + resolve({ success: false, error: error.message }); + }); + }); + } + + /** + * Logout from Copilot (stops client - no CLI logout command exists) + */ + async logout(): Promise<{ success: boolean; error?: string }> { + console.log('[CopilotManager] Logging out (stopping client)...'); + + try { + await this.stop(); + return { success: true }; + } catch (error) { + console.error('[CopilotManager] Error during logout:', error); + return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }; + } + } + + /** + * Get available models + */ + async getAvailableModels(): Promise { + if (!this.isInitialized) { + await this.initialize(); + } + + try { + const models = await this.client.getAvailableModels(); + return models || ['gpt-4.1', 'gpt-4.1-mini', 'claude-sonnet-4']; + } catch (error) { + console.error('[CopilotManager] Error getting models:', error); + return ['gpt-4.1', 'gpt-4.1-mini', 'claude-sonnet-4']; + } + } + + /** + * Stop the client + */ + async stop(): Promise { + // Set stopping flag to prevent new initialization attempts + this.isStopping = true; + + // Wait for any pending initialization to complete (or fail) + if (this.initPromise) { + try { + await this.initPromise; + } catch { + // Ignore initialization errors during shutdown + } + } + + // Clear sessions first + for (const [conversationId] of this.sessions) { + try { + await this.destroySession(conversationId); + } catch { + // Ignore errors during cleanup + } + } + this.sessions.clear(); + + if (this.client) { + try { + await this.client.stop(); + } catch { + // Ignore errors during cleanup - client may not be fully initialized + } + this.client = null; + this.isInitialized = false; + } + } +} diff --git a/src/main/engine/index.ts b/src/main/engine/index.ts index d7c454c..53dee13 100644 --- a/src/main/engine/index.ts +++ b/src/main/engine/index.ts @@ -37,4 +37,16 @@ export { type FileDownloadResult, type ConflictResolution, } from './DropboxSyncEngine'; +export { + ChatEngine, + type ChatConversationData, + type ChatMessageData, + type CreateConversationInput, +} from './ChatEngine'; +export { + CopilotManager, + type SendMessageOptions, + type SendMessageResult, +} from './CopilotManager'; + diff --git a/src/main/ipc/chatHandlers.ts b/src/main/ipc/chatHandlers.ts new file mode 100644 index 0000000..79b6440 --- /dev/null +++ b/src/main/ipc/chatHandlers.ts @@ -0,0 +1,342 @@ +/** + * Chat IPC handlers - AI chat functionality using GitHub Copilot SDK + */ + +import { ipcMain, BrowserWindow } from 'electron'; +import { ChatEngine } from '../engine/ChatEngine'; +import { CopilotManager } from '../engine/CopilotManager'; +import { getPostEngine } from '../engine/PostEngine'; +import { getMediaEngine } from '../engine/MediaEngine'; +import { getDatabase } from '../database'; + +let chatEngine: ChatEngine | null = null; +let copilotManager: CopilotManager | null = null; +let mainWindowGetter: (() => BrowserWindow | null) | null = null; + +/** + * Initialize chat handlers with the main window reference + */ +export function initializeChatHandlers(getMainWindow: () => BrowserWindow | null): void { + mainWindowGetter = getMainWindow; +} + +/** + * Get or create the ChatEngine instance + */ +function getChatEngine(): ChatEngine { + if (!chatEngine) { + chatEngine = new ChatEngine(getDatabase()); + } + return chatEngine; +} + +/** + * Get or create the CopilotManager instance + */ +function getCopilotManager(): CopilotManager { + if (!copilotManager) { + copilotManager = new CopilotManager( + getChatEngine(), + getPostEngine(), + getMediaEngine(), + () => mainWindowGetter?.() || null + ); + } + return copilotManager; +} + +/** + * Register all chat-related IPC handlers + */ +export function registerChatHandlers(): void { + // ============ Copilot Authentication & Status ============ + + // Check if Copilot is ready + ipcMain.handle('chat:checkReady', async () => { + try { + const manager = getCopilotManager(); + const result = await manager.checkReady(); + return { + ready: result.ready, + error: result.error, + backend: result.ready ? 'copilot' : undefined, + }; + } catch (error) { + console.error('[Chat IPC] Error checking ready:', error); + return { ready: false, error: (error as Error).message }; + } + }); + + // Get Copilot authentication status + ipcMain.handle('chat:copilotAuthStatus', async () => { + try { + const manager = getCopilotManager(); + const status = await manager.getAuthStatus(); + // Transform to match frontend ChatAuthStatus type + return { + authenticated: status.isAuthenticated, + username: status.login, + }; + } catch (error) { + console.error('[Chat IPC] Error getting auth status:', error); + return { authenticated: false }; + } + }); + + // Trigger Copilot login + ipcMain.handle('chat:copilotLogin', async () => { + try { + console.log('[Chat IPC] copilotLogin called'); + const manager = getCopilotManager(); + const mainWindow = mainWindowGetter?.(); + + console.log('[Chat IPC] Calling manager.triggerLogin()'); + const result = await manager.triggerLogin({ + onDeviceCode: (deviceCode) => { + console.log('[Chat IPC] Received device code, sending to renderer:', deviceCode); + if (mainWindow) { + mainWindow.webContents.send('copilot-device-code', deviceCode); + } + }, + onMessage: (message) => { + console.log('[Chat IPC] Auth message:', message); + if (mainWindow) { + mainWindow.webContents.send('copilot-auth-message', { message }); + } + }, + }); + + console.log('[Chat IPC] triggerLogin result:', result); + return result; + } catch (error) { + console.error('[Chat IPC] Error during login:', error); + return { success: false, error: (error as Error).message }; + } + }); + + // Logout from Copilot + ipcMain.handle('chat:copilotLogout', async () => { + try { + const manager = getCopilotManager(); + return await manager.logout(); + } catch (error) { + console.error('[Chat IPC] Error during logout:', error); + return { success: false, error: (error as Error).message }; + } + }); + + // ============ Chat Settings ============ + + // Get available models + ipcMain.handle('chat:getAvailableModels', async () => { + try { + const manager = getCopilotManager(); + const models = await manager.getAvailableModels(); + const engine = getChatEngine(); + const selectedModel = await engine.getSelectedModel(); + return { success: true, models, selectedModel }; + } catch (error) { + console.error('[Chat IPC] Error getting models:', error); + return { success: false, error: (error as Error).message }; + } + }); + + // Set default model + ipcMain.handle('chat:setDefaultModel', async (_, modelId: string) => { + try { + const engine = getChatEngine(); + await engine.setSelectedModel(modelId); + return { success: true }; + } catch (error) { + console.error('[Chat IPC] Error setting model:', error); + return { success: false, error: (error as Error).message }; + } + }); + + // Get system prompt + ipcMain.handle('chat:getSystemPrompt', async () => { + try { + const engine = getChatEngine(); + const prompt = await engine.getDefaultSystemPrompt(); + return { success: true, prompt }; + } catch (error) { + console.error('[Chat IPC] Error getting system prompt:', error); + return { success: false, error: (error as Error).message }; + } + }); + + // Set system prompt + ipcMain.handle('chat:setSystemPrompt', async (_, prompt: string) => { + try { + const engine = getChatEngine(); + await engine.setDefaultSystemPrompt(prompt); + return { success: true }; + } catch (error) { + console.error('[Chat IPC] Error setting system prompt:', error); + return { success: false, error: (error as Error).message }; + } + }); + + // ============ Conversation CRUD ============ + + // Get all conversations + ipcMain.handle('chat:getConversations', async () => { + try { + const engine = getChatEngine(); + return await engine.getRecentConversations(); + } catch (error) { + console.error('[Chat IPC] Error getting conversations:', error); + return []; + } + }); + + // Create new conversation + ipcMain.handle('chat:createConversation', async (_, title?: string, model?: string) => { + try { + const engine = getChatEngine(); + const systemPrompt = await engine.getDefaultSystemPrompt(); + const selectedModel = model || (await engine.getSelectedModel()); + + const conversation = await engine.createConversation({ + title: title || 'New Chat', + model: selectedModel, + systemPrompt, + }); + + return conversation; + } catch (error) { + console.error('[Chat IPC] Error creating conversation:', error); + return { error: (error as Error).message }; + } + }); + + // Get conversation by ID + ipcMain.handle('chat:getConversation', async (_, id: string) => { + try { + const engine = getChatEngine(); + return await engine.getConversation(id); + } catch (error) { + console.error('[Chat IPC] Error getting conversation:', error); + return null; + } + }); + + // Update conversation + ipcMain.handle('chat:updateConversation', async (_, id: string, updates: { title?: string; model?: string }) => { + try { + const engine = getChatEngine(); + await engine.updateConversation(id, updates); + return { success: true }; + } catch (error) { + console.error('[Chat IPC] Error updating conversation:', error); + return { success: false, error: (error as Error).message }; + } + }); + + // Delete conversation + ipcMain.handle('chat:deleteConversation', async (_, id: string) => { + try { + const engine = getChatEngine(); + await engine.deleteConversation(id); + + // Also destroy any active session + const manager = getCopilotManager(); + await manager.destroySession(id); + + return { success: true }; + } catch (error) { + console.error('[Chat IPC] Error deleting conversation:', error); + return { success: false, error: (error as Error).message }; + } + }); + + // ============ Chat Messaging ============ + + // Send a message + ipcMain.handle('chat:sendMessage', async (_, conversationId: string, message: string) => { + try { + const manager = getCopilotManager(); + const mainWindow = mainWindowGetter?.(); + + const result = await manager.sendMessage(conversationId, message, { + onDelta: (delta) => { + if (mainWindow) { + mainWindow.webContents.send('chat-stream-delta', { conversationId, delta }); + } + }, + onToolCall: (toolCall) => { + if (mainWindow) { + mainWindow.webContents.send('chat-tool-call', { conversationId, toolCall }); + } + }, + onToolResult: (result) => { + if (mainWindow) { + mainWindow.webContents.send('chat-tool-result', { conversationId, result }); + } + }, + }); + + return result; + } catch (error) { + console.error('[Chat IPC] Error sending message:', error); + return { success: false, error: (error as Error).message }; + } + }); + + // Abort a running message + ipcMain.handle('chat:abortMessage', async (_, conversationId: string) => { + try { + const manager = getCopilotManager(); + return await manager.abortMessage(conversationId); + } catch (error) { + console.error('[Chat IPC] Error aborting message:', error); + return { success: false, error: (error as Error).message }; + } + }); + + // Get message history for a conversation + ipcMain.handle('chat:getHistory', async (_, conversationId: string) => { + try { + const engine = getChatEngine(); + return await engine.getMessages(conversationId); + } catch (error) { + console.error('[Chat IPC] Error getting history:', error); + return []; + } + }); + + // Clear messages from a conversation + ipcMain.handle('chat:clearMessages', async (_, conversationId: string) => { + try { + const engine = getChatEngine(); + await engine.clearMessages(conversationId); + return { success: true }; + } catch (error) { + console.error('[Chat IPC] Error clearing messages:', error); + return { success: false, error: (error as Error).message }; + } + }); + + // Set conversation model + ipcMain.handle('chat:setConversationModel', async (_, conversationId: string, modelId: string) => { + try { + const engine = getChatEngine(); + await engine.updateConversation(conversationId, { model: modelId }); + return { success: true }; + } catch (error) { + console.error('[Chat IPC] Error setting conversation model:', error); + return { success: false, error: (error as Error).message }; + } + }); +} + +/** + * Cleanup chat resources + */ +export async function cleanupChatHandlers(): Promise { + if (copilotManager) { + await copilotManager.stop(); + copilotManager = null; + } + chatEngine = null; +} diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts index 16d721d..1fc06f9 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -1 +1,2 @@ export { registerIpcHandlers } from './handlers'; +export { registerChatHandlers, initializeChatHandlers, cleanupChatHandlers } from './chatHandlers'; diff --git a/src/main/main.ts b/src/main/main.ts index 57adc6d..4233e6d 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -2,7 +2,7 @@ import { app, BrowserWindow, Menu, MenuItemConstructorOptions, ipcMain, protocol import * as path from 'path'; import * as fs from 'fs'; import { getDatabase } from './database'; -import { registerIpcHandlers } from './ipc'; +import { registerIpcHandlers, registerChatHandlers, initializeChatHandlers, cleanupChatHandlers } from './ipc'; import { media } from './database/schema'; import { eq } from 'drizzle-orm'; @@ -385,6 +385,10 @@ async function initialize(): Promise { // Register IPC handlers registerIpcHandlers(); + + // Initialize and register chat handlers + initializeChatHandlers(() => mainWindow); + registerChatHandlers(); } // App lifecycle @@ -406,6 +410,9 @@ app.on('window-all-closed', () => { }); app.on('before-quit', async () => { + // Cleanup chat resources + await cleanupChatHandlers(); + const db = getDatabase(); await db.close(); }); diff --git a/src/main/preload.ts b/src/main/preload.ts index 38ba919..497cb2c 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -129,6 +129,62 @@ contextBridge.exposeInMainWorld('electronAPI', { syncFromPosts: () => ipcRenderer.invoke('tags:syncFromPosts'), }, + // AI Chat (Copilot SDK integration) + chat: { + // Authentication + checkReady: () => ipcRenderer.invoke('chat:checkReady'), + copilotAuthStatus: () => ipcRenderer.invoke('chat:copilotAuthStatus'), + copilotLogin: () => ipcRenderer.invoke('chat:copilotLogin'), + copilotLogout: () => ipcRenderer.invoke('chat:copilotLogout'), + + // Settings + getAvailableModels: () => ipcRenderer.invoke('chat:getAvailableModels'), + setDefaultModel: (modelId: string) => ipcRenderer.invoke('chat:setDefaultModel', modelId), + getSystemPrompt: () => ipcRenderer.invoke('chat:getSystemPrompt'), + setSystemPrompt: (prompt: string) => ipcRenderer.invoke('chat:setSystemPrompt', prompt), + + // Conversations + getConversations: () => ipcRenderer.invoke('chat:getConversations'), + createConversation: (title?: string, model?: string) => ipcRenderer.invoke('chat:createConversation', title, model), + getConversation: (id: string) => ipcRenderer.invoke('chat:getConversation', id), + updateConversation: (id: string, updates: { title?: string; model?: string }) => ipcRenderer.invoke('chat:updateConversation', id, updates), + deleteConversation: (id: string) => ipcRenderer.invoke('chat:deleteConversation', id), + + // Messaging + sendMessage: (conversationId: string, message: string) => ipcRenderer.invoke('chat:sendMessage', conversationId, message), + abortMessage: (conversationId: string) => ipcRenderer.invoke('chat:abortMessage', conversationId), + getHistory: (conversationId: string) => ipcRenderer.invoke('chat:getHistory', conversationId), + clearMessages: (conversationId: string) => ipcRenderer.invoke('chat:clearMessages', conversationId), + setConversationModel: (conversationId: string, modelId: string) => ipcRenderer.invoke('chat:setConversationModel', conversationId, modelId), + + // Event listeners for streaming/progress + onStreamDelta: (callback: (data: { conversationId: string; delta: string }) => void) => { + const subscription = (_event: Electron.IpcRendererEvent, data: { conversationId: string; delta: string }) => callback(data); + ipcRenderer.on('chat-stream-delta', subscription); + return () => ipcRenderer.removeListener('chat-stream-delta', subscription); + }, + onToolCall: (callback: (data: { conversationId: string; toolCall: unknown }) => void) => { + const subscription = (_event: Electron.IpcRendererEvent, data: { conversationId: string; toolCall: unknown }) => callback(data); + ipcRenderer.on('chat-tool-call', subscription); + return () => ipcRenderer.removeListener('chat-tool-call', subscription); + }, + onToolResult: (callback: (data: { conversationId: string; result: unknown }) => void) => { + const subscription = (_event: Electron.IpcRendererEvent, data: { conversationId: string; result: unknown }) => callback(data); + ipcRenderer.on('chat-tool-result', subscription); + return () => ipcRenderer.removeListener('chat-tool-result', subscription); + }, + onTitleUpdated: (callback: (data: { conversationId: string; title: string }) => void) => { + const subscription = (_event: Electron.IpcRendererEvent, data: { conversationId: string; title: string }) => callback(data); + ipcRenderer.on('chat-title-updated', subscription); + return () => ipcRenderer.removeListener('chat-title-updated', subscription); + }, + onDeviceCode: (callback: (data: { verificationUri: string; userCode: string }) => void) => { + const subscription = (_event: Electron.IpcRendererEvent, data: { verificationUri: string; userCode: string }) => callback(data); + ipcRenderer.on('copilot-device-code', subscription); + return () => ipcRenderer.removeListener('copilot-device-code', subscription); + }, + }, + // Event listeners on: (channel: string, callback: (...args: unknown[]) => void) => { const subscription = (_event: Electron.IpcRendererEvent, ...args: unknown[]) => callback(...args); @@ -225,6 +281,53 @@ export interface ElectronAPI { removeCategory: (category: string) => Promise; syncOnStartup: () => Promise<{ tags: string[]; categories: string[] }>; }; + tags: { + getAll: () => Promise; + getWithCounts: () => Promise; + get: (id: string) => Promise; + getByName: (name: string) => Promise; + create: (data: { name: string; color?: string }) => Promise; + update: (id: string, data: { name?: string; color?: string | null }) => Promise; + delete: (id: string) => Promise; + merge: (sourceTagIds: string[], targetTagId: string) => Promise; + rename: (id: string, newName: string) => Promise; + getPostsWithTag: (tagId: string) => Promise; + syncFromPosts: () => Promise; + }; + chat: { + // Authentication + checkReady: () => Promise<{ ready: boolean; authenticated: boolean }>; + copilotAuthStatus: () => Promise<{ authenticated: boolean; username?: string }>; + copilotLogin: () => Promise<{ success: boolean; error?: string; login?: string }>; + copilotLogout: () => Promise; + + // Settings + getAvailableModels: () => Promise>; + setDefaultModel: (modelId: string) => Promise; + getSystemPrompt: () => Promise; + setSystemPrompt: (prompt: string) => Promise; + + // Conversations + getConversations: () => Promise; + createConversation: (title?: string, model?: string) => Promise; + getConversation: (id: string) => Promise; + updateConversation: (id: string, updates: { title?: string; model?: string }) => Promise; + deleteConversation: (id: string) => Promise; + + // Messaging + sendMessage: (conversationId: string, message: string) => Promise; + abortMessage: (conversationId: string) => Promise; + getHistory: (conversationId: string) => Promise; + clearMessages: (conversationId: string) => Promise; + setConversationModel: (conversationId: string, modelId: string) => Promise; + + // Event listeners + onStreamDelta: (callback: (data: { conversationId: string; delta: string }) => void) => () => void; + onToolCall: (callback: (data: { conversationId: string; toolCall: unknown }) => void) => () => void; + onToolResult: (callback: (data: { conversationId: string; result: unknown }) => void) => () => void; + onTitleUpdated: (callback: (data: { conversationId: string; title: string }) => void) => () => void; + onDeviceCode: (callback: (data: { verificationUri: string; userCode: string }) => void) => () => void; + }; on: (channel: string, callback: (...args: unknown[]) => void) => () => void; once: (channel: string, callback: (...args: unknown[]) => void) => void; } diff --git a/src/renderer/components/ActivityBar/ActivityBar.tsx b/src/renderer/components/ActivityBar/ActivityBar.tsx index 0758ed8..38767ec 100644 --- a/src/renderer/components/ActivityBar/ActivityBar.tsx +++ b/src/renderer/components/ActivityBar/ActivityBar.tsx @@ -28,6 +28,15 @@ const TagsIcon = () => ( ); +const ChatIcon = () => ( + + + + + + +); + const SyncIcon = () => ( @@ -45,8 +54,11 @@ export const ActivityBar: React.FC = () => { // Check if tags tab is currently active const isTagsTabActive = tabs.some(t => t.type === 'tags' && t.id === activeTabId); + // Check if chat sidebar is active (activeView === 'chat' and sidebar is visible) + const isChatActive = activeView === 'chat' && sidebarVisible; + // Handle view click - toggle sidebar if clicking on active view, otherwise switch view - const handleViewClick = (view: 'posts' | 'media') => { + const handleViewClick = (view: 'posts' | 'media' | 'chat') => { if (activeView === view && sidebarVisible) { // Clicking on active view toggles sidebar off toggleSidebar(); @@ -96,6 +108,13 @@ export const ActivityBar: React.FC = () => { > +
diff --git a/src/renderer/components/ChatPanel/ChatPanel.css b/src/renderer/components/ChatPanel/ChatPanel.css new file mode 100644 index 0000000..5d4399c --- /dev/null +++ b/src/renderer/components/ChatPanel/ChatPanel.css @@ -0,0 +1,346 @@ +.chat-panel { + display: flex; + flex-direction: column; + height: 100%; + background-color: var(--vscode-editor-background); +} + +.chat-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid var(--vscode-editorGroup-border); + background-color: var(--vscode-sideBar-background); +} + +.chat-panel-title { + font-size: 14px; + font-weight: 500; + color: var(--vscode-foreground); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; +} + +.chat-panel-model { + position: relative; +} + +.model-selector-button { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + font-size: 12px; + color: var(--vscode-descriptionForeground); + background: transparent; + border: 1px solid var(--vscode-input-border); + border-radius: 4px; + cursor: pointer; +} + +.model-selector-button:hover { + background-color: var(--vscode-list-hoverBackground); +} + +.model-dropdown-icon { + font-size: 10px; +} + +.model-dropdown { + position: absolute; + top: 100%; + right: 0; + margin-top: 4px; + min-width: 160px; + background-color: var(--vscode-dropdown-background); + border: 1px solid var(--vscode-dropdown-border); + border-radius: 4px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); + z-index: 100; +} + +.model-option { + display: block; + width: 100%; + padding: 8px 12px; + font-size: 12px; + text-align: left; + color: var(--vscode-foreground); + background: transparent; + border: none; + cursor: pointer; +} + +.model-option:hover { + background-color: var(--vscode-list-hoverBackground); +} + +.model-option.active { + background-color: var(--vscode-list-activeSelectionBackground); + color: var(--vscode-list-activeSelectionForeground); +} + +.chat-messages { + flex: 1; + overflow-y: auto; + padding: 16px; +} + +.chat-welcome { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + padding: 32px; + color: var(--vscode-descriptionForeground); +} + +.chat-welcome-icon { + font-size: 48px; + margin-bottom: 16px; +} + +.chat-welcome h2 { + margin: 0 0 12px 0; + font-size: 18px; + font-weight: 500; + color: var(--vscode-foreground); +} + +.chat-welcome p { + margin: 0 0 12px 0; + font-size: 14px; +} + +.chat-welcome ul { + margin: 0; + padding: 0; + list-style: none; + text-align: left; +} + +.chat-welcome li { + padding: 4px 0; + font-size: 13px; +} + +.chat-welcome li::before { + content: '•'; + margin-right: 8px; + color: var(--vscode-textLink-foreground); +} + +.chat-message { + display: flex; + gap: 12px; + margin-bottom: 16px; +} + +.chat-message.user { + flex-direction: row-reverse; +} + +.chat-message-avatar { + flex-shrink: 0; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + border-radius: 50%; + background-color: var(--vscode-input-background); +} + +.chat-message.user .chat-message-avatar { + background-color: var(--vscode-button-background); +} + +.chat-message-content { + max-width: 80%; + min-width: 100px; +} + +.chat-message.user .chat-message-content { + text-align: right; +} + +.chat-message-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 4px; +} + +.chat-message.user .chat-message-header { + justify-content: flex-end; +} + +.chat-message-role { + font-size: 12px; + font-weight: 500; + color: var(--vscode-descriptionForeground); +} + +.streaming-indicator { + color: var(--vscode-button-background); + animation: pulse 1s infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.3; } +} + +.chat-message-text { + font-size: 14px; + line-height: 1.5; + color: var(--vscode-foreground); + white-space: pre-wrap; + word-break: break-word; + padding: 10px 14px; + border-radius: 12px; + background-color: var(--vscode-input-background); +} + +.chat-message.user .chat-message-text { + background-color: var(--vscode-button-background); + color: var(--vscode-button-foreground); + border-radius: 12px 12px 2px 12px; +} + +.chat-message.assistant .chat-message-text { + border-radius: 12px 12px 12px 2px; +} + +.chat-message.streaming .chat-message-text { + background: linear-gradient( + 90deg, + var(--vscode-input-background) 0%, + var(--vscode-list-hoverBackground) 50%, + var(--vscode-input-background) 100% + ); + background-size: 200% 100%; + animation: shimmer 2s infinite; +} + +@keyframes shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + +.chat-thinking-indicator { + display: flex; + gap: 4px; + padding: 12px 16px; +} + +.chat-thinking-indicator span { + width: 8px; + height: 8px; + border-radius: 50%; + background-color: var(--vscode-descriptionForeground); + animation: bounce 1.4s infinite ease-in-out both; +} + +.chat-thinking-indicator span:nth-child(1) { + animation-delay: -0.32s; +} + +.chat-thinking-indicator span:nth-child(2) { + animation-delay: -0.16s; +} + +@keyframes bounce { + 0%, 80%, 100% { + transform: scale(0); + } + 40% { + transform: scale(1); + } +} + +.chat-input-container { + padding: 16px; + border-top: 1px solid var(--vscode-editorGroup-border); + background-color: var(--vscode-sideBar-background); +} + +.chat-abort-button { + display: block; + width: 100%; + margin-bottom: 8px; + padding: 8px; + font-size: 13px; + color: var(--vscode-errorForeground); + background-color: transparent; + border: 1px solid var(--vscode-errorForeground); + border-radius: 4px; + cursor: pointer; +} + +.chat-abort-button:hover { + background-color: var(--vscode-inputValidation-errorBackground); +} + +.chat-input-wrapper { + display: flex; + align-items: flex-end; + gap: 8px; + background-color: var(--vscode-input-background); + border: 1px solid var(--vscode-input-border); + border-radius: 8px; + padding: 8px; +} + +.chat-input-wrapper:focus-within { + border-color: var(--vscode-focusBorder); +} + +.chat-input { + flex: 1; + min-height: 24px; + max-height: 120px; + padding: 0; + font-size: 14px; + font-family: inherit; + line-height: 1.5; + color: var(--vscode-input-foreground); + background: transparent; + border: none; + outline: none; + resize: none; +} + +.chat-input::placeholder { + color: var(--vscode-input-placeholderForeground); +} + +.chat-send-button { + flex-shrink: 0; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + color: var(--vscode-button-foreground); + background-color: var(--vscode-button-background); + border: none; + border-radius: 50%; + cursor: pointer; + transition: background-color 0.15s; +} + +.chat-send-button:hover:not(:disabled) { + background-color: var(--vscode-button-hoverBackground); +} + +.chat-send-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} diff --git a/src/renderer/components/ChatPanel/ChatPanel.tsx b/src/renderer/components/ChatPanel/ChatPanel.tsx new file mode 100644 index 0000000..0a18236 --- /dev/null +++ b/src/renderer/components/ChatPanel/ChatPanel.tsx @@ -0,0 +1,263 @@ +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import type { ChatMessage, ChatConversation, ChatModel } from '../../types/electron'; +import './ChatPanel.css'; + +interface ChatPanelProps { + conversationId: string; +} + +export const ChatPanel: React.FC = ({ conversationId }) => { + const [conversation, setConversation] = useState(null); + const [messages, setMessages] = useState([]); + const [inputValue, setInputValue] = useState(''); + const [isStreaming, setIsStreaming] = useState(false); + const [streamingContent, setStreamingContent] = useState(''); + const [availableModels, setAvailableModels] = useState([]); + const [showModelSelector, setShowModelSelector] = useState(false); + const messagesEndRef = useRef(null); + const inputRef = useRef(null); + const streamingRef = useRef(''); + + // Scroll to bottom when messages change + const scrollToBottom = useCallback(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, []); + + // Load conversation and messages + const loadData = useCallback(async () => { + try { + const [conv, msgs, models] = await Promise.all([ + window.electronAPI?.chat.getConversation(conversationId), + window.electronAPI?.chat.getHistory(conversationId), + window.electronAPI?.chat.getAvailableModels() + ]); + + if (conv) setConversation(conv); + if (msgs) setMessages(msgs); + if (models) setAvailableModels(models); + } catch (error) { + console.error('Failed to load chat data:', error); + } + }, [conversationId]); + + useEffect(() => { + loadData(); + + // Subscribe to stream events + const unsubDelta = window.electronAPI?.chat.onStreamDelta((data) => { + if (data.conversationId === conversationId) { + streamingRef.current += data.delta; + setStreamingContent(streamingRef.current); + scrollToBottom(); + } + }); + + const unsubTitle = window.electronAPI?.chat.onTitleUpdated((data) => { + if (data.conversationId === conversationId) { + setConversation(prev => prev ? { ...prev, title: data.title } : null); + } + }); + + return () => { + unsubDelta?.(); + unsubTitle?.(); + }; + }, [conversationId, loadData, scrollToBottom]); + + // Scroll on new messages or streaming content + useEffect(() => { + scrollToBottom(); + }, [messages, streamingContent, scrollToBottom]); + + const handleSend = async () => { + const message = inputValue.trim(); + if (!message || isStreaming) return; + + setInputValue(''); + setIsStreaming(true); + streamingRef.current = ''; + setStreamingContent(''); + + // Add user message optimistically + const userMessage: ChatMessage = { + id: `temp-${Date.now()}`, + conversationId, + role: 'user', + content: message, + createdAt: new Date().toISOString() + }; + setMessages(prev => [...prev, userMessage]); + + try { + // Send message and wait for complete response + await window.electronAPI?.chat.sendMessage(conversationId, message); + + // Reload messages to get the saved assistant response + const msgs = await window.electronAPI?.chat.getHistory(conversationId); + if (msgs) setMessages(msgs); + } catch (error) { + console.error('Failed to send message:', error); + // Add error message + const errorMessage: ChatMessage = { + id: `error-${Date.now()}`, + conversationId, + role: 'assistant', + content: 'Sorry, an error occurred while processing your message.', + createdAt: new Date().toISOString() + }; + setMessages(prev => [...prev, errorMessage]); + } finally { + setIsStreaming(false); + setStreamingContent(''); + streamingRef.current = ''; + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }; + + const handleAbort = async () => { + try { + await window.electronAPI?.chat.abortMessage(conversationId); + } catch (error) { + console.error('Failed to abort:', error); + } + }; + + const handleModelChange = async (modelId: string) => { + try { + await window.electronAPI?.chat.setConversationModel(conversationId, modelId); + setConversation(prev => prev ? { ...prev, model: modelId } : null); + setShowModelSelector(false); + } catch (error) { + console.error('Failed to change model:', error); + } + }; + + const renderMessage = (msg: ChatMessage) => { + if (msg.role === 'system' || msg.role === 'tool') return null; + + return ( +
+
+ {msg.role === 'user' ? '👤' : '🤖'} +
+
+
+ + {msg.role === 'user' ? 'You' : 'Assistant'} + +
+
{msg.content}
+
+
+ ); + }; + + return ( +
+
+
+ {conversation?.title || 'New Chat'} +
+
+ + {showModelSelector && ( +
+ {availableModels.map(model => ( + + ))} +
+ )} +
+
+ +
+ {messages.length === 0 && !isStreaming && ( +
+
🤖
+

Welcome to the AI Assistant

+

I can help you manage your posts and media. Try asking me to:

+
    +
  • Search for posts about a specific topic
  • +
  • Get details about a specific post
  • +
  • Update metadata for posts or media
  • +
  • List all images in your media library
  • +
+
+ )} + + {messages.map(renderMessage)} + + {isStreaming && streamingContent && ( +
+
🤖
+
+
+ Assistant + +
+
{streamingContent}
+
+
+ )} + + {isStreaming && !streamingContent && ( +
+
🤖
+
+
+ +
+
+
+ )} + +
+
+ +
+ {isStreaming && ( + + )} +
+