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:
@@ -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)}" />`),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user