diff --git a/src/main/engine/OpenCodeManager.ts b/src/main/engine/OpenCodeManager.ts index 5cfa531..757106d 100644 --- a/src/main/engine/OpenCodeManager.ts +++ b/src/main/engine/OpenCodeManager.ts @@ -33,6 +33,10 @@ const ZEN_ANTHROPIC_URL = 'https://opencode.ai/zen/v1/messages'; const ZEN_OPENAI_URL = 'https://opencode.ai/zen/v1/chat/completions'; const ZEN_MODELS_URL = 'https://opencode.ai/zen/v1/models'; +// Mistral API endpoints +const MISTRAL_API_URL = 'https://api.mistral.ai/v1/chat/completions'; +const MISTRAL_MODELS_URL = 'https://api.mistral.ai/v1/models'; + // Known model display names: maps model IDs to polished names and serves as offline fallback const MODEL_DISPLAY_NAMES: Record = { // Anthropic Claude @@ -75,6 +79,12 @@ const MODEL_DISPLAY_NAMES: Record = { 'kimi-k2-thinking': 'Kimi K2 Thinking', 'big-pickle': 'Big Pickle', 'trinity-large-preview-free': 'Trinity Large Preview Free', + // Mistral AI + 'mistral-large-latest': 'Mistral Large', + 'mistral-medium-latest': 'Mistral Medium', + 'mistral-small-latest': 'Mistral Small', + 'devstral-small-latest': 'Devstral Small', + 'devstral-large-latest': 'Devstral Large', }; @@ -82,10 +92,49 @@ const MODEL_DISPLAY_NAMES: Record = { // Uppercase prefixes that should not be title-cased const UPPERCASE_PREFIXES = ['gpt', 'glm']; +// Per-model context token budgets for truncation +// OpenCode models default to 150,000; Mistral models have specific budgets +const MODEL_CONTEXT_BUDGETS: Record = { + 'mistral-large-latest': 35_000, + 'mistral-medium-latest': 35_000, + 'mistral-small-latest': 120_000, + 'devstral-small-latest': 120_000, + 'devstral-large-latest': 240_000, +}; + +// Vision capabilities per model (APIs don't expose this) +const MODEL_CAPABILITIES: Record = { + // Anthropic Claude — all vision-capable + 'claude-opus-4-6': { vision: true }, + 'claude-opus-4-5': { vision: true }, + 'claude-opus-4-1': { vision: true }, + 'claude-sonnet-4-6': { vision: true }, + 'claude-sonnet-4-5': { vision: true }, + 'claude-sonnet-4': { vision: true }, + 'claude-haiku-4-5': { vision: true }, + 'claude-3-5-haiku': { vision: true }, + // OpenAI GPT — most are vision-capable + 'gpt-5': { vision: true }, + 'gpt-5.1': { vision: true }, + 'gpt-5.2': { vision: true }, + 'gpt-5-nano': { vision: true }, + // Google Gemini — vision-capable + 'gemini-3.1-pro': { vision: true }, + 'gemini-3-pro': { vision: true }, + 'gemini-3-flash': { vision: true }, + // Mistral AI + 'mistral-large-latest': { vision: true }, + 'mistral-medium-latest': { vision: true }, + 'mistral-small-latest': { vision: true }, + 'devstral-small-latest': { vision: false }, + 'devstral-large-latest': { vision: false }, +}; + export interface ModelInfo { id: string; name: string; provider: string; + vision?: boolean; } export interface SendMessageOptions { @@ -171,6 +220,7 @@ export class OpenCodeManager { private postMediaEngine: PostMediaEngine; private getMainWindow: () => BrowserWindow | null; private apiKey: string = ''; + private mistralApiKey: string = ''; private abortControllers: Map = new Map(); private cachedModels: ModelInfo[] | null = null; private cachedModelsAt: number = 0; @@ -212,17 +262,38 @@ export class OpenCodeManager { } /** - * Check if the service is configured and ready + * Set API key for Mistral AI */ - async checkReady(): Promise<{ ready: boolean; error?: string }> { - if (!this.apiKey) { - return { ready: false, error: 'API key not configured' }; - } - return { ready: true }; + setMistralApiKey(key: string): void { + this.mistralApiKey = key; + // Invalidate model cache so merged list is re-fetched + this.cachedModels = null; + this.cachedModelsAt = 0; } /** - * Validate an API key by calling the models endpoint + * Get current Mistral API key + */ + getMistralApiKey(): string { + return this.mistralApiKey; + } + + /** + * Check if the service is configured and ready + */ + async checkReady(): Promise<{ ready: boolean; error?: string; providers?: { opencode: boolean; mistral: boolean } }> { + const providers = { + opencode: !!this.apiKey, + mistral: !!this.mistralApiKey, + }; + if (!this.apiKey && !this.mistralApiKey) { + return { ready: false, error: 'API key not configured', providers }; + } + return { ready: true, providers }; + } + + /** + * Validate an OpenCode API key by calling the models endpoint */ async validateApiKey(apiKey: string): Promise<{ isValid: boolean; models: ModelInfo[] }> { if (!apiKey || apiKey.length < 3) { @@ -242,7 +313,11 @@ export class OpenCodeManager { headers, }); if (response.statusCode >= 200 && response.statusCode < 300) { - return { isValid: true, models: Object.entries(MODEL_DISPLAY_NAMES).map(([id, name]) => ({ id, name, provider: this.detectProvider(id) })) }; + // Filter to only OpenCode models (not Mistral) + const models = Object.entries(MODEL_DISPLAY_NAMES) + .map(([id, name]) => ({ id, name, provider: this.detectProvider(id), vision: MODEL_CAPABILITIES[id]?.vision ?? false })) + .filter(m => this.isProviderKeySet(m.provider) || m.provider !== 'mistral'); + return { isValid: true, models }; } } catch { // Try next auth method @@ -252,8 +327,42 @@ export class OpenCodeManager { return { isValid: false, models: [] }; } + /** + * Validate a Mistral API key by calling the Mistral models endpoint + */ + async validateMistralApiKey(apiKey: string): Promise<{ isValid: boolean; models: ModelInfo[] }> { + if (!apiKey || apiKey.length < 3) { + return { isValid: false, models: [] }; + } + + try { + const response = await this.httpRequest(MISTRAL_MODELS_URL, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${apiKey}`, + }, + }); + + if (response.statusCode >= 200 && response.statusCode < 300) { + const data = JSON.parse(response.body); + if (data.data && Array.isArray(data.data) && data.data.length > 0) { + // Return Mistral models from display name map + const models = Object.entries(MODEL_DISPLAY_NAMES) + .filter(([id]) => this.detectProvider(id) === 'mistral') + .map(([id, name]) => ({ id, name, provider: 'mistral', vision: MODEL_CAPABILITIES[id]?.vision ?? false })); + return { isValid: true, models }; + } + } + } catch { + // Fall through + } + + return { isValid: false, models: [] }; + } + /** * Get available models (cached with 5-minute TTL) + * Merges models from all configured providers. */ async getAvailableModels(): Promise { // Return cached models if within TTL @@ -261,7 +370,10 @@ export class OpenCodeManager { return this.cachedModels; } - // Try fetching from API + const allModels: ModelInfo[] = []; + let fetched = false; + + // Fetch OpenCode models if (this.apiKey) { try { const response = await this.httpRequest(ZEN_MODELS_URL, { @@ -274,14 +386,15 @@ export class OpenCodeManager { if (response.statusCode === 200) { const data = JSON.parse(response.body); if (data.data && Array.isArray(data.data)) { - const models = data.data.map((m: { id: string }) => ({ - id: m.id, - name: this.formatModelName(m.id), - provider: this.detectProvider(m.id), - })); - this.cachedModels = models; - this.cachedModelsAt = Date.now(); - return models; + for (const m of data.data as Array<{ id: string }>) { + allModels.push({ + id: m.id, + name: this.formatModelName(m.id), + provider: this.detectProvider(m.id), + vision: MODEL_CAPABILITIES[m.id]?.vision ?? false, + }); + } + fetched = true; } } } catch { @@ -289,12 +402,52 @@ export class OpenCodeManager { } } - // Build fallback from display name map - const fallback = Object.entries(MODEL_DISPLAY_NAMES).map(([id, name]) => ({ - id, - name, - provider: this.detectProvider(id), - })); + // Fetch Mistral models + if (this.mistralApiKey) { + try { + const response = await this.httpRequest(MISTRAL_MODELS_URL, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${this.mistralApiKey}`, + }, + }); + if (response.statusCode === 200) { + const data = JSON.parse(response.body); + if (data.data && Array.isArray(data.data)) { + for (const m of data.data as Array<{ id: string }>) { + // Only include models we know about (have display names) + if (MODEL_DISPLAY_NAMES[m.id]) { + allModels.push({ + id: m.id, + name: this.formatModelName(m.id), + provider: 'mistral', + vision: MODEL_CAPABILITIES[m.id]?.vision ?? false, + }); + } + } + fetched = true; + } + } + } catch { + // Fall through to fallback + } + } + + if (fetched && allModels.length > 0) { + this.cachedModels = allModels; + this.cachedModelsAt = Date.now(); + return allModels; + } + + // Build fallback from display name map, filtered by available provider keys + const fallback = Object.entries(MODEL_DISPLAY_NAMES) + .map(([id, name]) => ({ + id, + name, + provider: this.detectProvider(id), + vision: MODEL_CAPABILITIES[id]?.vision ?? false, + })) + .filter(m => this.isProviderKeySet(m.provider)); return fallback; } @@ -335,6 +488,12 @@ export class OpenCodeManager { const modelId = conversation.model || 'claude-sonnet-4'; const provider = this.detectProvider(modelId); + // Check that the provider's API key is available + if (!this.isProviderKeySet(provider)) { + const providerLabel = provider === 'mistral' ? 'Mistral' : 'OpenCode'; + return { success: false, error: `The model '${modelId}' requires a ${providerLabel} API key. Configure it in Settings.` }; + } + // Get system prompt const systemMessage = conversation.messages.find(m => m.role === 'system'); const basePrompt = systemMessage?.content || await this.chatEngine.getDefaultSystemPrompt(); @@ -387,6 +546,8 @@ export class OpenCodeManager { ); } + // Get provider-specific config (URL, key, options) + const config = this.getProviderConfig(provider); return this.sendOpenAIMessage( modelId, prompt, @@ -395,6 +556,9 @@ export class OpenCodeManager { { onDelta, onToolCall, onToolResult, onTokenUsage }, conversationId, emitA2UIMessages, + config.apiUrl, + config.apiKey, + config.options, ); }; @@ -735,7 +899,8 @@ export class OpenCodeManager { } /** - * Send via OpenAI-compatible API (non-Claude models) + * Send via OpenAI-compatible API (non-Claude models, including Mistral) + * Parameterized to support multiple providers with identical API format. */ private async sendOpenAIMessage( modelId: string, @@ -750,6 +915,9 @@ export class OpenCodeManager { }, conversationId: string, emitA2UIMessages: (messages: A2UIServerMessage[]) => void, + apiUrl: string = ZEN_OPENAI_URL, + apiKey: string = this.apiKey, + providerOptions?: { parallelToolCalls?: boolean }, ): Promise<{ content: string; toolCalls: Array<{ name: string; args: unknown }> }> { // Build OpenAI-format messages const allMessages: Array> = [ @@ -775,12 +943,13 @@ export class OpenCodeManager { // Truncate conversation history to fit within context window // Keep system message (index 0), truncate from oldest conversation messages + const contextBudget = MODEL_CONTEXT_BUDGETS[modelId] ?? 150000; const conversationMessages = allMessages.slice(1); const anthropicFmt = conversationMessages.map(m => ({ role: m.role as 'user' | 'assistant', content: (m.content as string) || '', })); - const truncated = this.truncateToTokenBudget(anthropicFmt, systemPrompt, anthropicTools); + const truncated = this.truncateToTokenBudget(anthropicFmt, systemPrompt, anthropicTools, contextBudget); const messages: Array> = [ allMessages[0], ...truncated.map(m => ({ role: m.role, content: m.content })), @@ -804,14 +973,19 @@ export class OpenCodeManager { stream_options: { include_usage: true }, }; + // Set parallel_tool_calls based on provider options (Mistral needs false) + if (providerOptions?.parallelToolCalls === false) { + body.parallel_tool_calls = false; + } + // Retry only the HTTP connection (429/502/503 are caught before any events are emitted). // Event processing is outside retry scope to prevent double-emission of onDelta on retry. const { events } = await withRetry(async () => { - return httpRequestStream(ZEN_OPENAI_URL, { + return httpRequestStream(apiUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', - 'Authorization': `Bearer ${this.apiKey}`, + 'Authorization': `Bearer ${apiKey}`, }, body: JSON.stringify(body), signal, @@ -970,11 +1144,39 @@ export class OpenCodeManager { callbacks.onToolResult({ name: toolName, result }); } - messages.push({ - role: 'tool', - content: JSON.stringify(result), - tool_call_id: toolCall.id, - }); + // Check for image result that needs multimodal formatting (OpenAI image_url format) + if (result && typeof result === 'object' && (result as Record).__isImageResult) { + const imageResult = result as { + __isImageResult: boolean; + success: boolean; + mediaType: string; + base64: string; + metadata: Record; + }; + + messages.push({ + role: 'tool', + content: [ + { + type: 'image_url', + image_url: { + url: `data:${imageResult.mediaType};base64,${imageResult.base64}`, + }, + }, + { + type: 'text', + text: JSON.stringify({ success: true, metadata: imageResult.metadata }), + }, + ], + tool_call_id: toolCall.id, + }); + } else { + messages.push({ + role: 'tool', + content: JSON.stringify(result), + tool_call_id: toolCall.id, + }); + } } if (signal.aborted) break; @@ -1850,7 +2052,9 @@ export class OpenCodeManager { } /** - * Generate a title for a conversation + * Generate a title for a conversation. + * Uses the configured title model (fallback: claude-haiku-4-5) and routes + * the request to the correct provider API. */ private async generateConversationTitle( conversationId: string, @@ -1858,32 +2062,40 @@ export class OpenCodeManager { _assistantResponse: string ): Promise { try { - const body = { - model: 'claude-haiku-4-5', - max_tokens: 20, - system: 'Generate an ultra-short title (2-3 words, max 25 characters) for this conversation. Focus ONLY on the topic. Ignore any capability disclaimers. Output ONLY the title text.', - messages: [ - { - role: 'user', - content: `Topic: ${userMessage.substring(0, 100)}`, + // Read configured title model + const titleModel = await this.chatEngine.getSetting('chat_title_model') || 'claude-haiku-4-5'; + const provider = this.detectProvider(titleModel); + + // Ensure we have the key for this provider + if (!this.isProviderKeySet(provider)) return; + + const promptText = `Topic: ${userMessage.substring(0, 100)}`; + const systemText = 'Generate an ultra-short title (2-3 words, max 25 characters) for this conversation. Focus ONLY on the topic. Ignore any capability disclaimers. Output ONLY the title text.'; + + let title = ''; + + if (provider === 'anthropic') { + const body = { + model: titleModel, + max_tokens: 20, + system: systemText, + messages: [{ role: 'user', content: promptText }], + }; + + const response = await this.httpRequest(ZEN_ANTHROPIC_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': this.apiKey, + 'Authorization': `Bearer ${this.apiKey}`, + 'anthropic-version': '2023-06-01', }, - ], - }; + body: JSON.stringify(body), + }); - const response = await this.httpRequest(ZEN_ANTHROPIC_URL, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': this.apiKey, - 'Authorization': `Bearer ${this.apiKey}`, - 'anthropic-version': '2023-06-01', - }, - body: JSON.stringify(body), - }); + if (response.statusCode !== 200) return; - if (response.statusCode === 200) { const data = JSON.parse(response.body); - let title = ''; if (Array.isArray(data.content)) { title = data.content .filter((b: AnthropicContentBlock) => b.type === 'text') @@ -1892,23 +2104,47 @@ export class OpenCodeManager { } else { title = data.content || ''; } + } else { + // OpenAI-compatible (includes Mistral) + const config = this.getProviderConfig(provider); + const body = { + model: titleModel, + max_tokens: 20, + messages: [ + { role: 'system', content: systemText }, + { role: 'user', content: promptText }, + ], + }; - // Clean up and truncate title - title = title.trim().replace(/^["']|["']$/g, '').replace(/[.!?]+$/, ''); - - // Hard limit on title length - const MAX_TITLE_LENGTH = 30; - if (title.length > MAX_TITLE_LENGTH) { - title = title.substring(0, MAX_TITLE_LENGTH - 1) + '…'; - } + const response = await this.httpRequest(config.apiUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${config.apiKey}`, + }, + body: JSON.stringify(body), + }); - if (title) { - await this.chatEngine.updateConversation(conversationId, { title }); + if (response.statusCode !== 200) return; - const mainWindow = this.getMainWindow(); - if (mainWindow) { - mainWindow.webContents.send('chat-title-updated', { conversationId, title }); - } + const data = JSON.parse(response.body); + title = data.choices?.[0]?.message?.content || ''; + } + + // Clean up and truncate title + title = title.trim().replace(/^["']|["']$/g, '').replace(/[.!?]+$/, ''); + + const MAX_TITLE_LENGTH = 30; + if (title.length > MAX_TITLE_LENGTH) { + title = title.substring(0, MAX_TITLE_LENGTH - 1) + '…'; + } + + if (title) { + await this.chatEngine.updateConversation(conversationId, { title }); + + const mainWindow = this.getMainWindow(); + if (mainWindow) { + mainWindow.webContents.send('chat-title-updated', { conversationId, title }); } } } catch (error) { @@ -1996,11 +2232,32 @@ NOTE: Use pagination (offset/limit) in list_posts and search_posts to access all return this.modelCatalogEngine; } + /** + * Check whether the given provider's API key is configured. + * All non-mistral providers are routed through OpenCode Zen and share apiKey. + */ + private isProviderKeySet(provider: string): boolean { + if (provider === 'mistral') return !!this.mistralApiKey; + return !!this.apiKey; + } + + /** + * Return API URL, key and provider-specific options for a given provider. + * Used to parameterise sendOpenAIMessage() for non-Anthropic providers. + */ + private getProviderConfig(provider: string): { apiUrl: string; apiKey: string; options?: { parallelToolCalls?: boolean } } { + if (provider === 'mistral') { + return { apiUrl: MISTRAL_API_URL, apiKey: this.mistralApiKey, options: { parallelToolCalls: false } }; + } + return { apiUrl: ZEN_OPENAI_URL, apiKey: this.apiKey }; + } + private detectProvider(modelId: string): string { const id = modelId.toLowerCase(); if (id.startsWith('claude')) return 'anthropic'; if (id.startsWith('gpt') || id.startsWith('o3') || id.startsWith('o4')) return 'openai'; if (id.startsWith('gemini')) return 'google'; + if (id.startsWith('mistral') || id.startsWith('ministral') || id.startsWith('devstral') || id.startsWith('codestral') || id.startsWith('pixtral')) return 'mistral'; return 'other'; } @@ -2053,11 +2310,11 @@ NOTE: Use pagination (offset/limit) in list_posts and search_posts to access all tagMappings?: Record; error?: string; }> { - if (!this.apiKey) { - return { success: false, error: 'API key not set' }; - } - const provider = this.detectProvider(modelId); + if (!this.isProviderKeySet(provider)) { + const providerLabel = provider === 'mistral' ? 'Mistral' : 'OpenCode'; + return { success: false, error: `${providerLabel} API key not set` }; + } // Build the prompt for taxonomy analysis const existingCategories = categories.filter(c => c.existsInProject).map(c => c.name); @@ -2148,7 +2405,8 @@ Remember: Only suggest mappings from NEW items to EXISTING items. Consider langu } } } else { - // OpenAI-compatible + // OpenAI-compatible (includes Mistral) + const config = this.getProviderConfig(provider); const body = { model: modelId, max_tokens: 4096, @@ -2158,11 +2416,11 @@ Remember: Only suggest mappings from NEW items to EXISTING items. Consider langu ], }; - const response = await this.httpRequest(ZEN_OPENAI_URL, { + const response = await this.httpRequest(config.apiUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', - Authorization: `Bearer ${this.apiKey}`, + Authorization: `Bearer ${config.apiKey}`, }, body: JSON.stringify(body), }); @@ -2224,7 +2482,8 @@ Remember: Only suggest mappings from NEW items to EXISTING items. Consider langu /** * Analyze a media image and generate title, alt text, and caption using AI - * This is a one-shot request that looks at the image and suggests metadata + * This is a one-shot request that looks at the image and suggests metadata. + * Uses the configured image analysis model and routes to the correct provider. */ async analyzeMediaImage(mediaId: string, language: string = 'en'): Promise<{ success: boolean; @@ -2233,8 +2492,13 @@ Remember: Only suggest mappings from NEW items to EXISTING items. Consider langu caption?: string; error?: string; }> { - if (!this.apiKey) { - return { success: false, error: 'API key not configured. Please set your OpenCode API key in Settings.' }; + // Read configured image analysis model (default: claude-sonnet-4-5) + const modelId = await this.chatEngine.getSetting('chat_image_analysis_model') || 'claude-sonnet-4-5'; + const provider = this.detectProvider(modelId); + + if (!this.isProviderKeySet(provider)) { + const providerLabel = provider === 'mistral' ? 'Mistral' : 'OpenCode'; + return { success: false, error: `API key not configured. Please set your ${providerLabel} API key in Settings.` }; } // Get media metadata @@ -2278,59 +2542,100 @@ CAPTION: Short, engaging blog caption (5-20 words). Respond with JSON only: {"title": "...", "alt": "...", "caption": "..."}`; try { - // Using Claude Sonnet 4.5 for best image analysis - const modelId = 'claude-sonnet-4-5'; - - const body = { - model: modelId, - max_tokens: 200, - system: systemPrompt, - messages: [ - { - role: 'user', - content: [ - { - type: 'image', - source: { - type: 'base64', - media_type: 'image/webp', - data: base64Data, - }, - }, - { - type: 'text', - text: 'Analyze and respond with JSON.', - }, - ], - }, - ], - }; - - const response = await this.httpRequest(ZEN_ANTHROPIC_URL, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': this.apiKey, - 'Authorization': `Bearer ${this.apiKey}`, - 'anthropic-version': '2023-06-01', - }, - body: JSON.stringify(body), - }); - - if (response.statusCode !== 200) { - console.error('[OpenCodeManager] Image analysis failed:', response.body); - const errorMsg = this.parseErrorResponse(response); - return { success: false, error: errorMsg }; - } - - const data = JSON.parse(response.body); - - // Extract text from Anthropic response let responseText = ''; - for (const block of data.content || []) { - if (block.type === 'text') { - responseText += block.text; + + if (provider === 'anthropic') { + const body = { + model: modelId, + max_tokens: 200, + system: systemPrompt, + messages: [ + { + role: 'user', + content: [ + { + type: 'image', + source: { + type: 'base64', + media_type: 'image/webp', + data: base64Data, + }, + }, + { + type: 'text', + text: 'Analyze and respond with JSON.', + }, + ], + }, + ], + }; + + const response = await this.httpRequest(ZEN_ANTHROPIC_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': this.apiKey, + 'Authorization': `Bearer ${this.apiKey}`, + 'anthropic-version': '2023-06-01', + }, + body: JSON.stringify(body), + }); + + if (response.statusCode !== 200) { + console.error('[OpenCodeManager] Image analysis failed:', response.body); + const errorMsg = this.parseErrorResponse(response); + return { success: false, error: errorMsg }; } + + const data = JSON.parse(response.body); + for (const block of data.content || []) { + if (block.type === 'text') { + responseText += block.text; + } + } + } else { + // OpenAI-compatible (includes Mistral) + const config = this.getProviderConfig(provider); + const body = { + model: modelId, + max_tokens: 200, + messages: [ + { role: 'system', content: systemPrompt }, + { + role: 'user', + content: [ + { + type: 'image_url', + image_url: { + url: `data:image/webp;base64,${base64Data}`, + }, + }, + { + type: 'text', + text: 'Analyze and respond with JSON.', + }, + ], + }, + ], + }; + + const response = await this.httpRequest(config.apiUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${config.apiKey}`, + }, + body: JSON.stringify(body), + }); + + if (response.statusCode !== 200) { + console.error('[OpenCodeManager] Image analysis failed:', response.body); + const errorMsg = this.parseErrorResponse(response); + return { success: false, error: errorMsg }; + } + + const data = JSON.parse(response.body); + responseText = data.choices?.[0]?.message?.content || ''; } // Parse the JSON response diff --git a/src/main/ipc/chatHandlers.ts b/src/main/ipc/chatHandlers.ts index cea3395..31485a2 100644 --- a/src/main/ipc/chatHandlers.ts +++ b/src/main/ipc/chatHandlers.ts @@ -78,6 +78,16 @@ async function getOpenCodeManager(): Promise { } catch { // Silently ignore errors loading the key } + + // Load Mistral API key from encrypted storage + try { + const mistralKey = await keyStore.retrieve('mistral_api_key'); + if (mistralKey) { + openCodeManager!.setMistralApiKey(mistralKey); + } + } catch { + // Silently ignore errors loading the Mistral key + } })(); } @@ -104,6 +114,7 @@ export function registerChatHandlers(): void { ready: result.ready, error: result.error, backend: 'opencode', + providers: result.providers, }; } catch (error) { console.error('[Chat IPC] Error checking ready:', error); @@ -160,6 +171,106 @@ export function registerChatHandlers(): void { } }); + // ============ Mistral API Key ============ + + // Validate Mistral API key + ipcMain.handle('chat:validateMistralApiKey', async (_, apiKey: string) => { + try { + const manager = await getOpenCodeManager(); + const result = await manager.validateMistralApiKey(apiKey); + return result; + } catch (error) { + console.error('[Chat IPC] Error validating Mistral API key:', error); + return { isValid: false, models: [] }; + } + }); + + // Set Mistral API key + ipcMain.handle('chat:setMistralApiKey', async (_, apiKey: string) => { + try { + const manager = await getOpenCodeManager(); + const previousKey = manager.getMistralApiKey(); + manager.setMistralApiKey(apiKey); + + // Persist to encrypted storage — roll back in-memory key on failure + try { + await getSecureKeyStore().store('mistral_api_key', apiKey); + } catch (storeError) { + manager.setMistralApiKey(previousKey); + throw storeError; + } + + return { success: true }; + } catch (error) { + console.error('[Chat IPC] Error setting Mistral API key:', error); + return { success: false, error: (error as Error).message }; + } + }); + + // Get Mistral API key (masked) + ipcMain.handle('chat:getMistralApiKey', async () => { + try { + const manager = await getOpenCodeManager(); + const key = manager.getMistralApiKey(); + if (!key) return { hasKey: false, maskedKey: '' }; + const masked = '•'.repeat(Math.max(0, key.length - 4)) + key.slice(-4); + return { hasKey: true, maskedKey: masked }; + } catch (error) { + console.error('[Chat IPC] Error getting Mistral API key:', error); + return { hasKey: false, maskedKey: '' }; + } + }); + + // ============ Per-Purpose Model Preferences ============ + + // Get title generation model + ipcMain.handle('chat:getTitleModel', async () => { + try { + const engine = getChatEngine(); + const model = await engine.getSetting('chat_title_model'); + return { success: true, modelId: model || 'claude-haiku-4-5' }; + } catch (error) { + console.error('[Chat IPC] Error getting title model:', error); + return { success: false, modelId: 'claude-haiku-4-5' }; + } + }); + + // Set title generation model + ipcMain.handle('chat:setTitleModel', async (_, modelId: string) => { + try { + const engine = getChatEngine(); + await engine.setSetting('chat_title_model', modelId); + return { success: true }; + } catch (error) { + console.error('[Chat IPC] Error setting title model:', error); + return { success: false, error: (error as Error).message }; + } + }); + + // Get image analysis model + ipcMain.handle('chat:getImageAnalysisModel', async () => { + try { + const engine = getChatEngine(); + const model = await engine.getSetting('chat_image_analysis_model'); + return { success: true, modelId: model || 'claude-sonnet-4-5' }; + } catch (error) { + console.error('[Chat IPC] Error getting image analysis model:', error); + return { success: false, modelId: 'claude-sonnet-4-5' }; + } + }); + + // Set image analysis model + ipcMain.handle('chat:setImageAnalysisModel', async (_, modelId: string) => { + try { + const engine = getChatEngine(); + await engine.setSetting('chat_image_analysis_model', modelId); + return { success: true }; + } catch (error) { + console.error('[Chat IPC] Error setting image analysis model:', error); + return { success: false, error: (error as Error).message }; + } + }); + // ============ Chat Settings ============ // Get available models diff --git a/src/main/preload.ts b/src/main/preload.ts index 57afbf5..1ba9d79 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -309,6 +309,17 @@ export const electronAPI: ElectronAPI = { setApiKey: (apiKey: string) => ipcRenderer.invoke('chat:setApiKey', apiKey), getApiKey: () => ipcRenderer.invoke('chat:getApiKey'), + // Mistral API Key Management + validateMistralApiKey: (apiKey: string) => ipcRenderer.invoke('chat:validateMistralApiKey', apiKey), + setMistralApiKey: (apiKey: string) => ipcRenderer.invoke('chat:setMistralApiKey', apiKey), + getMistralApiKey: () => ipcRenderer.invoke('chat:getMistralApiKey'), + + // Per-Purpose Model Preferences + getTitleModel: () => ipcRenderer.invoke('chat:getTitleModel'), + setTitleModel: (modelId: string | null) => ipcRenderer.invoke('chat:setTitleModel', modelId), + getImageAnalysisModel: () => ipcRenderer.invoke('chat:getImageAnalysisModel'), + setImageAnalysisModel: (modelId: string | null) => ipcRenderer.invoke('chat:setImageAnalysisModel', modelId), + // Settings getAvailableModels: () => ipcRenderer.invoke('chat:getAvailableModels'), setDefaultModel: (modelId: string) => ipcRenderer.invoke('chat:setDefaultModel', modelId), diff --git a/src/main/shared/electronApi.ts b/src/main/shared/electronApi.ts index c75b41e..91dfad0 100644 --- a/src/main/shared/electronApi.ts +++ b/src/main/shared/electronApi.ts @@ -422,6 +422,7 @@ export interface ChatModel { id: string; name: string; provider?: string; + vision?: boolean; } export interface ModelCatalogEntry { @@ -450,6 +451,7 @@ export interface ChatReadyStatus { ready: boolean; error?: string; backend?: string; + providers?: { opencode: boolean; mistral: boolean }; } export interface ChatApiKeyStatus { @@ -825,12 +827,23 @@ export interface ElectronAPI { setApiKey: (apiKey: string) => Promise<{ success: boolean; error?: string }>; getApiKey: () => Promise; + // Mistral API Key + validateMistralApiKey: (apiKey: string) => Promise<{ isValid: boolean; models: ChatModel[] }>; + setMistralApiKey: (apiKey: string) => Promise<{ success: boolean; error?: string }>; + getMistralApiKey: () => Promise; + // Settings getAvailableModels: () => Promise<{ success: boolean; models?: ChatModel[]; selectedModel?: string; error?: string }>; setDefaultModel: (modelId: string) => Promise<{ success: boolean; error?: string }>; getSystemPrompt: () => Promise<{ success: boolean; prompt?: string; error?: string }>; setSystemPrompt: (prompt: string) => Promise<{ success: boolean; error?: string }>; + // Per-purpose model preferences + getTitleModel: () => Promise<{ success: boolean; modelId?: string | null; error?: string }>; + setTitleModel: (modelId: string | null) => Promise<{ success: boolean; error?: string }>; + getImageAnalysisModel: () => Promise<{ success: boolean; modelId?: string | null; error?: string }>; + setImageAnalysisModel: (modelId: string | null) => Promise<{ success: boolean; error?: string }>; + // Model Catalog refreshModelCatalog: () => Promise; getModelCatalog: () => Promise<{ success: boolean; entries: ModelCatalogEntry[]; error?: string }>; diff --git a/src/renderer/components/ChatPanel/ChatPanel.css b/src/renderer/components/ChatPanel/ChatPanel.css index 8db2caf..81695ce 100644 --- a/src/renderer/components/ChatPanel/ChatPanel.css +++ b/src/renderer/components/ChatPanel/ChatPanel.css @@ -85,6 +85,21 @@ color: var(--vscode-list-activeSelectionForeground); } +.model-group-header { + padding: 6px 12px 4px; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--vscode-descriptionForeground); + pointer-events: none; + border-top: 1px solid var(--vscode-dropdown-border); +} + +.model-group-header:first-child { + border-top: none; +} + .chat-messages { flex: 1; overflow-y: auto; diff --git a/src/renderer/components/ChatPanel/ChatPanel.tsx b/src/renderer/components/ChatPanel/ChatPanel.tsx index 8de6bc6..7fedb38 100644 --- a/src/renderer/components/ChatPanel/ChatPanel.tsx +++ b/src/renderer/components/ChatPanel/ChatPanel.tsx @@ -355,15 +355,31 @@ export const ChatPanel: React.FC = ({ conversationId }) => { {showModelSelector && (
- {availableModels.map(model => ( - - ))} + {(() => { + // Group models by provider for visual separation + const groups: Record = {}; + for (const model of availableModels) { + const p = model.provider || 'other'; + if (!groups[p]) groups[p] = []; + groups[p].push(model); + } + return Object.entries(groups).map(([provider, models]) => ( + + {Object.keys(groups).length > 1 && ( +
{provider === 'mistral' ? 'Mistral' : 'OpenCode'}
+ )} + {models.map(model => ( + + ))} +
+ )); + })()}
)} diff --git a/src/renderer/components/ImportAnalysisView/ImportAnalysisView.tsx b/src/renderer/components/ImportAnalysisView/ImportAnalysisView.tsx index 01c5fd4..985b586 100644 --- a/src/renderer/components/ImportAnalysisView/ImportAnalysisView.tsx +++ b/src/renderer/components/ImportAnalysisView/ImportAnalysisView.tsx @@ -1371,15 +1371,30 @@ const TaxonomySection: React.FC<{ {showModelSelector && (
- {availableModels.map(model => ( - - ))} + {(() => { + const groups: Record = {}; + for (const model of availableModels) { + const p = model.provider || 'other'; + if (!groups[p]) groups[p] = []; + groups[p].push(model); + } + return Object.entries(groups).map(([provider, models]) => ( + + {Object.keys(groups).length > 1 && ( +
{provider === 'mistral' ? 'Mistral' : 'OpenCode'}
+ )} + {models.map(model => ( + + ))} +
+ )); + })()}
)} diff --git a/src/renderer/components/SettingsView/SettingsView.tsx b/src/renderer/components/SettingsView/SettingsView.tsx index fa95e99..eac422c 100644 --- a/src/renderer/components/SettingsView/SettingsView.tsx +++ b/src/renderer/components/SettingsView/SettingsView.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef, useCallback } from 'react'; +import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { useAppStore } from '../../store'; import { showToast } from '../Toast'; import { useI18n } from '../../i18n'; @@ -242,7 +242,12 @@ export const SettingsView: React.FC = () => { const [aiApiKeyMasked, setAiApiKeyMasked] = useState(''); const [aiHasApiKey, setAiHasApiKey] = useState(false); const [newApiKey, setNewApiKey] = useState(''); - const [availableModels, setAvailableModels] = useState<{id: string; name: string}[]>([]); + const [aiHasMistralKey, setAiHasMistralKey] = useState(false); + const [aiMistralKeyMasked, setAiMistralKeyMasked] = useState(''); + const [newMistralKey, setNewMistralKey] = useState(''); + const [titleModel, setTitleModel] = useState('claude-haiku-4-5'); + const [imageAnalysisModel, setImageAnalysisModel] = useState('claude-sonnet-4-5'); + const [availableModels, setAvailableModels] = useState<{id: string; name: string; provider?: string; vision?: boolean}[]>([]); const [selectedModel, setSelectedModel] = useState(''); const [modelCatalog, setModelCatalog] = useState { setSelectedModel(modelsResult.selectedModel || ''); } + // Load Mistral API key status + const mistralKeyResult = await window.electronAPI?.chat.getMistralApiKey(); + if (mistralKeyResult) { + setAiHasMistralKey(mistralKeyResult.hasKey); + setAiMistralKeyMasked(mistralKeyResult.maskedKey || ''); + } + + // Load per-purpose model preferences + const titleModelResult = await window.electronAPI?.chat.getTitleModel(); + if (titleModelResult?.success && titleModelResult.modelId) { + setTitleModel(titleModelResult.modelId); + } + const imageModelResult = await window.electronAPI?.chat.getImageAnalysisModel(); + if (imageModelResult?.success && imageModelResult.modelId) { + setImageAnalysisModel(imageModelResult.modelId); + } + // Load model catalog metadata const catalogResult = await window.electronAPI?.chat.getModelCatalog(); if (catalogResult?.success && catalogResult.entries) { @@ -1080,6 +1102,13 @@ export const SettingsView: React.FC = () => { setAiApiKeyMasked('•'.repeat(Math.max(0, newApiKey.length - 4)) + newApiKey.slice(-4)); setNewApiKey(''); showToast.success(t('settings.toast.apiKeySaved')); + + // Refresh models after key change + const modelsResult = await window.electronAPI?.chat.getAvailableModels(); + if (modelsResult?.success && modelsResult.models) { + setAvailableModels(modelsResult.models); + setSelectedModel(modelsResult.selectedModel || ''); + } } else { showToast.error(t('settings.toast.apiKeyInvalid')); } @@ -1089,6 +1118,54 @@ export const SettingsView: React.FC = () => { } }; + const handleSaveMistralApiKey = async () => { + if (!newMistralKey.trim()) return; + try { + const validateResult = await window.electronAPI?.chat.validateMistralApiKey(newMistralKey.trim()); + if (validateResult?.isValid) { + await window.electronAPI?.chat.setMistralApiKey(newMistralKey.trim()); + setAiHasMistralKey(true); + setAiMistralKeyMasked('•'.repeat(Math.max(0, newMistralKey.length - 4)) + newMistralKey.slice(-4)); + setNewMistralKey(''); + showToast.success(t('settings.toast.apiKeySaved')); + + // Refresh models after key change + const modelsResult = await window.electronAPI?.chat.getAvailableModels(); + if (modelsResult?.success && modelsResult.models) { + setAvailableModels(modelsResult.models); + setSelectedModel(modelsResult.selectedModel || ''); + } + } else { + showToast.error(t('settings.toast.apiKeyInvalid')); + } + } catch (error) { + console.error('Failed to save Mistral API key:', error); + showToast.error(t('settings.toast.apiKeySaveFailed')); + } + }; + + const handleTitleModelChange = async (modelId: string) => { + try { + const result = await window.electronAPI?.chat.setTitleModel(modelId); + if (result?.success) { + setTitleModel(modelId); + } + } catch (error) { + console.error('Failed to set title model:', error); + } + }; + + const handleImageAnalysisModelChange = async (modelId: string) => { + try { + const result = await window.electronAPI?.chat.setImageAnalysisModel(modelId); + if (result?.success) { + setImageAnalysisModel(modelId); + } + } catch (error) { + console.error('Failed to set image analysis model:', error); + } + }; + const handleModelChange = async (modelId: string) => { try { const result = await window.electronAPI?.chat.setDefaultModel(modelId); @@ -1137,6 +1214,37 @@ export const SettingsView: React.FC = () => { } }; + // Group models by provider for optgroup display + const groupedModels = useMemo(() => { + const groups: Record = {}; + for (const model of availableModels) { + const provider = model.provider || 'other'; + if (!groups[provider]) groups[provider] = []; + groups[provider].push(model); + } + return groups; + }, [availableModels]); + + const providerLabel = (provider: string) => { + if (provider === 'anthropic' || provider === 'openai' || provider === 'google' || provider === 'other') return t('settings.ai.providerOpenCode'); + if (provider === 'mistral') return t('settings.ai.providerMistral'); + return provider; + }; + + // Render a model onChange(e.target.value)} disabled={disabled}> + {availableModels.length === 0 && } + {Object.entries(groupedModels).map(([provider, models]) => ( + + {models.map(model => ( + + ))} + + ))} + + ); + const renderAISettings = () => ( { )} + +
+ {aiHasMistralKey ? ( + <> + + {t('settings.ai.configured')} + + ) : ( + <> + setNewMistralKey(e.target.value)} + placeholder={t('chat.apiKeyPlaceholder')} + /> + + + )} +
+ {aiHasMistralKey && ( +
+ +
+ )} +
+
- + {renderModelSelect('ai-model', selectedModel, handleModelChange, !aiHasApiKey && !aiHasMistralKey)}