前端框架现代网页应用开发从需求拆出验证点

很多关于 Next.js 和 AI 结合的讨论,总是停留在调用一个现成 API 然后用 useState 渲染一串文本。然而,真实业务场景要复杂得多:当用户选择了一份 50 页的 PDF 文档并提问时,你需要从客户端高效抽取上下文,在 Server 端完成切片与向量检索,最后把 AI 流式生成的结构化响应实时推回前端界面,同时保证组件渲染不卡顿、状态不丢失。

我们以一个典型的“文档智能检索与多维分析面板”真实任务为例,看一看在 Next.js App Router 架构下,如何科学拆分组件职责,并设计最小可运行的数据编排流程。


最小架构模型与组件职责解耦

构建一个基于上下文增强(RAG)的 AI Web 应用,最大的难点往往在于数据流向的混乱。如果把向量检索、流式 HTTP 连接管理、Markdown 渲染以及交互按钮全都塞进一个巨大 Client Component 里,代码很快就会崩溃。

合理的做法是将架构分为三层:上下文供给层(Server Component)、流式状态管理层(Custom Hook / Provider)以及纯粹的渲染组件(Client Component)。

在上述拆分中:

  1. page.tsx 作为 React Server Component (RSC),负责在服务器端读取权限、加载文档静态元数据,杜绝客户端加载时的页面抖动(Layout Shift)。
  2. ChatShell 作为交互容器,隔离客户端 hydration 范围。
  3. useRAGChat 处理 Server-Sent Events (SSE) 流式传输,将数据流解析为原子增量状态。

核心流式编排与组件实现

以下是完整可运行的 Next.js 14+ (App Router) 核心代码片段,包含了 Server Action 状态调度与基于 Fetch ReadableStream 的自定义流处理 Hook。

1. 客户端流式管理 Custom Hook

// hooks/useRAGChat.ts
"use client";

import { useState, useCallback, useRef } from "react";

export interface Message {
  id: string;
  role: "user" | "assistant" | "system";
  content: string;
  sources?: Array<{ docId: string; snippet: string; score: number }>;
}

export function useRAGChat(documentId: string) {
  const [messages, setMessages] = useState<Message[]>([]);
  const [isGenerating, setIsGenerating] = useState(false);
  const abortControllerRef = useRef<AbortController | null>(null);

  const sendMessage = useCallback(async (prompt: string) => {
    if (!prompt.trim() || isGenerating) return;

    const userMsgId = `user-${Date.now()}`;
    const assistantMsgId = `assistant-${Date.now()}`;

    const userMessage: Message = { id: userMsgId, role: "user", content: prompt };
    setMessages((prev) => [...prev, userMessage]);
    
    setIsGenerating(true);
    abortControllerRef.current = new AbortController();

    // 占位 Assistant 消息
    setMessages((prev) => [
      ...prev,
      { id: assistantMsgId, role: "assistant", content: "", sources: [] }
    ]);

    try {
      const response = await fetch("/api/chat/stream", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        signal: abortControllerRef.current.signal,
        body: JSON.stringify({ documentId, prompt }),
      });

      if (!response.ok || !response.body) {
        throw new Error(`HTTP Error: ${response.status}`);
      }

      const reader = response.body.getReader();
      const decoder = new TextDecoder();
      let done = false;
      let accumulatedText = "";

      while (!done) {
        const { value, done: streamDone } = await reader.read();
        done = streamDone;
        if (value) {
          const chunk = decoder.decode(value, { stream: true });
          
          // 解析自定义协议帧 (数据块与 Sources 元数据)
          const lines = chunk.split("\n\n");
          for (const line of lines) {
            if (line.startsWith("data: ")) {
              const dataStr = line.replace("data: ", "").trim();
              if (dataStr === "[DONE]") break;

              try {
                const parsed = JSON.parse(dataStr);
                if (parsed.type === "sources") {
                  // 更新匹配到的文档引用源
                  setMessages((prev) =>
                    prev.map((msg) =>
                      msg.id === assistantMsgId
                        ? { ...msg, sources: parsed.payload }
                        : msg
                    )
                  );
                } else if (parsed.type === "text") {
                  accumulatedText += parsed.payload;
                  setMessages((prev) =>
                    prev.map((msg) =>
                      msg.id === assistantMsgId
                        ? { ...msg, content: accumulatedText }
                        : msg
                    )
                  );
                }
              } catch (e) {
                // 忽略非 JSON 帧的纯文本增量
                accumulatedText += dataStr;
                setMessages((prev) =>
                  prev.map((msg) =>
                    msg.id === assistantMsgId
                      ? { ...msg, content: accumulatedText }
                      : msg
                  )
                );
              }
            }
          }
        }
      }
    } catch (err: any) {
      if (err.name !== "AbortError") {
        setMessages((prev) => [
          ...prev,
          { id: `err-${Date.now()}`, role: "system", content: `请求中断: ${err.message}` }
        ]);
      }
    } finally {
      setIsGenerating(false);
      abortControllerRef.current = null;
    }
  }, [documentId, isGenerating]);

  const stopGeneration = useCallback(() => {
    if (abortControllerRef.current) {
      abortControllerRef.current.abort();
    }
  }, []);

  return { messages, isGenerating, sendMessage, stopGeneration };
}

2. 交互面板渲染组件

// components/ChatPanel.tsx
"use client";

import React, { useState } from "react";
import { useRAGChat } from "@/hooks/useRAGChat";

interface ChatPanelProps {
  documentId: string;
  initialTitle: string;
}

export function ChatPanel({ documentId, initialTitle }: ChatPanelProps) {
  const { messages, isGenerating, sendMessage, stopGeneration } = useRAGChat(documentId);
  const [inputPrompt, setInputPrompt] = useState("");

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (!inputPrompt.trim()) return;
    sendMessage(inputPrompt);
    setInputPrompt("");
  };

  return (
    <div className="flex flex-col h-screen max-w-4xl mx-auto p-4 border rounded-lg shadow-sm">
      <header className="pb-4 mb-4 border-b">
        <h1 className="text-xl font-bold text-gray-800">文档研读助手: {initialTitle}</h1>
        <span className="text-xs text-gray-500">Document ID: {documentId}</span>
      </header>

      <div className="flex-1 overflow-y-auto space-y-4 mb-4 pr-2">
        {messages.map((msg) => (
          <div
            key={msg.id}
            className={`p-3 rounded-md ${
              msg.role === "user"
                ? "bg-blue-50 ml-auto max-w-[80%]"
                : msg.role === "assistant"
                ? "bg-gray-100 mr-auto max-w-[90%]"
                : "bg-red-50 text-red-600 text-center"
            }`}
          >
            <div className="text-xs font-semibold mb-1 text-gray-600">
              {msg.role === "user" ? "用户" : msg.role === "assistant" ? "AI 助手" : "系统通知"}
            </div>
            <div className="whitespace-pre-wrap text-sm leading-relaxed">{msg.content}</div>

            {msg.sources && msg.sources.length > 0 && (
              <div className="mt-2 pt-2 border-t border-gray-200 text-xs text-gray-500">
                <span className="font-medium">参考切片:</span>
                <ul className="list-disc pl-4 mt-1 space-y-1">
                  {msg.sources.map((src, idx) => (
                    <li key={idx} className="truncate">
                      [{src.docId}] {src.snippet} (置信度: {(src.score * 100).toFixed(1)}%)
                    </li>
                  ))}
                </ul>
              </div>
            )}
          </div>
        ))}
      </div>

      <form onSubmit={handleSubmit} className="flex gap-2 border-t pt-3">
        <input
          type="text"
          value={inputPrompt}
          onChange={(e) => setInputPrompt(e.target.value)}
          placeholder="针对当前文档提问..."
          className="flex-1 px-3 py-2 border rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
          disabled={isGenerating}
        />
        {isGenerating ? (
          <button
            type="button"
            onClick={stopGeneration}
            className="px-4 py-2 bg-red-500 text-white text-sm font-medium rounded-md hover:bg-red-600"
          >
            停止
          </button>
        ) : (
          <button
            type="submit"
            className="px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700"
          >
            发送
          </button>
        )}
      </form>
    </div>
  );
}

边界处理与状态隔离原则

在开发此类 AI 增强应用时,最典型的技术误区是把全量对话历史存在单个组件内部的 state 里,导致每次打字吐字都引发整个组件树重新渲染。

为了保证生产环境的流畅度,需要遵守三个边界原则:

1. UI 渲染与流解码隔离

将 SSE 接收解码的逻辑封装在 Custom Hook(如 useRAGChat)中,不要在组件内部直接写 fetchreader.read() 循环。组件只依赖 Hook 露出的只读数组。

2. 服务器侧与客户端拆分边界

不要尝试把向量数据库客户端(如 Pinecone 或 Qdrant SDK)直接引入 Client Components。所有的检索动作只能在 Next.js 的 /api/chat/stream Route Handler 或 Server Actions 中发生,客户端仅传递 documentIdprompt 参数。

3. 错误恢复机制

智能大模型接口很容易发生 Timeout 或 504 错误。组件层应当支持 AbortController 手动中断,并且在网络中断时保留已经接收到的前文增量,而不是整个消息单元变成空白。

通过这种由真实任务倒推架构的设计方式,可以在保证应用代码可读性的同时,轻松支撑起企业级复杂的智能交互诉求。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐