UNPKG

@lobehub/chat

Version:

Lobe Chat - an open-source, high-performance chatbot framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.

613 lines (516 loc) 17.4 kB
import { MESSAGE_CANCEL_FLAT } from '@/const/message'; import { LOBE_CHAT_OBSERVATION_ID, LOBE_CHAT_TRACE_ID } from '@/const/trace'; import { parseToolCalls } from '@/libs/model-runtime'; import { ChatErrorType } from '@/types/fetch'; import { ResponseAnimation, ResponseAnimationStyle } from '@/types/llm'; import { ChatMessageError, MessageToolCall, MessageToolCallChunk, MessageToolCallSchema, ModelReasoning, ModelSpeed, ModelTokensUsage, } from '@/types/message'; import { ChatImageChunk } from '@/types/message/image'; import { GroundingSearch } from '@/types/search'; import { nanoid } from '@/utils/uuid'; import { fetchEventSource } from './fetchEventSource'; import { getMessageError } from './parseError'; type SSEFinishType = 'done' | 'error' | 'abort'; export type OnFinishHandler = ( text: string, context: { grounding?: GroundingSearch; images?: ChatImageChunk[]; observationId?: string | null; reasoning?: ModelReasoning; speed?: ModelSpeed; toolCalls?: MessageToolCall[]; traceId?: string | null; type?: SSEFinishType; usage?: ModelTokensUsage; }, ) => Promise<void>; export interface MessageUsageChunk { type: 'usage'; usage: ModelTokensUsage; } export interface MessageSpeedChunk { speed: ModelSpeed; type: 'speed'; } export interface MessageTextChunk { text: string; type: 'text'; } export interface MessageBase64ImageChunk { id: string; image: ChatImageChunk; images: ChatImageChunk[]; type: 'base64_image'; } export interface MessageReasoningChunk { signature?: string; text?: string; type: 'reasoning'; } export interface MessageGroundingChunk { grounding: GroundingSearch; type: 'grounding'; } interface MessageToolCallsChunk { isAnimationActives?: boolean[]; tool_calls: MessageToolCall[]; type: 'tool_calls'; } export interface FetchSSEOptions { fetcher?: typeof fetch; onAbort?: (text: string) => Promise<void>; onErrorHandle?: (error: ChatMessageError) => void; onFinish?: OnFinishHandler; onMessageHandle?: ( chunk: | MessageTextChunk | MessageToolCallsChunk | MessageReasoningChunk | MessageGroundingChunk | MessageUsageChunk | MessageBase64ImageChunk | MessageSpeedChunk, ) => void; responseAnimation?: ResponseAnimation; } const START_ANIMATION_SPEED = 10; // 默认起始速度 const END_ANIMATION_SPEED = 16; const createSmoothMessage = (params: { onTextUpdate: (delta: string, text: string) => void; startSpeed?: number; }) => { const { startSpeed = START_ANIMATION_SPEED } = params; let buffer = ''; let outputQueue: string[] = []; let isAnimationActive = false; let animationFrameId: number | null = null; let lastFrameTime = 0; let accumulatedTime = 0; let currentSpeed = startSpeed; let lastQueueLength = 0; // 记录上一帧的队列长度 const stopAnimation = () => { isAnimationActive = false; if (animationFrameId !== null) { cancelAnimationFrame(animationFrameId); animationFrameId = null; } }; const startAnimation = (speed = startSpeed) => { return new Promise<void>((resolve) => { if (isAnimationActive) { resolve(); return; } isAnimationActive = true; lastFrameTime = performance.now(); accumulatedTime = 0; currentSpeed = speed; lastQueueLength = 0; // 重置上一帧队列长度 const updateText = (timestamp: number) => { if (!isAnimationActive) { if (animationFrameId !== null) { cancelAnimationFrame(animationFrameId); } resolve(); return; } const frameDuration = timestamp - lastFrameTime; lastFrameTime = timestamp; accumulatedTime += frameDuration; let charsToProcess = 0; if (outputQueue.length > 0) { // 更平滑的速度调整 const targetSpeed = Math.max(speed, outputQueue.length); // 根据队列长度变化调整速度变化率 const speedChangeRate = Math.abs(outputQueue.length - lastQueueLength) * 0.0008 + 0.005; currentSpeed += (targetSpeed - currentSpeed) * speedChangeRate; charsToProcess = Math.floor((accumulatedTime * currentSpeed) / 1000); } if (charsToProcess > 0) { accumulatedTime -= (charsToProcess * 1000) / currentSpeed; let actualChars = Math.min(charsToProcess, outputQueue.length); // actualChars = Math.min(speed, actualChars); // 速度上限 // if (actualChars * 2 < outputQueue.length && /[\dA-Za-z]/.test(outputQueue[actualChars])) { // actualChars *= 2; // } const charsToAdd = outputQueue.splice(0, actualChars).join(''); buffer += charsToAdd; params.onTextUpdate(charsToAdd, buffer); } lastQueueLength = outputQueue.length; // 更新上一帧的队列长度 if (outputQueue.length > 0 && isAnimationActive) { animationFrameId = requestAnimationFrame(updateText); } else { isAnimationActive = false; animationFrameId = null; resolve(); } }; animationFrameId = requestAnimationFrame(updateText); }); }; const pushToQueue = (text: string) => { outputQueue.push(...text.split('')); }; return { isAnimationActive, isTokenRemain: () => outputQueue.length > 0, pushToQueue, startAnimation, stopAnimation, }; }; const createSmoothToolCalls = (params: { onToolCallsUpdate: (toolCalls: MessageToolCall[], isAnimationActives: boolean[]) => void; startSpeed?: number; }) => { const { startSpeed = START_ANIMATION_SPEED } = params; let toolCallsBuffer: MessageToolCall[] = []; // 为每个 tool_call 维护一个输出队列和动画控制器 const outputQueues: string[][] = []; const isAnimationActives: boolean[] = []; const animationFrameIds: (number | null)[] = []; const stopAnimation = (index: number) => { isAnimationActives[index] = false; if (animationFrameIds[index] !== null) { cancelAnimationFrame(animationFrameIds[index]!); animationFrameIds[index] = null; } }; const startAnimation = (index: number, speed = startSpeed) => new Promise<void>((resolve) => { if (isAnimationActives[index]) { resolve(); return; } isAnimationActives[index] = true; const updateToolCall = () => { if (!isAnimationActives[index]) { resolve(); return; } if (outputQueues[index].length > 0) { const charsToAdd = outputQueues[index].splice(0, speed).join(''); const toolCallToUpdate = toolCallsBuffer[index]; if (toolCallToUpdate) { toolCallToUpdate.function.arguments += charsToAdd; // 触发 ui 更新 params.onToolCallsUpdate(toolCallsBuffer, [...isAnimationActives]); } animationFrameIds[index] = requestAnimationFrame(() => updateToolCall()); } else { isAnimationActives[index] = false; animationFrameIds[index] = null; resolve(); } }; animationFrameIds[index] = requestAnimationFrame(() => updateToolCall()); }); const pushToQueue = (toolCallChunks: MessageToolCallChunk[]) => { toolCallChunks.forEach((chunk) => { // init the tool call buffer and output queue if (!toolCallsBuffer[chunk.index]) { toolCallsBuffer[chunk.index] = MessageToolCallSchema.parse(chunk); } if (!outputQueues[chunk.index]) { outputQueues[chunk.index] = []; isAnimationActives[chunk.index] = false; animationFrameIds[chunk.index] = null; } outputQueues[chunk.index].push(...(chunk.function?.arguments || '').split('')); }); }; const startAnimations = async (speed = startSpeed) => { const pools = toolCallsBuffer.map(async (_, index) => { if (outputQueues[index].length > 0 && !isAnimationActives[index]) { await startAnimation(index, speed); } }); await Promise.all(pools); }; const stopAnimations = () => { toolCallsBuffer.forEach((_, index) => { stopAnimation(index); }); }; return { isAnimationActives, isTokenRemain: () => outputQueues.some((token) => token.length > 0), pushToQueue, startAnimations, stopAnimations, }; }; export const standardizeAnimationStyle = ( animationStyle?: ResponseAnimation, ): Exclude<ResponseAnimation, ResponseAnimationStyle> => { return typeof animationStyle === 'object' ? animationStyle : { text: animationStyle, toolsCalling: animationStyle }; }; /** * Fetch data using stream method */ // eslint-disable-next-line no-undef export const fetchSSE = async (url: string, options: RequestInit & FetchSSEOptions = {}) => { let toolCalls: undefined | MessageToolCall[]; let triggerOnMessageHandler = false; let finishedType: SSEFinishType = 'done'; let response!: Response; const { text, toolsCalling, speed: smoothingSpeed, } = standardizeAnimationStyle(options.responseAnimation ?? {}); const shouldSkipTextProcessing = text === 'none'; const textSmoothing = text === 'smooth'; const toolsCallingSmoothing = toolsCalling === 'smooth'; // 添加文本buffer和计时器相关变量 let textBuffer = ''; // eslint-disable-next-line no-undef let bufferTimer: NodeJS.Timeout | null = null; const BUFFER_INTERVAL = 300; // 300ms const flushTextBuffer = () => { if (textBuffer) { options.onMessageHandle?.({ text: textBuffer, type: 'text' }); textBuffer = ''; } }; let output = ''; const textController = createSmoothMessage({ onTextUpdate: (delta, text) => { output = text; options.onMessageHandle?.({ text: delta, type: 'text' }); }, startSpeed: smoothingSpeed, }); let thinking = ''; let thinkingSignature: string | undefined; const thinkingController = createSmoothMessage({ onTextUpdate: (delta, text) => { thinking = text; options.onMessageHandle?.({ text: delta, type: 'reasoning' }); }, startSpeed: smoothingSpeed, }); let thinkingBuffer = ''; // eslint-disable-next-line no-undef let thinkingBufferTimer: NodeJS.Timeout | null = null; // 创建一个函数来处理buffer的刷新 const flushThinkingBuffer = () => { if (thinkingBuffer) { options.onMessageHandle?.({ text: thinkingBuffer, type: 'reasoning' }); thinkingBuffer = ''; } }; const toolCallsController = createSmoothToolCalls({ onToolCallsUpdate: (toolCalls, isAnimationActives) => { options.onMessageHandle?.({ isAnimationActives, tool_calls: toolCalls, type: 'tool_calls' }); }, startSpeed: smoothingSpeed, }); let grounding: GroundingSearch | undefined = undefined; let usage: ModelTokensUsage | undefined = undefined; let images: ChatImageChunk[] = []; let speed: ModelSpeed | undefined = undefined; await fetchEventSource(url, { body: options.body, fetch: options?.fetcher, headers: options.headers as Record<string, string>, method: options.method, onerror: (error) => { if (error === MESSAGE_CANCEL_FLAT || (error as TypeError).name === 'AbortError') { finishedType = 'abort'; options?.onAbort?.(output); textController.stopAnimation(); } else { finishedType = 'error'; options.onErrorHandle?.( error.type ? error : { body: { message: error.message, name: error.name, stack: error.stack, }, message: error.message, type: ChatErrorType.UnknownChatFetchError, }, ); return; } }, onmessage: (ev) => { triggerOnMessageHandler = true; let data; try { data = JSON.parse(ev.data); } catch (e) { console.warn('parse error:', e); options.onErrorHandle?.({ body: { context: { chunk: ev.data, error: { message: (e as Error).message, name: (e as Error).name }, }, message: 'chat response streaming chunk parse error, please contact your API Provider to fix it.', }, message: 'parse error', type: 'StreamChunkError', }); return; } switch (ev.event) { case 'error': { finishedType = 'error'; options.onErrorHandle?.(data); break; } case 'base64_image': { const id = 'tmp_img_' + nanoid(); const item = { data, id, isBase64: true }; images.push(item); options.onMessageHandle?.({ id, image: item, images, type: 'base64_image' }); break; } case 'text': { // skip empty text if (!data) break; if (shouldSkipTextProcessing) { output += data; options.onMessageHandle?.({ text: data, type: 'text' }); } else if (textSmoothing) { textController.pushToQueue(data); if (!textController.isAnimationActive) textController.startAnimation(); } else { output += data; // 使用buffer机制 textBuffer += data; // 如果还没有设置计时器,创建一个 if (!bufferTimer) { bufferTimer = setTimeout(() => { flushTextBuffer(); bufferTimer = null; }, BUFFER_INTERVAL); } } break; } case 'usage': { usage = data; options.onMessageHandle?.({ type: 'usage', usage: data }); break; } case 'speed': { speed = data; options.onMessageHandle?.({ speed: data, type: 'speed' }); break; } case 'grounding': { grounding = data; options.onMessageHandle?.({ grounding: data, type: 'grounding' }); break; } case 'reasoning_signature': { thinkingSignature = data; break; } case 'reasoning': { if (textSmoothing) { thinkingController.pushToQueue(data); if (!thinkingController.isAnimationActive) thinkingController.startAnimation(); } else { thinking += data; // 使用buffer机制 thinkingBuffer += data; // 如果还没有设置计时器,创建一个 if (!thinkingBufferTimer) { thinkingBufferTimer = setTimeout(() => { flushThinkingBuffer(); thinkingBufferTimer = null; }, BUFFER_INTERVAL); } } break; } case 'tool_calls': { // get finial // if there is no tool calls, we should initialize the tool calls if (!toolCalls) toolCalls = []; toolCalls = parseToolCalls(toolCalls, data); if (toolsCallingSmoothing) { // make the tool calls smooth // push the tool calls to the smooth queue toolCallsController.pushToQueue(data); // if there is no animation active, we should start the animation if (toolCallsController.isAnimationActives.some((value) => !value)) { toolCallsController.startAnimations(); } } else { options.onMessageHandle?.({ tool_calls: toolCalls, type: 'tool_calls' }); } } } }, onopen: async (res) => { response = res.clone(); // 如果不 ok 说明有请求错误 if (!response.ok) { throw await getMessageError(res); } }, signal: options.signal, }); // only call onFinish when response is available // so like abort, we don't need to call onFinish if (response) { textController.stopAnimation(); toolCallsController.stopAnimations(); // 确保所有缓冲区数据都被处理 if (bufferTimer) { clearTimeout(bufferTimer); flushTextBuffer(); } if (thinkingBufferTimer) { clearTimeout(thinkingBufferTimer); flushThinkingBuffer(); } if (response.ok) { // if there is no onMessageHandler, we should call onHandleMessage first if (!triggerOnMessageHandler) { output = await response.clone().text(); options.onMessageHandle?.({ text: output, type: 'text' }); } const traceId = response.headers.get(LOBE_CHAT_TRACE_ID); const observationId = response.headers.get(LOBE_CHAT_OBSERVATION_ID); if (textController.isTokenRemain()) { await textController.startAnimation(smoothingSpeed); } if (toolCallsController.isTokenRemain()) { await toolCallsController.startAnimations(END_ANIMATION_SPEED); } await options?.onFinish?.(output, { grounding, images: images.length > 0 ? images : undefined, observationId, reasoning: !!thinking ? { content: thinking, signature: thinkingSignature } : undefined, speed, toolCalls, traceId, type: finishedType, usage, }); } } return response; };