본문으로 건너뛰기

Nuxt 프로젝트에 Mastra 통합

이 가이드에서는 Mastra를 사용하여 Tool 호출 AI Agent를 구축한 다음 서버 경로에서 직접 Agent를 가져오고 호출하여 Nuxt에 연결합니다.

AI SDK UI를 사용하여 Vue로 아름다운 대화형 채팅 환경을 만듭니다.

시작하기 전에
시작하기 전에에 대한 직접 링크

  • 지원되는 Model Provider의 API 키가 필요합니다. 선호하는 Provider가 없다면 OpenAI를 사용하세요.
  • Node.js v22.13.0 이상 설치

새로운 Nuxt 앱 생성(선택 사항)
새로운 Nuxt 앱 생성(선택 사항)에 대한 직접 링크

이미 Nuxt 앱이 있다면 다음 단계로 건너뛰세요.

다음 명령을 실행하여 새 Nuxt 앱을 생성합니다.

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

이 명령은 mastra-nuxt라는 프로젝트를 생성하지만 원하는 이름으로 바꿀 수 있습니다.

마스트라 초기화
마스트라 초기화에 대한 직접 링크

Nuxt 프로젝트로 이동합니다:

cd mastra-nuxt

mastra init을 실행합니다. 메시지가 표시되면 Provider(예: OpenAI)를 선택하고 키를 입력합니다.

npx mastra@latest init

그러면 예제 날씨 Agent와 다음 파일을 포함하는 mastra 폴더가 생성됩니다.

  • index.ts- Memory를 포함한 마스트라 구성
  • tools/weather-tool.ts- 특정 위치의 날씨를 가져오는 Tool
  • agents/weather-agent.ts- Tool을 사용하는 Prompt가 있는 기상 요원

다음 단계에서는 Nuxt 서버 라우트에서 weather-agent.ts를 호출합니다.

AI SDK UI 설치
AI SDK UI 설치에 대한 직접 링크

Mastra 어댑터와 함께 AI SDK UI를 설치합니다.

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 핸들러는 Prompt를 받아 Agent의 응답을 AI SDK 형식으로 스트리밍하고, GET 핸들러는 Memory에서 메시지 기록을 가져와 클라이언트가 다시 로드될 때 UI를 hydrate할 수 있게 합니다.

채팅 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 엔드포인트에 연결하여 Prompt를 전송하고 응답을 청크 단위로 스트리밍합니다. 사용자 정의 메시지 스타일을 사용하여 응답 텍스트를 렌더링하고 축소 가능한 세부 정보 요소에 Tool 호출을 표시합니다.

Agent 테스트
Agent 테스트에 대한 직접 링크

  1. 다음으로 Nuxt 앱을 실행하세요.npm run dev
  2. 다음에서 채팅을 엽니다.http://localhost:3000
  3. 날씨에 대해 물어보세요. API 키가 올바르게 설정되면 응답을 받게 됩니다.

다음 단계
다음 단계에 대한 직접 링크

Nuxt를 사용하여 Mastra Agent를 구축한 것을 축하합니다! 🎉

여기에서 자신만의 Tool와 논리를 사용하여 프로젝트를 확장할 수 있습니다.

  • Agent에 관해 자세히 알아보기

  • Agent에 고유한 기능을 제공하는 Tool 추가하기

  • Agent에 사람과 같은 Memory 추가하기 준비가 되면 Mastra가 AI SDK UI 및 Nuxt와 통합되는 방법과 Agent를 어디에나 배포하는 방법에 대해 자세히 알아보세요.

  • Mastra를 다음과 통합하세요.AI SDK UI

  • Agent 배포anywhere