+ Zielsprache:
+ {#each LANGUAGES as lang (lang.code)}
+
+ {/each}
+
+ {/if}
+
+ {#if expanded === 'rewrite'}
+
+ ev.key === 'Enter' && rewrite()}
+ />
+
+
+ {/if}
+
+
+
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}