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 <hugoms@me.com>
This commit is contained in:
Georg Bauer
2026-03-07 09:54:13 +01:00
committed by GitHub
parent 72b21ddba7
commit 9871cb827f
30 changed files with 1270 additions and 245 deletions

52
API.md
View File

@@ -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.

View File

@@ -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.

102
TODO.md
View File

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

View File

@@ -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, ']]]]><![CDATA[>');
}
@@ -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) => `<category>${escapeXml(category)}</category>`),
...(post.tags || []).map((tag) => `<category>${escapeXml(tag)}</category>`),
@@ -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) => `<category term="${escapeXml(tag)}" />`),
...(post.categories || []).map((category) => `<category term="${escapeXml(category)}" />`),

View File

@@ -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,8 +499,7 @@ export class PostEngine extends EventEmitter {
const checksum = this.calculateChecksum(updated.content);
// All updates go to DB only — no file writes
await db.update(posts)
.set({
const dbSet: Record<string, unknown> = {
title: updated.title,
slug: updated.slug,
excerpt: updated.excerpt,
@@ -492,7 +512,12 @@ export class PostEngine extends EventEmitter {
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(dbSet)
.where(eq(posts.id, id));
// Update FTS index

View File

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

View File

@@ -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<string, unknown> }) => {

View File

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

View File

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

View File

@@ -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}",

View File

@@ -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}",

View File

@@ -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}",

View File

@@ -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}",

View File

@@ -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}",

View File

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

View File

@@ -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<AISuggestions>) => void;
modalTitle: string;
loadingText: string;
emptyText: string;
onConfirm: (values: Record<string, string>) => void;
onClose: () => void;
}
export const AISuggestionsModal: React.FC<AISuggestionsModalProps> = ({
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<Record<string, boolean>>({});
// 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<string, boolean> = {};
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<AISuggestionsModalProps> = ({
}, [isLoading, onClose]);
const handleConfirm = useCallback(() => {
const valuesToApply: Partial<AISuggestions> = {};
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<string, string> = {};
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<SuggestionFieldKey, [boolean, (checked: boolean) => 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 (
<div key={field.key} className="ai-suggestion-item">
<label className="ai-suggestion-checkbox">
<input
type="checkbox"
checked={isChecked}
onChange={(e) => setChecked(e.target.checked)}
/>
<span className="checkmark"></span>
</label>
<div className="ai-suggestion-content">
<div className="ai-suggestion-label">
{field.label}
{currentValue && (
<span className="ai-suggestion-has-value" title={tr('aiSuggestions.hasExisting')}>
{tr('aiSuggestions.hasExisting')}
</span>
)}
</div>
<div className="ai-suggestion-value">{suggestedValue}</div>
{currentValue && (
<div className="ai-suggestion-current">
{tr('aiSuggestions.current')}: <em>{currentValue}</em>
</div>
)}
</div>
</div>
);
};
const fieldsWithSuggestions = fields.filter(f => !!f.suggestedValue);
const hasAnySuggestion = fieldsWithSuggestions.length > 0;
const hasAnySelected = Object.values(checked).some(v => v);
return (
<div className="ai-suggestions-modal-backdrop" onClick={handleBackdropClick}>
<div className="ai-suggestions-modal">
<div className="ai-suggestions-modal-header">
<h2>{tr('aiSuggestions.title')}</h2>
<h2>{modalTitle}</h2>
{!isLoading && (
<button className="ai-suggestions-modal-close" onClick={onClose} title={tr('aiSuggestions.close')}>
@@ -141,7 +107,7 @@ export const AISuggestionsModal: React.FC<AISuggestionsModalProps> = ({
{isLoading && (
<div className="ai-suggestions-loading">
<div className="ai-suggestions-spinner"></div>
<p>{tr('aiSuggestions.analyzing')}</p>
<p>{loadingText}</p>
</div>
)}
@@ -157,13 +123,44 @@ export const AISuggestionsModal: React.FC<AISuggestionsModalProps> = ({
<p className="ai-suggestions-intro">
{tr('aiSuggestions.intro')}
</p>
{SUGGESTION_FIELDS.map((field) => renderSuggestionField({ ...field, label: tr(field.label) }))}
{fieldsWithSuggestions.map((field) => (
<div key={field.key} className="ai-suggestion-item">
<label className="ai-suggestion-checkbox">
<input
type="checkbox"
checked={!!checked[field.key]}
disabled={field.disabled}
onChange={(e) => setFieldChecked(field.key, e.target.checked)}
/>
<span className="checkmark"></span>
</label>
<div className="ai-suggestion-content">
<div className="ai-suggestion-label">
{field.label}
{field.currentValue && (
<span className="ai-suggestion-has-value" title={tr('aiSuggestions.hasExisting')}>
{tr('aiSuggestions.hasExisting')}
</span>
)}
</div>
<div className="ai-suggestion-value">{field.suggestedValue}</div>
{field.warning && (
<div className="ai-suggestion-current">{field.warning}</div>
)}
{field.currentValue && (
<div className="ai-suggestion-current">
{tr('aiSuggestions.current')}: <em>{field.currentValue}</em>
</div>
)}
</div>
</div>
))}
</div>
)}
{!isLoading && !error && !hasAnySuggestion && suggestions && (
{!isLoading && !error && !hasAnySuggestion && fields.length > 0 && (
<div className="ai-suggestions-empty">
{tr('aiSuggestions.empty')}
{emptyText}
</div>
)}
</div>

View File

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

View File

@@ -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<typeof window.electronAPI.posts.update>[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<PostEditorProps> = ({ postId }) => {
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
const [excerpt, setExcerpt] = useState('');
const [author, setAuthor] = useState('');
const [tags, setTags] = useState<string[]>([]);
const [selectedCategories, setSelectedCategories] = useState<string[]>(['article']);
@@ -209,11 +212,21 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
const [showPostSearch, setShowPostSearch] = useState(false);
const [showMediaSearch, setShowMediaSearch] = useState(false);
const [metadataExpanded, setMetadataExpanded] = useState(true);
const [excerptExpanded, setExcerptExpanded] = useState(false);
const editorRef = useRef<unknown>(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<HTMLDivElement>(null);
const [showPostAISuggestionsModal, setShowPostAISuggestionsModal] = useState(false);
const [isAnalyzingPost, setIsAnalyzingPost] = useState(false);
const [postAISuggestionFields, setPostAISuggestionFields] = useState<SuggestionField[]>([]);
const [postAIError, setPostAIError] = useState<string | undefined>(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<PostEditorProps> = ({ 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<PostEditorProps> = ({ 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<PostEditorProps> = ({ postId }) => {
autoSaveManager.notifyChange(postId, {
title,
content,
excerpt,
author,
tags: tags.join(', '),
categories: selectedCategories,
@@ -372,7 +388,7 @@ export const PostEditor: React.FC<PostEditorProps> = ({ 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<PostEditorProps> = ({ 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<PostEditorProps> = ({ 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<string, string>) => {
setShowPostAISuggestionsModal(false);
if (Object.keys(values).length === 0) return;
try {
const updatePayload: Record<string, unknown> = {};
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<typeof window.electronAPI.posts.update>[1]);
if (updated) {
updatePost(postId, updated as Partial<PostData>);
setPost(prev => prev ? { ...prev, ...updated as Partial<PostData> } : 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,6 +854,31 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
{post.status}
</span>
{isSaving && <span className="auto-save-indicator">{tr('editor.saving')}</span>}
<div className="quick-actions-wrapper" ref={postQuickActionsRef}>
<button
className="secondary quick-actions-btn"
onClick={() => setShowPostQuickActions(!showPostQuickActions)}
disabled={isAnalyzingPost}
title={tr('editor.post.quickActions.title')}
>
{isAnalyzingPost ? tr('editor.post.quickActions.analyzing') : tr('editor.post.quickActions.button')}
</button>
{showPostQuickActions && (
<div className="quick-actions-menu">
<button
className="quick-action-item"
onClick={handlePostAIAnalysis}
disabled={isAnalyzingPost || !content}
>
<span className="quick-action-icon">🤖</span>
<span className="quick-action-text">
<strong>{tr('editor.post.quickActions.aiTitle')}</strong>
<small>{tr('editor.post.quickActions.aiDescription')}</small>
</span>
</button>
</div>
)}
</div>
{post.status === 'draft' && (
<button
onClick={handlePublish}
@@ -881,6 +1017,27 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
</div>
)}
<button
className={`metadata-toggle ${excerptExpanded ? 'expanded' : ''}`}
onClick={() => setExcerptExpanded(v => !v)}
>
<span className="metadata-toggle-chevron">{excerptExpanded ? '▼' : '▶'}</span>
<span>{tr('editor.excerpt.toggle')}</span>
</button>
{excerptExpanded && (
<div className="editor-excerpt-panel">
<div className="editor-field">
<label>{tr('editor.field.excerpt')}</label>
<textarea
value={excerpt}
onChange={(e) => setExcerpt(e.target.value)}
placeholder={tr('editor.placeholder.excerpt')}
rows={4}
/>
</div>
</div>
)}
<div className="editor-body">
<div className="editor-toolbar">
<div className="editor-toolbar-left">
@@ -1038,6 +1195,19 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
onClose={() => setShowMediaSearch(false)}
/>
)}
{/* AI Post Suggestions Modal */}
<AISuggestionsModal
isOpen={showPostAISuggestionsModal}
isLoading={isAnalyzingPost}
fields={postAISuggestionFields}
modalTitle={tr('aiSuggestions.postTitle')}
loadingText={tr('aiSuggestions.analyzingPost')}
emptyText={tr('aiSuggestions.postEmpty')}
error={postAIError}
onConfirm={handleApplyPostAISuggestions}
onClose={handleClosePostAISuggestionsModal}
/>
</div>
);
};
@@ -1067,7 +1237,7 @@ const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
// AI suggestions modal state
const [showAISuggestionsModal, setShowAISuggestionsModal] = useState(false);
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [aiSuggestions, setAISuggestions] = useState<AISuggestions | null>(null);
const [aiSuggestionFields, setAISuggestionFields] = useState<Array<{ key: string; label: string; currentValue: string; suggestedValue?: string }>>([]);
const [aiError, setAIError] = useState<string | undefined>(undefined);
// Load project language setting
@@ -1100,18 +1270,18 @@ const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
setShowQuickActions(false);
setShowAISuggestionsModal(true);
setIsAnalyzing(true);
setAISuggestions(null);
setAISuggestionFields([]);
setAIError(undefined);
try {
const result = await window.electronAPI?.chat.analyzeMediaImage(item.id, projectLanguage);
if (result?.success) {
setAISuggestions({
title: result.title,
alt: result.alt,
caption: result.caption,
});
setAISuggestionFields([
{ key: 'title', label: tr('aiSuggestions.titleField'), currentValue: title, suggestedValue: result.title },
{ key: 'alt', label: tr('aiSuggestions.altField'), currentValue: alt, suggestedValue: result.alt },
{ key: 'caption', label: tr('aiSuggestions.captionField'), currentValue: caption, suggestedValue: result.caption },
]);
} else {
setAIError(result?.error || tr('editor.media.error.analyzeImage'));
}
@@ -1124,7 +1294,7 @@ const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
};
// Handle applying AI suggestions
const handleApplyAISuggestions = (values: Partial<AISuggestions>) => {
const handleApplyAISuggestions = (values: Record<string, string>) => {
if (values.title) setTitle(values.title);
if (values.alt) setAlt(values.alt);
if (values.caption) setCaption(values.caption);
@@ -1551,8 +1721,10 @@ const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
<AISuggestionsModal
isOpen={showAISuggestionsModal}
isLoading={isAnalyzing}
suggestions={aiSuggestions}
currentValues={{ title, alt, caption }}
fields={aiSuggestionFields}
modalTitle={tr('aiSuggestions.title')}
loadingText={tr('aiSuggestions.analyzing')}
emptyText={tr('aiSuggestions.empty')}
error={aiError}
onConfirm={handleApplyAISuggestions}
onClose={handleCloseAISuggestionsModal}

View File

@@ -229,6 +229,12 @@
"aiSuggestions.empty": "Für dieses Bild wurden keine Vorschläge erstellt.",
"aiSuggestions.wait": "Bitte warten...",
"aiSuggestions.applySelected": "Ausgewählte übernehmen",
"aiSuggestions.postTitle": "KI-Beitragsanalyse",
"aiSuggestions.analyzingPost": "Beitrag wird analysiert…",
"aiSuggestions.excerptField": "Zusammenfassung / Auszug",
"aiSuggestions.slugField": "Slug",
"aiSuggestions.slugLockedWarning": "Der Slug kann nur vor der ersten Veröffentlichung geändert werden.",
"aiSuggestions.postEmpty": "Für diesen Beitrag wurden keine Vorschläge generiert.",
"insert.title.link": "Link einfügen",
"insert.title.image": "Bild einfügen",
"insert.tab.linkInternal": "Mit Beitrag verlinken",
@@ -545,6 +551,7 @@
"editor.field.tags": "Schlagwörter",
"editor.field.author": "Autor",
"editor.field.slug": "Slug",
"editor.field.excerpt": "Auszug",
"editor.field.categories": "Kategorien",
"editor.field.content": "Inhalt",
"editor.field.template": "Vorlage",
@@ -558,6 +565,7 @@
"language.es": "Spanisch",
"editor.placeholder.tags": "Tags hinzufügen...",
"editor.placeholder.author": "Autorenname",
"editor.placeholder.excerpt": "Optionale Zusammenfassung für Listen und Vorschauen",
"editor.placeholder.categories": "Kategorien hinzufügen...",
"editor.placeholder.startWriting": "Mit dem Schreiben beginnen...",
"editor.mode.visual": "Visuell",
@@ -570,6 +578,7 @@
"editor.previewFrameTitle": "Beitragsvorschau",
"editor.previewLoading": "Vorschau wird geladen...",
"editor.metadata.toggle": "Metadaten",
"editor.excerpt.toggle": "Auszug",
"editor.footer.created": "Erstellt",
"editor.footer.updated": "Aktualisiert",
"editor.footer.published": "Veröffentlicht",
@@ -918,10 +927,18 @@
"editor.media.quickActions.button": "⚡ Schnellaktionen",
"editor.media.quickActions.aiTitle": "KI: Titel, Alt-Text und Bildunterschrift erzeugen",
"editor.media.quickActions.aiDescription": "Analysiert das Bild und schlägt Metadaten vor",
"editor.post.quickActions.title": "Schnellaktionen",
"editor.post.quickActions.analyzing": "⏳ Wird analysiert…",
"editor.post.quickActions.button": "⚡ Schnellaktionen",
"editor.post.quickActions.aiTitle": "KI: Titel, Zusammenfassung & Slug vorschlagen",
"editor.post.quickActions.aiDescription": "Analysiert den Inhalt und schlägt Metadaten vor",
"editor.post.quickActions.detectLanguageDescription": "Sprache mit KI erkennen",
"editor.post.quickActions.detecting": "Erkennung…",
"editor.post.quickActions.languageDetected": "Sprache erkannt",
"editor.post.quickActions.detectLanguageFailed": "Spracherkennung fehlgeschlagen",
"editor.post.error.analyzePost": "Beitragsanalyse fehlgeschlagen",
"editor.post.error.applyFailed": "KI-Vorschläge konnten nicht angewendet werden",
"editor.post.toast.aiApplied": "KI-Vorschläge angewendet",
"editor.media.replaceFile": "Datei ersetzen",
"editor.media.field.fileName": "Dateiname",
"editor.media.field.type": "Typ",

View File

@@ -229,6 +229,12 @@
"aiSuggestions.empty": "No suggestions were generated for this image.",
"aiSuggestions.wait": "Please wait...",
"aiSuggestions.applySelected": "Apply Selected",
"aiSuggestions.postTitle": "AI Post Analysis",
"aiSuggestions.analyzingPost": "Analyzing post…",
"aiSuggestions.excerptField": "Summary / Excerpt",
"aiSuggestions.slugField": "Slug",
"aiSuggestions.slugLockedWarning": "Slug can only be changed before the first publish.",
"aiSuggestions.postEmpty": "No suggestions were generated for this post.",
"insert.title.link": "Insert Link",
"insert.title.image": "Insert Image",
"insert.tab.linkInternal": "Link to Post",
@@ -545,6 +551,7 @@
"editor.field.tags": "Tags",
"editor.field.author": "Author",
"editor.field.slug": "Slug",
"editor.field.excerpt": "Excerpt",
"editor.field.categories": "Categories",
"editor.field.content": "Content",
"editor.field.template": "Template",
@@ -558,6 +565,7 @@
"language.es": "Spanish",
"editor.placeholder.tags": "Add tags...",
"editor.placeholder.author": "Author name",
"editor.placeholder.excerpt": "Optional summary for lists and previews",
"editor.placeholder.categories": "Add categories...",
"editor.placeholder.startWriting": "Start writing...",
"editor.mode.visual": "Visual",
@@ -570,6 +578,7 @@
"editor.previewFrameTitle": "Post preview",
"editor.previewLoading": "Loading preview...",
"editor.metadata.toggle": "Metadata",
"editor.excerpt.toggle": "Excerpt",
"editor.footer.created": "Created",
"editor.footer.updated": "Updated",
"editor.footer.published": "Published",
@@ -918,10 +927,18 @@
"editor.media.quickActions.button": "⚡ Quick Actions",
"editor.media.quickActions.aiTitle": "AI: Generate Title, Alt & Caption",
"editor.media.quickActions.aiDescription": "Analyzes the image to suggest metadata",
"editor.post.quickActions.title": "Quick Actions",
"editor.post.quickActions.analyzing": "⏳ Analyzing…",
"editor.post.quickActions.button": "⚡ Quick Actions",
"editor.post.quickActions.aiTitle": "AI: Suggest Title, Summary & Slug",
"editor.post.quickActions.aiDescription": "Analyzes post content to suggest metadata",
"editor.post.quickActions.detectLanguageDescription": "Detect language using AI",
"editor.post.quickActions.detecting": "Detecting…",
"editor.post.quickActions.languageDetected": "Language detected",
"editor.post.quickActions.detectLanguageFailed": "Language detection failed",
"editor.post.error.analyzePost": "Failed to analyze post",
"editor.post.error.applyFailed": "Failed to apply AI suggestions",
"editor.post.toast.aiApplied": "AI suggestions applied",
"editor.media.replaceFile": "Replace File",
"editor.media.field.fileName": "File Name",
"editor.media.field.type": "Type",

View File

@@ -229,6 +229,12 @@
"aiSuggestions.empty": "No se generaron sugerencias para esta imagen.",
"aiSuggestions.wait": "Por favor espera...",
"aiSuggestions.applySelected": "Aplicar seleccionados",
"aiSuggestions.postTitle": "Análisis IA del artículo",
"aiSuggestions.analyzingPost": "Analizando artículo…",
"aiSuggestions.excerptField": "Resumen / Extracto",
"aiSuggestions.slugField": "Slug",
"aiSuggestions.slugLockedWarning": "El slug solo puede cambiarse antes de la primera publicación.",
"aiSuggestions.postEmpty": "No se generaron sugerencias para este artículo.",
"insert.title.link": "Insertar enlace",
"insert.title.image": "Insertar imagen",
"insert.tab.linkInternal": "Enlazar a entrada",
@@ -545,6 +551,7 @@
"editor.field.tags": "Etiquetas",
"editor.field.author": "Autor",
"editor.field.slug": "Slug",
"editor.field.excerpt": "Extracto",
"editor.field.categories": "Categorías",
"editor.field.content": "Contenido",
"editor.field.template": "Plantilla",
@@ -558,6 +565,7 @@
"language.es": "Español",
"editor.placeholder.tags": "Agregar etiquetas...",
"editor.placeholder.author": "Nombre del autor",
"editor.placeholder.excerpt": "Resumen opcional para listas y vistas previas",
"editor.placeholder.categories": "Agregar categorías...",
"editor.placeholder.startWriting": "Empieza a escribir...",
"editor.mode.visual": "Visual",
@@ -570,6 +578,7 @@
"editor.previewFrameTitle": "Vista previa de la entrada",
"editor.previewLoading": "Cargando vista previa...",
"editor.metadata.toggle": "Metadatos",
"editor.excerpt.toggle": "Extracto",
"editor.footer.created": "Creado",
"editor.footer.updated": "Actualizado",
"editor.footer.published": "Publicado",
@@ -918,10 +927,18 @@
"editor.media.quickActions.button": "✨ Analizar con IA",
"editor.media.quickActions.aiTitle": "Título sugerido por IA",
"editor.media.quickActions.aiDescription": "Genera automáticamente título, texto alternativo y pie de foto.",
"editor.post.quickActions.title": "Acciones rápidas",
"editor.post.quickActions.analyzing": "⏳ Analizando…",
"editor.post.quickActions.button": "⚡ Acciones rápidas",
"editor.post.quickActions.aiTitle": "IA: Sugerir título, resumen y slug",
"editor.post.quickActions.aiDescription": "Analiza el contenido para sugerir metadatos",
"editor.post.quickActions.detectLanguageDescription": "Detectar idioma con IA",
"editor.post.quickActions.detecting": "Detectando…",
"editor.post.quickActions.languageDetected": "Idioma detectado",
"editor.post.quickActions.detectLanguageFailed": "Error al detectar el idioma",
"editor.post.error.analyzePost": "Error al analizar el artículo",
"editor.post.error.applyFailed": "No se pudieron aplicar las sugerencias de IA",
"editor.post.toast.aiApplied": "Sugerencias de IA aplicadas",
"editor.media.replaceFile": "Reemplazar archivo",
"editor.media.field.fileName": "Nombre de archivo",
"editor.media.field.type": "Tipo",

View File

@@ -229,6 +229,12 @@
"aiSuggestions.empty": "Aucune suggestion na été générée pour cette image.",
"aiSuggestions.wait": "Veuillez patienter...",
"aiSuggestions.applySelected": "Appliquer la sélection",
"aiSuggestions.postTitle": "Analyse IA de l'article",
"aiSuggestions.analyzingPost": "Analyse de l'article…",
"aiSuggestions.excerptField": "Résumé / Extrait",
"aiSuggestions.slugField": "Slug",
"aiSuggestions.slugLockedWarning": "Le slug ne peut être modifié qu'avant la première publication.",
"aiSuggestions.postEmpty": "Aucune suggestion n'a été générée pour cet article.",
"insert.title.link": "Insérer un lien",
"insert.title.image": "Insérer une image",
"insert.tab.linkInternal": "Lier à un article",
@@ -545,6 +551,7 @@
"editor.field.tags": "Étiquettes",
"editor.field.author": "Auteur",
"editor.field.slug": "Slug",
"editor.field.excerpt": "Extrait",
"editor.field.categories": "Catégories",
"editor.field.content": "Contenu",
"editor.field.template": "Modèle",
@@ -558,6 +565,7 @@
"language.es": "Espagnol",
"editor.placeholder.tags": "Ajouter des étiquettes...",
"editor.placeholder.author": "Nom de lauteur",
"editor.placeholder.excerpt": "Résumé facultatif pour les listes et aperçus",
"editor.placeholder.categories": "Ajouter des catégories...",
"editor.placeholder.startWriting": "Commencez à écrire...",
"editor.mode.visual": "Visuel",
@@ -570,6 +578,7 @@
"editor.previewFrameTitle": "Aperçu de larticle",
"editor.previewLoading": "Chargement de l'aperçu...",
"editor.metadata.toggle": "Métadonnées",
"editor.excerpt.toggle": "Extrait",
"editor.footer.created": "Créé",
"editor.footer.updated": "Mis à jour",
"editor.footer.published": "Publié",
@@ -918,10 +927,18 @@
"editor.media.quickActions.button": "✨ Analyser avec lIA",
"editor.media.quickActions.aiTitle": "Titre suggéré par lIA",
"editor.media.quickActions.aiDescription": "Générez automatiquement un titre, un texte alternatif et une légende.",
"editor.post.quickActions.title": "Actions rapides",
"editor.post.quickActions.analyzing": "⏳ Analyse…",
"editor.post.quickActions.button": "⚡ Actions rapides",
"editor.post.quickActions.aiTitle": "IA : Suggérer titre, résumé et slug",
"editor.post.quickActions.aiDescription": "Analyse le contenu pour suggérer des métadonnées",
"editor.post.quickActions.detectLanguageDescription": "Détecter la langue avec l'IA",
"editor.post.quickActions.detecting": "Détection…",
"editor.post.quickActions.languageDetected": "Langue détectée",
"editor.post.quickActions.detectLanguageFailed": "Échec de la détection de la langue",
"editor.post.error.analyzePost": "Échec de l'analyse de l'article",
"editor.post.error.applyFailed": "Impossible d'appliquer les suggestions IA",
"editor.post.toast.aiApplied": "Suggestions IA appliquées",
"editor.media.replaceFile": "Remplacer le fichier",
"editor.media.field.fileName": "Nom du fichier",
"editor.media.field.type": "Type",

View File

@@ -229,6 +229,12 @@
"aiSuggestions.empty": "Nessun suggerimento è stato generato per questa immagine.",
"aiSuggestions.wait": "Attendere...",
"aiSuggestions.applySelected": "Applica selezionati",
"aiSuggestions.postTitle": "Analisi IA dell'articolo",
"aiSuggestions.analyzingPost": "Analisi dell'articolo…",
"aiSuggestions.excerptField": "Riassunto / Estratto",
"aiSuggestions.slugField": "Slug",
"aiSuggestions.slugLockedWarning": "Lo slug può essere modificato solo prima della prima pubblicazione.",
"aiSuggestions.postEmpty": "Nessun suggerimento generato per questo articolo.",
"insert.title.link": "Inserisci link",
"insert.title.image": "Inserisci immagine",
"insert.tab.linkInternal": "Collega al post",
@@ -545,6 +551,7 @@
"editor.field.tags": "Tag",
"editor.field.author": "Autore",
"editor.field.slug": "Slug",
"editor.field.excerpt": "Estratto",
"editor.field.categories": "Categorie",
"editor.field.content": "Contenuto",
"editor.field.template": "Modello",
@@ -558,6 +565,7 @@
"language.es": "Spagnolo",
"editor.placeholder.tags": "Aggiungi tag...",
"editor.placeholder.author": "Nome autore",
"editor.placeholder.excerpt": "Riassunto facoltativo per elenchi e anteprime",
"editor.placeholder.categories": "Aggiungi categorie...",
"editor.placeholder.startWriting": "Inizia a scrivere...",
"editor.mode.visual": "Visuale",
@@ -570,6 +578,7 @@
"editor.previewFrameTitle": "Anteprima post",
"editor.previewLoading": "Caricamento anteprima...",
"editor.metadata.toggle": "Metadati",
"editor.excerpt.toggle": "Estratto",
"editor.footer.created": "Creato",
"editor.footer.updated": "Aggiornato",
"editor.footer.published": "Pubblicato",
@@ -918,10 +927,18 @@
"editor.media.quickActions.button": "✨ Analizza con IA",
"editor.media.quickActions.aiTitle": "Titolo suggerito dallIA",
"editor.media.quickActions.aiDescription": "Genera automaticamente titolo, testo alternativo e didascalia.",
"editor.post.quickActions.title": "Azioni rapide",
"editor.post.quickActions.analyzing": "⏳ Analisi…",
"editor.post.quickActions.button": "⚡ Azioni rapide",
"editor.post.quickActions.aiTitle": "IA: Suggerisci titolo, riassunto e slug",
"editor.post.quickActions.aiDescription": "Analizza il contenuto per suggerire i metadati",
"editor.post.quickActions.detectLanguageDescription": "Rileva la lingua con l'IA",
"editor.post.quickActions.detecting": "Rilevamento…",
"editor.post.quickActions.languageDetected": "Lingua rilevata",
"editor.post.quickActions.detectLanguageFailed": "Rilevamento lingua non riuscito",
"editor.post.error.analyzePost": "Analisi dell'articolo fallita",
"editor.post.error.applyFailed": "Impossibile applicare i suggerimenti IA",
"editor.post.toast.aiApplied": "Suggerimenti IA applicati",
"editor.media.replaceFile": "Sostituisci file",
"editor.media.field.fileName": "Nome file",
"editor.media.field.type": "Tipo",

View File

@@ -183,4 +183,56 @@ describe('GenerationSitemapFeedService', () => {
expect(result.atomXml).toContain('xml:lang="en"');
expect(result.atomXml).toContain('xml:lang="de"');
});
it('uses excerpt instead of full body in feed entry content when excerpt is available', () => {
const publishedPosts = [
makePost({
id: '1',
slug: 'excerpt-post',
title: 'Excerpt Post',
excerpt: 'Short feed summary.',
content: '# Excerpt Post\n\nVery long body that should not appear in feed content.',
}),
];
const result = buildSitemapAndFeeds({
baseUrl: 'https://example.com',
projectName: 'Test Blog',
maxPostsPerPage: 10,
publishedPosts,
publishedListPosts: publishedPosts,
postIndex: buildIndex(publishedPosts),
includeFeeds: true,
});
expect(result.rssXml).toContain('<content:encoded><![CDATA[<p>Short feed summary.</p>]]></content:encoded>');
expect(result.rssXml).not.toContain('Very long body that should not appear in feed content.');
expect(result.atomXml).toContain('<content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><p>Short feed summary.</p></div></content>');
expect(result.atomXml).not.toContain('Very long body that should not appear in feed content.');
});
it('falls back to the post body in feed entry content when excerpt is missing', () => {
const publishedPosts = [
makePost({
id: '1',
slug: 'body-post',
title: 'Body Post',
content: '# Body Post\n\nBody paragraph used in feed content.',
}),
];
const result = buildSitemapAndFeeds({
baseUrl: 'https://example.com',
projectName: 'Test Blog',
maxPostsPerPage: 10,
publishedPosts,
publishedListPosts: publishedPosts,
postIndex: buildIndex(publishedPosts),
includeFeeds: true,
});
expect(result.rssXml).toContain('Body paragraph used in feed content.');
expect(result.atomXml).toContain('Body paragraph used in feed content.');
});
});

View File

@@ -1028,6 +1028,40 @@ Original content`);
expect(result?.slug).toBe('new-title');
});
it('should honor explicit slug when title and slug both change on a never-published draft', async () => {
const created = await postEngine.createPost({ title: 'Original Title' });
vi.mocked(mockLocalDb.select).mockImplementation(() => {
const chain = createSelectChain();
chain.where = vi.fn().mockReturnValue({
...chain,
get: vi.fn().mockResolvedValue({
id: created.id,
projectId: created.projectId,
title: created.title,
slug: created.slug,
status: 'draft',
content: created.content || '',
filePath: '',
tags: '[]',
categories: '[]',
createdAt: created.createdAt,
updatedAt: created.updatedAt,
publishedAt: null,
}),
});
return chain;
});
const result = await postEngine.updatePost(created.id, {
title: 'New Title',
slug: 'custom-ai-slug',
});
expect(result).not.toBeNull();
expect(result?.slug).toBe('custom-ai-slug');
});
it('should NOT auto-update slug when title changes on a previously published post', async () => {
const created = await postEngine.createPost({ title: 'Published Post' });
@@ -1059,6 +1093,37 @@ Original content`);
expect(result?.slug).toBe('published-post'); // slug preserved
});
it('should ignore explicit slug changes on a previously published post', async () => {
const created = await postEngine.createPost({ title: 'Published Post' });
vi.mocked(mockLocalDb.select).mockImplementation(() => {
const chain = createSelectChain();
chain.where = vi.fn().mockReturnValue({
...chain,
get: vi.fn().mockResolvedValue({
id: created.id,
projectId: created.projectId,
title: created.title,
slug: created.slug,
status: 'draft',
content: created.content || '',
filePath: '',
tags: '[]',
categories: '[]',
createdAt: created.createdAt,
updatedAt: created.updatedAt,
publishedAt: new Date('2025-01-01'),
}),
});
return chain;
});
const result = await postEngine.updatePost(created.id, { slug: 'new-slug' });
expect(result).not.toBeNull();
expect(result?.slug).toBe('published-post');
});
it('should allow empty title and use untitled as slug base', async () => {
const created = await postEngine.createPost({ title: '' });
expect(created.title).toBe('');

View File

@@ -0,0 +1,184 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock the AI SDK generateText before importing OneShotTasks
vi.mock('ai', () => ({
generateText: vi.fn(),
}));
// Mock i18n
vi.mock('../../../src/main/shared/i18n', () => ({
resolveSupportedRenderLanguage: vi.fn((lang: string) => lang),
translateRender: vi.fn((_lang: string, key: string) => {
const prompts: Record<string, string> = {
'ai.postAnalysis.system': 'You are a blog editor assistant. Analyze the blog post and suggest improvements. Return JSON with "title", "excerpt", "slug". Respond in en.',
'ai.postAnalysis.user': 'Analyze this blog post.',
};
return prompts[key] || key;
}),
}));
import { OneShotTasks, type PostAnalysisResult } from '../../../src/main/engine/ai/tasks';
import { generateText } from 'ai';
const mockGenerateText = vi.mocked(generateText);
function createMockDeps() {
const chatEngine = {
getSetting: vi.fn().mockResolvedValue(null),
} as any;
const providers = {
detectModelProvider: vi.fn().mockReturnValue('opencode'),
isProviderKeySet: vi.fn().mockReturnValue(true),
getOpencodeKey: vi.fn().mockReturnValue('test-key'),
getMistralKey: vi.fn().mockReturnValue(null),
resolveModel: vi.fn().mockReturnValue('mock-model'),
isOfflineMode: vi.fn().mockReturnValue(false),
isOllamaModel: vi.fn().mockReturnValue(false),
isLmstudioModel: vi.fn().mockReturnValue(false),
getFirstKnownLocalModelId: vi.fn().mockReturnValue(null),
} as any;
const mediaEngine = {} as any;
const postEngine = {
getPost: vi.fn(),
} as any;
return { chatEngine, providers, mediaEngine, postEngine };
}
describe('OneShotTasks.analyzePost', () => {
let deps: ReturnType<typeof createMockDeps>;
let tasks: OneShotTasks;
beforeEach(() => {
vi.clearAllMocks();
deps = createMockDeps();
tasks = new OneShotTasks(deps.providers, deps.chatEngine, deps.mediaEngine, deps.postEngine);
});
it('returns title, excerpt, and slug from AI response', async () => {
deps.postEngine.getPost.mockResolvedValue({
id: 'post-1',
title: 'My Post',
slug: 'my-post',
excerpt: '',
content: 'This is the content of my blog post about testing.',
status: 'draft',
});
mockGenerateText.mockResolvedValue({
text: '{"title": "Better Title", "excerpt": "A concise summary of the post.", "slug": "better-title"}',
} as any);
const result: PostAnalysisResult = await tasks.analyzePost('post-1', 'en');
expect(result.success).toBe(true);
expect(result.title).toBe('Better Title');
expect(result.excerpt).toBe('A concise summary of the post.');
expect(result.slug).toBe('better-title');
expect(deps.postEngine.getPost).toHaveBeenCalledWith('post-1');
});
it('returns error when post is not found', async () => {
deps.postEngine.getPost.mockResolvedValue(null);
const result = await tasks.analyzePost('nonexistent', 'en');
expect(result.success).toBe(false);
expect(result.error).toBe('Post not found');
});
it('returns error when post has no content', async () => {
deps.postEngine.getPost.mockResolvedValue({
id: 'post-1',
title: '',
slug: 'post-1',
content: '',
status: 'draft',
});
const result = await tasks.analyzePost('post-1', 'en');
expect(result.success).toBe(false);
expect(result.error).toBe('Post has no content to analyze');
});
it('returns error when no API key is configured', async () => {
deps.providers.getOpencodeKey.mockReturnValue(null);
deps.providers.getMistralKey.mockReturnValue(null);
deps.providers.isProviderKeySet.mockReturnValue(false);
deps.postEngine.getPost.mockResolvedValue({
id: 'post-1',
title: 'Test',
content: 'Content here',
status: 'draft',
});
const result = await tasks.analyzePost('post-1', 'en');
expect(result.success).toBe(false);
expect(result.error).toContain('API key');
});
it('sanitizes slug to lowercase with hyphens only', async () => {
deps.postEngine.getPost.mockResolvedValue({
id: 'post-1',
title: 'Test',
slug: 'test',
content: 'Content here',
status: 'draft',
});
mockGenerateText.mockResolvedValue({
text: '{"title": "New Title", "excerpt": "Summary text.", "slug": "Some Weird Slug!"}',
} as any);
const result = await tasks.analyzePost('post-1', 'en');
expect(result.success).toBe(true);
expect(result.slug).toMatch(/^[a-z0-9-]+$/);
});
it('handles AI response parse errors gracefully', async () => {
deps.postEngine.getPost.mockResolvedValue({
id: 'post-1',
title: 'Test',
content: 'Content',
status: 'draft',
});
mockGenerateText.mockResolvedValue({
text: 'not valid json at all',
} as any);
const result = await tasks.analyzePost('post-1', 'en');
expect(result.success).toBe(false);
expect(result.error).toBe('Invalid response format from AI');
});
it('uses title model when configured', async () => {
deps.chatEngine.getSetting.mockResolvedValue('custom-model-id');
deps.providers.detectModelProvider.mockReturnValue('opencode');
deps.providers.isProviderKeySet.mockReturnValue(true);
deps.postEngine.getPost.mockResolvedValue({
id: 'post-1',
title: 'Test',
content: 'Content',
status: 'draft',
});
mockGenerateText.mockResolvedValue({
text: '{"title": "T", "excerpt": "E", "slug": "t"}',
} as any);
await tasks.analyzePost('post-1', 'en');
expect(deps.chatEngine.getSetting).toHaveBeenCalledWith('chat_title_model');
expect(deps.providers.resolveModel).toHaveBeenCalledWith('custom-model-id');
});
});

View File

@@ -1,19 +1,13 @@
import React from 'react';
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { AISuggestionsModal, type AISuggestions } from '../../../src/renderer/components/AISuggestionsModal/AISuggestionsModal';
import { AISuggestionsModal, type SuggestionField } from '../../../src/renderer/components/AISuggestionsModal/AISuggestionsModal';
const currentValues = {
title: 'Existing title',
alt: 'Existing alt',
caption: '',
};
const baseSuggestions: AISuggestions = {
title: 'Suggested title',
alt: 'Suggested alt',
caption: 'Suggested caption',
};
const mediaFields: SuggestionField[] = [
{ key: 'title', label: 'Title', currentValue: 'Existing title', suggestedValue: 'Suggested title' },
{ key: 'alt', label: 'Alt Text', currentValue: 'Existing alt', suggestedValue: 'Suggested alt' },
{ key: 'caption', label: 'Caption', currentValue: '', suggestedValue: 'Suggested caption' },
];
describe('AISuggestionsModal', () => {
it('shows suggested fields and applies only selected values', () => {
@@ -23,8 +17,10 @@ describe('AISuggestionsModal', () => {
<AISuggestionsModal
isOpen
isLoading={false}
suggestions={baseSuggestions}
currentValues={currentValues}
fields={mediaFields}
modalTitle="AI Image Analysis"
loadingText="Analyzing image..."
emptyText="No suggestions."
onConfirm={onConfirm}
onClose={vi.fn()}
/>
@@ -37,8 +33,10 @@ describe('AISuggestionsModal', () => {
const applyButton = screen.getByRole('button', { name: 'Apply Selected' });
const [titleCheckbox, altCheckbox, captionCheckbox] = screen.getAllByRole('checkbox') as HTMLInputElement[];
// Fields with existing values should be unchecked
expect(titleCheckbox.checked).toBe(false);
expect(altCheckbox.checked).toBe(false);
// Field with empty current value should be checked
expect(captionCheckbox.checked).toBe(true);
expect(applyButton).not.toBeDisabled();
@@ -57,18 +55,78 @@ describe('AISuggestionsModal', () => {
});
it('hides apply button when no suggestions are available', () => {
const emptyFields: SuggestionField[] = [
{ key: 'title', label: 'Title', currentValue: '', suggestedValue: undefined },
];
render(
<AISuggestionsModal
isOpen
isLoading={false}
suggestions={{}}
currentValues={{ title: '', alt: '', caption: '' }}
fields={emptyFields}
modalTitle="AI Analysis"
loadingText="Analyzing..."
emptyText="No suggestions were generated."
onConfirm={vi.fn()}
onClose={vi.fn()}
/>
);
expect(screen.queryByRole('button', { name: 'Apply Selected' })).toBeNull();
expect(screen.getByText('No suggestions were generated for this image.')).toBeTruthy();
expect(screen.getByText('No suggestions were generated.')).toBeTruthy();
});
it('shows custom modal title and loading text', () => {
render(
<AISuggestionsModal
isOpen
isLoading={true}
fields={[]}
modalTitle="AI Post Analysis"
loadingText="Analyzing post…"
emptyText="No suggestions."
onConfirm={vi.fn()}
onClose={vi.fn()}
/>
);
expect(screen.getByText('AI Post Analysis')).toBeTruthy();
expect(screen.getByText('Analyzing post…')).toBeTruthy();
});
it('works with post analysis fields (title, excerpt, slug)', () => {
const postFields: SuggestionField[] = [
{ key: 'title', label: 'Title', currentValue: 'My Post', suggestedValue: 'Better Title' },
{ key: 'excerpt', label: 'Summary / Excerpt', currentValue: '', suggestedValue: 'A concise summary.' },
{ key: 'slug', label: 'Slug', currentValue: 'my-post', suggestedValue: 'better-title' },
];
const onConfirm = vi.fn();
render(
<AISuggestionsModal
isOpen
isLoading={false}
fields={postFields}
modalTitle="AI Post Analysis"
loadingText="Analyzing post…"
emptyText="No suggestions."
onConfirm={onConfirm}
onClose={vi.fn()}
/>
);
const checkboxes = screen.getAllByRole('checkbox') as HTMLInputElement[];
// title has value → unchecked; excerpt empty → checked; slug has value → unchecked
expect(checkboxes[0].checked).toBe(false); // title
expect(checkboxes[1].checked).toBe(true); // excerpt
expect(checkboxes[2].checked).toBe(false); // slug
// Apply only the excerpt (pre-selected)
fireEvent.click(screen.getByRole('button', { name: 'Apply Selected' }));
expect(onConfirm).toHaveBeenCalledWith({
excerpt: 'A concise summary.',
});
});
});

View File

@@ -229,4 +229,37 @@ describe('Editor metadata collapse', () => {
});
expect(container.querySelector('.editor-header-row')).toBeNull();
});
it('keeps excerpt panel collapsed by default and toggles it independently', async () => {
(window as any).electronAPI.posts.get = vi.fn().mockResolvedValue(createPost({ title: '' }));
const { container } = render(<PostEditor postId="post-1" />);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
expect(container.querySelector('.editor-header-row')).not.toBeNull();
expect(container.querySelector('.editor-excerpt-panel')).toBeNull();
const excerptToggle = Array.from(container.querySelectorAll('.metadata-toggle')).find((node) =>
node.textContent?.includes('Excerpt')
);
expect(excerptToggle).not.toBeNull();
expect(excerptToggle?.classList.contains('expanded')).toBe(false);
await act(async () => {
fireEvent.click(excerptToggle!);
});
expect(container.querySelector('.editor-excerpt-panel')).not.toBeNull();
await act(async () => {
fireEvent.click(excerptToggle!);
});
expect(container.querySelector('.editor-excerpt-panel')).toBeNull();
});
});

View File

@@ -0,0 +1,253 @@
import React from 'react';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, act, fireEvent, screen } from '@testing-library/react';
let lastSuggestionFields: Array<{ key: string; label: string; currentValue: string; suggestedValue?: string; disabled?: boolean; warning?: string }> = [];
vi.mock('@monaco-editor/react', () => ({
default: () => <div data-testid="monaco-editor" />,
}));
vi.mock('@milkdown/kit/core', () => {
const makeChain = () => {
const chain = {
config: (callback: (ctx: { set: () => void; get: () => { markdownUpdated: () => void } }) => void) => {
callback({
set: () => {},
get: () => ({ markdownUpdated: () => {} }),
});
return chain;
},
use: () => chain,
};
return chain;
};
return {
Editor: { make: makeChain },
defaultValueCtx: Symbol('defaultValueCtx'),
editorViewCtx: Symbol('editorViewCtx'),
rootCtx: Symbol('rootCtx'),
remarkStringifyOptionsCtx: Symbol('remarkStringifyOptionsCtx'),
remarkPluginsCtx: Symbol('remarkPluginsCtx'),
};
});
vi.mock('@milkdown/kit/preset/commonmark', () => ({
commonmark: {},
toggleStrongCommand: { key: 'toggleStrong' },
toggleEmphasisCommand: { key: 'toggleEmphasis' },
wrapInBlockquoteCommand: { key: 'wrapInBlockquote' },
wrapInBulletListCommand: { key: 'wrapInBulletList' },
wrapInOrderedListCommand: { key: 'wrapInOrderedList' },
insertHrCommand: { key: 'insertHr' },
toggleInlineCodeCommand: { key: 'toggleInlineCode' },
insertImageCommand: { key: 'insertImage' },
toggleLinkCommand: { key: 'toggleLink' },
}));
vi.mock('@milkdown/kit/preset/gfm', () => ({
gfm: {},
toggleStrikethroughCommand: { key: 'toggleStrike' },
}));
vi.mock('@milkdown/kit/plugin/history', () => ({
history: {},
undoCommand: { key: 'undo' },
redoCommand: { key: 'redo' },
}));
vi.mock('@milkdown/kit/plugin/listener', () => ({
listener: {},
listenerCtx: Symbol('listenerCtx'),
}));
vi.mock('@milkdown/kit/plugin/clipboard', () => ({ clipboard: {} }));
vi.mock('@milkdown/kit/plugin/trailing', () => ({ trailing: {} }));
vi.mock('@milkdown/kit/plugin/indent', () => ({ indent: {} }));
vi.mock('@milkdown/kit/plugin/cursor', () => ({ cursor: {} }));
vi.mock('@milkdown/kit/utils', () => ({
$node: () => ({}),
$inputRule: () => ({}),
$remark: () => ({}),
$prose: () => ({}),
replaceAll: () => () => {},
callCommand: () => () => {},
}));
vi.mock('@milkdown/react', () => ({
Milkdown: () => <div data-testid="milkdown" />,
MilkdownProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
useInstance: () => [false, () => ({ action: (action: unknown) => {
if (typeof action === 'function') {
action({ get: () => ({}) });
}
} })] as const,
useEditor: (factory: (root: Node) => unknown) => {
factory(document.createElement('div'));
},
}));
vi.mock('../../../src/renderer/components/Lightbox', () => ({
Lightbox: () => null,
useMarkdownImages: () => [],
}));
vi.mock('../../../src/renderer/components/PostLinks', () => ({ PostLinks: () => null }));
vi.mock('../../../src/renderer/components/LinkedMediaPanel', () => ({ LinkedMediaPanel: () => null }));
vi.mock('../../../src/renderer/components/ErrorModal', () => ({ ErrorModal: () => null }));
vi.mock('../../../src/renderer/components/ConfirmDeleteModal', () => ({ ConfirmDeleteModal: () => null }));
vi.mock('../../../src/renderer/components/SettingsView', () => ({ SettingsView: () => null }));
vi.mock('../../../src/renderer/components/TagsView', () => ({ TagsView: () => null }));
vi.mock('../../../src/renderer/components/TagInput', () => ({ TagInput: () => null }));
vi.mock('../../../src/renderer/components/ChatPanel', () => ({ ChatPanel: () => null }));
vi.mock('../../../src/renderer/components/ImportAnalysisView', () => ({ ImportAnalysisView: () => null }));
vi.mock('../../../src/renderer/components/MetadataDiffPanel', () => ({ MetadataDiffPanel: () => null }));
vi.mock('../../../src/renderer/components/GitDiffView/GitDiffView', () => ({ GitDiffView: () => null }));
vi.mock('../../../src/renderer/components/InsertModal', () => ({ InsertModal: () => null }));
vi.mock('../../../src/renderer/components/AISuggestionsModal/AISuggestionsModal', () => ({
AISuggestionsModal: ({ isOpen, fields, onConfirm }: { isOpen: boolean; fields: typeof lastSuggestionFields; onConfirm: (values: Record<string, string>) => void }) => {
if (!isOpen) return null;
lastSuggestionFields = fields;
return (
<div data-testid="ai-suggestions-modal">
{fields.map((field) => (
<div key={field.key} data-testid={`field-${field.key}`} data-disabled={field.disabled ? 'true' : 'false'}>
{field.label}
</div>
))}
<button onClick={() => onConfirm({ title: 'Better Title', slug: 'better-title' })}>apply-suggestions</button>
</div>
);
},
}));
vi.mock('../../../src/renderer/components/Toast', () => ({
showToast: {
success: vi.fn(),
error: vi.fn(),
},
}));
import { PostEditor } from '../../../src/renderer/components/Editor/Editor';
import { useAppStore } from '../../../src/renderer/store';
const createPost = (overrides: Record<string, unknown> = {}) => ({
id: 'post-1',
title: 'Test Post',
content: 'Some content',
excerpt: '',
slug: 'test-post',
status: 'draft' as const,
tags: [],
categories: ['article'],
featuredImage: null,
publishedAt: null,
createdAt: new Date('2026-02-16T12:00:00.000Z'),
updatedAt: new Date('2026-02-16T12:00:00.000Z'),
author: undefined,
metadata: {},
seoTitle: undefined,
seoDescription: undefined,
canonicalUrl: undefined,
projectId: 'project-1',
filePath: 'posts/test-post.md',
...overrides,
});
describe('Editor AI post suggestions', () => {
beforeEach(() => {
vi.clearAllMocks();
lastSuggestionFields = [];
const neverSettles = new Promise<never>(() => {});
(window as any).electronAPI ??= {};
(window as any).electronAPI.posts ??= {};
(window as any).electronAPI.meta ??= {};
(window as any).electronAPI.chat ??= {};
(window as any).electronAPI.templates ??= {};
(window as any).addEventListener = vi.fn();
(window as any).removeEventListener = vi.fn();
(window as any).electronAPI.posts.hasPublishedVersion = vi.fn().mockReturnValue(neverSettles);
(window as any).electronAPI.posts.getPreviewUrl = vi.fn().mockResolvedValue('http://127.0.0.1:4123/preview');
(window as any).electronAPI.posts.update = vi.fn().mockImplementation(async (_postId: string, payload: Record<string, string>) => ({
...createPost(),
...payload,
}));
(window as any).electronAPI.meta.getCategories = vi.fn().mockReturnValue(neverSettles);
(window as any).electronAPI.meta.getProjectMetadata = vi.fn().mockResolvedValue({ mainLanguage: 'en' });
(window as any).electronAPI.templates.getEnabledByKind = vi.fn().mockResolvedValue([]);
(window as any).electronAPI.chat.analyzePost = vi.fn().mockResolvedValue({
success: true,
title: 'Better Title',
excerpt: 'A concise summary.',
slug: 'better-title',
});
useAppStore.setState({
activeProject: { id: 'project-1', name: 'Test', path: '/tmp/test' } as any,
preferredEditorMode: 'wysiwyg',
posts: [],
media: [],
dirtyPosts: new Set<string>(),
isLoading: false,
});
});
it('passes a disabled slug suggestion for published posts', async () => {
(window as any).electronAPI.posts.get = vi.fn().mockResolvedValue(createPost({
status: 'published',
publishedAt: new Date('2026-02-16T12:00:00.000Z'),
}));
render(<PostEditor postId="post-1" />);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: '⚡ Quick Actions' }));
});
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /AI: Suggest Title, Summary & Slug/i }));
});
const slugField = lastSuggestionFields.find((field) => field.key === 'slug');
expect(slugField).toBeDefined();
expect(slugField?.disabled).toBe(true);
});
it('submits the AI slug for a never-published draft when applying suggestions', async () => {
(window as any).electronAPI.posts.get = vi.fn().mockResolvedValue(createPost());
render(<PostEditor postId="post-1" />);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: '⚡ Quick Actions' }));
});
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /AI: Suggest Title, Summary & Slug/i }));
});
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'apply-suggestions' }));
});
expect((window as any).electronAPI.posts.update).toHaveBeenCalledWith(
'post-1',
expect.objectContaining({ title: 'Better Title', slug: 'better-title' })
);
});
});

View File

@@ -62,7 +62,7 @@ describe('pythonApiContractV1', () => {
it('exposes analyzeMediaImage and detectPostLanguage from chat namespace', () => {
const methodNames = listPythonApiMethodNames();
const chatMethods = methodNames.filter((m) => m.startsWith('chat.'));
expect(chatMethods).toEqual(['chat.analyzeMediaImage', 'chat.detectPostLanguage']);
expect(chatMethods).toEqual(['chat.analyzeMediaImage', 'chat.detectPostLanguage', 'chat.analyzePost']);
});
it('documents chat.analyzeMediaImage contract with mediaId and language params', () => {
@@ -79,7 +79,7 @@ describe('pythonApiContractV1', () => {
it('contains semantic version metadata for compatibility checks', () => {
expect(BDS_PYTHON_API_CONTRACT_V1).toMatchObject({
version: '1.12.0',
version: '1.13.0',
generatedAt: expect.any(String),
});
});