第35期 | AI Agent前端交互
第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
我的调整:
- ✅ 组件拆分清晰
- ❌ 实时模式下的动画过渡需要用 CSS transition,AI 用了
opacity动画但max-height也要过渡才能让内容平滑展开 → 手动补上 - ✅ 工具图标映射表实用——每个工具都有专属图标和中文标签
学到了什么: Agent 交互的 UX 关键是渐进式展示——不是一下子展示所有内容,而是按 Agent 执行步骤逐步出现。实时模式下的动画过渡需要同时处理 opacity 和 max-height。
💻 动手练习
练习1(简单):实现 ToolCall 展示组件
实现一个工具调用卡片组件,展示:
- 工具名 + 状态图标(运行中/完成/失败)
- 可展开的参数 + 结果区域
- 执行耗时
练习2(中等):实现 Agent 消息渲染
用 AgentMessage 组件渲染一条包含思考 + 工具调用 + 文本回答的完整消息:
- 思考部分用 💭 图标 + 灰色斜体
- 工具调用用 ToolCall 组件
- 文本部分用 MarkdownRenderer
- 支持精简/详细两种模式切换
练习3(挑战):实现完整的 Agent 交互界面
组合所有组件实现完整的 Agent 交互:
- ChatInput 输入框
- AgentMessageList 消息列表
- DisplayModeToggle 模式切换
- Agent SSE 流解析
- 实时模式下每步即时出现 + 动画过渡
📌 本期要点
- Agent 有思考-行动循环: 思考 → 选择工具 → 执行 → 观察 → 再思考 → 最终回答
- 三种显示模式: 精简(只看回答)/ 详细(看每步)/ 实时(直播式)
- 过程可视化建立信任: 用户看到 AI 真的搜索了、调用了、验证了,才能相信回答
- 消息内容是多类型的: 一条 Agent 消息可以包含文本 + 思考 + 工具调用 + 工具结果
- SSE 流解析更复杂: 区分 thinking/tool_call/tool_result/text 四种数据类型
🔗 下期预告
下一期是模块四的综合实战——你将开发一个完整的 AI 助手应用:对话 + 知识库 + 工具调用,一个产品级的项目。
如果你没有苹果电脑,需要上传ios到APPStore可以访问以下网站
iPA上传工具 - IPA解析与AppStore提交
更多推荐

所有评论(0)