Skip to content

Commit 37c83ce

Browse files
committed
fixed vercel sdk
1 parent 191316a commit 37c83ce

6 files changed

Lines changed: 237 additions & 193 deletions

File tree

examples/cookbook/vercel-voice-agent/.env.example

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@ MOSS_PROJECT_ID=your-project-id
22
MOSS_PROJECT_KEY=your-project-key
33
MOSS_INDEX_NAME=your-index-name
44

5-
# Vercel AI Gateway (routes to openai/gpt-4.1-mini)
5+
# Vercel AI Gateway — generates WebSocket tokens + routes to gpt-realtime-2
6+
# Get key: https://vercel.com/dashboard/ai-gateway
67
AI_GATEWAY_API_KEY=your-vercel-ai-gateway-key
7-
8-
# Deepgram (STT + TTS)
9-
DEEPGRAM_API_KEY=your-deepgram-api-key

examples/cookbook/vercel-voice-agent/app/api/pipeline/route.ts

Lines changed: 0 additions & 65 deletions
This file was deleted.
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { createGateway } from '@ai-sdk/gateway';
2+
import { MossClient } from '@moss-dev/moss';
3+
import { mossSearchTool } from '@moss-tools/vercel-sdk';
4+
5+
export const runtime = 'nodejs';
6+
7+
const gateway = createGateway({ apiKey: process.env.AI_GATEWAY_API_KEY });
8+
9+
const client = new MossClient(
10+
process.env.MOSS_PROJECT_ID!,
11+
process.env.MOSS_PROJECT_KEY!,
12+
);
13+
14+
const searchTool = mossSearchTool({
15+
client,
16+
indexName: process.env.MOSS_INDEX_NAME!,
17+
});
18+
19+
// Load the index into local memory at startup.
20+
// Cloud query is broken (503) for this project — local queries work fine.
21+
void client.loadIndex(process.env.MOSS_INDEX_NAME!)
22+
.then(() => console.log('[MOSS] index loaded locally'))
23+
.catch((err: unknown) => console.error('[MOSS] loadIndex failed:', err));
24+
25+
const MOSS_TOOL = {
26+
type: 'function' as const,
27+
name: 'search_knowledge_base',
28+
description: searchTool.description,
29+
parameters: {
30+
type: 'object',
31+
properties: {
32+
query: { type: 'string', description: 'Concise search query' },
33+
topK: { type: 'number', description: 'Number of results (default 5)' },
34+
},
35+
required: ['query'],
36+
},
37+
};
38+
39+
// POST (empty body) → mint a short-lived WebSocket token via Vercel AI Gateway
40+
// POST ({ query }) → execute MOSS search on behalf of the realtime model's tool call
41+
export async function POST(req: Request) {
42+
const body = await req.json().catch(() => ({})) as Record<string, unknown>;
43+
44+
if (typeof body.query === 'string') {
45+
const topK = typeof body.topK === 'number' ? body.topK : 5;
46+
const result = await searchTool.execute!(
47+
{ query: body.query, topK },
48+
{ toolCallId: 'realtime', messages: [], abortSignal: req.signal },
49+
);
50+
const docs = (result as { docs: Array<{ text: string }> }).docs ?? [];
51+
return Response.json(docs.map((d) => d.text).join('\n\n'));
52+
}
53+
54+
const { token, url } = await gateway.experimental_realtime.getToken({
55+
model: 'openai/gpt-realtime-2',
56+
});
57+
return Response.json({ token, url, tools: [MOSS_TOOL] });
58+
}

examples/cookbook/vercel-voice-agent/app/page.tsx

Lines changed: 72 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -1,140 +1,108 @@
11
'use client';
22

3-
import { useRef, useState } from 'react';
4-
5-
type Status = 'idle' | 'recording' | 'processing' | 'speaking';
3+
import { experimental_useRealtime as useRealtime } from '@ai-sdk/react';
4+
import { gateway } from '@ai-sdk/gateway';
5+
import { useMemo, useState } from 'react';
66

77
export default function Page() {
8-
const [status, setStatus] = useState<Status>('idle');
9-
const [transcript, setTranscript] = useState('');
10-
const [reply, setReply] = useState('');
8+
const [log, setLog] = useState<string[]>([]);
119
const [error, setError] = useState<string | null>(null);
1210

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);
11+
const model = useMemo(
12+
() => gateway.experimental_realtime('openai/gpt-realtime-2'),
13+
[],
14+
);
1915

20-
const stream = await navigator.mediaDevices.getUserMedia({
21-
audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
16+
const { status, connect, disconnect, startAudioCapture, stopAudioCapture, isCapturing } =
17+
useRealtime({
18+
model,
19+
api: { token: '/api/token' },
20+
sessionConfig: {
21+
voice: 'alloy',
22+
turnDetection: { type: 'server-vad' },
23+
instructions:
24+
'You are a helpful voice assistant with access to a knowledge base. ' +
25+
'Always call search_knowledge_base before answering factual questions. ' +
26+
'Keep answers brief — two or three sentences.',
27+
},
28+
onError: (err) => {
29+
console.error('[realtime error]', err);
30+
setError(err instanceof Error ? err.message : String(err));
31+
},
32+
onToolCall: async ({ toolCall }) => {
33+
if (toolCall.toolName !== 'search_knowledge_base') return;
34+
const { query, topK } = toolCall.args as { query: string; topK?: number };
35+
setLog((l) => [`🔍 "${query}"`, ...l.slice(0, 9)]);
36+
37+
const res = await fetch('/api/token', {
38+
method: 'POST',
39+
headers: { 'Content-Type': 'application/json' },
40+
body: JSON.stringify({ query, topK: topK ?? 5 }),
41+
});
42+
const text = await res.json() as string;
43+
setLog((l) => [`✓ returned context`, ...l.slice(0, 9)]);
44+
return text;
45+
},
2246
});
2347

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;
48+
const handleButton = async () => {
49+
setError(null);
50+
if (status === 'connected') {
51+
stopAudioCapture();
52+
disconnect();
53+
} else {
54+
try {
55+
const stream = await navigator.mediaDevices.getUserMedia({
56+
audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
57+
});
58+
await connect();
59+
startAudioCapture(stream);
60+
} catch (err) {
61+
setError(err instanceof Error ? err.message : String(err));
5862
}
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');
7763
}
7864
};
7965

80-
const busy = status !== 'idle';
81-
8266
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>
67+
<main style={{ maxWidth: 480, margin: '4rem auto', fontFamily: 'sans-serif', padding: '0 1rem' }}>
68+
<h1 style={{ fontSize: '1.5rem', marginBottom: '0.25rem' }}>MOSS Voice Agent</h1>
8569
<p style={{ color: '#6b7280', fontSize: '0.85rem', marginBottom: '2rem' }}>
86-
Deepgram STT · GPT-4.1 Mini · Deepgram TTS · MOSS retrieval
70+
Vercel AI Gateway · gpt-realtime-2 · MOSS retrieval
8771
</p>
8872

8973
<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'}
74+
onClick={handleButton}
75+
disabled={status === 'connecting'}
9576
style={{
96-
padding: '1rem 2.5rem',
77+
padding: '0.75rem 1.75rem',
9778
fontSize: '1rem',
9879
fontWeight: 600,
99-
borderRadius: 12,
80+
borderRadius: 10,
10081
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',
82+
cursor: status === 'connecting' ? 'not-allowed' : 'pointer',
83+
background: status === 'connected' ? '#dc2626' : '#2563eb',
10684
color: '#fff',
107-
userSelect: 'none',
108-
WebkitUserSelect: 'none',
10985
transition: 'background 0.15s',
11086
}}
11187
>
112-
{status === 'recording' ? '🎙 Release to send'
113-
: status === 'processing' ? 'Transcribing…'
114-
: status === 'speaking' ? '🔊 Speaking…'
115-
: '● Hold to speak'}
88+
{status === 'connecting' ? 'Connecting…'
89+
: status === 'connected' ? 'Stop'
90+
: 'Start talking'}
11691
</button>
11792

118-
<p style={{ marginTop: '0.75rem', fontSize: '0.8rem', color: '#9ca3af' }}>
119-
{status === 'idle' ? 'Press and hold, speak, then release.' : `Status: ${status}`}
93+
<p style={{ marginTop: '0.6rem', fontSize: '0.8rem', color: '#9ca3af' }}>
94+
Status: <strong>{status}</strong>
95+
{isCapturing && <span style={{ color: '#059669' }}> · mic active</span>}
12096
</p>
12197

12298
{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>
99+
<p style={{ marginTop: '0.5rem', color: '#dc2626', fontSize: '0.8rem' }}>{error}</p>
131100
)}
132101

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>
102+
{log.length > 0 && (
103+
<ul style={{ marginTop: '1.5rem', fontSize: '0.8rem', color: '#374151', paddingLeft: '1.2rem' }}>
104+
{log.map((entry, i) => <li key={i}>{entry}</li>)}
105+
</ul>
138106
)}
139107
</main>
140108
);

0 commit comments

Comments
 (0)