From e2c46e94aa91c323de54984cc74649fe77d4ef20 Mon Sep 17 00:00:00 2001 From: hugo Date: Sun, 1 Mar 2026 15:24:15 +0100 Subject: [PATCH] fix: second round of fixes --- src/main/engine/OpenCodeManager.ts | 70 +++++++++---------- src/main/engine/index.ts | 1 - src/main/shared/electronApi.ts | 2 +- .../components/ChatPanel/ChatPanel.tsx | 46 ++---------- src/renderer/i18n/locales/de.json | 8 +-- src/renderer/i18n/locales/en.json | 8 +-- src/renderer/i18n/locales/es.json | 8 +-- src/renderer/i18n/locales/fr.json | 8 +-- src/renderer/i18n/locales/it.json | 8 +-- tests/engine/OpenCodeManagerMistral.test.ts | 25 +++---- tests/engine/OpenCodeModelDiscovery.test.ts | 13 ++-- 11 files changed, 78 insertions(+), 119 deletions(-) diff --git a/src/main/engine/OpenCodeManager.ts b/src/main/engine/OpenCodeManager.ts index f9f0777..4af29ac 100644 --- a/src/main/engine/OpenCodeManager.ts +++ b/src/main/engine/OpenCodeManager.ts @@ -27,6 +27,7 @@ import type { PostMediaEngine } from './PostMediaEngine'; import { ModelCatalogEngine, DEFAULT_MAX_OUTPUT_TOKENS } from './ModelCatalogEngine'; import { isRenderTool, generateFromToolCall } from '../a2ui/generator'; import type { A2UIServerMessage } from '../a2ui/types'; +import type { ChatModel } from '../shared/electronApi'; // OpenCode Zen API endpoints const ZEN_ANTHROPIC_URL = 'https://opencode.ai/zen/v1/messages'; @@ -130,13 +131,6 @@ const MODEL_CAPABILITIES: Record = { 'devstral-large-latest': { vision: false }, }; -export interface ModelInfo { - id: string; - name: string; - provider: string; - vision?: boolean; -} - export interface SendMessageOptions { metadata?: { surface?: 'tab' | 'sidebar'; @@ -222,7 +216,7 @@ export class OpenCodeManager { private apiKey: string = ''; private mistralApiKey: string = ''; private abortControllers: Map = new Map(); - private cachedModels: ModelInfo[] | null = null; + private cachedModels: ChatModel[] | null = null; private cachedModelsAt: number = 0; private static MODEL_CACHE_TTL = 5 * 60 * 1000; // 5 minutes private modelCatalogEngine = new ModelCatalogEngine(); @@ -298,7 +292,7 @@ export class OpenCodeManager { /** * Validate an OpenCode API key by calling the models endpoint */ - async validateApiKey(apiKey: string): Promise<{ isValid: boolean; models: ModelInfo[] }> { + async validateApiKey(apiKey: string): Promise<{ isValid: boolean; models: ChatModel[] }> { if (!apiKey || apiKey.length < 3) { return { isValid: false, models: [] }; } @@ -333,7 +327,7 @@ export class OpenCodeManager { /** * Validate a Mistral API key by calling the Mistral models endpoint */ - async validateMistralApiKey(apiKey: string): Promise<{ isValid: boolean; models: ModelInfo[] }> { + async validateMistralApiKey(apiKey: string): Promise<{ isValid: boolean; models: ChatModel[] }> { if (!apiKey || apiKey.length < 3) { return { isValid: false, models: [] }; } @@ -367,13 +361,13 @@ export class OpenCodeManager { * Get available models (cached with 5-minute TTL) * Merges models from all configured providers. */ - async getAvailableModels(): Promise { + async getAvailableModels(): Promise { // Return cached models if within TTL if (this.cachedModels && Date.now() - this.cachedModelsAt < OpenCodeManager.MODEL_CACHE_TTL) { return this.cachedModels; } - const allModels: ModelInfo[] = []; + const allModels: ChatModel[] = []; let fetched = false; // Fetch OpenCode models @@ -908,7 +902,7 @@ export class OpenCodeManager { private async sendOpenAIMessage( modelId: string, systemPrompt: string, - dbMessages: Array<{ role: string; content?: string }>, + dbMessages: Array<{ role: string; content?: string; toolCalls?: string; toolCallId?: string }>, signal: AbortSignal, callbacks: { onDelta?: (delta: string) => void; @@ -922,15 +916,18 @@ export class OpenCodeManager { apiKey: string = this.apiKey, providerOptions?: { parallelToolCalls?: boolean }, ): Promise<{ content: string; toolCalls: Array<{ name: string; args: unknown }> }> { - // Build OpenAI-format messages + // Build OpenAI-format messages (with tool-call summaries for context parity with Anthropic path) const allMessages: Array> = [ { role: 'system', content: systemPrompt }, ...dbMessages .filter(m => m.role === 'user' || m.role === 'assistant') - .map(m => ({ - role: m.role, - content: m.content || '', - })), + .map(m => { + let content = m.content || ''; + if (m.role === 'assistant') { + content += this.buildToolCallSummary(m.toolCalls); + } + return { role: m.role, content }; + }), ]; // Build OpenAI tools format @@ -2016,6 +2013,25 @@ export class OpenCodeManager { return truncated; } + /** + * Build a human-readable summary of tool calls from a serialized JSON string. + * Used by both Anthropic and OpenAI message builders to annotate assistant + * messages with tool-use context when resuming a conversation from DB history. + */ + private buildToolCallSummary(toolCallsJson?: string): string { + if (!toolCallsJson) return ''; + try { + const toolCalls = JSON.parse(toolCallsJson) as Array<{ name: string; args: unknown }>; + if (toolCalls.length === 0) return ''; + const summary = toolCalls + .map(tc => `- ${tc.name}(${JSON.stringify(tc.args)})`) + .join('\n'); + return `\n\n[Tools used in this turn:\n${summary}\n]`; + } catch { + return ''; + } + } + /** * Build Anthropic-format messages from DB message history. * For assistant messages that had tool calls, appends a summary annotation @@ -2030,23 +2046,7 @@ export class OpenCodeManager { if (msg.role === 'user') { messages.push({ role: 'user', content: msg.content || '' }); } else if (msg.role === 'assistant') { - let content = msg.content || ''; - - // If this message had tool calls, append a summary for context on resume - if (msg.toolCalls) { - try { - const toolCalls = JSON.parse(msg.toolCalls) as Array<{ name: string; args: unknown }>; - if (toolCalls.length > 0) { - const summary = toolCalls - .map(tc => `- ${tc.name}(${JSON.stringify(tc.args)})`) - .join('\n'); - content += `\n\n[Tools used in this turn:\n${summary}\n]`; - } - } catch { - // Ignore malformed toolCalls JSON - } - } - + const content = (msg.content || '') + this.buildToolCallSummary(msg.toolCalls); messages.push({ role: 'assistant', content }); } } diff --git a/src/main/engine/index.ts b/src/main/engine/index.ts index d7ebcb8..d1dab43 100644 --- a/src/main/engine/index.ts +++ b/src/main/engine/index.ts @@ -33,7 +33,6 @@ export { OpenCodeManager, type SendMessageOptions, type SendMessageResult, - type ModelInfo, } from './OpenCodeManager'; export { WxrParser, diff --git a/src/main/shared/electronApi.ts b/src/main/shared/electronApi.ts index 91dfad0..a54b176 100644 --- a/src/main/shared/electronApi.ts +++ b/src/main/shared/electronApi.ts @@ -421,7 +421,7 @@ export interface ChatMessage { export interface ChatModel { id: string; name: string; - provider?: string; + provider: string; vision?: boolean; } diff --git a/src/renderer/components/ChatPanel/ChatPanel.tsx b/src/renderer/components/ChatPanel/ChatPanel.tsx index a3e030f..7ff41a2 100644 --- a/src/renderer/components/ChatPanel/ChatPanel.tsx +++ b/src/renderer/components/ChatPanel/ChatPanel.tsx @@ -23,9 +23,6 @@ export const ChatPanel: React.FC = ({ conversationId }) => { const [availableModels, setAvailableModels] = useState([]); const [showModelSelector, setShowModelSelector] = useState(false); const [needsApiKey, setNeedsApiKey] = useState(false); - const [apiKeyInput, setApiKeyInput] = useState(''); - const [apiKeyError, setApiKeyError] = useState(''); - const [isValidating, setIsValidating] = useState(false); const [actionError, setActionError] = useState(null); const messagesEndRef = useRef(null); const inputRef = useRef(null); @@ -174,29 +171,6 @@ export const ChatPanel: React.FC = ({ conversationId }) => { scrollToBottom(); }, [messages, streamingContent, scrollToBottom]); - const handleApiKeySubmit = async () => { - if (!apiKeyInput.trim()) return; - - setIsValidating(true); - setApiKeyError(''); - - try { - const result = await window.electronAPI?.chat.validateApiKey(apiKeyInput.trim()); - if (result?.isValid) { - await window.electronAPI?.chat.setApiKey(apiKeyInput.trim()); - setNeedsApiKey(false); - setApiKeyInput(''); - loadData(); - } else { - setApiKeyError(tr('chat.apiKeyInvalid')); - } - } catch { - setApiKeyError(tr('chat.apiKeyValidationFailed')); - } finally { - setIsValidating(false); - } - }; - const handleSend = async () => { const message = inputValue.trim(); if (!message || isStreaming) return; @@ -303,6 +277,11 @@ export const ChatPanel: React.FC = ({ conversationId }) => { // API key setup screen if (needsApiKey) { + const handleOpenSettings = () => { + useAppStore.getState().setActiveView('settings'); + useAppStore.getState().openTab({ type: 'settings', id: 'settings', isTransient: false }); + }; + return (
@@ -314,23 +293,12 @@ export const ChatPanel: React.FC = ({ conversationId }) => {

{tr('chat.apiKeyRequiredTitle')}

{tr('chat.apiKeyRequiredDescription')}

- setApiKeyInput(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && handleApiKeySubmit()} - placeholder={tr('chat.apiKeyPlaceholder')} - disabled={isValidating} - /> - {apiKeyError &&
{apiKeyError}
}
diff --git a/src/renderer/i18n/locales/de.json b/src/renderer/i18n/locales/de.json index 70bca31..455df03 100644 --- a/src/renderer/i18n/locales/de.json +++ b/src/renderer/i18n/locales/de.json @@ -192,13 +192,11 @@ "settings.toast.thumbnailsComplete": "Generierung der Vorschaubilder abgeschlossen", "settings.toast.thumbnailsFailed": "Vorschaubilder konnten nicht erzeugt werden", "chat.setupTitle": "KI-Chat-Einrichtung", - "chat.apiKeyRequiredTitle": "OpenCode Zen API-Schlüssel erforderlich", - "chat.apiKeyRequiredDescription": "Gib deinen OpenCode API-Schlüssel ein, um den KI-Chat zu aktivieren.", + "chat.apiKeyRequiredTitle": "API-Schlüssel erforderlich", + "chat.apiKeyRequiredDescription": "Konfiguriere einen API-Schlüssel in den Einstellungen, um den KI-Chat zu aktivieren.", + "chat.openSettings": "Einstellungen öffnen", "chat.apiKeyPlaceholder": "API-Schlüssel eingeben...", "chat.apiKeySave": "Schlüssel speichern", - "chat.apiKeyValidating": "Wird validiert...", - "chat.apiKeyInvalid": "Ungültiger API-Schlüssel. Bitte prüfen und erneut versuchen.", - "chat.apiKeyValidationFailed": "API-Schlüssel konnte nicht validiert werden.", "chat.newChat": "Neuer Chat", "chat.welcomeTitle": "Willkommen beim KI-Assistenten", "chat.welcomeDescription": "Ich kann dir helfen, deinen Blog mit anschaulichen Darstellungen zu verwalten. Frag mich zum Beispiel:", diff --git a/src/renderer/i18n/locales/en.json b/src/renderer/i18n/locales/en.json index 1c24a3a..ec5a0a4 100644 --- a/src/renderer/i18n/locales/en.json +++ b/src/renderer/i18n/locales/en.json @@ -192,13 +192,11 @@ "settings.toast.thumbnailsComplete": "Thumbnail generation complete", "settings.toast.thumbnailsFailed": "Failed to generate thumbnails", "chat.setupTitle": "AI Chat Setup", - "chat.apiKeyRequiredTitle": "OpenCode Zen API Key Required", - "chat.apiKeyRequiredDescription": "Enter your OpenCode API key to enable AI chat.", + "chat.apiKeyRequiredTitle": "API Key Required", + "chat.apiKeyRequiredDescription": "Configure an API key in Settings to enable AI chat.", + "chat.openSettings": "Open Settings", "chat.apiKeyPlaceholder": "Enter your API key...", "chat.apiKeySave": "Save Key", - "chat.apiKeyValidating": "Validating...", - "chat.apiKeyInvalid": "Invalid API key. Please check and try again.", - "chat.apiKeyValidationFailed": "Failed to validate API key.", "chat.newChat": "New Chat", "chat.welcomeTitle": "Welcome to the AI Assistant", "chat.welcomeDescription": "I can help you manage your blog with rich visualizations. Try asking me to:", diff --git a/src/renderer/i18n/locales/es.json b/src/renderer/i18n/locales/es.json index e9220d6..985c05c 100644 --- a/src/renderer/i18n/locales/es.json +++ b/src/renderer/i18n/locales/es.json @@ -192,13 +192,11 @@ "settings.toast.thumbnailsComplete": "Generación de miniaturas completa", "settings.toast.thumbnailsFailed": "No se pudieron generar miniaturas", "chat.setupTitle": "Configuración de chat IA", - "chat.apiKeyRequiredTitle": "Se requiere clave API de OpenCode Zen", - "chat.apiKeyRequiredDescription": "Introduce tu clave API de OpenCode para habilitar el chat de IA.", + "chat.apiKeyRequiredTitle": "Clave API requerida", + "chat.apiKeyRequiredDescription": "Configura una clave API en Ajustes para habilitar el chat de IA.", + "chat.openSettings": "Abrir Ajustes", "chat.apiKeyPlaceholder": "Introduce tu clave API...", "chat.apiKeySave": "Guardar clave", - "chat.apiKeyValidating": "Validando...", - "chat.apiKeyInvalid": "Clave API no válida. Compruébala e inténtalo de nuevo.", - "chat.apiKeyValidationFailed": "No se pudo validar la clave API.", "chat.newChat": "Nuevo chat", "chat.welcomeTitle": "Bienvenido al asistente de IA", "chat.welcomeDescription": "Puedo ayudarte a gestionar tu blog con visualizaciones interactivas. Prueba a pedirme que:", diff --git a/src/renderer/i18n/locales/fr.json b/src/renderer/i18n/locales/fr.json index f78cbab..8356f65 100644 --- a/src/renderer/i18n/locales/fr.json +++ b/src/renderer/i18n/locales/fr.json @@ -190,13 +190,11 @@ "settings.toast.thumbnailsComplete": "Génération des miniatures terminée", "settings.toast.thumbnailsFailed": "Impossible de générer les miniatures", "chat.setupTitle": "Configuration du chat IA", - "chat.apiKeyRequiredTitle": "Clé API OpenCode Zen requise", - "chat.apiKeyRequiredDescription": "Saisissez votre clé API OpenCode pour activer le chat IA.", + "chat.apiKeyRequiredTitle": "Clé API requise", + "chat.apiKeyRequiredDescription": "Configurez une clé API dans les Réglages pour activer le chat IA.", + "chat.openSettings": "Ouvrir les Réglages", "chat.apiKeyPlaceholder": "Saisissez votre clé API...", "chat.apiKeySave": "Enregistrer la clé", - "chat.apiKeyValidating": "Validation...", - "chat.apiKeyInvalid": "Clé API invalide. Veuillez vérifier et réessayer.", - "chat.apiKeyValidationFailed": "Impossible de valider la clé API.", "chat.newChat": "Nouveau chat", "chat.welcomeTitle": "Bienvenue dans l’assistant IA", "chat.welcomeDescription": "Je peux vous aider à gérer votre blog avec des visualisations riches. Essayez par exemple :", diff --git a/src/renderer/i18n/locales/it.json b/src/renderer/i18n/locales/it.json index 76769be..e56364d 100644 --- a/src/renderer/i18n/locales/it.json +++ b/src/renderer/i18n/locales/it.json @@ -190,13 +190,11 @@ "settings.toast.thumbnailsComplete": "Generazione miniature completata", "settings.toast.thumbnailsFailed": "Impossibile generare le miniature", "chat.setupTitle": "Configurazione chat IA", - "chat.apiKeyRequiredTitle": "Chiave API OpenCode Zen richiesta", - "chat.apiKeyRequiredDescription": "Inserisci la tua chiave API OpenCode per abilitare la chat IA.", + "chat.apiKeyRequiredTitle": "Chiave API richiesta", + "chat.apiKeyRequiredDescription": "Configura una chiave API nelle Impostazioni per abilitare la chat IA.", + "chat.openSettings": "Apri Impostazioni", "chat.apiKeyPlaceholder": "Inserisci la tua chiave API...", "chat.apiKeySave": "Salva chiave", - "chat.apiKeyValidating": "Convalida in corso...", - "chat.apiKeyInvalid": "Chiave API non valida. Controlla e riprova.", - "chat.apiKeyValidationFailed": "Impossibile convalidare la chiave API.", "chat.newChat": "Nuova chat", "chat.welcomeTitle": "Benvenuto nell’assistente IA", "chat.welcomeDescription": "Posso aiutarti a gestire il tuo blog con visualizzazioni interattive. Prova a chiedermi di:", diff --git a/tests/engine/OpenCodeManagerMistral.test.ts b/tests/engine/OpenCodeManagerMistral.test.ts index d2c1f98..d7fefe6 100644 --- a/tests/engine/OpenCodeManagerMistral.test.ts +++ b/tests/engine/OpenCodeManagerMistral.test.ts @@ -45,7 +45,8 @@ vi.mock('../../src/main/database', () => ({ getDatabase: vi.fn(() => ({})), })); -import { OpenCodeManager, type ModelInfo } from '../../src/main/engine/OpenCodeManager'; +import { OpenCodeManager } from '../../src/main/engine/OpenCodeManager'; +import type { ChatModel } from '../../src/main/shared/electronApi'; // Helper to create manager with mocked httpRequest function createManager(): OpenCodeManager { @@ -278,7 +279,7 @@ describe('OpenCodeManager Mistral integration', () => { }); const models = await manager.getAvailableModels(); - const providers = new Set(models.map((m: ModelInfo) => m.provider)); + const providers = new Set(models.map((m: ChatModel) => m.provider)); expect(providers.has('mistral')).toBe(false); }); @@ -301,7 +302,7 @@ describe('OpenCodeManager Mistral integration', () => { const models = await manager.getAvailableModels(); expect(models.length).toBe(2); - expect(models.every((m: ModelInfo) => m.provider === 'mistral')).toBe(true); + expect(models.every((m: ChatModel) => m.provider === 'mistral')).toBe(true); }); it('merges models from both providers when both keys are set', async () => { @@ -330,7 +331,7 @@ describe('OpenCodeManager Mistral integration', () => { const models = await manager.getAvailableModels(); expect(models.length).toBe(4); - const providers = new Set(models.map((m: ModelInfo) => m.provider)); + const providers = new Set(models.map((m: ChatModel) => m.provider)); expect(providers.has('anthropic')).toBe(true); expect(providers.has('mistral')).toBe(true); }); @@ -353,8 +354,8 @@ describe('OpenCodeManager Mistral integration', () => { }); const models = await manager.getAvailableModels(); - const large = models.find((m: ModelInfo) => m.id === 'mistral-large-latest'); - const devstral = models.find((m: ModelInfo) => m.id === 'devstral-small-latest'); + const large = models.find((m: ChatModel) => m.id === 'mistral-large-latest'); + const devstral = models.find((m: ChatModel) => m.id === 'devstral-small-latest'); expect(large?.vision).toBe(true); expect(devstral?.vision).toBe(false); @@ -369,7 +370,7 @@ describe('OpenCodeManager Mistral integration', () => { const models = await manager.getAvailableModels(); // Should only have Mistral models from fallback - const providers = new Set(models.map((m: ModelInfo) => m.provider)); + const providers = new Set(models.map((m: ChatModel) => m.provider)); expect(providers.has('mistral')).toBe(true); expect(providers.has('anthropic')).toBe(false); expect(providers.has('openai')).toBe(false); @@ -580,13 +581,13 @@ describe('OpenCodeManager Mistral integration', () => { const models = await manager.getAvailableModels(); // Vision-capable models - expect(models.find((m: ModelInfo) => m.id === 'mistral-large-latest')?.vision).toBe(true); - expect(models.find((m: ModelInfo) => m.id === 'mistral-medium-latest')?.vision).toBe(true); - expect(models.find((m: ModelInfo) => m.id === 'mistral-small-latest')?.vision).toBe(true); + expect(models.find((m: ChatModel) => m.id === 'mistral-large-latest')?.vision).toBe(true); + expect(models.find((m: ChatModel) => m.id === 'mistral-medium-latest')?.vision).toBe(true); + expect(models.find((m: ChatModel) => m.id === 'mistral-small-latest')?.vision).toBe(true); // Non-vision models - expect(models.find((m: ModelInfo) => m.id === 'devstral-small-latest')?.vision).toBe(false); - expect(models.find((m: ModelInfo) => m.id === 'devstral-large-latest')?.vision).toBe(false); + expect(models.find((m: ChatModel) => m.id === 'devstral-small-latest')?.vision).toBe(false); + expect(models.find((m: ChatModel) => m.id === 'devstral-large-latest')?.vision).toBe(false); }); }); diff --git a/tests/engine/OpenCodeModelDiscovery.test.ts b/tests/engine/OpenCodeModelDiscovery.test.ts index d5281ec..e213ad8 100644 --- a/tests/engine/OpenCodeModelDiscovery.test.ts +++ b/tests/engine/OpenCodeModelDiscovery.test.ts @@ -29,7 +29,8 @@ vi.mock('../../src/main/database', () => ({ getDatabase: vi.fn(() => ({})), })); -import { OpenCodeManager, ModelInfo } from '../../src/main/engine/OpenCodeManager'; +import { OpenCodeManager } from '../../src/main/engine/OpenCodeManager'; +import type { ChatModel } from '../../src/main/shared/electronApi'; // Helper to create manager with mocked httpRequest function createManager(): OpenCodeManager { @@ -163,13 +164,13 @@ describe('OpenCodeManager model discovery', () => { expect(models.length).toBeGreaterThan(0); // Should include well-known models from the display name map - const ids = models.map((m: ModelInfo) => m.id); + const ids = models.map((m: ChatModel) => m.id); expect(ids).toContain('claude-sonnet-4'); expect(ids).toContain('gpt-5'); // Every model should have proper provider detection - const claudeModel = models.find((m: ModelInfo) => m.id === 'claude-sonnet-4'); + const claudeModel = models.find((m: ChatModel) => m.id === 'claude-sonnet-4'); expect(claudeModel?.provider).toBe('anthropic'); - const gptModel = models.find((m: ModelInfo) => m.id === 'gpt-5'); + const gptModel = models.find((m: ChatModel) => m.id === 'gpt-5'); expect(gptModel?.provider).toBe('openai'); }); @@ -183,7 +184,7 @@ describe('OpenCodeManager model discovery', () => { const models = await manager.getAvailableModels(); expect(models.length).toBeGreaterThan(0); - const ids = models.map((m: ModelInfo) => m.id); + const ids = models.map((m: ChatModel) => m.id); expect(ids).toContain('claude-sonnet-4'); }); @@ -245,7 +246,7 @@ describe('OpenCodeManager model discovery', () => { // Only Mistral models will be in fallback since only Mistral key is set expect(models.length).toBeGreaterThan(0); - const providers = new Set(models.map((m: ModelInfo) => m.provider)); + const providers = new Set(models.map((m: ChatModel) => m.provider)); expect(providers.has('mistral')).toBe(true); }); });