feat: switched to opencode

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

View File

@@ -1,7 +1,8 @@
{ {
"permissions": { "permissions": {
"allow": [ "allow": [
"Bash(npm run build:*)" "Bash(npm run build:*)",
"Bash(npx tsc:*)"
] ]
} }
} }

View File

@@ -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. 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 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 and rebuild posts in the database based on such a web traversal. To be able to do that, use the OpenCode Zen
to integrate copilot directly, so that HTML pages can be directly inspected and turned into actual blog 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 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 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. 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. of markdown and HTML.
For this AI support during import to work, the blog application needs to provide post management and media 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. on those posts.
The AI importing agent must discover the language of a post and put that in an attribute. Posts must have The AI importing agent must discover the language of a post and put that in an attribute. Posts must have

146
package-lock.json generated
View File

@@ -10,7 +10,6 @@
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@floating-ui/dom": "^1.7.5", "@floating-ui/dom": "^1.7.5",
"@github/copilot-sdk": "^0.1.23",
"@libsql/client": "^0.4.0", "@libsql/client": "^0.4.0",
"@milkdown/kit": "^7.18.0", "@milkdown/kit": "^7.18.0",
"@milkdown/plugin-block": "^7.18.0", "@milkdown/plugin-block": "^7.18.0",
@@ -2263,142 +2262,6 @@
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@github/copilot": {
"version": "0.0.403",
"resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-0.0.403.tgz",
"integrity": "sha512-v5jUdtGJReLmE1rmff/LZf+50nzmYQYAaSRNtVNr9g0j0GkCd/noQExe31i1+PudvWU0ZJjltR0B8pUfDRdA9Q==",
"license": "SEE LICENSE IN LICENSE.md",
"bin": {
"copilot": "npm-loader.js"
},
"optionalDependencies": {
"@github/copilot-darwin-arm64": "0.0.403",
"@github/copilot-darwin-x64": "0.0.403",
"@github/copilot-linux-arm64": "0.0.403",
"@github/copilot-linux-x64": "0.0.403",
"@github/copilot-win32-arm64": "0.0.403",
"@github/copilot-win32-x64": "0.0.403"
}
},
"node_modules/@github/copilot-darwin-arm64": {
"version": "0.0.403",
"resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-0.0.403.tgz",
"integrity": "sha512-dOw8IleA0d1soHnbr/6wc6vZiYWNTKMgfTe/NET1nCfMzyKDt/0F0I7PT5y+DLujJknTla/ZeEmmBUmliTW4Cg==",
"cpu": [
"arm64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"darwin"
],
"bin": {
"copilot-darwin-arm64": "copilot"
}
},
"node_modules/@github/copilot-darwin-x64": {
"version": "0.0.403",
"resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-0.0.403.tgz",
"integrity": "sha512-aK2jSNWgY8eiZ+TmrvGhssMCPDTKArc0ip6Ul5OaslpytKks8hyXoRbxGD0N9sKioSUSbvKUf+1AqavbDpJO+w==",
"cpu": [
"x64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"darwin"
],
"bin": {
"copilot-darwin-x64": "copilot"
}
},
"node_modules/@github/copilot-linux-arm64": {
"version": "0.0.403",
"resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-0.0.403.tgz",
"integrity": "sha512-KhoR2iR70O6vCkzf0h8/K+p82qAgOvMTgAPm9bVEHvbdGFR7Py9qL5v03bMbPxsA45oNaZAkzDhfTAqWhIAZsQ==",
"cpu": [
"arm64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"linux"
],
"bin": {
"copilot-linux-arm64": "copilot"
}
},
"node_modules/@github/copilot-linux-x64": {
"version": "0.0.403",
"resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-0.0.403.tgz",
"integrity": "sha512-eoswUc9vo4TB+/9PgFJLVtzI4dPjkpJXdCsAioVuoqPdNxHxlIHFe9HaVcqMRZxUNY1YHEBZozy+IpUEGjgdfQ==",
"cpu": [
"x64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"linux"
],
"bin": {
"copilot-linux-x64": "copilot"
}
},
"node_modules/@github/copilot-sdk": {
"version": "0.1.23",
"resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-0.1.23.tgz",
"integrity": "sha512-0by81bsBQlDKE5VbcegZfUMvPyPm1aXwSGS2rGaMAFxv3ps+dACf1Voruxik7hQTae0ziVFJjuVrlxZoRaXBLw==",
"license": "MIT",
"dependencies": {
"@github/copilot": "^0.0.403",
"vscode-jsonrpc": "^8.2.1",
"zod": "^4.3.6"
},
"engines": {
"node": ">=24.0.0"
}
},
"node_modules/@github/copilot-sdk/node_modules/zod": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/@github/copilot-win32-arm64": {
"version": "0.0.403",
"resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-0.0.403.tgz",
"integrity": "sha512-djWjzCsp2xPNafMyOZ/ivU328/WvWhdroGie/DugiJBTgQL2SP0quWW1fhTlDwE81a3g9CxfJonaRgOpFTJTcg==",
"cpu": [
"arm64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"win32"
],
"bin": {
"copilot-win32-arm64": "copilot.exe"
}
},
"node_modules/@github/copilot-win32-x64": {
"version": "0.0.403",
"resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-0.0.403.tgz",
"integrity": "sha512-lju8cHy2E6Ux7R7tWyLZeksYC2MVZu9i9ocjiBX/qfG2/pNJs7S5OlkwKJ0BSXSbZEHQYq7iHfEWp201bVfk9A==",
"cpu": [
"x64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"win32"
],
"bin": {
"copilot-win32-x64": "copilot.exe"
}
},
"node_modules/@hapi/address": { "node_modules/@hapi/address": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/@hapi/address/-/address-5.1.1.tgz", "resolved": "https://registry.npmjs.org/@hapi/address/-/address-5.1.1.tgz",
@@ -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": { "node_modules/vue": {
"version": "3.5.28", "version": "3.5.28",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.28.tgz", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.28.tgz",

View File

@@ -53,7 +53,6 @@
}, },
"dependencies": { "dependencies": {
"@floating-ui/dom": "^1.7.5", "@floating-ui/dom": "^1.7.5",
"@github/copilot-sdk": "^0.1.23",
"@libsql/client": "^0.4.0", "@libsql/client": "^0.4.0",
"@milkdown/kit": "^7.18.0", "@milkdown/kit": "^7.18.0",
"@milkdown/plugin-block": "^7.18.0", "@milkdown/plugin-block": "^7.18.0",

View File

@@ -113,7 +113,7 @@ export const chatConversations = sqliteTable('chat_conversations', {
id: text('id').primaryKey(), id: text('id').primaryKey(),
title: text('title').notNull(), title: text('title').notNull(),
model: text('model'), // Model used for this conversation 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(), createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
}); });

View File

@@ -14,7 +14,6 @@ export interface ChatConversationData {
id: string; id: string;
title: string; title: string;
model?: string; model?: string;
copilotSessionId?: string;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
} }
@@ -53,7 +52,7 @@ export class ChatEngine {
const id = `chat_${uuidv4()}`; const id = `chat_${uuidv4()}`;
const title = input.title || 'New Chat'; const title = input.title || 'New Chat';
const model = input.model || 'gpt-4.1'; const model = input.model || 'claude-sonnet-4';
const now = Date.now(); const now = Date.now();
await client.execute({ await client.execute({
@@ -103,7 +102,6 @@ export class ChatEngine {
id: row.id as string, id: row.id as string,
title: row.title as string, title: row.title as string,
model: row.model as string | undefined, model: row.model as string | undefined,
copilotSessionId: row.copilot_session_id as string | undefined,
createdAt: new Date(row.created_at as number), createdAt: new Date(row.created_at as number),
updatedAt: new Date(row.updated_at as number), updatedAt: new Date(row.updated_at as number),
}; };
@@ -144,7 +142,6 @@ export class ChatEngine {
id: row.id as string, id: row.id as string,
title: row.title as string, title: row.title as string,
model: row.model as string | undefined, model: row.model as string | undefined,
copilotSessionId: row.copilot_session_id as string | undefined,
createdAt: new Date(row.created_at as number), createdAt: new Date(row.created_at as number),
updatedAt: new Date(row.updated_at as number), updatedAt: new Date(row.updated_at as number),
})); }));
@@ -153,7 +150,7 @@ export class ChatEngine {
/** /**
* Update a conversation's metadata * Update a conversation's metadata
*/ */
async updateConversation(id: string, updates: Partial<Pick<ChatConversationData, 'title' | 'model' | 'copilotSessionId'>>): Promise<void> { async updateConversation(id: string, updates: Partial<Pick<ChatConversationData, 'title' | 'model'>>): Promise<void> {
const client = this.db.getLocalClient(); const client = this.db.getLocalClient();
if (!client) { if (!client) {
throw new Error('Database not initialized'); throw new Error('Database not initialized');
@@ -170,10 +167,6 @@ export class ChatEngine {
setClauses.push('model = ?'); setClauses.push('model = ?');
args.push(updates.model); args.push(updates.model);
} }
if (updates.copilotSessionId !== undefined) {
setClauses.push('copilot_session_id = ?');
args.push(updates.copilotSessionId);
}
args.push(id); 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: You have access to tools that allow you to:
- Search for posts using full-text search with optional category/tag filters - Search for posts using full-text search with optional category/tag filters
- Read individual post content and metadata - Read individual post content and metadata
- List and filter posts by status, category, or tags
- View information about media files (images) - View information about media files (images)
- Update metadata for posts and media files - 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: 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 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.`; Be concise but thorough in your responses. When displaying post information, format it clearly.`;
} }
/**
* Get a setting by key
*/
async getSetting(key: string): Promise<string | null> {
const client = this.db.getLocalClient();
if (!client) return null;
const result = await client.execute({
sql: `SELECT value FROM settings WHERE key = ?`,
args: [key],
});
if (result.rows.length > 0) {
return result.rows[0].value as string;
}
return null;
}
/**
* Set a setting by key
*/
async setSetting(key: string, value: string): Promise<void> {
const client = this.db.getLocalClient();
if (!client) {
throw new Error('Database not initialized');
}
const now = Date.now();
await client.execute({
sql: `INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, ?)`,
args: [key, value, now],
});
}
/** /**
* Get selected model for new conversations * Get selected model for new conversations
*/ */
async getSelectedModel(): Promise<string> { async getSelectedModel(): Promise<string> {
const client = this.db.getLocalClient(); const client = this.db.getLocalClient();
if (!client) { if (!client) {
return 'gpt-4.1'; return 'claude-sonnet-4';
} }
const result = await client.execute({ 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 result.rows[0].value as string;
} }
return 'gpt-4.1'; return 'claude-sonnet-4';
} }
/** /**

View File

@@ -1,986 +0,0 @@
/**
* CopilotManager - Handles AI chat using GitHub Copilot SDK
*
* Provides native Copilot integration with custom tool support for:
* - Searching posts with full-text search and filters
* - Reading post content and metadata
* - Viewing media file information
* - Updating post and media metadata
*/
import { BrowserWindow, app } from 'electron';
import { spawn } from 'child_process';
import path from 'path';
import { ChatEngine, ChatMessageData } from './ChatEngine';
import { PostEngine } from './PostEngine';
import { MediaEngine } from './MediaEngine';
// ESM modules - loaded dynamically
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let CopilotClient: any = null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let defineTool: any = null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let z: any = null;
let modulesLoaded = false;
// Dynamic import that bypasses TypeScript transformation to properly handle ESM
// TypeScript compiles import() to require() in CommonJS, which breaks ESM-only packages
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const dynamicImport = new Function('specifier', 'return import(specifier)') as (specifier: string) => Promise<any>;
/**
* Load ESM modules dynamically (required for CommonJS compatibility)
*/
async function loadModules(): Promise<boolean> {
if (modulesLoaded) return true;
try {
// Use dynamicImport to bypass TypeScript's transformation of import() to require()
const copilotSdk = await dynamicImport('@github/copilot-sdk');
CopilotClient = copilotSdk.CopilotClient;
defineTool = copilotSdk.defineTool;
const zod = await dynamicImport('zod');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
z = (zod as any).z || (zod as any).default?.z || zod;
modulesLoaded = true;
console.log('[CopilotManager] ESM modules loaded successfully');
return true;
} catch (error) {
console.error('[CopilotManager] Failed to load ESM modules:', error);
return false;
}
}
interface CopilotSession {
sessionId: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
session: any;
conversationId: string;
isResumed?: boolean;
model?: string;
}
export interface SendMessageOptions {
onDelta?: (delta: string) => void;
onToolCall?: (toolCall: { name: string; args: unknown }) => void;
onToolResult?: (result: { name: string; result: unknown }) => void;
}
export interface SendMessageResult {
success: boolean;
message?: string;
error?: string;
toolCalls?: Array<{ name: string; args: unknown }>;
}
export class CopilotManager {
private chatEngine: ChatEngine;
private postEngine: PostEngine;
private mediaEngine: MediaEngine;
private getMainWindow: () => BrowserWindow | null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private client: any = null;
private sessions: Map<string, CopilotSession> = new Map();
private isInitialized = false;
private initError: string | null = null;
private initPromise: Promise<{ success: boolean; error?: string }> | null = null;
private isStopping = false;
constructor(
chatEngine: ChatEngine,
postEngine: PostEngine,
mediaEngine: MediaEngine,
getMainWindow: () => BrowserWindow | null
) {
this.chatEngine = chatEngine;
this.postEngine = postEngine;
this.mediaEngine = mediaEngine;
this.getMainWindow = getMainWindow;
}
/**
* Get tools for the Copilot SDK
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private getToolsForCopilot(): any[] {
if (!modulesLoaded || !defineTool || !z) {
console.warn('[CopilotManager] Modules not loaded, returning empty tools list');
return [];
}
const tools = [
// Search posts with full-text search and filters
defineTool('search_posts', {
description: 'Search blog posts using full-text search. Can filter by category or tags. Returns matching posts with their metadata.',
parameters: z.object({
query: z.string().describe('The search query text to find in posts'),
category: z.string().optional().describe('Optional category to filter by (e.g., "article", "picture", "aside", "page")'),
tags: z.array(z.string()).optional().describe('Optional array of tags to filter by'),
limit: z.number().optional().describe('Maximum number of results to return (default: 10)'),
}),
handler: async (args: { query: string; category?: string; tags?: string[]; limit?: number }) => {
try {
// Get search results (returns id, title, slug, excerpt)
const searchResults = await this.postEngine.searchPosts(args.query);
// Fetch full post data for filtering
const fullPosts = await Promise.all(
searchResults.map(sr => this.postEngine.getPost(sr.id))
);
// Filter by category and tags
let filteredPosts = fullPosts.filter(p => p !== null);
if (args.category) {
filteredPosts = filteredPosts.filter(p => p!.categories.includes(args.category!));
}
if (args.tags && args.tags.length > 0) {
filteredPosts = filteredPosts.filter(p =>
args.tags!.every(tag => p!.tags.includes(tag))
);
}
// Apply limit
const limit = args.limit || 10;
filteredPosts = filteredPosts.slice(0, limit);
return {
success: true,
count: filteredPosts.length,
posts: filteredPosts.map(p => ({
id: p!.id,
title: p!.title,
slug: p!.slug,
excerpt: p!.excerpt,
status: p!.status,
categories: p!.categories,
tags: p!.tags,
createdAt: p!.createdAt,
updatedAt: p!.updatedAt,
})),
};
} catch (error) {
return { success: false, error: (error as Error).message };
}
},
}),
// Read a single post
defineTool('read_post', {
description: 'Read the full content and metadata of a specific blog post by its ID.',
parameters: z.object({
postId: z.string().describe('The unique ID of the post to read'),
}),
handler: async (args: { postId: string }) => {
try {
const post = await this.postEngine.getPost(args.postId);
if (!post) {
return { success: false, error: 'Post not found' };
}
return {
success: true,
post: {
id: post.id,
title: post.title,
slug: post.slug,
content: post.content,
excerpt: post.excerpt,
status: post.status,
author: post.author,
categories: post.categories,
tags: post.tags,
createdAt: post.createdAt,
updatedAt: post.updatedAt,
publishedAt: post.publishedAt,
},
};
} catch (error) {
return { success: false, error: (error as Error).message };
}
},
}),
// Get media file information
defineTool('get_media', {
description: 'Get information about a specific media file (image) by its ID.',
parameters: z.object({
mediaId: z.string().describe('The unique ID of the media file'),
}),
handler: async (args: { mediaId: string }) => {
try {
const media = await this.mediaEngine.getMedia(args.mediaId);
if (!media) {
return { success: false, error: 'Media not found' };
}
return {
success: true,
media: {
id: media.id,
filename: media.filename,
originalName: media.originalName,
mimeType: media.mimeType,
size: media.size,
width: media.width,
height: media.height,
alt: media.alt,
caption: media.caption,
tags: media.tags,
createdAt: media.createdAt,
updatedAt: media.updatedAt,
},
};
} catch (error) {
return { success: false, error: (error as Error).message };
}
},
}),
// List all media files
defineTool('list_media', {
description: 'List all media files in the current project with optional filtering.',
parameters: z.object({
mimeTypeFilter: z.string().optional().describe('Filter by MIME type prefix (e.g., "image/")'),
limit: z.number().optional().describe('Maximum number of results (default: 20)'),
}),
handler: async (args: { mimeTypeFilter?: string; limit?: number }) => {
try {
let mediaList = await this.mediaEngine.getAllMedia();
if (args.mimeTypeFilter) {
mediaList = mediaList.filter(m => m.mimeType.startsWith(args.mimeTypeFilter!));
}
const limit = args.limit || 20;
mediaList = mediaList.slice(0, limit);
return {
success: true,
count: mediaList.length,
media: mediaList.map(m => ({
id: m.id,
filename: m.filename,
originalName: m.originalName,
mimeType: m.mimeType,
alt: m.alt,
tags: m.tags,
})),
};
} catch (error) {
return { success: false, error: (error as Error).message };
}
},
}),
// Update post metadata
defineTool('update_post_metadata', {
description: 'Update metadata for a blog post (title, excerpt, tags, categories). Does NOT update post content.',
parameters: z.object({
postId: z.string().describe('The unique ID of the post to update'),
title: z.string().optional().describe('New title for the post'),
excerpt: z.string().optional().describe('New excerpt/summary for the post'),
tags: z.array(z.string()).optional().describe('New tags for the post'),
categories: z.array(z.string()).optional().describe('New categories for the post'),
}),
handler: async (args: { postId: string; title?: string; excerpt?: string; tags?: string[]; categories?: string[] }) => {
try {
const updates: Record<string, unknown> = {};
if (args.title !== undefined) updates.title = args.title;
if (args.excerpt !== undefined) updates.excerpt = args.excerpt;
if (args.tags !== undefined) updates.tags = args.tags;
if (args.categories !== undefined) updates.categories = args.categories;
if (Object.keys(updates).length === 0) {
return { success: false, error: 'No updates provided' };
}
await this.postEngine.updatePost(args.postId, updates);
return { success: true, message: `Post ${args.postId} metadata updated successfully` };
} catch (error) {
return { success: false, error: (error as Error).message };
}
},
}),
// Update media metadata
defineTool('update_media_metadata', {
description: 'Update metadata for a media file (alt text, caption, tags).',
parameters: z.object({
mediaId: z.string().describe('The unique ID of the media to update'),
alt: z.string().optional().describe('New alt text for the image'),
caption: z.string().optional().describe('New caption for the image'),
tags: z.array(z.string()).optional().describe('New tags for the media'),
}),
handler: async (args: { mediaId: string; alt?: string; caption?: string; tags?: string[] }) => {
try {
const updates: Record<string, unknown> = {};
if (args.alt !== undefined) updates.alt = args.alt;
if (args.caption !== undefined) updates.caption = args.caption;
if (args.tags !== undefined) updates.tags = args.tags;
if (Object.keys(updates).length === 0) {
return { success: false, error: 'No updates provided' };
}
await this.mediaEngine.updateMedia(args.mediaId, updates);
return { success: true, message: `Media ${args.mediaId} metadata updated successfully` };
} catch (error) {
return { success: false, error: (error as Error).message };
}
},
}),
// List posts with filtering
defineTool('list_posts', {
description: 'List blog posts with optional filtering by status, category, or tags.',
parameters: z.object({
status: z.enum(['draft', 'published', 'archived']).optional().describe('Filter by post status'),
category: z.string().optional().describe('Filter by category'),
tags: z.array(z.string()).optional().describe('Filter by tags (posts must have all specified tags)'),
limit: z.number().optional().describe('Maximum number of results (default: 20)'),
offset: z.number().optional().describe('Offset for pagination (default: 0)'),
}),
handler: async (args: { status?: 'draft' | 'published' | 'archived'; category?: string; tags?: string[]; limit?: number; offset?: number }) => {
try {
// Use getPostsFiltered for filtering
const filter: { status?: 'draft' | 'published' | 'archived'; tags?: string[]; categories?: string[] } = {};
if (args.status) filter.status = args.status;
if (args.tags) filter.tags = args.tags;
if (args.category) filter.categories = [args.category];
let posts;
if (Object.keys(filter).length > 0) {
posts = await this.postEngine.getPostsFiltered(filter);
} else {
const result = await this.postEngine.getAllPosts({
limit: args.limit || 20,
offset: args.offset || 0,
});
posts = result.items;
}
// Apply offset/limit for filtered results
const offset = args.offset || 0;
const limit = args.limit || 20;
const slicedPosts = posts.slice(offset, offset + limit);
return {
success: true,
count: slicedPosts.length,
total: posts.length,
hasMore: offset + limit < posts.length,
posts: slicedPosts.map(p => ({
id: p.id,
title: p.title,
slug: p.slug,
status: p.status,
categories: p.categories,
tags: p.tags,
createdAt: p.createdAt,
updatedAt: p.updatedAt,
})),
};
} catch (error) {
return { success: false, error: (error as Error).message };
}
},
}),
];
return tools;
}
/**
* Initialize the Copilot client
* Uses a lock to prevent concurrent initialization attempts
*/
async initialize(): Promise<{ success: boolean; error?: string }> {
// Already initialized
if (this.isInitialized && this.client) {
return { success: true };
}
// If stopping, don't initialize
if (this.isStopping) {
return { success: false, error: 'Client is stopping' };
}
// If already initializing, wait for that to complete
if (this.initPromise) {
return this.initPromise;
}
// Start initialization
this.initPromise = this.doInitialize();
const result = await this.initPromise;
this.initPromise = null;
return result;
}
private async doInitialize(): Promise<{ success: boolean; error?: string }> {
try {
console.log('[CopilotManager] Initializing Copilot client...');
const loaded = await loadModules();
if (!loaded) {
return { success: false, error: 'Failed to load Copilot SDK modules' };
}
// Check again in case stop was called during module loading
if (this.isStopping) {
return { success: false, error: 'Client is stopping' };
}
console.log('[CopilotManager] Creating CopilotClient instance...');
this.client = new CopilotClient({
autoStart: true,
autoRestart: true,
useLoggedInUser: true,
logLevel: 'info',
});
console.log('[CopilotManager] Starting client...');
await this.client.start();
console.log('[CopilotManager] Client started successfully');
this.isInitialized = true;
this.initError = null;
console.log('[CopilotManager] Copilot client initialized successfully');
return { success: true };
} catch (error) {
console.error('[CopilotManager] Failed to initialize Copilot client:', error);
this.initError = (error as Error).message;
this.isInitialized = false;
return { success: false, error: (error as Error).message };
}
}
/**
* Check if Copilot is ready
*/
async checkReady(): Promise<{ ready: boolean; error?: string; requiresCLI?: boolean }> {
try {
if (!this.isInitialized) {
const result = await this.initialize();
if (!result.success) {
const isCLIError =
result.error?.includes('copilot') ||
result.error?.includes('ENOENT') ||
result.error?.includes('spawn');
return {
ready: false,
error: result.error,
requiresCLI: isCLIError,
};
}
}
await this.client.ping();
return { ready: true };
} catch (error) {
console.error('[CopilotManager] Ready check failed:', error);
return {
ready: false,
error: (error as Error).message,
requiresCLI:
(error as Error).message?.includes('copilot') ||
(error as Error).message?.includes('ENOENT'),
};
}
}
/**
* Get or create a session for a conversation
*/
private async getOrCreateSession(
conversationId: string,
options: { model?: string; systemMessage?: string; copilotSessionId?: string; forceNewSession?: boolean } = {}
): Promise<CopilotSession> {
console.log(`[CopilotManager] getOrCreateSession called with model: ${options.model || 'default (gpt-4.1)'}`);
if (this.sessions.has(conversationId)) {
const existingSession = this.sessions.get(conversationId)!;
if (options.model && existingSession.model !== options.model) {
console.log(`[CopilotManager] Model changed, creating new session`);
this.sessions.delete(conversationId);
} else {
return existingSession;
}
}
if (!this.isInitialized) {
await this.initialize();
}
const tools = this.getToolsForCopilot();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let session: any;
let isResumed = false;
// Try to resume existing session
if (options.copilotSessionId && !options.forceNewSession) {
try {
console.log(`[CopilotManager] Resuming session: ${options.copilotSessionId}`);
session = await this.client.resumeSession(options.copilotSessionId, {
streaming: true,
tools,
});
isResumed = true;
console.log(`[CopilotManager] Session resumed successfully`);
} catch (error) {
console.log(`[CopilotManager] Failed to resume session, creating new: ${(error as Error).message}`);
session = null;
}
}
// Create new session if needed
if (!session) {
const modelToUse = options.model || 'gpt-4.1';
console.log(`[CopilotManager] Creating new session for conversation: ${conversationId} with model: ${modelToUse}`);
session = await this.client.createSession({
model: modelToUse,
streaming: true,
tools,
systemMessage: options.systemMessage
? {
content: options.systemMessage,
}
: undefined,
});
console.log(`[CopilotManager] New session created: ${session.sessionId}`);
// Store session ID in database
try {
await this.chatEngine.updateConversation(conversationId, {
copilotSessionId: session.sessionId,
});
} catch (error) {
console.error(`[CopilotManager] Failed to save session ID: ${(error as Error).message}`);
}
}
const copilotSession: CopilotSession = {
sessionId: session.sessionId,
session,
conversationId,
isResumed,
model: options.model || 'gpt-4.1',
};
this.sessions.set(conversationId, copilotSession);
return copilotSession;
}
/**
* Send a message to a conversation
*/
async sendMessage(
conversationId: string,
userMessage: string,
options: SendMessageOptions = {}
): Promise<SendMessageResult> {
const { onDelta, onToolCall, onToolResult } = options;
try {
const readyCheck = await this.checkReady();
if (!readyCheck.ready) {
return { success: false, error: readyCheck.error };
}
// Get conversation from database
const conversation = await this.chatEngine.getConversation(conversationId);
if (!conversation) {
return { success: false, error: 'Conversation not found' };
}
// Get or create session
const systemMessage = conversation.messages.find(m => m.role === 'system');
const copilotSession = await this.getOrCreateSession(conversationId, {
model: conversation.model,
systemMessage: systemMessage?.content,
copilotSessionId: conversation.copilotSessionId,
});
// Add user message to database
await this.chatEngine.addMessage({
conversationId,
role: 'user',
content: userMessage,
createdAt: new Date(),
});
// Collect response
let fullResponse = '';
const toolCalls: Array<{ name: string; args: unknown }> = [];
// Set up event handlers
const unsubscribeDelta = copilotSession.session.on('assistant.message_delta', (event: { data: { deltaContent?: string } }) => {
fullResponse += event.data.deltaContent || '';
if (onDelta) {
onDelta(event.data.deltaContent || '');
}
});
const unsubscribeToolStart = copilotSession.session.on('tool.execution_start', (event: { data?: { toolName?: string; args?: unknown } }) => {
if (onToolCall) {
onToolCall({
name: event.data?.toolName || 'unknown',
args: event.data?.args,
});
}
toolCalls.push({
name: event.data?.toolName || 'unknown',
args: event.data?.args,
});
});
const unsubscribeToolEnd = copilotSession.session.on('tool.execution_end', (event: { data?: { toolName?: string; result?: unknown } }) => {
if (onToolResult) {
onToolResult({
name: event.data?.toolName || 'unknown',
result: event.data?.result,
});
}
});
try {
// Send message and wait for completion (5 minute timeout)
const response = await copilotSession.session.sendAndWait(
{
prompt: userMessage,
},
300000
);
// Use final content if available
if (response?.data?.content) {
fullResponse = response.data.content;
}
// Save assistant response
if (fullResponse) {
await this.chatEngine.addMessage({
conversationId,
role: 'assistant',
content: fullResponse,
toolCalls: toolCalls.length > 0 ? JSON.stringify(toolCalls) : undefined,
createdAt: new Date(),
});
}
// Generate title after first exchange
const userMsgCount = conversation.messages.filter(m => m.role === 'user').length;
if (userMsgCount === 0 && fullResponse) {
this.generateConversationTitle(conversationId, userMessage, fullResponse).catch(err =>
console.error('[CopilotManager] Error generating title:', err)
);
}
return {
success: true,
message: fullResponse,
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
};
} finally {
unsubscribeDelta();
unsubscribeToolStart();
unsubscribeToolEnd();
}
} catch (error) {
console.error('[CopilotManager] Error sending message:', error);
return { success: false, error: (error as Error).message };
}
}
/**
* Generate a title for a conversation based on first exchange
*/
private async generateConversationTitle(conversationId: string, userMessage: string, assistantResponse: string): Promise<void> {
try {
// Create a quick session just for title generation
const titleSession = await this.client.createSession({
model: 'gpt-4.1-mini',
streaming: false,
});
const response = await titleSession.sendAndWait({
prompt: `Generate a short, concise title (max 6 words) for this conversation. Only output the title, nothing else.
User: ${userMessage.substring(0, 200)}
Assistant: ${assistantResponse.substring(0, 200)}`,
});
if (response?.data?.content) {
const title = response.data.content.trim().replace(/^["']|["']$/g, '');
await this.chatEngine.updateConversation(conversationId, { title });
// Notify UI of title update
const mainWindow = this.getMainWindow();
if (mainWindow) {
mainWindow.webContents.send('chat-title-updated', { conversationId, title });
}
}
await titleSession.destroy();
} catch (error) {
console.error('[CopilotManager] Error generating title:', error);
}
}
/**
* Abort a currently running message
*/
async abortMessage(conversationId: string): Promise<{ success: boolean; error?: string }> {
const copilotSession = this.sessions.get(conversationId);
if (!copilotSession) {
return { success: false, error: 'No active session for this conversation' };
}
try {
await copilotSession.session.abort();
console.log('[CopilotManager] Aborted message for conversation:', conversationId);
return { success: true };
} catch (error) {
console.error('[CopilotManager] Error aborting message:', error);
return { success: false, error: (error as Error).message };
}
}
/**
* Destroy a session
*/
async destroySession(conversationId: string): Promise<void> {
const copilotSession = this.sessions.get(conversationId);
if (copilotSession) {
try {
await copilotSession.session.destroy();
} catch (error) {
console.error('[CopilotManager] Error destroying session:', error);
}
this.sessions.delete(conversationId);
}
}
/**
* Get authentication status
* Does not auto-initialize - returns unauthenticated if client not ready
*/
async getAuthStatus(): Promise<{ isAuthenticated: boolean; authType?: string; login?: string; error?: string }> {
try {
// Don't auto-initialize on auth check - let frontend trigger login explicitly
if (!this.isInitialized || !this.client) {
return { isAuthenticated: false };
}
const authStatus = await this.client.getAuthStatus();
return {
isAuthenticated: authStatus.isAuthenticated,
authType: authStatus.authType,
login: authStatus.login,
};
} catch (error) {
console.error('[CopilotManager] Error getting auth status:', error);
return { isAuthenticated: false, error: (error as Error).message };
}
}
/**
* Get CLI path for the native Copilot binary
*/
private getCLIPath(): string {
const platform = process.platform;
const arch = process.arch;
const packageName = `@github/copilot-${platform}-${arch}`;
const exeName = platform === 'win32' ? 'copilot.exe' : 'copilot';
// In production (packaged app), node_modules is in resources/app.asar.unpacked
// In development, it's in the project root
const isPackaged = app.isPackaged;
let cliPath: string;
if (isPackaged) {
// In packaged app, asarUnpack puts these in app.asar.unpacked
const resourcesPath = process.resourcesPath;
cliPath = path.join(resourcesPath, 'app.asar.unpacked', 'node_modules', packageName, exeName);
} else {
// In development
cliPath = path.join(__dirname, '..', '..', '..', 'node_modules', packageName, exeName);
}
console.log('[CopilotManager] CLI path:', cliPath);
return cliPath;
}
/**
* Trigger interactive login using the CLI
*/
async triggerLogin(options: {
onDeviceCode?: (deviceCode: { verificationUri: string; userCode: string }) => void;
onMessage?: (message: string) => void;
} = {}): Promise<{ success: boolean; error?: string; login?: string }> {
const { onDeviceCode, onMessage } = options;
return new Promise((resolve) => {
const cliPath = this.getCLIPath();
console.log('[CopilotManager] Triggering interactive login...');
if (onMessage) onMessage('Starting authentication...');
// Spawn the CLI with 'login' command
const cliProcess = spawn(cliPath, ['login'], {
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env }
});
let stdout = '';
let stderr = '';
let deviceCodeSent = false;
cliProcess.stdout.on('data', (data: Buffer) => {
const text = data.toString();
stdout += text;
console.log('[CopilotManager] CLI stdout:', text);
// Parse for device code information
if (!deviceCodeSent) {
const urlMatch = text.match(/https:\/\/github\.com\/login\/device/);
const codeMatch = text.match(/code[:\s]+([A-Z0-9]{4}-[A-Z0-9]{4})/i);
if (urlMatch && codeMatch) {
deviceCodeSent = true;
if (onDeviceCode) {
onDeviceCode({
verificationUri: 'https://github.com/login/device',
userCode: codeMatch[1]
});
}
}
}
if (onMessage) {
onMessage(text.trim());
}
});
cliProcess.stderr.on('data', (data: Buffer) => {
const text = data.toString();
stderr += text;
console.log('[CopilotManager] CLI stderr:', text);
// Some CLIs output to stderr for prompts
if (!deviceCodeSent) {
const urlMatch = text.match(/https:\/\/github\.com\/login\/device/);
const codeMatch = text.match(/code[:\s]+([A-Z0-9]{4}-[A-Z0-9]{4})/i);
if (urlMatch && codeMatch) {
deviceCodeSent = true;
if (onDeviceCode) {
onDeviceCode({
verificationUri: 'https://github.com/login/device',
userCode: codeMatch[1]
});
}
}
}
if (onMessage) {
onMessage(text.trim());
}
});
cliProcess.on('close', (code: number) => {
console.log('[CopilotManager] CLI login process exited with code:', code);
if (code === 0) {
// Re-initialize client to pick up new credentials
this.isInitialized = false;
this.client = null;
resolve({ success: true });
} else {
resolve({ success: false, error: stderr || `Login failed with code ${code}` });
}
});
cliProcess.on('error', (error: Error) => {
console.error('[CopilotManager] CLI spawn error:', error);
resolve({ success: false, error: error.message });
});
});
}
/**
* Logout from Copilot (stops client - no CLI logout command exists)
*/
async logout(): Promise<{ success: boolean; error?: string }> {
console.log('[CopilotManager] Logging out (stopping client)...');
try {
await this.stop();
return { success: true };
} catch (error) {
console.error('[CopilotManager] Error during logout:', error);
return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
}
}
/**
* Get available models
*/
async getAvailableModels(): Promise<string[]> {
if (!this.isInitialized) {
await this.initialize();
}
try {
const models = await this.client.getAvailableModels();
return models || ['gpt-4.1', 'gpt-4.1-mini', 'claude-sonnet-4'];
} catch (error) {
console.error('[CopilotManager] Error getting models:', error);
return ['gpt-4.1', 'gpt-4.1-mini', 'claude-sonnet-4'];
}
}
/**
* Stop the client
*/
async stop(): Promise<void> {
// Set stopping flag to prevent new initialization attempts
this.isStopping = true;
// Wait for any pending initialization to complete (or fail)
if (this.initPromise) {
try {
await this.initPromise;
} catch {
// Ignore initialization errors during shutdown
}
}
// Clear sessions first
for (const [conversationId] of this.sessions) {
try {
await this.destroySession(conversationId);
} catch {
// Ignore errors during cleanup
}
}
this.sessions.clear();
if (this.client) {
try {
await this.client.stop();
} catch {
// Ignore errors during cleanup - client may not be fully initialized
}
this.client = null;
this.isInitialized = false;
}
}
}

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,16 +1,16 @@
/** /**
* Chat IPC handlers - AI chat functionality using GitHub Copilot SDK * Chat IPC handlers - AI chat functionality using OpenCode Zen API
*/ */
import { ipcMain, BrowserWindow } from 'electron'; import { ipcMain, BrowserWindow } from 'electron';
import { ChatEngine } from '../engine/ChatEngine'; import { ChatEngine } from '../engine/ChatEngine';
import { CopilotManager } from '../engine/CopilotManager'; import { OpenCodeManager } from '../engine/OpenCodeManager';
import { getPostEngine } from '../engine/PostEngine'; import { getPostEngine } from '../engine/PostEngine';
import { getMediaEngine } from '../engine/MediaEngine'; import { getMediaEngine } from '../engine/MediaEngine';
import { getDatabase } from '../database'; import { getDatabase } from '../database';
let chatEngine: ChatEngine | null = null; let chatEngine: ChatEngine | null = null;
let copilotManager: CopilotManager | null = null; let openCodeManager: OpenCodeManager | null = null;
let mainWindowGetter: (() => BrowserWindow | null) | 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 { function getOpenCodeManager(): OpenCodeManager {
if (!copilotManager) { if (!openCodeManager) {
copilotManager = new CopilotManager( openCodeManager = new OpenCodeManager(
getChatEngine(), getChatEngine(),
getPostEngine(), getPostEngine(),
getMediaEngine(), getMediaEngine(),
() => mainWindowGetter?.() || null () => 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 * Register all chat-related IPC handlers
*/ */
export function registerChatHandlers(): void { 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 () => { ipcMain.handle('chat:checkReady', async () => {
try { try {
const manager = getCopilotManager(); const manager = getOpenCodeManager();
const result = await manager.checkReady(); const result = await manager.checkReady();
return { return {
ready: result.ready, ready: result.ready,
error: result.error, error: result.error,
backend: result.ready ? 'copilot' : undefined, backend: 'opencode',
}; };
} catch (error) { } catch (error) {
console.error('[Chat IPC] Error checking ready:', error); console.error('[Chat IPC] Error checking ready:', error);
@@ -67,61 +75,47 @@ export function registerChatHandlers(): void {
} }
}); });
// Get Copilot authentication status // Validate API key
ipcMain.handle('chat:copilotAuthStatus', async () => { ipcMain.handle('chat:validateApiKey', async (_, apiKey: string) => {
try { try {
const manager = getCopilotManager(); const manager = getOpenCodeManager();
const status = await manager.getAuthStatus(); const result = await manager.validateApiKey(apiKey);
// Transform to match frontend ChatAuthStatus type
return {
authenticated: status.isAuthenticated,
username: status.login,
};
} catch (error) {
console.error('[Chat IPC] Error getting auth status:', error);
return { authenticated: false };
}
});
// Trigger Copilot login
ipcMain.handle('chat:copilotLogin', async () => {
try {
console.log('[Chat IPC] copilotLogin called');
const manager = getCopilotManager();
const mainWindow = mainWindowGetter?.();
console.log('[Chat IPC] Calling manager.triggerLogin()');
const result = await manager.triggerLogin({
onDeviceCode: (deviceCode) => {
console.log('[Chat IPC] Received device code, sending to renderer:', deviceCode);
if (mainWindow) {
mainWindow.webContents.send('copilot-device-code', deviceCode);
}
},
onMessage: (message) => {
console.log('[Chat IPC] Auth message:', message);
if (mainWindow) {
mainWindow.webContents.send('copilot-auth-message', { message });
}
},
});
console.log('[Chat IPC] triggerLogin result:', result);
return result; return result;
} catch (error) { } 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 }; return { success: false, error: (error as Error).message };
} }
}); });
// Logout from Copilot // Get API key (masked)
ipcMain.handle('chat:copilotLogout', async () => { ipcMain.handle('chat:getApiKey', async () => {
try { try {
const manager = getCopilotManager(); const manager = getOpenCodeManager();
return await manager.logout(); 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) { } catch (error) {
console.error('[Chat IPC] Error during logout:', error); console.error('[Chat IPC] Error getting API key:', error);
return { success: false, error: (error as Error).message }; return { hasKey: false, maskedKey: '' };
} }
}); });
@@ -130,7 +124,7 @@ export function registerChatHandlers(): void {
// Get available models // Get available models
ipcMain.handle('chat:getAvailableModels', async () => { ipcMain.handle('chat:getAvailableModels', async () => {
try { try {
const manager = getCopilotManager(); const manager = getOpenCodeManager();
const models = await manager.getAvailableModels(); const models = await manager.getAvailableModels();
const engine = getChatEngine(); const engine = getChatEngine();
const selectedModel = await engine.getSelectedModel(); const selectedModel = await engine.getSelectedModel();
@@ -238,11 +232,6 @@ export function registerChatHandlers(): void {
try { try {
const engine = getChatEngine(); const engine = getChatEngine();
await engine.deleteConversation(id); await engine.deleteConversation(id);
// Also destroy any active session
const manager = getCopilotManager();
await manager.destroySession(id);
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error('[Chat IPC] Error deleting conversation:', error); console.error('[Chat IPC] Error deleting conversation:', error);
@@ -255,7 +244,7 @@ export function registerChatHandlers(): void {
// Send a message // Send a message
ipcMain.handle('chat:sendMessage', async (_, conversationId: string, message: string) => { ipcMain.handle('chat:sendMessage', async (_, conversationId: string, message: string) => {
try { try {
const manager = getCopilotManager(); const manager = getOpenCodeManager();
const mainWindow = mainWindowGetter?.(); const mainWindow = mainWindowGetter?.();
const result = await manager.sendMessage(conversationId, message, { const result = await manager.sendMessage(conversationId, message, {
@@ -286,7 +275,7 @@ export function registerChatHandlers(): void {
// Abort a running message // Abort a running message
ipcMain.handle('chat:abortMessage', async (_, conversationId: string) => { ipcMain.handle('chat:abortMessage', async (_, conversationId: string) => {
try { try {
const manager = getCopilotManager(); const manager = getOpenCodeManager();
return await manager.abortMessage(conversationId); return await manager.abortMessage(conversationId);
} catch (error) { } catch (error) {
console.error('[Chat IPC] Error aborting message:', error); console.error('[Chat IPC] Error aborting message:', error);
@@ -334,9 +323,9 @@ export function registerChatHandlers(): void {
* Cleanup chat resources * Cleanup chat resources
*/ */
export async function cleanupChatHandlers(): Promise<void> { export async function cleanupChatHandlers(): Promise<void> {
if (copilotManager) { if (openCodeManager) {
await copilotManager.stop(); await openCodeManager.stop();
copilotManager = null; openCodeManager = null;
} }
chatEngine = null; chatEngine = null;
} }

View File

@@ -129,13 +129,13 @@ contextBridge.exposeInMainWorld('electronAPI', {
syncFromPosts: () => ipcRenderer.invoke('tags:syncFromPosts'), syncFromPosts: () => ipcRenderer.invoke('tags:syncFromPosts'),
}, },
// AI Chat (Copilot SDK integration) // AI Chat (OpenCode Zen API integration)
chat: { chat: {
// Authentication // API Key Management
checkReady: () => ipcRenderer.invoke('chat:checkReady'), checkReady: () => ipcRenderer.invoke('chat:checkReady'),
copilotAuthStatus: () => ipcRenderer.invoke('chat:copilotAuthStatus'), validateApiKey: (apiKey: string) => ipcRenderer.invoke('chat:validateApiKey', apiKey),
copilotLogin: () => ipcRenderer.invoke('chat:copilotLogin'), setApiKey: (apiKey: string) => ipcRenderer.invoke('chat:setApiKey', apiKey),
copilotLogout: () => ipcRenderer.invoke('chat:copilotLogout'), getApiKey: () => ipcRenderer.invoke('chat:getApiKey'),
// Settings // Settings
getAvailableModels: () => ipcRenderer.invoke('chat:getAvailableModels'), getAvailableModels: () => ipcRenderer.invoke('chat:getAvailableModels'),
@@ -178,11 +178,6 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.on('chat-title-updated', subscription); ipcRenderer.on('chat-title-updated', subscription);
return () => ipcRenderer.removeListener('chat-title-updated', subscription); return () => ipcRenderer.removeListener('chat-title-updated', subscription);
}, },
onDeviceCode: (callback: (data: { verificationUri: string; userCode: string }) => void) => {
const subscription = (_event: Electron.IpcRendererEvent, data: { verificationUri: string; userCode: string }) => callback(data);
ipcRenderer.on('copilot-device-code', subscription);
return () => ipcRenderer.removeListener('copilot-device-code', subscription);
},
}, },
// Event listeners // Event listeners
@@ -295,11 +290,11 @@ export interface ElectronAPI {
syncFromPosts: () => Promise<void>; syncFromPosts: () => Promise<void>;
}; };
chat: { chat: {
// Authentication // API Key Management
checkReady: () => Promise<{ ready: boolean; authenticated: boolean }>; checkReady: () => Promise<{ ready: boolean; error?: string; backend?: string }>;
copilotAuthStatus: () => Promise<{ authenticated: boolean; username?: string }>; validateApiKey: (apiKey: string) => Promise<{ isValid: boolean; models: Array<{ id: string; name: string }> }>;
copilotLogin: () => Promise<{ success: boolean; error?: string; login?: string }>; setApiKey: (apiKey: string) => Promise<{ success: boolean; error?: string }>;
copilotLogout: () => Promise<void>; getApiKey: () => Promise<{ hasKey: boolean; maskedKey: string }>;
// Settings // Settings
getAvailableModels: () => Promise<Array<{ id: string; name: string }>>; getAvailableModels: () => Promise<Array<{ id: string; name: string }>>;
@@ -326,7 +321,6 @@ export interface ElectronAPI {
onToolCall: (callback: (data: { conversationId: string; toolCall: unknown }) => void) => () => void; onToolCall: (callback: (data: { conversationId: string; toolCall: unknown }) => void) => () => void;
onToolResult: (callback: (data: { conversationId: string; result: unknown }) => void) => () => void; onToolResult: (callback: (data: { conversationId: string; result: unknown }) => void) => () => void;
onTitleUpdated: (callback: (data: { conversationId: string; title: string }) => void) => () => void; onTitleUpdated: (callback: (data: { conversationId: string; title: string }) => void) => () => void;
onDeviceCode: (callback: (data: { verificationUri: string; userCode: string }) => void) => () => void;
}; };
on: (channel: string, callback: (...args: unknown[]) => void) => () => void; on: (channel: string, callback: (...args: unknown[]) => void) => () => void;
once: (channel: string, callback: (...args: unknown[]) => void) => void; once: (channel: string, callback: (...args: unknown[]) => void) => void;

View File

@@ -344,3 +344,54 @@
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; 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;
}

View File

@@ -14,6 +14,10 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
const [streamingContent, setStreamingContent] = useState(''); const [streamingContent, setStreamingContent] = useState('');
const [availableModels, setAvailableModels] = useState<ChatModel[]>([]); const [availableModels, setAvailableModels] = useState<ChatModel[]>([]);
const [showModelSelector, setShowModelSelector] = useState(false); 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<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null); const inputRef = useRef<HTMLTextAreaElement>(null);
const streamingRef = useRef(''); const streamingRef = useRef('');
@@ -23,6 +27,20 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); 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 // Load conversation and messages
const loadData = useCallback(async () => { const loadData = useCallback(async () => {
try { try {
@@ -41,6 +59,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
}, [conversationId]); }, [conversationId]);
useEffect(() => { useEffect(() => {
checkReady();
loadData(); loadData();
// Subscribe to stream events // Subscribe to stream events
@@ -62,13 +81,36 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
unsubDelta?.(); unsubDelta?.();
unsubTitle?.(); unsubTitle?.();
}; };
}, [conversationId, loadData, scrollToBottom]); }, [conversationId, loadData, scrollToBottom, checkReady]);
// Scroll on new messages or streaming content // Scroll on new messages or streaming content
useEffect(() => { useEffect(() => {
scrollToBottom(); scrollToBottom();
}, [messages, streamingContent, 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 handleSend = async () => {
const message = inputValue.trim(); const message = inputValue.trim();
if (!message || isStreaming) return; if (!message || isStreaming) return;
@@ -144,7 +186,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
return ( return (
<div key={msg.id} className={`chat-message ${msg.role}`}> <div key={msg.id} className={`chat-message ${msg.role}`}>
<div className="chat-message-avatar"> <div className="chat-message-avatar">
{msg.role === 'user' ? '👤' : '🤖'} {msg.role === 'user' ? '\u{1F464}' : '\u{1F916}'}
</div> </div>
<div className="chat-message-content"> <div className="chat-message-content">
<div className="chat-message-header"> <div className="chat-message-header">
@@ -158,6 +200,43 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
); );
}; };
// API key setup screen
if (needsApiKey) {
return (
<div className="chat-panel">
<div className="chat-panel-header">
<div className="chat-panel-title">AI Chat Setup</div>
</div>
<div className="chat-messages">
<div className="chat-welcome">
<div className="chat-welcome-icon">{'\u{1F511}'}</div>
<h2>OpenCode Zen API Key Required</h2>
<p>Enter your OpenCode API key to enable AI chat.</p>
<div className="api-key-form">
<input
type="password"
className="api-key-input"
value={apiKeyInput}
onChange={(e) => setApiKeyInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleApiKeySubmit()}
placeholder="Enter your API key..."
disabled={isValidating}
/>
<button
className="api-key-submit"
onClick={handleApiKeySubmit}
disabled={!apiKeyInput.trim() || isValidating}
>
{isValidating ? 'Validating...' : 'Save Key'}
</button>
{apiKeyError && <div className="api-key-error">{apiKeyError}</div>}
</div>
</div>
</div>
</div>
);
}
return ( return (
<div className="chat-panel"> <div className="chat-panel">
<div className="chat-panel-header"> <div className="chat-panel-header">
@@ -169,8 +248,8 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
className="model-selector-button" className="model-selector-button"
onClick={() => setShowModelSelector(!showModelSelector)} onClick={() => setShowModelSelector(!showModelSelector)}
> >
{conversation?.model || 'gpt-4o'} {conversation?.model || 'claude-sonnet-4'}
<span className="model-dropdown-icon"></span> <span className="model-dropdown-icon">{'\u25BE'}</span>
</button> </button>
{showModelSelector && ( {showModelSelector && (
<div className="model-dropdown"> <div className="model-dropdown">
@@ -191,12 +270,13 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
<div className="chat-messages"> <div className="chat-messages">
{messages.length === 0 && !isStreaming && ( {messages.length === 0 && !isStreaming && (
<div className="chat-welcome"> <div className="chat-welcome">
<div className="chat-welcome-icon">🤖</div> <div className="chat-welcome-icon">{'\u{1F916}'}</div>
<h2>Welcome to the AI Assistant</h2> <h2>Welcome to the AI Assistant</h2>
<p>I can help you manage your posts and media. Try asking me to:</p> <p>I can help you manage your posts and media. Try asking me to:</p>
<ul> <ul>
<li>Search for posts about a specific topic</li> <li>Search for posts about a specific topic</li>
<li>Get details about a specific post</li> <li>Get details about a specific post</li>
<li>List all tags or categories in your blog</li>
<li>Update metadata for posts or media</li> <li>Update metadata for posts or media</li>
<li>List all images in your media library</li> <li>List all images in your media library</li>
</ul> </ul>
@@ -207,11 +287,11 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
{isStreaming && streamingContent && ( {isStreaming && streamingContent && (
<div className="chat-message assistant streaming"> <div className="chat-message assistant streaming">
<div className="chat-message-avatar">🤖</div> <div className="chat-message-avatar">{'\u{1F916}'}</div>
<div className="chat-message-content"> <div className="chat-message-content">
<div className="chat-message-header"> <div className="chat-message-header">
<span className="chat-message-role">Assistant</span> <span className="chat-message-role">Assistant</span>
<span className="streaming-indicator"></span> <span className="streaming-indicator">{'\u25CF'}</span>
</div> </div>
<div className="chat-message-text">{streamingContent}</div> <div className="chat-message-text">{streamingContent}</div>
</div> </div>
@@ -220,7 +300,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
{isStreaming && !streamingContent && ( {isStreaming && !streamingContent && (
<div className="chat-message assistant thinking"> <div className="chat-message assistant thinking">
<div className="chat-message-avatar">🤖</div> <div className="chat-message-avatar">{'\u{1F916}'}</div>
<div className="chat-message-content"> <div className="chat-message-content">
<div className="chat-thinking-indicator"> <div className="chat-thinking-indicator">
<span></span><span></span><span></span> <span></span><span></span><span></span>
@@ -235,7 +315,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
<div className="chat-input-container"> <div className="chat-input-container">
{isStreaming && ( {isStreaming && (
<button className="chat-abort-button" onClick={handleAbort}> <button className="chat-abort-button" onClick={handleAbort}>
Stop {'\u25FC'} Stop
</button> </button>
)} )}
<div className="chat-input-wrapper"> <div className="chat-input-wrapper">
@@ -254,7 +334,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
onClick={handleSend} onClick={handleSend}
disabled={!inputValue.trim() || isStreaming} disabled={!inputValue.trim() || isStreaming}
> >
{'\u2191'}
</button> </button>
</div> </div>
</div> </div>

View File

@@ -753,8 +753,7 @@ const ChatList: React.FC = () => {
const { openTab } = useAppStore(); const { openTab } = useAppStore();
const [conversations, setConversations] = useState<ChatConversation[]>([]); const [conversations, setConversations] = useState<ChatConversation[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [authStatus, setAuthStatus] = useState<{ authenticated: boolean; username?: string } | null>(null); const [isReady, setIsReady] = useState(false);
const [deviceCode, setDeviceCode] = useState<{ verificationUri: string; userCode: string } | null>(null);
// Load conversations // Load conversations
const loadConversations = useCallback(async () => { const loadConversations = useCallback(async () => {
@@ -768,20 +767,20 @@ const ChatList: React.FC = () => {
} }
}, []); }, []);
// Check auth status // Check if service is ready
const checkAuth = useCallback(async () => { const checkReady = useCallback(async () => {
try { try {
const status = await window.electronAPI?.chat.copilotAuthStatus(); const status = await window.electronAPI?.chat.checkReady();
setAuthStatus(status ?? null); setIsReady(status?.ready ?? false);
} catch (error) { } catch {
console.error('Failed to check auth:', error); setIsReady(false);
} }
}, []); }, []);
useEffect(() => { useEffect(() => {
const init = async () => { const init = async () => {
setIsLoading(true); setIsLoading(true);
await checkAuth(); await checkReady();
await loadConversations(); await loadConversations();
setIsLoading(false); setIsLoading(false);
}; };
@@ -794,16 +793,10 @@ const ChatList: React.FC = () => {
); );
}); });
// Subscribe to device code for login flow
const unsubDevice = window.electronAPI?.chat.onDeviceCode((data) => {
setDeviceCode(data);
});
return () => { return () => {
unsubTitle?.(); unsubTitle?.();
unsubDevice?.();
}; };
}, [loadConversations, checkAuth]); }, [loadConversations, checkReady]);
const handleNewChat = async () => { const handleNewChat = async () => {
try { 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 formatChatDate = (dateString: string) => {
const date = new Date(dateString); const date = new Date(dateString);
const now = new Date(); const now = new Date();
@@ -874,33 +851,6 @@ const ChatList: React.FC = () => {
); );
} }
// Show login prompt if not authenticated
if (!authStatus?.authenticated) {
return (
<div className="chat-list">
<div className="chat-list-header">
<span>AI ASSISTANT</span>
</div>
<div className="chat-auth-prompt">
<p>Sign in to GitHub Copilot to start chatting</p>
{deviceCode ? (
<div className="device-code-prompt">
<p>Enter this code at:</p>
<a href={deviceCode.verificationUri} target="_blank" rel="noopener noreferrer">
{deviceCode.verificationUri}
</a>
<div className="device-code">{deviceCode.userCode}</div>
</div>
) : (
<button className="chat-login-button" onClick={handleLogin}>
Sign in with GitHub
</button>
)}
</div>
</div>
);
}
return ( return (
<div className="chat-list"> <div className="chat-list">
<div className="chat-list-header"> <div className="chat-list-header">
@@ -909,10 +859,9 @@ const ChatList: React.FC = () => {
+ +
</button> </button>
</div> </div>
{authStatus.username && ( {!isReady && (
<div className="chat-user-info"> <div className="chat-auth-prompt">
<span className="chat-user-icon">👤</span> <p>API key needed. Open a chat to configure.</p>
<span className="chat-username">{authStatus.username}</span>
</div> </div>
)} )}
<div className="chat-list-items"> <div className="chat-list-items">

View File

@@ -173,10 +173,8 @@ export interface SyncTagsResult {
// Chat/AI types // Chat/AI types
export interface ChatConversation { export interface ChatConversation {
id: string; id: string;
projectId: string;
title: string; title: string;
model?: string; model?: string;
copilotSessionId?: string;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
@@ -194,16 +192,18 @@ export interface ChatMessage {
export interface ChatModel { export interface ChatModel {
id: string; id: string;
name: string; name: string;
} provider?: string;
export interface ChatAuthStatus {
authenticated: boolean;
username?: string;
} }
export interface ChatReadyStatus { export interface ChatReadyStatus {
ready: boolean; ready: boolean;
authenticated: boolean; error?: string;
backend?: string;
}
export interface ChatApiKeyStatus {
hasKey: boolean;
maskedKey: string;
} }
export interface ChatStreamDelta { export interface ChatStreamDelta {
@@ -229,11 +229,6 @@ export interface ChatTitleUpdate {
title: string; title: string;
} }
export interface ChatDeviceCode {
verificationUri: string;
userCode: string;
}
export interface ElectronAPI { export interface ElectronAPI {
projects: { projects: {
create: (data: { name: string; description?: string; slug?: string }) => Promise<ProjectData>; create: (data: { name: string; description?: string; slug?: string }) => Promise<ProjectData>;
@@ -339,11 +334,11 @@ export interface ElectronAPI {
syncFromPosts: () => Promise<SyncTagsResult>; syncFromPosts: () => Promise<SyncTagsResult>;
}; };
chat: { chat: {
// Authentication // API Key Management
checkReady: () => Promise<ChatReadyStatus>; checkReady: () => Promise<ChatReadyStatus>;
copilotAuthStatus: () => Promise<ChatAuthStatus>; validateApiKey: (apiKey: string) => Promise<{ isValid: boolean; models: ChatModel[] }>;
copilotLogin: () => Promise<{ success: boolean; error?: string; login?: string }>; setApiKey: (apiKey: string) => Promise<{ success: boolean; error?: string }>;
copilotLogout: () => Promise<void>; getApiKey: () => Promise<ChatApiKeyStatus>;
// Settings // Settings
getAvailableModels: () => Promise<ChatModel[]>; getAvailableModels: () => Promise<ChatModel[]>;
@@ -370,7 +365,6 @@ export interface ElectronAPI {
onToolCall: (callback: (data: ChatToolCall) => void) => () => void; onToolCall: (callback: (data: ChatToolCall) => void) => () => void;
onToolResult: (callback: (data: ChatToolResult) => void) => () => void; onToolResult: (callback: (data: ChatToolResult) => void) => () => void;
onTitleUpdated: (callback: (data: ChatTitleUpdate) => void) => () => void; onTitleUpdated: (callback: (data: ChatTitleUpdate) => void) => () => void;
onDeviceCode: (callback: (data: ChatDeviceCode) => void) => () => void;
}; };
on: (channel: string, callback: (...args: unknown[]) => void) => () => void; on: (channel: string, callback: (...args: unknown[]) => void) => () => void;
once: (channel: string, callback: (...args: unknown[]) => void) => void; once: (channel: string, callback: (...args: unknown[]) => void) => void;