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 type { JSX } from "preact";
import clsx from "clsx";
import styles from "../assets/content-editable.module.css";
import { highlight } from "../highlight";
import { useLayoutEffect, useRef } from 'preact/hooks';
import type { JSX } from 'preact';
import clsx from 'clsx';
import styles from '../assets/content-editable.module.css';
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;
placeholder?: string;
autoLines?: boolean;
highlight?: boolean;
/** Highlight every literal, case-sensitive match without changing value. */
highlightText?: string | readonly string[];
disabled?: boolean;
onInput?: JSX.EventHandler<JSX.TargetedInputEvent<HTMLDivElement>>;
};
const parseLines = (html: string, keepSpan: boolean = false): string => {
html = html
.replace(/\n?<div><br\/?><\/div>/g, '\n\n')
.replace(/\n?<div[^>]*>/, '\n')
.replace(/<\/?div[^>]*>/g, '')
.replace(/<br[^>]*>/g, '');
type Snapshot = { text: string; anchor: number; focus: number };
const normalize = (text: string) => text.replace(/\r\n?/g, '\n');
const nativeBlock = (node: Node) => node.nodeType === 1 && /^(DIV|P|LI|PRE|H[1-6])$/.test((node as Element).tagName);
const graphemes = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
type Point = [Node, number];
type TextRun = { node: Node; start: number; end: number };
let nextMatchId = 0;
if (!keepSpan) {
html = html.replace(/<[^>]+>/g, '');
function findTextMatches(root: HTMLElement, query: Props['highlightText']) {
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 });
}
return html;
};
function getCaretOffset(el: HTMLElement): number {
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;
}
for (const term of terms) {
let from = text.indexOf(term);
while (from !== -1) {
matches.push({ from, to: from + term.length });
from = text.indexOf(term, from + 1);
}
return false;
}
if (!traverse(el)) {
range.selectNodeContents(el);
range.collapse(false);
matches.sort((a, b) => a.from - b.from || a.to - b.to);
const merged: typeof matches = [];
for (const match of matches) {
const previous = merged.at(-1);
if (previous && match.from < previous.to) previous.to = Math.max(previous.to, match.to);
else merged.push({ ...match });
}
sel.removeAllRanges();
sel.addRange(range);
return { nodes, matches: merged };
}
function resizeToContent(el: HTMLElement) {
el.style.height = 'auto';
el.style.height = el.scrollHeight + 'px';
/** Paint-only ranges keep live text nodes and selection untouched. Older
* browsers use internal spans, which never become part of the source value. */
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 = ({
value,
placeholder,
autoLines,
highlight: enableHighlight,
disabled,
onInput,
class: externalClass,
value, placeholder, autoLines = false, highlight: enableHighlight = false,
highlightText,
disabled = false, onInput, class: externalClass, className,
onKeyDown, onBeforeInput, onPaste, onCopy, onCut,
onCompositionStart, onCompositionEnd, onCompositionUpdate, onBlur,
onCompositionStartCapture, onCompositionEndCapture, onCompositionUpdateCapture,
children: _children, dangerouslySetInnerHTML: _html, contentEditable: _editable,
...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 el = ref.current;
const sync = () => {
const el = root.current;
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 oldValue = parseLines(el.innerHTML, true);
const decorate = () => {
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;
el.innerHTML = newValue;
(el as any).value = value;
if (offset !== null) setCaretOffset(el, offset);
if (autoLines) resizeToContent(el);
}, [value, enableHighlight]);
const remember = (previous: Snapshot, next: Snapshot, kind: string) => {
if (previous.text === next.text) return;
const now = Date.now();
const group = grouping.current;
const continuous = group && group.kind === kind && (kind === 'composition' || (now - group.time < 750 &&
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) => {
if (!ref.current) return;
if (autoLines) resizeToContent(ref.current);
const text = parseLines(ref.current.innerHTML);
// add value prop to work with useInputState/useInputCallback
(ref.current as any).value = text;
onInput?.(e);
const notify = (inputType: string, data: string | null = null) => {
notifying.current = true;
try {
root.current!.dispatchEvent(new InputEvent('input', { bubbles: true, composed: true, inputType, data }));
} finally { notifying.current = false; }
};
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 (
<div
ref={ref}
{...props}
contentEditable={!disabled}
data-placeholder={value?.replaceAll('\n', '').length ? undefined : placeholder}
class={clsx(styles.root, autoLines && styles.autoLines, externalClass, disabled && styles.disabled)}
onInput={disabled ? undefined : handleInput}
ref={root}
class={clsx(styles.root, autoLines && styles.autoLines, externalClass, className, disabled && styles.disabled)}
contentEditable={disabled ? false : 'plaintext-only'}
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;
};
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 paused = options.paused ?? false;
const getInitialValue = (): T => {
try {
@ -73,8 +79,9 @@ export const useRemoteReducer = <T, A>(key: string, reducer: Reducer<T, A>, init
}, [storedKey]);
useEffect(() => {
if (paused) return;
saveObject(storedKey, state);
}, [storedKey, state]);
}, [storedKey, state, paused]);
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 clsx from "clsx";
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 { MOBILE_BREAKPOINT } from '../assets/breakpoints';
import styles from '../assets/chat-sidebar.module.css';
@ -15,55 +17,193 @@ import { Tools } from "../utils/tools";
import { useChapterSummarization } from "../utils/useChapterSummarization";
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 {
message: ChatMessage;
chatMessages: ChatMessage[];
override render() {
return <Render {...this.props} />;
}
};
}
const RoleHeader = ({ message, chatMessages }: RoleHeaderProps) => {
const { currentWorld, userName } = useAppState();
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;
}
}
interface RoleHeaderProps {
displayName: string;
roleLabel: string | null;
}
const RoleHeader = ({ displayName, roleLabel }: RoleHeaderProps) => {
return (
<div class={styles.role}>
<span>{displayName}</span>
{roleLabel && (
<span class={styles.toolBadge}>
{message.role}
{roleLabel}
</span>
)}
</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 }) => {
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 [input, setInput] = useInputState('');
const [isLoading, setIsLoading] = useState(false);
@ -82,6 +222,25 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => {
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(() => {
if (messagesRef.current) {
messagesRef.current.scrollTo({
@ -254,7 +413,6 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => {
});
if (tool_calls) {
console.log(tool_calls);
const toolMessages: ChatMessage[] = [];
for (const tool of tool_calls) {
if (abortControllerRef.current?.signal.aborted) {
@ -305,6 +463,8 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => {
setIsLoading(true);
setError(null);
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>();
try {
@ -320,9 +480,10 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => {
}
await sendMessage({ excludedMessageIds });
} finally {
setSavingPaused(false);
setIsLoading(false);
}
}, [currentStory, currentWorld, connection, model, isLoading, sendMessage]);
}, [currentStory, currentWorld, connection, model, isLoading, sendMessage, setSavingPaused]);
const handleSendMessage = useCallback(async () => {
if (!currentStory || !connection || !model || isLoading) return;
@ -338,6 +499,7 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => {
setIsLoading(true);
setError(null);
abortControllerRef.current = new AbortController();
setSavingPaused(true);
try {
await sendMessage({
@ -348,9 +510,10 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => {
}]
});
} finally {
setSavingPaused(false);
setIsLoading(false);
}
}, [currentStory, input, connection, model, isLoading, sendMessage, handleRegenerate]);
}, [currentStory, input, connection, model, isLoading, sendMessage, handleRegenerate, setSavingPaused]);
const handleContinue = useCallback(async () => {
if (!currentStory || !connection || !model || isLoading) return;
@ -359,6 +522,7 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => {
setIsLoading(true);
setError(null);
abortControllerRef.current = new AbortController();
setSavingPaused(true);
try {
if (currentWorld?.chatOnly) {
@ -373,9 +537,10 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => {
});
}
} finally {
setSavingPaused(false);
setIsLoading(false);
}
}, [currentStory, currentWorld, input, connection, model, isLoading, sendMessage]);
}, [currentStory, currentWorld, input, connection, model, isLoading, sendMessage, setSavingPaused]);
const handleStopGeneration = useCallback(() => {
abortControllerRef.current?.abort();
@ -390,6 +555,7 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => {
};
const handleDeleteMessage = useCallback((messageId: string) => {
const { currentWorld, currentStory } = appStateRef.current;
if (!currentStory || !currentWorld) return;
if (!confirm('Delete this and all following messages?')) return;
dispatch({
@ -398,9 +564,10 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => {
storyId: currentStory.id,
messageId,
});
}, [currentStory, currentWorld, dispatch]);
}, [dispatch]);
const handleForkChat = useCallback((messageId: string) => {
const { currentWorld, currentStory } = appStateRef.current;
if (!currentStory || !currentWorld) return;
dispatch({
type: 'DUPLICATE_STORY',
@ -408,27 +575,28 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => {
id: currentStory.id,
upToMessageId: messageId,
});
}, [currentStory, currentWorld, dispatch]);
}, [dispatch]);
const handleStartEdit = useCallback((message: ChatMessage) => {
setEditingMessageId(message.id);
setEditingContent(message.content);
}, [setEditingContent]);
const handleSaveEdit = useCallback(() => {
if (!currentStory || !currentWorld || !editingMessageId) return;
const handleSaveEdit = useCallback((messageId: string, content: string) => {
const { currentWorld, currentStory } = appStateRef.current;
if (!currentStory || !currentWorld || !content.trim()) return;
dispatch({
type: 'EDIT_CHAT_MESSAGE',
worldId: currentWorld.id,
storyId: currentStory.id,
messageId: editingMessageId,
content: editingContent.trim(),
messageId,
content: content.trim(),
});
setEditingMessageId(null);
setEditingContent('');
}, [currentStory, currentWorld, editingMessageId, editingContent, dispatch, setEditingContent]);
}, [dispatch, setEditingContent]);
const handleCancelEdit = useCallback(() => {
setEditingMessageId(null);
@ -457,109 +625,44 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => {
<div class={styles.messages} ref={messagesRef}>
{currentStory.chatMessages.map((message) => {
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 (
<div key={message.id} class={styles.message} data-role={message.role}>
<div class={styles.messageHeader}>
<RoleHeader message={message} chatMessages={currentStory.chatMessages} />
{!isLoading && canEdit && (
<div class={styles.messageActions}>
{isEditing ? (
<>
<button
class={clsx(styles.iconButton, styles.saveButton)}
onClick={handleSaveEdit}
disabled={!editingContent.trim()}
title="Save (Ctrl+Enter)"
>
<Check size={12} />
</button>
<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>
<MessageRow
key={message.id}
message={message}
displayName={displayName}
roleLabel={roleLabel}
isEditing={isEditing}
isLoading={isLoading}
chatOnly={currentWorld?.chatOnly ?? false}
substitution={substitution}
editingContent={isEditing ? editingContent : ''}
onEditInput={setEditingContent}
onStartEdit={handleStartEdit}
onCancelEdit={handleCancelEdit}
onSaveEdit={handleSaveEdit}
onDelete={handleDeleteMessage}
onFork={handleForkChat}
/>
);
})}
{error && (

View File

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

View File

@ -17,20 +17,6 @@ export const StoryEditor = ({ visible }: { visible: boolean }) => {
});
}, [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) {
return null;
}
@ -39,7 +25,8 @@ export const StoryEditor = ({ visible }: { visible: boolean }) => {
<ContentEditable
highlight
class={styles.editable}
value={value}
value={currentStory.text}
highlightText={currentStory.lastEditedText}
onInput={handleInput}
placeholder="Start writing your story..."
/>

View File

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

View File

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

View File

@ -377,14 +377,12 @@ export namespace Tools {
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 });
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!`
: `Text appended to ${target} successfully.`;
message += `\nTool note: you're not allowed to add more text now. Do not retry nor continue.`
return message;
}