1
0
Fork 0

Compare commits

...

3 Commits

Author SHA1 Message Date
Pabloader 76d4ef297e Prompt optimization 2026-11-03 13:10:03 +00:00
Pabloader 05eb1a8fc4 Good contenteditable 2026-11-03 08:53:08 +00:00
Pabloader a6a189de1d Optimize 2026-11-03 07:36:12 +00:00
8 changed files with 894 additions and 303 deletions

View File

@ -1,129 +1,534 @@
import { useEffect, useRef } from "preact/hooks"; import { useLayoutEffect, useRef } from 'preact/hooks';
import type { JSX } from "preact"; import type { JSX } from 'preact';
import clsx from "clsx"; import clsx from 'clsx';
import styles from "../assets/content-editable.module.css"; import styles from '../assets/content-editable.module.css';
import { highlight } from "../highlight"; import { highlight } from '../highlight';
type Props = Omit<JSX.HTMLAttributes<HTMLDivElement>, 'value' | 'onInput'> & { export type ContentEditableElement = HTMLDivElement & { value: string };
export type Props = Omit<JSX.HTMLAttributes<HTMLDivElement>, 'value' | 'onInput'> & {
value: string; value: string;
placeholder?: string; placeholder?: string;
autoLines?: boolean; autoLines?: boolean;
highlight?: boolean; highlight?: boolean;
/** Highlight every literal, case-sensitive match without changing value. */
highlightText?: string | readonly string[];
disabled?: boolean; disabled?: boolean;
onInput?: JSX.EventHandler<JSX.TargetedInputEvent<HTMLDivElement>>; onInput?: JSX.EventHandler<JSX.TargetedInputEvent<HTMLDivElement>>;
}; };
const parseLines = (html: string, keepSpan: boolean = false): string => { type Snapshot = { text: string; anchor: number; focus: number };
html = html const normalize = (text: string) => text.replace(/\r\n?/g, '\n');
.replace(/\n?<div><br\/?><\/div>/g, '\n\n') const nativeBlock = (node: Node) => node.nodeType === 1 && /^(DIV|P|LI|PRE|H[1-6])$/.test((node as Element).tagName);
.replace(/\n?<div[^>]*>/, '\n') const graphemes = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
.replace(/<\/?div[^>]*>/g, '') type Point = [Node, number];
.replace(/<br[^>]*>/g, ''); type TextRun = { node: Node; start: number; end: number };
let nextMatchId = 0;
if (!keepSpan) { function findTextMatches(root: HTMLElement, query: Props['highlightText']) {
html = html.replace(/<[^>]+>/g, ''); const terms = [...new Set((typeof query === 'string' ? [query] : query ?? []).map(normalize).filter(Boolean))];
const nodes: { node: Text; from: number; to: number }[] = [];
const matches: { from: number; to: number }[] = [];
if (!terms.length) return { nodes, matches };
let text = '';
const walker = root.ownerDocument.createTreeWalker(root, NodeFilter.SHOW_TEXT);
let node: Node | null;
while ((node = walker.nextNode())) {
const from = text.length;
text += node.nodeValue;
nodes.push({ node: node as Text, from, to: text.length });
} }
for (const term of terms) {
return html; let from = text.indexOf(term);
}; while (from !== -1) {
matches.push({ from, to: from + term.length });
function getCaretOffset(el: HTMLElement): number { from = text.indexOf(term, from + 1);
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0) return 0;
const range = sel.getRangeAt(0).cloneRange();
range.selectNodeContents(el);
range.setEnd(sel.getRangeAt(0).endContainer, sel.getRangeAt(0).endOffset);
const contents = range.cloneContents();
const div = document.createElement('div');
div.appendChild(contents);
const text = parseLines(div.innerHTML);
return text.length;
}
function setCaretOffset(el: HTMLElement, offset: number) {
const sel = window.getSelection();
if (!sel) return;
const range = document.createRange();
let remaining = offset;
function traverse(node: Node): boolean {
if (node.nodeType === Node.TEXT_NODE) {
const text = node.textContent || '';
const len = text.length;
if (remaining <= len) {
range.setStart(node, remaining);
range.collapse(true);
return true;
}
remaining -= len;
} else {
for (const child of Array.from(node.childNodes)) {
if (traverse(child)) return true;
}
} }
return false;
} }
matches.sort((a, b) => a.from - b.from || a.to - b.to);
if (!traverse(el)) { const merged: typeof matches = [];
range.selectNodeContents(el); for (const match of matches) {
range.collapse(false); const previous = merged.at(-1);
if (previous && match.from < previous.to) previous.to = Math.max(previous.to, match.to);
else merged.push({ ...match });
} }
return { nodes, matches: merged };
sel.removeAllRanges();
sel.addRange(range);
} }
function resizeToContent(el: HTMLElement) { /** Paint-only ranges keep live text nodes and selection untouched. Older
el.style.height = 'auto'; * browsers use internal spans, which never become part of the source value. */
el.style.height = el.scrollHeight + 'px'; function createMatchPainter(root: HTMLElement) {
const doc = root.ownerDocument;
const win = doc.defaultView as (Window & typeof globalThis) | null;
const registry = win?.CSS?.highlights;
const HighlightClass = win?.Highlight;
const native = !!registry && !!HighlightClass && !!win?.CSS.supports('selector(::highlight(ce-match))');
const name = `content-editable-match-${++nextMatchId}`;
const style = doc.createElement('style');
const colors = 'background-color: var(--content-editable-highlight-background, #ffeb70); color: var(--content-editable-highlight-color, #1b241c);';
style.textContent = `${native ? `::highlight(${name})` : `span[data-ce-match="${name}"]`} { ${colors} }`;
const tree = root.getRootNode();
(tree instanceof ShadowRoot ? tree : doc.head).append(style);
return {
native,
paint(query: Props['highlightText']) {
if (!native) return;
const { nodes, matches } = findTextMatches(root, query);
if (!matches.length) { registry!.delete(name); return; }
const ranges = new HighlightClass!();
let start = 0, end = 0;
for (const match of matches) {
while (nodes[start].to <= match.from) start++;
end = Math.max(start, end);
while (nodes[end].to < match.to) end++;
const range = doc.createRange();
range.setStart(nodes[start].node, match.from - nodes[start].from);
range.setEnd(nodes[end].node, match.to - nodes[end].from);
ranges.add(range);
}
registry!.set(name, ranges);
},
decorate(target: HTMLElement, query: Props['highlightText']) {
if (native) return;
const { nodes, matches } = findTextMatches(target, query);
for (const run of nodes) {
// Work backwards so earlier offsets still address the original
// text node, even when several matches occur in the same node.
for (let index = matches.length - 1; index >= 0; index--) {
const from = Math.max(run.from, matches[index].from);
const to = Math.min(run.to, matches[index].to);
if (from >= to) continue;
run.node.splitText(to - run.from);
const text = run.node.splitText(from - run.from);
const span = doc.createElement('span');
span.setAttribute('data-ce-match', name);
text.replaceWith(span);
span.append(text);
}
}
},
destroy() {
if (native) registry!.delete(name);
style.remove();
},
};
}
function deletionRange(root: HTMLElement, position: number, backward: boolean) {
const { text } = indexDOM(root);
const index = backward ? position - 1 : position;
if (index < 0 || index >= text.length) return;
const segment = graphemes.segment(text).containing(index)!;
return { anchor: segment.index, focus: segment.index + segment.segment.length };
}
/** Read the DOM, not its HTML serialization. The index also maps both ends of
* a selection, including native line breaks. */
function indexDOM(root: HTMLElement) {
let text = '';
const starts = new Map<Node, number>();
const boundaries = new Map<Node, number[]>();
const runs: TextRun[] = [];
const visit = (node: Node) => {
starts.set(node, text.length);
if (node.nodeType === Node.TEXT_NODE) {
const start = text.length;
text += node.nodeValue ?? '';
runs.push({ node, start, end: text.length });
return;
}
if (!(node instanceof Element)) return;
if (node.tagName === 'BR') {
if (!node.hasAttribute('data-ce-end') && !(nativeBlock(node.parentNode!) && node.parentNode!.childNodes.length === 1)) text += '\n';
return;
}
const points: number[] = [];
const children = Array.from(node.childNodes);
children.forEach((child, index) => {
if (nativeBlock(child) && index > 0 && (nativeBlock(children[index - 1]) || !text.endsWith('\n'))) text += '\n';
points.push(text.length);
visit(child);
});
points.push(text.length);
boundaries.set(node, points);
};
visit(root);
const offset = (node: Node | null, index: number) => {
if (!node || !root.contains(node)) return text.length;
if (node.nodeType === Node.TEXT_NODE) return (starts.get(node) ?? 0) + Math.min(index, node.nodeValue?.length ?? 0);
const points = boundaries.get(node);
return points?.[Math.min(index, points.length - 1)] ?? starts.get(node) ?? 0;
};
const point = (offset: number): Point => {
offset = Math.max(0, Math.min(offset, text.length));
// Prefer the next text node at shared boundaries, particularly after a
// heading's trailing newline.
for (const run of runs) {
if (offset >= run.start && offset < run.end) return [run.node, offset - run.start];
}
const following = runs.find(run => run.start >= offset);
if (following) return [following.node, 0];
const hasEnd = root.lastChild instanceof Element && root.lastChild.hasAttribute('data-ce-end');
const last = runs.at(-1);
if (last && !hasEnd) return [last.node, last.end - last.start];
return [root, Math.max(0, root.childNodes.length - (hasEnd ? 1 : 0))];
};
return { text, offset, point };
}
function readDOM(root: HTMLElement): Snapshot {
const index = indexDOM(root);
const selection = root.ownerDocument.getSelection();
return {
text: index.text,
anchor: index.offset(selection?.anchorNode ?? null, selection?.anchorOffset ?? 0),
focus: index.offset(selection?.focusNode ?? null, selection?.focusOffset ?? 0),
};
}
function restoreSelection(root: HTMLElement, snapshot: Snapshot) {
const { point } = indexDOM(root);
const [anchor, anchorOffset] = point(snapshot.anchor);
const [focus, focusOffset] = point(snapshot.focus);
root.ownerDocument.getSelection()?.setBaseAndExtent(anchor, anchorOffset, focus, focusOffset);
}
function renderSource(root: HTMLElement, text: string, enabled: boolean) {
if (enabled) root.innerHTML = highlight(text.replace(/&/g, '&amp;').replace(/</g, '&lt;'));
else root.textContent = text;
}
function selectedText(snapshot: Snapshot) {
const from = Math.min(snapshot.anchor, snapshot.focus), to = Math.max(snapshot.anchor, snapshot.focus);
return snapshot.text.slice(from, to);
}
/** Reuse live nodes whenever their shape matches. Ordinary word edits leave
* their original text nodes intact, including the browser's correction anchors. */
function reconcile(parent: Node, desired: Node): boolean {
let changed = false;
let current = parent.firstChild;
for (const next of Array.from(desired.childNodes)) {
const same = current && current.nodeType === next.nodeType && (
current.nodeType === Node.TEXT_NODE ||
(current instanceof Element && next instanceof Element && current.tagName === next.tagName && current.getAttribute('class') === next.getAttribute('class'))
);
if (!same) {
parent.insertBefore(next.cloneNode(true), current);
changed = true;
continue;
}
if (current!.nodeType === Node.TEXT_NODE) {
if (current!.nodeValue !== next.nodeValue) { current!.nodeValue = next.nodeValue; changed = true; }
} else {
const live = current as Element;
const wanted = next as Element;
for (const attribute of Array.from(live.attributes)) {
if (!wanted.hasAttribute(attribute.name)) { live.removeAttribute(attribute.name); changed = true; }
}
for (const attribute of Array.from(wanted.attributes)) {
if (live.getAttribute(attribute.name) !== attribute.value) { live.setAttribute(attribute.name, attribute.value); changed = true; }
}
changed = reconcile(live, wanted) || changed;
}
current = current!.nextSibling;
}
while (current) {
const next = current.nextSibling;
parent.removeChild(current);
changed = true;
current = next;
}
return changed;
} }
export const ContentEditable = ({ export const ContentEditable = ({
value, value, placeholder, autoLines = false, highlight: enableHighlight = false,
placeholder, highlightText,
autoLines, disabled = false, onInput, class: externalClass, className,
highlight: enableHighlight, onKeyDown, onBeforeInput, onPaste, onCopy, onCut,
disabled, onCompositionStart, onCompositionEnd, onCompositionUpdate, onBlur,
onInput, onCompositionStartCapture, onCompositionEndCapture, onCompositionUpdateCapture,
class: externalClass, children: _children, dangerouslySetInnerHTML: _html, contentEditable: _editable,
...props ...props
}: Props) => { }: Props) => {
const ref = useRef<HTMLDivElement>(null); const root = useRef<ContentEditableElement>(null);
const model = useRef<Snapshot>({ text: normalize(value), anchor: 0, focus: 0 });
const before = useRef<Snapshot>();
const past = useRef<Snapshot[]>([]);
const future = useRef<Snapshot[]>([]);
const grouping = useRef<{ kind: string; time: number; focus: number }>();
const composing = useRef(false);
const pending = useRef<string | null>(null);
const lastProp = useRef(normalize(value));
const timer = useRef<ReturnType<typeof setTimeout>>();
const settleTimer = useRef<ReturnType<typeof setTimeout>>();
const notifying = useRef(false);
const originalHeight = useRef<string | null>(null);
const matchPainter = useRef<ReturnType<typeof createMatchPainter>>();
const options = useRef({ autoLines, enableHighlight, highlightText, placeholder, disabled });
options.current = { autoLines, enableHighlight, highlightText, placeholder, disabled };
useEffect(() => { const sync = () => {
const el = ref.current; const el = root.current;
if (!el) return; if (!el) return;
el.value = model.current.text;
if (!el.value && options.current.placeholder) el.dataset.placeholder = options.current.placeholder;
else delete el.dataset.placeholder;
if (options.current.autoLines) {
if (originalHeight.current === null) originalHeight.current = el.style.height;
el.style.height = 'auto';
const style = getComputedStyle(el);
const adjustment = style.boxSizing === 'border-box'
? parseFloat(style.borderTopWidth) + parseFloat(style.borderBottomWidth)
: -parseFloat(style.paddingTop) - parseFloat(style.paddingBottom);
el.style.height = `${el.scrollHeight + adjustment}px`;
} else if (originalHeight.current !== null) {
el.style.height = originalHeight.current;
originalHeight.current = null;
}
matchPainter.current?.paint(options.current.highlightText);
};
const newValue = enableHighlight ? highlight(value) : value; const decorate = () => {
const oldValue = parseLines(el.innerHTML, true); clearTimeout(timer.current);
const el = root.current;
if (!el || composing.current) return;
const focused = el.ownerDocument.activeElement === el;
const selection = focused ? readDOM(el) : model.current;
const { scrollTop, scrollLeft } = el;
const desired = el.ownerDocument.createElement('div');
renderSource(desired, model.current.text, options.current.enableHighlight);
matchPainter.current?.decorate(desired, options.current.highlightText);
if (!model.current.text || model.current.text.endsWith('\n')) {
const end = el.ownerDocument.createElement('br');
end.setAttribute('data-ce-end', '');
desired.append(end);
}
const changed = reconcile(el, desired);
if (focused && changed) restoreSelection(el, { ...selection, text: model.current.text });
sync();
el.scrollTop = scrollTop;
el.scrollLeft = scrollLeft;
};
if (oldValue === newValue) return; const schedule = () => {
clearTimeout(timer.current);
if (!composing.current) timer.current = setTimeout(decorate, 120);
};
const offset = document.activeElement === el ? getCaretOffset(el) : null; const remember = (previous: Snapshot, next: Snapshot, kind: string) => {
el.innerHTML = newValue; if (previous.text === next.text) return;
(el as any).value = value; const now = Date.now();
if (offset !== null) setCaretOffset(el, offset); const group = grouping.current;
if (autoLines) resizeToContent(el); const continuous = group && group.kind === kind && (kind === 'composition' || (now - group.time < 750 &&
}, [value, enableHighlight]); previous.anchor === previous.focus && previous.focus === group.focus &&
(kind === 'insertText' || kind === 'deleteContentBackward' || kind === 'deleteContentForward')));
if (!continuous) {
past.current.push(previous);
if (past.current.length > 200) past.current.shift();
}
future.current = [];
grouping.current = { kind, time: now, focus: next.focus };
};
const handleInput: JSX.EventHandler<JSX.TargetedInputEvent<HTMLDivElement>> = (e) => { const notify = (inputType: string, data: string | null = null) => {
if (!ref.current) return; notifying.current = true;
if (autoLines) resizeToContent(ref.current); try {
const text = parseLines(ref.current.innerHTML); root.current!.dispatchEvent(new InputEvent('input', { bubbles: true, composed: true, inputType, data }));
// add value prop to work with useInputState/useInputCallback } finally { notifying.current = false; }
(ref.current as any).value = text; };
onInput?.(e);
const replaceSelection = (text: string, inputType: string, range?: Pick<Snapshot, 'anchor' | 'focus'>) => {
const el = root.current!;
const previous = readDOM(el);
const selection = range ?? previous;
const start = Math.min(selection.anchor, selection.focus);
const end = Math.max(selection.anchor, selection.focus);
const inserted = normalize(text);
const position = start + inserted.length;
const next = { text: previous.text.slice(0, start) + inserted + previous.text.slice(end), anchor: position, focus: position };
remember(previous, next, inputType);
model.current = next;
decorate();
restoreSelection(el, next);
before.current = undefined;
notify(inputType, inserted);
};
const undo = (redo: boolean) => {
const source = redo ? future.current : past.current;
const destination = redo ? past.current : future.current;
const snapshot = source.pop();
if (!snapshot) return;
destination.push(readDOM(root.current!));
model.current = snapshot;
grouping.current = undefined;
decorate();
restoreSelection(root.current!, snapshot);
notify(redo ? 'historyRedo' : 'historyUndo');
};
const applyExternal = (text: string) => {
const selection = readDOM(root.current!);
model.current = { text, anchor: Math.min(selection.anchor, text.length), focus: Math.min(selection.focus, text.length) };
past.current = [];
future.current = [];
grouping.current = undefined;
decorate();
};
useLayoutEffect(() => {
matchPainter.current = createMatchPainter(root.current!);
decorate();
let width = root.current!.clientWidth;
const observer = new ResizeObserver(() => {
const next = root.current?.clientWidth;
if (next !== width) { width = next ?? 0; sync(); }
});
observer.observe(root.current!);
const fonts = root.current!.ownerDocument.fonts;
fonts?.addEventListener('loadingdone', sync);
return () => {
observer.disconnect();
fonts?.removeEventListener('loadingdone', sync);
clearTimeout(timer.current);
clearTimeout(settleTimer.current);
matchPainter.current?.destroy();
matchPainter.current = undefined;
};
}, []);
useLayoutEffect(() => {
const next = normalize(value);
if (lastProp.current === next) return;
lastProp.current = next;
if (next === model.current.text) { pending.current = null; return; }
if (composing.current) pending.current = next;
else applyExternal(next);
}, [value]);
useLayoutEffect(() => { decorate(); sync(); }, [enableHighlight, autoLines, placeholder]);
useLayoutEffect(() => {
if (matchPainter.current?.native) matchPainter.current.paint(highlightText);
else decorate();
}, [highlightText]);
const handleInput: NonNullable<Props['onInput']> = event => {
if (disabled) return;
if (!notifying.current) {
const next = readDOM(root.current!);
const previous = before.current ?? model.current;
remember(previous, next, composing.current || event.isComposing ? 'composition' : event.inputType || 'insertText');
model.current = next;
before.current = undefined;
sync();
schedule();
}
onInput?.(event);
}; };
return ( return (
<div <div
ref={ref}
{...props} {...props}
contentEditable={!disabled} ref={root}
data-placeholder={value?.replaceAll('\n', '').length ? undefined : placeholder} class={clsx(styles.root, autoLines && styles.autoLines, externalClass, className, disabled && styles.disabled)}
class={clsx(styles.root, autoLines && styles.autoLines, externalClass, disabled && styles.disabled)} contentEditable={disabled ? false : 'plaintext-only'}
onInput={disabled ? undefined : handleInput} role={props.role ?? 'textbox'}
aria-multiline="true"
aria-disabled={disabled}
tabIndex={disabled ? -1 : props.tabIndex ?? props.tabindex ?? 0}
onInput={handleInput}
onBeforeInput={event => {
onBeforeInput?.(event);
if (event.defaultPrevented || disabled || notifying.current) return;
before.current = readDOM(root.current!);
if (composing.current || event.isComposing || !event.cancelable) return;
// Browsers merge styled block spans when replacing a selection,
// sometimes removing a newline outside the selected text. Apply
// that replacement to the source offsets instead of the layout.
const hasSelection = before.current.anchor !== before.current.focus;
if (hasSelection && event.inputType === 'insertText' && event.data !== null) {
event.preventDefault();
replaceSelection(event.data, event.inputType);
} else if (event.inputType === 'deleteContentBackward' || event.inputType === 'deleteContentForward') {
// Native block merging may remove an adjacent source newline
// as well as the intended glyph. Delete one source grapheme
// explicitly, keeping emoji and combining sequences whole.
event.preventDefault();
const range = hasSelection ? undefined : deletionRange(root.current!, before.current.focus, event.inputType === 'deleteContentBackward');
if (hasSelection || range) replaceSelection('', event.inputType, range);
} else if (event.inputType === 'insertParagraph' || event.inputType === 'insertLineBreak') {
event.preventDefault();
replaceSelection('\n', 'insertLineBreak');
} else if (event.inputType === 'historyUndo' || event.inputType === 'historyRedo') {
event.preventDefault();
undo(event.inputType === 'historyRedo');
}
}}
onKeyDown={event => {
onKeyDown?.(event);
if (event.defaultPrevented || disabled || composing.current || event.isComposing || event.keyCode === 229) return;
if ((event.ctrlKey || event.metaKey) && !event.altKey && (event.key.toLowerCase() === 'z' || event.key.toLowerCase() === 'y')) {
event.preventDefault();
undo(event.key.toLowerCase() === 'y' || event.shiftKey);
} else if (event.key === 'Enter') {
event.preventDefault();
replaceSelection('\n', 'insertLineBreak');
}
}}
onPaste={event => {
onPaste?.(event);
if (event.defaultPrevented || disabled || composing.current || !event.clipboardData) return;
event.preventDefault();
replaceSelection(event.clipboardData.getData('text/plain'), 'insertFromPaste');
}}
onCopy={event => {
onCopy?.(event);
if (event.defaultPrevented || !event.clipboardData) return;
const selection = readDOM(root.current!);
event.clipboardData.setData('text/plain', selectedText(selection));
event.preventDefault();
}}
onCut={event => {
onCut?.(event);
if (event.defaultPrevented || disabled || composing.current || !event.clipboardData) return;
const selection = readDOM(root.current!);
event.clipboardData.setData('text/plain', selectedText(selection));
event.preventDefault();
replaceSelection('', 'deleteByCut');
}}
{...{
// Preact core does not lowercase composition event names on browsers
// without an oncompositionstart property. Use their native names.
oncompositionstartCapture: onCompositionStartCapture,
oncompositionendCapture: onCompositionEndCapture,
oncompositionupdateCapture: onCompositionUpdateCapture,
oncompositionupdate: onCompositionUpdate,
oncompositionstart: (event: JSX.TargetedCompositionEvent<HTMLDivElement>) => {
composing.current = true;
grouping.current = undefined;
before.current = readDOM(root.current!);
clearTimeout(timer.current);
clearTimeout(settleTimer.current);
onCompositionStart?.(event);
},
oncompositionend: (event: JSX.TargetedCompositionEvent<HTMLDivElement>) => {
onCompositionEnd?.(event);
clearTimeout(settleTimer.current);
// Keep the DOM untouched through the final native input, which
// may arrive either before or after compositionend.
settleTimer.current = setTimeout(() => {
composing.current = false;
grouping.current = undefined;
if (pending.current !== null) {
const next = pending.current;
pending.current = null;
applyExternal(next);
} else schedule();
}, 30);
},
}}
onBlur={event => { if (!composing.current) decorate(); onBlur?.(event); }}
/> />
); );
}; };

View File

@ -52,8 +52,14 @@ export const importStoredReducer = <T>(key: string): boolean => {
return true; return true;
}; };
export const useRemoteReducer = <T, A>(key: string, reducer: Reducer<T, A>, initialValue: T) => { export interface RemoteReducerOptions {
/** Skip persisting changes while true; the latest state is saved when it turns false again. */
paused?: boolean;
}
export const useRemoteReducer = <T, A>(key: string, reducer: Reducer<T, A>, initialValue: T, options: RemoteReducerOptions = {}) => {
const storedKey = remoteReducerKey(key); const storedKey = remoteReducerKey(key);
const paused = options.paused ?? false;
const getInitialValue = (): T => { const getInitialValue = (): T => {
try { try {
@ -73,8 +79,9 @@ export const useRemoteReducer = <T, A>(key: string, reducer: Reducer<T, A>, init
}, [storedKey]); }, [storedKey]);
useEffect(() => { useEffect(() => {
if (paused) return;
saveObject(storedKey, state); saveObject(storedKey, state);
}, [storedKey, state]); }, [storedKey, state, paused]);
return [state, dispatch] as [T, Dispatch<A>]; return [state, dispatch] as [T, Dispatch<A>];
}; };

View File

@ -4,6 +4,8 @@ import { useInputState } from "@common/hooks/useInputState";
import { useMediaQuery } from "@common/hooks/useMediaQuery"; import { useMediaQuery } from "@common/hooks/useMediaQuery";
import clsx from "clsx"; import clsx from "clsx";
import { Check, ChevronsRight, Edit2, GitFork, RefreshCw, SendHorizonal, Sparkles, Square, Trash2, X } from "lucide-preact"; import { Check, ChevronsRight, Edit2, GitFork, RefreshCw, SendHorizonal, Sparkles, Square, Trash2, X } from "lucide-preact";
import { Component } from "preact";
import type { ComponentType } from "preact";
import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks"; import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks";
import { MOBILE_BREAKPOINT } from '../assets/breakpoints'; import { MOBILE_BREAKPOINT } from '../assets/breakpoints';
import styles from '../assets/chat-sidebar.module.css'; import styles from '../assets/chat-sidebar.module.css';
@ -15,55 +17,193 @@ import { Tools } from "../utils/tools";
import { useChapterSummarization } from "../utils/useChapterSummarization"; import { useChapterSummarization } from "../utils/useChapterSummarization";
import { Sidebar } from "./sidebar"; import { Sidebar } from "./sidebar";
/** Minimal `memo` for preact core (avoids pulling in the whole `preact/compat` layer). */
function memo<P extends object>(Render: ComponentType<P>) {
return class Memoized extends Component<P> {
override shouldComponentUpdate(nextProps: P) {
const prevProps = this.props as Record<string, unknown>;
const next = nextProps as Record<string, unknown>;
const prevKeys = Object.keys(prevProps);
const nextKeys = Object.keys(next);
if (prevKeys.length !== nextKeys.length) return true;
return nextKeys.some(key => prevProps[key] !== next[key]);
}
interface RoleHeaderProps { override render() {
message: ChatMessage; return <Render {...this.props} />;
chatMessages: ChatMessage[]; }
};
} }
const RoleHeader = ({ message, chatMessages }: RoleHeaderProps) => { interface RoleHeaderProps {
const { currentWorld, userName } = useAppState(); displayName: string;
roleLabel: string | null;
const toolName = useMemo(() => { }
if (message.role !== 'tool') return;
for (const m of chatMessages.toReversed()) {
if (m.role !== 'assistant') continue;
const toolCall = m.tool_calls?.find(tc => tc.id === message.tool_call_id);
if (toolCall) return toolCall.function.name;
}
}, [message, chatMessages]);
let displayName: string = message.role;
let roleLabel = null;
if (message.role === 'tool' && toolName) {
displayName = toolName;
roleLabel = message.role;
} else if (currentWorld?.chatOnly) {
if (message.role === 'user' && userName) {
displayName = userName;
roleLabel = message.role;
} else if (message.role === 'assistant' && currentWorld.title) {
displayName = currentWorld.title;
roleLabel = message.role;
}
}
const RoleHeader = ({ displayName, roleLabel }: RoleHeaderProps) => {
return ( return (
<div class={styles.role}> <div class={styles.role}>
<span>{displayName}</span> <span>{displayName}</span>
{roleLabel && ( {roleLabel && (
<span class={styles.toolBadge}> <span class={styles.toolBadge}>
{message.role} {roleLabel}
</span> </span>
)} )}
</div> </div>
); );
}; };
interface MessageRowProps {
message: ChatMessage;
displayName: string;
roleLabel: string | null;
isEditing: boolean;
isLoading: boolean;
chatOnly: boolean;
substitution: Prompt.SubstitutionVars;
editingContent: string;
onEditInput: (e: Event | string) => void;
onStartEdit: (message: ChatMessage) => void;
onCancelEdit: () => void;
onSaveEdit: (messageId: string, content: string) => void;
onDelete: (messageId: string) => void;
onFork: (messageId: string) => void;
}
const MessageRow = memo(function MessageRow({
message,
displayName,
roleLabel,
isEditing,
isLoading,
chatOnly,
substitution,
editingContent,
onEditInput,
onStartEdit,
onCancelEdit,
onSaveEdit,
onDelete,
onFork,
}: MessageRowProps) {
const canEdit = message.role === 'user' || message.role === 'assistant';
// Markdown highlighting is expensive; only redo it when this message (or the substitution vars) change.
const contentHtml = useMemo(
() => highlight(Prompt.substitute(message.content, substitution), false).trim(),
[message.content, substitution],
);
return (
<div class={styles.message} data-role={message.role}>
<div class={styles.messageHeader}>
<RoleHeader displayName={displayName} roleLabel={roleLabel} />
{!isLoading && canEdit && (
<div class={styles.messageActions}>
{isEditing ? (
<>
<button
class={clsx(styles.iconButton, styles.saveButton)}
onClick={() => onSaveEdit(message.id, editingContent)}
disabled={!editingContent.trim()}
title="Save (Ctrl+Enter)"
>
<Check size={12} />
</button>
<button
class={clsx(styles.iconButton, styles.cancelButton)}
onClick={onCancelEdit}
title="Cancel (Esc)"
>
<X size={12} />
</button>
</>
) : (
<>
{chatOnly && (
<button
class={styles.iconButton}
onClick={() => onFork(message.id)}
title="Fork chat up to this message"
>
<GitFork size={12} />
</button>
)}
<button
class={styles.iconButton}
onClick={() => onStartEdit(message)}
title="Edit message"
>
<Edit2 size={12} />
</button>
<button
class={styles.iconButton}
onClick={() => onDelete(message.id)}
title="Delete this and all following messages"
>
<Trash2 size={12} />
</button>
</>
)}
</div>
)}
</div>
{isEditing ? (
<div class={styles.editContainer}>
<ContentEditable
autoLines
highlight
class={styles.editTextarea}
value={editingContent}
onInput={onEditInput}
onKeyDown={(e) => {
if (e.key === 'Enter' && e.ctrlKey) {
e.preventDefault();
onSaveEdit(message.id, editingContent);
}
if (e.key === 'Escape') {
e.preventDefault();
onCancelEdit();
}
}}
/>
</div>
) : (
<>
{message.role === 'assistant' && message.reasoning_content?.trim() && (
<div class={styles.reasoningContent}>
{message.reasoning_content.trim()}
</div>
)}
<div
class={styles.content}
dangerouslySetInnerHTML={{ __html: contentHtml }}
/>
{message.role === 'assistant' && message.tool_calls && (
<div class={styles.toolCalls}>
{message.tool_calls.map((tool) => (
<span key={tool.id} class={styles.toolBadge}>
{tool.function.name}
</span>
))}
</div>
)}
</>
)}
</div>
);
});
export const ChatPanel = ({ visible }: { visible: boolean }) => { export const ChatPanel = ({ visible }: { visible: boolean }) => {
const appState = useAppState(); const appState = useAppState();
const { currentWorld, currentStory, dispatch, connection, model, enableThinking, chatOpen, continuePrompt } = appState; const {
currentWorld, currentStory, dispatch, connection, model, enableThinking,
chatOpen, continuePrompt, userName, chatSystemInstruction, setSavingPaused,
} = appState;
const { summarizeAll, isSummarizing } = useChapterSummarization(); const { summarizeAll, isSummarizing } = useChapterSummarization();
const [input, setInput] = useInputState(''); const [input, setInput] = useInputState('');
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@ -82,6 +222,25 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => {
const lastMessage = currentStory?.chatMessages.at(-1); const lastMessage = currentStory?.chatMessages.at(-1);
// Stable values for the memoized message rows: they must not change on every streamed chunk.
const substitution = useMemo(
() => Prompt.getSubstitutionVars(appState),
// eslint-disable-next-line react-hooks/exhaustive-deps -- exactly the fields read by getSubstitutionVars
[currentWorld?.title, userName, chatSystemInstruction],
);
// Resolve tool call ids once per message list change instead of scanning the whole history per message.
const toolNames = useMemo(() => {
const names = new Map<string, string>();
for (const message of currentStory?.chatMessages ?? []) {
if (message.role !== 'assistant') continue;
for (const toolCall of message.tool_calls ?? []) {
names.set(toolCall.id, toolCall.function.name);
}
}
return names;
}, [currentStory?.chatMessages]);
useEffect(() => { useEffect(() => {
if (messagesRef.current) { if (messagesRef.current) {
messagesRef.current.scrollTo({ messagesRef.current.scrollTo({
@ -254,7 +413,6 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => {
}); });
if (tool_calls) { if (tool_calls) {
console.log(tool_calls);
const toolMessages: ChatMessage[] = []; const toolMessages: ChatMessage[] = [];
for (const tool of tool_calls) { for (const tool of tool_calls) {
if (abortControllerRef.current?.signal.aborted) { if (abortControllerRef.current?.signal.aborted) {
@ -305,6 +463,8 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => {
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
abortControllerRef.current = new AbortController(); abortControllerRef.current = new AbortController();
// Don't persist on every streamed chunk — the final state is saved when this finishes.
setSavingPaused(true);
const excludedMessageIds = new Set<string>(); const excludedMessageIds = new Set<string>();
try { try {
@ -320,9 +480,10 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => {
} }
await sendMessage({ excludedMessageIds }); await sendMessage({ excludedMessageIds });
} finally { } finally {
setSavingPaused(false);
setIsLoading(false); setIsLoading(false);
} }
}, [currentStory, currentWorld, connection, model, isLoading, sendMessage]); }, [currentStory, currentWorld, connection, model, isLoading, sendMessage, setSavingPaused]);
const handleSendMessage = useCallback(async () => { const handleSendMessage = useCallback(async () => {
if (!currentStory || !connection || !model || isLoading) return; if (!currentStory || !connection || !model || isLoading) return;
@ -338,6 +499,7 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => {
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
abortControllerRef.current = new AbortController(); abortControllerRef.current = new AbortController();
setSavingPaused(true);
try { try {
await sendMessage({ await sendMessage({
@ -348,9 +510,10 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => {
}] }]
}); });
} finally { } finally {
setSavingPaused(false);
setIsLoading(false); setIsLoading(false);
} }
}, [currentStory, input, connection, model, isLoading, sendMessage, handleRegenerate]); }, [currentStory, input, connection, model, isLoading, sendMessage, handleRegenerate, setSavingPaused]);
const handleContinue = useCallback(async () => { const handleContinue = useCallback(async () => {
if (!currentStory || !connection || !model || isLoading) return; if (!currentStory || !connection || !model || isLoading) return;
@ -359,6 +522,7 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => {
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
abortControllerRef.current = new AbortController(); abortControllerRef.current = new AbortController();
setSavingPaused(true);
try { try {
if (currentWorld?.chatOnly) { if (currentWorld?.chatOnly) {
@ -373,9 +537,10 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => {
}); });
} }
} finally { } finally {
setSavingPaused(false);
setIsLoading(false); setIsLoading(false);
} }
}, [currentStory, currentWorld, input, connection, model, isLoading, sendMessage]); }, [currentStory, currentWorld, input, connection, model, isLoading, sendMessage, setSavingPaused]);
const handleStopGeneration = useCallback(() => { const handleStopGeneration = useCallback(() => {
abortControllerRef.current?.abort(); abortControllerRef.current?.abort();
@ -390,6 +555,7 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => {
}; };
const handleDeleteMessage = useCallback((messageId: string) => { const handleDeleteMessage = useCallback((messageId: string) => {
const { currentWorld, currentStory } = appStateRef.current;
if (!currentStory || !currentWorld) return; if (!currentStory || !currentWorld) return;
if (!confirm('Delete this and all following messages?')) return; if (!confirm('Delete this and all following messages?')) return;
dispatch({ dispatch({
@ -398,9 +564,10 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => {
storyId: currentStory.id, storyId: currentStory.id,
messageId, messageId,
}); });
}, [currentStory, currentWorld, dispatch]); }, [dispatch]);
const handleForkChat = useCallback((messageId: string) => { const handleForkChat = useCallback((messageId: string) => {
const { currentWorld, currentStory } = appStateRef.current;
if (!currentStory || !currentWorld) return; if (!currentStory || !currentWorld) return;
dispatch({ dispatch({
type: 'DUPLICATE_STORY', type: 'DUPLICATE_STORY',
@ -408,27 +575,28 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => {
id: currentStory.id, id: currentStory.id,
upToMessageId: messageId, upToMessageId: messageId,
}); });
}, [currentStory, currentWorld, dispatch]); }, [dispatch]);
const handleStartEdit = useCallback((message: ChatMessage) => { const handleStartEdit = useCallback((message: ChatMessage) => {
setEditingMessageId(message.id); setEditingMessageId(message.id);
setEditingContent(message.content); setEditingContent(message.content);
}, [setEditingContent]); }, [setEditingContent]);
const handleSaveEdit = useCallback(() => { const handleSaveEdit = useCallback((messageId: string, content: string) => {
if (!currentStory || !currentWorld || !editingMessageId) return; const { currentWorld, currentStory } = appStateRef.current;
if (!currentStory || !currentWorld || !content.trim()) return;
dispatch({ dispatch({
type: 'EDIT_CHAT_MESSAGE', type: 'EDIT_CHAT_MESSAGE',
worldId: currentWorld.id, worldId: currentWorld.id,
storyId: currentStory.id, storyId: currentStory.id,
messageId: editingMessageId, messageId,
content: editingContent.trim(), content: content.trim(),
}); });
setEditingMessageId(null); setEditingMessageId(null);
setEditingContent(''); setEditingContent('');
}, [currentStory, currentWorld, editingMessageId, editingContent, dispatch, setEditingContent]); }, [dispatch, setEditingContent]);
const handleCancelEdit = useCallback(() => { const handleCancelEdit = useCallback(() => {
setEditingMessageId(null); setEditingMessageId(null);
@ -457,109 +625,44 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => {
<div class={styles.messages} ref={messagesRef}> <div class={styles.messages} ref={messagesRef}>
{currentStory.chatMessages.map((message) => { {currentStory.chatMessages.map((message) => {
const isEditing = editingMessageId === message.id; const isEditing = editingMessageId === message.id;
const canEdit = message.role === 'user' || message.role === 'assistant'; const toolName = message.role === 'tool' && message.tool_call_id
? toolNames.get(message.tool_call_id)
: undefined;
let displayName: string = message.role;
let roleLabel: string | null = null;
if (message.role === 'tool' && toolName) {
displayName = toolName;
roleLabel = message.role;
} else if (currentWorld?.chatOnly) {
if (message.role === 'user' && userName) {
displayName = userName;
roleLabel = message.role;
} else if (message.role === 'assistant' && currentWorld.title) {
displayName = currentWorld.title;
roleLabel = message.role;
}
}
return ( return (
<div key={message.id} class={styles.message} data-role={message.role}> <MessageRow
<div class={styles.messageHeader}> key={message.id}
<RoleHeader message={message} chatMessages={currentStory.chatMessages} /> message={message}
displayName={displayName}
{!isLoading && canEdit && ( roleLabel={roleLabel}
<div class={styles.messageActions}> isEditing={isEditing}
{isEditing ? ( isLoading={isLoading}
<> chatOnly={currentWorld?.chatOnly ?? false}
<button substitution={substitution}
class={clsx(styles.iconButton, styles.saveButton)} editingContent={isEditing ? editingContent : ''}
onClick={handleSaveEdit} onEditInput={setEditingContent}
disabled={!editingContent.trim()} onStartEdit={handleStartEdit}
title="Save (Ctrl+Enter)" onCancelEdit={handleCancelEdit}
> onSaveEdit={handleSaveEdit}
<Check size={12} /> onDelete={handleDeleteMessage}
</button> onFork={handleForkChat}
<button />
class={clsx(styles.iconButton, styles.cancelButton)}
onClick={handleCancelEdit}
title="Cancel (Esc)"
>
<X size={12} />
</button>
</>
) : (
<>
{currentWorld?.chatOnly && (
<button
class={styles.iconButton}
onClick={() => handleForkChat(message.id)}
title="Fork chat up to this message"
>
<GitFork size={12} />
</button>
)}
<button
class={styles.iconButton}
onClick={() => handleStartEdit(message)}
title="Edit message"
>
<Edit2 size={12} />
</button>
<button
class={styles.iconButton}
onClick={() => handleDeleteMessage(message.id)}
title="Delete this and all following messages"
>
<Trash2 size={12} />
</button>
</>
)}
</div>
)}
</div>
{isEditing ? (
<div class={styles.editContainer}>
<ContentEditable
autoLines
highlight
class={styles.editTextarea}
value={editingContent}
onInput={setEditingContent}
onKeyDown={(e) => {
if (e.key === 'Enter' && e.ctrlKey) {
e.preventDefault();
handleSaveEdit();
}
if (e.key === 'Escape') {
e.preventDefault();
handleCancelEdit();
}
}}
/>
</div>
) : (
<>
{message.role === 'assistant' && message.reasoning_content?.trim() && (
<div class={styles.reasoningContent}>
{message.reasoning_content.trim()}
</div>
)}
<div
class={styles.content}
dangerouslySetInnerHTML={{ __html: highlight(Prompt.substituteVars(appState, message.content), false).trim() }}
/>
{message.role === 'assistant' && message.tool_calls && (
<div class={styles.toolCalls}>
{message.tool_calls.map((tool) => (
<span key={tool.id} class={styles.toolBadge}>
{tool.function.name}
</span>
))}
</div>
)}
</>
)}
</div>
); );
})} })}
{error && ( {error && (

View File

@ -67,10 +67,11 @@ export const Editor = () => {
const editorRef = useRef<HTMLDivElement>(null); const editorRef = useRef<HTMLDivElement>(null);
const promptPreview = useMemo(() => { const promptPreview = useMemo(() => {
if (currentTab !== 'prompt') return '';
let text = Prompt.formatSystemPrompt(appState); let text = Prompt.formatSystemPrompt(appState);
text = Prompt.substituteVars(appState, text); text = Prompt.substituteVars(appState, text);
return highlight(text, false); return highlight(text, false);
}, [appState]); }, [appState, currentTab]);
useEffect(() => { useEffect(() => {
if (currentStory?.lastEditedText) { if (currentStory?.lastEditedText) {

View File

@ -17,20 +17,6 @@ export const StoryEditor = ({ visible }: { visible: boolean }) => {
}); });
}, [currentStory?.id, currentWorld?.id]); }, [currentStory?.id, currentWorld?.id]);
const value = useMemo(() => {
if (!currentStory) return '';
const { text, lastEditedText } = currentStory;
if (!lastEditedText) return text;
const idx = text.lastIndexOf(lastEditedText);
if (idx === -1) return text;
const marked = text.slice(0, idx) + '<mark>' + lastEditedText + '</mark>' + text.slice(idx + lastEditedText.length);
return marked;
}, [currentStory?.text, currentStory?.lastEditedText]);
if (!currentWorld || !currentStory || !visible) { if (!currentWorld || !currentStory || !visible) {
return null; return null;
} }
@ -39,7 +25,8 @@ export const StoryEditor = ({ visible }: { visible: boolean }) => {
<ContentEditable <ContentEditable
highlight highlight
class={styles.editable} class={styles.editable}
value={value} value={currentStory.text}
highlightText={currentStory.lastEditedText}
onInput={handleInput} onInput={handleInput}
placeholder="Start writing your story..." placeholder="Start writing your story..."
/> />

View File

@ -1,6 +1,6 @@
import { useRemoteReducer } from "@common/hooks/useRemote"; import { useRemoteReducer } from "@common/hooks/useRemote";
import { createContext } from "preact"; import { createContext } from "preact";
import { useContext, useMemo } from "preact/hooks"; import { useContext, useMemo, useState } from "preact/hooks";
import Chapters from "../utils/chapters"; import Chapters from "../utils/chapters";
import LLM from "../utils/llm"; import LLM from "../utils/llm";
@ -686,6 +686,8 @@ export interface AppState {
imageGenerationSettings: ImageGenerationSettings; imageGenerationSettings: ImageGenerationSettings;
/** Effective system instruction: world override if set, otherwise global */ /** Effective system instruction: world override if set, otherwise global */
effectiveSystemInstruction: string; effectiveSystemInstruction: string;
/** Pause persistence (used while streaming; the latest state is saved once streaming ends) */
setSavingPaused: (paused: boolean) => void;
dispatch: (action: Action) => void; dispatch: (action: Action) => void;
} }
@ -698,7 +700,8 @@ export const useAppState = () => useContext(StateContext);
export const STATE_KEY = 'state'; export const STATE_KEY = 'state';
export const StateContextProvider = ({ children }: { children?: any }) => { export const StateContextProvider = ({ children }: { children?: any }) => {
const [state, dispatch] = useRemoteReducer(STATE_KEY, reducer, DEFAULT_STATE); const [savingPaused, setSavingPaused] = useState(false);
const [state, dispatch] = useRemoteReducer(STATE_KEY, reducer, DEFAULT_STATE, { paused: savingPaused });
const value = useMemo<AppState>(() => { const value = useMemo<AppState>(() => {
const currentWorld = state.worlds.find(w => w.id === state.currentWorldId) ?? null; const currentWorld = state.worlds.find(w => w.id === state.currentWorldId) ?? null;
@ -748,6 +751,7 @@ export const StateContextProvider = ({ children }: { children?: any }) => {
? chatSystemInstruction ? chatSystemInstruction
: systemInstruction) : systemInstruction)
|| systemInstruction, || systemInstruction,
setSavingPaused,
dispatch, dispatch,
}; };
}, [state]); }, [state]);

View File

@ -284,31 +284,43 @@ namespace Prompt {
return lines.join('\n'); return lines.join('\n');
} }
const objectUrls = new Map<string, string>(); /**
function stripDataUrlImages(text: string): string { * Inline images are meaningless to the model and their data/blob URLs are
return text.replace(/!\[([^\]]*)\]\((data:([^;]*);base64,[^)]+)\)/g, (_, alt, dataUrl, type) => { * unstable across sessions (which also breaks provider prompt caching).
let blobUrl = objectUrls.get(dataUrl); */
if (!blobUrl) { function stripImages(text: string): string {
const b64 = dataUrl.replace(/^data:[^;]*;base64,/, ''); return text.replace(/!\[([^\]]*)\]\((?:data|blob):[^)]*\)/g, (_match, alt: string) =>
const imageData = atob(b64); alt ? `[image: ${alt}]` : '[image]'
const blob = new Blob([imageData], { type }); );
blobUrl = URL.createObjectURL(blob); }
objectUrls.set(dataUrl, blobUrl);
} /** Values used by {@link substituteVars}, extracted so they can be memoized independently of the full app state. */
return `![${alt}](${blobUrl})`; export interface SubstitutionVars {
}); charName: string;
userName: string;
chatSystemInstruction: string;
}
export function getSubstitutionVars(state: AppState): SubstitutionVars {
return {
charName: state.currentWorld?.title || 'Assistant',
userName: state.userName || 'User',
chatSystemInstruction: state.chatSystemInstruction,
};
}
export function substitute(text: string, vars: SubstitutionVars): string {
return text
.replaceAll('{{system}}', vars.chatSystemInstruction)
.replaceAll('{{char}}', vars.charName)
.replaceAll('{{user}}', vars.userName);
} }
export function substituteVars(state: AppState, text: string): string { export function substituteVars(state: AppState, text: string): string {
const charName = state.currentWorld?.title || 'Assistant'; return substitute(text, getSubstitutionVars(state));
const userName = state.userName || 'User';
return text
.replaceAll('{{system}}', state.chatSystemInstruction)
.replaceAll('{{char}}', charName)
.replaceAll('{{user}}', userName);
} }
export function formatSystemPrompt(state: AppState, storyTokenBudget: number = 0): string { export function formatSystemPrompt(state: AppState): string {
const { currentStory, currentWorld } = state; const { currentStory, currentWorld } = state;
if (!currentStory) { if (!currentStory) {
return state.effectiveSystemInstruction; return state.effectiveSystemInstruction;
@ -350,11 +362,10 @@ namespace Prompt {
parts.push(`## Scratchpad`, currentStory.scratchpad); parts.push(`## Scratchpad`, currentStory.scratchpad);
} }
if (currentStory.text && storyTokenBudget > 0) { // The story is the source of truth and is sent in full, exactly as it
const storyText = formatStoryChunks(currentStory.text, currentStory.chapters ?? [], storyTokenBudget); // currently is in the editor (including manual user edits).
if (storyText) { if (currentStory.text) {
parts.push(`## Story Text:`, storyText); parts.push(`## Story Text:`, currentStory.text);
}
} }
} }
@ -368,6 +379,62 @@ namespace Prompt {
keepThinking?: boolean; keepThinking?: boolean;
} }
/**
* Reply used when an assistant turn consisted only of tool calls and has no
* text left. Keeps every user message paired with a reply so older
* instructions don't read like one continuous current instruction.
*/
const EMPTY_ASSISTANT_REPLY = 'Done.';
/**
* Story-mode transcript: older tool traffic is dropped because the story and
* scratchpad are sent in full inside the system prompt, while all
* user/assistant text is kept so earlier behavioural instructions survive.
*
* The most recent tool request/response round is kept so the model can see
* the result of its last action (including errors) and does not retry it.
*/
function toTranscript(chatMessages: ChatMessage[]): LLM.ChatMessage[] {
const transcript: LLM.ChatMessage[] = [];
for (let i = 0; i < chatMessages.length; i++) {
const message = chatMessages[i];
if (message.role === 'tool') {
transcript.push({
role: 'tool',
content: message.content,
tool_call_id: message.tool_call_id,
});
continue;
}
if (message.role === 'assistant') {
if (message.tool_calls?.length) {
transcript.push({
role: 'assistant',
content: message.content ?? '',
tool_calls: message.tool_calls,
});
continue;
}
if (!message.content.trim()) continue;
transcript.push({ role: 'assistant', content: message.content });
continue;
}
transcript.push({ role: message.role, content: message.content });
}
for (let i = transcript.length - 1; i > 0; i--) {
if (transcript[i].role === 'user' && transcript[i - 1].role === 'user') {
transcript.splice(i, 0, { role: 'assistant', content: EMPTY_ASSISTANT_REPLY });
}
}
return transcript;
}
export function compilePrompt( export function compilePrompt(
state: AppState, state: AppState,
config: CompileConfig = { keepThinking: true }, config: CompileConfig = { keepThinking: true },
@ -404,7 +471,7 @@ namespace Prompt {
const applyVars = (msgs: ChatMessage[]) => const applyVars = (msgs: ChatMessage[]) =>
msgs.map(m => ({ msgs.map(m => ({
...m, ...m,
content: stripDataUrlImages(substituteVars(state, m.content)), content: stripImages(substituteVars(state, m.content)),
reasoning_content: config.keepThinking && ('reasoning_content' in m) reasoning_content: config.keepThinking && ('reasoning_content' in m)
? m.reasoning_content ? m.reasoning_content
: undefined, : undefined,
@ -421,7 +488,7 @@ namespace Prompt {
formattedMessages.unshift({ formattedMessages.unshift({
id: crypto.randomUUID(), id: crypto.randomUUID(),
role: 'system', role: 'system',
content: formatSystemPrompt(state, 0), content: formatSystemPrompt(state),
}); });
return { return {
@ -436,25 +503,44 @@ namespace Prompt {
}; };
} }
// Estimate token budget for story text // Story mode: the current story text (with any manual user edits) lives
let storyTokenBudget = 0; // in the system prompt, so the transcript only carries user/assistant
if (model.context_length) { // text plus the most recent tool round. App-only fields (ids, reasoning)
const nonStorySystem = formatSystemPrompt(state, 0); // are never sent.
const chatText = messages.map(m => m.content).join('\n'); const transcript: LLM.ChatMessage[] = toTranscript(messages).map((message) => {
const maxOutput = model.top_provider.max_completion_tokens || 2048; const content = stripImages(substituteVars(state, message.content));
const otherTokens = approxTokens(nonStorySystem) + approxTokens(chatText) + maxOutput;
storyTokenBudget = model.context_length - otherTokens;
}
messages.unshift({ if (message.role === 'assistant' && message.tool_calls?.length) {
id: crypto.randomUUID(), return {
role: 'assistant',
content,
tool_calls: message.tool_calls.map(toolCall => ({
...toolCall,
function: {
...toolCall.function,
arguments: typeof toolCall.function.arguments === 'string'
? stripImages(toolCall.function.arguments)
: toolCall.function.arguments,
},
})),
};
}
if (message.role === 'tool') {
return { role: 'tool', content, tool_call_id: message.tool_call_id };
}
return { role: message.role, content } as LLM.ChatMessage;
});
transcript.unshift({
role: 'system', role: 'system',
content: formatSystemPrompt(state, storyTokenBudget), content: stripImages(substituteVars(state, formatSystemPrompt(state))),
}); });
return { return {
model: model.id, model: model.id,
messages: applyVars(messages), messages: transcript,
tools: Tools.getTools(), tools: Tools.getTools(),
banned_tokens: state.bannedTokens, banned_tokens: state.bannedTokens,
reasoning: { effort: enableThinking ? 'high' : 'none' }, reasoning: { effort: enableThinking ? 'high' : 'none' },
@ -465,13 +551,13 @@ namespace Prompt {
export function applyDelta(input: any, delta: any) { export function applyDelta(input: any, delta: any) {
if (Array.isArray(delta)) { if (Array.isArray(delta)) {
const result = [...(input ?? [])]; const result = input ?? [];
for (const item of delta) { for (const item of delta) {
result[item.index] = applyDelta(result[item.index], item); result[item.index] = applyDelta(result[item.index], item);
} }
return result; return result;
} else if (typeof delta === 'object') { } else if (typeof delta === 'object') {
const result = { ...input }; const result = input ?? {};
for (const key of Object.keys(delta)) { for (const key of Object.keys(delta)) {
const value = delta[key]; const value = delta[key];
result[key] = applyDelta(result[key], value); result[key] = applyDelta(result[key], value);

View File

@ -377,14 +377,12 @@ export namespace Tools {
cropped = true; cropped = true;
} }
dispatchEdit(currentText.trim() + '\n' + args.new_text.trim()); dispatchEdit(currentText.trim() + '\n\n' + args.new_text.trim());
appState.dispatch({ type: 'SET_CURRENT_TAB', tab }); appState.dispatch({ type: 'SET_CURRENT_TAB', tab });
let message = cropped let message = cropped
? `Added text:\n${args.new_text.split('\n').filter(l => l.trim()).map(l => '> ' + l).join('\n')}\n\nNote: The rest was cropped due to ${LINES_LIMIT} lines limit!` ? `Added text:\n${args.new_text.split('\n').filter(l => l.trim()).map(l => '> ' + l).join('\n')}\n\nNote: The rest was cropped due to ${LINES_LIMIT} lines limit!`
: `Text appended to ${target} successfully.`; : `Text appended to ${target} successfully.`;
message += `\nTool note: you're not allowed to add more text now. Do not retry nor continue.`
return message; return message;
} }