From 9871cb827f156967ca91117a754eab6baba80af4 Mon Sep 17 00:00:00 2001 From: Georg Bauer Date: Sat, 7 Mar 2026 09:54:13 +0100 Subject: [PATCH] Feature/ai post suggestions (#40) * feat: first cut on ai suggestion system for title and summary * feat: completion of titling/excerpt/slug-suggestion AI quick action * feat: feeds use existing excerpts. also documentation. --------- Co-authored-by: hugo --- API.md | 52 +++- DOCUMENTATION.md | 6 + TODO.md | 102 ------- .../engine/GenerationSitemapFeedService.ts | 11 +- src/main/engine/PostEngine.ts | 59 ++-- src/main/engine/ai/tasks.ts | 95 +++++++ src/main/ipc/chatHandlers.ts | 15 +- src/main/preload.ts | 3 + src/main/shared/electronApi.ts | 3 + src/main/shared/i18n/locales/de.json | 2 + src/main/shared/i18n/locales/en.json | 2 + src/main/shared/i18n/locales/es.json | 2 + src/main/shared/i18n/locales/fr.json | 2 + src/main/shared/i18n/locales/it.json | 2 + src/main/shared/pythonApiContractV1.ts | 16 +- .../AISuggestionsModal/AISuggestionsModal.tsx | 161 ++++++----- src/renderer/components/Editor/Editor.css | 6 + src/renderer/components/Editor/Editor.tsx | 208 ++++++++++++-- src/renderer/i18n/locales/de.json | 17 ++ src/renderer/i18n/locales/en.json | 17 ++ src/renderer/i18n/locales/es.json | 17 ++ src/renderer/i18n/locales/fr.json | 17 ++ src/renderer/i18n/locales/it.json | 17 ++ .../GenerationSitemapFeedService.test.ts | 52 ++++ tests/engine/PostEngine.test.ts | 65 +++++ tests/engine/ai/analyzePost.test.ts | 184 +++++++++++++ .../components/AISuggestionsModal.test.tsx | 92 +++++-- .../EditorMetadataCollapse.test.tsx | 33 +++ .../EditorPostAISuggestions.test.tsx | 253 ++++++++++++++++++ .../python/pythonApiContractV1.test.ts | 4 +- 30 files changed, 1270 insertions(+), 245 deletions(-) create mode 100644 tests/engine/ai/analyzePost.test.ts create mode 100644 tests/renderer/components/EditorPostAISuggestions.test.tsx diff --git a/API.md b/API.md index e874f32..1b96b53 100644 --- a/API.md +++ b/API.md @@ -1,6 +1,6 @@ # API Documentation -Contract version: 1.12.0 +Contract version: 1.13.0 This reference documents all Python runtime API calls available through `bds_api` in embedded Pyodide. @@ -3513,6 +3513,7 @@ result = await bds.tags.sync_from_posts() - [chat.analyzeMediaImage](#chatanalyzemediaimage) - [chat.detectPostLanguage](#chatdetectpostlanguage) +- [chat.analyzePost](#chatanalyzepost) ### chat.analyzeMediaImage @@ -3573,6 +3574,39 @@ result = await bds.chat.detect_post_language(title='title', content='content') {} ``` +### chat.analyzePost + +Analyze a post and generate suggested title, excerpt, and slug using AI. + +**Parameters** + +- postId (str, required) +- language (str, optional) + +**Response specification** + +- Return type: `PostAnalysisResult` +- Data structures: `PostAnalysisResult` + +**Example call** + +```python +from bds_api import bds +result = await bds.chat.analyze_post(post_id='post-1') +``` + +**Example response** + +```python +{ + 'success': False, + 'title': 'value', + 'excerpt': 'value', + 'slug': 'value', + 'error': 'value' +} +``` + [↑ Back to Table of contents](#table-of-contents) ## sync @@ -4375,6 +4409,20 @@ Result from AI image analysis containing generated title, alt text, and caption. [↑ Back to Table of contents](#table-of-contents) +### PostAnalysisResult + +Result from AI post analysis containing suggested title, excerpt, and slug. + +**Fields** + +- success (`boolean`, required): Whether the analysis succeeded. +- title (`string`, optional): Suggested post title. +- excerpt (`string`, optional): Suggested plain-text excerpt summarizing the post. +- slug (`string`, optional): Suggested URL-friendly slug. +- error (`string`, optional): Error message when analysis failed. + +[↑ Back to Table of contents](#table-of-contents) + ### SimilarPost A post with its semantic similarity score relative to a reference post. @@ -4411,4 +4459,4 @@ A pair of posts with high content similarity that may be duplicates. --- -Generated from contract at 2026-03-05T00:00:00.000Z. +Generated from contract at 2026-03-07T00:00:00.000Z. diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 3927343..c54ded2 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -103,6 +103,12 @@ A post usually combines several layers of information: title, body content, cate A reliable post workflow starts by drafting content to completion, then reviewing structure and metadata, and finally previewing the output before publishing. After publishing, commit in Source Control immediately so the editorially approved state is recoverable and shareable. +When you want help refining post metadata, use the **Quick Actions** button in the post editor and choose the AI suggestion action for title, summary, and slug. bDS analyzes the current post content and opens a review dialog that shows the suggested values next to the current ones. You can apply only the fields you want, so this is best used as an editorial assistant rather than an automatic rewrite step. + +The generated summary is stored in the post excerpt field. In the editor, the excerpt appears in its own collapsible block between the metadata area and the main body, and it starts closed by default. Open this block when you want to review or manually adjust the summary text for list pages, previews, and other overview contexts. + +This feature has a few practical limits. It depends on AI being configured in bDS, so it is not available in projects that are using only local editing without AI access. Slug suggestions can only be applied before the post has ever been published. After the first publish, the slug remains locked to protect existing URLs, so the dialog will still show the suggestion for reference but will not let you apply it. + ### Key takeaways - Use Posts for date-oriented, regularly updated content. diff --git a/TODO.md b/TODO.md index 867cfe0..edc45eb 100644 --- a/TODO.md +++ b/TODO.md @@ -125,108 +125,6 @@ In `PageRenderer` and `BlogGenerationEngine`: --- -## 2. AI Post Summary, Title & Slug Suggestions - -### Goal - -The post editor has AI buttons that generate summaries (excerpts), improved -titles, and better slugs — so the user can focus on writing content and let AI -handle the metadata. - -### Current State - -- `analyzeMediaImage()` in `OpenCodeManager` already implements the exact - pattern: one-shot AI call, JSON response, language-aware. -- `AISuggestionsModal` already provides the UI: loading state, field-by-field - checkboxes, current vs. suggested comparison, apply/cancel. -- The media editor has an "Analyze with AI" button in a quick-actions menu. -- The post editor metadata area has title, tags, author, slug, and categories - fields but no AI buttons. -- The `excerpt` field exists on `PostData` and can serve as the summary. -- Slug is read-only in the UI after first publish (auto-generated from title). - -### Implementation Plan - -#### 2.1 Backend — `analyzePost()` in OpenCodeManager - -Add a new method following the `analyzeMediaImage()` pattern: - -**Input:** `postId: string, language: string` - -**Process:** -1. Load post content, title, excerpt, and slug via `PostEngine`. -2. Build a system prompt: - ``` - You are a blog editor assistant. Analyze the following blog post and suggest - improvements. Return a JSON object with: - - "title": a clear, engaging title for this post - - "excerpt": a 2-3 sentence summary suitable for overview pages - - "slug": a concise, SEO-friendly URL slug (lowercase, hyphens only) - Respond in {language}. Return only the JSON object. - ``` -3. Send post content as user message to OpenCode Zen API. -4. Parse JSON response. -5. Return `{ success, title?, excerpt?, slug?, error? }`. - -Register IPC handler: `chat:analyzePost`. - -#### 2.2 Frontend — Post Editor AI Button - -In the post editor metadata area (`Editor.tsx`, around line 720): - -- Add a "Quick Actions" dropdown button (same pattern as media editor at - line 1242). -- Menu item: "Suggest Title, Summary & Slug" with a robot icon. -- On click: call `window.electronAPI.chat.analyzePost(postId, projectLanguage)`. -- Show `AISuggestionsModal` with the results. - -#### 2.3 Extend AISuggestionsModal - -The modal currently supports `title`, `alt`, `caption` fields. Adapt it to -also support a post mode with `title`, `excerpt`, `slug` fields: - -- Add a `mode` prop (`'media'` | `'post'`) or make field configuration - dynamic. -- For post mode, show title, excerpt, and slug fields. -- Slug field should show a warning that it only applies to unpublished posts. - -Alternatively, keep the modal generic and pass field definitions as props: -```typescript -interface SuggestionField { - key: string; - label: string; // i18n key - currentValue: string; - suggestedValue?: string; - warning?: string; // e.g., "slug is locked after first publish" -} -``` - -#### 2.4 Applying Suggestions - -On "Apply Selected": - -- Title: update via existing `onTitleChange` handler. -- Excerpt: update via `onExcerptChange` (may need to add this handler if not - present — excerpt editing may need a field in the metadata area). -- Slug: only apply if post has never been published. Show a warning and disable - the checkbox if the post has `publishedAt` set. - -#### 2.5 i18n - -Add keys to all 5 locale files: - -- `aiSuggestions.postTitle`, `aiSuggestions.excerptField`, - `aiSuggestions.slugField` -- `aiSuggestions.analyzingPost` -- `aiSuggestions.slugLockedWarning` -- `postEditor.quickActions`, `postEditor.analyzeWithAI` - -#### 2.6 Excerpt Field in Editor - -If the excerpt/summary is not currently editable in the post metadata area, -add a multi-line text field for it between title and tags. This is needed both -for manual editing and for applying AI suggestions. - --- ## 3. Drag-and-Drop Image Insertion diff --git a/src/main/engine/GenerationSitemapFeedService.ts b/src/main/engine/GenerationSitemapFeedService.ts index 1cefc24..0587ac4 100644 --- a/src/main/engine/GenerationSitemapFeedService.ts +++ b/src/main/engine/GenerationSitemapFeedService.ts @@ -156,6 +156,13 @@ function excerptToXhtml(post: PostData): string { return paragraphToXhtml(firstParagraph); } +function feedContentToXhtml(post: PostData): string { + if (typeof post.excerpt === 'string' && post.excerpt.trim().length > 0) { + return paragraphToXhtml(post.excerpt.trim()); + } + return markdownToXhtml(post.content || ''); +} + function escapeCdata(value: string): string { return value.replace(/]]>/g, ']]]]>'); } @@ -384,7 +391,7 @@ export function buildSitemapAndFeeds(params: BuildSitemapAndFeedsParams): Sitema const canonicalPath = buildCanonicalPreviewPath(createdAt, post.slug); const permalink = `${baseUrl}${canonicalPath}`; const excerptXhtml = excerptToXhtml(post); - const contentXhtml = markdownToXhtml(post.content || ''); + const contentXhtml = feedContentToXhtml(post); const categories = [ ...(post.categories || []).map((category) => `${escapeXml(category)}`), ...(post.tags || []).map((tag) => `${escapeXml(tag)}`), @@ -425,7 +432,7 @@ export function buildSitemapAndFeeds(params: BuildSitemapAndFeedsParams): Sitema const canonicalPath = buildCanonicalPreviewPath(createdAt, post.slug); const permalink = `${baseUrl}${canonicalPath}`; const excerptXhtml = excerptToXhtml(post); - const contentXhtml = markdownToXhtml(post.content || ''); + const contentXhtml = feedContentToXhtml(post); const categories = [ ...(post.tags || []).map((tag) => ``), ...(post.categories || []).map((category) => ``), diff --git a/src/main/engine/PostEngine.ts b/src/main/engine/PostEngine.ts index a5ec21c..4378905 100644 --- a/src/main/engine/PostEngine.ts +++ b/src/main/engine/PostEngine.ts @@ -448,6 +448,7 @@ export class PostEngine extends EventEmitter { // automatically transition to draft status (content moves from file to DB) const isContentOrMetadataChange = data.content !== undefined || data.title !== undefined || + data.slug !== undefined || data.tags !== undefined || data.categories !== undefined || data.excerpt !== undefined || @@ -459,12 +460,32 @@ export class PostEngine extends EventEmitter { newStatus = 'draft'; } - // Auto-update slug when title changes, but only if post was never published - let newSlug = data.slug ?? existing.slug; - if (data.title !== undefined && data.title !== existing.title && !existing.publishedAt) { + // Explicit slug changes are only allowed before the first publish. + const requestedSlug = typeof data.slug === 'string' ? slugify(data.slug) : undefined; + let newSlug = existing.slug; + if (!existing.publishedAt && requestedSlug) { + newSlug = await this.isSlugAvailable(requestedSlug, id) + ? requestedSlug + : await this.generateUniqueSlug(requestedSlug, id); + } else if (data.title !== undefined && data.title !== existing.title && !existing.publishedAt) { newSlug = await this.generateUniqueSlug(data.title || 'untitled', id); } + // If slug changed and the post has a file on disk, rename the file + let newFilePath: string | undefined; + if (newSlug !== existing.slug) { + const dbRow = await db.select().from(posts).where(eq(posts.id, id)).get(); + if (dbRow?.filePath) { + const dir = path.dirname(dbRow.filePath); + newFilePath = path.join(dir, `${newSlug}.md`); + try { + await fs.rename(dbRow.filePath, newFilePath); + } catch { + // Old file may not exist + } + } + } + const updated: PostData = { ...existing, ...data, @@ -478,21 +499,25 @@ export class PostEngine extends EventEmitter { const checksum = this.calculateChecksum(updated.content); // All updates go to DB only — no file writes + const dbSet: Record = { + title: updated.title, + slug: updated.slug, + excerpt: updated.excerpt, + content: updated.content, + status: updated.status, + author: updated.author, + updatedAt: updated.updatedAt, + publishedAt: updated.publishedAt, + checksum, + tags: JSON.stringify(updated.tags), + categories: JSON.stringify(updated.categories), + language: updated.language || null, + }; + if (newFilePath !== undefined) { + dbSet.filePath = newFilePath; + } await db.update(posts) - .set({ - title: updated.title, - slug: updated.slug, - excerpt: updated.excerpt, - content: updated.content, - status: updated.status, - author: updated.author, - updatedAt: updated.updatedAt, - publishedAt: updated.publishedAt, - checksum, - tags: JSON.stringify(updated.tags), - categories: JSON.stringify(updated.categories), - language: updated.language || null, - }) + .set(dbSet) .where(eq(posts.id, id)); // Update FTS index diff --git a/src/main/engine/ai/tasks.ts b/src/main/engine/ai/tasks.ts index bbaa693..2b2a271 100644 --- a/src/main/engine/ai/tasks.ts +++ b/src/main/engine/ai/tasks.ts @@ -8,8 +8,10 @@ import { generateText } from 'ai'; import type { ChatEngine } from '../ChatEngine'; import type { MediaEngine } from '../MediaEngine'; +import type { PostEngine } from '../PostEngine'; import { ProviderRegistry } from './providers'; import { resolveSupportedRenderLanguage, translateRender } from '../../shared/i18n'; +import { slugify } from '../slugify'; // --------------------------------------------------------------------------- // Types @@ -36,6 +38,14 @@ export interface LanguageDetectionResult { error?: string; } +export interface PostAnalysisResult { + success: boolean; + title?: string; + excerpt?: string; + slug?: string; + error?: string; +} + // --------------------------------------------------------------------------- // OneShotTasks // --------------------------------------------------------------------------- @@ -44,15 +54,18 @@ export class OneShotTasks { private providers: ProviderRegistry; private chatEngine: ChatEngine; private mediaEngine: MediaEngine; + private postEngine?: PostEngine; constructor( providers: ProviderRegistry, chatEngine: ChatEngine, mediaEngine: MediaEngine, + postEngine?: PostEngine, ) { this.providers = providers; this.chatEngine = chatEngine; this.mediaEngine = mediaEngine; + this.postEngine = postEngine; } /** @@ -347,4 +360,86 @@ Remember: Only suggest mappings from NEW items to EXISTING items. Consider langu return { success: false, error: (error as Error).message }; } } + + /** + * Analyze a blog post and suggest title, excerpt (plain text), and slug. + * Uses the configured title model (text-only). + */ + async analyzePost( + postId: string, + language: string = 'en', + ): Promise { + if (!this.postEngine) { + return { success: false, error: 'Post engine not available' }; + } + + // Load post (resolves content from filesystem for published posts) + const post = await this.postEngine.getPost(postId); + if (!post) return { success: false, error: 'Post not found' }; + if (!post.content || post.content.trim().length === 0) { + return { success: false, error: 'Post has no content to analyze' }; + } + + // Use the title model — lightweight, text-only task + let modelId = await this.chatEngine.getSetting('chat_title_model'); + if (!modelId || !this.providers.isProviderKeySet(this.providers.detectModelProvider(modelId))) { + modelId = this.providers.getOpencodeKey() + ? 'claude-sonnet-4-5' + : this.providers.getMistralKey() + ? 'mistral-large-latest' + : null; + } + + // In offline mode, swap to configured offline title model + if (this.providers.isOfflineMode()) { + const offlineModel = await this.chatEngine.getSetting('offline_title_model') + || this.providers.getFirstKnownLocalModelId(); + if (offlineModel) { + modelId = offlineModel; + } else if (!modelId || (!this.providers.isOllamaModel(modelId) && !this.providers.isLmstudioModel(modelId))) { + return { success: false, error: 'No offline model configured. Set one in Settings → AI → Airplane Mode.' }; + } + } + + if (!modelId) { + return { success: false, error: 'API key not configured. Please set an API key in Settings.' }; + } + + const snippet = post.content.slice(0, 2000); + const renderLanguage = resolveSupportedRenderLanguage(language); + const systemPrompt = translateRender(renderLanguage, 'ai.postAnalysis.system'); + const userPrompt = translateRender(renderLanguage, 'ai.postAnalysis.user') + .replace('{title}', post.title || '') + .replace('{content}', snippet); + + try { + const model = this.providers.resolveModel(modelId); + + const { text } = await generateText({ + model, + system: systemPrompt, + prompt: userPrompt, + maxOutputTokens: 500, + maxRetries: 2, + }); + + const jsonMatch = text.match(/\{[\s\S]*\}/); + if (!jsonMatch) return { success: false, error: 'Invalid response format from AI' }; + + const result = JSON.parse(jsonMatch[0]); + + // Sanitize slug: lowercase, hyphens only + let resultSlug = result.slug ? slugify(result.slug) : undefined; + if (resultSlug === '') resultSlug = undefined; + + return { + success: true, + title: result.title || undefined, + excerpt: result.excerpt || undefined, + slug: resultSlug, + }; + } catch (error) { + return { success: false, error: (error as Error).message }; + } + } } diff --git a/src/main/ipc/chatHandlers.ts b/src/main/ipc/chatHandlers.ts index f699261..0496340 100644 --- a/src/main/ipc/chatHandlers.ts +++ b/src/main/ipc/chatHandlers.ts @@ -91,7 +91,7 @@ function getChatService(): ChatService { */ function getOneShotTasks(): OneShotTasks { if (!oneShotTasks) { - oneShotTasks = new OneShotTasks(getProviders(), getChatEngine(), engineBundle!.mediaEngine); + oneShotTasks = new OneShotTasks(getProviders(), getChatEngine(), engineBundle!.mediaEngine, engineBundle!.postEngine); } return oneShotTasks; } @@ -894,6 +894,19 @@ export function registerChatHandlers(): void { } }); + // ============ Post Analysis ============ + + // Analyze a post and suggest title, excerpt, and slug using AI + ipcMain.handle('chat:analyzePost', async (_, postId: string, language?: string) => { + try { + await ensureInitialized(); + return await getOneShotTasks().analyzePost(postId, language || 'en'); + } catch (error) { + console.error('[Chat IPC] Error analyzing post:', error); + return { success: false, error: (error as Error).message }; + } + }); + // ============ A2UI Actions ============ ipcMain.handle('a2ui:dispatch', async (_, action: { surfaceId: string; componentId: string; action: string; payload?: Record }) => { diff --git a/src/main/preload.ts b/src/main/preload.ts index aae0b10..f8c6b8f 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -393,6 +393,9 @@ export const electronAPI: ElectronAPI = { // Post Language Detection detectPostLanguage: (title: string, content: string) => ipcRenderer.invoke('chat:detectPostLanguage', title, content), + // Post Analysis (title, excerpt, slug suggestions) + analyzePost: (postId: string, language?: string) => ipcRenderer.invoke('chat:analyzePost', postId, language), + // 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); diff --git a/src/main/shared/electronApi.ts b/src/main/shared/electronApi.ts index 57b6fe0..22214c9 100644 --- a/src/main/shared/electronApi.ts +++ b/src/main/shared/electronApi.ts @@ -994,6 +994,9 @@ export interface ElectronAPI { // Post Language Detection detectPostLanguage: (title: string, content: string) => Promise<{ success: boolean; language?: string; error?: string }>; + // Post Analysis (title, excerpt, slug suggestions) + analyzePost: (postId: string, language?: string) => Promise<{ success: boolean; title?: string; excerpt?: string; slug?: string; error?: string }>; + // Event listeners for streaming/progress onStreamDelta: (callback: (data: ChatStreamDelta) => void) => () => void; onToolCall: (callback: (data: ChatToolCall) => void) => () => void; diff --git a/src/main/shared/i18n/locales/de.json b/src/main/shared/i18n/locales/de.json index 031579b..59b6782 100644 --- a/src/main/shared/i18n/locales/de.json +++ b/src/main/shared/i18n/locales/de.json @@ -83,6 +83,8 @@ "render.month.12": "Dezember", "ai.imageAnalysis.system": "Du erzeugst Bild-Metadaten. Schreibe alle Werte auf Deutsch.\n\nRegeln:\n- \"title\": kurzer beschreibender Titel (3-8 Wörter)\n- \"alt\": sachliche Beschreibung des Sichtbaren (5-12 Wörter). Keine Interpretationen. Kein Präfix \"Bild von\".\n- \"caption\": ansprechende Blog-Bildunterschrift (5-20 Wörter)\n\nAntworte ausschließlich mit JSON: {\"title\": \"...\", \"alt\": \"...\", \"caption\": \"...\"}", "ai.imageAnalysis.user": "Analysiere dieses Bild. Antworte mit JSON auf Deutsch.", + "ai.postAnalysis.system": "Du bist ein Blog-Redaktionsassistent. Analysiere den folgenden Blogbeitrag und schlage Verbesserungen vor.\n\nGib ein JSON-Objekt zurück mit:\n- \"title\": ein klarer, ansprechender Titel (3-10 Wörter)\n- \"excerpt\": eine Zusammenfassung als Fließtext (2-3 Sätze, kein Markdown, keine Formatierung)\n- \"slug\": ein kurzer, SEO-freundlicher URL-Slug (Kleinbuchstaben, nur Bindestriche, keine Sonderzeichen)\n\nAntworte auf Deutsch. Gib nur das JSON-Objekt zurück.", + "ai.postAnalysis.user": "Titel: {title}\n\nInhalt:\n{content}", "task.embeddingIndex.name": "Beiträge für semantische Ähnlichkeit indexieren", "task.embeddingIndex.loading": "Modell wird geladen…", "task.embeddingIndex.indexing": "Indexierung: {indexed}/{total}", diff --git a/src/main/shared/i18n/locales/en.json b/src/main/shared/i18n/locales/en.json index 5ff9841..597d640 100644 --- a/src/main/shared/i18n/locales/en.json +++ b/src/main/shared/i18n/locales/en.json @@ -83,6 +83,8 @@ "render.month.12": "December", "ai.imageAnalysis.system": "You generate image metadata. Write all values in English.\n\nRules:\n- \"title\": short descriptive title (3-8 words)\n- \"alt\": factual description of what is visible (5-12 words). No interpretations. No \"Image of\" prefix.\n- \"caption\": engaging blog caption (5-20 words)\n\nRespond with JSON only: {\"title\": \"...\", \"alt\": \"...\", \"caption\": \"...\"}", "ai.imageAnalysis.user": "Analyze this image. Respond with JSON in English.", + "ai.postAnalysis.system": "You are a blog editor assistant. Analyze the following blog post and suggest improvements.\n\nReturn a JSON object with:\n- \"title\": a clear, engaging title for this post (3-10 words)\n- \"excerpt\": a plain text summary paragraph (2-3 sentences, no markdown, no formatting)\n- \"slug\": a concise, SEO-friendly URL slug (lowercase, hyphens only, no special characters)\n\nRespond in English. Return only the JSON object.", + "ai.postAnalysis.user": "Title: {title}\n\nContent:\n{content}", "task.embeddingIndex.name": "Index posts for Semantic Similarity", "task.embeddingIndex.loading": "Loading model…", "task.embeddingIndex.indexing": "Indexing: {indexed}/{total}", diff --git a/src/main/shared/i18n/locales/es.json b/src/main/shared/i18n/locales/es.json index 7162f1f..f4ebb55 100644 --- a/src/main/shared/i18n/locales/es.json +++ b/src/main/shared/i18n/locales/es.json @@ -83,6 +83,8 @@ "render.month.12": "diciembre", "ai.imageAnalysis.system": "Generas metadatos de imagen. Escribe todos los valores en español.\n\nReglas:\n- \"title\": título descriptivo corto (3-8 palabras)\n- \"alt\": descripción factual de lo visible (5-12 palabras). Sin interpretaciones. Sin prefijo \"Imagen de\".\n- \"caption\": pie de foto atractivo para blog (5-20 palabras)\n\nResponde solo con JSON: {\"title\": \"...\", \"alt\": \"...\", \"caption\": \"...\"}", "ai.imageAnalysis.user": "Analiza esta imagen. Responde con JSON en español.", + "ai.postAnalysis.system": "Eres un asistente de edición de blog. Analiza el siguiente artículo y sugiere mejoras.\n\nDevuelve un objeto JSON con:\n- \"title\": un título claro y atractivo (3-10 palabras)\n- \"excerpt\": un párrafo de resumen en texto plano (2-3 oraciones, sin markdown, sin formato)\n- \"slug\": un slug URL conciso y SEO-friendly (minúsculas, solo guiones, sin caracteres especiales)\n\nResponde en español. Devuelve solo el objeto JSON.", + "ai.postAnalysis.user": "Título: {title}\n\nContenido:\n{content}", "task.embeddingIndex.name": "Indexar entradas para similitud semántica", "task.embeddingIndex.loading": "Cargando modelo…", "task.embeddingIndex.indexing": "Indexando: {indexed}/{total}", diff --git a/src/main/shared/i18n/locales/fr.json b/src/main/shared/i18n/locales/fr.json index 6adbc3b..cde2d51 100644 --- a/src/main/shared/i18n/locales/fr.json +++ b/src/main/shared/i18n/locales/fr.json @@ -83,6 +83,8 @@ "render.month.12": "décembre", "ai.imageAnalysis.system": "Tu génères des métadonnées d'image. Écris toutes les valeurs en français.\n\nRègles :\n- \"title\" : titre descriptif court (3-8 mots)\n- \"alt\" : description factuelle de ce qui est visible (5-12 mots). Pas d'interprétations. Pas de préfixe \"Image de\".\n- \"caption\" : légende de blog engageante (5-20 mots)\n\nRéponds uniquement en JSON : {\"title\": \"...\", \"alt\": \"...\", \"caption\": \"...\"}", "ai.imageAnalysis.user": "Analyse cette image. Réponds en JSON en français.", + "ai.postAnalysis.system": "Tu es un assistant de rédaction de blog. Analyse l'article suivant et suggère des améliorations.\n\nRetourne un objet JSON avec :\n- \"title\" : un titre clair et engageant (3-10 mots)\n- \"excerpt\" : un paragraphe de résumé en texte brut (2-3 phrases, pas de markdown, pas de formatage)\n- \"slug\" : un slug URL court et SEO-friendly (minuscules, tirets uniquement, pas de caractères spéciaux)\n\nRéponds en français. Retourne uniquement l'objet JSON.", + "ai.postAnalysis.user": "Titre : {title}\n\nContenu :\n{content}", "task.embeddingIndex.name": "Indexer les articles pour la similarité sémantique", "task.embeddingIndex.loading": "Chargement du modèle…", "task.embeddingIndex.indexing": "Indexation : {indexed}/{total}", diff --git a/src/main/shared/i18n/locales/it.json b/src/main/shared/i18n/locales/it.json index a744d30..bf3d644 100644 --- a/src/main/shared/i18n/locales/it.json +++ b/src/main/shared/i18n/locales/it.json @@ -83,6 +83,8 @@ "render.month.12": "dicembre", "ai.imageAnalysis.system": "Generi metadati per immagini. Scrivi tutti i valori in italiano.\n\nRegole:\n- \"title\": titolo descrittivo breve (3-8 parole)\n- \"alt\": descrizione fattuale di ciò che è visibile (5-12 parole). Nessuna interpretazione. Nessun prefisso \"Immagine di\".\n- \"caption\": didascalia blog coinvolgente (5-20 parole)\n\nRispondi solo con JSON: {\"title\": \"...\", \"alt\": \"...\", \"caption\": \"...\"}", "ai.imageAnalysis.user": "Analizza questa immagine. Rispondi con JSON in italiano.", + "ai.postAnalysis.system": "Sei un assistente di redazione blog. Analizza il seguente articolo e suggerisci miglioramenti.\n\nRestituisci un oggetto JSON con:\n- \"title\": un titolo chiaro e coinvolgente (3-10 parole)\n- \"excerpt\": un paragrafo di riassunto in testo semplice (2-3 frasi, no markdown, nessuna formattazione)\n- \"slug\": uno slug URL conciso e SEO-friendly (minuscolo, solo trattini, nessun carattere speciale)\n\nRispondi in italiano. Restituisci solo l'oggetto JSON.", + "ai.postAnalysis.user": "Titolo: {title}\n\nContenuto:\n{content}", "task.embeddingIndex.name": "Indicizza i post per la similarità semantica", "task.embeddingIndex.loading": "Caricamento modello…", "task.embeddingIndex.indexing": "Indicizzazione: {indexed}/{total}", diff --git a/src/main/shared/pythonApiContractV1.ts b/src/main/shared/pythonApiContractV1.ts index d95d09a..3b6f8d1 100644 --- a/src/main/shared/pythonApiContractV1.ts +++ b/src/main/shared/pythonApiContractV1.ts @@ -191,6 +191,7 @@ const METHODS_V1: PythonApiMethodContractV1[] = [ method('chat.analyzeMediaImage', 'Analyze an image and generate title, alt text, and caption using AI.', [requiredString('mediaId'), optionalString('language')], 'ImageAnalysisResult'), method('chat.detectPostLanguage', 'Detect the language of a post from its title and content.', [requiredString('title'), requiredString('content')], '{ success: boolean; language?: string; error?: string }'), + method('chat.analyzePost', 'Analyze a post and generate suggested title, excerpt, and slug using AI.', [requiredString('postId'), optionalString('language')], 'PostAnalysisResult'), method('sync.checkAvailability', 'Check if git is available.', [], 'GitAvailability'), method('sync.getRepoState', 'Get repository state for active project.', [], 'RepoState'), @@ -425,6 +426,17 @@ const DATA_STRUCTURES_V1: PythonApiDataStructureContractV1[] = [ { name: 'error', type: 'string', required: false, description: 'Error message when analysis failed.' }, ], }, + { + name: 'PostAnalysisResult', + description: 'Result from AI post analysis containing suggested title, excerpt, and slug.', + fields: [ + { name: 'success', type: 'boolean', required: true, description: 'Whether the analysis succeeded.' }, + { name: 'title', type: 'string', required: false, description: 'Suggested post title.' }, + { name: 'excerpt', type: 'string', required: false, description: 'Suggested plain-text excerpt summarizing the post.' }, + { name: 'slug', type: 'string', required: false, description: 'Suggested URL-friendly slug.' }, + { name: 'error', type: 'string', required: false, description: 'Error message when analysis failed.' }, + ], + }, { name: 'SimilarPost', description: 'A post with its semantic similarity score relative to a reference post.', @@ -453,8 +465,8 @@ const DATA_STRUCTURES_V1: PythonApiDataStructureContractV1[] = [ ]; export const BDS_PYTHON_API_CONTRACT_V1: PythonApiContractV1 = { - version: '1.12.0', - generatedAt: '2026-03-05T00:00:00.000Z', + version: '1.13.0', + generatedAt: '2026-03-07T00:00:00.000Z', methods: METHODS_V1, dataStructures: DATA_STRUCTURES_V1, }; diff --git a/src/renderer/components/AISuggestionsModal/AISuggestionsModal.tsx b/src/renderer/components/AISuggestionsModal/AISuggestionsModal.tsx index ea53ec1..f44f1a9 100644 --- a/src/renderer/components/AISuggestionsModal/AISuggestionsModal.tsx +++ b/src/renderer/components/AISuggestionsModal/AISuggestionsModal.tsx @@ -2,6 +2,7 @@ import React, { useState, useEffect, useCallback } from 'react'; import { useI18n } from '../../i18n'; import './AISuggestionsModal.css'; +// Keep legacy types for backward-compatible re-export export interface AISuggestions { title?: string; alt?: string; @@ -14,52 +15,55 @@ export interface CurrentValues { caption: string; } -type SuggestionFieldKey = 'title' | 'alt' | 'caption'; - -interface SuggestionFieldConfig { - key: SuggestionFieldKey; +/** + * Generic field definition for the AI suggestions modal. + * Each field represents one suggestion the user can accept or reject. + */ +export interface SuggestionField { + key: string; label: string; + currentValue: string; + suggestedValue?: string; + disabled?: boolean; + warning?: string; } -const SUGGESTION_FIELDS: SuggestionFieldConfig[] = [ - { key: 'title', label: 'aiSuggestions.titleField' }, - { key: 'alt', label: 'aiSuggestions.altField' }, - { key: 'caption', label: 'aiSuggestions.captionField' }, -]; - interface AISuggestionsModalProps { isOpen: boolean; isLoading: boolean; - suggestions: AISuggestions | null; - currentValues: CurrentValues; + fields: SuggestionField[]; error?: string; - onConfirm: (values: Partial) => void; + modalTitle: string; + loadingText: string; + emptyText: string; + onConfirm: (values: Record) => void; onClose: () => void; } export const AISuggestionsModal: React.FC = ({ isOpen, isLoading, - suggestions, - currentValues, + fields, error, + modalTitle, + loadingText, + emptyText, onConfirm, onClose, }) => { const { t: tr } = useI18n(); - // Checkbox state - initialized based on whether current values are empty - const [useTitle, setUseTitle] = useState(false); - const [useAlt, setUseAlt] = useState(false); - const [useCaption, setUseCaption] = useState(false); + // Dynamic checkbox state keyed by field key + const [checked, setChecked] = useState>({}); - // Update checkbox state when suggestions arrive, based on whether current fields are empty + // Auto-check fields when suggestions arrive: + // checked only when there IS a suggestion AND current value is empty useEffect(() => { - if (suggestions) { - setUseTitle(suggestions.title ? !currentValues.title : false); - setUseAlt(suggestions.alt ? !currentValues.alt : false); - setUseCaption(suggestions.caption ? !currentValues.caption : false); + const initial: Record = {}; + for (const field of fields) { + initial[field.key] = !field.disabled && !!field.suggestedValue && !field.currentValue; } - }, [suggestions, currentValues]); + setChecked(initial); + }, [fields]); const handleBackdropClick = useCallback((e: React.MouseEvent) => { if (e.target === e.currentTarget && !isLoading) { @@ -68,68 +72,30 @@ export const AISuggestionsModal: React.FC = ({ }, [isLoading, onClose]); const handleConfirm = useCallback(() => { - const valuesToApply: Partial = {}; - if (useTitle && suggestions?.title) valuesToApply.title = suggestions.title; - if (useAlt && suggestions?.alt) valuesToApply.alt = suggestions.alt; - if (useCaption && suggestions?.caption) valuesToApply.caption = suggestions.caption; + const valuesToApply: Record = {}; + for (const field of fields) { + if (checked[field.key] && field.suggestedValue) { + valuesToApply[field.key] = field.suggestedValue; + } + } onConfirm(valuesToApply); - }, [useTitle, useAlt, useCaption, suggestions, onConfirm]); + }, [checked, fields, onConfirm]); + + const setFieldChecked = useCallback((key: string, value: boolean) => { + setChecked(prev => ({ ...prev, [key]: value })); + }, []); if (!isOpen) return null; - const hasAnySuggestion = suggestions && (suggestions.title || suggestions.alt || suggestions.caption); - const hasAnySelected = useTitle || useAlt || useCaption; - - const fieldSelection: Record void]> = { - title: [useTitle, setUseTitle], - alt: [useAlt, setUseAlt], - caption: [useCaption, setUseCaption], - }; - - const renderSuggestionField = (field: SuggestionFieldConfig) => { - if (!suggestions?.[field.key]) { - return null; - } - - const [isChecked, setChecked] = fieldSelection[field.key]; - const currentValue = currentValues[field.key]; - const suggestedValue = suggestions[field.key]; - - return ( -
- -
-
- {field.label} - {currentValue && ( - - {tr('aiSuggestions.hasExisting')} - - )} -
-
{suggestedValue}
- {currentValue && ( -
- {tr('aiSuggestions.current')}: {currentValue} -
- )} -
-
- ); - }; + const fieldsWithSuggestions = fields.filter(f => !!f.suggestedValue); + const hasAnySuggestion = fieldsWithSuggestions.length > 0; + const hasAnySelected = Object.values(checked).some(v => v); return (
-

{tr('aiSuggestions.title')}

+

{modalTitle}

{!isLoading && (
)} - {!isLoading && !error && !hasAnySuggestion && suggestions && ( + {!isLoading && !error && !hasAnySuggestion && fields.length > 0 && (
- {tr('aiSuggestions.empty')} + {emptyText}
)}
diff --git a/src/renderer/components/Editor/Editor.css b/src/renderer/components/Editor/Editor.css index 2954fe3..139c1c0 100644 --- a/src/renderer/components/Editor/Editor.css +++ b/src/renderer/components/Editor/Editor.css @@ -147,6 +147,12 @@ align-items: flex-start; } +.editor-excerpt-panel { + display: flex; + flex-direction: column; + gap: 8px; +} + .editor-meta { display: flex; flex-direction: column; diff --git a/src/renderer/components/Editor/Editor.tsx b/src/renderer/components/Editor/Editor.tsx index 1bcf5c9..3cf0d2b 100644 --- a/src/renderer/components/Editor/Editor.tsx +++ b/src/renderer/components/Editor/Editor.tsx @@ -24,7 +24,8 @@ import { TemplatesView } from '../TemplatesView/TemplatesView'; import { DuplicatesView } from '../DuplicatesView/DuplicatesView'; import { AutoSaveManager, getContrastColor, loadTagColorMap } from '../../utils'; import { InsertModal } from '../InsertModal'; -import { AISuggestionsModal, AISuggestions } from '../AISuggestionsModal/AISuggestionsModal'; +import { AISuggestionsModal } from '../AISuggestionsModal/AISuggestionsModal'; +import type { SuggestionField } from '../AISuggestionsModal/AISuggestionsModal'; import { openEntityTab } from '../../navigation/tabPolicy'; import { EditorRoute, resolveEditorRoute } from '../../navigation/editorRouting'; import { useEntityLoader, useSaveShortcut } from '../../navigation/useEntityEditor'; @@ -67,6 +68,7 @@ const autoSaveManager = new AutoSaveManager({ const update: Parameters[1] = {}; if ('title' in changes) update.title = changes.title as string; if ('content' in changes) update.content = changes.content as string; + if ('excerpt' in changes) update.excerpt = changes.excerpt as string; if ('tags' in changes) { const tagsStr = changes.tags as string; update.tags = tagsStr.split(',').map(t => t.trim()).filter(t => t.length > 0); @@ -193,6 +195,7 @@ export const PostEditor: React.FC = ({ postId }) => { const [title, setTitle] = useState(''); const [content, setContent] = useState(''); + const [excerpt, setExcerpt] = useState(''); const [author, setAuthor] = useState(''); const [tags, setTags] = useState([]); const [selectedCategories, setSelectedCategories] = useState(['article']); @@ -209,11 +212,21 @@ export const PostEditor: React.FC = ({ postId }) => { const [showPostSearch, setShowPostSearch] = useState(false); const [showMediaSearch, setShowMediaSearch] = useState(false); const [metadataExpanded, setMetadataExpanded] = useState(true); + const [excerptExpanded, setExcerptExpanded] = useState(false); const editorRef = useRef(null); // Token incremented to signal Monaco that it should re-read its defaultValue. // This is used instead of controlled `value` to avoid cursor-reset races. const [monacoResetToken, setMonacoResetToken] = useState(0); + // Quick actions state for AI post analysis + const [showPostQuickActions, setShowPostQuickActions] = useState(false); + const [projectLanguage, setProjectLanguage] = useState('en'); + const postQuickActionsRef = useRef(null); + const [showPostAISuggestionsModal, setShowPostAISuggestionsModal] = useState(false); + const [isAnalyzingPost, setIsAnalyzingPost] = useState(false); + const [postAISuggestionFields, setPostAISuggestionFields] = useState([]); + const [postAIError, setPostAIError] = useState(undefined); + const isDirty = checkIsDirty(postId); // Listen for auto-save events to keep local post state in sync @@ -325,6 +338,7 @@ export const PostEditor: React.FC = ({ postId }) => { if (post && !isInitialized) { setTitle(post.title); setContent(post.content); + setExcerpt(post.excerpt || ''); setAuthor(post.author || ''); setTags(post.tags); setSelectedCategories(post.categories.length > 0 ? post.categories : ['article']); @@ -349,10 +363,11 @@ export const PostEditor: React.FC = ({ postId }) => { // Short-circuit: check cheap comparisons first (content changes on every keystroke) const contentChanged = content !== post.content; const titleChanged = title !== post.title; + const excerptChanged = excerpt !== (post.excerpt || ''); const authorChanged = author !== (post.author || ''); const templateSlugChanged = templateSlug !== ((post as PostData & { templateSlug?: string }).templateSlug || ''); const languageChanged = postLanguage !== (post.language || ''); - const hasChanges = contentChanged || titleChanged || authorChanged || templateSlugChanged || languageChanged || + const hasChanges = contentChanged || titleChanged || excerptChanged || authorChanged || templateSlugChanged || languageChanged || JSON.stringify(tags.slice().sort()) !== JSON.stringify(post.tags.slice().sort()) || JSON.stringify(selectedCategories.slice().sort()) !== JSON.stringify(post.categories.slice().sort()); @@ -363,6 +378,7 @@ export const PostEditor: React.FC = ({ postId }) => { autoSaveManager.notifyChange(postId, { title, content, + excerpt, author, tags: tags.join(', '), categories: selectedCategories, @@ -372,7 +388,7 @@ export const PostEditor: React.FC = ({ postId }) => { } else { markClean(postId); } - }, [title, content, author, tags, selectedCategories, templateSlug, postLanguage, post, postId, isInitialized, isDirty, markDirty, markClean]); + }, [title, content, excerpt, author, tags, selectedCategories, templateSlug, postLanguage, post, postId, isInitialized, isDirty, markDirty, markClean]); // Handle editor mode change and persist preference const handleEditorModeChange = (mode: EditorMode) => { @@ -391,6 +407,7 @@ export const PostEditor: React.FC = ({ postId }) => { const updated = await window.electronAPI?.posts.update(postId, { title, content, + excerpt: excerpt || undefined, author: author || undefined, language: postLanguage || undefined, tags, @@ -434,6 +451,100 @@ export const PostEditor: React.FC = ({ postId }) => { setIsDetectingLanguage(false); } }, [title, content, isDetectingLanguage, tr]); + + // Load project language for AI post analysis + useEffect(() => { + window.electronAPI?.meta?.getProjectMetadata?.()?.then(metadata => { + if (metadata?.mainLanguage) { + setProjectLanguage(metadata.mainLanguage); + } + }); + }, []); + + // Close quick actions menu when clicking outside + useEffect(() => { + if (!showPostQuickActions) return; + const handleClickOutside = (e: MouseEvent) => { + if (postQuickActionsRef.current && !postQuickActionsRef.current.contains(e.target as Node)) { + setShowPostQuickActions(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [showPostQuickActions]); + + // Handle AI post analysis (title, excerpt, slug suggestions) + const handlePostAIAnalysis = useCallback(async () => { + if (!post || isAnalyzingPost) return; + + setShowPostQuickActions(false); + setShowPostAISuggestionsModal(true); + setIsAnalyzingPost(true); + setPostAISuggestionFields([]); + setPostAIError(undefined); + + try { + const result = await window.electronAPI?.chat.analyzePost(postId, projectLanguage); + + if (result?.success) { + const slugLocked = !!post.publishedAt; + setPostAISuggestionFields([ + { key: 'title', label: tr('aiSuggestions.titleField'), currentValue: title, suggestedValue: result.title }, + { key: 'excerpt', label: tr('aiSuggestions.excerptField'), currentValue: excerpt, suggestedValue: result.excerpt }, + { + key: 'slug', + label: tr('aiSuggestions.slugField'), + currentValue: post.slug, + suggestedValue: result.slug, + disabled: slugLocked, + warning: slugLocked ? tr('aiSuggestions.slugLockedWarning') : undefined, + }, + ]); + } else { + setPostAIError(result?.error || tr('editor.post.error.analyzePost')); + } + } catch (error) { + console.error('Failed to analyze post:', error); + setPostAIError((error as Error).message || tr('editor.post.error.analyzePost')); + } finally { + setIsAnalyzingPost(false); + } + }, [post, postId, projectLanguage, isAnalyzingPost, title, excerpt, tr]); + + // Handle applying AI post suggestions + const handleApplyPostAISuggestions = useCallback(async (values: Record) => { + setShowPostAISuggestionsModal(false); + if (Object.keys(values).length === 0) return; + + try { + const updatePayload: Record = {}; + if (values.title) updatePayload.title = values.title; + if (values.excerpt) updatePayload.excerpt = values.excerpt; + if (values.slug && !post?.publishedAt) updatePayload.slug = values.slug; + + const updated = await window.electronAPI?.posts.update(postId, updatePayload as Parameters[1]); + if (updated) { + updatePost(postId, updated as Partial); + setPost(prev => prev ? { ...prev, ...updated as Partial } : prev); + // Update local state for fields that changed + if (values.title) setTitle(values.title); + if (values.excerpt) setExcerpt(values.excerpt); + markDirty(postId); + showToast.success(tr('editor.post.toast.aiApplied')); + } + } catch (error) { + console.error('Failed to apply AI suggestions:', error); + showToast.error(tr('editor.post.error.applyFailed')); + } + }, [post, postId, updatePost, markDirty, tr]); + + // Close AI post suggestions modal + const handleClosePostAISuggestionsModal = useCallback(() => { + setShowPostAISuggestionsModal(false); + setPostAISuggestionFields([]); + setPostAIError(undefined); + }, []); + const handlePublish = async () => { await handleSave(); try { @@ -743,9 +854,34 @@ export const PostEditor: React.FC = ({ postId }) => { {post.status} {isSaving && {tr('editor.saving')}} +
+ + {showPostQuickActions && ( +
+ +
+ )} +
{post.status === 'draft' && ( -
)} + + + {excerptExpanded && ( +
+
+ +