diff --git a/backend/generator.py b/backend/generator.py index 15dffcb..365d04b 100644 --- a/backend/generator.py +++ b/backend/generator.py @@ -635,13 +635,17 @@ async def _generate_onepager( recherche_path = content_path.parent / f"{content_path.stem}.recherche.md" fragment_paths.append(recherche_path) recherche_path.unlink(missing_ok=True) + # Projekte bekommen eigene Recherche-Dimensionen — Produkt-Fragen + # (Version, Lizenz, Alternativen) laufen dort ins Leere. if project: source = _prompt("OnePager-Quelle-Projekt", project=project) + recherche_template = "OnePager-Recherche-Projekt" else: source = _prompt("OnePager-Quelle-Thema", topic=topic) + recherche_template = "OnePager-Recherche" slots = [{ "key": f"{guide_id}-recherche", - "prompt": _prompt("OnePager-Recherche", topic=topic, source=source, out_path=recherche_path, extra=_extra(instructions)), + "prompt": _prompt(recherche_template, topic=topic, source=source, out_path=recherche_path, extra=_extra(instructions)), "role": "quick", "capabilities": "files" if project else "full", "payload": (lambda result: recherche_path.read_text(encoding="utf-8") if recherche_path.exists() else None), }] diff --git a/backend/routes.py b/backend/routes.py index 0a29feb..bb1edb0 100644 --- a/backend/routes.py +++ b/backend/routes.py @@ -122,6 +122,10 @@ async def remove_bausteine(topic: str): async def create(req: GuideCreateRequest): if req.format != "OnePager" and not bausteine_path(req.topic.strip()).exists(): raise HTTPException(400, "Erst Bausteine erstellen") + # Kein Duplikat-Start: pro Thema+Format höchstens eine laufende Generierung + for g in await list_guides(): + if g["topic"] == req.topic.strip() and g["format"] == req.format and g["status"] in ("queued", "generating"): + raise HTTPException(409, "Generierung läuft bereits") await create_topic(req.topic.strip()) now = datetime.now(timezone.utc).isoformat() guide = { diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 0031a76..f665dd9 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -189,6 +189,12 @@ async function handleBausteineClick({ instructions }) { async function handleFormatClick({ format, instructions }) { if (!selectedTopic.value) return + // Kein Duplikat-Start: läuft für Thema+Format schon eine Generierung, ignorieren + const running = guides.value.some( + (g) => g.topic === selectedTopic.value && g.format === format + && (g.status === 'generating' || g.status === 'queued'), + ) + if (running) return await apiCreate(selectedTopic.value, format, instructions, provider.value) await loadGuides() startPolling() diff --git a/frontend/src/components/TopicSidebar.vue b/frontend/src/components/TopicSidebar.vue index c87ca9b..01ae03a 100644 --- a/frontend/src/components/TopicSidebar.vue +++ b/frontend/src/components/TopicSidebar.vue @@ -49,14 +49,28 @@ const activeGenerations = computed(() => { return [...bausteinLines, ...guideLines] }) +// Inline-Bestätigung statt confirm(): erster Klick scharfschalten („Sicher?"), +// zweiter Klick führt aus. Browser-Dialoge können unterdrückt sein (Firefox). +const pendingConfirm = ref(null) +let confirmTimer = null + +function armOrRun(key, action) { + clearTimeout(confirmTimer) + if (pendingConfirm.value === key) { + pendingConfirm.value = null + action() + } else { + pendingConfirm.value = key + confirmTimer = setTimeout(() => { pendingConfirm.value = null }, 3000) + } +} + function confirmCancelBausteine() { - if (!confirm('Aktuellen Schritt abbrechen? Bisheriger Fortschritt bleibt erhalten.')) return - emit('cancelBausteine') + armOrRun('bausteine', () => emit('cancelBausteine')) } function confirmResetBausteine() { - if (!confirm('Gespeicherten Bausteine-Fortschritt löschen?')) return - emit('resetBausteine') + armOrRun('bausteine', () => emit('resetBausteine')) } function handleBausteinePlay() { @@ -68,10 +82,12 @@ function handleBausteinePlay() { } function guideStatus(format) { - if (props.doneByFormat[format]) return 'done' + // Laufende Generierung hat Vorrang — sonst maskiert ein älterer fertiger + // Guide den Lauf und ▶ würde Duplikate starten. const latest = props.latestByFormat[format] - if (!latest) return 'none' - if (latest.status === 'error') return 'none' + if (latest && (latest.status === 'generating' || latest.status === 'queued')) return latest.status + if (props.doneByFormat[format]) return 'done' + if (!latest || latest.status === 'error') return 'none' return latest.status } @@ -116,15 +132,19 @@ function dismissError(format) { } function handleDelete(format) { - const guide = props.latestByFormat[format] - if (!guide) return - if (guide.status === 'generating' || guide.status === 'queued') { - if (!confirm('Generierung abbrechen?')) return - emit('cancelGuide', guide.id) - } else { - if (!confirm('Guide löschen?')) return - emit('deleteGuide', guide.id) - } + if (!props.latestByFormat[format]) return + armOrRun('fmt-' + format, () => { + // Alle laufenden Generierungen des Formats abbrechen (deckt auch Duplikate ab) + const running = props.allGuides.filter( + (g) => g.topic === props.selectedTopic && g.format === format + && (g.status === 'generating' || g.status === 'queued'), + ) + if (running.length) { + for (const g of running) emit('cancelGuide', g.id) + } else { + emit('deleteGuide', props.latestByFormat[format].id) + } + }) } const newTopic = ref('') @@ -137,13 +157,11 @@ function submit() { } function confirmDeleteTopic(topic) { - if (!confirm(`Thema "${topic}" und alle zugehörigen Guides löschen?`)) return - emit('deleteTopic', topic) + armOrRun('topic-' + topic, () => emit('deleteTopic', topic)) } function confirmDeleteProject(name) { - if (!confirm(`Projekt "${name}" entfernen?\n\nAchtung: Der Quellordner ./projects/${name} wird gelöscht.`)) return - emit('deleteProject', name) + armOrRun('project-' + name, () => emit('deleteProject', name)) } @@ -196,15 +214,17 @@ function confirmDeleteProject(name) { × + >{{ pendingConfirm === 'bausteine' ? 'Sicher?' : '×' }} × + >{{ pendingConfirm === 'bausteine' ? 'Sicher?' : '×' }}