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

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

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