本文记录了以express和react两种技术框架实现建议的 AI 聊天效果,下面分别展示前后端的核心编码。
后端代码(nodejs + express)
// sse.core.ts
...
/**
* 懒加载 OpenAI 客户端:未配置 API Key 时不在模块加载阶段崩溃,
* 而是在请求时返回明确的错误提示
*/
function createOpenAIClient() {
const apiKey = process.env.OPENAI_API_KEY;
if (!apiKey) {
throw new Error('未配置 OPENAI_API_KEY,请在 server/.env.development 中填写');
}
return new OpenAI({
apiKey,
baseURL: process.env.OPENAI_BASE_URL,
});
}
/* 后端发送sse的核心功能 */
async function sseHandler(req: Requset, res: Response) {
const { messages } = req.body;
if (!message || !Array.isArray(messages)) {
res.status(400).json({ message: '请求的数据需要是一个数组' });
return;
}
const abortController = new AbortController();
// 客户端断开时中止上游大模型请求,避免资源泄漏。
// 注意:不能用 req.on('close')——Node 16+ 起 IncomingMessage 的 'close' 语义是
// 「请求消息接收完毕」,express.json() 消费完 body 后就会立刻触发,正常请求也会被误判为断开,
// 导致上游请求被 abort(报错 "Request was aborted.")。只能监听 res,并用 writableEnded 区分
// 「正常结束」(true)和「客户端真的断开」(false)。
res.on('close', () => {
if (!res.writableEnded) abortControl.abort();
})
// 先建立上游连接:认证失败、未配置 key 等错误以普通 JSON 返回,
// 避免已 flush 的 SSE 头无法回传 HTTP 错误码
let stream: Awaited<ReturnType<ReturnType<typeof createOpenAIClient>['chat']['completions']['create']>>;
try{
const openai = createOpenAIClient();
stream = await openai.chat.completions.create({
model: process.env.OPENAI_MODEL ?? 'deepseek-chat',
messages,
stream: true,
})
}catch (err: any) {
return res.status(err.status ?? 500).json({ message: err.message ?? '上游模型请求失败' });
}
// SSE请求头的设置
res.setHeader('Content-type', 'text/event-stream')
res.setHeader('Connection', 'keep-alive')
res.setHeader('Cache-control', 'no-cache')
res.flushHeaders();
// 发送消息
try{
for (await chunk of stream) {
if (res.destory) break
res.write(`data: ${JSON.stringify(event)}\n\n`)
}
res.write('data: [DONE]\n\n')
}catch(error){
// 流中途出错:以标准 SSE error 事件通知客户端
if (!res.destroyed) {
res.write(`event: error\ndata: ${JSON.stringify({ error: err.message ?? 'stream error' })}\n\n`);
}
}finally {
if (!res.destroyed) res.end();
}
}
...
前端代码(react)
// useSSE.ts hook文件
type SSEOptions = {
url: string;
method?: string;
headers?: Record<string, string>;
}
type CallBackFoo = {
/** 收到一个 data 事件(已解析的 JSON) */
onChunk?: (data: unknown) => void;
/** 流正常结束(收到 [DONE] 或读取完毕),只触发一次 */
onDone?: () => void;
/** 出错:HTTP 非 2xx、服务端 error 事件、网络异常 */
onError?: (error: Error) => void;
}
const useSSE = (options: SSEOptions) => {
const { url, method = 'GET', headers } = options
const abortControllerRef = useRef<AbortController | null>(null);
const connection = useCallback((body?: BodyInit, callback?: CallBackFoo) => {
const { onChunk, onDone, onError } = callbacks ?? {};
const controller = new AbortController();
// 上一次连接若还在进行中,先断开
abortControllerRef.current?.abort();
abortControllerRef.current = controller;
const run = async () => {
try{
const response = await fetch(url, {
method,
headers,
body,
singal: controller.singal,
})
if (!response.ok) {
let message = `HTTP error! status: ${response.status}`;
try {
const data = await response.json();
if (data?.message) message = data.message;
} catch { /* 非 JSON 响应体则用默认错误信息 */ }
throw new Error(message);
}
if (!response.body) throw new Error('response body empty');
// 读取内容
const reader = await response.body.reader();
const decoder = new TextDecoder('utf-8');
let buff = '';
while(true) {
const { done, value } = await reader.read()
if (done) break
buff += decoder.decode(value, { stream: true });
const chunks = buff.split('\n\n')
buff = chunks.pop() ?? ''
for(const chunk of chunks) {
if (!chunk.trim()) continue
// 服务端的error事件
const eventMatch = chunk.match(/^event:\s*(\S+)/m)
if (eventMatch && eventMatch[1] === 'error') {
const dataMatch = chunk.match(/^data:\s*(.*)$/ms);
let message = 'SSE 服务端错误';
if (dataMatch) {
try { message = JSON.parse(dataMatch[1]).error ?? message; }
catch { /* ignore */ }
}
throw new Error(message);
}
// 普通 data 事件
const dataMatch = chunk.match(/^data:\s*(.*)$/ms);
if (!dataMatch) continue;
const payload = dataMatch[1];
if (payload === '[DONE]') {
onDone?.();
return;
}
try {
onChunk?.(JSON.parse(payload));
} catch { /* 忽略无法解析的数据 */ }
}
}
}
onDone?.();
}catch(error: any) {
// 主动 abort(disconnect / 发起新连接)不视为错误
if (error instanceof Error && error.name === 'AbortError') return;
onError?.(error instanceof Error ? error : new Error(String(error)));
}finally{
// 只清理属于本次连接的引用,避免误清掉后续新连接
if (abortControllerRef.current === controller) {
abortControllerRef.current = null;
}
}
run()
}, [url, method, headers])
const disconnect = useCallback(() => {
abortControllerRef.current?.abort();
abortControllerRef.current = null;
}, []);
return { connect, disconnect };
}
export default useFetchSSE;
// home.tsx中使用useSSE hook
const { connect, disconnect } = useFetchSSE({
url: '/api/sse/chat',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${getToken() ?? ''}`,
},
});
const onSend = () => { // 发送消息的监听事件
// 获取输入的值,并且构造一个openai的请求格式:
// [{role: user | assistant | system, content: inputContent}]
connect(
JSON.stringify({ messages: [{ role: 'user', content }] }), // 请求内容
{
onChunk: (data) => { // 获取回复的消息 },
onDone: () => { // 结束 },
onError: (error: any) => { // 获取出错时的消息 },
}
)
}