This commit is contained in:
team3
2026-05-25 22:59:37 +02:00
parent e964c807d9
commit 619bac34cb
8 changed files with 339 additions and 88 deletions

View File

@@ -6,6 +6,7 @@ CREATE TABLE IF NOT EXISTS guides (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
topic TEXT NOT NULL, topic TEXT NOT NULL,
format TEXT NOT NULL, format TEXT NOT NULL,
instructions TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'queued', status TEXT NOT NULL DEFAULT 'queued',
progress TEXT, progress TEXT,
error_msg TEXT, error_msg TEXT,
@@ -16,11 +17,36 @@ CREATE TABLE IF NOT EXISTS guides (
) )
""" """
_db: aiosqlite.Connection | None = None
async def get_db() -> aiosqlite.Connection:
global _db
if _db is None:
_db = await aiosqlite.connect(DB_PATH)
_db.row_factory = None
return _db
async def init_db(): async def init_db():
async with aiosqlite.connect(DB_PATH) as db: db = await get_db()
await db.execute(CREATE_TABLE) await db.execute(CREATE_TABLE)
await db.commit() cursor = await db.execute("PRAGMA table_info(guides)")
columns = {row[1] for row in await cursor.fetchall()}
if "instructions" not in columns:
await db.execute("ALTER TABLE guides ADD COLUMN instructions TEXT NOT NULL DEFAULT ''")
await db.execute(
"UPDATE guides SET status = 'error', progress = NULL, error_msg = 'Server-Neustart' "
"WHERE status IN ('queued', 'generating')"
)
await db.commit()
async def close_db():
global _db
if _db is not None:
await _db.close()
_db = None
def _row_to_dict(row, cursor): def _row_to_dict(row, cursor):
@@ -29,42 +55,42 @@ def _row_to_dict(row, cursor):
async def create_guide(guide: dict) -> dict: async def create_guide(guide: dict) -> dict:
async with aiosqlite.connect(DB_PATH) as db: db = await get_db()
await db.execute( await db.execute(
"""INSERT INTO guides (id, topic, format, status, progress, html_path, pdf_path, created_at, updated_at) """INSERT INTO guides (id, topic, format, instructions, status, progress, html_path, pdf_path, created_at, updated_at)
VALUES (:id, :topic, :format, :status, :progress, :html_path, :pdf_path, :created_at, :updated_at)""", VALUES (:id, :topic, :format, :instructions, :status, :progress, :html_path, :pdf_path, :created_at, :updated_at)""",
guide, guide,
) )
await db.commit() await db.commit()
return guide return guide
async def get_guide(guide_id: str) -> dict | None: async def get_guide(guide_id: str) -> dict | None:
async with aiosqlite.connect(DB_PATH) as db: db = await get_db()
cursor = await db.execute("SELECT * FROM guides WHERE id = ?", (guide_id,)) cursor = await db.execute("SELECT * FROM guides WHERE id = ?", (guide_id,))
row = await cursor.fetchone() row = await cursor.fetchone()
if row is None: if row is None:
return None return None
return _row_to_dict(row, cursor) return _row_to_dict(row, cursor)
async def list_guides() -> list[dict]: async def list_guides() -> list[dict]:
async with aiosqlite.connect(DB_PATH) as db: db = await get_db()
cursor = await db.execute("SELECT * FROM guides ORDER BY created_at DESC") cursor = await db.execute("SELECT * FROM guides ORDER BY created_at DESC")
rows = await cursor.fetchall() rows = await cursor.fetchall()
return [_row_to_dict(row, cursor) for row in rows] return [_row_to_dict(row, cursor) for row in rows]
async def update_guide(guide_id: str, **fields) -> None: async def update_guide(guide_id: str, **fields) -> None:
sets = ", ".join(f"{k} = :{k}" for k in fields) sets = ", ".join(f"{k} = :{k}" for k in fields)
fields["id"] = guide_id fields["id"] = guide_id
async with aiosqlite.connect(DB_PATH) as db: db = await get_db()
await db.execute(f"UPDATE guides SET {sets} WHERE id = :id", fields) await db.execute(f"UPDATE guides SET {sets} WHERE id = :id", fields)
await db.commit() await db.commit()
async def delete_guide(guide_id: str) -> bool: async def delete_guide(guide_id: str) -> bool:
async with aiosqlite.connect(DB_PATH) as db: db = await get_db()
cursor = await db.execute("DELETE FROM guides WHERE id = ?", (guide_id,)) cursor = await db.execute("DELETE FROM guides WHERE id = ?", (guide_id,))
await db.commit() await db.commit()
return cursor.rowcount > 0 return cursor.rowcount > 0

View File

@@ -16,16 +16,17 @@ from database import update_guide
_semaphore = asyncio.Semaphore(MAX_CONCURRENT_GENERATIONS) _semaphore = asyncio.Semaphore(MAX_CONCURRENT_GENERATIONS)
_active_processes: dict[str, asyncio.subprocess.Process] = {} _active_processes: dict[str, asyncio.subprocess.Process] = {}
_cancelled: set[str] = set()
async def cancel_guide(guide_id: str) -> bool: async def cancel_guide(guide_id: str) -> bool:
_cancelled.add(guide_id)
process = _active_processes.get(guide_id) process = _active_processes.get(guide_id)
if process and process.returncode is None: if process and process.returncode is None:
process.kill() process.kill()
now = datetime.now(timezone.utc).isoformat() now = datetime.now(timezone.utc).isoformat()
await update_guide(guide_id, status="error", progress=None, error_msg="Abgebrochen", updated_at=now) await update_guide(guide_id, status="error", progress=None, error_msg="Abgebrochen", updated_at=now)
return True return True
return False
async def _set_progress(guide_id: str, progress: str) -> None: async def _set_progress(guide_id: str, progress: str) -> None:
@@ -70,7 +71,7 @@ async def _render_pngs(pdf_path: Path, preview_dir: Path) -> list[Path]:
preview_dir.mkdir(parents=True, exist_ok=True) preview_dir.mkdir(parents=True, exist_ok=True)
proc = await asyncio.create_subprocess_exec( proc = await asyncio.create_subprocess_exec(
"python3", "-c", "python3", "-c",
f"from pdf2image import convert_from_path; pages = convert_from_path('{pdf_path}', dpi=120); [p.save('{preview_dir}/page_{{i}}.png') for i, p in enumerate(pages)]; print(len(pages))", f"from pdf2image import convert_from_path; pages = convert_from_path('{pdf_path}', dpi=120); [p.save('{preview_dir}/page_' + str(i) + '.png') for i, p in enumerate(pages)]; print(len(pages))",
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
) )
@@ -79,10 +80,12 @@ async def _render_pngs(pdf_path: Path, preview_dir: Path) -> list[Path]:
return pngs return pngs
def _build_generator_prompt(topic: str, format_name: str, html_path: Path) -> str: def _build_generator_prompt(topic: str, format_name: str, html_path: Path, instructions: str = "") -> str:
spec = (TEMPLATES_DIR / "Format" / f"{format_name}.md").read_text(encoding="utf-8") spec = (TEMPLATES_DIR / "Format" / f"{format_name}.md").read_text(encoding="utf-8")
reference = (TEMPLATES_DIR / "Referenz" / f"{format_name}.md").read_text(encoding="utf-8") reference = (TEMPLATES_DIR / "Referenz" / f"{format_name}.md").read_text(encoding="utf-8")
extra = f"\n\nZUSÄTZLICHE ANWEISUNGEN VOM NUTZER:\n{instructions}\n" if instructions else ""
return f"""Erstelle einen Lern-Guide zum Thema "{topic}" im Format "{format_name}". return f"""Erstelle einen Lern-Guide zum Thema "{topic}" im Format "{format_name}".
Recherchiere zuerst die aktuelle Version und aktuelle Fakten zu "{topic}" per Websuche, damit Versionsnummern und Angaben stimmen. Recherchiere zuerst die aktuelle Version und aktuelle Fakten zu "{topic}" per Websuche, damit Versionsnummern und Angaben stimmen.
@@ -96,6 +99,23 @@ FORMAT-SPEZIFIKATION:
REFERENZ-IMPLEMENTIERUNG (Stil-Vorlage, adaptiere für "{topic}"): REFERENZ-IMPLEMENTIERUNG (Stil-Vorlage, adaptiere für "{topic}"):
{reference} {reference}
{extra}"""
def _build_rework_prompt(topic: str, format_name: str, html_path: Path, instructions: str) -> str:
spec = (TEMPLATES_DIR / "Format" / f"{format_name}.md").read_text(encoding="utf-8")
return f"""Überarbeite die bestehende HTML-Datei {html_path} für den "{format_name}" zum Thema "{topic}".
Lies zuerst die aktuelle HTML-Datei mit dem Read-Tool.
ANWEISUNGEN VOM NUTZER:
{instructions}
FORMAT-SPEZIFIKATION (muss weiterhin eingehalten werden):
{spec}
Schreibe die überarbeitete Version in dieselbe Datei: {html_path}
Führe KEIN weasyprint aus, erzeuge KEINE PDF.
""" """
@@ -113,24 +133,28 @@ Führe KEIN weasyprint aus, erzeuge KEINE PDF.
def _build_review_prompt(format_name: str, png_paths: list[Path], page_count: int) -> str: def _build_review_prompt(format_name: str, png_paths: list[Path], page_count: int) -> str:
spec = (TEMPLATES_DIR / "Format" / f"{format_name}.md").read_text(encoding="utf-8") spec = (TEMPLATES_DIR / "Format" / f"{format_name}.md").read_text(encoding="utf-8")
png_list = "\n".join(str(p) for p in png_paths) read_instructions = "\n".join(
f"- Öffne mit dem Read-Tool: {p}" for p in png_paths
)
return f"""Prüfe die folgenden Preview-Bilder eines generierten "{format_name}" Guides. return f"""Prüfe visuell einen generierten "{format_name}" Guide.
Das PDF hat {page_count} Seite(n). Lies die Preview-Bilder und prüfe sie: SCHRITT 1 — Bilder laden:
{png_list} Das PDF hat {page_count} Seite(n), gerendert als PNG-Screenshots.
Nutze das Read-Tool, um JEDE der folgenden Dateien zu öffnen und visuell zu inspizieren:
{read_instructions}
FORMAT-SPEZIFIKATION (Prüfkriterien): SCHRITT 2 — Visuell prüfen anhand dieser Spezifikation:
{spec} {spec}
Prüfe anhand der Spezifikation: Prüfkriterien (basierend auf dem, was du in den Bildern SIEHST):
- Stimmt die Seitenanzahl? (OnePager/Cheatsheet = exakt 1 Seite) - Stimmt die Seitenanzahl? (OnePager/Cheatsheet = exakt 1 Seite)
- Sind Elemente abgeschnitten oder überlappend? - Ist Text abgeschnitten, überlappt oder läuft aus dem sichtbaren Bereich?
- Fehlen Pflicht-Elemente (Cover, TOC, Recall-Boxen, Callouts, etc.)? - Fehlen Pflicht-Elemente (Cover, TOC, Recall-Boxen, Callouts, etc.)?
- Sind Code-Blöcke über Seitenumbrüche zerrissen? - Sind Code-Blöcke über Seitenumbrüche zerrissen?
- Ist das Layout korrekt (Spalten, Grid, Footer)? - Ist das Layout korrekt (Spalten, Grid, Footer)?
Antworte mit GENAU EINEM der folgenden Formate: SCHRITT 3 — Antworte mit GENAU EINEM der folgenden Formate:
Bei Bestehen: Bei Bestehen:
PASS PASS
@@ -143,7 +167,7 @@ FAIL
""" """
async def generate_guide(guide_id: str, topic: str, format_name: str) -> None: async def generate_guide(guide_id: str, topic: str, format_name: str, instructions: str = "") -> None:
async with _semaphore: async with _semaphore:
now = datetime.now(timezone.utc).isoformat() now = datetime.now(timezone.utc).isoformat()
await update_guide(guide_id, status="generating", progress="Recherche…", updated_at=now) await update_guide(guide_id, status="generating", progress="Recherche…", updated_at=now)
@@ -155,11 +179,16 @@ async def generate_guide(guide_id: str, topic: str, format_name: str) -> None:
max_iter = MAX_ITERATIONS.get(format_name, 3) max_iter = MAX_ITERATIONS.get(format_name, 3)
try: try:
if guide_id in _cancelled:
return
# Step 1: Generator-Agent erstellt HTML # Step 1: Generator-Agent erstellt HTML
await _set_progress(guide_id, "Generiere HTML…") await _set_progress(guide_id, "Generiere HTML…")
gen_prompt = _build_generator_prompt(topic, format_name, html_path) gen_prompt = _build_generator_prompt(topic, format_name, html_path, instructions)
returncode, stdout, stderr = await _run_claude(guide_id, gen_prompt, timeout) returncode, stdout, stderr = await _run_claude(guide_id, gen_prompt, timeout)
if guide_id in _cancelled:
return
if returncode != 0: if returncode != 0:
await _fail(guide_id, f"Generator-Fehler: {stderr[:1000]}") await _fail(guide_id, f"Generator-Fehler: {stderr[:1000]}")
return return
@@ -170,6 +199,9 @@ async def generate_guide(guide_id: str, topic: str, format_name: str) -> None:
# Step 2-N: Render → Review → Fix Loop # Step 2-N: Render → Review → Fix Loop
for iteration in range(1, max_iter + 1): for iteration in range(1, max_iter + 1):
if guide_id in _cancelled:
return
await _set_progress(guide_id, f"Rendere PDF… (Iteration {iteration})") await _set_progress(guide_id, f"Rendere PDF… (Iteration {iteration})")
ok, err = await _render_pdf(html_path, pdf_path) ok, err = await _render_pdf(html_path, pdf_path)
if not ok: if not ok:
@@ -222,6 +254,7 @@ async def generate_guide(guide_id: str, topic: str, format_name: str) -> None:
await _fail(guide_id, str(e)[:2000]) await _fail(guide_id, str(e)[:2000])
finally: finally:
_active_processes.pop(guide_id, None) _active_processes.pop(guide_id, None)
_cancelled.discard(guide_id)
# Preview-PNGs aufräumen # Preview-PNGs aufräumen
if preview_dir.exists(): if preview_dir.exists():
for f in preview_dir.glob("*"): for f in preview_dir.glob("*"):
@@ -229,6 +262,88 @@ async def generate_guide(guide_id: str, topic: str, format_name: str) -> None:
preview_dir.rmdir() preview_dir.rmdir()
async def rework_guide(guide_id: str, topic: str, format_name: str, instructions: str) -> None:
async with _semaphore:
now = datetime.now(timezone.utc).isoformat()
await update_guide(guide_id, status="generating", progress="Überarbeite…", updated_at=now)
html_path = STORAGE_DIR / "html" / f"{guide_id}.html"
pdf_path = STORAGE_DIR / "pdf" / f"{guide_id}.pdf"
preview_dir = STORAGE_DIR / "preview" / guide_id
timeout = GENERATION_TIMEOUTS.get(format_name, 600)
max_iter = MAX_ITERATIONS.get(format_name, 3)
try:
if guide_id in _cancelled:
return
rework_prompt = _build_rework_prompt(topic, format_name, html_path, instructions)
returncode, stdout, stderr = await _run_claude(guide_id, rework_prompt, timeout)
if guide_id in _cancelled:
return
if returncode != 0:
await _fail(guide_id, f"Rework-Fehler: {stderr[:1000]}")
return
if not html_path.exists():
await _fail(guide_id, "HTML-Datei wurde nicht erstellt")
return
for iteration in range(1, max_iter + 1):
if guide_id in _cancelled:
return
await _set_progress(guide_id, f"Rendere PDF… (Iteration {iteration})")
ok, err = await _render_pdf(html_path, pdf_path)
if not ok:
await _fail(guide_id, f"WeasyPrint-Fehler: {err}")
return
await _set_progress(guide_id, f"Prüfe… (Iteration {iteration})")
pngs = await _render_pngs(pdf_path, preview_dir)
page_count = len(pngs)
review_prompt = _build_review_prompt(format_name, pngs, page_count)
returncode, review_out, review_err = await _run_claude(guide_id, review_prompt, 120)
if returncode != 0:
await _fail(guide_id, f"Review-Fehler: {review_err[:1000]}")
return
review_text = review_out.strip()
if review_text.startswith("PASS"):
break
if iteration == max_iter:
break
feedback = review_text.replace("FAIL", "").strip()
await _set_progress(guide_id, f"Korrigiere… (Iteration {iteration})")
fix_prompt = _build_fix_prompt(topic, format_name, html_path, feedback)
returncode, _, fix_err = await _run_claude(guide_id, fix_prompt, timeout)
if returncode != 0:
await _fail(guide_id, f"Fix-Fehler: {fix_err[:1000]}")
return
now = datetime.now(timezone.utc).isoformat()
await update_guide(
guide_id, status="done", progress=None,
html_path=str(html_path), pdf_path=str(pdf_path), updated_at=now,
)
except asyncio.TimeoutError:
await _fail(guide_id, f"Timeout nach {timeout}s")
except Exception as e:
await _fail(guide_id, str(e)[:2000])
finally:
_active_processes.pop(guide_id, None)
_cancelled.discard(guide_id)
if preview_dir.exists():
for f in preview_dir.glob("*"):
f.unlink()
preview_dir.rmdir()
async def _fail(guide_id: str, msg: str) -> None: async def _fail(guide_id: str, msg: str) -> None:
now = datetime.now(timezone.utc).isoformat() now = datetime.now(timezone.utc).isoformat()
await update_guide(guide_id, status="error", progress=None, error_msg=msg, updated_at=now) await update_guide(guide_id, status="error", progress=None, error_msg=msg, updated_at=now)

View File

@@ -4,7 +4,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from config import STORAGE_DIR from config import STORAGE_DIR
from database import init_db from database import init_db, close_db
from routes import router from routes import router
@@ -15,6 +15,7 @@ async def lifespan(app: FastAPI):
(STORAGE_DIR / "preview").mkdir(parents=True, exist_ok=True) (STORAGE_DIR / "preview").mkdir(parents=True, exist_ok=True)
await init_db() await init_db()
yield yield
await close_db()
app = FastAPI(title="Guides Generator", lifespan=lifespan) app = FastAPI(title="Guides Generator", lifespan=lifespan)

View File

@@ -14,6 +14,11 @@ FormatType = Literal[
class GuideCreateRequest(BaseModel): class GuideCreateRequest(BaseModel):
topic: str = Field(min_length=1, max_length=100) topic: str = Field(min_length=1, max_length=100)
format: FormatType format: FormatType
instructions: str = Field(default="", max_length=2000)
class GuideReworkRequest(BaseModel):
instructions: str = Field(min_length=1, max_length=2000)
class GuideResponse(BaseModel): class GuideResponse(BaseModel):

View File

@@ -8,8 +8,8 @@ from fastapi.responses import FileResponse
from config import FORMAT_META, STORAGE_DIR from config import FORMAT_META, STORAGE_DIR
from database import create_guide, delete_guide, get_guide, list_guides from database import create_guide, delete_guide, get_guide, list_guides
from generator import generate_guide, cancel_guide from generator import generate_guide, rework_guide, cancel_guide
from models import GuideCreateRequest, GuideResponse from models import GuideCreateRequest, GuideReworkRequest, GuideResponse
router = APIRouter(prefix="/api") router = APIRouter(prefix="/api")
@@ -26,6 +26,7 @@ async def create(req: GuideCreateRequest):
"id": str(uuid.uuid4()), "id": str(uuid.uuid4()),
"topic": req.topic.strip(), "topic": req.topic.strip(),
"format": req.format, "format": req.format,
"instructions": req.instructions.strip(),
"status": "queued", "status": "queued",
"progress": None, "progress": None,
"html_path": None, "html_path": None,
@@ -34,7 +35,7 @@ async def create(req: GuideCreateRequest):
"updated_at": now, "updated_at": now,
} }
await create_guide(guide) await create_guide(guide)
asyncio.create_task(generate_guide(guide["id"], guide["topic"], guide["format"])) asyncio.create_task(generate_guide(guide["id"], guide["topic"], guide["format"], guide["instructions"]))
return guide return guide
@@ -64,6 +65,17 @@ async def download_html(guide_id: str):
return FileResponse(path, filename=f"{guide['topic']}-{guide['format']}.html", media_type="text/html") return FileResponse(path, filename=f"{guide['topic']}-{guide['format']}.html", media_type="text/html")
@router.post("/guides/{guide_id}/rework")
async def rework(guide_id: str, req: GuideReworkRequest):
guide = await get_guide(guide_id)
if guide is None:
raise HTTPException(404, "Guide nicht gefunden")
if guide["status"] != "done":
raise HTTPException(400, "Guide muss fertig sein")
asyncio.create_task(rework_guide(guide_id, guide["topic"], guide["format"], req.instructions.strip()))
return {"ok": True}
@router.post("/guides/{guide_id}/cancel") @router.post("/guides/{guide_id}/cancel")
async def cancel(guide_id: str): async def cancel(guide_id: str):
cancelled = await cancel_guide(guide_id) cancelled = await cancel_guide(guide_id)

View File

@@ -1,6 +1,6 @@
<script setup> <script setup>
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue' import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { fetchGuides, createGuide as apiCreate, deleteGuide, cancelGuide as apiCancel } from './api.js' import { fetchGuides, createGuide as apiCreate, deleteGuide, cancelGuide as apiCancel, reworkGuide as apiRework } from './api.js'
import TopicSidebar from './components/TopicSidebar.vue' import TopicSidebar from './components/TopicSidebar.vue'
import TopicDetail from './components/TopicDetail.vue' import TopicDetail from './components/TopicDetail.vue'
@@ -73,9 +73,15 @@ function createTopic(topic) {
previewGuide.value = null previewGuide.value = null
} }
async function handleFormatClick(format) { async function handleFormatClick({ format, instructions }) {
if (!selectedTopic.value) return if (!selectedTopic.value) return
await apiCreate(selectedTopic.value, format) await apiCreate(selectedTopic.value, format, instructions)
await loadGuides()
startPolling()
}
async function handleRework({ guideId, instructions }) {
await apiRework(guideId, instructions)
await loadGuides() await loadGuides()
startPolling() startPolling()
} }
@@ -162,6 +168,7 @@ onUnmounted(() => {
@cancelGuide="handleCancel" @cancelGuide="handleCancel"
@deleteGuide="handleDeleteGuide" @deleteGuide="handleDeleteGuide"
@preview="handlePreview" @preview="handlePreview"
@rework="handleRework"
/> />
<TopicDetail <TopicDetail
v-if="selectedTopic" v-if="selectedTopic"

View File

@@ -10,11 +10,20 @@ export async function fetchGuide(id) {
return res.json() return res.json()
} }
export async function createGuide(topic, format) { export async function createGuide(topic, format, instructions = '') {
const res = await fetch(`${BASE}/guides`, { const res = await fetch(`${BASE}/guides`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic, format }), body: JSON.stringify({ topic, format, instructions }),
})
return res.json()
}
export async function reworkGuide(id, instructions) {
const res = await fetch(`${BASE}/guides/${id}/rework`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ instructions }),
}) })
return res.json() return res.json()
} }

View File

@@ -8,7 +8,7 @@ const props = defineProps({
allGuides: { type: Array, default: () => [] }, allGuides: { type: Array, default: () => [] },
}) })
const emit = defineEmits(['select', 'create', 'formatClick', 'deleteTopic', 'cancelGuide', 'deleteGuide', 'preview']) const emit = defineEmits(['select', 'create', 'formatClick', 'deleteTopic', 'cancelGuide', 'deleteGuide', 'preview', 'rework'])
const formats = [ const formats = [
{ key: 'OnePager', label: 'OnePager' }, { key: 'OnePager', label: 'OnePager' },
@@ -38,12 +38,33 @@ function handleFormatClick(format) {
} }
} }
function handlePlay(format) { const activeInput = ref(null)
const guide = props.guidesByFormat[format] const inputText = ref('')
if (guide?.status === 'done') {
if (!confirm('Guide überschreiben?')) return function toggleInput(format) {
if (activeInput.value === format) {
activeInput.value = null
inputText.value = ''
} else {
activeInput.value = format
inputText.value = ''
} }
emit('formatClick', format) }
function handlePlay(format) {
const text = activeInput.value === format ? inputText.value.trim() : ''
emit('formatClick', { format, instructions: text })
activeInput.value = null
inputText.value = ''
}
function handleRefresh(format) {
const guide = props.guidesByFormat[format]
if (!guide) return
const text = activeInput.value === format ? inputText.value.trim() : ''
emit('rework', { guideId: guide.id, instructions: text || 'Überarbeite das Layout' })
activeInput.value = null
inputText.value = ''
} }
function handleDelete(format) { function handleDelete(format) {
@@ -94,31 +115,44 @@ function submit() {
<div class="progress-info" v-if="activeGenerations.length"> <div class="progress-info" v-if="activeGenerations.length">
<div v-for="(line, i) in activeGenerations" :key="i">{{ line }}</div> <div v-for="(line, i) in activeGenerations" :key="i">{{ line }}</div>
</div> </div>
<div <div v-for="f in formats" :key="f.key">
v-for="f in formats" <div :class="['format-row', 'fmt-' + guideStatus(f.key)]">
:key="f.key" <button class="format-name" @click="handleFormatClick(f.key)">
:class="['format-row', 'fmt-' + guideStatus(f.key)]" <span class="format-label">{{ f.label }}</span>
> <span
<button class="format-name" @click="handleFormatClick(f.key)"> v-if="guideStatus(f.key) !== 'none'"
{{ f.label }} class="format-x"
</button> @click.stop="handleDelete(f.key)"
<div class="format-actions"> :title="guideStatus(f.key) === 'generating' || guideStatus(f.key) === 'queued' ? 'Abbrechen' : 'Löschen'"
<button >&times;</span>
v-if="guideStatus(f.key) !== 'generating' && guideStatus(f.key) !== 'queued'"
class="action-btn play"
:title="guideStatus(f.key) === 'done' ? 'Neu generieren' : 'Generieren'"
@click="handlePlay(f.key)"
>
{{ guideStatus(f.key) === 'done' ? '↻' : '▶' }}
</button>
<button
v-if="guideStatus(f.key) !== 'none'"
class="action-btn delete"
:title="guideStatus(f.key) === 'generating' || guideStatus(f.key) === 'queued' ? 'Abbrechen' : 'Löschen'"
@click="handleDelete(f.key)"
>
&times;
</button> </button>
<div class="format-actions">
<template v-if="guideStatus(f.key) === 'done'">
<button class="action-btn refresh" title="Überarbeiten" @click="handleRefresh(f.key)"></button>
<button
class="action-btn pencil"
:class="{ active: activeInput === f.key }"
title="Anweisungen"
@click="toggleInput(f.key)"
></button>
</template>
<template v-else-if="guideStatus(f.key) !== 'generating' && guideStatus(f.key) !== 'queued'">
<button class="action-btn play" title="Generieren" @click="handlePlay(f.key)"></button>
<button
class="action-btn pencil"
:class="{ active: activeInput === f.key }"
title="Anweisungen"
@click="toggleInput(f.key)"
></button>
</template>
</div>
</div>
<div v-if="activeInput === f.key" class="format-input">
<input
v-model="inputText"
:placeholder="guideStatus(f.key) === 'done' ? 'Was soll überarbeitet werden?' : 'Anweisungen (optional)…'"
@keyup.enter="guideStatus(f.key) === 'done' ? handleRefresh(f.key) : handlePlay(f.key)"
/>
</div> </div>
</div> </div>
</div> </div>
@@ -252,6 +286,22 @@ function submit() {
border-radius: 4px; border-radius: 4px;
cursor: default; cursor: default;
color: #999; color: #999;
display: flex;
align-items: center;
justify-content: space-between;
}
.format-x {
display: none;
color: #991b1b;
font-size: 1.1rem;
line-height: 1;
cursor: pointer;
padding: 0 2px;
}
.format-name:hover .format-x {
display: inline;
} }
.fmt-done .format-name { .fmt-done .format-name {
@@ -309,14 +359,40 @@ function submit() {
border-color: #34d399; border-color: #34d399;
} }
.action-btn.delete { .action-btn.refresh {
color: #991b1b; color: #059669;
font-size: 1.1rem;
} }
.action-btn.delete:hover { .action-btn.refresh:hover {
background: #fee2e2; background: #d1fae5;
border-color: #f87171; border-color: #34d399;
}
.action-btn.pencil {
color: #6366f1;
}
.action-btn.pencil:hover,
.action-btn.pencil.active {
background: #ede9fe;
border-color: #a5b4fc;
}
.format-input {
padding: 4px 0.75rem 8px;
}
.format-input input {
width: 100%;
padding: 4px 8px;
border: 1px solid #d8dde3;
border-radius: 4px;
font-size: 0.8rem;
outline: none;
}
.format-input input:focus {
border-color: #6366f1;
} }
@keyframes pulse { @keyframes pulse {