概念讲多了容易飘。我们聊完了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快捷提示,还有最重要的useXAgentuseXChat两个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.cssant-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代理对象,它上面有generatestream等方法——这些方法名和我们之前讲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:useXAgentuseXChat。前者负责跟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是一个响应式数组,每条消息有idrolecontentstatus等字段。我们把它转成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,
  })),
)

加载中的消息我们给了一个渐变色底部边框,模拟“正在思考”的视觉效果。typingBubble组件的属性,设为true时会在消息末尾显示闪烁的光标,用户能直观感受到AI在生成。

会话管理:新建、切换、删除

会话管理的逻辑不复杂,主要靠conversationscurConversationmessageHistory三个状态配合。

新建对话:生成一个时间戳作为会话key,插入到会话列表顶部,切换到新会话,清空当前消息。

切换对话:保存当前会话的消息到messageHistory(一个按key缓存消息的对象),然后加载目标会话的历史消息。

删除对话:从列表和缓存中删除,如果当前正在该会话,自动跳到第一条。

持久化目前只放在内存里,刷新页面就没了。生产环境可以改成存LocalStorage或对接后端数据库——Mastra的Memory已经存了对话历史,前端也可以做一层本地缓存提升切换速度,但这是后话了。

一些边角但重要的细节

  • watch(messages, persistHistory, { deep: true })——每次消息变化都自动缓存,切换会话时不会丢。
  • persistHistory同时也在新建和切换时手动调用,保证边界情况不丢数据。
  • SENDER_PROMPTSwelcomePromptItems是快捷提示词,欢迎页和输入框上方的Prompts复用同一组数据,体验统一。
  • bubbleRoles定义了AI和用户气泡的样式差异:AI气泡在左边,蓝色头像;用户气泡在右边,绿色头像。这个视觉区分让长对话的阅读体验好很多。

跑起来

一切就绪后,确保Mastra后端在localhost:3000运行,然后:

pnpm dev

浏览器打开,左侧新建对话,输入问题,你应该能看到AI一个字一个字回复。左侧会话列表可以切换、重命名、删除。到目前为止,一个完整的前端聊天界面就跑通了。
在这里插入图片描述
在这里插入图片描述

接下来

这篇文章搭好了一个基础框架,但它离生产还差一些东西:Tool Calling的可视化反馈、RAG检索结果的高亮引用、错误重试机制、暗黑模式切换、移动端适配。后续我们可以逐个往上加。

如果你跟着这个系列一路读下来,从LLM原理到Agent概念再到Mastra框架,现在又亲手搭了一个前端,你应该对整套技术栈有了立体的感觉。这不是什么黑魔法——就是用TypeScript,把一个一个模块拼起来的工程活儿。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐