Skip to content

Commit 191316a

Browse files
committed
vercel ai
1 parent d39a85f commit 191316a

10 files changed

Lines changed: 1584 additions & 0 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
MOSS_PROJECT_ID=your-project-id
2+
MOSS_PROJECT_KEY=your-project-key
3+
MOSS_INDEX_NAME=your-index-name
4+
5+
# Vercel AI Gateway (routes to openai/gpt-4.1-mini)
6+
AI_GATEWAY_API_KEY=your-vercel-ai-gateway-key
7+
8+
# Deepgram (STT + TTS)
9+
DEEPGRAM_API_KEY=your-deepgram-api-key
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# MOSS + Vercel AI Gateway Voice Agent
2+
3+
Realtime voice agent using [Vercel AI Gateway](https://vercel.com/blog/realtime-voice-agents-on-ai-gateway) with MOSS as the knowledge base. Speak a question — the agent searches your MOSS index and answers out loud.
4+
5+
## How it works
6+
7+
```text
8+
Browser (useRealtime) ──WebSocket── Vercel AI Gateway ── gpt-realtime-2
9+
│ │
10+
│ tool call: search_knowledge_base │
11+
└──── POST /api/token ────────────────►│
12+
MOSS index
13+
```
14+
15+
- `POST /api/token` (empty body) — generates a short-lived WebSocket token (keeps API keys off the client)
16+
- `POST /api/token` (with `{ query }`) — executes MOSS search when the model calls `search_knowledge_base`
17+
18+
## Setup
19+
20+
### 1. Install dependencies
21+
22+
```bash
23+
npm install
24+
```
25+
26+
### 2. Add credentials
27+
28+
```bash
29+
cp .env.example .env
30+
```
31+
32+
Fill in `.env`:
33+
34+
| Variable | Where to get it |
35+
| --- | --- |
36+
| `MOSS_PROJECT_ID` | [moss.dev](https://moss.dev) dashboard |
37+
| `MOSS_PROJECT_KEY` | [moss.dev](https://moss.dev) dashboard |
38+
| `MOSS_INDEX_NAME` | Name of the index to search |
39+
| `AI_GATEWAY_API_KEY` | [Vercel AI Gateway](https://vercel.com/dashboard/ai-gateway) → API Keys |
40+
| `OPENAI_API_KEY` | [platform.openai.com](https://platform.openai.com) |
41+
42+
### 3. Run
43+
44+
```bash
45+
npm run dev
46+
```
47+
48+
Open [http://localhost:3000](http://localhost:3000), click **Start talking**, and ask anything covered by your index.
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { deepgram } from '@ai-sdk/deepgram';
2+
import { createGateway } from '@ai-sdk/gateway';
3+
import { MossClient } from '@moss-dev/moss';
4+
import { transcribe, generateSpeech, generateText, tool } from 'ai';
5+
import { z } from 'zod';
6+
7+
export const runtime = 'nodejs';
8+
export const maxDuration = 30;
9+
10+
const gateway = createGateway({ apiKey: process.env.AI_GATEWAY_API_KEY });
11+
const client = new MossClient(process.env.MOSS_PROJECT_ID!, process.env.MOSS_PROJECT_KEY!);
12+
13+
// Same pattern as @moss-tools/vercel-sdk mossSearchTool — inlined to avoid ai@6/7 peer dep conflict
14+
// TODO: replace with mossSearchTool({ client, indexName }) once @moss-tools/vercel-sdk supports ai@7
15+
const searchTool = tool({
16+
description: 'Search the knowledge base for information relevant to the user\'s question.',
17+
inputSchema: z.object({
18+
query: z.string().describe('Concise search query'),
19+
topK: z.number().int().min(1).max(10).default(5),
20+
}),
21+
execute: async ({ query, topK }) => {
22+
return client.query(process.env.MOSS_INDEX_NAME!, query, { topK });
23+
},
24+
});
25+
26+
// POST: audio → Deepgram STT → GPT-4.1 Mini + MOSS tool → Deepgram TTS → audio
27+
export async function POST(req: Request) {
28+
const audioBuffer = Buffer.from(await req.arrayBuffer());
29+
if (!audioBuffer.length) return Response.json({ error: 'No audio' }, { status: 400 });
30+
31+
// 1. Transcribe with Deepgram
32+
const { text: transcript } = await transcribe({
33+
model: deepgram.transcription('nova-3'),
34+
audio: audioBuffer,
35+
});
36+
if (!transcript.trim()) return new Response(null, { status: 204 });
37+
console.log('[STT]', transcript);
38+
39+
// 2. LLM with MOSS search tool
40+
const { text: reply } = await generateText({
41+
model: gateway('openai/gpt-4.1-mini'),
42+
system:
43+
'You are a concise voice assistant with access to a knowledge base. ' +
44+
'Search it before answering. Always reply with a spoken answer in 2–3 sentences.',
45+
tools: { search: searchTool },
46+
maxSteps: 5,
47+
prompt: transcript,
48+
});
49+
console.log('[LLM]', reply);
50+
if (!reply.trim()) return new Response(null, { status: 204 });
51+
52+
// 3. TTS with Deepgram Aura
53+
const { audio } = await generateSpeech({
54+
model: deepgram.speech('aura-asteria-en'),
55+
text: reply,
56+
});
57+
58+
return new Response(audio, {
59+
headers: {
60+
'Content-Type': 'audio/mpeg',
61+
'X-Transcript': encodeURIComponent(transcript),
62+
'X-Reply': encodeURIComponent(reply),
63+
},
64+
});
65+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
export const metadata = {
2+
title: 'Next.js',
3+
description: 'Generated by Next.js',
4+
}
5+
6+
export default function RootLayout({
7+
children,
8+
}: {
9+
children: React.ReactNode
10+
}) {
11+
return (
12+
<html lang="en">
13+
<body>{children}</body>
14+
</html>
15+
)
16+
}
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
'use client';
2+
3+
import { useRef, useState } from 'react';
4+
5+
type Status = 'idle' | 'recording' | 'processing' | 'speaking';
6+
7+
export default function Page() {
8+
const [status, setStatus] = useState<Status>('idle');
9+
const [transcript, setTranscript] = useState('');
10+
const [reply, setReply] = useState('');
11+
const [error, setError] = useState<string | null>(null);
12+
13+
const recorderRef = useRef<MediaRecorder | null>(null);
14+
const chunksRef = useRef<Blob[]>([]);
15+
16+
const startRecording = async () => {
17+
if (status !== 'idle') return;
18+
setError(null);
19+
20+
const stream = await navigator.mediaDevices.getUserMedia({
21+
audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
22+
});
23+
24+
const recorder = new MediaRecorder(stream, { mimeType: 'audio/webm' });
25+
chunksRef.current = [];
26+
27+
recorder.ondataavailable = (e) => { if (e.data.size > 0) chunksRef.current.push(e.data); };
28+
recorder.onstop = () => {
29+
stream.getTracks().forEach((t) => t.stop());
30+
runPipeline(new Blob(chunksRef.current, { type: 'audio/webm' }));
31+
};
32+
33+
recorder.start();
34+
recorderRef.current = recorder;
35+
setStatus('recording');
36+
};
37+
38+
const stopRecording = () => {
39+
if (recorderRef.current?.state === 'recording') {
40+
recorderRef.current.stop();
41+
recorderRef.current = null;
42+
setStatus('processing');
43+
}
44+
};
45+
46+
const runPipeline = async (blob: Blob) => {
47+
try {
48+
const res = await fetch('/api/pipeline', {
49+
method: 'POST',
50+
headers: { 'Content-Type': 'audio/webm' },
51+
body: blob,
52+
});
53+
54+
if (res.status === 204) {
55+
setError('No speech detected');
56+
setStatus('idle');
57+
return;
58+
}
59+
if (!res.ok) throw new Error(`Pipeline failed: ${res.status}`);
60+
61+
const raw = res.headers.get('X-Transcript');
62+
if (raw) setTranscript(decodeURIComponent(raw));
63+
64+
const rawReply = res.headers.get('X-Reply');
65+
if (rawReply) setReply(decodeURIComponent(rawReply));
66+
67+
const audioBlob = await res.blob();
68+
const url = URL.createObjectURL(audioBlob);
69+
const audio = new Audio(url);
70+
setStatus('speaking');
71+
audio.onended = () => { URL.revokeObjectURL(url); setStatus('idle'); };
72+
audio.onerror = () => { URL.revokeObjectURL(url); setStatus('idle'); };
73+
audio.play();
74+
} catch (err) {
75+
setError(err instanceof Error ? err.message : String(err));
76+
setStatus('idle');
77+
}
78+
};
79+
80+
const busy = status !== 'idle';
81+
82+
return (
83+
<main style={{ maxWidth: 520, margin: '4rem auto', fontFamily: 'sans-serif', padding: '0 1rem' }}>
84+
<h1 style={{ fontSize: '1.6rem', marginBottom: '0.25rem' }}>MOSS Voice Agent</h1>
85+
<p style={{ color: '#6b7280', fontSize: '0.85rem', marginBottom: '2rem' }}>
86+
Deepgram STT · GPT-4.1 Mini · Deepgram TTS · MOSS retrieval
87+
</p>
88+
89+
<button
90+
onMouseDown={startRecording}
91+
onMouseUp={stopRecording}
92+
onTouchStart={(e) => { e.preventDefault(); startRecording(); }}
93+
onTouchEnd={(e) => { e.preventDefault(); stopRecording(); }}
94+
disabled={status === 'processing' || status === 'speaking'}
95+
style={{
96+
padding: '1rem 2.5rem',
97+
fontSize: '1rem',
98+
fontWeight: 600,
99+
borderRadius: 12,
100+
border: 'none',
101+
cursor: busy && status !== 'recording' ? 'not-allowed' : 'pointer',
102+
background:
103+
status === 'recording' ? '#dc2626' :
104+
status === 'processing' ? '#6b7280' :
105+
status === 'speaking' ? '#059669' : '#2563eb',
106+
color: '#fff',
107+
userSelect: 'none',
108+
WebkitUserSelect: 'none',
109+
transition: 'background 0.15s',
110+
}}
111+
>
112+
{status === 'recording' ? '🎙 Release to send'
113+
: status === 'processing' ? 'Transcribing…'
114+
: status === 'speaking' ? '🔊 Speaking…'
115+
: '● Hold to speak'}
116+
</button>
117+
118+
<p style={{ marginTop: '0.75rem', fontSize: '0.8rem', color: '#9ca3af' }}>
119+
{status === 'idle' ? 'Press and hold, speak, then release.' : `Status: ${status}`}
120+
</p>
121+
122+
{error && (
123+
<p style={{ marginTop: '0.75rem', color: '#dc2626', fontSize: '0.82rem' }}>{error}</p>
124+
)}
125+
126+
{transcript && (
127+
<div style={{ marginTop: '1.5rem', padding: '0.75rem 1rem', background: '#f3f4f6', borderRadius: 8 }}>
128+
<p style={{ fontSize: '0.72rem', color: '#9ca3af', margin: '0 0 0.25rem' }}>YOU</p>
129+
<p style={{ margin: 0, fontSize: '0.9rem' }}>{transcript}</p>
130+
</div>
131+
)}
132+
133+
{reply && (
134+
<div style={{ marginTop: '0.75rem', padding: '0.75rem 1rem', background: '#eff6ff', borderRadius: 8 }}>
135+
<p style={{ fontSize: '0.72rem', color: '#93c5fd', margin: '0 0 0.25rem' }}>AGENT</p>
136+
<p style={{ margin: 0, fontSize: '0.9rem' }}>{reply}</p>
137+
</div>
138+
)}
139+
</main>
140+
);
141+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
/// <reference types="next" />
2+
/// <reference types="next/image-types/global" />
3+
/// <reference path="./.next/types/routes.d.ts" />
4+
5+
// NOTE: This file should not be edited
6+
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import type { NextConfig } from 'next';
2+
3+
const nextConfig: NextConfig = {
4+
serverExternalPackages: ['@moss-dev/moss', '@moss-dev/moss-core'],
5+
};
6+
7+
export default nextConfig;

0 commit comments

Comments
 (0)