- 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>
478 lines
15 KiB
TypeScript
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>
|
|
);
|
|
}
|