前端 + AI 进阶 Day 3:打字机效果 + 流式交互控制

前端 + AI 进阶学习路线|Week 1-2:流式体验优化

Day 3:打字机效果 + 流式交互控制

学习时间:2025年12月27日(星期六)
关键词:打字机效果、流式控制、暂停/继续、回退、自动滚动


🎯 今日学习目标

  1. 实现可交互的“打字机”流式输出(逐字/逐 token)
  2. 支持 暂停 / 继续 / 回退 三种用户控制操作
  3. 将流式消息与 虚拟列表聊天界面 完整集成
  4. 实现智能 自动滚动(用户查看历史时不强行拉回底部)

💡 为什么需要流式交互控制?

在真实 AI 聊天场景中,用户需求是动态的:

  • 暂停输出,仔细阅读当前内容
  • 误触发送后希望回退(撤销正在生成的 AI 回复)
  • 网络慢时希望手动继续生成

❌ 若只有“自动流式”,体验是单向、不可控
✅ 加入交互控制,才能实现以人为本的 AI 对话


📚 核心设计思路

功能 实现方式
打字机效果 useState 逐步拼接文本,useEffect + setInterval 控制节奏
暂停/继续 isStreaming 状态控制定时器启停
回退(撤销) 清除当前流式消息,恢复到上一条完整消息
自动滚动 监听滚动位置,仅在用户未手动滚动时自动到底部

⚠️ 关键难点:滚动控制 —— 不能在用户查看历史时强行拉回底部!


🔧 动手实践:构建可交互的流式聊天组件

步骤 1:安装依赖(复用 Day 1 & Day 2)

npx create-react-app day03-streaming-control
cd day03-streaming-control
npm install react-virtual react-markdown remark-gfm rehype-highlight rehype-katex katex

💡 确保在组件中引入 KaTeX CSS(已在 StreamingMarkdown 中处理)

步骤 2:复用并升级组件

复用文件

  • src/components/StreamingMarkdown.jsx(Day 2)
  • src/components/VirtualChatList.jsx(Day 1,升级为动态高度)
升级 VirtualChatList.jsx(支持动态高度)
// src/components/VirtualChatList.jsx
import { useVirtual } from 'react-virtual';
import { useRef } from 'react';
import StreamingMarkdown from './StreamingMarkdown';

const VirtualChatList = ({ messages }) => {
  const parentRef = useRef(null);

  const virtualizer = useVirtual({
    size: messages.length,
    parentRef,
    estimateSize: () => 80, // 初始估算
    overscan: 5,
    measureElement: (el) => el?.getBoundingClientRect().height || 60,
  });

  return (
    <div
      ref={parentRef}
      style={{
        height: '100%',
        overflow: 'auto',
        position: 'relative',
      }}
    >
      <div
        style={{
          height: `${virtualizer.totalSize}px`,
          width: '100%',
          position: 'relative',
        }}
      >
        {virtualizer.virtualItems.map((virtualRow) => {
          const message = messages[virtualRow.index];
          return (
            <div
              key={virtualRow.index}
              ref={virtualizer.measureElement}
              data-index={virtualRow.index}
              style={{
                position: 'absolute',
                top: 0,
                left: 0,
                width: '100%',
                transform: `translateY(${virtualRow.start}px)`,
                padding: '12px 16px',
                borderBottom: '1px solid #f5f5f5',
                boxSizing: 'border-box',
              }}
            >
              <div style={{ 
                fontWeight: '600', 
                color: message.role === 'user' ? '#1890ff' : '#52c41a',
                marginBottom: '6px'
              }}>
                {message.role === 'user' ? '👤 用户' : '🤖 AI'}
              </div>
              <div>
                <StreamingMarkdown content={message.content} />
              </div>
            </div>
          );
        })}
      </div>
    </div>
  );
};

export default VirtualChatList;

步骤 3:编写主聊天组件(含控制逻辑)

// src/components/StreamingChat.jsx
import { useState, useRef, useEffect } from 'react';
import VirtualChatList from './VirtualChatList';

const StreamingChat = () => {
  // 消息历史(含初始欢迎消息)
  const [messages, setMessages] = useState([
    { id: 0, role: 'assistant', content: '你好!我是你的 AI 助手。你可以问我任何问题,我会逐字回答。' }
  ]);

  // 流式状态
  const [streamingMessage, setStreamingMessage] = useState('');
  const [isStreaming, setIsStreaming] = useState(false);
  const [streamIndex, setStreamIndex] = useState(0);
  const [isManualScrolling, setIsManualScrolling] = useState(false);

  // 模拟 AI 响应内容(含 Markdown)
  const fullAIResponse = `# 流式渲染(Streaming Rendering)

流式渲染是一种**逐步输出内容**的技术,特别适合 AI 聊天场景。

## 为什么需要流式?
- 用户无需等待完整响应
- 提升感知性能
- 支持**打字机效果**

## 代码示例
\`\`\`js
// 模拟流式输出
let index = 0;
const interval = setInterval(() => {
  if (index < response.length) {
    setDisplayedText(response.slice(0, ++index));
  } else {
    clearInterval(interval);
  }
}, 20);
\`\`\`

## 数学公式
流式也可渲染公式:$\\sum_{i=1}^{n} x_i = X$

欢迎体验!`;

  const chatContainerRef = useRef(null);
  const streamingIntervalRef = useRef(null);
  const streamingMessageRef = useRef('');

  // 监听滚动,判断用户是否在查看历史
  const handleScroll = () => {
    if (!chatContainerRef.current) return;
    const { scrollTop, scrollHeight, clientHeight } = chatContainerRef.current;
    const isAtBottom = scrollHeight - scrollTop - clientHeight < 50; // 50px 容差
    setIsManualScrolling(!isAtBottom);
  };

  // 自动滚动到底部(仅当用户未手动滚动)
  useEffect(() => {
    if (chatContainerRef.current && !isManualScrolling) {
      chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight;
    }
  }, [messages, streamingMessage, isManualScrolling]);

  // 保持最新 streamingMessage(避免闭包 stale 问题)
  useEffect(() => {
    streamingMessageRef.current = streamingMessage;
  }, [streamingMessage]);

  // 启动流式输出
  const startStreaming = () => {
    setStreamingMessage('');
    setStreamIndex(0);
    setIsStreaming(true);
  };

  // 暂停
  const pauseStreaming = () => {
    setIsStreaming(false);
    if (streamingIntervalRef.current) {
      clearInterval(streamingIntervalRef.current);
    }
  };

  // 继续
  const resumeStreaming = () => {
    setIsStreaming(true);
  };

  // 回退(撤销当前 AI 消息)
  const rollbackStreaming = () => {
    pauseStreaming();
    setStreamingMessage('');
    setStreamIndex(0);
    // 移除最后一条(不完整的 AI 消息)
    if (messages.length > 0 && messages[messages.length - 1].role === 'assistant') {
      setMessages(prev => prev.slice(0, -1));
    }
  };

  // 模拟发送消息
  const handleSend = () => {
    const newUserMsg = { 
      id: Date.now(), 
      role: 'user', 
      content: '再讲讲交互控制?' 
    };
    setMessages(prev => [...prev, newUserMsg]);
    setTimeout(() => startStreaming(), 300);
  };

  // 核心流式逻辑
  useEffect(() => {
    if (!isStreaming || streamIndex >= fullAIResponse.length) {
      if (streamIndex > 0) {
        // 流结束,保存完整消息
        setMessages(prev => [
          ...prev,
          { id: Date.now(), role: 'assistant', content: streamingMessageRef.current }
        ]);
        setStreamingMessage('');
        setStreamIndex(0);
        setIsStreaming(false);
      }
      return;
    }

    streamingIntervalRef.current = setInterval(() => {
      setStreamIndex(prev => {
        const next = prev + 1;
        setStreamingMessage(fullAIResponse.slice(0, next));
        return next;
      });
    }, 30); // 每 30ms 输出一个字符

    return () => {
      if (streamingIntervalRef.current) {
        clearInterval(streamingIntervalRef.current);
      }
    };
  }, [isStreaming, streamIndex]);

  // 构建渲染消息列表(含流式中消息)
  const renderMessages = [...messages];
  if (streamingMessage) {
    renderMessages.push({
      id: 'streaming',
      role: 'assistant',
      content: streamingMessage,
    });
  }

  return (
    <div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
      {/* 聊天消息区域 */}
      <div
        ref={chatContainerRef}
        onScroll={handleScroll}
        style={{
          flex: 1,
          overflow: 'auto',
          border: '1px solid #e8e8e8',
          borderRadius: '8px',
          padding: '8px',
          marginBottom: '16px',
          backgroundColor: '#fff',
        }}
      >
        <VirtualChatList messages={renderMessages} />
      </div>

      {/* 控制按钮 */}
      <div style={{ display: 'flex', gap: '10px', flexWrap: 'wrap' }}>
        {!isStreaming && streamIndex === 0 && (
          <button
            onClick={handleSend}
            style={{
              padding: '8px 16px',
              backgroundColor: '#1890ff',
              color: 'white',
              border: 'none',
              borderRadius: '6px',
              cursor: 'pointer',
            }}
          >
            发送测试消息
          </button>
        )}

        {isStreaming ? (
          <button
            onClick={pauseStreaming}
            style={{
              padding: '8px 16px',
              backgroundColor: '#faad14',
              color: 'white',
              border: 'none',
              borderRadius: '6px',
              cursor: 'pointer',
            }}
          >
            ⏸ 暂停
          </button>
        ) : streamIndex > 0 && streamIndex < fullAIResponse.length ? (
          <button
            onClick={resumeStreaming}
            style={{
              padding: '8px 16px',
              backgroundColor: '#52c41a',
              color: 'white',
              border: 'none',
              borderRadius: '6px',
              cursor: 'pointer',
            }}
          >
            ▶ 继续
          </button>
        ) : null}

        {streamIndex > 0 && (
          <button
            onClick={rollbackStreaming}
            style={{
              padding: '8px 16px',
              backgroundColor: '#ff4d4f',
              color: 'white',
              border: 'none',
              borderRadius: '6px',
              cursor: 'pointer',
            }}
          >
            ↩ 回退
          </button>
        )}
      </div>
    </div>
  );
};

export default StreamingChat;

步骤 4:在 App 中集成

// src/App.jsx
import 'katex/dist/katex.min.css'; // 必须引入
import StreamingChat from './components/StreamingChat';

function App() {
  return (
    <div style={{ 
      padding: '20px', 
      fontFamily: 'Inter, -apple-system, sans-serif', 
      maxWidth: '800px', 
      margin: '0 auto',
      height: '90vh'
    }}>
      <h1 style={{ textAlign: 'center', fontSize: '24px', fontWeight: '700', color: '#333' }}>
        🎥 打字机效果 + 流式交互控制
      </h1>
      <p style={{ textAlign: 'center', color: '#666', marginBottom: '20px' }}>
        支持暂停、继续、回退,智能滚动
      </p>
      <StreamingChat />
    </div>
  );
}

export default App;

✅ 效果验证

  • 点击“发送测试消息” → AI 开始逐字输出(含代码/公式/表格)
  • 点击 ⏸ 暂停 → 输出停止
  • 点击 ▶ 继续 → 从断点继续
  • 点击 ↩ 回退 → 移除当前 AI 消息
  • 滚动查看历史 → 不会自动拉回底部
  • 新消息完成 → 自动平滑滚动到底部

🤔 思考与延伸

  1. 性能优化:频繁 setState 触发重渲染,是否可合并?
    → 可尝试 useReducer + requestAnimationFrame 优化更新频率

  2. 真实 API 对接:如何将模拟数据替换为 fetchEventSource(SSE)?
    → Day 4 将接入 Ollama / OpenAI 真实流式接口

  3. 多消息流式:能否同时流式多个 AI 消息?(如 Agent 分步思考)
    → 需要更复杂的状态管理(按消息 ID 区分流)

提示:为提升用户体验,可将“继续”按钮逻辑改为从断点续传(而非重新请求),但需后端支持。


📅 明日预告

Day 4:接入真实 AI 流式接口(SSE)

  • 使用 fetchEventSource 消费 OpenAI / Ollama 的 SSE 流
  • 动态解析 token 并渲染
  • 错误重试 + 连接状态提示

✍️ 小结

今天,我们不仅让 AI “说话”,还让它能“听话”——用户可以随时暂停、继续、回退,真正掌握对话节奏。结合虚拟列表与流式 Markdown,一个高性能、可交互的 AI 聊天界面已初具雏形。交互控制,是流式体验从“能用”到“好用”的关键跃迁。

💬 实践提示:如果自动滚动出现“抖动”,可增大容差值(如 50px → 80px)。欢迎分享你的交互控制设计!

posted @ 2025-12-26 15:19  XiaoZhengTou  阅读(106)  评论(0)    收藏  举报