@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.
213 lines (188 loc) • 6.9 kB
text/typescript
import { GenerateContentResponse } from '@google/genai';
import { GroundingSearch } from '@lobechat/types';
import { ChatStreamCallbacks } from '../../../types';
import { nanoid } from '../../../utils/uuid';
import { convertGoogleAIUsage } from '../../usageConverters/google-ai';
import {
ChatPayloadForTransformStream,
StreamContext,
StreamProtocolChunk,
StreamToolCallChunkData,
createCallbacksTransformer,
createSSEProtocolTransformer,
createTokenSpeedCalculator,
generateToolCallId,
} from '../protocol';
import { GOOGLE_AI_BLOCK_REASON } from './const';
export const LOBE_ERROR_KEY = '__lobe_error';
const getBlockReasonMessage = (blockReason: string): string => {
const blockReasonMessages = GOOGLE_AI_BLOCK_REASON;
return (
blockReasonMessages[blockReason as keyof typeof blockReasonMessages] ||
blockReasonMessages.default.replace('{{blockReason}}', blockReason)
);
};
const transformGoogleGenerativeAIStream = (
chunk: GenerateContentResponse,
context: StreamContext,
payload?: ChatPayloadForTransformStream,
): StreamProtocolChunk | StreamProtocolChunk[] => {
// Handle injected internal error marker to pass through detailed error info
if ((chunk as any)?.[LOBE_ERROR_KEY]) {
return {
data: (chunk as any)[LOBE_ERROR_KEY],
id: context?.id || 'error',
type: 'error',
};
}
// Handle promptFeedback with blockReason (e.g., PROHIBITED_CONTENT)
if ('promptFeedback' in chunk && (chunk as any).promptFeedback?.blockReason) {
const blockReason = (chunk as any).promptFeedback.blockReason;
const humanFriendlyMessage = getBlockReasonMessage(blockReason);
return {
data: {
body: {
context: {
promptFeedback: (chunk as any).promptFeedback,
},
message: humanFriendlyMessage,
provider: 'google',
},
type: 'ProviderBizError',
},
id: context?.id || 'error',
type: 'error',
};
}
// maybe need another structure to add support for multiple choices
const candidate = chunk.candidates?.[0];
const { usageMetadata } = chunk;
const usageChunks: StreamProtocolChunk[] = [];
if (candidate?.finishReason && usageMetadata) {
usageChunks.push({ data: candidate.finishReason, id: context?.id, type: 'stop' });
const convertedUsage = convertGoogleAIUsage(usageMetadata, payload?.pricing);
if (convertedUsage) {
usageChunks.push({ data: convertedUsage, id: context?.id, type: 'usage' });
}
}
const functionCalls = chunk.functionCalls;
if (functionCalls) {
return [
{
data: functionCalls.map(
(value, index): StreamToolCallChunkData => ({
function: {
arguments: JSON.stringify(value.args),
name: value.name,
},
id: generateToolCallId(index, value.name),
index: index,
type: 'function',
}),
),
id: context.id,
type: 'tool_calls',
},
...usageChunks,
];
}
const text = chunk.text;
if (candidate) {
// 首先检查是否为 reasoning 内容 (thought: true)
if (Array.isArray(candidate.content?.parts) && candidate.content.parts.length > 0) {
for (const part of candidate.content.parts) {
if (part && part.text && part.thought === true) {
return { data: part.text, id: context.id, type: 'reasoning' };
}
}
}
// return the grounding
const { groundingChunks, webSearchQueries } = candidate.groundingMetadata ?? {};
if (groundingChunks) {
return [
{ data: text, id: context.id, type: 'text' },
{
data: {
citations: groundingChunks?.map((chunk) => ({
// google 返回的 uri 是经过 google 自己处理过的 url,因此无法展现真实的 favicon
// 需要使用 title 作为替换
favicon: chunk.web?.title,
title: chunk.web?.title,
url: chunk.web?.uri,
})),
searchQueries: webSearchQueries,
} as GroundingSearch,
id: context.id,
type: 'grounding',
},
...usageChunks,
];
}
// Check for image data before handling finishReason
if (Array.isArray(candidate.content?.parts) && candidate.content.parts.length > 0) {
const part = candidate.content.parts[0];
if (part && part.inlineData && part.inlineData.data && part.inlineData.mimeType) {
const imageChunk = {
data: `data:${part.inlineData.mimeType};base64,${part.inlineData.data}`,
id: context.id,
type: 'base64_image' as const,
};
// If also has finishReason, combine image with finish chunks
if (candidate.finishReason) {
const chunks: StreamProtocolChunk[] = [imageChunk];
if (chunk.usageMetadata) {
// usageChunks already includes the 'stop' chunk as its first entry when usage exists,
// so append usageChunks to avoid sending a duplicate 'stop'.
chunks.push(...usageChunks);
} else {
// No usage metadata, we need to send the stop chunk explicitly.
chunks.push({ data: candidate.finishReason, id: context?.id, type: 'stop' });
}
return chunks;
}
return imageChunk;
}
}
if (candidate.finishReason) {
if (chunk.usageMetadata) {
return [
!!text ? { data: text, id: context?.id, type: 'text' } : undefined,
...usageChunks,
].filter(Boolean) as StreamProtocolChunk[];
}
return { data: candidate.finishReason, id: context?.id, type: 'stop' };
}
if (!!text?.trim()) return { data: text, id: context?.id, type: 'text' };
}
return {
data: text || '',
id: context?.id,
type: 'text',
};
};
export interface GoogleAIStreamOptions {
callbacks?: ChatStreamCallbacks;
enableStreaming?: boolean; // 选择 TPS 计算方式(非流式时传 false)
inputStartAt?: number;
payload?: ChatPayloadForTransformStream;
}
export const GoogleGenerativeAIStream = (
rawStream: ReadableStream<GenerateContentResponse>,
{ callbacks, inputStartAt, enableStreaming = true, payload }: GoogleAIStreamOptions = {},
) => {
const streamStack: StreamContext = { id: 'chat_' + nanoid() };
const transformWithPayload: typeof transformGoogleGenerativeAIStream = (chunk, ctx) =>
transformGoogleGenerativeAIStream(chunk, ctx, payload);
return rawStream
.pipeThrough(
createTokenSpeedCalculator(transformWithPayload, {
enableStreaming: enableStreaming,
inputStartAt,
streamStack,
}),
)
.pipeThrough(
createSSEProtocolTransformer((c) => c, streamStack, { requireTerminalEvent: true }),
)
.pipeThrough(createCallbacksTransformer(callbacks));
};