前端 + AI 进阶 Day 10 富交互消息

前端 + AI 进阶学习路线|Week 9-10:对话式界面设计

Day 10:富交互消息

学习时间:2026年1月3日(星期六)
关键词:富交互消息、按钮/卡片/表单、消息流嵌入、AI 原生交互、action 触发


📁 项目文件结构

day10-rich-interactive-messages/
├── src/
│   ├── components/
│   │   ├── MessageRenderer.jsx      # 智能消息渲染器(支持富交互)
│   │   ├── ChatInput.jsx            # 聊天输入框(支持快捷操作)
│   │   └── SessionSidebar.jsx       # 复用 Day 9 的会话管理
│   ├── hooks/
│   │   └── useSessionManager.js     # 复用 Day 9 的会话 Hook
│   ├── lib/
│   │   └── aiClient.js              # 复用 Day 4 的 AI 客户端
│   └── App.jsx                      # 主应用集成
└── public/

✅ 本日核心:让 AI 消息不再只是文本,而是可交互的 UI 元素


🎯 今日学习目标

  1. 实现 消息流中嵌入按钮、卡片、表单
  2. 支持 用户点击按钮触发 AI 动作(如“重试”、“查看详情”)
  3. 构建 AI 原生交互范式(消息即界面)
  4. 与多会话系统无缝集成(复用 Day 9)

💡 为什么需要富交互消息?

传统聊天界面只能“看文字”,但 AI 可以:

  • 生成 带操作按钮的回复(如“是否保存此代码?” → ✅ 保存 / ❌ 取消)
  • 返回 结构化卡片(商品信息 + “加入购物车”)
  • 嵌入 表单(“请填写以下信息” → 输入框 + 提交)

消息即界面(Message-as-UI) 是 AI 原生应用的核心范式,超越传统聊天


📚 核心设计思路

功能 实现方式
消息类型扩展 `message.type = 'text'
动作触发 按钮点击 → 调用 onAction(actionType, payload)
状态管理 消息流中包含可交互元素,状态由会话管理器统一维护
安全渲染 富消息由前端解析(非 dangerouslySetInnerHTML

⚠️ 注意:AI 返回的富消息需为 结构化 JSON(非 HTML),前端安全渲染


🔧 动手实践:构建富交互消息系统

步骤 1:创建项目并复用 Day 9 组件

npx create-react-app day10-rich-interactive-messages
cd day10-rich-interactive-messages
# 复制 Day 9 的 hooks/ 和 components/SessionSidebar.jsx
# 安装依赖(同 Day 9)
npm install react-virtual react-markdown remark-gfm rehype-highlight rehype-katex katex @microsoft/fetch-event-source

步骤 2:扩展消息数据结构(在会话管理中)

更新 useSessionManager.js 中的 createNewSession

// 默认消息支持富交互
const createNewSession = (title = '新会话') => ({
  id: generateSessionId(),
  title,
  messages: [
    { 
      id: 'welcome', 
      role: 'assistant', 
      type: 'text',
      content: '你好!我是你的 AI 助手。我可以生成代码、回答问题,甚至提供交互式操作。试试问我:“帮我生成一个登录表单”?'
    }
  ],
  createdAt: Date.now(),
});

步骤 3:创建智能消息渲染器

// src/components/MessageRenderer.jsx
import React from 'react';
import StreamingMarkdown from './StreamingMarkdown'; // 复用 Day 2

const MessageRenderer = ({ message, onAction, isStreaming = false }) => {
  // 流式消息或普通文本
  if (message.type === 'text' || !message.type) {
    return <StreamingMarkdown content={message.content} />;
  }

  // 按钮消息
  if (message.type === 'button') {
    return (
      <div style={{ marginTop: '8px' }}>
        <div style={{ marginBottom: '8px' }}>
          <StreamingMarkdown content={message.content} />
        </div>
        <div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
          {message.buttons?.map((btn, idx) => (
            <button
              key={idx}
              onClick={() => onAction?.(btn.action, btn.payload)}
              style={{
                padding: '6px 12px',
                backgroundColor: btn.style === 'primary' ? '#1890ff' : '#f0f0f0',
                color: btn.style === 'primary' ? 'white' : '#333',
                border: '1px solid #d9d9d9',
                borderRadius: '4px',
                cursor: 'pointer',
                fontSize: '14px',
              }}
            >
              {btn.text}
            </button>
          ))}
        </div>
      </div>
    );
  }

  // 卡片消息
  if (message.type === 'card') {
    return (
      <div style={{
        border: '1px solid #e8e8e8',
        borderRadius: '8px',
        padding: '16px',
        backgroundColor: '#fafafa',
        marginTop: '8px',
      }}>
        <div style={{ fontWeight: '600', marginBottom: '8px' }}>
          {message.title}
        </div>
        <div style={{ marginBottom: '12px', fontSize: '14px' }}>
          <StreamingMarkdown content={message.content} />
        </div>
        {message.actions && (
          <div style={{ display: 'flex', gap: '8px' }}>
            {message.actions.map((action, idx) => (
              <button
                key={idx}
                onClick={() => onAction?.(action.type, action.payload)}
                style={{
                  padding: '6px 12px',
                  fontSize: '14px',
                  backgroundColor: '#52c41a',
                  color: 'white',
                  border: 'none',
                  borderRadius: '4px',
                  cursor: 'pointer',
                }}
              >
                {action.text}
              </button>
            ))}
          </div>
        )}
      </div>
    );
  }

  // 表单消息(简化版)
  if (message.type === 'form') {
    const [formData, setFormData] = React.useState({});
    
    const handleChange = (field, value) => {
      setFormData(prev => ({ ...prev, [field]: value }));
    };

    const handleSubmit = () => {
      onAction?.('submit_form', { formId: message.formId, data: formData });
    };

    return (
      <div style={{
        border: '1px solid #e8e8e8',
        borderRadius: '8px',
        padding: '16px',
        backgroundColor: '#fafafa',
        marginTop: '8 p
      }}>
        <div style={{ fontWeight: '600', marginBottom: '12px' }}>
          {message.title}
        </div>
        {message.fields?.map((field) => (
          <div key={field.name} style={{ marginBottom: '12px' }}>
            <div style={{ fontSize: '14px', marginBottom: '4px' }}>
              {field.label} {field.required && <span style={{ color: '#ff4d4f' }}>*</span>}
            </div>
            {field.type === 'text' && (
              <input
                type="text"
                value={formData[field.name] || ''}
                onChange={(e) => handleChange(field.name, e.target.value)}
                placeholder={field.placeholder}
                style={{
                  width: '100%',
                  padding: '8px',
                  border: '1px solid #d9d9d9',
                  borderRadius: '4px',
                }}
              />
            )}
            {field.type === 'textarea' && (
              <textarea
                value={formData[field.name] || ''}
                onChange={(e) => handleChange(field.name, e.target.value)}
                placeholder={field.placeholder}
                rows={3}
                style={{
                  width: '100%',
                  padding: '8px',
                  border: '1px solid #d9d9d9',
                  borderRadius: '4px',
                }}
              />
            )}
          </div>
        ))}
        <button
          onClick={handleSubmit}
          style={{
            padding: '8px 16px',
            backgroundColor: '#1890ff',
            color: 'white',
            border: 'none',
            borderRadius: '4px',
            cursor: 'pointer',
            fontSize: '14px',
          }}
        >
          提交
        </button>
      </div>
    );
  }

  // 默认回退到文本
  return <StreamingMarkdown content={message.content} />;
};

export default MessageRenderer;

步骤 4:在聊天窗口中处理动作

// src/components/ChatWindow.jsx(关键部分)
import { useState, useRef, useEffect } from 'react';
import VirtualChatList from './VirtualChatList';
import MessageRenderer from './MessageRenderer';
import { streamAIResponse } from '../lib/aiClient';

const ChatWindow = ({ session, onUpdateMessages }) => {
  const [inputValue, setInputValue] = useState('');
  const [streamingMessage, setStreamingMessage] = useState('');
  const [isStreaming, setIsStreaming] = useState(false);
  const chatContainerRef = useRef(null);

  // 处理 AI 返回的富消息(模拟)
  const handleAIResponse = (fullResponse) => {
    let messages = session.messages;

    // 模拟 AI 返回结构化消息
    if (fullResponse.includes('登录表单')) {
      const formMessage = {
        id: Date.now(),
        role: 'assistant',
        type: 'form',
        formId: 'login_form',
        title: '请填写登录信息',
        fields: [
          { name: 'email', label: '邮箱', type: 'text', required: true, placeholder: 'your@email.com' },
          { name: 'password', label: '密码', type: 'text', required: true, placeholder: '••••••••' }
        ]
      };
      messages = [...messages, formMessage];
    } else if (fullResponse.includes('代码')) {
      const buttonMessage = {
        id: Date.now(),
        role: 'assistant',
        type: 'button',
        content: '这是你请求的 React 组件代码:\n\n```jsx\nfunction Hello() {\n  return <h1>Hello World</h1>;\n}\n```',
        buttons: [
          { text: '✅ 保存到项目', action: 'save_code', payload: { code: 'function Hello() { return <h1>Hello World</h1>; }' }, style: 'primary' },
          { text: '🔄 重新生成', action: 'regenerate', payload: { prompt: inputValue }, style: 'default' }
        ]
      };
      messages = [...messages, buttonMessage];
    } else {
      // 普通文本消息
      messages = [...messages, { id: Date.now(), role: 'assistant', type: 'text', content: fullResponse }];
    }

    onUpdateMessages(messages);
  };

  // 处理用户动作(按钮点击/表单提交)
  const handleAction = (actionType, payload) => {
    const userActionMessage = {
      id: Date.now(),
      role: 'user',
      type: 'action',
      content: `[用户执行操作: ${actionType}]`,
      action: actionType,
      payload
    };
    
    const updatedMessages = [...session.messages, userActionMessage];
    onUpdateMessages(updatedMessages);

    // 模拟 AI 对动作的响应
    setTimeout(() => {
      let aiResponse = '';
      if (actionType === 'save_code') {
        aiResponse = '✅ 代码已保存到你的项目中!是否需要我帮你部署?';
      } else if (actionType === 'regenerate') {
        aiResponse = '🔄 正在重新生成代码...\n\n```jsx\nfunction Greeting({ name }) {\n  return <h1>Hello, {name}!</h1>;\n}\n```';
      } else if (actionType === 'submit_form') {
        aiResponse = `✅ 收到你的表单数据!邮箱: ${payload.data.email}。我们将尽快联系你。`;
      } else {
        aiResponse = '已收到你的操作。';
      }

      const aiMessage = {
        id: Date.now() + 1,
        role: 'assistant',
        type: 'text',
        content: aiResponse
      };
      onUpdateMessages([...updatedMessages, aiMessage]);
    }, 800);
  };

  // 发送消息逻辑(略,复用 Day 9)
  const handleSend = async () => { /* ... */ };

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

  return (
    <div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
      <div
        ref={chatContainerRef}
        style={{
          flex: 1,
          overflow: 'auto',
          padding: '16px',
        }}
      >
        <VirtualChatList 
          messages={renderMessages} 
          onAction={handleAction}
          isStreaming={isStreaming}
        />
      </div>
      
      {/* 聊天输入框(略) */}
    </div>
  );
};

// 更新 VirtualChatList 以传递 onAction
// VirtualChatList.jsx 中每个消息渲染:
// <MessageRenderer message={message} onAction={onAction} isStreaming={isStreaming} />

步骤 5:在 App.jsx 中集成

// src/App.jsx(复用 Day 9 结构,仅需确保 ChatWindow 支持 onAction)
import 'katex/dist/katex.min.css';
import { useSessionManager } from './hooks/useSessionManager';
import SessionSidebar from './components/SessionSidebar';
import ChatWindow from './components/ChatWindow';

function App() {
  const {
    sessions,
    currentSession,
    createSession,
    switchSession,
    updateSessionMessages,
    updateSessionTitle,
    deleteSession,
  } = useSessionManager();

  if (!currentSession) return <div style={{ padding: '20px' }}>加载中...</div>;

  return (
    <div style={{ display: 'flex', height: '100vh', fontFamily: 'Inter, -apple-system, sans-serif' }}>
      <SessionSidebar {...{ sessions, currentSessionId: currentSession.id, createSession, switchSession, updateSessionTitle, deleteSession }} />
      <div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
        <div style={{ padding: '16px 24px', borderBottom: '1px solid #e8e8e8', fontSize: '16px', fontWeight: '600' }}>
          {currentSession.title}
        </div>
        <ChatWindow 
          session={currentSession} 
          onUpdateMessages={updateSessionMessages} 
        />
      </div>
    </div>
  );
}

export default App;

✅ 效果验证

  • ✅ 输入“帮我生成一个登录表单” → 显示可填写表单
  • ✅ 输入“生成 React 代码” → 显示代码 + “保存”/“重新生成”按钮
  • ✅ 点击按钮 → 触发用户动作消息 + AI 响应
  • ✅ 表单提交 → 显示确认消息
  • ✅ 所有交互消息支持 Markdown(代码高亮/公式)
  • ✅ 与多会话系统无缝集成(不同会话独立状态)

🤔 思考与延伸

  1. AI 返回结构化消息:如何让 LLM 返回 JSON 而非纯文本?
    → 在 prompt 中指定格式,如“请以 JSON 格式返回,包含 type、content、buttons 字段”

  2. 安全性:如何防止恶意 payload?
    → 前端对 actionpayload 做白名单校验

  3. 扩展性:如何支持更多交互类型(如日历、地图)?
    → 在 MessageRenderer 中增加 case 分支

💡 为 Week 3 做准备:富交互消息可作为工作流节点的输入/输出组件


📅 明日预告

Day 11:语音输入/输出

  • 集成 Web Speech API 实现语音输入
  • AI 回答自动语音播报
  • 构建全语音 AI 交互体验

✍️ 小结

今天,我们让 AI 消息从“只读文本”进化为“可操作界面”!通过嵌入按钮、卡片、表单,用户可直接在消息流中完成操作,无需跳转。消息即界面,是 AI 原生应用的终极形态。

💬 实践提示:真实项目中,建议用 JSON Schema 校验 AI 返回的富消息结构。欢迎分享你的富交互消息设计!

posted @ 2025-12-30 09:44  XiaoZhengTou  阅读(11)  评论(0)    收藏  举报