feat: 修复文件下载截断问题并优化前端流式消息体验,新增流平滑调度层
This commit is contained in:
parent
637981b060
commit
08330a9351
@ -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 校验脚本文件与输出结果文件是否成功落盘
|
||||
|
||||
@ -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
|
||||
})
|
||||
|
||||
// 发送消息并处理流式响应
|
||||
|
||||
@ -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%;
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
433
web/src/composables/useStreamSmoother.js
Normal file
433
web/src/composables/useStreamSmoother.js
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user