Skip to content

Commit b86e747

Browse files
committed
feat: 🎉 support streamChatCompletion
1 parent 16f4994 commit b86e747

File tree

9 files changed

+2389
-41
lines changed

9 files changed

+2389
-41
lines changed

examples/serve-astack/backend/src/routes/chat.ts

Lines changed: 22 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { FastifyInstance } from 'fastify';
22
import { classifyIntent, getStreamingAgentByIntent } from '../agents/index.js';
3-
import { createLLMClient, chatWithLLM } from '../services/llm.js';
3+
import { createLLMClient, chatWithLLMStreaming } from '../services/llm.js';
44

55
// AI SDK 5.0 compatible types
66
interface UIMessagePart {
@@ -58,34 +58,34 @@ export default async function chatRoutes(fastify: FastifyInstance) {
5858
.join(' '),
5959
}));
6060

61-
const response = await chatWithLLM(llmClient, llmMessages);
62-
6361
// Set proper headers for AI SDK Data Stream Protocol
6462
reply.type('text/plain; charset=utf-8');
6563
reply.header('X-Vercel-AI-Data-Stream', 'v1');
6664

67-
// Send text chunks using AI SDK Data Stream Protocol format
68-
const textContent = response.content;
69-
const chunks = textContent.split('');
65+
let completionTokens = 0;
7066

71-
// Simulate streaming by sending character by character
72-
for (let i = 0; i < chunks.length; i++) {
73-
// Text Part: 0:string\n
74-
const chunk = `0:${JSON.stringify(chunks[i])}\n`;
75-
reply.raw.write(chunk);
67+
try {
68+
// Real streaming from LLM
69+
for await (const chunk of chatWithLLMStreaming(llmClient, llmMessages)) {
70+
// Send each chunk as it arrives from LLM
71+
const textPart = `0:${JSON.stringify(chunk)}\n`;
72+
reply.raw.write(textPart);
73+
completionTokens += chunk.length;
74+
}
7675

77-
// Small delay to simulate streaming
78-
await new Promise(resolve => setTimeout(resolve, 50));
76+
// Send completion using Finish Message Part
77+
const finishPart = `d:${JSON.stringify({
78+
finishReason: 'stop',
79+
usage: { promptTokens: 0, completionTokens },
80+
})}\n`;
81+
reply.raw.write(finishPart);
82+
reply.raw.end();
83+
} catch (error) {
84+
fastify.log.error(error, 'Error in LLM streaming');
85+
const errorPart = `3:${JSON.stringify(error instanceof Error ? error.message : 'LLM streaming error')}\n`;
86+
reply.raw.write(errorPart);
87+
reply.raw.end();
7988
}
80-
81-
// Send completion using Finish Message Part
82-
// d:{finishReason:'stop' | 'length' | 'content-filter' | 'tool-calls' | 'error' | 'other' | 'unknown';usage:{promptTokens:number; completionTokens:number;}}
83-
const finishPart = `d:${JSON.stringify({
84-
finishReason: 'stop',
85-
usage: { promptTokens: 0, completionTokens: chunks.length },
86-
})}\n`;
87-
reply.raw.write(finishPart);
88-
reply.raw.end();
8989
} else {
9090
// Handle agent-based processing with AI SDK Data Stream Protocol
9191
const streamingAgent = getStreamingAgentByIntent(intent);

examples/serve-astack/backend/src/services/llm.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,27 @@ export async function chatWithLLM(
5050
content: response.content,
5151
};
5252
}
53+
54+
export async function* chatWithLLMStreaming(
55+
client: ModelProvider,
56+
messages: LLMMessage[]
57+
): AsyncGenerator<string> {
58+
// 检查是否支持流式调用
59+
if (!client.streamChatCompletion) {
60+
throw new Error('ModelProvider does not support streaming');
61+
}
62+
63+
// 使用流式调用
64+
const response = await client.streamChatCompletion(
65+
messages.map(msg => ({
66+
role: msg.role,
67+
content: msg.content,
68+
}))
69+
);
70+
71+
for await (const chunk of response) {
72+
if (chunk.content) {
73+
yield chunk.content;
74+
}
75+
}
76+
}

examples/serve-astack/frontend/package.json

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,25 +10,27 @@
1010
"lint": "eslint"
1111
},
1212
"dependencies": {
13-
"react": "19.1.0",
14-
"react-dom": "19.1.0",
15-
"next": "15.5.4",
1613
"@ai-sdk/react": "^1.0.18",
17-
"@radix-ui/react-slot": "^1.0.2",
1814
"@radix-ui/react-scroll-area": "^1.0.5",
15+
"@radix-ui/react-slot": "^1.0.2",
1916
"class-variance-authority": "^0.7.0",
2017
"clsx": "^2.1.0",
21-
"lucide-react": "^0.263.1"
18+
"lucide-react": "^0.263.1",
19+
"next": "15.5.4",
20+
"react": "19.1.0",
21+
"react-dom": "19.1.0",
22+
"shiki": "^3.13.0",
23+
"streamdown": "^1.3.0"
2224
},
2325
"devDependencies": {
24-
"typescript": "^5",
26+
"@eslint/eslintrc": "^3",
27+
"@tailwindcss/postcss": "^4",
2528
"@types/node": "^20",
2629
"@types/react": "^19",
2730
"@types/react-dom": "^19",
28-
"@tailwindcss/postcss": "^4",
29-
"tailwindcss": "^4",
3031
"eslint": "^9",
3132
"eslint-config-next": "15.5.4",
32-
"@eslint/eslintrc": "^3"
33+
"tailwindcss": "^4",
34+
"typescript": "^5"
3335
}
3436
}

examples/serve-astack/frontend/src/app/globals.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
@import "tailwindcss";
2+
@source "../node_modules/streamdown/dist/index.js";
23

34
:root {
45
--background: #ffffff;

examples/serve-astack/frontend/src/app/page.tsx

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
/* eslint-disable @typescript-eslint/no-explicit-any */
44

55
import { useChat } from '@ai-sdk/react';
6+
import { Streamdown } from 'streamdown';
67
import {
78
Send,
89
Bot,
@@ -327,11 +328,8 @@ export default function ChatPage() {
327328
{message.parts.map((part, partIndex) => {
328329
if (part.type === 'text') {
329330
return (
330-
<div
331-
key={partIndex}
332-
className="leading-relaxed whitespace-pre-wrap"
333-
>
334-
{part.text}
331+
<div key={partIndex} className="leading-relaxed">
332+
<Streamdown>{part.text}</Streamdown>
335333
</div>
336334
);
337335
}

packages/components/src/agents/StreamingAgent.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -215,9 +215,9 @@ export class StreamingAgent extends Component {
215215
};
216216

217217
// 调用模型获取回复(与原 Agent 完全一致)
218-
const modelResponse = await model.chatCompletion(currentMessages, {
218+
const modelResponse = (await model.chatCompletion(currentMessages, {
219219
temporaryTools: tools,
220-
});
220+
})) as MessageWithToolCalls;
221221

222222
if (verbose) {
223223
console.log(

packages/components/src/agents/index.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,17 @@ export interface ModelProvider {
170170
messages: Message[],
171171
options?: ModelProviderOptions
172172
): Promise<MessageWithToolCalls>;
173+
174+
/**
175+
* 流式调用模型生成回复
176+
* @param messages 输入消息列表
177+
* @param options 可选的模型调用选项
178+
* @returns 流式回复消息的异步生成器
179+
*/
180+
streamChatCompletion?(
181+
messages: Message[],
182+
options?: ModelProviderOptions
183+
): AsyncGenerator<Partial<MessageWithToolCalls>>;
173184
}
174185

175186
/**

packages/integrations/src/model-provider/deepseek/index.ts

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,124 @@ class Deepseek extends Component {
224224
return response.choices[0].message.content || '';
225225
}
226226

227+
/**
228+
* 流式处理对话消息
229+
* @param messages 对话消息数组
230+
* @param options 可选的调用选项,包含临时工具列表
231+
* @returns 生成的流式响应消息异步生成器
232+
*/
233+
async *streamChatCompletion(
234+
messages: Message[],
235+
options?: { temporaryTools?: unknown[] }
236+
): AsyncGenerator<Partial<Message>> {
237+
// 转换消息格式 (复用现有逻辑)
238+
const formattedMessages: Array<OpenAI.Chat.ChatCompletionMessageParam> = [];
239+
240+
// 添加系统提示
241+
if (this.systemPrompt) {
242+
formattedMessages.push({
243+
role: 'system',
244+
content: this.systemPrompt,
245+
});
246+
}
247+
248+
// 添加用户提供的消息
249+
formattedMessages.push(
250+
...messages.map(msg => {
251+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
252+
const formattedMsg: any = {
253+
role: msg.role,
254+
content: msg.content,
255+
};
256+
257+
if (msg.role === 'tool' && msg.tool_call_id) {
258+
formattedMsg.tool_call_id = msg.tool_call_id;
259+
}
260+
261+
return formattedMsg;
262+
})
263+
);
264+
265+
// 创建请求参数
266+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
267+
const requestParams: any = {
268+
model: this.model,
269+
messages: formattedMessages,
270+
temperature: this.temperature,
271+
max_tokens: this.maxTokens,
272+
top_p: this.topP,
273+
stream: true, // 开启流式模式
274+
};
275+
276+
// 决定使用哪些工具
277+
const toolsToUse = options?.temporaryTools || this.tools;
278+
279+
// 如果有工具定义,添加到请求中
280+
if (toolsToUse && toolsToUse.length > 0) {
281+
const formattedTools = toolsToUse.map(tool => {
282+
type ToolType = {
283+
type?: string;
284+
name?: string;
285+
description?: string;
286+
parameters?: Record<string, unknown>;
287+
};
288+
289+
const typedTool = tool as ToolType;
290+
291+
if (typedTool.type === 'function') {
292+
return tool;
293+
}
294+
295+
return {
296+
type: 'function',
297+
function: {
298+
name: typedTool.name,
299+
description: typedTool.description,
300+
parameters: typedTool.parameters || {},
301+
},
302+
};
303+
});
304+
305+
requestParams.tools = formattedTools;
306+
}
307+
308+
// 调用流式 API
309+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
310+
const stream = (await this.client.chat.completions.create(requestParams)) as any;
311+
312+
// 处理流式响应
313+
for await (const chunk of stream) {
314+
const delta = chunk.choices[0]?.delta;
315+
316+
if (delta?.content) {
317+
// 流式文本内容
318+
yield {
319+
role: 'assistant',
320+
content: delta.content,
321+
};
322+
}
323+
324+
if (delta?.tool_calls) {
325+
// 流式工具调用(如果需要)
326+
yield {
327+
role: 'assistant',
328+
content: '',
329+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
330+
tool_calls: delta.tool_calls.map((toolCall: any) => ({
331+
id: toolCall.id || '',
332+
function: {
333+
name: toolCall.function?.name || '',
334+
arguments: toolCall.function?.arguments || '',
335+
},
336+
type: 'function',
337+
tool_name: toolCall.function?.name || '',
338+
arguments: JSON.parse(toolCall.function?.arguments || '{}'),
339+
})),
340+
};
341+
}
342+
}
343+
}
344+
227345
/**
228346
* 处理对话消息
229347
* @param messages 对话消息数组

0 commit comments

Comments
 (0)