摘要

在 AI 对话应用中,流式响应(Streaming Response)已成为标配。但如何将 AI 返回的流式数据与前端表单实时同步,并优雅地处理视图切换?本文将分享一个完整的解决方案,包括流式字符串解析、增量数据更新、以及智能视图管理。


🎯 业务场景

在 AI 简历编辑器中,用户希望:

  1. 点击"翻译成英语",AI 逐步返回翻译结果
  2. 翻译过程中,实时看到简历数据的变化
  3. 翻译完成后,自动切换回预览视图
  4. 整个过程流畅、无卡顿、体验丝滑

挑战:

  • ❌ AI 返回的是流式文本,如何解析为结构化数据?
  • ❌ 如何避免频繁更新导致的性能问题?
  • ❌ 如何在流式更新过程中管理视图状态?

🏗️ 架构设计

ResumeChatCopilot (AI 对话组件)
  ↓
onSend() → bufferStringHandler.onBegin()
  ↓
onRequest() → AI API 流式返回
  ↓
handleAiUpdate() → bufferStringHandler.onChunk()
  ↓
setState() → 增量更新 resumeJsonData
  ↓
handleAiMessage() → 检测代码更新
  ↓
bufferStringHandler.onFinish() → 完成

🛠️ 核心实现

1️⃣ 流式字符串处理器:useBufferStringHandler

这是整个方案的核心,负责将 AI 返回的流式文本解析为结构化数据。

export function useBufferStringHandler<T>({
  setState,
  onBeginProcess,
  onFinishProcess,
}) {
  const [isDataRegion, setIsDataRegion] = useState(false);
  
  const stateRef = useRef({
    buffer: '',           // 未解析的残余字符串
    isDataRegion: false,  // 是否进入数据区
  });

  // 开始处理
  const onBegin = useStableCallback(() => {
    stateRef.current = { buffer: '', isDataRegion: false };
    setIsDataRegion(false);
  });

  // 核心:处理每个 chunk
  const onChunk = useStableCallback((chunkMessage: string) => {
    // A. 累加新数据
    stateRef.current.buffer += chunkMessage;
    const ref = stateRef.current;

    // B. 检测开始标记
    if (!ref.isDataRegion) {
      const startIndex = ref.buffer.indexOf('/*---DataStart---*/');
      if (startIndex !== -1) {
        ref.isDataRegion = true;
        setIsDataRegion(true);
        onBeginProcess?.();
        ref.buffer = ref.buffer.substring(startIndex + '/*---DataStart---*/'.length);
      }
    }

    // C. 在数据区内解析
    if (ref.isDataRegion) {
      const endIndex = ref.buffer.indexOf('/*---DataEnd---*/');

      // C1. 发现结束标记
      if (endIndex !== -1) {
        const finalContent = ref.buffer.substring(0, endIndex);
        processLines(finalContent);
        ref.isDataRegion = false;
        setIsDataRegion(false);
        onFinishProcess?.();
        ref.buffer = '';
        return;
      }

      // C2. 行级处理(只处理完整的行)
      const lastLineIndex = ref.buffer.lastIndexOf('\n');
      if (lastLineIndex !== -1) {
        const completeLines = ref.buffer.substring(0, lastLineIndex);
        ref.buffer = ref.buffer.substring(lastLineIndex + 1);
        processLines(completeLines);
      }
    }
  });

  return { onBegin, onFinish, onChunk, isDataRegion };
}

关键技术点:

  • 缓冲区管理:维护未解析的残余字符串
  • 标记位识别/*---DataStart---*//*---DataEnd---*/
  • 行级解析:只处理完整的行,避免截断
  • 性能优化:使用 useRef 避免闭包陷阱和不必要的重渲染

2️⃣ 增量数据更新:Immer + 路径解析

const processLines = useStableCallback((linesStr: string) => {
  const lines = linesStr.split('\n');
  setState(
    produce((draft) => {
      lines.forEach((line) => {
        const trimmed = line.trim();
        if (!trimmed || trimmed.startsWith('```')) return;

        // 解析 key=value 格式
        const match = trimmed.match(/^([^=]+)=(.*)$/);
        if (match) {
          const [, pathStr, value] = match;
          setValueByPath(draft, parsePath(pathStr.trim()), value.trim());
        }
      });
    })
  );
});

数据格式示例:

/*---DataStart---*/
basic.name=John Doe
basic.email=john@example.com
experience.0.company=Tech Corp
experience.0.role=Senior Engineer
experience.0.content.0=Led AI project
/*---DataEnd---*/

路径解析:

const parsePath = (pathStr: string) => {
  return pathStr.split('.').map((key) => 
    isNaN(Number(key)) ? key : Number(key)
  );
};

// "experience.0.company" → ["experience", 0, "company"]

深度设值:

const setValueByPath = (draft, path, value) => {
  let current = draft;
  for (let i = 0; i < path.length - 1; i++) {
    const key = path[i];
    if (!current[key]) {
      current[key] = typeof path[i + 1] === 'number' ? [] : {};
    }
    current = current[key];
  }
  current[path[path.length - 1]] = value;
};

3️⃣ 智能视图管理

const bufferStringHandler = useBufferStringHandler({
  setState: (dispatch) => {
    const value = form.getFieldValue('resumeJsonData');
    form.setFieldValue('resumeJsonData', dispatch(value));
  },
  onBeginProcess: () => {
    // AI 开始返回数据时:
    form.setFieldValue('resumeJsonData', {});  // 清空数据
    setViewMode('data');  // 切换到数据视图(避免频繁渲染)
  },
  onFinishProcess: () => {
    // AI 返回完成时:
    setViewMode('preview');  // 切换回预览视图
    notification.success({ description: '已经更新完毕!' });
  },
});

视图切换逻辑:

用户点击"翻译"
  ↓
onBeginProcess() → 切换到 data 视图
  ↓
AI 流式返回数据
  ↓
onChunk() → 实时更新表单数据(data 视图下看不到,避免闪烁)
  ↓
onFinishProcess() → 切换到 preview 视图
  ↓
用户看到完整翻译后的简历

4️⃣ AI 对话组件集成

<ResumeChatCopilot
  ref={copilotRef}
  systemPrompt={systemPrompt}
  handleAiMessage={handleAiMessage}
  onSend={bufferStringHandler.onBegin}
  handleAiUpdate={bufferStringHandler.onChunk}
  toolContent={
    <OptionButton options={languageOptions}>
      智能翻译
    </OptionButton>
  }
/>

暴露 send 方法:

useImperativeHandle(ref, () => ({
  send: handleUserSubmit  // 父组件可通过 ref 调用
}));

5️ 代码更新检测

AI 有时需要更新组件代码,而不仅仅是数据:

const handleAiMessage = useStableCallback((message: string) => {
  bufferStringHandler.onFinish();
  
  const startTag = '/*---CodeStart---*/';
  const endTag = '/*---CodeEnd---*/';
  
  if (message.indexOf(endTag) > -1) {
    const startIndex = message.indexOf(startTag);
    const endIndex = message.indexOf(endTag);
    let code = message.substring(
      startIndex + startTag.length, 
      endIndex
    ).trim();
    
    // 去除 markdown 代码块标记
    if (code.startsWith('```tsx')) {
      code = code.substring(7, code.lastIndexOf('```'));
    }
    
    form.setFieldValue('sourceCode', code);
  }
});

🎨 完整流程图

1. 用户点击"翻译成 English"
  ↓
2. copilotRef.current?.send("翻译成 英语...")
  ↓
3. handleUserSubmit(question)
  ├─ props.onSend(question) → bufferStringHandler.onBegin()
  │   ├─ 清空 resumeJsonData
  │   └─ setViewMode('data')
  ─ onRequest({ messages: [userMessage] })
  ↓
4. AI 流式返回响应
  ↓
5. handleAiUpdate(chunk) → bufferStringHandler.onChunk()
  ├─ 检测 /*---DataStart---*/
  ├─ 解析 key=value 格式
  └─ produce() 增量更新状态
  ↓
6. handleAiMessage(message) → bufferStringHandler.onFinish()
  ├─ 检测 /*---CodeStart---*/
  └─ 更新 sourceCode(如需要)
  ↓
7. bufferStringHandler.onFinishProcess()
  ├─ setViewMode('preview')
  └─ notification.success('已经更新完毕!')

💡 技术亮点

特性说明
流式解析实时处理 AI 返回的文本块
增量更新使用 Immer 高效更新嵌套数据
智能视图自动切换视图避免性能问题
缓冲区管理处理不完整的行,避免数据截断
双模式支持同时支持数据更新和代码更新
性能优化useRef 避免闭包陷阱

🚀 应用场景

  1. AI 翻译:多语言简历实时翻译
  2. AI 修改:根据指令更新简历内容
  3. AI 生成:从自然语言生成结构化数据
  4. 实时协作:多人同时编辑同一文档

️ 注意事项

  • 标记位设计:确保 AI 输出包含开始和结束标记
  • 错误处理:AI 可能输出格式错误的数据,需要容错
  • 性能优化:频繁更新时考虑防抖或节流
  • 用户体验:流式更新过程中显示加载状态

📚 参考资源



PatWu16
82 声望6 粉丝

仰望星空,脚踏实地