Feat/language detection (#31)

* feat: implementation of language detection

* run utility scripts in tasks

* fix: addiitonal fixes for background utilities

* feat: toast() also for utility scripts

---------

Co-authored-by: hugo <hugoms@me.com>
This commit is contained in:
Georg Bauer
2026-03-03 14:36:15 +01:00
committed by GitHub
parent 5747925503
commit 32b66e1677
37 changed files with 2616 additions and 55 deletions

View File

@@ -34,6 +34,7 @@ export const posts = sqliteTable('posts', {
tags: text('tags'), // JSON array stored as text
categories: text('categories'), // JSON array stored as text
templateSlug: text('template_slug'), // Optional user template override for this post
language: text('language'), // Optional per-post language override (ISO code, e.g. 'en', 'de')
// Legacy columns (kept for migration compatibility, no longer written)
publishedTitle: text('published_title'),
publishedContent: text('published_content'),

View File

@@ -397,6 +397,7 @@ export function buildSitemapAndFeeds(params: BuildSitemapAndFeedsParams): Sitema
` <guid isPermaLink="true">${escapeXml(permalink)}</guid>`,
` <pubDate>${(post.publishedAt || post.updatedAt).toUTCString()}</pubDate>`,
post.author ? ` <author>${escapeXml(post.author)}</author>` : null,
(post as { language?: string }).language ? ` <dc:language>${escapeXml((post as { language?: string }).language!)}</dc:language>` : null,
` <description><![CDATA[${escapeCdata(excerptXhtml)}]]></description>`,
` <content:encoded><![CDATA[${escapeCdata(contentXhtml)}]]></content:encoded>`,
...categories.map((entry) => ` ${entry}`),
@@ -406,7 +407,7 @@ export function buildSitemapAndFeeds(params: BuildSitemapAndFeedsParams): Sitema
const rssXml = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/">',
'<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/">',
' <channel>',
` <title>${escapeXml(feedTitle)}</title>`,
` <link>${escapeXml(baseLink)}</link>`,
@@ -430,8 +431,10 @@ export function buildSitemapAndFeeds(params: BuildSitemapAndFeedsParams): Sitema
...(post.categories || []).map((category) => `<category term="${escapeXml(category)}" />`),
];
const postLanguageAttr = (post as { language?: string }).language ? ` xml:lang="${escapeXml((post as { language?: string }).language!)}"` : '';
return [
' <entry>',
` <entry${postLanguageAttr}>`,
` <title>${escapeXml(post.title)}</title>`,
` <id>${escapeXml(permalink)}</id>`,
` <link href="${escapeXml(permalink)}" />`,

View File

@@ -25,7 +25,7 @@ export interface FieldDifference<T = unknown> {
/**
* The fields that can have differences
*/
export type DiffField = 'tags' | 'categories' | 'title' | 'excerpt' | 'author';
export type DiffField = 'tags' | 'categories' | 'title' | 'excerpt' | 'author' | 'language';
/**
* Metadata differences for a single post
@@ -248,6 +248,11 @@ export class MetadataDiffEngine extends EventEmitter {
differences.author = { dbValue: dbPost.author || '', fileValue: fileData.author || '' };
}
// Compare language
if ((dbPost.language || '') !== (fileData.language || '')) {
differences.language = { dbValue: dbPost.language || '', fileValue: fileData.language || '' };
}
return {
postId: dbPost.id,
title: dbPost.title,
@@ -279,7 +284,7 @@ export class MetadataDiffEngine extends EventEmitter {
// Get all published posts with file paths
const result = await client.execute({
sql: `SELECT id, title, slug, file_path, tags, categories, excerpt, author
sql: `SELECT id, title, slug, file_path, tags, categories, excerpt, author, language
FROM posts
WHERE project_id = ?
AND status = 'published'
@@ -331,6 +336,7 @@ export class MetadataDiffEngine extends EventEmitter {
title: 'Title',
excerpt: 'Excerpt',
author: 'Author',
language: 'Language',
};
for (const diff of diffs) {
@@ -428,6 +434,9 @@ export class MetadataDiffEngine extends EventEmitter {
if (!field || field === 'author') {
updateData.author = fileData.author || null;
}
if (!field || field === 'language') {
updateData.language = fileData.language || null;
}
// Update database
await db

View File

@@ -62,6 +62,7 @@ export interface TemplatePostEntry {
title: string;
content: string;
show_title: boolean;
language?: string;
}
export interface CategoryRenderSettings {
@@ -1485,8 +1486,12 @@ export class PageRenderer {
const canonicalPostPathBySlug = mapToRecord(rewriteContext.canonicalPostPathBySlug);
// Per-post language overrides the page-level language when present
const postLanguage = (renderablePost as { language?: string }).language;
const context: SinglePostTemplateContext = {
...pageContext,
language: postLanguage || pageContext.language,
menu_items: pageContext.menu_items ?? [],
post: {
id: renderablePost.id,
@@ -1494,6 +1499,7 @@ export class PageRenderer {
title: renderablePost.title,
content: renderablePost.content,
show_title: false,
language: postLanguage,
},
post_categories: postCategories,
post_tags: postTags,

View File

@@ -24,6 +24,7 @@ export interface PostData {
content: string;
status: 'draft' | 'published' | 'archived';
author?: string;
language?: string;
createdAt: Date;
updatedAt: Date;
publishedAt?: Date;
@@ -39,6 +40,7 @@ export interface PostMetadata {
excerpt?: string;
status: 'draft' | 'published' | 'archived';
author?: string;
language?: string;
createdAt: string;
updatedAt: string;
publishedAt?: string;
@@ -319,6 +321,7 @@ export class PostEngine extends EventEmitter {
// Only add optional fields if they have values (gray-matter can't serialize undefined)
if (post.excerpt) metadata.excerpt = post.excerpt;
if (post.author) metadata.author = post.author;
if (post.language) metadata.language = post.language;
if (post.publishedAt) metadata.publishedAt = post.publishedAt.toISOString();
// Use date-based directory structure (posts/YYYY/MM/)
@@ -392,6 +395,7 @@ export class PostEngine extends EventEmitter {
content: data.content || '',
status: data.status || 'draft',
author: data.author,
language: data.language,
createdAt: now,
updatedAt: now,
publishedAt: data.publishedAt,
@@ -418,6 +422,7 @@ export class PostEngine extends EventEmitter {
checksum,
tags: JSON.stringify(post.tags),
categories: JSON.stringify(post.categories),
language: post.language || null,
};
await db.insert(posts).values(dbPost);
@@ -445,7 +450,9 @@ export class PostEngine extends EventEmitter {
data.title !== undefined ||
data.tags !== undefined ||
data.categories !== undefined ||
data.excerpt !== undefined;
data.excerpt !== undefined ||
data.language !== undefined ||
data.author !== undefined;
let newStatus = data.status || existing.status;
if (existing.status === 'published' && isContentOrMetadataChange && !data.status) {
@@ -484,6 +491,7 @@ export class PostEngine extends EventEmitter {
checksum,
tags: JSON.stringify(updated.tags),
categories: JSON.stringify(updated.categories),
language: updated.language || null,
})
.where(eq(posts.id, id));
@@ -576,6 +584,7 @@ export class PostEngine extends EventEmitter {
content: body,
status: dbPost.status as 'draft' | 'published' | 'archived',
author: dbPost.author || undefined,
language: (dbPost as { language?: string | null }).language || undefined,
createdAt: dbPost.createdAt,
updatedAt: dbPost.updatedAt,
publishedAt: dbPost.publishedAt || undefined,
@@ -1331,6 +1340,7 @@ export class PostEngine extends EventEmitter {
checksum,
tags: JSON.stringify(published.tags),
categories: JSON.stringify(published.categories),
language: published.language || null,
})
.where(eq(posts.id, id));
@@ -1550,6 +1560,7 @@ export class PostEngine extends EventEmitter {
content: null,
status: 'published',
author: fileData.author,
language: fileData.language || null,
createdAt: fileData.createdAt,
updatedAt: fileData.updatedAt,
publishedAt: nextPublishedAt,
@@ -1579,6 +1590,7 @@ export class PostEngine extends EventEmitter {
content: fileData.content,
status: 'published',
author: fileData.author || undefined,
language: fileData.language || undefined,
createdAt: fileData.createdAt,
updatedAt: fileData.updatedAt,
publishedAt: nextPublishedAt || undefined,
@@ -1630,6 +1642,7 @@ export class PostEngine extends EventEmitter {
content: null,
status: 'published',
author: fileData.author,
language: fileData.language || null,
createdAt: fileData.createdAt,
updatedAt: fileData.updatedAt,
publishedAt,
@@ -1856,6 +1869,7 @@ export class PostEngine extends EventEmitter {
content: null,
status: 'published',
author: postData.author,
language: postData.language || null,
createdAt: postData.createdAt,
updatedAt: postData.updatedAt,
publishedAt: postData.publishedAt || postData.updatedAt,

View File

@@ -27,6 +27,7 @@ export interface Task<T = unknown> {
export class TaskManager extends EventEmitter {
private tasks: Map<string, TaskProgress> = new Map();
private runningTasks: Map<string, AbortController> = new Map();
private externalTasks: Set<string> = new Set();
private maxConcurrentTasks = 3;
private taskQueue: Task[] = [];
@@ -136,7 +137,80 @@ export class TaskManager extends EventEmitter {
}
}
// ---------------------------------------------------------------------------
// External tasks — lifecycle controlled by the caller (e.g. renderer-side
// utility script execution) rather than an execute() callback.
// ---------------------------------------------------------------------------
startExternalTask(taskId: string, name: string): void {
const progress: TaskProgress = {
taskId,
name,
status: 'running',
progress: 0,
message: 'Running…',
startTime: new Date(),
};
this.tasks.set(taskId, progress);
this.externalTasks.add(taskId);
this.emit('taskCreated', progress);
this.emit('taskStarted', progress);
}
updateExternalTaskProgress(taskId: string, progress: number, message: string): void {
const entry = this.tasks.get(taskId);
if (!entry || !this.externalTasks.has(taskId)) {
return;
}
entry.progress = progress;
entry.message = message;
this.emit('taskProgress', { ...entry });
}
completeExternalTask(taskId: string): void {
const entry = this.tasks.get(taskId);
if (!entry || !this.externalTasks.has(taskId)) {
return;
}
entry.status = 'completed';
entry.progress = 100;
entry.message = 'Completed';
entry.endTime = new Date();
this.externalTasks.delete(taskId);
this.emit('taskCompleted', entry);
}
failExternalTask(taskId: string, error: string): void {
const entry = this.tasks.get(taskId);
if (!entry || !this.externalTasks.has(taskId)) {
return;
}
entry.status = 'failed';
entry.error = error;
entry.message = `Failed: ${error}`;
entry.endTime = new Date();
this.externalTasks.delete(taskId);
this.emit('taskFailed', entry);
}
cancelTask(taskId: string): boolean {
// Check external tasks first
if (this.externalTasks.has(taskId)) {
this.externalTasks.delete(taskId);
const progress = this.tasks.get(taskId);
if (progress) {
progress.status = 'cancelled';
progress.message = 'Cancelled';
progress.endTime = new Date();
this.emit('taskCancelled', progress);
}
return true;
}
const controller = this.runningTasks.get(taskId);
if (controller) {
controller.abort();

View File

@@ -30,6 +30,12 @@ export interface ImageAnalysisResult {
error?: string;
}
export interface LanguageDetectionResult {
success: boolean;
language?: string;
error?: string;
}
// ---------------------------------------------------------------------------
// OneShotTasks
// ---------------------------------------------------------------------------
@@ -275,4 +281,70 @@ Remember: Only suggest mappings from NEW items to EXISTING items. Consider langu
return { success: false, error: (error as Error).message };
}
}
/**
* Detect the language of a post based on its title and content.
* Uses the configured title model (lightweight, text-only).
*/
async detectPostLanguage(
title: string,
content: string,
): Promise<LanguageDetectionResult> {
// 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 = content.slice(0, 500);
const supportedLanguages = ['en', 'de', 'fr', 'it', 'es'];
const systemPrompt = `You are a language detection assistant. Given a blog post title and a content snippet, determine the language of the text. Respond with ONLY a JSON object: { "language": "<code>" } where <code> is one of: ${supportedLanguages.join(', ')}. If the language is not in the list, pick the closest match. No other text.`;
const userPrompt = `Title: ${title}\n\nContent:\n${snippet}`;
try {
const model = this.providers.resolveModel(modelId);
const { text } = await generateText({
model,
system: systemPrompt,
prompt: userPrompt,
maxOutputTokens: 50,
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]);
const detected = (result.language || '').toLowerCase().trim();
if (!supportedLanguages.includes(detected)) {
return { success: false, error: `Unsupported language detected: ${detected}` };
}
return { success: true, language: detected };
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
}

View File

@@ -14,6 +14,7 @@ export interface PostFileData {
content: string;
status: 'draft' | 'published' | 'archived';
author?: string;
language?: string;
createdAt: Date;
updatedAt: Date;
publishedAt?: Date;
@@ -28,6 +29,7 @@ interface PostFileMetadata {
excerpt?: string;
status: 'draft' | 'published' | 'archived';
author?: string;
language?: string;
createdAt: string;
updatedAt: string;
publishedAt?: string;
@@ -62,6 +64,7 @@ export async function readPostFile(filePath: string): Promise<PostFileData | nul
content: body,
status: metadata.status,
author: metadata.author,
language: metadata.language || undefined,
createdAt: new Date(metadata.createdAt),
updatedAt: new Date(metadata.updatedAt),
publishedAt: metadata.publishedAt ? new Date(metadata.publishedAt) : undefined,

View File

@@ -881,6 +881,19 @@ export function registerChatHandlers(): void {
}
});
// ============ Post Language Detection ============
// Detect the language of a post from its title and content
ipcMain.handle('chat:detectPostLanguage', async (_, title: string, content: string) => {
try {
await ensureInitialized();
return await getOneShotTasks().detectPostLanguage(title, content);
} catch (error) {
console.error('[Chat IPC] Error detecting post language:', 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

@@ -416,6 +416,15 @@ export function registerIpcHandlers(bundle: EngineBundle): void {
data.author = metadata.defaultAuthor;
}
}
// If no language provided, default from project settings
if (!data.language) {
const metaEngine = bundle.metaEngine;
const metadata = await metaEngine.getProjectMetadata();
if (metadata?.mainLanguage) {
data.language = metadata.mainLanguage;
}
}
return engine.createPost(data);
});
@@ -828,6 +837,20 @@ export function registerIpcHandlers(bundle: EngineBundle): void {
return true;
});
// ============ Script Task Lifecycle (external tasks for utility scripts) ====
safeHandle('scripts:startTask', async (_, taskId: string, name: string) => {
bundle.taskManager.startExternalTask(taskId, name);
});
safeHandle('scripts:completeTask', async (_, taskId: string) => {
bundle.taskManager.completeExternalTask(taskId);
});
safeHandle('scripts:failTask', async (_, taskId: string, error: string) => {
bundle.taskManager.failExternalTask(taskId, error);
});
// ============ Template Handlers ============
safeHandle('templates:create', async (_, data: CreateTemplateInput) => {

View File

@@ -110,6 +110,9 @@ export const electronAPI: ElectronAPI = {
getAll: () => ipcRenderer.invoke('scripts:getAll'),
getEnabledMacroSlugs: () => ipcRenderer.invoke('scripts:getEnabledMacroSlugs'),
rebuildFromFiles: () => ipcRenderer.invoke('scripts:rebuildFromFiles'),
startTask: (taskId: string, name: string) => ipcRenderer.invoke('scripts:startTask', taskId, name),
completeTask: (taskId: string) => ipcRenderer.invoke('scripts:completeTask', taskId),
failTask: (taskId: string, error: string) => ipcRenderer.invoke('scripts:failTask', taskId, error),
},
// Templates
@@ -376,6 +379,9 @@ export const electronAPI: ElectronAPI = {
// Media Analysis
analyzeMediaImage: (mediaId: string, language?: string) => ipcRenderer.invoke('chat:analyzeMediaImage', mediaId, language),
// Post Language Detection
detectPostLanguage: (title: string, content: string) => ipcRenderer.invoke('chat:detectPostLanguage', title, content),
// 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

@@ -94,6 +94,7 @@ export interface PostData {
content: string;
status: 'draft' | 'published' | 'archived';
author?: string;
language?: string;
createdAt: string;
updatedAt: string;
publishedAt?: string;
@@ -643,6 +644,12 @@ export interface ElectronAPI {
/** Internal: editor macro plugin helper. Not exposed via Python API contract. */
getEnabledMacroSlugs: () => Promise<string[]>;
rebuildFromFiles: () => Promise<void>;
/** Create a task entry for a running utility script. */
startTask: (taskId: string, name: string) => Promise<void>;
/** Mark a utility script task as completed. */
completeTask: (taskId: string) => Promise<void>;
/** Mark a utility script task as failed. */
failTask: (taskId: string, error: string) => Promise<void>;
};
templates: {
create: (data: {
@@ -894,6 +901,9 @@ export interface ElectronAPI {
// Media Analysis
analyzeMediaImage: (mediaId: string, language?: string) => Promise<{ success: boolean; title?: string; alt?: string; caption?: string; error?: string }>;
// Post Language Detection
detectPostLanguage: (title: string, content: string) => Promise<{ success: boolean; language?: string; error?: string }>;
// Event listeners for streaming/progress
onStreamDelta: (callback: (data: ChatStreamDelta) => void) => () => void;
onToolCall: (callback: (data: ChatToolCall) => void) => () => void;

View File

@@ -188,6 +188,9 @@ const METHODS_V1: PythonApiMethodContractV1[] = [
// expensive external API calls that require user oversight and interactive streaming.
// This namespace can be re-added in a future version if AI-from-Python becomes a
// supported use case with proper rate limiting and cost controls.
// Exception: detectPostLanguage is exposed as a lightweight one-shot task.
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('sync.checkAvailability', 'Check if git is available.', [], 'GitAvailability'),
method('sync.getRepoState', 'Get repository state for active project.', [], 'RepoState'),
@@ -239,6 +242,7 @@ const DATA_STRUCTURES_V1: PythonApiDataStructureContractV1[] = [
{ name: 'content', type: 'string', required: true, description: 'Markdown body content.' },
{ name: 'status', type: "'draft' | 'published' | 'archived'", required: true, description: 'Publication lifecycle state.' },
{ name: 'author', type: 'string', required: false, description: 'Optional author name.' },
{ name: 'language', type: 'string', required: false, description: 'Optional per-post language code (e.g. en, de, fr, it, es).' },
{ name: 'createdAt', type: 'string', required: true, description: 'Creation timestamp (ISO string).' },
{ name: 'updatedAt', type: 'string', required: true, description: 'Last update timestamp (ISO string).' },
{ name: 'publishedAt', type: 'string', required: false, description: 'Publication timestamp for published posts.' },
@@ -404,7 +408,7 @@ const DATA_STRUCTURES_V1: PythonApiDataStructureContractV1[] = [
];
export const BDS_PYTHON_API_CONTRACT_V1: PythonApiContractV1 = {
version: '1.9.0',
version: '1.10.0',
generatedAt: '2026-02-27T00:00:00.000Z',
methods: METHODS_V1,
dataStructures: DATA_STRUCTURES_V1,

View File

@@ -192,6 +192,23 @@
gap: 12px;
}
.editor-language-row {
display: flex;
gap: 6px;
align-items: center;
}
.editor-language-row select {
flex: 1;
}
.editor-language-row button.compact {
padding: 6px 8px;
font-size: 13px;
min-width: unset;
line-height: 1;
}
.editor-body {
flex: 1;
display: flex;

View File

@@ -75,6 +75,9 @@ const autoSaveManager = new AutoSaveManager({
if ('templateSlug' in changes) {
(update as Record<string, unknown>).templateSlug = changes.templateSlug as string || null;
}
if ('language' in changes) {
update.language = changes.language as string || undefined;
}
const updated = await window.electronAPI?.posts.update(id, update);
if (updated) {
@@ -196,8 +199,10 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
const [tags, setTags] = useState<string[]>([]);
const [selectedCategories, setSelectedCategories] = useState<string[]>(['article']);
const [templateSlug, setTemplateSlug] = useState('');
const [postLanguage, setPostLanguage] = useState('');
const [availablePostTemplates, setAvailablePostTemplates] = useState<Array<{ slug: string; title: string }>>([]);
const [isSaving, setIsSaving] = useState(false);
const [isDetectingLanguage, setIsDetectingLanguage] = useState(false);
const [hasPublishedVersion, setHasPublishedVersion] = useState(false);
const [editorMode, setEditorMode] = useState<EditorMode>(preferredEditorMode);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
@@ -326,6 +331,7 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
setTags(post.tags);
setSelectedCategories(post.categories.length > 0 ? post.categories : ['article']);
setTemplateSlug((post as PostData & { templateSlug?: string }).templateSlug || '');
setPostLanguage(post.language || '');
setMetadataExpanded(post.title === '');
markClean(postId);
// Mark as initialized AFTER setting local state
@@ -347,7 +353,8 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
const titleChanged = title !== post.title;
const authorChanged = author !== (post.author || '');
const templateSlugChanged = templateSlug !== ((post as PostData & { templateSlug?: string }).templateSlug || '');
const hasChanges = contentChanged || titleChanged || authorChanged || templateSlugChanged ||
const languageChanged = postLanguage !== (post.language || '');
const hasChanges = contentChanged || titleChanged || 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());
@@ -362,11 +369,12 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
tags: tags.join(', '),
categories: selectedCategories,
templateSlug: templateSlug || undefined,
language: postLanguage || undefined,
});
} else {
markClean(postId);
}
}, [title, content, author, tags, selectedCategories, templateSlug, post, postId, isInitialized, isDirty, markDirty, markClean]);
}, [title, content, author, tags, selectedCategories, templateSlug, postLanguage, post, postId, isInitialized, isDirty, markDirty, markClean]);
// Handle editor mode change and persist preference
const handleEditorModeChange = (mode: EditorMode) => {
@@ -386,6 +394,7 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
title,
content,
author: author || undefined,
language: postLanguage || undefined,
tags,
categories: selectedCategories.length > 0 ? selectedCategories : ['article'],
templateSlug: templateSlug || null,
@@ -409,6 +418,24 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
}
}, [postId, title, content, author, tags, selectedCategories, isDirty, isSaving, updatePost, markClean, showErrorModal]);
const handleDetectLanguage = useCallback(async () => {
if (isDetectingLanguage || (!title && !content)) return;
setIsDetectingLanguage(true);
try {
const result = await window.electronAPI?.chat.detectPostLanguage(title, content);
if (result?.success && result.language) {
setPostLanguage(result.language);
showToast.success(tr('editor.post.quickActions.languageDetected'));
} else {
showToast.error(result?.error || tr('editor.post.quickActions.detectLanguageFailed'));
}
} catch (error) {
console.error('Failed to detect post language:', error);
showToast.error(tr('editor.post.quickActions.detectLanguageFailed'));
} finally {
setIsDetectingLanguage(false);
}
}, [title, content, isDetectingLanguage, tr]);
const handlePublish = async () => {
await handleSave();
try {
@@ -791,6 +818,30 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
placeholder={tr('editor.placeholder.author')}
/>
</div>
<div className="editor-field">
<label>{tr('editor.field.language')}</label>
<div className="editor-language-row">
<select
value={postLanguage}
onChange={(e) => setPostLanguage(e.target.value)}
>
<option value="">{tr('editor.field.languageDefault')}</option>
<option value="en">{tr('language.en')}</option>
<option value="de">{tr('language.de')}</option>
<option value="fr">{tr('language.fr')}</option>
<option value="it">{tr('language.it')}</option>
<option value="es">{tr('language.es')}</option>
</select>
<button
className="secondary compact"
onClick={handleDetectLanguage}
disabled={isDetectingLanguage || (!title && !content)}
title={tr('editor.post.quickActions.detectLanguageDescription')}
>
{isDetectingLanguage ? tr('editor.post.quickActions.detecting') : '🤖'}
</button>
</div>
</div>
<div className="editor-field-row">
<div className="editor-field">
<label>{tr('editor.field.slug')}</label>

View File

@@ -363,11 +363,27 @@ export const ScriptsView: React.FC<ScriptsViewProps> = ({ scriptId }) => {
setIsRunning(true);
const isUtility = kind === 'utility';
const taskId = isUtility ? `script-${script.id}-${Date.now()}` : undefined;
if (isUtility && taskId) {
await window.electronAPI?.scripts.startTask(taskId, title || script.title);
}
try {
const runtimeManager = getPythonRuntimeManager();
const result = await runtimeManager.execute(scriptContent, {
cacheKey: buildCacheKey(script, scriptContent),
entrypoint,
timeoutMs: 0,
onStdout: (chunk: string) => {
appendPanelOutputEntry({
id: `output-${Date.now()}-stdout-stream`,
message: chunk,
createdAt: new Date().toISOString(),
kind: 'stdout',
});
},
});
const now = new Date().toISOString();
@@ -380,21 +396,21 @@ export const ScriptsView: React.FC<ScriptsViewProps> = ({ scriptId }) => {
});
}
if (result.stdout.trim().length > 0) {
appendPanelOutputEntry({
id: `output-${Date.now()}-stdout`,
message: result.stdout,
createdAt: now,
kind: 'stdout',
});
if (isUtility && taskId) {
await window.electronAPI?.scripts.completeTask(taskId);
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
appendPanelOutputEntry({
id: `output-${Date.now()}-error`,
message: error instanceof Error ? error.message : String(error),
message: errorMessage,
createdAt: new Date().toISOString(),
kind: 'error',
});
if (isUtility && taskId) {
await window.electronAPI?.scripts.failTask(taskId, errorMessage);
}
} finally {
setIsRunning(false);
}

View File

@@ -517,6 +517,13 @@
"editor.field.content": "Inhalt",
"editor.field.template": "Vorlage",
"editor.field.templateDefault": "Standard",
"editor.field.language": "Sprache",
"editor.field.languageDefault": "Projektstandard",
"language.en": "Englisch",
"language.de": "Deutsch",
"language.fr": "Französisch",
"language.it": "Italienisch",
"language.es": "Spanisch",
"editor.placeholder.tags": "Tags hinzufügen...",
"editor.placeholder.author": "Autorenname",
"editor.placeholder.categories": "Kategorien hinzufügen...",
@@ -879,6 +886,10 @@
"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.detectLanguageDescription": "Sprache mit KI erkennen",
"editor.post.quickActions.detecting": "Erkennung…",
"editor.post.quickActions.languageDetected": "Sprache erkannt",
"editor.post.quickActions.detectLanguageFailed": "Spracherkennung fehlgeschlagen",
"editor.media.replaceFile": "Datei ersetzen",
"editor.media.field.fileName": "Dateiname",
"editor.media.field.type": "Typ",

View File

@@ -517,6 +517,13 @@
"editor.field.content": "Content",
"editor.field.template": "Template",
"editor.field.templateDefault": "Default",
"editor.field.language": "Language",
"editor.field.languageDefault": "Project default",
"language.en": "English",
"language.de": "German",
"language.fr": "French",
"language.it": "Italian",
"language.es": "Spanish",
"editor.placeholder.tags": "Add tags...",
"editor.placeholder.author": "Author name",
"editor.placeholder.categories": "Add categories...",
@@ -879,6 +886,10 @@
"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.detectLanguageDescription": "Detect language using AI",
"editor.post.quickActions.detecting": "Detecting…",
"editor.post.quickActions.languageDetected": "Language detected",
"editor.post.quickActions.detectLanguageFailed": "Language detection failed",
"editor.media.replaceFile": "Replace File",
"editor.media.field.fileName": "File Name",
"editor.media.field.type": "Type",

View File

@@ -517,6 +517,13 @@
"editor.field.content": "Contenido",
"editor.field.template": "Plantilla",
"editor.field.templateDefault": "Predeterminada",
"editor.field.language": "Idioma",
"editor.field.languageDefault": "Predeterminado del proyecto",
"language.en": "Inglés",
"language.de": "Alemán",
"language.fr": "Francés",
"language.it": "Italiano",
"language.es": "Español",
"editor.placeholder.tags": "Agregar etiquetas...",
"editor.placeholder.author": "Nombre del autor",
"editor.placeholder.categories": "Agregar categorías...",
@@ -879,6 +886,10 @@
"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.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.media.replaceFile": "Reemplazar archivo",
"editor.media.field.fileName": "Nombre de archivo",
"editor.media.field.type": "Tipo",

View File

@@ -517,6 +517,13 @@
"editor.field.content": "Contenu",
"editor.field.template": "Modèle",
"editor.field.templateDefault": "Par défaut",
"editor.field.language": "Langue",
"editor.field.languageDefault": "Par défaut du projet",
"language.en": "Anglais",
"language.de": "Allemand",
"language.fr": "Français",
"language.it": "Italien",
"language.es": "Espagnol",
"editor.placeholder.tags": "Ajouter des étiquettes...",
"editor.placeholder.author": "Nom de lauteur",
"editor.placeholder.categories": "Ajouter des catégories...",
@@ -879,6 +886,10 @@
"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.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.media.replaceFile": "Remplacer le fichier",
"editor.media.field.fileName": "Nom du fichier",
"editor.media.field.type": "Type",

View File

@@ -517,6 +517,13 @@
"editor.field.content": "Contenuto",
"editor.field.template": "Modello",
"editor.field.templateDefault": "Predefinito",
"editor.field.language": "Lingua",
"editor.field.languageDefault": "Predefinito del progetto",
"language.en": "Inglese",
"language.de": "Tedesco",
"language.fr": "Francese",
"language.it": "Italiano",
"language.es": "Spagnolo",
"editor.placeholder.tags": "Aggiungi tag...",
"editor.placeholder.author": "Nome autore",
"editor.placeholder.categories": "Aggiungi categorie...",
@@ -879,6 +886,10 @@
"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.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.media.replaceFile": "Sostituisci file",
"editor.media.field.fileName": "Nome file",
"editor.media.field.type": "Tipo",

View File

@@ -3,12 +3,22 @@ import type { PythonWorkerMessage, PythonWorkerRequest } from './runtimeProtocol
import type { PythonSyntaxError } from './runtimeProtocol';
import { parseMacroContextV1, parseMacroResultV1, type MacroContextV1, type MacroResultV1 } from './abiV1';
import { invokePythonApiMethodV1 } from './pythonApiInvokerV1';
import { showToast } from '../components/Toast';
type WorkerFactory = () => Worker;
type PythonApiInvoker = (method: string, args: unknown) => Promise<unknown>;
type ToastHandler = (message: string, toastType?: string) => void;
const TOAST_TYPES = new Set(['success', 'error', 'info']);
function defaultToastHandler(message: string, toastType?: string): void {
const resolvedType = (toastType && TOAST_TYPES.has(toastType) ? toastType : 'info') as 'success' | 'error' | 'info';
showToast[resolvedType](message);
}
interface PythonRuntimeManagerOptions {
invokeApiCall?: PythonApiInvoker;
onToast?: ToastHandler;
}
interface InitializeDeferred {
@@ -22,6 +32,8 @@ interface PendingRun {
resolve: (value: PythonRunResult | PythonMacroV1Result | string[] | PythonSyntaxCheckResult) => void;
reject: (error: Error) => void;
timeoutId: ReturnType<typeof setTimeout> | null;
timeoutMs: number;
onStdout?: (chunk: string) => void;
}
export interface PythonRunResult {
@@ -33,6 +45,7 @@ export interface PythonExecuteOptions {
timeoutMs?: number;
cacheKey?: string;
entrypoint?: string;
onStdout?: (chunk: string) => void;
}
export interface PythonMacroSourceOptions {
@@ -65,12 +78,14 @@ export class PythonRuntimeManager {
private activeRequestId: string | null = null;
private requestCounter = 0;
private readonly invokeApiCall: PythonApiInvoker;
private readonly onToast: ToastHandler;
constructor(
private readonly workerFactory: WorkerFactory = createPythonRuntimeWorker,
options: PythonRuntimeManagerOptions = {}
) {
this.invokeApiCall = options.invokeApiCall ?? invokePythonApiMethodV1;
this.onToast = options.onToast ?? defaultToastHandler;
}
initialize(): Promise<void> {
@@ -116,18 +131,14 @@ export class PythonRuntimeManager {
const timeoutMs = options?.timeoutMs ?? 5000;
return new Promise<PythonRunResult>((resolve, reject) => {
const timeoutId = setTimeout(() => {
this.pendingRuns.delete(requestId);
this.resetRuntime(`Python script execution timed out after ${timeoutMs}ms`);
reject(new Error(`Python script execution timed out after ${timeoutMs}ms`));
}, timeoutMs);
this.pendingRuns.set(requestId, {
kind: 'run',
stdout: '',
resolve: (value) => resolve(value as PythonRunResult),
reject,
timeoutId,
timeoutId: null,
timeoutMs,
onStdout: options?.onStdout,
});
const message: PythonWorkerRequest = {
@@ -155,18 +166,13 @@ export class PythonRuntimeManager {
const timeoutMs = options?.timeoutMs ?? 5000;
return new Promise<PythonMacroV1Result>((resolve, reject) => {
const timeoutId = setTimeout(() => {
this.pendingRuns.delete(requestId);
this.resetRuntime(`Python script execution timed out after ${timeoutMs}ms`);
reject(new Error(`Python script execution timed out after ${timeoutMs}ms`));
}, timeoutMs);
this.pendingRuns.set(requestId, {
kind: 'macro-v1',
stdout: '',
resolve: (value) => resolve(value as PythonMacroV1Result),
reject,
timeoutId,
timeoutId: null,
timeoutMs,
});
const message: PythonWorkerRequest = {
@@ -194,18 +200,13 @@ export class PythonRuntimeManager {
const timeoutMs = options?.timeoutMs ?? 5000;
return new Promise<string[]>((resolve, reject) => {
const timeoutId = setTimeout(() => {
this.pendingRuns.delete(requestId);
this.resetRuntime(`Python script execution timed out after ${timeoutMs}ms`);
reject(new Error(`Python script execution timed out after ${timeoutMs}ms`));
}, timeoutMs);
this.pendingRuns.set(requestId, {
kind: 'inspect-entrypoints',
stdout: '',
resolve: (value) => resolve(value as string[]),
reject,
timeoutId,
timeoutId: null,
timeoutMs,
});
const message: PythonWorkerRequest = {
@@ -230,18 +231,13 @@ export class PythonRuntimeManager {
const timeoutMs = options?.timeoutMs ?? 5000;
return new Promise<PythonSyntaxCheckResult>((resolve, reject) => {
const timeoutId = setTimeout(() => {
this.pendingRuns.delete(requestId);
this.resetRuntime(`Python script execution timed out after ${timeoutMs}ms`);
reject(new Error(`Python script execution timed out after ${timeoutMs}ms`));
}, timeoutMs);
this.pendingRuns.set(requestId, {
kind: 'syntax-check',
stdout: '',
resolve: (value) => resolve(value as PythonSyntaxCheckResult),
reject,
timeoutId,
timeoutId: null,
timeoutMs,
});
const message: PythonWorkerRequest = {
@@ -282,6 +278,11 @@ export class PythonRuntimeManager {
return;
}
if (payload.type === 'toast') {
this.onToast(payload.message, payload.toastType);
return;
}
const pendingRun = this.pendingRuns.get(payload.requestId);
if (!pendingRun) {
if (this.activeRequestId === payload.requestId && payload.type !== 'stdout') {
@@ -293,6 +294,7 @@ export class PythonRuntimeManager {
if (payload.type === 'stdout') {
pendingRun.stdout += payload.chunk;
pendingRun.onStdout?.(payload.chunk);
return;
}
@@ -440,6 +442,7 @@ export class PythonRuntimeManager {
}
this.activeRequestId = request.requestId;
this.startTimeoutForRequest(request.requestId);
this.worker.postMessage(request);
}
@@ -454,9 +457,23 @@ export class PythonRuntimeManager {
}
this.activeRequestId = nextRequest.requestId;
this.startTimeoutForRequest(nextRequest.requestId);
this.worker.postMessage(nextRequest);
}
private startTimeoutForRequest(requestId: string): void {
const pendingRun = this.pendingRuns.get(requestId);
if (!pendingRun || pendingRun.timeoutMs <= 0) {
return;
}
pendingRun.timeoutId = setTimeout(() => {
this.pendingRuns.delete(requestId);
this.resetRuntime(`Python script execution timed out after ${pendingRun.timeoutMs}ms`);
pendingRun.reject(new Error(`Python script execution timed out after ${pendingRun.timeoutMs}ms`));
}, pendingRun.timeoutMs);
}
private finishRequest(requestId: string): void {
if (this.activeRequestId === requestId) {
this.activeRequestId = null;

View File

@@ -76,6 +76,7 @@ export async function invokePythonApiMethodV1(method: string, args: unknown): Pr
}
const normalizedArgs = asRecord(args);
const electronApi = getElectronApi();
const [namespace, member] = contract.method.split('.');
if (!namespace || !member) {

View File

@@ -344,11 +344,22 @@ async function bootstrapRuntime(): Promise<void> {
},
});
runtime.globals.set('__bds_push_toast', (message: unknown, toastType?: unknown) => {
postRuntimeMessage({
type: 'toast',
message: String(message ?? ''),
...(typeof toastType === 'string' && toastType.length > 0 ? { toastType } : {}),
});
});
runtime.globals.set('__bds_api_module_source', generatePythonApiModuleV1());
await runtime.runPythonAsync(`
import sys
import types
def toast(message, type="info"):
__bds_push_toast(str(message), str(type))
__bds_api_module = types.ModuleType("bds_api")
exec(__bds_api_module_source, __bds_api_module.__dict__)

View File

@@ -55,4 +55,5 @@ export type PythonWorkerMessage =
| { type: 'entrypoints'; requestId: string; entrypoints: string[] }
| { type: 'syntaxResult'; requestId: string; errors: PythonSyntaxError[] }
| { type: 'macroResult'; requestId: string; result: MacroResultV1 }
| { type: 'runError'; requestId: string; error: string };
| { type: 'runError'; requestId: string; error: string }
| { type: 'toast'; message: string; toastType?: string };