Compare commits
No commits in common. "76d4ef297ef9dea82dd60b049f196fb1ab9c0a3a" and "97533daa91774e663120dfc642790aa4feae3909" have entirely different histories.
76d4ef297e
...
97533daa91
|
|
@ -1,534 +1,129 @@
|
||||||
import { useLayoutEffect, useRef } from 'preact/hooks';
|
import { useEffect, 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";
|
||||||
|
|
||||||
export type ContentEditableElement = HTMLDivElement & { value: string };
|
type Props = Omit<JSX.HTMLAttributes<HTMLDivElement>, 'value' | 'onInput'> & {
|
||||||
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>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Snapshot = { text: string; anchor: number; focus: number };
|
const parseLines = (html: string, keepSpan: boolean = false): string => {
|
||||||
const normalize = (text: string) => text.replace(/\r\n?/g, '\n');
|
html = html
|
||||||
const nativeBlock = (node: Node) => node.nodeType === 1 && /^(DIV|P|LI|PRE|H[1-6])$/.test((node as Element).tagName);
|
.replace(/\n?<div><br\/?><\/div>/g, '\n\n')
|
||||||
const graphemes = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
|
.replace(/\n?<div[^>]*>/, '\n')
|
||||||
type Point = [Node, number];
|
.replace(/<\/?div[^>]*>/g, '')
|
||||||
type TextRun = { node: Node; start: number; end: number };
|
.replace(/<br[^>]*>/g, '');
|
||||||
let nextMatchId = 0;
|
|
||||||
|
|
||||||
function findTextMatches(root: HTMLElement, query: Props['highlightText']) {
|
if (!keepSpan) {
|
||||||
const terms = [...new Set((typeof query === 'string' ? [query] : query ?? []).map(normalize).filter(Boolean))];
|
html = html.replace(/<[^>]+>/g, '');
|
||||||
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) {
|
|
||||||
let from = text.indexOf(term);
|
return html;
|
||||||
while (from !== -1) {
|
};
|
||||||
matches.push({ from, to: from + term.length });
|
|
||||||
from = text.indexOf(term, from + 1);
|
function getCaretOffset(el: HTMLElement): number {
|
||||||
}
|
const sel = window.getSelection();
|
||||||
}
|
if (!sel || sel.rangeCount === 0) return 0;
|
||||||
matches.sort((a, b) => a.from - b.from || a.to - b.to);
|
|
||||||
const merged: typeof matches = [];
|
const range = sel.getRangeAt(0).cloneRange();
|
||||||
for (const match of matches) {
|
range.selectNodeContents(el);
|
||||||
const previous = merged.at(-1);
|
range.setEnd(sel.getRangeAt(0).endContainer, sel.getRangeAt(0).endOffset);
|
||||||
if (previous && match.from < previous.to) previous.to = Math.max(previous.to, match.to);
|
const contents = range.cloneContents();
|
||||||
else merged.push({ ...match });
|
const div = document.createElement('div');
|
||||||
}
|
div.appendChild(contents);
|
||||||
return { nodes, matches: merged };
|
const text = parseLines(div.innerHTML);
|
||||||
|
return text.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Paint-only ranges keep live text nodes and selection untouched. Older
|
function setCaretOffset(el: HTMLElement, offset: number) {
|
||||||
* browsers use internal spans, which never become part of the source value. */
|
const sel = window.getSelection();
|
||||||
function createMatchPainter(root: HTMLElement) {
|
if (!sel) return;
|
||||||
const doc = root.ownerDocument;
|
const range = document.createRange();
|
||||||
const win = doc.defaultView as (Window & typeof globalThis) | null;
|
let remaining = offset;
|
||||||
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) {
|
function traverse(node: Node): 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) {
|
if (node.nodeType === Node.TEXT_NODE) {
|
||||||
const start = text.length;
|
const text = node.textContent || '';
|
||||||
text += node.nodeValue ?? '';
|
const len = text.length;
|
||||||
runs.push({ node, start, end: text.length });
|
if (remaining <= len) {
|
||||||
return;
|
range.setStart(node, remaining);
|
||||||
}
|
range.collapse(true);
|
||||||
if (!(node instanceof Element)) return;
|
return true;
|
||||||
if (node.tagName === 'BR') {
|
}
|
||||||
if (!node.hasAttribute('data-ce-end') && !(nativeBlock(node.parentNode!) && node.parentNode!.childNodes.length === 1)) text += '\n';
|
remaining -= len;
|
||||||
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, '&').replace(/</g, '<'));
|
|
||||||
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 {
|
} else {
|
||||||
const live = current as Element;
|
for (const child of Array.from(node.childNodes)) {
|
||||||
const wanted = next as Element;
|
if (traverse(child)) return true;
|
||||||
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;
|
return false;
|
||||||
}
|
}
|
||||||
while (current) {
|
|
||||||
const next = current.nextSibling;
|
if (!traverse(el)) {
|
||||||
parent.removeChild(current);
|
range.selectNodeContents(el);
|
||||||
changed = true;
|
range.collapse(false);
|
||||||
current = next;
|
|
||||||
}
|
}
|
||||||
return changed;
|
|
||||||
|
sel.removeAllRanges();
|
||||||
|
sel.addRange(range);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resizeToContent(el: HTMLElement) {
|
||||||
|
el.style.height = 'auto';
|
||||||
|
el.style.height = el.scrollHeight + 'px';
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ContentEditable = ({
|
export const ContentEditable = ({
|
||||||
value, placeholder, autoLines = false, highlight: enableHighlight = false,
|
value,
|
||||||
highlightText,
|
placeholder,
|
||||||
disabled = false, onInput, class: externalClass, className,
|
autoLines,
|
||||||
onKeyDown, onBeforeInput, onPaste, onCopy, onCut,
|
highlight: enableHighlight,
|
||||||
onCompositionStart, onCompositionEnd, onCompositionUpdate, onBlur,
|
disabled,
|
||||||
onCompositionStartCapture, onCompositionEndCapture, onCompositionUpdateCapture,
|
onInput,
|
||||||
children: _children, dangerouslySetInnerHTML: _html, contentEditable: _editable,
|
class: externalClass,
|
||||||
...props
|
...props
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const root = useRef<ContentEditableElement>(null);
|
const ref = useRef<HTMLDivElement>(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 };
|
|
||||||
|
|
||||||
const sync = () => {
|
useEffect(() => {
|
||||||
const el = root.current;
|
const el = ref.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 decorate = () => {
|
const newValue = enableHighlight ? highlight(value) : value;
|
||||||
clearTimeout(timer.current);
|
const oldValue = parseLines(el.innerHTML, true);
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
const schedule = () => {
|
if (oldValue === newValue) return;
|
||||||
clearTimeout(timer.current);
|
|
||||||
if (!composing.current) timer.current = setTimeout(decorate, 120);
|
|
||||||
};
|
|
||||||
|
|
||||||
const remember = (previous: Snapshot, next: Snapshot, kind: string) => {
|
const offset = document.activeElement === el ? getCaretOffset(el) : null;
|
||||||
if (previous.text === next.text) return;
|
el.innerHTML = newValue;
|
||||||
const now = Date.now();
|
(el as any).value = value;
|
||||||
const group = grouping.current;
|
if (offset !== null) setCaretOffset(el, offset);
|
||||||
const continuous = group && group.kind === kind && (kind === 'composition' || (now - group.time < 750 &&
|
if (autoLines) resizeToContent(el);
|
||||||
previous.anchor === previous.focus && previous.focus === group.focus &&
|
}, [value, enableHighlight]);
|
||||||
(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 notify = (inputType: string, data: string | null = null) => {
|
const handleInput: JSX.EventHandler<JSX.TargetedInputEvent<HTMLDivElement>> = (e) => {
|
||||||
notifying.current = true;
|
if (!ref.current) return;
|
||||||
try {
|
if (autoLines) resizeToContent(ref.current);
|
||||||
root.current!.dispatchEvent(new InputEvent('input', { bubbles: true, composed: true, inputType, data }));
|
const text = parseLines(ref.current.innerHTML);
|
||||||
} finally { notifying.current = false; }
|
// add value prop to work with useInputState/useInputCallback
|
||||||
};
|
(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}
|
||||||
ref={root}
|
contentEditable={!disabled}
|
||||||
class={clsx(styles.root, autoLines && styles.autoLines, externalClass, className, disabled && styles.disabled)}
|
data-placeholder={value?.replaceAll('\n', '').length ? undefined : placeholder}
|
||||||
contentEditable={disabled ? false : 'plaintext-only'}
|
class={clsx(styles.root, autoLines && styles.autoLines, externalClass, disabled && styles.disabled)}
|
||||||
role={props.role ?? 'textbox'}
|
onInput={disabled ? undefined : handleInput}
|
||||||
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); }}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -52,14 +52,8 @@ export const importStoredReducer = <T>(key: string): boolean => {
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface RemoteReducerOptions {
|
export const useRemoteReducer = <T, A>(key: string, reducer: Reducer<T, A>, initialValue: T) => {
|
||||||
/** 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 {
|
||||||
|
|
@ -79,9 +73,8 @@ 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, paused]);
|
}, [storedKey, state]);
|
||||||
|
|
||||||
return [state, dispatch] as [T, Dispatch<A>];
|
return [state, dispatch] as [T, Dispatch<A>];
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,6 @@ 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';
|
||||||
|
|
@ -17,193 +15,55 @@ 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]);
|
|
||||||
}
|
|
||||||
|
|
||||||
override render() {
|
|
||||||
return <Render {...this.props} />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RoleHeaderProps {
|
interface RoleHeaderProps {
|
||||||
displayName: string;
|
message: ChatMessage;
|
||||||
roleLabel: string | null;
|
chatMessages: ChatMessage[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const RoleHeader = ({ displayName, roleLabel }: RoleHeaderProps) => {
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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}>
|
||||||
{roleLabel}
|
{message.role}
|
||||||
</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 {
|
const { currentWorld, currentStory, dispatch, connection, model, enableThinking, chatOpen, continuePrompt } = appState;
|
||||||
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);
|
||||||
|
|
@ -222,25 +82,6 @@ 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({
|
||||||
|
|
@ -413,6 +254,7 @@ 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) {
|
||||||
|
|
@ -463,8 +305,6 @@ 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 {
|
||||||
|
|
@ -480,10 +320,9 @@ 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, setSavingPaused]);
|
}, [currentStory, currentWorld, connection, model, isLoading, sendMessage]);
|
||||||
|
|
||||||
const handleSendMessage = useCallback(async () => {
|
const handleSendMessage = useCallback(async () => {
|
||||||
if (!currentStory || !connection || !model || isLoading) return;
|
if (!currentStory || !connection || !model || isLoading) return;
|
||||||
|
|
@ -499,7 +338,6 @@ 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({
|
||||||
|
|
@ -510,10 +348,9 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => {
|
||||||
}]
|
}]
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setSavingPaused(false);
|
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, [currentStory, input, connection, model, isLoading, sendMessage, handleRegenerate, setSavingPaused]);
|
}, [currentStory, input, connection, model, isLoading, sendMessage, handleRegenerate]);
|
||||||
|
|
||||||
const handleContinue = useCallback(async () => {
|
const handleContinue = useCallback(async () => {
|
||||||
if (!currentStory || !connection || !model || isLoading) return;
|
if (!currentStory || !connection || !model || isLoading) return;
|
||||||
|
|
@ -522,7 +359,6 @@ 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) {
|
||||||
|
|
@ -537,10 +373,9 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setSavingPaused(false);
|
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, [currentStory, currentWorld, input, connection, model, isLoading, sendMessage, setSavingPaused]);
|
}, [currentStory, currentWorld, input, connection, model, isLoading, sendMessage]);
|
||||||
|
|
||||||
const handleStopGeneration = useCallback(() => {
|
const handleStopGeneration = useCallback(() => {
|
||||||
abortControllerRef.current?.abort();
|
abortControllerRef.current?.abort();
|
||||||
|
|
@ -555,7 +390,6 @@ 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({
|
||||||
|
|
@ -564,10 +398,9 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => {
|
||||||
storyId: currentStory.id,
|
storyId: currentStory.id,
|
||||||
messageId,
|
messageId,
|
||||||
});
|
});
|
||||||
}, [dispatch]);
|
}, [currentStory, currentWorld, 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',
|
||||||
|
|
@ -575,28 +408,27 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => {
|
||||||
id: currentStory.id,
|
id: currentStory.id,
|
||||||
upToMessageId: messageId,
|
upToMessageId: messageId,
|
||||||
});
|
});
|
||||||
}, [dispatch]);
|
}, [currentStory, currentWorld, 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((messageId: string, content: string) => {
|
const handleSaveEdit = useCallback(() => {
|
||||||
const { currentWorld, currentStory } = appStateRef.current;
|
if (!currentStory || !currentWorld || !editingMessageId) return;
|
||||||
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,
|
messageId: editingMessageId,
|
||||||
content: content.trim(),
|
content: editingContent.trim(),
|
||||||
});
|
});
|
||||||
|
|
||||||
setEditingMessageId(null);
|
setEditingMessageId(null);
|
||||||
setEditingContent('');
|
setEditingContent('');
|
||||||
}, [dispatch, setEditingContent]);
|
}, [currentStory, currentWorld, editingMessageId, editingContent, dispatch, setEditingContent]);
|
||||||
|
|
||||||
const handleCancelEdit = useCallback(() => {
|
const handleCancelEdit = useCallback(() => {
|
||||||
setEditingMessageId(null);
|
setEditingMessageId(null);
|
||||||
|
|
@ -625,44 +457,109 @@ 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 toolName = message.role === 'tool' && message.tool_call_id
|
const canEdit = message.role === 'user' || message.role === 'assistant';
|
||||||
? 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 (
|
||||||
<MessageRow
|
<div key={message.id} class={styles.message} data-role={message.role}>
|
||||||
key={message.id}
|
<div class={styles.messageHeader}>
|
||||||
message={message}
|
<RoleHeader message={message} chatMessages={currentStory.chatMessages} />
|
||||||
displayName={displayName}
|
|
||||||
roleLabel={roleLabel}
|
{!isLoading && canEdit && (
|
||||||
isEditing={isEditing}
|
<div class={styles.messageActions}>
|
||||||
isLoading={isLoading}
|
{isEditing ? (
|
||||||
chatOnly={currentWorld?.chatOnly ?? false}
|
<>
|
||||||
substitution={substitution}
|
<button
|
||||||
editingContent={isEditing ? editingContent : ''}
|
class={clsx(styles.iconButton, styles.saveButton)}
|
||||||
onEditInput={setEditingContent}
|
onClick={handleSaveEdit}
|
||||||
onStartEdit={handleStartEdit}
|
disabled={!editingContent.trim()}
|
||||||
onCancelEdit={handleCancelEdit}
|
title="Save (Ctrl+Enter)"
|
||||||
onSaveEdit={handleSaveEdit}
|
>
|
||||||
onDelete={handleDeleteMessage}
|
<Check size={12} />
|
||||||
onFork={handleForkChat}
|
</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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{error && (
|
{error && (
|
||||||
|
|
|
||||||
|
|
@ -67,11 +67,10 @@ 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, currentTab]);
|
}, [appState]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentStory?.lastEditedText) {
|
if (currentStory?.lastEditedText) {
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,20 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
@ -25,8 +39,7 @@ export const StoryEditor = ({ visible }: { visible: boolean }) => {
|
||||||
<ContentEditable
|
<ContentEditable
|
||||||
highlight
|
highlight
|
||||||
class={styles.editable}
|
class={styles.editable}
|
||||||
value={currentStory.text}
|
value={value}
|
||||||
highlightText={currentStory.lastEditedText}
|
|
||||||
onInput={handleInput}
|
onInput={handleInput}
|
||||||
placeholder="Start writing your story..."
|
placeholder="Start writing your story..."
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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, useState } from "preact/hooks";
|
import { useContext, useMemo } from "preact/hooks";
|
||||||
import Chapters from "../utils/chapters";
|
import Chapters from "../utils/chapters";
|
||||||
import LLM from "../utils/llm";
|
import LLM from "../utils/llm";
|
||||||
|
|
||||||
|
|
@ -686,8 +686,6 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -700,8 +698,7 @@ 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 [savingPaused, setSavingPaused] = useState(false);
|
const [state, dispatch] = useRemoteReducer(STATE_KEY, reducer, DEFAULT_STATE);
|
||||||
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;
|
||||||
|
|
@ -751,7 +748,6 @@ export const StateContextProvider = ({ children }: { children?: any }) => {
|
||||||
? chatSystemInstruction
|
? chatSystemInstruction
|
||||||
: systemInstruction)
|
: systemInstruction)
|
||||||
|| systemInstruction,
|
|| systemInstruction,
|
||||||
setSavingPaused,
|
|
||||||
dispatch,
|
dispatch,
|
||||||
};
|
};
|
||||||
}, [state]);
|
}, [state]);
|
||||||
|
|
|
||||||
|
|
@ -284,43 +284,31 @@ namespace Prompt {
|
||||||
return lines.join('\n');
|
return lines.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
const objectUrls = new Map<string, string>();
|
||||||
* Inline images are meaningless to the model and their data/blob URLs are
|
function stripDataUrlImages(text: string): string {
|
||||||
* unstable across sessions (which also breaks provider prompt caching).
|
return text.replace(/!\[([^\]]*)\]\((data:([^;]*);base64,[^)]+)\)/g, (_, alt, dataUrl, type) => {
|
||||||
*/
|
let blobUrl = objectUrls.get(dataUrl);
|
||||||
function stripImages(text: string): string {
|
if (!blobUrl) {
|
||||||
return text.replace(/!\[([^\]]*)\]\((?:data|blob):[^)]*\)/g, (_match, alt: string) =>
|
const b64 = dataUrl.replace(/^data:[^;]*;base64,/, '');
|
||||||
alt ? `[image: ${alt}]` : '[image]'
|
const imageData = atob(b64);
|
||||||
);
|
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. */
|
}
|
||||||
export interface SubstitutionVars {
|
return ``;
|
||||||
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 {
|
||||||
return substitute(text, getSubstitutionVars(state));
|
const charName = state.currentWorld?.title || 'Assistant';
|
||||||
|
const userName = state.userName || 'User';
|
||||||
|
return text
|
||||||
|
.replaceAll('{{system}}', state.chatSystemInstruction)
|
||||||
|
.replaceAll('{{char}}', charName)
|
||||||
|
.replaceAll('{{user}}', userName);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatSystemPrompt(state: AppState): string {
|
export function formatSystemPrompt(state: AppState, storyTokenBudget: number = 0): string {
|
||||||
const { currentStory, currentWorld } = state;
|
const { currentStory, currentWorld } = state;
|
||||||
if (!currentStory) {
|
if (!currentStory) {
|
||||||
return state.effectiveSystemInstruction;
|
return state.effectiveSystemInstruction;
|
||||||
|
|
@ -362,10 +350,11 @@ namespace Prompt {
|
||||||
parts.push(`## Scratchpad`, currentStory.scratchpad);
|
parts.push(`## Scratchpad`, currentStory.scratchpad);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The story is the source of truth and is sent in full, exactly as it
|
if (currentStory.text && storyTokenBudget > 0) {
|
||||||
// currently is in the editor (including manual user edits).
|
const storyText = formatStoryChunks(currentStory.text, currentStory.chapters ?? [], storyTokenBudget);
|
||||||
if (currentStory.text) {
|
if (storyText) {
|
||||||
parts.push(`## Story Text:`, currentStory.text);
|
parts.push(`## Story Text:`, storyText);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -379,62 +368,6 @@ 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 },
|
||||||
|
|
@ -471,7 +404,7 @@ namespace Prompt {
|
||||||
const applyVars = (msgs: ChatMessage[]) =>
|
const applyVars = (msgs: ChatMessage[]) =>
|
||||||
msgs.map(m => ({
|
msgs.map(m => ({
|
||||||
...m,
|
...m,
|
||||||
content: stripImages(substituteVars(state, m.content)),
|
content: stripDataUrlImages(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,
|
||||||
|
|
@ -488,7 +421,7 @@ namespace Prompt {
|
||||||
formattedMessages.unshift({
|
formattedMessages.unshift({
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
role: 'system',
|
role: 'system',
|
||||||
content: formatSystemPrompt(state),
|
content: formatSystemPrompt(state, 0),
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -503,44 +436,25 @@ namespace Prompt {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Story mode: the current story text (with any manual user edits) lives
|
// Estimate token budget for story text
|
||||||
// in the system prompt, so the transcript only carries user/assistant
|
let storyTokenBudget = 0;
|
||||||
// text plus the most recent tool round. App-only fields (ids, reasoning)
|
if (model.context_length) {
|
||||||
// are never sent.
|
const nonStorySystem = formatSystemPrompt(state, 0);
|
||||||
const transcript: LLM.ChatMessage[] = toTranscript(messages).map((message) => {
|
const chatText = messages.map(m => m.content).join('\n');
|
||||||
const content = stripImages(substituteVars(state, message.content));
|
const maxOutput = model.top_provider.max_completion_tokens || 2048;
|
||||||
|
const otherTokens = approxTokens(nonStorySystem) + approxTokens(chatText) + maxOutput;
|
||||||
|
storyTokenBudget = model.context_length - otherTokens;
|
||||||
|
}
|
||||||
|
|
||||||
if (message.role === 'assistant' && message.tool_calls?.length) {
|
messages.unshift({
|
||||||
return {
|
id: crypto.randomUUID(),
|
||||||
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: stripImages(substituteVars(state, formatSystemPrompt(state))),
|
content: formatSystemPrompt(state, storyTokenBudget),
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
model: model.id,
|
model: model.id,
|
||||||
messages: transcript,
|
messages: applyVars(messages),
|
||||||
tools: Tools.getTools(),
|
tools: Tools.getTools(),
|
||||||
banned_tokens: state.bannedTokens,
|
banned_tokens: state.bannedTokens,
|
||||||
reasoning: { effort: enableThinking ? 'high' : 'none' },
|
reasoning: { effort: enableThinking ? 'high' : 'none' },
|
||||||
|
|
@ -551,13 +465,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);
|
||||||
|
|
|
||||||
|
|
@ -377,12 +377,14 @@ export namespace Tools {
|
||||||
cropped = true;
|
cropped = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
dispatchEdit(currentText.trim() + '\n\n' + args.new_text.trim());
|
dispatchEdit(currentText.trim() + '\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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue