feat: switched to opencode

This commit is contained in:
2026-02-11 19:07:06 +01:00
parent 870bec4dcd
commit 49f2b620db
15 changed files with 1343 additions and 1351 deletions

View File

@@ -113,7 +113,7 @@ 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
copilotSessionId: text('copilot_session_id'), // Legacy, no longer used
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
});

View File

@@ -14,7 +14,6 @@ export interface ChatConversationData {
id: string;
title: string;
model?: string;
copilotSessionId?: string;
createdAt: Date;
updatedAt: Date;
}
@@ -53,7 +52,7 @@ export class ChatEngine {
const id = `chat_${uuidv4()}`;
const title = input.title || 'New Chat';
const model = input.model || 'gpt-4.1';
const model = input.model || 'claude-sonnet-4';
const now = Date.now();
await client.execute({
@@ -103,7 +102,6 @@ export class ChatEngine {
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),
};
@@ -144,7 +142,6 @@ export class ChatEngine {
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),
}));
@@ -153,7 +150,7 @@ export class ChatEngine {
/**
* Update a conversation's metadata
*/
async updateConversation(id: string, updates: Partial<Pick<ChatConversationData, 'title' | 'model' | 'copilotSessionId'>>): Promise<void> {
async updateConversation(id: string, updates: Partial<Pick<ChatConversationData, 'title' | 'model'>>): Promise<void> {
const client = this.db.getLocalClient();
if (!client) {
throw new Error('Database not initialized');
@@ -170,10 +167,6 @@ export class ChatEngine {
setClauses.push('model = ?');
args.push(updates.model);
}
if (updates.copilotSessionId !== undefined) {
setClauses.push('copilot_session_id = ?');
args.push(updates.copilotSessionId);
}
args.push(id);
@@ -332,24 +325,62 @@ 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
- List and filter posts by status, category, or tags
- View information about media files (images)
- Update metadata for posts and media files
- List all tags with post counts
- List all categories with post counts
When answering questions about the user's blog content:
1. Use the search tool to find relevant posts
1. Use the search or list tools to find relevant posts
2. Read specific posts to get detailed content
3. Provide helpful summaries and suggestions
3. Use list_tags and list_categories to understand the taxonomy
4. Provide helpful summaries and suggestions
Be concise but thorough in your responses. When displaying post information, format it clearly.`;
}
/**
* Get a setting by key
*/
async getSetting(key: string): Promise<string | null> {
const client = this.db.getLocalClient();
if (!client) return null;
const result = await client.execute({
sql: `SELECT value FROM settings WHERE key = ?`,
args: [key],
});
if (result.rows.length > 0) {
return result.rows[0].value as string;
}
return null;
}
/**
* Set a setting by key
*/
async setSetting(key: string, value: 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: [key, value, now],
});
}
/**
* Get selected model for new conversations
*/
async getSelectedModel(): Promise<string> {
const client = this.db.getLocalClient();
if (!client) {
return 'gpt-4.1';
return 'claude-sonnet-4';
}
const result = await client.execute({
@@ -361,7 +392,7 @@ Be concise but thorough in your responses. When displaying post information, for
return result.rows[0].value as string;
}
return 'gpt-4.1';
return 'claude-sonnet-4';
}
/**

View File

@@ -1,986 +0,0 @@
/**
* 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;
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -44,9 +44,10 @@ export {
type CreateConversationInput,
} from './ChatEngine';
export {
CopilotManager,
OpenCodeManager,
type SendMessageOptions,
type SendMessageResult,
} from './CopilotManager';
type ModelInfo,
} from './OpenCodeManager';

View File

@@ -1,16 +1,16 @@
/**
* Chat IPC handlers - AI chat functionality using GitHub Copilot SDK
* Chat IPC handlers - AI chat functionality using OpenCode Zen API
*/
import { ipcMain, BrowserWindow } from 'electron';
import { ChatEngine } from '../engine/ChatEngine';
import { CopilotManager } from '../engine/CopilotManager';
import { OpenCodeManager } from '../engine/OpenCodeManager';
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 openCodeManager: OpenCodeManager | null = null;
let mainWindowGetter: (() => BrowserWindow | null) | null = null;
/**
@@ -31,35 +31,43 @@ function getChatEngine(): ChatEngine {
}
/**
* Get or create the CopilotManager instance
* Get or create the OpenCodeManager instance
*/
function getCopilotManager(): CopilotManager {
if (!copilotManager) {
copilotManager = new CopilotManager(
function getOpenCodeManager(): OpenCodeManager {
if (!openCodeManager) {
openCodeManager = new OpenCodeManager(
getChatEngine(),
getPostEngine(),
getMediaEngine(),
() => mainWindowGetter?.() || null
);
// Load API key from settings
const engine = getChatEngine();
engine.getSetting('opencode_api_key').then(key => {
if (key) {
openCodeManager!.setApiKey(key);
}
}).catch(() => {});
}
return copilotManager;
return openCodeManager;
}
/**
* Register all chat-related IPC handlers
*/
export function registerChatHandlers(): void {
// ============ Copilot Authentication & Status ============
// ============ API Key & Status ============
// Check if Copilot is ready
// Check if service is ready
ipcMain.handle('chat:checkReady', async () => {
try {
const manager = getCopilotManager();
const manager = getOpenCodeManager();
const result = await manager.checkReady();
return {
ready: result.ready,
error: result.error,
backend: result.ready ? 'copilot' : undefined,
backend: 'opencode',
};
} catch (error) {
console.error('[Chat IPC] Error checking ready:', error);
@@ -67,61 +75,47 @@ export function registerChatHandlers(): void {
}
});
// Get Copilot authentication status
ipcMain.handle('chat:copilotAuthStatus', async () => {
// Validate API key
ipcMain.handle('chat:validateApiKey', async (_, apiKey: string) => {
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);
const manager = getOpenCodeManager();
const result = await manager.validateApiKey(apiKey);
return result;
} catch (error) {
console.error('[Chat IPC] Error during login:', error);
console.error('[Chat IPC] Error validating API key:', error);
return { isValid: false, models: [] };
}
});
// Set API key
ipcMain.handle('chat:setApiKey', async (_, apiKey: string) => {
try {
const manager = getOpenCodeManager();
manager.setApiKey(apiKey);
// Persist to settings
const engine = getChatEngine();
await engine.setSetting('opencode_api_key', apiKey);
return { success: true };
} catch (error) {
console.error('[Chat IPC] Error setting API key:', error);
return { success: false, error: (error as Error).message };
}
});
// Logout from Copilot
ipcMain.handle('chat:copilotLogout', async () => {
// Get API key (masked)
ipcMain.handle('chat:getApiKey', async () => {
try {
const manager = getCopilotManager();
return await manager.logout();
const manager = getOpenCodeManager();
const key = manager.getApiKey();
if (!key) return { hasKey: false, maskedKey: '' };
// Mask all but last 4 characters
const masked = '•'.repeat(Math.max(0, key.length - 4)) + key.slice(-4);
return { hasKey: true, maskedKey: masked };
} catch (error) {
console.error('[Chat IPC] Error during logout:', error);
return { success: false, error: (error as Error).message };
console.error('[Chat IPC] Error getting API key:', error);
return { hasKey: false, maskedKey: '' };
}
});
@@ -130,7 +124,7 @@ export function registerChatHandlers(): void {
// Get available models
ipcMain.handle('chat:getAvailableModels', async () => {
try {
const manager = getCopilotManager();
const manager = getOpenCodeManager();
const models = await manager.getAvailableModels();
const engine = getChatEngine();
const selectedModel = await engine.getSelectedModel();
@@ -238,11 +232,6 @@ export function registerChatHandlers(): void {
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);
@@ -255,7 +244,7 @@ export function registerChatHandlers(): void {
// Send a message
ipcMain.handle('chat:sendMessage', async (_, conversationId: string, message: string) => {
try {
const manager = getCopilotManager();
const manager = getOpenCodeManager();
const mainWindow = mainWindowGetter?.();
const result = await manager.sendMessage(conversationId, message, {
@@ -286,7 +275,7 @@ export function registerChatHandlers(): void {
// Abort a running message
ipcMain.handle('chat:abortMessage', async (_, conversationId: string) => {
try {
const manager = getCopilotManager();
const manager = getOpenCodeManager();
return await manager.abortMessage(conversationId);
} catch (error) {
console.error('[Chat IPC] Error aborting message:', error);
@@ -334,9 +323,9 @@ export function registerChatHandlers(): void {
* Cleanup chat resources
*/
export async function cleanupChatHandlers(): Promise<void> {
if (copilotManager) {
await copilotManager.stop();
copilotManager = null;
if (openCodeManager) {
await openCodeManager.stop();
openCodeManager = null;
}
chatEngine = null;
}

View File

@@ -129,34 +129,34 @@ contextBridge.exposeInMainWorld('electronAPI', {
syncFromPosts: () => ipcRenderer.invoke('tags:syncFromPosts'),
},
// AI Chat (Copilot SDK integration)
// AI Chat (OpenCode Zen API integration)
chat: {
// Authentication
// API Key Management
checkReady: () => ipcRenderer.invoke('chat:checkReady'),
copilotAuthStatus: () => ipcRenderer.invoke('chat:copilotAuthStatus'),
copilotLogin: () => ipcRenderer.invoke('chat:copilotLogin'),
copilotLogout: () => ipcRenderer.invoke('chat:copilotLogout'),
validateApiKey: (apiKey: string) => ipcRenderer.invoke('chat:validateApiKey', apiKey),
setApiKey: (apiKey: string) => ipcRenderer.invoke('chat:setApiKey', apiKey),
getApiKey: () => ipcRenderer.invoke('chat:getApiKey'),
// 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);
@@ -178,11 +178,6 @@ contextBridge.exposeInMainWorld('electronAPI', {
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
@@ -295,38 +290,37 @@ export interface ElectronAPI {
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>;
// API Key Management
checkReady: () => Promise<{ ready: boolean; error?: string; backend?: string }>;
validateApiKey: (apiKey: string) => Promise<{ isValid: boolean; models: Array<{ id: string; name: string }> }>;
setApiKey: (apiKey: string) => Promise<{ success: boolean; error?: string }>;
getApiKey: () => Promise<{ hasKey: boolean; maskedKey: string }>;
// 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;