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>
This commit is contained in:
2025-11-09 00:27:40 +00:00
parent 393be3c46e
commit e4c5960d7a
8 changed files with 599 additions and 180 deletions

View File

@@ -1,202 +1,286 @@
# **File: COMMIT\_06\_WRITE\_FLOW.md**
# **File: COMMIT\_07\_CHAT.md**
## **Commit 6: Core Write-Through Cache API**
## **Commit 7: AI Interviewer: UI & Backend**
### **Objective**
Implement the POST /api/nodes route. This is the core "write-through cache" logic, which is the architectural foundation of the application. It must:
Build the conversational chat interface using the Vercel AI SDK. This includes:
1. Authenticate the user via their SurrealDB JWT.
2. Retrieve their ATproto access token (from the encrypted cookie).
3. **Step 1 (Truth):** Publish the new node to their PDS using the com.ponderants.node lexicon.
4. **Step 2 (Cache):** Generate a gemini-embedding-001 vector from the node's body.
5. **Step 3 (Cache):** Write the node, its atp\_uri, and its embedding to our SurrealDB cache.
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\. Create lib/db.ts**
**1\. Define Structured Output Schema (lib/ai-schemas.ts)**
Create a helper file at /lib/db.ts for connecting to SurrealDB:
Create a file at /lib/ai-schemas.ts to define the Zod schema for the AI's structured output:
TypeScript
import { Surreal } from 'surrealdb.js';
const db \= new Surreal();
import { z } from 'zod';
/\*\*
\* Connects to the SurrealDB instance.
\* @param {string} token \- The user's app-specific (SurrealDB) JWT.
\* 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 async function connectToDB(token: string) {
if (\!db.connected) {
await db.connect(process.env.SURREALDB\_URL\!);
}
// Authenticate as the user for this request.
// This enforces the row-level security (PERMISSIONS)
// defined in the schema for all subsequent queries.
await db.authenticate(token);
return db;
}
**2\. Create lib/ai.ts**
Create a helper file at /lib/ai.ts for AI operations:
TypeScript
import { GoogleGenerativeAI } from '@google/generative-ai';
const genAI \= new GoogleGenerativeAI(process.env.GOOGLE\_API\_KEY\!);
const embeddingModel \= genAI.getGenerativeModel({
model: 'gemini-embedding-001',
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.'),
});
/\*\*
\* Generates a vector embedding for a given text.
\* @param text The text to embed.
\* @returns A 1536-dimension vector (Array\<number\>).
\*/
export async function generateEmbedding(text: string): Promise\<number\> {
try {
const result \= await embeddingModel.embedContent(text);
return result.embedding.values;
} catch (error) {
console.error('Error generating embedding:', error);
throw new Error('Failed to generate AI embedding.');
}
}
export type NodeSuggestion \= z.infer\<typeof NodeSuggestionSchema\>;
**3\. Create Write API Route (app/api/nodes/route.ts)**
**2\. Create Chat API Route (app/api/chat/route.ts)**
Create the main API file at /app/api/nodes/route.ts:
Create the file at /app/api/chat/route.ts:
TypeScript
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { AtpAgent, RichText } from '@atproto/api';
import { connectToDB } from '@/lib/db';
import { generateEmbedding } from '@/lib/ai';
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';
export async function POST(request: NextRequest) {
const surrealJwt \= cookies().get('ponderants-auth')?.value;
const atpAccessToken \= cookies().get('atproto\_access\_token')?.value;
// Note: Ensure GOOGLE\_API\_KEY is set in your.env.local
const googleAi \= google('gemini-1.5-flash');
if (\!surrealJwt ||\!atpAccessToken) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
export async function POST(req: NextRequest) {
const { messages, data } \= await req.json();
let userDid: string;
try {
// Decode the JWT to get the DID for the SurrealDB query
// In a real app, we'd verify it, but for now we just
// pass it to connectToDB which authenticates with it.
const { payload } \= jwt.decode(surrealJwt, { complete: true })\!;
userDid \= (payload as { did: string }).did;
} catch (e) {
return NextResponse.json({ error: 'Invalid auth token' }, { status: 401 });
}
// Get the 'persona' from the custom 'data' (or 'body') object
const { persona } \= z
.object({
persona: z.string().optional().default('Socratic'),
})
.parse(data);
const { title, body, links } \= (await request.json()) as {
title: string;
body: string;
links: string; // Array of at-uri strings
};
// 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.\`;
if (\!title ||\!body) {
return NextResponse.json({ error: 'Title and body are required' }, { status: 400 });
}
const createdAt \= new Date().toISOString();
// \--- Step 1: Write to Source of Truth (ATproto) \---
let atp\_uri: string;
let atp\_cid: string;
try {
const agent \= new AtpAgent({ service: 'https://bsky.social' }); // The service URL may need to be dynamic
await agent.resumeSession({ accessJwt: atpAccessToken, did: userDid, handle: '' }); // Simplified resume
// Format the body as RichText
const rt \= new RichText({ text: body });
await rt.detectFacets(agent); // Detect links, mentions
const response \= await agent.post({
$type: 'com.ponderants.node',
repo: userDid,
collection: 'com.ponderants.node',
record: {
title,
body: rt.text,
facets: rt.facets, // Include facets for rich text
links: links?.map(uri \=\> ({ $link: uri })) ||, // Convert URIs to strong refs
createdAt,
},
});
// Use the Vercel AI SDK's streamText function
const result: StreamTextResult \= await streamText({
model: googleAi,
system: systemPrompt,
messages: messages,
atp\_uri \= response.uri;
atp\_cid \= response.cid;
// 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();
}
} catch (error) {
console.error('ATproto write error:', error);
return NextResponse.json({ error: 'Failed to publish to PDS' }, { status: 500 });
}
**3\. Create Chat UI (app/chat/page.tsx)**
// \--- Step 2: Generate AI Embedding (Cache) \---
let embedding: number;
try {
embedding \= await generateEmbedding(title \+ '\\n' \+ body);
} catch (error) {
console.error('Embedding error:', error);
return NextResponse.json({ error: 'Failed to generate embedding' }, { status: 500 });
}
Create the file at /app/chat/page.tsx:
// \--- Step 3: Write to App View Cache (SurrealDB) \---
try {
const db \= await connectToDB(surrealJwt);
TypeScript
// Create the node record in our cache.
// The \`user\_did\` field is set, satisfying the 'PERMISSIONS'
// clause defined in the schema.
const newNode \= await db.create('node', {
user\_did: userDid,
atp\_uri: atp\_uri,
title: title,
body: body, // Store the raw text body
embedding: embedding,
// coords\_3d will be calculated later
});
'use client';
// Handle linking
if (links && links.length \> 0) {
// Find the corresponding cache nodes for the AT-URIs
const targetNodes: { id: string } \= await db.query(
'SELECT id FROM node WHERE user\_did \= $did AND atp\_uri IN $links',
{ did: userDid, links: links }
);
// Create graph relations
for (const targetNode of targetNodes) {
await db.query('RELATE $from-\>links\_to-\>$to', {
from: (newNode as any).id,
to: targetNode.id,
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}"\`,
});
}
}
return NextResponse.json(newNode);
},
});
} catch (error) {
console.error('SurrealDB write error:', error);
// TODO: Implement rollback for the ATproto post?
return NextResponse.json({ error: 'Failed to save to app cache' }, { status: 500 });
}
// 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**
This is an API-only commit. It will be tested via the end-to-end flow in **Commit 10 (Linking)**, which will provide the UI (the "Publish" button) to trigger this route.
**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..."'
);
});