Initial commit: RA-H Open Source Edition
Local-first knowledge management system with BYO API keys. Features: - 3-panel UI (Nodes | Focus | Helpers) - SQLite + sqlite-vec for vector search - Agent system (Easy/Hard mode orchestrators) - Content extraction (YouTube, PDF, web) - Integrate workflow for connection discovery - Dimension system with auto-assignment Tech stack: - Next.js 15 + TypeScript + Tailwind CSS - Anthropic (Claude) + OpenAI (GPT) via Vercel AI SDK Setup: npm install && npm rebuild better-sqlite3 scripts/dev/bootstrap-local.sh npm run dev MIT License
This commit is contained in:
@@ -0,0 +1,593 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import RAHChat from './RAHChat';
|
||||
import QuickAddInput from './QuickAddInput';
|
||||
import QuickAddStatus from './QuickAddStatus';
|
||||
import { Zap, Flame } from 'lucide-react';
|
||||
import type { AgentDelegation } from '@/services/agents/delegation';
|
||||
import { Node } from '@/types/database';
|
||||
|
||||
interface AgentsPanelProps {
|
||||
openTabsData: Node[];
|
||||
activeTabId: number | null;
|
||||
onNodeClick?: (nodeId: number) => void;
|
||||
}
|
||||
|
||||
type ActiveTab = 'ra-h' | string; // 'ra-h' or delegation sessionId
|
||||
type Mode = 'quickadd' | 'session';
|
||||
|
||||
export default function AgentsPanel({ openTabsData, activeTabId, onNodeClick }: AgentsPanelProps) {
|
||||
const [delegationsMap, setDelegationsMap] = useState<Record<string, AgentDelegation>>({});
|
||||
const [activeAgentTab, setActiveAgentTab] = useState<ActiveTab>('ra-h');
|
||||
const [mode, setMode] = useState<Mode>('quickadd');
|
||||
const [rahMode, setRahMode] = useState<'easy' | 'hard'>('easy');
|
||||
const [modelDropdownOpen, setModelDropdownOpen] = useState(false);
|
||||
// Lift messages state to prevent losing it on tab switch
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const [rahMessages, setRahMessages] = useState<any[]>([]);
|
||||
|
||||
// Store delegation messages per sessionId
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const [delegationMessages, setDelegationMessages] = useState<Record<string, any[]>>({});
|
||||
|
||||
const getDelegationMessages = useCallback((sessionId: string) => {
|
||||
return delegationMessages[sessionId] || [];
|
||||
}, [delegationMessages]);
|
||||
|
||||
const setDelegationMessagesFor = useCallback((sessionId: string) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return (updater: (prev: any[]) => any[]) => {
|
||||
setDelegationMessages(prev => ({
|
||||
...prev,
|
||||
[sessionId]: updater(prev[sessionId] || [])
|
||||
}));
|
||||
};
|
||||
}, []);
|
||||
|
||||
const upsertDelegation = useCallback((delegation: AgentDelegation) => {
|
||||
setDelegationsMap((prev) => ({
|
||||
...prev,
|
||||
[delegation.sessionId]: delegation,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const loadExisting = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/rah/delegations?status=active&includeCompleted=true');
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
if (!Array.isArray(data.delegations)) return;
|
||||
|
||||
setDelegationsMap((prev) => {
|
||||
if (cancelled) return prev;
|
||||
const next = { ...prev };
|
||||
for (const delegation of data.delegations as AgentDelegation[]) {
|
||||
next[delegation.sessionId] = delegation;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[AgentsPanel] Failed to load delegations:', error);
|
||||
}
|
||||
};
|
||||
|
||||
loadExisting();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const stored = window.localStorage.getItem('rah-mode');
|
||||
if (stored === 'easy' || stored === 'hard') {
|
||||
setRahMode(stored);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.localStorage.setItem('rah-mode', rahMode);
|
||||
}, [rahMode]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ mode: 'easy' | 'hard' }>).detail;
|
||||
if (detail?.mode) {
|
||||
setRahMode(detail.mode);
|
||||
}
|
||||
};
|
||||
const quickAddHandler = () => setMode('quickadd');
|
||||
window.addEventListener('rah:mode-toggle', handler as EventListener);
|
||||
window.addEventListener('rah:switch-quickadd', quickAddHandler);
|
||||
return () => {
|
||||
window.removeEventListener('rah:mode-toggle', handler as EventListener);
|
||||
window.removeEventListener('rah:switch-quickadd', quickAddHandler);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleCreated = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ delegation: AgentDelegation }>).detail;
|
||||
console.log('[AgentsPanel] Delegation created:', detail?.delegation);
|
||||
if (detail?.delegation) upsertDelegation(detail.delegation);
|
||||
};
|
||||
const handleUpdated = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ delegation: AgentDelegation }>).detail;
|
||||
console.log('[AgentsPanel] Delegation updated:', detail?.delegation);
|
||||
if (detail?.delegation) upsertDelegation(detail.delegation);
|
||||
};
|
||||
|
||||
window.addEventListener('delegations:created', handleCreated as EventListener);
|
||||
window.addEventListener('delegations:updated', handleUpdated as EventListener);
|
||||
return () => {
|
||||
window.removeEventListener('delegations:created', handleCreated as EventListener);
|
||||
window.removeEventListener('delegations:updated', handleUpdated as EventListener);
|
||||
};
|
||||
}, [upsertDelegation]);
|
||||
|
||||
const delegations = useMemo(() => Object.values(delegationsMap), [delegationsMap]);
|
||||
|
||||
const orderedDelegations = useMemo(() => {
|
||||
const statusWeight = (status: AgentDelegation['status']) => {
|
||||
switch (status) {
|
||||
case 'queued':
|
||||
return 0;
|
||||
case 'in_progress':
|
||||
return 1;
|
||||
case 'completed':
|
||||
return 2;
|
||||
case 'failed':
|
||||
default:
|
||||
return 3;
|
||||
}
|
||||
};
|
||||
|
||||
// No staleness filtering - delegations persist until user closes them
|
||||
const ordered = delegations.sort((a, b) => {
|
||||
const diff = statusWeight(a.status) - statusWeight(b.status);
|
||||
if (diff !== 0) return diff;
|
||||
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
|
||||
});
|
||||
|
||||
const wise = ordered.filter((d) => d.agentType === 'wise-rah');
|
||||
const mini = ordered.filter((d) => d.agentType !== 'wise-rah');
|
||||
|
||||
return [...wise, ...mini];
|
||||
}, [delegations]);
|
||||
|
||||
// Don't auto-switch tabs - user stays in ra-h to see responses
|
||||
|
||||
const selectedDelegation = orderedDelegations.find(d => d.sessionId === activeAgentTab);
|
||||
|
||||
const handleQuickAddSubmit = async ({ input, mode }: { input: string; mode: 'link' | 'note' | 'chat' }) => {
|
||||
try {
|
||||
const response = await fetch('/api/quick-add', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ input, mode })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Failed to submit Quick Add');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[AgentsPanel] Quick Add error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelegationClick = (sessionId: string) => {
|
||||
setActiveAgentTab(sessionId);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', background: '#0a0a0a' }}>
|
||||
{/* Mode Header */}
|
||||
{mode === 'quickadd' ? (
|
||||
<div style={{
|
||||
padding: '20px',
|
||||
borderBottom: '1px solid #1a1a1a',
|
||||
background: '#0a0a0a',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '24px',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
height: '100%'
|
||||
}}>
|
||||
{/* Top spacer */}
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
{/* Session Section */}
|
||||
<div style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
padding: '20px',
|
||||
textAlign: 'center',
|
||||
width: '100%',
|
||||
maxWidth: '600px'
|
||||
}}>
|
||||
<pre style={{
|
||||
color: '#22c55e',
|
||||
fontSize: '13px',
|
||||
fontFamily: "'JetBrains Mono', ui-monospace, Courier, monospace",
|
||||
lineHeight: '1.1',
|
||||
margin: '0 0 20px 0',
|
||||
letterSpacing: '0',
|
||||
fontWeight: 700,
|
||||
textShadow: '0 0 10px rgba(139, 212, 80, 0.4)'
|
||||
}}>{`
|
||||
██████╗ █████╗ ██╗ ██╗
|
||||
██╔══██╗██╔══██╗ ██║ ██║
|
||||
██████╔╝███████║█████╗███████║
|
||||
██╔══██╗██╔══██║╚════╝██╔══██║
|
||||
██║ ██║██║ ██║ ██║ ██║
|
||||
╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═╝
|
||||
`}</pre>
|
||||
<button
|
||||
onClick={() => {
|
||||
setMode('session');
|
||||
setRahMode('easy'); // Always start new session in easy mode
|
||||
}}
|
||||
style={{
|
||||
width: 'auto',
|
||||
padding: '14px 32px',
|
||||
background: '#22c55e',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
color: '#0a0a0a',
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
fontFamily: "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
letterSpacing: '0.02em'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#2dd46d';
|
||||
e.currentTarget.style.boxShadow = '0 4px 16px rgba(34, 197, 94, 0.4)';
|
||||
e.currentTarget.style.transform = 'translateY(-2px)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = '#22c55e';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
}}
|
||||
>
|
||||
Start Session
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Bottom spacer */}
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
{/* Divider */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
width: '100%',
|
||||
maxWidth: '600px',
|
||||
marginBottom: '12px'
|
||||
}}>
|
||||
<div style={{ flex: 1, height: '1px', background: '#1a1a1a' }} />
|
||||
<span style={{
|
||||
color: '#6b6b6b',
|
||||
fontSize: '12px',
|
||||
fontWeight: 700,
|
||||
fontFamily: "'JetBrains Mono', ui-monospace",
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.15em',
|
||||
padding: '0 12px'
|
||||
}}>or</span>
|
||||
<div style={{ flex: 1, height: '1px', background: '#1a1a1a' }} />
|
||||
</div>
|
||||
|
||||
{/* Quick Add Section */}
|
||||
<div style={{
|
||||
background: 'transparent',
|
||||
padding: '0',
|
||||
width: '100%',
|
||||
maxWidth: '600px',
|
||||
marginBottom: '12px'
|
||||
}}>
|
||||
<QuickAddInput
|
||||
activeDelegations={orderedDelegations}
|
||||
onSubmit={handleQuickAddSubmit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Tab Bar (only show in session mode) */}
|
||||
{mode === 'session' && (
|
||||
<div className="agent-tabs" style={{ position: 'relative' }}>
|
||||
{/* Quick Add button - positioned at far right */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
right: '12px',
|
||||
transform: 'translateY(-50%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
zIndex: 20
|
||||
}}>
|
||||
<button
|
||||
onClick={() => {
|
||||
window.dispatchEvent(new CustomEvent('rah:switch-quickadd'));
|
||||
}}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#fff',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
padding: '4px 8px',
|
||||
transition: 'color 0.2s',
|
||||
fontFamily: 'inherit',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.color = '#d0d0d0';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = '#fff';
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
borderRadius: '50%',
|
||||
background: '#fff',
|
||||
color: '#0a0a0a',
|
||||
fontSize: '12px',
|
||||
lineHeight: 1,
|
||||
fontWeight: 300,
|
||||
flexShrink: 0
|
||||
}}>+</span>
|
||||
Quick Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* RA-H Main Tab */}
|
||||
<button
|
||||
onClick={() => setActiveAgentTab('ra-h')}
|
||||
className={`agent-tab ${activeAgentTab === 'ra-h' ? 'active' : ''}`}
|
||||
>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '6px', color: rahMode === 'easy' ? '#22c55e' : '#f97316' }}>
|
||||
{(rahMode === 'easy' ? <Zap size={12} strokeWidth={2.4} /> : <Flame size={12} strokeWidth={2.4} />)}
|
||||
<span>RA-H · {rahMode === 'easy' ? 'Easy' : 'Hard'}</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Delegation Tabs */}
|
||||
{orderedDelegations.map((delegation) => {
|
||||
const isActive = activeAgentTab === delegation.sessionId;
|
||||
const isWiseRAH = delegation.agentType === 'wise-rah';
|
||||
|
||||
// Status colors based on agent type
|
||||
const statusColor = isWiseRAH
|
||||
? (delegation.status === 'in_progress' ? '#8b5cf6' :
|
||||
delegation.status === 'completed' ? '#6b6b6b' :
|
||||
delegation.status === 'failed' ? '#ff6b6b' : '#a78bfa')
|
||||
: (delegation.status === 'in_progress' ? '#8bd450' :
|
||||
delegation.status === 'completed' ? '#6b6b6b' :
|
||||
delegation.status === 'failed' ? '#ff6b6b' : '#5c9aff');
|
||||
|
||||
const shortId = delegation.sessionId.split('_').pop() || '';
|
||||
const tabLabel = isWiseRAH
|
||||
? `WISE RA-H · ${shortId.slice(0, 6)}`
|
||||
: `MINI · ${shortId.slice(0, 6)}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={delegation.sessionId}
|
||||
className={`agent-tab-wrapper ${isWiseRAH ? 'wise' : 'mini'} ${isActive ? 'active' : ''}`}
|
||||
>
|
||||
<button
|
||||
onClick={() => setActiveAgentTab(delegation.sessionId)}
|
||||
className="agent-tab"
|
||||
>
|
||||
<span className="status-dot" style={{ background: statusColor }} />
|
||||
<span className="agent-tab-label">{tabLabel}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
console.log(`[AgentsPanel] User clicked close button for delegation ${delegation.sessionId}`);
|
||||
|
||||
// Delete from database
|
||||
try {
|
||||
await fetch(`/api/rah/delegations/${delegation.sessionId}`, { method: 'DELETE' });
|
||||
} catch (error) {
|
||||
console.error(`Failed to delete delegation ${delegation.sessionId}:`, error);
|
||||
}
|
||||
|
||||
// Remove from UI state
|
||||
setDelegationsMap((prev) => {
|
||||
const { [delegation.sessionId]: _ignored, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
setDelegationMessages((prev) => {
|
||||
const { [delegation.sessionId]: _ignored, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
if (activeAgentTab === delegation.sessionId) {
|
||||
setActiveAgentTab('ra-h');
|
||||
}
|
||||
}}
|
||||
className="agent-tab-close"
|
||||
title="Close tab"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active Panel */}
|
||||
<div style={{ flex: 1, minHeight: 0, position: 'relative', overflow: 'hidden' }}>
|
||||
{mode === 'quickadd' ? (
|
||||
<div style={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<QuickAddStatus
|
||||
delegations={orderedDelegations}
|
||||
onDelegationClick={handleDelegationClick}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Keep RAHChat always mounted, just hide when not active */}
|
||||
<div style={{
|
||||
height: '100%',
|
||||
display: activeAgentTab === 'ra-h' ? 'block' : 'none'
|
||||
}}>
|
||||
<RAHChat
|
||||
openTabsData={openTabsData}
|
||||
activeTabId={activeTabId}
|
||||
onNodeClick={onNodeClick}
|
||||
delegations={orderedDelegations}
|
||||
messages={rahMessages}
|
||||
setMessages={setRahMessages}
|
||||
mode={rahMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Show delegation chat when delegation tab is active */}
|
||||
{selectedDelegation && activeAgentTab !== 'ra-h' && (
|
||||
<div style={{ height: '100%', display: 'block' }}>
|
||||
<RAHChat
|
||||
openTabsData={openTabsData}
|
||||
activeTabId={activeTabId}
|
||||
onNodeClick={onNodeClick}
|
||||
delegations={orderedDelegations}
|
||||
messages={getDelegationMessages(selectedDelegation.sessionId)}
|
||||
setMessages={setDelegationMessagesFor(selectedDelegation.sessionId)}
|
||||
mode="easy"
|
||||
delegationMode={true}
|
||||
delegationSessionId={selectedDelegation.sessionId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<style jsx>{`
|
||||
.agent-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px 0 12px;
|
||||
background: #0a0a0a;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.agent-tabs::-webkit-scrollbar {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.agent-tabs::-webkit-scrollbar-track {
|
||||
background: #0d0d0d;
|
||||
}
|
||||
|
||||
.agent-tabs::-webkit-scrollbar-thumb {
|
||||
background: #1f1f1f;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.agent-tabs::-webkit-scrollbar-thumb:hover {
|
||||
background: #2c2c2c;
|
||||
}
|
||||
|
||||
.agent-tab-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
border-radius: 10px 10px 0 0;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.agent-tab-wrapper.active {
|
||||
background: #141414;
|
||||
}
|
||||
|
||||
.agent-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 9px 14px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: #6f6f6f;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.agent-tab-wrapper.active .agent-tab {
|
||||
color: #f3f3f3;
|
||||
}
|
||||
|
||||
.agent-tab:hover {
|
||||
color: #d0d0d0;
|
||||
}
|
||||
|
||||
.agent-tab-wrapper.wise .agent-tab-label {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.agent-tab-wrapper.mini .agent-tab-label {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.agent-tab-close {
|
||||
padding: 4px 8px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
font-size: 14px;
|
||||
color: #5a5a5a;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.agent-tab-close:hover {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
interface AsciiBannerProps {
|
||||
helperName: string;
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
export default function AsciiBanner({ helperName, displayName }: AsciiBannerProps) {
|
||||
const name = displayName || helperName;
|
||||
const nameUpper = name.toUpperCase();
|
||||
|
||||
// Create a unique minimal banner
|
||||
const createBanner = () => {
|
||||
// Dynamic sizing based on name length
|
||||
const minWidth = 24;
|
||||
const bannerWidth = Math.max(minWidth, nameUpper.length + 6);
|
||||
const padding = Math.floor((bannerWidth - nameUpper.length - 2) / 2);
|
||||
const paddedName = ' '.repeat(padding) + nameUpper + ' '.repeat(bannerWidth - nameUpper.length - padding - 2);
|
||||
|
||||
const topBottom = '─'.repeat(bannerWidth - 2);
|
||||
|
||||
return `┌${topBottom}┐
|
||||
│${paddedName}│
|
||||
└${topBottom}┘`;
|
||||
};
|
||||
|
||||
const banner = createBanner();
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
padding: '32px 16px',
|
||||
fontFamily: 'inherit',
|
||||
opacity: 0,
|
||||
animation: 'fadeIn 0.5s ease-out forwards',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<pre style={{
|
||||
color: '#353535',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.3',
|
||||
whiteSpace: 'pre',
|
||||
margin: '0 auto',
|
||||
display: 'inline-block',
|
||||
letterSpacing: '0.05em'
|
||||
}}>
|
||||
{banner}
|
||||
</pre>
|
||||
|
||||
<div style={{
|
||||
marginTop: '12px',
|
||||
fontSize: '11px',
|
||||
letterSpacing: '0.15em',
|
||||
textTransform: 'uppercase'
|
||||
}}>
|
||||
<span style={{ color: '#2a2a2a' }}>━━━</span>
|
||||
<span style={{ color: '#353535', margin: '0 8px' }}>ready</span>
|
||||
<span style={{ color: '#2a2a2a' }}>━━━</span>
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
@keyframes fadeIn {
|
||||
to { opacity: 1; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { AgentDelegation } from '@/services/agents/delegation';
|
||||
|
||||
interface DelegationIndicatorProps {
|
||||
delegations: AgentDelegation[];
|
||||
}
|
||||
|
||||
export default function DelegationIndicator({ delegations }: DelegationIndicatorProps) {
|
||||
if (!delegations.length) return null;
|
||||
|
||||
const activeCount = delegations.filter((d) => d.status === 'queued' || d.status === 'in_progress').length;
|
||||
|
||||
if (activeCount === 0) return null;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '4px',
|
||||
background: '#0f0f0f',
|
||||
border: '1px solid #1a1a1a',
|
||||
fontSize: '10px',
|
||||
fontFamily: "'JetBrains Mono', ui-monospace",
|
||||
color: '#6b6b6b'
|
||||
}}>
|
||||
<span style={{
|
||||
width: '6px',
|
||||
height: '6px',
|
||||
borderRadius: '50%',
|
||||
background: '#22c55e'
|
||||
}} />
|
||||
<span>{activeCount} active</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import { ReactNode } from 'react';
|
||||
import type { AgentDelegation } from '@/services/agents/delegation';
|
||||
|
||||
interface MiniRAHPanelProps {
|
||||
delegation: AgentDelegation;
|
||||
onNodeClick?: (nodeId: number) => void;
|
||||
}
|
||||
|
||||
const statusPalette: Record<string, { border: string; badge: string }> = {
|
||||
queued: { border: '#1f3a5f', badge: '#5c9aff' },
|
||||
in_progress: { border: '#3b5f2a', badge: '#8bd450' },
|
||||
completed: { border: '#2a2a2a', badge: '#6b6b6b' },
|
||||
failed: { border: '#5f2a2a', badge: '#ff6b6b' },
|
||||
};
|
||||
|
||||
const NODE_LINK_REGEX = /\[NODE:(\d+):"([^"]+)"\]/g;
|
||||
|
||||
function formatStatus(status: string) {
|
||||
switch (status) {
|
||||
case 'queued':
|
||||
return 'Queued';
|
||||
case 'in_progress':
|
||||
return 'Working';
|
||||
case 'completed':
|
||||
return 'Completed';
|
||||
case 'failed':
|
||||
return 'Failed';
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
function renderNodeAwareLine(text: string, onNodeClick?: (nodeId: number) => void): ReactNode {
|
||||
const parts: ReactNode[] = [];
|
||||
let lastIndex = 0;
|
||||
|
||||
text.replace(NODE_LINK_REGEX, (match, id, title, offset) => {
|
||||
if (offset > lastIndex) {
|
||||
parts.push(<span key={`mini-text-${offset}`}>{text.slice(lastIndex, offset)}</span>);
|
||||
}
|
||||
|
||||
const nodeId = Number(id);
|
||||
const handleClick = () => {
|
||||
if (onNodeClick) onNodeClick(nodeId);
|
||||
};
|
||||
|
||||
parts.push(
|
||||
<button
|
||||
key={`mini-node-${offset}`}
|
||||
type="button"
|
||||
className="node-pill"
|
||||
onClick={handleClick}
|
||||
>
|
||||
<span className="node-pill-id">NODE:{nodeId}</span>
|
||||
<span className="node-pill-title">{title}</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
lastIndex = offset + match.length;
|
||||
return match;
|
||||
});
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(<span key="mini-text-end">{text.slice(lastIndex)}</span>);
|
||||
}
|
||||
|
||||
return <>{parts}</>;
|
||||
}
|
||||
|
||||
export default function MiniRAHPanel({ delegation, onNodeClick }: MiniRAHPanelProps) {
|
||||
const palette = statusPalette[delegation.status] ?? statusPalette.queued;
|
||||
const summaryLines = delegation.summary ? delegation.summary.split('\n').filter(Boolean) : [];
|
||||
|
||||
return (
|
||||
<div className="mini-panel">
|
||||
<header className="mini-header" style={{ borderBottomColor: palette.border }}>
|
||||
<span className="status-dot" style={{ background: palette.badge }} />
|
||||
<span className="header-title">MINI RA-H</span>
|
||||
<span className="header-status">· {formatStatus(delegation.status)}</span>
|
||||
<span className="header-time">{new Date(delegation.updatedAt).toLocaleTimeString()}</span>
|
||||
</header>
|
||||
|
||||
<section className="section">
|
||||
<h3>Task</h3>
|
||||
<p>{delegation.task}</p>
|
||||
</section>
|
||||
|
||||
{delegation.context.length > 0 && (
|
||||
<section className="section">
|
||||
<h3>Context</h3>
|
||||
<ul className="context-list">
|
||||
{delegation.context.map((item, idx) => (
|
||||
<li key={idx}>{renderNodeAwareLine(item, onNodeClick)}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{summaryLines.length > 0 && (
|
||||
<section className="section">
|
||||
<h3>Summary</h3>
|
||||
<div className="summary-card">
|
||||
{summaryLines.map((line, idx) => (
|
||||
<p key={idx}>{renderNodeAwareLine(line, onNodeClick)}</p>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<style jsx>{`
|
||||
.mini-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 24px;
|
||||
background: #0a0a0a;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.mini-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid rgba(91, 154, 255, 0.25);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.12em;
|
||||
color: #84c8ff;
|
||||
}
|
||||
|
||||
.header-status {
|
||||
font-size: 12px;
|
||||
color: #7da0b6;
|
||||
}
|
||||
|
||||
.header-time {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
color: #5f5f5f;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.section h3 {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: #5c9aff;
|
||||
}
|
||||
|
||||
.section p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: #d4d4d4;
|
||||
}
|
||||
|
||||
.context-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.context-list li {
|
||||
font-size: 13px;
|
||||
color: #9aa7b6;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
background: #101010;
|
||||
border: 1px solid rgba(92, 154, 255, 0.15);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.summary-card p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: #f0f0f0;
|
||||
}
|
||||
|
||||
.node-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 8px;
|
||||
margin: 0 4px 4px 0;
|
||||
font-size: 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(92, 154, 255, 0.35);
|
||||
background: rgba(92, 154, 255, 0.08);
|
||||
color: #cfe4ff;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, border 0.15s ease;
|
||||
}
|
||||
|
||||
.node-pill:hover {
|
||||
background: rgba(92, 154, 255, 0.18);
|
||||
border-color: rgba(92, 154, 255, 0.65);
|
||||
}
|
||||
|
||||
.node-pill-id {
|
||||
font-size: 11px;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.node-pill-title {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { AgentDelegation } from '@/services/agents/delegation';
|
||||
|
||||
type QuickAddIntent = 'link' | 'note' | 'chat';
|
||||
|
||||
interface QuickAddSubmitPayload {
|
||||
input: string;
|
||||
mode: QuickAddIntent;
|
||||
}
|
||||
|
||||
interface QuickAddInputProps {
|
||||
activeDelegations: AgentDelegation[];
|
||||
onSubmit: (payload: QuickAddSubmitPayload) => Promise<void>;
|
||||
}
|
||||
|
||||
const MODE_CONFIG: Array<{
|
||||
key: QuickAddIntent;
|
||||
label: string;
|
||||
hint: string;
|
||||
icon: ReactNode;
|
||||
}> = [
|
||||
{
|
||||
key: 'link',
|
||||
label: 'Link',
|
||||
hint: 'Drop URLs for auto YouTube/PDF/Web extraction',
|
||||
icon: (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.35l2.12-2.12a5 5 0 1 0-7.07-7.07l-1.29 1.29" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.35l-2.12 2.12a5 5 0 1 0 7.07 7.07l1.29-1.29" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'note',
|
||||
label: 'Note',
|
||||
hint: 'Quick jot — no extraction, no summary',
|
||||
icon: (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
|
||||
<path d="M12 20h9" strokeLinecap="round" />
|
||||
<path d="M12 4h9" strokeLinecap="round" />
|
||||
<path d="M4 4h1v16H4z" />
|
||||
<path d="M7 9h7" strokeLinecap="round" />
|
||||
<path d="M7 13h5" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'chat',
|
||||
label: 'Chat',
|
||||
hint: 'Paste messy transcripts — store raw text + summary',
|
||||
icon: (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H9l-4 4V5a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2z" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M7 8h10" strokeLinecap="round" />
|
||||
<path d="M7 12h6" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
export default function QuickAddInput({ activeDelegations, onSubmit }: QuickAddInputProps) {
|
||||
const [input, setInput] = useState('');
|
||||
const [isPosting, setIsPosting] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [manualMode, setManualMode] = useState<QuickAddIntent | null>(null);
|
||||
const [autoMode, setAutoMode] = useState<QuickAddIntent>('link');
|
||||
const [autoReason, setAutoReason] = useState<string | null>(null);
|
||||
|
||||
const effectiveMode: QuickAddIntent = manualMode ?? autoMode;
|
||||
|
||||
const currentPlaceholder = useMemo(() => {
|
||||
if (effectiveMode === 'note') {
|
||||
return 'Write a quick note — no extraction, just append to your graph';
|
||||
}
|
||||
if (effectiveMode === 'chat') {
|
||||
return 'Paste any conversation (ChatGPT, Claude, etc.) and we will store raw text + summary';
|
||||
}
|
||||
return "Drop links, URL's, ideas or notes to add new nodes";
|
||||
}, [effectiveMode]);
|
||||
|
||||
const maxConcurrent = 5;
|
||||
const activeCount = activeDelegations.filter(
|
||||
(d) => d.status === 'queued' || d.status === 'in_progress'
|
||||
).length;
|
||||
const isSoftLimited = activeCount >= maxConcurrent;
|
||||
|
||||
const inferChatIntent = (value: string) => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
setAutoMode('link');
|
||||
setAutoReason(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (manualMode) return;
|
||||
|
||||
const newlineCount = (trimmed.match(/\n/g)?.length ?? 0);
|
||||
const looksLikeTranscript =
|
||||
newlineCount >= 2 ||
|
||||
/You said:|ChatGPT said:|Claude said:|Assistant:|User:/i.test(trimmed) ||
|
||||
/\b\d{1,2}:\d{2}\b/.test(trimmed);
|
||||
|
||||
if (looksLikeTranscript && trimmed.length > 280) {
|
||||
setAutoMode('chat');
|
||||
setAutoReason('Detected chat transcript');
|
||||
} else {
|
||||
setAutoMode('link');
|
||||
setAutoReason(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (value: string) => {
|
||||
setInput(value);
|
||||
inferChatIntent(value);
|
||||
};
|
||||
|
||||
const handleModeClick = (mode: QuickAddIntent) => {
|
||||
setManualMode(mode);
|
||||
setAutoMode(mode);
|
||||
setAutoReason(null);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!input.trim() || isPosting || isSoftLimited) return;
|
||||
|
||||
setIsPosting(true);
|
||||
try {
|
||||
await onSubmit({ input: input.trim(), mode: effectiveMode });
|
||||
setInput('');
|
||||
setManualMode(null);
|
||||
setAutoMode('link');
|
||||
setAutoReason(null);
|
||||
} catch (error) {
|
||||
console.error('[QuickAddInput] Submit error:', error);
|
||||
} finally {
|
||||
setIsPosting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
const isLinkMode = effectiveMode === 'link';
|
||||
const shouldSubmit = isLinkMode
|
||||
? (e.key === 'Enter' && !e.shiftKey)
|
||||
: (e.key === 'Enter' && (e.metaKey || e.ctrlKey));
|
||||
|
||||
if (shouldSubmit) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
const renderInputField = () => {
|
||||
if (effectiveMode === 'link') {
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => handleInputChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={currentPlaceholder}
|
||||
disabled={isPosting || isSoftLimited}
|
||||
autoFocus
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '12px 16px',
|
||||
background: '#0a0a0a',
|
||||
border: '2px solid #22c55e',
|
||||
borderRadius: '6px',
|
||||
color: '#e5e5e5',
|
||||
fontSize: '13px',
|
||||
fontFamily: "'JetBrains Mono', ui-monospace",
|
||||
outline: 'none',
|
||||
transition: 'all 0.15s ease',
|
||||
boxShadow: '0 0 0 3px rgba(34, 197, 94, 0.15)'
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.borderColor = '#22c55e';
|
||||
e.currentTarget.style.boxShadow = '0 0 0 3px rgba(34, 197, 94, 0.25)';
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.borderColor = '#22c55e';
|
||||
e.currentTarget.style.boxShadow = '0 0 0 3px rgba(34, 197, 94, 0.15)';
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<textarea
|
||||
value={input}
|
||||
onChange={(e) => handleInputChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={currentPlaceholder}
|
||||
disabled={isPosting || isSoftLimited}
|
||||
autoFocus
|
||||
rows={effectiveMode === 'chat' ? 6 : 4}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '12px 16px',
|
||||
background: '#0a0a0a',
|
||||
border: '2px solid #22c55e',
|
||||
borderRadius: '6px',
|
||||
color: '#e5e5e5',
|
||||
fontSize: '13px',
|
||||
fontFamily: "'JetBrains Mono', ui-monospace",
|
||||
outline: 'none',
|
||||
transition: 'all 0.15s ease',
|
||||
resize: 'vertical',
|
||||
minHeight: effectiveMode === 'chat' ? '150px' : '110px',
|
||||
boxShadow: '0 0 0 3px rgba(34, 197, 94, 0.15)'
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.borderColor = '#22c55e';
|
||||
e.currentTarget.style.boxShadow = '0 0 0 3px rgba(34, 197, 94, 0.25)';
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.borderColor = '#22c55e';
|
||||
e.currentTarget.style.boxShadow = '0 0 0 3px rgba(34, 197, 94, 0.15)';
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '10px'
|
||||
}}>
|
||||
{!isExpanded ? (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '16px'
|
||||
}}>
|
||||
<div style={{
|
||||
color: '#6b6b6b',
|
||||
fontSize: '13px',
|
||||
fontFamily: "'JetBrains Mono', ui-monospace",
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
Quickly add stuff
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsExpanded(true)}
|
||||
style={{
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: '#22c55e',
|
||||
border: 'none',
|
||||
borderRadius: '50%',
|
||||
color: '#0a0a0a',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 150ms ease',
|
||||
fontSize: '28px',
|
||||
fontWeight: 300,
|
||||
lineHeight: 1
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1.05)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 16px rgba(34, 197, 94, 0.4)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
title="Add new content"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
<div style={{ display: 'flex', gap: '8px', justifyContent: 'center' }}>
|
||||
{MODE_CONFIG.map((mode) => {
|
||||
const isActive = effectiveMode === mode.key && (manualMode === mode.key || (!manualMode && autoMode === mode.key));
|
||||
return (
|
||||
<button
|
||||
key={mode.key}
|
||||
type="button"
|
||||
onClick={() => handleModeClick(mode.key)}
|
||||
title={mode.hint}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
padding: '6px 12px',
|
||||
borderRadius: '999px',
|
||||
border: `1px solid ${isActive ? '#22c55e' : '#1f1f1f'}`,
|
||||
background: isActive ? 'rgba(34, 197, 94, 0.15)' : 'transparent',
|
||||
color: isActive ? '#22c55e' : '#9c9c9c',
|
||||
fontSize: '11px',
|
||||
fontFamily: "'JetBrains Mono', ui-monospace",
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.08em',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease'
|
||||
}}
|
||||
>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}>{mode.icon}</span>
|
||||
{mode.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{autoReason && !manualMode && (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
color: '#9c9c9c',
|
||||
fontSize: '11px',
|
||||
letterSpacing: '0.05em',
|
||||
textTransform: 'uppercase'
|
||||
}}>
|
||||
Auto-detected chat transcript
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'stretch' }}>
|
||||
{renderInputField()}
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!input.trim() || isPosting || isSoftLimited}
|
||||
aria-label={isPosting ? 'Adding' : 'Add'}
|
||||
title={isPosting ? 'Adding…' : 'Add (Enter)'}
|
||||
style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: input.trim() && !isPosting && !isSoftLimited ? '#22c55e' : '#22c55e',
|
||||
border: `2px solid #22c55e`,
|
||||
borderRadius: '50%',
|
||||
color: input.trim() && !isPosting && !isSoftLimited ? '#0a0a0a' : '#0a0a0a',
|
||||
cursor: input.trim() && !isPosting && !isSoftLimited ? 'pointer' : 'not-allowed',
|
||||
transition: 'all 150ms ease',
|
||||
opacity: input.trim() && !isPosting && !isSoftLimited ? 1 : 0.7,
|
||||
flexShrink: 0
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (input.trim() && !isPosting && !isSoftLimited) {
|
||||
e.currentTarget.style.transform = 'translateY(-1px)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(34, 197, 94, 0.4)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (input.trim() && !isPosting && !isSoftLimited) {
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isPosting ? (
|
||||
<span style={{ fontSize: '12px' }}>•••</span>
|
||||
) : (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M12 4l-6.5 6.5 1.42 1.42L11 8.84V20h2V8.84l4.08 3.08 1.42-1.42L12 4z"/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active processing indicator */}
|
||||
{activeCount > 0 && (
|
||||
<div style={{
|
||||
padding: '10px 14px',
|
||||
background: '#0a1a0a',
|
||||
border: '1px solid #1a3a1a',
|
||||
borderRadius: '6px',
|
||||
color: '#22c55e',
|
||||
fontSize: '11px',
|
||||
fontFamily: "'JetBrains Mono', ui-monospace",
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}>
|
||||
<span style={{
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: '50%',
|
||||
background: '#22c55e',
|
||||
animation: 'pulse 2s ease-in-out infinite'
|
||||
}} />
|
||||
<span>
|
||||
{activeCount === 1
|
||||
? 'Adding 1 node to your database...'
|
||||
: `Adding ${activeCount} nodes to your database...`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isSoftLimited && (
|
||||
<div style={{
|
||||
padding: '10px 14px',
|
||||
background: '#1a0a00',
|
||||
border: '1px solid #3a2a1a',
|
||||
borderRadius: '6px',
|
||||
color: '#ff9b5c',
|
||||
fontSize: '11px',
|
||||
fontFamily: "'JetBrains Mono', ui-monospace",
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}>
|
||||
<span>⚠</span>
|
||||
<span>Finish one of the 5 active Quick Adds before adding more.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style jsx>{`
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
"use client";
|
||||
|
||||
import type { AgentDelegation } from '@/services/agents/delegation';
|
||||
|
||||
interface QuickAddStatusProps {
|
||||
delegations: AgentDelegation[];
|
||||
onDelegationClick: (sessionId: string) => void;
|
||||
}
|
||||
|
||||
export default function QuickAddStatus({ delegations, onDelegationClick }: QuickAddStatusProps) {
|
||||
const activeDelegations = delegations.filter(d => d.status === 'queued' || d.status === 'in_progress');
|
||||
const completedDelegations = delegations.filter(d => d.status === 'completed' || d.status === 'failed');
|
||||
|
||||
console.log('[QuickAddStatus] Rendering with', delegations.length, 'total delegations,', activeDelegations.length, 'active');
|
||||
|
||||
const handleClearCompleted = async () => {
|
||||
for (const delegation of completedDelegations) {
|
||||
try {
|
||||
await fetch(`/api/rah/delegations/${delegation.sessionId}`, { method: 'DELETE' });
|
||||
} catch (error) {
|
||||
console.error('Failed to delete delegation:', error);
|
||||
}
|
||||
}
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
padding: '12px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
overflowY: 'auto',
|
||||
height: '100%',
|
||||
background: '#0a0a0a'
|
||||
}}>
|
||||
{/* Header */}
|
||||
{delegations.length > 0 && (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '8px 12px',
|
||||
background: '#151515',
|
||||
borderRadius: '6px',
|
||||
color: '#a8a8a8',
|
||||
fontSize: '11px',
|
||||
fontFamily: "'JetBrains Mono', ui-monospace",
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
borderLeft: activeDelegations.length > 0 ? '3px solid #22c55e' : '3px solid #6b6b6b'
|
||||
}}>
|
||||
<span>
|
||||
{activeDelegations.length > 0
|
||||
? `Processing ${activeDelegations.length} Quick Add${activeDelegations.length > 1 ? 's' : ''}...`
|
||||
: `${delegations.length} Recent Quick Add${delegations.length > 1 ? 's' : ''}`
|
||||
}
|
||||
</span>
|
||||
{completedDelegations.length > 0 && (
|
||||
<button
|
||||
onClick={handleClearCompleted}
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
background: '#1a1a1a',
|
||||
border: '1px solid #2a2a2a',
|
||||
borderRadius: '4px',
|
||||
color: '#6b6b6b',
|
||||
fontSize: '10px',
|
||||
fontFamily: "'JetBrains Mono', ui-monospace",
|
||||
cursor: 'pointer',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
transition: 'all 0.15s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#222';
|
||||
e.currentTarget.style.color = '#a8a8a8';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = '#1a1a1a';
|
||||
e.currentTarget.style.color = '#6b6b6b';
|
||||
}}
|
||||
>
|
||||
Clear Completed
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{delegations.map((delegation) => {
|
||||
const statusColor = delegation.status === 'in_progress' ? '#22c55e' :
|
||||
delegation.status === 'completed' ? '#6b6b6b' :
|
||||
delegation.status === 'failed' ? '#ff6b6b' : '#5c9aff';
|
||||
|
||||
const statusLabel = delegation.status === 'in_progress' ? 'Processing' :
|
||||
delegation.status === 'completed' ? 'Done' :
|
||||
delegation.status === 'failed' ? 'Failed' : 'Queued';
|
||||
|
||||
return (
|
||||
<button
|
||||
key={delegation.sessionId}
|
||||
onClick={() => onDelegationClick(delegation.sessionId)}
|
||||
style={{
|
||||
padding: '12px',
|
||||
background: '#151515',
|
||||
border: '1px solid #2a2a2a',
|
||||
borderRadius: '6px',
|
||||
textAlign: 'left',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '6px'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#1a1a1a';
|
||||
e.currentTarget.style.borderColor = '#3a3a3a';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = '#151515';
|
||||
e.currentTarget.style.borderColor = '#2a2a2a';
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: '8px'
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}>
|
||||
<span style={{
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: '50%',
|
||||
background: statusColor,
|
||||
flexShrink: 0
|
||||
}} />
|
||||
<span style={{
|
||||
color: '#e5e5e5',
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
fontFamily: "'JetBrains Mono', ui-monospace",
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.04em'
|
||||
}}>
|
||||
{statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
<span style={{
|
||||
color: '#6b6b6b',
|
||||
fontSize: '11px',
|
||||
fontFamily: "'JetBrains Mono', ui-monospace"
|
||||
}}>
|
||||
{new Date(delegation.createdAt).toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
color: '#a8a8a8',
|
||||
fontSize: '12px',
|
||||
fontFamily: "'JetBrains Mono', ui-monospace",
|
||||
lineHeight: '1.5',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
}}>
|
||||
{delegation.task}
|
||||
</div>
|
||||
|
||||
{delegation.summary && delegation.status === 'completed' && (
|
||||
<div style={{
|
||||
color: '#6b6b6b',
|
||||
fontSize: '11px',
|
||||
fontFamily: "'JetBrains Mono', ui-monospace",
|
||||
lineHeight: '1.4',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
}}>
|
||||
{delegation.summary}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{delegation.summary && delegation.status === 'failed' && (
|
||||
<div style={{
|
||||
color: '#ff6b6b',
|
||||
fontSize: '11px',
|
||||
fontFamily: "'JetBrains Mono', ui-monospace",
|
||||
lineHeight: '1.4'
|
||||
}}>
|
||||
{delegation.summary}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,691 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Node } from '@/types/database';
|
||||
import AsciiBanner from './AsciiBanner';
|
||||
import TerminalMessage from './TerminalMessage';
|
||||
import TerminalInput from './TerminalInput';
|
||||
import { Zap, Flame } from 'lucide-react';
|
||||
import DelegationIndicator from './DelegationIndicator';
|
||||
import type { AgentDelegation } from '@/services/agents/delegation';
|
||||
import { useSSEChat, ChatMessage, MessageRole } from './hooks/useSSEChat';
|
||||
import { useQuotaHandler } from '@/hooks/useQuotaHandler';
|
||||
import { apiKeyService } from '@/services/storage/apiKeys';
|
||||
import { useVoiceSession } from './hooks/useVoiceSession';
|
||||
import { useAssistantTTS } from './hooks/useAssistantTTS';
|
||||
import { useRealtimeVoiceClient } from './hooks/useRealtimeVoiceClient';
|
||||
import { useVoiceInterruption } from './hooks/useVoiceInterruption';
|
||||
|
||||
const createSessionId = () => `session_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
const createVoiceRequestId = () =>
|
||||
typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
|
||||
? crypto.randomUUID()
|
||||
: `voice_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
interface RAHChatProps {
|
||||
openTabsData: Node[];
|
||||
activeTabId: number | null;
|
||||
onNodeClick?: (nodeId: number) => void;
|
||||
delegations?: AgentDelegation[];
|
||||
messages?: ChatMessage[];
|
||||
setMessages?: (updater: (prev: ChatMessage[]) => ChatMessage[]) => void;
|
||||
mode?: 'easy' | 'hard';
|
||||
delegationMode?: boolean;
|
||||
delegationSessionId?: string;
|
||||
}
|
||||
|
||||
export default function RAHChat({
|
||||
openTabsData,
|
||||
activeTabId,
|
||||
onNodeClick,
|
||||
delegations = [],
|
||||
messages: externalMessages,
|
||||
setMessages: externalSetMessages,
|
||||
mode = 'easy',
|
||||
delegationMode = false,
|
||||
delegationSessionId,
|
||||
}: RAHChatProps) {
|
||||
// Use external state if provided (lifted state), otherwise use local state
|
||||
const [internalMessages, internalSetMessages] = useState<ChatMessage[]>([]);
|
||||
const messages = externalMessages !== undefined ? externalMessages : internalMessages;
|
||||
const setMessages = externalSetMessages || internalSetMessages;
|
||||
|
||||
const [sessionId, setSessionId] = useState(() => createSessionId());
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const chatMode = mode === 'hard' ? 'hard' : 'easy';
|
||||
const helperKey = chatMode === 'hard' ? 'ra-h' : 'ra-h-easy';
|
||||
const helperDisplayName = helperKey === 'ra-h' ? 'ra-h (hard)' : 'ra-h (easy)';
|
||||
const {
|
||||
quotaError,
|
||||
handleAPIError: handleQuotaApiError,
|
||||
checkQuotaBeforeRequest,
|
||||
refetchUsage,
|
||||
isQuotaExceeded,
|
||||
} = useQuotaHandler();
|
||||
const streamCompleteHandlerRef = useRef<() => Promise<void> | void>(async () => {
|
||||
await refetchUsage();
|
||||
});
|
||||
const setMessagesRef = useRef(setMessages);
|
||||
const voice = useVoiceSession();
|
||||
const {
|
||||
isActive: isVoiceActive,
|
||||
amplitude: voiceAmplitude,
|
||||
startSession: startVoice,
|
||||
stopSession: stopVoice,
|
||||
resetTranscript: resetVoiceTranscript,
|
||||
setStatus: setVoiceStatus,
|
||||
setAmplitude: setVoiceAmplitude,
|
||||
setInterimTranscript,
|
||||
appendFinalTranscript,
|
||||
} = voice;
|
||||
const pendingVoiceQueueRef = useRef<{ text: string; queuedAt: number }[]>([]);
|
||||
const assistantSpeechMapRef = useRef<Map<string, string>>(new Map());
|
||||
const [voiceError, setVoiceError] = useState<string | null>(null);
|
||||
const voiceErrorHandledRef = useRef(false);
|
||||
const voiceStartTimestampRef = useRef<number | null>(null);
|
||||
|
||||
const handleVoiceError = useCallback((error: Error) => {
|
||||
console.error('[RAHChat] Voice error:', error);
|
||||
setVoiceError(error.message);
|
||||
if (isVoiceActive) {
|
||||
stopVoice();
|
||||
}
|
||||
resetVoiceTranscript();
|
||||
setVoiceAmplitude(0);
|
||||
setVoiceStatus('idle');
|
||||
pendingVoiceQueueRef.current = [];
|
||||
assistantSpeechMapRef.current.clear();
|
||||
}, [isVoiceActive, resetVoiceTranscript, setVoiceAmplitude, setVoiceStatus, stopVoice]);
|
||||
|
||||
const { speak: speakAssistantResponse, stop: stopAssistantTTS, status: ttsStatus } = useAssistantTTS({
|
||||
onSpeechStart: () => {
|
||||
if (isVoiceActive) {
|
||||
setVoiceStatus('speaking');
|
||||
}
|
||||
},
|
||||
onSpeechComplete: () => {
|
||||
setVoiceStatus(isVoiceActive ? 'listening' : 'idle');
|
||||
},
|
||||
onError: handleVoiceError,
|
||||
});
|
||||
|
||||
const handleVoiceInterruption = useCallback(() => {
|
||||
stopAssistantTTS();
|
||||
setVoiceStatus('listening');
|
||||
}, [setVoiceStatus, stopAssistantTTS]);
|
||||
|
||||
const sse = useSSEChat('/api/rah/chat', setMessages, {
|
||||
getAuthToken: () => null,
|
||||
beforeRequest: checkQuotaBeforeRequest,
|
||||
onRequestError: handleQuotaApiError,
|
||||
onStreamComplete: async () => {
|
||||
const handler = streamCompleteHandlerRef.current;
|
||||
if (handler) {
|
||||
await handler();
|
||||
}
|
||||
},
|
||||
getApiKeys: () => apiKeyService.getStoredKeys(),
|
||||
});
|
||||
|
||||
useVoiceInterruption({
|
||||
amplitude: voiceAmplitude,
|
||||
isVoiceActive,
|
||||
ttsStatus,
|
||||
onInterruption: handleVoiceInterruption,
|
||||
});
|
||||
|
||||
|
||||
const sendMessage = useCallback(async (text: string) => {
|
||||
if (delegationMode) return; // Delegation chats are read-only
|
||||
if (isQuotaExceeded) {
|
||||
checkQuotaBeforeRequest();
|
||||
return;
|
||||
}
|
||||
await sse.send({
|
||||
text,
|
||||
history: messages,
|
||||
openTabs: openTabsData,
|
||||
activeTabId,
|
||||
sessionId,
|
||||
mode: chatMode
|
||||
});
|
||||
}, [activeTabId, chatMode, checkQuotaBeforeRequest, delegationMode, isQuotaExceeded, messages, openTabsData, sse, sessionId]);
|
||||
|
||||
const handleVoiceFinalTranscript = useCallback(
|
||||
(raw: string) => {
|
||||
const normalized = raw.trim();
|
||||
console.info('[RAHVoice] Final transcript received:', normalized || '(empty)');
|
||||
setInterimTranscript('');
|
||||
if (!normalized) {
|
||||
console.info('[RAHVoice] Ignoring empty transcript');
|
||||
return;
|
||||
}
|
||||
appendFinalTranscript(normalized);
|
||||
if (sse.isLoading) {
|
||||
pendingVoiceQueueRef.current.push({ text: normalized, queuedAt: Date.now() });
|
||||
console.info('[RAHVoice] SSE busy, queueing transcript', {
|
||||
queuedCount: pendingVoiceQueueRef.current.length,
|
||||
});
|
||||
setVoiceStatus('thinking');
|
||||
return;
|
||||
}
|
||||
setVoiceStatus('thinking');
|
||||
console.info('[RAHVoice] Dispatching transcript to /api/rah/chat');
|
||||
void sendMessage(normalized);
|
||||
},
|
||||
[appendFinalTranscript, sendMessage, setInterimTranscript, setVoiceStatus, sse.isLoading]
|
||||
);
|
||||
|
||||
const voiceRealtime = useRealtimeVoiceClient(
|
||||
{
|
||||
onStatusChange: (status) => {
|
||||
if (!isVoiceActive && status !== 'idle') return;
|
||||
if (status === 'listening' && (sse.isLoading || pendingVoiceQueueRef.current.length > 0)) {
|
||||
setVoiceStatus('thinking');
|
||||
return;
|
||||
}
|
||||
setVoiceStatus(status);
|
||||
},
|
||||
onInterimTranscript: setInterimTranscript,
|
||||
onFinalTranscript: handleVoiceFinalTranscript,
|
||||
onAmplitude: setVoiceAmplitude,
|
||||
onError: handleVoiceError,
|
||||
},
|
||||
{
|
||||
getAuthToken: () => null,
|
||||
}
|
||||
);
|
||||
|
||||
const handleStreamComplete = useCallback(async () => {
|
||||
if (pendingVoiceQueueRef.current.length > 0) {
|
||||
console.info('[RAHVoice] SSE stream complete, draining queued transcripts', {
|
||||
queued: pendingVoiceQueueRef.current.length,
|
||||
});
|
||||
}
|
||||
while (pendingVoiceQueueRef.current.length > 0) {
|
||||
const nextQueued = pendingVoiceQueueRef.current.shift();
|
||||
if (!nextQueued) {
|
||||
break;
|
||||
}
|
||||
const queueLatency = Date.now() - nextQueued.queuedAt;
|
||||
setVoiceStatus('thinking');
|
||||
console.info('[RAHVoice] Dispatching queued transcript to /api/rah/chat', {
|
||||
queuedMs: queueLatency,
|
||||
});
|
||||
await sendMessage(nextQueued.text);
|
||||
}
|
||||
await refetchUsage();
|
||||
}, [sendMessage, setVoiceStatus, refetchUsage]);
|
||||
|
||||
useEffect(() => {
|
||||
streamCompleteHandlerRef.current = handleStreamComplete;
|
||||
}, [handleStreamComplete]);
|
||||
|
||||
useEffect(() => {
|
||||
setMessagesRef.current = setMessages;
|
||||
}, [setMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!voiceError) {
|
||||
voiceErrorHandledRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (voiceErrorHandledRef.current) return;
|
||||
voiceErrorHandledRef.current = true;
|
||||
voiceRealtime.stop();
|
||||
stopAssistantTTS();
|
||||
}, [voiceError, voiceRealtime, stopAssistantTTS]);
|
||||
|
||||
const focusSummary = useMemo(() => {
|
||||
if (!openTabsData.length) return null;
|
||||
const titles = openTabsData.map((node) => node?.title || 'Untitled');
|
||||
const activeNode = openTabsData.find((node) => node.id === activeTabId) || openTabsData[0];
|
||||
const truncate = (value: string, limit = 64) => {
|
||||
if (value.length <= limit) return value;
|
||||
return `${value.slice(0, limit - 1)}…`;
|
||||
};
|
||||
return {
|
||||
id: activeNode?.id ?? null,
|
||||
title: truncate(activeNode?.title || 'Untitled'),
|
||||
total: titles.length,
|
||||
};
|
||||
}, [openTabsData, activeTabId]);
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVoiceActive) {
|
||||
assistantSpeechMapRef.current.clear();
|
||||
stopAssistantTTS();
|
||||
return;
|
||||
}
|
||||
if (sse.isLoading) return;
|
||||
const assistantMessages = messages.filter((m) => m.role === MessageRole.ASSISTANT);
|
||||
if (!assistantMessages.length) return;
|
||||
const latest = assistantMessages[assistantMessages.length - 1];
|
||||
const spokenContent = assistantSpeechMapRef.current.get(latest.id);
|
||||
if (!latest.content.trim() || spokenContent === latest.content) return;
|
||||
assistantSpeechMapRef.current.set(latest.id, latest.content);
|
||||
const voiceRequestId = createVoiceRequestId();
|
||||
speakAssistantResponse(latest.content, {
|
||||
flush: true,
|
||||
metadata: {
|
||||
sessionId,
|
||||
helper: helperKey,
|
||||
requestId: voiceRequestId,
|
||||
messageId: latest.id,
|
||||
},
|
||||
});
|
||||
}, [helperKey, isVoiceActive, messages, sessionId, sse.isLoading, speakAssistantResponse, stopAssistantTTS]);
|
||||
|
||||
|
||||
const handleNewChat = () => {
|
||||
if (delegationMode) return;
|
||||
sse.abort();
|
||||
setMessages((_prev) => []);
|
||||
setSessionId(createSessionId());
|
||||
if (isVoiceActive) {
|
||||
stopVoice();
|
||||
resetVoiceTranscript();
|
||||
}
|
||||
};
|
||||
|
||||
// Subscribe to delegation stream if in delegation mode
|
||||
useEffect(() => {
|
||||
if (!delegationMode || !delegationSessionId) return;
|
||||
|
||||
const eventSource = new EventSource(`/api/rah/delegations/stream?sessionId=${delegationSessionId}`);
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.type === 'text-delta' && data.delta) {
|
||||
setMessagesRef.current((prev) => {
|
||||
const lastMsg = prev[prev.length - 1];
|
||||
if (lastMsg && lastMsg.role === MessageRole.ASSISTANT) {
|
||||
const updated = [...prev];
|
||||
updated[updated.length - 1] = { ...lastMsg, content: lastMsg.content + data.delta };
|
||||
return updated;
|
||||
}
|
||||
return [...prev, {
|
||||
id: crypto.randomUUID(),
|
||||
role: MessageRole.ASSISTANT,
|
||||
content: data.delta,
|
||||
timestamp: new Date()
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
if (data.type === 'tool-input-start' || data.type === 'tool-call') {
|
||||
const toolMessage: ChatMessage = {
|
||||
id: `tool-${data.toolCallId || crypto.randomUUID()}`,
|
||||
role: MessageRole.TOOL,
|
||||
content: data.toolName,
|
||||
timestamp: new Date(),
|
||||
toolName: data.toolName,
|
||||
status: (data.status as ChatMessage['status']) || 'running',
|
||||
toolArgs: data.input ?? data.args ?? data.parameters
|
||||
};
|
||||
setMessagesRef.current((prev) => [...prev, toolMessage]);
|
||||
}
|
||||
|
||||
if (data.type === 'tool-output-available' || data.type === 'tool-result') {
|
||||
setMessagesRef.current((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === `tool-${data.toolCallId}`
|
||||
? {
|
||||
...m,
|
||||
content: `${m.toolName} ${data.status === 'error' ? '✗' : '✓'}`,
|
||||
status: (data.status as ChatMessage['status']) || (data.error ? 'error' : 'complete'),
|
||||
toolResult: data.result ?? data.output ?? (data.summary ? { summary: data.summary } : undefined),
|
||||
}
|
||||
: m
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (data.type === 'assistant-message') {
|
||||
setMessagesRef.current((prev) => [...prev, {
|
||||
id: crypto.randomUUID(),
|
||||
role: MessageRole.ASSISTANT,
|
||||
content: '',
|
||||
timestamp: new Date()
|
||||
}]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[RAHChat] Failed to parse delegation stream event:', error);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = () => {
|
||||
console.error('[RAHChat] Delegation stream connection error');
|
||||
};
|
||||
|
||||
return () => {
|
||||
eventSource.close();
|
||||
};
|
||||
}, [delegationMode, delegationSessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (delegationMode && isVoiceActive) {
|
||||
voiceRealtime.stop();
|
||||
stopVoice();
|
||||
resetVoiceTranscript();
|
||||
stopAssistantTTS();
|
||||
}
|
||||
}, [delegationMode, isVoiceActive, resetVoiceTranscript, stopAssistantTTS, stopVoice, voiceRealtime]);
|
||||
|
||||
const handleVoiceToggle = useCallback(async () => {
|
||||
if (isVoiceActive) {
|
||||
voiceRealtime.stop();
|
||||
stopVoice();
|
||||
resetVoiceTranscript();
|
||||
setVoiceAmplitude(0);
|
||||
setVoiceStatus('idle');
|
||||
assistantSpeechMapRef.current.clear();
|
||||
pendingVoiceQueueRef.current = [];
|
||||
stopAssistantTTS();
|
||||
voiceStartTimestampRef.current = null;
|
||||
return;
|
||||
}
|
||||
setVoiceError(null);
|
||||
try {
|
||||
voiceStartTimestampRef.current = performance.now();
|
||||
console.info('[RAHVoice] Voice session starting');
|
||||
await voiceRealtime.start();
|
||||
startVoice();
|
||||
setVoiceStatus('listening');
|
||||
} catch (error) {
|
||||
voiceStartTimestampRef.current = null;
|
||||
handleVoiceError(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
}, [
|
||||
handleVoiceError,
|
||||
isVoiceActive,
|
||||
resetVoiceTranscript,
|
||||
setVoiceAmplitude,
|
||||
setVoiceStatus,
|
||||
setVoiceError,
|
||||
startVoice,
|
||||
stopAssistantTTS,
|
||||
stopVoice,
|
||||
voiceRealtime,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: '#0a0a0a'
|
||||
}}>
|
||||
{focusSummary && (
|
||||
<header style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '8px 16px',
|
||||
borderBottom: '1px solid #1a1a1a',
|
||||
background: '#0a0a0a'
|
||||
}}>
|
||||
{/* Focused node info */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', minWidth: 0, flex: 1 }}>
|
||||
<span style={{
|
||||
color: '#22c55e',
|
||||
fontSize: '10px',
|
||||
letterSpacing: '0.18em',
|
||||
textTransform: 'uppercase'
|
||||
}}>
|
||||
Focused Node ({focusSummary.total})
|
||||
</span>
|
||||
<span style={{ color: '#d0d0d0', fontSize: '10px' }}>#{focusSummary.id}</span>
|
||||
<span
|
||||
style={{
|
||||
color: '#f3f3f3',
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis'
|
||||
}}
|
||||
title={focusSummary.title}
|
||||
>
|
||||
{focusSummary.title}
|
||||
</span>
|
||||
</div>
|
||||
<DelegationIndicator delegations={delegations} />
|
||||
</header>
|
||||
)}
|
||||
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: '16px', background: '#0a0a0a' }}>
|
||||
{messages.length === 0 ? (
|
||||
<AsciiBanner helperName="ra-h" displayName={helperDisplayName} />
|
||||
) : (
|
||||
<>
|
||||
{messages.map((message) => (
|
||||
<TerminalMessage
|
||||
key={message.id}
|
||||
role={message.role}
|
||||
content={message.content}
|
||||
timestamp={message.timestamp}
|
||||
toolName={message.toolName}
|
||||
status={message.status}
|
||||
toolArgs={message.toolArgs}
|
||||
toolResult={message.toolResult}
|
||||
onNodeClick={onNodeClick}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{/* Voice transcript preview removed for streamlined UI */}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{!delegationMode && (
|
||||
<div style={{
|
||||
padding: '0 16px 16px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px'
|
||||
}}>
|
||||
{(quotaError || isQuotaExceeded) && (
|
||||
<div style={{
|
||||
background: 'rgba(239,68,68,0.12)',
|
||||
border: '1px solid rgba(239,68,68,0.35)',
|
||||
color: '#fca5a5',
|
||||
fontSize: '11px',
|
||||
padding: '10px 12px',
|
||||
borderRadius: '6px',
|
||||
lineHeight: 1.4
|
||||
}}>
|
||||
{quotaError?.message ?? 'Your monthly allowance has been used. Upgrade your plan to keep chatting with RA-H.'}
|
||||
</div>
|
||||
)}
|
||||
<TerminalInput
|
||||
onSubmit={sendMessage}
|
||||
isProcessing={sse.isLoading || isQuotaExceeded}
|
||||
placeholder={isVoiceActive ? 'voice mode active — end session to type…' : `ask ${helperDisplayName}...`}
|
||||
helperId={activeTabId ?? undefined}
|
||||
disabledExternally={isVoiceActive}
|
||||
disabledMessage="voice mode active"
|
||||
onVoiceToggle={handleVoiceToggle}
|
||||
isVoiceActive={isVoiceActive}
|
||||
voiceAmplitude={voiceAmplitude}
|
||||
voiceError={voiceError || undefined}
|
||||
/>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: '12px',
|
||||
flexWrap: 'wrap'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', flexWrap: 'wrap' }}>
|
||||
<ModelSelector chatMode={chatMode} />
|
||||
</div>
|
||||
<button
|
||||
onClick={handleNewChat}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#fff',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
padding: '4px 8px',
|
||||
transition: 'color 0.2s',
|
||||
fontFamily: 'inherit',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.color = '#d0d0d0';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = '#fff';
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
borderRadius: '50%',
|
||||
background: '#fff',
|
||||
color: '#0a0a0a',
|
||||
fontSize: '12px',
|
||||
lineHeight: 1,
|
||||
fontWeight: 300,
|
||||
flexShrink: 0
|
||||
}}>+</span>
|
||||
New Chat
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ModelSelectorProps {
|
||||
chatMode: 'easy' | 'hard';
|
||||
}
|
||||
|
||||
function ModelSelector({ chatMode }: ModelSelectorProps) {
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
|
||||
const currentModel = chatMode === 'easy' ? 'Easy (GPT)' : 'Hard (Claude)';
|
||||
const Icon = chatMode === 'easy' ? Zap : Flame;
|
||||
const activeColor = chatMode === 'easy' ? '#22c55e' : '#f97316';
|
||||
|
||||
const options = [
|
||||
{ id: 'easy', label: 'Easy (GPT)', icon: Zap, color: '#22c55e' },
|
||||
{ id: 'hard', label: 'Hard (Claude)', icon: Flame, color: '#f97316' },
|
||||
{ id: 'soon', label: 'Ra-h (Soon)', icon: null, color: '#666', disabled: true }
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button
|
||||
onClick={() => setDropdownOpen(!dropdownOpen)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #1a1a1a',
|
||||
borderRadius: '6px',
|
||||
background: '#0f0f0f',
|
||||
color: '#e5e5e5',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = '#333';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = '#1a1a1a';
|
||||
}}
|
||||
>
|
||||
<Icon size={12} strokeWidth={2.4} color={activeColor} />
|
||||
{currentModel}
|
||||
<span style={{
|
||||
marginLeft: '4px',
|
||||
transform: dropdownOpen ? 'rotate(180deg)' : 'rotate(0deg)',
|
||||
transition: 'transform 0.2s'
|
||||
}}>▲</span>
|
||||
</button>
|
||||
|
||||
{dropdownOpen && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: '100%', /* Open upward */
|
||||
left: '0',
|
||||
marginBottom: '4px',
|
||||
background: '#1a1a1a',
|
||||
border: '1px solid #333',
|
||||
borderRadius: '6px',
|
||||
minWidth: '150px',
|
||||
zIndex: 1000,
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.4)'
|
||||
}}>
|
||||
{options.map((option) => {
|
||||
const OptionIcon = option.icon;
|
||||
const isActive = (option.id === 'easy' && chatMode === 'easy') || (option.id === 'hard' && chatMode === 'hard');
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
onClick={() => {
|
||||
if (!option.disabled && !isActive) {
|
||||
window.dispatchEvent(new CustomEvent('rah:mode-toggle', { detail: { mode: option.id as 'easy' | 'hard' } }));
|
||||
}
|
||||
setDropdownOpen(false);
|
||||
}}
|
||||
disabled={option.disabled}
|
||||
style={{
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '8px 12px',
|
||||
border: 'none',
|
||||
background: isActive ? 'rgba(34, 197, 94, 0.1)' : 'transparent',
|
||||
color: option.disabled ? '#666' : (isActive ? '#22c55e' : '#e5e5e5'),
|
||||
fontSize: '12px',
|
||||
cursor: option.disabled ? 'not-allowed' : 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
borderRadius: '4px'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!option.disabled && !isActive) {
|
||||
e.currentTarget.style.background = '#0a0a0a';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!option.disabled && !isActive) {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
}
|
||||
}}
|
||||
>
|
||||
{OptionIcon && <OptionIcon size={12} strokeWidth={2} color={option.color} />}
|
||||
{!OptionIcon && <div style={{ width: '12px' }} />} {/* Spacer for alignment */}
|
||||
{option.label}
|
||||
{isActive && <span style={{ marginLeft: 'auto', color: '#22c55e' }}>✓</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Mic, MicOff } from 'lucide-react';
|
||||
|
||||
interface TerminalInputProps {
|
||||
onSubmit: (text: string) => void;
|
||||
isProcessing: boolean;
|
||||
placeholder?: string;
|
||||
helperId?: number;
|
||||
disabledExternally?: boolean;
|
||||
disabledMessage?: string;
|
||||
onVoiceToggle?: () => void;
|
||||
isVoiceActive?: boolean;
|
||||
voiceAmplitude?: number;
|
||||
voiceError?: string | null;
|
||||
}
|
||||
|
||||
export default function TerminalInput({
|
||||
onSubmit,
|
||||
isProcessing,
|
||||
placeholder,
|
||||
helperId,
|
||||
disabledExternally = false,
|
||||
disabledMessage,
|
||||
onVoiceToggle,
|
||||
isVoiceActive = false,
|
||||
voiceAmplitude = 0,
|
||||
voiceError,
|
||||
}: TerminalInputProps) {
|
||||
const [input, setInput] = useState('');
|
||||
const [rows, setRows] = useState(1);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [prompts, setPrompts] = useState<Array<{ id: string; name: string; content: string }>>([]);
|
||||
const [showSlashMenu, setShowSlashMenu] = useState(false);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
if (!helperId) return;
|
||||
try {
|
||||
const resp = await fetch(`/api/helpers/${helperId}/prompts`);
|
||||
const data = await resp.json();
|
||||
if (resp.ok && data.success) setPrompts(Array.isArray(data.data?.prompts) ? data.data.prompts : []);
|
||||
} catch (e) {
|
||||
console.error('Failed to load prompts:', e);
|
||||
}
|
||||
};
|
||||
load();
|
||||
}, [helperId]);
|
||||
|
||||
// Auto-resize textarea
|
||||
useEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto';
|
||||
const scrollHeight = textareaRef.current.scrollHeight;
|
||||
const lineHeight = 20; // Approximate line height
|
||||
const newRows = Math.min(Math.max(1, Math.floor(scrollHeight / lineHeight)), 5);
|
||||
setRows(newRows);
|
||||
textareaRef.current.style.height = `${scrollHeight}px`;
|
||||
}
|
||||
}, [input]);
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (input.trim() && !isProcessing && !disabledExternally) {
|
||||
// Numeric slash expansion: only when input is exactly /N
|
||||
const m = input.trim().match(/^\/(\d{1,2})$/);
|
||||
if (m) {
|
||||
const n = parseInt(m[1], 10);
|
||||
const idx = n - 1;
|
||||
if (!isNaN(idx) && idx >= 0 && idx < prompts.length) {
|
||||
const content = String(prompts[idx]?.content || '').trim();
|
||||
if (content) {
|
||||
onSubmit(content);
|
||||
setInput('');
|
||||
setRows(1);
|
||||
setShowSlashMenu(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
onSubmit(input.trim());
|
||||
setInput('');
|
||||
setRows(1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
// Slash menu navigation
|
||||
if (showSlashMenu) {
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); setActiveIndex(i => Math.min(i + 1, prompts.length - 1)); return; }
|
||||
if (e.key === 'ArrowUp') { e.preventDefault(); setActiveIndex(i => Math.max(i - 1, 0)); return; }
|
||||
if (e.key === 'Tab') { e.preventDefault(); setActiveIndex(i => (i + 1) % Math.max(prompts.length, 1)); return; }
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const p = prompts[activeIndex];
|
||||
if (p) { setInput(p.content); setShowSlashMenu(false); }
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape') { setShowSlashMenu(false); }
|
||||
}
|
||||
if (e.key === 'Enter' && !e.shiftKey && !disabledExternally) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Toggle slash menu when typing starting '/'
|
||||
const trimmed = input.trimStart();
|
||||
if (trimmed.startsWith('/')) {
|
||||
setShowSlashMenu(true);
|
||||
setActiveIndex(0);
|
||||
} else {
|
||||
setShowSlashMenu(false);
|
||||
}
|
||||
}, [input]);
|
||||
|
||||
const trimmedInput = input.trim();
|
||||
const showVoiceStart = !isVoiceActive && !trimmedInput && Boolean(onVoiceToggle);
|
||||
const showVoiceStop = Boolean(onVoiceToggle) && isVoiceActive;
|
||||
const buttonIsDisabled =
|
||||
showVoiceStart || showVoiceStop
|
||||
? false
|
||||
: (!trimmedInput || isProcessing || disabledExternally);
|
||||
|
||||
const handlePrimaryAction = () => {
|
||||
if (showVoiceStart || showVoiceStop) {
|
||||
onVoiceToggle?.();
|
||||
return;
|
||||
}
|
||||
handleSubmit();
|
||||
};
|
||||
|
||||
const amplitudeBars = Array.from({ length: 8 });
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
@keyframes subtle-pulse {
|
||||
0%, 100% { opacity: 0.5; color: #3a3a3a; }
|
||||
50% { opacity: 0.7; color: #22c55e; }
|
||||
}
|
||||
textarea::placeholder {
|
||||
animation: subtle-pulse 4s ease-in-out infinite;
|
||||
}
|
||||
`}</style>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: '8px',
|
||||
padding: '8px 16px 12px',
|
||||
background: 'transparent',
|
||||
// Remove separator/border between chat area and input
|
||||
borderTop: 'none',
|
||||
fontFamily: 'inherit'
|
||||
}}>
|
||||
{/* Terminal Prompt Symbol */}
|
||||
<span style={{
|
||||
color: '#4a4a4a',
|
||||
fontSize: '13px',
|
||||
lineHeight: '1.5',
|
||||
paddingTop: '6px',
|
||||
userSelect: 'none',
|
||||
fontWeight: 500
|
||||
}}>
|
||||
{'>'}
|
||||
</span>
|
||||
|
||||
{/* Input Wrapper */}
|
||||
<div style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '4px'
|
||||
}}>
|
||||
{isVoiceActive && (
|
||||
<div style={{
|
||||
border: 'none',
|
||||
borderRadius: '0',
|
||||
background: 'transparent',
|
||||
padding: '12px 4px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<span style={{
|
||||
fontSize: '12px',
|
||||
letterSpacing: '0.08em',
|
||||
textTransform: 'uppercase',
|
||||
color: '#d4d4d4',
|
||||
}}>
|
||||
RA-H is listening
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(24, minmax(0, 1fr))', gap: '3px', height: '20px', marginTop: '6px' }}>
|
||||
{amplitudeBars.map((_, index) => {
|
||||
const level = (index + 1) / amplitudeBars.length;
|
||||
const active = voiceAmplitude >= level - 0.0001;
|
||||
return (
|
||||
<div
|
||||
key={`amp-${index}`}
|
||||
style={{
|
||||
width: '100%',
|
||||
borderRadius: '2px',
|
||||
height: `${10 + index * 1.2}px`,
|
||||
background: active ? '#22c55e' : '#1f1f1f',
|
||||
transition: 'background 120ms ease',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{voiceError && (
|
||||
<span style={{ color: '#f87171', fontSize: '11px' }}>{voiceError}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Input Row with Textarea and Button */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
alignItems: 'flex-start',
|
||||
position: 'relative'
|
||||
}}>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={isProcessing || disabledExternally}
|
||||
placeholder={placeholder || `ask ra-h...`}
|
||||
rows={rows}
|
||||
style={{
|
||||
flex: 1,
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
borderRadius: '0',
|
||||
color: '#e5e5e5',
|
||||
fontSize: '16px',
|
||||
fontFamily: 'inherit',
|
||||
padding: '8px 4px',
|
||||
resize: 'none',
|
||||
outline: 'none',
|
||||
lineHeight: '1.5',
|
||||
transition: 'all 200ms ease',
|
||||
minHeight: '32px',
|
||||
maxHeight: '120px',
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
caretColor: '#22c55e',
|
||||
...((isProcessing || disabledExternally) && {
|
||||
opacity: 0.5,
|
||||
cursor: 'not-allowed',
|
||||
caretColor: 'transparent'
|
||||
})
|
||||
}}
|
||||
// No focus border toggling; keep clean
|
||||
onFocus={() => {}}
|
||||
onBlur={() => {}}
|
||||
/>
|
||||
|
||||
{/* Slash menu */}
|
||||
{showSlashMenu && prompts.length > 0 && (
|
||||
<div style={{ position: 'absolute', bottom: '48px', left: '40px', background: '#0f0f0f', border: '1px solid #2a2a2a', borderRadius: '4px', padding: '6px', minWidth: '260px', maxHeight: '200px', overflowY: 'auto', zIndex: 1000 }}>
|
||||
{prompts.map((p, i) => (
|
||||
<div
|
||||
key={p.id}
|
||||
onMouseDown={(e) => { e.preventDefault(); setInput(p.content); setShowSlashMenu(false); }}
|
||||
onMouseEnter={() => setActiveIndex(i)}
|
||||
style={{ display: 'flex', gap: '8px', alignItems: 'center', padding: '6px 8px', cursor: 'pointer', background: i === activeIndex ? '#1a1a1a' : 'transparent', color: '#cfcfcf', fontSize: '12px' }}
|
||||
>
|
||||
<span style={{ color: '#5c9aff', fontSize: '10px', width: '20px' }}>/ {i + 1}</span>
|
||||
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{p.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit Button (minimal icon) */}
|
||||
<button
|
||||
onClick={handlePrimaryAction}
|
||||
disabled={buttonIsDisabled}
|
||||
aria-label={
|
||||
showVoiceStop
|
||||
? 'Stop voice session'
|
||||
: showVoiceStart
|
||||
? 'Start voice session'
|
||||
: isProcessing
|
||||
? 'Processing'
|
||||
: 'Send message'
|
||||
}
|
||||
title={
|
||||
showVoiceStop
|
||||
? 'Stop voice session'
|
||||
: showVoiceStart
|
||||
? 'Start voice session'
|
||||
: disabledExternally
|
||||
? (disabledMessage || 'Voice mode active')
|
||||
: isProcessing
|
||||
? 'Processing…'
|
||||
: 'Send (Enter)'
|
||||
}
|
||||
style={{
|
||||
width: '36px',
|
||||
height: '36px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: '#22c55e',
|
||||
border: '2px solid #22c55e',
|
||||
borderRadius: '50%',
|
||||
color: '#0a0a0a',
|
||||
cursor: buttonIsDisabled ? 'not-allowed' : 'pointer',
|
||||
transition: 'all 150ms ease',
|
||||
opacity: buttonIsDisabled ? 0.5 : 1,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!buttonIsDisabled) {
|
||||
e.currentTarget.style.transform = 'translateY(-1px)';
|
||||
e.currentTarget.style.boxShadow = `0 4px 12px rgba(${showVoiceStart || showVoiceStop ? '124,58,237' : '34,197,94'}, 0.4)`;
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!buttonIsDisabled) {
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}
|
||||
}}
|
||||
>
|
||||
{showVoiceStop ? (
|
||||
<MicOff size={16} strokeWidth={2.4} color="#0a0a0a" />
|
||||
) : showVoiceStart ? (
|
||||
<Mic size={16} strokeWidth={2.4} color="#0a0a0a" />
|
||||
) : isProcessing ? (
|
||||
<span style={{ fontSize: '12px' }}>•••</span>
|
||||
) : (
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M12 4l-6.5 6.5 1.42 1.42L11 8.84V20h2V8.84l4.08 3.08 1.42-1.42L12 4z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Subtle Keyboard Hints */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '10px',
|
||||
fontSize: '10px',
|
||||
color: '#353535',
|
||||
userSelect: 'none',
|
||||
marginTop: '2px'
|
||||
}}>
|
||||
<span>⏎ send</span>
|
||||
<span>⇧⏎ newline</span>
|
||||
{(isProcessing || disabledExternally) && (
|
||||
<span style={{
|
||||
marginLeft: 'auto',
|
||||
color: disabledExternally ? '#a855f7' : '#ffcc66',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.12em'
|
||||
}}>
|
||||
{disabledExternally ? (disabledMessage || 'voice mode active') : 'processing...'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { parseAndRenderContent } from '@/components/helpers/NodeLabelRenderer';
|
||||
import ToolDisplay from '@/components/helpers/ToolDisplay';
|
||||
import MarkdownRenderer from '@/components/helpers/MarkdownRenderer';
|
||||
import ReasoningTrace from '@/components/helpers/ReasoningTrace';
|
||||
import { extractToolContext, extractSources } from '@/utils/toolFormatting';
|
||||
|
||||
interface TerminalMessageProps {
|
||||
role: 'user' | 'assistant' | 'system' | 'tool' | 'thinking';
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
toolName?: string;
|
||||
status?: 'sending' | 'delivered' | 'error' | 'processing' | 'starting' | 'running' | 'complete';
|
||||
toolArgs?: any;
|
||||
toolResult?: any;
|
||||
onNodeClick?: (nodeId: number) => void;
|
||||
}
|
||||
|
||||
// no local state needed
|
||||
|
||||
export default function TerminalMessage({
|
||||
role,
|
||||
content,
|
||||
timestamp,
|
||||
toolName,
|
||||
status = 'delivered',
|
||||
toolArgs,
|
||||
toolResult,
|
||||
onNodeClick
|
||||
}: TerminalMessageProps) {
|
||||
|
||||
const dotColor = useMemo(() => {
|
||||
const colors = {
|
||||
user: '#5c9aff',
|
||||
assistant: '#52d97a',
|
||||
tool: '#ffcc66',
|
||||
thinking: '#b794f6',
|
||||
system: '#69d2e7'
|
||||
};
|
||||
return colors[role] || colors.assistant;
|
||||
}, [role]);
|
||||
|
||||
const isAnimated = role === 'thinking' || status === 'processing';
|
||||
|
||||
const formatTime = (date: Date) => {
|
||||
return date.toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
}).toLowerCase();
|
||||
};
|
||||
|
||||
// Handle tool messages specially
|
||||
if (role === 'tool') {
|
||||
// THINK tool: display structured trace when present in toolResult
|
||||
if (toolName === 'think') {
|
||||
const trace = toolResult?.data?.trace || null;
|
||||
return <ReasoningTrace trace={trace} collapsible />;
|
||||
}
|
||||
|
||||
// Generic tool display with context and sources
|
||||
const context =
|
||||
extractToolContext(toolName, toolArgs) ||
|
||||
(toolName === 'webSearch' && toolResult?.data?.query ? `Searching for: ${String(toolResult.data.query)}` : undefined);
|
||||
const sources = extractSources(toolName, toolResult);
|
||||
const normalizedStatus =
|
||||
status === 'processing' ? 'running' : status === 'delivered' ? 'complete' : ((status as any) || 'complete');
|
||||
return (
|
||||
<ToolDisplay
|
||||
name={toolName || 'tool'}
|
||||
status={normalizedStatus}
|
||||
context={context}
|
||||
sources={sources}
|
||||
result={toolResult}
|
||||
onNodeClick={onNodeClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '12px',
|
||||
padding: '8px 0',
|
||||
fontFamily: 'inherit'
|
||||
}}>
|
||||
{/* Status Dot */}
|
||||
<span
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
width: '6px',
|
||||
height: '6px',
|
||||
marginTop: '7px',
|
||||
borderRadius: '50%',
|
||||
background: dotColor,
|
||||
transition: 'all 150ms ease',
|
||||
...(isAnimated && {
|
||||
animation: 'pulse 1.5s infinite'
|
||||
})
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Message Content */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{content ? (
|
||||
<MarkdownRenderer content={content} streaming={status === 'processing'} onNodeClick={onNodeClick} />
|
||||
) : (
|
||||
role === 'thinking' ? <span>Thinking...</span> : null
|
||||
)}
|
||||
{/* References block for assistant messages: extract [Title](URL) */}
|
||||
{role === 'assistant' && typeof content === 'string' && (() => {
|
||||
const linkRegex = /\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g;
|
||||
const refs: Array<{ title: string; url: string }> = [];
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = linkRegex.exec(content)) !== null) {
|
||||
refs.push({ title: m[1], url: m[2] });
|
||||
if (refs.length >= 12) break;
|
||||
}
|
||||
if (refs.length === 0) return null;
|
||||
return (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<div style={{ color: '#8a8a8a', fontSize: 11, marginBottom: 4 }}>References</div>
|
||||
<div style={{ display: 'grid', gap: 4 }}>
|
||||
{refs.map((r, i) => (
|
||||
<div key={i} style={{ color: '#bdbdbd', fontSize: 12 }}>
|
||||
{i + 1}. <span style={{ color: '#e5e5e5' }}>{r.title}</span> — <a href={r.url} target="_blank" rel="noreferrer" style={{ color: '#5c9aff' }}>{r.url}</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Timestamp */}
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
marginTop: '4px',
|
||||
color: '#4a4a4a',
|
||||
fontSize: '11px'
|
||||
}}>
|
||||
{formatTime(timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
import { Fragment, ReactNode, useMemo } from 'react';
|
||||
import type { AgentDelegation } from '@/services/agents/delegation';
|
||||
|
||||
const statusPalette: Record<string, { border: string; badge: string }> = {
|
||||
queued: { border: '#3a2f5f', badge: '#a78bfa' },
|
||||
in_progress: { border: '#4a3a6f', badge: '#8b5cf6' },
|
||||
completed: { border: '#2a2a2a', badge: '#6b6b6b' },
|
||||
failed: { border: '#5f2a2a', badge: '#ff6b6b' },
|
||||
};
|
||||
|
||||
const NODE_LINK_REGEX = /\[NODE:(\d+):"([^"]+)"\]/g;
|
||||
const SUMMARY_HEADERS = ['Task', 'Actions', 'Result', 'Nodes', 'Follow-up'];
|
||||
|
||||
interface WiseRAHPanelProps {
|
||||
delegation: AgentDelegation;
|
||||
onNodeClick?: (nodeId: number) => void;
|
||||
}
|
||||
|
||||
function formatStatus(status: string) {
|
||||
switch (status) {
|
||||
case 'queued':
|
||||
return 'Queued';
|
||||
case 'in_progress':
|
||||
return 'Planning';
|
||||
case 'completed':
|
||||
return 'Completed';
|
||||
case 'failed':
|
||||
return 'Failed';
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
function renderNodeAwareText(text: string, onNodeClick?: (nodeId: number) => void): ReactNode {
|
||||
const segments: ReactNode[] = [];
|
||||
let lastIndex = 0;
|
||||
|
||||
text.replace(NODE_LINK_REGEX, (match, id, title, offset) => {
|
||||
if (offset > lastIndex) {
|
||||
segments.push(<span key={`text-${offset}`}>{text.slice(lastIndex, offset)}</span>);
|
||||
}
|
||||
const nodeId = Number(id);
|
||||
const handleClick = () => {
|
||||
if (onNodeClick) {
|
||||
onNodeClick(nodeId);
|
||||
}
|
||||
};
|
||||
segments.push(
|
||||
<button
|
||||
key={`node-${offset}`}
|
||||
type="button"
|
||||
className="node-pill"
|
||||
onClick={handleClick}
|
||||
>
|
||||
<span className="node-pill-id">NODE:{nodeId}</span>
|
||||
<span className="node-pill-title">{title}</span>
|
||||
</button>
|
||||
);
|
||||
lastIndex = offset + match.length;
|
||||
return match;
|
||||
});
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
segments.push(<span key={`text-end`}>{text.slice(lastIndex)}</span>);
|
||||
}
|
||||
|
||||
return <>{segments}</>;
|
||||
}
|
||||
|
||||
function parseSummary(summary: string) {
|
||||
const lines = summary.split('\n');
|
||||
const sections: Array<{ title: string; lines: string[] }> = [];
|
||||
let current: { title: string; lines: string[] } | null = null;
|
||||
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) continue;
|
||||
|
||||
const header = SUMMARY_HEADERS.find(h => line.toLowerCase().startsWith(`${h.toLowerCase()}:`));
|
||||
if (header) {
|
||||
const content = line.slice(header.length + 1).trim();
|
||||
current = { title: header, lines: [] };
|
||||
if (content) current.lines.push(content);
|
||||
sections.push(current);
|
||||
} else if (current) {
|
||||
current.lines.push(line);
|
||||
} else {
|
||||
if (!current) {
|
||||
current = { title: 'Summary', lines: [] };
|
||||
sections.push(current);
|
||||
}
|
||||
current.lines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
function renderSectionLines(lines: string[], onNodeClick?: (nodeId: number) => void) {
|
||||
const hasBullet = lines.some(line => /^[-*•]/.test(line.trim()));
|
||||
|
||||
if (hasBullet) {
|
||||
return (
|
||||
<ul className="summary-list">
|
||||
{lines.map((line, idx) => {
|
||||
const text = line.replace(/^[-*•]\s*/, '').trim();
|
||||
return (
|
||||
<li key={idx}>
|
||||
{renderNodeAwareText(text, onNodeClick)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="summary-paragraphs">
|
||||
{lines.map((line, idx) => (
|
||||
<p key={idx}>{renderNodeAwareText(line, onNodeClick)}</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WiseRAHPanel({ delegation, onNodeClick }: WiseRAHPanelProps) {
|
||||
const palette = statusPalette[delegation.status] ?? statusPalette.queued;
|
||||
|
||||
const parsedSummary = useMemo(() => {
|
||||
if (!delegation.summary) return [];
|
||||
return parseSummary(delegation.summary);
|
||||
}, [delegation.summary]);
|
||||
|
||||
return (
|
||||
<div className="wise-panel">
|
||||
<header className="wise-header" style={{ borderBottomColor: palette.border }}>
|
||||
<span className="status-dot" style={{ background: palette.badge }} />
|
||||
<span className="header-title">WISE RA-H</span>
|
||||
<span className="header-status">· {formatStatus(delegation.status)}</span>
|
||||
<span className="header-time">{new Date(delegation.updatedAt).toLocaleTimeString()}</span>
|
||||
</header>
|
||||
|
||||
<section className="section">
|
||||
<h3>Goal</h3>
|
||||
<p>{delegation.task}</p>
|
||||
</section>
|
||||
|
||||
{delegation.context.length > 0 && (
|
||||
<section className="section">
|
||||
<h3>Context</h3>
|
||||
<ul className="context-list">
|
||||
{delegation.context.map((item, idx) => (
|
||||
<li key={idx}>{renderNodeAwareText(item, onNodeClick)}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{parsedSummary.length > 0 && (
|
||||
<section className="section">
|
||||
<h3>Summary</h3>
|
||||
<div className="summary-card">
|
||||
{parsedSummary.map(section => (
|
||||
<div key={section.title} className="summary-section">
|
||||
<div className="summary-title">{section.title}</div>
|
||||
{renderSectionLines(section.lines, onNodeClick)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<style jsx>{`
|
||||
.wise-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
padding: 24px;
|
||||
background: #0a0a0a;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.wise-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid rgba(167, 139, 250, 0.35);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.12em;
|
||||
color: #bba4ff;
|
||||
}
|
||||
|
||||
.header-status {
|
||||
font-size: 12px;
|
||||
color: #8b82a6;
|
||||
}
|
||||
|
||||
.header-time {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
color: #5f5f5f;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.section h3 {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: #a78bfa;
|
||||
}
|
||||
|
||||
.section p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: #d4d4d4;
|
||||
}
|
||||
|
||||
.context-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.context-list li {
|
||||
font-size: 13px;
|
||||
color: #a0a0a0;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
background: #101010;
|
||||
border: 1px solid rgba(167, 139, 250, 0.1);
|
||||
border-radius: 12px;
|
||||
padding: 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.summary-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.summary-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #c5b5ff;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.summary-paragraphs p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: #e5e5e5;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.summary-list {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.summary-list li {
|
||||
font-size: 13px;
|
||||
color: #e5e5e5;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.node-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
margin: 0 4px 4px 0;
|
||||
font-size: 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(167, 139, 250, 0.3);
|
||||
background: rgba(167, 139, 250, 0.08);
|
||||
color: #d8d3ff;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, border 0.15s ease;
|
||||
}
|
||||
|
||||
.node-pill:hover {
|
||||
background: rgba(167, 139, 250, 0.18);
|
||||
border: 1px solid rgba(167, 139, 250, 0.6);
|
||||
}
|
||||
|
||||
.node-pill-id {
|
||||
font-size: 11px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.node-pill-title {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
export type TTSStatus = 'idle' | 'loading' | 'speaking';
|
||||
|
||||
interface UseAssistantTTSOptions {
|
||||
voice?: string;
|
||||
onSpeechStart?: () => void;
|
||||
onSpeechComplete?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
interface SpeakRequestMetadata {
|
||||
sessionId?: string | null;
|
||||
helper?: string | null;
|
||||
requestId?: string;
|
||||
messageId?: string | null;
|
||||
}
|
||||
|
||||
interface SpeakOptions {
|
||||
flush?: boolean;
|
||||
metadata?: SpeakRequestMetadata;
|
||||
}
|
||||
|
||||
type SpeakQueueItem = {
|
||||
text: string;
|
||||
metadata?: SpeakRequestMetadata;
|
||||
};
|
||||
|
||||
export function useAssistantTTS(options: UseAssistantTTSOptions = {}) {
|
||||
const [status, setStatus] = useState<TTSStatus>('idle');
|
||||
const queueRef = useRef<SpeakQueueItem[]>([]);
|
||||
const isProcessingRef = useRef(false);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const objectUrlRef = useRef<string | null>(null);
|
||||
const mediaSourceRef = useRef<MediaSource | null>(null);
|
||||
const sourceBufferRef = useRef<SourceBuffer | null>(null);
|
||||
const chunkQueueRef = useRef<ArrayBuffer[]>([]);
|
||||
const updateEndHandlerRef = useRef<(() => void) | null>(null);
|
||||
const readerDoneRef = useRef(false);
|
||||
const processQueueRef = useRef<(() => void) | null>(null);
|
||||
|
||||
const onSpeechStartRef = useRef(options.onSpeechStart);
|
||||
const onSpeechCompleteRef = useRef(options.onSpeechComplete);
|
||||
const onErrorRef = useRef(options.onError);
|
||||
const voiceRef = useRef(options.voice);
|
||||
|
||||
useEffect(() => {
|
||||
onSpeechStartRef.current = options.onSpeechStart;
|
||||
}, [options.onSpeechStart]);
|
||||
|
||||
useEffect(() => {
|
||||
onSpeechCompleteRef.current = options.onSpeechComplete;
|
||||
}, [options.onSpeechComplete]);
|
||||
|
||||
useEffect(() => {
|
||||
onErrorRef.current = options.onError;
|
||||
}, [options.onError]);
|
||||
|
||||
useEffect(() => {
|
||||
voiceRef.current = options.voice;
|
||||
}, [options.voice]);
|
||||
|
||||
const setStatusSafe = useCallback((next: TTSStatus) => {
|
||||
setStatus(next);
|
||||
}, []);
|
||||
|
||||
const cleanupMedia = useCallback(() => {
|
||||
if (sourceBufferRef.current && updateEndHandlerRef.current) {
|
||||
try {
|
||||
sourceBufferRef.current.removeEventListener('updateend', updateEndHandlerRef.current);
|
||||
} catch (err) {
|
||||
console.warn('[TTS] Failed to detach source buffer listener', err);
|
||||
}
|
||||
}
|
||||
updateEndHandlerRef.current = null;
|
||||
sourceBufferRef.current = null;
|
||||
mediaSourceRef.current = null;
|
||||
if (objectUrlRef.current) {
|
||||
URL.revokeObjectURL(objectUrlRef.current);
|
||||
objectUrlRef.current = null;
|
||||
}
|
||||
chunkQueueRef.current = [];
|
||||
readerDoneRef.current = false;
|
||||
}, []);
|
||||
|
||||
const handleError = useCallback((error: Error) => {
|
||||
console.error('[TTS] Playback error', error);
|
||||
onErrorRef.current?.(error);
|
||||
}, []);
|
||||
|
||||
const stopCurrentPlayback = useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = null;
|
||||
const audio = audioRef.current;
|
||||
if (audio) {
|
||||
audio.pause();
|
||||
audio.removeAttribute('src');
|
||||
try {
|
||||
audio.load();
|
||||
} catch (err) {
|
||||
console.warn('[TTS] Failed to reset audio element', err);
|
||||
}
|
||||
}
|
||||
cleanupMedia();
|
||||
isProcessingRef.current = false;
|
||||
}, [cleanupMedia]);
|
||||
|
||||
const ensureAudioElement = useCallback(() => {
|
||||
if (audioRef.current) {
|
||||
return audioRef.current;
|
||||
}
|
||||
const audio = new Audio();
|
||||
audio.autoplay = true;
|
||||
audio.preload = 'auto';
|
||||
audio.addEventListener('playing', () => {
|
||||
setStatusSafe('speaking');
|
||||
onSpeechStartRef.current?.();
|
||||
});
|
||||
audio.addEventListener('ended', () => {
|
||||
cleanupMedia();
|
||||
isProcessingRef.current = false;
|
||||
setStatusSafe('idle');
|
||||
onSpeechCompleteRef.current?.();
|
||||
processQueueRef.current?.();
|
||||
});
|
||||
audio.addEventListener('error', () => {
|
||||
handleError(new Error('Audio playback failed'));
|
||||
stopCurrentPlayback();
|
||||
processQueueRef.current?.();
|
||||
});
|
||||
audioRef.current = audio;
|
||||
return audio;
|
||||
}, [cleanupMedia, handleError, setStatusSafe, stopCurrentPlayback]);
|
||||
|
||||
const flushChunks = useCallback(() => {
|
||||
const sourceBuffer = sourceBufferRef.current;
|
||||
const mediaSource = mediaSourceRef.current;
|
||||
if (!sourceBuffer || !mediaSource || sourceBuffer.updating) {
|
||||
return;
|
||||
}
|
||||
const nextChunk = chunkQueueRef.current.shift();
|
||||
if (nextChunk) {
|
||||
try {
|
||||
sourceBuffer.appendBuffer(nextChunk);
|
||||
} catch (error) {
|
||||
handleError(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (readerDoneRef.current && !sourceBuffer.updating) {
|
||||
try {
|
||||
mediaSource.endOfStream();
|
||||
} catch (error) {
|
||||
console.warn('[TTS] Failed to end media source stream', error);
|
||||
}
|
||||
}
|
||||
}, [handleError]);
|
||||
|
||||
const processQueue = useCallback(async () => {
|
||||
if (isProcessingRef.current) return;
|
||||
const next = queueRef.current.shift();
|
||||
if (!next) {
|
||||
setStatusSafe('idle');
|
||||
return;
|
||||
}
|
||||
isProcessingRef.current = true;
|
||||
setStatusSafe('loading');
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
const payload: Record<string, any> = {
|
||||
text: next.text,
|
||||
voice: voiceRef.current,
|
||||
};
|
||||
|
||||
if (next.metadata?.sessionId) {
|
||||
payload.sessionId = next.metadata.sessionId;
|
||||
}
|
||||
if (next.metadata?.helper) {
|
||||
payload.helper = next.metadata.helper;
|
||||
}
|
||||
if (next.metadata?.requestId) {
|
||||
payload.requestId = next.metadata.requestId;
|
||||
}
|
||||
if (next.metadata?.messageId) {
|
||||
payload.messageId = next.metadata.messageId;
|
||||
}
|
||||
|
||||
const response = await fetch('/api/voice/tts', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error((await response.text().catch(() => '')) || 'Failed to synthesize audio');
|
||||
}
|
||||
const reader = response.body.getReader();
|
||||
const audio = ensureAudioElement();
|
||||
cleanupMedia();
|
||||
const mimeType = response.headers.get('Content-Type') || 'audio/mpeg';
|
||||
const mediaSource = new MediaSource();
|
||||
mediaSourceRef.current = mediaSource;
|
||||
const objectUrl = URL.createObjectURL(mediaSource);
|
||||
objectUrlRef.current = objectUrl;
|
||||
audio.src = objectUrl;
|
||||
audio.load();
|
||||
|
||||
const onSourceOpen = () => {
|
||||
mediaSource.removeEventListener('sourceopen', onSourceOpen);
|
||||
let sourceBuffer: SourceBuffer;
|
||||
try {
|
||||
sourceBuffer = mediaSource.addSourceBuffer(mimeType);
|
||||
} catch {
|
||||
throw new Error(`Unsupported audio format: ${mimeType}`);
|
||||
}
|
||||
sourceBufferRef.current = sourceBuffer;
|
||||
const handleUpdateEnd = () => flushChunks();
|
||||
updateEndHandlerRef.current = handleUpdateEnd;
|
||||
sourceBuffer.addEventListener('updateend', handleUpdateEnd);
|
||||
flushChunks();
|
||||
};
|
||||
|
||||
mediaSource.addEventListener('sourceopen', onSourceOpen);
|
||||
|
||||
const pump = async () => {
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) {
|
||||
readerDoneRef.current = true;
|
||||
flushChunks();
|
||||
break;
|
||||
}
|
||||
if (value) {
|
||||
const buffer = value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength);
|
||||
chunkQueueRef.current.push(buffer);
|
||||
flushChunks();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
pump().catch((error) => {
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
if ((err as DOMException).name !== 'AbortError') {
|
||||
handleError(err);
|
||||
}
|
||||
stopCurrentPlayback();
|
||||
processQueueRef.current?.();
|
||||
});
|
||||
|
||||
audio.play().catch((error) => {
|
||||
handleError(error instanceof Error ? error : new Error(String(error)));
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
isProcessingRef.current = false;
|
||||
setStatusSafe('idle');
|
||||
handleError(error instanceof Error ? error : new Error(String(error)));
|
||||
processQueueRef.current?.();
|
||||
}
|
||||
}, [cleanupMedia, ensureAudioElement, flushChunks, handleError, setStatusSafe, stopCurrentPlayback]);
|
||||
|
||||
processQueueRef.current = processQueue;
|
||||
|
||||
const speak = useCallback(
|
||||
(text: string, options?: SpeakOptions) => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return;
|
||||
if (options?.flush) {
|
||||
queueRef.current = [{ text: trimmed, metadata: options.metadata }];
|
||||
stopCurrentPlayback();
|
||||
} else {
|
||||
queueRef.current.push({ text: trimmed, metadata: options?.metadata });
|
||||
}
|
||||
processQueue();
|
||||
},
|
||||
[processQueue, stopCurrentPlayback]
|
||||
);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
queueRef.current = [];
|
||||
stopCurrentPlayback();
|
||||
setStatusSafe('idle');
|
||||
}, [setStatusSafe, stopCurrentPlayback]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
stop();
|
||||
};
|
||||
}, [stop]);
|
||||
|
||||
return {
|
||||
status,
|
||||
speak,
|
||||
stop,
|
||||
} as const;
|
||||
}
|
||||
@@ -0,0 +1,570 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
export type RealtimeConnectionState = 'idle' | 'connecting' | 'ready' | 'capturing';
|
||||
|
||||
interface VoiceRealtimeCallbacks {
|
||||
onStatusChange?: (status: 'idle' | 'listening' | 'thinking' | 'speaking') => void;
|
||||
onInterimTranscript?: (text: string) => void;
|
||||
onFinalTranscript?: (text: string) => void;
|
||||
onAmplitude?: (value: number) => void;
|
||||
onError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
interface UseRealtimeVoiceClientOptions {
|
||||
getAuthToken?: () => string | null | undefined;
|
||||
fetchEphemeralToken?: (
|
||||
authToken: string | null
|
||||
) => Promise<{ client_secret: { value: string }; model: string; voice: string }>;
|
||||
silenceThresholdMs?: number;
|
||||
silenceAmplitudeCutoff?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_SILENCE_THRESHOLD_MS = 800;
|
||||
const DEFAULT_SILENCE_AMPLITUDE = 0.0015;
|
||||
|
||||
type Nullable<T> = T | null;
|
||||
|
||||
function calculateRms(buffer: Float32Array) {
|
||||
if (!buffer.length) return 0;
|
||||
let sumSquares = 0;
|
||||
for (let i = 0; i < buffer.length; i += 1) {
|
||||
const value = buffer[i];
|
||||
sumSquares += value * value;
|
||||
}
|
||||
return Math.sqrt(sumSquares / buffer.length);
|
||||
}
|
||||
|
||||
|
||||
export function useRealtimeVoiceClient(
|
||||
callbacks: VoiceRealtimeCallbacks,
|
||||
options: UseRealtimeVoiceClientOptions = {}
|
||||
) {
|
||||
const { onStatusChange, onInterimTranscript, onFinalTranscript, onAmplitude, onError } = callbacks;
|
||||
|
||||
const { getAuthToken, fetchEphemeralToken, silenceThresholdMs, silenceAmplitudeCutoff } = options;
|
||||
|
||||
const connectionStateRef = useRef<RealtimeConnectionState>('idle');
|
||||
const peerConnectionRef = useRef<Nullable<RTCPeerConnection>>(null);
|
||||
const dataChannelRef = useRef<Nullable<RTCDataChannel>>(null);
|
||||
const audioContextRef = useRef<Nullable<AudioContext>>(null);
|
||||
const processorNodeRef = useRef<Nullable<ScriptProcessorNode>>(null);
|
||||
const mediaStreamRef = useRef<Nullable<MediaStream>>(null);
|
||||
const mediaSourceRef = useRef<Nullable<MediaStreamAudioSourceNode>>(null);
|
||||
const awaitingTranscriptRef = useRef(false);
|
||||
const hasUncommittedAudioRef = useRef(false);
|
||||
const lastSpeechAtRef = useRef<number | null>(null);
|
||||
const destroyedRef = useRef(false);
|
||||
const channelReadyRef = useRef(false);
|
||||
|
||||
const silenceWindowMs = silenceThresholdMs ?? DEFAULT_SILENCE_THRESHOLD_MS;
|
||||
const amplitudeGate = silenceAmplitudeCutoff ?? DEFAULT_SILENCE_AMPLITUDE;
|
||||
|
||||
const onStatusChangeRef = useRef(onStatusChange);
|
||||
const onInterimTranscriptRef = useRef(onInterimTranscript);
|
||||
const onFinalTranscriptRef = useRef(onFinalTranscript);
|
||||
const onAmplitudeRef = useRef(onAmplitude);
|
||||
const onErrorRef = useRef(onError);
|
||||
|
||||
useEffect(() => {
|
||||
onStatusChangeRef.current = onStatusChange;
|
||||
}, [onStatusChange]);
|
||||
|
||||
useEffect(() => {
|
||||
onInterimTranscriptRef.current = onInterimTranscript;
|
||||
}, [onInterimTranscript]);
|
||||
|
||||
useEffect(() => {
|
||||
onFinalTranscriptRef.current = onFinalTranscript;
|
||||
}, [onFinalTranscript]);
|
||||
|
||||
useEffect(() => {
|
||||
onAmplitudeRef.current = onAmplitude;
|
||||
}, [onAmplitude]);
|
||||
|
||||
useEffect(() => {
|
||||
onErrorRef.current = onError;
|
||||
}, [onError]);
|
||||
|
||||
const setConnectionState = useCallback((next: RealtimeConnectionState) => {
|
||||
connectionStateRef.current = next;
|
||||
}, []);
|
||||
|
||||
const teardownInputNodes = useCallback(() => {
|
||||
processorNodeRef.current?.disconnect();
|
||||
mediaSourceRef.current?.disconnect();
|
||||
processorNodeRef.current?.removeEventListener('audioprocess', () => undefined);
|
||||
processorNodeRef.current = null;
|
||||
mediaSourceRef.current = null;
|
||||
if (mediaStreamRef.current) {
|
||||
mediaStreamRef.current.getTracks().forEach((track) => track.stop());
|
||||
mediaStreamRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const closePeerConnection = useCallback(() => {
|
||||
if (dataChannelRef.current) {
|
||||
try {
|
||||
dataChannelRef.current.close();
|
||||
} catch (err) {
|
||||
console.warn('[VoiceRealtime] Failed to close data channel:', err);
|
||||
}
|
||||
}
|
||||
dataChannelRef.current = null;
|
||||
|
||||
if (peerConnectionRef.current) {
|
||||
try {
|
||||
peerConnectionRef.current.ontrack = null;
|
||||
peerConnectionRef.current.onconnectionstatechange = null;
|
||||
peerConnectionRef.current.close();
|
||||
} catch (err) {
|
||||
console.warn('[VoiceRealtime] Failed to close peer connection:', err);
|
||||
}
|
||||
}
|
||||
peerConnectionRef.current = null;
|
||||
|
||||
channelReadyPromiseRef.current = null;
|
||||
channelReadyResolveRef.current = null;
|
||||
}, []);
|
||||
|
||||
const resetState = useCallback(() => {
|
||||
awaitingTranscriptRef.current = false;
|
||||
hasUncommittedAudioRef.current = false;
|
||||
lastSpeechAtRef.current = null;
|
||||
channelReadyPromiseRef.current = null;
|
||||
channelReadyResolveRef.current = null;
|
||||
}, []);
|
||||
|
||||
const notifyError = useCallback((message: string | Error) => {
|
||||
const error = message instanceof Error ? message : new Error(message);
|
||||
onErrorRef.current?.(error);
|
||||
}, []);
|
||||
|
||||
const channelReadyPromiseRef = useRef<Promise<void> | null>(null);
|
||||
const channelReadyResolveRef = useRef<(() => void) | null>(null);
|
||||
|
||||
const ensureChannelReady = useCallback(async () => {
|
||||
const channel = dataChannelRef.current;
|
||||
if (channel?.readyState === 'open') return;
|
||||
if (!channelReadyPromiseRef.current) {
|
||||
channelReadyPromiseRef.current = new Promise<void>((resolve) => {
|
||||
channelReadyResolveRef.current = resolve;
|
||||
});
|
||||
}
|
||||
await channelReadyPromiseRef.current;
|
||||
}, []);
|
||||
|
||||
const sendEvent = useCallback(
|
||||
async (event: Record<string, unknown>) => {
|
||||
await ensureChannelReady();
|
||||
const channel = dataChannelRef.current;
|
||||
if (!channel || channel.readyState !== 'open') {
|
||||
throw new Error('Realtime data channel is not open');
|
||||
}
|
||||
channel.send(JSON.stringify(event));
|
||||
},
|
||||
[ensureChannelReady]
|
||||
);
|
||||
|
||||
const initialiseMicrophone = useCallback(async () => {
|
||||
if (typeof window === 'undefined') {
|
||||
throw new Error('Voice not supported in this environment');
|
||||
}
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
channelCount: 1,
|
||||
echoCancellation: false,
|
||||
autoGainControl: false,
|
||||
noiseSuppression: false,
|
||||
},
|
||||
});
|
||||
|
||||
if (destroyedRef.current) {
|
||||
stream.getTracks().forEach((track) => {
|
||||
try {
|
||||
track.stop();
|
||||
} catch (err) {
|
||||
console.warn('[VoiceRealtime] Failed to stop track after destroy', err);
|
||||
}
|
||||
});
|
||||
return stream;
|
||||
}
|
||||
|
||||
const audioContext = new AudioContext();
|
||||
const source = audioContext.createMediaStreamSource(stream);
|
||||
const processor = audioContext.createScriptProcessor(2048, 1, 1);
|
||||
|
||||
processor.onaudioprocess = (event) => {
|
||||
if (destroyedRef.current) return;
|
||||
const inputBuffer = event.inputBuffer.getChannelData(0);
|
||||
const amplitude = calculateRms(inputBuffer);
|
||||
onAmplitudeRef.current?.(Math.min(1, amplitude * 8));
|
||||
|
||||
const now = Date.now();
|
||||
const channelReady = channelReadyRef.current;
|
||||
if (amplitude > amplitudeGate && channelReady) {
|
||||
hasUncommittedAudioRef.current = true;
|
||||
lastSpeechAtRef.current = now;
|
||||
if (!awaitingTranscriptRef.current) {
|
||||
onStatusChangeRef.current?.('listening');
|
||||
}
|
||||
} else if (
|
||||
channelReady &&
|
||||
hasUncommittedAudioRef.current &&
|
||||
!awaitingTranscriptRef.current &&
|
||||
lastSpeechAtRef.current &&
|
||||
now - lastSpeechAtRef.current > silenceWindowMs
|
||||
) {
|
||||
awaitingTranscriptRef.current = true;
|
||||
hasUncommittedAudioRef.current = false;
|
||||
lastSpeechAtRef.current = null;
|
||||
onStatusChangeRef.current?.('thinking');
|
||||
}
|
||||
};
|
||||
|
||||
source.connect(processor);
|
||||
processor.connect(audioContext.destination);
|
||||
|
||||
audioContextRef.current = audioContext;
|
||||
processorNodeRef.current = processor;
|
||||
mediaSourceRef.current = source;
|
||||
mediaStreamRef.current = stream;
|
||||
return stream;
|
||||
}, [amplitudeGate, silenceWindowMs]);
|
||||
|
||||
const disconnect = useCallback(() => {
|
||||
destroyedRef.current = true;
|
||||
teardownInputNodes();
|
||||
closePeerConnection();
|
||||
audioContextRef.current?.close().catch(() => undefined);
|
||||
audioContextRef.current = null;
|
||||
resetState();
|
||||
channelReadyRef.current = false;
|
||||
setConnectionState('idle');
|
||||
onStatusChangeRef.current?.('idle');
|
||||
onAmplitudeRef.current?.(0);
|
||||
}, [closePeerConnection, resetState, setConnectionState, teardownInputNodes]);
|
||||
|
||||
const extractTextDelta = useCallback((payload: unknown): string => {
|
||||
if (!payload) return '';
|
||||
if (typeof payload === 'string') return payload;
|
||||
if (typeof payload !== 'object') return '';
|
||||
|
||||
const record = payload as Record<string, unknown>;
|
||||
if (typeof record.delta === 'string') return record.delta;
|
||||
if (typeof record.text === 'string') return record.text;
|
||||
|
||||
const outputText = record.output_text as Record<string, unknown> | undefined;
|
||||
if (typeof outputText?.text === 'string') return outputText.text;
|
||||
|
||||
if (Array.isArray(record.output)) {
|
||||
return record.output
|
||||
.flatMap((item) =>
|
||||
Array.isArray((item as Record<string, unknown>)?.content)
|
||||
? ((item as Record<string, unknown>).content as unknown[])
|
||||
: []
|
||||
)
|
||||
.map((content) => {
|
||||
if (!content || typeof content !== 'object') return '';
|
||||
const entry = content as Record<string, unknown>;
|
||||
const nestedText = entry.text as Record<string, unknown> | string | undefined;
|
||||
if (typeof nestedText === 'string') return nestedText;
|
||||
if (typeof nestedText === 'object' && nestedText !== null && typeof nestedText.value === 'string') {
|
||||
return nestedText.value;
|
||||
}
|
||||
return '';
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('');
|
||||
}
|
||||
|
||||
if (Array.isArray(record.content)) {
|
||||
return record.content
|
||||
.map((content) => {
|
||||
if (!content || typeof content !== 'object') return '';
|
||||
const entry = content as Record<string, unknown>;
|
||||
const nestedText = entry.text as Record<string, unknown> | string | undefined;
|
||||
if (typeof nestedText === 'string') return nestedText;
|
||||
if (typeof nestedText === 'object' && nestedText !== null && typeof nestedText.value === 'string') {
|
||||
return nestedText.value;
|
||||
}
|
||||
return '';
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('');
|
||||
}
|
||||
|
||||
return '';
|
||||
}, []);
|
||||
|
||||
const handleDataMessage = useCallback(
|
||||
(raw: string) => {
|
||||
try {
|
||||
const data = JSON.parse(raw);
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.debug('[VoiceRealtime] Event', data.type, data);
|
||||
}
|
||||
|
||||
if (data.type === 'conversation.item.input_audio_transcription.completed') {
|
||||
const transcriptSource =
|
||||
typeof data.transcript === 'string'
|
||||
? data.transcript
|
||||
: extractTextDelta(data.item ?? data.content ?? data);
|
||||
const transcript = transcriptSource?.trim();
|
||||
awaitingTranscriptRef.current = false;
|
||||
hasUncommittedAudioRef.current = false;
|
||||
lastSpeechAtRef.current = null;
|
||||
if (transcript) {
|
||||
console.info('[VoiceRealtime] Transcript completed', transcript);
|
||||
onInterimTranscriptRef.current?.('');
|
||||
onFinalTranscriptRef.current?.(transcript);
|
||||
} else {
|
||||
console.warn('[VoiceRealtime] Received empty transcription event');
|
||||
onInterimTranscriptRef.current?.('');
|
||||
}
|
||||
onStatusChangeRef.current?.('listening');
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.type === 'error' && data.error) {
|
||||
console.error('[VoiceRealtime] Server error', data.error);
|
||||
}
|
||||
|
||||
if (data.type === 'response.error' && data.error) {
|
||||
console.error('[VoiceRealtime] Response error', data.error);
|
||||
}
|
||||
} catch (err) {
|
||||
notifyError(err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
},
|
||||
[extractTextDelta, notifyError]
|
||||
);
|
||||
|
||||
const waitForIceGatheringComplete = useCallback((pc: RTCPeerConnection, timeoutMs = 2000) => {
|
||||
if (pc.iceGatheringState === 'complete') {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
let resolved = false;
|
||||
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const finish = () => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
pc.removeEventListener('icegatheringstatechange', handleStateChange);
|
||||
if (timeoutHandle) {
|
||||
clearTimeout(timeoutHandle);
|
||||
timeoutHandle = null;
|
||||
}
|
||||
resolve();
|
||||
};
|
||||
|
||||
const handleStateChange = () => {
|
||||
if (pc.iceGatheringState === 'complete') {
|
||||
finish();
|
||||
}
|
||||
};
|
||||
|
||||
if (timeoutMs) {
|
||||
timeoutHandle = setTimeout(() => {
|
||||
console.warn('[VoiceRealtime] ICE gathering timeout reached, proceeding with partial candidates');
|
||||
finish();
|
||||
}, timeoutMs);
|
||||
}
|
||||
|
||||
pc.addEventListener('icegatheringstatechange', handleStateChange);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const start = useCallback(async () => {
|
||||
try {
|
||||
destroyedRef.current = false;
|
||||
resetState();
|
||||
setConnectionState('connecting');
|
||||
|
||||
const authToken = getAuthToken?.() ?? null;
|
||||
const fetchToken = fetchEphemeralToken
|
||||
? fetchEphemeralToken
|
||||
: async (token: string | null) => {
|
||||
const response = await fetch('/api/realtime/ephemeral-token', {
|
||||
method: 'POST',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
throw new Error(payload.error || `Failed to mint ephemeral token (${response.status})`);
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
|
||||
const sessionPromise = fetchToken(authToken);
|
||||
const microphonePromise = initialiseMicrophone();
|
||||
|
||||
const session = await sessionPromise;
|
||||
const clientSecret = session?.client_secret?.value || session?.client_secret;
|
||||
if (!clientSecret || typeof clientSecret !== 'string') {
|
||||
throw new Error('Realtime session did not include a client_secret');
|
||||
}
|
||||
|
||||
const pc = new RTCPeerConnection({
|
||||
iceServers: [{ urls: ['stun:stun.l.google.com:19302'] }],
|
||||
});
|
||||
peerConnectionRef.current = pc;
|
||||
|
||||
const channel = pc.createDataChannel('oai-events');
|
||||
dataChannelRef.current = channel;
|
||||
channelReadyPromiseRef.current = new Promise<void>((resolve) => {
|
||||
channelReadyResolveRef.current = resolve;
|
||||
});
|
||||
channelReadyRef.current = false;
|
||||
|
||||
channel.onmessage = (event) => {
|
||||
handleDataMessage(event.data);
|
||||
};
|
||||
|
||||
channel.onopen = () => {
|
||||
console.info('[VoiceRealtime] Data channel open');
|
||||
setConnectionState('ready');
|
||||
channelReadyResolveRef.current?.();
|
||||
channelReadyResolveRef.current = null;
|
||||
channelReadyRef.current = true;
|
||||
|
||||
void sendEvent({
|
||||
type: 'session.update',
|
||||
session: {
|
||||
modalities: ['text'],
|
||||
input_audio_transcription: {
|
||||
model: 'whisper-1',
|
||||
},
|
||||
turn_detection: {
|
||||
type: 'server_vad',
|
||||
threshold: 0.5,
|
||||
silence_duration_ms: 1800,
|
||||
prefix_padding_ms: 300,
|
||||
create_response: false,
|
||||
},
|
||||
instructions: 'You are the RA-H voice transport. Only transcribe user speech accurately, never speak responses.',
|
||||
},
|
||||
}).catch((err) => {
|
||||
console.error('[VoiceRealtime] Failed to send session update:', err);
|
||||
});
|
||||
};
|
||||
|
||||
channel.onerror = (event) => {
|
||||
console.error('[VoiceRealtime] Data channel error:', event);
|
||||
notifyError(new Error('Realtime connection error'));
|
||||
channelReadyRef.current = false;
|
||||
disconnect();
|
||||
};
|
||||
|
||||
channel.onclose = () => {
|
||||
console.warn('[VoiceRealtime] Data channel closed');
|
||||
channelReadyPromiseRef.current = null;
|
||||
channelReadyResolveRef.current = null;
|
||||
channelReadyRef.current = false;
|
||||
if (!destroyedRef.current) {
|
||||
notifyError(new Error('Realtime data channel closed unexpectedly'));
|
||||
disconnect();
|
||||
}
|
||||
};
|
||||
|
||||
pc.onconnectionstatechange = () => {
|
||||
const state = pc.connectionState;
|
||||
console.info('[VoiceRealtime] Peer connection state:', state);
|
||||
if (state === 'connected') {
|
||||
onStatusChangeRef.current?.('listening');
|
||||
setConnectionState('capturing');
|
||||
}
|
||||
if (state === 'failed' || state === 'closed' || state === 'disconnected') {
|
||||
if (!destroyedRef.current) {
|
||||
notifyError(new Error(`Realtime connection ${state}`));
|
||||
}
|
||||
disconnect();
|
||||
}
|
||||
};
|
||||
|
||||
pc.oniceconnectionstatechange = () => {
|
||||
console.info('[VoiceRealtime] ICE connection state:', pc.iceConnectionState);
|
||||
};
|
||||
|
||||
pc.ontrack = () => undefined;
|
||||
|
||||
const mediaStream = await microphonePromise;
|
||||
mediaStream?.getAudioTracks().forEach((track) => {
|
||||
if (peerConnectionRef.current?.signalingState !== 'closed') {
|
||||
peerConnectionRef.current?.addTrack(track, mediaStream);
|
||||
}
|
||||
});
|
||||
|
||||
const offer = await pc.createOffer();
|
||||
await pc.setLocalDescription(offer);
|
||||
await waitForIceGatheringComplete(pc, 2000);
|
||||
|
||||
const localDescription = pc.localDescription;
|
||||
if (!localDescription) {
|
||||
throw new Error('Failed to create local description for realtime call');
|
||||
}
|
||||
|
||||
const response = await fetch('https://api.openai.com/v1/realtime/calls', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${clientSecret}`,
|
||||
'Content-Type': 'application/sdp',
|
||||
'OpenAI-Beta': 'realtime=v1',
|
||||
},
|
||||
body: localDescription.sdp,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorDetail = '';
|
||||
try {
|
||||
const text = await response.text();
|
||||
errorDetail = text;
|
||||
const maybeJson = text ? JSON.parse(text) : null;
|
||||
if (maybeJson?.error?.message) {
|
||||
errorDetail = maybeJson.error.message;
|
||||
}
|
||||
} catch {
|
||||
// ignore JSON parse errors and fall back to raw text
|
||||
}
|
||||
const friendly = errorDetail || response.statusText || 'Unknown realtime error';
|
||||
console.error('[VoiceRealtime] Failed to start realtime call:', friendly, { status: response.status });
|
||||
throw new Error(`Failed to establish realtime call (${response.status}): ${friendly}`);
|
||||
}
|
||||
|
||||
const answerSdp = await response.text();
|
||||
await pc.setRemoteDescription({ type: 'answer', sdp: answerSdp });
|
||||
|
||||
onStatusChangeRef.current?.('listening');
|
||||
setConnectionState('capturing');
|
||||
} catch (err) {
|
||||
disconnect();
|
||||
notifyError(err instanceof Error ? err : new Error(String(err)));
|
||||
throw err;
|
||||
}
|
||||
}, [disconnect, fetchEphemeralToken, getAuthToken, handleDataMessage, initialiseMicrophone, notifyError, resetState, sendEvent, setConnectionState, waitForIceGatheringComplete]);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
disconnect();
|
||||
}, [disconnect]);
|
||||
|
||||
const latestStopRef = useRef<() => void>(() => {});
|
||||
|
||||
useEffect(() => {
|
||||
latestStopRef.current = stop;
|
||||
}, [stop]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
latestStopRef.current();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
start,
|
||||
stop,
|
||||
} as const;
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from 'react';
|
||||
|
||||
export enum MessageRole {
|
||||
USER = 'user',
|
||||
ASSISTANT = 'assistant',
|
||||
TOOL = 'tool',
|
||||
SYSTEM = 'system',
|
||||
THINKING = 'thinking',
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
role: MessageRole.USER | MessageRole.ASSISTANT | MessageRole.TOOL | MessageRole.SYSTEM | MessageRole.THINKING;
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
toolName?: string;
|
||||
status?: 'processing' | 'delivered' | 'error' | 'starting' | 'running' | 'complete';
|
||||
toolArgs?: any;
|
||||
toolResult?: any;
|
||||
}
|
||||
|
||||
interface SendParams {
|
||||
text: string;
|
||||
history: ChatMessage[];
|
||||
openTabs: any[];
|
||||
activeTabId: number | null;
|
||||
currentView?: 'nodes' | 'memory';
|
||||
sessionId: string;
|
||||
mode: 'easy' | 'hard';
|
||||
}
|
||||
|
||||
interface UseSSEChatOptions {
|
||||
getAuthToken?: () => string | null | undefined;
|
||||
beforeRequest?: () => boolean;
|
||||
onRequestError?: (error: unknown, response?: Response) => boolean | void;
|
||||
onStreamComplete?: () => void | Promise<void>;
|
||||
getApiKeys?: () => { openai?: string; anthropic?: string } | undefined;
|
||||
}
|
||||
|
||||
export function useSSEChat(
|
||||
endpoint: string,
|
||||
setMessages: (updater: (prev: ChatMessage[]) => ChatMessage[]) => void,
|
||||
options: UseSSEChatOptions = {}
|
||||
) {
|
||||
const { getAuthToken, beforeRequest, onRequestError, onStreamComplete, getApiKeys } = options;
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const abort = () => {
|
||||
abortControllerRef.current?.abort();
|
||||
};
|
||||
|
||||
const send = async ({ text, history, openTabs, activeTabId, currentView, sessionId, mode }: SendParams) => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed || isLoading) return;
|
||||
if (beforeRequest && !beforeRequest()) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const userMessage: ChatMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: MessageRole.USER,
|
||||
content: trimmed,
|
||||
timestamp: new Date()
|
||||
};
|
||||
const assistantMessage: ChatMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: MessageRole.ASSISTANT,
|
||||
content: '',
|
||||
timestamp: new Date()
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, userMessage, assistantMessage]);
|
||||
|
||||
let handledError = false;
|
||||
let currentAssistantMessage = assistantMessage;
|
||||
|
||||
try {
|
||||
abortControllerRef.current = new AbortController();
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
const token = getAuthToken?.();
|
||||
if (token) {
|
||||
headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
messages: history
|
||||
.concat(userMessage)
|
||||
.filter((m) => [MessageRole.USER, MessageRole.ASSISTANT, MessageRole.SYSTEM].includes(m.role))
|
||||
.map((m) => ({ role: m.role, parts: [{ type: 'text', text: m.content }] })),
|
||||
openTabs,
|
||||
activeTabId,
|
||||
currentView,
|
||||
sessionId,
|
||||
mode,
|
||||
apiKeys: getApiKeys?.(),
|
||||
}),
|
||||
signal: abortControllerRef.current.signal
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = new Error(`HTTP error! status: ${response.status}`);
|
||||
handledError = Boolean(onRequestError?.(error, response));
|
||||
setMessages((prev) =>
|
||||
prev.filter((m) => !(m.id === currentAssistantMessage.id && m.content === ''))
|
||||
);
|
||||
if (handledError) {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
if (!reader) throw new Error('No response body');
|
||||
|
||||
let fullContent = '';
|
||||
let toolCallsActive: Record<string, string> = {};
|
||||
let hasToolCalls = false;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
const chunk = decoder.decode(value, { stream: true });
|
||||
const lines = chunk.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) continue;
|
||||
try {
|
||||
const data = JSON.parse(line.substring(6));
|
||||
|
||||
if (data.type === 'text-delta' && data.delta) {
|
||||
fullContent += data.delta;
|
||||
setMessages((prev) => prev.map((m) => (m.id === currentAssistantMessage.id ? { ...m, content: fullContent } : m)));
|
||||
}
|
||||
|
||||
if (data.type === 'tool-input-start') {
|
||||
hasToolCalls = true;
|
||||
toolCallsActive[data.toolCallId] = data.toolName;
|
||||
const toolMessage: ChatMessage = {
|
||||
id: `tool-${data.toolCallId}`,
|
||||
role: MessageRole.TOOL,
|
||||
content: data.toolName,
|
||||
timestamp: new Date(),
|
||||
toolName: data.toolName,
|
||||
status: 'running',
|
||||
toolArgs: (data.args ?? data.input ?? data.parameters ?? null)
|
||||
};
|
||||
setMessages((prev) => {
|
||||
const filtered = prev.filter((m) => m.id !== toolMessage.id);
|
||||
const index = filtered.findIndex((m) => m.id === currentAssistantMessage.id);
|
||||
return [...filtered.slice(0, index), toolMessage, ...filtered.slice(index)];
|
||||
});
|
||||
}
|
||||
|
||||
if (data.type === 'tool-output-available') {
|
||||
delete toolCallsActive[data.toolCallId];
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === `tool-${data.toolCallId}`
|
||||
? { ...m, content: `${m.toolName} ✓`, status: 'complete', toolResult: (data.result ?? data.output ?? null) }
|
||||
: m
|
||||
)
|
||||
);
|
||||
|
||||
if (Object.keys(toolCallsActive).length === 0 && hasToolCalls) {
|
||||
const newAssistantMessage: ChatMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: MessageRole.ASSISTANT,
|
||||
content: '',
|
||||
timestamp: new Date()
|
||||
};
|
||||
currentAssistantMessage = newAssistantMessage;
|
||||
fullContent = '';
|
||||
setMessages((prev) => [...prev, newAssistantMessage]);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed lines
|
||||
}
|
||||
}
|
||||
}
|
||||
if (onStreamComplete) {
|
||||
await onStreamComplete();
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err?.name !== 'AbortError') {
|
||||
if (!handledError) {
|
||||
handledError = Boolean(onRequestError?.(err));
|
||||
}
|
||||
if (!handledError) {
|
||||
setError(err?.message || 'Failed to send message');
|
||||
}
|
||||
setMessages((prev) =>
|
||||
prev.filter((m) => !(m.id === currentAssistantMessage.id && m.content === ''))
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
return { isLoading, error, send, abort };
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { TTSStatus } from './useAssistantTTS';
|
||||
|
||||
interface UseVoiceInterruptionOptions {
|
||||
amplitude: number;
|
||||
isVoiceActive: boolean;
|
||||
ttsStatus: TTSStatus;
|
||||
threshold?: number;
|
||||
holdDurationMs?: number;
|
||||
cooldownMs?: number;
|
||||
onInterruption: () => void;
|
||||
}
|
||||
|
||||
const DEFAULT_THRESHOLD = 0.18;
|
||||
const DEFAULT_HOLD_MS = 120;
|
||||
const DEFAULT_COOLDOWN_MS = 800;
|
||||
|
||||
export function useVoiceInterruption(options: UseVoiceInterruptionOptions) {
|
||||
const {
|
||||
amplitude,
|
||||
isVoiceActive,
|
||||
ttsStatus,
|
||||
onInterruption,
|
||||
threshold = DEFAULT_THRESHOLD,
|
||||
holdDurationMs = DEFAULT_HOLD_MS,
|
||||
cooldownMs = DEFAULT_COOLDOWN_MS,
|
||||
} = options;
|
||||
|
||||
const [isInterrupting, setIsInterrupting] = useState(false);
|
||||
const detectionTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const lastInterruptionAtRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (detectionTimeoutRef.current) {
|
||||
clearTimeout(detectionTimeoutRef.current);
|
||||
detectionTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const clearDetection = () => {
|
||||
if (detectionTimeoutRef.current) {
|
||||
clearTimeout(detectionTimeoutRef.current);
|
||||
detectionTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
if (!isVoiceActive || ttsStatus === 'idle') {
|
||||
clearDetection();
|
||||
if (isInterrupting) {
|
||||
setIsInterrupting(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (amplitude < threshold) {
|
||||
clearDetection();
|
||||
if (isInterrupting) {
|
||||
setIsInterrupting(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
if (now - lastInterruptionAtRef.current < cooldownMs) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (detectionTimeoutRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
detectionTimeoutRef.current = setTimeout(() => {
|
||||
lastInterruptionAtRef.current = Date.now();
|
||||
setIsInterrupting(true);
|
||||
onInterruption();
|
||||
}, holdDurationMs);
|
||||
}, [amplitude, cooldownMs, holdDurationMs, isInterrupting, isVoiceActive, onInterruption, threshold, ttsStatus]);
|
||||
|
||||
return { isInterrupting } as const;
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useCallback, useReducer } from 'react';
|
||||
|
||||
export type VoiceSessionStatus = 'idle' | 'listening' | 'thinking' | 'speaking';
|
||||
|
||||
export interface VoiceTranscriptSegment {
|
||||
id: string;
|
||||
text: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
interface VoiceSessionState {
|
||||
isActive: boolean;
|
||||
status: VoiceSessionStatus;
|
||||
interimTranscript: string;
|
||||
segments: VoiceTranscriptSegment[];
|
||||
amplitude: number;
|
||||
startedAt: number | null;
|
||||
}
|
||||
|
||||
type VoiceSessionAction =
|
||||
| { type: 'start' }
|
||||
| { type: 'stop' }
|
||||
| { type: 'set-status'; status: VoiceSessionStatus }
|
||||
| { type: 'set-amplitude'; amplitude: number }
|
||||
| { type: 'set-interim'; transcript: string }
|
||||
| { type: 'append-segment'; text: string }
|
||||
| { type: 'replace-segments'; segments: VoiceTranscriptSegment[] }
|
||||
| { type: 'reset-transcript' };
|
||||
|
||||
const initialState: VoiceSessionState = {
|
||||
isActive: false,
|
||||
status: 'idle',
|
||||
interimTranscript: '',
|
||||
segments: [],
|
||||
amplitude: 0,
|
||||
startedAt: null,
|
||||
};
|
||||
|
||||
function reducer(state: VoiceSessionState, action: VoiceSessionAction): VoiceSessionState {
|
||||
switch (action.type) {
|
||||
case 'start':
|
||||
return {
|
||||
...state,
|
||||
isActive: true,
|
||||
status: 'listening',
|
||||
interimTranscript: '',
|
||||
segments: [],
|
||||
amplitude: 0,
|
||||
startedAt: Date.now(),
|
||||
};
|
||||
case 'stop':
|
||||
return {
|
||||
...state,
|
||||
isActive: false,
|
||||
status: 'idle',
|
||||
amplitude: 0,
|
||||
interimTranscript: '',
|
||||
startedAt: null,
|
||||
};
|
||||
case 'set-status':
|
||||
return {
|
||||
...state,
|
||||
status: action.status,
|
||||
};
|
||||
case 'set-amplitude':
|
||||
return {
|
||||
...state,
|
||||
amplitude: Math.max(0, Math.min(1, action.amplitude)),
|
||||
};
|
||||
case 'set-interim':
|
||||
return {
|
||||
...state,
|
||||
interimTranscript: action.transcript,
|
||||
};
|
||||
case 'append-segment': {
|
||||
const trimmed = action.text.trim();
|
||||
if (!trimmed) return state;
|
||||
const segment: VoiceTranscriptSegment = {
|
||||
id: `voice-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
text: trimmed,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
return {
|
||||
...state,
|
||||
segments: [...state.segments, segment],
|
||||
};
|
||||
}
|
||||
case 'replace-segments':
|
||||
return {
|
||||
...state,
|
||||
segments: action.segments,
|
||||
};
|
||||
case 'reset-transcript':
|
||||
return {
|
||||
...state,
|
||||
interimTranscript: '',
|
||||
segments: [],
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export function useVoiceSession() {
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
|
||||
const startSession = useCallback(() => {
|
||||
dispatch({ type: 'start' });
|
||||
}, []);
|
||||
|
||||
const stopSession = useCallback(() => {
|
||||
dispatch({ type: 'stop' });
|
||||
}, []);
|
||||
|
||||
const setStatus = useCallback((status: VoiceSessionStatus) => {
|
||||
dispatch({ type: 'set-status', status });
|
||||
}, []);
|
||||
|
||||
const setAmplitude = useCallback((amplitude: number) => {
|
||||
dispatch({ type: 'set-amplitude', amplitude });
|
||||
}, []);
|
||||
|
||||
const setInterimTranscript = useCallback((transcript: string) => {
|
||||
dispatch({ type: 'set-interim', transcript });
|
||||
}, []);
|
||||
|
||||
const appendFinalTranscript = useCallback((text: string) => {
|
||||
dispatch({ type: 'append-segment', text });
|
||||
}, []);
|
||||
|
||||
const resetTranscript = useCallback(() => {
|
||||
dispatch({ type: 'reset-transcript' });
|
||||
}, []);
|
||||
|
||||
const replaceSegments = useCallback((segments: VoiceTranscriptSegment[]) => {
|
||||
dispatch({ type: 'replace-segments', segments });
|
||||
}, []);
|
||||
|
||||
return {
|
||||
...state,
|
||||
startSession,
|
||||
stopSession,
|
||||
setStatus,
|
||||
setAmplitude,
|
||||
setInterimTranscript,
|
||||
appendFinalTranscript,
|
||||
resetTranscript,
|
||||
replaceSegments,
|
||||
} as const;
|
||||
}
|
||||
Reference in New Issue
Block a user