Nodejs也能写Agent - 9.Mastra篇 - Mastra客户端
概念讲多了容易飘。我们聊完了LLM、Prompt、Tools、RAG、Agent、MCP,也把Mastra这个框架的设计理了一遍。这篇开始动真格的——从零搭一个能跟Mastra Agent对话的前端界面。
选型上我用的是Vue3 + Ant Design Vue + Mastra Client JS。为什么是Vue而不是React?因为最近几个项目都在用Vue,手感正热,而且Ant Design Vue的X套件(ant-design-x-vue)给AI聊天场景做了不少封装,省掉大量重复代码。如果你用React,思路是一样的,Mastra有@mastra/client-js包,不管前端框架是啥,调API的逻辑完全一致。
前提:你的Mastra后端已经跑起来了
这篇文章重点讲前端,但有个前提不能跳过——你得先有一个运行在http://localhost:3000的Mastra项目,里面至少定义了一个Agent。上一篇文章我们讲过怎么用Mastra定义Agent,你项目里大概率会有一个类似base-agent的Agent,这个id我们在前端代码里会硬编码,后面可以改成动态读取。
如果你还没搭后端,去Mastra官网过一遍Quick Start,五分钟就能跑起来一个带Agent的后端服务。
创建Vue3项目
没什么花活,直接用官方脚手架:
pnpm create vue@latest
选项我选了空模板,没有ts等等东西,但这些不是必须的,按你习惯来。创建完cd进去。
安装依赖
前端需要的东西列一下:
pnpm add @mastra/client-js reset.css ant-design-vue ant-design-x-vue dayjs
pnpm add vite-plugin-vue-devtools -D
一个个说:@mastra/client-js是Mastra官方的JavaScript客户端,负责跟前端Agent通信。reset.css重置浏览器默认样式,ant-design-vue是基础UI库,ant-design-x-vue是Ant Design专门给AI场景做的扩展包——Bubble聊天气泡、Sender输入框、Welcome欢迎页、Prompts快捷提示,还有最重要的useXAgent和useXChat两个Composable。dayjs处理时间戳,轻量。vite-plugin-vue-devtools是Vue开发者工具插件,开发时用,生产不用。
调整Vite配置
vite.config.js基本是脚手架生成的,我只加了vueDevTools插件和路径别名:
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
export default defineConfig({
plugins: [
vue(),
vueDevTools(),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
},
},
})
@别名指向src目录,后面导入组件不用写一堆../../,代码看起来干净。
入口文件与全局样式
main.js很简单——创建Vue应用,注册Ant Design Vue和它的X扩展,引入样式:
import { createApp } from 'vue'
import Antd from 'ant-design-vue'
import antDesignXVue from 'ant-design-x-vue'
import App from './App.vue'
import 'reset.css'
import 'ant-design-vue/dist/reset.css'
const app = createApp(App)
app.use(Antd)
app.use(antDesignXVue)
app.mount('#app')
reset.css和ant-design-vue/dist/reset.css这两行很重要——Ant Design Vue 5默认不带CSS重置,你不引入的话不同浏览器渲染会有差异。我们不用全局Ant Design的CSS文件,而是按需加载,但重置样式仍然需要。
App.vue是根组件,包了一层ConfigProvider来设置中文语言:
<template>
<ConfigProvider :locale="zhCN">
<ChatApp />
</ConfigProvider>
</template>
<script setup>
import { ConfigProvider } from 'ant-design-vue'
import zhCN from 'ant-design-vue/es/locale/zh_CN'
import ChatApp from '@/components/ChatApp.vue'
</script>
ChatApp是我们接下来要写的核心组件。
封装Mastra客户端
在src/lib/mastra.js里封装Mastra客户端的初始化:
import { MastraClient } from '@mastra/client-js'
const baseUrl = 'http://localhost:3000'
export const mastraClient = new MastraClient({ baseUrl })
export const MASTRA_AGENT_ID = 'base-agent'
export function getMastraAgent() {
return mastraClient.getAgent(MASTRA_AGENT_ID)
}
MastraClient需要一个baseUrl指向Mastra服务的地址。getAgent方法返回一个Agent代理对象,它上面有generate和stream等方法——这些方法名和我们之前讲Tool Calling时提到的API是对应的。这里我们用stream做流式对话,让回复一个字一个字蹦出来,体验好。
MASTRA_AGENT_ID写死了'base-agent',你的项目里Agent叫什么,这里就改成什么。也可以后面做成环境变量或动态配置,但刚开始硬编码最省事。
聊天组件ChatApp.vue——布局结构
这个组件的代码比较长,但拆开来看其实很清晰。先看模板结构:
整个页面分为左、右两部分:
左侧:侧边栏
- 顶部Logo和标题
- "新建对话"按钮
- 会话列表(Conversations组件)
- 底部用户信息区
右侧:主聊天区
- 上方:聊天消息列表(Bubble.List)
- 有消息时:显示对话气泡
- 没消息时:显示欢迎页(Welcome)和快捷提示(Prompts)
- 下方:快捷提示(Prompts)+ 输入框(Sender)
这个布局参考了ChatGPT的交互模式——左侧管理多轮对话,右侧是当前对话的主界面。ant-design-x-vue的组件把聊天场景里的常见需求都封装好了,省得自己写一堆滚动、状态管理、气泡样式的逻辑。
样式计算:用Token保持主题一致
组件里用theme.useToken()拿到当前主题的Token,然后把样式定义成一个computed:
const { token } = theme.useToken()
const styles = computed(() => ({
layout: {
width: '100%',
minWidth: '960px',
height: '100vh',
display: 'flex',
background: token.value.colorBgContainer,
fontFamily: token.value.fontFamily,
},
sider: {
background: `${token.value.colorBgLayout}cc`,
width: '280px',
height: '100%',
display: 'flex',
flexDirection: 'column',
padding: '0 12px',
boxSizing: 'border-box',
flexShrink: 0,
},
logo: {
display: 'flex',
alignItems: 'center',
gap: '10px',
padding: '0 12px',
margin: '20px 0 16px',
},
logoTitle: {
fontWeight: 600,
fontSize: '16px',
color: token.value.colorText,
},
addBtn: {
background: `${token.value.colorPrimary}14`,
border: `1px solid ${token.value.colorPrimary}40`,
height: '40px',
},
conversations: {
flex: 1,
overflowY: 'auto',
marginTop: '8px',
},
siderFooter: {
borderTop: `1px solid ${token.value.colorBorderSecondary}`,
height: '48px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '0 4px',
},
chat: {
flex: 1,
minWidth: 0,
display: 'flex',
flexDirection: 'column',
paddingBlock: `${token.value.paddingLG}px`,
gap: '16px',
boxSizing: 'border-box',
},
chatList: {
flex: 1,
overflow: 'auto',
minHeight: 0,
},
chatInner: {
maxWidth: '760px',
margin: '0 auto',
width: '100%',
paddingInline: '24px',
boxSizing: 'border-box',
},
placeholder: {
paddingTop: '32px',
},
sender: {
width: '100%',
maxWidth: '760px',
margin: '0 auto',
},
senderPrompt: {
width: '100%',
maxWidth: '760px',
margin: '0 auto',
},
loadingMessage: {
backgroundImage:
'linear-gradient(90deg, #1677ff 0%, #722ed1 50%, #13c2c2 100%)',
backgroundSize: '100% 2px',
backgroundRepeat: 'no-repeat',
backgroundPosition: 'bottom',
},
}))
这样做的目的是让UI颜色、字体、间距都跟随Ant Design的主题系统,将来换暗黑模式或者自定义主题,不用改组件代码。实际项目里用CSS变量也能达到类似效果,但Ant Design的Token方式跟组件库绑定更紧,风格更统一。
对话逻辑的核心:useXAgent + useXChat
ant-design-x-vue提供了一对组合式API:useXAgent和useXChat。前者负责跟AI后端交互,后者负责管理前端消息状态。
useXAgent:处理请求与流式输出
const [agent] = useXAgent({
request: async ({ message, messages }, { onUpdate, onSuccess, onError, onStream }) => {
const controller = new AbortController()
onStream?.(controller)
const userText = typeof message === 'string' ? message : message?.content
if (!userText?.trim()) {
onError(new Error('消息不能为空'))
return
}
const history = (messages || []).map((m) => ({
role: m.role === 'assistant' ? 'assistant' : 'user',
content: typeof m === 'string' ? m : m.content,
}))
try {
const mastraAgent = getMastraAgent()
const response = await mastraAgent.stream(
[...history, { role: 'user', content: userText }],
{
memory: {
thread: curConversation.value,
resource: 'learning-agent-web',
},
},
)
let fullText = ''
await response.processDataStream({
onChunk: async (chunk) => {
if (controller.signal.aborted) return
if (chunk.type === 'text-delta' && chunk.payload?.text) {
fullText += chunk.payload.text
onUpdate({ role: 'assistant', content: fullText })
}
},
})
if (controller.signal.aborted) {
onError(new DOMException('已取消', 'AbortError'))
return
}
onSuccess([{ role: 'assistant', content: fullText || '(无回复内容)' }])
} catch (err) {
onError(err instanceof Error ? err : new Error(String(err)))
}
},
})
这里有几个关键点值得展开:
1. AbortController:用户点“停止生成”时,我们需要取消当前请求。onStream回调把controller传给外部,让Sender组件的@cancel事件能触发abort()。Mastra的流式响应支持AbortSignal,中断后不会再推送新chunk。
2. 消息历史处理:messages参数是useXChat维护的消息数组,我们把它们转成{ role, content }格式,再拼上当前用户发送的新消息,一起发给Mastra。注意,这里我们没有传System Prompt,因为System Prompt已经在后端Agent定义里写好了——前端不需要也不应该传人设指令,那是后端职责。
3. memory配置:传给mastraAgent.stream()的第二个参数里有memory: { thread, resource }。thread是我们当前会话的唯一标识(curConversation.value),resource是一个命名空间。这两样东西让Mastra能按会话把对话记录存下来——前端切换会话时,后端可以自动召回对应线程的历史消息,而不需要前端每次都传完整历史。这和我们在Memory那篇讲的分层记忆完全对应:thread是会话记忆的key,resource是应用级的存储隔离。
4. 流式处理:response.processDataStream()接收一个onChunk回调。每个chunk是一个text-delta类型的文本增量,我们把它拼到fullText里,然后通过onUpdate实时推给前端UI。这样消息气泡就能一个字一个字往外蹦,就是我们熟悉的ChatGPT效果。
useXChat:管理消息状态
const { onRequest, messages, setMessages } = useXChat({
agent: agent.value,
requestFallback: (_, { error }) => {
if (error?.name === 'AbortError') {
return { role: 'assistant', content: '已停止生成' }
}
return {
role: 'assistant',
content: `请求失败:${error?.message || '请确认 Mastra 服务已启动(pnpm dev)'}`,
}
},
resolveAbortController: (controller) => {
abortController.value = controller
},
})
onRequest是外部触发发送消息的方法(绑在Sender的@submit上)。requestFallback处理异常情况——用户取消请求时友好提示“已停止生成”,其他错误则展示具体信息。resolveAbortController把controller存起来,用于Sender的取消操作。
messages是一个响应式数组,每条消息有id、role、content、status等字段。我们把它转成Bubble.List需要的格式:
const bubbleItems = computed(() =>
(messages.value || []).map((item) => ({
key: item.id,
...item.message,
loading: item.status === 'loading',
styles: {
content: item.status === 'loading' ? styles.value.loadingMessage : undefined,
},
typing: item.status === 'loading' ? { step: 5, interval: 20 } : false,
})),
)
加载中的消息我们给了一个渐变色底部边框,模拟“正在思考”的视觉效果。typing是Bubble组件的属性,设为true时会在消息末尾显示闪烁的光标,用户能直观感受到AI在生成。
会话管理:新建、切换、删除
会话管理的逻辑不复杂,主要靠conversations、curConversation和messageHistory三个状态配合。
新建对话:生成一个时间戳作为会话key,插入到会话列表顶部,切换到新会话,清空当前消息。
切换对话:保存当前会话的消息到messageHistory(一个按key缓存消息的对象),然后加载目标会话的历史消息。
删除对话:从列表和缓存中删除,如果当前正在该会话,自动跳到第一条。
持久化目前只放在内存里,刷新页面就没了。生产环境可以改成存LocalStorage或对接后端数据库——Mastra的Memory已经存了对话历史,前端也可以做一层本地缓存提升切换速度,但这是后话了。
一些边角但重要的细节
watch(messages, persistHistory, { deep: true })——每次消息变化都自动缓存,切换会话时不会丢。persistHistory同时也在新建和切换时手动调用,保证边界情况不丢数据。SENDER_PROMPTS和welcomePromptItems是快捷提示词,欢迎页和输入框上方的Prompts复用同一组数据,体验统一。bubbleRoles定义了AI和用户气泡的样式差异:AI气泡在左边,蓝色头像;用户气泡在右边,绿色头像。这个视觉区分让长对话的阅读体验好很多。
跑起来
一切就绪后,确保Mastra后端在localhost:3000运行,然后:
pnpm dev
浏览器打开,左侧新建对话,输入问题,你应该能看到AI一个字一个字回复。左侧会话列表可以切换、重命名、删除。到目前为止,一个完整的前端聊天界面就跑通了。

接下来
这篇文章搭好了一个基础框架,但它离生产还差一些东西:Tool Calling的可视化反馈、RAG检索结果的高亮引用、错误重试机制、暗黑模式切换、移动端适配。后续我们可以逐个往上加。
如果你跟着这个系列一路读下来,从LLM原理到Agent概念再到Mastra框架,现在又亲手搭了一个前端,你应该对整套技术栈有了立体的感觉。这不是什么黑魔法——就是用TypeScript,把一个一个模块拼起来的工程活儿。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)