Files
app/app/chat/page.tsx
Albert b51cb1b516 wip: Font and logo fixes in progress
- Reverted logo SVG to original viewBox
- Applied forum.variable to body for CSS variable
- Updated Save button to generate draft from conversation
- Logo size and font variables still need fixes

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-09 16:35:46 +00:00

478 lines
15 KiB
TypeScript

'use client';
import { useChat } from '@ai-sdk/react';
import {
Stack,
TextInput,
Button,
Paper,
ScrollArea,
Title,
Container,
Group,
Text,
Loader,
Tooltip,
} from '@mantine/core';
import { useRef, useEffect, useState } from 'react';
import { IconVolume, IconMicrophone, IconDeviceFloppy } from '@tabler/icons-react';
import { UserMenu } from '@/components/UserMenu';
import { useVoiceMode } from '@/hooks/useVoiceMode';
import { useAppMachine } from '@/hooks/useAppMachine';
import { notifications } from '@mantine/notifications';
import { useMediaQuery } from '@mantine/hooks';
import { useSelector } from '@xstate/react';
/**
* Get the voice button text based on the current state
*/
function getVoiceButtonText(state: any): string {
if (state.matches('idle')) {
return 'Start Voice Conversation';
} else if (state.matches('checkingForGreeting')) {
return 'Checking for greeting...';
} else if (state.matches('listening')) {
return 'Listening... Start speaking';
} else if (state.matches('userSpeaking')) {
return 'Speaking... (will auto-submit after 3s silence)';
} else if (state.matches('timingOut')) {
return 'Speaking... (auto-submits soon)';
} else if (state.matches('submittingUser')) {
return 'Submitting...';
} else if (state.matches('waitingForAI')) {
return 'Waiting for AI...';
} else if (state.matches('generatingTTS')) {
return 'Generating speech...';
} else if (state.matches('playingTTS')) {
return 'AI is speaking...';
}
return 'Start Voice Conversation';
}
export default function ChatPage() {
const viewport = useRef<HTMLDivElement>(null);
const { messages, sendMessage, setMessages, status } = useChat();
const isMobile = useMediaQuery('(max-width: 768px)');
// Text input state (managed manually since useChat doesn't provide form helpers)
const [input, setInput] = useState('');
// App machine for navigation
const appActor = useAppMachine();
// State for creating node
const [isCreatingNode, setIsCreatingNode] = useState(false);
// Check if we have a pending draft (using useSelector for reactivity)
const pendingNodeDraft = useSelector(appActor, (state) => state.context.pendingNodeDraft);
const hasPendingDraft = !!pendingNodeDraft;
// Use the clean voice mode hook
const { state, send, transcript, error } = useVoiceMode({
messages,
status,
onSubmit: (text: string) => {
sendMessage({ text });
},
});
// Handler to create node from conversation
const handleCreateNode = async () => {
if (messages.length === 0) {
notifications.show({
title: 'No conversation',
message: 'Start a conversation before creating a node',
color: 'red',
});
return;
}
setIsCreatingNode(true);
try {
const response = await fetch('/api/generate-node-draft', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include', // Include cookies for authentication
body: JSON.stringify({ messages }),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to generate node draft');
}
const { draft } = await response.json();
// Transition to edit mode with the draft
appActor.send({
type: 'CREATE_NODE_FROM_CONVERSATION',
draft,
});
notifications.show({
title: 'Node draft created',
message: 'Review and edit your node before publishing',
color: 'green',
});
} catch (error) {
console.error('[Create Node] Error:', error);
notifications.show({
title: 'Error',
message: error instanceof Error ? error.message : 'Failed to create node draft',
color: 'red',
});
} finally {
setIsCreatingNode(false);
}
};
// Handler for Manual/Save button
const handleManualOrSave = async () => {
if (hasPendingDraft && pendingNodeDraft) {
// If we have a draft, navigate to edit with it
appActor.send({
type: 'NAVIGATE_TO_EDIT',
draft: pendingNodeDraft,
});
} else {
// Generate a draft from the conversation
if (messages.length === 0) {
notifications.show({
title: 'No conversation',
message: 'Start a conversation before creating a node',
color: 'red',
});
return;
}
setIsCreatingNode(true);
try {
const response = await fetch('/api/generate-node-draft', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ messages }),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to generate node draft');
}
const { draft } = await response.json();
appActor.send({
type: 'CREATE_NODE_FROM_CONVERSATION',
draft,
});
notifications.show({
title: 'Node draft created',
message: 'Review and edit your node before publishing',
color: 'green',
});
} catch (error) {
console.error('[Create Node] Error:', error);
notifications.show({
title: 'Error',
message: error instanceof Error ? error.message : 'Failed to create node draft',
color: 'red',
});
} finally {
setIsCreatingNode(false);
}
}
};
// Add initial greeting message on first load
useEffect(() => {
if (messages.length === 0) {
setMessages([
{
id: 'initial-greeting',
role: 'assistant',
parts: [
{
type: 'text',
text: 'Welcome to Ponderants! I\'m here to help you explore and structure your ideas through conversation.\n\nWhat would you like to talk about today? I can adapt my interview style to best suit your needs (Socratic questioning, collaborative brainstorming, or other approaches).\n\nJust start sharing your thoughts, and we\'ll discover meaningful insights together.',
},
],
} as any,
]);
}
}, []);
// Auto-scroll to bottom
useEffect(() => {
viewport.current?.scrollTo({
top: viewport.current.scrollHeight,
behavior: 'smooth',
});
}, [messages]);
const handleNewConversation = () => {
setMessages([
{
id: 'initial-greeting',
role: 'assistant',
parts: [
{
type: 'text',
text: 'Welcome to Ponderants! I\'m here to help you explore and structure your ideas through conversation.\n\nWhat would you like to talk about today? I can adapt my interview style to best suit your needs (Socratic questioning, collaborative brainstorming, or other approaches).\n\nJust start sharing your thoughts, and we\'ll discover meaningful insights together.',
},
],
} as any,
]);
};
const isVoiceActive = !state.matches('idle');
const canSkipAudio = state.hasTag('canSkipAudio');
return (
<Container size="md" style={{ paddingTop: isMobile ? '0' : '1rem', paddingBottom: '300px', maxWidth: '100%', height: '100vh' }}>
{/* Scrollable Messages Area */}
<ScrollArea h={isMobile ? 'calc(100vh - 250px)' : 'calc(100vh - 300px)'} 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>
{(() => {
if ('parts' in m && Array.isArray((m as any).parts)) {
return (m as any).parts.map((part: any, i: number) => {
if (part.type === 'text') {
return (
<Text key={i} style={{ whiteSpace: 'pre-wrap' }}>
{part.text}
</Text>
);
}
return null;
});
}
return <Text>Message content unavailable</Text>;
})()}
</Paper>
))}
{/* Typing indicator */}
{(status === 'submitted' || status === 'streaming') && (
<Paper
withBorder
shadow="md"
p="sm"
radius="lg"
style={{ alignSelf: 'flex-start', backgroundColor: '#212529' }}
w="80%"
>
<Text fw={700} size="sm">
AI
</Text>
<Group gap="xs" mt="xs">
<Loader size="xs" />
<Text size="sm" c="dimmed">
Thinking...
</Text>
</Group>
</Paper>
)}
{/* Show current transcript while speaking */}
{transcript && (state.matches('userSpeaking') || state.matches('timingOut')) && (
<Paper
withBorder
shadow="md"
p="sm"
radius="lg"
style={{ alignSelf: 'flex-end', backgroundColor: '#343a40' }}
w="80%"
>
<Text fw={700} size="sm">
You (speaking...)
</Text>
<Text style={{ whiteSpace: 'pre-wrap' }}>{transcript}</Text>
</Paper>
)}
</Stack>
</ScrollArea>
{/* Fixed Voice Mode Controls */}
<Paper
withBorder
p="md"
radius={0}
style={{
position: 'fixed',
bottom: isMobile ? '90px' : 0,
left: isMobile ? 0 : '260px',
right: 0,
zIndex: 50,
borderTop: '1px solid #373A40',
backgroundColor: '#1a1b1e',
}}
>
<Container size="md">
<Stack gap="sm">
<Group gap="sm">
{/* Main Voice Button */}
<Button
onClick={() => send({ type: isVoiceActive ? 'STOP_VOICE' : 'START_VOICE' })}
size="xl"
radius="xl"
h={80}
style={{ flex: 1 }}
color={
canSkipAudio
? 'blue'
: state.matches('userSpeaking') || state.matches('timingOut')
? 'green'
: state.matches('listening')
? 'yellow'
: state.matches('waitingForAI') || state.matches('submittingUser')
? 'blue'
: 'gray'
}
variant={isVoiceActive ? 'filled' : 'light'}
leftSection={
canSkipAudio ? (
<IconVolume size={32} />
) : state.matches('userSpeaking') ||
state.matches('timingOut') ||
state.matches('listening') ? (
<IconMicrophone size={32} />
) : (
<IconMicrophone size={32} />
)
}
disabled={status === 'submitted' || status === 'streaming'}
>
{getVoiceButtonText(state)}
</Button>
{/* Skip Button */}
{canSkipAudio && (
<Button
onClick={() => send({ type: 'SKIP_AUDIO' })}
size="xl"
radius="xl"
h={80}
color="gray"
variant="outline"
>
Skip
</Button>
)}
</Group>
{/* Development Test Controls */}
{process.env.NODE_ENV === 'development' && (
<Paper withBorder p="sm" radius="md" style={{ backgroundColor: '#1a1b1e' }}>
<Stack gap="xs">
<Text size="xs" fw={700} c="dimmed">
DEV: State Machine Testing
</Text>
<Text size="xs" c="dimmed">
State: {JSON.stringify(state.value)} | Tags: {Array.from(state.tags).join(', ')}
</Text>
<Group gap="xs">
<Button
size="xs"
onClick={() => send({ type: 'START_LISTENING' })}
disabled={!state.matches('checkingForGreeting')}
>
Force Listen
</Button>
<Button
size="xs"
onClick={() => send({ type: 'USER_STARTED_SPEAKING' })}
disabled={!state.matches('listening')}
>
Simulate Speech
</Button>
<Button
size="xs"
onClick={() => send({ type: 'FINALIZED_PHRASE', phrase: 'Test message' })}
disabled={!state.matches('userSpeaking') && !state.matches('listening')}
>
Add Phrase
</Button>
<Button
size="xs"
onClick={() => send({ type: 'SILENCE_TIMEOUT' })}
disabled={!state.matches('timingOut')}
>
Trigger Timeout
</Button>
</Group>
</Stack>
</Paper>
)}
{/* Text Input */}
<form
onSubmit={(e) => {
e.preventDefault();
if (input.trim() && !isVoiceActive) {
sendMessage({ text: input });
setInput('');
}
}}
>
<Group>
<TextInput
value={input}
onChange={(e) => setInput(e.currentTarget.value)}
placeholder="Or type your thoughts here..."
style={{ flex: 1 }}
variant="filled"
disabled={isVoiceActive}
/>
<Button
onClick={handleManualOrSave}
radius="xl"
variant={hasPendingDraft ? 'filled' : 'light'}
color={hasPendingDraft ? 'blue' : 'gray'}
leftSection={<IconDeviceFloppy size={20} />}
loading={isCreatingNode}
disabled={isCreatingNode}
>
{hasPendingDraft ? 'Save Draft' : 'Save'}
</Button>
<Button
type="submit"
radius="xl"
loading={status === 'submitted' || status === 'streaming'}
disabled={!input.trim() || isVoiceActive}
>
Send
</Button>
</Group>
</form>
{/* Error Display */}
{error && (
<Text size="sm" c="red">
Error: {error}
</Text>
)}
</Stack>
</Container>
</Paper>
</Container>
);
}