前端不是 Agent 的核心,但它能把核心机制变得可观察。教学版页面有四个区域:聊天、Session Tree、工具列表、事件时间线。
对应文件:
examples/teaching-agent/src/client/App.tsx
examples/teaching-agent/src/client/styles.css
index.html
src/client/main.tsx
src/client/App.tsx
src/client/styles.css
public/favicon.svg
本节的目标不是做一个花哨 UI,而是让 Agent 内部状态可观察:消息、工具、事件都要看得见。
flowchart TB App["App"] --> Topbar["刷新 / 重置"] App --> Chat["MessageCard[]"] App --> Composer["输入框 + 发送按钮"] App --> Tree["Session Tree"] App --> Tools["ToolCard[]"] App --> Timeline["Event Timeline"]
这个布局的教学目的很明确:
| 区域 | 让读者观察什么 |
|---|---|
| 聊天区 | user、assistant、toolResult 都是消息 |
| Session Tree | id / parentId 如何形成分支,当前 leafId 在哪里 |
| 工具列表 | 当前 Agent 有哪些可调用能力 |
| 事件时间线 | 一次 prompt 的内部生命周期 |
const [session, setSession] = useState<SessionResponse>(EMPTY_SESSION);
async function refresh() {
const response = await fetch("/api/session");
setSession(await response.json());
}
发送消息时:
const response = await fetch("/api/runs", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text }),
});
const { runId } = await response.json();
const source = new EventSource(`/api/runs/${runId}/events`);
完整仓库已经使用 SSE:message_start/update/end 会增量更新聊天区,tool_execution_start/end 和 tool_permission 会进入事件时间线,最后 run_done 用完整 session 对齐最终状态。
::: tip 跟做建议
如果你从空目录第一次实现,可以先用 POST /api/prompt 一次性返回 JSON,让页面跑起来;再按完整仓库把提交逻辑升级成 /api/runs + SSE。这样学习曲线更平。
:::
先写 src/client/main.tsx:
import { createRoot } from "react-dom/client";
import { App } from "./App";
import "./styles.css";
createRoot(document.getElementById("root")!).render(<App />);
再写一个最小 App:
export function App() {
const [session, setSession] = useState<SessionResponse>(EMPTY_SESSION);
const [input, setInput] = useState("列出工作区文件");
async function submit(event: FormEvent) {
event.preventDefault();
// 跟做最小版可以先用 /api/prompt;完整仓库默认走 /api/runs + SSE。
const response = await fetch("/api/prompt", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: input }),
});
setSession(await response.json());
}
return (
<main>
{session.messages.map((message, index) => (
<pre key={index}>{JSON.stringify(message, null, 2)}</pre>
))}
<form onSubmit={submit}>
<input value={input} onChange={(event) => setInput(event.target.value)} />
<button>发送</button>
</form>
</main>
);
}
等数据流跑通后,再替换成仓库里的精致版组件:MessageCard、ToolCard、Event Timeline。
assistant message 需要特殊处理 tool call:
message.content.map((block) =>
block.type === "toolCall" ? (
<ToolCallBlock block={block} />
) : (
<p>{block.text}</p>
),
)
这是前端层面再次提醒读者:assistant message 不是字符串。
function summarizeEvents(events: AgentEvent[]): string[] {
return events.slice(-80).map((event) => {
switch (event.type) {
case "tool_execution_start":
return `tool_start: ${event.toolName}`;
case "tool_execution_end":
return `tool_end: ${event.toolName}${event.isError ? " error" : ""}`;
case "compaction":
return `compaction: ${event.tokensBefore} approx tokens`;
default:
return event.type;
}
});
}
真实产品里,这里可以变成更完整的调试面板:耗时、参数、输出摘要、token 用量、错误栈。
教学版 CSS 做了两件事:
Agent UI 里最容易出问题的是动态内容:工具参数、长文件名、错误日志。样式里要允许换行和滚动,不要让一段 JSON 撑破页面。
npm run teaching-agent:dev
打开:
http://localhost:5174/
依次输入:
列出工作区文件
读取 agent-notes.md
写一条笔记
你应该能看到工具调用卡片和事件时间线同步变化。
预期页面至少能显示:
user: 列出工作区文件
assistant: list_files toolCall
tool:list_files: README.md / agent-notes.md
assistant: 我已经列出工作区文件...
| 错误 | 后果 |
|---|---|
| 只展示 assistant 文本 | 看不到 tool call,读者误以为模型直接读了文件 |
| loading 时不禁用按钮 | 容易发起并发 prompt/reset |
| 时间线只展示最终状态 | 读者看不到 loop 内部过程 |
| 移动端固定两列 | 390px 视口内容溢出 |
给 ToolCallBlock 增加一个折叠按钮,默认只显示工具名,展开后显示 JSON 参数。这个练习能让你处理真实 Agent UI 里常见的“结构化内容太长”问题。
git add index.html src/client public
git commit -m "step 6: add teaching agent ui"