From 49f2b620dbcdb538dcbba05c3477a6b4bbba1eb7 Mon Sep 17 00:00:00 2001 From: hugo Date: Wed, 11 Feb 2026 19:07:06 +0100 Subject: [PATCH] feat: switched to opencode --- .claude/settings.local.json | 3 +- VISION.md | 6 +- package-lock.json | 146 --- package.json | 1 - src/main/database/schema.ts | 2 +- src/main/engine/ChatEngine.ts | 57 +- src/main/engine/CopilotManager.ts | 986 ---------------- src/main/engine/OpenCodeManager.ts | 1035 +++++++++++++++++ src/main/engine/index.ts | 5 +- src/main/ipc/chatHandlers.ts | 127 +- src/main/preload.ts | 42 +- .../components/ChatPanel/ChatPanel.css | 51 + .../components/ChatPanel/ChatPanel.tsx | 118 +- src/renderer/components/Sidebar/Sidebar.tsx | 77 +- src/renderer/types/electron.d.ts | 38 +- 15 files changed, 1343 insertions(+), 1351 deletions(-) delete mode 100644 src/main/engine/CopilotManager.ts create mode 100644 src/main/engine/OpenCodeManager.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json index bbf2e31..e63406b 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,7 +1,8 @@ { "permissions": { "allow": [ - "Bash(npm run build:*)" + "Bash(npm run build:*)", + "Bash(npx tsc:*)" ] } } diff --git a/VISION.md b/VISION.md index a8e5d13..9f63da3 100644 --- a/VISION.md +++ b/VISION.md @@ -210,8 +210,8 @@ We should use UpdraftPlus for backups and loading data into the system from thos have full data available from the site, including all meta data and uploads. Additionally we need another importer to traverse a full website and deduct post structure from that website -and rebuild posts in the database based on such a web traversal. To be able to do that, use copilot SDK -to integrate copilot directly, so that HTML pages can be directly inspected and turned into actual blog +and rebuild posts in the database based on such a web traversal. To be able to do that, use the OpenCode Zen +AI integration so that HTML pages can be directly inspected and turned into actual blog posts in proper structure and proper markdown, despite the source being HTML. This is a variant of the wordpress importer that directly works on already rendered HTML websites. The importer should only stay within the actual site it was handled, not following any off-site links. @@ -221,7 +221,7 @@ embedded HTML, for as much as possible. We want clean markdown in the posts afte of markdown and HTML. For this AI support during import to work, the blog application needs to provide post management and media -management functionality as proper SDK tools to the copilot instance, so that it will be able to work +management functionality as proper AI tools to the OpenCode Zen API, so that it will be able to work on those posts. The AI importing agent must discover the language of a post and put that in an attribute. Posts must have diff --git a/package-lock.json b/package-lock.json index ba6331f..8d9acd4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,6 @@ "license": "MIT", "dependencies": { "@floating-ui/dom": "^1.7.5", - "@github/copilot-sdk": "^0.1.23", "@libsql/client": "^0.4.0", "@milkdown/kit": "^7.18.0", "@milkdown/plugin-block": "^7.18.0", @@ -2263,142 +2262,6 @@ "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", "license": "MIT" }, - "node_modules/@github/copilot": { - "version": "0.0.403", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-0.0.403.tgz", - "integrity": "sha512-v5jUdtGJReLmE1rmff/LZf+50nzmYQYAaSRNtVNr9g0j0GkCd/noQExe31i1+PudvWU0ZJjltR0B8pUfDRdA9Q==", - "license": "SEE LICENSE IN LICENSE.md", - "bin": { - "copilot": "npm-loader.js" - }, - "optionalDependencies": { - "@github/copilot-darwin-arm64": "0.0.403", - "@github/copilot-darwin-x64": "0.0.403", - "@github/copilot-linux-arm64": "0.0.403", - "@github/copilot-linux-x64": "0.0.403", - "@github/copilot-win32-arm64": "0.0.403", - "@github/copilot-win32-x64": "0.0.403" - } - }, - "node_modules/@github/copilot-darwin-arm64": { - "version": "0.0.403", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-0.0.403.tgz", - "integrity": "sha512-dOw8IleA0d1soHnbr/6wc6vZiYWNTKMgfTe/NET1nCfMzyKDt/0F0I7PT5y+DLujJknTla/ZeEmmBUmliTW4Cg==", - "cpu": [ - "arm64" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "darwin" - ], - "bin": { - "copilot-darwin-arm64": "copilot" - } - }, - "node_modules/@github/copilot-darwin-x64": { - "version": "0.0.403", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-0.0.403.tgz", - "integrity": "sha512-aK2jSNWgY8eiZ+TmrvGhssMCPDTKArc0ip6Ul5OaslpytKks8hyXoRbxGD0N9sKioSUSbvKUf+1AqavbDpJO+w==", - "cpu": [ - "x64" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "darwin" - ], - "bin": { - "copilot-darwin-x64": "copilot" - } - }, - "node_modules/@github/copilot-linux-arm64": { - "version": "0.0.403", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-0.0.403.tgz", - "integrity": "sha512-KhoR2iR70O6vCkzf0h8/K+p82qAgOvMTgAPm9bVEHvbdGFR7Py9qL5v03bMbPxsA45oNaZAkzDhfTAqWhIAZsQ==", - "cpu": [ - "arm64" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "linux" - ], - "bin": { - "copilot-linux-arm64": "copilot" - } - }, - "node_modules/@github/copilot-linux-x64": { - "version": "0.0.403", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-0.0.403.tgz", - "integrity": "sha512-eoswUc9vo4TB+/9PgFJLVtzI4dPjkpJXdCsAioVuoqPdNxHxlIHFe9HaVcqMRZxUNY1YHEBZozy+IpUEGjgdfQ==", - "cpu": [ - "x64" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "linux" - ], - "bin": { - "copilot-linux-x64": "copilot" - } - }, - "node_modules/@github/copilot-sdk": { - "version": "0.1.23", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-0.1.23.tgz", - "integrity": "sha512-0by81bsBQlDKE5VbcegZfUMvPyPm1aXwSGS2rGaMAFxv3ps+dACf1Voruxik7hQTae0ziVFJjuVrlxZoRaXBLw==", - "license": "MIT", - "dependencies": { - "@github/copilot": "^0.0.403", - "vscode-jsonrpc": "^8.2.1", - "zod": "^4.3.6" - }, - "engines": { - "node": ">=24.0.0" - } - }, - "node_modules/@github/copilot-sdk/node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, - "node_modules/@github/copilot-win32-arm64": { - "version": "0.0.403", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-0.0.403.tgz", - "integrity": "sha512-djWjzCsp2xPNafMyOZ/ivU328/WvWhdroGie/DugiJBTgQL2SP0quWW1fhTlDwE81a3g9CxfJonaRgOpFTJTcg==", - "cpu": [ - "arm64" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "win32" - ], - "bin": { - "copilot-win32-arm64": "copilot.exe" - } - }, - "node_modules/@github/copilot-win32-x64": { - "version": "0.0.403", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-0.0.403.tgz", - "integrity": "sha512-lju8cHy2E6Ux7R7tWyLZeksYC2MVZu9i9ocjiBX/qfG2/pNJs7S5OlkwKJ0BSXSbZEHQYq7iHfEWp201bVfk9A==", - "cpu": [ - "x64" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "win32" - ], - "bin": { - "copilot-win32-x64": "copilot.exe" - } - }, "node_modules/@hapi/address": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/@hapi/address/-/address-5.1.1.tgz", @@ -14687,15 +14550,6 @@ } } }, - "node_modules/vscode-jsonrpc": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.1.tgz", - "integrity": "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ==", - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/vue": { "version": "3.5.28", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.28.tgz", diff --git a/package.json b/package.json index 8b70b97..dc2c543 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,6 @@ }, "dependencies": { "@floating-ui/dom": "^1.7.5", - "@github/copilot-sdk": "^0.1.23", "@libsql/client": "^0.4.0", "@milkdown/kit": "^7.18.0", "@milkdown/plugin-block": "^7.18.0", diff --git a/src/main/database/schema.ts b/src/main/database/schema.ts index 526003e..39e4ad9 100644 --- a/src/main/database/schema.ts +++ b/src/main/database/schema.ts @@ -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(), }); diff --git a/src/main/engine/ChatEngine.ts b/src/main/engine/ChatEngine.ts index 46134cb..79eb05d 100644 --- a/src/main/engine/ChatEngine.ts +++ b/src/main/engine/ChatEngine.ts @@ -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>): Promise { + async updateConversation(id: string, updates: Partial>): Promise { 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 { + 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 { + 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 { 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'; } /** diff --git a/src/main/engine/CopilotManager.ts b/src/main/engine/CopilotManager.ts deleted file mode 100644 index 6d2ad21..0000000 --- a/src/main/engine/CopilotManager.ts +++ /dev/null @@ -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; - -/** - * Load ESM modules dynamically (required for CommonJS compatibility) - */ -async function loadModules(): Promise { - if (modulesLoaded) return true; - - try { - // Use dynamicImport to bypass TypeScript's transformation of import() to require() - const copilotSdk = await dynamicImport('@github/copilot-sdk'); - CopilotClient = copilotSdk.CopilotClient; - defineTool = copilotSdk.defineTool; - - const zod = await dynamicImport('zod'); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - z = (zod as any).z || (zod as any).default?.z || zod; - - modulesLoaded = true; - console.log('[CopilotManager] ESM modules loaded successfully'); - return true; - } catch (error) { - console.error('[CopilotManager] Failed to load ESM modules:', error); - return false; - } -} - -interface CopilotSession { - sessionId: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - session: any; - conversationId: string; - isResumed?: boolean; - model?: string; -} - -export interface SendMessageOptions { - onDelta?: (delta: string) => void; - onToolCall?: (toolCall: { name: string; args: unknown }) => void; - onToolResult?: (result: { name: string; result: unknown }) => void; -} - -export interface SendMessageResult { - success: boolean; - message?: string; - error?: string; - toolCalls?: Array<{ name: string; args: unknown }>; -} - -export class CopilotManager { - private chatEngine: ChatEngine; - private postEngine: PostEngine; - private mediaEngine: MediaEngine; - private getMainWindow: () => BrowserWindow | null; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private client: any = null; - private sessions: Map = new Map(); - private isInitialized = false; - private initError: string | null = null; - private initPromise: Promise<{ success: boolean; error?: string }> | null = null; - private isStopping = false; - - constructor( - chatEngine: ChatEngine, - postEngine: PostEngine, - mediaEngine: MediaEngine, - getMainWindow: () => BrowserWindow | null - ) { - this.chatEngine = chatEngine; - this.postEngine = postEngine; - this.mediaEngine = mediaEngine; - this.getMainWindow = getMainWindow; - } - - /** - * Get tools for the Copilot SDK - */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private getToolsForCopilot(): any[] { - if (!modulesLoaded || !defineTool || !z) { - console.warn('[CopilotManager] Modules not loaded, returning empty tools list'); - return []; - } - - const tools = [ - // Search posts with full-text search and filters - defineTool('search_posts', { - description: 'Search blog posts using full-text search. Can filter by category or tags. Returns matching posts with their metadata.', - parameters: z.object({ - query: z.string().describe('The search query text to find in posts'), - category: z.string().optional().describe('Optional category to filter by (e.g., "article", "picture", "aside", "page")'), - tags: z.array(z.string()).optional().describe('Optional array of tags to filter by'), - limit: z.number().optional().describe('Maximum number of results to return (default: 10)'), - }), - handler: async (args: { query: string; category?: string; tags?: string[]; limit?: number }) => { - try { - // Get search results (returns id, title, slug, excerpt) - const searchResults = await this.postEngine.searchPosts(args.query); - - // Fetch full post data for filtering - const fullPosts = await Promise.all( - searchResults.map(sr => this.postEngine.getPost(sr.id)) - ); - - // Filter by category and tags - let filteredPosts = fullPosts.filter(p => p !== null); - - if (args.category) { - filteredPosts = filteredPosts.filter(p => p!.categories.includes(args.category!)); - } - - if (args.tags && args.tags.length > 0) { - filteredPosts = filteredPosts.filter(p => - args.tags!.every(tag => p!.tags.includes(tag)) - ); - } - - // Apply limit - const limit = args.limit || 10; - filteredPosts = filteredPosts.slice(0, limit); - - return { - success: true, - count: filteredPosts.length, - posts: filteredPosts.map(p => ({ - id: p!.id, - title: p!.title, - slug: p!.slug, - excerpt: p!.excerpt, - status: p!.status, - categories: p!.categories, - tags: p!.tags, - createdAt: p!.createdAt, - updatedAt: p!.updatedAt, - })), - }; - } catch (error) { - return { success: false, error: (error as Error).message }; - } - }, - }), - - // Read a single post - defineTool('read_post', { - description: 'Read the full content and metadata of a specific blog post by its ID.', - parameters: z.object({ - postId: z.string().describe('The unique ID of the post to read'), - }), - handler: async (args: { postId: string }) => { - try { - const post = await this.postEngine.getPost(args.postId); - if (!post) { - return { success: false, error: 'Post not found' }; - } - return { - success: true, - post: { - id: post.id, - title: post.title, - slug: post.slug, - content: post.content, - excerpt: post.excerpt, - status: post.status, - author: post.author, - categories: post.categories, - tags: post.tags, - createdAt: post.createdAt, - updatedAt: post.updatedAt, - publishedAt: post.publishedAt, - }, - }; - } catch (error) { - return { success: false, error: (error as Error).message }; - } - }, - }), - - // Get media file information - defineTool('get_media', { - description: 'Get information about a specific media file (image) by its ID.', - parameters: z.object({ - mediaId: z.string().describe('The unique ID of the media file'), - }), - handler: async (args: { mediaId: string }) => { - try { - const media = await this.mediaEngine.getMedia(args.mediaId); - if (!media) { - return { success: false, error: 'Media not found' }; - } - return { - success: true, - media: { - id: media.id, - filename: media.filename, - originalName: media.originalName, - mimeType: media.mimeType, - size: media.size, - width: media.width, - height: media.height, - alt: media.alt, - caption: media.caption, - tags: media.tags, - createdAt: media.createdAt, - updatedAt: media.updatedAt, - }, - }; - } catch (error) { - return { success: false, error: (error as Error).message }; - } - }, - }), - - // List all media files - defineTool('list_media', { - description: 'List all media files in the current project with optional filtering.', - parameters: z.object({ - mimeTypeFilter: z.string().optional().describe('Filter by MIME type prefix (e.g., "image/")'), - limit: z.number().optional().describe('Maximum number of results (default: 20)'), - }), - handler: async (args: { mimeTypeFilter?: string; limit?: number }) => { - try { - let mediaList = await this.mediaEngine.getAllMedia(); - - if (args.mimeTypeFilter) { - mediaList = mediaList.filter(m => m.mimeType.startsWith(args.mimeTypeFilter!)); - } - - const limit = args.limit || 20; - mediaList = mediaList.slice(0, limit); - - return { - success: true, - count: mediaList.length, - media: mediaList.map(m => ({ - id: m.id, - filename: m.filename, - originalName: m.originalName, - mimeType: m.mimeType, - alt: m.alt, - tags: m.tags, - })), - }; - } catch (error) { - return { success: false, error: (error as Error).message }; - } - }, - }), - - // Update post metadata - defineTool('update_post_metadata', { - description: 'Update metadata for a blog post (title, excerpt, tags, categories). Does NOT update post content.', - parameters: z.object({ - postId: z.string().describe('The unique ID of the post to update'), - title: z.string().optional().describe('New title for the post'), - excerpt: z.string().optional().describe('New excerpt/summary for the post'), - tags: z.array(z.string()).optional().describe('New tags for the post'), - categories: z.array(z.string()).optional().describe('New categories for the post'), - }), - handler: async (args: { postId: string; title?: string; excerpt?: string; tags?: string[]; categories?: string[] }) => { - try { - const updates: Record = {}; - if (args.title !== undefined) updates.title = args.title; - if (args.excerpt !== undefined) updates.excerpt = args.excerpt; - if (args.tags !== undefined) updates.tags = args.tags; - if (args.categories !== undefined) updates.categories = args.categories; - - if (Object.keys(updates).length === 0) { - return { success: false, error: 'No updates provided' }; - } - - await this.postEngine.updatePost(args.postId, updates); - return { success: true, message: `Post ${args.postId} metadata updated successfully` }; - } catch (error) { - return { success: false, error: (error as Error).message }; - } - }, - }), - - // Update media metadata - defineTool('update_media_metadata', { - description: 'Update metadata for a media file (alt text, caption, tags).', - parameters: z.object({ - mediaId: z.string().describe('The unique ID of the media to update'), - alt: z.string().optional().describe('New alt text for the image'), - caption: z.string().optional().describe('New caption for the image'), - tags: z.array(z.string()).optional().describe('New tags for the media'), - }), - handler: async (args: { mediaId: string; alt?: string; caption?: string; tags?: string[] }) => { - try { - const updates: Record = {}; - if (args.alt !== undefined) updates.alt = args.alt; - if (args.caption !== undefined) updates.caption = args.caption; - if (args.tags !== undefined) updates.tags = args.tags; - - if (Object.keys(updates).length === 0) { - return { success: false, error: 'No updates provided' }; - } - - await this.mediaEngine.updateMedia(args.mediaId, updates); - return { success: true, message: `Media ${args.mediaId} metadata updated successfully` }; - } catch (error) { - return { success: false, error: (error as Error).message }; - } - }, - }), - - // List posts with filtering - defineTool('list_posts', { - description: 'List blog posts with optional filtering by status, category, or tags.', - parameters: z.object({ - status: z.enum(['draft', 'published', 'archived']).optional().describe('Filter by post status'), - category: z.string().optional().describe('Filter by category'), - tags: z.array(z.string()).optional().describe('Filter by tags (posts must have all specified tags)'), - limit: z.number().optional().describe('Maximum number of results (default: 20)'), - offset: z.number().optional().describe('Offset for pagination (default: 0)'), - }), - handler: async (args: { status?: 'draft' | 'published' | 'archived'; category?: string; tags?: string[]; limit?: number; offset?: number }) => { - try { - // Use getPostsFiltered for filtering - const filter: { status?: 'draft' | 'published' | 'archived'; tags?: string[]; categories?: string[] } = {}; - if (args.status) filter.status = args.status; - if (args.tags) filter.tags = args.tags; - if (args.category) filter.categories = [args.category]; - - let posts; - if (Object.keys(filter).length > 0) { - posts = await this.postEngine.getPostsFiltered(filter); - } else { - const result = await this.postEngine.getAllPosts({ - limit: args.limit || 20, - offset: args.offset || 0, - }); - posts = result.items; - } - - // Apply offset/limit for filtered results - const offset = args.offset || 0; - const limit = args.limit || 20; - const slicedPosts = posts.slice(offset, offset + limit); - - return { - success: true, - count: slicedPosts.length, - total: posts.length, - hasMore: offset + limit < posts.length, - posts: slicedPosts.map(p => ({ - id: p.id, - title: p.title, - slug: p.slug, - status: p.status, - categories: p.categories, - tags: p.tags, - createdAt: p.createdAt, - updatedAt: p.updatedAt, - })), - }; - } catch (error) { - return { success: false, error: (error as Error).message }; - } - }, - }), - ]; - - return tools; - } - - /** - * Initialize the Copilot client - * Uses a lock to prevent concurrent initialization attempts - */ - async initialize(): Promise<{ success: boolean; error?: string }> { - // Already initialized - if (this.isInitialized && this.client) { - return { success: true }; - } - - // If stopping, don't initialize - if (this.isStopping) { - return { success: false, error: 'Client is stopping' }; - } - - // If already initializing, wait for that to complete - if (this.initPromise) { - return this.initPromise; - } - - // Start initialization - this.initPromise = this.doInitialize(); - const result = await this.initPromise; - this.initPromise = null; - return result; - } - - private async doInitialize(): Promise<{ success: boolean; error?: string }> { - try { - console.log('[CopilotManager] Initializing Copilot client...'); - - const loaded = await loadModules(); - if (!loaded) { - return { success: false, error: 'Failed to load Copilot SDK modules' }; - } - - // Check again in case stop was called during module loading - if (this.isStopping) { - return { success: false, error: 'Client is stopping' }; - } - - console.log('[CopilotManager] Creating CopilotClient instance...'); - this.client = new CopilotClient({ - autoStart: true, - autoRestart: true, - useLoggedInUser: true, - logLevel: 'info', - }); - - console.log('[CopilotManager] Starting client...'); - await this.client.start(); - console.log('[CopilotManager] Client started successfully'); - - this.isInitialized = true; - this.initError = null; - - console.log('[CopilotManager] Copilot client initialized successfully'); - return { success: true }; - } catch (error) { - console.error('[CopilotManager] Failed to initialize Copilot client:', error); - this.initError = (error as Error).message; - this.isInitialized = false; - return { success: false, error: (error as Error).message }; - } - } - - /** - * Check if Copilot is ready - */ - async checkReady(): Promise<{ ready: boolean; error?: string; requiresCLI?: boolean }> { - try { - if (!this.isInitialized) { - const result = await this.initialize(); - if (!result.success) { - const isCLIError = - result.error?.includes('copilot') || - result.error?.includes('ENOENT') || - result.error?.includes('spawn'); - - return { - ready: false, - error: result.error, - requiresCLI: isCLIError, - }; - } - } - - await this.client.ping(); - return { ready: true }; - } catch (error) { - console.error('[CopilotManager] Ready check failed:', error); - return { - ready: false, - error: (error as Error).message, - requiresCLI: - (error as Error).message?.includes('copilot') || - (error as Error).message?.includes('ENOENT'), - }; - } - } - - /** - * Get or create a session for a conversation - */ - private async getOrCreateSession( - conversationId: string, - options: { model?: string; systemMessage?: string; copilotSessionId?: string; forceNewSession?: boolean } = {} - ): Promise { - console.log(`[CopilotManager] getOrCreateSession called with model: ${options.model || 'default (gpt-4.1)'}`); - - if (this.sessions.has(conversationId)) { - const existingSession = this.sessions.get(conversationId)!; - if (options.model && existingSession.model !== options.model) { - console.log(`[CopilotManager] Model changed, creating new session`); - this.sessions.delete(conversationId); - } else { - return existingSession; - } - } - - if (!this.isInitialized) { - await this.initialize(); - } - - const tools = this.getToolsForCopilot(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let session: any; - let isResumed = false; - - // Try to resume existing session - if (options.copilotSessionId && !options.forceNewSession) { - try { - console.log(`[CopilotManager] Resuming session: ${options.copilotSessionId}`); - session = await this.client.resumeSession(options.copilotSessionId, { - streaming: true, - tools, - }); - isResumed = true; - console.log(`[CopilotManager] Session resumed successfully`); - } catch (error) { - console.log(`[CopilotManager] Failed to resume session, creating new: ${(error as Error).message}`); - session = null; - } - } - - // Create new session if needed - if (!session) { - const modelToUse = options.model || 'gpt-4.1'; - console.log(`[CopilotManager] Creating new session for conversation: ${conversationId} with model: ${modelToUse}`); - session = await this.client.createSession({ - model: modelToUse, - streaming: true, - tools, - systemMessage: options.systemMessage - ? { - content: options.systemMessage, - } - : undefined, - }); - console.log(`[CopilotManager] New session created: ${session.sessionId}`); - - // Store session ID in database - try { - await this.chatEngine.updateConversation(conversationId, { - copilotSessionId: session.sessionId, - }); - } catch (error) { - console.error(`[CopilotManager] Failed to save session ID: ${(error as Error).message}`); - } - } - - const copilotSession: CopilotSession = { - sessionId: session.sessionId, - session, - conversationId, - isResumed, - model: options.model || 'gpt-4.1', - }; - - this.sessions.set(conversationId, copilotSession); - return copilotSession; - } - - /** - * Send a message to a conversation - */ - async sendMessage( - conversationId: string, - userMessage: string, - options: SendMessageOptions = {} - ): Promise { - const { onDelta, onToolCall, onToolResult } = options; - - try { - const readyCheck = await this.checkReady(); - if (!readyCheck.ready) { - return { success: false, error: readyCheck.error }; - } - - // Get conversation from database - const conversation = await this.chatEngine.getConversation(conversationId); - if (!conversation) { - return { success: false, error: 'Conversation not found' }; - } - - // Get or create session - const systemMessage = conversation.messages.find(m => m.role === 'system'); - const copilotSession = await this.getOrCreateSession(conversationId, { - model: conversation.model, - systemMessage: systemMessage?.content, - copilotSessionId: conversation.copilotSessionId, - }); - - // Add user message to database - await this.chatEngine.addMessage({ - conversationId, - role: 'user', - content: userMessage, - createdAt: new Date(), - }); - - // Collect response - let fullResponse = ''; - const toolCalls: Array<{ name: string; args: unknown }> = []; - - // Set up event handlers - const unsubscribeDelta = copilotSession.session.on('assistant.message_delta', (event: { data: { deltaContent?: string } }) => { - fullResponse += event.data.deltaContent || ''; - if (onDelta) { - onDelta(event.data.deltaContent || ''); - } - }); - - const unsubscribeToolStart = copilotSession.session.on('tool.execution_start', (event: { data?: { toolName?: string; args?: unknown } }) => { - if (onToolCall) { - onToolCall({ - name: event.data?.toolName || 'unknown', - args: event.data?.args, - }); - } - toolCalls.push({ - name: event.data?.toolName || 'unknown', - args: event.data?.args, - }); - }); - - const unsubscribeToolEnd = copilotSession.session.on('tool.execution_end', (event: { data?: { toolName?: string; result?: unknown } }) => { - if (onToolResult) { - onToolResult({ - name: event.data?.toolName || 'unknown', - result: event.data?.result, - }); - } - }); - - try { - // Send message and wait for completion (5 minute timeout) - const response = await copilotSession.session.sendAndWait( - { - prompt: userMessage, - }, - 300000 - ); - - // Use final content if available - if (response?.data?.content) { - fullResponse = response.data.content; - } - - // Save assistant response - if (fullResponse) { - await this.chatEngine.addMessage({ - conversationId, - role: 'assistant', - content: fullResponse, - toolCalls: toolCalls.length > 0 ? JSON.stringify(toolCalls) : undefined, - createdAt: new Date(), - }); - } - - // Generate title after first exchange - const userMsgCount = conversation.messages.filter(m => m.role === 'user').length; - if (userMsgCount === 0 && fullResponse) { - this.generateConversationTitle(conversationId, userMessage, fullResponse).catch(err => - console.error('[CopilotManager] Error generating title:', err) - ); - } - - return { - success: true, - message: fullResponse, - toolCalls: toolCalls.length > 0 ? toolCalls : undefined, - }; - } finally { - unsubscribeDelta(); - unsubscribeToolStart(); - unsubscribeToolEnd(); - } - } catch (error) { - console.error('[CopilotManager] Error sending message:', error); - return { success: false, error: (error as Error).message }; - } - } - - /** - * Generate a title for a conversation based on first exchange - */ - private async generateConversationTitle(conversationId: string, userMessage: string, assistantResponse: string): Promise { - try { - // Create a quick session just for title generation - const titleSession = await this.client.createSession({ - model: 'gpt-4.1-mini', - streaming: false, - }); - - const response = await titleSession.sendAndWait({ - prompt: `Generate a short, concise title (max 6 words) for this conversation. Only output the title, nothing else. - -User: ${userMessage.substring(0, 200)} -Assistant: ${assistantResponse.substring(0, 200)}`, - }); - - if (response?.data?.content) { - const title = response.data.content.trim().replace(/^["']|["']$/g, ''); - await this.chatEngine.updateConversation(conversationId, { title }); - - // Notify UI of title update - const mainWindow = this.getMainWindow(); - if (mainWindow) { - mainWindow.webContents.send('chat-title-updated', { conversationId, title }); - } - } - - await titleSession.destroy(); - } catch (error) { - console.error('[CopilotManager] Error generating title:', error); - } - } - - /** - * Abort a currently running message - */ - async abortMessage(conversationId: string): Promise<{ success: boolean; error?: string }> { - const copilotSession = this.sessions.get(conversationId); - if (!copilotSession) { - return { success: false, error: 'No active session for this conversation' }; - } - - try { - await copilotSession.session.abort(); - console.log('[CopilotManager] Aborted message for conversation:', conversationId); - return { success: true }; - } catch (error) { - console.error('[CopilotManager] Error aborting message:', error); - return { success: false, error: (error as Error).message }; - } - } - - /** - * Destroy a session - */ - async destroySession(conversationId: string): Promise { - const copilotSession = this.sessions.get(conversationId); - if (copilotSession) { - try { - await copilotSession.session.destroy(); - } catch (error) { - console.error('[CopilotManager] Error destroying session:', error); - } - this.sessions.delete(conversationId); - } - } - - /** - * Get authentication status - * Does not auto-initialize - returns unauthenticated if client not ready - */ - async getAuthStatus(): Promise<{ isAuthenticated: boolean; authType?: string; login?: string; error?: string }> { - try { - // Don't auto-initialize on auth check - let frontend trigger login explicitly - if (!this.isInitialized || !this.client) { - return { isAuthenticated: false }; - } - - const authStatus = await this.client.getAuthStatus(); - return { - isAuthenticated: authStatus.isAuthenticated, - authType: authStatus.authType, - login: authStatus.login, - }; - } catch (error) { - console.error('[CopilotManager] Error getting auth status:', error); - return { isAuthenticated: false, error: (error as Error).message }; - } - } - - /** - * Get CLI path for the native Copilot binary - */ - private getCLIPath(): string { - const platform = process.platform; - const arch = process.arch; - const packageName = `@github/copilot-${platform}-${arch}`; - const exeName = platform === 'win32' ? 'copilot.exe' : 'copilot'; - - // In production (packaged app), node_modules is in resources/app.asar.unpacked - // In development, it's in the project root - const isPackaged = app.isPackaged; - - let cliPath: string; - if (isPackaged) { - // In packaged app, asarUnpack puts these in app.asar.unpacked - const resourcesPath = process.resourcesPath; - cliPath = path.join(resourcesPath, 'app.asar.unpacked', 'node_modules', packageName, exeName); - } else { - // In development - cliPath = path.join(__dirname, '..', '..', '..', 'node_modules', packageName, exeName); - } - - console.log('[CopilotManager] CLI path:', cliPath); - return cliPath; - } - - /** - * Trigger interactive login using the CLI - */ - async triggerLogin(options: { - onDeviceCode?: (deviceCode: { verificationUri: string; userCode: string }) => void; - onMessage?: (message: string) => void; - } = {}): Promise<{ success: boolean; error?: string; login?: string }> { - const { onDeviceCode, onMessage } = options; - - return new Promise((resolve) => { - const cliPath = this.getCLIPath(); - - console.log('[CopilotManager] Triggering interactive login...'); - if (onMessage) onMessage('Starting authentication...'); - - // Spawn the CLI with 'login' command - const cliProcess = spawn(cliPath, ['login'], { - stdio: ['pipe', 'pipe', 'pipe'], - env: { ...process.env } - }); - - let stdout = ''; - let stderr = ''; - let deviceCodeSent = false; - - cliProcess.stdout.on('data', (data: Buffer) => { - const text = data.toString(); - stdout += text; - console.log('[CopilotManager] CLI stdout:', text); - - // Parse for device code information - if (!deviceCodeSent) { - const urlMatch = text.match(/https:\/\/github\.com\/login\/device/); - const codeMatch = text.match(/code[:\s]+([A-Z0-9]{4}-[A-Z0-9]{4})/i); - - if (urlMatch && codeMatch) { - deviceCodeSent = true; - if (onDeviceCode) { - onDeviceCode({ - verificationUri: 'https://github.com/login/device', - userCode: codeMatch[1] - }); - } - } - } - - if (onMessage) { - onMessage(text.trim()); - } - }); - - cliProcess.stderr.on('data', (data: Buffer) => { - const text = data.toString(); - stderr += text; - console.log('[CopilotManager] CLI stderr:', text); - - // Some CLIs output to stderr for prompts - if (!deviceCodeSent) { - const urlMatch = text.match(/https:\/\/github\.com\/login\/device/); - const codeMatch = text.match(/code[:\s]+([A-Z0-9]{4}-[A-Z0-9]{4})/i); - - if (urlMatch && codeMatch) { - deviceCodeSent = true; - if (onDeviceCode) { - onDeviceCode({ - verificationUri: 'https://github.com/login/device', - userCode: codeMatch[1] - }); - } - } - } - - if (onMessage) { - onMessage(text.trim()); - } - }); - - cliProcess.on('close', (code: number) => { - console.log('[CopilotManager] CLI login process exited with code:', code); - - if (code === 0) { - // Re-initialize client to pick up new credentials - this.isInitialized = false; - this.client = null; - resolve({ success: true }); - } else { - resolve({ success: false, error: stderr || `Login failed with code ${code}` }); - } - }); - - cliProcess.on('error', (error: Error) => { - console.error('[CopilotManager] CLI spawn error:', error); - resolve({ success: false, error: error.message }); - }); - }); - } - - /** - * Logout from Copilot (stops client - no CLI logout command exists) - */ - async logout(): Promise<{ success: boolean; error?: string }> { - console.log('[CopilotManager] Logging out (stopping client)...'); - - try { - await this.stop(); - return { success: true }; - } catch (error) { - console.error('[CopilotManager] Error during logout:', error); - return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }; - } - } - - /** - * Get available models - */ - async getAvailableModels(): Promise { - if (!this.isInitialized) { - await this.initialize(); - } - - try { - const models = await this.client.getAvailableModels(); - return models || ['gpt-4.1', 'gpt-4.1-mini', 'claude-sonnet-4']; - } catch (error) { - console.error('[CopilotManager] Error getting models:', error); - return ['gpt-4.1', 'gpt-4.1-mini', 'claude-sonnet-4']; - } - } - - /** - * Stop the client - */ - async stop(): Promise { - // Set stopping flag to prevent new initialization attempts - this.isStopping = true; - - // Wait for any pending initialization to complete (or fail) - if (this.initPromise) { - try { - await this.initPromise; - } catch { - // Ignore initialization errors during shutdown - } - } - - // Clear sessions first - for (const [conversationId] of this.sessions) { - try { - await this.destroySession(conversationId); - } catch { - // Ignore errors during cleanup - } - } - this.sessions.clear(); - - if (this.client) { - try { - await this.client.stop(); - } catch { - // Ignore errors during cleanup - client may not be fully initialized - } - this.client = null; - this.isInitialized = false; - } - } -} diff --git a/src/main/engine/OpenCodeManager.ts b/src/main/engine/OpenCodeManager.ts new file mode 100644 index 0000000..494028b --- /dev/null +++ b/src/main/engine/OpenCodeManager.ts @@ -0,0 +1,1035 @@ +/** + * OpenCodeManager - Handles AI chat using OpenCode Zen API gateway + * + * Supports Anthropic Claude (Messages API with native tool_use) and + * OpenAI-compatible models via the OpenCode Zen gateway. + * + * Tools are provided as proper Anthropic tool definitions so the AI + * can call them natively via tool_use blocks. + */ + +import https from 'https'; +import http from 'http'; +import { URL } from 'url'; +import { BrowserWindow } from 'electron'; +import { ChatEngine } from './ChatEngine'; +import { PostEngine } from './PostEngine'; +import { MediaEngine } from './MediaEngine'; + +// OpenCode Zen API endpoints +const ZEN_ANTHROPIC_URL = 'https://opencode.ai/zen/v1/messages'; +const ZEN_OPENAI_URL = 'https://opencode.ai/zen/v1/chat/completions'; +const ZEN_MODELS_URL = 'https://opencode.ai/zen/v1/models'; + +// Full model catalog from OpenCode Zen (fallback when API unavailable) +const AVAILABLE_MODELS: ModelInfo[] = [ + // Anthropic Claude + { id: 'claude-sonnet-4', name: 'Claude Sonnet 4', provider: 'anthropic' }, + { id: 'claude-sonnet-4-5', name: 'Claude Sonnet 4.5', provider: 'anthropic' }, + { id: 'claude-haiku-4-5', name: 'Claude Haiku 4.5', provider: 'anthropic' }, + { id: 'claude-3-5-haiku', name: 'Claude Haiku 3.5', provider: 'anthropic' }, + { id: 'claude-opus-4-5', name: 'Claude Opus 4.5', provider: 'anthropic' }, + { id: 'claude-opus-4-1', name: 'Claude Opus 4.1', provider: 'anthropic' }, + // OpenAI GPT + { id: 'gpt-5.2', name: 'GPT 5.2', provider: 'openai' }, + { id: 'gpt-5.2-codex', name: 'GPT 5.2 Codex', provider: 'openai' }, + { id: 'gpt-5.1', name: 'GPT 5.1', provider: 'openai' }, + { id: 'gpt-5.1-codex', name: 'GPT 5.1 Codex', provider: 'openai' }, + { id: 'gpt-5.1-codex-max', name: 'GPT 5.1 Codex Max', provider: 'openai' }, + { id: 'gpt-5.1-codex-mini', name: 'GPT 5.1 Codex Mini', provider: 'openai' }, + { id: 'gpt-5', name: 'GPT 5', provider: 'openai' }, + { id: 'gpt-5-codex', name: 'GPT 5 Codex', provider: 'openai' }, + { id: 'gpt-5-nano', name: 'GPT 5 Nano (Free)', provider: 'openai' }, + // Google Gemini + { id: 'gemini-3-pro', name: 'Gemini 3 Pro', provider: 'google' }, + { id: 'gemini-3-flash', name: 'Gemini 3 Flash', provider: 'google' }, + // Other providers + { id: 'qwen3-coder', name: 'Qwen3 Coder 480B', provider: 'other' }, + { id: 'minimax-m2.1', name: 'MiniMax M2.1', provider: 'other' }, + { id: 'minimax-m2.1-free', name: 'MiniMax M2.1 (Free)', provider: 'other' }, + { id: 'glm-4.7', name: 'GLM 4.7', provider: 'other' }, + { id: 'glm-4.7-free', name: 'GLM 4.7 (Free)', provider: 'other' }, + { id: 'glm-4.6', name: 'GLM 4.6', provider: 'other' }, + { id: 'kimi-k2.5', name: 'Kimi K2.5', provider: 'other' }, + { id: 'kimi-k2.5-free', name: 'Kimi K2.5 (Free)', provider: 'other' }, + { id: 'kimi-k2', name: 'Kimi K2', provider: 'other' }, + { id: 'kimi-k2-thinking', name: 'Kimi K2 Thinking', provider: 'other' }, + { id: 'big-pickle', name: 'Big Pickle (Free)', provider: 'other' }, + { id: 'trinity-large-preview-free', name: 'Trinity Large Preview (Free)', provider: 'other' }, +]; + +export interface ModelInfo { + id: string; + name: string; + provider: 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 }>; +} + +interface ToolDefinition { + name: string; + description: string; + input_schema: { + type: 'object'; + properties: Record; + required?: string[]; + }; +} + +interface AnthropicMessage { + role: 'user' | 'assistant'; + content: string | AnthropicContentBlock[]; +} + +interface AnthropicContentBlock { + type: string; + text?: string; + id?: string; + name?: string; + input?: unknown; + tool_use_id?: string; + content?: string; +} + +interface HttpResponse { + statusCode: number; + body: string; +} + +export class OpenCodeManager { + private chatEngine: ChatEngine; + private postEngine: PostEngine; + private mediaEngine: MediaEngine; + private getMainWindow: () => BrowserWindow | null; + private apiKey: string = ''; + private abortControllers: Map = new Map(); + + constructor( + chatEngine: ChatEngine, + postEngine: PostEngine, + mediaEngine: MediaEngine, + getMainWindow: () => BrowserWindow | null + ) { + this.chatEngine = chatEngine; + this.postEngine = postEngine; + this.mediaEngine = mediaEngine; + this.getMainWindow = getMainWindow; + } + + /** + * Set API key for OpenCode Zen + */ + setApiKey(key: string): void { + this.apiKey = key; + } + + /** + * Get current API key + */ + getApiKey(): string { + return this.apiKey; + } + + /** + * Check if the service is configured and ready + */ + async checkReady(): Promise<{ ready: boolean; error?: string }> { + if (!this.apiKey) { + return { ready: false, error: 'API key not configured' }; + } + return { ready: true }; + } + + /** + * Validate an API key by calling the models endpoint + */ + async validateApiKey(apiKey: string): Promise<{ isValid: boolean; models: ModelInfo[] }> { + if (!apiKey || apiKey.length < 3) { + return { isValid: false, models: [] }; + } + + // Try both auth header styles (OpenCode Zen quirk) + const attempts: Record[] = [ + { 'Authorization': `Bearer ${apiKey}` }, + { 'x-api-key': apiKey }, + ]; + + for (const headers of attempts) { + try { + const response = await this.httpRequest(ZEN_MODELS_URL, { + method: 'GET', + headers, + }); + if (response.statusCode >= 200 && response.statusCode < 300) { + return { isValid: true, models: AVAILABLE_MODELS }; + } + } catch { + // Try next auth method + } + } + + return { isValid: false, models: [] }; + } + + /** + * Get available models + */ + async getAvailableModels(): Promise { + // Try fetching from API, fall back to hardcoded list + if (this.apiKey) { + try { + const response = await this.httpRequest(ZEN_MODELS_URL, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${this.apiKey}`, + 'x-api-key': this.apiKey, + }, + }); + if (response.statusCode === 200) { + const data = JSON.parse(response.body); + if (data.data && Array.isArray(data.data)) { + return data.data.map((m: { id: string; name?: string }) => ({ + id: m.id, + name: m.name || this.formatModelName(m.id), + provider: this.detectProvider(m.id), + })); + } + } + } catch { + // Fall through to hardcoded + } + } + return AVAILABLE_MODELS; + } + + /** + * Send a message to a conversation with tool use support + */ + async sendMessage( + conversationId: string, + userMessage: string, + options: SendMessageOptions = {} + ): Promise { + const { onDelta, onToolCall, onToolResult } = options; + + try { + const readyCheck = await this.checkReady(); + if (!readyCheck.ready) { + return { success: false, error: readyCheck.error }; + } + + // Get conversation from database + const conversation = await this.chatEngine.getConversation(conversationId); + if (!conversation) { + return { success: false, error: 'Conversation not found' }; + } + + // Add user message to database + await this.chatEngine.addMessage({ + conversationId, + role: 'user', + content: userMessage, + createdAt: new Date(), + }); + + // Set up abort controller + const abortController = new AbortController(); + this.abortControllers.set(conversationId, abortController); + + try { + const modelId = conversation.model || 'claude-sonnet-4'; + const provider = this.detectProvider(modelId); + + // Get system prompt + const systemMessage = conversation.messages.find(m => m.role === 'system'); + const systemPrompt = systemMessage?.content || await this.chatEngine.getDefaultSystemPrompt(); + + // Build message history from DB (excluding system messages) + const dbMessages = conversation.messages.filter(m => m.role !== 'system'); + // Add the new user message + dbMessages.push({ + conversationId, + role: 'user', + content: userMessage, + createdAt: new Date(), + }); + + let fullResponse = ''; + const toolCallsCollected: Array<{ name: string; args: unknown }> = []; + + if (provider === 'anthropic') { + const result = await this.sendAnthropicMessage( + modelId, + systemPrompt, + dbMessages, + abortController.signal, + { onDelta, onToolCall, onToolResult } + ); + fullResponse = result.content; + toolCallsCollected.push(...result.toolCalls); + } else { + const result = await this.sendOpenAIMessage( + modelId, + systemPrompt, + dbMessages, + abortController.signal, + { onDelta } + ); + fullResponse = result.content; + } + + // Save assistant response + if (fullResponse) { + await this.chatEngine.addMessage({ + conversationId, + role: 'assistant', + content: fullResponse, + toolCalls: toolCallsCollected.length > 0 ? JSON.stringify(toolCallsCollected) : 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('[OpenCodeManager] Error generating title:', err) + ); + } + + return { + success: true, + message: fullResponse, + toolCalls: toolCallsCollected.length > 0 ? toolCallsCollected : undefined, + }; + } finally { + this.abortControllers.delete(conversationId); + } + } catch (error) { + console.error('[OpenCodeManager] Error sending message:', error); + return { success: false, error: (error as Error).message }; + } + } + + /** + * Send via Anthropic Messages API with native tool_use + */ + private async sendAnthropicMessage( + modelId: string, + systemPrompt: string, + dbMessages: Array<{ role: string; content?: string; toolCalls?: string; toolCallId?: string }>, + signal: AbortSignal, + callbacks: { + onDelta?: (delta: string) => void; + onToolCall?: (toolCall: { name: string; args: unknown }) => void; + onToolResult?: (result: { name: string; result: unknown }) => void; + } + ): Promise<{ content: string; toolCalls: Array<{ name: string; args: unknown }> }> { + const tools = this.getToolDefinitions(); + const allToolCalls: Array<{ name: string; args: unknown }> = []; + + // Convert DB messages to Anthropic format + let messages = this.buildAnthropicMessages(dbMessages); + + // Tool use loop - keep going until the model stops calling tools + const MAX_TOOL_ROUNDS = 10; + let round = 0; + + while (round < MAX_TOOL_ROUNDS) { + round++; + + const body: Record = { + model: modelId, + max_tokens: 4096, + system: systemPrompt, + messages, + tools, + }; + + const response = await this.httpRequest(ZEN_ANTHROPIC_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': this.apiKey, + 'Authorization': `Bearer ${this.apiKey}`, + 'anthropic-version': '2023-06-01', + }, + body: JSON.stringify(body), + signal, + }); + + if (response.statusCode >= 400) { + const errorMsg = this.parseErrorResponse(response); + throw new Error(errorMsg); + } + + const data = JSON.parse(response.body); + + if (!data.content) { + throw new Error('API response missing content field'); + } + + // Check if there are tool_use blocks + const toolUseBlocks = (data.content as AnthropicContentBlock[]).filter( + (b: AnthropicContentBlock) => b.type === 'tool_use' + ); + const textBlocks = (data.content as AnthropicContentBlock[]).filter( + (b: AnthropicContentBlock) => b.type === 'text' + ); + + // Stream text content to frontend + for (const block of textBlocks) { + if (block.text && callbacks.onDelta) { + callbacks.onDelta(block.text); + } + } + + if (toolUseBlocks.length === 0 || data.stop_reason !== 'tool_use') { + // No more tool calls - extract final text and return + const finalText = textBlocks.map((b: AnthropicContentBlock) => b.text || '').join(''); + return { content: finalText, toolCalls: allToolCalls }; + } + + // Execute tool calls + const toolResults: AnthropicContentBlock[] = []; + + for (const toolBlock of toolUseBlocks) { + const toolName = toolBlock.name!; + const toolArgs = toolBlock.input; + const toolUseId = toolBlock.id!; + + allToolCalls.push({ name: toolName, args: toolArgs }); + + if (callbacks.onToolCall) { + callbacks.onToolCall({ name: toolName, args: toolArgs }); + } + + // Execute the tool + const result = await this.executeTool(toolName, toolArgs as Record); + + if (callbacks.onToolResult) { + callbacks.onToolResult({ name: toolName, result }); + } + + toolResults.push({ + type: 'tool_result', + tool_use_id: toolUseId, + content: JSON.stringify(result), + }); + } + + // Add assistant response and tool results to messages for next round + messages = [ + ...messages, + { role: 'assistant' as const, content: data.content }, + { role: 'user' as const, content: toolResults }, + ]; + } + + // If we hit max rounds, return whatever we have + return { content: 'I reached the maximum number of tool calls. Please try again.', toolCalls: allToolCalls }; + } + + /** + * Send via OpenAI-compatible API (non-Claude models) + */ + private async sendOpenAIMessage( + modelId: string, + systemPrompt: string, + dbMessages: Array<{ role: string; content?: string }>, + signal: AbortSignal, + callbacks: { onDelta?: (delta: string) => void } + ): Promise<{ content: string }> { + // Build OpenAI-format messages + const messages = [ + { role: 'system', content: systemPrompt }, + ...dbMessages + .filter(m => m.role === 'user' || m.role === 'assistant') + .map(m => ({ + role: m.role, + content: m.content || '', + })), + ]; + + // Build OpenAI tools format + const anthropicTools = this.getToolDefinitions(); + const openaiTools = anthropicTools.map(t => ({ + type: 'function' as const, + function: { + name: t.name, + description: t.description, + parameters: t.input_schema, + }, + })); + + const body: Record = { + model: modelId, + max_tokens: 4096, + messages, + tools: openaiTools, + }; + + const response = await this.httpRequest(ZEN_OPENAI_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.apiKey}`, + }, + body: JSON.stringify(body), + signal, + }); + + if (response.statusCode >= 400) { + const errorMsg = this.parseErrorResponse(response); + throw new Error(errorMsg); + } + + const data = JSON.parse(response.body); + const choice = data.choices?.[0]; + + if (!choice?.message) { + throw new Error('API response missing expected message content'); + } + + // Handle tool calls in OpenAI format + if (choice.message.tool_calls && choice.message.tool_calls.length > 0) { + // Execute tools and do follow-up call + const toolMessages = [ + ...messages, + choice.message, + ]; + + for (const toolCall of choice.message.tool_calls) { + const toolName = toolCall.function.name; + const toolArgs = JSON.parse(toolCall.function.arguments || '{}'); + const result = await this.executeTool(toolName, toolArgs); + + toolMessages.push({ + role: 'tool', + content: JSON.stringify(result), + tool_call_id: toolCall.id, + } as Record as typeof messages[0]); + } + + // Make follow-up call with tool results + const followUpBody: Record = { + model: modelId, + max_tokens: 4096, + messages: toolMessages, + tools: openaiTools, + }; + + const followUpResponse = await this.httpRequest(ZEN_OPENAI_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.apiKey}`, + }, + body: JSON.stringify(followUpBody), + signal, + }); + + if (followUpResponse.statusCode >= 400) { + throw new Error(this.parseErrorResponse(followUpResponse)); + } + + const followUpData = JSON.parse(followUpResponse.body); + const content = followUpData.choices?.[0]?.message?.content || ''; + + if (callbacks.onDelta) { + callbacks.onDelta(content); + } + + return { content }; + } + + const content = choice.message.content || ''; + if (callbacks.onDelta) { + callbacks.onDelta(content); + } + + return { content }; + } + + /** + * Get Anthropic-format tool definitions for all available tools + */ + private getToolDefinitions(): ToolDefinition[] { + return [ + { + name: 'search_posts', + description: 'Search blog posts using full-text search. Can filter by category or tags. Returns matching posts with their metadata.', + input_schema: { + type: 'object', + properties: { + query: { type: 'string', description: 'The search query text to find in posts' }, + category: { type: 'string', description: 'Optional category to filter by (e.g., "article", "picture", "aside", "page")' }, + tags: { type: 'array', items: { type: 'string' }, description: 'Optional array of tags to filter by' }, + limit: { type: 'number', description: 'Maximum number of results to return (default: 10)' }, + }, + required: ['query'], + }, + }, + { + name: 'read_post', + description: 'Read the full content and metadata of a specific blog post by its ID.', + input_schema: { + type: 'object', + properties: { + postId: { type: 'string', description: 'The unique ID of the post to read' }, + }, + required: ['postId'], + }, + }, + { + name: 'list_posts', + description: 'List blog posts with optional filtering by status, category, or tags.', + input_schema: { + type: 'object', + properties: { + status: { type: 'string', enum: ['draft', 'published', 'archived'], description: 'Filter by post status' }, + category: { type: 'string', description: 'Filter by category' }, + tags: { type: 'array', items: { type: 'string' }, description: 'Filter by tags (posts must have all specified tags)' }, + limit: { type: 'number', description: 'Maximum number of results (default: 20)' }, + offset: { type: 'number', description: 'Offset for pagination (default: 0)' }, + }, + }, + }, + { + name: 'get_media', + description: 'Get information about a specific media file (image) by its ID.', + input_schema: { + type: 'object', + properties: { + mediaId: { type: 'string', description: 'The unique ID of the media file' }, + }, + required: ['mediaId'], + }, + }, + { + name: 'list_media', + description: 'List all media files in the current project with optional filtering.', + input_schema: { + type: 'object', + properties: { + mimeTypeFilter: { type: 'string', description: 'Filter by MIME type prefix (e.g., "image/")' }, + limit: { type: 'number', description: 'Maximum number of results (default: 20)' }, + }, + }, + }, + { + name: 'update_post_metadata', + description: 'Update metadata for a blog post (title, excerpt, tags, categories). Does NOT update post content.', + input_schema: { + type: 'object', + properties: { + postId: { type: 'string', description: 'The unique ID of the post to update' }, + title: { type: 'string', description: 'New title for the post' }, + excerpt: { type: 'string', description: 'New excerpt/summary for the post' }, + tags: { type: 'array', items: { type: 'string' }, description: 'New tags for the post' }, + categories: { type: 'array', items: { type: 'string' }, description: 'New categories for the post' }, + }, + required: ['postId'], + }, + }, + { + name: 'update_media_metadata', + description: 'Update metadata for a media file (alt text, caption, tags).', + input_schema: { + type: 'object', + properties: { + mediaId: { type: 'string', description: 'The unique ID of the media to update' }, + alt: { type: 'string', description: 'New alt text for the image' }, + caption: { type: 'string', description: 'New caption for the image' }, + tags: { type: 'array', items: { type: 'string' }, description: 'New tags for the media' }, + }, + required: ['mediaId'], + }, + }, + { + name: 'list_tags', + description: 'List all tags used across blog posts, with the count of posts using each tag. Useful for understanding the tag taxonomy.', + input_schema: { + type: 'object', + properties: {}, + }, + }, + { + name: 'list_categories', + description: 'List all categories used across blog posts, with the count of posts in each category. Useful for understanding the category structure.', + input_schema: { + type: 'object', + properties: {}, + }, + }, + ]; + } + + /** + * Execute a tool by name with given arguments + */ + private async executeTool(name: string, args: Record): Promise { + try { + switch (name) { + case 'search_posts': { + const searchResults = await this.postEngine.searchPosts(args.query as string); + const fullPosts = await Promise.all( + searchResults.map(sr => this.postEngine.getPost(sr.id)) + ); + let filteredPosts = fullPosts.filter(p => p !== null); + + if (args.category) { + filteredPosts = filteredPosts.filter(p => p!.categories.includes(args.category as string)); + } + if (args.tags && Array.isArray(args.tags) && (args.tags as string[]).length > 0) { + filteredPosts = filteredPosts.filter(p => + (args.tags as string[]).every(tag => p!.tags.includes(tag)) + ); + } + + const limit = (args.limit as number) || 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, + })), + }; + } + + case 'read_post': { + const post = await this.postEngine.getPost(args.postId as string); + 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, + }, + }; + } + + case 'list_posts': { + const filter: { status?: 'draft' | 'published' | 'archived'; tags?: string[]; categories?: string[] } = {}; + if (args.status) filter.status = args.status as 'draft' | 'published' | 'archived'; + if (args.tags) filter.tags = args.tags as string[]; + if (args.category) filter.categories = [args.category as string]; + + let posts; + if (Object.keys(filter).length > 0) { + posts = await this.postEngine.getPostsFiltered(filter); + } else { + const result = await this.postEngine.getAllPosts({ + limit: (args.limit as number) || 20, + offset: (args.offset as number) || 0, + }); + posts = result.items; + } + + const offset = (args.offset as number) || 0; + const limit = (args.limit as number) || 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, + })), + }; + } + + case 'get_media': { + const media = await this.mediaEngine.getMedia(args.mediaId as string); + 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, + }, + }; + } + + case 'list_media': { + let mediaList = await this.mediaEngine.getAllMedia(); + if (args.mimeTypeFilter) { + mediaList = mediaList.filter(m => m.mimeType.startsWith(args.mimeTypeFilter as string)); + } + const limit = (args.limit as number) || 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, + })), + }; + } + + case 'update_post_metadata': { + const updates: Record = {}; + if (args.title !== undefined) updates.title = args.title; + if (args.excerpt !== undefined) updates.excerpt = args.excerpt; + if (args.tags !== undefined) updates.tags = args.tags; + if (args.categories !== undefined) updates.categories = args.categories; + + if (Object.keys(updates).length === 0) { + return { success: false, error: 'No updates provided' }; + } + + await this.postEngine.updatePost(args.postId as string, updates); + return { success: true, message: `Post ${args.postId} metadata updated successfully` }; + } + + case 'update_media_metadata': { + const updates: Record = {}; + if (args.alt !== undefined) updates.alt = args.alt; + if (args.caption !== undefined) updates.caption = args.caption; + if (args.tags !== undefined) updates.tags = args.tags; + + if (Object.keys(updates).length === 0) { + return { success: false, error: 'No updates provided' }; + } + + await this.mediaEngine.updateMedia(args.mediaId as string, updates); + return { success: true, message: `Media ${args.mediaId} metadata updated successfully` }; + } + + case 'list_tags': { + const tagsWithCounts = await this.postEngine.getTagsWithCounts(); + return { + success: true, + count: tagsWithCounts.length, + tags: tagsWithCounts, + }; + } + + case 'list_categories': { + const categoriesWithCounts = await this.postEngine.getCategoriesWithCounts(); + return { + success: true, + count: categoriesWithCounts.length, + categories: categoriesWithCounts, + }; + } + + default: + return { success: false, error: `Unknown tool: ${name}` }; + } + } catch (error) { + return { success: false, error: (error as Error).message }; + } + } + + /** + * Build Anthropic-format messages from DB message history + */ + private buildAnthropicMessages( + dbMessages: Array<{ role: string; content?: string; toolCalls?: string; toolCallId?: string }> + ): AnthropicMessage[] { + const messages: AnthropicMessage[] = []; + + for (const msg of dbMessages) { + if (msg.role === 'user') { + messages.push({ role: 'user', content: msg.content || '' }); + } else if (msg.role === 'assistant') { + messages.push({ role: 'assistant', content: msg.content || '' }); + } + // Tool messages from history are already incorporated into assistant responses + } + + return messages; + } + + /** + * Generate a title for a conversation + */ + private async generateConversationTitle( + conversationId: string, + userMessage: string, + assistantResponse: string + ): Promise { + try { + const body = { + model: 'claude-haiku-4-5', + max_tokens: 100, + system: 'Generate a short, concise title (max 6 words) for this conversation. Only output the title, nothing else.', + messages: [ + { + role: 'user', + content: `User: ${userMessage.substring(0, 200)}\nAssistant: ${assistantResponse.substring(0, 200)}`, + }, + ], + }; + + const response = await this.httpRequest(ZEN_ANTHROPIC_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': this.apiKey, + 'Authorization': `Bearer ${this.apiKey}`, + 'anthropic-version': '2023-06-01', + }, + body: JSON.stringify(body), + }); + + if (response.statusCode === 200) { + const data = JSON.parse(response.body); + let title = ''; + if (Array.isArray(data.content)) { + title = data.content + .filter((b: AnthropicContentBlock) => b.type === 'text') + .map((b: AnthropicContentBlock) => b.text || '') + .join(''); + } else { + title = data.content || ''; + } + + title = title.trim().replace(/^["']|["']$/g, ''); + + if (title) { + await this.chatEngine.updateConversation(conversationId, { title }); + + const mainWindow = this.getMainWindow(); + if (mainWindow) { + mainWindow.webContents.send('chat-title-updated', { conversationId, title }); + } + } + } + } catch (error) { + console.error('[OpenCodeManager] Error generating title:', error); + } + } + + /** + * Abort a running message + */ + async abortMessage(conversationId: string): Promise<{ success: boolean; error?: string }> { + const controller = this.abortControllers.get(conversationId); + if (!controller) { + return { success: false, error: 'No active request for this conversation' }; + } + + controller.abort(); + this.abortControllers.delete(conversationId); + return { success: true }; + } + + /** + * Stop/cleanup + */ + async stop(): Promise { + for (const [, controller] of this.abortControllers) { + controller.abort(); + } + this.abortControllers.clear(); + } + + // ── Helpers ── + + private detectProvider(modelId: string): string { + const id = modelId.toLowerCase(); + if (id.startsWith('claude')) return 'anthropic'; + if (id.startsWith('gpt') || id.startsWith('o3') || id.startsWith('o4')) return 'openai'; + if (id.startsWith('gemini')) return 'google'; + return 'other'; + } + + private formatModelName(modelId: string): string { + return modelId + .split('-') + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); + } + + private parseErrorResponse(response: HttpResponse): string { + let errorMsg = `API error: ${response.statusCode}`; + try { + const errorBody = JSON.parse(response.body); + const rawMsg = errorBody.error?.message || errorBody.message || ''; + if (rawMsg.includes('prompt_tokens') || rawMsg.includes('usage')) { + errorMsg = `Model is currently unavailable on the API gateway. Try a different model.`; + } else { + errorMsg = rawMsg || errorMsg; + } + } catch { + errorMsg = `${errorMsg}: ${response.body.slice(0, 200)}`; + } + return errorMsg; + } + + private httpRequest( + urlStr: string, + options: { + method?: string; + headers?: Record; + body?: string; + signal?: AbortSignal; + } + ): Promise { + return new Promise((resolve, reject) => { + const url = new URL(urlStr); + const protocol = url.protocol === 'https:' ? https : http; + + const req = protocol.request(url, { + method: options.method || 'POST', + headers: options.headers || {}, + timeout: 120000, + }, (res) => { + let body = ''; + res.on('data', (chunk: Buffer) => { body += chunk; }); + res.on('end', () => { + resolve({ statusCode: res.statusCode || 0, body }); + }); + }); + + req.on('error', reject); + req.on('timeout', () => { + req.destroy(); + reject(new Error('Request timed out')); + }); + + if (options.signal) { + options.signal.addEventListener('abort', () => { + req.destroy(); + reject(new Error('Request cancelled')); + }); + } + + if (options.body) { + req.write(options.body); + } + req.end(); + }); + } +} diff --git a/src/main/engine/index.ts b/src/main/engine/index.ts index 53dee13..7507f35 100644 --- a/src/main/engine/index.ts +++ b/src/main/engine/index.ts @@ -44,9 +44,10 @@ export { type CreateConversationInput, } from './ChatEngine'; export { - CopilotManager, + OpenCodeManager, type SendMessageOptions, type SendMessageResult, -} from './CopilotManager'; + type ModelInfo, +} from './OpenCodeManager'; diff --git a/src/main/ipc/chatHandlers.ts b/src/main/ipc/chatHandlers.ts index 79b6440..4cdec7b 100644 --- a/src/main/ipc/chatHandlers.ts +++ b/src/main/ipc/chatHandlers.ts @@ -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 { - if (copilotManager) { - await copilotManager.stop(); - copilotManager = null; + if (openCodeManager) { + await openCodeManager.stop(); + openCodeManager = null; } chatEngine = null; } diff --git a/src/main/preload.ts b/src/main/preload.ts index 497cb2c..90adb0e 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -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; }; chat: { - // Authentication - checkReady: () => Promise<{ ready: boolean; authenticated: boolean }>; - copilotAuthStatus: () => Promise<{ authenticated: boolean; username?: string }>; - copilotLogin: () => Promise<{ success: boolean; error?: string; login?: string }>; - copilotLogout: () => Promise; - + // 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>; setDefaultModel: (modelId: string) => Promise; getSystemPrompt: () => Promise; setSystemPrompt: (prompt: string) => Promise; - + // Conversations getConversations: () => Promise; createConversation: (title?: string, model?: string) => Promise; getConversation: (id: string) => Promise; updateConversation: (id: string, updates: { title?: string; model?: string }) => Promise; deleteConversation: (id: string) => Promise; - + // Messaging sendMessage: (conversationId: string, message: string) => Promise; abortMessage: (conversationId: string) => Promise; getHistory: (conversationId: string) => Promise; clearMessages: (conversationId: string) => Promise; setConversationModel: (conversationId: string, modelId: string) => Promise; - + // Event listeners onStreamDelta: (callback: (data: { conversationId: string; delta: string }) => void) => () => void; onToolCall: (callback: (data: { conversationId: string; toolCall: unknown }) => void) => () => void; onToolResult: (callback: (data: { conversationId: string; result: unknown }) => void) => () => void; onTitleUpdated: (callback: (data: { conversationId: string; title: string }) => void) => () => void; - onDeviceCode: (callback: (data: { verificationUri: string; userCode: string }) => void) => () => void; }; on: (channel: string, callback: (...args: unknown[]) => void) => () => void; once: (channel: string, callback: (...args: unknown[]) => void) => void; diff --git a/src/renderer/components/ChatPanel/ChatPanel.css b/src/renderer/components/ChatPanel/ChatPanel.css index 5d4399c..bbd737e 100644 --- a/src/renderer/components/ChatPanel/ChatPanel.css +++ b/src/renderer/components/ChatPanel/ChatPanel.css @@ -344,3 +344,54 @@ opacity: 0.5; cursor: not-allowed; } + +/* API Key form */ +.api-key-form { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 16px; + width: 100%; + max-width: 400px; +} + +.api-key-input { + padding: 10px 14px; + font-size: 14px; + color: var(--vscode-input-foreground); + background-color: var(--vscode-input-background); + border: 1px solid var(--vscode-input-border); + border-radius: 6px; + outline: none; +} + +.api-key-input:focus { + border-color: var(--vscode-focusBorder); +} + +.api-key-submit { + padding: 10px 20px; + font-size: 14px; + font-weight: 500; + color: var(--vscode-button-foreground); + background-color: var(--vscode-button-background); + border: none; + border-radius: 6px; + cursor: pointer; + transition: background-color 0.15s; +} + +.api-key-submit:hover:not(:disabled) { + background-color: var(--vscode-button-hoverBackground); +} + +.api-key-submit:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.api-key-error { + font-size: 13px; + color: var(--vscode-errorForeground); + margin-top: 4px; +} diff --git a/src/renderer/components/ChatPanel/ChatPanel.tsx b/src/renderer/components/ChatPanel/ChatPanel.tsx index 0a18236..de15d83 100644 --- a/src/renderer/components/ChatPanel/ChatPanel.tsx +++ b/src/renderer/components/ChatPanel/ChatPanel.tsx @@ -14,6 +14,10 @@ export const ChatPanel: React.FC = ({ conversationId }) => { const [streamingContent, setStreamingContent] = useState(''); const [availableModels, setAvailableModels] = useState([]); const [showModelSelector, setShowModelSelector] = useState(false); + const [needsApiKey, setNeedsApiKey] = useState(false); + const [apiKeyInput, setApiKeyInput] = useState(''); + const [apiKeyError, setApiKeyError] = useState(''); + const [isValidating, setIsValidating] = useState(false); const messagesEndRef = useRef(null); const inputRef = useRef(null); const streamingRef = useRef(''); @@ -23,6 +27,20 @@ export const ChatPanel: React.FC = ({ conversationId }) => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, []); + // Check if service is ready + const checkReady = useCallback(async () => { + try { + const status = await window.electronAPI?.chat.checkReady(); + if (!status?.ready) { + setNeedsApiKey(true); + } else { + setNeedsApiKey(false); + } + } catch { + setNeedsApiKey(true); + } + }, []); + // Load conversation and messages const loadData = useCallback(async () => { try { @@ -31,7 +49,7 @@ export const ChatPanel: React.FC = ({ conversationId }) => { window.electronAPI?.chat.getHistory(conversationId), window.electronAPI?.chat.getAvailableModels() ]); - + if (conv) setConversation(conv); if (msgs) setMessages(msgs); if (models) setAvailableModels(models); @@ -41,8 +59,9 @@ export const ChatPanel: React.FC = ({ conversationId }) => { }, [conversationId]); useEffect(() => { + checkReady(); loadData(); - + // Subscribe to stream events const unsubDelta = window.electronAPI?.chat.onStreamDelta((data) => { if (data.conversationId === conversationId) { @@ -62,13 +81,36 @@ export const ChatPanel: React.FC = ({ conversationId }) => { unsubDelta?.(); unsubTitle?.(); }; - }, [conversationId, loadData, scrollToBottom]); + }, [conversationId, loadData, scrollToBottom, checkReady]); // Scroll on new messages or streaming content useEffect(() => { scrollToBottom(); }, [messages, streamingContent, scrollToBottom]); + const handleApiKeySubmit = async () => { + if (!apiKeyInput.trim()) return; + + setIsValidating(true); + setApiKeyError(''); + + try { + const result = await window.electronAPI?.chat.validateApiKey(apiKeyInput.trim()); + if (result?.isValid) { + await window.electronAPI?.chat.setApiKey(apiKeyInput.trim()); + setNeedsApiKey(false); + setApiKeyInput(''); + loadData(); + } else { + setApiKeyError('Invalid API key. Please check and try again.'); + } + } catch { + setApiKeyError('Failed to validate API key.'); + } finally { + setIsValidating(false); + } + }; + const handleSend = async () => { const message = inputValue.trim(); if (!message || isStreaming) return; @@ -91,7 +133,7 @@ export const ChatPanel: React.FC = ({ conversationId }) => { try { // Send message and wait for complete response await window.electronAPI?.chat.sendMessage(conversationId, message); - + // Reload messages to get the saved assistant response const msgs = await window.electronAPI?.chat.getHistory(conversationId); if (msgs) setMessages(msgs); @@ -144,7 +186,7 @@ export const ChatPanel: React.FC = ({ conversationId }) => { return (
- {msg.role === 'user' ? '👤' : '🤖'} + {msg.role === 'user' ? '\u{1F464}' : '\u{1F916}'}
@@ -158,6 +200,43 @@ export const ChatPanel: React.FC = ({ conversationId }) => { ); }; + // API key setup screen + if (needsApiKey) { + return ( +
+
+
AI Chat Setup
+
+
+
+
{'\u{1F511}'}
+

OpenCode Zen API Key Required

+

Enter your OpenCode API key to enable AI chat.

+
+ setApiKeyInput(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleApiKeySubmit()} + placeholder="Enter your API key..." + disabled={isValidating} + /> + + {apiKeyError &&
{apiKeyError}
} +
+
+
+
+ ); + } + return (
@@ -165,12 +244,12 @@ export const ChatPanel: React.FC = ({ conversationId }) => { {conversation?.title || 'New Chat'}
- {showModelSelector && (
@@ -191,36 +270,37 @@ export const ChatPanel: React.FC = ({ conversationId }) => {
{messages.length === 0 && !isStreaming && (
-
🤖
+
{'\u{1F916}'}

Welcome to the AI Assistant

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

  • Search for posts about a specific topic
  • Get details about a specific post
  • +
  • List all tags or categories in your blog
  • Update metadata for posts or media
  • List all images in your media library
)} - + {messages.map(renderMessage)} - + {isStreaming && streamingContent && (
-
🤖
+
{'\u{1F916}'}
Assistant - + {'\u25CF'}
{streamingContent}
)} - + {isStreaming && !streamingContent && (
-
🤖
+
{'\u{1F916}'}
@@ -228,14 +308,14 @@ export const ChatPanel: React.FC = ({ conversationId }) => {
)} - +
{isStreaming && ( )}
@@ -249,12 +329,12 @@ export const ChatPanel: React.FC = ({ conversationId }) => { rows={1} disabled={isStreaming} /> -
diff --git a/src/renderer/components/Sidebar/Sidebar.tsx b/src/renderer/components/Sidebar/Sidebar.tsx index 1de044c..82bf3f7 100644 --- a/src/renderer/components/Sidebar/Sidebar.tsx +++ b/src/renderer/components/Sidebar/Sidebar.tsx @@ -753,8 +753,7 @@ const ChatList: React.FC = () => { const { openTab } = useAppStore(); const [conversations, setConversations] = useState([]); const [isLoading, setIsLoading] = useState(true); - const [authStatus, setAuthStatus] = useState<{ authenticated: boolean; username?: string } | null>(null); - const [deviceCode, setDeviceCode] = useState<{ verificationUri: string; userCode: string } | null>(null); + const [isReady, setIsReady] = useState(false); // Load conversations const loadConversations = useCallback(async () => { @@ -768,20 +767,20 @@ const ChatList: React.FC = () => { } }, []); - // Check auth status - const checkAuth = useCallback(async () => { + // Check if service is ready + const checkReady = useCallback(async () => { try { - const status = await window.electronAPI?.chat.copilotAuthStatus(); - setAuthStatus(status ?? null); - } catch (error) { - console.error('Failed to check auth:', error); + const status = await window.electronAPI?.chat.checkReady(); + setIsReady(status?.ready ?? false); + } catch { + setIsReady(false); } }, []); useEffect(() => { const init = async () => { setIsLoading(true); - await checkAuth(); + await checkReady(); await loadConversations(); setIsLoading(false); }; @@ -789,21 +788,15 @@ const ChatList: React.FC = () => { // Subscribe to title updates const unsubTitle = window.electronAPI?.chat.onTitleUpdated((data) => { - setConversations(prev => + setConversations(prev => prev.map(c => c.id === data.conversationId ? { ...c, title: data.title } : c) ); }); - // Subscribe to device code for login flow - const unsubDevice = window.electronAPI?.chat.onDeviceCode((data) => { - setDeviceCode(data); - }); - return () => { unsubTitle?.(); - unsubDevice?.(); }; - }, [loadConversations, checkAuth]); + }, [loadConversations, checkReady]); const handleNewChat = async () => { try { @@ -832,22 +825,6 @@ const ChatList: React.FC = () => { } }; - const handleLogin = async () => { - try { - const result = await window.electronAPI?.chat.copilotLogin(); - if (result?.success) { - setDeviceCode(null); - await checkAuth(); - } else if (result?.error) { - console.error('Login failed:', result.error); - showToast.error(result.error); - } - } catch (error) { - console.error('Login failed:', error); - showToast.error('Login failed'); - } - }; - const formatChatDate = (dateString: string) => { const date = new Date(dateString); const now = new Date(); @@ -874,33 +851,6 @@ const ChatList: React.FC = () => { ); } - // Show login prompt if not authenticated - if (!authStatus?.authenticated) { - return ( -
-
- AI ASSISTANT -
-
-

Sign in to GitHub Copilot to start chatting

- {deviceCode ? ( -
-

Enter this code at:

- - {deviceCode.verificationUri} - -
{deviceCode.userCode}
-
- ) : ( - - )} -
-
- ); - } - return (
@@ -909,10 +859,9 @@ const ChatList: React.FC = () => { +
- {authStatus.username && ( -
- 👤 - {authStatus.username} + {!isReady && ( +
+

API key needed. Open a chat to configure.

)}
diff --git a/src/renderer/types/electron.d.ts b/src/renderer/types/electron.d.ts index 9c14e30..e32f84b 100644 --- a/src/renderer/types/electron.d.ts +++ b/src/renderer/types/electron.d.ts @@ -173,10 +173,8 @@ export interface SyncTagsResult { // Chat/AI types export interface ChatConversation { id: string; - projectId: string; title: string; model?: string; - copilotSessionId?: string; createdAt: string; updatedAt: string; } @@ -194,16 +192,18 @@ export interface ChatMessage { export interface ChatModel { id: string; name: string; -} - -export interface ChatAuthStatus { - authenticated: boolean; - username?: string; + provider?: string; } export interface ChatReadyStatus { ready: boolean; - authenticated: boolean; + error?: string; + backend?: string; +} + +export interface ChatApiKeyStatus { + hasKey: boolean; + maskedKey: string; } export interface ChatStreamDelta { @@ -229,11 +229,6 @@ export interface ChatTitleUpdate { title: string; } -export interface ChatDeviceCode { - verificationUri: string; - userCode: string; -} - export interface ElectronAPI { projects: { create: (data: { name: string; description?: string; slug?: string }) => Promise; @@ -339,38 +334,37 @@ export interface ElectronAPI { syncFromPosts: () => Promise; }; chat: { - // Authentication + // API Key Management checkReady: () => Promise; - copilotAuthStatus: () => Promise; - copilotLogin: () => Promise<{ success: boolean; error?: string; login?: string }>; - copilotLogout: () => Promise; - + validateApiKey: (apiKey: string) => Promise<{ isValid: boolean; models: ChatModel[] }>; + setApiKey: (apiKey: string) => Promise<{ success: boolean; error?: string }>; + getApiKey: () => Promise; + // Settings getAvailableModels: () => Promise; setDefaultModel: (modelId: string) => Promise; getSystemPrompt: () => Promise; setSystemPrompt: (prompt: string) => Promise; - + // Conversations getConversations: () => Promise; createConversation: (title?: string, model?: string) => Promise; getConversation: (id: string) => Promise; updateConversation: (id: string, updates: { title?: string; model?: string }) => Promise; deleteConversation: (id: string) => Promise; - + // Messaging sendMessage: (conversationId: string, message: string) => Promise; abortMessage: (conversationId: string) => Promise; getHistory: (conversationId: string) => Promise; clearMessages: (conversationId: string) => Promise; setConversationModel: (conversationId: string, modelId: string) => Promise; - + // Event listeners for streaming/progress onStreamDelta: (callback: (data: ChatStreamDelta) => void) => () => void; onToolCall: (callback: (data: ChatToolCall) => void) => () => void; onToolResult: (callback: (data: ChatToolResult) => void) => () => void; onTitleUpdated: (callback: (data: ChatTitleUpdate) => void) => () => void; - onDeviceCode: (callback: (data: ChatDeviceCode) => void) => () => void; }; on: (channel: string, callback: (...args: unknown[]) => void) => () => void; once: (channel: string, callback: (...args: unknown[]) => void) => void;