fix: better working ai chat

This commit is contained in:
2026-02-11 21:03:50 +01:00
parent 53ebe91895
commit 226298dd15
8 changed files with 311 additions and 34 deletions

View File

@@ -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.`;
}
/**

View File

@@ -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<void> {
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 });

View File

@@ -297,10 +297,10 @@ export interface ElectronAPI {
getApiKey: () => Promise<{ hasKey: boolean; maskedKey: string }>;
// Settings
getAvailableModels: () => Promise<Array<{ id: string; name: string }>>;
setDefaultModel: (modelId: string) => Promise<void>;
getSystemPrompt: () => Promise<string | null>;
setSystemPrompt: (prompt: string) => Promise<void>;
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<unknown[]>;

View File

@@ -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;
}

View File

@@ -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<string[]>(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 = () => {
</SettingSection>
);
// 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 = () => (
<SettingSection
id="settings-section-ai"
title="AI Assistant"
description="Configure the AI chat assistant that helps you manage your blog content."
hidden={!sectionHasMatches(aiKeywords)}
>
<SettingRow
id="ai-api-key"
label="OpenCode API Key"
description="Your API key for the OpenCode Zen gateway. Required to use AI features."
>
<div className="setting-input-group">
{aiHasApiKey ? (
<>
<input
id="ai-api-key"
type="text"
value={aiApiKeyMasked}
disabled
placeholder="API key configured"
/>
<span className="setting-status-badge success"> Configured</span>
</>
) : (
<>
<input
id="ai-api-key"
type="password"
value={newApiKey}
onChange={(e) => setNewApiKey(e.target.value)}
placeholder="Enter your API key..."
/>
<button className="primary" onClick={handleSaveApiKey} disabled={!newApiKey.trim()}>
Save Key
</button>
</>
)}
</div>
{aiHasApiKey && (
<div className="setting-inline-action">
<button className="text-button" onClick={() => { setAiHasApiKey(false); setAiApiKeyMasked(''); }}>
Change API Key
</button>
</div>
)}
</SettingRow>
<SettingRow
id="ai-model"
label="Default Model"
description="The AI model to use for new chat conversations."
>
<select
id="ai-model"
value={selectedModel}
onChange={(e) => handleModelChange(e.target.value)}
disabled={!aiHasApiKey}
>
{availableModels.length === 0 && <option value="">No models available</option>}
{availableModels.map(model => (
<option key={model.id} value={model.id}>{model.name}</option>
))}
</select>
</SettingRow>
<SettingRow
id="ai-system-prompt"
label="System Prompt"
description="Instructions given to the AI at the start of each conversation. This defines how the assistant behaves and what tools it knows about."
>
<textarea
id="ai-system-prompt"
value={aiSystemPrompt}
onChange={(e) => {
setAiSystemPrompt(e.target.value);
setAiSystemPromptModified(true);
}}
placeholder="Enter system instructions for the AI assistant..."
rows={12}
className="system-prompt-textarea"
/>
<div className="setting-actions">
<button
className="primary"
onClick={handleSaveSystemPrompt}
disabled={!aiSystemPromptModified}
>
Save Prompt
</button>
<button className="secondary" onClick={handleResetSystemPrompt}>
Reset to Default
</button>
</div>
</SettingRow>
</SettingSection>
);
const renderSyncSettings = () => (
<>
<SettingSection
@@ -778,6 +972,7 @@ export const SettingsView: React.FC = () => {
sectionHasMatches(projectKeywords) ||
sectionHasMatches(editorKeywords) ||
sectionHasMatches(contentKeywords) ||
sectionHasMatches(aiKeywords) ||
sectionHasMatches(syncKeywords) ||
sectionHasMatches(publishingKeywords) ||
sectionHasMatches(dataKeywords);
@@ -813,6 +1008,7 @@ export const SettingsView: React.FC = () => {
{renderProjectSettings()}
{renderEditorSettings()}
{renderContentSettings()}
{renderAISettings()}
{renderSyncSettings()}
{renderPublishingSettings()}
{renderDataSettings()}

View File

@@ -82,8 +82,8 @@
gap: 4px;
padding: 0 10px;
height: 100%;
min-width: 120px;
max-width: 200px;
min-width: 100px;
max-width: 160px;
cursor: pointer;
background-color: var(--vscode-tab-inactiveBackground, #2d2d2d);
border-right: 1px solid var(--vscode-tab-border, #252526);

View File

@@ -2,7 +2,7 @@ import React, { useRef, useState, useEffect, useCallback } from 'react';
import { useAppStore, Tab } from '../../store';
import './TabBar.css';
const MAX_CHAT_TITLE_LENGTH = 25;
const MAX_CHAT_TITLE_LENGTH = 18;
const getTabTitle = (
tab: Tab,

View File

@@ -342,9 +342,9 @@ export interface ElectronAPI {
// Settings
getAvailableModels: () => Promise<{ success: boolean; models?: ChatModel[]; selectedModel?: string; error?: string }>;
setDefaultModel: (modelId: string) => Promise<void>;
getSystemPrompt: () => Promise<string | null>;
setSystemPrompt: (prompt: string) => Promise<void>;
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<ChatConversation[]>;