Files
app/docs/steps/step-07.md
Albert e4c5960d7a feat: Step 7 & 9 - AI Chat + Voice client integration
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>
2025-11-09 00:27:40 +00:00

8.7 KiB

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..."'
);
});