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

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