feat: 修复文件下载截断问题并优化前端流式消息体验,新增流平滑调度层

This commit is contained in:
Wenjie Zhang 2026-03-25 13:46:52 +08:00
parent 637981b060
commit 08330a9351
8 changed files with 577 additions and 145 deletions

View File

@ -30,6 +30,8 @@
## v0.6
<!-- 添加到这里 -->
- 修复 AgentPanel 文件下载截断问题viewer 下载接口对线程 `user-data` 文件改为直接返回实际文件,避免复用 sandbox 预览读取链路导致下载内容不完整
- 优化前端流式消息体验:新增通用 `useStreamSmoother` 调度层,统一平滑 Agent runs SSE、普通聊天流与审批恢复流中的 `loading` chunk按帧释放文本增量改善消息输出“一顿一顿”的观感
- 统一沙盒虚拟路径前缀默认值为 `/home/yuxi/user-data`,修正生产环境仍使用旧版 `/mnt/user-data` 的不一致配置;同时收紧 sandbox provisioner 对 Docker host bind 路径的归一化逻辑,保留 Docker Desktop for Windows 兼容
- 修复沙盒文件读取错误提示不准确的问题:`read_file` 不再把所有读取异常都显示为 `file not found`改为区分无效路径、目录路径、权限不足、实际读取失败同时对图片、PDF 等二进制文件返回明确提示,避免误报与乱码文本
- 新增 Agent runs 沙盒文件生成 E2E 脚本:通过管理员账号登录、创建 thread、调用 Agent 生成并执行冒泡排序脚本,然后仅通过线程文件 API 校验脚本文件与输出结果文件是否成功落盘

View File

@ -245,6 +245,7 @@ import { useApproval } from '@/composables/useApproval'
import { useAgentThreadState } from '@/composables/useAgentThreadState'
import { useAgentRunStream } from '@/composables/useAgentRunStream'
import { useAgentStreamHandler } from '@/composables/useAgentStreamHandler'
import { useStreamSmoother } from '@/composables/useStreamSmoother'
import { useAgentMentionConfig } from '@/composables/useAgentMentionConfig'
import AgentPanel from '@/components/AgentPanel.vue'
import UserInfoComponent from '@/components/UserInfoComponent.vue'
@ -304,9 +305,15 @@ const chatState = reactive({
// threadId线
threadStates: {}
})
const streamSmoother = useStreamSmoother({
getThreadState: (threadId) => chatState.threadStates[threadId] || null
})
const { getThreadState, resetOnGoingConv, stopThreadStream } = useAgentThreadState({
chatState,
getCurrentThreadId: () => chatState.currentThreadId
getCurrentThreadId: () => chatState.currentThreadId,
onStopThread: (threadId) => streamSmoother.flushThread(threadId),
onBeforeResetThread: (threadId) => streamSmoother.resetThread(threadId),
onBeforeCleanupThread: (threadId) => streamSmoother.resetThread(threadId)
})
// 线
@ -861,7 +868,8 @@ const { handleAgentResponse, handleStreamChunk } = useAgentStreamHandler({
processApprovalInStream,
currentAgentId,
supportsTodo,
supportsFiles
supportsFiles,
streamSmoother
})
const { startRunStream, resumeActiveRunForThread, stopRunStreamSubscription } = useAgentRunStream({
getThreadState,
@ -872,7 +880,8 @@ const { startRunStream, resumeActiveRunForThread, stopRunStreamSubscription } =
fetchThreadMessages,
fetchAgentState,
resetOnGoingConv,
onScrollToBottom: () => scrollController.scrollToBottom()
onScrollToBottom: () => scrollController.scrollToBottom(),
streamSmoother
})
//

View File

@ -1,114 +1,115 @@
<template>
<div class="basic-settings-section">
<template v-if="userStore.isSuperAdmin">
<div class="section-title">检索配置</div>
<template v-if="userStore.isAdmin">
<div class="section-title">默认项配置</div>
<div class="settings-panel">
<div class="setting-row two-cols">
<div class="col-item">
<div class="setting-label">{{ items?.default_model?.des || '默认对话模型' }}</div>
<div class="setting-content">
<ModelSelectorComponent
@select-model="handleChatModelSelect"
:model_spec="configStore.config?.default_model"
placeholder="请选择默认模型"
/>
<template v-if="userStore.isSuperAdmin">
<div class="setting-row two-cols">
<div class="col-item">
<div class="setting-label">{{ items?.default_model?.des || '默认对话模型' }}</div>
<div class="setting-content">
<ModelSelectorComponent
@select-model="handleChatModelSelect"
:model_spec="configStore.config?.default_model"
placeholder="请选择默认模型"
/>
</div>
</div>
<div class="col-item">
<div class="setting-label">{{ items?.fast_model?.des }}</div>
<div class="setting-content">
<ModelSelectorComponent
@select-model="handleFastModelSelect"
:model_spec="configStore.config?.fast_model"
placeholder="请选择模型"
/>
</div>
</div>
</div>
<div class="col-item">
<div class="setting-label">{{ items?.fast_model?.des }}</div>
<div class="setting-content">
<ModelSelectorComponent
@select-model="handleFastModelSelect"
:model_spec="configStore.config?.fast_model"
placeholder="请选择模型"
/>
<div class="setting-row two-cols">
<div class="col-item">
<div class="setting-label">{{ items?.embed_model?.des }}</div>
<div class="setting-content">
<EmbeddingModelSelector
:value="configStore.config?.embed_model"
@change="handleChange('embed_model', $event)"
style="width: 100%"
/>
</div>
</div>
<div class="col-item">
<div class="setting-label">{{ items?.reranker?.des }}</div>
<div class="setting-content">
<a-select
class="full-width"
:value="configStore.config?.reranker"
@change="handleChange('reranker', $event)"
placeholder="请选择重排序模型"
>
<a-select-option v-for="(name, idx) in rerankerChoices" :key="idx" :value="name"
>{{ name }}
</a-select-option>
</a-select>
</div>
</div>
</div>
</div>
<div class="setting-row two-cols">
</template>
<div class="setting-row">
<div class="col-item">
<div class="setting-label">{{ items?.embed_model?.des }}</div>
<div class="setting-content">
<EmbeddingModelSelector
:value="configStore.config?.embed_model"
@change="handleChange('embed_model', $event)"
style="width: 100%"
/>
</div>
</div>
<div class="col-item">
<div class="setting-label">{{ items?.reranker?.des }}</div>
<div class="setting-label">默认智能体</div>
<div class="setting-content">
<a-select
class="full-width"
:value="configStore.config?.reranker"
@change="handleChange('reranker', $event)"
placeholder="请选择重排序模型"
>
<a-select-option v-for="(name, idx) in rerankerChoices" :key="idx" :value="name"
>{{ name }}
</a-select-option>
</a-select>
class="agent-select"
:value="agentStore.defaultAgentId"
:options="agentOptions"
:loading="isSettingDefaultAgent"
:disabled="isSettingDefaultAgent || !agentOptions.length"
placeholder="请选择默认智能体"
@change="handleDefaultAgentChange"
/>
</div>
</div>
</div>
</div>
<div class="section-title">内容审查配置</div>
<div class="section">
<div class="card">
<span class="label">{{ items?.enable_content_guard?.des }}</span>
<a-switch
:checked="configStore.config?.enable_content_guard"
@change="handleChange('enable_content_guard', $event)"
/>
<template v-if="userStore.isSuperAdmin">
<div class="section-title">内容审查配置</div>
<div class="section">
<div class="card">
<span class="label">{{ items?.enable_content_guard?.des }}</span>
<a-switch
:checked="configStore.config?.enable_content_guard"
@change="handleChange('enable_content_guard', $event)"
/>
</div>
<div class="card" v-if="configStore.config?.enable_content_guard">
<span class="label">{{ items?.enable_content_guard_llm?.des }}</span>
<a-switch
:checked="configStore.config?.enable_content_guard_llm"
@change="handleChange('enable_content_guard_llm', $event)"
/>
</div>
<div
class="card card-select"
v-if="
configStore.config?.enable_content_guard && configStore.config?.enable_content_guard_llm
"
>
<span class="label">{{ items?.content_guard_llm_model?.des }}</span>
<ModelSelectorComponent
@select-model="handleContentGuardModelSelect"
:model_spec="configStore.config?.content_guard_llm_model"
placeholder="请选择模型"
/>
</div>
</div>
<div class="card" v-if="configStore.config?.enable_content_guard">
<span class="label">{{ items?.enable_content_guard_llm?.des }}</span>
<a-switch
:checked="configStore.config?.enable_content_guard_llm"
@change="handleChange('enable_content_guard_llm', $event)"
/>
</div>
<div
class="card card-select"
v-if="
configStore.config?.enable_content_guard && configStore.config?.enable_content_guard_llm
"
>
<span class="label">{{ items?.content_guard_llm_model?.des }}</span>
<ModelSelectorComponent
@select-model="handleContentGuardModelSelect"
:model_spec="configStore.config?.content_guard_llm_model"
placeholder="请选择模型"
/>
</div>
</div>
</template>
</template>
<div v-if="userStore.isAdmin" class="section-title">智能体配置</div>
<div v-if="userStore.isAdmin" class="section">
<div class="card card-select agent-card">
<div class="label-group">
<span class="label">默认智能体</span>
<span class="helper-text">用于默认进入的智能体也作为新会话的默认选择</span>
</div>
<a-select
class="agent-select"
:value="agentStore.defaultAgentId"
:options="agentOptions"
:loading="isSettingDefaultAgent"
:disabled="isSettingDefaultAgent || !agentOptions.length"
placeholder="请选择默认智能体"
@change="handleDefaultAgentChange"
/>
</div>
</div>
<!-- 服务链接部分 -->
<div v-if="userStore.isAdmin" class="section-title">服务链接</div>
<div v-if="userStore.isAdmin">
<p class="service-description">
<p class="section-description">
快速访问系统相关的外部服务需要将 localhost 替换为实际的 IP 地址
</p>
<div class="services-grid">
@ -253,28 +254,6 @@ onMounted(async () => {
<style lang="less" scoped>
.basic-settings-section {
.settings-content {
max-width: 100%;
}
.section-title {
color: var(--gray-900);
font-size: 16px;
font-weight: 600;
margin: 24px 0 0 0;
padding-bottom: 8px;
&:first-child {
margin-top: 12px;
}
}
.service-description {
color: var(--gray-600);
font-size: 14px;
margin: 0 0 16px 0;
line-height: 1.5;
}
.section {
background-color: var(--gray-0);
@ -353,23 +332,6 @@ onMounted(async () => {
}
}
.agent-card {
align-items: center;
}
.label-group {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.helper-text {
color: var(--gray-500);
font-size: 12px;
line-height: 1.5;
}
.agent-select {
width: 320px;
max-width: 100%;
@ -419,11 +381,6 @@ onMounted(async () => {
}
@media (max-width: 768px) {
.agent-card {
align-items: flex-start;
flex-direction: column;
}
.agent-select {
width: 100%;
}

View File

@ -294,7 +294,7 @@ watch(
}
.settings-content {
padding: 12px 16px 16px; /* Keep inner readability without outer panel padding */
padding: 16px 16px; /* Keep inner readability without outer panel padding */
// margin-bottom: 40px; /* Matches SettingView .setting margin-bottom */
overflow-y: scroll;
height: auto;
@ -326,7 +326,7 @@ watch(
font-weight: 500;
color: var(--gray-900);
line-height: 1.4;
margin: 12px 0 4px;
margin: 12px 0 12px;
}
.section-description {

View File

@ -124,7 +124,8 @@ export function useAgentRunStream({
fetchThreadMessages,
fetchAgentState,
resetOnGoingConv,
onScrollToBottom
onScrollToBottom,
streamSmoother
}) {
const saveActiveRunSnapshot = (threadId, runId, lastSeq = '0') => {
if (!threadId || !runId) return
@ -157,6 +158,7 @@ export function useAgentRunStream({
const stopRunStreamSubscription = (threadId) => {
const ts = getThreadState(threadId)
if (!ts) return
streamSmoother?.flushThread(threadId)
if (ts.runStreamAbortController) {
ts.runStreamAbortController.abort()
ts.runStreamAbortController = null
@ -233,6 +235,7 @@ export function useAgentRunStream({
}
if (event === 'close') {
streamSmoother?.flushThread(threadId)
ts.isStreaming = false
if (RUN_TERMINAL_STATUSES.has(data.status)) {
ts.activeRunId = null
@ -275,6 +278,7 @@ export function useAgentRunStream({
})
} catch (error) {
if (error?.name !== 'AbortError') {
streamSmoother?.flushThread(threadId)
console.error('Run SSE stream error:', error)
handleChatError(error, 'stream')
if (ts.activeRunId === runId) {

View File

@ -67,7 +67,8 @@ export function useAgentStreamHandler({
processApprovalInStream,
currentAgentId,
supportsTodo,
supportsFiles
supportsFiles,
streamSmoother
}) {
const debugPrefix = '[AgentStateDebug]'
/**
@ -89,14 +90,19 @@ export function useAgentStreamHandler({
case 'loading':
if (msg.id) {
if (!threadState.onGoingConv.msgChunks[msg.id]) {
threadState.onGoingConv.msgChunks[msg.id] = []
if (streamSmoother) {
streamSmoother.pushChunk(msg, threadId)
} else {
if (!threadState.onGoingConv.msgChunks[msg.id]) {
threadState.onGoingConv.msgChunks[msg.id] = []
}
threadState.onGoingConv.msgChunks[msg.id].push(msg)
}
threadState.onGoingConv.msgChunks[msg.id].push(msg)
}
return false
case 'error':
streamSmoother?.flushThread(threadId)
handleChatError({ message: chunkMessage }, 'stream')
// Stop the loading indicator
if (threadState) {
@ -112,6 +118,7 @@ export function useAgentStreamHandler({
case 'ask_user_question_required':
case 'human_approval_required':
streamSmoother?.flushThread(threadId)
console.log(`${debugPrefix}[approval_required]`, {
threadId,
currentAgentId: unref(currentAgentId)
@ -151,6 +158,7 @@ export function useAgentStreamHandler({
return false
case 'finished':
streamSmoother?.flushThread(threadId)
// 先标记流式结束,但保持消息显示直到历史记录加载完成
if (threadState) {
threadState.isStreaming = false
@ -175,6 +183,7 @@ export function useAgentStreamHandler({
return true
case 'interrupted':
streamSmoother?.flushThread(threadId)
// 中断状态,刷新消息历史
console.warn(`${debugPrefix}[interrupted]`, {
threadId,

View File

@ -5,7 +5,13 @@ const createOnGoingConvState = () => ({
toolCallBuffers: {}
})
export function useAgentThreadState({ chatState, getCurrentThreadId }) {
export function useAgentThreadState({
chatState,
getCurrentThreadId,
onStopThread = null,
onBeforeResetThread = null,
onBeforeCleanupThread = null
}) {
const getThreadState = (threadId) => {
if (!threadId) return null
if (!chatState.threadStates[threadId]) {
@ -26,6 +32,10 @@ export function useAgentThreadState({ chatState, getCurrentThreadId }) {
const stopThreadStream = (threadId) => {
if (!threadId) return
const threadState = chatState.threadStates[threadId]
if (typeof onStopThread === 'function') {
onStopThread(threadId)
}
if (!threadState?.streamAbortController) return
threadState.streamAbortController.abort()
@ -38,6 +48,10 @@ export function useAgentThreadState({ chatState, getCurrentThreadId }) {
const threadState = chatState.threadStates[threadId]
if (!threadState) return
if (typeof onBeforeCleanupThread === 'function') {
onBeforeCleanupThread(threadId)
}
if (threadState.streamAbortController) {
threadState.streamAbortController.abort()
}
@ -55,6 +69,10 @@ export function useAgentThreadState({ chatState, getCurrentThreadId }) {
const threadState = getThreadState(targetThreadId)
if (!threadState) return
if (typeof onBeforeResetThread === 'function') {
onBeforeResetThread(targetThreadId)
}
if (threadState.streamAbortController) {
threadState.streamAbortController.abort()
threadState.streamAbortController = null

View File

@ -0,0 +1,433 @@
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
}
}