mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:41:09 +02:00
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:
parent
27c1860f82
commit
19e0f33665
6 changed files with 1014 additions and 2 deletions
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue