From 226298dd1503c73e5cfb5a584b4160b74fb51c78 Mon Sep 17 00:00:00 2001 From: hugo Date: Wed, 11 Feb 2026 21:03:50 +0100 Subject: [PATCH] fix: better working ai chat --- src/main/engine/ChatEngine.ts | 35 ++-- src/main/engine/OpenCodeManager.ts | 35 +++- src/main/preload.ts | 8 +- .../components/SettingsView/SettingsView.css | 57 +++++ .../components/SettingsView/SettingsView.tsx | 198 +++++++++++++++++- src/renderer/components/TabBar/TabBar.css | 4 +- src/renderer/components/TabBar/TabBar.tsx | 2 +- src/renderer/types/electron.d.ts | 6 +- 8 files changed, 311 insertions(+), 34 deletions(-) diff --git a/src/main/engine/ChatEngine.ts b/src/main/engine/ChatEngine.ts index 79eb05d..617a276 100644 --- a/src/main/engine/ChatEngine.ts +++ b/src/main/engine/ChatEngine.ts @@ -319,25 +319,28 @@ export class ChatEngine { * 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. + return `You are an AI assistant integrated into the Blogging Desktop Server (bDS) application. +Your role is to help users manage their blog posts and media files using ONLY the tools provided to you. -You have access to tools that allow you to: -- Search for posts using full-text search with optional category/tag filters -- Read individual post content and metadata -- List and filter posts by status, category, or tags -- View information about media files (images) -- Update metadata for posts and media files -- List all tags with post counts -- List all categories with post counts +IMPORTANT: You do NOT have access to the internet, real-time data, or any external services. +You can ONLY access information through the tools listed below. Do not claim otherwise. -When answering questions about the user's blog content: -1. Use the search or list tools to find relevant posts -2. Read specific posts to get detailed content -3. Use list_tags and list_categories to understand the taxonomy -4. Provide helpful summaries and suggestions +Available Tools: +- search_posts: Search blog posts using full-text search. Supports category/tag filters. +- read_post: Read the full content and metadata of a specific post by ID. +- list_posts: List posts with optional filtering by status, category, or tags. +- get_media: Get information about a specific media file by ID. +- list_media: List media files with optional MIME type filtering. +- update_post_metadata: Update a post's title, excerpt, tags, or categories. +- update_media_metadata: Update a media file's alt text, caption, or tags. +- list_tags: List all tags with post counts. +- list_categories: List all categories with post counts. -Be concise but thorough in your responses. When displaying post information, format it clearly.`; +When answering questions: +1. USE THE TOOLS to find information. Never make up data about posts or media. +2. If asked about something outside your tools (weather, news, websites), explain that you can only access the user's local blog content. +3. Be concise and helpful. Format post information clearly when displaying it. +4. If a search returns no results, suggest alternative queries or filters.`; } /** diff --git a/src/main/engine/OpenCodeManager.ts b/src/main/engine/OpenCodeManager.ts index dd45043..81321f5 100644 --- a/src/main/engine/OpenCodeManager.ts +++ b/src/main/engine/OpenCodeManager.ts @@ -396,8 +396,10 @@ export class OpenCodeManager { const toolUseBlocks = (data.content as AnthropicContentBlock[]).filter( (b: AnthropicContentBlock) => b.type === 'tool_use' ); + + // Capture text from any block type that has a text field (text, thinking, etc.) const textBlocks = (data.content as AnthropicContentBlock[]).filter( - (b: AnthropicContentBlock) => b.type === 'text' + (b: AnthropicContentBlock) => b.text ); // Accumulate and stream text content to frontend @@ -536,10 +538,22 @@ export class OpenCodeManager { textContent = content; } else if (Array.isArray(content)) { // Handle array of content parts (some models return this format) + // Accept any part that has a text field, regardless of type textContent = content - .filter((part: { type?: string; text?: string }) => part.type === 'text' && part.text) + .filter((part: { type?: string; text?: string }) => part.text) .map((part: { text: string }) => part.text) .join(''); + + // Log what types we're seeing for debugging + const types = content.map((p: { type?: string }) => p.type).filter(Boolean); + if (types.length > 0) { + console.log('[OpenCodeManager:OpenAI] Content block types:', types); + } + } else if (content && typeof content === 'object') { + // Handle single object with text field + if ('text' in content && typeof content.text === 'string') { + textContent = content.text; + } } if (textContent) { @@ -892,17 +906,17 @@ export class OpenCodeManager { private async generateConversationTitle( conversationId: string, userMessage: string, - assistantResponse: string + _assistantResponse: string ): Promise { try { const body = { model: 'claude-haiku-4-5', - max_tokens: 100, - system: 'Generate a short, concise title (max 6 words) for this conversation. Only output the title, nothing else.', + 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: `User: ${userMessage.substring(0, 200)}\nAssistant: ${assistantResponse.substring(0, 200)}`, + content: `Topic: ${userMessage.substring(0, 100)}`, }, ], }; @@ -930,7 +944,14 @@ export class OpenCodeManager { title = data.content || ''; } - title = title.trim().replace(/^["']|["']$/g, ''); + // 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) + '…'; + } if (title) { await this.chatEngine.updateConversation(conversationId, { title }); diff --git a/src/main/preload.ts b/src/main/preload.ts index 90adb0e..ce72658 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -297,10 +297,10 @@ export interface ElectronAPI { getApiKey: () => Promise<{ hasKey: boolean; maskedKey: string }>; // Settings - getAvailableModels: () => Promise>; - setDefaultModel: (modelId: string) => Promise; - getSystemPrompt: () => Promise; - setSystemPrompt: (prompt: string) => Promise; + getAvailableModels: () => Promise<{ success: boolean; models?: Array<{ id: string; name: string }>; 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 }>; // Conversations getConversations: () => Promise; diff --git a/src/renderer/components/SettingsView/SettingsView.css b/src/renderer/components/SettingsView/SettingsView.css index 0af0204..a11241e 100644 --- a/src/renderer/components/SettingsView/SettingsView.css +++ b/src/renderer/components/SettingsView/SettingsView.css @@ -414,4 +414,61 @@ color: var(--vscode-input-placeholderForeground); } +/* AI Settings */ +.system-prompt-textarea { + width: 100%; + min-height: 150px; + max-height: 400px; + padding: 10px 12px; + font-size: 12px; + font-family: var(--vscode-editor-font-family), monospace; + line-height: 1.5; + background-color: var(--vscode-input-background); + border: 1px solid var(--vscode-input-border); + color: var(--vscode-input-foreground); + border-radius: 4px; + outline: none; + resize: vertical; +} +.system-prompt-textarea:focus { + border-color: var(--vscode-focusBorder); +} + +.system-prompt-textarea::placeholder { + color: var(--vscode-input-placeholderForeground); +} + +.setting-inline-action { + display: flex; + gap: 8px; + align-items: center; +} + +.setting-inline-action input { + flex: 1; + max-width: 300px; +} + +.setting-row-full { + display: flex; + flex-direction: column; + gap: 8px; + padding: 12px 16px; + border-radius: 4px; + transition: background-color 0.15s; +} + +.setting-row-full:hover { + background-color: var(--vscode-list-hoverBackground); +} + +.setting-row-full .setting-control { + width: 100%; +} + +.setting-row-full .setting-actions { + display: flex; + gap: 8px; + margin-top: 8px; +} diff --git a/src/renderer/components/SettingsView/SettingsView.tsx b/src/renderer/components/SettingsView/SettingsView.tsx index a100f67..7aedbf8 100644 --- a/src/renderer/components/SettingsView/SettingsView.tsx +++ b/src/renderer/components/SettingsView/SettingsView.tsx @@ -4,7 +4,7 @@ import { showToast } from '../Toast'; import './SettingsView.css'; // Export category IDs for sidebar navigation -export type SettingsCategory = 'project' | 'editor' | 'content' | 'sync' | 'publishing' | 'data'; +export type SettingsCategory = 'project' | 'editor' | 'content' | 'ai' | 'sync' | 'publishing' | 'data'; // Scroll to a settings section by category ID export const scrollToSettingsSection = (category: SettingsCategory) => { @@ -113,6 +113,15 @@ export const SettingsView: React.FC = () => { const [postCategories, setPostCategories] = useState(DEFAULT_POST_CATEGORIES); const [newCategoryInput, setNewCategoryInput] = useState(''); + // AI Assistant settings + const [aiSystemPrompt, setAiSystemPrompt] = useState(''); + const [aiSystemPromptModified, setAiSystemPromptModified] = useState(false); + const [aiApiKeyMasked, setAiApiKeyMasked] = useState(''); + const [aiHasApiKey, setAiHasApiKey] = useState(false); + const [newApiKey, setNewApiKey] = useState(''); + const [availableModels, setAvailableModels] = useState<{id: string; name: string}[]>([]); + const [selectedModel, setSelectedModel] = useState(''); + // Check if a section has any matching settings const sectionHasMatches = useCallback((sectionKeywords: string[]) => { if (!searchQuery) return true; @@ -147,6 +156,28 @@ export const SettingsView: React.FC = () => { setPostCategories(DEFAULT_POST_CATEGORIES); } + // Load AI settings + try { + const promptResult = await window.electronAPI?.chat.getSystemPrompt(); + if (promptResult?.success && promptResult.prompt) { + setAiSystemPrompt(promptResult.prompt); + } + + const keyResult = await window.electronAPI?.chat.getApiKey(); + if (keyResult) { + setAiHasApiKey(keyResult.hasKey); + setAiApiKeyMasked(keyResult.maskedKey || ''); + } + + const modelsResult = await window.electronAPI?.chat.getAvailableModels(); + if (modelsResult?.success && modelsResult.models) { + setAvailableModels(modelsResult.models); + setSelectedModel(modelsResult.selectedModel || ''); + } + } catch (error) { + console.error('Failed to load AI settings:', error); + } + // Check Dropbox status const dbxConfigured = await window.electronAPI?.dropbox?.isConfigured(); setDropboxConfigured(dbxConfigured || false); @@ -270,6 +301,7 @@ export const SettingsView: React.FC = () => { const projectKeywords = ['project', 'name', 'description', 'blog', 'site']; const editorKeywords = ['editor', 'mode', 'wysiwyg', 'markdown', 'preview', 'visual']; const contentKeywords = ['content', 'categories', 'post', 'article', 'picture', 'aside', 'page']; + const aiKeywords = ['ai', 'assistant', 'chat', 'model', 'prompt', 'system', 'api', 'key', 'claude', 'gpt', 'opencode']; const syncKeywords = ['sync', 'dropbox', 'file', 'backup', 'token', 'remote']; const publishingKeywords = ['publishing', 'ftp', 'ssh', 'deploy', 'server', 'host', 'upload']; const dataKeywords = ['data', 'database', 'rebuild', 'maintenance', 'posts', 'media', 'links', 'folder', 'filesystem']; @@ -459,6 +491,168 @@ export const SettingsView: React.FC = () => { ); + // AI Assistant handlers + const handleSaveSystemPrompt = async () => { + try { + const result = await window.electronAPI?.chat.setSystemPrompt(aiSystemPrompt); + if (result?.success) { + setAiSystemPromptModified(false); + showToast.success('System prompt saved'); + } else { + showToast.error('Failed to save system prompt'); + } + } catch (error) { + console.error('Failed to save system prompt:', error); + showToast.error('Failed to save system prompt'); + } + }; + + const handleResetSystemPrompt = async () => { + try { + // Set to empty to use built-in default + await window.electronAPI?.chat.setSystemPrompt(''); + const result = await window.electronAPI?.chat.getSystemPrompt(); + if (result?.prompt) { + setAiSystemPrompt(result.prompt); + setAiSystemPromptModified(false); + showToast.success('System prompt reset to default'); + } + } catch (error) { + console.error('Failed to reset system prompt:', error); + showToast.error('Failed to reset system prompt'); + } + }; + + const handleSaveApiKey = async () => { + if (!newApiKey.trim()) return; + try { + const validateResult = await window.electronAPI?.chat.validateApiKey(newApiKey.trim()); + if (validateResult?.isValid) { + await window.electronAPI?.chat.setApiKey(newApiKey.trim()); + setAiHasApiKey(true); + setAiApiKeyMasked('•'.repeat(Math.max(0, newApiKey.length - 4)) + newApiKey.slice(-4)); + setNewApiKey(''); + showToast.success('API key saved and validated'); + } else { + showToast.error('Invalid API key'); + } + } catch (error) { + console.error('Failed to save API key:', error); + showToast.error('Failed to save API key'); + } + }; + + const handleModelChange = async (modelId: string) => { + try { + const result = await window.electronAPI?.chat.setDefaultModel(modelId); + if (result?.success) { + setSelectedModel(modelId); + showToast.success('Default model updated'); + } + } catch (error) { + console.error('Failed to set model:', error); + showToast.error('Failed to set default model'); + } + }; + + const renderAISettings = () => ( +