feat(writing): M6 — selection-refinement tools

Users can now select a passage in the editor and refine it in place via
five targeted operations instead of re-generating the whole draft:
Shorten, Expand, Change tone, Rewrite (freeform instruction), Translate.

- Five selection-specific prompt builders in utils/prompt-builder.ts.
  Each forbids preamble / quoting / explanation so the output is a drop-
  in replacement for the selected text. Style context is injected when
  present so refinements stay on-voice.
- generations.store.refineSelection() sizes the token budget to the
  selection (selectionWords * 4 + 200), runs at temperature 0.4 for
  consistency, and records the attempt as a LocalGeneration with kind
  'selection-*' + inputSelection range regardless of whether the user
  accepts — every refine-attempt stays auditable.
- applyRefinement() commits the replacement to the current version's
  content (not a new version; in-place per the plan) and back-links the
  generation via outputVersionId so later audits can trace each edit.
- SelectionToolbar appears above the editor when the user has a non-
  empty selection; Tone + Translate expand to pickers, Rewrite to a
  text input.
- RefinementPanel shows original + refined side-by-side with Übernehmen /
  Noch mal / Verwerfen. Running and failed states get their own chrome.
- VersionEditor tracks textarea selection via select/mouseup/keyup and
  reports {start, end, text} via onselect. New `forceContent` prop
  nonce lets the parent swap the editor's local text after an apply or
  undo without breaking the debouncing layer.
- One-step undo: "↶ Rückgängig: <tool>" button surfaces briefly after
  an accepted refinement and restores the pre-refinement content via
  draftsStore.updateVersionContent. Kicking off a new refinement
  clears the undo target.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-24 15:37:04 +02:00
parent 27c1860f82
commit 19e0f33665
6 changed files with 1014 additions and 2 deletions

View file

@ -0,0 +1,220 @@
<!--
RefinementPanel — preview a selection-refinement result and let the
user accept / retry / discard. Renders the original and the refined
version side-by-side so the diff is obvious at a glance. While the
generation is still in flight we show a pulsing state; on failure the
error message + a "Noch mal" button.
-->
<script lang="ts">
import type { SelectionToolKind } from './SelectionToolbar.svelte';
export interface RefinementState {
kind: SelectionToolKind;
toolLabel: string;
originalText: string;
status: 'running' | 'succeeded' | 'failed';
refined?: string;
error?: string;
}
let {
state,
onaccept,
onretry,
oncancel,
}: {
state: RefinementState;
onaccept: () => void;
onretry: () => void;
oncancel: () => void;
} = $props();
</script>
<section
class="panel"
class:running={state.status === 'running'}
class:error={state.status === 'failed'}
>
<header>
<div class="label">
<span class="dot" aria-hidden="true"></span>
<strong>{state.toolLabel}</strong>
{#if state.status === 'running'}
<span class="muted">Läuft…</span>
{:else if state.status === 'failed'}
<span class="err-label">Fehlgeschlagen</span>
{:else}
<span class="muted">Vorschlag bereit</span>
{/if}
</div>
<button type="button" class="close" onclick={oncancel} aria-label="Schließen">×</button>
</header>
<div class="cols">
<div class="col">
<h4>Original</h4>
<p class="text">{state.originalText}</p>
</div>
<div class="col">
<h4>Vorschlag</h4>
{#if state.status === 'running'}
<p class="text muted italic">Generiert…</p>
{:else if state.status === 'failed'}
<p class="text err-text">{state.error ?? 'Unbekannter Fehler.'}</p>
{:else if state.refined}
<p class="text refined">{state.refined}</p>
{:else}
<p class="text muted italic">Kein Ergebnis.</p>
{/if}
</div>
</div>
<footer>
{#if state.status === 'succeeded'}
<button type="button" class="primary" onclick={onaccept}>Übernehmen</button>
<button type="button" class="secondary" onclick={onretry}>Noch mal</button>
<button type="button" class="secondary" onclick={oncancel}>Verwerfen</button>
{:else if state.status === 'failed'}
<button type="button" class="primary" onclick={onretry}>Noch mal</button>
<button type="button" class="secondary" onclick={oncancel}>Abbrechen</button>
{/if}
</footer>
</section>
<style>
.panel {
display: flex;
flex-direction: column;
gap: 0.6rem;
padding: 0.75rem 1rem;
border-radius: 0.65rem;
border: 1px solid color-mix(in srgb, #0ea5e9 40%, transparent);
background: color-mix(in srgb, #0ea5e9 5%, transparent);
}
.panel.error {
border-color: color-mix(in srgb, #ef4444 50%, transparent);
background: color-mix(in srgb, #ef4444 5%, transparent);
}
header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.label {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.85rem;
}
.dot {
width: 0.55rem;
height: 0.55rem;
border-radius: 999px;
background: #0ea5e9;
}
.panel.running .dot {
animation: pulse 1.1s ease-in-out infinite;
}
.panel.error .dot {
background: #ef4444;
}
@keyframes pulse {
0%,
100% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(0.7);
opacity: 0.5;
}
}
.muted {
color: var(--color-text-muted, rgba(0, 0, 0, 0.55));
}
.err-label {
color: #ef4444;
}
.close {
background: transparent;
border: none;
color: inherit;
font-size: 1.2rem;
line-height: 1;
padding: 0 0.3rem;
cursor: pointer;
}
.cols {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
}
.col h4 {
margin: 0 0 0.3rem;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted, rgba(0, 0, 0, 0.55));
font-weight: 500;
}
.text {
margin: 0;
padding: 0.5rem 0.7rem;
border-radius: 0.45rem;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.08));
background: var(--color-surface, rgba(255, 255, 255, 0.9));
font-size: 0.85rem;
line-height: 1.5;
white-space: pre-wrap;
max-height: 260px;
overflow-y: auto;
}
.text.refined {
border-color: color-mix(in srgb, #0ea5e9 50%, transparent);
background: color-mix(in srgb, #0ea5e9 3%, var(--color-surface, white));
}
.text.italic {
font-style: italic;
}
.text.err-text {
color: #ef4444;
border-color: color-mix(in srgb, #ef4444 40%, transparent);
}
footer {
display: inline-flex;
gap: 0.4rem;
flex-wrap: wrap;
}
footer button {
padding: 0.4rem 0.9rem;
border-radius: 0.45rem;
font: inherit;
font-weight: 500;
cursor: pointer;
font-size: 0.85rem;
}
footer .primary {
background: #0ea5e9;
color: white;
border: 1px solid #0ea5e9;
}
footer .primary:hover {
background: #0284c7;
border-color: #0284c7;
}
footer .secondary {
background: transparent;
color: inherit;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.1));
}
footer .secondary:hover {
background: var(--color-surface, rgba(0, 0, 0, 0.04));
}
@media (max-width: 700px) {
.cols {
grid-template-columns: 1fr;
}
}
</style>

View file

@ -0,0 +1,258 @@
<!--
SelectionToolbar — the refinement surface that appears when the user
has a non-empty selection in the version editor. Five operations:
Shorten, Expand, Change tone, Rewrite, Translate. Tone and Translate
expand to pickers; Rewrite expands to a freeform instruction input.
Emits a `{ kind, params }` payload via `ontool`. The parent owns the
generation call + preview panel so the toolbar stays dumb.
-->
<script lang="ts">
import { TONE_PRESETS } from '../constants';
export type SelectionToolKind =
| 'selection-shorten'
| 'selection-expand'
| 'selection-tone'
| 'selection-rewrite'
| 'selection-translate';
export interface SelectionToolInvocation {
kind: SelectionToolKind;
params?: { targetTone?: string; instruction?: string; targetLanguage?: string };
}
let {
selectionText,
disabled = false,
ontool,
}: {
selectionText: string;
disabled?: boolean;
ontool: (invocation: SelectionToolInvocation) => void;
} = $props();
const selectedWordCount = $derived(selectionText.trim().split(/\s+/).filter(Boolean).length);
type ExpandedMenu = 'none' | 'tone' | 'translate' | 'rewrite';
let expanded = $state<ExpandedMenu>('none');
let rewriteInstruction = $state('');
const LANGUAGES: Array<{ code: string; label: string }> = [
{ code: 'de', label: 'Deutsch' },
{ code: 'en', label: 'English' },
{ code: 'fr', label: 'Français' },
{ code: 'es', label: 'Español' },
{ code: 'it', label: 'Italiano' },
];
function simple(kind: SelectionToolKind) {
expanded = 'none';
ontool({ kind });
}
function tone(targetTone: string) {
expanded = 'none';
ontool({ kind: 'selection-tone', params: { targetTone } });
}
function translate(targetLanguage: string) {
expanded = 'none';
ontool({ kind: 'selection-translate', params: { targetLanguage } });
}
function rewrite() {
const instruction = rewriteInstruction.trim();
if (!instruction) return;
expanded = 'none';
rewriteInstruction = '';
ontool({ kind: 'selection-rewrite', params: { instruction } });
}
</script>
<div class="toolbar" role="toolbar" aria-label="Textauswahl-Werkzeuge">
<span class="info">
Auswahl: {selectedWordCount}
{selectedWordCount === 1 ? 'Wort' : 'Wörter'}
</span>
<div class="actions">
<button type="button" {disabled} onclick={() => simple('selection-shorten')}> Kürzen </button>
<button type="button" {disabled} onclick={() => simple('selection-expand')}>
⇠ Erweitern
</button>
<button
type="button"
{disabled}
class:active={expanded === 'tone'}
onclick={() => (expanded = expanded === 'tone' ? 'none' : 'tone')}
>
🗣 Ton
</button>
<button
type="button"
{disabled}
class:active={expanded === 'rewrite'}
onclick={() => (expanded = expanded === 'rewrite' ? 'none' : 'rewrite')}
>
✎ Umschreiben
</button>
<button
type="button"
{disabled}
class:active={expanded === 'translate'}
onclick={() => (expanded = expanded === 'translate' ? 'none' : 'translate')}
>
🌐 Übersetzen
</button>
</div>
{#if expanded === 'tone'}
<div class="submenu">
<span class="submenu-label">Neuer Ton:</span>
{#each TONE_PRESETS as preset (preset.id)}
<button type="button" class="chip" {disabled} onclick={() => tone(preset.id)}>
{preset.de}
</button>
{/each}
</div>
{/if}
{#if expanded === 'translate'}
<div class="submenu">
<span class="submenu-label">Zielsprache:</span>
{#each LANGUAGES as lang (lang.code)}
<button type="button" class="chip" {disabled} onclick={() => translate(lang.code)}>
{lang.label}
</button>
{/each}
</div>
{/if}
{#if expanded === 'rewrite'}
<div class="submenu rewrite-row">
<input
type="text"
bind:value={rewriteInstruction}
placeholder={'Anweisung (z.B. "aktiver formulieren", "mit einem Zitat ergänzen")'}
{disabled}
onkeydown={(ev) => ev.key === 'Enter' && rewrite()}
/>
<button
type="button"
class="primary"
disabled={disabled || !rewriteInstruction.trim()}
onclick={rewrite}
>
Los
</button>
</div>
{/if}
</div>
<style>
.toolbar {
display: flex;
flex-direction: column;
gap: 0.45rem;
padding: 0.5rem 0.75rem;
border-radius: 0.55rem;
border: 1px solid color-mix(in srgb, #0ea5e9 30%, transparent);
background: color-mix(in srgb, #0ea5e9 4%, transparent);
}
.info {
font-size: 0.75rem;
color: var(--color-text-muted, rgba(0, 0, 0, 0.55));
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
}
.actions > button {
padding: 0.35rem 0.7rem;
border-radius: 0.45rem;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.08));
background: var(--color-surface, rgba(255, 255, 255, 0.9));
cursor: pointer;
font: inherit;
font-size: 0.8rem;
color: inherit;
}
.actions > button:hover:not(:disabled) {
border-color: #0ea5e9;
color: #0ea5e9;
}
.actions > button.active {
border-color: #0ea5e9;
color: #0ea5e9;
background: color-mix(in srgb, #0ea5e9 10%, transparent);
}
.actions > button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.submenu {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
align-items: center;
padding-top: 0.3rem;
border-top: 1px dashed color-mix(in srgb, #0ea5e9 30%, transparent);
}
.submenu-label {
font-size: 0.75rem;
color: var(--color-text-muted, rgba(0, 0, 0, 0.55));
}
.chip {
padding: 0.2rem 0.55rem;
border-radius: 999px;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.08));
background: var(--color-surface, transparent);
cursor: pointer;
font: inherit;
font-size: 0.75rem;
color: inherit;
}
.chip:hover:not(:disabled) {
border-color: #0ea5e9;
color: #0ea5e9;
}
.chip:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.rewrite-row {
gap: 0.4rem;
}
.rewrite-row input {
flex: 1;
min-width: 0;
padding: 0.4rem 0.6rem;
border-radius: 0.4rem;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.1));
background: var(--color-surface, rgba(255, 255, 255, 0.9));
font: inherit;
font-size: 0.85rem;
color: inherit;
}
.rewrite-row input:focus {
outline: 2px solid #0ea5e9;
outline-offset: 1px;
border-color: transparent;
}
.rewrite-row .primary {
padding: 0.4rem 0.9rem;
border-radius: 0.4rem;
border: 1px solid #0ea5e9;
background: #0ea5e9;
color: white;
cursor: pointer;
font: inherit;
font-size: 0.8rem;
font-weight: 500;
}
.rewrite-row .primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>

View file

@ -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.
-->
<script lang="ts">
import { draftsStore } from '../stores/drafts.svelte';
import type { DraftVersion } from '../types';
export interface EditorSelection {
start: number;
end: number;
text: string;
}
let {
version,
targetWords = null,
forceContent = null,
onchange,
onselect,
}: {
version: DraftVersion;
targetWords?: number | null;
/**
* Setting this to a non-null string causes the editor to replace
* its local text with it the next time the value changes. Used by
* the detail view after a refinement apply or a version restore,
* where the Dexie update alone wouldn't re-sync the local text
* (the debouncing layer owns it until the version id flips).
*/
forceContent?: string | null;
onchange?: (content: string) => void;
onselect?: (selection: EditorSelection | null) => void;
} = $props();
// Snapshot id so we reset local state when the active version flips
@ -25,6 +47,8 @@
/* svelte-ignore state_referenced_locally */
let text = $state<string>(version.content);
let textarea = $state<HTMLTextAreaElement | null>(null);
$effect(() => {
if (version.id !== lastVersionId) {
lastVersionId = version.id;
@ -32,6 +56,17 @@
}
});
// External-content swaps (refinement apply / programmatic replace).
// Tracked via a nonce prop so the effect re-fires even if two applies
// in a row happen to produce the same string.
let lastForceSeen = $state<string | null>(null);
$effect(() => {
if (forceContent === null) return;
if (forceContent === lastForceSeen) return;
lastForceSeen = forceContent;
text = forceContent;
});
const wordCount = $derived.by(() => {
const trimmed = text.trim();
return trimmed ? trimmed.split(/\s+/).length : 0;
@ -63,13 +98,32 @@
pending = false;
}
}
function captureSelection() {
if (!textarea) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
if (start === end) {
onselect?.(null);
return;
}
onselect?.({
start,
end,
text: text.slice(start, end),
});
}
</script>
<div class="editor">
<textarea
bind:this={textarea}
bind:value={text}
oninput={handleInput}
onblur={flush}
onselect={captureSelection}
onmouseup={captureSelection}
onkeyup={captureSelection}
placeholder="Hier schreibst du (oder die KI). Leer lassen für Generate."
spellcheck="true"
></textarea>

View file

@ -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<string, unknown> = {
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<string, unknown> = {
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

View file

@ -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: ~5060% 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: ~150180% 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'),
};
}

View file

@ -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<string | null>(null);
// Selection refinement (M6).
let activeSelection = $state<EditorSelection | null>(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<string | null>(null);
const TOOL_LABEL: Record<SelectionToolKind, string> = {
'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}
<p class="error">{generateError}</p>
{/if}
<VersionEditor version={currentVersion} {targetWords} />
{#if activeSelection && !refinement}
<SelectionToolbar
selectionText={activeSelection.text}
ontool={(invocation) => activeSelection && runRefinement(invocation, activeSelection)}
/>
{/if}
{#if refinement}
<RefinementPanel
state={refinement}
onaccept={acceptRefinement}
onretry={retryRefinement}
oncancel={cancelRefinement}
/>
{/if}
{#if refineUndo && !refinement}
<div class="undo-row">
<button type="button" class="undo-btn" onclick={undoLastRefinement}>
↶ Rückgängig: {refineUndo.label}
</button>
</div>
{/if}
<VersionEditor
version={currentVersion}
{targetWords}
forceContent={forceEditorContent}
onselect={(sel) => (activeSelection = sel)}
/>
{:else}
<p class="muted">Diese Version existiert nicht mehr.</p>
{/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;