Prompt optimization
This commit is contained in:
parent
05eb1a8fc4
commit
76d4ef297e
|
|
@ -10,6 +10,8 @@ export type Props = Omit<JSX.HTMLAttributes<HTMLDivElement>, 'value' | 'onInput'
|
||||||
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>>;
|
||||||
};
|
};
|
||||||
|
|
@ -18,8 +20,101 @@ type Snapshot = { text: string; anchor: number; focus: number };
|
||||||
const normalize = (text: string) => text.replace(/\r\n?/g, '\n');
|
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 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' });
|
const graphemes = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
|
||||||
|
type Point = [Node, number];
|
||||||
|
type TextRun = { node: Node; start: number; end: number };
|
||||||
|
let nextMatchId = 0;
|
||||||
|
|
||||||
function deletionRange(text: string, position: number, backward: boolean) {
|
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 });
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
return { nodes, matches: merged };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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;
|
const index = backward ? position - 1 : position;
|
||||||
if (index < 0 || index >= text.length) return;
|
if (index < 0 || index >= text.length) return;
|
||||||
const segment = graphemes.segment(text).containing(index)!;
|
const segment = graphemes.segment(text).containing(index)!;
|
||||||
|
|
@ -27,15 +122,18 @@ function deletionRange(text: string, position: number, backward: boolean) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Read the DOM, not its HTML serialization. The index also maps both ends of
|
/** Read the DOM, not its HTML serialization. The index also maps both ends of
|
||||||
* a selection, including native BRs and paragraphs created by mobile browsers. */
|
* a selection, including native line breaks. */
|
||||||
function readDOM(root: HTMLElement): Snapshot {
|
function indexDOM(root: HTMLElement) {
|
||||||
let text = '';
|
let text = '';
|
||||||
const starts = new Map<Node, number>();
|
const starts = new Map<Node, number>();
|
||||||
const boundaries = new Map<Node, number[]>();
|
const boundaries = new Map<Node, number[]>();
|
||||||
|
const runs: TextRun[] = [];
|
||||||
const visit = (node: Node) => {
|
const visit = (node: Node) => {
|
||||||
starts.set(node, text.length);
|
starts.set(node, text.length);
|
||||||
if (node.nodeType === Node.TEXT_NODE) {
|
if (node.nodeType === Node.TEXT_NODE) {
|
||||||
|
const start = text.length;
|
||||||
text += node.nodeValue ?? '';
|
text += node.nodeValue ?? '';
|
||||||
|
runs.push({ node, start, end: text.length });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!(node instanceof Element)) return;
|
if (!(node instanceof Element)) return;
|
||||||
|
|
@ -60,38 +158,50 @@ function readDOM(root: HTMLElement): Snapshot {
|
||||||
const points = boundaries.get(node);
|
const points = boundaries.get(node);
|
||||||
return points?.[Math.min(index, points.length - 1)] ?? starts.get(node) ?? 0;
|
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();
|
const selection = root.ownerDocument.getSelection();
|
||||||
return {
|
return {
|
||||||
text,
|
text: index.text,
|
||||||
anchor: offset(selection?.anchorNode ?? null, selection?.anchorOffset ?? 0),
|
anchor: index.offset(selection?.anchorNode ?? null, selection?.anchorOffset ?? 0),
|
||||||
focus: offset(selection?.focusNode ?? null, selection?.focusOffset ?? 0),
|
focus: index.offset(selection?.focusNode ?? null, selection?.focusOffset ?? 0),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function restoreSelection(root: HTMLElement, snapshot: Snapshot) {
|
function restoreSelection(root: HTMLElement, snapshot: Snapshot) {
|
||||||
const point = (offset: number): [Node, number] => {
|
const { point } = indexDOM(root);
|
||||||
const walker = root.ownerDocument.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
||||||
let node: Node | null;
|
|
||||||
let last: Node | null = null;
|
|
||||||
let remaining = Math.max(0, Math.min(offset, snapshot.text.length));
|
|
||||||
while ((node = walker.nextNode())) {
|
|
||||||
const length = node.nodeValue?.length ?? 0;
|
|
||||||
// Prefer the next text node at a shared boundary. The end of a
|
|
||||||
// heading's block span can visually precede its trailing newline,
|
|
||||||
// whereas the following node starts on the intended source line.
|
|
||||||
if (remaining < length) return [node, remaining];
|
|
||||||
remaining -= length;
|
|
||||||
last = node;
|
|
||||||
}
|
|
||||||
const hasEnd = root.lastChild instanceof Element && root.lastChild.hasAttribute('data-ce-end');
|
|
||||||
if (last && !hasEnd) return [last, last.nodeValue?.length ?? 0];
|
|
||||||
return [root, Math.max(0, root.childNodes.length - (hasEnd ? 1 : 0))];
|
|
||||||
};
|
|
||||||
const [anchor, anchorOffset] = point(snapshot.anchor);
|
const [anchor, anchorOffset] = point(snapshot.anchor);
|
||||||
const [focus, focusOffset] = point(snapshot.focus);
|
const [focus, focusOffset] = point(snapshot.focus);
|
||||||
root.ownerDocument.getSelection()?.setBaseAndExtent(anchor, anchorOffset, focus, focusOffset);
|
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
|
/** Reuse live nodes whenever their shape matches. Ordinary word edits leave
|
||||||
* their original text nodes intact, including the browser's correction anchors. */
|
* their original text nodes intact, including the browser's correction anchors. */
|
||||||
function reconcile(parent: Node, desired: Node): boolean {
|
function reconcile(parent: Node, desired: Node): boolean {
|
||||||
|
|
@ -133,6 +243,7 @@ function reconcile(parent: Node, desired: Node): boolean {
|
||||||
|
|
||||||
export const ContentEditable = ({
|
export const ContentEditable = ({
|
||||||
value, placeholder, autoLines = false, highlight: enableHighlight = false,
|
value, placeholder, autoLines = false, highlight: enableHighlight = false,
|
||||||
|
highlightText,
|
||||||
disabled = false, onInput, class: externalClass, className,
|
disabled = false, onInput, class: externalClass, className,
|
||||||
onKeyDown, onBeforeInput, onPaste, onCopy, onCut,
|
onKeyDown, onBeforeInput, onPaste, onCopy, onCut,
|
||||||
onCompositionStart, onCompositionEnd, onCompositionUpdate, onBlur,
|
onCompositionStart, onCompositionEnd, onCompositionUpdate, onBlur,
|
||||||
|
|
@ -153,8 +264,9 @@ export const ContentEditable = ({
|
||||||
const settleTimer = useRef<ReturnType<typeof setTimeout>>();
|
const settleTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||||
const notifying = useRef(false);
|
const notifying = useRef(false);
|
||||||
const originalHeight = useRef<string | null>(null);
|
const originalHeight = useRef<string | null>(null);
|
||||||
const options = useRef({ autoLines, enableHighlight, placeholder, disabled });
|
const matchPainter = useRef<ReturnType<typeof createMatchPainter>>();
|
||||||
options.current = { autoLines, enableHighlight, placeholder, disabled };
|
const options = useRef({ autoLines, enableHighlight, highlightText, placeholder, disabled });
|
||||||
|
options.current = { autoLines, enableHighlight, highlightText, placeholder, disabled };
|
||||||
|
|
||||||
const sync = () => {
|
const sync = () => {
|
||||||
const el = root.current;
|
const el = root.current;
|
||||||
|
|
@ -174,6 +286,7 @@ export const ContentEditable = ({
|
||||||
el.style.height = originalHeight.current;
|
el.style.height = originalHeight.current;
|
||||||
originalHeight.current = null;
|
originalHeight.current = null;
|
||||||
}
|
}
|
||||||
|
matchPainter.current?.paint(options.current.highlightText);
|
||||||
};
|
};
|
||||||
|
|
||||||
const decorate = () => {
|
const decorate = () => {
|
||||||
|
|
@ -184,11 +297,8 @@ export const ContentEditable = ({
|
||||||
const selection = focused ? readDOM(el) : model.current;
|
const selection = focused ? readDOM(el) : model.current;
|
||||||
const { scrollTop, scrollLeft } = el;
|
const { scrollTop, scrollLeft } = el;
|
||||||
const desired = el.ownerDocument.createElement('div');
|
const desired = el.ownerDocument.createElement('div');
|
||||||
if (options.current.enableHighlight) {
|
renderSource(desired, model.current.text, options.current.enableHighlight);
|
||||||
// Keep the shared highlighter untouched. Escape only at this call
|
matchPainter.current?.decorate(desired, options.current.highlightText);
|
||||||
// site; '>' and quotes remain available to its existing tokenizer.
|
|
||||||
desired.innerHTML = highlight(model.current.text.replace(/&/g, '&').replace(/</g, '<'));
|
|
||||||
} else desired.textContent = model.current.text;
|
|
||||||
if (!model.current.text || model.current.text.endsWith('\n')) {
|
if (!model.current.text || model.current.text.endsWith('\n')) {
|
||||||
const end = el.ownerDocument.createElement('br');
|
const end = el.ownerDocument.createElement('br');
|
||||||
end.setAttribute('data-ce-end', '');
|
end.setAttribute('data-ce-end', '');
|
||||||
|
|
@ -268,6 +378,7 @@ export const ContentEditable = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
|
matchPainter.current = createMatchPainter(root.current!);
|
||||||
decorate();
|
decorate();
|
||||||
let width = root.current!.clientWidth;
|
let width = root.current!.clientWidth;
|
||||||
const observer = new ResizeObserver(() => {
|
const observer = new ResizeObserver(() => {
|
||||||
|
|
@ -282,6 +393,8 @@ export const ContentEditable = ({
|
||||||
fonts?.removeEventListener('loadingdone', sync);
|
fonts?.removeEventListener('loadingdone', sync);
|
||||||
clearTimeout(timer.current);
|
clearTimeout(timer.current);
|
||||||
clearTimeout(settleTimer.current);
|
clearTimeout(settleTimer.current);
|
||||||
|
matchPainter.current?.destroy();
|
||||||
|
matchPainter.current = undefined;
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
@ -295,6 +408,10 @@ export const ContentEditable = ({
|
||||||
}, [value]);
|
}, [value]);
|
||||||
|
|
||||||
useLayoutEffect(() => { decorate(); sync(); }, [enableHighlight, autoLines, placeholder]);
|
useLayoutEffect(() => { decorate(); sync(); }, [enableHighlight, autoLines, placeholder]);
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (matchPainter.current?.native) matchPainter.current.paint(highlightText);
|
||||||
|
else decorate();
|
||||||
|
}, [highlightText]);
|
||||||
|
|
||||||
const handleInput: NonNullable<Props['onInput']> = event => {
|
const handleInput: NonNullable<Props['onInput']> = event => {
|
||||||
if (disabled) return;
|
if (disabled) return;
|
||||||
|
|
@ -338,7 +455,7 @@ export const ContentEditable = ({
|
||||||
// as well as the intended glyph. Delete one source grapheme
|
// as well as the intended glyph. Delete one source grapheme
|
||||||
// explicitly, keeping emoji and combining sequences whole.
|
// explicitly, keeping emoji and combining sequences whole.
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const range = hasSelection ? undefined : deletionRange(before.current.text, before.current.focus, event.inputType === 'deleteContentBackward');
|
const range = hasSelection ? undefined : deletionRange(root.current!, before.current.focus, event.inputType === 'deleteContentBackward');
|
||||||
if (hasSelection || range) replaceSelection('', event.inputType, range);
|
if (hasSelection || range) replaceSelection('', event.inputType, range);
|
||||||
} else if (event.inputType === 'insertParagraph' || event.inputType === 'insertLineBreak') {
|
} else if (event.inputType === 'insertParagraph' || event.inputType === 'insertLineBreak') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
@ -369,14 +486,14 @@ export const ContentEditable = ({
|
||||||
onCopy?.(event);
|
onCopy?.(event);
|
||||||
if (event.defaultPrevented || !event.clipboardData) return;
|
if (event.defaultPrevented || !event.clipboardData) return;
|
||||||
const selection = readDOM(root.current!);
|
const selection = readDOM(root.current!);
|
||||||
event.clipboardData.setData('text/plain', selection.text.slice(Math.min(selection.anchor, selection.focus), Math.max(selection.anchor, selection.focus)));
|
event.clipboardData.setData('text/plain', selectedText(selection));
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
}}
|
}}
|
||||||
onCut={event => {
|
onCut={event => {
|
||||||
onCut?.(event);
|
onCut?.(event);
|
||||||
if (event.defaultPrevented || disabled || composing.current || !event.clipboardData) return;
|
if (event.defaultPrevented || disabled || composing.current || !event.clipboardData) return;
|
||||||
const selection = readDOM(root.current!);
|
const selection = readDOM(root.current!);
|
||||||
event.clipboardData.setData('text/plain', selection.text.slice(Math.min(selection.anchor, selection.focus), Math.max(selection.anchor, selection.focus)));
|
event.clipboardData.setData('text/plain', selectedText(selection));
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
replaceSelection('', 'deleteByCut');
|
replaceSelection('', 'deleteByCut');
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -17,20 +17,6 @@ export const StoryEditor = ({ visible }: { visible: boolean }) => {
|
||||||
});
|
});
|
||||||
}, [currentStory?.id, currentWorld?.id]);
|
}, [currentStory?.id, currentWorld?.id]);
|
||||||
|
|
||||||
const value = useMemo(() => {
|
|
||||||
if (!currentStory) return '';
|
|
||||||
|
|
||||||
const { text, lastEditedText } = currentStory;
|
|
||||||
if (!lastEditedText) return text;
|
|
||||||
|
|
||||||
const idx = text.lastIndexOf(lastEditedText);
|
|
||||||
if (idx === -1) return text;
|
|
||||||
|
|
||||||
const marked = text.slice(0, idx) + '<mark>' + lastEditedText + '</mark>' + text.slice(idx + lastEditedText.length);
|
|
||||||
return marked;
|
|
||||||
}, [currentStory?.text, currentStory?.lastEditedText]);
|
|
||||||
|
|
||||||
|
|
||||||
if (!currentWorld || !currentStory || !visible) {
|
if (!currentWorld || !currentStory || !visible) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -39,7 +25,8 @@ export const StoryEditor = ({ visible }: { visible: boolean }) => {
|
||||||
<ContentEditable
|
<ContentEditable
|
||||||
highlight
|
highlight
|
||||||
class={styles.editable}
|
class={styles.editable}
|
||||||
value={value}
|
value={currentStory.text}
|
||||||
|
highlightText={currentStory.lastEditedText}
|
||||||
onInput={handleInput}
|
onInput={handleInput}
|
||||||
placeholder="Start writing your story..."
|
placeholder="Start writing your story..."
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -284,19 +284,14 @@ namespace Prompt {
|
||||||
return lines.join('\n');
|
return lines.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
const objectUrls = new Map<string, string>();
|
/**
|
||||||
function stripDataUrlImages(text: string): string {
|
* Inline images are meaningless to the model and their data/blob URLs are
|
||||||
return text.replace(/!\[([^\]]*)\]\((data:([^;]*);base64,[^)]+)\)/g, (_, alt, dataUrl, type) => {
|
* unstable across sessions (which also breaks provider prompt caching).
|
||||||
let blobUrl = objectUrls.get(dataUrl);
|
*/
|
||||||
if (!blobUrl) {
|
function stripImages(text: string): string {
|
||||||
const b64 = dataUrl.replace(/^data:[^;]*;base64,/, '');
|
return text.replace(/!\[([^\]]*)\]\((?:data|blob):[^)]*\)/g, (_match, alt: string) =>
|
||||||
const imageData = atob(b64);
|
alt ? `[image: ${alt}]` : '[image]'
|
||||||
const blob = new Blob([imageData], { type });
|
);
|
||||||
blobUrl = URL.createObjectURL(blob);
|
|
||||||
objectUrls.set(dataUrl, blobUrl);
|
|
||||||
}
|
|
||||||
return ``;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Values used by {@link substituteVars}, extracted so they can be memoized independently of the full app state. */
|
/** Values used by {@link substituteVars}, extracted so they can be memoized independently of the full app state. */
|
||||||
|
|
@ -325,7 +320,7 @@ namespace Prompt {
|
||||||
return substitute(text, getSubstitutionVars(state));
|
return substitute(text, getSubstitutionVars(state));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatSystemPrompt(state: AppState, storyTokenBudget: number = 0): string {
|
export function formatSystemPrompt(state: AppState): string {
|
||||||
const { currentStory, currentWorld } = state;
|
const { currentStory, currentWorld } = state;
|
||||||
if (!currentStory) {
|
if (!currentStory) {
|
||||||
return state.effectiveSystemInstruction;
|
return state.effectiveSystemInstruction;
|
||||||
|
|
@ -367,11 +362,10 @@ namespace Prompt {
|
||||||
parts.push(`## Scratchpad`, currentStory.scratchpad);
|
parts.push(`## Scratchpad`, currentStory.scratchpad);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentStory.text && storyTokenBudget > 0) {
|
// The story is the source of truth and is sent in full, exactly as it
|
||||||
const storyText = formatStoryChunks(currentStory.text, currentStory.chapters ?? [], storyTokenBudget);
|
// currently is in the editor (including manual user edits).
|
||||||
if (storyText) {
|
if (currentStory.text) {
|
||||||
parts.push(`## Story Text:`, storyText);
|
parts.push(`## Story Text:`, currentStory.text);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -385,6 +379,62 @@ namespace Prompt {
|
||||||
keepThinking?: boolean;
|
keepThinking?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reply used when an assistant turn consisted only of tool calls and has no
|
||||||
|
* text left. Keeps every user message paired with a reply so older
|
||||||
|
* instructions don't read like one continuous current instruction.
|
||||||
|
*/
|
||||||
|
const EMPTY_ASSISTANT_REPLY = 'Done.';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Story-mode transcript: older tool traffic is dropped because the story and
|
||||||
|
* scratchpad are sent in full inside the system prompt, while all
|
||||||
|
* user/assistant text is kept so earlier behavioural instructions survive.
|
||||||
|
*
|
||||||
|
* The most recent tool request/response round is kept so the model can see
|
||||||
|
* the result of its last action (including errors) and does not retry it.
|
||||||
|
*/
|
||||||
|
function toTranscript(chatMessages: ChatMessage[]): LLM.ChatMessage[] {
|
||||||
|
const transcript: LLM.ChatMessage[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < chatMessages.length; i++) {
|
||||||
|
const message = chatMessages[i];
|
||||||
|
|
||||||
|
if (message.role === 'tool') {
|
||||||
|
transcript.push({
|
||||||
|
role: 'tool',
|
||||||
|
content: message.content,
|
||||||
|
tool_call_id: message.tool_call_id,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.role === 'assistant') {
|
||||||
|
if (message.tool_calls?.length) {
|
||||||
|
transcript.push({
|
||||||
|
role: 'assistant',
|
||||||
|
content: message.content ?? '',
|
||||||
|
tool_calls: message.tool_calls,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!message.content.trim()) continue;
|
||||||
|
transcript.push({ role: 'assistant', content: message.content });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
transcript.push({ role: message.role, content: message.content });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = transcript.length - 1; i > 0; i--) {
|
||||||
|
if (transcript[i].role === 'user' && transcript[i - 1].role === 'user') {
|
||||||
|
transcript.splice(i, 0, { role: 'assistant', content: EMPTY_ASSISTANT_REPLY });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return transcript;
|
||||||
|
}
|
||||||
|
|
||||||
export function compilePrompt(
|
export function compilePrompt(
|
||||||
state: AppState,
|
state: AppState,
|
||||||
config: CompileConfig = { keepThinking: true },
|
config: CompileConfig = { keepThinking: true },
|
||||||
|
|
@ -421,7 +471,7 @@ namespace Prompt {
|
||||||
const applyVars = (msgs: ChatMessage[]) =>
|
const applyVars = (msgs: ChatMessage[]) =>
|
||||||
msgs.map(m => ({
|
msgs.map(m => ({
|
||||||
...m,
|
...m,
|
||||||
content: stripDataUrlImages(substituteVars(state, m.content)),
|
content: stripImages(substituteVars(state, m.content)),
|
||||||
reasoning_content: config.keepThinking && ('reasoning_content' in m)
|
reasoning_content: config.keepThinking && ('reasoning_content' in m)
|
||||||
? m.reasoning_content
|
? m.reasoning_content
|
||||||
: undefined,
|
: undefined,
|
||||||
|
|
@ -438,7 +488,7 @@ namespace Prompt {
|
||||||
formattedMessages.unshift({
|
formattedMessages.unshift({
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
role: 'system',
|
role: 'system',
|
||||||
content: formatSystemPrompt(state, 0),
|
content: formatSystemPrompt(state),
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -453,25 +503,44 @@ namespace Prompt {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Estimate token budget for story text
|
// Story mode: the current story text (with any manual user edits) lives
|
||||||
let storyTokenBudget = 0;
|
// in the system prompt, so the transcript only carries user/assistant
|
||||||
if (model.context_length) {
|
// text plus the most recent tool round. App-only fields (ids, reasoning)
|
||||||
const nonStorySystem = formatSystemPrompt(state, 0);
|
// are never sent.
|
||||||
const chatText = messages.map(m => m.content).join('\n');
|
const transcript: LLM.ChatMessage[] = toTranscript(messages).map((message) => {
|
||||||
const maxOutput = model.top_provider.max_completion_tokens || 2048;
|
const content = stripImages(substituteVars(state, message.content));
|
||||||
const otherTokens = approxTokens(nonStorySystem) + approxTokens(chatText) + maxOutput;
|
|
||||||
storyTokenBudget = model.context_length - otherTokens;
|
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,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
messages.unshift({
|
if (message.role === 'tool') {
|
||||||
id: crypto.randomUUID(),
|
return { role: 'tool', content, tool_call_id: message.tool_call_id };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { role: message.role, content } as LLM.ChatMessage;
|
||||||
|
});
|
||||||
|
|
||||||
|
transcript.unshift({
|
||||||
role: 'system',
|
role: 'system',
|
||||||
content: formatSystemPrompt(state, storyTokenBudget),
|
content: stripImages(substituteVars(state, formatSystemPrompt(state))),
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
model: model.id,
|
model: model.id,
|
||||||
messages: applyVars(messages),
|
messages: transcript,
|
||||||
tools: Tools.getTools(),
|
tools: Tools.getTools(),
|
||||||
banned_tokens: state.bannedTokens,
|
banned_tokens: state.bannedTokens,
|
||||||
reasoning: { effort: enableThinking ? 'high' : 'none' },
|
reasoning: { effort: enableThinking ? 'high' : 'none' },
|
||||||
|
|
|
||||||
|
|
@ -383,8 +383,6 @@ export namespace Tools {
|
||||||
? `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