diff --git a/docs/evidence/pr-1260-magic-rewrite/after.png b/docs/evidence/pr-1260-magic-rewrite/after.png
new file mode 100644
index 000000000..d0e4f004e
Binary files /dev/null and b/docs/evidence/pr-1260-magic-rewrite/after.png differ
diff --git a/docs/evidence/pr-1260-magic-rewrite/provider-request.json b/docs/evidence/pr-1260-magic-rewrite/provider-request.json
new file mode 100644
index 000000000..90c644b87
--- /dev/null
+++ b/docs/evidence/pr-1260-magic-rewrite/provider-request.json
@@ -0,0 +1,18 @@
+[
+ {
+ "max_tokens": 4000,
+ "messages": [
+ {
+ "content": "You are a rewriting assistant for roleplay, fiction, and worldbuilding content.\nRewrite or generate the requested text according to the user's instructions.\nReturn ONLY the rewritten text -- no explanations, no markdown fences, no preamble.",
+ "role": "system"
+ },
+ {
+ "content": "Instruction:\nMake this sharper and more dramatic, but preserve the core identity.\n\n---\n\nText to rewrite:\nA flat sentinel character waits in a quiet room with plain motives.",
+ "role": "user"
+ }
+ ],
+ "model": "proof-model",
+ "stream": false,
+ "temperature": 0.7
+ }
+]
diff --git a/src/features/catalog/lorebooks/components/LorebookFormFields.tsx b/src/features/catalog/lorebooks/components/LorebookFormFields.tsx
index bc3919113..8d845dc7e 100644
--- a/src/features/catalog/lorebooks/components/LorebookFormFields.tsx
+++ b/src/features/catalog/lorebooks/components/LorebookFormFields.tsx
@@ -4,10 +4,24 @@
// and LorebookEntryRow (the per-entry inline drawer).
// Extracted from LorebookEditor.tsx so styling stays consistent.
// ──────────────────────────────────────────────
-import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
-import { FileText, Maximize2, ToggleLeft, ToggleRight, X } from "lucide-react";
+import {
+ useCallback,
+ useEffect,
+ useRef,
+ useState,
+ type KeyboardEvent as ReactKeyboardEvent,
+} from "react";
+import {
+ FileText,
+ Maximize2,
+ Sparkles,
+ ToggleLeft,
+ ToggleRight,
+ X,
+} from "lucide-react";
import { cn } from "../../../../shared/lib/utils";
import { HelpTooltip } from "../../../../shared/components/ui/HelpTooltip";
+import { MagicRewritePanel } from "../../../../shared/components/ui/MagicRewritePanel";
export function FieldGroup({
label,
@@ -32,7 +46,13 @@ export function FieldGroup({
);
}
-export function KeysEditor({ keys, onChange }: { keys: string[]; onChange: (keys: string[]) => void }) {
+export function KeysEditor({
+ keys,
+ onChange,
+}: {
+ keys: string[];
+ onChange: (keys: string[]) => void;
+}) {
const [input, setInput] = useState("");
const addKey = () => {
@@ -123,7 +143,9 @@ export function NumberField({
}) {
return (
-
{label}
+
+ {label}
+
void,
) {
- if (event.key !== "Tab" || event.shiftKey || event.altKey || event.metaKey || event.ctrlKey) return;
+ if (
+ event.key !== "Tab" ||
+ event.shiftKey ||
+ event.altKey ||
+ event.metaKey ||
+ event.ctrlKey
+ )
+ return;
event.preventDefault();
insertTabAtSelection(event.currentTarget, value, applyValue);
}
@@ -233,6 +262,8 @@ export function ExpandedContentModal({
placeholder?: string;
}) {
const [local, setLocal] = useState(value);
+ const [magicRewriteMode, setMagicRewriteMode] = useState(false);
+ const [magicRewriteResult, setMagicRewriteResult] = useState("");
const textareaRef = useRef
(null);
useEffect(() => {
@@ -241,7 +272,7 @@ export function ExpandedContentModal({
useEffect(() => {
const handler = (e: KeyboardEvent) => {
- if (e.key === "Escape") {
+ if (e.key === "Escape" && !magicRewriteMode) {
onChange(local);
onCommit?.();
onClose();
@@ -249,7 +280,7 @@ export function ExpandedContentModal({
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
- }, [onClose, onChange, onCommit, local]);
+ }, [onClose, onChange, onCommit, local, magicRewriteMode]);
const handleClose = () => {
onChange(local);
@@ -257,36 +288,105 @@ export function ExpandedContentModal({
onClose();
};
+ const handleMagicRewriteBack = () => {
+ setMagicRewriteMode(false);
+ setMagicRewriteResult("");
+ };
+
+ const handleMagicRewriteApply = () => {
+ if (!magicRewriteResult) return;
+ setLocal(magicRewriteResult);
+ setMagicRewriteMode(false);
+ setMagicRewriteResult("");
+ window.setTimeout(() => textareaRef.current?.focus(), 100);
+ };
+
+ const handleMagicRewriteResultChange = useCallback((next: string) => {
+ setMagicRewriteResult(next);
+ }, []);
+
return (
-
-
+
+
-
{title}
-
-
-
+
+ {magicRewriteMode ? "Magic Rewrite" : title}
+
+
+
+
+
+
-
- Changes auto-save on close. Press Escape to close.
+ {magicRewriteMode
+ ? "Back returns to the editor without applying. Apply moves the preview into the editor."
+ : "Changes auto-save on close. Press Escape to close."}
-
- Done
-
+ {magicRewriteMode ? (
+
+
+ Back
+
+
+ Apply
+
+
+ ) : (
+
+ setMagicRewriteMode(true)}
+ className="inline-flex items-center gap-1.5 rounded-lg border border-violet-400/30 bg-violet-500/10 px-3 py-1.5 text-xs font-medium text-violet-200 hover:bg-violet-500/20"
+ title="Open Magic Rewrite"
+ >
+
+ Rewrite
+
+
+ Done
+
+
+ )}
diff --git a/src/shared/components/ui/ExpandedTextarea.tsx b/src/shared/components/ui/ExpandedTextarea.tsx
index 7a0de2151..779c9a46b 100644
--- a/src/shared/components/ui/ExpandedTextarea.tsx
+++ b/src/shared/components/ui/ExpandedTextarea.tsx
@@ -1,10 +1,11 @@
// ──────────────────────────────────────────────
// Expanded Textarea — Fullscreen editing overlay
// ──────────────────────────────────────────────
-import { useEffect, useRef } from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { motion, AnimatePresence } from "framer-motion";
-import { Minimize2 } from "lucide-react";
+import { Minimize2, Sparkles } from "lucide-react";
+import { MagicRewritePanel } from "./MagicRewritePanel";
interface ExpandedTextareaProps {
open: boolean;
@@ -15,24 +16,65 @@ interface ExpandedTextareaProps {
placeholder?: string;
}
-export function ExpandedTextarea({ open, onClose, title, value, onChange, placeholder }: ExpandedTextareaProps) {
+export function ExpandedTextarea({
+ open,
+ onClose,
+ title,
+ value,
+ onChange,
+ placeholder,
+}: ExpandedTextareaProps) {
const textareaRef = useRef
(null);
+ const [local, setLocal] = useState(value);
+ const [magicRewriteMode, setMagicRewriteMode] = useState(false);
+ const [magicRewriteResult, setMagicRewriteResult] = useState("");
+
+ const handleClose = useCallback(() => {
+ onChange(local);
+ onClose();
+ }, [local, onChange, onClose]);
+
+ useEffect(() => {
+ if (!open) {
+ setMagicRewriteMode(false);
+ setMagicRewriteResult("");
+ }
+ setLocal(value);
+ }, [value, open]);
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => {
- if (e.key === "Escape") onClose();
+ if (e.key === "Escape" && !magicRewriteMode) handleClose();
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
- }, [open, onClose]);
+ }, [open, handleClose, magicRewriteMode]);
// Focus textarea when opened
useEffect(() => {
- if (open) {
+ if (open && !magicRewriteMode) {
requestAnimationFrame(() => textareaRef.current?.focus());
}
- }, [open]);
+ }, [open, magicRewriteMode]);
+
+ const handleMagicRewriteResultChange = useCallback((next: string) => {
+ setMagicRewriteResult(next);
+ }, []);
+
+ const handleMagicRewriteApply = () => {
+ if (!magicRewriteResult) return;
+ setLocal(magicRewriteResult);
+ setMagicRewriteMode(false);
+ setMagicRewriteResult("");
+ onChange(magicRewriteResult);
+ requestAnimationFrame(() => textareaRef.current?.focus());
+ };
+
+ const handleMagicRewriteBack = () => {
+ setMagicRewriteMode(false);
+ setMagicRewriteResult("");
+ };
return createPortal(
@@ -46,11 +88,42 @@ export function ExpandedTextarea({ open, onClose, title, value, onChange, placeh
>
{/* Header */}
-
{title}
+
+ {magicRewriteMode ? "Magic Rewrite" : title}
+
- {value.length} characters
+ {!magicRewriteMode && (
+ setMagicRewriteMode(true)}
+ className="flex items-center gap-1.5 rounded-lg border border-violet-400/30 bg-violet-500/10 px-2.5 py-1.5 text-xs font-medium text-violet-200 hover:bg-violet-500/20"
+ title="Open Magic Rewrite"
+ >
+
+ Rewrite
+
+ )}
+ {magicRewriteMode && (
+
+ Back
+
+ )}
+ {magicRewriteMode && (
+
+ Apply
+
+ )}
+
+ {local.length} characters
+
@@ -59,15 +132,21 @@ export function ExpandedTextarea({ open, onClose, title, value, onChange, placeh
- {/* Textarea */}
- onChange(e.target.value)}
- placeholder={placeholder}
- className="h-full w-full resize-none rounded-xl border border-[var(--border)] bg-[var(--secondary)] p-5 text-sm leading-relaxed outline-none transition-colors placeholder:text-[var(--muted-foreground)]/40 focus:border-[var(--primary)]/40 focus:ring-1 focus:ring-[var(--primary)]/20"
- />
+ {magicRewriteMode ? (
+
+ ) : (
+ setLocal(e.target.value)}
+ placeholder={placeholder}
+ className="h-full w-full resize-none rounded-xl border border-[var(--border)] bg-[var(--secondary)] p-5 text-sm leading-relaxed outline-none transition-colors placeholder:text-[var(--muted-foreground)]/40 focus:border-[var(--primary)]/40 focus:ring-1 focus:ring-[var(--primary)]/20"
+ />
+ )}
)}
diff --git a/src/shared/components/ui/MagicRewritePanel.tsx b/src/shared/components/ui/MagicRewritePanel.tsx
new file mode 100644
index 000000000..04ffb8ef7
--- /dev/null
+++ b/src/shared/components/ui/MagicRewritePanel.tsx
@@ -0,0 +1,159 @@
+import { useEffect, useMemo } from "react";
+import { Loader2, Sparkles } from "lucide-react";
+import { useMagicRewrite } from "../../hooks/use-magic-rewrite";
+import { HelpTooltip } from "./HelpTooltip";
+
+type DiffPart = { text: string; changed: boolean };
+type DiffResult =
+ | { skipped: true; before: string; after: string }
+ | { skipped: false; before: DiffPart[]; after: DiffPart[] };
+
+function diffWords(before: string, after: string): DiffResult {
+ const beforeWords = before.match(/\S+|\s+/g) ?? [];
+ const afterWords = after.match(/\S+|\s+/g) ?? [];
+ if (beforeWords.length + afterWords.length > 3000) {
+ return { before, after, skipped: true };
+ }
+
+ const dp = Array.from(
+ { length: beforeWords.length + 1 },
+ () => new Uint16Array(afterWords.length + 1),
+ );
+ for (let i = 1; i <= beforeWords.length; i++) {
+ for (let j = 1; j <= afterWords.length; j++) {
+ dp[i][j] =
+ beforeWords[i - 1] === afterWords[j - 1]
+ ? dp[i - 1][j - 1] + 1
+ : Math.max(dp[i - 1][j], dp[i][j - 1]);
+ }
+ }
+
+ const deleted = new Uint8Array(beforeWords.length);
+ const added = new Uint8Array(afterWords.length);
+ let i = beforeWords.length;
+ let j = afterWords.length;
+ while (i > 0 || j > 0) {
+ if (i > 0 && j > 0 && beforeWords[i - 1] === afterWords[j - 1]) {
+ i--;
+ j--;
+ } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
+ added[--j] = 1;
+ } else {
+ deleted[--i] = 1;
+ }
+ }
+
+ return {
+ skipped: false,
+ before: beforeWords.map((word, index) => ({
+ text: word,
+ changed: deleted[index] === 1,
+ })),
+ after: afterWords.map((word, index) => ({
+ text: word,
+ changed: added[index] === 1,
+ })),
+ };
+}
+
+export function MagicRewritePanel({
+ value,
+ onResultChange,
+}: {
+ value: string;
+ onResultChange: (value: string) => void;
+}) {
+ const { instruction, setInstruction, result, loading, error, generate } =
+ useMagicRewrite(value);
+ const diff = useMemo(
+ () => (result ? diffWords(value, result) : null),
+ [value, result],
+ );
+
+ useEffect(() => {
+ onResultChange(result);
+ }, [onResultChange, result]);
+
+ return (
+
+
+
+
+ Rewrite instructions{" "}
+
+
+
setInstruction(event.target.value)}
+ placeholder='e.g. "Make this more vivid and dramatic..."'
+ className="min-h-0 flex-1 resize-none rounded-xl bg-[var(--secondary)] p-3 text-sm ring-1 ring-[var(--border)] focus:outline-none focus:ring-2 focus:ring-violet-500/50"
+ />
+
+
+
+ {error &&
{error}
}
+
+
+ {loading ? (
+
+ ) : (
+
+ )}
+ {loading ? "Rewriting..." : "Generate Rewrite"}
+
+
+
+
+
+
+
+
+ Before
+
+
+ {diff && !diff.skipped && Array.isArray(diff.before)
+ ? diff.before.map((part, index) => (
+
+ {part.text}
+
+ ))
+ : value}
+
+
+
+
+ After
+
+
+ {diff && !diff.skipped && Array.isArray(diff.after)
+ ? diff.after.map((part, index) => (
+
+ {part.text}
+
+ ))
+ : result || "Generated rewrite will appear here."}
+
+
+
+
+ );
+}
diff --git a/src/shared/hooks/use-magic-rewrite.ts b/src/shared/hooks/use-magic-rewrite.ts
new file mode 100644
index 000000000..ba43e2456
--- /dev/null
+++ b/src/shared/hooks/use-magic-rewrite.ts
@@ -0,0 +1,117 @@
+import { useEffect, useState } from "react";
+import { llmApi } from "../api/llm-api";
+import { storageApi } from "../api/storage-api";
+
+const PROMPT_KEY = "magic-rewrite-prompt";
+
+const REWRITE_SYSTEM_PROMPT = `You are a rewriting assistant for roleplay, fiction, and worldbuilding content.
+Rewrite or generate the requested text according to the user's instructions.
+Return ONLY the rewritten text -- no explanations, no markdown fences, no preamble.`;
+
+type ConnectionRecord = {
+ id?: unknown;
+ provider?: unknown;
+ isDefault?: unknown;
+ default?: unknown;
+};
+
+function boolish(value: unknown): boolean {
+ return value === true || value === "true" || value === 1 || value === "1";
+}
+
+function readStoredInstruction() {
+ try {
+ return window.localStorage.getItem(PROMPT_KEY) ?? "";
+ } catch {
+ return "";
+ }
+}
+
+function writeStoredInstruction(instruction: string) {
+ try {
+ window.localStorage.setItem(PROMPT_KEY, instruction);
+ } catch {
+ // Ignore storage failures; the rewrite flow still works without persistence.
+ }
+}
+
+function buildRewriteMessages(value: string, instructionValue: string) {
+ const text = value.trim();
+ const hasSourceText = text.length > 0;
+ const instruction =
+ instructionValue.trim() ||
+ (hasSourceText
+ ? "Improve this text while preserving its meaning."
+ : "Generate suitable content.");
+
+ return [
+ { role: "system" as const, content: REWRITE_SYSTEM_PROMPT },
+ {
+ role: "user" as const,
+ content: hasSourceText
+ ? `Instruction:\n${instruction}\n\n---\n\nText to rewrite:\n${value}`
+ : `Instruction:\n${instruction}\n\n---\n\nNo source text was provided; generate new content from the instruction.`,
+ },
+ ];
+}
+
+async function resolveDefaultConnectionId() {
+ const connections = await storageApi.list("connections");
+ const textConnections = connections.filter(
+ (connection) => connection.provider !== "image_generation",
+ );
+ const selected =
+ textConnections.find(
+ (connection) =>
+ boolish(connection.isDefault) || boolish(connection.default),
+ ) ?? textConnections[0];
+ const connectionId =
+ typeof selected?.id === "string" ? selected.id.trim() : "";
+
+ if (!connectionId) throw new Error("No text connection configured");
+
+ return connectionId;
+}
+
+export function useMagicRewrite(value: string) {
+ const [instruction, setInstruction] = useState(readStoredInstruction);
+ const [result, setResult] = useState("");
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState("");
+
+ useEffect(() => {
+ const timer = window.setTimeout(
+ () => writeStoredInstruction(instruction),
+ 300,
+ );
+ return () => window.clearTimeout(timer);
+ }, [instruction]);
+
+ async function generate() {
+ setLoading(true);
+ setError("");
+ setResult("");
+ try {
+ const connectionId = await resolveDefaultConnectionId();
+ const text = await llmApi.complete({
+ connectionId,
+ messages: buildRewriteMessages(value, instruction),
+ parameters: { temperature: 0.7, maxTokens: 4000 },
+ });
+ setResult(text.trim());
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Magic Rewrite failed");
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ return {
+ instruction,
+ setInstruction,
+ result,
+ loading,
+ error,
+ generate,
+ };
+}