第35期 | AI Agent前端交互

🎯 今天你将学会

  • 实现 AI Agent 的前端交互:工具调用展示 + 思考过程可视化
  • 设计多轮对话中 Agent 的「思考 → 调用工具 → 观察结果 → 回答」循环界面
  • 实现工具调用的实时展示(搜索数据库、调用 API、执行操作)
  • 理解 Agent 交互的 UX 设计原则——让用户看到 AI 在做什么

📖 核心知识

Agent 不只是聊天:它有「思考-行动」循环

普通聊天 AI 的工作流很简单:用户问 → AI 回答。

Agent 的工作流是一个循环:

用户提问 → Agent 思考(我要做什么)→ 选择工具 → 执行工具 → 观察结果 →
再次思考(还需要做什么?)→ 选择工具 → 执行 → 观察 → ... → 最终回答

前端需要可视化这个循环——让用户看到 Agent 在思考什么、调用了什么工具、得到了什么结果。这不是为了炫酷,而是为了信任——用户看到 AI 的思考过程,才能相信它的回答。

Agent 交互的三种可视化模式

模式1:精简模式(默认)
只展示最终回答,中间的思考/工具调用折叠在「查看详情」中。

用户:帮我查一下订单 #12345 的状态

AI:订单 #12345 的当前状态是「已发货」,预计明天送达。
   [查看思考过程 ▼]

模式2:详细模式(可切换)
每一步思考、每次工具调用都展开显示。

用户:帮我查一下订单 #12345 的状态

💭 Agent 思考:用户要查订单状态,我需要调用 orders_search 工具

🔧 调用工具:orders_search
   参数:{ order_id: "12345" }
   结果:{ status: "shipped", eta: "2026-06-27" }

💭 Agent 思考:我拿到了订单信息,现在可以回答用户了

AI:订单 #12345 的当前状态是「已发货」,预计明天送达。

模式3:实时模式(推荐)
Agent 每一步都实时展示,像看直播一样。

用户:帮我查一下订单 #12345 的状态

💭 正在思考...                       ← 实时出现
🔧 正在调用 orders_search...         ← 实时出现
   ✓ 调用完成,耗时 1.2s              ← 实时出现
💭 正在生成回答...                   ← 实时出现

AI:订单 #12345 的当前状态是「已发货」,预计明天送达。

工具调用展示组件

// features/agent/components/ToolCall.tsx
interface ToolCall {
  id: string;
  name: string;       // 工具名称:orders_search / weather_query / calculator
  args: Record<string, unknown>;  // 调用参数
  result?: unknown;   // 返回结果
  status: 'running' | 'completed' | 'error';
  duration?: number;  // 执行耗时(ms)
  error?: string;
}

interface ToolCallProps {
  call: ToolCall;
  expanded?: boolean;
}

// 工具图标映射
const toolIcons: Record<string, { icon: string; label: string }> = {
  orders_search: { icon: '🔍', label: '搜索订单' },
  weather_query: { icon: '🌤', label: '查询天气' },
  calculator: { icon: '🧮', label: '计算' },
  web_search: { icon: '🌐', label: '网页搜索' },
  database_query: { icon: '💾', label: '数据库查询' },
};

export function ToolCall({ call, expanded = false }: ToolCallProps) {
  const toolInfo = toolIcons[call.name] || { icon: '🔧', label: call.name };
  const [isExpanded, setIsExpanded] = useState(expanded);

  return (
    <div className="my-2 rounded-lg border border-gray-200 bg-gray-50 dark:border-gray-600 dark:bg-gray-800">
      {/* 头部:工具名 + 状态 */}
      <div
        className="flex items-center justify-between px-3 py-2 cursor-pointer"
        onClick={() => setIsExpanded(!isExpanded)}
      >
        <div className="flex items-center gap-2 text-sm">
          <span>{toolInfo.icon}</span>
          <span className="font-medium">{toolInfo.label}</span>
          {call.status === 'running' && (
            <Loader2 size={14} className="animate-spin text-blue-500" />
          )}
          {call.status === 'completed' && (
            <CheckCircle size={14} className="text-green-500" />
          )}
          {call.status === 'error' && (
            <XCircle size={14} className="text-red-500" />
          )}
        </div>
        <div className="flex items-center gap-2 text-xs text-gray-400">
          {call.duration && <span>{call.duration}ms</span>}
          <ChevronDown size={14} className={isExpanded ? 'rotate-180' : ''} />
        </div>
      </div>

      {/* 展开内容:参数 + 结果 */}
      {isExpanded && (
        <div className="px-3 pb-3 text-sm">
          {/* 调用参数 */}
          <div className="mb-2">
            <div className="text-xs text-gray-500 mb-1">调用参数:</div>
            <pre className="bg-gray-100 rounded p-2 text-xs font-mono overflow-x-auto dark:bg-gray-900">
              {JSON.stringify(call.args, null, 2)}
            </pre>
          </div>

          {/* 返回结果 */}
          {call.result && (
            <div>
              <div className="text-xs text-gray-500 mb-1">返回结果:</div>
              <pre className="bg-gray-100 rounded p-2 text-xs font-mono overflow-x-auto dark:bg-gray-900">
                {typeof call.result === 'string'
                  ? call.result
                  : JSON.stringify(call.result, null, 2)}
              </pre>
            </div>
          )}

          {/* 错误信息 */}
          {call.error && (
            <div className="text-red-500 text-xs">{call.error}</div>
          )}
        </div>
      )}
    </div>
  );
}

思考过程可视化

// features/agent/components/ThinkingStep.tsx
interface ThinkingStep {
  id: string;
  content: string;    // Agent 的思考内容
  timestamp: string;
}

interface ThinkingStepProps {
  step: ThinkingStep;
}

export function ThinkingStep({ step }: ThinkingStepProps) {
  return (
    <div className="my-1 flex items-start gap-2 text-sm">
      <div className="w-5 h-5 rounded-full bg-yellow-100 flex items-center justify-center text-xs shrink-0">
        💭
      </div>
      <div className="text-gray-600 dark:text-gray-300 italic">
        {step.content}
      </div>
    </div>
  );
}

Agent 消息类型定义

Agent 的对话比普通聊天复杂——一条消息可能包含多种内容:

// features/agent/types/index.ts
interface AgentMessage {
  id: string;
  role: 'user' | 'assistant';

  // Agent 消息的内容是一个数组,每项可以是不同类型
  content: MessageContent[];
  timestamp: string;
}

// 内容类型定义
type MessageContent =
  | TextContent        // 普通文本
  | ThinkingContent    // 思考过程
  | ToolCallContent    // 工具调用
  | ToolResultContent; // 工具返回结果

interface TextContent {
  type: 'text';
  text: string;
}

interface ThinkingContent {
  type: 'thinking';
  thinking: string;
}

interface ToolCallContent {
  type: 'tool_call';
  toolCall: ToolCall;
}

interface ToolResultContent {
  type: 'tool_result';
  toolCallId: string;
  result: unknown;
}

Agent 消息渲染

// features/agent/components/AgentMessage.tsx
import { MarkdownRenderer } from '../../chat/components/MarkdownRenderer';
import { ToolCall } from './ToolCall';
import { ThinkingStep } from './ThinkingStep';

interface AgentMessageProps {
  message: AgentMessage;
  displayMode: 'compact' | 'detailed' | 'live';
}

export function AgentMessage({ message, displayMode }: AgentMessageProps) {
  // 根据显示模式过滤内容
  const visibleContent = getVisibleContent(message.content, displayMode);

  return (
    <div className="py-2">
      {visibleContent.map((item, idx) => {
        switch (item.type) {
          case 'text':
            return (
              <div key={idx} className="text-gray-800 dark:text-gray-200">
                <MarkdownRenderer content={item.text} />
              </div>
            );

          case 'thinking':
            return <ThinkingStep key={idx} step={{ id: `${idx}`, content: item.thinking, timestamp: '' }} />;

          case 'tool_call':
            return (
              <ToolCall
                key={idx}
                call={item.toolCall}
                expanded={displayMode === 'detailed' || displayMode === 'live'}
              />
            );

          case 'tool_result':
            return null; // tool_result 已经嵌套在 tool_call 中展示

          default:
            return null;
        }
      })}
    </div>
  );
}

// 根据显示模式决定展示哪些内容
function getVisibleContent(
  content: MessageContent[],
  mode: 'compact' | 'detailed' | 'live'
): MessageContent[] {
  switch (mode) {
    case 'compact':
      // 精简模式:只展示最终文本回答
      return content.filter(c => c.type === 'text');

    case 'detailed':
      // 详细模式:展示所有内容
      return content;

    case 'live':
      // 实时模式:展示所有内容(实时追加)
      return content;

    default:
      return content;
  }
}

显示模式切换

// features/agent/components/DisplayModeToggle.tsx
import { Eye, EyeOff, Radio } from 'lucide-react';

interface DisplayModeToggleProps {
  mode: 'compact' | 'detailed' | 'live';
  onChange: (mode: 'compact' | 'detailed' | 'live') => void;
}

const modes = [
  { value: 'compact', icon: EyeOff, label: '精简', desc: '只看最终回答' },
  { value: 'detailed', icon: Eye, label: '详细', desc: '查看每一步思考' },
  { value: 'live', icon: Radio, label: '实时', desc: '实时观看Agent执行' },
] as const;

export function DisplayModeToggle({ mode, onChange }: DisplayModeToggleProps) {
  return (
    <div className="flex gap-1 rounded-lg border border-gray-200 p-1 dark:border-gray-600">
      {modes.map((m) => (
        <button
          key={m.value}
          onClick={() => onChange(m.value)}
          className={`flex items-center gap-1 px-3 py-1 rounded text-sm transition-colors
            ${mode === m.value ? 'bg-blue-500 text-white' : 'text-gray-500 hover:bg-gray-100'}`}
        >
          <m.icon size={14} />
          {m.label}
        </button>
      ))}
    </div>
  );
}

Agent 流式响应的前端解析

Agent 的 SSE 流比普通聊天更复杂——每种内容类型有不同的 SSE 格式:

data: {"type": "thinking", "thinking": "用户要查订单,我需要调用 orders_search"}\n\n
data: {"type": "tool_call", "toolCall": {"name": "orders_search", "args": {...}, "status": "running"}}\n\n
data: {"type": "tool_result", "toolCallId": "xxx", "result": {...}}\n\n
data: {"type": "text", "content": "订单状态是..."}\n\n
data: [DONE]\n\n

前端解析逻辑:

// lib/agent-stream-parser.ts
export function parseAgentSSEStream(rawStream: ReadableStream<Uint8Array>) {
  const decoder = new TextDecoder();
  let buffer = '';

  return new ReadableStream<MessageContent>({
    async start(controller) {
      const reader = rawStream.getReader();

      try {
        while (true) {
          const { done, value } = await reader.read();
          if (done) break;

          buffer += decoder.decode(value, { stream: true });
          const lines = buffer.split('\n');
          buffer = lines.pop() || '';

          for (const line of lines) {
            if (!line.startsWith('data: ')) continue;
            const data = line.slice(6);
            if (data === '[DONE]') {
              controller.close();
              return;
            }

            try {
              const parsed = JSON.parse(data);
              switch (parsed.type) {
                case 'thinking':
                  controller.enqueue({
                    type: 'thinking',
                    thinking: parsed.thinking,
                  });
                  break;
                case 'tool_call':
                  controller.enqueue({
                    type: 'tool_call',
                    toolCall: parsed.toolCall,
                  });
                  break;
                case 'tool_result':
                  controller.enqueue({
                    type: 'tool_result',
                    toolCallId: parsed.toolCallId,
                    result: parsed.result,
                  });
                  break;
                case 'text':
                  controller.enqueue({
                    type: 'text',
                    text: parsed.content,
                  });
                  break;
              }
            } catch {
              // 无法解析的行
            }
          }
        }
      } catch (error) {
        controller.error(error);
      }
    },
  });
}

常见误区

误区1:只展示最终回答
Agent 的核心价值不只是结果,更是过程。用户需要看到 AI 思考了什么、调用了什么工具、为什么得出这个结论。

误区2:所有内容默认展开
展开所有思考过程会让界面很乱。默认精简模式,用户想了解细节时切换到详细模式。

误区3:工具调用信息对用户没用
工具调用展示建立信任——用户看到 AI 真的搜索了数据库、调用了 API,才能相信回答不是编造的。

🤖 AI协作实战

实战场景:设计 Agent 交互的完整 UX

我给 AI 的 prompt:

设计一个 AI Agent 技术助手的完整交互界面。

功能需求:
- 用户提问后,Agent 会思考、搜索知识库、调用工具、最终回答
- 三种显示模式切换(精简/详细/实时)
- 工具调用展示(名称、参数、结果、耗时)
- 思考过程展示(💭图标 + 灰色斜体文字)
- 最终回答的 Markdown 渲染 + 引用来源

UI要求:
- 消息列表垂直排列,Agent 消息中的思考/工具/回答按时间顺序排列
- 工具调用卡片可展开/收起
- 实时模式下每一步都即时出现,有动画过渡
- 用 shadcn/ui 风格 + Tailwind CSS

请给出完整的组件树和核心组件代码。

AI 的输出(审查后):

组件树合理:AgentInterface → AgentMessageList → AgentMessage → ThinkingStep / ToolCall / MarkdownRenderer / SourceReference + DisplayModeToggle + ChatInput

我的调整:

  1. ✅ 组件拆分清晰
  2. ❌ 实时模式下的动画过渡需要用 CSS transition,AI 用了 opacity 动画但 max-height 也要过渡才能让内容平滑展开 → 手动补上
  3. ✅ 工具图标映射表实用——每个工具都有专属图标和中文标签

学到了什么: Agent 交互的 UX 关键是渐进式展示——不是一下子展示所有内容,而是按 Agent 执行步骤逐步出现。实时模式下的动画过渡需要同时处理 opacity 和 max-height。

💻 动手练习

练习1(简单):实现 ToolCall 展示组件

实现一个工具调用卡片组件,展示:

  • 工具名 + 状态图标(运行中/完成/失败)
  • 可展开的参数 + 结果区域
  • 执行耗时

练习2(中等):实现 Agent 消息渲染

用 AgentMessage 组件渲染一条包含思考 + 工具调用 + 文本回答的完整消息:

  • 思考部分用 💭 图标 + 灰色斜体
  • 工具调用用 ToolCall 组件
  • 文本部分用 MarkdownRenderer
  • 支持精简/详细两种模式切换

练习3(挑战):实现完整的 Agent 交互界面

组合所有组件实现完整的 Agent 交互:

  • ChatInput 输入框
  • AgentMessageList 消息列表
  • DisplayModeToggle 模式切换
  • Agent SSE 流解析
  • 实时模式下每步即时出现 + 动画过渡

📌 本期要点

  1. Agent 有思考-行动循环: 思考 → 选择工具 → 执行 → 观察 → 再思考 → 最终回答
  2. 三种显示模式: 精简(只看回答)/ 详细(看每步)/ 实时(直播式)
  3. 过程可视化建立信任: 用户看到 AI 真的搜索了、调用了、验证了,才能相信回答
  4. 消息内容是多类型的: 一条 Agent 消息可以包含文本 + 思考 + 工具调用 + 工具结果
  5. SSE 流解析更复杂: 区分 thinking/tool_call/tool_result/text 四种数据类型

🔗 下期预告

下一期是模块四的综合实战——你将开发一个完整的 AI 助手应用:对话 + 知识库 + 工具调用,一个产品级的项目。
如果你没有苹果电脑,需要上传ios到APPStore可以访问以下网站
iPA上传工具 - IPA解析与AppStore提交

Logo

欢迎加入 MCP 技术社区!与志同道合者携手前行,一同解锁 MCP 技术的无限可能!

更多推荐