1
0
Fork 0

Prompt optimization

This commit is contained in:
Pabloader 2026-11-03 13:10:03 +00:00
parent 05eb1a8fc4
commit 76d4ef297e
4 changed files with 256 additions and 85 deletions

View File

@ -10,6 +10,8 @@ export type Props = Omit<JSX.HTMLAttributes<HTMLDivElement>, 'value' | 'onInput'
placeholder?: string;
autoLines?: boolean;
highlight?: boolean;
/** Highlight every literal, case-sensitive match without changing value. */
highlightText?: string | readonly string[];
disabled?: boolean;
onInput?: JSX.EventHandler<JSX.TargetedInputEvent<HTMLDivElement>>;
};
@ -18,8 +20,101 @@ type Snapshot = { text: string; anchor: number; focus: number };
const normalize = (text: string) => text.replace(/\r\n?/g, '\n');
const nativeBlock = (node: Node) => node.nodeType === 1 && /^(DIV|P|LI|PRE|H[1-6])$/.test((node as Element).tagName);
const graphemes = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
type Point = [Node, number];
type TextRun = { node: Node; start: number; end: number };
let nextMatchId = 0;
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;
if (index < 0 || index >= text.length) return;
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
* a selection, including native BRs and paragraphs created by mobile browsers. */
function readDOM(root: HTMLElement): Snapshot {
* a selection, including native line breaks. */
function indexDOM(root: HTMLElement) {
let text = '';
const starts = new Map<Node, number>();
const boundaries = new Map<Node, number[]>();
const runs: TextRun[] = [];
const visit = (node: Node) => {
starts.set(node, text.length);
if (node.nodeType === Node.TEXT_NODE) {
const start = text.length;
text += node.nodeValue ?? '';
runs.push({ node, start, end: text.length });
return;
}
if (!(node instanceof Element)) return;
@ -60,38 +158,50 @@ function readDOM(root: HTMLElement): Snapshot {
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,
anchor: offset(selection?.anchorNode ?? null, selection?.anchorOffset ?? 0),
focus: offset(selection?.focusNode ?? null, selection?.focusOffset ?? 0),
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 = (offset: number): [Node, number] => {
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 { point } = indexDOM(root);
const [anchor, anchorOffset] = point(snapshot.anchor);
const [focus, focusOffset] = point(snapshot.focus);
root.ownerDocument.getSelection()?.setBaseAndExtent(anchor, anchorOffset, focus, focusOffset);
}
function renderSource(root: HTMLElement, text: string, enabled: boolean) {
if (enabled) root.innerHTML = highlight(text.replace(/&/g, '&amp;').replace(/</g, '&lt;'));
else root.textContent = text;
}
function selectedText(snapshot: Snapshot) {
const from = Math.min(snapshot.anchor, snapshot.focus), to = Math.max(snapshot.anchor, snapshot.focus);
return snapshot.text.slice(from, to);
}
/** Reuse live nodes whenever their shape matches. Ordinary word edits leave
* their original text nodes intact, including the browser's correction anchors. */
function reconcile(parent: Node, desired: Node): boolean {
@ -133,6 +243,7 @@ function reconcile(parent: Node, desired: Node): boolean {
export const ContentEditable = ({
value, placeholder, autoLines = false, highlight: enableHighlight = false,
highlightText,
disabled = false, onInput, class: externalClass, className,
onKeyDown, onBeforeInput, onPaste, onCopy, onCut,
onCompositionStart, onCompositionEnd, onCompositionUpdate, onBlur,
@ -153,8 +264,9 @@ export const ContentEditable = ({
const settleTimer = useRef<ReturnType<typeof setTimeout>>();
const notifying = useRef(false);
const originalHeight = useRef<string | null>(null);
const options = useRef({ autoLines, enableHighlight, placeholder, disabled });
options.current = { autoLines, enableHighlight, placeholder, disabled };
const matchPainter = useRef<ReturnType<typeof createMatchPainter>>();
const options = useRef({ autoLines, enableHighlight, highlightText, placeholder, disabled });
options.current = { autoLines, enableHighlight, highlightText, placeholder, disabled };
const sync = () => {
const el = root.current;
@ -174,6 +286,7 @@ export const ContentEditable = ({
el.style.height = originalHeight.current;
originalHeight.current = null;
}
matchPainter.current?.paint(options.current.highlightText);
};
const decorate = () => {
@ -184,11 +297,8 @@ export const ContentEditable = ({
const selection = focused ? readDOM(el) : model.current;
const { scrollTop, scrollLeft } = el;
const desired = el.ownerDocument.createElement('div');
if (options.current.enableHighlight) {
// Keep the shared highlighter untouched. Escape only at this call
// site; '>' and quotes remain available to its existing tokenizer.
desired.innerHTML = highlight(model.current.text.replace(/&/g, '&amp;').replace(/</g, '&lt;'));
} else desired.textContent = model.current.text;
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', '');
@ -268,6 +378,7 @@ export const ContentEditable = ({
};
useLayoutEffect(() => {
matchPainter.current = createMatchPainter(root.current!);
decorate();
let width = root.current!.clientWidth;
const observer = new ResizeObserver(() => {
@ -282,6 +393,8 @@ export const ContentEditable = ({
fonts?.removeEventListener('loadingdone', sync);
clearTimeout(timer.current);
clearTimeout(settleTimer.current);
matchPainter.current?.destroy();
matchPainter.current = undefined;
};
}, []);
@ -295,6 +408,10 @@ export const ContentEditable = ({
}, [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;
@ -338,7 +455,7 @@ export const ContentEditable = ({
// as well as the intended glyph. Delete one source grapheme
// explicitly, keeping emoji and combining sequences whole.
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);
} else if (event.inputType === 'insertParagraph' || event.inputType === 'insertLineBreak') {
event.preventDefault();
@ -369,14 +486,14 @@ export const ContentEditable = ({
onCopy?.(event);
if (event.defaultPrevented || !event.clipboardData) return;
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();
}}
onCut={event => {
onCut?.(event);
if (event.defaultPrevented || disabled || composing.current || !event.clipboardData) return;
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();
replaceSelection('', 'deleteByCut');
}}

View File

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

View File

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

View File

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