From 76d4ef297ef9dea82dd60b049f196fb1ab9c0a3a Mon Sep 17 00:00:00 2001 From: Pabloader Date: Tue, 3 Nov 2026 13:10:03 +0000 Subject: [PATCH] Prompt optimization --- src/common/components/ContentEditable.tsx | 185 ++++++++++++++---- .../storywriter/components/editors/story.tsx | 17 +- src/games/storywriter/utils/prompt.ts | 137 +++++++++---- src/games/storywriter/utils/tools.ts | 2 - 4 files changed, 256 insertions(+), 85 deletions(-) diff --git a/src/common/components/ContentEditable.tsx b/src/common/components/ContentEditable.tsx index 685e5d3..d3d21aa 100644 --- a/src/common/components/ContentEditable.tsx +++ b/src/common/components/ContentEditable.tsx @@ -10,6 +10,8 @@ export type Props = Omit, '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>; }; @@ -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(); const boundaries = new Map(); + 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, '&').replace(/>(); const notifying = useRef(false); const originalHeight = useRef(null); - const options = useRef({ autoLines, enableHighlight, placeholder, disabled }); - options.current = { autoLines, enableHighlight, placeholder, disabled }; + const matchPainter = useRef>(); + 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, '&').replace(/ { + 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 = 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'); }} diff --git a/src/games/storywriter/components/editors/story.tsx b/src/games/storywriter/components/editors/story.tsx index b9ed4a5..23a69dc 100644 --- a/src/games/storywriter/components/editors/story.tsx +++ b/src/games/storywriter/components/editors/story.tsx @@ -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) + '' + lastEditedText + '' + 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 }) => { diff --git a/src/games/storywriter/utils/prompt.ts b/src/games/storywriter/utils/prompt.ts index 74538a9..8b108ca 100644 --- a/src/games/storywriter/utils/prompt.ts +++ b/src/games/storywriter/utils/prompt.ts @@ -284,19 +284,14 @@ namespace Prompt { return lines.join('\n'); } - const objectUrls = new Map(); - 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)); - messages.unshift({ - id: crypto.randomUUID(), + if (message.role === 'assistant' && message.tool_calls?.length) { + return { + role: 'assistant', + content, + tool_calls: message.tool_calls.map(toolCall => ({ + ...toolCall, + function: { + ...toolCall.function, + arguments: typeof toolCall.function.arguments === 'string' + ? stripImages(toolCall.function.arguments) + : toolCall.function.arguments, + }, + })), + }; + } + + if (message.role === 'tool') { + return { role: 'tool', content, tool_call_id: message.tool_call_id }; + } + + return { role: message.role, content } as LLM.ChatMessage; + }); + + transcript.unshift({ role: 'system', - content: formatSystemPrompt(state, storyTokenBudget), + content: stripImages(substituteVars(state, formatSystemPrompt(state))), }); return { model: model.id, - messages: applyVars(messages), + messages: transcript, tools: Tools.getTools(), banned_tokens: state.bannedTokens, reasoning: { effort: enableThinking ? 'high' : 'none' }, diff --git a/src/games/storywriter/utils/tools.ts b/src/games/storywriter/utils/tools.ts index 0a62746..a19401e 100644 --- a/src/games/storywriter/utils/tools.ts +++ b/src/games/storywriter/utils/tools.ts @@ -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; }