feat: ai chat added, login flow still broken
This commit is contained in:
@@ -410,6 +410,34 @@ export class DatabaseConnection {
|
||||
args: ['default', 'Default Project', 'default', 'Your first blog project', now, now, 1],
|
||||
});
|
||||
}
|
||||
|
||||
// Create chat_conversations table for AI chat persistence
|
||||
await this.localClient.execute(`
|
||||
CREATE TABLE IF NOT EXISTS chat_conversations (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
model TEXT,
|
||||
copilot_session_id TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)
|
||||
`);
|
||||
await this.localClient.execute('CREATE INDEX IF NOT EXISTS idx_chat_conversations_updated_at ON chat_conversations(updated_at)');
|
||||
|
||||
// Create chat_messages table for storing conversation messages
|
||||
await this.localClient.execute(`
|
||||
CREATE TABLE IF NOT EXISTS chat_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
conversation_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
content TEXT,
|
||||
tool_call_id TEXT,
|
||||
tool_calls TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (conversation_id) REFERENCES chat_conversations(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
await this.localClient.execute('CREATE INDEX IF NOT EXISTS idx_chat_messages_conversation_id ON chat_messages(conversation_id)');
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
|
||||
@@ -108,6 +108,27 @@ export const tags = sqliteTable('tags', {
|
||||
projectNameIdx: uniqueIndex('tags_project_name_idx').on(table.projectId, table.name),
|
||||
}));
|
||||
|
||||
// Chat conversations table - stores AI chat sessions
|
||||
export const chatConversations = sqliteTable('chat_conversations', {
|
||||
id: text('id').primaryKey(),
|
||||
title: text('title').notNull(),
|
||||
model: text('model'), // Model used for this conversation
|
||||
copilotSessionId: text('copilot_session_id'), // Copilot SDK session ID for resuming
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||
});
|
||||
|
||||
// Chat messages table - stores messages within conversations
|
||||
export const chatMessages = sqliteTable('chat_messages', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
conversationId: text('conversation_id').notNull(),
|
||||
role: text('role', { enum: ['system', 'user', 'assistant', 'tool'] }).notNull(),
|
||||
content: text('content'),
|
||||
toolCallId: text('tool_call_id'), // For tool responses
|
||||
toolCalls: text('tool_calls'), // JSON array of tool calls
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||
});
|
||||
|
||||
// Types for TypeScript
|
||||
export type Project = typeof projects.$inferSelect;
|
||||
export type NewProject = typeof projects.$inferInsert;
|
||||
@@ -123,3 +144,7 @@ export type PostLink = typeof postLinks.$inferSelect;
|
||||
export type NewPostLink = typeof postLinks.$inferInsert;
|
||||
export type Tag = typeof tags.$inferSelect;
|
||||
export type NewTag = typeof tags.$inferInsert;
|
||||
export type ChatConversation = typeof chatConversations.$inferSelect;
|
||||
export type NewChatConversation = typeof chatConversations.$inferInsert;
|
||||
export type ChatMessage = typeof chatMessages.$inferSelect;
|
||||
export type NewChatMessage = typeof chatMessages.$inferInsert;
|
||||
|
||||
382
src/main/engine/ChatEngine.ts
Normal file
382
src/main/engine/ChatEngine.ts
Normal 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],
|
||||
});
|
||||
}
|
||||
}
|
||||
986
src/main/engine/CopilotManager.ts
Normal file
986
src/main/engine/CopilotManager.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,4 +37,16 @@ export {
|
||||
type FileDownloadResult,
|
||||
type ConflictResolution,
|
||||
} from './DropboxSyncEngine';
|
||||
export {
|
||||
ChatEngine,
|
||||
type ChatConversationData,
|
||||
type ChatMessageData,
|
||||
type CreateConversationInput,
|
||||
} from './ChatEngine';
|
||||
export {
|
||||
CopilotManager,
|
||||
type SendMessageOptions,
|
||||
type SendMessageResult,
|
||||
} from './CopilotManager';
|
||||
|
||||
|
||||
|
||||
342
src/main/ipc/chatHandlers.ts
Normal file
342
src/main/ipc/chatHandlers.ts
Normal 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;
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
export { registerIpcHandlers } from './handlers';
|
||||
export { registerChatHandlers, initializeChatHandlers, cleanupChatHandlers } from './chatHandlers';
|
||||
|
||||
@@ -2,7 +2,7 @@ import { app, BrowserWindow, Menu, MenuItemConstructorOptions, ipcMain, protocol
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import { getDatabase } from './database';
|
||||
import { registerIpcHandlers } from './ipc';
|
||||
import { registerIpcHandlers, registerChatHandlers, initializeChatHandlers, cleanupChatHandlers } from './ipc';
|
||||
import { media } from './database/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
@@ -385,6 +385,10 @@ async function initialize(): Promise<void> {
|
||||
|
||||
// Register IPC handlers
|
||||
registerIpcHandlers();
|
||||
|
||||
// Initialize and register chat handlers
|
||||
initializeChatHandlers(() => mainWindow);
|
||||
registerChatHandlers();
|
||||
}
|
||||
|
||||
// App lifecycle
|
||||
@@ -406,6 +410,9 @@ app.on('window-all-closed', () => {
|
||||
});
|
||||
|
||||
app.on('before-quit', async () => {
|
||||
// Cleanup chat resources
|
||||
await cleanupChatHandlers();
|
||||
|
||||
const db = getDatabase();
|
||||
await db.close();
|
||||
});
|
||||
|
||||
@@ -129,6 +129,62 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
syncFromPosts: () => ipcRenderer.invoke('tags:syncFromPosts'),
|
||||
},
|
||||
|
||||
// AI Chat (Copilot SDK integration)
|
||||
chat: {
|
||||
// Authentication
|
||||
checkReady: () => ipcRenderer.invoke('chat:checkReady'),
|
||||
copilotAuthStatus: () => ipcRenderer.invoke('chat:copilotAuthStatus'),
|
||||
copilotLogin: () => ipcRenderer.invoke('chat:copilotLogin'),
|
||||
copilotLogout: () => ipcRenderer.invoke('chat:copilotLogout'),
|
||||
|
||||
// Settings
|
||||
getAvailableModels: () => ipcRenderer.invoke('chat:getAvailableModels'),
|
||||
setDefaultModel: (modelId: string) => ipcRenderer.invoke('chat:setDefaultModel', modelId),
|
||||
getSystemPrompt: () => ipcRenderer.invoke('chat:getSystemPrompt'),
|
||||
setSystemPrompt: (prompt: string) => ipcRenderer.invoke('chat:setSystemPrompt', prompt),
|
||||
|
||||
// Conversations
|
||||
getConversations: () => ipcRenderer.invoke('chat:getConversations'),
|
||||
createConversation: (title?: string, model?: string) => ipcRenderer.invoke('chat:createConversation', title, model),
|
||||
getConversation: (id: string) => ipcRenderer.invoke('chat:getConversation', id),
|
||||
updateConversation: (id: string, updates: { title?: string; model?: string }) => ipcRenderer.invoke('chat:updateConversation', id, updates),
|
||||
deleteConversation: (id: string) => ipcRenderer.invoke('chat:deleteConversation', id),
|
||||
|
||||
// Messaging
|
||||
sendMessage: (conversationId: string, message: string) => ipcRenderer.invoke('chat:sendMessage', conversationId, message),
|
||||
abortMessage: (conversationId: string) => ipcRenderer.invoke('chat:abortMessage', conversationId),
|
||||
getHistory: (conversationId: string) => ipcRenderer.invoke('chat:getHistory', conversationId),
|
||||
clearMessages: (conversationId: string) => ipcRenderer.invoke('chat:clearMessages', conversationId),
|
||||
setConversationModel: (conversationId: string, modelId: string) => ipcRenderer.invoke('chat:setConversationModel', conversationId, modelId),
|
||||
|
||||
// Event listeners for streaming/progress
|
||||
onStreamDelta: (callback: (data: { conversationId: string; delta: string }) => void) => {
|
||||
const subscription = (_event: Electron.IpcRendererEvent, data: { conversationId: string; delta: string }) => callback(data);
|
||||
ipcRenderer.on('chat-stream-delta', subscription);
|
||||
return () => ipcRenderer.removeListener('chat-stream-delta', subscription);
|
||||
},
|
||||
onToolCall: (callback: (data: { conversationId: string; toolCall: unknown }) => void) => {
|
||||
const subscription = (_event: Electron.IpcRendererEvent, data: { conversationId: string; toolCall: unknown }) => callback(data);
|
||||
ipcRenderer.on('chat-tool-call', subscription);
|
||||
return () => ipcRenderer.removeListener('chat-tool-call', subscription);
|
||||
},
|
||||
onToolResult: (callback: (data: { conversationId: string; result: unknown }) => void) => {
|
||||
const subscription = (_event: Electron.IpcRendererEvent, data: { conversationId: string; result: unknown }) => callback(data);
|
||||
ipcRenderer.on('chat-tool-result', subscription);
|
||||
return () => ipcRenderer.removeListener('chat-tool-result', subscription);
|
||||
},
|
||||
onTitleUpdated: (callback: (data: { conversationId: string; title: string }) => void) => {
|
||||
const subscription = (_event: Electron.IpcRendererEvent, data: { conversationId: string; title: string }) => callback(data);
|
||||
ipcRenderer.on('chat-title-updated', subscription);
|
||||
return () => ipcRenderer.removeListener('chat-title-updated', subscription);
|
||||
},
|
||||
onDeviceCode: (callback: (data: { verificationUri: string; userCode: string }) => void) => {
|
||||
const subscription = (_event: Electron.IpcRendererEvent, data: { verificationUri: string; userCode: string }) => callback(data);
|
||||
ipcRenderer.on('copilot-device-code', subscription);
|
||||
return () => ipcRenderer.removeListener('copilot-device-code', subscription);
|
||||
},
|
||||
},
|
||||
|
||||
// Event listeners
|
||||
on: (channel: string, callback: (...args: unknown[]) => void) => {
|
||||
const subscription = (_event: Electron.IpcRendererEvent, ...args: unknown[]) => callback(...args);
|
||||
@@ -225,6 +281,53 @@ export interface ElectronAPI {
|
||||
removeCategory: (category: string) => Promise<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;
|
||||
once: (channel: string, callback: (...args: unknown[]) => void) => void;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user