feat(python): add queued worker runtime and configurable transform mode

This commit is contained in:
2026-02-23 22:26:54 +01:00
parent 8e8f099768
commit 838ea34ab7
21 changed files with 744 additions and 88 deletions

View File

@@ -10,7 +10,7 @@ import {
import './SettingsView.css';
// Export category IDs for sidebar navigation
export type SettingsCategory = 'project' | 'editor' | 'content' | 'ai' | 'publishing' | 'data';
export type SettingsCategory = 'project' | 'editor' | 'content' | 'ai' | 'technology' | 'publishing' | 'data';
// Scroll to a settings section by category ID
export const scrollToSettingsSection = (category: SettingsCategory) => {
@@ -150,6 +150,7 @@ export const SettingsView: React.FC = () => {
const [projectDefaultAuthor, setProjectDefaultAuthor] = useState('');
const [projectMaxPostsPerPage, setProjectMaxPostsPerPage] = useState(50);
const [projectBlogmarkCategory, setProjectBlogmarkCategory] = useState('article');
const [projectPythonRuntimeMode, setProjectPythonRuntimeMode] = useState<'webworker' | 'main-thread'>('webworker');
// Post categories management
const [postCategories, setPostCategories] = useState<string[]>(DEFAULT_POST_CATEGORIES);
@@ -208,6 +209,9 @@ export const SettingsView: React.FC = () => {
const incomingBlogmarkCategory = normalizeBlogmarkCategory((metadata as { blogmarkCategory?: unknown } | null)?.blogmarkCategory);
setProjectBlogmarkCategory(incomingBlogmarkCategory || 'article');
const incomingPythonRuntimeMode = (metadata as { pythonRuntimeMode?: unknown } | null)?.pythonRuntimeMode;
setProjectPythonRuntimeMode(incomingPythonRuntimeMode === 'main-thread' ? 'main-thread' : 'webworker');
const incomingCategoryMetadata = (metadata as any)?.categoryMetadata as Record<string, CategoryMetadata> | undefined;
const incomingLegacyCategorySettings = (metadata as any)?.categorySettings as Record<string, { renderInLists: boolean; showTitle: boolean }> | undefined;
setCategoryMetadata((current) => {
@@ -342,6 +346,7 @@ export const SettingsView: React.FC = () => {
defaultAuthor: projectDefaultAuthor.trim() || undefined,
maxPostsPerPage: Math.min(500, Math.max(1, Math.floor(projectMaxPostsPerPage || 50))),
blogmarkCategory: normalizeBlogmarkCategory(projectBlogmarkCategory) || undefined,
pythonRuntimeMode: projectPythonRuntimeMode,
categoryMetadata,
});
}
@@ -389,6 +394,7 @@ export const SettingsView: React.FC = () => {
const editorKeywords = ['editor', 'mode', 'wysiwyg', 'markdown', 'preview', 'visual'];
const contentKeywords = ['content', 'categories', 'post', 'article', 'picture', 'aside', 'page'];
const aiKeywords = ['ai', 'assistant', 'chat', 'model', 'prompt', 'system', 'api', 'key', 'claude', 'gpt', 'opencode'];
const technologyKeywords = ['technology', 'python', 'runtime', 'worker', 'webworker', 'main thread', 'execution'];
const publishingKeywords = ['publishing', 'ftp', 'ssh', 'deploy', 'server', 'host', 'upload'];
const dataKeywords = ['data', 'database', 'rebuild', 'maintenance', 'posts', 'media', 'links', 'folder', 'filesystem'];
@@ -1023,6 +1029,30 @@ export const SettingsView: React.FC = () => {
</SettingSection>
);
const renderTechnologySettings = () => (
<SettingSection
id="settings-section-technology"
title={t('settings.technology.title')}
description={t('settings.technology.description')}
hidden={!sectionHasMatches(technologyKeywords)}
>
<SettingRow
id="project-python-runtime-mode"
label={t('settings.technology.pythonRuntimeModeLabel')}
description={t('settings.technology.pythonRuntimeModeDescription')}
>
<select
id="project-python-runtime-mode"
value={projectPythonRuntimeMode}
onChange={(event) => setProjectPythonRuntimeMode(event.target.value as 'webworker' | 'main-thread')}
>
<option value="webworker">{t('settings.technology.pythonRuntimeMode.webworker')}</option>
<option value="main-thread">{t('settings.technology.pythonRuntimeMode.mainThread')}</option>
</select>
</SettingRow>
</SettingSection>
);
const renderPublishingSettings = () => (
<>
<SettingSection
@@ -1290,6 +1320,7 @@ export const SettingsView: React.FC = () => {
sectionHasMatches(editorKeywords) ||
sectionHasMatches(contentKeywords) ||
sectionHasMatches(aiKeywords) ||
sectionHasMatches(technologyKeywords) ||
sectionHasMatches(publishingKeywords) ||
sectionHasMatches(dataKeywords);
@@ -1325,6 +1356,7 @@ export const SettingsView: React.FC = () => {
{renderEditorSettings()}
{renderContentSettings()}
{renderAISettings()}
{renderTechnologySettings()}
{renderPublishingSettings()}
{renderDataSettings()}
</>

View File

@@ -1261,7 +1261,7 @@ const SettingsNav: React.FC = () => {
const { tabs, activeTabId, openTab } = useAppStore();
const [activeSection, setActiveSection] = useState<SettingsCategory | null>(() => {
const persisted = getPersistedSidebarSection('settings');
if (persisted === 'project' || persisted === 'editor' || persisted === 'content' || persisted === 'ai' || persisted === 'publishing' || persisted === 'data') {
if (persisted === 'project' || persisted === 'editor' || persisted === 'content' || persisted === 'ai' || persisted === 'technology' || persisted === 'publishing' || persisted === 'data') {
return persisted;
}
return null;
@@ -1322,6 +1322,13 @@ const SettingsNav: React.FC = () => {
<span className="settings-nav-entry-icon">🤖</span>
<span>{t('sidebar.nav.ai')}</span>
</button>
<button
className={`settings-nav-entry ${activeSection === 'technology' ? 'active' : ''}`}
onClick={() => handleNavClick('technology')}
>
<span className="settings-nav-entry-icon"></span>
<span>{t('sidebar.nav.technology')}</span>
</button>
<button
className={`settings-nav-entry ${activeSection === 'publishing' ? 'active' : ''}`}
onClick={() => handleNavClick('publishing')}

View File

@@ -127,6 +127,12 @@
"settings.content.showTitles": "Titel anzeigen",
"settings.ai.title": "KI-Assistent",
"settings.ai.noModels": "Keine Modelle verfügbar",
"settings.technology.title": "Technologie",
"settings.technology.description": "Konfiguriere das Laufzeitverhalten für die Python-Skriptausführung.",
"settings.technology.pythonRuntimeModeLabel": "Python-Laufzeitmodus",
"settings.technology.pythonRuntimeModeDescription": "Lege fest, wo Python-Skripte für Transformationspipelines ausgeführt werden.",
"settings.technology.pythonRuntimeMode.webworker": "Web Worker (empfohlen)",
"settings.technology.pythonRuntimeMode.mainThread": "Hauptthread (Legacy)",
"settings.publishing.ftpTitle": "FTP-Veröffentlichung",
"settings.publishing.sshTitle": "SSH-Veröffentlichung",
"settings.data.title": "Datenbankwartung",
@@ -421,6 +427,7 @@
"sidebar.nav.editor": "Texteditor",
"sidebar.nav.content": "Inhalt",
"sidebar.nav.ai": "KI-Assistent",
"sidebar.nav.technology": "Technologie",
"sidebar.nav.publishing": "Veröffentlichung",
"sidebar.nav.data": "Daten",
"sidebar.nav.style": "Stil",

View File

@@ -127,6 +127,12 @@
"settings.content.showTitles": "Show titles",
"settings.ai.title": "AI Assistant",
"settings.ai.noModels": "No models available",
"settings.technology.title": "Technology",
"settings.technology.description": "Configure runtime behavior for Python script execution.",
"settings.technology.pythonRuntimeModeLabel": "Python Runtime Mode",
"settings.technology.pythonRuntimeModeDescription": "Choose where Python scripts execute for transform pipelines.",
"settings.technology.pythonRuntimeMode.webworker": "Web Worker (Recommended)",
"settings.technology.pythonRuntimeMode.mainThread": "Main Thread (Legacy)",
"settings.publishing.ftpTitle": "FTP Publishing",
"settings.publishing.sshTitle": "SSH Publishing",
"settings.data.title": "Database Maintenance",
@@ -421,6 +427,7 @@
"sidebar.nav.editor": "Editor",
"sidebar.nav.content": "Content",
"sidebar.nav.ai": "AI Assistant",
"sidebar.nav.technology": "Technology",
"sidebar.nav.publishing": "Publishing",
"sidebar.nav.data": "Data",
"sidebar.nav.style": "Style",

View File

@@ -127,6 +127,12 @@
"settings.content.showTitles": "Mostrar títulos",
"settings.ai.title": "Asistente IA",
"settings.ai.noModels": "No hay modelos disponibles",
"settings.technology.title": "Tecnología",
"settings.technology.description": "Configura el comportamiento de ejecución para scripts de Python.",
"settings.technology.pythonRuntimeModeLabel": "Modo de ejecución de Python",
"settings.technology.pythonRuntimeModeDescription": "Elige dónde se ejecutan los scripts de Python para los flujos de transformación.",
"settings.technology.pythonRuntimeMode.webworker": "Web Worker (recomendado)",
"settings.technology.pythonRuntimeMode.mainThread": "Hilo principal (heredado)",
"settings.publishing.ftpTitle": "Publicación FTP",
"settings.publishing.sshTitle": "Publicación SSH",
"settings.data.title": "Mantenimiento de base de datos",
@@ -421,6 +427,7 @@
"sidebar.nav.editor": "Editor",
"sidebar.nav.content": "Contenido",
"sidebar.nav.ai": "Asistente IA",
"sidebar.nav.technology": "Tecnología",
"sidebar.nav.publishing": "Publicación",
"sidebar.nav.data": "Datos",
"sidebar.nav.style": "Estilo",

View File

@@ -127,6 +127,12 @@
"settings.content.showTitles": "Afficher les titres",
"settings.ai.title": "Assistant IA",
"settings.ai.noModels": "Aucun modèle disponible",
"settings.technology.title": "Technologie",
"settings.technology.description": "Configurez le comportement dexécution des scripts Python.",
"settings.technology.pythonRuntimeModeLabel": "Mode dexécution Python",
"settings.technology.pythonRuntimeModeDescription": "Choisissez où les scripts Python sexécutent pour les pipelines de transformation.",
"settings.technology.pythonRuntimeMode.webworker": "Web Worker (recommandé)",
"settings.technology.pythonRuntimeMode.mainThread": "Thread principal (hérité)",
"settings.publishing.ftpTitle": "Publication FTP",
"settings.publishing.sshTitle": "Publication SSH",
"settings.data.title": "Maintenance de la base de données",
@@ -421,6 +427,7 @@
"sidebar.nav.editor": "Éditeur",
"sidebar.nav.content": "Contenu",
"sidebar.nav.ai": "Assistant IA",
"sidebar.nav.technology": "Technologie",
"sidebar.nav.publishing": "Publication",
"sidebar.nav.data": "Données",
"sidebar.nav.style": "Style",

View File

@@ -127,6 +127,12 @@
"settings.content.showTitles": "Mostra titoli",
"settings.ai.title": "Assistente IA",
"settings.ai.noModels": "Nessun modello disponibile",
"settings.technology.title": "Tecnologia",
"settings.technology.description": "Configura il comportamento di runtime per l'esecuzione degli script Python.",
"settings.technology.pythonRuntimeModeLabel": "Modalità runtime Python",
"settings.technology.pythonRuntimeModeDescription": "Scegli dove eseguire gli script Python per le pipeline di trasformazione.",
"settings.technology.pythonRuntimeMode.webworker": "Web Worker (consigliato)",
"settings.technology.pythonRuntimeMode.mainThread": "Thread principale (legacy)",
"settings.publishing.ftpTitle": "Pubblicazione FTP",
"settings.publishing.sshTitle": "Pubblicazione SSH",
"settings.data.title": "Manutenzione database",
@@ -421,6 +427,7 @@
"sidebar.nav.editor": "Editor",
"sidebar.nav.content": "Contenuto",
"sidebar.nav.ai": "Assistente IA",
"sidebar.nav.technology": "Tecnologia",
"sidebar.nav.publishing": "Pubblicazione",
"sidebar.nav.data": "Dati",
"sidebar.nav.style": "Stile",

View File

@@ -54,6 +54,8 @@ export class PythonRuntimeManager {
private initializeDeferred: InitializeDeferred | null = null;
private ready = false;
private pendingRuns = new Map<string, PendingRun>();
private requestQueue: PythonWorkerRequest[] = [];
private activeRequestId: string | null = null;
private requestCounter = 0;
constructor(private readonly workerFactory: WorkerFactory = createPythonRuntimeWorker) {}
@@ -123,7 +125,7 @@ export class PythonRuntimeManager {
entrypoint: options?.entrypoint,
};
this.worker!.postMessage(message);
this.enqueueRequest(message);
});
}
@@ -162,7 +164,7 @@ export class PythonRuntimeManager {
cacheKey: options?.cacheKey,
};
this.worker!.postMessage(message);
this.enqueueRequest(message);
});
}
@@ -198,7 +200,7 @@ export class PythonRuntimeManager {
cacheKey: options?.cacheKey,
};
this.worker!.postMessage(message);
this.enqueueRequest(message);
});
}
@@ -234,7 +236,7 @@ export class PythonRuntimeManager {
cacheKey: options?.cacheKey,
};
this.worker!.postMessage(message);
this.enqueueRequest(message);
});
}
@@ -262,6 +264,10 @@ export class PythonRuntimeManager {
const pendingRun = this.pendingRuns.get(payload.requestId);
if (!pendingRun) {
if (this.activeRequestId === payload.requestId && payload.type !== 'stdout') {
this.activeRequestId = null;
this.dispatchNextRequest();
}
return;
}
@@ -278,33 +284,40 @@ export class PythonRuntimeManager {
if (payload.type === 'runResult') {
if (pendingRun.kind !== 'run') {
pendingRun.reject(new Error('Invalid response type for pending macro request'));
this.finishRequest(payload.requestId);
return;
}
pendingRun.resolve({ result: payload.result, stdout: pendingRun.stdout });
this.finishRequest(payload.requestId);
return;
}
if (payload.type === 'entrypoints') {
if (pendingRun.kind !== 'inspect-entrypoints') {
pendingRun.reject(new Error('Invalid response type for pending run request'));
this.finishRequest(payload.requestId);
return;
}
pendingRun.resolve(payload.entrypoints);
this.finishRequest(payload.requestId);
return;
}
if (payload.type === 'syntaxResult') {
if (pendingRun.kind !== 'syntax-check') {
pendingRun.reject(new Error('Invalid response type for pending syntax check request'));
this.finishRequest(payload.requestId);
return;
}
pendingRun.resolve({ errors: payload.errors });
this.finishRequest(payload.requestId);
return;
}
if (payload.type === 'macroResult') {
if (pendingRun.kind !== 'macro-v1') {
pendingRun.reject(new Error('Invalid response type for pending run request'));
this.finishRequest(payload.requestId);
return;
}
@@ -314,10 +327,12 @@ export class PythonRuntimeManager {
} catch (error) {
pendingRun.reject(error instanceof Error ? error : new Error(String(error)));
}
this.finishRequest(payload.requestId);
return;
}
pendingRun.reject(new Error(payload.error));
this.finishRequest(payload.requestId);
}
private handleWorkerError(error: Error): void {
@@ -334,6 +349,8 @@ export class PythonRuntimeManager {
}
this.pendingRuns.clear();
this.requestQueue = [];
this.activeRequestId = null;
this.worker?.terminate();
this.worker = null;
this.initializingPromise = null;
@@ -356,12 +373,50 @@ export class PythonRuntimeManager {
}
this.pendingRuns.clear();
this.requestQueue = [];
this.activeRequestId = null;
this.worker?.terminate();
this.worker = null;
this.initializingPromise = null;
this.ready = false;
}
private enqueueRequest(request: PythonWorkerRequest): void {
if (!this.worker || !this.ready) {
this.requestQueue.push(request);
return;
}
if (this.activeRequestId !== null) {
this.requestQueue.push(request);
return;
}
this.activeRequestId = request.requestId;
this.worker.postMessage(request);
}
private dispatchNextRequest(): void {
if (!this.worker || !this.ready || this.activeRequestId !== null || this.requestQueue.length === 0) {
return;
}
const nextRequest = this.requestQueue.shift();
if (!nextRequest) {
return;
}
this.activeRequestId = nextRequest.requestId;
this.worker.postMessage(nextRequest);
}
private finishRequest(requestId: string): void {
if (this.activeRequestId === requestId) {
this.activeRequestId = null;
}
this.dispatchNextRequest();
}
private nextRequestId(): string {
this.requestCounter += 1;
return `req-${this.requestCounter}`;