mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-21 00:02:23 +00:00
feat: smooth bursty AI chat streaming with a typewriter reveal (#9991)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -58,6 +58,7 @@ import { BROWSER } from 'esm-env'
|
||||
import { workspaceStore, type DBSchemas } from '$lib/stores'
|
||||
import { askTools, prepareAskSystemMessage, prepareAskUserMessage } from './ask/core'
|
||||
import { readDocsPageTool, searchDocsTool } from './docs/core'
|
||||
import { TypewriterReveal } from './typewriterReveal'
|
||||
import { chatState, DEFAULT_SIZE, triggerablesByAi } from './sharedChatState.svelte'
|
||||
import {
|
||||
createAppBackendRunnableContextElement,
|
||||
@@ -102,6 +103,11 @@ import { getLocalSetting, storeLocalSetting } from '$lib/utils'
|
||||
import { AttachedFilesStore } from './files/attachedFiles.svelte'
|
||||
import { appendAttachedFilesRoster } from './files/fileTools'
|
||||
|
||||
// SSR and users who prefer reduced motion get no typewriter pacing.
|
||||
function prefersInstantReveal(): boolean {
|
||||
return !BROWSER || (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false)
|
||||
}
|
||||
|
||||
// Compaction of the stored history: once the projected request size
|
||||
// (contextTokens — the provider's report when current, a fresh chars/4
|
||||
// estimate otherwise — plus the new user message) reaches the trigger ratio of
|
||||
@@ -277,6 +283,19 @@ export class AIChatManager {
|
||||
currentReply = $state<string>('')
|
||||
currentReasoning = $state<string>('')
|
||||
currentReasoningActive = $state<boolean>(false)
|
||||
// Smooths the provider's bursty delivery into continuous typing by revealing
|
||||
// buffered text a slice per frame. The reply and the reasoning/thinking stream
|
||||
// each get their own reveal (independent buffers, both append to their own
|
||||
// $state). Reduced-motion (sampled once — the pref never changes mid-session)
|
||||
// and SSR fall back to instant.
|
||||
private replyReveal = new TypewriterReveal({
|
||||
onReveal: (chunk) => (this.currentReply += chunk),
|
||||
instant: prefersInstantReveal()
|
||||
})
|
||||
private reasoningReveal = new TypewriterReveal({
|
||||
onReveal: (chunk) => (this.currentReasoning += chunk),
|
||||
instant: prefersInstantReveal()
|
||||
})
|
||||
displayMessages = $state<DisplayMessage[]>([])
|
||||
messages = $state<ChatCompletionMessageParam[]>([])
|
||||
/** Provider-reported context size of the last committed turn (prompt +
|
||||
@@ -1746,6 +1765,8 @@ export class AIChatManager {
|
||||
this.modifiedItems ? [...this.modifiedItems] : undefined
|
||||
)
|
||||
|
||||
this.replyReveal.reset()
|
||||
this.reasoningReveal.reset()
|
||||
this.currentReply = ''
|
||||
this.currentReasoning = ''
|
||||
this.currentReasoningActive = false
|
||||
@@ -1806,10 +1827,16 @@ export class AIChatManager {
|
||||
messages: [...this.messages],
|
||||
abortController: this.abortController,
|
||||
callbacks: {
|
||||
onNewToken: (token) => (this.currentReply += token),
|
||||
onReasoningDelta: (token) => (this.currentReasoning += token),
|
||||
onNewToken: (token) => this.replyReveal.push(token),
|
||||
onReasoningDelta: (token) => this.reasoningReveal.push(token),
|
||||
onReasoningStart: () => (this.currentReasoningActive = true),
|
||||
onMessageEnd: () => {
|
||||
// Drain any un-revealed backlog into currentReply first, so the reads
|
||||
// below see the full text. This funnel covers clean completion, tool
|
||||
// boundaries, and abort/error — flush-before-read is the invariant that
|
||||
// keeps text from being lost or duplicated on any exit path.
|
||||
this.replyReveal.flush()
|
||||
this.reasoningReveal.flush()
|
||||
// Keep the streamed text for the abort/error paths. Non-empty only:
|
||||
// parsers flush (and reset) when a tool call starts after text, and
|
||||
// the catch's later empty call would wipe it — stale keeps are
|
||||
@@ -2012,6 +2039,11 @@ export class AIChatManager {
|
||||
sendUserToast(getSendRequestErrorMessage(err, webSearchUnavailable), true)
|
||||
} finally {
|
||||
this.loading = false
|
||||
// Turn teardown: cancel any in-flight reveal frame and drop leftover
|
||||
// backlog. onMessageEnd already flushed on every outcome, so this only
|
||||
// releases the loop; it never discards uncommitted text.
|
||||
this.replyReveal.reset()
|
||||
this.reasoningReveal.reset()
|
||||
}
|
||||
// Flush the queued message. Send it after a cleanly committed turn OR a
|
||||
// deliberate user cancel (Esc / Stop) — in both cases the user is ready
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { TypewriterReveal, type TypewriterRevealOptions } from './typewriterReveal'
|
||||
|
||||
// A fake clock + manual scheduler modelling requestAnimationFrame: each frame()
|
||||
// advances the clock by `dt` ms, then fires whatever callback is currently
|
||||
// scheduled (which may reschedule the next). This drives the pacing
|
||||
// deterministically without a browser.
|
||||
class FakeScheduler {
|
||||
t = 0
|
||||
scheduleCount = 0
|
||||
private queue = new Map<number, () => void>()
|
||||
private id = 0
|
||||
|
||||
now = () => this.t
|
||||
schedule = (cb: () => void) => {
|
||||
this.scheduleCount++
|
||||
const h = ++this.id
|
||||
this.queue.set(h, cb)
|
||||
return h
|
||||
}
|
||||
cancel = (h: unknown) => {
|
||||
this.queue.delete(h as number)
|
||||
}
|
||||
pending() {
|
||||
return this.queue.size
|
||||
}
|
||||
frame(dt: number) {
|
||||
this.t += dt
|
||||
const cbs = [...this.queue.values()]
|
||||
this.queue.clear()
|
||||
cbs.forEach((cb) => cb())
|
||||
}
|
||||
frames(count: number, dt = 16) {
|
||||
for (let i = 0; i < count; i++) this.frame(dt)
|
||||
}
|
||||
}
|
||||
|
||||
function makeReveal(sched: FakeScheduler, opts: Partial<TypewriterRevealOptions> = {}) {
|
||||
const chunks: string[] = []
|
||||
const reveal = new TypewriterReveal({
|
||||
onReveal: (c) => chunks.push(c),
|
||||
now: sched.now,
|
||||
schedule: sched.schedule,
|
||||
cancel: sched.cancel,
|
||||
...opts
|
||||
})
|
||||
return { reveal, chunks, revealed: () => chunks.join('') }
|
||||
}
|
||||
|
||||
// No lone surrogate at any string end (a split pair would leave one).
|
||||
function hasLoneSurrogate(s: string): boolean {
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const c = s.charCodeAt(i)
|
||||
if (c >= 0xd800 && c <= 0xdbff) {
|
||||
const next = s.charCodeAt(i + 1)
|
||||
if (!(next >= 0xdc00 && next <= 0xdfff)) return true
|
||||
i++
|
||||
} else if (c >= 0xdc00 && c <= 0xdfff) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
describe('TypewriterReveal', () => {
|
||||
it('reveals gradually — a burst is not fully painted on the first frame', () => {
|
||||
const sched = new FakeScheduler()
|
||||
const { reveal, revealed } = makeReveal(sched)
|
||||
const text = 'x'.repeat(300)
|
||||
reveal.push(text)
|
||||
sched.frame(16)
|
||||
expect(revealed().length).toBeGreaterThan(0)
|
||||
expect(revealed().length).toBeLessThan(text.length)
|
||||
})
|
||||
|
||||
it('preserves text exactly after flush (no loss, no duplication)', () => {
|
||||
const sched = new FakeScheduler()
|
||||
const { reveal, revealed } = makeReveal(sched)
|
||||
const parts = ['Here are ', 'some names: ', 'Charles, George, ', 'Alfred, Harold.']
|
||||
parts.forEach((p) => reveal.push(p))
|
||||
sched.frames(3) // reveal only part of it
|
||||
reveal.flush()
|
||||
expect(revealed()).toBe(parts.join(''))
|
||||
})
|
||||
|
||||
it('flushes repeatedly across tool boundaries without loss or duplication', () => {
|
||||
const sched = new FakeScheduler()
|
||||
const { reveal, revealed } = makeReveal(sched)
|
||||
// Each segment is a message that ends in a flush (as onMessageEnd does at a
|
||||
// tool-call boundary); the buffer is compacted between them.
|
||||
const segments = ['first reply', 'second reply', 'third reply']
|
||||
segments.forEach((seg) => {
|
||||
reveal.push(seg)
|
||||
sched.frames(2) // partially reveal
|
||||
reveal.flush()
|
||||
})
|
||||
expect(revealed()).toBe(segments.join(''))
|
||||
})
|
||||
|
||||
it('reset() drops un-revealed backlog', () => {
|
||||
const sched = new FakeScheduler()
|
||||
const { reveal, revealed } = makeReveal(sched)
|
||||
const text = 'y'.repeat(300)
|
||||
reveal.push(text)
|
||||
sched.frame(16) // reveal a prefix
|
||||
const afterOneFrame = revealed()
|
||||
expect(afterOneFrame.length).toBeLessThan(text.length)
|
||||
expect(text.startsWith(afterOneFrame)).toBe(true)
|
||||
reveal.reset()
|
||||
sched.frames(20)
|
||||
expect(revealed()).toBe(afterOneFrame) // nothing further reached onReveal
|
||||
})
|
||||
|
||||
it('fast-path: a backlog above the cap is revealed whole in one emit', () => {
|
||||
const sched = new FakeScheduler()
|
||||
const { reveal, chunks, revealed } = makeReveal(sched, { maxBacklogChars: 1500 })
|
||||
const text = 'z'.repeat(2000)
|
||||
reveal.push(text)
|
||||
sched.frame(16)
|
||||
expect(revealed()).toBe(text)
|
||||
expect(chunks.length).toBe(1) // dumped, not stretched
|
||||
})
|
||||
|
||||
it('never splits a surrogate pair across chunks', () => {
|
||||
const sched = new FakeScheduler()
|
||||
const { reveal, chunks, revealed } = makeReveal(sched, { smoothingMs: 5000 }) // force ~1 char/frame
|
||||
const text = 'a😀b👨👩👧c'
|
||||
reveal.push(text)
|
||||
sched.frames(60)
|
||||
reveal.flush()
|
||||
expect(revealed()).toBe(text)
|
||||
chunks.forEach((c) => expect(hasLoneSurrogate(c)).toBe(false))
|
||||
})
|
||||
|
||||
it('instant mode reveals synchronously and never schedules', () => {
|
||||
const sched = new FakeScheduler()
|
||||
const { reveal, revealed } = makeReveal(sched, { instant: true })
|
||||
reveal.push('hello ')
|
||||
reveal.push('world')
|
||||
expect(revealed()).toBe('hello world')
|
||||
expect(sched.scheduleCount).toBe(0)
|
||||
})
|
||||
|
||||
it('steady-state backlog converges to ~arrivalRate × smoothingMs (no runaway, no stall)', () => {
|
||||
const sched = new FakeScheduler()
|
||||
const { reveal, revealed } = makeReveal(sched, { smoothingMs: 500 })
|
||||
const perFrame = 10 // chars pushed each 16ms frame → 0.625 chars/ms
|
||||
let pushed = 0
|
||||
for (let i = 0; i < 200; i++) {
|
||||
reveal.push('c'.repeat(perFrame))
|
||||
pushed += perFrame
|
||||
sched.frame(16)
|
||||
}
|
||||
const backlog = pushed - revealed().length
|
||||
// Target ≈ 0.625 * 500 = ~312. Assert it neither ran away nor drained to zero.
|
||||
expect(backlog).toBeGreaterThan(50)
|
||||
expect(backlog).toBeLessThan(900)
|
||||
})
|
||||
|
||||
it('emit frequency stays bounded by the throttle', () => {
|
||||
const sched = new FakeScheduler()
|
||||
const minEmitIntervalMs = 33
|
||||
const { reveal, chunks } = makeReveal(sched, { minEmitIntervalMs })
|
||||
const frames = 60
|
||||
const dt = 16
|
||||
for (let i = 0; i < frames; i++) {
|
||||
reveal.push('c'.repeat(10))
|
||||
sched.frame(dt)
|
||||
}
|
||||
const windowMs = frames * dt
|
||||
expect(chunks.length).toBeLessThanOrEqual(Math.ceil(windowMs / minEmitIntervalMs) + 2)
|
||||
})
|
||||
|
||||
it('suspends when fully revealed and resumes on the next push', () => {
|
||||
const sched = new FakeScheduler()
|
||||
const { reveal, revealed } = makeReveal(sched)
|
||||
reveal.push('short')
|
||||
sched.frames(30)
|
||||
expect(revealed()).toBe('short')
|
||||
expect(sched.pending()).toBe(0) // idle: no live frame scheduled
|
||||
reveal.push(' more')
|
||||
expect(sched.pending()).toBe(1) // restarted
|
||||
sched.frames(30)
|
||||
expect(revealed()).toBe('short more')
|
||||
})
|
||||
|
||||
it('clamps a large elapsed time (backgrounded-tab resume) instead of blasting the backlog', () => {
|
||||
const sched = new FakeScheduler()
|
||||
const { reveal, revealed } = makeReveal(sched, { smoothingMs: 500, maxCatchupMs: 100 })
|
||||
const text = 'q'.repeat(300)
|
||||
reveal.push(text)
|
||||
sched.frame(16) // first (nominal) emit; lastEmit now set
|
||||
const before = revealed().length
|
||||
sched.frame(5000) // loop kept running but the frame fired seconds late
|
||||
const revealedInBigFrame = revealed().length - before
|
||||
// Without the clamp, rate × 5000ms would reveal the entire backlog at once.
|
||||
expect(revealed().length).toBeLessThan(text.length)
|
||||
expect(revealedInBigFrame).toBeLessThan(text.length / 2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,204 @@
|
||||
// Perceived-smoothness layer for streamed assistant text.
|
||||
//
|
||||
// Providers (notably Anthropic for some model/tier combos) deliver text in
|
||||
// coarse bursts — tens of tokens batched into one delta every ~450 ms — so the
|
||||
// raw stream reads as freeze→jump→freeze. This module decouples *display* from
|
||||
// *arrival*: pushed text lands in a non-reactive buffer, and a paint loop
|
||||
// reveals a slice at a time through `onReveal`, so the bursts read as continuous
|
||||
// typing. It is deliberately free of Svelte — the only coupling to reactive
|
||||
// state is the `onReveal` callback — so the pacing is unit-testable with an
|
||||
// injected clock and scheduler.
|
||||
|
||||
type Schedule = (cb: () => void) => unknown
|
||||
type Cancel = (handle: unknown) => void
|
||||
|
||||
export interface TypewriterRevealOptions {
|
||||
/** Called with each revealed slice; the owner appends it to reactive state. */
|
||||
onReveal: (chunk: string) => void
|
||||
/** Reveal synchronously on push with no pacing (reduced-motion / SSR). */
|
||||
instant?: boolean
|
||||
/** Target lag between arrival and display, in ms. The one meaningful knob. */
|
||||
smoothingMs?: number
|
||||
/** Backlog at/above which the whole buffer is dumped in one emit (fast path). */
|
||||
maxBacklogChars?: number
|
||||
/** Minimum gap between emits, in ms — caps downstream re-parse/reflow frequency. */
|
||||
minEmitIntervalMs?: number
|
||||
/** Upper bound on the per-emit elapsed time, so a backgrounded-tab resume
|
||||
* catches up over several emits instead of one large jump. */
|
||||
maxCatchupMs?: number
|
||||
// Injectables for tests:
|
||||
now?: () => number
|
||||
schedule?: Schedule
|
||||
cancel?: Cancel
|
||||
}
|
||||
|
||||
const defaultNow: () => number =
|
||||
typeof performance !== 'undefined' && typeof performance.now === 'function'
|
||||
? () => performance.now()
|
||||
: () => Date.now()
|
||||
|
||||
const hasRAF = typeof requestAnimationFrame !== 'undefined'
|
||||
const defaultSchedule: Schedule = hasRAF
|
||||
? (cb) => requestAnimationFrame(cb)
|
||||
: (cb) => setTimeout(cb, 16)
|
||||
const defaultCancel: Cancel = hasRAF
|
||||
? (h) => cancelAnimationFrame(h as number)
|
||||
: (h) => clearTimeout(h as ReturnType<typeof setTimeout>)
|
||||
|
||||
export class TypewriterReveal {
|
||||
private readonly onReveal: (chunk: string) => void
|
||||
private readonly instant: boolean
|
||||
private readonly smoothingMs: number
|
||||
private readonly maxBacklogChars: number
|
||||
private readonly minEmitIntervalMs: number
|
||||
private readonly maxCatchupMs: number
|
||||
private readonly now: () => number
|
||||
private readonly schedule: Schedule
|
||||
private readonly cancel: Cancel
|
||||
|
||||
// A stable buffer + an index into it: reveal advances `revealed` (O(1) per
|
||||
// emit, no re-split). Everything before `revealed` has been emitted.
|
||||
private buffer = ''
|
||||
private revealed = 0
|
||||
private lastEmit: number | null = null
|
||||
private handle: unknown = null
|
||||
private running = false
|
||||
|
||||
constructor(opts: TypewriterRevealOptions) {
|
||||
this.onReveal = opts.onReveal
|
||||
this.instant = opts.instant ?? false
|
||||
this.smoothingMs = opts.smoothingMs ?? 500
|
||||
this.maxBacklogChars = opts.maxBacklogChars ?? 1500
|
||||
this.minEmitIntervalMs = opts.minEmitIntervalMs ?? 33
|
||||
this.maxCatchupMs = opts.maxCatchupMs ?? 100
|
||||
this.now = opts.now ?? defaultNow
|
||||
this.schedule = opts.schedule ?? defaultSchedule
|
||||
this.cancel = opts.cancel ?? defaultCancel
|
||||
}
|
||||
|
||||
/** Enqueue received text. In instant mode it is revealed synchronously. */
|
||||
push(text: string): void {
|
||||
if (!text) return
|
||||
if (this.instant) {
|
||||
this.onReveal(text)
|
||||
return
|
||||
}
|
||||
this.buffer += text
|
||||
this.ensureRunning()
|
||||
}
|
||||
|
||||
/** Reveal everything still buffered now and stop. Call before reading the
|
||||
* owner's reactive state into committed state, so the read sees the full text. */
|
||||
flush(): void {
|
||||
this.stop()
|
||||
if (this.instant) return
|
||||
if (this.revealed < this.buffer.length) {
|
||||
this.onReveal(this.buffer.slice(this.revealed))
|
||||
}
|
||||
// Everything is revealed now, so drop the buffer rather than carry an
|
||||
// ever-growing turn's worth of text: onMessageEnd fires flush() at every
|
||||
// tool-call boundary, and without this the buffer would keep every prior
|
||||
// segment until the turn's reset(). The next push starts a fresh buffer.
|
||||
this.buffer = ''
|
||||
this.revealed = 0
|
||||
}
|
||||
|
||||
/** Drop un-revealed backlog and stop. Call at turn boundaries. */
|
||||
reset(): void {
|
||||
this.stop()
|
||||
this.buffer = ''
|
||||
this.revealed = 0
|
||||
this.lastEmit = null
|
||||
}
|
||||
|
||||
private ensureRunning(): void {
|
||||
if (this.running) return
|
||||
this.running = true
|
||||
// Re-anchor on resume from idle: a long gap since the last emit must not
|
||||
// count as elapsed reveal time (the maxCatchupMs clamp only covers a
|
||||
// still-running loop whose frame fired late).
|
||||
this.lastEmit = null
|
||||
this.handle = this.schedule(this.tick)
|
||||
}
|
||||
|
||||
private stop(): void {
|
||||
if (this.handle != null) {
|
||||
this.cancel(this.handle)
|
||||
this.handle = null
|
||||
}
|
||||
this.running = false
|
||||
}
|
||||
|
||||
private tick = (): void => {
|
||||
this.handle = null
|
||||
const t = this.now()
|
||||
const backlog = this.buffer.length - this.revealed
|
||||
if (backlog <= 0) {
|
||||
this.running = false
|
||||
return
|
||||
}
|
||||
|
||||
if (backlog >= this.maxBacklogChars) {
|
||||
// Fast path: a cached/non-streaming reply dumped as one big delta shows
|
||||
// instantly instead of being stretched. Steady streaming settles well
|
||||
// under the cap, so smoothing only ever applies to genuinely bursty input.
|
||||
this.emit(backlog)
|
||||
this.lastEmit = t
|
||||
} else {
|
||||
const first = this.lastEmit === null
|
||||
// First emit after (re)start reveals a small nominal slice one frame
|
||||
// after arrival, keeping first paint essentially immediate.
|
||||
const sinceLast = first ? this.minEmitIntervalMs : t - this.lastEmit!
|
||||
if (sinceLast < this.minEmitIntervalMs) {
|
||||
// Throttle: too soon since the last emit — wait another frame.
|
||||
this.handle = this.schedule(this.tick)
|
||||
return
|
||||
}
|
||||
const elapsedMs = Math.min(sinceLast, this.maxCatchupMs)
|
||||
const rate = backlog / this.smoothingMs // chars per ms; grows with backlog
|
||||
const n = Math.min(backlog, Math.max(1, Math.floor(rate * elapsedMs)))
|
||||
const emitted = this.emit(n)
|
||||
this.lastEmit = t
|
||||
if (emitted === 0) {
|
||||
// Nothing revealable yet (a lone trailing high surrogate awaiting its
|
||||
// low half). Suspend; the next push restarts the loop.
|
||||
this.running = false
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (this.revealed < this.buffer.length) {
|
||||
this.handle = this.schedule(this.tick)
|
||||
} else {
|
||||
this.running = false
|
||||
}
|
||||
}
|
||||
|
||||
// Reveal up to `n` chars from `revealed`, never splitting a surrogate pair.
|
||||
// Returns the number of chars actually emitted (0 only when the buffer ends on
|
||||
// a lone high surrogate whose low half hasn't arrived).
|
||||
private emit(n: number): number {
|
||||
let end = Math.min(this.revealed + n, this.buffer.length)
|
||||
if (end < this.buffer.length) {
|
||||
const c = this.buffer.charCodeAt(end)
|
||||
// Landed on a low surrogate → cut before its high half.
|
||||
if (c >= 0xdc00 && c <= 0xdfff) end -= 1
|
||||
}
|
||||
if (end <= this.revealed) {
|
||||
// A floor-1 slice landed inside a pair; take the whole pair so we still
|
||||
// make progress rather than stalling on the same boundary each tick.
|
||||
end = Math.min(this.revealed + 2, this.buffer.length)
|
||||
}
|
||||
if (end === this.buffer.length && end - 1 >= this.revealed) {
|
||||
// Hold back a lone trailing high surrogate: its low half may still be
|
||||
// streaming in, and revealing it alone would emit a broken code unit.
|
||||
const last = this.buffer.charCodeAt(end - 1)
|
||||
if (last >= 0xd800 && last <= 0xdbff) end -= 1
|
||||
}
|
||||
if (end <= this.revealed) return 0
|
||||
const chunk = this.buffer.slice(this.revealed, end)
|
||||
this.revealed = end
|
||||
this.onReveal(chunk)
|
||||
return chunk.length
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user