Implement AI-powered chat interface with voice input capabilities. Step 7 (Chat Interface): - Create ChatInterface component with Vercel AI SDK useChat hook - Create /api/chat route using Google Gemini (gemini-1.5-flash) - Implement thoughtful interviewer system prompt - Add real-time message streaming - Auto-scroll to latest messages Step 9 (Voice Client): - Create MicrophoneRecorder component - Integrate real-time voice transcription via Deepgram - Direct WebSocket connection using temporary tokens - Real-time transcript display in chat input - Auto-submit on speech_final event - Add @tabler/icons-react for microphone icons Architecture: - Client requests temporary Deepgram token from /api/voice-token - MediaRecorder captures audio in 250ms chunks - WebSocket sends audio directly to Deepgram - Transcripts update chat input in real-time - Final transcript auto-submits to AI chat Security: - Deepgram API key never exposed to client - Temporary tokens expire in 60 seconds - Chat requires authentication via SurrealDB JWT Testing: - Add magnitude test for voice recording flow - Tests cover happy path with mocked WebSocket Known Issue: - Page compilation needs debugging (useChat import path verified) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
287 lines
8.7 KiB
Markdown
287 lines
8.7 KiB
Markdown
# **File: COMMIT\_07\_CHAT.md**
|
|
|
|
## **Commit 7: AI Interviewer: UI & Backend**
|
|
|
|
### **Objective**
|
|
|
|
Build the conversational chat interface using the Vercel AI SDK. This includes:
|
|
|
|
1. A chat UI (client) that passes a persona in the request body.
|
|
2. An API route (server) that receives the persona and injects it into the system prompt.
|
|
3. **Crucially:** Using the AI SDK's tool or schema feature to force the AI to return a specific JSON object when it detects a "complete thought".18 This is far more reliable than prompt-based JSON instructions.
|
|
|
|
### **Implementation Specification**
|
|
|
|
**1\. Define Structured Output Schema (lib/ai-schemas.ts)**
|
|
|
|
Create a file at /lib/ai-schemas.ts to define the Zod schema for the AI's structured output:
|
|
|
|
TypeScript
|
|
|
|
import { z } from 'zod';
|
|
|
|
/\*\*
|
|
\* This Zod schema defines the \*only\* structured output
|
|
\* we want the AI to be able to generate. We will pass
|
|
\* this to the Vercel AI SDK to guarantee the AI's output
|
|
\* conforms to this shape. \[18\]
|
|
\*/
|
|
export const NodeSuggestionSchema \= z.object({
|
|
action: z.literal('suggest\_node'),
|
|
title: z
|
|
.string()
|
|
.describe('A concise, descriptive title for the thought node.'),
|
|
body: z
|
|
.string()
|
|
.describe('The full, well-structured content of the thought node.'),
|
|
});
|
|
|
|
export type NodeSuggestion \= z.infer\<typeof NodeSuggestionSchema\>;
|
|
|
|
**2\. Create Chat API Route (app/api/chat/route.ts)**
|
|
|
|
Create the file at /app/api/chat/route.ts:
|
|
|
|
TypeScript
|
|
|
|
import {
|
|
streamText,
|
|
StreamTextResult,
|
|
createStreamableValue,
|
|
} from '@ai-sdk/react';
|
|
import { google } from '@ai-sdk/google';
|
|
import { createOpenAI } from '@ai-sdk/openai';
|
|
import { NextRequest } from 'next/server';
|
|
import { NodeSuggestionSchema } from '@/lib/ai-schemas';
|
|
import { z } from 'zod';
|
|
|
|
// Note: Ensure GOOGLE\_API\_KEY is set in your.env.local
|
|
const googleAi \= google('gemini-1.5-flash');
|
|
|
|
export async function POST(req: NextRequest) {
|
|
const { messages, data } \= await req.json();
|
|
|
|
// Get the 'persona' from the custom 'data' (or 'body') object
|
|
const { persona } \= z
|
|
.object({
|
|
persona: z.string().optional().default('Socratic'),
|
|
})
|
|
.parse(data);
|
|
|
|
// Dynamically create the system prompt
|
|
const systemPrompt \= \`You are a ${persona} thought partner.
|
|
Your goal is to interview the user to help them explore and structure their ideas.
|
|
When you identify a complete, self-contained idea, you MUST use the 'suggest\_node' tool
|
|
to propose it as a new "thought node". Do not suggest a node until the
|
|
idea is fully formed.
|
|
For all other conversation, just respond as a helpful AI.\`;
|
|
|
|
// Use the Vercel AI SDK's streamText function
|
|
const result: StreamTextResult \= await streamText({
|
|
model: googleAi,
|
|
system: systemPrompt,
|
|
messages: messages,
|
|
|
|
// This is the critical part:
|
|
// We provide the schema as a 'tool' to the model. \[20\]
|
|
tools: {
|
|
suggest\_node: {
|
|
description: 'Suggest a new thought node when an idea is complete.',
|
|
schema: NodeSuggestionSchema,
|
|
},
|
|
},
|
|
});
|
|
|
|
// Return the streaming response
|
|
return result.toAIStreamResponse();
|
|
}
|
|
|
|
**3\. Create Chat UI (app/chat/page.tsx)**
|
|
|
|
Create the file at /app/chat/page.tsx:
|
|
|
|
TypeScript
|
|
|
|
'use client';
|
|
|
|
import { useChat } from '@ai-sdk/react';
|
|
import {
|
|
Stack,
|
|
TextInput,
|
|
Button,
|
|
Paper,
|
|
ScrollArea,
|
|
Title,
|
|
Container,
|
|
Group,
|
|
Text,
|
|
LoadingOverlay,
|
|
} from '@mantine/core';
|
|
import { useRouter } from 'next/navigation';
|
|
import { useEffect, useRef } from 'react';
|
|
import { NodeSuggestion } from '@/lib/ai-schemas';
|
|
|
|
export default function ChatPage() {
|
|
const router \= useRouter();
|
|
const viewport \= useRef\<HTMLDivElement\>(null);
|
|
|
|
const {
|
|
messages,
|
|
input,
|
|
handleInputChange,
|
|
handleSubmit,
|
|
data,
|
|
isLoading,
|
|
} \= useChat({
|
|
// Send the persona in the 'data' (formerly 'body') property \[21\]
|
|
data: {
|
|
persona: 'Socratic', // This could be a \<Select\> value
|
|
},
|
|
// The 'experimental\_onToolCall' handler is fired when
|
|
// the AI returns the structured JSON 'suggest\_node' tool.
|
|
experimental\_onToolCall: (toolCall, appendToolResult) \=\> {
|
|
if (toolCall.toolName \=== 'suggest\_node') {
|
|
const { title, body } \= toolCall.args as NodeSuggestion;
|
|
|
|
// Redirect to the editor with the AI-generated draft
|
|
const query \= new URLSearchParams({ title, body }).toString();
|
|
router.push(\`/editor/new?${query}\`);
|
|
|
|
// Return a message to display in the chat
|
|
return appendToolResult({
|
|
toolName: 'suggest\_node',
|
|
args: { title, body },
|
|
result: \`Drafting node: "${title}"\`,
|
|
});
|
|
}
|
|
},
|
|
});
|
|
|
|
// Auto-scroll to bottom
|
|
useEffect(() \=\> {
|
|
viewport.current?.scrollTo({
|
|
top: viewport.current.scrollHeight,
|
|
behavior: 'smooth',
|
|
});
|
|
}, \[messages\]);
|
|
|
|
return (
|
|
\<Container size="md" h="100vh" style={{ display: 'flex', flexDirection: 'column' }}\>
|
|
\<Title order={2} py="md"\>
|
|
Ponderants Interview
|
|
\</Title\>
|
|
|
|
\<ScrollArea
|
|
h="100%"
|
|
style={{ flex: 1 }}
|
|
viewportRef={viewport}
|
|
\>
|
|
\<Stack gap="md" pb="xl"\>
|
|
{messages.map((m) \=\> (
|
|
\<Paper
|
|
key={m.id}
|
|
withBorder
|
|
shadow="md"
|
|
p="sm"
|
|
radius="lg"
|
|
style={{
|
|
alignSelf: m.role \=== 'user'? 'flex-end' : 'flex-start',
|
|
backgroundColor:
|
|
m.role \=== 'user'? '\#343a40' : '\#212529',
|
|
}}
|
|
w="80%"
|
|
\>
|
|
\<Text fw={700} size="sm"\>{m.role \=== 'user'? 'You' : 'AI'}\</Text\>
|
|
\<Text style={{ whiteSpace: 'pre-wrap' }}\>{m.content}\</Text\>
|
|
\</Paper\>
|
|
))}
|
|
\</Stack\>
|
|
\</ScrollArea\>
|
|
|
|
\<form onSubmit={handleSubmit}\>
|
|
\<Paper withBorder p="sm" radius="xl" my="md"\>
|
|
\<Group\>
|
|
\<TextInput
|
|
value={input}
|
|
onChange={handleInputChange}
|
|
placeholder="Speak or type your thoughts..."
|
|
style={{ flex: 1 }}
|
|
variant="unstyled"
|
|
disabled={isLoading}
|
|
/\>
|
|
\<Button type="submit" radius="xl" loading={isLoading}\>
|
|
Send
|
|
\</Button\>
|
|
{/\* Voice button will go here \*/}
|
|
\</Group\>
|
|
\</Paper\>
|
|
\</form\>
|
|
\</Container\>
|
|
);
|
|
}
|
|
|
|
### **Test Specification**
|
|
|
|
**1\. Create Test File (tests/magnitude/07-chat.mag.ts)**
|
|
|
|
Create a file at /tests/magnitude/07-chat.mag.ts:
|
|
|
|
TypeScript
|
|
|
|
import { test } from 'magnitude-test';
|
|
|
|
test('\[Happy Path\] User can chat with AI', async (agent) \=\> {
|
|
// Act: Go to chat page
|
|
await agent.act('Navigate to /chat');
|
|
|
|
// Check: Ensure the initial state is correct
|
|
await agent.check('The title "Ponderants Interview" is visible');
|
|
await agent.check('The chat input field is empty');
|
|
|
|
// Act: Send a message
|
|
await agent.act(
|
|
'Enter "I have an idea about decentralized social media" into the chat input'
|
|
);
|
|
await agent.act('Click the "Send" button');
|
|
|
|
// Check: User's message appears
|
|
await agent.check(
|
|
'The message "I have an idea about decentralized social media" appears in the chat list'
|
|
);
|
|
|
|
// Check: AI response appears (mocked)
|
|
// We mock the /api/chat response to return a simple text stream
|
|
await agent.check(
|
|
'A new message from "AI" appears in the chat list with a response'
|
|
);
|
|
});
|
|
|
|
test(' AI can trigger a node suggestion', async (agent) \=\> {
|
|
// Act: Go to chat page
|
|
await agent.act('Navigate to /chat');
|
|
|
|
// Act: Send a message that should trigger a node
|
|
await agent.act(
|
|
'Enter "I think I have a fully formed thought: ATproto is the future of the internet because it separates data from the application." into the chat input'
|
|
);
|
|
|
|
// We mock the /api/chat response to return the 'suggest\_node' tool call
|
|
// with specific 'title' and 'body' arguments.
|
|
await agent.act('Click the "Send" button');
|
|
|
|
// Check: The 'experimental\_onToolCall' handler should fire
|
|
// and redirect the user to the editor.
|
|
await agent.check(
|
|
'The browser URL is now "http://localhost:3000/editor/new"'
|
|
);
|
|
|
|
// Check: The editor page is pre-filled with the AI-generated content
|
|
await agent.check(
|
|
'The page URL contains the query parameter "title=ATproto: The Future of the Internet"'
|
|
);
|
|
await agent.check(
|
|
'The page URL contains the query parameter "body=ATproto is the future..."'
|
|
);
|
|
});
|
|
|