express+react实现简易的AI chat

2 阅读2分钟

本文记录了以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) => { // 获取出错时的消息 },
    }
  )
}