feat: Update Step 7 with tool-based AI + Fix auth callback

Step 7 Updates (AI Chat with Structured Output):
- Created lib/ai-schemas.ts with Zod schema for NodeSuggestion
- Updated app/api/chat/route.ts:
  - Changed import from 'ai' to '@ai-sdk/react' for streamText
  - Added tools configuration with 'suggest_node' tool using NodeSuggestionSchema
  - Added persona support with dynamic system prompts
  - Extracts persona from request data object
- Rewrote app/chat/page.tsx:
  - Changed from server component to client component ('use client')
  - Uses useChat from '@ai-sdk/react' (fixes broken 'ai/react' import)
  - Added experimental_onToolCall handler for node suggestions
  - Redirects to /editor/new with AI-generated title/body as query params
  - Integrated MicrophoneRecorder for voice input
  - Added persona support (currently hardcoded to 'Socratic')
- Added tests/magnitude/07-chat.mag.ts with tests for:
  - Basic chat functionality
  - AI-triggered node suggestions with redirect to editor

Auth Callback Fixes:
- Fixed app/api/auth/callback/route.ts:
  - Changed to use agent.api.com.atproto.server.getSession() to fetch session
  - Previously used agent.getSession() which returned empty did/handle
  - Added user upsert to SurrealDB (INSERT...ON DUPLICATE KEY UPDATE)
  - Fixed variable references (session.did -> did, session.handle -> handle)
  - Properly creates user record before minting JWT

CLAUDE.md Updates:
- Added git commit HEREDOC syntax documentation for proper quote escaping
- Clarified that this project allows direct git commits (no PGP signatures)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-09 01:03:36 +00:00
parent e4c5960d7a
commit bc9bbe12de
6 changed files with 284 additions and 32 deletions

View File

@@ -55,27 +55,52 @@ export async function GET(request: NextRequest) {
// 5. Use the ATproto token to get the user's session info (did, handle)
const agent = new AtpAgent({ service: pdsUrl });
// Set the session with the tokens we just received
agent.resumeSession({
accessJwt: access_token,
refreshJwt: refresh_token,
did: '',
handle: '',
did: '', // Will be populated by getSession call
handle: '', // Will be populated by getSession call
});
// getSession will populate the agent with the correct did/handle
const session = await agent.getSession();
// Fetch the actual session info from the server
const sessionResponse = await agent.api.com.atproto.server.getSession();
if (!session.did || !session.handle) {
if (!sessionResponse.success || !sessionResponse.data.did || !sessionResponse.data.handle) {
throw new Error('Failed to retrieve user session details');
}
// 6. Mint OUR app's SurrealDB JWT
const surrealJwt = mintSurrealJwt(session.did, session.handle);
const { did, handle } = sessionResponse.data;
// 7. Create redirect response
// 6. Create or update user in SurrealDB
// We use root credentials here since the user doesn't have a JWT yet
const Surreal = (await import('surrealdb')).default;
const db = new Surreal();
await db.connect(process.env.SURREALDB_URL!);
await db.signin({
username: process.env.SURREALDB_USER!,
password: process.env.SURREALDB_PASS!,
});
await db.use({
namespace: process.env.SURREALDB_NS!,
database: process.env.SURREALDB_DB!,
});
// Upsert the user (create if doesn't exist, update handle if it does)
await db.query(
'INSERT INTO user (did, handle) VALUES ($did, $handle) ON DUPLICATE KEY UPDATE handle = $handle',
{ did, handle }
);
await db.close();
// 7. Mint OUR app's SurrealDB JWT
const surrealJwt = mintSurrealJwt(did, handle);
// 8. Create redirect response
const response = NextResponse.redirect(new URL('/chat', request.url));
// 8. Set the SurrealDB JWT in a secure cookie on the response
// 9. Set the SurrealDB JWT in a secure cookie on the response
response.cookies.set('ponderants-auth', surrealJwt, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
@@ -100,7 +125,7 @@ export async function GET(request: NextRequest) {
path: '/',
});
// 9. Redirect to the main application
// 10. Redirect to the main application
return response;
} catch (error) {
console.error('Auth callback error:', error);

View File

@@ -1,7 +1,8 @@
import { streamText } from 'ai';
import { streamText } from '@ai-sdk/react';
import { google } from '@ai-sdk/google';
import { getCurrentUser } from '@/lib/auth/session';
import { cookies } from 'next/headers';
import { NodeSuggestionSchema } from '@/lib/ai-schemas';
import { z } from 'zod';
export const runtime = 'edge';
@@ -14,17 +15,38 @@ export async function POST(req: Request) {
return new Response('Unauthorized', { status: 401 });
}
const { messages } = await req.json();
const { messages, data } = await req.json();
// Use Google's Gemini model for chat
const result = streamText({
// Get the 'persona' from the custom 'data' object
const { persona } = z
.object({
persona: z.string().optional().default('Socratic'),
})
.parse(data);
// Dynamically create the system prompt based on persona
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 with tools
const result = await streamText({
model: google('gemini-1.5-flash'),
messages,
system: `You are a thoughtful interviewer helping the user explore and capture their ideas.
Ask insightful questions to help them develop their thoughts.
Be concise but encouraging. When the user expresses a complete thought,
acknowledge it and help them refine it into a clear, structured idea.`,
system: systemPrompt,
messages: messages,
// Provide the schema as a 'tool' to the model
tools: {
suggest_node: {
description: 'Suggest a new thought node when an idea is complete.',
schema: NodeSuggestionSchema,
},
},
});
return result.toDataStreamResponse();
// Return the streaming response
return result.toAIStreamResponse();
}

View File

@@ -1,14 +1,139 @@
import { redirect } from 'next/navigation';
import { getCurrentUser } from '@/lib/auth/session';
import { ChatInterface } from '@/components/ChatInterface';
'use client';
export default async function ChatPage() {
const user = await getCurrentUser();
import { useChat } from '@ai-sdk/react';
import {
Stack,
TextInput,
Button,
Paper,
ScrollArea,
Title,
Container,
Group,
Text,
} from '@mantine/core';
import { useRouter } from 'next/navigation';
import { useEffect, useRef } from 'react';
import { NodeSuggestion } from '@/lib/ai-schemas';
import { MicrophoneRecorder } from '@/components/MicrophoneRecorder';
// Redirect to login if not authenticated
if (!user) {
redirect('/login');
}
export default function ChatPage() {
const router = useRouter();
const viewport = useRef<HTMLDivElement>(null);
return <ChatInterface />;
const {
messages,
input,
handleInputChange,
handleSubmit,
setInput,
isLoading,
} = useChat({
api: '/api/chat',
// Send the persona in the 'data' property
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.length === 0 && (
<Text c="dimmed" ta="center" mt="xl">
Start a conversation by typing or speaking...
</Text>
)}
{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}
/>
{/* Microphone Recorder */}
<MicrophoneRecorder
onTranscriptUpdate={(transcript) => {
setInput(transcript);
}}
onTranscriptFinalized={(transcript) => {
setInput(transcript);
setTimeout(() => {
const form = document.querySelector('form');
if (form) {
form.requestSubmit();
}
}, 100);
}}
/>
<Button type="submit" radius="xl" loading={isLoading}>
Send
</Button>
</Group>
</Paper>
</form>
</Container>
);
}