diff --git a/bookstack_agent/ui/app/components/chat-page.tsx b/bookstack_agent/ui/app/components/chat-page.tsx index 4c79a8b..ee8d54d 100644 --- a/bookstack_agent/ui/app/components/chat-page.tsx +++ b/bookstack_agent/ui/app/components/chat-page.tsx @@ -131,6 +131,37 @@ export default function ChatPage({ user }: { user: User | null }) { const bottomRef = useRef(null) const inputRef = useRef(null) + // RAF-based text buffering: text_chunk events are accumulated here and + // flushed to React state once per animation frame. If text_clear arrives + // before the RAF fires, the buffer is discarded silently — no flicker. + const pendingTextRef = useRef('') + const flushRafRef = useRef(null) + + function schedulePendingFlush() { + if (flushRafRef.current !== null) return + flushRafRef.current = requestAnimationFrame(() => { + flushRafRef.current = null + if (pendingTextRef.current === '') return + const text = pendingTextRef.current + pendingTextRef.current = '' + patchLast((msg) => ({ ...msg, content: (msg.content ?? '') + text })) + }) + } + + function cancelPendingFlush() { + if (flushRafRef.current !== null) { + cancelAnimationFrame(flushRafRef.current) + flushRafRef.current = null + } + pendingTextRef.current = '' + } + + useEffect(() => { + return () => { + if (flushRafRef.current !== null) cancelAnimationFrame(flushRafRef.current) + } + }, []) + useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [messages]) @@ -220,8 +251,17 @@ export default function ChatPage({ user }: { user: User | null }) { setSessionId(event.session_id as string) break + case 'text_chunk': + // Buffer in a ref; RAF flushes to state once per frame. + // If text_clear fires before the RAF, text is discarded with no flicker. + pendingTextRef.current += event.chunk as string + schedulePendingFlush() + break + case 'text_clear': - // Model emitted reasoning/thinking text before a tool call — discard it + // Reasoning text preceded a tool call — discard buffer and clear any + // text that already made it into state before the RAF could be cancelled. + cancelPendingFlush() patchLast((msg) => ({ ...msg, content: null })) break @@ -236,14 +276,6 @@ export default function ChatPage({ user }: { user: User | null }) { })) break - case 'text_chunk': - // Append streaming text character-by-character - patchLast((msg) => ({ - ...msg, - content: (msg.content ?? '') + (event.chunk as string), - })) - break - case 'tool_resolve': // Update the matching get_page step with the resolved title patchLast((msg) => ({ @@ -257,7 +289,9 @@ export default function ChatPage({ user }: { user: User | null }) { break case 'answer': - // Finalise: replace with authoritative complete text, stop cursor + // Finalise: commit the authoritative complete text, stop streaming cursor. + // Cancel any pending RAF first so a stale flush doesn't overwrite this. + cancelPendingFlush() patchLast((msg) => ({ ...msg, content: event.text as string, @@ -266,6 +300,7 @@ export default function ChatPage({ user }: { user: User | null }) { break case 'error': + cancelPendingFlush() patchLast((msg) => ({ ...msg, content: `⚠️ ${event.message as string}`, @@ -276,13 +311,20 @@ export default function ChatPage({ user }: { user: User | null }) { } } } catch (err) { + cancelPendingFlush() patchLast((msg) => ({ ...msg, content: `⚠️ Could not reach the agent: ${err}`, streaming: false, })) } finally { - // Ensure streaming flag is cleared even on unexpected stream end + // Flush any remaining buffered text, then clear the RAF. + if (pendingTextRef.current) { + const text = pendingTextRef.current + pendingTextRef.current = '' + patchLast((msg) => ({ ...msg, content: (msg.content ?? '') + text })) + } + cancelPendingFlush() patchLast((msg) => (msg.streaming ? { ...msg, streaming: false } : msg)) setLoading(false) inputRef.current?.focus() diff --git a/src/aieng_bot/bookstack/prompts.py b/src/aieng_bot/bookstack/prompts.py index 2e85345..d4cdba7 100644 --- a/src/aieng_bot/bookstack/prompts.py +++ b/src/aieng_bot/bookstack/prompts.py @@ -13,12 +13,13 @@ -- Start directly with the answer. No preamble ("Based on the docs…", "I found…", etc.). +- Begin your response with the answer immediately. Do NOT write any preamble, transition, or meta-commentary such as "Based on the docs…", "I found…", "Now I have all the information…", "Let me synthesize…", or anything similar. Just answer. - Match length to complexity: simple questions get a sentence or two; multi-part questions get structured sections. - Use `##` headings, bullets, and numbered lists only when they genuinely aid readability. Prefer prose for short answers. - Use code blocks for commands, paths, or code snippets. -- End every answer with a `## Sources` section listing each page you read as a markdown link: - `- [Page title](https://bookstack.vectorinstitute.ai/…)` +- End every answer with a `## Sources` section. List each page you fetched as a markdown link using its title and URL exactly as returned by the tool: + `- [Page title](page url)` + NEVER include page numbers, numeric IDs, or any internal identifiers. Use only the page title and its URL. - If the answer is not in the wiki, say so in one sentence. Do not speculate. """ diff --git a/src/aieng_bot/bookstack/tools.py b/src/aieng_bot/bookstack/tools.py index 237a6e7..665e686 100644 --- a/src/aieng_bot/bookstack/tools.py +++ b/src/aieng_bot/bookstack/tools.py @@ -94,16 +94,11 @@ def execute_tool(name: str, tool_input: dict[str, Any], client: BookStackClient) if name == "get_page": page_id = int(tool_input["page_id"]) raw = client.get_page(page_id) - # Return only the fields useful for answering — omits large HTML body return json.dumps( { - "id": raw.get("id"), "name": raw.get("name"), - "book_id": raw.get("book_id"), - "chapter_id": raw.get("chapter_id"), "markdown": raw.get("markdown", ""), "url": raw.get("url", ""), - "updated_at": raw.get("updated_at"), }, indent=2, )