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:
“BeeRad”
2025-12-15 16:14:28 +11:00
commit 733d1c3407
226 changed files with 46231 additions and 0 deletions
+593
View File
@@ -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>
);
}
+67
View File
@@ -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>
);
}
+237
View File
@@ -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>
);
}
+433
View File
@@ -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>
);
}
+204
View File
@@ -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>
);
}
+691
View File
@@ -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>
);
}
+380
View File
@@ -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>
</>
);
}
+148
View File
@@ -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>
);
}
+328
View File
@@ -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;
}
+212
View File
@@ -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;
}