diff --git a/apps/mana/apps/web/src/lib/modules/writing/components/RefinementPanel.svelte b/apps/mana/apps/web/src/lib/modules/writing/components/RefinementPanel.svelte new file mode 100644 index 000000000..4662f5c64 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/writing/components/RefinementPanel.svelte @@ -0,0 +1,220 @@ + + + +
+
+
+ + {state.toolLabel} + {#if state.status === 'running'} + Läuft… + {:else if state.status === 'failed'} + Fehlgeschlagen + {:else} + Vorschlag bereit + {/if} +
+ +
+ +
+
+

Original

+

{state.originalText}

+
+
+

Vorschlag

+ {#if state.status === 'running'} +

Generiert…

+ {:else if state.status === 'failed'} +

{state.error ?? 'Unbekannter Fehler.'}

+ {:else if state.refined} +

{state.refined}

+ {:else} +

Kein Ergebnis.

+ {/if} +
+
+ + +
+ + diff --git a/apps/mana/apps/web/src/lib/modules/writing/components/SelectionToolbar.svelte b/apps/mana/apps/web/src/lib/modules/writing/components/SelectionToolbar.svelte new file mode 100644 index 000000000..36c193c42 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/writing/components/SelectionToolbar.svelte @@ -0,0 +1,258 @@ + + + + + + diff --git a/apps/mana/apps/web/src/lib/modules/writing/components/VersionEditor.svelte b/apps/mana/apps/web/src/lib/modules/writing/components/VersionEditor.svelte index e333c5625..0956d7605 100644 --- a/apps/mana/apps/web/src/lib/modules/writing/components/VersionEditor.svelte +++ b/apps/mana/apps/web/src/lib/modules/writing/components/VersionEditor.svelte @@ -3,19 +3,41 @@ Saves with a short debounce so every keystroke doesn't hit Dexie; on blur it force-flushes any pending edit. The word-count is computed locally for live feedback and re-derived on save. + + Selection tracking (M6): the textarea reports its selection range via + onselect whenever the user drags or keyboard-selects, and accepts + external content swaps (after a selection-refinement apply, or a + version restore) via the `forceContent` prop. -->
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 index a92784abc..985c490e7 100644 --- 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 @@ -19,12 +19,25 @@ 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 { + buildDraftPrompt, + buildShortenPrompt, + buildExpandPrompt, + buildChangeTonePrompt, + buildRewritePrompt, + buildTranslatePrompt, + estimateMaxTokens, + type SelectionContext, + type ChangeToneParams, + type RewriteParams, + type TranslateParams, +} from '../utils/prompt-builder'; import { getStylePreset, type StylePreset } from '../presets/styles'; import type { LocalDraftVersion, LocalGeneration, LocalWritingStyle, + DraftSelection, GenerationKind, GenerationProvider, } from '../types'; @@ -233,6 +246,196 @@ export const generationsStore = { } }, + /** + * Run a selection-refinement against the LLM. Does NOT mutate the + * version — the UI decides whether to accept the result via + * `VersionEditor.applyReplacement()`. Returns the refined text so the + * RefinementPanel can render it alongside the original. The + * LocalGeneration record is written either way so every refine-attempt + * stays auditable, including rejected ones. + * + * `params` shape depends on `kind`: + * - selection-shorten / selection-expand: no params + * - selection-tone: { targetTone } + * - selection-rewrite: { instruction } + * - selection-translate: { targetLanguage } + */ + async refineSelection( + draftId: string, + versionId: string, + selection: DraftSelection & { text: string }, + kind: + | 'selection-shorten' + | 'selection-expand' + | 'selection-tone' + | 'selection-rewrite' + | 'selection-translate', + params?: ChangeToneParams | RewriteParams | TranslateParams + ): Promise<{ generationId: string; refined: string }> { + const draft = await draftTable.get(draftId); + if (!draft) throw new Error(`Draft ${draftId} not found`); + + const resolved = await loadStyle(draft.styleId); + const stylePreset = + resolved?.source === 'preset' + ? resolved.preset + : resolved?.source === 'custom' && resolved.row.presetId + ? getStylePreset(resolved.row.presetId) + : undefined; + const styleExtracted = + resolved?.source === 'custom' ? (resolved.row.extractedPrinciples ?? undefined) : undefined; + + const ctx: SelectionContext = { + selectionText: selection.text, + language: draft.briefing.language, + stylePreset, + styleExtracted, + }; + + let prompt; + switch (kind) { + case 'selection-shorten': + prompt = buildShortenPrompt(ctx); + break; + case 'selection-expand': + prompt = buildExpandPrompt(ctx); + break; + case 'selection-tone': + prompt = buildChangeTonePrompt(ctx, params as ChangeToneParams); + break; + case 'selection-rewrite': + prompt = buildRewritePrompt(ctx, params as RewriteParams); + break; + case 'selection-translate': + prompt = buildTranslatePrompt(ctx, params as TranslateParams); + break; + } + + // Size the token budget to the selection, not the whole draft — + // the output is a replacement for the selected text, so the input + // size is the right anchor. Leave 2x headroom for expand. + const selectionWords = selection.text.trim().split(/\s+/).filter(Boolean).length; + const maxTokens = Math.min(4000, Math.max(200, Math.round(selectionWords * 4 + 200))); + // Refinements are deliberately less creative than fresh generations — + // the user picked a narrow operation, don't wander. + const temperature = 0.4; + + const generationId = crypto.randomUUID(); + const nowIso = new Date().toISOString(); + const queued: LocalGeneration = { + id: generationId, + draftId, + kind, + status: 'queued', + prompt: `SYSTEM:\n${prompt.system}\n\nUSER:\n${prompt.user}`, + provider: PROVIDER, + model: null, + params: { temperature, maxTokens }, + inputSelection: { start: selection.start, end: selection.end }, + output: null, + outputVersionId: null, + startedAt: nowIso, + completedAt: null, + durationMs: null, + tokenUsage: null, + error: null, + missionId: null, + }; + await encryptRecord('writingGenerations', queued); + await generationTable.add(queued); + + emitDomainEvent( + 'WritingSelectionRefineStarted', + 'writing', + 'writingGenerations', + generationId, + { generationId, draftId, versionId, kind } + ); + + await generationTable.update(generationId, { + status: 'running', + updatedAt: new Date().toISOString(), + }); + + try { + const result = await callWritingGeneration({ + systemPrompt: prompt.system, + userPrompt: prompt.user, + kind, + temperature, + maxTokens, + }); + const completedAt = new Date().toISOString(); + const successPatch: Record = { + status: 'succeeded', + output: result.output, + model: result.model, + tokenUsage: result.tokenUsage ?? null, + completedAt, + durationMs: result.durationMs, + updatedAt: completedAt, + }; + await encryptRecord('writingGenerations', successPatch); + await generationTable.update(generationId, successPatch); + return { generationId, refined: result.output }; + } 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(nowIso).getTime(), + updatedAt: completedAt, + }); + throw err; + } + }, + + /** + * Commit a refinement: replace the selection range in the current + * version's content with `replacement` and link the source generation + * to the updated version. Returns the pre-refinement content so the + * caller can offer a one-step undo. + */ + async applyRefinement( + versionId: string, + selection: DraftSelection, + replacement: string, + generationId: string + ): Promise<{ before: string; after: string }> { + const existing = await draftVersionTable.get(versionId); + if (!existing) throw new Error(`Version ${versionId} not found`); + const before = existing.content; + const after = before.slice(0, selection.start) + replacement + before.slice(selection.end); + + const wrapped: Record = { + content: after, + wordCount: wordCountOf(after), + }; + await encryptRecord('writingDraftVersions', wrapped); + const now = new Date().toISOString(); + await draftVersionTable.update(versionId, { ...wrapped, updatedAt: now }); + await draftTable.update(existing.draftId, { updatedAt: now }); + + // Mark the generation as "applied" by pointing it at the version + // whose content it modified. The version isn't a new row — it's + // the same version with replaced content — but having the back- + // reference makes the generation record useful for audits. + await generationTable.update(generationId, { + outputVersionId: versionId, + updatedAt: now, + }); + + emitDomainEvent('WritingSelectionRefineApplied', 'writing', 'writingDraftVersions', versionId, { + versionId, + draftId: existing.draftId, + generationId, + }); + + return { before, after }; + }, + /** * 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 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 index ce17a6f22..216665cf3 100644 --- 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 @@ -131,3 +131,120 @@ export function estimateMaxTokens(briefing: DraftBriefing): number { const words = unit === 'words' ? target : unit === 'chars' ? target / 5 : target * 150; return Math.min(8000, Math.max(256, Math.round(words * 2 + 200))); } + +// ─── Selection-refinement prompts (M6) ─────────────────── + +/** + * Optional style hint appended to selection-refinement prompts so the + * replacement doesn't drift away from the draft's overall voice. We pass + * the raw principles description rather than re-instantiating the whole + * system prompt because the selection prompt has different guardrails + * (never add preamble, never explain, just return the replacement). + */ +function styleHintBlock( + stylePreset: StylePreset | undefined, + styleExtracted: StyleExtractedPrinciples | undefined +): string | null { + if (stylePreset) { + return `Stil-Kontext: ${stylePreset.name.de}. ${stylePreset.principles.rawAnalysis ?? ''}`.trim(); + } + if (styleExtracted?.rawAnalysis) { + return `Stil-Kontext: ${styleExtracted.rawAnalysis}`; + } + return null; +} + +export interface SelectionContext { + selectionText: string; + language: string; + stylePreset?: StylePreset; + styleExtracted?: StyleExtractedPrinciples; +} + +const SELECTION_SYSTEM_TAIL = + 'Gib ausschließlich die neue Version des Ausschnitts zurück — kein Präfix wie "Hier ist…", keine Anführungszeichen, keine Erklärung davor oder danach. Nur der Ersatztext.'; + +function fenceSelection(selection: string): string { + return `---\n${selection}\n---`; +} + +function selectionPrompt( + ctx: SelectionContext, + systemHead: string, + userInstruction: string +): PromptPair { + const systemLines = [systemHead, SELECTION_SYSTEM_TAIL]; + const styleBlock = styleHintBlock(ctx.stylePreset, ctx.styleExtracted); + if (styleBlock) systemLines.push(styleBlock); + const userLines = [ + userInstruction, + `Sprache: ${languageLabel(ctx.language)}.`, + '', + fenceSelection(ctx.selectionText), + ]; + return { + system: systemLines.join('\n\n'), + user: userLines.join('\n'), + }; +} + +export function buildShortenPrompt(ctx: SelectionContext): PromptPair { + return selectionPrompt( + ctx, + 'Du kürzt Textpassagen. Behalte den Kerngedanken und den Ton bei, entferne nur Redundanzen, Füllwörter und Nebensätze.', + 'Kürze den folgenden Ausschnitt deutlich (ziel: ~50–60% der ursprünglichen Länge).' + ); +} + +export function buildExpandPrompt(ctx: SelectionContext): PromptPair { + return selectionPrompt( + ctx, + 'Du erweiterst Textpassagen mit zusätzlichem Detail, Beispielen oder Nuancen, ohne den Ton zu verlieren.', + 'Erweitere den folgenden Ausschnitt deutlich (ziel: ~150–180% der ursprünglichen Länge). Füge Details, Beispiele oder weiterführende Gedanken hinzu, bleib aber beim Thema.' + ); +} + +export interface ChangeToneParams { + targetTone: string; +} + +export function buildChangeTonePrompt(ctx: SelectionContext, params: ChangeToneParams): PromptPair { + return selectionPrompt( + ctx, + 'Du schreibst Textpassagen im angegebenen Ton um, ohne den Inhalt zu verändern.', + `Schreibe den folgenden Ausschnitt im Ton "${params.targetTone}" um. Behalte den Sinn und die Länge grob bei, passe nur Wortwahl, Satzbau und Haltung an den neuen Ton an.` + ); +} + +export interface RewriteParams { + instruction: string; +} + +export function buildRewritePrompt(ctx: SelectionContext, params: RewriteParams): PromptPair { + return selectionPrompt( + ctx, + 'Du schreibst Textpassagen nach der Anweisung des Nutzers um.', + `Schreibe den folgenden Ausschnitt gemäß dieser Anweisung um: ${params.instruction}` + ); +} + +export interface TranslateParams { + targetLanguage: string; +} + +export function buildTranslatePrompt(ctx: SelectionContext, params: TranslateParams): PromptPair { + const targetLabel = languageLabel(params.targetLanguage); + // Translate overrides the usual "keep source language" rule; build a + // lean pair that doesn't contradict itself. + return { + system: [ + 'Du übersetzt Textpassagen. Behalte Ton und Struktur des Originals bei. Behalte Eigennamen und technische Begriffe unverändert, außer sie haben eine etablierte Entsprechung.', + SELECTION_SYSTEM_TAIL, + ].join('\n\n'), + user: [ + `Übersetze den folgenden Ausschnitt nach ${targetLabel}.`, + '', + fenceSelection(ctx.selectionText), + ].join('\n'), + }; +} 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 160c654e7..33664be97 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 @@ -11,8 +11,16 @@ import BriefingForm from '../components/BriefingForm.svelte'; import StatusBadge from '../components/StatusBadge.svelte'; import VersionEditor from '../components/VersionEditor.svelte'; + import type { EditorSelection } from '../components/VersionEditor.svelte'; import VersionHistory from '../components/VersionHistory.svelte'; import GenerationStatus from '../components/GenerationStatus.svelte'; + import SelectionToolbar from '../components/SelectionToolbar.svelte'; + import type { + SelectionToolKind, + SelectionToolInvocation, + } from '../components/SelectionToolbar.svelte'; + import RefinementPanel from '../components/RefinementPanel.svelte'; + import type { RefinementState } from '../components/RefinementPanel.svelte'; import { draftsStore } from '../stores/drafts.svelte'; import { generationsStore } from '../stores/generations.svelte'; import { @@ -62,6 +70,32 @@ let generating = $state(false); let generateError = $state(null); + // Selection refinement (M6). + let activeSelection = $state(null); + let refinement = $state< + | (RefinementState & { + generationId?: string; + selection: EditorSelection; + params?: SelectionToolInvocation['params']; + }) + | null + >(null); + // One-step undo target after an accepted refinement. Cleared when + // the user starts the next refinement or navigates away. + let refineUndo = $state<{ content: string; label: string } | null>(null); + // Nonce string that VersionEditor watches to swap its local text + // after an apply / undo. Monotonic so two identical-content swaps + // still trigger a re-sync. + let forceEditorContent = $state(null); + + const TOOL_LABEL: Record = { + 'selection-shorten': 'Kürzen', + 'selection-expand': 'Erweitern', + 'selection-tone': 'Ton ändern', + 'selection-rewrite': 'Umschreiben', + 'selection-translate': 'Übersetzen', + }; + async function setStatus(next: DraftStatus) { if (!draft) return; await draftsStore.setStatus(draft.id, next); @@ -106,6 +140,88 @@ dismissedGenerationIds = new Set([...dismissedGenerationIds, id]); } + // ─── Selection-refinement handlers (M6) ────────────────── + + async function runRefinement(invocation: SelectionToolInvocation, selection: EditorSelection) { + if (!draft || !currentVersion) return; + // Fresh refinements supersede any visible undo target; otherwise + // "Rückgängig" would revert to a pre-previous-refinement state + // the user has already moved past. + refineUndo = null; + refinement = { + kind: invocation.kind, + toolLabel: TOOL_LABEL[invocation.kind], + originalText: selection.text, + status: 'running', + selection, + params: invocation.params, + }; + try { + const { generationId, refined } = await generationsStore.refineSelection( + draft.id, + currentVersion.id, + selection, + invocation.kind, + // The store validates params shape per kind; undefined is fine + // for the two no-param kinds (shorten / expand). + invocation.params as never + ); + if (!refinement || refinement.selection !== selection) return; // user moved on + refinement = { + ...refinement, + status: 'succeeded', + refined, + generationId, + }; + } catch (err) { + if (!refinement || refinement.selection !== selection) return; + refinement = { + ...refinement, + status: 'failed', + error: err instanceof Error ? err.message : String(err), + }; + } + } + + async function acceptRefinement() { + if (!refinement || !currentVersion) return; + if (refinement.status !== 'succeeded' || !refinement.refined || !refinement.generationId) { + return; + } + const { before, after } = await generationsStore.applyRefinement( + currentVersion.id, + refinement.selection, + refinement.refined, + refinement.generationId + ); + // Nudge the editor to replace its local text with the new content. + // `forceContent` is watched as a nonce so identical strings still + // trigger a re-sync if two applies happen back to back. + forceEditorContent = after; + refineUndo = { content: before, label: refinement.toolLabel }; + refinement = null; + activeSelection = null; + } + + function retryRefinement() { + if (!refinement) return; + const sel = refinement.selection; + const params = refinement.params; + void runRefinement({ kind: refinement.kind, params }, sel); + } + + function cancelRefinement() { + refinement = null; + } + + async function undoLastRefinement() { + if (!refineUndo || !currentVersion) return; + const restored = refineUndo.content; + await draftsStore.updateVersionContent(currentVersion.id, restored); + forceEditorContent = restored; + refineUndo = null; + } + const hasDraftContent = $derived((currentVersion?.content ?? '').trim().length > 0); const kind = $derived(draft ? KIND_LABELS[draft.kind] : null); @@ -239,7 +355,33 @@ {#if generateError}

{generateError}

{/if} - + {#if activeSelection && !refinement} + activeSelection && runRefinement(invocation, activeSelection)} + /> + {/if} + {#if refinement} + + {/if} + {#if refineUndo && !refinement} +
+ +
+ {/if} + (activeSelection = sel)} + /> {:else}

Diese Version existiert nicht mehr.

{/if} @@ -477,6 +619,24 @@ border: 1px solid color-mix(in srgb, #ef4444 40%, transparent); font-size: 0.85rem; } + .undo-row { + display: flex; + justify-content: flex-end; + } + .undo-btn { + padding: 0.35rem 0.8rem; + border-radius: 0.45rem; + border: 1px solid var(--color-border, rgba(0, 0, 0, 0.1)); + background: var(--color-surface, transparent); + color: inherit; + cursor: pointer; + font: inherit; + font-size: 0.8rem; + } + .undo-btn:hover { + border-color: #0ea5e9; + color: #0ea5e9; + } .history-column h2 { font-size: 0.8rem; margin: 0 0 0.5rem;