diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 90dd30ba0..638a10624 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -42,6 +42,7 @@ import { newsRoutes } from './modules/news/routes'; import { newsResearchRoutes } from './modules/news-research/routes'; import { articlesRoutes } from './modules/articles/routes'; import { tracesRoutes } from './modules/traces/routes'; +import { writingRoutes } from './modules/writing/routes'; import { presiRoutes } from './modules/presi/routes'; import { researchRoutes } from './modules/research/routes'; import { whoRoutes } from './modules/who/routes'; @@ -96,6 +97,7 @@ const RESOURCE_MODULES = [ 'research', 'traces', 'who', + 'writing', ] as const; for (const mod of RESOURCE_MODULES) { app.use(`/api/v1/${mod}/*`, requireTier('beta')); @@ -131,6 +133,7 @@ app.route('/api/v1/presi', presiRoutes); app.route('/api/v1/research', researchRoutes); app.route('/api/v1/website', websiteRoutes); app.route('/api/v1/who', whoRoutes); +app.route('/api/v1/writing', writingRoutes); // ─── Server Info ──────────────────────────────────────────── console.log(`mana-api starting on port ${PORT}...`); diff --git a/apps/api/src/lib/llm.ts b/apps/api/src/lib/llm.ts index 6ae0b4954..6744d99c7 100644 --- a/apps/api/src/lib/llm.ts +++ b/apps/api/src/lib/llm.ts @@ -31,6 +31,15 @@ export interface LlmJsonOptions { maxTokens?: number; } +export interface LlmTextOptions { + model: string; + system?: string; + user: string; + temperature?: number; + maxTokens?: number; + signal?: AbortSignal; +} + export interface LlmStreamOptions { model: string; system?: string; @@ -101,6 +110,56 @@ export async function llmJson(opts: LlmJsonOptions): Promise { } } +/** + * Call the LLM and return the raw text content — no JSON parsing, no + * streaming. Used when you want a finished prose artifact (a generated + * draft, a summary, a translation) as one string. Includes token usage + * when the provider reports it so generation records can store it. + */ +export interface LlmTextResult { + text: string; + tokenUsage?: { input: number; output: number }; + model: string; +} + +export async function llmText(opts: LlmTextOptions): Promise { + const res = await fetch(`${LLM_URL}/v1/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: opts.model, + messages: buildMessages(opts.system, opts.user), + temperature: opts.temperature ?? 0.7, + max_tokens: opts.maxTokens ?? 2000, + }), + signal: opts.signal, + }); + + if (!res.ok) { + const body = await res.text().catch(() => ''); + throw new LlmError(`mana-llm returned ${res.status}`, res.status, body); + } + + const data = (await res.json()) as { + choices?: Array<{ message?: { content?: string } }>; + usage?: { prompt_tokens?: number; completion_tokens?: number }; + model?: string; + }; + const text = data.choices?.[0]?.message?.content; + if (!text) throw new LlmError('mana-llm response missing content'); + return { + text: text.trim(), + tokenUsage: + data.usage && typeof data.usage.prompt_tokens === 'number' + ? { + input: data.usage.prompt_tokens ?? 0, + output: data.usage.completion_tokens ?? 0, + } + : undefined, + model: data.model ?? opts.model, + }; +} + /** * Call the LLM in streaming mode. Invokes onToken() for each delta and * returns the full concatenated text once the stream completes. diff --git a/apps/api/src/modules/writing/routes.ts b/apps/api/src/modules/writing/routes.ts new file mode 100644 index 000000000..c546eeb37 --- /dev/null +++ b/apps/api/src/modules/writing/routes.ts @@ -0,0 +1,94 @@ +/** + * Writing module — one-shot prose generation against mana-llm. + * + * M3 scope: the client sends a fully-built prompt (system + user), we + * round-trip to mana-llm and return the raw completion text. Draft + + * version bookkeeping stays entirely client-side — the browser writes + * the returned text into a new LocalDraftVersion via the generations + * store. This keeps the server stateless and lets the same endpoint + * serve refinement calls later (shorten / expand / tone). + * + * Later milestones: + * M6 — selection-refinement tools will call this same endpoint with + * different system/user prompts (shorten, expand, change tone). + * M7 — long-form drafts flip to mana-ai missions with streaming; the + * sync endpoint here stays for short-form as a fast path. + */ + +import { Hono } from 'hono'; +import { llmText, LlmError } from '../../lib/llm'; +import { logger, type AuthVariables } from '@mana/shared-hono'; + +const DEFAULT_MODEL = process.env.WRITING_MODEL || 'ollama/gemma3:4b'; + +/** Hard cap so a runaway briefing can't burn unlimited tokens. */ +const MAX_OUTPUT_TOKENS = 8000; + +interface GenerationRequest { + systemPrompt?: string; + userPrompt: string; + /** Kind discriminator — logged for observability, not used for routing. */ + kind?: string; + /** Ghostwriter default 0.7; selection-refinements might want 0.3. */ + temperature?: number; + /** Token ceiling. Server clamps to MAX_OUTPUT_TOKENS. */ + maxTokens?: number; + /** Optional model override — most callers leave this unset. */ + model?: string; +} + +const routes = new Hono<{ Variables: AuthVariables }>(); + +routes.post('/generations', async (c) => { + const userId = c.get('userId'); + const body = (await c.req.json()) as Partial; + + if (!body.userPrompt || typeof body.userPrompt !== 'string') { + return c.json({ error: 'userPrompt required' }, 400); + } + + const maxTokens = Math.min(MAX_OUTPUT_TOKENS, Math.max(64, body.maxTokens ?? 2000)); + const temperature = + typeof body.temperature === 'number' ? Math.max(0, Math.min(1.2, body.temperature)) : 0.7; + const model = body.model || DEFAULT_MODEL; + + const startedAt = Date.now(); + try { + const result = await llmText({ + model, + system: body.systemPrompt, + user: body.userPrompt, + temperature, + maxTokens, + }); + const durationMs = Date.now() - startedAt; + logger.info('writing.generation_ok', { + userId, + kind: body.kind, + model: result.model, + outputChars: result.text.length, + tokenUsage: result.tokenUsage, + durationMs, + }); + return c.json({ + output: result.text, + model: result.model, + tokenUsage: result.tokenUsage, + durationMs, + }); + } catch (err) { + const durationMs = Date.now() - startedAt; + const message = err instanceof Error ? err.message : String(err); + logger.error('writing.generation_failed', { + userId, + kind: body.kind, + model, + error: message, + status: err instanceof LlmError ? err.status : undefined, + durationMs, + }); + return c.json({ error: 'Generation failed', detail: message, durationMs }, 500); + } +}); + +export { routes as writingRoutes }; diff --git a/apps/mana/apps/web/src/lib/modules/writing/api.ts b/apps/mana/apps/web/src/lib/modules/writing/api.ts new file mode 100644 index 000000000..fead66017 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/writing/api.ts @@ -0,0 +1,54 @@ +/** + * Writing — server-only API client. Browser → mana-api → mana-llm. + * + * CRUD of drafts/versions/generations stays local-first (IndexedDB + * + sync). This module talks to mana-api for the one operation that + * needs a server-side LLM round-trip: generating the prose itself. + * Everything before and after the fetch — briefing storage, prompt + * composition, version bookkeeping — lives on the client. + */ + +import { authStore } from '$lib/stores/auth.svelte'; +import { getManaApiUrl } from '$lib/api/config'; + +export interface GenerateDraftRequest { + systemPrompt?: string; + userPrompt: string; + /** Plaintext kind discriminator forwarded for server-side logging. */ + kind?: string; + temperature?: number; + maxTokens?: number; + model?: string; +} + +export interface GenerateDraftResponse { + output: string; + model: string; + tokenUsage?: { input: number; output: number }; + durationMs: number; +} + +async function authHeader(): Promise> { + const token = await authStore.getValidToken(); + return token ? { Authorization: `Bearer ${token}` } : {}; +} + +export async function callWritingGeneration( + req: GenerateDraftRequest, + signal?: AbortSignal +): Promise { + const res = await fetch(`${getManaApiUrl()}/api/v1/writing/generations`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(await authHeader()), + }, + body: JSON.stringify(req), + signal, + }); + if (!res.ok) { + const body = await res.text().catch(() => ''); + throw new Error(`Generation failed (${res.status}): ${body || res.statusText}`); + } + return res.json() as Promise; +} diff --git a/apps/mana/apps/web/src/lib/modules/writing/components/GenerationStatus.svelte b/apps/mana/apps/web/src/lib/modules/writing/components/GenerationStatus.svelte new file mode 100644 index 000000000..542f4241c --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/writing/components/GenerationStatus.svelte @@ -0,0 +1,111 @@ + + + + + + diff --git a/apps/mana/apps/web/src/lib/modules/writing/index.ts b/apps/mana/apps/web/src/lib/modules/writing/index.ts index fd6d37b2b..d65864d9a 100644 --- a/apps/mana/apps/web/src/lib/modules/writing/index.ts +++ b/apps/mana/apps/web/src/lib/modules/writing/index.ts @@ -8,6 +8,15 @@ export type { CreateDraftInput, UpdateDraftPatch } from './stores/drafts.svelte' export { stylesStore } from './stores/styles.svelte'; export type { CreateStyleInput, UpdateStylePatch } from './stores/styles.svelte'; +export { generationsStore } from './stores/generations.svelte'; +export type { StartDraftGenerationOptions } from './stores/generations.svelte'; + +export { callWritingGeneration } from './api'; +export type { GenerateDraftRequest, GenerateDraftResponse } from './api'; + +export { buildDraftPrompt, estimateMaxTokens } from './utils/prompt-builder'; +export type { PromptPair, BuildDraftPromptInput } from './utils/prompt-builder'; + export { useAllDrafts, useDraft, diff --git a/apps/mana/apps/web/src/lib/modules/writing/stores/generations.svelte.ts b/apps/mana/apps/web/src/lib/modules/writing/stores/generations.svelte.ts new file mode 100644 index 000000000..ff0e26e7f --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/writing/stores/generations.svelte.ts @@ -0,0 +1,231 @@ +/** + * Writing generations store — orchestrates the "Generate" button end-to-end. + * + * startDraftGeneration flow: + * 1. Write a LocalGeneration with status='queued' → UI shows pending. + * 2. Build the prompt from the draft's briefing + any attached style. + * 3. Flip to status='running', call the mana-api /generations endpoint. + * 4. On success: create a new LocalDraftVersion with the output, point + * the draft at it (currentVersionId flip), mark generation succeeded + * and link it to the version. + * 5. On failure: mark generation failed with the error message so the + * UI can surface it; leave the current version untouched. + * + * Selection-refinements (M6) will add a second entrypoint that writes + * back into the same current version in-place. + */ + +import { encryptRecord } from '$lib/data/crypto'; +import { emitDomainEvent } from '$lib/data/events'; +import { generationTable, draftTable, draftVersionTable, writingStyleTable } from '../collections'; +import { callWritingGeneration } from '../api'; +import { buildDraftPrompt, estimateMaxTokens } from '../utils/prompt-builder'; +import { getStylePreset } from '../presets/styles'; +import type { + LocalDraftVersion, + LocalGeneration, + LocalWritingStyle, + GenerationKind, + GenerationProvider, +} from '../types'; + +const PROVIDER: GenerationProvider = 'mana-llm'; + +function wordCountOf(text: string): number { + const trimmed = text.trim(); + if (!trimmed) return 0; + return trimmed.split(/\s+/).length; +} + +async function loadStyle(styleId: string | null | undefined): Promise { + if (!styleId) return null; + const row = await writingStyleTable.get(styleId); + return row && !row.deletedAt ? row : null; +} + +async function nextVersionNumber(draftId: string): Promise { + const existing = await draftVersionTable.where('draftId').equals(draftId).toArray(); + return Math.max(0, ...existing.map((v) => v.versionNumber)) + 1; +} + +export interface StartDraftGenerationOptions { + /** Override the default ghostwriter temperature (0.7). */ + temperature?: number; + /** Override the auto-computed max-token ceiling. */ + maxTokens?: number; + /** Override the default model. Leave unset to use the server default. */ + model?: string; +} + +export const generationsStore = { + /** + * Generate a fresh draft from the briefing attached to the draft. + * Writes a new LocalDraftVersion and points the draft at it on success. + * Returns the generation id so the caller can subscribe for UI status. + */ + async startDraftGeneration( + draftId: string, + opts: StartDraftGenerationOptions = {} + ): Promise { + const draft = await draftTable.get(draftId); + if (!draft) throw new Error(`Draft ${draftId} not found`); + + const generationId = crypto.randomUUID(); + const kind: GenerationKind = + draft.currentVersionId && + (await draftVersionTable.get(draft.currentVersionId))?.content?.trim() + ? 'full-regenerate' + : 'draft-from-brief'; + const style = await loadStyle(draft.styleId); + const stylePreset = + style?.source === 'preset' && style.presetId ? getStylePreset(style.presetId) : undefined; + + const { system, user } = buildDraftPrompt({ + kind: draft.kind, + title: draft.title, + briefing: draft.briefing, + stylePreset, + styleExtracted: style?.extractedPrinciples ?? undefined, + }); + + const maxTokens = opts.maxTokens ?? estimateMaxTokens(draft.briefing); + const temperature = opts.temperature ?? 0.7; + + // 1. Queued record. Prompt is stored so a later audit knows exactly + // what went to the model; it's encrypted alongside the output. + const now = new Date().toISOString(); + const queued: LocalGeneration = { + id: generationId, + draftId, + kind, + status: 'queued', + prompt: `SYSTEM:\n${system}\n\nUSER:\n${user}`, + provider: PROVIDER, + model: opts.model ?? null, + params: { temperature, maxTokens }, + inputSelection: null, + output: null, + outputVersionId: null, + startedAt: null, + completedAt: null, + durationMs: null, + tokenUsage: null, + error: null, + missionId: null, + }; + await encryptRecord('writingGenerations', queued); + await generationTable.add(queued); + + emitDomainEvent( + 'WritingDraftGenerationStarted', + 'writing', + 'writingGenerations', + generationId, + { generationId, draftId, kind } + ); + + // 2. Flip to running before the fetch so the UI gets a progress tick. + await generationTable.update(generationId, { + status: 'running', + startedAt: now, + updatedAt: new Date().toISOString(), + }); + await draftTable.update(draftId, { + status: 'refining', + updatedAt: new Date().toISOString(), + }); + + try { + const result = await callWritingGeneration({ + systemPrompt: system, + userPrompt: user, + kind: draft.kind, + temperature, + maxTokens, + model: opts.model, + }); + + const versionId = crypto.randomUUID(); + const versionNumber = await nextVersionNumber(draftId); + const newVersion: LocalDraftVersion = { + id: versionId, + draftId, + versionNumber, + content: result.output, + wordCount: wordCountOf(result.output), + generationId, + isAiGenerated: true, + parentVersionId: draft.currentVersionId ?? null, + summary: null, + }; + await encryptRecord('writingDraftVersions', newVersion); + await draftVersionTable.add(newVersion); + + const completedAt = new Date().toISOString(); + const successPatch: Record = { + status: 'succeeded', + output: result.output, + outputVersionId: versionId, + model: result.model, + tokenUsage: result.tokenUsage ?? null, + completedAt, + durationMs: result.durationMs, + updatedAt: completedAt, + }; + await encryptRecord('writingGenerations', successPatch); + await generationTable.update(generationId, successPatch); + + // Point the draft at the new version. Keep status='refining' + // because the user typically reviews + tweaks after a generate. + await draftTable.update(draftId, { + currentVersionId: versionId, + updatedAt: completedAt, + }); + + emitDomainEvent('WritingDraftVersionCreated', 'writing', 'writingDraftVersions', versionId, { + draftId, + versionId, + versionNumber, + isAiGenerated: true, + generationId, + }); + + return generationId; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const completedAt = new Date().toISOString(); + await generationTable.update(generationId, { + status: 'failed', + error: message, + completedAt, + durationMs: Date.now() - new Date(now).getTime(), + updatedAt: completedAt, + }); + emitDomainEvent( + 'WritingDraftGenerationFailed', + 'writing', + 'writingGenerations', + generationId, + { generationId, draftId, error: message } + ); + throw err; + } + }, + + /** + * Mark a generation as cancelled client-side. We don't abort the + * server call in M3 (the fetch runs to completion and the result is + * just ignored); a proper AbortSignal pass-through can come with the + * streaming path in M7. + */ + async cancelGeneration(generationId: string) { + const existing = await generationTable.get(generationId); + if (!existing) return; + if (existing.status === 'succeeded' || existing.status === 'failed') return; + await generationTable.update(generationId, { + status: 'cancelled', + completedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + }, +}; diff --git a/apps/mana/apps/web/src/lib/modules/writing/utils/prompt-builder.ts b/apps/mana/apps/web/src/lib/modules/writing/utils/prompt-builder.ts new file mode 100644 index 000000000..ce17a6f22 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/writing/utils/prompt-builder.ts @@ -0,0 +1,133 @@ +/** + * Prompt builder — turns a briefing (+ optional style) into a system/user + * prompt pair for mana-llm. Deliberately pure and client-side: everything + * that goes into the prompt is already decrypted in the store and the + * API endpoint is a thin passthrough, so building the prompt here keeps + * all the taste-and-tone decisions visible to the user. + * + * References (M5+) will extend `buildDraftPrompt` with resolved input + * snippets. Selection-refinement prompts (M6) live as their own builders + * alongside this one. + */ + +import { KIND_LABELS } from '../constants'; +import type { DraftBriefing, DraftKind, StyleExtractedPrinciples } from '../types'; +import type { StylePreset } from '../presets/styles'; + +export interface PromptPair { + system: string; + user: string; +} + +const LANGUAGE_LABELS: Record = { + de: 'Deutsch', + en: 'English', + fr: 'Français', + es: 'Español', + it: 'Italiano', +}; + +function languageLabel(code: string): string { + return LANGUAGE_LABELS[code] ?? code; +} + +function kindLabel(kind: DraftKind): string { + const de = KIND_LABELS[kind].de; + // Drop pluralising 's' / use singular voice for the prompt. + if (kind === 'blog') return 'Blogpost'; + if (kind === 'essay') return 'Essay'; + if (kind === 'email') return 'E-Mail'; + if (kind === 'social') return 'Social-Media-Post'; + if (kind === 'story') return 'Kurzgeschichte'; + if (kind === 'letter') return 'Brief'; + if (kind === 'speech') return 'Rede'; + if (kind === 'cover-letter') return 'Bewerbungsanschreiben'; + if (kind === 'product-description') return 'Produktbeschreibung'; + if (kind === 'press-release') return 'Pressemitteilung'; + if (kind === 'bio') return 'Bio / Kurzvita'; + return de; +} + +function renderStyle( + preset: StylePreset | undefined, + principles: StyleExtractedPrinciples | undefined +): string | null { + if (preset) { + const lines: string[] = [`Stil: ${preset.name.de}. ${preset.description.de}`]; + if (preset.principles.rawAnalysis) { + lines.push(`Stil-Richtlinien: ${preset.principles.rawAnalysis}`); + } + if (preset.principles.toneTraits.length) { + lines.push(`Ton: ${preset.principles.toneTraits.join(', ')}.`); + } + return lines.join('\n'); + } + if (principles) { + const lines: string[] = []; + if (principles.rawAnalysis) lines.push(`Stil: ${principles.rawAnalysis}`); + if (principles.toneTraits.length) lines.push(`Ton: ${principles.toneTraits.join(', ')}.`); + if (principles.vocabulary?.length) + lines.push(`Bevorzugtes Vokabular: ${principles.vocabulary.join(', ')}.`); + return lines.length ? lines.join('\n') : null; + } + return null; +} + +export interface BuildDraftPromptInput { + kind: DraftKind; + title: string; + briefing: DraftBriefing; + stylePreset?: StylePreset; + styleExtracted?: StyleExtractedPrinciples; +} + +/** + * Build a system + user prompt for a fresh draft. M3 shape — referenced + * inputs (articles / notes / library) are NOT injected yet; that's M5. + * The system prompt forbids preamble / sign-off / meta commentary so + * the returned text is ready to paste into a version. + */ +export function buildDraftPrompt(input: BuildDraftPromptInput): PromptPair { + const { kind, title, briefing, stylePreset, styleExtracted } = input; + const lang = languageLabel(briefing.language); + const kindLbl = kindLabel(kind); + + const systemLines: string[] = [ + `Du bist ein professioneller Ghostwriter. Deine Aufgabe: Schreibe einen fertigen ${kindLbl} auf ${lang} basierend auf dem Briefing des Nutzers.`, + `Gib ausschließlich den fertigen Text zurück. Keine Einleitung, keine Metakommentare, kein "Hier ist dein Text", keine Abschlussphrase nach dem Text. Markdown ist erlaubt, aber nicht erzwungen.`, + ]; + const styleBlock = renderStyle(stylePreset, styleExtracted); + if (styleBlock) systemLines.push(styleBlock); + + const userLines: string[] = []; + userLines.push(`Titel: ${title}`); + userLines.push(`Thema: ${briefing.topic}`); + if (briefing.audience) userLines.push(`Zielgruppe: ${briefing.audience}`); + if (briefing.tone) userLines.push(`Ton: ${briefing.tone}`); + if (briefing.targetLength) { + const { type, value } = briefing.targetLength; + const unitLabel = type === 'words' ? 'Wörter' : type === 'chars' ? 'Zeichen' : 'Minuten'; + userLines.push(`Ziel-Länge: ca. ${value} ${unitLabel}`); + } + if (briefing.extraInstructions) { + userLines.push(`Zusätzliche Hinweise: ${briefing.extraInstructions}`); + } + userLines.push(''); + userLines.push(`Schreibe den ${kindLbl} jetzt.`); + + return { + system: systemLines.join('\n\n'), + user: userLines.join('\n'), + }; +} + +/** + * Rough max-tokens heuristic — 2x target words + buffer, clamped to 8000. + * Words-to-tokens ratio of ~1.5 for German and English; 2x leaves room. + */ +export function estimateMaxTokens(briefing: DraftBriefing): number { + const target = briefing.targetLength?.value ?? 500; + const unit = briefing.targetLength?.type ?? 'words'; + const words = unit === 'words' ? target : unit === 'chars' ? target / 5 : target * 150; + return Math.min(8000, Math.max(256, Math.round(words * 2 + 200))); +} diff --git a/apps/mana/apps/web/src/lib/modules/writing/views/DetailView.svelte b/apps/mana/apps/web/src/lib/modules/writing/views/DetailView.svelte index 0ac74e284..595b7fd91 100644 --- a/apps/mana/apps/web/src/lib/modules/writing/views/DetailView.svelte +++ b/apps/mana/apps/web/src/lib/modules/writing/views/DetailView.svelte @@ -12,8 +12,15 @@ import StatusBadge from '../components/StatusBadge.svelte'; import VersionEditor from '../components/VersionEditor.svelte'; import VersionHistory from '../components/VersionHistory.svelte'; + import GenerationStatus from '../components/GenerationStatus.svelte'; import { draftsStore } from '../stores/drafts.svelte'; - import { useDraft, useVersionsForDraft, useCurrentVersionForDraft } from '../queries'; + import { generationsStore } from '../stores/generations.svelte'; + import { + useDraft, + useVersionsForDraft, + useCurrentVersionForDraft, + useGenerationsForDraft, + } from '../queries'; import { KIND_LABELS, STATUS_LABELS } from '../constants'; import type { DraftStatus } from '../types'; @@ -28,12 +35,30 @@ const versions$ = useVersionsForDraft(id); /* svelte-ignore state_referenced_locally */ const currentVersion$ = useCurrentVersionForDraft(id); + /* svelte-ignore state_referenced_locally */ + const generations$ = useGenerationsForDraft(id); const draft = $derived(draft$.value); const versions = $derived(versions$.value); const currentVersion = $derived(currentVersion$.value); + const generations = $derived(generations$.value); + + // Surface the freshest running generation, or the most recent failure + // so the user can dismiss it. On success we hide — the new version is + // already live in the editor via the currentVersionId pointer. + const latestGeneration = $derived( + generations.find((g) => g.status === 'queued' || g.status === 'running') ?? + generations.find((g) => g.status === 'failed') ?? + null + ); + let dismissedGenerationIds = $state>(new Set()); + const visibleGeneration = $derived( + latestGeneration && !dismissedGenerationIds.has(latestGeneration.id) ? latestGeneration : null + ); let briefingOpen = $state(false); let saving = $state(false); + let generating = $state(false); + let generateError = $state(null); async function setStatus(next: DraftStatus) { if (!draft) return; @@ -62,6 +87,25 @@ goto('/writing'); } + async function generate() { + if (!draft || generating) return; + generating = true; + generateError = null; + try { + await generationsStore.startDraftGeneration(draft.id); + } catch (err) { + generateError = err instanceof Error ? err.message : String(err); + } finally { + generating = false; + } + } + + function dismissGeneration(id: string) { + dismissedGenerationIds = new Set([...dismissedGenerationIds, id]); + } + + const hasDraftContent = $derived((currentVersion?.content ?? '').trim().length > 0); + const kind = $derived(draft ? KIND_LABELS[draft.kind] : null); const targetWords = $derived(draft?.briefing.targetLength?.value ?? null); const STATUS_ORDER: DraftStatus[] = ['draft', 'refining', 'complete', 'published']; @@ -130,22 +174,50 @@
{#if currentVersion}
-
+
Version {currentVersion.versionNumber} {#if currentVersion.isAiGenerated} KI {/if}
- +
+ + +
+ {#if visibleGeneration} + dismissGeneration(visibleGeneration.id)} + /> + {/if} + {#if generateError} +

{generateError}

+ {/if} {:else}

Diese Version existiert nicht mehr.

@@ -321,6 +393,34 @@ text-transform: uppercase; letter-spacing: 0.04em; } + .version-label { + display: flex; + align-items: center; + gap: 0.3rem; + } + .editor-actions { + display: inline-flex; + gap: 0.4rem; + } + .generate { + padding: 0.4rem 0.9rem; + border-radius: 0.5rem; + border: 1px solid #0ea5e9; + background: #0ea5e9; + color: white; + cursor: pointer; + font: inherit; + font-weight: 500; + font-size: 0.85rem; + } + .generate:hover:not(:disabled) { + background: #0284c7; + border-color: #0284c7; + } + .generate:disabled { + opacity: 0.6; + cursor: not-allowed; + } .checkpoint { padding: 0.4rem 0.8rem; border-radius: 0.5rem; @@ -338,6 +438,15 @@ opacity: 0.5; cursor: not-allowed; } + .error { + margin: 0; + padding: 0.5rem 0.75rem; + border-radius: 0.5rem; + color: #ef4444; + background: color-mix(in srgb, #ef4444 6%, transparent); + border: 1px solid color-mix(in srgb, #ef4444 40%, transparent); + font-size: 0.85rem; + } .history-column h2 { font-size: 0.8rem; margin: 0 0 0.5rem;