跳至主要內容

將 Mastra 整合至你的 Nuxt 項目

本指南會帶你使用 Mastra 建立一個可呼叫 Tool 的 AI Agent,然後在伺服器路由中直接匯入並呼叫該 Agent,將它連接至 Nuxt。

你會使用 AI SDK UI,透過 Vue 建立美觀且具互動性的聊天體驗。

開始之前
開始之前 的直接連結

  • 你需要取得受支援模型 Provider 的 API 金鑰。如果沒有偏好,請使用 OpenAI
  • 安裝 Node.js v22.13.0 或以上版本

建立新的 Nuxt 應用程式(可選)
建立新的 Nuxt 應用程式(可選) 的直接連結

如果你已經有 Nuxt 應用程式,請跳至下一步。

執行以下命令以建立新的 Nuxt 應用程式

npm create nuxt@latest mastra-nuxt -- --template minimal --packageManager npm --gitInit --modules

這會建立名為 mastra-nuxt 的項目,你亦可以換成任何想要的名稱。

初始化 Mastra
初始化 Mastra 的直接連結

前往你的 Nuxt 項目:

cd mastra-nuxt

執行 mastra init。系統提示時,選擇 Provider(例如 OpenAI)並輸入你的金鑰:

npx mastra@latest init

這會建立 mastra 資料夾,當中包含一個天氣 Agent 範例和以下檔案:

  • index.ts - Mastra 設定,包括記憶體
  • tools/weather-tool.ts - 擷取指定地點天氣的 Tool
  • agents/weather-agent.ts- 使用該 Tool 的天氣 Agent,內含提示詞

接下來的步驟會從 Nuxt 伺服器路由呼叫 weather-agent.ts

安裝 AI SDK UI
安裝 AI SDK UI 的直接連結

安裝 AI SDK UI 和 Mastra 配接器:

npm install @mastra/ai-sdk@latest @ai-sdk/vue ai

建立聊天路由
建立聊天路由 的直接連結

建立 server/api/chat.ts

server/api/chat.ts
import { handleChatStream } from '@mastra/ai-sdk'
import { toAISdkV5Messages } from '@mastra/ai-sdk/ui'
import { createUIMessageStreamResponse } from 'ai'
import { mastra } from '../../src/mastra'

const THREAD_ID = 'example-user-id'
const RESOURCE_ID = 'weather-chat'

export default defineEventHandler(async event => {
const method = event.method

if (method === 'POST') {
const params = await readBody(event)
const stream = await handleChatStream({
mastra,
agentId: 'weather-agent',
params: {
...params,
memory: {
...params.memory,
thread: THREAD_ID,
resource: RESOURCE_ID,
},
},
})

return createUIMessageStreamResponse({ stream })
}

if (method === 'GET') {
const memory = await mastra.getAgentById('weather-agent').getMemory()
let response = null

try {
response = await memory?.recall({
threadId: THREAD_ID,
resourceId: RESOURCE_ID,
})
} catch {
console.log('No previous messages found.')
}

const uiMessages = toAISdkV5Messages(response?.messages || [])

return uiMessages
}
})

POST 處理程式會接收提示詞,並以 AI SDK 格式串流傳回 Agent 的回應;GET 處理程式則會從記憶體擷取訊息記錄,讓用戶端重新載入時可以還原 UI 狀態。

加入聊天 UI
加入聊天 UI 的直接連結

app/app.vue 的內容替換如下:

app/app.vue
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { Chat } from '@ai-sdk/vue'
import { DefaultChatTransport, type ToolUIPart } from 'ai'

const chat = new Chat({
transport: new DefaultChatTransport({
api: '/api/chat',
}),
})

const STATE_TO_LABEL_MAP: Record<string, string> = {
'input-streaming': 'Pending',
'input-available': 'Running',
'output-available': 'Completed',
'output-error': 'Error',
'output-denied': 'Denied',
}

const input = ref('')

onMounted(async () => {
const res = await fetch('/api/chat')
const data = await res.json()
chat.messages = [...data]
})

function handleSubmit() {
if (!input.value.trim()) return

chat.sendMessage({ text: input.value })
input.value = ''
}
</script>

<template>
<div class="chat-container">
<div class="messages">
<div v-for="message in chat.messages" :key="message.id" class="message-wrapper">
<div v-for="(part, i) in message.parts" :key="`${message.id}-${i}`">
<div v-if="part.type === 'text'" :class="['message', message.role]">
<div class="message-content">{{ part.text }}</div>
</div>

<details v-else-if="part.type?.startsWith('tool-')" class="tool">
<summary class="tool-header">
{{ (part as ToolUIPart).type?.split('-').slice(1).join('-') }} - {{
STATE_TO_LABEL_MAP[(part as ToolUIPart).state ?? 'output-available'] }}
</summary>
<div class="tool-content">
<div class="tool-section">
<div class="tool-label">Parameters</div>
<pre><code>{{ JSON.stringify((part as ToolUIPart).input, null, 2) }}</code></pre>
</div>
<div class="tool-section">
<div class="tool-label">
{{ (part as ToolUIPart).errorText ? 'Error' : 'Result' }}
</div>
<pre><code>{{ JSON.stringify((part as ToolUIPart).output, null, 2) }}</code></pre>
<div v-if="(part as ToolUIPart).errorText" class="tool-error">
{{ (part as ToolUIPart).errorText }}
</div>
</div>
</div>
</details>
</div>
</div>
</div>

<form class="input-form" @submit.prevent="handleSubmit">
<input
v-model="input"
type="text"
placeholder="Ask about the weather..."
:disabled="chat.status !== 'ready'"
class="chat-input"
/>
<button type="submit" class="submit-button" :disabled="chat.status !== 'ready'">Send</button>
</form>
</div>
</template>

<style>
*,
*::before,
*::after {
box-sizing: border-box;
}

*:not(dialog) {
margin: 0;
}

@media (prefers-reduced-motion: no-preference) {
html {
interpolate-size: allow-keywords;
}
}

html {
font-family:
-apple-system,
BlinkMacSystemFont,
avenir next,
avenir,
segoe ui,
helvetica neue,
Adwaita Sans,
Cantarell,
Ubuntu,
roboto,
noto,
helvetica,
arial,
sans-serif;
}

body {
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}

img,
picture,
video,
canvas,
svg {
display: block;
max-width: 100%;
}

input,
button,
textarea,
select {
font: inherit;
}

p,
h1,
h2,
h3,
h4,
h5,
h6 {
overflow-wrap: break-word;
}

p {
text-wrap: pretty;
}
h1,
h2,
h3,
h4,
h5,
h6 {
text-wrap: balance;
}

.chat-container {
max-width: 48rem;
margin: 0 auto;
padding: 1.5rem;
height: 100vh;
display: flex;
flex-direction: column;
}

.messages {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 1rem;
}

.message-wrapper {
display: flex;
flex-direction: column;
gap: 0.5rem;
}

.message {
padding: 0.75rem 1rem;
border-radius: 0.5rem;
}

.message.user {
background-color: #3b82f6;
color: white;
margin-left: auto;
max-width: 60%;
}

.message.assistant {
background-color: #f3f4f6;
color: #1f2937;
max-width: 80%;
}

.tool {
border: 1px solid #d1d5db;
border-radius: 0.5rem;
margin: 0.5rem 0;
overflow: hidden;
}

.tool-header {
padding: 0.75rem 1rem;
background-color: #f9fafb;
cursor: pointer;
font-weight: 500;
font-size: 0.875rem;
}

.tool-content {
padding: 1rem;
border-top: 1px solid #d1d5db;
}

.tool-section {
margin-bottom: 1rem;
}

.tool-section:last-child {
margin-bottom: 0;
}

.tool-label {
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
color: #6b7280;
margin-bottom: 0.5rem;
}

.tool pre {
background-color: #f3f4f6;
padding: 0.75rem;
border-radius: 0.375rem;
overflow-x: auto;
font-size: 0.875rem;
}

.tool-error {
color: #dc2626;
margin-top: 0.5rem;
}

.input-form {
display: grid;
grid-template-columns: 1fr auto;
gap: 0.75rem;
padding-top: 1rem;
border-top: 1px solid #e5e7eb;
margin-top: 1rem;
}

.chat-input {
padding: 0.75rem 1rem;
border: 1px solid #d1d5db;
border-radius: 0.5rem;
font-size: 1rem;
}

.chat-input:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}

.chat-input:disabled {
background-color: #f3f4f6;
cursor: not-allowed;
}

.submit-button {
padding: 0.75rem 1.5rem;
background-color: #3b82f6;
color: white;
border: none;
border-radius: 0.5rem;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
}

.submit-button:hover:not(:disabled) {
background-color: #2563eb;
}

.submit-button:disabled {
background-color: #9ca3af;
cursor: not-allowed;
}
</style>

此元件會將 Chat() 連接至 /api/chat 端點,把提示詞傳送至該端點,並以分段串流方式傳回回應。

它會使用自訂訊息樣式顯示回應文字,並在可摺疊的詳細資料元素中顯示任何 Tool 呼叫。

測試你的 Agent
測試你的 Agent 的直接連結

  1. 使用 npm run dev 執行 Nuxt 應用程式
  2. http://localhost:3000 開啟聊天頁面
  3. 嘗試查問天氣。如果 API 金鑰設定正確,你便會收到回應

下一步
下一步 的直接連結

恭喜你使用 Nuxt 建立了 Mastra Agent!🎉

接下來,你可以使用自己的 Tool 和邏輯擴充項目:

  • 深入了解 Agent
  • 為你的 Agent 加入專屬 Tool
  • 為 Agent 加入仿人類的記憶體

準備好後,可進一步了解 Mastra 如何與 AI SDK UI 和 Nuxt 整合,以及如何將 Agent 部署至任何地方: