Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 53 additions & 11 deletions bookstack_agent/ui/app/components/chat-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,37 @@ export default function ChatPage({ user }: { user: User | null }) {
const bottomRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLTextAreaElement>(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<string>('')
const flushRafRef = useRef<number | null>(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])
Expand Down Expand Up @@ -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

Expand All @@ -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) => ({
Expand All @@ -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,
Expand All @@ -266,6 +300,7 @@ export default function ChatPage({ user }: { user: User | null }) {
break

case 'error':
cancelPendingFlush()
patchLast((msg) => ({
...msg,
content: `⚠️ ${event.message as string}`,
Expand All @@ -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()
Expand Down
7 changes: 4 additions & 3 deletions src/aieng_bot/bookstack/prompts.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,13 @@
</tool_strategy>

<response_format>
- 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.
</response_format>
"""
5 changes: 0 additions & 5 deletions src/aieng_bot/bookstack/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down