feat: ai chat added, login flow still broken

This commit is contained in:
2026-02-11 18:00:37 +01:00
parent 258e313f0e
commit 870bec4dcd
21 changed files with 3174 additions and 25 deletions

174
package-lock.json generated
View File

@@ -10,6 +10,7 @@
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@floating-ui/dom": "^1.7.5", "@floating-ui/dom": "^1.7.5",
"@github/copilot-sdk": "^0.1.23",
"@libsql/client": "^0.4.0", "@libsql/client": "^0.4.0",
"@milkdown/kit": "^7.18.0", "@milkdown/kit": "^7.18.0",
"@milkdown/plugin-block": "^7.18.0", "@milkdown/plugin-block": "^7.18.0",
@@ -36,6 +37,7 @@
"sharp": "^0.34.5", "sharp": "^0.34.5",
"snowball-stemmers": "^0.6.0", "snowball-stemmers": "^0.6.0",
"uuid": "^9.0.1", "uuid": "^9.0.1",
"zod": "^3.25.76",
"zustand": "^4.4.7" "zustand": "^4.4.7"
}, },
"devDependencies": { "devDependencies": {
@@ -53,7 +55,7 @@
"concurrently": "^8.2.2", "concurrently": "^8.2.2",
"cross-env": "^10.1.0", "cross-env": "^10.1.0",
"drizzle-kit": "^0.20.0", "drizzle-kit": "^0.20.0",
"electron": "^28.0.0", "electron": "^39.5.2",
"electron-builder": "^24.9.1", "electron-builder": "^24.9.1",
"jsdom": "^28.0.0", "jsdom": "^28.0.0",
"memfs": "^4.6.0", "memfs": "^4.6.0",
@@ -2250,7 +2252,6 @@
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.5.tgz", "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.5.tgz",
"integrity": "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==", "integrity": "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@floating-ui/core": "^1.7.4", "@floating-ui/core": "^1.7.4",
"@floating-ui/utils": "^0.2.10" "@floating-ui/utils": "^0.2.10"
@@ -2262,6 +2263,142 @@
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
"license": "MIT" "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": { "node_modules/@hapi/address": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/@hapi/address/-/address-5.1.1.tgz", "resolved": "https://registry.npmjs.org/@hapi/address/-/address-5.1.1.tgz",
@@ -7615,15 +7752,15 @@
} }
}, },
"node_modules/electron": { "node_modules/electron": {
"version": "28.3.3", "version": "39.5.2",
"resolved": "https://registry.npmjs.org/electron/-/electron-28.3.3.tgz", "resolved": "https://registry.npmjs.org/electron/-/electron-39.5.2.tgz",
"integrity": "sha512-ObKMLSPNhomtCOBAxFS8P2DW/4umkh72ouZUlUKzXGtYuPzgr1SYhskhFWgzAsPtUzhL2CzyV2sfbHcEW4CXqw==", "integrity": "sha512-EiGFKoTjCuJTsdNxSwOiKJvRbWOFHTmqnnVftpUUZf7rdMkvM6yn9i55uLNDKefvTE69M+vfMgGLa7HuY94WZg==",
"dev": true, "dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@electron/get": "^2.0.0", "@electron/get": "^2.0.0",
"@types/node": "^18.11.18", "@types/node": "^22.7.7",
"extract-zip": "^2.0.1" "extract-zip": "^2.0.1"
}, },
"bin": { "bin": {
@@ -7824,22 +7961,15 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/electron/node_modules/@types/node": { "node_modules/electron/node_modules/@types/node": {
"version": "18.19.130", "version": "22.19.11",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz",
"integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", "integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "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": { "node_modules/emoji-regex": {
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "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": { "node_modules/vue": {
"version": "3.5.28", "version": "3.5.28",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.28.tgz", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.28.tgz",
@@ -14942,7 +15081,6 @@
"version": "3.25.76", "version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"peer": true, "peer": true,
"funding": { "funding": {

View File

@@ -41,7 +41,7 @@
"concurrently": "^8.2.2", "concurrently": "^8.2.2",
"cross-env": "^10.1.0", "cross-env": "^10.1.0",
"drizzle-kit": "^0.20.0", "drizzle-kit": "^0.20.0",
"electron": "^28.0.0", "electron": "^39.5.2",
"electron-builder": "^24.9.1", "electron-builder": "^24.9.1",
"jsdom": "^28.0.0", "jsdom": "^28.0.0",
"memfs": "^4.6.0", "memfs": "^4.6.0",
@@ -53,6 +53,7 @@
}, },
"dependencies": { "dependencies": {
"@floating-ui/dom": "^1.7.5", "@floating-ui/dom": "^1.7.5",
"@github/copilot-sdk": "^0.1.23",
"@libsql/client": "^0.4.0", "@libsql/client": "^0.4.0",
"@milkdown/kit": "^7.18.0", "@milkdown/kit": "^7.18.0",
"@milkdown/plugin-block": "^7.18.0", "@milkdown/plugin-block": "^7.18.0",
@@ -79,6 +80,7 @@
"sharp": "^0.34.5", "sharp": "^0.34.5",
"snowball-stemmers": "^0.6.0", "snowball-stemmers": "^0.6.0",
"uuid": "^9.0.1", "uuid": "^9.0.1",
"zod": "^3.25.76",
"zustand": "^4.4.7" "zustand": "^4.4.7"
}, },
"build": { "build": {

View File

@@ -410,6 +410,34 @@ export class DatabaseConnection {
args: ['default', 'Default Project', 'default', 'Your first blog project', now, now, 1], 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<void> { async close(): Promise<void> {

View File

@@ -108,6 +108,27 @@ export const tags = sqliteTable('tags', {
projectNameIdx: uniqueIndex('tags_project_name_idx').on(table.projectId, table.name), 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 // Types for TypeScript
export type Project = typeof projects.$inferSelect; export type Project = typeof projects.$inferSelect;
export type NewProject = typeof projects.$inferInsert; export type NewProject = typeof projects.$inferInsert;
@@ -123,3 +144,7 @@ export type PostLink = typeof postLinks.$inferSelect;
export type NewPostLink = typeof postLinks.$inferInsert; export type NewPostLink = typeof postLinks.$inferInsert;
export type Tag = typeof tags.$inferSelect; export type Tag = typeof tags.$inferSelect;
export type NewTag = typeof tags.$inferInsert; 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;

View File

@@ -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<ChatConversationData> {
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<ChatConversationData[]> {
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<Pick<ChatConversationData, 'title' | 'model' | 'copilotSessionId'>>): Promise<void> {
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<void> {
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<ChatMessageData, 'id'>): Promise<ChatMessageData> {
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<ChatMessageData[]> {
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<void> {
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<string> {
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<void> {
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<string> {
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<void> {
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],
});
}
}

View File

@@ -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<any>;
/**
* Load ESM modules dynamically (required for CommonJS compatibility)
*/
async function loadModules(): Promise<boolean> {
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<string, CopilotSession> = 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<string, unknown> = {};
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<string, unknown> = {};
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<CopilotSession> {
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<SendMessageResult> {
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<void> {
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<void> {
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<string[]> {
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<void> {
// 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;
}
}
}

View File

@@ -37,4 +37,16 @@ export {
type FileDownloadResult, type FileDownloadResult,
type ConflictResolution, type ConflictResolution,
} from './DropboxSyncEngine'; } from './DropboxSyncEngine';
export {
ChatEngine,
type ChatConversationData,
type ChatMessageData,
type CreateConversationInput,
} from './ChatEngine';
export {
CopilotManager,
type SendMessageOptions,
type SendMessageResult,
} from './CopilotManager';

View File

@@ -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<void> {
if (copilotManager) {
await copilotManager.stop();
copilotManager = null;
}
chatEngine = null;
}

View File

@@ -1 +1,2 @@
export { registerIpcHandlers } from './handlers'; export { registerIpcHandlers } from './handlers';
export { registerChatHandlers, initializeChatHandlers, cleanupChatHandlers } from './chatHandlers';

View File

@@ -2,7 +2,7 @@ import { app, BrowserWindow, Menu, MenuItemConstructorOptions, ipcMain, protocol
import * as path from 'path'; import * as path from 'path';
import * as fs from 'fs'; import * as fs from 'fs';
import { getDatabase } from './database'; import { getDatabase } from './database';
import { registerIpcHandlers } from './ipc'; import { registerIpcHandlers, registerChatHandlers, initializeChatHandlers, cleanupChatHandlers } from './ipc';
import { media } from './database/schema'; import { media } from './database/schema';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
@@ -385,6 +385,10 @@ async function initialize(): Promise<void> {
// Register IPC handlers // Register IPC handlers
registerIpcHandlers(); registerIpcHandlers();
// Initialize and register chat handlers
initializeChatHandlers(() => mainWindow);
registerChatHandlers();
} }
// App lifecycle // App lifecycle
@@ -406,6 +410,9 @@ app.on('window-all-closed', () => {
}); });
app.on('before-quit', async () => { app.on('before-quit', async () => {
// Cleanup chat resources
await cleanupChatHandlers();
const db = getDatabase(); const db = getDatabase();
await db.close(); await db.close();
}); });

View File

@@ -129,6 +129,62 @@ contextBridge.exposeInMainWorld('electronAPI', {
syncFromPosts: () => ipcRenderer.invoke('tags:syncFromPosts'), 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 // Event listeners
on: (channel: string, callback: (...args: unknown[]) => void) => { on: (channel: string, callback: (...args: unknown[]) => void) => {
const subscription = (_event: Electron.IpcRendererEvent, ...args: unknown[]) => callback(...args); const subscription = (_event: Electron.IpcRendererEvent, ...args: unknown[]) => callback(...args);
@@ -225,6 +281,53 @@ export interface ElectronAPI {
removeCategory: (category: string) => Promise<string[]>; removeCategory: (category: string) => Promise<string[]>;
syncOnStartup: () => Promise<{ tags: string[]; categories: string[] }>; syncOnStartup: () => Promise<{ tags: string[]; categories: string[] }>;
}; };
tags: {
getAll: () => Promise<unknown[]>;
getWithCounts: () => Promise<unknown[]>;
get: (id: string) => Promise<unknown>;
getByName: (name: string) => Promise<unknown>;
create: (data: { name: string; color?: string }) => Promise<unknown>;
update: (id: string, data: { name?: string; color?: string | null }) => Promise<unknown>;
delete: (id: string) => Promise<boolean>;
merge: (sourceTagIds: string[], targetTagId: string) => Promise<void>;
rename: (id: string, newName: string) => Promise<unknown>;
getPostsWithTag: (tagId: string) => Promise<unknown[]>;
syncFromPosts: () => Promise<void>;
};
chat: {
// Authentication
checkReady: () => Promise<{ ready: boolean; authenticated: boolean }>;
copilotAuthStatus: () => Promise<{ authenticated: boolean; username?: string }>;
copilotLogin: () => Promise<{ success: boolean; error?: string; login?: string }>;
copilotLogout: () => Promise<void>;
// Settings
getAvailableModels: () => Promise<Array<{ id: string; name: string }>>;
setDefaultModel: (modelId: string) => Promise<void>;
getSystemPrompt: () => Promise<string | null>;
setSystemPrompt: (prompt: string) => Promise<void>;
// Conversations
getConversations: () => Promise<unknown[]>;
createConversation: (title?: string, model?: string) => Promise<unknown>;
getConversation: (id: string) => Promise<unknown>;
updateConversation: (id: string, updates: { title?: string; model?: string }) => Promise<unknown>;
deleteConversation: (id: string) => Promise<boolean>;
// Messaging
sendMessage: (conversationId: string, message: string) => Promise<string>;
abortMessage: (conversationId: string) => Promise<void>;
getHistory: (conversationId: string) => Promise<unknown[]>;
clearMessages: (conversationId: string) => Promise<void>;
setConversationModel: (conversationId: string, modelId: string) => Promise<void>;
// 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; on: (channel: string, callback: (...args: unknown[]) => void) => () => void;
once: (channel: string, callback: (...args: unknown[]) => void) => void; once: (channel: string, callback: (...args: unknown[]) => void) => void;
} }

View File

@@ -28,6 +28,15 @@ const TagsIcon = () => (
</svg> </svg>
); );
const ChatIcon = () => (
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H6l-2 2V4h16v12z"/>
<circle cx="8" cy="10" r="1.5"/>
<circle cx="12" cy="10" r="1.5"/>
<circle cx="16" cy="10" r="1.5"/>
</svg>
);
const SyncIcon = () => ( const SyncIcon = () => (
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"> <svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46C19.54 15.03 20 13.57 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74C4.46 8.97 4 10.43 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z"/> <path d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46C19.54 15.03 20 13.57 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74C4.46 8.97 4 10.43 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z"/>
@@ -45,8 +54,11 @@ export const ActivityBar: React.FC = () => {
// Check if tags tab is currently active // Check if tags tab is currently active
const isTagsTabActive = tabs.some(t => t.type === 'tags' && t.id === activeTabId); 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 // 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) { if (activeView === view && sidebarVisible) {
// Clicking on active view toggles sidebar off // Clicking on active view toggles sidebar off
toggleSidebar(); toggleSidebar();
@@ -96,6 +108,13 @@ export const ActivityBar: React.FC = () => {
> >
<TagsIcon /> <TagsIcon />
</button> </button>
<button
className={`activity-bar-item ${isChatActive ? 'active' : ''}`}
onClick={() => handleViewClick('chat')}
title="AI Assistant (click again to toggle sidebar)"
>
<ChatIcon />
</button>
</div> </div>
<div className="activity-bar-bottom"> <div className="activity-bar-bottom">

View File

@@ -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;
}

View File

@@ -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<ChatPanelProps> = ({ conversationId }) => {
const [conversation, setConversation] = useState<ChatConversation | null>(null);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [inputValue, setInputValue] = useState('');
const [isStreaming, setIsStreaming] = useState(false);
const [streamingContent, setStreamingContent] = useState('');
const [availableModels, setAvailableModels] = useState<ChatModel[]>([]);
const [showModelSelector, setShowModelSelector] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(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 (
<div key={msg.id} className={`chat-message ${msg.role}`}>
<div className="chat-message-avatar">
{msg.role === 'user' ? '👤' : '🤖'}
</div>
<div className="chat-message-content">
<div className="chat-message-header">
<span className="chat-message-role">
{msg.role === 'user' ? 'You' : 'Assistant'}
</span>
</div>
<div className="chat-message-text">{msg.content}</div>
</div>
</div>
);
};
return (
<div className="chat-panel">
<div className="chat-panel-header">
<div className="chat-panel-title">
{conversation?.title || 'New Chat'}
</div>
<div className="chat-panel-model">
<button
className="model-selector-button"
onClick={() => setShowModelSelector(!showModelSelector)}
>
{conversation?.model || 'gpt-4o'}
<span className="model-dropdown-icon"></span>
</button>
{showModelSelector && (
<div className="model-dropdown">
{availableModels.map(model => (
<button
key={model.id}
className={`model-option ${conversation?.model === model.id ? 'active' : ''}`}
onClick={() => handleModelChange(model.id)}
>
{model.name}
</button>
))}
</div>
)}
</div>
</div>
<div className="chat-messages">
{messages.length === 0 && !isStreaming && (
<div className="chat-welcome">
<div className="chat-welcome-icon">🤖</div>
<h2>Welcome to the AI Assistant</h2>
<p>I can help you manage your posts and media. Try asking me to:</p>
<ul>
<li>Search for posts about a specific topic</li>
<li>Get details about a specific post</li>
<li>Update metadata for posts or media</li>
<li>List all images in your media library</li>
</ul>
</div>
)}
{messages.map(renderMessage)}
{isStreaming && streamingContent && (
<div className="chat-message assistant streaming">
<div className="chat-message-avatar">🤖</div>
<div className="chat-message-content">
<div className="chat-message-header">
<span className="chat-message-role">Assistant</span>
<span className="streaming-indicator"></span>
</div>
<div className="chat-message-text">{streamingContent}</div>
</div>
</div>
)}
{isStreaming && !streamingContent && (
<div className="chat-message assistant thinking">
<div className="chat-message-avatar">🤖</div>
<div className="chat-message-content">
<div className="chat-thinking-indicator">
<span></span><span></span><span></span>
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
<div className="chat-input-container">
{isStreaming && (
<button className="chat-abort-button" onClick={handleAbort}>
Stop
</button>
)}
<div className="chat-input-wrapper">
<textarea
ref={inputRef}
className="chat-input"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type a message..."
rows={1}
disabled={isStreaming}
/>
<button
className="chat-send-button"
onClick={handleSend}
disabled={!inputValue.trim() || isStreaming}
>
</button>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1 @@
export { ChatPanel } from './ChatPanel';

View File

@@ -9,6 +9,7 @@ import { ErrorModal } from '../ErrorModal';
import { SettingsView } from '../SettingsView'; import { SettingsView } from '../SettingsView';
import { TagsView } from '../TagsView'; import { TagsView } from '../TagsView';
import { TagInput } from '../TagInput'; import { TagInput } from '../TagInput';
import { ChatPanel } from '../ChatPanel';
import { AutoSaveManager } from '../../utils'; import { AutoSaveManager } from '../../utils';
import './Editor.css'; import './Editor.css';
@@ -1005,6 +1006,7 @@ export const Editor: React.FC = () => {
const showMedia = activeTab?.type === 'media'; const showMedia = activeTab?.type === 'media';
const showSettings = activeTab?.type === 'settings' || (activeView === 'settings' && !activeTab); const showSettings = activeTab?.type === 'settings' || (activeView === 'settings' && !activeTab);
const showTags = activeTab?.type === 'tags' || (activeView === 'tags' && !activeTab); const showTags = activeTab?.type === 'tags' || (activeView === 'tags' && !activeTab);
const showChat = activeTab?.type === 'chat';
// Clear selectedPostId if the post doesn't exist (e.g., after project switch) // Clear selectedPostId if the post doesn't exist (e.g., after project switch)
useEffect(() => { useEffect(() => {
@@ -1068,6 +1070,16 @@ export const Editor: React.FC = () => {
); );
} }
// Show chat if chat tab is active
if (showChat && activeTabId) {
return (
<>
<ChatPanel key={activeTabId} conversationId={activeTabId} />
{renderErrorModal()}
</>
);
}
// Show post editor if a post tab is active // Show post editor if a post tab is active
if (showPost && activeTabId) { if (showPost && activeTabId) {
const post = posts.find(p => p.id === activeTabId); const post = posts.find(p => p.id === activeTabId);

View File

@@ -606,3 +606,180 @@
.sidebar-item.unsaved .sidebar-item-meta { .sidebar-item.unsaved .sidebar-item-meta {
font-style: italic; font-style: italic;
} }
/* Chat List Styles */
.chat-list {
height: 100%;
display: flex;
flex-direction: column;
}
.chat-list-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--vscode-sideBar-foreground);
border-bottom: 1px solid var(--vscode-sideBar-border);
}
.chat-new-button {
background: transparent;
border: none;
color: var(--vscode-textLink-foreground);
cursor: pointer;
font-size: 16px;
line-height: 1;
padding: 2px 6px;
border-radius: 3px;
}
.chat-new-button:hover {
background-color: var(--vscode-list-hoverBackground);
}
.chat-user-info {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
font-size: 12px;
color: var(--vscode-descriptionForeground);
border-bottom: 1px solid var(--vscode-sideBar-border);
}
.chat-user-icon {
font-size: 14px;
}
.chat-username {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-list-items {
flex: 1;
overflow-y: auto;
}
.chat-list-item {
display: flex;
align-items: center;
padding: 8px 12px;
cursor: pointer;
border-bottom: 1px solid var(--vscode-sideBar-border);
}
.chat-list-item:hover {
background-color: var(--vscode-list-hoverBackground);
}
.chat-item-content {
flex: 1;
overflow: hidden;
}
.chat-item-title {
font-size: 13px;
color: var(--vscode-foreground);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-bottom: 2px;
}
.chat-item-date {
font-size: 11px;
color: var(--vscode-descriptionForeground);
}
.chat-item-delete {
background: transparent;
border: none;
color: var(--vscode-descriptionForeground);
cursor: pointer;
font-size: 16px;
padding: 0 4px;
opacity: 0;
transition: opacity 0.15s;
}
.chat-list-item:hover .chat-item-delete {
opacity: 1;
}
.chat-item-delete:hover {
color: var(--vscode-errorForeground);
}
.chat-loading,
.chat-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24px;
text-align: center;
color: var(--vscode-descriptionForeground);
font-size: 13px;
}
.chat-start-button,
.chat-login-button {
margin-top: 12px;
padding: 8px 16px;
font-size: 13px;
color: var(--vscode-button-foreground);
background-color: var(--vscode-button-background);
border: none;
border-radius: 4px;
cursor: pointer;
}
.chat-start-button:hover,
.chat-login-button:hover {
background-color: var(--vscode-button-hoverBackground);
}
.chat-auth-prompt {
padding: 24px;
text-align: center;
color: var(--vscode-descriptionForeground);
}
.chat-auth-prompt p {
margin: 0 0 12px 0;
font-size: 13px;
}
.device-code-prompt {
margin-top: 16px;
}
.device-code-prompt a {
color: var(--vscode-textLink-foreground);
text-decoration: none;
font-size: 12px;
}
.device-code-prompt a:hover {
text-decoration: underline;
}
.device-code {
margin-top: 8px;
padding: 8px 16px;
font-size: 20px;
font-weight: 600;
font-family: var(--vscode-editor-font-family, monospace);
letter-spacing: 3px;
color: var(--vscode-foreground);
background-color: var(--vscode-input-background);
border: 1px solid var(--vscode-input-border);
border-radius: 4px;
}

View File

@@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { useAppStore, PostData } from '../../store'; import { useAppStore, PostData } from '../../store';
import { showToast } from '../Toast'; import { showToast } from '../Toast';
import type { ChatConversation } from '../../types/electron';
import './Sidebar.css'; import './Sidebar.css';
const formatDate = (dateString: string) => { const formatDate = (dateString: string) => {
@@ -747,6 +748,210 @@ const SettingsNav: React.FC = () => {
); );
}; };
// Chat conversations list
const ChatList: React.FC = () => {
const { openTab } = useAppStore();
const [conversations, setConversations] = useState<ChatConversation[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [authStatus, setAuthStatus] = useState<{ authenticated: boolean; username?: string } | null>(null);
const [deviceCode, setDeviceCode] = useState<{ verificationUri: string; userCode: string } | null>(null);
// Load conversations
const loadConversations = useCallback(async () => {
try {
const convs = await window.electronAPI?.chat.getConversations();
if (convs) {
setConversations(convs);
}
} catch (error) {
console.error('Failed to load conversations:', error);
}
}, []);
// Check auth status
const checkAuth = useCallback(async () => {
try {
const status = await window.electronAPI?.chat.copilotAuthStatus();
setAuthStatus(status ?? null);
} catch (error) {
console.error('Failed to check auth:', error);
}
}, []);
useEffect(() => {
const init = async () => {
setIsLoading(true);
await checkAuth();
await loadConversations();
setIsLoading(false);
};
init();
// Subscribe to title updates
const unsubTitle = window.electronAPI?.chat.onTitleUpdated((data) => {
setConversations(prev =>
prev.map(c => c.id === data.conversationId ? { ...c, title: data.title } : c)
);
});
// Subscribe to device code for login flow
const unsubDevice = window.electronAPI?.chat.onDeviceCode((data) => {
setDeviceCode(data);
});
return () => {
unsubTitle?.();
unsubDevice?.();
};
}, [loadConversations, checkAuth]);
const handleNewChat = async () => {
try {
const conversation = await window.electronAPI?.chat.createConversation();
if (conversation) {
setConversations(prev => [conversation, ...prev]);
openTab({ type: 'chat', id: conversation.id, isTransient: false });
}
} catch (error) {
console.error('Failed to create conversation:', error);
showToast.error('Failed to create new chat');
}
};
const handleOpenChat = (conversationId: string) => {
openTab({ type: 'chat', id: conversationId, isTransient: false });
};
const handleDeleteChat = async (conversationId: string) => {
try {
await window.electronAPI?.chat.deleteConversation(conversationId);
setConversations(prev => prev.filter(c => c.id !== conversationId));
} catch (error) {
console.error('Failed to delete conversation:', error);
showToast.error('Failed to delete chat');
}
};
const handleLogin = async () => {
try {
const result = await window.electronAPI?.chat.copilotLogin();
if (result?.success) {
setDeviceCode(null);
await checkAuth();
} else if (result?.error) {
console.error('Login failed:', result.error);
showToast.error(result.error);
}
} catch (error) {
console.error('Login failed:', error);
showToast.error('Login failed');
}
};
const formatChatDate = (dateString: string) => {
const date = new Date(dateString);
const now = new Date();
const diffDays = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24));
if (diffDays === 0) {
return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
} else if (diffDays === 1) {
return 'Yesterday';
} else if (diffDays < 7) {
return date.toLocaleDateString('en-US', { weekday: 'short' });
}
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
};
if (isLoading) {
return (
<div className="chat-list">
<div className="chat-list-header">
<span>AI ASSISTANT</span>
</div>
<div className="chat-loading">Loading...</div>
</div>
);
}
// Show login prompt if not authenticated
if (!authStatus?.authenticated) {
return (
<div className="chat-list">
<div className="chat-list-header">
<span>AI ASSISTANT</span>
</div>
<div className="chat-auth-prompt">
<p>Sign in to GitHub Copilot to start chatting</p>
{deviceCode ? (
<div className="device-code-prompt">
<p>Enter this code at:</p>
<a href={deviceCode.verificationUri} target="_blank" rel="noopener noreferrer">
{deviceCode.verificationUri}
</a>
<div className="device-code">{deviceCode.userCode}</div>
</div>
) : (
<button className="chat-login-button" onClick={handleLogin}>
Sign in with GitHub
</button>
)}
</div>
</div>
);
}
return (
<div className="chat-list">
<div className="chat-list-header">
<span>AI ASSISTANT</span>
<button className="chat-new-button" onClick={handleNewChat} title="New Chat">
+
</button>
</div>
{authStatus.username && (
<div className="chat-user-info">
<span className="chat-user-icon">👤</span>
<span className="chat-username">{authStatus.username}</span>
</div>
)}
<div className="chat-list-items">
{conversations.length === 0 ? (
<div className="chat-empty">
<p>No conversations yet</p>
<button className="chat-start-button" onClick={handleNewChat}>
Start a new chat
</button>
</div>
) : (
conversations.map(conv => (
<div
key={conv.id}
className="chat-list-item"
onClick={() => handleOpenChat(conv.id)}
>
<div className="chat-item-content">
<div className="chat-item-title">{conv.title}</div>
<div className="chat-item-date">{formatChatDate(conv.updatedAt)}</div>
</div>
<button
className="chat-item-delete"
onClick={(e) => {
e.stopPropagation();
handleDeleteChat(conv.id);
}}
title="Delete conversation"
>
×
</button>
</div>
))
)}
</div>
</div>
);
};
export const Sidebar: React.FC = () => { export const Sidebar: React.FC = () => {
const { activeView, sidebarVisible } = useAppStore(); const { activeView, sidebarVisible } = useAppStore();
@@ -760,6 +965,7 @@ export const Sidebar: React.FC = () => {
{activeView === 'media' && <MediaList />} {activeView === 'media' && <MediaList />}
{activeView === 'settings' && <SettingsNav />} {activeView === 'settings' && <SettingsNav />}
{activeView === 'tags' && <TagsNav />} {activeView === 'tags' && <TagsNav />}
{activeView === 'chat' && <ChatList />}
</div> </div>
); );
}; };

View File

@@ -16,3 +16,4 @@ export { TagsView, scrollToTagsSection, type TagsCategory } from './TagsView';
export { TagInput } from './TagInput'; export { TagInput } from './TagInput';
export { PostLinks } from './PostLinks'; export { PostLinks } from './PostLinks';
export { ErrorModal, type ErrorDetails } from './ErrorModal'; export { ErrorModal, type ErrorDetails } from './ErrorModal';
export { ChatPanel } from './ChatPanel';

View File

@@ -5,7 +5,7 @@ import { persist } from 'zustand/middleware';
const STORAGE_KEY = 'bds-app-state'; const STORAGE_KEY = 'bds-app-state';
// Tab types // Tab types
export type TabType = 'post' | 'media' | 'settings' | 'tags'; export type TabType = 'post' | 'media' | 'settings' | 'tags' | 'chat';
export interface Tab { export interface Tab {
type: TabType; type: TabType;
@@ -88,7 +88,7 @@ interface AppState {
activeTabId: string | null; activeTabId: string | null;
// UI State // UI State
activeView: 'posts' | 'media' | 'settings' | 'tags'; activeView: 'posts' | 'media' | 'settings' | 'tags' | 'chat';
sidebarVisible: boolean; sidebarVisible: boolean;
panelVisible: boolean; panelVisible: boolean;
selectedPostId: string | null; selectedPostId: string | null;
@@ -136,7 +136,7 @@ interface AppState {
restoreTabState: (state: TabState) => void; restoreTabState: (state: TabState) => void;
// Actions // Actions
setActiveView: (view: 'posts' | 'media' | 'settings' | 'tags') => void; setActiveView: (view: 'posts' | 'media' | 'settings' | 'tags' | 'chat') => void;
toggleSidebar: () => void; toggleSidebar: () => void;
togglePanel: () => void; togglePanel: () => void;
setSelectedPost: (id: string | null) => void; setSelectedPost: (id: string | null) => void;

View File

@@ -170,6 +170,70 @@ export interface SyncTagsResult {
added: string[]; added: string[];
} }
// Chat/AI types
export interface ChatConversation {
id: string;
projectId: string;
title: string;
model?: string;
copilotSessionId?: string;
createdAt: string;
updatedAt: string;
}
export interface ChatMessage {
id: string;
conversationId: string;
role: 'user' | 'assistant' | 'system' | 'tool';
content: string;
toolCallId?: string;
toolCalls?: string;
createdAt: string;
}
export interface ChatModel {
id: string;
name: string;
}
export interface ChatAuthStatus {
authenticated: boolean;
username?: string;
}
export interface ChatReadyStatus {
ready: boolean;
authenticated: boolean;
}
export interface ChatStreamDelta {
conversationId: string;
delta: string;
}
export interface ChatToolCall {
conversationId: string;
toolCall: {
name: string;
arguments: Record<string, unknown>;
};
}
export interface ChatToolResult {
conversationId: string;
result: unknown;
}
export interface ChatTitleUpdate {
conversationId: string;
title: string;
}
export interface ChatDeviceCode {
verificationUri: string;
userCode: string;
}
export interface ElectronAPI { export interface ElectronAPI {
projects: { projects: {
create: (data: { name: string; description?: string; slug?: string }) => Promise<ProjectData>; create: (data: { name: string; description?: string; slug?: string }) => Promise<ProjectData>;
@@ -274,6 +338,40 @@ export interface ElectronAPI {
getPostsWithTag: (tagId: string) => Promise<string[]>; getPostsWithTag: (tagId: string) => Promise<string[]>;
syncFromPosts: () => Promise<SyncTagsResult>; syncFromPosts: () => Promise<SyncTagsResult>;
}; };
chat: {
// Authentication
checkReady: () => Promise<ChatReadyStatus>;
copilotAuthStatus: () => Promise<ChatAuthStatus>;
copilotLogin: () => Promise<{ success: boolean; error?: string; login?: string }>;
copilotLogout: () => Promise<void>;
// Settings
getAvailableModels: () => Promise<ChatModel[]>;
setDefaultModel: (modelId: string) => Promise<void>;
getSystemPrompt: () => Promise<string | null>;
setSystemPrompt: (prompt: string) => Promise<void>;
// Conversations
getConversations: () => Promise<ChatConversation[]>;
createConversation: (title?: string, model?: string) => Promise<ChatConversation>;
getConversation: (id: string) => Promise<ChatConversation | null>;
updateConversation: (id: string, updates: { title?: string; model?: string }) => Promise<ChatConversation | null>;
deleteConversation: (id: string) => Promise<boolean>;
// Messaging
sendMessage: (conversationId: string, message: string) => Promise<string>;
abortMessage: (conversationId: string) => Promise<void>;
getHistory: (conversationId: string) => Promise<ChatMessage[]>;
clearMessages: (conversationId: string) => Promise<void>;
setConversationModel: (conversationId: string, modelId: string) => Promise<void>;
// Event listeners for streaming/progress
onStreamDelta: (callback: (data: ChatStreamDelta) => void) => () => void;
onToolCall: (callback: (data: ChatToolCall) => void) => () => void;
onToolResult: (callback: (data: ChatToolResult) => void) => () => void;
onTitleUpdated: (callback: (data: ChatTitleUpdate) => void) => () => void;
onDeviceCode: (callback: (data: ChatDeviceCode) => void) => () => void;
};
on: (channel: string, callback: (...args: unknown[]) => void) => () => void; on: (channel: string, callback: (...args: unknown[]) => void) => () => void;
once: (channel: string, callback: (...args: unknown[]) => void) => void; once: (channel: string, callback: (...args: unknown[]) => void) => void;
} }