feat: ai chat added, login flow still broken
This commit is contained in:
@@ -410,6 +410,34 @@ export class DatabaseConnection {
|
||||
args: ['default', 'Default Project', 'default', 'Your first blog project', now, now, 1],
|
||||
});
|
||||
}
|
||||
|
||||
// Create chat_conversations table for AI chat persistence
|
||||
await this.localClient.execute(`
|
||||
CREATE TABLE IF NOT EXISTS chat_conversations (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
model TEXT,
|
||||
copilot_session_id TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)
|
||||
`);
|
||||
await this.localClient.execute('CREATE INDEX IF NOT EXISTS idx_chat_conversations_updated_at ON chat_conversations(updated_at)');
|
||||
|
||||
// Create chat_messages table for storing conversation messages
|
||||
await this.localClient.execute(`
|
||||
CREATE TABLE IF NOT EXISTS chat_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
conversation_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
content TEXT,
|
||||
tool_call_id TEXT,
|
||||
tool_calls TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (conversation_id) REFERENCES chat_conversations(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
await this.localClient.execute('CREATE INDEX IF NOT EXISTS idx_chat_messages_conversation_id ON chat_messages(conversation_id)');
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
|
||||
@@ -108,6 +108,27 @@ export const tags = sqliteTable('tags', {
|
||||
projectNameIdx: uniqueIndex('tags_project_name_idx').on(table.projectId, table.name),
|
||||
}));
|
||||
|
||||
// Chat conversations table - stores AI chat sessions
|
||||
export const chatConversations = sqliteTable('chat_conversations', {
|
||||
id: text('id').primaryKey(),
|
||||
title: text('title').notNull(),
|
||||
model: text('model'), // Model used for this conversation
|
||||
copilotSessionId: text('copilot_session_id'), // Copilot SDK session ID for resuming
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||
});
|
||||
|
||||
// Chat messages table - stores messages within conversations
|
||||
export const chatMessages = sqliteTable('chat_messages', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
conversationId: text('conversation_id').notNull(),
|
||||
role: text('role', { enum: ['system', 'user', 'assistant', 'tool'] }).notNull(),
|
||||
content: text('content'),
|
||||
toolCallId: text('tool_call_id'), // For tool responses
|
||||
toolCalls: text('tool_calls'), // JSON array of tool calls
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||
});
|
||||
|
||||
// Types for TypeScript
|
||||
export type Project = typeof projects.$inferSelect;
|
||||
export type NewProject = typeof projects.$inferInsert;
|
||||
@@ -123,3 +144,7 @@ export type PostLink = typeof postLinks.$inferSelect;
|
||||
export type NewPostLink = typeof postLinks.$inferInsert;
|
||||
export type Tag = typeof tags.$inferSelect;
|
||||
export type NewTag = typeof tags.$inferInsert;
|
||||
export type ChatConversation = typeof chatConversations.$inferSelect;
|
||||
export type NewChatConversation = typeof chatConversations.$inferInsert;
|
||||
export type ChatMessage = typeof chatMessages.$inferSelect;
|
||||
export type NewChatMessage = typeof chatMessages.$inferInsert;
|
||||
|
||||
382
src/main/engine/ChatEngine.ts
Normal file
382
src/main/engine/ChatEngine.ts
Normal file
@@ -0,0 +1,382 @@
|
||||
/**
|
||||
* ChatEngine - Manages AI chat sessions and message persistence
|
||||
*
|
||||
* Responsible for:
|
||||
* - Creating, updating, and deleting chat conversations
|
||||
* - Storing and retrieving chat messages
|
||||
* - Managing conversation state (titles, models, etc.)
|
||||
*/
|
||||
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { DatabaseConnection } from '../database/connection';
|
||||
|
||||
export interface ChatConversationData {
|
||||
id: string;
|
||||
title: string;
|
||||
model?: string;
|
||||
copilotSessionId?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface ChatMessageData {
|
||||
id?: number;
|
||||
conversationId: string;
|
||||
role: 'system' | 'user' | 'assistant' | 'tool';
|
||||
content?: string;
|
||||
toolCallId?: string;
|
||||
toolCalls?: string; // JSON array of tool calls
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface CreateConversationInput {
|
||||
title?: string;
|
||||
model?: string;
|
||||
systemPrompt?: string;
|
||||
}
|
||||
|
||||
export class ChatEngine {
|
||||
private db: DatabaseConnection;
|
||||
|
||||
constructor(database: DatabaseConnection) {
|
||||
this.db = database;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new chat conversation
|
||||
*/
|
||||
async createConversation(input: CreateConversationInput = {}): Promise<ChatConversationData> {
|
||||
const client = this.db.getLocalClient();
|
||||
if (!client) {
|
||||
throw new Error('Database not initialized');
|
||||
}
|
||||
|
||||
const id = `chat_${uuidv4()}`;
|
||||
const title = input.title || 'New Chat';
|
||||
const model = input.model || 'gpt-4.1';
|
||||
const now = Date.now();
|
||||
|
||||
await client.execute({
|
||||
sql: `INSERT INTO chat_conversations (id, title, model, created_at, updated_at) VALUES (?, ?, ?, ?, ?)`,
|
||||
args: [id, title, model, now, now],
|
||||
});
|
||||
|
||||
// Add system prompt as first message if provided
|
||||
if (input.systemPrompt) {
|
||||
await this.addMessage({
|
||||
conversationId: id,
|
||||
role: 'system',
|
||||
content: input.systemPrompt,
|
||||
createdAt: new Date(now),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
model,
|
||||
createdAt: new Date(now),
|
||||
updatedAt: new Date(now),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a conversation by ID with all messages
|
||||
*/
|
||||
async getConversation(id: string): Promise<(ChatConversationData & { messages: ChatMessageData[] }) | null> {
|
||||
const client = this.db.getLocalClient();
|
||||
if (!client) {
|
||||
throw new Error('Database not initialized');
|
||||
}
|
||||
|
||||
const convResult = await client.execute({
|
||||
sql: `SELECT * FROM chat_conversations WHERE id = ?`,
|
||||
args: [id],
|
||||
});
|
||||
|
||||
if (convResult.rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const row = convResult.rows[0];
|
||||
const conversation: ChatConversationData = {
|
||||
id: row.id as string,
|
||||
title: row.title as string,
|
||||
model: row.model as string | undefined,
|
||||
copilotSessionId: row.copilot_session_id as string | undefined,
|
||||
createdAt: new Date(row.created_at as number),
|
||||
updatedAt: new Date(row.updated_at as number),
|
||||
};
|
||||
|
||||
const messagesResult = await client.execute({
|
||||
sql: `SELECT * FROM chat_messages WHERE conversation_id = ? ORDER BY created_at ASC`,
|
||||
args: [id],
|
||||
});
|
||||
|
||||
const messages: ChatMessageData[] = messagesResult.rows.map(r => ({
|
||||
id: r.id as number,
|
||||
conversationId: r.conversation_id as string,
|
||||
role: r.role as 'system' | 'user' | 'assistant' | 'tool',
|
||||
content: r.content as string | undefined,
|
||||
toolCallId: r.tool_call_id as string | undefined,
|
||||
toolCalls: r.tool_calls as string | undefined,
|
||||
createdAt: new Date(r.created_at as number),
|
||||
}));
|
||||
|
||||
return { ...conversation, messages };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all conversations, sorted by most recently updated
|
||||
*/
|
||||
async getRecentConversations(limit: number = 50): Promise<ChatConversationData[]> {
|
||||
const client = this.db.getLocalClient();
|
||||
if (!client) {
|
||||
throw new Error('Database not initialized');
|
||||
}
|
||||
|
||||
const result = await client.execute({
|
||||
sql: `SELECT * FROM chat_conversations ORDER BY updated_at DESC LIMIT ?`,
|
||||
args: [limit],
|
||||
});
|
||||
|
||||
return result.rows.map(row => ({
|
||||
id: row.id as string,
|
||||
title: row.title as string,
|
||||
model: row.model as string | undefined,
|
||||
copilotSessionId: row.copilot_session_id as string | undefined,
|
||||
createdAt: new Date(row.created_at as number),
|
||||
updatedAt: new Date(row.updated_at as number),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a conversation's metadata
|
||||
*/
|
||||
async updateConversation(id: string, updates: Partial<Pick<ChatConversationData, 'title' | 'model' | 'copilotSessionId'>>): Promise<void> {
|
||||
const client = this.db.getLocalClient();
|
||||
if (!client) {
|
||||
throw new Error('Database not initialized');
|
||||
}
|
||||
|
||||
const setClauses: string[] = ['updated_at = ?'];
|
||||
const args: (string | number | null)[] = [Date.now()];
|
||||
|
||||
if (updates.title !== undefined) {
|
||||
setClauses.push('title = ?');
|
||||
args.push(updates.title);
|
||||
}
|
||||
if (updates.model !== undefined) {
|
||||
setClauses.push('model = ?');
|
||||
args.push(updates.model);
|
||||
}
|
||||
if (updates.copilotSessionId !== undefined) {
|
||||
setClauses.push('copilot_session_id = ?');
|
||||
args.push(updates.copilotSessionId);
|
||||
}
|
||||
|
||||
args.push(id);
|
||||
|
||||
await client.execute({
|
||||
sql: `UPDATE chat_conversations SET ${setClauses.join(', ')} WHERE id = ?`,
|
||||
args,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a conversation and all its messages
|
||||
*/
|
||||
async deleteConversation(id: string): Promise<void> {
|
||||
const client = this.db.getLocalClient();
|
||||
if (!client) {
|
||||
throw new Error('Database not initialized');
|
||||
}
|
||||
|
||||
// Messages are deleted via CASCADE, but let's be explicit
|
||||
await client.execute({
|
||||
sql: `DELETE FROM chat_messages WHERE conversation_id = ?`,
|
||||
args: [id],
|
||||
});
|
||||
|
||||
await client.execute({
|
||||
sql: `DELETE FROM chat_conversations WHERE id = ?`,
|
||||
args: [id],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a message to a conversation
|
||||
*/
|
||||
async addMessage(message: Omit<ChatMessageData, 'id'>): Promise<ChatMessageData> {
|
||||
const client = this.db.getLocalClient();
|
||||
if (!client) {
|
||||
throw new Error('Database not initialized');
|
||||
}
|
||||
|
||||
const createdAt = message.createdAt?.getTime() || Date.now();
|
||||
|
||||
const result = await client.execute({
|
||||
sql: `INSERT INTO chat_messages (conversation_id, role, content, tool_call_id, tool_calls, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
args: [
|
||||
message.conversationId,
|
||||
message.role,
|
||||
message.content || null,
|
||||
message.toolCallId || null,
|
||||
message.toolCalls || null,
|
||||
createdAt,
|
||||
],
|
||||
});
|
||||
|
||||
// Update conversation's updated_at timestamp
|
||||
await client.execute({
|
||||
sql: `UPDATE chat_conversations SET updated_at = ? WHERE id = ?`,
|
||||
args: [createdAt, message.conversationId],
|
||||
});
|
||||
|
||||
return {
|
||||
id: Number(result.lastInsertRowid),
|
||||
conversationId: message.conversationId,
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
toolCallId: message.toolCallId,
|
||||
toolCalls: message.toolCalls,
|
||||
createdAt: new Date(createdAt),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get messages for a conversation
|
||||
*/
|
||||
async getMessages(conversationId: string): Promise<ChatMessageData[]> {
|
||||
const client = this.db.getLocalClient();
|
||||
if (!client) {
|
||||
throw new Error('Database not initialized');
|
||||
}
|
||||
|
||||
const result = await client.execute({
|
||||
sql: `SELECT * FROM chat_messages WHERE conversation_id = ? ORDER BY created_at ASC`,
|
||||
args: [conversationId],
|
||||
});
|
||||
|
||||
return result.rows.map(r => ({
|
||||
id: r.id as number,
|
||||
conversationId: r.conversation_id as string,
|
||||
role: r.role as 'system' | 'user' | 'assistant' | 'tool',
|
||||
content: r.content as string | undefined,
|
||||
toolCallId: r.tool_call_id as string | undefined,
|
||||
toolCalls: r.tool_calls as string | undefined,
|
||||
createdAt: new Date(r.created_at as number),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all messages from a conversation (but keep the conversation)
|
||||
*/
|
||||
async clearMessages(conversationId: string): Promise<void> {
|
||||
const client = this.db.getLocalClient();
|
||||
if (!client) {
|
||||
throw new Error('Database not initialized');
|
||||
}
|
||||
|
||||
await client.execute({
|
||||
sql: `DELETE FROM chat_messages WHERE conversation_id = ?`,
|
||||
args: [conversationId],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default system prompt for new conversations
|
||||
*/
|
||||
async getDefaultSystemPrompt(): Promise<string> {
|
||||
const client = this.db.getLocalClient();
|
||||
if (!client) {
|
||||
return this.getBuiltInSystemPrompt();
|
||||
}
|
||||
|
||||
const result = await client.execute({
|
||||
sql: `SELECT value FROM settings WHERE key = 'chat_system_prompt'`,
|
||||
args: [],
|
||||
});
|
||||
|
||||
if (result.rows.length > 0) {
|
||||
return result.rows[0].value as string;
|
||||
}
|
||||
|
||||
return this.getBuiltInSystemPrompt();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set default system prompt for new conversations
|
||||
*/
|
||||
async setDefaultSystemPrompt(prompt: string): Promise<void> {
|
||||
const client = this.db.getLocalClient();
|
||||
if (!client) {
|
||||
throw new Error('Database not initialized');
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
await client.execute({
|
||||
sql: `INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, ?)`,
|
||||
args: ['chat_system_prompt', prompt, now],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the built-in default system prompt
|
||||
*/
|
||||
private getBuiltInSystemPrompt(): string {
|
||||
return `You are an AI assistant for the Blogging Desktop Server (bDS) application.
|
||||
You help users manage their blog posts and media files.
|
||||
|
||||
You have access to tools that allow you to:
|
||||
- Search for posts using full-text search with optional category/tag filters
|
||||
- Read individual post content and metadata
|
||||
- View information about media files (images)
|
||||
- Update metadata for posts and media files
|
||||
|
||||
When answering questions about the user's blog content:
|
||||
1. Use the search tool to find relevant posts
|
||||
2. Read specific posts to get detailed content
|
||||
3. Provide helpful summaries and suggestions
|
||||
|
||||
Be concise but thorough in your responses. When displaying post information, format it clearly.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get selected model for new conversations
|
||||
*/
|
||||
async getSelectedModel(): Promise<string> {
|
||||
const client = this.db.getLocalClient();
|
||||
if (!client) {
|
||||
return 'gpt-4.1';
|
||||
}
|
||||
|
||||
const result = await client.execute({
|
||||
sql: `SELECT value FROM settings WHERE key = 'chat_model'`,
|
||||
args: [],
|
||||
});
|
||||
|
||||
if (result.rows.length > 0) {
|
||||
return result.rows[0].value as string;
|
||||
}
|
||||
|
||||
return 'gpt-4.1';
|
||||
}
|
||||
|
||||
/**
|
||||
* Set selected model for new conversations
|
||||
*/
|
||||
async setSelectedModel(model: string): Promise<void> {
|
||||
const client = this.db.getLocalClient();
|
||||
if (!client) {
|
||||
throw new Error('Database not initialized');
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
await client.execute({
|
||||
sql: `INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, ?)`,
|
||||
args: ['chat_model', model, now],
|
||||
});
|
||||
}
|
||||
}
|
||||
986
src/main/engine/CopilotManager.ts
Normal file
986
src/main/engine/CopilotManager.ts
Normal file
@@ -0,0 +1,986 @@
|
||||
/**
|
||||
* CopilotManager - Handles AI chat using GitHub Copilot SDK
|
||||
*
|
||||
* Provides native Copilot integration with custom tool support for:
|
||||
* - Searching posts with full-text search and filters
|
||||
* - Reading post content and metadata
|
||||
* - Viewing media file information
|
||||
* - Updating post and media metadata
|
||||
*/
|
||||
|
||||
import { BrowserWindow, app } from 'electron';
|
||||
import { spawn } from 'child_process';
|
||||
import path from 'path';
|
||||
import { ChatEngine, ChatMessageData } from './ChatEngine';
|
||||
import { PostEngine } from './PostEngine';
|
||||
import { MediaEngine } from './MediaEngine';
|
||||
|
||||
// ESM modules - loaded dynamically
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let CopilotClient: any = null;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let defineTool: any = null;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let z: any = null;
|
||||
|
||||
let modulesLoaded = false;
|
||||
|
||||
// Dynamic import that bypasses TypeScript transformation to properly handle ESM
|
||||
// TypeScript compiles import() to require() in CommonJS, which breaks ESM-only packages
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const dynamicImport = new Function('specifier', 'return import(specifier)') as (specifier: string) => Promise<any>;
|
||||
|
||||
/**
|
||||
* Load ESM modules dynamically (required for CommonJS compatibility)
|
||||
*/
|
||||
async function loadModules(): Promise<boolean> {
|
||||
if (modulesLoaded) return true;
|
||||
|
||||
try {
|
||||
// Use dynamicImport to bypass TypeScript's transformation of import() to require()
|
||||
const copilotSdk = await dynamicImport('@github/copilot-sdk');
|
||||
CopilotClient = copilotSdk.CopilotClient;
|
||||
defineTool = copilotSdk.defineTool;
|
||||
|
||||
const zod = await dynamicImport('zod');
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
z = (zod as any).z || (zod as any).default?.z || zod;
|
||||
|
||||
modulesLoaded = true;
|
||||
console.log('[CopilotManager] ESM modules loaded successfully');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[CopilotManager] Failed to load ESM modules:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
interface CopilotSession {
|
||||
sessionId: string;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
session: any;
|
||||
conversationId: string;
|
||||
isResumed?: boolean;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
export interface SendMessageOptions {
|
||||
onDelta?: (delta: string) => void;
|
||||
onToolCall?: (toolCall: { name: string; args: unknown }) => void;
|
||||
onToolResult?: (result: { name: string; result: unknown }) => void;
|
||||
}
|
||||
|
||||
export interface SendMessageResult {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
error?: string;
|
||||
toolCalls?: Array<{ name: string; args: unknown }>;
|
||||
}
|
||||
|
||||
export class CopilotManager {
|
||||
private chatEngine: ChatEngine;
|
||||
private postEngine: PostEngine;
|
||||
private mediaEngine: MediaEngine;
|
||||
private getMainWindow: () => BrowserWindow | null;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
private client: any = null;
|
||||
private sessions: Map<string, CopilotSession> = new Map();
|
||||
private isInitialized = false;
|
||||
private initError: string | null = null;
|
||||
private initPromise: Promise<{ success: boolean; error?: string }> | null = null;
|
||||
private isStopping = false;
|
||||
|
||||
constructor(
|
||||
chatEngine: ChatEngine,
|
||||
postEngine: PostEngine,
|
||||
mediaEngine: MediaEngine,
|
||||
getMainWindow: () => BrowserWindow | null
|
||||
) {
|
||||
this.chatEngine = chatEngine;
|
||||
this.postEngine = postEngine;
|
||||
this.mediaEngine = mediaEngine;
|
||||
this.getMainWindow = getMainWindow;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tools for the Copilot SDK
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
private getToolsForCopilot(): any[] {
|
||||
if (!modulesLoaded || !defineTool || !z) {
|
||||
console.warn('[CopilotManager] Modules not loaded, returning empty tools list');
|
||||
return [];
|
||||
}
|
||||
|
||||
const tools = [
|
||||
// Search posts with full-text search and filters
|
||||
defineTool('search_posts', {
|
||||
description: 'Search blog posts using full-text search. Can filter by category or tags. Returns matching posts with their metadata.',
|
||||
parameters: z.object({
|
||||
query: z.string().describe('The search query text to find in posts'),
|
||||
category: z.string().optional().describe('Optional category to filter by (e.g., "article", "picture", "aside", "page")'),
|
||||
tags: z.array(z.string()).optional().describe('Optional array of tags to filter by'),
|
||||
limit: z.number().optional().describe('Maximum number of results to return (default: 10)'),
|
||||
}),
|
||||
handler: async (args: { query: string; category?: string; tags?: string[]; limit?: number }) => {
|
||||
try {
|
||||
// Get search results (returns id, title, slug, excerpt)
|
||||
const searchResults = await this.postEngine.searchPosts(args.query);
|
||||
|
||||
// Fetch full post data for filtering
|
||||
const fullPosts = await Promise.all(
|
||||
searchResults.map(sr => this.postEngine.getPost(sr.id))
|
||||
);
|
||||
|
||||
// Filter by category and tags
|
||||
let filteredPosts = fullPosts.filter(p => p !== null);
|
||||
|
||||
if (args.category) {
|
||||
filteredPosts = filteredPosts.filter(p => p!.categories.includes(args.category!));
|
||||
}
|
||||
|
||||
if (args.tags && args.tags.length > 0) {
|
||||
filteredPosts = filteredPosts.filter(p =>
|
||||
args.tags!.every(tag => p!.tags.includes(tag))
|
||||
);
|
||||
}
|
||||
|
||||
// Apply limit
|
||||
const limit = args.limit || 10;
|
||||
filteredPosts = filteredPosts.slice(0, limit);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
count: filteredPosts.length,
|
||||
posts: filteredPosts.map(p => ({
|
||||
id: p!.id,
|
||||
title: p!.title,
|
||||
slug: p!.slug,
|
||||
excerpt: p!.excerpt,
|
||||
status: p!.status,
|
||||
categories: p!.categories,
|
||||
tags: p!.tags,
|
||||
createdAt: p!.createdAt,
|
||||
updatedAt: p!.updatedAt,
|
||||
})),
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
},
|
||||
}),
|
||||
|
||||
// Read a single post
|
||||
defineTool('read_post', {
|
||||
description: 'Read the full content and metadata of a specific blog post by its ID.',
|
||||
parameters: z.object({
|
||||
postId: z.string().describe('The unique ID of the post to read'),
|
||||
}),
|
||||
handler: async (args: { postId: string }) => {
|
||||
try {
|
||||
const post = await this.postEngine.getPost(args.postId);
|
||||
if (!post) {
|
||||
return { success: false, error: 'Post not found' };
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
post: {
|
||||
id: post.id,
|
||||
title: post.title,
|
||||
slug: post.slug,
|
||||
content: post.content,
|
||||
excerpt: post.excerpt,
|
||||
status: post.status,
|
||||
author: post.author,
|
||||
categories: post.categories,
|
||||
tags: post.tags,
|
||||
createdAt: post.createdAt,
|
||||
updatedAt: post.updatedAt,
|
||||
publishedAt: post.publishedAt,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
},
|
||||
}),
|
||||
|
||||
// Get media file information
|
||||
defineTool('get_media', {
|
||||
description: 'Get information about a specific media file (image) by its ID.',
|
||||
parameters: z.object({
|
||||
mediaId: z.string().describe('The unique ID of the media file'),
|
||||
}),
|
||||
handler: async (args: { mediaId: string }) => {
|
||||
try {
|
||||
const media = await this.mediaEngine.getMedia(args.mediaId);
|
||||
if (!media) {
|
||||
return { success: false, error: 'Media not found' };
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
media: {
|
||||
id: media.id,
|
||||
filename: media.filename,
|
||||
originalName: media.originalName,
|
||||
mimeType: media.mimeType,
|
||||
size: media.size,
|
||||
width: media.width,
|
||||
height: media.height,
|
||||
alt: media.alt,
|
||||
caption: media.caption,
|
||||
tags: media.tags,
|
||||
createdAt: media.createdAt,
|
||||
updatedAt: media.updatedAt,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
},
|
||||
}),
|
||||
|
||||
// List all media files
|
||||
defineTool('list_media', {
|
||||
description: 'List all media files in the current project with optional filtering.',
|
||||
parameters: z.object({
|
||||
mimeTypeFilter: z.string().optional().describe('Filter by MIME type prefix (e.g., "image/")'),
|
||||
limit: z.number().optional().describe('Maximum number of results (default: 20)'),
|
||||
}),
|
||||
handler: async (args: { mimeTypeFilter?: string; limit?: number }) => {
|
||||
try {
|
||||
let mediaList = await this.mediaEngine.getAllMedia();
|
||||
|
||||
if (args.mimeTypeFilter) {
|
||||
mediaList = mediaList.filter(m => m.mimeType.startsWith(args.mimeTypeFilter!));
|
||||
}
|
||||
|
||||
const limit = args.limit || 20;
|
||||
mediaList = mediaList.slice(0, limit);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
count: mediaList.length,
|
||||
media: mediaList.map(m => ({
|
||||
id: m.id,
|
||||
filename: m.filename,
|
||||
originalName: m.originalName,
|
||||
mimeType: m.mimeType,
|
||||
alt: m.alt,
|
||||
tags: m.tags,
|
||||
})),
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
},
|
||||
}),
|
||||
|
||||
// Update post metadata
|
||||
defineTool('update_post_metadata', {
|
||||
description: 'Update metadata for a blog post (title, excerpt, tags, categories). Does NOT update post content.',
|
||||
parameters: z.object({
|
||||
postId: z.string().describe('The unique ID of the post to update'),
|
||||
title: z.string().optional().describe('New title for the post'),
|
||||
excerpt: z.string().optional().describe('New excerpt/summary for the post'),
|
||||
tags: z.array(z.string()).optional().describe('New tags for the post'),
|
||||
categories: z.array(z.string()).optional().describe('New categories for the post'),
|
||||
}),
|
||||
handler: async (args: { postId: string; title?: string; excerpt?: string; tags?: string[]; categories?: string[] }) => {
|
||||
try {
|
||||
const updates: Record<string, unknown> = {};
|
||||
if (args.title !== undefined) updates.title = args.title;
|
||||
if (args.excerpt !== undefined) updates.excerpt = args.excerpt;
|
||||
if (args.tags !== undefined) updates.tags = args.tags;
|
||||
if (args.categories !== undefined) updates.categories = args.categories;
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
return { success: false, error: 'No updates provided' };
|
||||
}
|
||||
|
||||
await this.postEngine.updatePost(args.postId, updates);
|
||||
return { success: true, message: `Post ${args.postId} metadata updated successfully` };
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
},
|
||||
}),
|
||||
|
||||
// Update media metadata
|
||||
defineTool('update_media_metadata', {
|
||||
description: 'Update metadata for a media file (alt text, caption, tags).',
|
||||
parameters: z.object({
|
||||
mediaId: z.string().describe('The unique ID of the media to update'),
|
||||
alt: z.string().optional().describe('New alt text for the image'),
|
||||
caption: z.string().optional().describe('New caption for the image'),
|
||||
tags: z.array(z.string()).optional().describe('New tags for the media'),
|
||||
}),
|
||||
handler: async (args: { mediaId: string; alt?: string; caption?: string; tags?: string[] }) => {
|
||||
try {
|
||||
const updates: Record<string, unknown> = {};
|
||||
if (args.alt !== undefined) updates.alt = args.alt;
|
||||
if (args.caption !== undefined) updates.caption = args.caption;
|
||||
if (args.tags !== undefined) updates.tags = args.tags;
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
return { success: false, error: 'No updates provided' };
|
||||
}
|
||||
|
||||
await this.mediaEngine.updateMedia(args.mediaId, updates);
|
||||
return { success: true, message: `Media ${args.mediaId} metadata updated successfully` };
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
},
|
||||
}),
|
||||
|
||||
// List posts with filtering
|
||||
defineTool('list_posts', {
|
||||
description: 'List blog posts with optional filtering by status, category, or tags.',
|
||||
parameters: z.object({
|
||||
status: z.enum(['draft', 'published', 'archived']).optional().describe('Filter by post status'),
|
||||
category: z.string().optional().describe('Filter by category'),
|
||||
tags: z.array(z.string()).optional().describe('Filter by tags (posts must have all specified tags)'),
|
||||
limit: z.number().optional().describe('Maximum number of results (default: 20)'),
|
||||
offset: z.number().optional().describe('Offset for pagination (default: 0)'),
|
||||
}),
|
||||
handler: async (args: { status?: 'draft' | 'published' | 'archived'; category?: string; tags?: string[]; limit?: number; offset?: number }) => {
|
||||
try {
|
||||
// Use getPostsFiltered for filtering
|
||||
const filter: { status?: 'draft' | 'published' | 'archived'; tags?: string[]; categories?: string[] } = {};
|
||||
if (args.status) filter.status = args.status;
|
||||
if (args.tags) filter.tags = args.tags;
|
||||
if (args.category) filter.categories = [args.category];
|
||||
|
||||
let posts;
|
||||
if (Object.keys(filter).length > 0) {
|
||||
posts = await this.postEngine.getPostsFiltered(filter);
|
||||
} else {
|
||||
const result = await this.postEngine.getAllPosts({
|
||||
limit: args.limit || 20,
|
||||
offset: args.offset || 0,
|
||||
});
|
||||
posts = result.items;
|
||||
}
|
||||
|
||||
// Apply offset/limit for filtered results
|
||||
const offset = args.offset || 0;
|
||||
const limit = args.limit || 20;
|
||||
const slicedPosts = posts.slice(offset, offset + limit);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
count: slicedPosts.length,
|
||||
total: posts.length,
|
||||
hasMore: offset + limit < posts.length,
|
||||
posts: slicedPosts.map(p => ({
|
||||
id: p.id,
|
||||
title: p.title,
|
||||
slug: p.slug,
|
||||
status: p.status,
|
||||
categories: p.categories,
|
||||
tags: p.tags,
|
||||
createdAt: p.createdAt,
|
||||
updatedAt: p.updatedAt,
|
||||
})),
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
return tools;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the Copilot client
|
||||
* Uses a lock to prevent concurrent initialization attempts
|
||||
*/
|
||||
async initialize(): Promise<{ success: boolean; error?: string }> {
|
||||
// Already initialized
|
||||
if (this.isInitialized && this.client) {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// If stopping, don't initialize
|
||||
if (this.isStopping) {
|
||||
return { success: false, error: 'Client is stopping' };
|
||||
}
|
||||
|
||||
// If already initializing, wait for that to complete
|
||||
if (this.initPromise) {
|
||||
return this.initPromise;
|
||||
}
|
||||
|
||||
// Start initialization
|
||||
this.initPromise = this.doInitialize();
|
||||
const result = await this.initPromise;
|
||||
this.initPromise = null;
|
||||
return result;
|
||||
}
|
||||
|
||||
private async doInitialize(): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
console.log('[CopilotManager] Initializing Copilot client...');
|
||||
|
||||
const loaded = await loadModules();
|
||||
if (!loaded) {
|
||||
return { success: false, error: 'Failed to load Copilot SDK modules' };
|
||||
}
|
||||
|
||||
// Check again in case stop was called during module loading
|
||||
if (this.isStopping) {
|
||||
return { success: false, error: 'Client is stopping' };
|
||||
}
|
||||
|
||||
console.log('[CopilotManager] Creating CopilotClient instance...');
|
||||
this.client = new CopilotClient({
|
||||
autoStart: true,
|
||||
autoRestart: true,
|
||||
useLoggedInUser: true,
|
||||
logLevel: 'info',
|
||||
});
|
||||
|
||||
console.log('[CopilotManager] Starting client...');
|
||||
await this.client.start();
|
||||
console.log('[CopilotManager] Client started successfully');
|
||||
|
||||
this.isInitialized = true;
|
||||
this.initError = null;
|
||||
|
||||
console.log('[CopilotManager] Copilot client initialized successfully');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('[CopilotManager] Failed to initialize Copilot client:', error);
|
||||
this.initError = (error as Error).message;
|
||||
this.isInitialized = false;
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Copilot is ready
|
||||
*/
|
||||
async checkReady(): Promise<{ ready: boolean; error?: string; requiresCLI?: boolean }> {
|
||||
try {
|
||||
if (!this.isInitialized) {
|
||||
const result = await this.initialize();
|
||||
if (!result.success) {
|
||||
const isCLIError =
|
||||
result.error?.includes('copilot') ||
|
||||
result.error?.includes('ENOENT') ||
|
||||
result.error?.includes('spawn');
|
||||
|
||||
return {
|
||||
ready: false,
|
||||
error: result.error,
|
||||
requiresCLI: isCLIError,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
await this.client.ping();
|
||||
return { ready: true };
|
||||
} catch (error) {
|
||||
console.error('[CopilotManager] Ready check failed:', error);
|
||||
return {
|
||||
ready: false,
|
||||
error: (error as Error).message,
|
||||
requiresCLI:
|
||||
(error as Error).message?.includes('copilot') ||
|
||||
(error as Error).message?.includes('ENOENT'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a session for a conversation
|
||||
*/
|
||||
private async getOrCreateSession(
|
||||
conversationId: string,
|
||||
options: { model?: string; systemMessage?: string; copilotSessionId?: string; forceNewSession?: boolean } = {}
|
||||
): Promise<CopilotSession> {
|
||||
console.log(`[CopilotManager] getOrCreateSession called with model: ${options.model || 'default (gpt-4.1)'}`);
|
||||
|
||||
if (this.sessions.has(conversationId)) {
|
||||
const existingSession = this.sessions.get(conversationId)!;
|
||||
if (options.model && existingSession.model !== options.model) {
|
||||
console.log(`[CopilotManager] Model changed, creating new session`);
|
||||
this.sessions.delete(conversationId);
|
||||
} else {
|
||||
return existingSession;
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.isInitialized) {
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
const tools = this.getToolsForCopilot();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let session: any;
|
||||
let isResumed = false;
|
||||
|
||||
// Try to resume existing session
|
||||
if (options.copilotSessionId && !options.forceNewSession) {
|
||||
try {
|
||||
console.log(`[CopilotManager] Resuming session: ${options.copilotSessionId}`);
|
||||
session = await this.client.resumeSession(options.copilotSessionId, {
|
||||
streaming: true,
|
||||
tools,
|
||||
});
|
||||
isResumed = true;
|
||||
console.log(`[CopilotManager] Session resumed successfully`);
|
||||
} catch (error) {
|
||||
console.log(`[CopilotManager] Failed to resume session, creating new: ${(error as Error).message}`);
|
||||
session = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Create new session if needed
|
||||
if (!session) {
|
||||
const modelToUse = options.model || 'gpt-4.1';
|
||||
console.log(`[CopilotManager] Creating new session for conversation: ${conversationId} with model: ${modelToUse}`);
|
||||
session = await this.client.createSession({
|
||||
model: modelToUse,
|
||||
streaming: true,
|
||||
tools,
|
||||
systemMessage: options.systemMessage
|
||||
? {
|
||||
content: options.systemMessage,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
console.log(`[CopilotManager] New session created: ${session.sessionId}`);
|
||||
|
||||
// Store session ID in database
|
||||
try {
|
||||
await this.chatEngine.updateConversation(conversationId, {
|
||||
copilotSessionId: session.sessionId,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`[CopilotManager] Failed to save session ID: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const copilotSession: CopilotSession = {
|
||||
sessionId: session.sessionId,
|
||||
session,
|
||||
conversationId,
|
||||
isResumed,
|
||||
model: options.model || 'gpt-4.1',
|
||||
};
|
||||
|
||||
this.sessions.set(conversationId, copilotSession);
|
||||
return copilotSession;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to a conversation
|
||||
*/
|
||||
async sendMessage(
|
||||
conversationId: string,
|
||||
userMessage: string,
|
||||
options: SendMessageOptions = {}
|
||||
): Promise<SendMessageResult> {
|
||||
const { onDelta, onToolCall, onToolResult } = options;
|
||||
|
||||
try {
|
||||
const readyCheck = await this.checkReady();
|
||||
if (!readyCheck.ready) {
|
||||
return { success: false, error: readyCheck.error };
|
||||
}
|
||||
|
||||
// Get conversation from database
|
||||
const conversation = await this.chatEngine.getConversation(conversationId);
|
||||
if (!conversation) {
|
||||
return { success: false, error: 'Conversation not found' };
|
||||
}
|
||||
|
||||
// Get or create session
|
||||
const systemMessage = conversation.messages.find(m => m.role === 'system');
|
||||
const copilotSession = await this.getOrCreateSession(conversationId, {
|
||||
model: conversation.model,
|
||||
systemMessage: systemMessage?.content,
|
||||
copilotSessionId: conversation.copilotSessionId,
|
||||
});
|
||||
|
||||
// Add user message to database
|
||||
await this.chatEngine.addMessage({
|
||||
conversationId,
|
||||
role: 'user',
|
||||
content: userMessage,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
// Collect response
|
||||
let fullResponse = '';
|
||||
const toolCalls: Array<{ name: string; args: unknown }> = [];
|
||||
|
||||
// Set up event handlers
|
||||
const unsubscribeDelta = copilotSession.session.on('assistant.message_delta', (event: { data: { deltaContent?: string } }) => {
|
||||
fullResponse += event.data.deltaContent || '';
|
||||
if (onDelta) {
|
||||
onDelta(event.data.deltaContent || '');
|
||||
}
|
||||
});
|
||||
|
||||
const unsubscribeToolStart = copilotSession.session.on('tool.execution_start', (event: { data?: { toolName?: string; args?: unknown } }) => {
|
||||
if (onToolCall) {
|
||||
onToolCall({
|
||||
name: event.data?.toolName || 'unknown',
|
||||
args: event.data?.args,
|
||||
});
|
||||
}
|
||||
toolCalls.push({
|
||||
name: event.data?.toolName || 'unknown',
|
||||
args: event.data?.args,
|
||||
});
|
||||
});
|
||||
|
||||
const unsubscribeToolEnd = copilotSession.session.on('tool.execution_end', (event: { data?: { toolName?: string; result?: unknown } }) => {
|
||||
if (onToolResult) {
|
||||
onToolResult({
|
||||
name: event.data?.toolName || 'unknown',
|
||||
result: event.data?.result,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
// Send message and wait for completion (5 minute timeout)
|
||||
const response = await copilotSession.session.sendAndWait(
|
||||
{
|
||||
prompt: userMessage,
|
||||
},
|
||||
300000
|
||||
);
|
||||
|
||||
// Use final content if available
|
||||
if (response?.data?.content) {
|
||||
fullResponse = response.data.content;
|
||||
}
|
||||
|
||||
// Save assistant response
|
||||
if (fullResponse) {
|
||||
await this.chatEngine.addMessage({
|
||||
conversationId,
|
||||
role: 'assistant',
|
||||
content: fullResponse,
|
||||
toolCalls: toolCalls.length > 0 ? JSON.stringify(toolCalls) : undefined,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
// Generate title after first exchange
|
||||
const userMsgCount = conversation.messages.filter(m => m.role === 'user').length;
|
||||
if (userMsgCount === 0 && fullResponse) {
|
||||
this.generateConversationTitle(conversationId, userMessage, fullResponse).catch(err =>
|
||||
console.error('[CopilotManager] Error generating title:', err)
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: fullResponse,
|
||||
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
|
||||
};
|
||||
} finally {
|
||||
unsubscribeDelta();
|
||||
unsubscribeToolStart();
|
||||
unsubscribeToolEnd();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[CopilotManager] Error sending message:', error);
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a title for a conversation based on first exchange
|
||||
*/
|
||||
private async generateConversationTitle(conversationId: string, userMessage: string, assistantResponse: string): Promise<void> {
|
||||
try {
|
||||
// Create a quick session just for title generation
|
||||
const titleSession = await this.client.createSession({
|
||||
model: 'gpt-4.1-mini',
|
||||
streaming: false,
|
||||
});
|
||||
|
||||
const response = await titleSession.sendAndWait({
|
||||
prompt: `Generate a short, concise title (max 6 words) for this conversation. Only output the title, nothing else.
|
||||
|
||||
User: ${userMessage.substring(0, 200)}
|
||||
Assistant: ${assistantResponse.substring(0, 200)}`,
|
||||
});
|
||||
|
||||
if (response?.data?.content) {
|
||||
const title = response.data.content.trim().replace(/^["']|["']$/g, '');
|
||||
await this.chatEngine.updateConversation(conversationId, { title });
|
||||
|
||||
// Notify UI of title update
|
||||
const mainWindow = this.getMainWindow();
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send('chat-title-updated', { conversationId, title });
|
||||
}
|
||||
}
|
||||
|
||||
await titleSession.destroy();
|
||||
} catch (error) {
|
||||
console.error('[CopilotManager] Error generating title:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Abort a currently running message
|
||||
*/
|
||||
async abortMessage(conversationId: string): Promise<{ success: boolean; error?: string }> {
|
||||
const copilotSession = this.sessions.get(conversationId);
|
||||
if (!copilotSession) {
|
||||
return { success: false, error: 'No active session for this conversation' };
|
||||
}
|
||||
|
||||
try {
|
||||
await copilotSession.session.abort();
|
||||
console.log('[CopilotManager] Aborted message for conversation:', conversationId);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('[CopilotManager] Error aborting message:', error);
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy a session
|
||||
*/
|
||||
async destroySession(conversationId: string): Promise<void> {
|
||||
const copilotSession = this.sessions.get(conversationId);
|
||||
if (copilotSession) {
|
||||
try {
|
||||
await copilotSession.session.destroy();
|
||||
} catch (error) {
|
||||
console.error('[CopilotManager] Error destroying session:', error);
|
||||
}
|
||||
this.sessions.delete(conversationId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get authentication status
|
||||
* Does not auto-initialize - returns unauthenticated if client not ready
|
||||
*/
|
||||
async getAuthStatus(): Promise<{ isAuthenticated: boolean; authType?: string; login?: string; error?: string }> {
|
||||
try {
|
||||
// Don't auto-initialize on auth check - let frontend trigger login explicitly
|
||||
if (!this.isInitialized || !this.client) {
|
||||
return { isAuthenticated: false };
|
||||
}
|
||||
|
||||
const authStatus = await this.client.getAuthStatus();
|
||||
return {
|
||||
isAuthenticated: authStatus.isAuthenticated,
|
||||
authType: authStatus.authType,
|
||||
login: authStatus.login,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[CopilotManager] Error getting auth status:', error);
|
||||
return { isAuthenticated: false, error: (error as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get CLI path for the native Copilot binary
|
||||
*/
|
||||
private getCLIPath(): string {
|
||||
const platform = process.platform;
|
||||
const arch = process.arch;
|
||||
const packageName = `@github/copilot-${platform}-${arch}`;
|
||||
const exeName = platform === 'win32' ? 'copilot.exe' : 'copilot';
|
||||
|
||||
// In production (packaged app), node_modules is in resources/app.asar.unpacked
|
||||
// In development, it's in the project root
|
||||
const isPackaged = app.isPackaged;
|
||||
|
||||
let cliPath: string;
|
||||
if (isPackaged) {
|
||||
// In packaged app, asarUnpack puts these in app.asar.unpacked
|
||||
const resourcesPath = process.resourcesPath;
|
||||
cliPath = path.join(resourcesPath, 'app.asar.unpacked', 'node_modules', packageName, exeName);
|
||||
} else {
|
||||
// In development
|
||||
cliPath = path.join(__dirname, '..', '..', '..', 'node_modules', packageName, exeName);
|
||||
}
|
||||
|
||||
console.log('[CopilotManager] CLI path:', cliPath);
|
||||
return cliPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger interactive login using the CLI
|
||||
*/
|
||||
async triggerLogin(options: {
|
||||
onDeviceCode?: (deviceCode: { verificationUri: string; userCode: string }) => void;
|
||||
onMessage?: (message: string) => void;
|
||||
} = {}): Promise<{ success: boolean; error?: string; login?: string }> {
|
||||
const { onDeviceCode, onMessage } = options;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const cliPath = this.getCLIPath();
|
||||
|
||||
console.log('[CopilotManager] Triggering interactive login...');
|
||||
if (onMessage) onMessage('Starting authentication...');
|
||||
|
||||
// Spawn the CLI with 'login' command
|
||||
const cliProcess = spawn(cliPath, ['login'], {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: { ...process.env }
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let deviceCodeSent = false;
|
||||
|
||||
cliProcess.stdout.on('data', (data: Buffer) => {
|
||||
const text = data.toString();
|
||||
stdout += text;
|
||||
console.log('[CopilotManager] CLI stdout:', text);
|
||||
|
||||
// Parse for device code information
|
||||
if (!deviceCodeSent) {
|
||||
const urlMatch = text.match(/https:\/\/github\.com\/login\/device/);
|
||||
const codeMatch = text.match(/code[:\s]+([A-Z0-9]{4}-[A-Z0-9]{4})/i);
|
||||
|
||||
if (urlMatch && codeMatch) {
|
||||
deviceCodeSent = true;
|
||||
if (onDeviceCode) {
|
||||
onDeviceCode({
|
||||
verificationUri: 'https://github.com/login/device',
|
||||
userCode: codeMatch[1]
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (onMessage) {
|
||||
onMessage(text.trim());
|
||||
}
|
||||
});
|
||||
|
||||
cliProcess.stderr.on('data', (data: Buffer) => {
|
||||
const text = data.toString();
|
||||
stderr += text;
|
||||
console.log('[CopilotManager] CLI stderr:', text);
|
||||
|
||||
// Some CLIs output to stderr for prompts
|
||||
if (!deviceCodeSent) {
|
||||
const urlMatch = text.match(/https:\/\/github\.com\/login\/device/);
|
||||
const codeMatch = text.match(/code[:\s]+([A-Z0-9]{4}-[A-Z0-9]{4})/i);
|
||||
|
||||
if (urlMatch && codeMatch) {
|
||||
deviceCodeSent = true;
|
||||
if (onDeviceCode) {
|
||||
onDeviceCode({
|
||||
verificationUri: 'https://github.com/login/device',
|
||||
userCode: codeMatch[1]
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (onMessage) {
|
||||
onMessage(text.trim());
|
||||
}
|
||||
});
|
||||
|
||||
cliProcess.on('close', (code: number) => {
|
||||
console.log('[CopilotManager] CLI login process exited with code:', code);
|
||||
|
||||
if (code === 0) {
|
||||
// Re-initialize client to pick up new credentials
|
||||
this.isInitialized = false;
|
||||
this.client = null;
|
||||
resolve({ success: true });
|
||||
} else {
|
||||
resolve({ success: false, error: stderr || `Login failed with code ${code}` });
|
||||
}
|
||||
});
|
||||
|
||||
cliProcess.on('error', (error: Error) => {
|
||||
console.error('[CopilotManager] CLI spawn error:', error);
|
||||
resolve({ success: false, error: error.message });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout from Copilot (stops client - no CLI logout command exists)
|
||||
*/
|
||||
async logout(): Promise<{ success: boolean; error?: string }> {
|
||||
console.log('[CopilotManager] Logging out (stopping client)...');
|
||||
|
||||
try {
|
||||
await this.stop();
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('[CopilotManager] Error during logout:', error);
|
||||
return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available models
|
||||
*/
|
||||
async getAvailableModels(): Promise<string[]> {
|
||||
if (!this.isInitialized) {
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
try {
|
||||
const models = await this.client.getAvailableModels();
|
||||
return models || ['gpt-4.1', 'gpt-4.1-mini', 'claude-sonnet-4'];
|
||||
} catch (error) {
|
||||
console.error('[CopilotManager] Error getting models:', error);
|
||||
return ['gpt-4.1', 'gpt-4.1-mini', 'claude-sonnet-4'];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the client
|
||||
*/
|
||||
async stop(): Promise<void> {
|
||||
// Set stopping flag to prevent new initialization attempts
|
||||
this.isStopping = true;
|
||||
|
||||
// Wait for any pending initialization to complete (or fail)
|
||||
if (this.initPromise) {
|
||||
try {
|
||||
await this.initPromise;
|
||||
} catch {
|
||||
// Ignore initialization errors during shutdown
|
||||
}
|
||||
}
|
||||
|
||||
// Clear sessions first
|
||||
for (const [conversationId] of this.sessions) {
|
||||
try {
|
||||
await this.destroySession(conversationId);
|
||||
} catch {
|
||||
// Ignore errors during cleanup
|
||||
}
|
||||
}
|
||||
this.sessions.clear();
|
||||
|
||||
if (this.client) {
|
||||
try {
|
||||
await this.client.stop();
|
||||
} catch {
|
||||
// Ignore errors during cleanup - client may not be fully initialized
|
||||
}
|
||||
this.client = null;
|
||||
this.isInitialized = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,4 +37,16 @@ export {
|
||||
type FileDownloadResult,
|
||||
type ConflictResolution,
|
||||
} from './DropboxSyncEngine';
|
||||
export {
|
||||
ChatEngine,
|
||||
type ChatConversationData,
|
||||
type ChatMessageData,
|
||||
type CreateConversationInput,
|
||||
} from './ChatEngine';
|
||||
export {
|
||||
CopilotManager,
|
||||
type SendMessageOptions,
|
||||
type SendMessageResult,
|
||||
} from './CopilotManager';
|
||||
|
||||
|
||||
|
||||
342
src/main/ipc/chatHandlers.ts
Normal file
342
src/main/ipc/chatHandlers.ts
Normal file
@@ -0,0 +1,342 @@
|
||||
/**
|
||||
* Chat IPC handlers - AI chat functionality using GitHub Copilot SDK
|
||||
*/
|
||||
|
||||
import { ipcMain, BrowserWindow } from 'electron';
|
||||
import { ChatEngine } from '../engine/ChatEngine';
|
||||
import { CopilotManager } from '../engine/CopilotManager';
|
||||
import { getPostEngine } from '../engine/PostEngine';
|
||||
import { getMediaEngine } from '../engine/MediaEngine';
|
||||
import { getDatabase } from '../database';
|
||||
|
||||
let chatEngine: ChatEngine | null = null;
|
||||
let copilotManager: CopilotManager | null = null;
|
||||
let mainWindowGetter: (() => BrowserWindow | null) | null = null;
|
||||
|
||||
/**
|
||||
* Initialize chat handlers with the main window reference
|
||||
*/
|
||||
export function initializeChatHandlers(getMainWindow: () => BrowserWindow | null): void {
|
||||
mainWindowGetter = getMainWindow;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create the ChatEngine instance
|
||||
*/
|
||||
function getChatEngine(): ChatEngine {
|
||||
if (!chatEngine) {
|
||||
chatEngine = new ChatEngine(getDatabase());
|
||||
}
|
||||
return chatEngine;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create the CopilotManager instance
|
||||
*/
|
||||
function getCopilotManager(): CopilotManager {
|
||||
if (!copilotManager) {
|
||||
copilotManager = new CopilotManager(
|
||||
getChatEngine(),
|
||||
getPostEngine(),
|
||||
getMediaEngine(),
|
||||
() => mainWindowGetter?.() || null
|
||||
);
|
||||
}
|
||||
return copilotManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register all chat-related IPC handlers
|
||||
*/
|
||||
export function registerChatHandlers(): void {
|
||||
// ============ Copilot Authentication & Status ============
|
||||
|
||||
// Check if Copilot is ready
|
||||
ipcMain.handle('chat:checkReady', async () => {
|
||||
try {
|
||||
const manager = getCopilotManager();
|
||||
const result = await manager.checkReady();
|
||||
return {
|
||||
ready: result.ready,
|
||||
error: result.error,
|
||||
backend: result.ready ? 'copilot' : undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[Chat IPC] Error checking ready:', error);
|
||||
return { ready: false, error: (error as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
// Get Copilot authentication status
|
||||
ipcMain.handle('chat:copilotAuthStatus', async () => {
|
||||
try {
|
||||
const manager = getCopilotManager();
|
||||
const status = await manager.getAuthStatus();
|
||||
// Transform to match frontend ChatAuthStatus type
|
||||
return {
|
||||
authenticated: status.isAuthenticated,
|
||||
username: status.login,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[Chat IPC] Error getting auth status:', error);
|
||||
return { authenticated: false };
|
||||
}
|
||||
});
|
||||
|
||||
// Trigger Copilot login
|
||||
ipcMain.handle('chat:copilotLogin', async () => {
|
||||
try {
|
||||
console.log('[Chat IPC] copilotLogin called');
|
||||
const manager = getCopilotManager();
|
||||
const mainWindow = mainWindowGetter?.();
|
||||
|
||||
console.log('[Chat IPC] Calling manager.triggerLogin()');
|
||||
const result = await manager.triggerLogin({
|
||||
onDeviceCode: (deviceCode) => {
|
||||
console.log('[Chat IPC] Received device code, sending to renderer:', deviceCode);
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send('copilot-device-code', deviceCode);
|
||||
}
|
||||
},
|
||||
onMessage: (message) => {
|
||||
console.log('[Chat IPC] Auth message:', message);
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send('copilot-auth-message', { message });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
console.log('[Chat IPC] triggerLogin result:', result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('[Chat IPC] Error during login:', error);
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
// Logout from Copilot
|
||||
ipcMain.handle('chat:copilotLogout', async () => {
|
||||
try {
|
||||
const manager = getCopilotManager();
|
||||
return await manager.logout();
|
||||
} catch (error) {
|
||||
console.error('[Chat IPC] Error during logout:', error);
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
// ============ Chat Settings ============
|
||||
|
||||
// Get available models
|
||||
ipcMain.handle('chat:getAvailableModels', async () => {
|
||||
try {
|
||||
const manager = getCopilotManager();
|
||||
const models = await manager.getAvailableModels();
|
||||
const engine = getChatEngine();
|
||||
const selectedModel = await engine.getSelectedModel();
|
||||
return { success: true, models, selectedModel };
|
||||
} catch (error) {
|
||||
console.error('[Chat IPC] Error getting models:', error);
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
// Set default model
|
||||
ipcMain.handle('chat:setDefaultModel', async (_, modelId: string) => {
|
||||
try {
|
||||
const engine = getChatEngine();
|
||||
await engine.setSelectedModel(modelId);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('[Chat IPC] Error setting model:', error);
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
// Get system prompt
|
||||
ipcMain.handle('chat:getSystemPrompt', async () => {
|
||||
try {
|
||||
const engine = getChatEngine();
|
||||
const prompt = await engine.getDefaultSystemPrompt();
|
||||
return { success: true, prompt };
|
||||
} catch (error) {
|
||||
console.error('[Chat IPC] Error getting system prompt:', error);
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
// Set system prompt
|
||||
ipcMain.handle('chat:setSystemPrompt', async (_, prompt: string) => {
|
||||
try {
|
||||
const engine = getChatEngine();
|
||||
await engine.setDefaultSystemPrompt(prompt);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('[Chat IPC] Error setting system prompt:', error);
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
// ============ Conversation CRUD ============
|
||||
|
||||
// Get all conversations
|
||||
ipcMain.handle('chat:getConversations', async () => {
|
||||
try {
|
||||
const engine = getChatEngine();
|
||||
return await engine.getRecentConversations();
|
||||
} catch (error) {
|
||||
console.error('[Chat IPC] Error getting conversations:', error);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
// Create new conversation
|
||||
ipcMain.handle('chat:createConversation', async (_, title?: string, model?: string) => {
|
||||
try {
|
||||
const engine = getChatEngine();
|
||||
const systemPrompt = await engine.getDefaultSystemPrompt();
|
||||
const selectedModel = model || (await engine.getSelectedModel());
|
||||
|
||||
const conversation = await engine.createConversation({
|
||||
title: title || 'New Chat',
|
||||
model: selectedModel,
|
||||
systemPrompt,
|
||||
});
|
||||
|
||||
return conversation;
|
||||
} catch (error) {
|
||||
console.error('[Chat IPC] Error creating conversation:', error);
|
||||
return { error: (error as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
// Get conversation by ID
|
||||
ipcMain.handle('chat:getConversation', async (_, id: string) => {
|
||||
try {
|
||||
const engine = getChatEngine();
|
||||
return await engine.getConversation(id);
|
||||
} catch (error) {
|
||||
console.error('[Chat IPC] Error getting conversation:', error);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
// Update conversation
|
||||
ipcMain.handle('chat:updateConversation', async (_, id: string, updates: { title?: string; model?: string }) => {
|
||||
try {
|
||||
const engine = getChatEngine();
|
||||
await engine.updateConversation(id, updates);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('[Chat IPC] Error updating conversation:', error);
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
// Delete conversation
|
||||
ipcMain.handle('chat:deleteConversation', async (_, id: string) => {
|
||||
try {
|
||||
const engine = getChatEngine();
|
||||
await engine.deleteConversation(id);
|
||||
|
||||
// Also destroy any active session
|
||||
const manager = getCopilotManager();
|
||||
await manager.destroySession(id);
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('[Chat IPC] Error deleting conversation:', error);
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
// ============ Chat Messaging ============
|
||||
|
||||
// Send a message
|
||||
ipcMain.handle('chat:sendMessage', async (_, conversationId: string, message: string) => {
|
||||
try {
|
||||
const manager = getCopilotManager();
|
||||
const mainWindow = mainWindowGetter?.();
|
||||
|
||||
const result = await manager.sendMessage(conversationId, message, {
|
||||
onDelta: (delta) => {
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send('chat-stream-delta', { conversationId, delta });
|
||||
}
|
||||
},
|
||||
onToolCall: (toolCall) => {
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send('chat-tool-call', { conversationId, toolCall });
|
||||
}
|
||||
},
|
||||
onToolResult: (result) => {
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send('chat-tool-result', { conversationId, result });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('[Chat IPC] Error sending message:', error);
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
// Abort a running message
|
||||
ipcMain.handle('chat:abortMessage', async (_, conversationId: string) => {
|
||||
try {
|
||||
const manager = getCopilotManager();
|
||||
return await manager.abortMessage(conversationId);
|
||||
} catch (error) {
|
||||
console.error('[Chat IPC] Error aborting message:', error);
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
// Get message history for a conversation
|
||||
ipcMain.handle('chat:getHistory', async (_, conversationId: string) => {
|
||||
try {
|
||||
const engine = getChatEngine();
|
||||
return await engine.getMessages(conversationId);
|
||||
} catch (error) {
|
||||
console.error('[Chat IPC] Error getting history:', error);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
// Clear messages from a conversation
|
||||
ipcMain.handle('chat:clearMessages', async (_, conversationId: string) => {
|
||||
try {
|
||||
const engine = getChatEngine();
|
||||
await engine.clearMessages(conversationId);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('[Chat IPC] Error clearing messages:', error);
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
// Set conversation model
|
||||
ipcMain.handle('chat:setConversationModel', async (_, conversationId: string, modelId: string) => {
|
||||
try {
|
||||
const engine = getChatEngine();
|
||||
await engine.updateConversation(conversationId, { model: modelId });
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('[Chat IPC] Error setting conversation model:', error);
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup chat resources
|
||||
*/
|
||||
export async function cleanupChatHandlers(): Promise<void> {
|
||||
if (copilotManager) {
|
||||
await copilotManager.stop();
|
||||
copilotManager = null;
|
||||
}
|
||||
chatEngine = null;
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
export { registerIpcHandlers } from './handlers';
|
||||
export { registerChatHandlers, initializeChatHandlers, cleanupChatHandlers } from './chatHandlers';
|
||||
|
||||
@@ -2,7 +2,7 @@ import { app, BrowserWindow, Menu, MenuItemConstructorOptions, ipcMain, protocol
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import { getDatabase } from './database';
|
||||
import { registerIpcHandlers } from './ipc';
|
||||
import { registerIpcHandlers, registerChatHandlers, initializeChatHandlers, cleanupChatHandlers } from './ipc';
|
||||
import { media } from './database/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
@@ -385,6 +385,10 @@ async function initialize(): Promise<void> {
|
||||
|
||||
// Register IPC handlers
|
||||
registerIpcHandlers();
|
||||
|
||||
// Initialize and register chat handlers
|
||||
initializeChatHandlers(() => mainWindow);
|
||||
registerChatHandlers();
|
||||
}
|
||||
|
||||
// App lifecycle
|
||||
@@ -406,6 +410,9 @@ app.on('window-all-closed', () => {
|
||||
});
|
||||
|
||||
app.on('before-quit', async () => {
|
||||
// Cleanup chat resources
|
||||
await cleanupChatHandlers();
|
||||
|
||||
const db = getDatabase();
|
||||
await db.close();
|
||||
});
|
||||
|
||||
@@ -129,6 +129,62 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
syncFromPosts: () => ipcRenderer.invoke('tags:syncFromPosts'),
|
||||
},
|
||||
|
||||
// AI Chat (Copilot SDK integration)
|
||||
chat: {
|
||||
// Authentication
|
||||
checkReady: () => ipcRenderer.invoke('chat:checkReady'),
|
||||
copilotAuthStatus: () => ipcRenderer.invoke('chat:copilotAuthStatus'),
|
||||
copilotLogin: () => ipcRenderer.invoke('chat:copilotLogin'),
|
||||
copilotLogout: () => ipcRenderer.invoke('chat:copilotLogout'),
|
||||
|
||||
// Settings
|
||||
getAvailableModels: () => ipcRenderer.invoke('chat:getAvailableModels'),
|
||||
setDefaultModel: (modelId: string) => ipcRenderer.invoke('chat:setDefaultModel', modelId),
|
||||
getSystemPrompt: () => ipcRenderer.invoke('chat:getSystemPrompt'),
|
||||
setSystemPrompt: (prompt: string) => ipcRenderer.invoke('chat:setSystemPrompt', prompt),
|
||||
|
||||
// Conversations
|
||||
getConversations: () => ipcRenderer.invoke('chat:getConversations'),
|
||||
createConversation: (title?: string, model?: string) => ipcRenderer.invoke('chat:createConversation', title, model),
|
||||
getConversation: (id: string) => ipcRenderer.invoke('chat:getConversation', id),
|
||||
updateConversation: (id: string, updates: { title?: string; model?: string }) => ipcRenderer.invoke('chat:updateConversation', id, updates),
|
||||
deleteConversation: (id: string) => ipcRenderer.invoke('chat:deleteConversation', id),
|
||||
|
||||
// Messaging
|
||||
sendMessage: (conversationId: string, message: string) => ipcRenderer.invoke('chat:sendMessage', conversationId, message),
|
||||
abortMessage: (conversationId: string) => ipcRenderer.invoke('chat:abortMessage', conversationId),
|
||||
getHistory: (conversationId: string) => ipcRenderer.invoke('chat:getHistory', conversationId),
|
||||
clearMessages: (conversationId: string) => ipcRenderer.invoke('chat:clearMessages', conversationId),
|
||||
setConversationModel: (conversationId: string, modelId: string) => ipcRenderer.invoke('chat:setConversationModel', conversationId, modelId),
|
||||
|
||||
// Event listeners for streaming/progress
|
||||
onStreamDelta: (callback: (data: { conversationId: string; delta: string }) => void) => {
|
||||
const subscription = (_event: Electron.IpcRendererEvent, data: { conversationId: string; delta: string }) => callback(data);
|
||||
ipcRenderer.on('chat-stream-delta', subscription);
|
||||
return () => ipcRenderer.removeListener('chat-stream-delta', subscription);
|
||||
},
|
||||
onToolCall: (callback: (data: { conversationId: string; toolCall: unknown }) => void) => {
|
||||
const subscription = (_event: Electron.IpcRendererEvent, data: { conversationId: string; toolCall: unknown }) => callback(data);
|
||||
ipcRenderer.on('chat-tool-call', subscription);
|
||||
return () => ipcRenderer.removeListener('chat-tool-call', subscription);
|
||||
},
|
||||
onToolResult: (callback: (data: { conversationId: string; result: unknown }) => void) => {
|
||||
const subscription = (_event: Electron.IpcRendererEvent, data: { conversationId: string; result: unknown }) => callback(data);
|
||||
ipcRenderer.on('chat-tool-result', subscription);
|
||||
return () => ipcRenderer.removeListener('chat-tool-result', subscription);
|
||||
},
|
||||
onTitleUpdated: (callback: (data: { conversationId: string; title: string }) => void) => {
|
||||
const subscription = (_event: Electron.IpcRendererEvent, data: { conversationId: string; title: string }) => callback(data);
|
||||
ipcRenderer.on('chat-title-updated', subscription);
|
||||
return () => ipcRenderer.removeListener('chat-title-updated', subscription);
|
||||
},
|
||||
onDeviceCode: (callback: (data: { verificationUri: string; userCode: string }) => void) => {
|
||||
const subscription = (_event: Electron.IpcRendererEvent, data: { verificationUri: string; userCode: string }) => callback(data);
|
||||
ipcRenderer.on('copilot-device-code', subscription);
|
||||
return () => ipcRenderer.removeListener('copilot-device-code', subscription);
|
||||
},
|
||||
},
|
||||
|
||||
// Event listeners
|
||||
on: (channel: string, callback: (...args: unknown[]) => void) => {
|
||||
const subscription = (_event: Electron.IpcRendererEvent, ...args: unknown[]) => callback(...args);
|
||||
@@ -225,6 +281,53 @@ export interface ElectronAPI {
|
||||
removeCategory: (category: string) => Promise<string[]>;
|
||||
syncOnStartup: () => Promise<{ tags: string[]; categories: string[] }>;
|
||||
};
|
||||
tags: {
|
||||
getAll: () => Promise<unknown[]>;
|
||||
getWithCounts: () => Promise<unknown[]>;
|
||||
get: (id: string) => Promise<unknown>;
|
||||
getByName: (name: string) => Promise<unknown>;
|
||||
create: (data: { name: string; color?: string }) => Promise<unknown>;
|
||||
update: (id: string, data: { name?: string; color?: string | null }) => Promise<unknown>;
|
||||
delete: (id: string) => Promise<boolean>;
|
||||
merge: (sourceTagIds: string[], targetTagId: string) => Promise<void>;
|
||||
rename: (id: string, newName: string) => Promise<unknown>;
|
||||
getPostsWithTag: (tagId: string) => Promise<unknown[]>;
|
||||
syncFromPosts: () => Promise<void>;
|
||||
};
|
||||
chat: {
|
||||
// Authentication
|
||||
checkReady: () => Promise<{ ready: boolean; authenticated: boolean }>;
|
||||
copilotAuthStatus: () => Promise<{ authenticated: boolean; username?: string }>;
|
||||
copilotLogin: () => Promise<{ success: boolean; error?: string; login?: string }>;
|
||||
copilotLogout: () => Promise<void>;
|
||||
|
||||
// Settings
|
||||
getAvailableModels: () => Promise<Array<{ id: string; name: string }>>;
|
||||
setDefaultModel: (modelId: string) => Promise<void>;
|
||||
getSystemPrompt: () => Promise<string | null>;
|
||||
setSystemPrompt: (prompt: string) => Promise<void>;
|
||||
|
||||
// Conversations
|
||||
getConversations: () => Promise<unknown[]>;
|
||||
createConversation: (title?: string, model?: string) => Promise<unknown>;
|
||||
getConversation: (id: string) => Promise<unknown>;
|
||||
updateConversation: (id: string, updates: { title?: string; model?: string }) => Promise<unknown>;
|
||||
deleteConversation: (id: string) => Promise<boolean>;
|
||||
|
||||
// Messaging
|
||||
sendMessage: (conversationId: string, message: string) => Promise<string>;
|
||||
abortMessage: (conversationId: string) => Promise<void>;
|
||||
getHistory: (conversationId: string) => Promise<unknown[]>;
|
||||
clearMessages: (conversationId: string) => Promise<void>;
|
||||
setConversationModel: (conversationId: string, modelId: string) => Promise<void>;
|
||||
|
||||
// Event listeners
|
||||
onStreamDelta: (callback: (data: { conversationId: string; delta: string }) => void) => () => void;
|
||||
onToolCall: (callback: (data: { conversationId: string; toolCall: unknown }) => void) => () => void;
|
||||
onToolResult: (callback: (data: { conversationId: string; result: unknown }) => void) => () => void;
|
||||
onTitleUpdated: (callback: (data: { conversationId: string; title: string }) => void) => () => void;
|
||||
onDeviceCode: (callback: (data: { verificationUri: string; userCode: string }) => void) => () => void;
|
||||
};
|
||||
on: (channel: string, callback: (...args: unknown[]) => void) => () => void;
|
||||
once: (channel: string, callback: (...args: unknown[]) => void) => void;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,15 @@ const TagsIcon = () => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ChatIcon = () => (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H6l-2 2V4h16v12z"/>
|
||||
<circle cx="8" cy="10" r="1.5"/>
|
||||
<circle cx="12" cy="10" r="1.5"/>
|
||||
<circle cx="16" cy="10" r="1.5"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const SyncIcon = () => (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46C19.54 15.03 20 13.57 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74C4.46 8.97 4 10.43 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z"/>
|
||||
@@ -45,8 +54,11 @@ export const ActivityBar: React.FC = () => {
|
||||
// Check if tags tab is currently active
|
||||
const isTagsTabActive = tabs.some(t => t.type === 'tags' && t.id === activeTabId);
|
||||
|
||||
// Check if chat sidebar is active (activeView === 'chat' and sidebar is visible)
|
||||
const isChatActive = activeView === 'chat' && sidebarVisible;
|
||||
|
||||
// Handle view click - toggle sidebar if clicking on active view, otherwise switch view
|
||||
const handleViewClick = (view: 'posts' | 'media') => {
|
||||
const handleViewClick = (view: 'posts' | 'media' | 'chat') => {
|
||||
if (activeView === view && sidebarVisible) {
|
||||
// Clicking on active view toggles sidebar off
|
||||
toggleSidebar();
|
||||
@@ -96,6 +108,13 @@ export const ActivityBar: React.FC = () => {
|
||||
>
|
||||
<TagsIcon />
|
||||
</button>
|
||||
<button
|
||||
className={`activity-bar-item ${isChatActive ? 'active' : ''}`}
|
||||
onClick={() => handleViewClick('chat')}
|
||||
title="AI Assistant (click again to toggle sidebar)"
|
||||
>
|
||||
<ChatIcon />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="activity-bar-bottom">
|
||||
|
||||
346
src/renderer/components/ChatPanel/ChatPanel.css
Normal file
346
src/renderer/components/ChatPanel/ChatPanel.css
Normal file
@@ -0,0 +1,346 @@
|
||||
.chat-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background-color: var(--vscode-editor-background);
|
||||
}
|
||||
|
||||
.chat-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--vscode-editorGroup-border);
|
||||
background-color: var(--vscode-sideBar-background);
|
||||
}
|
||||
|
||||
.chat-panel-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--vscode-foreground);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.chat-panel-model {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.model-selector-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
background: transparent;
|
||||
border: 1px solid var(--vscode-input-border);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.model-selector-button:hover {
|
||||
background-color: var(--vscode-list-hoverBackground);
|
||||
}
|
||||
|
||||
.model-dropdown-icon {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.model-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
margin-top: 4px;
|
||||
min-width: 160px;
|
||||
background-color: var(--vscode-dropdown-background);
|
||||
border: 1px solid var(--vscode-dropdown-border);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.model-option {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
color: var(--vscode-foreground);
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.model-option:hover {
|
||||
background-color: var(--vscode-list-hoverBackground);
|
||||
}
|
||||
|
||||
.model-option.active {
|
||||
background-color: var(--vscode-list-activeSelectionBackground);
|
||||
color: var(--vscode-list-activeSelectionForeground);
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.chat-welcome {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 32px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
|
||||
.chat-welcome-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.chat-welcome h2 {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
color: var(--vscode-foreground);
|
||||
}
|
||||
|
||||
.chat-welcome p {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.chat-welcome ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.chat-welcome li {
|
||||
padding: 4px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.chat-welcome li::before {
|
||||
content: '•';
|
||||
margin-right: 8px;
|
||||
color: var(--vscode-textLink-foreground);
|
||||
}
|
||||
|
||||
.chat-message {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.chat-message.user {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.chat-message-avatar {
|
||||
flex-shrink: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--vscode-input-background);
|
||||
}
|
||||
|
||||
.chat-message.user .chat-message-avatar {
|
||||
background-color: var(--vscode-button-background);
|
||||
}
|
||||
|
||||
.chat-message-content {
|
||||
max-width: 80%;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.chat-message.user .chat-message-content {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.chat-message-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.chat-message.user .chat-message-header {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.chat-message-role {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
|
||||
.streaming-indicator {
|
||||
color: var(--vscode-button-background);
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
|
||||
.chat-message-text {
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: var(--vscode-foreground);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
padding: 10px 14px;
|
||||
border-radius: 12px;
|
||||
background-color: var(--vscode-input-background);
|
||||
}
|
||||
|
||||
.chat-message.user .chat-message-text {
|
||||
background-color: var(--vscode-button-background);
|
||||
color: var(--vscode-button-foreground);
|
||||
border-radius: 12px 12px 2px 12px;
|
||||
}
|
||||
|
||||
.chat-message.assistant .chat-message-text {
|
||||
border-radius: 12px 12px 12px 2px;
|
||||
}
|
||||
|
||||
.chat-message.streaming .chat-message-text {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--vscode-input-background) 0%,
|
||||
var(--vscode-list-hoverBackground) 50%,
|
||||
var(--vscode-input-background) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
.chat-thinking-indicator {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.chat-thinking-indicator span {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--vscode-descriptionForeground);
|
||||
animation: bounce 1.4s infinite ease-in-out both;
|
||||
}
|
||||
|
||||
.chat-thinking-indicator span:nth-child(1) {
|
||||
animation-delay: -0.32s;
|
||||
}
|
||||
|
||||
.chat-thinking-indicator span:nth-child(2) {
|
||||
animation-delay: -0.16s;
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0%, 80%, 100% {
|
||||
transform: scale(0);
|
||||
}
|
||||
40% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.chat-input-container {
|
||||
padding: 16px;
|
||||
border-top: 1px solid var(--vscode-editorGroup-border);
|
||||
background-color: var(--vscode-sideBar-background);
|
||||
}
|
||||
|
||||
.chat-abort-button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-bottom: 8px;
|
||||
padding: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--vscode-errorForeground);
|
||||
background-color: transparent;
|
||||
border: 1px solid var(--vscode-errorForeground);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chat-abort-button:hover {
|
||||
background-color: var(--vscode-inputValidation-errorBackground);
|
||||
}
|
||||
|
||||
.chat-input-wrapper {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
background-color: var(--vscode-input-background);
|
||||
border: 1px solid var(--vscode-input-border);
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.chat-input-wrapper:focus-within {
|
||||
border-color: var(--vscode-focusBorder);
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
flex: 1;
|
||||
min-height: 24px;
|
||||
max-height: 120px;
|
||||
padding: 0;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
line-height: 1.5;
|
||||
color: var(--vscode-input-foreground);
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.chat-input::placeholder {
|
||||
color: var(--vscode-input-placeholderForeground);
|
||||
}
|
||||
|
||||
.chat-send-button {
|
||||
flex-shrink: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
color: var(--vscode-button-foreground);
|
||||
background-color: var(--vscode-button-background);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.chat-send-button:hover:not(:disabled) {
|
||||
background-color: var(--vscode-button-hoverBackground);
|
||||
}
|
||||
|
||||
.chat-send-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
263
src/renderer/components/ChatPanel/ChatPanel.tsx
Normal file
263
src/renderer/components/ChatPanel/ChatPanel.tsx
Normal file
@@ -0,0 +1,263 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import type { ChatMessage, ChatConversation, ChatModel } from '../../types/electron';
|
||||
import './ChatPanel.css';
|
||||
|
||||
interface ChatPanelProps {
|
||||
conversationId: string;
|
||||
}
|
||||
|
||||
export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
||||
const [conversation, setConversation] = useState<ChatConversation | null>(null);
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [streamingContent, setStreamingContent] = useState('');
|
||||
const [availableModels, setAvailableModels] = useState<ChatModel[]>([]);
|
||||
const [showModelSelector, setShowModelSelector] = useState(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const streamingRef = useRef('');
|
||||
|
||||
// Scroll to bottom when messages change
|
||||
const scrollToBottom = useCallback(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, []);
|
||||
|
||||
// Load conversation and messages
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
const [conv, msgs, models] = await Promise.all([
|
||||
window.electronAPI?.chat.getConversation(conversationId),
|
||||
window.electronAPI?.chat.getHistory(conversationId),
|
||||
window.electronAPI?.chat.getAvailableModels()
|
||||
]);
|
||||
|
||||
if (conv) setConversation(conv);
|
||||
if (msgs) setMessages(msgs);
|
||||
if (models) setAvailableModels(models);
|
||||
} catch (error) {
|
||||
console.error('Failed to load chat data:', error);
|
||||
}
|
||||
}, [conversationId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
|
||||
// Subscribe to stream events
|
||||
const unsubDelta = window.electronAPI?.chat.onStreamDelta((data) => {
|
||||
if (data.conversationId === conversationId) {
|
||||
streamingRef.current += data.delta;
|
||||
setStreamingContent(streamingRef.current);
|
||||
scrollToBottom();
|
||||
}
|
||||
});
|
||||
|
||||
const unsubTitle = window.electronAPI?.chat.onTitleUpdated((data) => {
|
||||
if (data.conversationId === conversationId) {
|
||||
setConversation(prev => prev ? { ...prev, title: data.title } : null);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubDelta?.();
|
||||
unsubTitle?.();
|
||||
};
|
||||
}, [conversationId, loadData, scrollToBottom]);
|
||||
|
||||
// Scroll on new messages or streaming content
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages, streamingContent, scrollToBottom]);
|
||||
|
||||
const handleSend = async () => {
|
||||
const message = inputValue.trim();
|
||||
if (!message || isStreaming) return;
|
||||
|
||||
setInputValue('');
|
||||
setIsStreaming(true);
|
||||
streamingRef.current = '';
|
||||
setStreamingContent('');
|
||||
|
||||
// Add user message optimistically
|
||||
const userMessage: ChatMessage = {
|
||||
id: `temp-${Date.now()}`,
|
||||
conversationId,
|
||||
role: 'user',
|
||||
content: message,
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
setMessages(prev => [...prev, userMessage]);
|
||||
|
||||
try {
|
||||
// Send message and wait for complete response
|
||||
await window.electronAPI?.chat.sendMessage(conversationId, message);
|
||||
|
||||
// Reload messages to get the saved assistant response
|
||||
const msgs = await window.electronAPI?.chat.getHistory(conversationId);
|
||||
if (msgs) setMessages(msgs);
|
||||
} catch (error) {
|
||||
console.error('Failed to send message:', error);
|
||||
// Add error message
|
||||
const errorMessage: ChatMessage = {
|
||||
id: `error-${Date.now()}`,
|
||||
conversationId,
|
||||
role: 'assistant',
|
||||
content: 'Sorry, an error occurred while processing your message.',
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
setMessages(prev => [...prev, errorMessage]);
|
||||
} finally {
|
||||
setIsStreaming(false);
|
||||
setStreamingContent('');
|
||||
streamingRef.current = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
const handleAbort = async () => {
|
||||
try {
|
||||
await window.electronAPI?.chat.abortMessage(conversationId);
|
||||
} catch (error) {
|
||||
console.error('Failed to abort:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleModelChange = async (modelId: string) => {
|
||||
try {
|
||||
await window.electronAPI?.chat.setConversationModel(conversationId, modelId);
|
||||
setConversation(prev => prev ? { ...prev, model: modelId } : null);
|
||||
setShowModelSelector(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to change model:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const renderMessage = (msg: ChatMessage) => {
|
||||
if (msg.role === 'system' || msg.role === 'tool') return null;
|
||||
|
||||
return (
|
||||
<div key={msg.id} className={`chat-message ${msg.role}`}>
|
||||
<div className="chat-message-avatar">
|
||||
{msg.role === 'user' ? '👤' : '🤖'}
|
||||
</div>
|
||||
<div className="chat-message-content">
|
||||
<div className="chat-message-header">
|
||||
<span className="chat-message-role">
|
||||
{msg.role === 'user' ? 'You' : 'Assistant'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="chat-message-text">{msg.content}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="chat-panel">
|
||||
<div className="chat-panel-header">
|
||||
<div className="chat-panel-title">
|
||||
{conversation?.title || 'New Chat'}
|
||||
</div>
|
||||
<div className="chat-panel-model">
|
||||
<button
|
||||
className="model-selector-button"
|
||||
onClick={() => setShowModelSelector(!showModelSelector)}
|
||||
>
|
||||
{conversation?.model || 'gpt-4o'}
|
||||
<span className="model-dropdown-icon">▾</span>
|
||||
</button>
|
||||
{showModelSelector && (
|
||||
<div className="model-dropdown">
|
||||
{availableModels.map(model => (
|
||||
<button
|
||||
key={model.id}
|
||||
className={`model-option ${conversation?.model === model.id ? 'active' : ''}`}
|
||||
onClick={() => handleModelChange(model.id)}
|
||||
>
|
||||
{model.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="chat-messages">
|
||||
{messages.length === 0 && !isStreaming && (
|
||||
<div className="chat-welcome">
|
||||
<div className="chat-welcome-icon">🤖</div>
|
||||
<h2>Welcome to the AI Assistant</h2>
|
||||
<p>I can help you manage your posts and media. Try asking me to:</p>
|
||||
<ul>
|
||||
<li>Search for posts about a specific topic</li>
|
||||
<li>Get details about a specific post</li>
|
||||
<li>Update metadata for posts or media</li>
|
||||
<li>List all images in your media library</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map(renderMessage)}
|
||||
|
||||
{isStreaming && streamingContent && (
|
||||
<div className="chat-message assistant streaming">
|
||||
<div className="chat-message-avatar">🤖</div>
|
||||
<div className="chat-message-content">
|
||||
<div className="chat-message-header">
|
||||
<span className="chat-message-role">Assistant</span>
|
||||
<span className="streaming-indicator">●</span>
|
||||
</div>
|
||||
<div className="chat-message-text">{streamingContent}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isStreaming && !streamingContent && (
|
||||
<div className="chat-message assistant thinking">
|
||||
<div className="chat-message-avatar">🤖</div>
|
||||
<div className="chat-message-content">
|
||||
<div className="chat-thinking-indicator">
|
||||
<span></span><span></span><span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
<div className="chat-input-container">
|
||||
{isStreaming && (
|
||||
<button className="chat-abort-button" onClick={handleAbort}>
|
||||
◼ Stop
|
||||
</button>
|
||||
)}
|
||||
<div className="chat-input-wrapper">
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
className="chat-input"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Type a message..."
|
||||
rows={1}
|
||||
disabled={isStreaming}
|
||||
/>
|
||||
<button
|
||||
className="chat-send-button"
|
||||
onClick={handleSend}
|
||||
disabled={!inputValue.trim() || isStreaming}
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
1
src/renderer/components/ChatPanel/index.ts
Normal file
1
src/renderer/components/ChatPanel/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ChatPanel } from './ChatPanel';
|
||||
@@ -9,6 +9,7 @@ import { ErrorModal } from '../ErrorModal';
|
||||
import { SettingsView } from '../SettingsView';
|
||||
import { TagsView } from '../TagsView';
|
||||
import { TagInput } from '../TagInput';
|
||||
import { ChatPanel } from '../ChatPanel';
|
||||
import { AutoSaveManager } from '../../utils';
|
||||
import './Editor.css';
|
||||
|
||||
@@ -1005,6 +1006,7 @@ export const Editor: React.FC = () => {
|
||||
const showMedia = activeTab?.type === 'media';
|
||||
const showSettings = activeTab?.type === 'settings' || (activeView === 'settings' && !activeTab);
|
||||
const showTags = activeTab?.type === 'tags' || (activeView === 'tags' && !activeTab);
|
||||
const showChat = activeTab?.type === 'chat';
|
||||
|
||||
// Clear selectedPostId if the post doesn't exist (e.g., after project switch)
|
||||
useEffect(() => {
|
||||
@@ -1068,6 +1070,16 @@ export const Editor: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
// Show chat if chat tab is active
|
||||
if (showChat && activeTabId) {
|
||||
return (
|
||||
<>
|
||||
<ChatPanel key={activeTabId} conversationId={activeTabId} />
|
||||
{renderErrorModal()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Show post editor if a post tab is active
|
||||
if (showPost && activeTabId) {
|
||||
const post = posts.find(p => p.id === activeTabId);
|
||||
|
||||
@@ -606,3 +606,180 @@
|
||||
.sidebar-item.unsaved .sidebar-item-meta {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Chat List Styles */
|
||||
.chat-list {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chat-list-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--vscode-sideBar-foreground);
|
||||
border-bottom: 1px solid var(--vscode-sideBar-border);
|
||||
}
|
||||
|
||||
.chat-new-button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--vscode-textLink-foreground);
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.chat-new-button:hover {
|
||||
background-color: var(--vscode-list-hoverBackground);
|
||||
}
|
||||
|
||||
.chat-user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
border-bottom: 1px solid var(--vscode-sideBar-border);
|
||||
}
|
||||
|
||||
.chat-user-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.chat-username {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chat-list-items {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.chat-list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid var(--vscode-sideBar-border);
|
||||
}
|
||||
|
||||
.chat-list-item:hover {
|
||||
background-color: var(--vscode-list-hoverBackground);
|
||||
}
|
||||
|
||||
.chat-item-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-item-title {
|
||||
font-size: 13px;
|
||||
color: var(--vscode-foreground);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.chat-item-date {
|
||||
font-size: 11px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
|
||||
.chat-item-delete {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
padding: 0 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.chat-list-item:hover .chat-item-delete {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.chat-item-delete:hover {
|
||||
color: var(--vscode-errorForeground);
|
||||
}
|
||||
|
||||
.chat-loading,
|
||||
.chat-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.chat-start-button,
|
||||
.chat-login-button {
|
||||
margin-top: 12px;
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
color: var(--vscode-button-foreground);
|
||||
background-color: var(--vscode-button-background);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chat-start-button:hover,
|
||||
.chat-login-button:hover {
|
||||
background-color: var(--vscode-button-hoverBackground);
|
||||
}
|
||||
|
||||
.chat-auth-prompt {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
|
||||
.chat-auth-prompt p {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.device-code-prompt {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.device-code-prompt a {
|
||||
color: var(--vscode-textLink-foreground);
|
||||
text-decoration: none;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.device-code-prompt a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.device-code {
|
||||
margin-top: 8px;
|
||||
padding: 8px 16px;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
font-family: var(--vscode-editor-font-family, monospace);
|
||||
letter-spacing: 3px;
|
||||
color: var(--vscode-foreground);
|
||||
background-color: var(--vscode-input-background);
|
||||
border: 1px solid var(--vscode-input-border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useAppStore, PostData } from '../../store';
|
||||
import { showToast } from '../Toast';
|
||||
import type { ChatConversation } from '../../types/electron';
|
||||
import './Sidebar.css';
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
@@ -747,6 +748,210 @@ const SettingsNav: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
// Chat conversations list
|
||||
const ChatList: React.FC = () => {
|
||||
const { openTab } = useAppStore();
|
||||
const [conversations, setConversations] = useState<ChatConversation[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [authStatus, setAuthStatus] = useState<{ authenticated: boolean; username?: string } | null>(null);
|
||||
const [deviceCode, setDeviceCode] = useState<{ verificationUri: string; userCode: string } | null>(null);
|
||||
|
||||
// Load conversations
|
||||
const loadConversations = useCallback(async () => {
|
||||
try {
|
||||
const convs = await window.electronAPI?.chat.getConversations();
|
||||
if (convs) {
|
||||
setConversations(convs);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load conversations:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Check auth status
|
||||
const checkAuth = useCallback(async () => {
|
||||
try {
|
||||
const status = await window.electronAPI?.chat.copilotAuthStatus();
|
||||
setAuthStatus(status ?? null);
|
||||
} catch (error) {
|
||||
console.error('Failed to check auth:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
setIsLoading(true);
|
||||
await checkAuth();
|
||||
await loadConversations();
|
||||
setIsLoading(false);
|
||||
};
|
||||
init();
|
||||
|
||||
// Subscribe to title updates
|
||||
const unsubTitle = window.electronAPI?.chat.onTitleUpdated((data) => {
|
||||
setConversations(prev =>
|
||||
prev.map(c => c.id === data.conversationId ? { ...c, title: data.title } : c)
|
||||
);
|
||||
});
|
||||
|
||||
// Subscribe to device code for login flow
|
||||
const unsubDevice = window.electronAPI?.chat.onDeviceCode((data) => {
|
||||
setDeviceCode(data);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubTitle?.();
|
||||
unsubDevice?.();
|
||||
};
|
||||
}, [loadConversations, checkAuth]);
|
||||
|
||||
const handleNewChat = async () => {
|
||||
try {
|
||||
const conversation = await window.electronAPI?.chat.createConversation();
|
||||
if (conversation) {
|
||||
setConversations(prev => [conversation, ...prev]);
|
||||
openTab({ type: 'chat', id: conversation.id, isTransient: false });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create conversation:', error);
|
||||
showToast.error('Failed to create new chat');
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenChat = (conversationId: string) => {
|
||||
openTab({ type: 'chat', id: conversationId, isTransient: false });
|
||||
};
|
||||
|
||||
const handleDeleteChat = async (conversationId: string) => {
|
||||
try {
|
||||
await window.electronAPI?.chat.deleteConversation(conversationId);
|
||||
setConversations(prev => prev.filter(c => c.id !== conversationId));
|
||||
} catch (error) {
|
||||
console.error('Failed to delete conversation:', error);
|
||||
showToast.error('Failed to delete chat');
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogin = async () => {
|
||||
try {
|
||||
const result = await window.electronAPI?.chat.copilotLogin();
|
||||
if (result?.success) {
|
||||
setDeviceCode(null);
|
||||
await checkAuth();
|
||||
} else if (result?.error) {
|
||||
console.error('Login failed:', result.error);
|
||||
showToast.error(result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Login failed:', error);
|
||||
showToast.error('Login failed');
|
||||
}
|
||||
};
|
||||
|
||||
const formatChatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffDays = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 0) {
|
||||
return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
|
||||
} else if (diffDays === 1) {
|
||||
return 'Yesterday';
|
||||
} else if (diffDays < 7) {
|
||||
return date.toLocaleDateString('en-US', { weekday: 'short' });
|
||||
}
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="chat-list">
|
||||
<div className="chat-list-header">
|
||||
<span>AI ASSISTANT</span>
|
||||
</div>
|
||||
<div className="chat-loading">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show login prompt if not authenticated
|
||||
if (!authStatus?.authenticated) {
|
||||
return (
|
||||
<div className="chat-list">
|
||||
<div className="chat-list-header">
|
||||
<span>AI ASSISTANT</span>
|
||||
</div>
|
||||
<div className="chat-auth-prompt">
|
||||
<p>Sign in to GitHub Copilot to start chatting</p>
|
||||
{deviceCode ? (
|
||||
<div className="device-code-prompt">
|
||||
<p>Enter this code at:</p>
|
||||
<a href={deviceCode.verificationUri} target="_blank" rel="noopener noreferrer">
|
||||
{deviceCode.verificationUri}
|
||||
</a>
|
||||
<div className="device-code">{deviceCode.userCode}</div>
|
||||
</div>
|
||||
) : (
|
||||
<button className="chat-login-button" onClick={handleLogin}>
|
||||
Sign in with GitHub
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="chat-list">
|
||||
<div className="chat-list-header">
|
||||
<span>AI ASSISTANT</span>
|
||||
<button className="chat-new-button" onClick={handleNewChat} title="New Chat">
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
{authStatus.username && (
|
||||
<div className="chat-user-info">
|
||||
<span className="chat-user-icon">👤</span>
|
||||
<span className="chat-username">{authStatus.username}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="chat-list-items">
|
||||
{conversations.length === 0 ? (
|
||||
<div className="chat-empty">
|
||||
<p>No conversations yet</p>
|
||||
<button className="chat-start-button" onClick={handleNewChat}>
|
||||
Start a new chat
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
conversations.map(conv => (
|
||||
<div
|
||||
key={conv.id}
|
||||
className="chat-list-item"
|
||||
onClick={() => handleOpenChat(conv.id)}
|
||||
>
|
||||
<div className="chat-item-content">
|
||||
<div className="chat-item-title">{conv.title}</div>
|
||||
<div className="chat-item-date">{formatChatDate(conv.updatedAt)}</div>
|
||||
</div>
|
||||
<button
|
||||
className="chat-item-delete"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteChat(conv.id);
|
||||
}}
|
||||
title="Delete conversation"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Sidebar: React.FC = () => {
|
||||
const { activeView, sidebarVisible } = useAppStore();
|
||||
|
||||
@@ -760,6 +965,7 @@ export const Sidebar: React.FC = () => {
|
||||
{activeView === 'media' && <MediaList />}
|
||||
{activeView === 'settings' && <SettingsNav />}
|
||||
{activeView === 'tags' && <TagsNav />}
|
||||
{activeView === 'chat' && <ChatList />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -16,3 +16,4 @@ export { TagsView, scrollToTagsSection, type TagsCategory } from './TagsView';
|
||||
export { TagInput } from './TagInput';
|
||||
export { PostLinks } from './PostLinks';
|
||||
export { ErrorModal, type ErrorDetails } from './ErrorModal';
|
||||
export { ChatPanel } from './ChatPanel';
|
||||
|
||||
@@ -5,7 +5,7 @@ import { persist } from 'zustand/middleware';
|
||||
const STORAGE_KEY = 'bds-app-state';
|
||||
|
||||
// Tab types
|
||||
export type TabType = 'post' | 'media' | 'settings' | 'tags';
|
||||
export type TabType = 'post' | 'media' | 'settings' | 'tags' | 'chat';
|
||||
|
||||
export interface Tab {
|
||||
type: TabType;
|
||||
@@ -88,7 +88,7 @@ interface AppState {
|
||||
activeTabId: string | null;
|
||||
|
||||
// UI State
|
||||
activeView: 'posts' | 'media' | 'settings' | 'tags';
|
||||
activeView: 'posts' | 'media' | 'settings' | 'tags' | 'chat';
|
||||
sidebarVisible: boolean;
|
||||
panelVisible: boolean;
|
||||
selectedPostId: string | null;
|
||||
@@ -136,7 +136,7 @@ interface AppState {
|
||||
restoreTabState: (state: TabState) => void;
|
||||
|
||||
// Actions
|
||||
setActiveView: (view: 'posts' | 'media' | 'settings' | 'tags') => void;
|
||||
setActiveView: (view: 'posts' | 'media' | 'settings' | 'tags' | 'chat') => void;
|
||||
toggleSidebar: () => void;
|
||||
togglePanel: () => void;
|
||||
setSelectedPost: (id: string | null) => void;
|
||||
|
||||
98
src/renderer/types/electron.d.ts
vendored
98
src/renderer/types/electron.d.ts
vendored
@@ -170,6 +170,70 @@ export interface SyncTagsResult {
|
||||
added: string[];
|
||||
}
|
||||
|
||||
// Chat/AI types
|
||||
export interface ChatConversation {
|
||||
id: string;
|
||||
projectId: string;
|
||||
title: string;
|
||||
model?: string;
|
||||
copilotSessionId?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
conversationId: string;
|
||||
role: 'user' | 'assistant' | 'system' | 'tool';
|
||||
content: string;
|
||||
toolCallId?: string;
|
||||
toolCalls?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ChatModel {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ChatAuthStatus {
|
||||
authenticated: boolean;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
export interface ChatReadyStatus {
|
||||
ready: boolean;
|
||||
authenticated: boolean;
|
||||
}
|
||||
|
||||
export interface ChatStreamDelta {
|
||||
conversationId: string;
|
||||
delta: string;
|
||||
}
|
||||
|
||||
export interface ChatToolCall {
|
||||
conversationId: string;
|
||||
toolCall: {
|
||||
name: string;
|
||||
arguments: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ChatToolResult {
|
||||
conversationId: string;
|
||||
result: unknown;
|
||||
}
|
||||
|
||||
export interface ChatTitleUpdate {
|
||||
conversationId: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface ChatDeviceCode {
|
||||
verificationUri: string;
|
||||
userCode: string;
|
||||
}
|
||||
|
||||
export interface ElectronAPI {
|
||||
projects: {
|
||||
create: (data: { name: string; description?: string; slug?: string }) => Promise<ProjectData>;
|
||||
@@ -274,6 +338,40 @@ export interface ElectronAPI {
|
||||
getPostsWithTag: (tagId: string) => Promise<string[]>;
|
||||
syncFromPosts: () => Promise<SyncTagsResult>;
|
||||
};
|
||||
chat: {
|
||||
// Authentication
|
||||
checkReady: () => Promise<ChatReadyStatus>;
|
||||
copilotAuthStatus: () => Promise<ChatAuthStatus>;
|
||||
copilotLogin: () => Promise<{ success: boolean; error?: string; login?: string }>;
|
||||
copilotLogout: () => Promise<void>;
|
||||
|
||||
// Settings
|
||||
getAvailableModels: () => Promise<ChatModel[]>;
|
||||
setDefaultModel: (modelId: string) => Promise<void>;
|
||||
getSystemPrompt: () => Promise<string | null>;
|
||||
setSystemPrompt: (prompt: string) => Promise<void>;
|
||||
|
||||
// Conversations
|
||||
getConversations: () => Promise<ChatConversation[]>;
|
||||
createConversation: (title?: string, model?: string) => Promise<ChatConversation>;
|
||||
getConversation: (id: string) => Promise<ChatConversation | null>;
|
||||
updateConversation: (id: string, updates: { title?: string; model?: string }) => Promise<ChatConversation | null>;
|
||||
deleteConversation: (id: string) => Promise<boolean>;
|
||||
|
||||
// Messaging
|
||||
sendMessage: (conversationId: string, message: string) => Promise<string>;
|
||||
abortMessage: (conversationId: string) => Promise<void>;
|
||||
getHistory: (conversationId: string) => Promise<ChatMessage[]>;
|
||||
clearMessages: (conversationId: string) => Promise<void>;
|
||||
setConversationModel: (conversationId: string, modelId: string) => Promise<void>;
|
||||
|
||||
// Event listeners for streaming/progress
|
||||
onStreamDelta: (callback: (data: ChatStreamDelta) => void) => () => void;
|
||||
onToolCall: (callback: (data: ChatToolCall) => void) => () => void;
|
||||
onToolResult: (callback: (data: ChatToolResult) => void) => () => void;
|
||||
onTitleUpdated: (callback: (data: ChatTitleUpdate) => void) => () => void;
|
||||
onDeviceCode: (callback: (data: ChatDeviceCode) => void) => () => void;
|
||||
};
|
||||
on: (channel: string, callback: (...args: unknown[]) => void) => () => void;
|
||||
once: (channel: string, callback: (...args: unknown[]) => void) => void;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user