ForcePilot/web/src/composables/useStreamSmoother.js
2026-03-26 13:53:35 +08:00

436 lines
13 KiB
JavaScript

const cloneChunk = (value) => {
if (typeof structuredClone === 'function') {
return structuredClone(value)
}
return JSON.parse(JSON.stringify(value))
}
const hasText = (value) => typeof value === 'string' && value.length > 0
const createEmptyToolCallChunk = (toolCallChunk) => ({
...toolCallChunk,
args: ''
})
const stripBufferedFields = (chunk) => {
const stripped = cloneChunk(chunk)
stripped.content = ''
stripped.reasoning_content = ''
if (stripped.additional_kwargs?.reasoning_content !== undefined) {
stripped.additional_kwargs = {
...stripped.additional_kwargs,
reasoning_content: ''
}
}
if (Array.isArray(stripped.tool_call_chunks)) {
stripped.tool_call_chunks = stripped.tool_call_chunks.map(createEmptyToolCallChunk)
}
return stripped
}
const hasBufferedPayload = (chunk) =>
hasText(chunk?.content) ||
hasText(chunk?.reasoning_content) ||
hasText(chunk?.additional_kwargs?.reasoning_content) ||
(Array.isArray(chunk?.tool_call_chunks) &&
chunk.tool_call_chunks.some((item) => hasText(item?.args)))
const appendLoadingChunk = (threadState, chunk) => {
if (!threadState || !chunk?.id) return
if (!threadState.onGoingConv.msgChunks[chunk.id]) {
threadState.onGoingConv.msgChunks[chunk.id] = []
}
threadState.onGoingConv.msgChunks[chunk.id].push(chunk)
}
const raf =
typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function'
? (callback) => window.requestAnimationFrame(callback)
: (callback) => setTimeout(() => callback(Date.now()), 16)
const caf =
typeof window !== 'undefined' && typeof window.cancelAnimationFrame === 'function'
? (id) => window.cancelAnimationFrame(id)
: (id) => clearTimeout(id)
const DEFAULT_OPTIONS = {
minChunkSize: 1,
maxChunkSize: 64,
defaultIntervalMs: 1000,
minDrainWindowMs: 400,
maxDrainWindowMs: 1400,
targetLagMs: 900,
minReserveChars: 48,
maxReserveChars: 240,
emaAlpha: 0.2,
basePaceMultiplier: 0.92,
overflowDivisor: 180,
maxBurstFactor: 2.6,
reserveReleaseDelayMs: 1200,
reserveDecayWindowMs: 2200
}
const getIncomingSize = (chunk) => {
let total = 0
total += (chunk?.content || '').length
total += (chunk?.reasoning_content || '').length
total += (chunk?.additional_kwargs?.reasoning_content || '').length
if (Array.isArray(chunk?.tool_call_chunks)) {
chunk.tool_call_chunks.forEach((item) => {
total += (item?.args || '').length
})
}
return total
}
const createController = (chunk, options) => ({
skeleton: stripBufferedFields(chunk),
contentBuffer: '',
reasoningBuffer: '',
additionalReasoningBuffer: '',
toolCallArgBuffers: new Map(),
scheduled: false,
frameId: null,
lastPushAt: Date.now(),
lastEmitAt: 0,
lastFrameAt: Date.now(),
carryChars: 0,
avgIntervalMs: options.defaultIntervalMs,
avgChunkChars: Math.max(options.minReserveChars, getIncomingSize(chunk))
})
const mergeSkeleton = (controller, chunk) => {
const stripped = stripBufferedFields(chunk)
Object.entries(stripped).forEach(([key, value]) => {
if (
key === 'content' ||
key === 'reasoning_content' ||
key === 'tool_call_chunks' ||
key === 'additional_kwargs'
) {
return
}
controller.skeleton[key] = value
})
if (stripped.additional_kwargs) {
controller.skeleton.additional_kwargs = {
...(controller.skeleton.additional_kwargs || {}),
...stripped.additional_kwargs
}
}
if (Array.isArray(stripped.tool_call_chunks)) {
const existing = Array.isArray(controller.skeleton.tool_call_chunks)
? [...controller.skeleton.tool_call_chunks]
: []
stripped.tool_call_chunks.forEach((toolCallChunk) => {
const index = toolCallChunk?.index
const existingIndex = existing.findIndex((item) => item?.index === index)
if (existingIndex >= 0) {
existing[existingIndex] = {
...existing[existingIndex],
...toolCallChunk
}
} else {
existing.push(toolCallChunk)
}
})
controller.skeleton.tool_call_chunks = existing
}
}
const getBufferedLength = (controller) => {
let total =
controller.contentBuffer.length +
controller.reasoningBuffer.length +
controller.additionalReasoningBuffer.length
controller.toolCallArgBuffers.forEach((entry) => {
total += entry.buffer.length
})
return total
}
const clamp = (value, min, max) => Math.min(max, Math.max(min, value))
const getReserveSize = (controller, options) => {
const charsPerMs = controller.avgChunkChars / Math.max(1, controller.avgIntervalMs)
const lagReserve = Math.ceil(charsPerMs * options.targetLagMs)
return clamp(
Math.max(options.minReserveChars, lagReserve),
options.minReserveChars,
options.maxReserveChars
)
}
const getDynamicReserve = (controller, options, now) => {
const reserveSize = getReserveSize(controller, options)
const elapsedSincePush = Math.max(0, now - controller.lastPushAt)
const releaseDelay = Math.max(0, options.reserveReleaseDelayMs)
if (elapsedSincePush <= releaseDelay) {
return reserveSize
}
const decayProgress = clamp(
(elapsedSincePush - releaseDelay) / Math.max(1, options.reserveDecayWindowMs),
0,
1
)
return Math.ceil(reserveSize * (1 - decayProgress))
}
const getChunkSize = (controller, pending, options) => {
const now = Date.now()
const deltaMs = Math.max(16, now - controller.lastFrameAt)
const charsPerMs = controller.avgChunkChars / Math.max(1, controller.avgIntervalMs)
const baseRate = charsPerMs * options.basePaceMultiplier
const dynamicReserve = getDynamicReserve(controller, options, now)
const overflow = Math.max(0, pending - dynamicReserve)
const overflowBoost = overflow / options.overflowDivisor
const maxRate = Math.max(baseRate, charsPerMs * options.maxBurstFactor)
const pacedRate = clamp(baseRate + overflowBoost, options.minChunkSize / 240, maxRate)
controller.carryChars += pacedRate * deltaMs
controller.lastFrameAt = now
const budget = Math.floor(controller.carryChars)
if (budget <= 0) return 0
const maxAllowed = Math.max(1, pending - dynamicReserve)
const emitCount = Math.min(budget, maxAllowed, options.maxChunkSize)
if (emitCount <= 0) {
return 0
}
controller.carryChars -= emitCount
return emitCount
}
const takeFromBuffer = (value, count) => {
if (!value || count <= 0) {
return { emitted: '', rest: value || '' }
}
return {
emitted: value.slice(0, count),
rest: value.slice(count)
}
}
export function useStreamSmoother({ getThreadState, options = {} }) {
const resolvedOptions = {
...DEFAULT_OPTIONS,
...(options || {})
}
const controllersByThread = new Map()
const getThreadControllers = (threadId) => {
if (!controllersByThread.has(threadId)) {
controllersByThread.set(threadId, new Map())
}
return controllersByThread.get(threadId)
}
const emitDelta = (threadId, messageId, forceFlush = false) => {
const threadState = getThreadState(threadId)
const threadControllers = controllersByThread.get(threadId)
const controller = threadControllers?.get(messageId)
if (!threadState || !controller) return
const pending = getBufferedLength(controller)
if (pending <= 0) {
controller.scheduled = false
controller.frameId = null
return
}
const budget = forceFlush ? pending : getChunkSize(controller, pending, resolvedOptions)
let remaining = budget
const delta = stripBufferedFields(controller.skeleton)
const contentPart = takeFromBuffer(controller.contentBuffer, remaining)
delta.content = contentPart.emitted
controller.contentBuffer = contentPart.rest
remaining -= contentPart.emitted.length
const reasoningPart = takeFromBuffer(controller.reasoningBuffer, remaining)
delta.reasoning_content = reasoningPart.emitted
controller.reasoningBuffer = reasoningPart.rest
remaining -= reasoningPart.emitted.length
const additionalReasoningPart = takeFromBuffer(controller.additionalReasoningBuffer, remaining)
if (
additionalReasoningPart.emitted ||
delta.additional_kwargs?.reasoning_content !== undefined
) {
delta.additional_kwargs = {
...(delta.additional_kwargs || {}),
reasoning_content: additionalReasoningPart.emitted
}
}
controller.additionalReasoningBuffer = additionalReasoningPart.rest
remaining -= additionalReasoningPart.emitted.length
if (Array.isArray(delta.tool_call_chunks)) {
delta.tool_call_chunks = delta.tool_call_chunks
.map((toolCallChunk) => {
const entry = controller.toolCallArgBuffers.get(toolCallChunk.index)
if (!entry) return null
const argPart = takeFromBuffer(
entry.buffer,
remaining > 0 ? remaining : forceFlush ? pending : 0
)
entry.buffer = argPart.rest
remaining -= argPart.emitted.length
return {
...toolCallChunk,
args: argPart.emitted
}
})
.filter(Boolean)
}
const hasOutput =
hasText(delta.content) ||
hasText(delta.reasoning_content) ||
hasText(delta.additional_kwargs?.reasoning_content) ||
(Array.isArray(delta.tool_call_chunks) &&
delta.tool_call_chunks.some((item) => hasText(item?.args)))
if (hasOutput) {
controller.lastEmitAt = Date.now()
appendLoadingChunk(threadState, delta)
}
const remainingPending = getBufferedLength(controller)
if (remainingPending > 0 && !forceFlush) {
controller.scheduled = true
controller.frameId = raf(() => emitDelta(threadId, messageId))
return
}
controller.scheduled = false
controller.frameId = null
}
const schedule = (threadId, messageId) => {
const controller = controllersByThread.get(threadId)?.get(messageId)
if (!controller || controller.scheduled) return
controller.scheduled = true
controller.frameId = raf(() => emitDelta(threadId, messageId))
}
const pushChunk = (chunk, threadId) => {
const threadState = getThreadState(threadId)
if (!threadState || !chunk?.id) return
if (!hasBufferedPayload(chunk)) {
appendLoadingChunk(threadState, chunk)
return
}
const threadControllers = getThreadControllers(threadId)
let controller = threadControllers.get(chunk.id)
const now = Date.now()
if (!controller) {
controller = createController(chunk, resolvedOptions)
threadControllers.set(chunk.id, controller)
appendLoadingChunk(threadState, controller.skeleton)
} else {
mergeSkeleton(controller, chunk)
const observedInterval = now - controller.lastPushAt
if (observedInterval > 0) {
controller.avgIntervalMs = clamp(
controller.avgIntervalMs * (1 - resolvedOptions.emaAlpha) +
observedInterval * resolvedOptions.emaAlpha,
resolvedOptions.minDrainWindowMs,
resolvedOptions.maxDrainWindowMs
)
}
}
const incomingSize = Math.max(1, getIncomingSize(chunk))
controller.avgChunkChars = clamp(
controller.avgChunkChars * (1 - resolvedOptions.emaAlpha) +
incomingSize * resolvedOptions.emaAlpha,
resolvedOptions.minReserveChars,
resolvedOptions.maxReserveChars * 4
)
controller.lastPushAt = now
controller.contentBuffer += chunk.content || ''
controller.reasoningBuffer += chunk.reasoning_content || ''
controller.additionalReasoningBuffer += chunk.additional_kwargs?.reasoning_content || ''
if (Array.isArray(chunk.tool_call_chunks)) {
chunk.tool_call_chunks.forEach((toolCallChunk) => {
const index = toolCallChunk?.index
if (index === undefined || index === null) return
const existing = controller.toolCallArgBuffers.get(index) || { buffer: '' }
existing.buffer += toolCallChunk.args || ''
controller.toolCallArgBuffers.set(index, existing)
})
}
schedule(threadId, chunk.id)
}
const flushThread = (threadId) => {
const threadControllers = controllersByThread.get(threadId)
if (!threadControllers) return
threadControllers.forEach((controller, messageId) => {
if (controller.frameId !== null) {
caf(controller.frameId)
}
emitDelta(threadId, messageId, true)
})
}
const resetThread = (threadId = null) => {
if (threadId) {
const threadControllers = controllersByThread.get(threadId)
if (!threadControllers) return
threadControllers.forEach((controller) => {
if (controller.frameId !== null) {
caf(controller.frameId)
}
})
controllersByThread.delete(threadId)
return
}
controllersByThread.forEach((threadControllers) => {
threadControllers.forEach((controller) => {
if (controller.frameId !== null) {
caf(controller.frameId)
}
})
})
controllersByThread.clear()
}
return {
pushChunk,
flushThread,
resetThread
}
}