Initial commit: RA-H Open Source Edition
Local-first knowledge management system with BYO API keys. Features: - 3-panel UI (Nodes | Focus | Helpers) - SQLite + sqlite-vec for vector search - Agent system (Easy/Hard mode orchestrators) - Content extraction (YouTube, PDF, web) - Integrate workflow for connection discovery - Dimension system with auto-assignment Tech stack: - Next.js 15 + TypeScript + Tailwind CSS - Anthropic (Claude) + OpenAI (GPT) via Vercel AI SDK Setup: npm install && npm rebuild better-sqlite3 scripts/dev/bootstrap-local.sh npm run dev MIT License
This commit is contained in:
@@ -0,0 +1,593 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import RAHChat from './RAHChat';
|
||||
import QuickAddInput from './QuickAddInput';
|
||||
import QuickAddStatus from './QuickAddStatus';
|
||||
import { Zap, Flame } from 'lucide-react';
|
||||
import type { AgentDelegation } from '@/services/agents/delegation';
|
||||
import { Node } from '@/types/database';
|
||||
|
||||
interface AgentsPanelProps {
|
||||
openTabsData: Node[];
|
||||
activeTabId: number | null;
|
||||
onNodeClick?: (nodeId: number) => void;
|
||||
}
|
||||
|
||||
type ActiveTab = 'ra-h' | string; // 'ra-h' or delegation sessionId
|
||||
type Mode = 'quickadd' | 'session';
|
||||
|
||||
export default function AgentsPanel({ openTabsData, activeTabId, onNodeClick }: AgentsPanelProps) {
|
||||
const [delegationsMap, setDelegationsMap] = useState<Record<string, AgentDelegation>>({});
|
||||
const [activeAgentTab, setActiveAgentTab] = useState<ActiveTab>('ra-h');
|
||||
const [mode, setMode] = useState<Mode>('quickadd');
|
||||
const [rahMode, setRahMode] = useState<'easy' | 'hard'>('easy');
|
||||
const [modelDropdownOpen, setModelDropdownOpen] = useState(false);
|
||||
// Lift messages state to prevent losing it on tab switch
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const [rahMessages, setRahMessages] = useState<any[]>([]);
|
||||
|
||||
// Store delegation messages per sessionId
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const [delegationMessages, setDelegationMessages] = useState<Record<string, any[]>>({});
|
||||
|
||||
const getDelegationMessages = useCallback((sessionId: string) => {
|
||||
return delegationMessages[sessionId] || [];
|
||||
}, [delegationMessages]);
|
||||
|
||||
const setDelegationMessagesFor = useCallback((sessionId: string) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return (updater: (prev: any[]) => any[]) => {
|
||||
setDelegationMessages(prev => ({
|
||||
...prev,
|
||||
[sessionId]: updater(prev[sessionId] || [])
|
||||
}));
|
||||
};
|
||||
}, []);
|
||||
|
||||
const upsertDelegation = useCallback((delegation: AgentDelegation) => {
|
||||
setDelegationsMap((prev) => ({
|
||||
...prev,
|
||||
[delegation.sessionId]: delegation,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const loadExisting = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/rah/delegations?status=active&includeCompleted=true');
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
if (!Array.isArray(data.delegations)) return;
|
||||
|
||||
setDelegationsMap((prev) => {
|
||||
if (cancelled) return prev;
|
||||
const next = { ...prev };
|
||||
for (const delegation of data.delegations as AgentDelegation[]) {
|
||||
next[delegation.sessionId] = delegation;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[AgentsPanel] Failed to load delegations:', error);
|
||||
}
|
||||
};
|
||||
|
||||
loadExisting();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const stored = window.localStorage.getItem('rah-mode');
|
||||
if (stored === 'easy' || stored === 'hard') {
|
||||
setRahMode(stored);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.localStorage.setItem('rah-mode', rahMode);
|
||||
}, [rahMode]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ mode: 'easy' | 'hard' }>).detail;
|
||||
if (detail?.mode) {
|
||||
setRahMode(detail.mode);
|
||||
}
|
||||
};
|
||||
const quickAddHandler = () => setMode('quickadd');
|
||||
window.addEventListener('rah:mode-toggle', handler as EventListener);
|
||||
window.addEventListener('rah:switch-quickadd', quickAddHandler);
|
||||
return () => {
|
||||
window.removeEventListener('rah:mode-toggle', handler as EventListener);
|
||||
window.removeEventListener('rah:switch-quickadd', quickAddHandler);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleCreated = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ delegation: AgentDelegation }>).detail;
|
||||
console.log('[AgentsPanel] Delegation created:', detail?.delegation);
|
||||
if (detail?.delegation) upsertDelegation(detail.delegation);
|
||||
};
|
||||
const handleUpdated = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ delegation: AgentDelegation }>).detail;
|
||||
console.log('[AgentsPanel] Delegation updated:', detail?.delegation);
|
||||
if (detail?.delegation) upsertDelegation(detail.delegation);
|
||||
};
|
||||
|
||||
window.addEventListener('delegations:created', handleCreated as EventListener);
|
||||
window.addEventListener('delegations:updated', handleUpdated as EventListener);
|
||||
return () => {
|
||||
window.removeEventListener('delegations:created', handleCreated as EventListener);
|
||||
window.removeEventListener('delegations:updated', handleUpdated as EventListener);
|
||||
};
|
||||
}, [upsertDelegation]);
|
||||
|
||||
const delegations = useMemo(() => Object.values(delegationsMap), [delegationsMap]);
|
||||
|
||||
const orderedDelegations = useMemo(() => {
|
||||
const statusWeight = (status: AgentDelegation['status']) => {
|
||||
switch (status) {
|
||||
case 'queued':
|
||||
return 0;
|
||||
case 'in_progress':
|
||||
return 1;
|
||||
case 'completed':
|
||||
return 2;
|
||||
case 'failed':
|
||||
default:
|
||||
return 3;
|
||||
}
|
||||
};
|
||||
|
||||
// No staleness filtering - delegations persist until user closes them
|
||||
const ordered = delegations.sort((a, b) => {
|
||||
const diff = statusWeight(a.status) - statusWeight(b.status);
|
||||
if (diff !== 0) return diff;
|
||||
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
|
||||
});
|
||||
|
||||
const wise = ordered.filter((d) => d.agentType === 'wise-rah');
|
||||
const mini = ordered.filter((d) => d.agentType !== 'wise-rah');
|
||||
|
||||
return [...wise, ...mini];
|
||||
}, [delegations]);
|
||||
|
||||
// Don't auto-switch tabs - user stays in ra-h to see responses
|
||||
|
||||
const selectedDelegation = orderedDelegations.find(d => d.sessionId === activeAgentTab);
|
||||
|
||||
const handleQuickAddSubmit = async ({ input, mode }: { input: string; mode: 'link' | 'note' | 'chat' }) => {
|
||||
try {
|
||||
const response = await fetch('/api/quick-add', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ input, mode })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Failed to submit Quick Add');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[AgentsPanel] Quick Add error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelegationClick = (sessionId: string) => {
|
||||
setActiveAgentTab(sessionId);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', background: '#0a0a0a' }}>
|
||||
{/* Mode Header */}
|
||||
{mode === 'quickadd' ? (
|
||||
<div style={{
|
||||
padding: '20px',
|
||||
borderBottom: '1px solid #1a1a1a',
|
||||
background: '#0a0a0a',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '24px',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
height: '100%'
|
||||
}}>
|
||||
{/* Top spacer */}
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
{/* Session Section */}
|
||||
<div style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
padding: '20px',
|
||||
textAlign: 'center',
|
||||
width: '100%',
|
||||
maxWidth: '600px'
|
||||
}}>
|
||||
<pre style={{
|
||||
color: '#22c55e',
|
||||
fontSize: '13px',
|
||||
fontFamily: "'JetBrains Mono', ui-monospace, Courier, monospace",
|
||||
lineHeight: '1.1',
|
||||
margin: '0 0 20px 0',
|
||||
letterSpacing: '0',
|
||||
fontWeight: 700,
|
||||
textShadow: '0 0 10px rgba(139, 212, 80, 0.4)'
|
||||
}}>{`
|
||||
██████╗ █████╗ ██╗ ██╗
|
||||
██╔══██╗██╔══██╗ ██║ ██║
|
||||
██████╔╝███████║█████╗███████║
|
||||
██╔══██╗██╔══██║╚════╝██╔══██║
|
||||
██║ ██║██║ ██║ ██║ ██║
|
||||
╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═╝
|
||||
`}</pre>
|
||||
<button
|
||||
onClick={() => {
|
||||
setMode('session');
|
||||
setRahMode('easy'); // Always start new session in easy mode
|
||||
}}
|
||||
style={{
|
||||
width: 'auto',
|
||||
padding: '14px 32px',
|
||||
background: '#22c55e',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
color: '#0a0a0a',
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
fontFamily: "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
letterSpacing: '0.02em'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#2dd46d';
|
||||
e.currentTarget.style.boxShadow = '0 4px 16px rgba(34, 197, 94, 0.4)';
|
||||
e.currentTarget.style.transform = 'translateY(-2px)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = '#22c55e';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
}}
|
||||
>
|
||||
Start Session
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Bottom spacer */}
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
{/* Divider */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
width: '100%',
|
||||
maxWidth: '600px',
|
||||
marginBottom: '12px'
|
||||
}}>
|
||||
<div style={{ flex: 1, height: '1px', background: '#1a1a1a' }} />
|
||||
<span style={{
|
||||
color: '#6b6b6b',
|
||||
fontSize: '12px',
|
||||
fontWeight: 700,
|
||||
fontFamily: "'JetBrains Mono', ui-monospace",
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.15em',
|
||||
padding: '0 12px'
|
||||
}}>or</span>
|
||||
<div style={{ flex: 1, height: '1px', background: '#1a1a1a' }} />
|
||||
</div>
|
||||
|
||||
{/* Quick Add Section */}
|
||||
<div style={{
|
||||
background: 'transparent',
|
||||
padding: '0',
|
||||
width: '100%',
|
||||
maxWidth: '600px',
|
||||
marginBottom: '12px'
|
||||
}}>
|
||||
<QuickAddInput
|
||||
activeDelegations={orderedDelegations}
|
||||
onSubmit={handleQuickAddSubmit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Tab Bar (only show in session mode) */}
|
||||
{mode === 'session' && (
|
||||
<div className="agent-tabs" style={{ position: 'relative' }}>
|
||||
{/* Quick Add button - positioned at far right */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
right: '12px',
|
||||
transform: 'translateY(-50%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
zIndex: 20
|
||||
}}>
|
||||
<button
|
||||
onClick={() => {
|
||||
window.dispatchEvent(new CustomEvent('rah:switch-quickadd'));
|
||||
}}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#fff',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
padding: '4px 8px',
|
||||
transition: 'color 0.2s',
|
||||
fontFamily: 'inherit',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.color = '#d0d0d0';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = '#fff';
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
borderRadius: '50%',
|
||||
background: '#fff',
|
||||
color: '#0a0a0a',
|
||||
fontSize: '12px',
|
||||
lineHeight: 1,
|
||||
fontWeight: 300,
|
||||
flexShrink: 0
|
||||
}}>+</span>
|
||||
Quick Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* RA-H Main Tab */}
|
||||
<button
|
||||
onClick={() => setActiveAgentTab('ra-h')}
|
||||
className={`agent-tab ${activeAgentTab === 'ra-h' ? 'active' : ''}`}
|
||||
>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '6px', color: rahMode === 'easy' ? '#22c55e' : '#f97316' }}>
|
||||
{(rahMode === 'easy' ? <Zap size={12} strokeWidth={2.4} /> : <Flame size={12} strokeWidth={2.4} />)}
|
||||
<span>RA-H · {rahMode === 'easy' ? 'Easy' : 'Hard'}</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Delegation Tabs */}
|
||||
{orderedDelegations.map((delegation) => {
|
||||
const isActive = activeAgentTab === delegation.sessionId;
|
||||
const isWiseRAH = delegation.agentType === 'wise-rah';
|
||||
|
||||
// Status colors based on agent type
|
||||
const statusColor = isWiseRAH
|
||||
? (delegation.status === 'in_progress' ? '#8b5cf6' :
|
||||
delegation.status === 'completed' ? '#6b6b6b' :
|
||||
delegation.status === 'failed' ? '#ff6b6b' : '#a78bfa')
|
||||
: (delegation.status === 'in_progress' ? '#8bd450' :
|
||||
delegation.status === 'completed' ? '#6b6b6b' :
|
||||
delegation.status === 'failed' ? '#ff6b6b' : '#5c9aff');
|
||||
|
||||
const shortId = delegation.sessionId.split('_').pop() || '';
|
||||
const tabLabel = isWiseRAH
|
||||
? `WISE RA-H · ${shortId.slice(0, 6)}`
|
||||
: `MINI · ${shortId.slice(0, 6)}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={delegation.sessionId}
|
||||
className={`agent-tab-wrapper ${isWiseRAH ? 'wise' : 'mini'} ${isActive ? 'active' : ''}`}
|
||||
>
|
||||
<button
|
||||
onClick={() => setActiveAgentTab(delegation.sessionId)}
|
||||
className="agent-tab"
|
||||
>
|
||||
<span className="status-dot" style={{ background: statusColor }} />
|
||||
<span className="agent-tab-label">{tabLabel}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
console.log(`[AgentsPanel] User clicked close button for delegation ${delegation.sessionId}`);
|
||||
|
||||
// Delete from database
|
||||
try {
|
||||
await fetch(`/api/rah/delegations/${delegation.sessionId}`, { method: 'DELETE' });
|
||||
} catch (error) {
|
||||
console.error(`Failed to delete delegation ${delegation.sessionId}:`, error);
|
||||
}
|
||||
|
||||
// Remove from UI state
|
||||
setDelegationsMap((prev) => {
|
||||
const { [delegation.sessionId]: _ignored, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
setDelegationMessages((prev) => {
|
||||
const { [delegation.sessionId]: _ignored, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
if (activeAgentTab === delegation.sessionId) {
|
||||
setActiveAgentTab('ra-h');
|
||||
}
|
||||
}}
|
||||
className="agent-tab-close"
|
||||
title="Close tab"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active Panel */}
|
||||
<div style={{ flex: 1, minHeight: 0, position: 'relative', overflow: 'hidden' }}>
|
||||
{mode === 'quickadd' ? (
|
||||
<div style={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<QuickAddStatus
|
||||
delegations={orderedDelegations}
|
||||
onDelegationClick={handleDelegationClick}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Keep RAHChat always mounted, just hide when not active */}
|
||||
<div style={{
|
||||
height: '100%',
|
||||
display: activeAgentTab === 'ra-h' ? 'block' : 'none'
|
||||
}}>
|
||||
<RAHChat
|
||||
openTabsData={openTabsData}
|
||||
activeTabId={activeTabId}
|
||||
onNodeClick={onNodeClick}
|
||||
delegations={orderedDelegations}
|
||||
messages={rahMessages}
|
||||
setMessages={setRahMessages}
|
||||
mode={rahMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Show delegation chat when delegation tab is active */}
|
||||
{selectedDelegation && activeAgentTab !== 'ra-h' && (
|
||||
<div style={{ height: '100%', display: 'block' }}>
|
||||
<RAHChat
|
||||
openTabsData={openTabsData}
|
||||
activeTabId={activeTabId}
|
||||
onNodeClick={onNodeClick}
|
||||
delegations={orderedDelegations}
|
||||
messages={getDelegationMessages(selectedDelegation.sessionId)}
|
||||
setMessages={setDelegationMessagesFor(selectedDelegation.sessionId)}
|
||||
mode="easy"
|
||||
delegationMode={true}
|
||||
delegationSessionId={selectedDelegation.sessionId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<style jsx>{`
|
||||
.agent-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px 0 12px;
|
||||
background: #0a0a0a;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.agent-tabs::-webkit-scrollbar {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.agent-tabs::-webkit-scrollbar-track {
|
||||
background: #0d0d0d;
|
||||
}
|
||||
|
||||
.agent-tabs::-webkit-scrollbar-thumb {
|
||||
background: #1f1f1f;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.agent-tabs::-webkit-scrollbar-thumb:hover {
|
||||
background: #2c2c2c;
|
||||
}
|
||||
|
||||
.agent-tab-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
border-radius: 10px 10px 0 0;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.agent-tab-wrapper.active {
|
||||
background: #141414;
|
||||
}
|
||||
|
||||
.agent-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 9px 14px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: #6f6f6f;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.agent-tab-wrapper.active .agent-tab {
|
||||
color: #f3f3f3;
|
||||
}
|
||||
|
||||
.agent-tab:hover {
|
||||
color: #d0d0d0;
|
||||
}
|
||||
|
||||
.agent-tab-wrapper.wise .agent-tab-label {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.agent-tab-wrapper.mini .agent-tab-label {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.agent-tab-close {
|
||||
padding: 4px 8px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
font-size: 14px;
|
||||
color: #5a5a5a;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.agent-tab-close:hover {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
interface AsciiBannerProps {
|
||||
helperName: string;
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
export default function AsciiBanner({ helperName, displayName }: AsciiBannerProps) {
|
||||
const name = displayName || helperName;
|
||||
const nameUpper = name.toUpperCase();
|
||||
|
||||
// Create a unique minimal banner
|
||||
const createBanner = () => {
|
||||
// Dynamic sizing based on name length
|
||||
const minWidth = 24;
|
||||
const bannerWidth = Math.max(minWidth, nameUpper.length + 6);
|
||||
const padding = Math.floor((bannerWidth - nameUpper.length - 2) / 2);
|
||||
const paddedName = ' '.repeat(padding) + nameUpper + ' '.repeat(bannerWidth - nameUpper.length - padding - 2);
|
||||
|
||||
const topBottom = '─'.repeat(bannerWidth - 2);
|
||||
|
||||
return `┌${topBottom}┐
|
||||
│${paddedName}│
|
||||
└${topBottom}┘`;
|
||||
};
|
||||
|
||||
const banner = createBanner();
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
padding: '32px 16px',
|
||||
fontFamily: 'inherit',
|
||||
opacity: 0,
|
||||
animation: 'fadeIn 0.5s ease-out forwards',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<pre style={{
|
||||
color: '#353535',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.3',
|
||||
whiteSpace: 'pre',
|
||||
margin: '0 auto',
|
||||
display: 'inline-block',
|
||||
letterSpacing: '0.05em'
|
||||
}}>
|
||||
{banner}
|
||||
</pre>
|
||||
|
||||
<div style={{
|
||||
marginTop: '12px',
|
||||
fontSize: '11px',
|
||||
letterSpacing: '0.15em',
|
||||
textTransform: 'uppercase'
|
||||
}}>
|
||||
<span style={{ color: '#2a2a2a' }}>━━━</span>
|
||||
<span style={{ color: '#353535', margin: '0 8px' }}>ready</span>
|
||||
<span style={{ color: '#2a2a2a' }}>━━━</span>
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
@keyframes fadeIn {
|
||||
to { opacity: 1; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { AgentDelegation } from '@/services/agents/delegation';
|
||||
|
||||
interface DelegationIndicatorProps {
|
||||
delegations: AgentDelegation[];
|
||||
}
|
||||
|
||||
export default function DelegationIndicator({ delegations }: DelegationIndicatorProps) {
|
||||
if (!delegations.length) return null;
|
||||
|
||||
const activeCount = delegations.filter((d) => d.status === 'queued' || d.status === 'in_progress').length;
|
||||
|
||||
if (activeCount === 0) return null;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '4px',
|
||||
background: '#0f0f0f',
|
||||
border: '1px solid #1a1a1a',
|
||||
fontSize: '10px',
|
||||
fontFamily: "'JetBrains Mono', ui-monospace",
|
||||
color: '#6b6b6b'
|
||||
}}>
|
||||
<span style={{
|
||||
width: '6px',
|
||||
height: '6px',
|
||||
borderRadius: '50%',
|
||||
background: '#22c55e'
|
||||
}} />
|
||||
<span>{activeCount} active</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import { ReactNode } from 'react';
|
||||
import type { AgentDelegation } from '@/services/agents/delegation';
|
||||
|
||||
interface MiniRAHPanelProps {
|
||||
delegation: AgentDelegation;
|
||||
onNodeClick?: (nodeId: number) => void;
|
||||
}
|
||||
|
||||
const statusPalette: Record<string, { border: string; badge: string }> = {
|
||||
queued: { border: '#1f3a5f', badge: '#5c9aff' },
|
||||
in_progress: { border: '#3b5f2a', badge: '#8bd450' },
|
||||
completed: { border: '#2a2a2a', badge: '#6b6b6b' },
|
||||
failed: { border: '#5f2a2a', badge: '#ff6b6b' },
|
||||
};
|
||||
|
||||
const NODE_LINK_REGEX = /\[NODE:(\d+):"([^"]+)"\]/g;
|
||||
|
||||
function formatStatus(status: string) {
|
||||
switch (status) {
|
||||
case 'queued':
|
||||
return 'Queued';
|
||||
case 'in_progress':
|
||||
return 'Working';
|
||||
case 'completed':
|
||||
return 'Completed';
|
||||
case 'failed':
|
||||
return 'Failed';
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
function renderNodeAwareLine(text: string, onNodeClick?: (nodeId: number) => void): ReactNode {
|
||||
const parts: ReactNode[] = [];
|
||||
let lastIndex = 0;
|
||||
|
||||
text.replace(NODE_LINK_REGEX, (match, id, title, offset) => {
|
||||
if (offset > lastIndex) {
|
||||
parts.push(<span key={`mini-text-${offset}`}>{text.slice(lastIndex, offset)}</span>);
|
||||
}
|
||||
|
||||
const nodeId = Number(id);
|
||||
const handleClick = () => {
|
||||
if (onNodeClick) onNodeClick(nodeId);
|
||||
};
|
||||
|
||||
parts.push(
|
||||
<button
|
||||
key={`mini-node-${offset}`}
|
||||
type="button"
|
||||
className="node-pill"
|
||||
onClick={handleClick}
|
||||
>
|
||||
<span className="node-pill-id">NODE:{nodeId}</span>
|
||||
<span className="node-pill-title">{title}</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
lastIndex = offset + match.length;
|
||||
return match;
|
||||
});
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(<span key="mini-text-end">{text.slice(lastIndex)}</span>);
|
||||
}
|
||||
|
||||
return <>{parts}</>;
|
||||
}
|
||||
|
||||
export default function MiniRAHPanel({ delegation, onNodeClick }: MiniRAHPanelProps) {
|
||||
const palette = statusPalette[delegation.status] ?? statusPalette.queued;
|
||||
const summaryLines = delegation.summary ? delegation.summary.split('\n').filter(Boolean) : [];
|
||||
|
||||
return (
|
||||
<div className="mini-panel">
|
||||
<header className="mini-header" style={{ borderBottomColor: palette.border }}>
|
||||
<span className="status-dot" style={{ background: palette.badge }} />
|
||||
<span className="header-title">MINI RA-H</span>
|
||||
<span className="header-status">· {formatStatus(delegation.status)}</span>
|
||||
<span className="header-time">{new Date(delegation.updatedAt).toLocaleTimeString()}</span>
|
||||
</header>
|
||||
|
||||
<section className="section">
|
||||
<h3>Task</h3>
|
||||
<p>{delegation.task}</p>
|
||||
</section>
|
||||
|
||||
{delegation.context.length > 0 && (
|
||||
<section className="section">
|
||||
<h3>Context</h3>
|
||||
<ul className="context-list">
|
||||
{delegation.context.map((item, idx) => (
|
||||
<li key={idx}>{renderNodeAwareLine(item, onNodeClick)}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{summaryLines.length > 0 && (
|
||||
<section className="section">
|
||||
<h3>Summary</h3>
|
||||
<div className="summary-card">
|
||||
{summaryLines.map((line, idx) => (
|
||||
<p key={idx}>{renderNodeAwareLine(line, onNodeClick)}</p>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<style jsx>{`
|
||||
.mini-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 24px;
|
||||
background: #0a0a0a;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.mini-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid rgba(91, 154, 255, 0.25);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.12em;
|
||||
color: #84c8ff;
|
||||
}
|
||||
|
||||
.header-status {
|
||||
font-size: 12px;
|
||||
color: #7da0b6;
|
||||
}
|
||||
|
||||
.header-time {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
color: #5f5f5f;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.section h3 {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: #5c9aff;
|
||||
}
|
||||
|
||||
.section p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: #d4d4d4;
|
||||
}
|
||||
|
||||
.context-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.context-list li {
|
||||
font-size: 13px;
|
||||
color: #9aa7b6;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
background: #101010;
|
||||
border: 1px solid rgba(92, 154, 255, 0.15);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.summary-card p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: #f0f0f0;
|
||||
}
|
||||
|
||||
.node-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 8px;
|
||||
margin: 0 4px 4px 0;
|
||||
font-size: 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(92, 154, 255, 0.35);
|
||||
background: rgba(92, 154, 255, 0.08);
|
||||
color: #cfe4ff;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, border 0.15s ease;
|
||||
}
|
||||
|
||||
.node-pill:hover {
|
||||
background: rgba(92, 154, 255, 0.18);
|
||||
border-color: rgba(92, 154, 255, 0.65);
|
||||
}
|
||||
|
||||
.node-pill-id {
|
||||
font-size: 11px;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.node-pill-title {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { AgentDelegation } from '@/services/agents/delegation';
|
||||
|
||||
type QuickAddIntent = 'link' | 'note' | 'chat';
|
||||
|
||||
interface QuickAddSubmitPayload {
|
||||
input: string;
|
||||
mode: QuickAddIntent;
|
||||
}
|
||||
|
||||
interface QuickAddInputProps {
|
||||
activeDelegations: AgentDelegation[];
|
||||
onSubmit: (payload: QuickAddSubmitPayload) => Promise<void>;
|
||||
}
|
||||
|
||||
const MODE_CONFIG: Array<{
|
||||
key: QuickAddIntent;
|
||||
label: string;
|
||||
hint: string;
|
||||
icon: ReactNode;
|
||||
}> = [
|
||||
{
|
||||
key: 'link',
|
||||
label: 'Link',
|
||||
hint: 'Drop URLs for auto YouTube/PDF/Web extraction',
|
||||
icon: (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.35l2.12-2.12a5 5 0 1 0-7.07-7.07l-1.29 1.29" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.35l-2.12 2.12a5 5 0 1 0 7.07 7.07l1.29-1.29" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'note',
|
||||
label: 'Note',
|
||||
hint: 'Quick jot — no extraction, no summary',
|
||||
icon: (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
|
||||
<path d="M12 20h9" strokeLinecap="round" />
|
||||
<path d="M12 4h9" strokeLinecap="round" />
|
||||
<path d="M4 4h1v16H4z" />
|
||||
<path d="M7 9h7" strokeLinecap="round" />
|
||||
<path d="M7 13h5" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'chat',
|
||||
label: 'Chat',
|
||||
hint: 'Paste messy transcripts — store raw text + summary',
|
||||
icon: (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H9l-4 4V5a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2z" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M7 8h10" strokeLinecap="round" />
|
||||
<path d="M7 12h6" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
export default function QuickAddInput({ activeDelegations, onSubmit }: QuickAddInputProps) {
|
||||
const [input, setInput] = useState('');
|
||||
const [isPosting, setIsPosting] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [manualMode, setManualMode] = useState<QuickAddIntent | null>(null);
|
||||
const [autoMode, setAutoMode] = useState<QuickAddIntent>('link');
|
||||
const [autoReason, setAutoReason] = useState<string | null>(null);
|
||||
|
||||
const effectiveMode: QuickAddIntent = manualMode ?? autoMode;
|
||||
|
||||
const currentPlaceholder = useMemo(() => {
|
||||
if (effectiveMode === 'note') {
|
||||
return 'Write a quick note — no extraction, just append to your graph';
|
||||
}
|
||||
if (effectiveMode === 'chat') {
|
||||
return 'Paste any conversation (ChatGPT, Claude, etc.) and we will store raw text + summary';
|
||||
}
|
||||
return "Drop links, URL's, ideas or notes to add new nodes";
|
||||
}, [effectiveMode]);
|
||||
|
||||
const maxConcurrent = 5;
|
||||
const activeCount = activeDelegations.filter(
|
||||
(d) => d.status === 'queued' || d.status === 'in_progress'
|
||||
).length;
|
||||
const isSoftLimited = activeCount >= maxConcurrent;
|
||||
|
||||
const inferChatIntent = (value: string) => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
setAutoMode('link');
|
||||
setAutoReason(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (manualMode) return;
|
||||
|
||||
const newlineCount = (trimmed.match(/\n/g)?.length ?? 0);
|
||||
const looksLikeTranscript =
|
||||
newlineCount >= 2 ||
|
||||
/You said:|ChatGPT said:|Claude said:|Assistant:|User:/i.test(trimmed) ||
|
||||
/\b\d{1,2}:\d{2}\b/.test(trimmed);
|
||||
|
||||
if (looksLikeTranscript && trimmed.length > 280) {
|
||||
setAutoMode('chat');
|
||||
setAutoReason('Detected chat transcript');
|
||||
} else {
|
||||
setAutoMode('link');
|
||||
setAutoReason(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (value: string) => {
|
||||
setInput(value);
|
||||
inferChatIntent(value);
|
||||
};
|
||||
|
||||
const handleModeClick = (mode: QuickAddIntent) => {
|
||||
setManualMode(mode);
|
||||
setAutoMode(mode);
|
||||
setAutoReason(null);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!input.trim() || isPosting || isSoftLimited) return;
|
||||
|
||||
setIsPosting(true);
|
||||
try {
|
||||
await onSubmit({ input: input.trim(), mode: effectiveMode });
|
||||
setInput('');
|
||||
setManualMode(null);
|
||||
setAutoMode('link');
|
||||
setAutoReason(null);
|
||||
} catch (error) {
|
||||
console.error('[QuickAddInput] Submit error:', error);
|
||||
} finally {
|
||||
setIsPosting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
const isLinkMode = effectiveMode === 'link';
|
||||
const shouldSubmit = isLinkMode
|
||||
? (e.key === 'Enter' && !e.shiftKey)
|
||||
: (e.key === 'Enter' && (e.metaKey || e.ctrlKey));
|
||||
|
||||
if (shouldSubmit) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
const renderInputField = () => {
|
||||
if (effectiveMode === 'link') {
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => handleInputChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={currentPlaceholder}
|
||||
disabled={isPosting || isSoftLimited}
|
||||
autoFocus
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '12px 16px',
|
||||
background: '#0a0a0a',
|
||||
border: '2px solid #22c55e',
|
||||
borderRadius: '6px',
|
||||
color: '#e5e5e5',
|
||||
fontSize: '13px',
|
||||
fontFamily: "'JetBrains Mono', ui-monospace",
|
||||
outline: 'none',
|
||||
transition: 'all 0.15s ease',
|
||||
boxShadow: '0 0 0 3px rgba(34, 197, 94, 0.15)'
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.borderColor = '#22c55e';
|
||||
e.currentTarget.style.boxShadow = '0 0 0 3px rgba(34, 197, 94, 0.25)';
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.borderColor = '#22c55e';
|
||||
e.currentTarget.style.boxShadow = '0 0 0 3px rgba(34, 197, 94, 0.15)';
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<textarea
|
||||
value={input}
|
||||
onChange={(e) => handleInputChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={currentPlaceholder}
|
||||
disabled={isPosting || isSoftLimited}
|
||||
autoFocus
|
||||
rows={effectiveMode === 'chat' ? 6 : 4}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '12px 16px',
|
||||
background: '#0a0a0a',
|
||||
border: '2px solid #22c55e',
|
||||
borderRadius: '6px',
|
||||
color: '#e5e5e5',
|
||||
fontSize: '13px',
|
||||
fontFamily: "'JetBrains Mono', ui-monospace",
|
||||
outline: 'none',
|
||||
transition: 'all 0.15s ease',
|
||||
resize: 'vertical',
|
||||
minHeight: effectiveMode === 'chat' ? '150px' : '110px',
|
||||
boxShadow: '0 0 0 3px rgba(34, 197, 94, 0.15)'
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.borderColor = '#22c55e';
|
||||
e.currentTarget.style.boxShadow = '0 0 0 3px rgba(34, 197, 94, 0.25)';
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.borderColor = '#22c55e';
|
||||
e.currentTarget.style.boxShadow = '0 0 0 3px rgba(34, 197, 94, 0.15)';
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '10px'
|
||||
}}>
|
||||
{!isExpanded ? (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '16px'
|
||||
}}>
|
||||
<div style={{
|
||||
color: '#6b6b6b',
|
||||
fontSize: '13px',
|
||||
fontFamily: "'JetBrains Mono', ui-monospace",
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
Quickly add stuff
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsExpanded(true)}
|
||||
style={{
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: '#22c55e',
|
||||
border: 'none',
|
||||
borderRadius: '50%',
|
||||
color: '#0a0a0a',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 150ms ease',
|
||||
fontSize: '28px',
|
||||
fontWeight: 300,
|
||||
lineHeight: 1
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1.05)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 16px rgba(34, 197, 94, 0.4)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
title="Add new content"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
<div style={{ display: 'flex', gap: '8px', justifyContent: 'center' }}>
|
||||
{MODE_CONFIG.map((mode) => {
|
||||
const isActive = effectiveMode === mode.key && (manualMode === mode.key || (!manualMode && autoMode === mode.key));
|
||||
return (
|
||||
<button
|
||||
key={mode.key}
|
||||
type="button"
|
||||
onClick={() => handleModeClick(mode.key)}
|
||||
title={mode.hint}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
padding: '6px 12px',
|
||||
borderRadius: '999px',
|
||||
border: `1px solid ${isActive ? '#22c55e' : '#1f1f1f'}`,
|
||||
background: isActive ? 'rgba(34, 197, 94, 0.15)' : 'transparent',
|
||||
color: isActive ? '#22c55e' : '#9c9c9c',
|
||||
fontSize: '11px',
|
||||
fontFamily: "'JetBrains Mono', ui-monospace",
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.08em',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease'
|
||||
}}
|
||||
>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}>{mode.icon}</span>
|
||||
{mode.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{autoReason && !manualMode && (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
color: '#9c9c9c',
|
||||
fontSize: '11px',
|
||||
letterSpacing: '0.05em',
|
||||
textTransform: 'uppercase'
|
||||
}}>
|
||||
Auto-detected chat transcript
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'stretch' }}>
|
||||
{renderInputField()}
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!input.trim() || isPosting || isSoftLimited}
|
||||
aria-label={isPosting ? 'Adding' : 'Add'}
|
||||
title={isPosting ? 'Adding…' : 'Add (Enter)'}
|
||||
style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: input.trim() && !isPosting && !isSoftLimited ? '#22c55e' : '#22c55e',
|
||||
border: `2px solid #22c55e`,
|
||||
borderRadius: '50%',
|
||||
color: input.trim() && !isPosting && !isSoftLimited ? '#0a0a0a' : '#0a0a0a',
|
||||
cursor: input.trim() && !isPosting && !isSoftLimited ? 'pointer' : 'not-allowed',
|
||||
transition: 'all 150ms ease',
|
||||
opacity: input.trim() && !isPosting && !isSoftLimited ? 1 : 0.7,
|
||||
flexShrink: 0
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (input.trim() && !isPosting && !isSoftLimited) {
|
||||
e.currentTarget.style.transform = 'translateY(-1px)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(34, 197, 94, 0.4)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (input.trim() && !isPosting && !isSoftLimited) {
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isPosting ? (
|
||||
<span style={{ fontSize: '12px' }}>•••</span>
|
||||
) : (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M12 4l-6.5 6.5 1.42 1.42L11 8.84V20h2V8.84l4.08 3.08 1.42-1.42L12 4z"/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active processing indicator */}
|
||||
{activeCount > 0 && (
|
||||
<div style={{
|
||||
padding: '10px 14px',
|
||||
background: '#0a1a0a',
|
||||
border: '1px solid #1a3a1a',
|
||||
borderRadius: '6px',
|
||||
color: '#22c55e',
|
||||
fontSize: '11px',
|
||||
fontFamily: "'JetBrains Mono', ui-monospace",
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}>
|
||||
<span style={{
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: '50%',
|
||||
background: '#22c55e',
|
||||
animation: 'pulse 2s ease-in-out infinite'
|
||||
}} />
|
||||
<span>
|
||||
{activeCount === 1
|
||||
? 'Adding 1 node to your database...'
|
||||
: `Adding ${activeCount} nodes to your database...`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isSoftLimited && (
|
||||
<div style={{
|
||||
padding: '10px 14px',
|
||||
background: '#1a0a00',
|
||||
border: '1px solid #3a2a1a',
|
||||
borderRadius: '6px',
|
||||
color: '#ff9b5c',
|
||||
fontSize: '11px',
|
||||
fontFamily: "'JetBrains Mono', ui-monospace",
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}>
|
||||
<span>⚠</span>
|
||||
<span>Finish one of the 5 active Quick Adds before adding more.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style jsx>{`
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
"use client";
|
||||
|
||||
import type { AgentDelegation } from '@/services/agents/delegation';
|
||||
|
||||
interface QuickAddStatusProps {
|
||||
delegations: AgentDelegation[];
|
||||
onDelegationClick: (sessionId: string) => void;
|
||||
}
|
||||
|
||||
export default function QuickAddStatus({ delegations, onDelegationClick }: QuickAddStatusProps) {
|
||||
const activeDelegations = delegations.filter(d => d.status === 'queued' || d.status === 'in_progress');
|
||||
const completedDelegations = delegations.filter(d => d.status === 'completed' || d.status === 'failed');
|
||||
|
||||
console.log('[QuickAddStatus] Rendering with', delegations.length, 'total delegations,', activeDelegations.length, 'active');
|
||||
|
||||
const handleClearCompleted = async () => {
|
||||
for (const delegation of completedDelegations) {
|
||||
try {
|
||||
await fetch(`/api/rah/delegations/${delegation.sessionId}`, { method: 'DELETE' });
|
||||
} catch (error) {
|
||||
console.error('Failed to delete delegation:', error);
|
||||
}
|
||||
}
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
padding: '12px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
overflowY: 'auto',
|
||||
height: '100%',
|
||||
background: '#0a0a0a'
|
||||
}}>
|
||||
{/* Header */}
|
||||
{delegations.length > 0 && (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '8px 12px',
|
||||
background: '#151515',
|
||||
borderRadius: '6px',
|
||||
color: '#a8a8a8',
|
||||
fontSize: '11px',
|
||||
fontFamily: "'JetBrains Mono', ui-monospace",
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
borderLeft: activeDelegations.length > 0 ? '3px solid #22c55e' : '3px solid #6b6b6b'
|
||||
}}>
|
||||
<span>
|
||||
{activeDelegations.length > 0
|
||||
? `Processing ${activeDelegations.length} Quick Add${activeDelegations.length > 1 ? 's' : ''}...`
|
||||
: `${delegations.length} Recent Quick Add${delegations.length > 1 ? 's' : ''}`
|
||||
}
|
||||
</span>
|
||||
{completedDelegations.length > 0 && (
|
||||
<button
|
||||
onClick={handleClearCompleted}
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
background: '#1a1a1a',
|
||||
border: '1px solid #2a2a2a',
|
||||
borderRadius: '4px',
|
||||
color: '#6b6b6b',
|
||||
fontSize: '10px',
|
||||
fontFamily: "'JetBrains Mono', ui-monospace",
|
||||
cursor: 'pointer',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
transition: 'all 0.15s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#222';
|
||||
e.currentTarget.style.color = '#a8a8a8';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = '#1a1a1a';
|
||||
e.currentTarget.style.color = '#6b6b6b';
|
||||
}}
|
||||
>
|
||||
Clear Completed
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{delegations.map((delegation) => {
|
||||
const statusColor = delegation.status === 'in_progress' ? '#22c55e' :
|
||||
delegation.status === 'completed' ? '#6b6b6b' :
|
||||
delegation.status === 'failed' ? '#ff6b6b' : '#5c9aff';
|
||||
|
||||
const statusLabel = delegation.status === 'in_progress' ? 'Processing' :
|
||||
delegation.status === 'completed' ? 'Done' :
|
||||
delegation.status === 'failed' ? 'Failed' : 'Queued';
|
||||
|
||||
return (
|
||||
<button
|
||||
key={delegation.sessionId}
|
||||
onClick={() => onDelegationClick(delegation.sessionId)}
|
||||
style={{
|
||||
padding: '12px',
|
||||
background: '#151515',
|
||||
border: '1px solid #2a2a2a',
|
||||
borderRadius: '6px',
|
||||
textAlign: 'left',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '6px'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#1a1a1a';
|
||||
e.currentTarget.style.borderColor = '#3a3a3a';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = '#151515';
|
||||
e.currentTarget.style.borderColor = '#2a2a2a';
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: '8px'
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}>
|
||||
<span style={{
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: '50%',
|
||||
background: statusColor,
|
||||
flexShrink: 0
|
||||
}} />
|
||||
<span style={{
|
||||
color: '#e5e5e5',
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
fontFamily: "'JetBrains Mono', ui-monospace",
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.04em'
|
||||
}}>
|
||||
{statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
<span style={{
|
||||
color: '#6b6b6b',
|
||||
fontSize: '11px',
|
||||
fontFamily: "'JetBrains Mono', ui-monospace"
|
||||
}}>
|
||||
{new Date(delegation.createdAt).toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
color: '#a8a8a8',
|
||||
fontSize: '12px',
|
||||
fontFamily: "'JetBrains Mono', ui-monospace",
|
||||
lineHeight: '1.5',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
}}>
|
||||
{delegation.task}
|
||||
</div>
|
||||
|
||||
{delegation.summary && delegation.status === 'completed' && (
|
||||
<div style={{
|
||||
color: '#6b6b6b',
|
||||
fontSize: '11px',
|
||||
fontFamily: "'JetBrains Mono', ui-monospace",
|
||||
lineHeight: '1.4',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
}}>
|
||||
{delegation.summary}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{delegation.summary && delegation.status === 'failed' && (
|
||||
<div style={{
|
||||
color: '#ff6b6b',
|
||||
fontSize: '11px',
|
||||
fontFamily: "'JetBrains Mono', ui-monospace",
|
||||
lineHeight: '1.4'
|
||||
}}>
|
||||
{delegation.summary}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,691 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Node } from '@/types/database';
|
||||
import AsciiBanner from './AsciiBanner';
|
||||
import TerminalMessage from './TerminalMessage';
|
||||
import TerminalInput from './TerminalInput';
|
||||
import { Zap, Flame } from 'lucide-react';
|
||||
import DelegationIndicator from './DelegationIndicator';
|
||||
import type { AgentDelegation } from '@/services/agents/delegation';
|
||||
import { useSSEChat, ChatMessage, MessageRole } from './hooks/useSSEChat';
|
||||
import { useQuotaHandler } from '@/hooks/useQuotaHandler';
|
||||
import { apiKeyService } from '@/services/storage/apiKeys';
|
||||
import { useVoiceSession } from './hooks/useVoiceSession';
|
||||
import { useAssistantTTS } from './hooks/useAssistantTTS';
|
||||
import { useRealtimeVoiceClient } from './hooks/useRealtimeVoiceClient';
|
||||
import { useVoiceInterruption } from './hooks/useVoiceInterruption';
|
||||
|
||||
const createSessionId = () => `session_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
const createVoiceRequestId = () =>
|
||||
typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
|
||||
? crypto.randomUUID()
|
||||
: `voice_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
interface RAHChatProps {
|
||||
openTabsData: Node[];
|
||||
activeTabId: number | null;
|
||||
onNodeClick?: (nodeId: number) => void;
|
||||
delegations?: AgentDelegation[];
|
||||
messages?: ChatMessage[];
|
||||
setMessages?: (updater: (prev: ChatMessage[]) => ChatMessage[]) => void;
|
||||
mode?: 'easy' | 'hard';
|
||||
delegationMode?: boolean;
|
||||
delegationSessionId?: string;
|
||||
}
|
||||
|
||||
export default function RAHChat({
|
||||
openTabsData,
|
||||
activeTabId,
|
||||
onNodeClick,
|
||||
delegations = [],
|
||||
messages: externalMessages,
|
||||
setMessages: externalSetMessages,
|
||||
mode = 'easy',
|
||||
delegationMode = false,
|
||||
delegationSessionId,
|
||||
}: RAHChatProps) {
|
||||
// Use external state if provided (lifted state), otherwise use local state
|
||||
const [internalMessages, internalSetMessages] = useState<ChatMessage[]>([]);
|
||||
const messages = externalMessages !== undefined ? externalMessages : internalMessages;
|
||||
const setMessages = externalSetMessages || internalSetMessages;
|
||||
|
||||
const [sessionId, setSessionId] = useState(() => createSessionId());
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const chatMode = mode === 'hard' ? 'hard' : 'easy';
|
||||
const helperKey = chatMode === 'hard' ? 'ra-h' : 'ra-h-easy';
|
||||
const helperDisplayName = helperKey === 'ra-h' ? 'ra-h (hard)' : 'ra-h (easy)';
|
||||
const {
|
||||
quotaError,
|
||||
handleAPIError: handleQuotaApiError,
|
||||
checkQuotaBeforeRequest,
|
||||
refetchUsage,
|
||||
isQuotaExceeded,
|
||||
} = useQuotaHandler();
|
||||
const streamCompleteHandlerRef = useRef<() => Promise<void> | void>(async () => {
|
||||
await refetchUsage();
|
||||
});
|
||||
const setMessagesRef = useRef(setMessages);
|
||||
const voice = useVoiceSession();
|
||||
const {
|
||||
isActive: isVoiceActive,
|
||||
amplitude: voiceAmplitude,
|
||||
startSession: startVoice,
|
||||
stopSession: stopVoice,
|
||||
resetTranscript: resetVoiceTranscript,
|
||||
setStatus: setVoiceStatus,
|
||||
setAmplitude: setVoiceAmplitude,
|
||||
setInterimTranscript,
|
||||
appendFinalTranscript,
|
||||
} = voice;
|
||||
const pendingVoiceQueueRef = useRef<{ text: string; queuedAt: number }[]>([]);
|
||||
const assistantSpeechMapRef = useRef<Map<string, string>>(new Map());
|
||||
const [voiceError, setVoiceError] = useState<string | null>(null);
|
||||
const voiceErrorHandledRef = useRef(false);
|
||||
const voiceStartTimestampRef = useRef<number | null>(null);
|
||||
|
||||
const handleVoiceError = useCallback((error: Error) => {
|
||||
console.error('[RAHChat] Voice error:', error);
|
||||
setVoiceError(error.message);
|
||||
if (isVoiceActive) {
|
||||
stopVoice();
|
||||
}
|
||||
resetVoiceTranscript();
|
||||
setVoiceAmplitude(0);
|
||||
setVoiceStatus('idle');
|
||||
pendingVoiceQueueRef.current = [];
|
||||
assistantSpeechMapRef.current.clear();
|
||||
}, [isVoiceActive, resetVoiceTranscript, setVoiceAmplitude, setVoiceStatus, stopVoice]);
|
||||
|
||||
const { speak: speakAssistantResponse, stop: stopAssistantTTS, status: ttsStatus } = useAssistantTTS({
|
||||
onSpeechStart: () => {
|
||||
if (isVoiceActive) {
|
||||
setVoiceStatus('speaking');
|
||||
}
|
||||
},
|
||||
onSpeechComplete: () => {
|
||||
setVoiceStatus(isVoiceActive ? 'listening' : 'idle');
|
||||
},
|
||||
onError: handleVoiceError,
|
||||
});
|
||||
|
||||
const handleVoiceInterruption = useCallback(() => {
|
||||
stopAssistantTTS();
|
||||
setVoiceStatus('listening');
|
||||
}, [setVoiceStatus, stopAssistantTTS]);
|
||||
|
||||
const sse = useSSEChat('/api/rah/chat', setMessages, {
|
||||
getAuthToken: () => null,
|
||||
beforeRequest: checkQuotaBeforeRequest,
|
||||
onRequestError: handleQuotaApiError,
|
||||
onStreamComplete: async () => {
|
||||
const handler = streamCompleteHandlerRef.current;
|
||||
if (handler) {
|
||||
await handler();
|
||||
}
|
||||
},
|
||||
getApiKeys: () => apiKeyService.getStoredKeys(),
|
||||
});
|
||||
|
||||
useVoiceInterruption({
|
||||
amplitude: voiceAmplitude,
|
||||
isVoiceActive,
|
||||
ttsStatus,
|
||||
onInterruption: handleVoiceInterruption,
|
||||
});
|
||||
|
||||
|
||||
const sendMessage = useCallback(async (text: string) => {
|
||||
if (delegationMode) return; // Delegation chats are read-only
|
||||
if (isQuotaExceeded) {
|
||||
checkQuotaBeforeRequest();
|
||||
return;
|
||||
}
|
||||
await sse.send({
|
||||
text,
|
||||
history: messages,
|
||||
openTabs: openTabsData,
|
||||
activeTabId,
|
||||
sessionId,
|
||||
mode: chatMode
|
||||
});
|
||||
}, [activeTabId, chatMode, checkQuotaBeforeRequest, delegationMode, isQuotaExceeded, messages, openTabsData, sse, sessionId]);
|
||||
|
||||
const handleVoiceFinalTranscript = useCallback(
|
||||
(raw: string) => {
|
||||
const normalized = raw.trim();
|
||||
console.info('[RAHVoice] Final transcript received:', normalized || '(empty)');
|
||||
setInterimTranscript('');
|
||||
if (!normalized) {
|
||||
console.info('[RAHVoice] Ignoring empty transcript');
|
||||
return;
|
||||
}
|
||||
appendFinalTranscript(normalized);
|
||||
if (sse.isLoading) {
|
||||
pendingVoiceQueueRef.current.push({ text: normalized, queuedAt: Date.now() });
|
||||
console.info('[RAHVoice] SSE busy, queueing transcript', {
|
||||
queuedCount: pendingVoiceQueueRef.current.length,
|
||||
});
|
||||
setVoiceStatus('thinking');
|
||||
return;
|
||||
}
|
||||
setVoiceStatus('thinking');
|
||||
console.info('[RAHVoice] Dispatching transcript to /api/rah/chat');
|
||||
void sendMessage(normalized);
|
||||
},
|
||||
[appendFinalTranscript, sendMessage, setInterimTranscript, setVoiceStatus, sse.isLoading]
|
||||
);
|
||||
|
||||
const voiceRealtime = useRealtimeVoiceClient(
|
||||
{
|
||||
onStatusChange: (status) => {
|
||||
if (!isVoiceActive && status !== 'idle') return;
|
||||
if (status === 'listening' && (sse.isLoading || pendingVoiceQueueRef.current.length > 0)) {
|
||||
setVoiceStatus('thinking');
|
||||
return;
|
||||
}
|
||||
setVoiceStatus(status);
|
||||
},
|
||||
onInterimTranscript: setInterimTranscript,
|
||||
onFinalTranscript: handleVoiceFinalTranscript,
|
||||
onAmplitude: setVoiceAmplitude,
|
||||
onError: handleVoiceError,
|
||||
},
|
||||
{
|
||||
getAuthToken: () => null,
|
||||
}
|
||||
);
|
||||
|
||||
const handleStreamComplete = useCallback(async () => {
|
||||
if (pendingVoiceQueueRef.current.length > 0) {
|
||||
console.info('[RAHVoice] SSE stream complete, draining queued transcripts', {
|
||||
queued: pendingVoiceQueueRef.current.length,
|
||||
});
|
||||
}
|
||||
while (pendingVoiceQueueRef.current.length > 0) {
|
||||
const nextQueued = pendingVoiceQueueRef.current.shift();
|
||||
if (!nextQueued) {
|
||||
break;
|
||||
}
|
||||
const queueLatency = Date.now() - nextQueued.queuedAt;
|
||||
setVoiceStatus('thinking');
|
||||
console.info('[RAHVoice] Dispatching queued transcript to /api/rah/chat', {
|
||||
queuedMs: queueLatency,
|
||||
});
|
||||
await sendMessage(nextQueued.text);
|
||||
}
|
||||
await refetchUsage();
|
||||
}, [sendMessage, setVoiceStatus, refetchUsage]);
|
||||
|
||||
useEffect(() => {
|
||||
streamCompleteHandlerRef.current = handleStreamComplete;
|
||||
}, [handleStreamComplete]);
|
||||
|
||||
useEffect(() => {
|
||||
setMessagesRef.current = setMessages;
|
||||
}, [setMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!voiceError) {
|
||||
voiceErrorHandledRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (voiceErrorHandledRef.current) return;
|
||||
voiceErrorHandledRef.current = true;
|
||||
voiceRealtime.stop();
|
||||
stopAssistantTTS();
|
||||
}, [voiceError, voiceRealtime, stopAssistantTTS]);
|
||||
|
||||
const focusSummary = useMemo(() => {
|
||||
if (!openTabsData.length) return null;
|
||||
const titles = openTabsData.map((node) => node?.title || 'Untitled');
|
||||
const activeNode = openTabsData.find((node) => node.id === activeTabId) || openTabsData[0];
|
||||
const truncate = (value: string, limit = 64) => {
|
||||
if (value.length <= limit) return value;
|
||||
return `${value.slice(0, limit - 1)}…`;
|
||||
};
|
||||
return {
|
||||
id: activeNode?.id ?? null,
|
||||
title: truncate(activeNode?.title || 'Untitled'),
|
||||
total: titles.length,
|
||||
};
|
||||
}, [openTabsData, activeTabId]);
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVoiceActive) {
|
||||
assistantSpeechMapRef.current.clear();
|
||||
stopAssistantTTS();
|
||||
return;
|
||||
}
|
||||
if (sse.isLoading) return;
|
||||
const assistantMessages = messages.filter((m) => m.role === MessageRole.ASSISTANT);
|
||||
if (!assistantMessages.length) return;
|
||||
const latest = assistantMessages[assistantMessages.length - 1];
|
||||
const spokenContent = assistantSpeechMapRef.current.get(latest.id);
|
||||
if (!latest.content.trim() || spokenContent === latest.content) return;
|
||||
assistantSpeechMapRef.current.set(latest.id, latest.content);
|
||||
const voiceRequestId = createVoiceRequestId();
|
||||
speakAssistantResponse(latest.content, {
|
||||
flush: true,
|
||||
metadata: {
|
||||
sessionId,
|
||||
helper: helperKey,
|
||||
requestId: voiceRequestId,
|
||||
messageId: latest.id,
|
||||
},
|
||||
});
|
||||
}, [helperKey, isVoiceActive, messages, sessionId, sse.isLoading, speakAssistantResponse, stopAssistantTTS]);
|
||||
|
||||
|
||||
const handleNewChat = () => {
|
||||
if (delegationMode) return;
|
||||
sse.abort();
|
||||
setMessages((_prev) => []);
|
||||
setSessionId(createSessionId());
|
||||
if (isVoiceActive) {
|
||||
stopVoice();
|
||||
resetVoiceTranscript();
|
||||
}
|
||||
};
|
||||
|
||||
// Subscribe to delegation stream if in delegation mode
|
||||
useEffect(() => {
|
||||
if (!delegationMode || !delegationSessionId) return;
|
||||
|
||||
const eventSource = new EventSource(`/api/rah/delegations/stream?sessionId=${delegationSessionId}`);
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.type === 'text-delta' && data.delta) {
|
||||
setMessagesRef.current((prev) => {
|
||||
const lastMsg = prev[prev.length - 1];
|
||||
if (lastMsg && lastMsg.role === MessageRole.ASSISTANT) {
|
||||
const updated = [...prev];
|
||||
updated[updated.length - 1] = { ...lastMsg, content: lastMsg.content + data.delta };
|
||||
return updated;
|
||||
}
|
||||
return [...prev, {
|
||||
id: crypto.randomUUID(),
|
||||
role: MessageRole.ASSISTANT,
|
||||
content: data.delta,
|
||||
timestamp: new Date()
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
if (data.type === 'tool-input-start' || data.type === 'tool-call') {
|
||||
const toolMessage: ChatMessage = {
|
||||
id: `tool-${data.toolCallId || crypto.randomUUID()}`,
|
||||
role: MessageRole.TOOL,
|
||||
content: data.toolName,
|
||||
timestamp: new Date(),
|
||||
toolName: data.toolName,
|
||||
status: (data.status as ChatMessage['status']) || 'running',
|
||||
toolArgs: data.input ?? data.args ?? data.parameters
|
||||
};
|
||||
setMessagesRef.current((prev) => [...prev, toolMessage]);
|
||||
}
|
||||
|
||||
if (data.type === 'tool-output-available' || data.type === 'tool-result') {
|
||||
setMessagesRef.current((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === `tool-${data.toolCallId}`
|
||||
? {
|
||||
...m,
|
||||
content: `${m.toolName} ${data.status === 'error' ? '✗' : '✓'}`,
|
||||
status: (data.status as ChatMessage['status']) || (data.error ? 'error' : 'complete'),
|
||||
toolResult: data.result ?? data.output ?? (data.summary ? { summary: data.summary } : undefined),
|
||||
}
|
||||
: m
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (data.type === 'assistant-message') {
|
||||
setMessagesRef.current((prev) => [...prev, {
|
||||
id: crypto.randomUUID(),
|
||||
role: MessageRole.ASSISTANT,
|
||||
content: '',
|
||||
timestamp: new Date()
|
||||
}]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[RAHChat] Failed to parse delegation stream event:', error);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = () => {
|
||||
console.error('[RAHChat] Delegation stream connection error');
|
||||
};
|
||||
|
||||
return () => {
|
||||
eventSource.close();
|
||||
};
|
||||
}, [delegationMode, delegationSessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (delegationMode && isVoiceActive) {
|
||||
voiceRealtime.stop();
|
||||
stopVoice();
|
||||
resetVoiceTranscript();
|
||||
stopAssistantTTS();
|
||||
}
|
||||
}, [delegationMode, isVoiceActive, resetVoiceTranscript, stopAssistantTTS, stopVoice, voiceRealtime]);
|
||||
|
||||
const handleVoiceToggle = useCallback(async () => {
|
||||
if (isVoiceActive) {
|
||||
voiceRealtime.stop();
|
||||
stopVoice();
|
||||
resetVoiceTranscript();
|
||||
setVoiceAmplitude(0);
|
||||
setVoiceStatus('idle');
|
||||
assistantSpeechMapRef.current.clear();
|
||||
pendingVoiceQueueRef.current = [];
|
||||
stopAssistantTTS();
|
||||
voiceStartTimestampRef.current = null;
|
||||
return;
|
||||
}
|
||||
setVoiceError(null);
|
||||
try {
|
||||
voiceStartTimestampRef.current = performance.now();
|
||||
console.info('[RAHVoice] Voice session starting');
|
||||
await voiceRealtime.start();
|
||||
startVoice();
|
||||
setVoiceStatus('listening');
|
||||
} catch (error) {
|
||||
voiceStartTimestampRef.current = null;
|
||||
handleVoiceError(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
}, [
|
||||
handleVoiceError,
|
||||
isVoiceActive,
|
||||
resetVoiceTranscript,
|
||||
setVoiceAmplitude,
|
||||
setVoiceStatus,
|
||||
setVoiceError,
|
||||
startVoice,
|
||||
stopAssistantTTS,
|
||||
stopVoice,
|
||||
voiceRealtime,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: '#0a0a0a'
|
||||
}}>
|
||||
{focusSummary && (
|
||||
<header style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '8px 16px',
|
||||
borderBottom: '1px solid #1a1a1a',
|
||||
background: '#0a0a0a'
|
||||
}}>
|
||||
{/* Focused node info */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', minWidth: 0, flex: 1 }}>
|
||||
<span style={{
|
||||
color: '#22c55e',
|
||||
fontSize: '10px',
|
||||
letterSpacing: '0.18em',
|
||||
textTransform: 'uppercase'
|
||||
}}>
|
||||
Focused Node ({focusSummary.total})
|
||||
</span>
|
||||
<span style={{ color: '#d0d0d0', fontSize: '10px' }}>#{focusSummary.id}</span>
|
||||
<span
|
||||
style={{
|
||||
color: '#f3f3f3',
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis'
|
||||
}}
|
||||
title={focusSummary.title}
|
||||
>
|
||||
{focusSummary.title}
|
||||
</span>
|
||||
</div>
|
||||
<DelegationIndicator delegations={delegations} />
|
||||
</header>
|
||||
)}
|
||||
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: '16px', background: '#0a0a0a' }}>
|
||||
{messages.length === 0 ? (
|
||||
<AsciiBanner helperName="ra-h" displayName={helperDisplayName} />
|
||||
) : (
|
||||
<>
|
||||
{messages.map((message) => (
|
||||
<TerminalMessage
|
||||
key={message.id}
|
||||
role={message.role}
|
||||
content={message.content}
|
||||
timestamp={message.timestamp}
|
||||
toolName={message.toolName}
|
||||
status={message.status}
|
||||
toolArgs={message.toolArgs}
|
||||
toolResult={message.toolResult}
|
||||
onNodeClick={onNodeClick}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{/* Voice transcript preview removed for streamlined UI */}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{!delegationMode && (
|
||||
<div style={{
|
||||
padding: '0 16px 16px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px'
|
||||
}}>
|
||||
{(quotaError || isQuotaExceeded) && (
|
||||
<div style={{
|
||||
background: 'rgba(239,68,68,0.12)',
|
||||
border: '1px solid rgba(239,68,68,0.35)',
|
||||
color: '#fca5a5',
|
||||
fontSize: '11px',
|
||||
padding: '10px 12px',
|
||||
borderRadius: '6px',
|
||||
lineHeight: 1.4
|
||||
}}>
|
||||
{quotaError?.message ?? 'Your monthly allowance has been used. Upgrade your plan to keep chatting with RA-H.'}
|
||||
</div>
|
||||
)}
|
||||
<TerminalInput
|
||||
onSubmit={sendMessage}
|
||||
isProcessing={sse.isLoading || isQuotaExceeded}
|
||||
placeholder={isVoiceActive ? 'voice mode active — end session to type…' : `ask ${helperDisplayName}...`}
|
||||
helperId={activeTabId ?? undefined}
|
||||
disabledExternally={isVoiceActive}
|
||||
disabledMessage="voice mode active"
|
||||
onVoiceToggle={handleVoiceToggle}
|
||||
isVoiceActive={isVoiceActive}
|
||||
voiceAmplitude={voiceAmplitude}
|
||||
voiceError={voiceError || undefined}
|
||||
/>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: '12px',
|
||||
flexWrap: 'wrap'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', flexWrap: 'wrap' }}>
|
||||
<ModelSelector chatMode={chatMode} />
|
||||
</div>
|
||||
<button
|
||||
onClick={handleNewChat}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#fff',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
padding: '4px 8px',
|
||||
transition: 'color 0.2s',
|
||||
fontFamily: 'inherit',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.color = '#d0d0d0';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = '#fff';
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
borderRadius: '50%',
|
||||
background: '#fff',
|
||||
color: '#0a0a0a',
|
||||
fontSize: '12px',
|
||||
lineHeight: 1,
|
||||
fontWeight: 300,
|
||||
flexShrink: 0
|
||||
}}>+</span>
|
||||
New Chat
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ModelSelectorProps {
|
||||
chatMode: 'easy' | 'hard';
|
||||
}
|
||||
|
||||
function ModelSelector({ chatMode }: ModelSelectorProps) {
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
|
||||
const currentModel = chatMode === 'easy' ? 'Easy (GPT)' : 'Hard (Claude)';
|
||||
const Icon = chatMode === 'easy' ? Zap : Flame;
|
||||
const activeColor = chatMode === 'easy' ? '#22c55e' : '#f97316';
|
||||
|
||||
const options = [
|
||||
{ id: 'easy', label: 'Easy (GPT)', icon: Zap, color: '#22c55e' },
|
||||
{ id: 'hard', label: 'Hard (Claude)', icon: Flame, color: '#f97316' },
|
||||
{ id: 'soon', label: 'Ra-h (Soon)', icon: null, color: '#666', disabled: true }
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button
|
||||
onClick={() => setDropdownOpen(!dropdownOpen)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #1a1a1a',
|
||||
borderRadius: '6px',
|
||||
background: '#0f0f0f',
|
||||
color: '#e5e5e5',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = '#333';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = '#1a1a1a';
|
||||
}}
|
||||
>
|
||||
<Icon size={12} strokeWidth={2.4} color={activeColor} />
|
||||
{currentModel}
|
||||
<span style={{
|
||||
marginLeft: '4px',
|
||||
transform: dropdownOpen ? 'rotate(180deg)' : 'rotate(0deg)',
|
||||
transition: 'transform 0.2s'
|
||||
}}>▲</span>
|
||||
</button>
|
||||
|
||||
{dropdownOpen && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: '100%', /* Open upward */
|
||||
left: '0',
|
||||
marginBottom: '4px',
|
||||
background: '#1a1a1a',
|
||||
border: '1px solid #333',
|
||||
borderRadius: '6px',
|
||||
minWidth: '150px',
|
||||
zIndex: 1000,
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.4)'
|
||||
}}>
|
||||
{options.map((option) => {
|
||||
const OptionIcon = option.icon;
|
||||
const isActive = (option.id === 'easy' && chatMode === 'easy') || (option.id === 'hard' && chatMode === 'hard');
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
onClick={() => {
|
||||
if (!option.disabled && !isActive) {
|
||||
window.dispatchEvent(new CustomEvent('rah:mode-toggle', { detail: { mode: option.id as 'easy' | 'hard' } }));
|
||||
}
|
||||
setDropdownOpen(false);
|
||||
}}
|
||||
disabled={option.disabled}
|
||||
style={{
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '8px 12px',
|
||||
border: 'none',
|
||||
background: isActive ? 'rgba(34, 197, 94, 0.1)' : 'transparent',
|
||||
color: option.disabled ? '#666' : (isActive ? '#22c55e' : '#e5e5e5'),
|
||||
fontSize: '12px',
|
||||
cursor: option.disabled ? 'not-allowed' : 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
borderRadius: '4px'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!option.disabled && !isActive) {
|
||||
e.currentTarget.style.background = '#0a0a0a';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!option.disabled && !isActive) {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
}
|
||||
}}
|
||||
>
|
||||
{OptionIcon && <OptionIcon size={12} strokeWidth={2} color={option.color} />}
|
||||
{!OptionIcon && <div style={{ width: '12px' }} />} {/* Spacer for alignment */}
|
||||
{option.label}
|
||||
{isActive && <span style={{ marginLeft: 'auto', color: '#22c55e' }}>✓</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Mic, MicOff } from 'lucide-react';
|
||||
|
||||
interface TerminalInputProps {
|
||||
onSubmit: (text: string) => void;
|
||||
isProcessing: boolean;
|
||||
placeholder?: string;
|
||||
helperId?: number;
|
||||
disabledExternally?: boolean;
|
||||
disabledMessage?: string;
|
||||
onVoiceToggle?: () => void;
|
||||
isVoiceActive?: boolean;
|
||||
voiceAmplitude?: number;
|
||||
voiceError?: string | null;
|
||||
}
|
||||
|
||||
export default function TerminalInput({
|
||||
onSubmit,
|
||||
isProcessing,
|
||||
placeholder,
|
||||
helperId,
|
||||
disabledExternally = false,
|
||||
disabledMessage,
|
||||
onVoiceToggle,
|
||||
isVoiceActive = false,
|
||||
voiceAmplitude = 0,
|
||||
voiceError,
|
||||
}: TerminalInputProps) {
|
||||
const [input, setInput] = useState('');
|
||||
const [rows, setRows] = useState(1);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [prompts, setPrompts] = useState<Array<{ id: string; name: string; content: string }>>([]);
|
||||
const [showSlashMenu, setShowSlashMenu] = useState(false);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
if (!helperId) return;
|
||||
try {
|
||||
const resp = await fetch(`/api/helpers/${helperId}/prompts`);
|
||||
const data = await resp.json();
|
||||
if (resp.ok && data.success) setPrompts(Array.isArray(data.data?.prompts) ? data.data.prompts : []);
|
||||
} catch (e) {
|
||||
console.error('Failed to load prompts:', e);
|
||||
}
|
||||
};
|
||||
load();
|
||||
}, [helperId]);
|
||||
|
||||
// Auto-resize textarea
|
||||
useEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto';
|
||||
const scrollHeight = textareaRef.current.scrollHeight;
|
||||
const lineHeight = 20; // Approximate line height
|
||||
const newRows = Math.min(Math.max(1, Math.floor(scrollHeight / lineHeight)), 5);
|
||||
setRows(newRows);
|
||||
textareaRef.current.style.height = `${scrollHeight}px`;
|
||||
}
|
||||
}, [input]);
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (input.trim() && !isProcessing && !disabledExternally) {
|
||||
// Numeric slash expansion: only when input is exactly /N
|
||||
const m = input.trim().match(/^\/(\d{1,2})$/);
|
||||
if (m) {
|
||||
const n = parseInt(m[1], 10);
|
||||
const idx = n - 1;
|
||||
if (!isNaN(idx) && idx >= 0 && idx < prompts.length) {
|
||||
const content = String(prompts[idx]?.content || '').trim();
|
||||
if (content) {
|
||||
onSubmit(content);
|
||||
setInput('');
|
||||
setRows(1);
|
||||
setShowSlashMenu(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
onSubmit(input.trim());
|
||||
setInput('');
|
||||
setRows(1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
// Slash menu navigation
|
||||
if (showSlashMenu) {
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); setActiveIndex(i => Math.min(i + 1, prompts.length - 1)); return; }
|
||||
if (e.key === 'ArrowUp') { e.preventDefault(); setActiveIndex(i => Math.max(i - 1, 0)); return; }
|
||||
if (e.key === 'Tab') { e.preventDefault(); setActiveIndex(i => (i + 1) % Math.max(prompts.length, 1)); return; }
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const p = prompts[activeIndex];
|
||||
if (p) { setInput(p.content); setShowSlashMenu(false); }
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape') { setShowSlashMenu(false); }
|
||||
}
|
||||
if (e.key === 'Enter' && !e.shiftKey && !disabledExternally) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Toggle slash menu when typing starting '/'
|
||||
const trimmed = input.trimStart();
|
||||
if (trimmed.startsWith('/')) {
|
||||
setShowSlashMenu(true);
|
||||
setActiveIndex(0);
|
||||
} else {
|
||||
setShowSlashMenu(false);
|
||||
}
|
||||
}, [input]);
|
||||
|
||||
const trimmedInput = input.trim();
|
||||
const showVoiceStart = !isVoiceActive && !trimmedInput && Boolean(onVoiceToggle);
|
||||
const showVoiceStop = Boolean(onVoiceToggle) && isVoiceActive;
|
||||
const buttonIsDisabled =
|
||||
showVoiceStart || showVoiceStop
|
||||
? false
|
||||
: (!trimmedInput || isProcessing || disabledExternally);
|
||||
|
||||
const handlePrimaryAction = () => {
|
||||
if (showVoiceStart || showVoiceStop) {
|
||||
onVoiceToggle?.();
|
||||
return;
|
||||
}
|
||||
handleSubmit();
|
||||
};
|
||||
|
||||
const amplitudeBars = Array.from({ length: 8 });
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
@keyframes subtle-pulse {
|
||||
0%, 100% { opacity: 0.5; color: #3a3a3a; }
|
||||
50% { opacity: 0.7; color: #22c55e; }
|
||||
}
|
||||
textarea::placeholder {
|
||||
animation: subtle-pulse 4s ease-in-out infinite;
|
||||
}
|
||||
`}</style>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: '8px',
|
||||
padding: '8px 16px 12px',
|
||||
background: 'transparent',
|
||||
// Remove separator/border between chat area and input
|
||||
borderTop: 'none',
|
||||
fontFamily: 'inherit'
|
||||
}}>
|
||||
{/* Terminal Prompt Symbol */}
|
||||
<span style={{
|
||||
color: '#4a4a4a',
|
||||
fontSize: '13px',
|
||||
lineHeight: '1.5',
|
||||
paddingTop: '6px',
|
||||
userSelect: 'none',
|
||||
fontWeight: 500
|
||||
}}>
|
||||
{'>'}
|
||||
</span>
|
||||
|
||||
{/* Input Wrapper */}
|
||||
<div style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '4px'
|
||||
}}>
|
||||
{isVoiceActive && (
|
||||
<div style={{
|
||||
border: 'none',
|
||||
borderRadius: '0',
|
||||
background: 'transparent',
|
||||
padding: '12px 4px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<span style={{
|
||||
fontSize: '12px',
|
||||
letterSpacing: '0.08em',
|
||||
textTransform: 'uppercase',
|
||||
color: '#d4d4d4',
|
||||
}}>
|
||||
RA-H is listening
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(24, minmax(0, 1fr))', gap: '3px', height: '20px', marginTop: '6px' }}>
|
||||
{amplitudeBars.map((_, index) => {
|
||||
const level = (index + 1) / amplitudeBars.length;
|
||||
const active = voiceAmplitude >= level - 0.0001;
|
||||
return (
|
||||
<div
|
||||
key={`amp-${index}`}
|
||||
style={{
|
||||
width: '100%',
|
||||
borderRadius: '2px',
|
||||
height: `${10 + index * 1.2}px`,
|
||||
background: active ? '#22c55e' : '#1f1f1f',
|
||||
transition: 'background 120ms ease',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{voiceError && (
|
||||
<span style={{ color: '#f87171', fontSize: '11px' }}>{voiceError}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Input Row with Textarea and Button */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
alignItems: 'flex-start',
|
||||
position: 'relative'
|
||||
}}>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={isProcessing || disabledExternally}
|
||||
placeholder={placeholder || `ask ra-h...`}
|
||||
rows={rows}
|
||||
style={{
|
||||
flex: 1,
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
borderRadius: '0',
|
||||
color: '#e5e5e5',
|
||||
fontSize: '16px',
|
||||
fontFamily: 'inherit',
|
||||
padding: '8px 4px',
|
||||
resize: 'none',
|
||||
outline: 'none',
|
||||
lineHeight: '1.5',
|
||||
transition: 'all 200ms ease',
|
||||
minHeight: '32px',
|
||||
maxHeight: '120px',
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
caretColor: '#22c55e',
|
||||
...((isProcessing || disabledExternally) && {
|
||||
opacity: 0.5,
|
||||
cursor: 'not-allowed',
|
||||
caretColor: 'transparent'
|
||||
})
|
||||
}}
|
||||
// No focus border toggling; keep clean
|
||||
onFocus={() => {}}
|
||||
onBlur={() => {}}
|
||||
/>
|
||||
|
||||
{/* Slash menu */}
|
||||
{showSlashMenu && prompts.length > 0 && (
|
||||
<div style={{ position: 'absolute', bottom: '48px', left: '40px', background: '#0f0f0f', border: '1px solid #2a2a2a', borderRadius: '4px', padding: '6px', minWidth: '260px', maxHeight: '200px', overflowY: 'auto', zIndex: 1000 }}>
|
||||
{prompts.map((p, i) => (
|
||||
<div
|
||||
key={p.id}
|
||||
onMouseDown={(e) => { e.preventDefault(); setInput(p.content); setShowSlashMenu(false); }}
|
||||
onMouseEnter={() => setActiveIndex(i)}
|
||||
style={{ display: 'flex', gap: '8px', alignItems: 'center', padding: '6px 8px', cursor: 'pointer', background: i === activeIndex ? '#1a1a1a' : 'transparent', color: '#cfcfcf', fontSize: '12px' }}
|
||||
>
|
||||
<span style={{ color: '#5c9aff', fontSize: '10px', width: '20px' }}>/ {i + 1}</span>
|
||||
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{p.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit Button (minimal icon) */}
|
||||
<button
|
||||
onClick={handlePrimaryAction}
|
||||
disabled={buttonIsDisabled}
|
||||
aria-label={
|
||||
showVoiceStop
|
||||
? 'Stop voice session'
|
||||
: showVoiceStart
|
||||
? 'Start voice session'
|
||||
: isProcessing
|
||||
? 'Processing'
|
||||
: 'Send message'
|
||||
}
|
||||
title={
|
||||
showVoiceStop
|
||||
? 'Stop voice session'
|
||||
: showVoiceStart
|
||||
? 'Start voice session'
|
||||
: disabledExternally
|
||||
? (disabledMessage || 'Voice mode active')
|
||||
: isProcessing
|
||||
? 'Processing…'
|
||||
: 'Send (Enter)'
|
||||
}
|
||||
style={{
|
||||
width: '36px',
|
||||
height: '36px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: '#22c55e',
|
||||
border: '2px solid #22c55e',
|
||||
borderRadius: '50%',
|
||||
color: '#0a0a0a',
|
||||
cursor: buttonIsDisabled ? 'not-allowed' : 'pointer',
|
||||
transition: 'all 150ms ease',
|
||||
opacity: buttonIsDisabled ? 0.5 : 1,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!buttonIsDisabled) {
|
||||
e.currentTarget.style.transform = 'translateY(-1px)';
|
||||
e.currentTarget.style.boxShadow = `0 4px 12px rgba(${showVoiceStart || showVoiceStop ? '124,58,237' : '34,197,94'}, 0.4)`;
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!buttonIsDisabled) {
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}
|
||||
}}
|
||||
>
|
||||
{showVoiceStop ? (
|
||||
<MicOff size={16} strokeWidth={2.4} color="#0a0a0a" />
|
||||
) : showVoiceStart ? (
|
||||
<Mic size={16} strokeWidth={2.4} color="#0a0a0a" />
|
||||
) : isProcessing ? (
|
||||
<span style={{ fontSize: '12px' }}>•••</span>
|
||||
) : (
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M12 4l-6.5 6.5 1.42 1.42L11 8.84V20h2V8.84l4.08 3.08 1.42-1.42L12 4z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Subtle Keyboard Hints */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '10px',
|
||||
fontSize: '10px',
|
||||
color: '#353535',
|
||||
userSelect: 'none',
|
||||
marginTop: '2px'
|
||||
}}>
|
||||
<span>⏎ send</span>
|
||||
<span>⇧⏎ newline</span>
|
||||
{(isProcessing || disabledExternally) && (
|
||||
<span style={{
|
||||
marginLeft: 'auto',
|
||||
color: disabledExternally ? '#a855f7' : '#ffcc66',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.12em'
|
||||
}}>
|
||||
{disabledExternally ? (disabledMessage || 'voice mode active') : 'processing...'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { parseAndRenderContent } from '@/components/helpers/NodeLabelRenderer';
|
||||
import ToolDisplay from '@/components/helpers/ToolDisplay';
|
||||
import MarkdownRenderer from '@/components/helpers/MarkdownRenderer';
|
||||
import ReasoningTrace from '@/components/helpers/ReasoningTrace';
|
||||
import { extractToolContext, extractSources } from '@/utils/toolFormatting';
|
||||
|
||||
interface TerminalMessageProps {
|
||||
role: 'user' | 'assistant' | 'system' | 'tool' | 'thinking';
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
toolName?: string;
|
||||
status?: 'sending' | 'delivered' | 'error' | 'processing' | 'starting' | 'running' | 'complete';
|
||||
toolArgs?: any;
|
||||
toolResult?: any;
|
||||
onNodeClick?: (nodeId: number) => void;
|
||||
}
|
||||
|
||||
// no local state needed
|
||||
|
||||
export default function TerminalMessage({
|
||||
role,
|
||||
content,
|
||||
timestamp,
|
||||
toolName,
|
||||
status = 'delivered',
|
||||
toolArgs,
|
||||
toolResult,
|
||||
onNodeClick
|
||||
}: TerminalMessageProps) {
|
||||
|
||||
const dotColor = useMemo(() => {
|
||||
const colors = {
|
||||
user: '#5c9aff',
|
||||
assistant: '#52d97a',
|
||||
tool: '#ffcc66',
|
||||
thinking: '#b794f6',
|
||||
system: '#69d2e7'
|
||||
};
|
||||
return colors[role] || colors.assistant;
|
||||
}, [role]);
|
||||
|
||||
const isAnimated = role === 'thinking' || status === 'processing';
|
||||
|
||||
const formatTime = (date: Date) => {
|
||||
return date.toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
}).toLowerCase();
|
||||
};
|
||||
|
||||
// Handle tool messages specially
|
||||
if (role === 'tool') {
|
||||
// THINK tool: display structured trace when present in toolResult
|
||||
if (toolName === 'think') {
|
||||
const trace = toolResult?.data?.trace || null;
|
||||
return <ReasoningTrace trace={trace} collapsible />;
|
||||
}
|
||||
|
||||
// Generic tool display with context and sources
|
||||
const context =
|
||||
extractToolContext(toolName, toolArgs) ||
|
||||
(toolName === 'webSearch' && toolResult?.data?.query ? `Searching for: ${String(toolResult.data.query)}` : undefined);
|
||||
const sources = extractSources(toolName, toolResult);
|
||||
const normalizedStatus =
|
||||
status === 'processing' ? 'running' : status === 'delivered' ? 'complete' : ((status as any) || 'complete');
|
||||
return (
|
||||
<ToolDisplay
|
||||
name={toolName || 'tool'}
|
||||
status={normalizedStatus}
|
||||
context={context}
|
||||
sources={sources}
|
||||
result={toolResult}
|
||||
onNodeClick={onNodeClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '12px',
|
||||
padding: '8px 0',
|
||||
fontFamily: 'inherit'
|
||||
}}>
|
||||
{/* Status Dot */}
|
||||
<span
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
width: '6px',
|
||||
height: '6px',
|
||||
marginTop: '7px',
|
||||
borderRadius: '50%',
|
||||
background: dotColor,
|
||||
transition: 'all 150ms ease',
|
||||
...(isAnimated && {
|
||||
animation: 'pulse 1.5s infinite'
|
||||
})
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Message Content */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{content ? (
|
||||
<MarkdownRenderer content={content} streaming={status === 'processing'} onNodeClick={onNodeClick} />
|
||||
) : (
|
||||
role === 'thinking' ? <span>Thinking...</span> : null
|
||||
)}
|
||||
{/* References block for assistant messages: extract [Title](URL) */}
|
||||
{role === 'assistant' && typeof content === 'string' && (() => {
|
||||
const linkRegex = /\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g;
|
||||
const refs: Array<{ title: string; url: string }> = [];
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = linkRegex.exec(content)) !== null) {
|
||||
refs.push({ title: m[1], url: m[2] });
|
||||
if (refs.length >= 12) break;
|
||||
}
|
||||
if (refs.length === 0) return null;
|
||||
return (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<div style={{ color: '#8a8a8a', fontSize: 11, marginBottom: 4 }}>References</div>
|
||||
<div style={{ display: 'grid', gap: 4 }}>
|
||||
{refs.map((r, i) => (
|
||||
<div key={i} style={{ color: '#bdbdbd', fontSize: 12 }}>
|
||||
{i + 1}. <span style={{ color: '#e5e5e5' }}>{r.title}</span> — <a href={r.url} target="_blank" rel="noreferrer" style={{ color: '#5c9aff' }}>{r.url}</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Timestamp */}
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
marginTop: '4px',
|
||||
color: '#4a4a4a',
|
||||
fontSize: '11px'
|
||||
}}>
|
||||
{formatTime(timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
import { Fragment, ReactNode, useMemo } from 'react';
|
||||
import type { AgentDelegation } from '@/services/agents/delegation';
|
||||
|
||||
const statusPalette: Record<string, { border: string; badge: string }> = {
|
||||
queued: { border: '#3a2f5f', badge: '#a78bfa' },
|
||||
in_progress: { border: '#4a3a6f', badge: '#8b5cf6' },
|
||||
completed: { border: '#2a2a2a', badge: '#6b6b6b' },
|
||||
failed: { border: '#5f2a2a', badge: '#ff6b6b' },
|
||||
};
|
||||
|
||||
const NODE_LINK_REGEX = /\[NODE:(\d+):"([^"]+)"\]/g;
|
||||
const SUMMARY_HEADERS = ['Task', 'Actions', 'Result', 'Nodes', 'Follow-up'];
|
||||
|
||||
interface WiseRAHPanelProps {
|
||||
delegation: AgentDelegation;
|
||||
onNodeClick?: (nodeId: number) => void;
|
||||
}
|
||||
|
||||
function formatStatus(status: string) {
|
||||
switch (status) {
|
||||
case 'queued':
|
||||
return 'Queued';
|
||||
case 'in_progress':
|
||||
return 'Planning';
|
||||
case 'completed':
|
||||
return 'Completed';
|
||||
case 'failed':
|
||||
return 'Failed';
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
function renderNodeAwareText(text: string, onNodeClick?: (nodeId: number) => void): ReactNode {
|
||||
const segments: ReactNode[] = [];
|
||||
let lastIndex = 0;
|
||||
|
||||
text.replace(NODE_LINK_REGEX, (match, id, title, offset) => {
|
||||
if (offset > lastIndex) {
|
||||
segments.push(<span key={`text-${offset}`}>{text.slice(lastIndex, offset)}</span>);
|
||||
}
|
||||
const nodeId = Number(id);
|
||||
const handleClick = () => {
|
||||
if (onNodeClick) {
|
||||
onNodeClick(nodeId);
|
||||
}
|
||||
};
|
||||
segments.push(
|
||||
<button
|
||||
key={`node-${offset}`}
|
||||
type="button"
|
||||
className="node-pill"
|
||||
onClick={handleClick}
|
||||
>
|
||||
<span className="node-pill-id">NODE:{nodeId}</span>
|
||||
<span className="node-pill-title">{title}</span>
|
||||
</button>
|
||||
);
|
||||
lastIndex = offset + match.length;
|
||||
return match;
|
||||
});
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
segments.push(<span key={`text-end`}>{text.slice(lastIndex)}</span>);
|
||||
}
|
||||
|
||||
return <>{segments}</>;
|
||||
}
|
||||
|
||||
function parseSummary(summary: string) {
|
||||
const lines = summary.split('\n');
|
||||
const sections: Array<{ title: string; lines: string[] }> = [];
|
||||
let current: { title: string; lines: string[] } | null = null;
|
||||
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) continue;
|
||||
|
||||
const header = SUMMARY_HEADERS.find(h => line.toLowerCase().startsWith(`${h.toLowerCase()}:`));
|
||||
if (header) {
|
||||
const content = line.slice(header.length + 1).trim();
|
||||
current = { title: header, lines: [] };
|
||||
if (content) current.lines.push(content);
|
||||
sections.push(current);
|
||||
} else if (current) {
|
||||
current.lines.push(line);
|
||||
} else {
|
||||
if (!current) {
|
||||
current = { title: 'Summary', lines: [] };
|
||||
sections.push(current);
|
||||
}
|
||||
current.lines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
function renderSectionLines(lines: string[], onNodeClick?: (nodeId: number) => void) {
|
||||
const hasBullet = lines.some(line => /^[-*•]/.test(line.trim()));
|
||||
|
||||
if (hasBullet) {
|
||||
return (
|
||||
<ul className="summary-list">
|
||||
{lines.map((line, idx) => {
|
||||
const text = line.replace(/^[-*•]\s*/, '').trim();
|
||||
return (
|
||||
<li key={idx}>
|
||||
{renderNodeAwareText(text, onNodeClick)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="summary-paragraphs">
|
||||
{lines.map((line, idx) => (
|
||||
<p key={idx}>{renderNodeAwareText(line, onNodeClick)}</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WiseRAHPanel({ delegation, onNodeClick }: WiseRAHPanelProps) {
|
||||
const palette = statusPalette[delegation.status] ?? statusPalette.queued;
|
||||
|
||||
const parsedSummary = useMemo(() => {
|
||||
if (!delegation.summary) return [];
|
||||
return parseSummary(delegation.summary);
|
||||
}, [delegation.summary]);
|
||||
|
||||
return (
|
||||
<div className="wise-panel">
|
||||
<header className="wise-header" style={{ borderBottomColor: palette.border }}>
|
||||
<span className="status-dot" style={{ background: palette.badge }} />
|
||||
<span className="header-title">WISE RA-H</span>
|
||||
<span className="header-status">· {formatStatus(delegation.status)}</span>
|
||||
<span className="header-time">{new Date(delegation.updatedAt).toLocaleTimeString()}</span>
|
||||
</header>
|
||||
|
||||
<section className="section">
|
||||
<h3>Goal</h3>
|
||||
<p>{delegation.task}</p>
|
||||
</section>
|
||||
|
||||
{delegation.context.length > 0 && (
|
||||
<section className="section">
|
||||
<h3>Context</h3>
|
||||
<ul className="context-list">
|
||||
{delegation.context.map((item, idx) => (
|
||||
<li key={idx}>{renderNodeAwareText(item, onNodeClick)}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{parsedSummary.length > 0 && (
|
||||
<section className="section">
|
||||
<h3>Summary</h3>
|
||||
<div className="summary-card">
|
||||
{parsedSummary.map(section => (
|
||||
<div key={section.title} className="summary-section">
|
||||
<div className="summary-title">{section.title}</div>
|
||||
{renderSectionLines(section.lines, onNodeClick)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<style jsx>{`
|
||||
.wise-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
padding: 24px;
|
||||
background: #0a0a0a;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.wise-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid rgba(167, 139, 250, 0.35);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.12em;
|
||||
color: #bba4ff;
|
||||
}
|
||||
|
||||
.header-status {
|
||||
font-size: 12px;
|
||||
color: #8b82a6;
|
||||
}
|
||||
|
||||
.header-time {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
color: #5f5f5f;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.section h3 {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: #a78bfa;
|
||||
}
|
||||
|
||||
.section p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: #d4d4d4;
|
||||
}
|
||||
|
||||
.context-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.context-list li {
|
||||
font-size: 13px;
|
||||
color: #a0a0a0;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
background: #101010;
|
||||
border: 1px solid rgba(167, 139, 250, 0.1);
|
||||
border-radius: 12px;
|
||||
padding: 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.summary-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.summary-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #c5b5ff;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.summary-paragraphs p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: #e5e5e5;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.summary-list {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.summary-list li {
|
||||
font-size: 13px;
|
||||
color: #e5e5e5;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.node-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
margin: 0 4px 4px 0;
|
||||
font-size: 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(167, 139, 250, 0.3);
|
||||
background: rgba(167, 139, 250, 0.08);
|
||||
color: #d8d3ff;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, border 0.15s ease;
|
||||
}
|
||||
|
||||
.node-pill:hover {
|
||||
background: rgba(167, 139, 250, 0.18);
|
||||
border: 1px solid rgba(167, 139, 250, 0.6);
|
||||
}
|
||||
|
||||
.node-pill-id {
|
||||
font-size: 11px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.node-pill-title {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
export type TTSStatus = 'idle' | 'loading' | 'speaking';
|
||||
|
||||
interface UseAssistantTTSOptions {
|
||||
voice?: string;
|
||||
onSpeechStart?: () => void;
|
||||
onSpeechComplete?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
interface SpeakRequestMetadata {
|
||||
sessionId?: string | null;
|
||||
helper?: string | null;
|
||||
requestId?: string;
|
||||
messageId?: string | null;
|
||||
}
|
||||
|
||||
interface SpeakOptions {
|
||||
flush?: boolean;
|
||||
metadata?: SpeakRequestMetadata;
|
||||
}
|
||||
|
||||
type SpeakQueueItem = {
|
||||
text: string;
|
||||
metadata?: SpeakRequestMetadata;
|
||||
};
|
||||
|
||||
export function useAssistantTTS(options: UseAssistantTTSOptions = {}) {
|
||||
const [status, setStatus] = useState<TTSStatus>('idle');
|
||||
const queueRef = useRef<SpeakQueueItem[]>([]);
|
||||
const isProcessingRef = useRef(false);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const objectUrlRef = useRef<string | null>(null);
|
||||
const mediaSourceRef = useRef<MediaSource | null>(null);
|
||||
const sourceBufferRef = useRef<SourceBuffer | null>(null);
|
||||
const chunkQueueRef = useRef<ArrayBuffer[]>([]);
|
||||
const updateEndHandlerRef = useRef<(() => void) | null>(null);
|
||||
const readerDoneRef = useRef(false);
|
||||
const processQueueRef = useRef<(() => void) | null>(null);
|
||||
|
||||
const onSpeechStartRef = useRef(options.onSpeechStart);
|
||||
const onSpeechCompleteRef = useRef(options.onSpeechComplete);
|
||||
const onErrorRef = useRef(options.onError);
|
||||
const voiceRef = useRef(options.voice);
|
||||
|
||||
useEffect(() => {
|
||||
onSpeechStartRef.current = options.onSpeechStart;
|
||||
}, [options.onSpeechStart]);
|
||||
|
||||
useEffect(() => {
|
||||
onSpeechCompleteRef.current = options.onSpeechComplete;
|
||||
}, [options.onSpeechComplete]);
|
||||
|
||||
useEffect(() => {
|
||||
onErrorRef.current = options.onError;
|
||||
}, [options.onError]);
|
||||
|
||||
useEffect(() => {
|
||||
voiceRef.current = options.voice;
|
||||
}, [options.voice]);
|
||||
|
||||
const setStatusSafe = useCallback((next: TTSStatus) => {
|
||||
setStatus(next);
|
||||
}, []);
|
||||
|
||||
const cleanupMedia = useCallback(() => {
|
||||
if (sourceBufferRef.current && updateEndHandlerRef.current) {
|
||||
try {
|
||||
sourceBufferRef.current.removeEventListener('updateend', updateEndHandlerRef.current);
|
||||
} catch (err) {
|
||||
console.warn('[TTS] Failed to detach source buffer listener', err);
|
||||
}
|
||||
}
|
||||
updateEndHandlerRef.current = null;
|
||||
sourceBufferRef.current = null;
|
||||
mediaSourceRef.current = null;
|
||||
if (objectUrlRef.current) {
|
||||
URL.revokeObjectURL(objectUrlRef.current);
|
||||
objectUrlRef.current = null;
|
||||
}
|
||||
chunkQueueRef.current = [];
|
||||
readerDoneRef.current = false;
|
||||
}, []);
|
||||
|
||||
const handleError = useCallback((error: Error) => {
|
||||
console.error('[TTS] Playback error', error);
|
||||
onErrorRef.current?.(error);
|
||||
}, []);
|
||||
|
||||
const stopCurrentPlayback = useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = null;
|
||||
const audio = audioRef.current;
|
||||
if (audio) {
|
||||
audio.pause();
|
||||
audio.removeAttribute('src');
|
||||
try {
|
||||
audio.load();
|
||||
} catch (err) {
|
||||
console.warn('[TTS] Failed to reset audio element', err);
|
||||
}
|
||||
}
|
||||
cleanupMedia();
|
||||
isProcessingRef.current = false;
|
||||
}, [cleanupMedia]);
|
||||
|
||||
const ensureAudioElement = useCallback(() => {
|
||||
if (audioRef.current) {
|
||||
return audioRef.current;
|
||||
}
|
||||
const audio = new Audio();
|
||||
audio.autoplay = true;
|
||||
audio.preload = 'auto';
|
||||
audio.addEventListener('playing', () => {
|
||||
setStatusSafe('speaking');
|
||||
onSpeechStartRef.current?.();
|
||||
});
|
||||
audio.addEventListener('ended', () => {
|
||||
cleanupMedia();
|
||||
isProcessingRef.current = false;
|
||||
setStatusSafe('idle');
|
||||
onSpeechCompleteRef.current?.();
|
||||
processQueueRef.current?.();
|
||||
});
|
||||
audio.addEventListener('error', () => {
|
||||
handleError(new Error('Audio playback failed'));
|
||||
stopCurrentPlayback();
|
||||
processQueueRef.current?.();
|
||||
});
|
||||
audioRef.current = audio;
|
||||
return audio;
|
||||
}, [cleanupMedia, handleError, setStatusSafe, stopCurrentPlayback]);
|
||||
|
||||
const flushChunks = useCallback(() => {
|
||||
const sourceBuffer = sourceBufferRef.current;
|
||||
const mediaSource = mediaSourceRef.current;
|
||||
if (!sourceBuffer || !mediaSource || sourceBuffer.updating) {
|
||||
return;
|
||||
}
|
||||
const nextChunk = chunkQueueRef.current.shift();
|
||||
if (nextChunk) {
|
||||
try {
|
||||
sourceBuffer.appendBuffer(nextChunk);
|
||||
} catch (error) {
|
||||
handleError(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (readerDoneRef.current && !sourceBuffer.updating) {
|
||||
try {
|
||||
mediaSource.endOfStream();
|
||||
} catch (error) {
|
||||
console.warn('[TTS] Failed to end media source stream', error);
|
||||
}
|
||||
}
|
||||
}, [handleError]);
|
||||
|
||||
const processQueue = useCallback(async () => {
|
||||
if (isProcessingRef.current) return;
|
||||
const next = queueRef.current.shift();
|
||||
if (!next) {
|
||||
setStatusSafe('idle');
|
||||
return;
|
||||
}
|
||||
isProcessingRef.current = true;
|
||||
setStatusSafe('loading');
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
const payload: Record<string, any> = {
|
||||
text: next.text,
|
||||
voice: voiceRef.current,
|
||||
};
|
||||
|
||||
if (next.metadata?.sessionId) {
|
||||
payload.sessionId = next.metadata.sessionId;
|
||||
}
|
||||
if (next.metadata?.helper) {
|
||||
payload.helper = next.metadata.helper;
|
||||
}
|
||||
if (next.metadata?.requestId) {
|
||||
payload.requestId = next.metadata.requestId;
|
||||
}
|
||||
if (next.metadata?.messageId) {
|
||||
payload.messageId = next.metadata.messageId;
|
||||
}
|
||||
|
||||
const response = await fetch('/api/voice/tts', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error((await response.text().catch(() => '')) || 'Failed to synthesize audio');
|
||||
}
|
||||
const reader = response.body.getReader();
|
||||
const audio = ensureAudioElement();
|
||||
cleanupMedia();
|
||||
const mimeType = response.headers.get('Content-Type') || 'audio/mpeg';
|
||||
const mediaSource = new MediaSource();
|
||||
mediaSourceRef.current = mediaSource;
|
||||
const objectUrl = URL.createObjectURL(mediaSource);
|
||||
objectUrlRef.current = objectUrl;
|
||||
audio.src = objectUrl;
|
||||
audio.load();
|
||||
|
||||
const onSourceOpen = () => {
|
||||
mediaSource.removeEventListener('sourceopen', onSourceOpen);
|
||||
let sourceBuffer: SourceBuffer;
|
||||
try {
|
||||
sourceBuffer = mediaSource.addSourceBuffer(mimeType);
|
||||
} catch {
|
||||
throw new Error(`Unsupported audio format: ${mimeType}`);
|
||||
}
|
||||
sourceBufferRef.current = sourceBuffer;
|
||||
const handleUpdateEnd = () => flushChunks();
|
||||
updateEndHandlerRef.current = handleUpdateEnd;
|
||||
sourceBuffer.addEventListener('updateend', handleUpdateEnd);
|
||||
flushChunks();
|
||||
};
|
||||
|
||||
mediaSource.addEventListener('sourceopen', onSourceOpen);
|
||||
|
||||
const pump = async () => {
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) {
|
||||
readerDoneRef.current = true;
|
||||
flushChunks();
|
||||
break;
|
||||
}
|
||||
if (value) {
|
||||
const buffer = value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength);
|
||||
chunkQueueRef.current.push(buffer);
|
||||
flushChunks();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
pump().catch((error) => {
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
if ((err as DOMException).name !== 'AbortError') {
|
||||
handleError(err);
|
||||
}
|
||||
stopCurrentPlayback();
|
||||
processQueueRef.current?.();
|
||||
});
|
||||
|
||||
audio.play().catch((error) => {
|
||||
handleError(error instanceof Error ? error : new Error(String(error)));
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
isProcessingRef.current = false;
|
||||
setStatusSafe('idle');
|
||||
handleError(error instanceof Error ? error : new Error(String(error)));
|
||||
processQueueRef.current?.();
|
||||
}
|
||||
}, [cleanupMedia, ensureAudioElement, flushChunks, handleError, setStatusSafe, stopCurrentPlayback]);
|
||||
|
||||
processQueueRef.current = processQueue;
|
||||
|
||||
const speak = useCallback(
|
||||
(text: string, options?: SpeakOptions) => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return;
|
||||
if (options?.flush) {
|
||||
queueRef.current = [{ text: trimmed, metadata: options.metadata }];
|
||||
stopCurrentPlayback();
|
||||
} else {
|
||||
queueRef.current.push({ text: trimmed, metadata: options?.metadata });
|
||||
}
|
||||
processQueue();
|
||||
},
|
||||
[processQueue, stopCurrentPlayback]
|
||||
);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
queueRef.current = [];
|
||||
stopCurrentPlayback();
|
||||
setStatusSafe('idle');
|
||||
}, [setStatusSafe, stopCurrentPlayback]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
stop();
|
||||
};
|
||||
}, [stop]);
|
||||
|
||||
return {
|
||||
status,
|
||||
speak,
|
||||
stop,
|
||||
} as const;
|
||||
}
|
||||
@@ -0,0 +1,570 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
export type RealtimeConnectionState = 'idle' | 'connecting' | 'ready' | 'capturing';
|
||||
|
||||
interface VoiceRealtimeCallbacks {
|
||||
onStatusChange?: (status: 'idle' | 'listening' | 'thinking' | 'speaking') => void;
|
||||
onInterimTranscript?: (text: string) => void;
|
||||
onFinalTranscript?: (text: string) => void;
|
||||
onAmplitude?: (value: number) => void;
|
||||
onError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
interface UseRealtimeVoiceClientOptions {
|
||||
getAuthToken?: () => string | null | undefined;
|
||||
fetchEphemeralToken?: (
|
||||
authToken: string | null
|
||||
) => Promise<{ client_secret: { value: string }; model: string; voice: string }>;
|
||||
silenceThresholdMs?: number;
|
||||
silenceAmplitudeCutoff?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_SILENCE_THRESHOLD_MS = 800;
|
||||
const DEFAULT_SILENCE_AMPLITUDE = 0.0015;
|
||||
|
||||
type Nullable<T> = T | null;
|
||||
|
||||
function calculateRms(buffer: Float32Array) {
|
||||
if (!buffer.length) return 0;
|
||||
let sumSquares = 0;
|
||||
for (let i = 0; i < buffer.length; i += 1) {
|
||||
const value = buffer[i];
|
||||
sumSquares += value * value;
|
||||
}
|
||||
return Math.sqrt(sumSquares / buffer.length);
|
||||
}
|
||||
|
||||
|
||||
export function useRealtimeVoiceClient(
|
||||
callbacks: VoiceRealtimeCallbacks,
|
||||
options: UseRealtimeVoiceClientOptions = {}
|
||||
) {
|
||||
const { onStatusChange, onInterimTranscript, onFinalTranscript, onAmplitude, onError } = callbacks;
|
||||
|
||||
const { getAuthToken, fetchEphemeralToken, silenceThresholdMs, silenceAmplitudeCutoff } = options;
|
||||
|
||||
const connectionStateRef = useRef<RealtimeConnectionState>('idle');
|
||||
const peerConnectionRef = useRef<Nullable<RTCPeerConnection>>(null);
|
||||
const dataChannelRef = useRef<Nullable<RTCDataChannel>>(null);
|
||||
const audioContextRef = useRef<Nullable<AudioContext>>(null);
|
||||
const processorNodeRef = useRef<Nullable<ScriptProcessorNode>>(null);
|
||||
const mediaStreamRef = useRef<Nullable<MediaStream>>(null);
|
||||
const mediaSourceRef = useRef<Nullable<MediaStreamAudioSourceNode>>(null);
|
||||
const awaitingTranscriptRef = useRef(false);
|
||||
const hasUncommittedAudioRef = useRef(false);
|
||||
const lastSpeechAtRef = useRef<number | null>(null);
|
||||
const destroyedRef = useRef(false);
|
||||
const channelReadyRef = useRef(false);
|
||||
|
||||
const silenceWindowMs = silenceThresholdMs ?? DEFAULT_SILENCE_THRESHOLD_MS;
|
||||
const amplitudeGate = silenceAmplitudeCutoff ?? DEFAULT_SILENCE_AMPLITUDE;
|
||||
|
||||
const onStatusChangeRef = useRef(onStatusChange);
|
||||
const onInterimTranscriptRef = useRef(onInterimTranscript);
|
||||
const onFinalTranscriptRef = useRef(onFinalTranscript);
|
||||
const onAmplitudeRef = useRef(onAmplitude);
|
||||
const onErrorRef = useRef(onError);
|
||||
|
||||
useEffect(() => {
|
||||
onStatusChangeRef.current = onStatusChange;
|
||||
}, [onStatusChange]);
|
||||
|
||||
useEffect(() => {
|
||||
onInterimTranscriptRef.current = onInterimTranscript;
|
||||
}, [onInterimTranscript]);
|
||||
|
||||
useEffect(() => {
|
||||
onFinalTranscriptRef.current = onFinalTranscript;
|
||||
}, [onFinalTranscript]);
|
||||
|
||||
useEffect(() => {
|
||||
onAmplitudeRef.current = onAmplitude;
|
||||
}, [onAmplitude]);
|
||||
|
||||
useEffect(() => {
|
||||
onErrorRef.current = onError;
|
||||
}, [onError]);
|
||||
|
||||
const setConnectionState = useCallback((next: RealtimeConnectionState) => {
|
||||
connectionStateRef.current = next;
|
||||
}, []);
|
||||
|
||||
const teardownInputNodes = useCallback(() => {
|
||||
processorNodeRef.current?.disconnect();
|
||||
mediaSourceRef.current?.disconnect();
|
||||
processorNodeRef.current?.removeEventListener('audioprocess', () => undefined);
|
||||
processorNodeRef.current = null;
|
||||
mediaSourceRef.current = null;
|
||||
if (mediaStreamRef.current) {
|
||||
mediaStreamRef.current.getTracks().forEach((track) => track.stop());
|
||||
mediaStreamRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const closePeerConnection = useCallback(() => {
|
||||
if (dataChannelRef.current) {
|
||||
try {
|
||||
dataChannelRef.current.close();
|
||||
} catch (err) {
|
||||
console.warn('[VoiceRealtime] Failed to close data channel:', err);
|
||||
}
|
||||
}
|
||||
dataChannelRef.current = null;
|
||||
|
||||
if (peerConnectionRef.current) {
|
||||
try {
|
||||
peerConnectionRef.current.ontrack = null;
|
||||
peerConnectionRef.current.onconnectionstatechange = null;
|
||||
peerConnectionRef.current.close();
|
||||
} catch (err) {
|
||||
console.warn('[VoiceRealtime] Failed to close peer connection:', err);
|
||||
}
|
||||
}
|
||||
peerConnectionRef.current = null;
|
||||
|
||||
channelReadyPromiseRef.current = null;
|
||||
channelReadyResolveRef.current = null;
|
||||
}, []);
|
||||
|
||||
const resetState = useCallback(() => {
|
||||
awaitingTranscriptRef.current = false;
|
||||
hasUncommittedAudioRef.current = false;
|
||||
lastSpeechAtRef.current = null;
|
||||
channelReadyPromiseRef.current = null;
|
||||
channelReadyResolveRef.current = null;
|
||||
}, []);
|
||||
|
||||
const notifyError = useCallback((message: string | Error) => {
|
||||
const error = message instanceof Error ? message : new Error(message);
|
||||
onErrorRef.current?.(error);
|
||||
}, []);
|
||||
|
||||
const channelReadyPromiseRef = useRef<Promise<void> | null>(null);
|
||||
const channelReadyResolveRef = useRef<(() => void) | null>(null);
|
||||
|
||||
const ensureChannelReady = useCallback(async () => {
|
||||
const channel = dataChannelRef.current;
|
||||
if (channel?.readyState === 'open') return;
|
||||
if (!channelReadyPromiseRef.current) {
|
||||
channelReadyPromiseRef.current = new Promise<void>((resolve) => {
|
||||
channelReadyResolveRef.current = resolve;
|
||||
});
|
||||
}
|
||||
await channelReadyPromiseRef.current;
|
||||
}, []);
|
||||
|
||||
const sendEvent = useCallback(
|
||||
async (event: Record<string, unknown>) => {
|
||||
await ensureChannelReady();
|
||||
const channel = dataChannelRef.current;
|
||||
if (!channel || channel.readyState !== 'open') {
|
||||
throw new Error('Realtime data channel is not open');
|
||||
}
|
||||
channel.send(JSON.stringify(event));
|
||||
},
|
||||
[ensureChannelReady]
|
||||
);
|
||||
|
||||
const initialiseMicrophone = useCallback(async () => {
|
||||
if (typeof window === 'undefined') {
|
||||
throw new Error('Voice not supported in this environment');
|
||||
}
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
channelCount: 1,
|
||||
echoCancellation: false,
|
||||
autoGainControl: false,
|
||||
noiseSuppression: false,
|
||||
},
|
||||
});
|
||||
|
||||
if (destroyedRef.current) {
|
||||
stream.getTracks().forEach((track) => {
|
||||
try {
|
||||
track.stop();
|
||||
} catch (err) {
|
||||
console.warn('[VoiceRealtime] Failed to stop track after destroy', err);
|
||||
}
|
||||
});
|
||||
return stream;
|
||||
}
|
||||
|
||||
const audioContext = new AudioContext();
|
||||
const source = audioContext.createMediaStreamSource(stream);
|
||||
const processor = audioContext.createScriptProcessor(2048, 1, 1);
|
||||
|
||||
processor.onaudioprocess = (event) => {
|
||||
if (destroyedRef.current) return;
|
||||
const inputBuffer = event.inputBuffer.getChannelData(0);
|
||||
const amplitude = calculateRms(inputBuffer);
|
||||
onAmplitudeRef.current?.(Math.min(1, amplitude * 8));
|
||||
|
||||
const now = Date.now();
|
||||
const channelReady = channelReadyRef.current;
|
||||
if (amplitude > amplitudeGate && channelReady) {
|
||||
hasUncommittedAudioRef.current = true;
|
||||
lastSpeechAtRef.current = now;
|
||||
if (!awaitingTranscriptRef.current) {
|
||||
onStatusChangeRef.current?.('listening');
|
||||
}
|
||||
} else if (
|
||||
channelReady &&
|
||||
hasUncommittedAudioRef.current &&
|
||||
!awaitingTranscriptRef.current &&
|
||||
lastSpeechAtRef.current &&
|
||||
now - lastSpeechAtRef.current > silenceWindowMs
|
||||
) {
|
||||
awaitingTranscriptRef.current = true;
|
||||
hasUncommittedAudioRef.current = false;
|
||||
lastSpeechAtRef.current = null;
|
||||
onStatusChangeRef.current?.('thinking');
|
||||
}
|
||||
};
|
||||
|
||||
source.connect(processor);
|
||||
processor.connect(audioContext.destination);
|
||||
|
||||
audioContextRef.current = audioContext;
|
||||
processorNodeRef.current = processor;
|
||||
mediaSourceRef.current = source;
|
||||
mediaStreamRef.current = stream;
|
||||
return stream;
|
||||
}, [amplitudeGate, silenceWindowMs]);
|
||||
|
||||
const disconnect = useCallback(() => {
|
||||
destroyedRef.current = true;
|
||||
teardownInputNodes();
|
||||
closePeerConnection();
|
||||
audioContextRef.current?.close().catch(() => undefined);
|
||||
audioContextRef.current = null;
|
||||
resetState();
|
||||
channelReadyRef.current = false;
|
||||
setConnectionState('idle');
|
||||
onStatusChangeRef.current?.('idle');
|
||||
onAmplitudeRef.current?.(0);
|
||||
}, [closePeerConnection, resetState, setConnectionState, teardownInputNodes]);
|
||||
|
||||
const extractTextDelta = useCallback((payload: unknown): string => {
|
||||
if (!payload) return '';
|
||||
if (typeof payload === 'string') return payload;
|
||||
if (typeof payload !== 'object') return '';
|
||||
|
||||
const record = payload as Record<string, unknown>;
|
||||
if (typeof record.delta === 'string') return record.delta;
|
||||
if (typeof record.text === 'string') return record.text;
|
||||
|
||||
const outputText = record.output_text as Record<string, unknown> | undefined;
|
||||
if (typeof outputText?.text === 'string') return outputText.text;
|
||||
|
||||
if (Array.isArray(record.output)) {
|
||||
return record.output
|
||||
.flatMap((item) =>
|
||||
Array.isArray((item as Record<string, unknown>)?.content)
|
||||
? ((item as Record<string, unknown>).content as unknown[])
|
||||
: []
|
||||
)
|
||||
.map((content) => {
|
||||
if (!content || typeof content !== 'object') return '';
|
||||
const entry = content as Record<string, unknown>;
|
||||
const nestedText = entry.text as Record<string, unknown> | string | undefined;
|
||||
if (typeof nestedText === 'string') return nestedText;
|
||||
if (typeof nestedText === 'object' && nestedText !== null && typeof nestedText.value === 'string') {
|
||||
return nestedText.value;
|
||||
}
|
||||
return '';
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('');
|
||||
}
|
||||
|
||||
if (Array.isArray(record.content)) {
|
||||
return record.content
|
||||
.map((content) => {
|
||||
if (!content || typeof content !== 'object') return '';
|
||||
const entry = content as Record<string, unknown>;
|
||||
const nestedText = entry.text as Record<string, unknown> | string | undefined;
|
||||
if (typeof nestedText === 'string') return nestedText;
|
||||
if (typeof nestedText === 'object' && nestedText !== null && typeof nestedText.value === 'string') {
|
||||
return nestedText.value;
|
||||
}
|
||||
return '';
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('');
|
||||
}
|
||||
|
||||
return '';
|
||||
}, []);
|
||||
|
||||
const handleDataMessage = useCallback(
|
||||
(raw: string) => {
|
||||
try {
|
||||
const data = JSON.parse(raw);
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.debug('[VoiceRealtime] Event', data.type, data);
|
||||
}
|
||||
|
||||
if (data.type === 'conversation.item.input_audio_transcription.completed') {
|
||||
const transcriptSource =
|
||||
typeof data.transcript === 'string'
|
||||
? data.transcript
|
||||
: extractTextDelta(data.item ?? data.content ?? data);
|
||||
const transcript = transcriptSource?.trim();
|
||||
awaitingTranscriptRef.current = false;
|
||||
hasUncommittedAudioRef.current = false;
|
||||
lastSpeechAtRef.current = null;
|
||||
if (transcript) {
|
||||
console.info('[VoiceRealtime] Transcript completed', transcript);
|
||||
onInterimTranscriptRef.current?.('');
|
||||
onFinalTranscriptRef.current?.(transcript);
|
||||
} else {
|
||||
console.warn('[VoiceRealtime] Received empty transcription event');
|
||||
onInterimTranscriptRef.current?.('');
|
||||
}
|
||||
onStatusChangeRef.current?.('listening');
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.type === 'error' && data.error) {
|
||||
console.error('[VoiceRealtime] Server error', data.error);
|
||||
}
|
||||
|
||||
if (data.type === 'response.error' && data.error) {
|
||||
console.error('[VoiceRealtime] Response error', data.error);
|
||||
}
|
||||
} catch (err) {
|
||||
notifyError(err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
},
|
||||
[extractTextDelta, notifyError]
|
||||
);
|
||||
|
||||
const waitForIceGatheringComplete = useCallback((pc: RTCPeerConnection, timeoutMs = 2000) => {
|
||||
if (pc.iceGatheringState === 'complete') {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
let resolved = false;
|
||||
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const finish = () => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
pc.removeEventListener('icegatheringstatechange', handleStateChange);
|
||||
if (timeoutHandle) {
|
||||
clearTimeout(timeoutHandle);
|
||||
timeoutHandle = null;
|
||||
}
|
||||
resolve();
|
||||
};
|
||||
|
||||
const handleStateChange = () => {
|
||||
if (pc.iceGatheringState === 'complete') {
|
||||
finish();
|
||||
}
|
||||
};
|
||||
|
||||
if (timeoutMs) {
|
||||
timeoutHandle = setTimeout(() => {
|
||||
console.warn('[VoiceRealtime] ICE gathering timeout reached, proceeding with partial candidates');
|
||||
finish();
|
||||
}, timeoutMs);
|
||||
}
|
||||
|
||||
pc.addEventListener('icegatheringstatechange', handleStateChange);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const start = useCallback(async () => {
|
||||
try {
|
||||
destroyedRef.current = false;
|
||||
resetState();
|
||||
setConnectionState('connecting');
|
||||
|
||||
const authToken = getAuthToken?.() ?? null;
|
||||
const fetchToken = fetchEphemeralToken
|
||||
? fetchEphemeralToken
|
||||
: async (token: string | null) => {
|
||||
const response = await fetch('/api/realtime/ephemeral-token', {
|
||||
method: 'POST',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
throw new Error(payload.error || `Failed to mint ephemeral token (${response.status})`);
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
|
||||
const sessionPromise = fetchToken(authToken);
|
||||
const microphonePromise = initialiseMicrophone();
|
||||
|
||||
const session = await sessionPromise;
|
||||
const clientSecret = session?.client_secret?.value || session?.client_secret;
|
||||
if (!clientSecret || typeof clientSecret !== 'string') {
|
||||
throw new Error('Realtime session did not include a client_secret');
|
||||
}
|
||||
|
||||
const pc = new RTCPeerConnection({
|
||||
iceServers: [{ urls: ['stun:stun.l.google.com:19302'] }],
|
||||
});
|
||||
peerConnectionRef.current = pc;
|
||||
|
||||
const channel = pc.createDataChannel('oai-events');
|
||||
dataChannelRef.current = channel;
|
||||
channelReadyPromiseRef.current = new Promise<void>((resolve) => {
|
||||
channelReadyResolveRef.current = resolve;
|
||||
});
|
||||
channelReadyRef.current = false;
|
||||
|
||||
channel.onmessage = (event) => {
|
||||
handleDataMessage(event.data);
|
||||
};
|
||||
|
||||
channel.onopen = () => {
|
||||
console.info('[VoiceRealtime] Data channel open');
|
||||
setConnectionState('ready');
|
||||
channelReadyResolveRef.current?.();
|
||||
channelReadyResolveRef.current = null;
|
||||
channelReadyRef.current = true;
|
||||
|
||||
void sendEvent({
|
||||
type: 'session.update',
|
||||
session: {
|
||||
modalities: ['text'],
|
||||
input_audio_transcription: {
|
||||
model: 'whisper-1',
|
||||
},
|
||||
turn_detection: {
|
||||
type: 'server_vad',
|
||||
threshold: 0.5,
|
||||
silence_duration_ms: 1800,
|
||||
prefix_padding_ms: 300,
|
||||
create_response: false,
|
||||
},
|
||||
instructions: 'You are the RA-H voice transport. Only transcribe user speech accurately, never speak responses.',
|
||||
},
|
||||
}).catch((err) => {
|
||||
console.error('[VoiceRealtime] Failed to send session update:', err);
|
||||
});
|
||||
};
|
||||
|
||||
channel.onerror = (event) => {
|
||||
console.error('[VoiceRealtime] Data channel error:', event);
|
||||
notifyError(new Error('Realtime connection error'));
|
||||
channelReadyRef.current = false;
|
||||
disconnect();
|
||||
};
|
||||
|
||||
channel.onclose = () => {
|
||||
console.warn('[VoiceRealtime] Data channel closed');
|
||||
channelReadyPromiseRef.current = null;
|
||||
channelReadyResolveRef.current = null;
|
||||
channelReadyRef.current = false;
|
||||
if (!destroyedRef.current) {
|
||||
notifyError(new Error('Realtime data channel closed unexpectedly'));
|
||||
disconnect();
|
||||
}
|
||||
};
|
||||
|
||||
pc.onconnectionstatechange = () => {
|
||||
const state = pc.connectionState;
|
||||
console.info('[VoiceRealtime] Peer connection state:', state);
|
||||
if (state === 'connected') {
|
||||
onStatusChangeRef.current?.('listening');
|
||||
setConnectionState('capturing');
|
||||
}
|
||||
if (state === 'failed' || state === 'closed' || state === 'disconnected') {
|
||||
if (!destroyedRef.current) {
|
||||
notifyError(new Error(`Realtime connection ${state}`));
|
||||
}
|
||||
disconnect();
|
||||
}
|
||||
};
|
||||
|
||||
pc.oniceconnectionstatechange = () => {
|
||||
console.info('[VoiceRealtime] ICE connection state:', pc.iceConnectionState);
|
||||
};
|
||||
|
||||
pc.ontrack = () => undefined;
|
||||
|
||||
const mediaStream = await microphonePromise;
|
||||
mediaStream?.getAudioTracks().forEach((track) => {
|
||||
if (peerConnectionRef.current?.signalingState !== 'closed') {
|
||||
peerConnectionRef.current?.addTrack(track, mediaStream);
|
||||
}
|
||||
});
|
||||
|
||||
const offer = await pc.createOffer();
|
||||
await pc.setLocalDescription(offer);
|
||||
await waitForIceGatheringComplete(pc, 2000);
|
||||
|
||||
const localDescription = pc.localDescription;
|
||||
if (!localDescription) {
|
||||
throw new Error('Failed to create local description for realtime call');
|
||||
}
|
||||
|
||||
const response = await fetch('https://api.openai.com/v1/realtime/calls', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${clientSecret}`,
|
||||
'Content-Type': 'application/sdp',
|
||||
'OpenAI-Beta': 'realtime=v1',
|
||||
},
|
||||
body: localDescription.sdp,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorDetail = '';
|
||||
try {
|
||||
const text = await response.text();
|
||||
errorDetail = text;
|
||||
const maybeJson = text ? JSON.parse(text) : null;
|
||||
if (maybeJson?.error?.message) {
|
||||
errorDetail = maybeJson.error.message;
|
||||
}
|
||||
} catch {
|
||||
// ignore JSON parse errors and fall back to raw text
|
||||
}
|
||||
const friendly = errorDetail || response.statusText || 'Unknown realtime error';
|
||||
console.error('[VoiceRealtime] Failed to start realtime call:', friendly, { status: response.status });
|
||||
throw new Error(`Failed to establish realtime call (${response.status}): ${friendly}`);
|
||||
}
|
||||
|
||||
const answerSdp = await response.text();
|
||||
await pc.setRemoteDescription({ type: 'answer', sdp: answerSdp });
|
||||
|
||||
onStatusChangeRef.current?.('listening');
|
||||
setConnectionState('capturing');
|
||||
} catch (err) {
|
||||
disconnect();
|
||||
notifyError(err instanceof Error ? err : new Error(String(err)));
|
||||
throw err;
|
||||
}
|
||||
}, [disconnect, fetchEphemeralToken, getAuthToken, handleDataMessage, initialiseMicrophone, notifyError, resetState, sendEvent, setConnectionState, waitForIceGatheringComplete]);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
disconnect();
|
||||
}, [disconnect]);
|
||||
|
||||
const latestStopRef = useRef<() => void>(() => {});
|
||||
|
||||
useEffect(() => {
|
||||
latestStopRef.current = stop;
|
||||
}, [stop]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
latestStopRef.current();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
start,
|
||||
stop,
|
||||
} as const;
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from 'react';
|
||||
|
||||
export enum MessageRole {
|
||||
USER = 'user',
|
||||
ASSISTANT = 'assistant',
|
||||
TOOL = 'tool',
|
||||
SYSTEM = 'system',
|
||||
THINKING = 'thinking',
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
role: MessageRole.USER | MessageRole.ASSISTANT | MessageRole.TOOL | MessageRole.SYSTEM | MessageRole.THINKING;
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
toolName?: string;
|
||||
status?: 'processing' | 'delivered' | 'error' | 'starting' | 'running' | 'complete';
|
||||
toolArgs?: any;
|
||||
toolResult?: any;
|
||||
}
|
||||
|
||||
interface SendParams {
|
||||
text: string;
|
||||
history: ChatMessage[];
|
||||
openTabs: any[];
|
||||
activeTabId: number | null;
|
||||
currentView?: 'nodes' | 'memory';
|
||||
sessionId: string;
|
||||
mode: 'easy' | 'hard';
|
||||
}
|
||||
|
||||
interface UseSSEChatOptions {
|
||||
getAuthToken?: () => string | null | undefined;
|
||||
beforeRequest?: () => boolean;
|
||||
onRequestError?: (error: unknown, response?: Response) => boolean | void;
|
||||
onStreamComplete?: () => void | Promise<void>;
|
||||
getApiKeys?: () => { openai?: string; anthropic?: string } | undefined;
|
||||
}
|
||||
|
||||
export function useSSEChat(
|
||||
endpoint: string,
|
||||
setMessages: (updater: (prev: ChatMessage[]) => ChatMessage[]) => void,
|
||||
options: UseSSEChatOptions = {}
|
||||
) {
|
||||
const { getAuthToken, beforeRequest, onRequestError, onStreamComplete, getApiKeys } = options;
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const abort = () => {
|
||||
abortControllerRef.current?.abort();
|
||||
};
|
||||
|
||||
const send = async ({ text, history, openTabs, activeTabId, currentView, sessionId, mode }: SendParams) => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed || isLoading) return;
|
||||
if (beforeRequest && !beforeRequest()) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const userMessage: ChatMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: MessageRole.USER,
|
||||
content: trimmed,
|
||||
timestamp: new Date()
|
||||
};
|
||||
const assistantMessage: ChatMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: MessageRole.ASSISTANT,
|
||||
content: '',
|
||||
timestamp: new Date()
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, userMessage, assistantMessage]);
|
||||
|
||||
let handledError = false;
|
||||
let currentAssistantMessage = assistantMessage;
|
||||
|
||||
try {
|
||||
abortControllerRef.current = new AbortController();
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
const token = getAuthToken?.();
|
||||
if (token) {
|
||||
headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
messages: history
|
||||
.concat(userMessage)
|
||||
.filter((m) => [MessageRole.USER, MessageRole.ASSISTANT, MessageRole.SYSTEM].includes(m.role))
|
||||
.map((m) => ({ role: m.role, parts: [{ type: 'text', text: m.content }] })),
|
||||
openTabs,
|
||||
activeTabId,
|
||||
currentView,
|
||||
sessionId,
|
||||
mode,
|
||||
apiKeys: getApiKeys?.(),
|
||||
}),
|
||||
signal: abortControllerRef.current.signal
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = new Error(`HTTP error! status: ${response.status}`);
|
||||
handledError = Boolean(onRequestError?.(error, response));
|
||||
setMessages((prev) =>
|
||||
prev.filter((m) => !(m.id === currentAssistantMessage.id && m.content === ''))
|
||||
);
|
||||
if (handledError) {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
if (!reader) throw new Error('No response body');
|
||||
|
||||
let fullContent = '';
|
||||
let toolCallsActive: Record<string, string> = {};
|
||||
let hasToolCalls = false;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
const chunk = decoder.decode(value, { stream: true });
|
||||
const lines = chunk.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) continue;
|
||||
try {
|
||||
const data = JSON.parse(line.substring(6));
|
||||
|
||||
if (data.type === 'text-delta' && data.delta) {
|
||||
fullContent += data.delta;
|
||||
setMessages((prev) => prev.map((m) => (m.id === currentAssistantMessage.id ? { ...m, content: fullContent } : m)));
|
||||
}
|
||||
|
||||
if (data.type === 'tool-input-start') {
|
||||
hasToolCalls = true;
|
||||
toolCallsActive[data.toolCallId] = data.toolName;
|
||||
const toolMessage: ChatMessage = {
|
||||
id: `tool-${data.toolCallId}`,
|
||||
role: MessageRole.TOOL,
|
||||
content: data.toolName,
|
||||
timestamp: new Date(),
|
||||
toolName: data.toolName,
|
||||
status: 'running',
|
||||
toolArgs: (data.args ?? data.input ?? data.parameters ?? null)
|
||||
};
|
||||
setMessages((prev) => {
|
||||
const filtered = prev.filter((m) => m.id !== toolMessage.id);
|
||||
const index = filtered.findIndex((m) => m.id === currentAssistantMessage.id);
|
||||
return [...filtered.slice(0, index), toolMessage, ...filtered.slice(index)];
|
||||
});
|
||||
}
|
||||
|
||||
if (data.type === 'tool-output-available') {
|
||||
delete toolCallsActive[data.toolCallId];
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === `tool-${data.toolCallId}`
|
||||
? { ...m, content: `${m.toolName} ✓`, status: 'complete', toolResult: (data.result ?? data.output ?? null) }
|
||||
: m
|
||||
)
|
||||
);
|
||||
|
||||
if (Object.keys(toolCallsActive).length === 0 && hasToolCalls) {
|
||||
const newAssistantMessage: ChatMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: MessageRole.ASSISTANT,
|
||||
content: '',
|
||||
timestamp: new Date()
|
||||
};
|
||||
currentAssistantMessage = newAssistantMessage;
|
||||
fullContent = '';
|
||||
setMessages((prev) => [...prev, newAssistantMessage]);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed lines
|
||||
}
|
||||
}
|
||||
}
|
||||
if (onStreamComplete) {
|
||||
await onStreamComplete();
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err?.name !== 'AbortError') {
|
||||
if (!handledError) {
|
||||
handledError = Boolean(onRequestError?.(err));
|
||||
}
|
||||
if (!handledError) {
|
||||
setError(err?.message || 'Failed to send message');
|
||||
}
|
||||
setMessages((prev) =>
|
||||
prev.filter((m) => !(m.id === currentAssistantMessage.id && m.content === ''))
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
return { isLoading, error, send, abort };
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { TTSStatus } from './useAssistantTTS';
|
||||
|
||||
interface UseVoiceInterruptionOptions {
|
||||
amplitude: number;
|
||||
isVoiceActive: boolean;
|
||||
ttsStatus: TTSStatus;
|
||||
threshold?: number;
|
||||
holdDurationMs?: number;
|
||||
cooldownMs?: number;
|
||||
onInterruption: () => void;
|
||||
}
|
||||
|
||||
const DEFAULT_THRESHOLD = 0.18;
|
||||
const DEFAULT_HOLD_MS = 120;
|
||||
const DEFAULT_COOLDOWN_MS = 800;
|
||||
|
||||
export function useVoiceInterruption(options: UseVoiceInterruptionOptions) {
|
||||
const {
|
||||
amplitude,
|
||||
isVoiceActive,
|
||||
ttsStatus,
|
||||
onInterruption,
|
||||
threshold = DEFAULT_THRESHOLD,
|
||||
holdDurationMs = DEFAULT_HOLD_MS,
|
||||
cooldownMs = DEFAULT_COOLDOWN_MS,
|
||||
} = options;
|
||||
|
||||
const [isInterrupting, setIsInterrupting] = useState(false);
|
||||
const detectionTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const lastInterruptionAtRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (detectionTimeoutRef.current) {
|
||||
clearTimeout(detectionTimeoutRef.current);
|
||||
detectionTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const clearDetection = () => {
|
||||
if (detectionTimeoutRef.current) {
|
||||
clearTimeout(detectionTimeoutRef.current);
|
||||
detectionTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
if (!isVoiceActive || ttsStatus === 'idle') {
|
||||
clearDetection();
|
||||
if (isInterrupting) {
|
||||
setIsInterrupting(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (amplitude < threshold) {
|
||||
clearDetection();
|
||||
if (isInterrupting) {
|
||||
setIsInterrupting(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
if (now - lastInterruptionAtRef.current < cooldownMs) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (detectionTimeoutRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
detectionTimeoutRef.current = setTimeout(() => {
|
||||
lastInterruptionAtRef.current = Date.now();
|
||||
setIsInterrupting(true);
|
||||
onInterruption();
|
||||
}, holdDurationMs);
|
||||
}, [amplitude, cooldownMs, holdDurationMs, isInterrupting, isVoiceActive, onInterruption, threshold, ttsStatus]);
|
||||
|
||||
return { isInterrupting } as const;
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useCallback, useReducer } from 'react';
|
||||
|
||||
export type VoiceSessionStatus = 'idle' | 'listening' | 'thinking' | 'speaking';
|
||||
|
||||
export interface VoiceTranscriptSegment {
|
||||
id: string;
|
||||
text: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
interface VoiceSessionState {
|
||||
isActive: boolean;
|
||||
status: VoiceSessionStatus;
|
||||
interimTranscript: string;
|
||||
segments: VoiceTranscriptSegment[];
|
||||
amplitude: number;
|
||||
startedAt: number | null;
|
||||
}
|
||||
|
||||
type VoiceSessionAction =
|
||||
| { type: 'start' }
|
||||
| { type: 'stop' }
|
||||
| { type: 'set-status'; status: VoiceSessionStatus }
|
||||
| { type: 'set-amplitude'; amplitude: number }
|
||||
| { type: 'set-interim'; transcript: string }
|
||||
| { type: 'append-segment'; text: string }
|
||||
| { type: 'replace-segments'; segments: VoiceTranscriptSegment[] }
|
||||
| { type: 'reset-transcript' };
|
||||
|
||||
const initialState: VoiceSessionState = {
|
||||
isActive: false,
|
||||
status: 'idle',
|
||||
interimTranscript: '',
|
||||
segments: [],
|
||||
amplitude: 0,
|
||||
startedAt: null,
|
||||
};
|
||||
|
||||
function reducer(state: VoiceSessionState, action: VoiceSessionAction): VoiceSessionState {
|
||||
switch (action.type) {
|
||||
case 'start':
|
||||
return {
|
||||
...state,
|
||||
isActive: true,
|
||||
status: 'listening',
|
||||
interimTranscript: '',
|
||||
segments: [],
|
||||
amplitude: 0,
|
||||
startedAt: Date.now(),
|
||||
};
|
||||
case 'stop':
|
||||
return {
|
||||
...state,
|
||||
isActive: false,
|
||||
status: 'idle',
|
||||
amplitude: 0,
|
||||
interimTranscript: '',
|
||||
startedAt: null,
|
||||
};
|
||||
case 'set-status':
|
||||
return {
|
||||
...state,
|
||||
status: action.status,
|
||||
};
|
||||
case 'set-amplitude':
|
||||
return {
|
||||
...state,
|
||||
amplitude: Math.max(0, Math.min(1, action.amplitude)),
|
||||
};
|
||||
case 'set-interim':
|
||||
return {
|
||||
...state,
|
||||
interimTranscript: action.transcript,
|
||||
};
|
||||
case 'append-segment': {
|
||||
const trimmed = action.text.trim();
|
||||
if (!trimmed) return state;
|
||||
const segment: VoiceTranscriptSegment = {
|
||||
id: `voice-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
text: trimmed,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
return {
|
||||
...state,
|
||||
segments: [...state.segments, segment],
|
||||
};
|
||||
}
|
||||
case 'replace-segments':
|
||||
return {
|
||||
...state,
|
||||
segments: action.segments,
|
||||
};
|
||||
case 'reset-transcript':
|
||||
return {
|
||||
...state,
|
||||
interimTranscript: '',
|
||||
segments: [],
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export function useVoiceSession() {
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
|
||||
const startSession = useCallback(() => {
|
||||
dispatch({ type: 'start' });
|
||||
}, []);
|
||||
|
||||
const stopSession = useCallback(() => {
|
||||
dispatch({ type: 'stop' });
|
||||
}, []);
|
||||
|
||||
const setStatus = useCallback((status: VoiceSessionStatus) => {
|
||||
dispatch({ type: 'set-status', status });
|
||||
}, []);
|
||||
|
||||
const setAmplitude = useCallback((amplitude: number) => {
|
||||
dispatch({ type: 'set-amplitude', amplitude });
|
||||
}, []);
|
||||
|
||||
const setInterimTranscript = useCallback((transcript: string) => {
|
||||
dispatch({ type: 'set-interim', transcript });
|
||||
}, []);
|
||||
|
||||
const appendFinalTranscript = useCallback((text: string) => {
|
||||
dispatch({ type: 'append-segment', text });
|
||||
}, []);
|
||||
|
||||
const resetTranscript = useCallback(() => {
|
||||
dispatch({ type: 'reset-transcript' });
|
||||
}, []);
|
||||
|
||||
const replaceSegments = useCallback((segments: VoiceTranscriptSegment[]) => {
|
||||
dispatch({ type: 'replace-segments', segments });
|
||||
}, []);
|
||||
|
||||
return {
|
||||
...state,
|
||||
startSession,
|
||||
stopSession,
|
||||
setStatus,
|
||||
setAmplitude,
|
||||
setInterimTranscript,
|
||||
appendFinalTranscript,
|
||||
resetTranscript,
|
||||
replaceSegments,
|
||||
} as const;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { isLocalMode } from '@/config/runtime';
|
||||
import { apiKeyService } from '@/services/storage/apiKeys';
|
||||
|
||||
interface LocalKeyGateProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const panelStyle: React.CSSProperties = {
|
||||
maxWidth: 420,
|
||||
background: 'rgba(15, 15, 15, 0.92)',
|
||||
border: '1px solid #2a2a2a',
|
||||
borderRadius: 16,
|
||||
padding: '28px 32px',
|
||||
boxShadow: '0 18px 40px rgba(0,0,0,0.45)'
|
||||
};
|
||||
|
||||
const buttonStyle: React.CSSProperties = {
|
||||
background: '#22c55e',
|
||||
color: '#0b1113',
|
||||
border: 'none',
|
||||
borderRadius: 6,
|
||||
padding: '12px 18px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer'
|
||||
};
|
||||
|
||||
export function LocalKeyGate({ children }: LocalKeyGateProps) {
|
||||
const isLocal = useMemo(() => isLocalMode(), []);
|
||||
const [hasKeys, setHasKeys] = useState(() => (!isLocal) || apiKeyService.hasUserKeys());
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLocal) return;
|
||||
const handleUpdate = () => {
|
||||
setHasKeys(apiKeyService.hasUserKeys());
|
||||
};
|
||||
handleUpdate();
|
||||
const listener = () => handleUpdate();
|
||||
window.addEventListener('api-keys:updated', listener);
|
||||
return () => window.removeEventListener('api-keys:updated', listener);
|
||||
}, [isLocal]);
|
||||
|
||||
if (!isLocal || hasKeys) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
const openApiKeySettings = () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent('settings:open', { detail: { tab: 'apikeys' } }));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 24,
|
||||
right: 24,
|
||||
maxWidth: 420,
|
||||
zIndex: 9999
|
||||
}}
|
||||
>
|
||||
<div style={panelStyle}>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<h2 style={{ marginBottom: 8, fontSize: '20px' }}>Connect your AI keys</h2>
|
||||
<p style={{ color: '#b0b8c3', lineHeight: 1.6 }}>
|
||||
Local mode needs an OpenAI or Anthropic key. Add one under <strong>Settings → API Keys</strong>
|
||||
to unlock the workspace.
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 12, justifyContent: 'center', flexWrap: 'wrap' }}>
|
||||
<button style={buttonStyle} onClick={() => {
|
||||
openApiKeySettings();
|
||||
}}>
|
||||
Open API Key Settings
|
||||
</button>
|
||||
<button
|
||||
style={{ ...buttonStyle, background: '#1f2933', color: '#e5e7eb' }}
|
||||
onClick={() => setHasKeys(true)}
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
|
||||
import { CSSProperties, useState } from 'react';
|
||||
|
||||
interface ChipProps {
|
||||
label: string;
|
||||
onRemove?: () => void;
|
||||
color?: string;
|
||||
maxWidth?: number;
|
||||
icon?: React.ReactNode;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export default function Chip({
|
||||
label,
|
||||
onRemove,
|
||||
color = '#1a1a1a',
|
||||
maxWidth = 150,
|
||||
icon,
|
||||
style = {}
|
||||
}: ChipProps) {
|
||||
const [showTooltip, setShowTooltip] = useState(false);
|
||||
const needsTruncation = label.length > 20;
|
||||
const displayLabel = needsTruncation ? label.slice(0, 18) + '...' : label;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
display: 'inline-block',
|
||||
...style
|
||||
}}
|
||||
onMouseEnter={() => needsTruncation && setShowTooltip(true)}
|
||||
onMouseLeave={() => setShowTooltip(false)}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
background: color,
|
||||
color: '#fff',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '12px',
|
||||
fontSize: '10px',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
maxWidth: `${maxWidth}px`,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)'
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
<span style={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
}}>
|
||||
{displayLabel}
|
||||
</span>
|
||||
{onRemove && (
|
||||
<button
|
||||
onClick={onRemove}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#ccc',
|
||||
cursor: 'pointer',
|
||||
fontSize: '8px',
|
||||
padding: '0',
|
||||
lineHeight: 1,
|
||||
marginLeft: '2px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '12px',
|
||||
height: '12px',
|
||||
borderRadius: '50%',
|
||||
transition: 'background 0.2s'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.2)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'none';
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{/* Tooltip */}
|
||||
{showTooltip && needsTruncation && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '100%',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
marginBottom: '4px',
|
||||
padding: '4px 8px',
|
||||
background: '#333',
|
||||
color: '#fff',
|
||||
fontSize: '11px',
|
||||
borderRadius: '3px',
|
||||
whiteSpace: 'nowrap',
|
||||
zIndex: 1000,
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.5)',
|
||||
pointerEvents: 'none'
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
"use client";
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
open: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export default function ConfirmDialog({
|
||||
open,
|
||||
title,
|
||||
message,
|
||||
confirmLabel = 'Confirm',
|
||||
cancelLabel = 'Cancel',
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ConfirmDialogProps) {
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(0, 0, 0, 0.8)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 9999,
|
||||
padding: '20px'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '380px',
|
||||
maxWidth: '100%',
|
||||
background: '#050505',
|
||||
border: '1px solid #1f1f1f',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
boxShadow: '0 25px 65px rgba(0, 0, 0, 0.7), 0 0 0 1px rgba(255, 255, 255, 0.05)'
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
fontSize: '15px',
|
||||
fontWeight: 600,
|
||||
color: '#f8fafc',
|
||||
marginBottom: '12px',
|
||||
letterSpacing: '0.02em'
|
||||
}}>
|
||||
{title}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '13px',
|
||||
color: '#94a3b8',
|
||||
marginBottom: '24px',
|
||||
lineHeight: 1.6
|
||||
}}>
|
||||
{message}
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '12px' }}>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
style={{
|
||||
padding: '10px 16px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #1f1f1f',
|
||||
background: 'transparent',
|
||||
color: '#94a3b8',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
fontSize: '11px',
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#0f0f0f';
|
||||
e.currentTarget.style.borderColor = '#2a2a2a';
|
||||
e.currentTarget.style.color = '#cbd5f5';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
e.currentTarget.style.borderColor = '#1f1f1f';
|
||||
e.currentTarget.style.color = '#94a3b8';
|
||||
}}
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
style={{
|
||||
padding: '10px 16px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #dc2626',
|
||||
background: '#7f1d1d',
|
||||
color: '#fca5a5',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
fontSize: '11px',
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#991b1b';
|
||||
e.currentTarget.style.borderColor = '#b91c1c';
|
||||
e.currentTarget.style.color = '#fecaca';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = '#7f1d1d';
|
||||
e.currentTarget.style.borderColor = '#dc2626';
|
||||
e.currentTarget.style.color = '#fca5a5';
|
||||
}}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode, CSSProperties } from 'react';
|
||||
|
||||
interface EditableSectionProps {
|
||||
label: string;
|
||||
summary: ReactNode;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
children: ReactNode;
|
||||
metadata?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function EditableSection({
|
||||
label,
|
||||
summary,
|
||||
expanded,
|
||||
onToggle,
|
||||
children,
|
||||
metadata,
|
||||
disabled = false
|
||||
}: EditableSectionProps) {
|
||||
return (
|
||||
<div style={{
|
||||
marginBottom: '12px',
|
||||
background: '#1a1a1a',
|
||||
border: '1px solid #2a2a2a',
|
||||
borderRadius: '4px',
|
||||
overflow: 'visible'
|
||||
}}>
|
||||
<div
|
||||
onClick={disabled ? undefined : onToggle}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
cursor: disabled ? 'default' : 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
transition: 'background 0.2s',
|
||||
background: expanded ? '#202020' : 'transparent'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!disabled && !expanded) {
|
||||
e.currentTarget.style.background = '#1f1f1f';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!disabled && !expanded) {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
fontSize: '10px',
|
||||
fontWeight: 600,
|
||||
color: '#5c9aff',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
minWidth: '40px'
|
||||
}}>
|
||||
{label}
|
||||
</span>
|
||||
|
||||
<div style={{
|
||||
flex: 1,
|
||||
fontSize: '11px',
|
||||
color: '#888',
|
||||
lineHeight: '1.4',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
}}>
|
||||
{summary}
|
||||
</div>
|
||||
|
||||
{metadata && (
|
||||
<span style={{
|
||||
fontSize: '10px',
|
||||
color: '#555',
|
||||
flexShrink: 0
|
||||
}}>
|
||||
{metadata}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{!disabled && (
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
style={{
|
||||
color: '#555',
|
||||
transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)',
|
||||
transition: 'transform 0.2s'
|
||||
}}
|
||||
>
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
)}
|
||||
|
||||
{disabled && (
|
||||
<span style={{
|
||||
fontSize: '9px',
|
||||
color: '#555',
|
||||
background: '#252525',
|
||||
padding: '2px 4px',
|
||||
borderRadius: '2px'
|
||||
}}>
|
||||
locked
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div style={{
|
||||
padding: '12px',
|
||||
borderTop: '1px solid #2a2a2a',
|
||||
background: '#161616',
|
||||
position: 'relative'
|
||||
}}>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
|
||||
interface InputDialogProps {
|
||||
open: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
placeholder?: string;
|
||||
defaultValue?: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
onConfirm: (value: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export default function InputDialog({
|
||||
open,
|
||||
title,
|
||||
message,
|
||||
placeholder = '',
|
||||
defaultValue = '',
|
||||
confirmLabel = 'Confirm',
|
||||
cancelLabel = 'Cancel',
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: InputDialogProps) {
|
||||
const [inputValue, setInputValue] = useState(defaultValue);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setInputValue(defaultValue);
|
||||
// Focus input when dialog opens
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
}, 100);
|
||||
}
|
||||
}, [open, defaultValue]);
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (inputValue.trim()) {
|
||||
onConfirm(inputValue.trim());
|
||||
setInputValue('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleConfirm();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(0, 0, 0, 0.8)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 9999,
|
||||
padding: '20px'
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onCancel();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '380px',
|
||||
maxWidth: '100%',
|
||||
background: '#050505',
|
||||
border: '1px solid #1f1f1f',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
boxShadow: '0 25px 65px rgba(0, 0, 0, 0.7), 0 0 0 1px rgba(255, 255, 255, 0.05)'
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
fontSize: '15px',
|
||||
fontWeight: 600,
|
||||
color: '#f8fafc',
|
||||
marginBottom: '12px',
|
||||
letterSpacing: '0.02em'
|
||||
}}>
|
||||
{title}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '13px',
|
||||
color: '#94a3b8',
|
||||
marginBottom: '16px',
|
||||
lineHeight: 1.6
|
||||
}}>
|
||||
{message}
|
||||
</div>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
background: '#0f0f0f',
|
||||
border: '1px solid #2a2a2a',
|
||||
borderRadius: '8px',
|
||||
color: '#f8fafc',
|
||||
fontSize: '13px',
|
||||
marginBottom: '24px',
|
||||
outline: 'none',
|
||||
transition: 'border-color 0.2s'
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.target.style.borderColor = '#3a3a3a';
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.target.style.borderColor = '#2a2a2a';
|
||||
}}
|
||||
/>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '12px' }}>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
style={{
|
||||
padding: '10px 16px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #1f1f1f',
|
||||
background: 'transparent',
|
||||
color: '#94a3b8',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
fontSize: '11px',
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#0f0f0f';
|
||||
e.currentTarget.style.borderColor = '#2a2a2a';
|
||||
e.currentTarget.style.color = '#cbd5f5';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
e.currentTarget.style.borderColor = '#1f1f1f';
|
||||
e.currentTarget.style.color = '#94a3b8';
|
||||
}}
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={!inputValue.trim()}
|
||||
style={{
|
||||
padding: '10px 16px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #22c55e',
|
||||
background: inputValue.trim() ? '#1a3529' : '#0f1a15',
|
||||
color: inputValue.trim() ? '#7de8a5' : '#4a5a4f',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
fontSize: '11px',
|
||||
fontWeight: 500,
|
||||
cursor: inputValue.trim() ? 'pointer' : 'not-allowed',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (inputValue.trim()) {
|
||||
e.currentTarget.style.background = '#1f3d2f';
|
||||
e.currentTarget.style.borderColor = '#2dd47e';
|
||||
e.currentTarget.style.color = '#9ef5b8';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (inputValue.trim()) {
|
||||
e.currentTarget.style.background = '#1a3529';
|
||||
e.currentTarget.style.borderColor = '#22c55e';
|
||||
e.currentTarget.style.color = '#7de8a5';
|
||||
}
|
||||
}}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode, CSSProperties } from 'react';
|
||||
|
||||
interface PanelHeaderProps {
|
||||
title: string;
|
||||
leftContent?: ReactNode;
|
||||
rightContent?: ReactNode;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export default function PanelHeader({
|
||||
title,
|
||||
leftContent,
|
||||
rightContent,
|
||||
className = '',
|
||||
style = {}
|
||||
}: PanelHeaderProps) {
|
||||
return (
|
||||
<div
|
||||
className={`panel-header ${className}`}
|
||||
style={{
|
||||
height: '48px',
|
||||
padding: '0 12px',
|
||||
borderBottom: '1px solid rgba(42, 42, 42, 0.8)',
|
||||
background: '#0f0f0f',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
flexShrink: 0,
|
||||
...style
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
flex: 1,
|
||||
minWidth: 0
|
||||
}}>
|
||||
<span style={{
|
||||
textTransform: 'uppercase',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
color: '#888',
|
||||
letterSpacing: '0.05em',
|
||||
flexShrink: 0
|
||||
}}>
|
||||
{title}
|
||||
</span>
|
||||
{leftContent}
|
||||
</div>
|
||||
|
||||
{rightContent && (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
flexShrink: 0
|
||||
}}>
|
||||
{rightContent}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,376 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
interface DimensionSearchModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onDimensionSelect: (dimension: string) => void;
|
||||
existingDimensions: string[];
|
||||
}
|
||||
|
||||
interface DimensionSuggestion {
|
||||
dimension: string;
|
||||
count: number;
|
||||
isPriority: boolean;
|
||||
}
|
||||
|
||||
export default function DimensionSearchModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onDimensionSelect,
|
||||
existingDimensions
|
||||
}: DimensionSearchModalProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [suggestions, setSuggestions] = useState<DimensionSuggestion[]>([]);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const returnFocusRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
// Store the element that triggered the modal for return focus
|
||||
useEffect(() => {
|
||||
if (isOpen && document.activeElement instanceof HTMLElement) {
|
||||
returnFocusRef.current = document.activeElement;
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Focus trap and accessibility
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
// Autofocus input
|
||||
inputRef.current?.focus();
|
||||
|
||||
// Lock body scroll
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
// Handle Escape key
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
// Focus trap: keep focus within modal
|
||||
const handleTab = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Tab') return;
|
||||
|
||||
const focusableElements = modalRef.current?.querySelectorAll(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
);
|
||||
|
||||
if (!focusableElements || focusableElements.length === 0) return;
|
||||
|
||||
const firstElement = focusableElements[0] as HTMLElement;
|
||||
const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement;
|
||||
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === firstElement) {
|
||||
e.preventDefault();
|
||||
lastElement.focus();
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === lastElement) {
|
||||
e.preventDefault();
|
||||
firstElement.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
document.addEventListener('keydown', handleTab);
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
document.removeEventListener('keydown', handleTab);
|
||||
|
||||
// Return focus to trigger element
|
||||
if (returnFocusRef.current) {
|
||||
returnFocusRef.current.focus();
|
||||
}
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
// Fetch dimension suggestions
|
||||
useEffect(() => {
|
||||
const fetchSuggestions = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/dimensions/popular?limit=50');
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
const allDimensions: DimensionSuggestion[] = result.data;
|
||||
|
||||
// Filter based on search query and exclude existing dimensions
|
||||
const filtered = allDimensions.filter(dim => {
|
||||
const matchesQuery = !searchQuery.trim() ||
|
||||
dim.dimension.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const notExisting = !existingDimensions.includes(dim.dimension);
|
||||
return matchesQuery && notExisting;
|
||||
});
|
||||
|
||||
// Sort: priority first, then by count
|
||||
const sorted = filtered.sort((a, b) => {
|
||||
if (a.isPriority && !b.isPriority) return -1;
|
||||
if (!a.isPriority && b.isPriority) return 1;
|
||||
return b.count - a.count;
|
||||
});
|
||||
|
||||
setSuggestions(sorted.slice(0, 20));
|
||||
setSelectedIndex(0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching dimension suggestions:', error);
|
||||
setSuggestions([]);
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
const timeoutId = setTimeout(fetchSuggestions, 100);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
}, [searchQuery, existingDimensions, isOpen]);
|
||||
|
||||
// Handle keyboard navigation
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev => Math.min(prev + 1, suggestions.length - 1));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev => Math.max(prev - 1, 0));
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
|
||||
if (suggestions[selectedIndex]) {
|
||||
// Select existing dimension
|
||||
handleSelectDimension(suggestions[selectedIndex].dimension);
|
||||
} else if (searchQuery.trim()) {
|
||||
// Create new dimension
|
||||
handleSelectDimension(searchQuery.trim());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectDimension = (dimension: string) => {
|
||||
onDimensionSelect(dimension);
|
||||
setSearchQuery('');
|
||||
setSuggestions([]);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const canCreateNew = searchQuery.trim() &&
|
||||
!suggestions.some(s => s.dimension.toLowerCase() === searchQuery.toLowerCase());
|
||||
|
||||
const modalContent = (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(0, 0, 0, 0.8)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 9999,
|
||||
animation: 'fadeIn 150ms ease-out'
|
||||
}}
|
||||
onClick={handleBackdropClick}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Search dimensions"
|
||||
>
|
||||
<div
|
||||
ref={modalRef}
|
||||
style={{
|
||||
background: '#050505',
|
||||
border: '1px solid #1f1f1f',
|
||||
borderRadius: '12px',
|
||||
width: '90%',
|
||||
maxWidth: '500px',
|
||||
boxShadow: '0 25px 65px rgba(0, 0, 0, 0.7), 0 0 0 1px rgba(255, 255, 255, 0.05)',
|
||||
animation: 'slideIn 150ms ease-out'
|
||||
}}
|
||||
>
|
||||
{/* Search Input */}
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
borderBottom: (suggestions.length > 0 || canCreateNew) ? '1px solid #1f1f1f' : 'none'
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
background: '#0a0a0a',
|
||||
padding: '12px',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid #1f1f1f'
|
||||
}}>
|
||||
{/* Search Icon */}
|
||||
<svg width="16" height="16" viewBox="0 0 20 20" fill="#666">
|
||||
<path fillRule="evenodd" d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z" clipRule="evenodd" />
|
||||
</svg>
|
||||
|
||||
{/* Input */}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Search or create dimension..."
|
||||
style={{
|
||||
flex: 1,
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
color: '#fff',
|
||||
fontSize: '14px',
|
||||
fontFamily: 'inherit'
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Close Button */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#666',
|
||||
cursor: 'pointer',
|
||||
fontSize: '20px',
|
||||
padding: '0 4px',
|
||||
lineHeight: 1,
|
||||
transition: 'color 0.2s'
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.color = '#fff'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = '#666'; }}
|
||||
aria-label="Close search"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Suggestions */}
|
||||
{suggestions.length > 0 && (
|
||||
<div style={{
|
||||
maxHeight: '300px',
|
||||
overflowY: 'auto'
|
||||
}}>
|
||||
{suggestions.map((suggestion, index) => (
|
||||
<button
|
||||
key={suggestion.dimension}
|
||||
onClick={() => handleSelectDimension(suggestion.dimension)}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px 16px',
|
||||
textAlign: 'left',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
fontSize: '13px',
|
||||
background: index === selectedIndex ? '#252525' : 'transparent',
|
||||
border: 'none',
|
||||
borderBottom: index < suggestions.length - 1 ? '1px solid #1f1f1f' : 'none',
|
||||
color: '#e5e5e5',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.1s',
|
||||
fontFamily: 'inherit'
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{
|
||||
color: suggestion.isPriority ? '#22c55e' : '#e5e5e5'
|
||||
}}>
|
||||
{suggestion.dimension}
|
||||
</span>
|
||||
</div>
|
||||
<span style={{ color: '#666', fontSize: '11px' }}>
|
||||
{suggestion.count}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create New Option */}
|
||||
{canCreateNew && (
|
||||
<button
|
||||
onClick={() => handleSelectDimension(searchQuery.trim())}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px 16px',
|
||||
textAlign: 'left',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
fontSize: '13px',
|
||||
background: selectedIndex === suggestions.length ? '#252525' : 'transparent',
|
||||
border: 'none',
|
||||
borderTop: suggestions.length > 0 ? '1px solid #1f1f1f' : 'none',
|
||||
color: '#22c55e',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.1s',
|
||||
fontFamily: 'inherit'
|
||||
}}
|
||||
onMouseEnter={() => setSelectedIndex(suggestions.length)}
|
||||
>
|
||||
<span style={{ fontSize: '16px', fontWeight: 300 }}>+</span>
|
||||
Create "{searchQuery.trim()}"
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Keyboard Hint */}
|
||||
<div style={{
|
||||
padding: '12px 16px',
|
||||
borderTop: '1px solid #1f1f1f',
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: '16px',
|
||||
fontSize: '11px',
|
||||
color: '#666'
|
||||
}}>
|
||||
<span>↑↓ Navigate</span>
|
||||
<span>↵ Select</span>
|
||||
<span>Esc Close</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
|
||||
return typeof window !== 'undefined' ? createPortal(modalContent, document.body) : null;
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import DimensionSearchModal from './DimensionSearchModal';
|
||||
|
||||
interface DimensionTagsProps {
|
||||
dimensions: string[];
|
||||
priorityDimensions?: string[];
|
||||
onUpdate: (dimensions: string[]) => Promise<void>;
|
||||
onPriorityToggle?: (dimension: string) => Promise<void>;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface DimensionSuggestion {
|
||||
dimension: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export default function DimensionTags({
|
||||
dimensions,
|
||||
priorityDimensions = [],
|
||||
onUpdate,
|
||||
onPriorityToggle,
|
||||
disabled = false
|
||||
}: DimensionTagsProps) {
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [suggestions, setSuggestions] = useState<DimensionSuggestion[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Sort dimensions with priority ones first
|
||||
const sortedDimensions = [...dimensions].sort((a, b) => {
|
||||
const aPriority = priorityDimensions.includes(a);
|
||||
const bPriority = priorityDimensions.includes(b);
|
||||
if (aPriority && !bPriority) return -1;
|
||||
if (!aPriority && bPriority) return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isAdding && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, [isAdding]);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchQuery.length > 0) {
|
||||
fetchSuggestions(searchQuery);
|
||||
} else if (isAdding) {
|
||||
// Load popular dimensions when field is empty
|
||||
fetchPopularDimensions();
|
||||
} else {
|
||||
setSuggestions([]);
|
||||
}
|
||||
}, [searchQuery, isAdding]);
|
||||
|
||||
const fetchSuggestions = async (query: string) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/api/dimensions/search?q=${encodeURIComponent(query)}`);
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setSuggestions(data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching dimension suggestions:', error);
|
||||
setSuggestions([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchPopularDimensions = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetch('/api/dimensions/popular');
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setSuggestions(data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching popular dimensions:', error);
|
||||
setSuggestions([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addDimension = async (dimension: string) => {
|
||||
if (!dimension.trim() || dimensions.includes(dimension.trim())) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newDimensions = [...dimensions, dimension.trim()];
|
||||
await onUpdate(newDimensions);
|
||||
|
||||
setSearchQuery('');
|
||||
setSuggestions([]);
|
||||
setIsAdding(false);
|
||||
};
|
||||
|
||||
const removeDimension = async (index: number) => {
|
||||
const dimension = sortedDimensions[index];
|
||||
const newDimensions = dimensions.filter(d => d !== dimension);
|
||||
await onUpdate(newDimensions);
|
||||
};
|
||||
|
||||
const handleDragStart = (index: number) => {
|
||||
setDraggedIndex(index);
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent, index: number) => {
|
||||
e.preventDefault();
|
||||
if (draggedIndex === null || draggedIndex === index) return;
|
||||
|
||||
const draggedDimension = sortedDimensions[draggedIndex];
|
||||
const targetDimension = sortedDimensions[index];
|
||||
|
||||
// Find original positions in unsorted array
|
||||
const draggedOrigIndex = dimensions.indexOf(draggedDimension);
|
||||
const targetOrigIndex = dimensions.indexOf(targetDimension);
|
||||
|
||||
const newDimensions = [...dimensions];
|
||||
newDimensions.splice(draggedOrigIndex, 1);
|
||||
newDimensions.splice(targetOrigIndex, 0, draggedDimension);
|
||||
|
||||
onUpdate(newDimensions);
|
||||
setDraggedIndex(index);
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setDraggedIndex(null);
|
||||
};
|
||||
|
||||
const moveDimension = async (fromIndex: number, direction: 'up' | 'down') => {
|
||||
const toIndex = direction === 'up' ? fromIndex - 1 : fromIndex + 1;
|
||||
if (toIndex < 0 || toIndex >= sortedDimensions.length) return;
|
||||
|
||||
const fromDimension = sortedDimensions[fromIndex];
|
||||
const toDimension = sortedDimensions[toIndex];
|
||||
|
||||
// Find original positions in unsorted array
|
||||
const fromOrigIndex = dimensions.indexOf(fromDimension);
|
||||
const toOrigIndex = dimensions.indexOf(toDimension);
|
||||
|
||||
const newDimensions = [...dimensions];
|
||||
[newDimensions[fromOrigIndex], newDimensions[toOrigIndex]] = [newDimensions[toOrigIndex], newDimensions[fromOrigIndex]];
|
||||
await onUpdate(newDimensions);
|
||||
};
|
||||
|
||||
const togglePriority = async (dimension: string) => {
|
||||
if (onPriorityToggle) {
|
||||
await onPriorityToggle(dimension);
|
||||
}
|
||||
};
|
||||
|
||||
// Check if dimensions overflow 2 lines (approximate)
|
||||
const shouldShowExpandButton = sortedDimensions.length > 6; // Rough estimate for 2 lines
|
||||
const displayedDimensions = (!isExpanded && shouldShowExpandButton)
|
||||
? sortedDimensions.slice(0, 6)
|
||||
: sortedDimensions;
|
||||
const hiddenCount = sortedDimensions.length - 6;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
ref={containerRef}
|
||||
onClick={() => {
|
||||
if (shouldShowExpandButton && !isExpanded) {
|
||||
setIsExpanded(true);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '6px',
|
||||
marginBottom: '8px',
|
||||
cursor: (shouldShowExpandButton && !isExpanded) ? 'pointer' : 'default',
|
||||
position: 'relative',
|
||||
minHeight: dimensions.length === 0 ? '24px' : 'auto'
|
||||
}}
|
||||
>
|
||||
{/* Show placeholder when no dimensions */}
|
||||
{dimensions.length === 0 && !disabled && (
|
||||
<span style={{
|
||||
fontSize: '11px',
|
||||
color: '#555',
|
||||
fontStyle: 'italic',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px'
|
||||
}}>
|
||||
No dimensions
|
||||
</span>
|
||||
)}
|
||||
|
||||
{displayedDimensions.map((dimension, index) => {
|
||||
const isPriority = priorityDimensions.includes(dimension);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${dimension}-${index}`}
|
||||
draggable={!disabled}
|
||||
onDragStart={() => handleDragStart(index)}
|
||||
onDragOver={(e) => handleDragOver(e, index)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!disabled && onPriorityToggle) {
|
||||
togglePriority(dimension);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '3px',
|
||||
fontSize: '10px',
|
||||
color: isPriority ? '#22c55e' : '#d1d5db', /* Changed from gold to green */
|
||||
background: isPriority ? '#0f2417' : '#1a1a1a', /* Green-tinted background */
|
||||
border: isPriority ? '1px solid #166534' : '1px solid #333', /* Green border */
|
||||
borderRadius: '8px',
|
||||
padding: '2px 6px',
|
||||
cursor: disabled ? 'default' : (onPriorityToggle ? 'pointer' : 'grab'),
|
||||
opacity: draggedIndex === index ? 0.5 : 1,
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!disabled) {
|
||||
e.currentTarget.style.borderColor = isPriority ? '#22c55e' : '#555'; /* Green hover */
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = isPriority ? '#166534' : '#333'; /* Green default */
|
||||
}}
|
||||
title={isPriority ? 'Priority dimension (click to unpin)' : 'Click to pin as priority dimension'}
|
||||
>
|
||||
<span>{dimension}</span>
|
||||
|
||||
{/* Reorder buttons removed - no longer needed */}
|
||||
|
||||
{/* Remove button */}
|
||||
{!disabled && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeDimension(index);
|
||||
}}
|
||||
style={{
|
||||
padding: '0 2px',
|
||||
fontSize: '14px',
|
||||
color: '#666',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
marginLeft: '2px'
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.color = '#ff6b6b'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = '#666'; }}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Show "+X more" indicator */}
|
||||
{shouldShowExpandButton && !isExpanded && hiddenCount > 0 && (
|
||||
<div
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsExpanded(true);
|
||||
}}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
fontSize: '11px',
|
||||
color: '#666',
|
||||
background: 'transparent',
|
||||
border: '1px dashed #333',
|
||||
borderRadius: '12px',
|
||||
padding: '2px 8px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.color = '#999';
|
||||
e.currentTarget.style.borderColor = '#444';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = '#666';
|
||||
e.currentTarget.style.borderColor = '#333';
|
||||
}}
|
||||
>
|
||||
+{hiddenCount} more
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Collapse button when expanded */}
|
||||
{isExpanded && shouldShowExpandButton && (
|
||||
<div
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsExpanded(false);
|
||||
}}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
fontSize: '11px',
|
||||
color: '#666',
|
||||
background: 'transparent',
|
||||
border: '1px dashed #333',
|
||||
borderRadius: '12px',
|
||||
padding: '2px 8px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.color = '#999';
|
||||
e.currentTarget.style.borderColor = '#444';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = '#666';
|
||||
e.currentTarget.style.borderColor = '#333';
|
||||
}}
|
||||
>
|
||||
show less
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add dimension button - Opens modal */}
|
||||
{!disabled && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsAdding(true);
|
||||
}}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
color: '#22c55e',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.1em',
|
||||
borderBottom: '1px solid #1a1a1a',
|
||||
background: '#0a0a0a',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: '8px 12px',
|
||||
borderRadius: '8px',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#151515';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = '#0a0a0a';
|
||||
}}
|
||||
title="Add dimension"
|
||||
>
|
||||
<span style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
borderRadius: '50%',
|
||||
background: '#22c55e',
|
||||
color: '#0a0a0a',
|
||||
fontSize: '12px',
|
||||
lineHeight: 1,
|
||||
fontWeight: 300,
|
||||
flexShrink: 0
|
||||
}}>+</span>
|
||||
Add
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dimension Search Modal */}
|
||||
<DimensionSearchModal
|
||||
isOpen={isAdding}
|
||||
onClose={() => setIsAdding(false)}
|
||||
onDimensionSelect={(dim) => {
|
||||
addDimension(dim);
|
||||
}}
|
||||
existingDimensions={dimensions}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
interface EdgeSearchModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onEdgeCreate: (targetNodeId: number, targetNodeTitle: string) => void;
|
||||
sourceNodeId: number;
|
||||
}
|
||||
|
||||
interface NodeSuggestion {
|
||||
id: number;
|
||||
title: string;
|
||||
dimensions?: string[];
|
||||
}
|
||||
|
||||
export default function EdgeSearchModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onEdgeCreate,
|
||||
sourceNodeId
|
||||
}: EdgeSearchModalProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [suggestions, setSuggestions] = useState<NodeSuggestion[]>([]);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const returnFocusRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
// Store the element that triggered the modal for return focus
|
||||
useEffect(() => {
|
||||
if (isOpen && document.activeElement instanceof HTMLElement) {
|
||||
returnFocusRef.current = document.activeElement;
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Focus trap and accessibility
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
// Autofocus input
|
||||
inputRef.current?.focus();
|
||||
|
||||
// Lock body scroll
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
// Handle Escape key
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
// Focus trap: keep focus within modal
|
||||
const handleTab = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Tab') return;
|
||||
|
||||
const focusableElements = modalRef.current?.querySelectorAll(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
);
|
||||
|
||||
if (!focusableElements || focusableElements.length === 0) return;
|
||||
|
||||
const firstElement = focusableElements[0] as HTMLElement;
|
||||
const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement;
|
||||
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === firstElement) {
|
||||
e.preventDefault();
|
||||
lastElement.focus();
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === lastElement) {
|
||||
e.preventDefault();
|
||||
firstElement.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
document.addEventListener('keydown', handleTab);
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
document.removeEventListener('keydown', handleTab);
|
||||
|
||||
// Return focus to trigger element
|
||||
if (returnFocusRef.current) {
|
||||
returnFocusRef.current.focus();
|
||||
}
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
// Generate suggestions based on search query
|
||||
useEffect(() => {
|
||||
if (!searchQuery.trim()) {
|
||||
setSuggestions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchSuggestions = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/nodes/search?q=${encodeURIComponent(searchQuery)}&limit=10`);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
const nodeSuggestions: NodeSuggestion[] = result.data
|
||||
.filter((node: any) => node.id !== sourceNodeId) // Exclude source node
|
||||
.map((node: any) => ({
|
||||
id: node.id,
|
||||
title: node.title,
|
||||
dimensions: node.dimensions || []
|
||||
}));
|
||||
|
||||
setSuggestions(nodeSuggestions);
|
||||
setSelectedIndex(0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching suggestions:', error);
|
||||
setSuggestions([]);
|
||||
}
|
||||
};
|
||||
|
||||
const timeoutId = setTimeout(fetchSuggestions, 200);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [searchQuery, sourceNodeId]);
|
||||
|
||||
// Handle keyboard navigation
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev => Math.min(prev + 1, suggestions.length - 1));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev => Math.max(prev - 1, 0));
|
||||
} else if (e.key === 'Enter' && suggestions[selectedIndex]) {
|
||||
e.preventDefault();
|
||||
handleSelectSuggestion(suggestions[selectedIndex]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectSuggestion = (suggestion: NodeSuggestion) => {
|
||||
onEdgeCreate(suggestion.id, suggestion.title);
|
||||
setSearchQuery('');
|
||||
setSuggestions([]);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const modalContent = (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(0, 0, 0, 0.8)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 9999,
|
||||
animation: 'fadeIn 150ms ease-out'
|
||||
}}
|
||||
onClick={handleBackdropClick}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Search nodes to connect"
|
||||
>
|
||||
<div
|
||||
ref={modalRef}
|
||||
style={{
|
||||
background: '#1a1a1a',
|
||||
border: '1px solid #333',
|
||||
borderRadius: '8px',
|
||||
width: '90%',
|
||||
maxWidth: '600px',
|
||||
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.6)',
|
||||
animation: 'slideIn 150ms ease-out'
|
||||
}}
|
||||
>
|
||||
{/* Search Input */}
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
borderBottom: suggestions.length > 0 ? '1px solid #2a2a2a' : 'none'
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
background: '#0a0a0a',
|
||||
padding: '12px',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid #333'
|
||||
}}>
|
||||
{/* Search Icon */}
|
||||
<svg width="16" height="16" viewBox="0 0 20 20" fill="#666">
|
||||
<path fillRule="evenodd" d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z" clipRule="evenodd" />
|
||||
</svg>
|
||||
|
||||
{/* Input */}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Search node to connect..."
|
||||
style={{
|
||||
flex: 1,
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
color: '#fff',
|
||||
fontSize: '14px',
|
||||
fontFamily: 'inherit'
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Close Button */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#666',
|
||||
cursor: 'pointer',
|
||||
fontSize: '20px',
|
||||
padding: '0 4px',
|
||||
lineHeight: 1,
|
||||
transition: 'color 0.2s'
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.color = '#fff'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = '#666'; }}
|
||||
aria-label="Close search"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Suggestions */}
|
||||
{suggestions.length > 0 && (
|
||||
<div style={{
|
||||
maxHeight: '300px',
|
||||
overflowY: 'auto'
|
||||
}}>
|
||||
{suggestions.map((suggestion, index) => {
|
||||
const primaryDimension = suggestion.dimensions && suggestion.dimensions.length > 0
|
||||
? suggestion.dimensions[0]
|
||||
: '';
|
||||
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => handleSelectSuggestion(suggestion)}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px 16px',
|
||||
textAlign: 'left',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
fontSize: '13px',
|
||||
background: index === selectedIndex ? '#252525' : 'transparent',
|
||||
border: 'none',
|
||||
borderBottom: index < suggestions.length - 1 ? '1px solid #2a2a2a' : 'none',
|
||||
color: '#ccc',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.1s',
|
||||
fontFamily: 'inherit'
|
||||
}}
|
||||
>
|
||||
{primaryDimension && (
|
||||
<span style={{
|
||||
color: '#666',
|
||||
fontSize: '10px',
|
||||
fontWeight: 600,
|
||||
minWidth: '60px',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em'
|
||||
}}>
|
||||
{primaryDimension}
|
||||
</span>
|
||||
)}
|
||||
<span style={{
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
color: '#e5e5e5'
|
||||
}}>
|
||||
{suggestion.title}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Keyboard Hint */}
|
||||
<div style={{
|
||||
padding: '12px 16px',
|
||||
borderTop: '1px solid #2a2a2a',
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: '16px',
|
||||
fontSize: '11px',
|
||||
color: '#666'
|
||||
}}>
|
||||
<span>↑↓ Navigate</span>
|
||||
<span>↵ Select</span>
|
||||
<span>Esc Close</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
|
||||
return typeof window !== 'undefined' ? createPortal(modalContent, document.body) : null;
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from 'react';
|
||||
import { parseAndRenderContent } from './NodeLabelRenderer';
|
||||
|
||||
interface MarkdownRendererProps {
|
||||
content: string;
|
||||
streaming?: boolean;
|
||||
onNodeClick?: (nodeId: number) => void;
|
||||
}
|
||||
|
||||
export default function MarkdownRenderer({ content, streaming, onNodeClick }: MarkdownRendererProps) {
|
||||
if (!content) return null;
|
||||
|
||||
const segments = splitCodeBlocks(content);
|
||||
return (
|
||||
<div style={{ color: '#e5e5e5', fontSize: 16, lineHeight: 1.7, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
|
||||
{segments.map((seg, i) =>
|
||||
seg.type === 'code' ? (
|
||||
<CodeBlock key={i} language={seg.lang} code={seg.text} />
|
||||
) : (
|
||||
<span key={i}>{renderTextWithFormatting(transformMarkdownNodeLinks(seg.text), onNodeClick)}</span>
|
||||
)
|
||||
)}
|
||||
{streaming ? (
|
||||
<span style={{ display: 'inline-block', width: 3, height: 12, marginLeft: 2, background: 'rgba(200,200,200,0.5)', verticalAlign: 'baseline' }} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeBlock({ code, language }: { code: string; language?: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1000);
|
||||
} catch {}
|
||||
};
|
||||
return (
|
||||
<div style={{ margin: '8px 0' }}>
|
||||
<div style={{
|
||||
background: '#0f0f0f', border: '1px solid #2a2a2a', borderRadius: 6,
|
||||
padding: 8, overflowX: 'auto', fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', fontSize: 12
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 6 }}>
|
||||
<span style={{ color: '#8a8a8a', fontSize: 11 }}>{language || 'code'}</span>
|
||||
<button onClick={handleCopy} style={{ marginLeft: 'auto', fontSize: 11, color: '#8a8a8a', background: 'transparent', border: '1px solid #2a2a2a', borderRadius: 4, padding: '1px 6px', cursor: 'pointer' }}>
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
<pre style={{ margin: 0 }}>
|
||||
<code>{code}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderTextWithFormatting(text: string, onNodeClick?: (nodeId: number) => void): React.ReactNode[] {
|
||||
// Extract source quotes ONLY (pattern: > "quote text")
|
||||
const quoteSegments = splitSourceQuotes(text);
|
||||
|
||||
return quoteSegments.flatMap((segment, segIdx) => {
|
||||
if (segment.type === 'quote') {
|
||||
return (
|
||||
<div key={`quote-${segIdx}`} style={{
|
||||
margin: '12px 0',
|
||||
padding: '10px 14px',
|
||||
borderLeft: '3px solid #333',
|
||||
background: '#0f0f0f',
|
||||
fontStyle: 'italic',
|
||||
color: '#b8b8b8',
|
||||
position: 'relative'
|
||||
}}>
|
||||
<span style={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
left: 8,
|
||||
fontSize: 24,
|
||||
color: '#3a3a3a',
|
||||
lineHeight: 1
|
||||
}}>"</span>
|
||||
<div style={{ paddingLeft: 12 }}>
|
||||
{parseInlineFormatting(segment.text, onNodeClick)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return <span key={`text-${segIdx}`}>{parseInlineFormatting(segment.text, onNodeClick)}</span>;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function splitSourceQuotes(input: string): Array<{ type: 'text' | 'quote'; text: string }> {
|
||||
const out: Array<{ type: 'text' | 'quote'; text: string }> = [];
|
||||
|
||||
// ONLY match quotes that start with "> " (blockquote syntax from search tool)
|
||||
// This ensures we don't break [NODE:ID:"title"] patterns
|
||||
const quotePattern = /^>\s+"(.+?)"$/gm;
|
||||
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = quotePattern.exec(input)) !== null) {
|
||||
// Add text before quote
|
||||
if (match.index > lastIndex) {
|
||||
out.push({ type: 'text', text: input.slice(lastIndex, match.index) });
|
||||
}
|
||||
|
||||
// Add the quote content (without the > and quote marks)
|
||||
out.push({ type: 'quote', text: match[1] });
|
||||
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
// Add remaining text
|
||||
if (lastIndex < input.length) {
|
||||
out.push({ type: 'text', text: input.slice(lastIndex) });
|
||||
}
|
||||
|
||||
return out.length > 0 ? out : [{ type: 'text', text: input }];
|
||||
}
|
||||
|
||||
function parseInlineFormatting(text: string, onNodeClick?: (nodeId: number) => void): React.ReactNode[] {
|
||||
// Pattern for **bold** text
|
||||
const boldPattern = /\*\*(.+?)\*\*/g;
|
||||
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
// First pass: handle bold text
|
||||
const textParts: Array<{ type: 'text' | 'bold'; text: string }> = [];
|
||||
while ((match = boldPattern.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
textParts.push({ type: 'text', text: text.slice(lastIndex, match.index) });
|
||||
}
|
||||
textParts.push({ type: 'bold', text: match[1] });
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
if (lastIndex < text.length) {
|
||||
textParts.push({ type: 'text', text: text.slice(lastIndex) });
|
||||
}
|
||||
|
||||
// If no bold text found, just use the original text
|
||||
if (textParts.length === 0) {
|
||||
textParts.push({ type: 'text', text });
|
||||
}
|
||||
|
||||
// Second pass: render each part with node label parsing
|
||||
return textParts.flatMap((part, idx) => {
|
||||
if (part.type === 'bold') {
|
||||
return (
|
||||
<strong key={`bold-${idx}`} style={{
|
||||
fontWeight: 600,
|
||||
textDecoration: 'underline',
|
||||
textDecorationColor: '#4a4a4a',
|
||||
textDecorationThickness: '1px',
|
||||
textUnderlineOffset: '2px',
|
||||
color: '#f0f0f0'
|
||||
}}>
|
||||
{parseAndRenderContent(part.text, onNodeClick)}
|
||||
</strong>
|
||||
);
|
||||
} else {
|
||||
return <span key={`text-${idx}`}>{parseAndRenderContent(part.text, onNodeClick)}</span>;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function splitCodeBlocks(input: string): Array<{ type: 'text' | 'code'; text: string; lang?: string }>
|
||||
{
|
||||
const out: Array<{ type: 'text' | 'code'; text: string; lang?: string }> = [];
|
||||
const codeFence = /```([a-zA-Z0-9_-]+)?\n([\s\S]*?)```/g;
|
||||
let lastIndex = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = codeFence.exec(input)) !== null) {
|
||||
if (m.index > lastIndex) out.push({ type: 'text', text: input.slice(lastIndex, m.index) });
|
||||
out.push({ type: 'code', lang: m[1], text: m[2] });
|
||||
lastIndex = m.index + m[0].length;
|
||||
}
|
||||
if (lastIndex < input.length) out.push({ type: 'text', text: input.slice(lastIndex) });
|
||||
return out;
|
||||
}
|
||||
|
||||
// Convert markdown links that point to #NODE:ID:"Title" into the inline token [NODE:ID:"Title"]
|
||||
function transformMarkdownNodeLinks(input: string): string {
|
||||
if (!input) return input;
|
||||
// Use non-greedy match (.+?) to handle quotes inside titles
|
||||
const nodeLink = /\[[^\]]*\]\(\s*#NODE:\s*(\d+)\s*:\s*["""'](.+?)["""']\s*\)/g;
|
||||
return input.replace(nodeLink, (_m, id, title) => `[NODE:${id}:"${title}"]`);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
import { FileText } from 'lucide-react';
|
||||
|
||||
interface NodeLabelProps {
|
||||
id: string;
|
||||
title: string;
|
||||
dimensions: string[];
|
||||
onNodeClick?: (nodeId: number) => void;
|
||||
}
|
||||
|
||||
function NodeLabel({ id, title, dimensions, onNodeClick }: NodeLabelProps) {
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
// Prevent bubbling into parent containers (e.g., content view onClick that toggles edit)
|
||||
e.stopPropagation();
|
||||
if (onNodeClick) {
|
||||
onNodeClick(parseInt(id));
|
||||
}
|
||||
};
|
||||
|
||||
const maxTitleLength = 40;
|
||||
const truncatedTitle = title.length > maxTitleLength
|
||||
? `${title.substring(0, maxTitleLength)}...`
|
||||
: title;
|
||||
const showTooltip = title.length > maxTitleLength;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Clickable ID badge - inline with existing line height */}
|
||||
<span
|
||||
onClick={handleClick}
|
||||
title={showTooltip ? title : undefined}
|
||||
style={{
|
||||
display: 'inline',
|
||||
padding: '2px 6px',
|
||||
background: '#22c55e',
|
||||
color: '#000',
|
||||
borderRadius: '3px',
|
||||
fontSize: '11px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
marginRight: '4px',
|
||||
lineHeight: '1', /* prevent line height issues */
|
||||
verticalAlign: 'baseline'
|
||||
}}
|
||||
>
|
||||
#{id}
|
||||
</span>
|
||||
{/* Non-clickable title - bold and underlined */}
|
||||
<span style={{
|
||||
fontWeight: 'bold',
|
||||
textDecoration: 'underline',
|
||||
color: '#e5e5e5'
|
||||
}}>
|
||||
{truncatedTitle}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function parseAndRenderContent(content: string, onNodeClick?: (nodeId: number) => void): React.ReactNode[] {
|
||||
if (!content) return [content];
|
||||
|
||||
// Pattern to match [NODE:id:"title"] (dimensions removed)
|
||||
// Be tolerant of spaces and curly quotes
|
||||
// Use non-greedy match (.+?) to handle quotes inside titles
|
||||
const nodePattern = /\[NODE:\s*(\d+)\s*:\s*["""'](.+?)["""']\s*\]/g;
|
||||
|
||||
const parts: React.ReactNode[] = [];
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
|
||||
while ((match = nodePattern.exec(content)) !== null) {
|
||||
// Add text before the node
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(content.substring(lastIndex, match.index));
|
||||
}
|
||||
|
||||
// Parse the node data
|
||||
const id = match[1];
|
||||
const title = match[2];
|
||||
const dimensions: string[] = []; // No dimensions in new format
|
||||
|
||||
// Add the node label
|
||||
parts.push(
|
||||
<NodeLabel
|
||||
key={`node-${id}-${match.index}`}
|
||||
id={id}
|
||||
title={title}
|
||||
dimensions={dimensions}
|
||||
onNodeClick={onNodeClick}
|
||||
/>
|
||||
);
|
||||
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
// Add any remaining text
|
||||
if (lastIndex < content.length) {
|
||||
parts.push(content.substring(lastIndex));
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts : [content];
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
interface ReasoningTraceProps {
|
||||
trace?: {
|
||||
purpose?: string;
|
||||
thoughts?: string;
|
||||
next_action?: string;
|
||||
step?: number;
|
||||
done?: boolean;
|
||||
timestamp?: string;
|
||||
} | null;
|
||||
collapsible?: boolean;
|
||||
}
|
||||
|
||||
export default function ReasoningTrace({ trace, collapsible = true }: ReasoningTraceProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
if (!trace) return null;
|
||||
const short = (s?: string, n = 180) => (s || '').slice(0, n) + ((s || '').length > n ? '…' : '');
|
||||
return (
|
||||
<div style={{
|
||||
margin: '6px 0', padding: '8px 10px',
|
||||
background: '#0e0e12', border: '1px solid #2a2a2a',
|
||||
borderLeft: '2px solid #6b6b7f', borderRadius: 6,
|
||||
fontSize: 12, color: '#cfcfcf', lineHeight: 1.5
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
||||
<span style={{ color: '#8b8b9f', fontWeight: 600 }}>Reasoning trace</span>
|
||||
{trace.timestamp ? (
|
||||
<span style={{ color: '#4a4a4a', fontSize: 11 }}>{new Date(trace.timestamp).toLocaleTimeString()}</span>
|
||||
) : null}
|
||||
<span style={{ marginLeft: 'auto', color: '#4a4a4a', fontSize: 11 }}>{trace.done ? 'final' : 'in-progress'}</span>
|
||||
</div>
|
||||
{/* Collapsed: show only the purpose as the title */}
|
||||
{trace.purpose ? (
|
||||
<div style={{ color: '#9adbc2' }}>Goal: {expanded ? trace.purpose : short(trace.purpose, 80)}</div>
|
||||
) : null}
|
||||
{/* Expanded: show details */}
|
||||
{expanded && trace.thoughts ? <div style={{ marginTop: 6 }}>{trace.thoughts}</div> : null}
|
||||
{expanded && trace.next_action ? (
|
||||
<div style={{ marginTop: 6, color: '#a5b4fc' }}>Next: {trace.next_action}</div>
|
||||
) : null}
|
||||
{collapsible ? (
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((e) => !e)}
|
||||
style={{
|
||||
background: 'transparent', border: '1px solid #2a2a2a', color: '#8a8a8a',
|
||||
fontSize: 11, padding: '2px 6px', borderRadius: 4, cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
{expanded ? 'Hide reasoning' : 'Show full reasoning'}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
interface SourceChipProps {
|
||||
url?: string;
|
||||
domain?: string;
|
||||
}
|
||||
|
||||
function extractDomain(input?: string) {
|
||||
if (!input) return '';
|
||||
try {
|
||||
const d = input.includes('://') ? new URL(input).hostname : input;
|
||||
return d.replace(/^www\./, '');
|
||||
} catch {
|
||||
return input.replace(/^www\./, '');
|
||||
}
|
||||
}
|
||||
|
||||
export default function SourceChip({ url, domain }: SourceChipProps) {
|
||||
const d = extractDomain(domain || url);
|
||||
const favicon = d ? `https://icons.duckduckgo.com/ip3/${d}.ico` : '';
|
||||
return (
|
||||
<span
|
||||
title={url || d}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
background: '#1a1a1a',
|
||||
border: '1px solid #2a2a2a',
|
||||
borderRadius: 999,
|
||||
padding: '2px 8px',
|
||||
fontSize: 11,
|
||||
color: '#bdbdbd',
|
||||
lineHeight: 1
|
||||
}}
|
||||
>
|
||||
{d ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={favicon}
|
||||
alt=""
|
||||
width={12}
|
||||
height={12}
|
||||
onError={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
style={{ borderRadius: 2 }}
|
||||
/>
|
||||
) : null}
|
||||
<span>{d || 'source'}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
"use client";
|
||||
|
||||
import SourceChip from './SourceChip';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface ToolDisplayProps {
|
||||
name: string;
|
||||
status: 'starting' | 'running' | 'complete' | 'error';
|
||||
context?: string;
|
||||
sources?: Array<{ url?: string; domain?: string }> | string[];
|
||||
result?: any;
|
||||
error?: string;
|
||||
onNodeClick?: (nodeId: number) => void;
|
||||
}
|
||||
|
||||
export default function ToolDisplay({ name, status, context, sources, result, error, onNodeClick }: ToolDisplayProps) {
|
||||
const isRunning = status === 'starting' || status === 'running';
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const accentColor = '#22c55e';
|
||||
const bgTint = '#0b0b0b';
|
||||
const displayName = name;
|
||||
|
||||
// Normalize sources to array of objects
|
||||
const normalizedSources: Array<{ url?: string; domain?: string }> = Array.isArray(sources)
|
||||
? sources.map((s: any) => (typeof s === 'string' ? { domain: s } : s))
|
||||
: [];
|
||||
|
||||
// Web search simple preview renderer
|
||||
const renderWebSearchPreview = () => {
|
||||
const items = result?.data?.results || result?.results || [];
|
||||
if (!Array.isArray(items) || items.length === 0) return null;
|
||||
return (
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((e) => !e)}
|
||||
style={{
|
||||
background: 'transparent', border: '1px solid #2a2a2a', color: '#8a8a8a',
|
||||
fontSize: 11, padding: '2px 6px', borderRadius: 4, cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
{expanded ? 'Hide results' : `Show results (${Math.min(items.length, 3)})`}
|
||||
</button>
|
||||
{expanded ? (
|
||||
<div style={{ display: 'grid', gap: 6, marginTop: 6 }}>
|
||||
{items.slice(0, 3).map((r: any, i: number) => (
|
||||
<div key={i} style={{
|
||||
background: '#0f0f0f',
|
||||
border: '1px solid #2a2a2a',
|
||||
borderRadius: 6,
|
||||
padding: '6px 8px',
|
||||
fontSize: 12,
|
||||
color: '#cfcfcf'
|
||||
}}>
|
||||
<div style={{ color: '#e5e5e5', fontWeight: 600 }}>{r.title || 'Result'}</div>
|
||||
{r.snippet ? <div style={{ color: '#9a9a9a', marginTop: 2 }}>{r.snippet}</div> : null}
|
||||
{r.url ? (
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<a href={r.url} target="_blank" rel="noreferrer" style={{ color: '#5c9aff', fontSize: 11 }}>
|
||||
{r.url}
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
margin: '8px 0',
|
||||
padding: '10px 12px',
|
||||
background: bgTint,
|
||||
border: '1px solid #2a2a2a',
|
||||
borderLeft: `2px solid ${accentColor}`,
|
||||
borderRadius: 6,
|
||||
fontSize: 12,
|
||||
color: '#cfcfcf'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
||||
{isRunning ? (
|
||||
<span style={{
|
||||
display: 'inline-block', width: 12, height: 12,
|
||||
border: '2px solid #2a2a2a', borderTopColor: accentColor,
|
||||
borderRadius: '50%', animation: 'spin 1s linear infinite'
|
||||
}} />
|
||||
) : (
|
||||
<span style={{ color: accentColor, fontSize: 12 }}>{status === 'error' ? '✗' : '✓'}</span>
|
||||
)}
|
||||
<span style={{ color: accentColor, fontWeight: 600 }}>{displayName}</span>
|
||||
<span style={{ marginLeft: 'auto', color: '#4a4a4a', fontSize: 11 }}>{status}</span>
|
||||
</div>
|
||||
{context ? <div style={{ color: '#9adbc2' }}>{context}</div> : null}
|
||||
{normalizedSources.length > 0 ? (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 6 }}>
|
||||
{normalizedSources.slice(0, 6).map((s, i) => (
|
||||
<SourceChip key={i} url={s.url} domain={s.domain} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{/* Specific previews */}
|
||||
{name === 'webSearch' ? renderWebSearchPreview() : null}
|
||||
{name === 'websiteExtract' ? (
|
||||
<WebsiteExtractSummary result={result} onNodeClick={onNodeClick} />
|
||||
) : null}
|
||||
{error ? (
|
||||
<div style={{ color: '#ff6b6b', marginTop: 6 }}>Error: {error}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WebsiteExtractSummary({ result, onNodeClick }: { result?: any; onNodeClick?: (id: number) => void }) {
|
||||
const data = result?.data || {};
|
||||
const title = data.title || 'Website added';
|
||||
const url = data.url || '';
|
||||
const nodeId = data.nodeId as number | undefined;
|
||||
return (
|
||||
<div style={{ marginTop: 8, border: '1px solid #2a2a2a', background: '#0f0f0f', borderRadius: 6, padding: 8 }}>
|
||||
<div style={{ color: '#e5e5e5', fontWeight: 600 }}>{title}</div>
|
||||
{url ? (
|
||||
<div style={{ marginTop: 4, fontSize: 11 }}>
|
||||
<a href={url} target="_blank" rel="noreferrer" style={{ color: '#5c9aff' }}>
|
||||
{url}
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
<div style={{ marginTop: 6, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{typeof nodeId === 'number' && onNodeClick ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onNodeClick(nodeId)}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: '1px solid #2a2a2a',
|
||||
color: '#cfcfcf',
|
||||
fontSize: 11,
|
||||
padding: '2px 8px',
|
||||
borderRadius: 4,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
Open node #{nodeId}
|
||||
</button>
|
||||
) : null}
|
||||
{typeof data.contentLength === 'number' ? (
|
||||
<span style={{ fontSize: 11, color: '#7a7a7a' }}>
|
||||
content ~{Math.round((data.contentLength as number) / 1000)}k chars
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,561 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import NodesPanel from '../nodes/NodesPanel';
|
||||
import FocusPanel from '../focus/FocusPanel';
|
||||
import AgentsPanel from '../agents/AgentsPanel';
|
||||
import SettingsCog from '../settings/SettingsCog';
|
||||
import SettingsModal, { SettingsTab } from '../settings/SettingsModal';
|
||||
import { Node } from '@/types/database';
|
||||
import { DatabaseEvent } from '@/services/events';
|
||||
import { usePersistentState } from '@/hooks/usePersistentState';
|
||||
import FolderViewOverlay from '../nodes/FolderViewOverlay';
|
||||
|
||||
export default function ThreePanelLayout() {
|
||||
// Panel widths as percentages (20% | 40% | 40%)
|
||||
const [leftWidth, setLeftWidth] = useState(20);
|
||||
const [middleWidth, setMiddleWidth] = useState(40);
|
||||
|
||||
// Collapsible state for nodes panel
|
||||
const [nodesCollapsed, setNodesCollapsed] = usePersistentState('ui.nodesCollapsed', false);
|
||||
|
||||
// Settings dropdown state
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [settingsInitialTab, setSettingsInitialTab] = useState<SettingsTab>();
|
||||
const handleCloseSettings = useCallback(() => {
|
||||
setShowSettings(false);
|
||||
setSettingsInitialTab(undefined);
|
||||
}, []);
|
||||
// Dragging states
|
||||
const [isDraggingLeft, setIsDraggingLeft] = useState(false);
|
||||
const [isDraggingRight, setIsDraggingRight] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Track selected nodes and open tabs
|
||||
const [selectedNodes, setSelectedNodes] = useState<Set<number>>(new Set<number>());
|
||||
const [openTabs, setOpenTabs] = usePersistentState<number[]>('ui.focus.openTabs', []);
|
||||
const [activeTab, setActiveTab] = usePersistentState<number | null>('ui.focus.activeTab', null);
|
||||
const [openTabsData, setOpenTabsData] = useState<Node[]>([]);
|
||||
|
||||
// Event handlers for SSE events
|
||||
const [nodesPanelRefresh, setNodesPanelRefresh] = useState(0);
|
||||
const [focusPanelRefresh, setFocusPanelRefresh] = useState(0);
|
||||
const [folderViewOpen, setFolderViewOpen] = useState(false);
|
||||
const [folderViewRefresh, setFolderViewRefresh] = useState(0);
|
||||
|
||||
// Ref to get current openTabs value in SSE handler
|
||||
const openTabsRef = useRef<number[]>([]);
|
||||
|
||||
// Fetch full node data for open tabs
|
||||
const fetchOpenTabsData = async (tabIds: number[]) => {
|
||||
if (tabIds.length === 0) {
|
||||
setOpenTabsData([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const nodePromises = tabIds.map(async (id) => {
|
||||
const response = await fetch(`/api/nodes/${id}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
return data.node as Node;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const nodes = await Promise.all(nodePromises);
|
||||
const validNodes = nodes.filter((node): node is Node => Boolean(node)).map(node => ({
|
||||
id: node.id,
|
||||
title: node.title,
|
||||
link: node.link,
|
||||
content: node.content,
|
||||
dimensions: node.dimensions,
|
||||
created_at: node.created_at,
|
||||
updated_at: node.updated_at
|
||||
}));
|
||||
setOpenTabsData(validNodes);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch tab data:', error);
|
||||
setOpenTabsData([]);
|
||||
}
|
||||
};
|
||||
|
||||
// Update tab data whenever openTabs changes
|
||||
useEffect(() => {
|
||||
openTabsRef.current = openTabs; // Keep ref updated
|
||||
fetchOpenTabsData(openTabs);
|
||||
}, [openTabs]);
|
||||
|
||||
// Keyboard shortcut handler
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Check for Cmd+1 (Mac) or Ctrl+1 (Windows/Linux)
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === '1') {
|
||||
e.preventDefault();
|
||||
setNodesCollapsed(prev => !prev);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [setNodesCollapsed]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleSettingsOpen = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ tab?: SettingsTab }>).detail;
|
||||
setSettingsInitialTab(detail?.tab);
|
||||
setShowSettings(true);
|
||||
};
|
||||
window.addEventListener('settings:open', handleSettingsOpen as EventListener);
|
||||
return () => window.removeEventListener('settings:open', handleSettingsOpen as EventListener);
|
||||
}, []);
|
||||
|
||||
|
||||
// SSE connection for real-time updates
|
||||
useEffect(() => {
|
||||
let eventSource: EventSource | null = null;
|
||||
|
||||
try {
|
||||
eventSource = new EventSource('/api/events');
|
||||
|
||||
eventSource.onopen = () => {
|
||||
console.log('🔌 SSE connected for real-time updates');
|
||||
};
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data: DatabaseEvent = JSON.parse(event.data);
|
||||
|
||||
switch (data.type) {
|
||||
case 'NODE_CREATED':
|
||||
// Trigger NodesPanel refresh
|
||||
setNodesPanelRefresh(prev => prev + 1);
|
||||
console.log('📥 Node created via helper:', data.data.node.title);
|
||||
break;
|
||||
|
||||
case 'NODE_UPDATED':
|
||||
const currentOpenTabs = openTabsRef.current;
|
||||
const updatedNodeId = Number(data.data.nodeId);
|
||||
console.log('🔍 NODE_UPDATED - nodeId:', updatedNodeId, 'openTabs:', currentOpenTabs);
|
||||
// Trigger FocusPanel refresh for open tabs
|
||||
if (currentOpenTabs.includes(updatedNodeId)) {
|
||||
console.log('🔄 Triggering FocusPanel refresh for node:', updatedNodeId);
|
||||
setFocusPanelRefresh(prev => prev + 1);
|
||||
} else {
|
||||
console.log('⚠️ Node not in open tabs, skipping FocusPanel refresh');
|
||||
}
|
||||
// Also refresh NodesPanel in case title changed
|
||||
setNodesPanelRefresh(prev => prev + 1);
|
||||
console.log('✏️ Node updated via helper:', updatedNodeId);
|
||||
break;
|
||||
|
||||
case 'NODE_DELETED':
|
||||
// Remove from tabs and selection if open
|
||||
handleNodeDeleted(data.data.nodeId);
|
||||
// Trigger NodesPanel refresh
|
||||
setNodesPanelRefresh(prev => prev + 1);
|
||||
console.log('🗑️ Node deleted via helper:', data.data.nodeId);
|
||||
break;
|
||||
|
||||
case 'EDGE_CREATED':
|
||||
case 'EDGE_DELETED':
|
||||
// Trigger FocusPanel refresh for affected nodes
|
||||
const currentOpenTabsForEdge = openTabsRef.current;
|
||||
console.log('🔗 Edge changed - fromNodeId:', data.data.fromNodeId, 'toNodeId:', data.data.toNodeId, 'openTabs:', currentOpenTabsForEdge);
|
||||
if (currentOpenTabsForEdge.includes(data.data.fromNodeId) || currentOpenTabsForEdge.includes(data.data.toNodeId)) {
|
||||
console.log('🔄 Triggering FocusPanel refresh for edge change');
|
||||
setFocusPanelRefresh(prev => prev + 1);
|
||||
} else {
|
||||
console.log('⚠️ Neither edge node in open tabs, skipping FocusPanel refresh');
|
||||
}
|
||||
console.log('🔗 Edge changed via helper');
|
||||
break;
|
||||
case 'DIMENSION_UPDATED':
|
||||
console.log('📁 Dimension updated event received:', data.data?.dimension);
|
||||
setNodesPanelRefresh(prev => prev + 1);
|
||||
setFolderViewRefresh(prev => prev + 1);
|
||||
break;
|
||||
|
||||
case 'HELPER_UPDATED':
|
||||
case 'AGENT_UPDATED':
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent('agents:updated', { detail: data.data }));
|
||||
}
|
||||
break;
|
||||
case 'AGENT_DELEGATION_CREATED':
|
||||
console.log('[ThreePanelLayout] AGENT_DELEGATION_CREATED received:', data.data);
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent('delegations:created', { detail: data.data }));
|
||||
console.log('[ThreePanelLayout] Dispatched delegations:created event');
|
||||
}
|
||||
break;
|
||||
case 'AGENT_DELEGATION_UPDATED':
|
||||
console.log('[ThreePanelLayout] AGENT_DELEGATION_UPDATED received:', data.data);
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent('delegations:updated', { detail: data.data }));
|
||||
console.log('[ThreePanelLayout] Dispatched delegations:updated event');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'WORKFLOW_PROGRESS':
|
||||
console.log('[ThreePanelLayout] WORKFLOW_PROGRESS received:', data.data);
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent('workflow:progress', { detail: data.data }));
|
||||
}
|
||||
break;
|
||||
|
||||
case 'CONNECTION_ESTABLISHED':
|
||||
console.log('✅ SSE connection established');
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log('📡 Unknown SSE event:', data.type);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to parse SSE event:', error);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = (error) => {
|
||||
console.error('SSE connection error:', error);
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to establish SSE connection:', error);
|
||||
}
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
console.log('🔌 SSE connection closed');
|
||||
}
|
||||
};
|
||||
}, []); // Empty dependency array - connect once on mount
|
||||
|
||||
// Handle panel resizing
|
||||
const handleMouseMove = useCallback((e: MouseEvent) => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const containerRect = containerRef.current.getBoundingClientRect();
|
||||
const mouseX = ((e.clientX - containerRect.left) / containerRect.width) * 100;
|
||||
|
||||
if (isDraggingLeft) {
|
||||
const newLeftWidth = Math.max(15, Math.min(35, mouseX));
|
||||
const diff = newLeftWidth - leftWidth;
|
||||
setLeftWidth(newLeftWidth);
|
||||
setMiddleWidth(prev => Math.max(20, prev - diff));
|
||||
} else if (isDraggingRight) {
|
||||
const rightEdge = leftWidth + middleWidth;
|
||||
const newMiddleWidth = Math.max(20, Math.min(65, mouseX - leftWidth));
|
||||
setMiddleWidth(newMiddleWidth);
|
||||
}
|
||||
}, [isDraggingLeft, isDraggingRight, leftWidth, middleWidth]);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
setIsDraggingLeft(false);
|
||||
setIsDraggingRight(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDraggingLeft || isDraggingRight) {
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
};
|
||||
}
|
||||
}, [isDraggingLeft, isDraggingRight, handleMouseMove, handleMouseUp]);
|
||||
|
||||
const handleToggleFolderView = (nextState?: boolean) => {
|
||||
setFolderViewOpen(prev => typeof nextState === 'boolean' ? nextState : !prev);
|
||||
};
|
||||
|
||||
const handleFolderViewDataChanged = useCallback(() => {
|
||||
setFolderViewRefresh(prev => prev + 1);
|
||||
setNodesPanelRefresh(prev => prev + 1);
|
||||
}, []);
|
||||
|
||||
const handleNodeOpenFromFolderView = (nodeId: number) => {
|
||||
handleNodeSelect(nodeId, false);
|
||||
setFolderViewOpen(false);
|
||||
};
|
||||
|
||||
const handleReorderTabs = (fromIndex: number, toIndex: number) => {
|
||||
if (fromIndex === toIndex) return;
|
||||
setOpenTabs(prev => {
|
||||
if (fromIndex < 0 || toIndex < 0 || fromIndex >= prev.length || toIndex >= prev.length) {
|
||||
return prev;
|
||||
}
|
||||
const updated = [...prev];
|
||||
const [moved] = updated.splice(fromIndex, 1);
|
||||
updated.splice(toIndex, 0, moved);
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
// Handle node selection
|
||||
const handleNodeSelect = (nodeId: number, multiSelect: boolean) => {
|
||||
if (folderViewOpen) {
|
||||
setFolderViewOpen(false);
|
||||
}
|
||||
if (multiSelect) {
|
||||
const newSelection = new Set(selectedNodes);
|
||||
if (newSelection.has(nodeId)) {
|
||||
newSelection.delete(nodeId);
|
||||
} else {
|
||||
newSelection.add(nodeId);
|
||||
}
|
||||
setSelectedNodes(newSelection);
|
||||
|
||||
const newTabs = Array.from(newSelection);
|
||||
setOpenTabs(newTabs);
|
||||
if (newTabs.length > 0 && !activeTab) {
|
||||
setActiveTab(newTabs[0]);
|
||||
}
|
||||
} else {
|
||||
setSelectedNodes(new Set<number>([nodeId]));
|
||||
|
||||
if (!openTabs.includes(nodeId)) {
|
||||
setOpenTabs([...openTabs, nodeId]);
|
||||
}
|
||||
setActiveTab(nodeId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTabSelect = (tabId: number) => {
|
||||
setSelectedNodes(new Set<number>([tabId]));
|
||||
setActiveTab(tabId);
|
||||
};
|
||||
|
||||
// Handle tab close
|
||||
const handleCloseTab = (tabId: number) => {
|
||||
const newTabs = openTabs.filter(id => id !== tabId);
|
||||
setOpenTabs(newTabs);
|
||||
|
||||
if (activeTab === tabId) {
|
||||
const currentIndex = openTabs.indexOf(tabId);
|
||||
if (newTabs.length > 0) {
|
||||
const newIndex = Math.min(currentIndex, newTabs.length - 1);
|
||||
setActiveTab(newTabs[newIndex]);
|
||||
} else {
|
||||
setActiveTab(null);
|
||||
}
|
||||
}
|
||||
|
||||
const newSelection = new Set(selectedNodes);
|
||||
newSelection.delete(tabId);
|
||||
setSelectedNodes(newSelection);
|
||||
};
|
||||
|
||||
// Handle node creation - auto-open the new node in Focus panel
|
||||
const handleNodeCreated = (newNode: Node) => {
|
||||
// Auto-select the new node
|
||||
setSelectedNodes(new Set<number>([newNode.id]));
|
||||
|
||||
// Auto-open the new node as a tab
|
||||
if (!openTabs.includes(newNode.id)) {
|
||||
setOpenTabs([...openTabs, newNode.id]);
|
||||
}
|
||||
|
||||
// Set as active tab
|
||||
setActiveTab(newNode.id);
|
||||
};
|
||||
|
||||
// Handle node deletion - close tab and remove from selection
|
||||
const handleNodeDeleted = (nodeId: number) => {
|
||||
// Close tab if open
|
||||
handleCloseTab(nodeId);
|
||||
};
|
||||
|
||||
const rightWidth = nodesCollapsed ? (100 - middleWidth) : (100 - leftWidth - middleWidth);
|
||||
const effectiveLeftWidth = nodesCollapsed ? 0 : leftWidth;
|
||||
const effectiveMiddleWidth = nodesCollapsed ? (leftWidth + middleWidth) : middleWidth;
|
||||
|
||||
const activeNodeId = activeTab;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{
|
||||
display: 'flex',
|
||||
height: '100vh',
|
||||
width: '100vw',
|
||||
background: '#0a0a0a',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
{/* Left Panel - Nodes (collapsible) */}
|
||||
{nodesCollapsed ? (
|
||||
// Collapsed rail
|
||||
<div style={{
|
||||
width: '40px',
|
||||
flexShrink: 0,
|
||||
borderRight: '1px solid #2a2a2a',
|
||||
background: '#0f0f0f',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
paddingTop: '12px'
|
||||
}}>
|
||||
<button
|
||||
onClick={() => setNodesCollapsed(false)}
|
||||
style={{
|
||||
padding: '8px',
|
||||
background: '#1a1a1a',
|
||||
border: '1px solid #333',
|
||||
borderRadius: '4px',
|
||||
color: '#888',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#2a2a2a';
|
||||
e.currentTarget.style.color = '#fff';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = '#1a1a1a';
|
||||
e.currentTarget.style.color = '#888';
|
||||
}}
|
||||
title="Expand Nodes (⌘1)"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
width: `${effectiveLeftWidth}%`,
|
||||
flexShrink: 0,
|
||||
borderRight: '1px solid #1a1a1a',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: '#0f0f0f',
|
||||
position: 'relative',
|
||||
padding: '4px'
|
||||
}}
|
||||
>
|
||||
<NodesPanel
|
||||
selectedNodes={selectedNodes}
|
||||
onNodeSelect={handleNodeSelect}
|
||||
onNodeCreated={handleNodeCreated}
|
||||
onNodeDeleted={handleNodeDeleted}
|
||||
refreshTrigger={nodesPanelRefresh}
|
||||
onToggleFolderView={handleToggleFolderView}
|
||||
folderViewOpen={folderViewOpen}
|
||||
/>
|
||||
<SettingsCog onClick={() => {
|
||||
setSettingsInitialTab(undefined);
|
||||
setShowSettings(true);
|
||||
}} />
|
||||
</div>
|
||||
|
||||
{/* Left Resize Handle */}
|
||||
<div
|
||||
onMouseDown={() => setIsDraggingLeft(true)}
|
||||
style={{
|
||||
width: '3px',
|
||||
cursor: 'col-resize',
|
||||
background: isDraggingLeft ? '#353535' : 'transparent',
|
||||
transition: isDraggingLeft ? 'none' : 'background 0.2s'
|
||||
}}
|
||||
onMouseEnter={(e) => { if (!isDraggingLeft) e.currentTarget.style.background = '#1f1f1f'; }}
|
||||
onMouseLeave={(e) => { if (!isDraggingLeft) e.currentTarget.style.background = 'transparent'; }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Middle Panel - Focus */}
|
||||
<div
|
||||
style={{
|
||||
width: `${effectiveMiddleWidth}%`,
|
||||
flexShrink: 0,
|
||||
borderRight: '1px solid #1a1a1a',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: '#0f0f0f',
|
||||
padding: '4px'
|
||||
}}
|
||||
>
|
||||
<div style={{ position: 'relative', flex: 1, minHeight: 0 }}>
|
||||
<div style={{
|
||||
height: '100%',
|
||||
visibility: folderViewOpen ? 'hidden' : 'visible',
|
||||
pointerEvents: folderViewOpen ? 'none' : 'auto'
|
||||
}}>
|
||||
<FocusPanel
|
||||
openTabs={openTabs}
|
||||
activeTab={activeTab}
|
||||
onTabSelect={handleTabSelect}
|
||||
onNodeClick={(nodeId) => handleNodeSelect(nodeId, false)}
|
||||
onTabClose={handleCloseTab}
|
||||
refreshTrigger={focusPanelRefresh}
|
||||
onReorderTabs={handleReorderTabs}
|
||||
/>
|
||||
</div>
|
||||
{folderViewOpen && (
|
||||
<FolderViewOverlay
|
||||
onClose={() => handleToggleFolderView(false)}
|
||||
onNodeOpen={handleNodeOpenFromFolderView}
|
||||
refreshToken={folderViewRefresh}
|
||||
onDataChanged={handleFolderViewDataChanged}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Resize Handle */}
|
||||
{!nodesCollapsed && (
|
||||
<div
|
||||
onMouseDown={() => setIsDraggingRight(true)}
|
||||
style={{
|
||||
width: '3px',
|
||||
cursor: 'col-resize',
|
||||
background: isDraggingRight ? '#353535' : 'transparent',
|
||||
transition: isDraggingRight ? 'none' : 'background 0.2s'
|
||||
}}
|
||||
onMouseEnter={(e) => { if (!isDraggingRight) e.currentTarget.style.background = '#1f1f1f'; }}
|
||||
onMouseLeave={(e) => { if (!isDraggingRight) e.currentTarget.style.background = 'transparent'; }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Right Panel - Agents */}
|
||||
<div
|
||||
style={{
|
||||
width: `${rightWidth}%`,
|
||||
flexShrink: 0,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: '#0a0a0a',
|
||||
position: 'relative',
|
||||
padding: '4px'
|
||||
}}
|
||||
>
|
||||
<AgentsPanel
|
||||
openTabsData={openTabsData}
|
||||
activeTabId={activeNodeId}
|
||||
onNodeClick={(nodeId) => handleNodeSelect(nodeId, false)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Settings Modal */}
|
||||
<SettingsModal
|
||||
isOpen={showSettings}
|
||||
onClose={handleCloseSettings}
|
||||
initialTab={settingsInitialTab}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
interface AddNodeButtonProps {
|
||||
onAddNode: () => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export default function AddNodeButton({ onAddNode, loading = false }: AddNodeButtonProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={onAddNode}
|
||||
disabled={loading}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: loading ? '#666' : '#fff',
|
||||
fontSize: '16px',
|
||||
padding: '6px',
|
||||
cursor: loading ? 'not-allowed' : 'pointer',
|
||||
borderRadius: '3px',
|
||||
transition: 'all 0.1s',
|
||||
fontFamily: 'inherit',
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!loading) {
|
||||
e.currentTarget.style.background = '#1a1a1a';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!loading) {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
}
|
||||
}}
|
||||
>
|
||||
{loading ? '...' : '+'}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,835 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, type DragEvent } from 'react';
|
||||
import { ChevronRight, ChevronDown, Folder, FolderOpen, Layers, List, Maximize2, Minimize2 } from 'lucide-react';
|
||||
import { Node } from '@/types/database';
|
||||
import AddNodeButton from './AddNodeButton';
|
||||
import Chip from '../common/Chip';
|
||||
import { getNodeIcon } from '@/utils/nodeIcons';
|
||||
import SearchModal from './SearchModal';
|
||||
|
||||
interface NodesPanelProps {
|
||||
selectedNodes: Set<number>;
|
||||
onNodeSelect: (nodeId: number, multiSelect: boolean) => void;
|
||||
onNodeCreated?: (node: Node) => void;
|
||||
onNodeDeleted?: (nodeId: number) => void;
|
||||
refreshTrigger?: number;
|
||||
onToggleFolderView?: (isOpen?: boolean) => void;
|
||||
folderViewOpen?: boolean;
|
||||
}
|
||||
|
||||
interface LockedDimension {
|
||||
dimension: string;
|
||||
count: number;
|
||||
isPriority: boolean;
|
||||
}
|
||||
|
||||
|
||||
export default function NodesPanel({ selectedNodes, onNodeSelect, onNodeCreated, onNodeDeleted, refreshTrigger, onToggleFolderView, folderViewOpen = false }: NodesPanelProps) {
|
||||
const [nodes, setNodes] = useState<Node[]>([]);
|
||||
const [allNodes, setAllNodes] = useState<Node[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [deletingNode, setDeletingNode] = useState<number | null>(null);
|
||||
const [priorityDimensions, setPriorityDimensions] = useState<string[]>([]);
|
||||
const [lockedDimensions, setLockedDimensions] = useState<LockedDimension[]>([]);
|
||||
const [expandedDimensions, setExpandedDimensions] = useState<Set<string>>(() => {
|
||||
if (typeof window === 'undefined') return new Set();
|
||||
const saved = window.localStorage.getItem('expandedDimensions');
|
||||
return saved ? new Set(JSON.parse(saved)) : new Set();
|
||||
});
|
||||
const [dimensionNodes, setDimensionNodes] = useState<Record<string, Node[]>>({});
|
||||
const [selectedFilters, setSelectedFilters] = useState<{type: 'dimension' | 'title', value: string}[]>([]);
|
||||
const [dimensionsSectionCollapsed, setDimensionsSectionCollapsed] = useState(true); // Default: collapsed
|
||||
const [allNodesSectionCollapsed, setAllNodesSectionCollapsed] = useState(false);
|
||||
const [showSearchModal, setShowSearchModal] = useState(false);
|
||||
const [dropFeedback, setDropFeedback] = useState<string | null>(null);
|
||||
const [dragHoverDimension, setDragHoverDimension] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchNodes();
|
||||
fetchLockedDimensions();
|
||||
}, []);
|
||||
|
||||
// Save expanded dimensions to localStorage
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.localStorage.setItem('expandedDimensions', JSON.stringify([...expandedDimensions]));
|
||||
}, [expandedDimensions]);
|
||||
|
||||
// Refresh nodes and locked dimensions when SSE events trigger updates
|
||||
useEffect(() => {
|
||||
if (refreshTrigger !== undefined && refreshTrigger > 0) {
|
||||
console.log('🔄 Refreshing nodes and locked dimensions due to SSE event');
|
||||
fetchNodes();
|
||||
fetchLockedDimensions();
|
||||
}
|
||||
}, [refreshTrigger]);
|
||||
|
||||
// Global Cmd+K / Ctrl+K keyboard shortcut for search
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
setShowSearchModal(true);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, []);
|
||||
|
||||
// Re-apply filters when allNodes changes
|
||||
useEffect(() => {
|
||||
if (selectedFilters.length > 0) {
|
||||
applyFiltersWithSelection(selectedFilters);
|
||||
} else {
|
||||
setNodes(allNodes);
|
||||
}
|
||||
}, [allNodes, priorityDimensions]);
|
||||
|
||||
const fetchNodes = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/nodes?limit=100');
|
||||
const result = await response.json();
|
||||
const fetchedNodes = result.data || [];
|
||||
setAllNodes(fetchedNodes);
|
||||
setNodes(fetchedNodes);
|
||||
} catch (error) {
|
||||
console.error('Error fetching nodes:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchLockedDimensions = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/dimensions/popular');
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
const locked = result.data.filter((d: any) => d.isPriority);
|
||||
setLockedDimensions(locked);
|
||||
const priority = locked.map((d: any) => d.dimension);
|
||||
setPriorityDimensions(priority);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching locked dimensions:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleDimension = async (dimension: string) => {
|
||||
const isCurrentlyExpanded = expandedDimensions.has(dimension);
|
||||
|
||||
setExpandedDimensions(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(dimension)) {
|
||||
next.delete(dimension);
|
||||
} else {
|
||||
next.add(dimension);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
// Fetch nodes for this dimension when expanding (if not already fetched)
|
||||
if (!isCurrentlyExpanded && !dimensionNodes[dimension]) {
|
||||
await fetchNodesForDimension(dimension);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchNodesForDimension = async (dimension: string) => {
|
||||
try {
|
||||
// Sort by edge count (most connected first), then updated_at
|
||||
const response = await fetch(`/api/nodes?dimensions=${encodeURIComponent(dimension)}&limit=200&sortBy=edges`);
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
setDimensionNodes(prev => ({
|
||||
...prev,
|
||||
[dimension]: result.data || []
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error fetching nodes for dimension ${dimension}:`, error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDimensionDragOver = (event: DragEvent<HTMLElement>) => {
|
||||
if (event.dataTransfer.types.includes('application/node-info')) {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = 'copy';
|
||||
}
|
||||
};
|
||||
|
||||
const handleDimensionDragEnter = (event: DragEvent<HTMLElement>, dimension: string) => {
|
||||
if (event.dataTransfer.types.includes('application/node-info')) {
|
||||
setDragHoverDimension(dimension);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDimensionDragLeave = (event: DragEvent<HTMLElement>, dimension: string) => {
|
||||
if (dragHoverDimension === dimension) {
|
||||
setDragHoverDimension(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNodeDropOnDimension = async (event: DragEvent<HTMLElement>, dimension: string) => {
|
||||
if (!event.dataTransfer.types.includes('application/node-info')) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
const raw = event.dataTransfer.getData('application/node-info');
|
||||
if (!raw) return;
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(raw) as { id: number; dimensions?: string[] };
|
||||
if (!payload?.id) return;
|
||||
|
||||
const currentDimensions = payload.dimensions || [];
|
||||
if (currentDimensions.some((dim) => dim.toLowerCase() === dimension.toLowerCase())) {
|
||||
setDropFeedback(`Node already in ${dimension}`);
|
||||
setTimeout(() => setDropFeedback(null), 2500);
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedDimensions = Array.from(new Set([...currentDimensions, dimension]));
|
||||
const response = await fetch(`/api/nodes/${payload.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ dimensions: updatedDimensions })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update node dimensions');
|
||||
}
|
||||
|
||||
setDropFeedback(`Added to ${dimension}`);
|
||||
setTimeout(() => setDropFeedback(null), 2500);
|
||||
if (expandedDimensions.has(dimension)) {
|
||||
fetchNodesForDimension(dimension);
|
||||
}
|
||||
fetchLockedDimensions();
|
||||
setDragHoverDimension(null);
|
||||
} catch (error) {
|
||||
console.error('Error handling node drop:', error);
|
||||
setDropFeedback('Failed to add dimension');
|
||||
setTimeout(() => setDropFeedback(null), 2500);
|
||||
setDragHoverDimension(null);
|
||||
}
|
||||
};
|
||||
|
||||
const getNodesForDimension = (dimension: string): Node[] => {
|
||||
// Use fetched dimension-specific nodes if available, otherwise filter from loaded nodes
|
||||
return dimensionNodes[dimension] || nodes.filter(node =>
|
||||
node.dimensions?.some(d => d.toLowerCase() === dimension.toLowerCase())
|
||||
);
|
||||
};
|
||||
|
||||
const applyFilters = () => {
|
||||
if (selectedFilters.length === 0) {
|
||||
setNodes(allNodes);
|
||||
return;
|
||||
}
|
||||
|
||||
const dimensionFilters = selectedFilters.filter(f => f.type === 'dimension').map(f => f.value);
|
||||
const titleFilters = selectedFilters.filter(f => f.type === 'title').map(f => f.value);
|
||||
|
||||
const filtered = allNodes.filter(node => {
|
||||
// Must match ALL selected dimension filters
|
||||
const dimensionMatch = dimensionFilters.length === 0 ||
|
||||
dimensionFilters.every(dim =>
|
||||
node.dimensions?.some(nodeDim => nodeDim.toLowerCase() === dim.toLowerCase())
|
||||
);
|
||||
|
||||
// Must match ANY selected title filter
|
||||
const titleMatch = titleFilters.length === 0 ||
|
||||
titleFilters.some(title =>
|
||||
node.title?.toLowerCase().includes(title.toLowerCase())
|
||||
);
|
||||
|
||||
return dimensionMatch && titleMatch;
|
||||
});
|
||||
|
||||
// Sort by priority dimensions first
|
||||
const sorted = filtered.sort((a, b) => {
|
||||
const aHasPriority = a.dimensions?.some(dim => priorityDimensions.includes(dim)) || false;
|
||||
const bHasPriority = b.dimensions?.some(dim => priorityDimensions.includes(dim)) || false;
|
||||
if (aHasPriority && !bHasPriority) return -1;
|
||||
if (!aHasPriority && bHasPriority) return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
setNodes(sorted);
|
||||
};
|
||||
|
||||
const addFilter = (type: 'dimension' | 'title', value: string) => {
|
||||
const newFilter = { type, value };
|
||||
const newFilters = [...selectedFilters, newFilter];
|
||||
setSelectedFilters(newFilters);
|
||||
|
||||
// Apply filters immediately
|
||||
setTimeout(() => {
|
||||
if (newFilters.length === 0) {
|
||||
setNodes(allNodes);
|
||||
} else {
|
||||
applyFiltersWithSelection(newFilters);
|
||||
}
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const removeFilter = (index: number) => {
|
||||
const newFilters = selectedFilters.filter((_, i) => i !== index);
|
||||
setSelectedFilters(newFilters);
|
||||
|
||||
// Apply filters immediately
|
||||
setTimeout(() => {
|
||||
if (newFilters.length === 0) {
|
||||
setNodes(allNodes);
|
||||
} else {
|
||||
applyFiltersWithSelection(newFilters);
|
||||
}
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const applyFiltersWithSelection = async (filters: {type: 'dimension' | 'title', value: string}[]) => {
|
||||
const dimensionFilters = filters.filter(f => f.type === 'dimension').map(f => f.value);
|
||||
const titleFilters = filters.filter(f => f.type === 'title').map(f => f.value);
|
||||
|
||||
try {
|
||||
// Build API query params
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (dimensionFilters.length > 0) {
|
||||
params.append('dimensions', dimensionFilters.join(','));
|
||||
}
|
||||
|
||||
if (titleFilters.length > 0) {
|
||||
// For title filters, we'll search each one and combine results
|
||||
const titleResults: Node[] = [];
|
||||
|
||||
for (const title of titleFilters) {
|
||||
const response = await fetch(`/api/nodes?search=${encodeURIComponent(title)}&limit=50`);
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
titleResults.push(...result.data);
|
||||
}
|
||||
}
|
||||
|
||||
// If we have dimension filters too, intersect the results
|
||||
if (dimensionFilters.length > 0) {
|
||||
const dimensionResponse = await fetch(`/api/nodes?${params.toString()}&limit=200`);
|
||||
const dimensionResult = await dimensionResponse.json();
|
||||
|
||||
if (dimensionResult.success) {
|
||||
// Find intersection: nodes that match dimensions AND titles
|
||||
const dimensionNodeIds = new Set(dimensionResult.data.map((n: Node) => n.id));
|
||||
const intersectedNodes = titleResults.filter(node => dimensionNodeIds.has(node.id));
|
||||
setNodes(intersectedNodes);
|
||||
}
|
||||
} else {
|
||||
// Only title filters
|
||||
setNodes(titleResults);
|
||||
}
|
||||
} else if (dimensionFilters.length > 0) {
|
||||
// Only dimension filters
|
||||
const response = await fetch(`/api/nodes?${params.toString()}&limit=200`);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
setNodes(result.data);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error applying filters:', error);
|
||||
// Fallback to local filtering
|
||||
const filtered = allNodes.filter(node => {
|
||||
const dimensionMatch = dimensionFilters.length === 0 ||
|
||||
dimensionFilters.every(dim =>
|
||||
node.dimensions?.some(nodeDim => nodeDim.toLowerCase() === dim.toLowerCase())
|
||||
);
|
||||
|
||||
const titleMatch = titleFilters.length === 0 ||
|
||||
titleFilters.some(title =>
|
||||
node.title?.toLowerCase().includes(title.toLowerCase())
|
||||
);
|
||||
|
||||
return dimensionMatch && titleMatch;
|
||||
});
|
||||
setNodes(filtered);
|
||||
}
|
||||
};
|
||||
|
||||
const clearSearch = () => {
|
||||
setSelectedFilters([]);
|
||||
setNodes(allNodes);
|
||||
};
|
||||
|
||||
const collapseAllDimensions = () => {
|
||||
setExpandedDimensions(new Set());
|
||||
};
|
||||
|
||||
const handleNodeDragStart = (event: DragEvent<HTMLElement>, node: Node) => {
|
||||
event.dataTransfer.effectAllowed = 'copy';
|
||||
event.dataTransfer.setData('application/node-info', JSON.stringify({
|
||||
id: node.id,
|
||||
dimensions: node.dimensions || []
|
||||
}));
|
||||
|
||||
const preview = document.createElement('div');
|
||||
preview.textContent = node.title || `Node #${node.id}`;
|
||||
preview.style.position = 'fixed';
|
||||
preview.style.top = '-1000px';
|
||||
preview.style.left = '-1000px';
|
||||
preview.style.padding = '4px 8px';
|
||||
preview.style.background = '#0f0f0f';
|
||||
preview.style.color = '#f8fafc';
|
||||
preview.style.fontSize = '11px';
|
||||
preview.style.fontWeight = '600';
|
||||
preview.style.borderRadius = '6px';
|
||||
preview.style.border = '1px solid #1f1f1f';
|
||||
document.body.appendChild(preview);
|
||||
event.dataTransfer.setDragImage(preview, 6, 6);
|
||||
setTimeout(() => {
|
||||
if (preview.parentNode) {
|
||||
preview.parentNode.removeChild(preview);
|
||||
}
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const renderNodeItem = (node: Node) => (
|
||||
<button
|
||||
key={node.id}
|
||||
draggable
|
||||
onDragStart={(event) => handleNodeDragStart(event, node)}
|
||||
onClick={(e) => {
|
||||
const multiSelect = e.metaKey || e.ctrlKey;
|
||||
onNodeSelect(node.id, multiSelect);
|
||||
}}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px 16px', /* Increased from 10px to 12px */
|
||||
margin: '2px 0', /* Added vertical margin for better spacing */
|
||||
textAlign: 'left',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
fontSize: '14px',
|
||||
fontFamily: 'inherit',
|
||||
background: selectedNodes.has(node.id) ? '#1a1a1a' : 'transparent',
|
||||
color: selectedNodes.has(node.id) ? '#fff' : '#d1d5db',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.1s',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!selectedNodes.has(node.id)) {
|
||||
e.currentTarget.style.background = '#0a0a0a';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!selectedNodes.has(node.id)) {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
|
||||
{/* Title */}
|
||||
<span style={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
flex: 1
|
||||
}}>
|
||||
{node.title || 'Untitled'}
|
||||
</span>
|
||||
|
||||
{/* Source Icon - Only show if node has a link */}
|
||||
{node.link && (
|
||||
<span style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flexShrink: 0,
|
||||
marginLeft: '8px'
|
||||
}}>
|
||||
{getNodeIcon(node)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
</button>
|
||||
);
|
||||
|
||||
const handleAddNode = async () => {
|
||||
setCreating(true);
|
||||
try {
|
||||
const response = await fetch('/api/nodes', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: 'New Node',
|
||||
content: '',
|
||||
link: null,
|
||||
dimensions: []
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
// Add new node to both lists immediately
|
||||
setAllNodes(prevNodes => [result.data, ...prevNodes]);
|
||||
setNodes(prevNodes => [result.data, ...prevNodes]);
|
||||
|
||||
// Notify parent component about new node creation
|
||||
if (onNodeCreated) {
|
||||
onNodeCreated(result.data);
|
||||
}
|
||||
} else {
|
||||
console.error('Failed to create node:', result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating node:', error);
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// handleDeleteNode removed - deletion only available in focus panel
|
||||
|
||||
const handleNodeSelectFromSearch = (nodeId: number) => {
|
||||
// Open node in focus panel without filtering the nodes panel
|
||||
onNodeSelect(nodeId, false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', background: '#000' }}>
|
||||
{/* Search Modal */}
|
||||
<SearchModal
|
||||
isOpen={showSearchModal}
|
||||
onClose={() => setShowSearchModal(false)}
|
||||
onNodeSelect={handleNodeSelectFromSearch}
|
||||
existingFilters={selectedFilters}
|
||||
/>
|
||||
|
||||
{/* Nodes List */}
|
||||
<div style={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
padding: '8px 0'
|
||||
}}>
|
||||
{loading ? (
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
color: '#666',
|
||||
fontSize: '14px'
|
||||
}}>
|
||||
Loading nodes...
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{/* + ADD NODE Button & folder expand */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', padding: '0 8px' }}>
|
||||
<button
|
||||
onClick={handleAddNode}
|
||||
disabled={creating}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '12px 12px',
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
color: '#22c55e',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.1em',
|
||||
borderBottom: '1px solid #1a1a1a',
|
||||
background: '#0a0a0a',
|
||||
border: 'none',
|
||||
cursor: creating ? 'not-allowed' : 'pointer',
|
||||
textAlign: 'left',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!creating) {
|
||||
e.currentTarget.style.background = '#151515';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = '#0a0a0a';
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
borderRadius: '50%',
|
||||
background: '#22c55e',
|
||||
color: '#0a0a0a',
|
||||
fontSize: '16px',
|
||||
lineHeight: 1,
|
||||
fontWeight: 300,
|
||||
flexShrink: 0
|
||||
}}>+</span>
|
||||
{creating ? 'Adding...' : 'Add Node'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onToggleFolderView?.()}
|
||||
style={{
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
borderRadius: '10px',
|
||||
border: '1px solid #1f1f1f',
|
||||
background: folderViewOpen ? '#16301f' : '#080808',
|
||||
color: folderViewOpen ? '#22c55e' : '#cbd5f5',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.2s ease, color 0.2s ease'
|
||||
}}
|
||||
title={folderViewOpen ? 'Close dimension folder view' : 'Open dimension folder view'}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = folderViewOpen ? '#1c3b27' : '#101010';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = folderViewOpen ? '#16301f' : '#080808';
|
||||
}}
|
||||
>
|
||||
{folderViewOpen ? <Minimize2 size={18} /> : <Maximize2 size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{dropFeedback && (
|
||||
<div style={{
|
||||
margin: '6px 8px 0',
|
||||
padding: '6px 10px',
|
||||
borderRadius: '6px',
|
||||
background: '#0d1a12',
|
||||
color: '#7de8a5',
|
||||
fontSize: '11px',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em'
|
||||
}}>
|
||||
{dropFeedback}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SEARCH Button */}
|
||||
<button
|
||||
onClick={() => setShowSearchModal(true)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px 16px',
|
||||
margin: '4px 8px', /* Added side margins */
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
color: '#555',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.1em',
|
||||
borderRadius: '4px', /* Added border radius */
|
||||
background: '#0f0f0f', /* Slightly different background */
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#151515';
|
||||
e.currentTarget.style.color = '#777';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = '#0a0a0a';
|
||||
e.currentTarget.style.color = '#555';
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<svg width="12" height="12" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z" clipRule="evenodd" />
|
||||
</svg>
|
||||
Search
|
||||
</div>
|
||||
<span style={{ fontSize: '9px', color: '#444', fontWeight: 400 }}>⌘K</span>
|
||||
</button>
|
||||
|
||||
{/* DIMENSIONS Section Header */}
|
||||
{lockedDimensions.length > 0 && (
|
||||
<button
|
||||
onClick={() => setDimensionsSectionCollapsed(!dimensionsSectionCollapsed)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '14px 18px',
|
||||
fontSize: '11px',
|
||||
fontWeight: 700,
|
||||
color: dimensionsSectionCollapsed ? '#94a3b8' : '#f8fafc',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.18em',
|
||||
borderBottom: '1px solid #151515',
|
||||
borderLeft: '3px solid transparent',
|
||||
background: dimensionsSectionCollapsed ? '#050505' : '#0e1811',
|
||||
borderTop: '1px solid #000',
|
||||
borderRight: 'none',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
<Layers size={14} color={dimensionsSectionCollapsed ? '#94a3b8' : '#f8fafc'} />
|
||||
<span>Dimensions ({lockedDimensions.length})</span>
|
||||
</div>
|
||||
{dimensionsSectionCollapsed ? (
|
||||
<ChevronRight size={14} strokeWidth={2.5} color="#94a3b8" />
|
||||
) : (
|
||||
<ChevronDown size={14} strokeWidth={2.5} color="#f8fafc" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Locked Dimension Sections */}
|
||||
{!dimensionsSectionCollapsed && lockedDimensions.map((lockedDim) => {
|
||||
const dimNodes = getNodesForDimension(lockedDim.dimension);
|
||||
const isExpanded = expandedDimensions.has(lockedDim.dimension);
|
||||
|
||||
return (
|
||||
<div key={lockedDim.dimension} style={{ marginBottom: '4px' }}>
|
||||
{/* Dimension Header */}
|
||||
<button
|
||||
onClick={() => toggleDimension(lockedDim.dimension)}
|
||||
onDragOver={(e) => handleDimensionDragOver(e)}
|
||||
onDragEnter={(e) => handleDimensionDragEnter(e, lockedDim.dimension)}
|
||||
onDragLeave={(e) => handleDimensionDragLeave(e, lockedDim.dimension)}
|
||||
onDrop={(e) => handleNodeDropOnDimension(e, lockedDim.dimension)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 18px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
background: dragHoverDimension === lockedDim.dimension ? '#152214' : (isExpanded ? '#0d0d0d' : '#050505'),
|
||||
border: dragHoverDimension === lockedDim.dimension ? '1px solid #1f3d28' : 'none',
|
||||
borderBottom: dragHoverDimension === lockedDim.dimension ? '1px solid #1f3d28' : '1px solid #121212',
|
||||
borderLeft: '2px solid transparent',
|
||||
color: '#cbd5f5',
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', flex: 1 }}>
|
||||
{isExpanded ? (
|
||||
<FolderOpen size={16} color="#7de8a5" />
|
||||
) : (
|
||||
<Folder size={16} color="#64748b" />
|
||||
)}
|
||||
<span style={{
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.08em'
|
||||
}}>
|
||||
{lockedDim.dimension}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
minWidth: '32px',
|
||||
textAlign: 'right',
|
||||
fontSize: '11px',
|
||||
color: '#94a3b8',
|
||||
fontWeight: 500,
|
||||
background: '#0f1a12',
|
||||
borderRadius: '999px',
|
||||
padding: '2px 8px'
|
||||
}}
|
||||
>
|
||||
{lockedDim.count}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Dimension Nodes (when expanded) */}
|
||||
{isExpanded && (
|
||||
<div>
|
||||
{dimNodes.length === 0 ? (
|
||||
<div style={{
|
||||
padding: '12px 16px',
|
||||
color: '#555',
|
||||
fontSize: '12px',
|
||||
fontStyle: 'italic'
|
||||
}}>
|
||||
No nodes in this dimension
|
||||
</div>
|
||||
) : (
|
||||
dimNodes.map((node) => renderNodeItem(node))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* ALL NODES Section Header */}
|
||||
<button
|
||||
onClick={() => setAllNodesSectionCollapsed(!allNodesSectionCollapsed)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '14px 18px',
|
||||
fontSize: '11px',
|
||||
fontWeight: 700,
|
||||
color: allNodesSectionCollapsed ? '#94a3b8' : '#f8fafc',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.18em',
|
||||
borderBottom: '1px solid #151515',
|
||||
borderLeft: '3px solid transparent',
|
||||
background: allNodesSectionCollapsed ? '#050505' : '#0e1811',
|
||||
borderTop: '1px solid #000',
|
||||
borderRight: 'none',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
<List size={14} color={allNodesSectionCollapsed ? '#94a3b8' : '#f8fafc'} />
|
||||
<span>Nodes</span>
|
||||
</div>
|
||||
{allNodesSectionCollapsed ? (
|
||||
<ChevronRight size={14} strokeWidth={2.5} color="#94a3b8" />
|
||||
) : (
|
||||
<ChevronDown size={14} strokeWidth={2.5} color="#f8fafc" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* All Nodes List */}
|
||||
{!allNodesSectionCollapsed && (
|
||||
nodes.length === 0 ? (
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
color: '#666',
|
||||
fontSize: '14px',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
{selectedFilters.length > 0 ? 'No nodes match filters' : 'No nodes yet - click "Add Node" above to get started'}
|
||||
</div>
|
||||
) : (
|
||||
nodes.map((node) => renderNodeItem(node))
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import Chip from '../common/Chip';
|
||||
|
||||
interface SearchModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onNodeSelect: (nodeId: number) => void;
|
||||
existingFilters: {type: 'dimension' | 'title', value: string}[];
|
||||
}
|
||||
|
||||
interface NodeSuggestion {
|
||||
id: number;
|
||||
title: string;
|
||||
dimensions?: string[];
|
||||
}
|
||||
|
||||
export default function SearchModal({ isOpen, onClose, onNodeSelect, existingFilters }: SearchModalProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [suggestions, setSuggestions] = useState<NodeSuggestion[]>([]);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const returnFocusRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
// Store the element that triggered the modal for return focus
|
||||
useEffect(() => {
|
||||
if (isOpen && document.activeElement instanceof HTMLElement) {
|
||||
returnFocusRef.current = document.activeElement;
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Focus trap and accessibility
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
// Autofocus input
|
||||
inputRef.current?.focus();
|
||||
|
||||
// Lock body scroll
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
// Handle Escape key
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
// Focus trap: keep focus within modal
|
||||
const handleTab = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Tab') return;
|
||||
|
||||
const focusableElements = modalRef.current?.querySelectorAll(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
);
|
||||
|
||||
if (!focusableElements || focusableElements.length === 0) return;
|
||||
|
||||
const firstElement = focusableElements[0] as HTMLElement;
|
||||
const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement;
|
||||
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === firstElement) {
|
||||
e.preventDefault();
|
||||
lastElement.focus();
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === lastElement) {
|
||||
e.preventDefault();
|
||||
firstElement.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
document.addEventListener('keydown', handleTab);
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
document.removeEventListener('keydown', handleTab);
|
||||
|
||||
// Return focus to trigger element
|
||||
if (returnFocusRef.current) {
|
||||
returnFocusRef.current.focus();
|
||||
}
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
// Generate suggestions based on search query
|
||||
useEffect(() => {
|
||||
if (!searchQuery.trim()) {
|
||||
setSuggestions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchSuggestions = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/nodes/search?q=${encodeURIComponent(searchQuery)}&limit=10`);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
const nodeSuggestions: NodeSuggestion[] = result.data.map((node: any) => ({
|
||||
id: node.id,
|
||||
title: node.title,
|
||||
dimensions: node.dimensions || []
|
||||
}));
|
||||
|
||||
setSuggestions(nodeSuggestions);
|
||||
setSelectedIndex(0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching suggestions:', error);
|
||||
setSuggestions([]);
|
||||
}
|
||||
};
|
||||
|
||||
const timeoutId = setTimeout(fetchSuggestions, 200);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [searchQuery, existingFilters]);
|
||||
|
||||
// Handle keyboard navigation
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev => Math.min(prev + 1, suggestions.length - 1));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev => Math.max(prev - 1, 0));
|
||||
} else if (e.key === 'Enter' && suggestions[selectedIndex]) {
|
||||
e.preventDefault();
|
||||
handleSelectSuggestion(suggestions[selectedIndex]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectSuggestion = (suggestion: NodeSuggestion) => {
|
||||
onNodeSelect(suggestion.id);
|
||||
setSearchQuery('');
|
||||
setSuggestions([]);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const modalContent = (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(0, 0, 0, 0.8)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 9999,
|
||||
animation: 'fadeIn 150ms ease-out'
|
||||
}}
|
||||
onClick={handleBackdropClick}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Search nodes"
|
||||
>
|
||||
<div
|
||||
ref={modalRef}
|
||||
style={{
|
||||
background: '#050505',
|
||||
border: '1px solid #1f1f1f',
|
||||
borderRadius: '12px',
|
||||
width: '90%',
|
||||
maxWidth: '600px',
|
||||
boxShadow: '0 25px 65px rgba(0, 0, 0, 0.7), 0 0 0 1px rgba(255, 255, 255, 0.05)',
|
||||
animation: 'slideIn 150ms ease-out'
|
||||
}}
|
||||
>
|
||||
{/* Search Input */}
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
borderBottom: suggestions.length > 0 ? '1px solid #1f1f1f' : 'none'
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
background: '#0a0a0a',
|
||||
padding: '12px',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid #1f1f1f'
|
||||
}}>
|
||||
{/* Search Icon */}
|
||||
<svg width="16" height="16" viewBox="0 0 20 20" fill="#666">
|
||||
<path fillRule="evenodd" d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z" clipRule="evenodd" />
|
||||
</svg>
|
||||
|
||||
{/* Selected Filters */}
|
||||
{existingFilters.map((filter, index) => (
|
||||
<Chip
|
||||
key={index}
|
||||
label={filter.value}
|
||||
color={'#1a1a4d'}
|
||||
maxWidth={120}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Input */}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={existingFilters.length === 0 ? "Search nodes..." : ""}
|
||||
style={{
|
||||
flex: 1,
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
color: '#fff',
|
||||
fontSize: '14px',
|
||||
fontFamily: 'inherit'
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Close Button */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#666',
|
||||
cursor: 'pointer',
|
||||
fontSize: '20px',
|
||||
padding: '0 4px',
|
||||
lineHeight: 1,
|
||||
transition: 'color 0.2s'
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.color = '#fff'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = '#666'; }}
|
||||
aria-label="Close search"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Suggestions */}
|
||||
{suggestions.length > 0 && (
|
||||
<div style={{
|
||||
maxHeight: '300px',
|
||||
overflowY: 'auto'
|
||||
}}>
|
||||
{suggestions.map((suggestion, index) => {
|
||||
const primaryDimension = suggestion.dimensions && suggestion.dimensions.length > 0
|
||||
? suggestion.dimensions[0]
|
||||
: '';
|
||||
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => handleSelectSuggestion(suggestion)}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px 16px',
|
||||
textAlign: 'left',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
fontSize: '13px',
|
||||
background: index === selectedIndex ? '#252525' : 'transparent',
|
||||
border: 'none',
|
||||
borderBottom: index < suggestions.length - 1 ? '1px solid #1f1f1f' : 'none',
|
||||
color: '#ccc',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.1s',
|
||||
fontFamily: 'inherit'
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
fontSize: '10px',
|
||||
fontWeight: 700,
|
||||
color: '#000',
|
||||
background: '#22c55e',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '6px',
|
||||
minWidth: '20px',
|
||||
textAlign: 'center',
|
||||
letterSpacing: '0.05em',
|
||||
flexShrink: 0
|
||||
}}>
|
||||
{suggestion.id}
|
||||
</span>
|
||||
{primaryDimension && (
|
||||
<span style={{
|
||||
color: '#666',
|
||||
fontSize: '10px',
|
||||
fontWeight: 600,
|
||||
minWidth: '60px',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em'
|
||||
}}>
|
||||
{primaryDimension}
|
||||
</span>
|
||||
)}
|
||||
<span style={{
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
color: '#e5e5e5'
|
||||
}}>
|
||||
{suggestion.title}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Keyboard Hint */}
|
||||
<div style={{
|
||||
padding: '12px 16px',
|
||||
borderTop: '1px solid #1f1f1f',
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: '16px',
|
||||
fontSize: '11px',
|
||||
color: '#666'
|
||||
}}>
|
||||
<span>↑↓ Navigate</span>
|
||||
<span>↵ Select</span>
|
||||
<span>Esc Close</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
|
||||
return typeof window !== 'undefined' ? createPortal(modalContent, document.body) : null;
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { apiKeyService, ApiKeyStatus } from '@/services/storage/apiKeys';
|
||||
|
||||
export default function ApiKeysViewer() {
|
||||
const [openaiKey, setOpenaiKey] = useState('');
|
||||
const [anthropicKey, setAnthropicKey] = useState('');
|
||||
const [status, setStatus] = useState<ApiKeyStatus>({
|
||||
openai: 'not-set',
|
||||
anthropic: 'not-set'
|
||||
});
|
||||
const [showKeys, setShowKeys] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Load existing keys and status
|
||||
const stored = apiKeyService.getStoredKeys();
|
||||
setOpenaiKey(stored.openai || '');
|
||||
setAnthropicKey(stored.anthropic || '');
|
||||
setStatus(apiKeyService.getStatus());
|
||||
}, []);
|
||||
|
||||
const handleSaveOpenAi = async () => {
|
||||
if (!openaiKey.trim()) return;
|
||||
|
||||
try {
|
||||
apiKeyService.setOpenAiKey(openaiKey.trim());
|
||||
// Test the connection
|
||||
const isConnected = await apiKeyService.testOpenAiConnection();
|
||||
setStatus(prev => ({ ...prev, openai: isConnected ? 'connected' : 'failed' }));
|
||||
|
||||
if (isConnected) {
|
||||
alert('OpenAI API key saved and connection verified!');
|
||||
} else {
|
||||
alert('OpenAI API key saved but connection failed. Please check your key.');
|
||||
}
|
||||
} catch (error) {
|
||||
alert(`Failed to save OpenAI key: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveAnthropic = async () => {
|
||||
if (!anthropicKey.trim()) return;
|
||||
|
||||
try {
|
||||
apiKeyService.setAnthropicKey(anthropicKey.trim());
|
||||
// Test the connection
|
||||
const isConnected = await apiKeyService.testAnthropicConnection();
|
||||
setStatus(prev => ({ ...prev, anthropic: isConnected ? 'connected' : 'failed' }));
|
||||
|
||||
if (isConnected) {
|
||||
alert('Anthropic API key saved and connection verified!');
|
||||
} else {
|
||||
alert('Anthropic API key saved but connection failed. Please check your key.');
|
||||
}
|
||||
} catch (error) {
|
||||
alert(`Failed to save Anthropic key: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestOpenAi = async () => {
|
||||
setStatus(prev => ({ ...prev, openai: 'testing' }));
|
||||
const isConnected = await apiKeyService.testOpenAiConnection();
|
||||
setStatus(prev => ({ ...prev, openai: isConnected ? 'connected' : 'failed' }));
|
||||
};
|
||||
|
||||
const handleTestAnthropic = async () => {
|
||||
setStatus(prev => ({ ...prev, anthropic: 'testing' }));
|
||||
const isConnected = await apiKeyService.testAnthropicConnection();
|
||||
setStatus(prev => ({ ...prev, anthropic: isConnected ? 'connected' : 'failed' }));
|
||||
};
|
||||
|
||||
const handleClearOpenAi = () => {
|
||||
apiKeyService.clearOpenAiKey();
|
||||
setOpenaiKey('');
|
||||
setStatus(prev => ({ ...prev, openai: 'not-set' }));
|
||||
};
|
||||
|
||||
const handleClearAnthropic = () => {
|
||||
apiKeyService.clearAnthropicKey();
|
||||
setAnthropicKey('');
|
||||
setStatus(prev => ({ ...prev, anthropic: 'not-set' }));
|
||||
};
|
||||
|
||||
const getStatusIcon = (statusValue: string) => {
|
||||
switch (statusValue) {
|
||||
case 'connected': return '✅';
|
||||
case 'failed': return '❌';
|
||||
case 'testing': return '⏳';
|
||||
default: return '⚪';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (statusValue: string) => {
|
||||
switch (statusValue) {
|
||||
case 'connected': return '#22c55e';
|
||||
case 'failed': return '#ef4444';
|
||||
case 'testing': return '#f59e0b';
|
||||
default: return '#6b7280';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
padding: '24px',
|
||||
height: '100%',
|
||||
overflow: 'auto',
|
||||
background: '#0f0f0f',
|
||||
color: '#fff'
|
||||
}}>
|
||||
<div style={{ marginBottom: '32px' }}>
|
||||
<h3 style={{ margin: '0 0 8px 0', fontSize: '16px', fontWeight: '600' }}>
|
||||
API Configuration
|
||||
</h3>
|
||||
<p style={{ margin: 0, fontSize: '14px', color: '#888', lineHeight: '1.5' }}>
|
||||
Keys are stored locally and never shared with RA-H servers.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Show/Hide Keys Toggle */}
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<label style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
fontSize: '14px',
|
||||
cursor: 'pointer',
|
||||
color: '#888'
|
||||
}}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showKeys}
|
||||
onChange={(e) => setShowKeys(e.target.checked)}
|
||||
style={{ marginRight: '8px' }}
|
||||
/>
|
||||
Show API keys in plain text
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* OpenAI Section */}
|
||||
<div style={{
|
||||
marginBottom: '32px',
|
||||
padding: '20px',
|
||||
border: '1px solid #2a2a2a',
|
||||
borderRadius: '8px',
|
||||
background: '#0a0a0a'
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
marginBottom: '16px'
|
||||
}}>
|
||||
<h4 style={{ margin: 0, fontSize: '14px', fontWeight: '600' }}>
|
||||
OpenAI API Key
|
||||
</h4>
|
||||
<div style={{
|
||||
marginLeft: '12px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
fontSize: '12px',
|
||||
color: getStatusColor(status.openai)
|
||||
}}>
|
||||
<span style={{ marginRight: '4px' }}>
|
||||
{getStatusIcon(status.openai)}
|
||||
</span>
|
||||
<span>
|
||||
{status.openai === 'connected' && 'Connected'}
|
||||
{status.openai === 'failed' && 'Connection Failed'}
|
||||
{status.openai === 'testing' && 'Testing...'}
|
||||
{status.openai === 'not-set' && 'Not Set'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<input
|
||||
type={showKeys ? 'text' : 'password'}
|
||||
value={openaiKey}
|
||||
onChange={(e) => setOpenaiKey(e.target.value)}
|
||||
placeholder="sk-proj-... or sk-..."
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
fontSize: '14px',
|
||||
background: '#1a1a1a',
|
||||
border: '1px solid #333',
|
||||
borderRadius: '6px',
|
||||
color: '#fff',
|
||||
fontFamily: 'monospace'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
onClick={handleSaveOpenAi}
|
||||
disabled={!openaiKey.trim()}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
fontSize: '12px',
|
||||
background: '#22c55e',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: openaiKey.trim() ? 'pointer' : 'not-allowed',
|
||||
opacity: openaiKey.trim() ? 1 : 0.5
|
||||
}}
|
||||
>
|
||||
Save & Test
|
||||
</button>
|
||||
|
||||
|
||||
<button
|
||||
onClick={handleClearOpenAi}
|
||||
disabled={!openaiKey}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
fontSize: '12px',
|
||||
background: '#ef4444',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: openaiKey ? 'pointer' : 'not-allowed',
|
||||
opacity: openaiKey ? 1 : 0.5
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Anthropic Section */}
|
||||
<div style={{
|
||||
marginBottom: '32px',
|
||||
padding: '20px',
|
||||
border: '1px solid #2a2a2a',
|
||||
borderRadius: '8px',
|
||||
background: '#0a0a0a'
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
marginBottom: '16px'
|
||||
}}>
|
||||
<h4 style={{ margin: 0, fontSize: '14px', fontWeight: '600' }}>
|
||||
Anthropic API Key
|
||||
</h4>
|
||||
<div style={{
|
||||
marginLeft: '12px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
fontSize: '12px',
|
||||
color: getStatusColor(status.anthropic)
|
||||
}}>
|
||||
<span style={{ marginRight: '4px' }}>
|
||||
{getStatusIcon(status.anthropic)}
|
||||
</span>
|
||||
<span>
|
||||
{status.anthropic === 'connected' && 'Connected'}
|
||||
{status.anthropic === 'failed' && 'Connection Failed'}
|
||||
{status.anthropic === 'testing' && 'Testing...'}
|
||||
{status.anthropic === 'not-set' && 'Not Set'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<input
|
||||
type={showKeys ? 'text' : 'password'}
|
||||
value={anthropicKey}
|
||||
onChange={(e) => setAnthropicKey(e.target.value)}
|
||||
placeholder="sk-ant-api03-..."
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
fontSize: '14px',
|
||||
background: '#1a1a1a',
|
||||
border: '1px solid #333',
|
||||
borderRadius: '6px',
|
||||
color: '#fff',
|
||||
fontFamily: 'monospace'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
onClick={handleSaveAnthropic}
|
||||
disabled={!anthropicKey.trim()}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
fontSize: '12px',
|
||||
background: '#22c55e',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: anthropicKey.trim() ? 'pointer' : 'not-allowed',
|
||||
opacity: anthropicKey.trim() ? 1 : 0.5
|
||||
}}
|
||||
>
|
||||
Save & Test
|
||||
</button>
|
||||
|
||||
|
||||
<button
|
||||
onClick={handleClearAnthropic}
|
||||
disabled={!anthropicKey}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
fontSize: '12px',
|
||||
background: '#ef4444',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: anthropicKey ? 'pointer' : 'not-allowed',
|
||||
opacity: anthropicKey ? 1 : 0.5
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Help Section */}
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
background: '#1a1a1a',
|
||||
border: '1px solid #333',
|
||||
borderRadius: '6px',
|
||||
fontSize: '12px',
|
||||
color: '#888',
|
||||
lineHeight: '1.5'
|
||||
}}>
|
||||
<h5 style={{ margin: '0 0 8px 0', color: '#fff', fontSize: '13px' }}>
|
||||
How to get API keys:
|
||||
</h5>
|
||||
<ul style={{ margin: 0, paddingLeft: '16px' }}>
|
||||
<li style={{ marginBottom: '4px' }}>
|
||||
<strong>OpenAI:</strong> Visit <a href="https://platform.openai.com/api-keys" target="_blank" rel="noopener noreferrer" style={{ color: '#22c55e' }}>platform.openai.com/api-keys</a>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Anthropic:</strong> Visit <a href="https://console.anthropic.com/settings/keys" target="_blank" rel="noopener noreferrer" style={{ color: '#22c55e' }}>console.anthropic.com/settings/keys</a>
|
||||
</li>
|
||||
</ul>
|
||||
<p style={{ margin: '12px 0 0 0', fontSize: '11px' }}>
|
||||
Your keys are stored locally and never sent to RA-H servers. They are only used to communicate directly with OpenAI and Anthropic APIs.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { Node } from '@/types/database';
|
||||
|
||||
interface AutoContextSettings {
|
||||
autoContextEnabled: boolean;
|
||||
lastPinnedMigration?: string;
|
||||
}
|
||||
|
||||
interface NodeWithMetrics extends Node {
|
||||
edge_count?: number;
|
||||
}
|
||||
|
||||
export default function ContextViewer() {
|
||||
const [nodes, setNodes] = useState<NodeWithMetrics[]>([]);
|
||||
const [autoContextEnabled, setAutoContextEnabled] = useState(false);
|
||||
const [lastMigrated, setLastMigrated] = useState<string | undefined>();
|
||||
const [loadingNodes, setLoadingNodes] = useState(true);
|
||||
const [loadingSettings, setLoadingSettings] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadNodes = async () => {
|
||||
setLoadingNodes(true);
|
||||
try {
|
||||
const response = await fetch('/api/nodes?sortBy=edges&limit=10');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load nodes');
|
||||
}
|
||||
const payload = await response.json();
|
||||
setNodes(payload.data || []);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError('Unable to load top nodes.');
|
||||
} finally {
|
||||
setLoadingNodes(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadSettings = async () => {
|
||||
setLoadingSettings(true);
|
||||
try {
|
||||
const response = await fetch('/api/system/auto-context');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load auto-context settings');
|
||||
}
|
||||
const payload = (await response.json()) as {
|
||||
success: boolean;
|
||||
data?: AutoContextSettings;
|
||||
};
|
||||
if (payload.success && payload.data) {
|
||||
setAutoContextEnabled(Boolean(payload.data.autoContextEnabled));
|
||||
setLastMigrated(payload.data.lastPinnedMigration);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError('Unable to load auto-context settings.');
|
||||
} finally {
|
||||
setLoadingSettings(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadNodes();
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
const toggleDescription = useMemo(() => {
|
||||
if (!autoContextEnabled) {
|
||||
return 'Disabled — chats and workflows will not receive BACKGROUND CONTEXT.';
|
||||
}
|
||||
return 'Enabled — top 10 most-connected nodes will be added to BACKGROUND CONTEXT.';
|
||||
}, [autoContextEnabled]);
|
||||
|
||||
const handleToggle = async () => {
|
||||
if (saving) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch('/api/system/auto-context', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ autoContextEnabled: !autoContextEnabled }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as {
|
||||
success: boolean;
|
||||
data?: AutoContextSettings;
|
||||
};
|
||||
|
||||
if (payload.success && payload.data) {
|
||||
setAutoContextEnabled(payload.data.autoContextEnabled);
|
||||
setLastMigrated(payload.data.lastPinnedMigration);
|
||||
} else {
|
||||
throw new Error(payload?.data ? 'Settings update failed' : 'Unknown error');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to update auto-context toggle', err);
|
||||
setError('Unable to update setting. Please try again.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', overflow: 'auto', padding: '24px', color: '#f8fafc' }}>
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<h3 style={{ margin: '0 0 8px', fontSize: '18px' }}>Auto-Context</h3>
|
||||
<p style={{ margin: 0, color: '#94a3b8', fontSize: '13px', lineHeight: 1.6 }}>
|
||||
Auto-context grabs your 10 nodes with the most edges and drops them into BACKGROUND
|
||||
CONTEXT so ra-h knows which ideas connect everything else.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '16px 20px',
|
||||
border: '1px solid #1f2937',
|
||||
borderRadius: '10px',
|
||||
background: '#050505',
|
||||
marginBottom: '24px',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: '14px' }}>Enable Auto-Context</div>
|
||||
<div style={{ fontSize: '12px', color: '#94a3b8', marginTop: '4px' }}>
|
||||
{loadingSettings ? 'Loading preference…' : toggleDescription}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
disabled={loadingSettings || saving}
|
||||
style={{
|
||||
width: '58px',
|
||||
height: '30px',
|
||||
borderRadius: '999px',
|
||||
border: 'none',
|
||||
cursor: saving ? 'wait' : 'pointer',
|
||||
background: autoContextEnabled ? '#22c55e' : '#334155',
|
||||
position: 'relative',
|
||||
transition: 'background 0.2s ease',
|
||||
}}
|
||||
title={autoContextEnabled ? 'Disable auto-context' : 'Enable auto-context'}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '4px',
|
||||
left: autoContextEnabled ? '32px' : '4px',
|
||||
width: '22px',
|
||||
height: '22px',
|
||||
borderRadius: '50%',
|
||||
background: '#fff',
|
||||
transition: 'left 0.2s ease',
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{lastMigrated && (
|
||||
<div style={{ fontSize: '11px', color: '#64748b', marginBottom: '16px' }}>
|
||||
Auto-enabled because you previously pinned nodes · {new Date(lastMigrated).toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, marginBottom: '12px' }}>Top 10 nodes by connections</div>
|
||||
{loadingNodes ? (
|
||||
<div style={{ color: '#94a3b8', fontSize: '13px' }}>Loading nodes…</div>
|
||||
) : nodes.length === 0 ? (
|
||||
<div style={{ color: '#94a3b8', fontSize: '13px' }}>
|
||||
No connected nodes yet. Create nodes and add edges to see context hubs.
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{nodes.map((node) => (
|
||||
<div
|
||||
key={node.id}
|
||||
style={{
|
||||
border: '1px solid #1f2937',
|
||||
borderRadius: '10px',
|
||||
padding: '12px 16px',
|
||||
background: '#080808',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '4px',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600 }}>
|
||||
[NODE:{node.id}] {node.title || 'Untitled node'}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#facc15' }}>
|
||||
{node.edge_count ?? 0} connections
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
|
||||
{node.dimensions && node.dimensions.length > 0 ? (
|
||||
node.dimensions.slice(0, 4).map((dimension) => (
|
||||
<span
|
||||
key={`${node.id}-${dimension}`}
|
||||
style={{
|
||||
padding: '2px 8px',
|
||||
borderRadius: '999px',
|
||||
fontSize: '11px',
|
||||
background: '#132018',
|
||||
color: '#86efac',
|
||||
}}
|
||||
>
|
||||
{dimension}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span style={{ fontSize: '11px', color: '#64748b' }}>No dimensions</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div style={{ marginTop: '24px', color: '#f87171', fontSize: '12px' }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,526 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState, type ChangeEvent, type KeyboardEvent } from 'react';
|
||||
import { Folder, Link as LinkIcon } from 'lucide-react';
|
||||
import { Node } from '@/types/database';
|
||||
|
||||
interface NodeWithMetrics extends Node {
|
||||
edge_count?: number;
|
||||
}
|
||||
|
||||
interface AppliedFilters {
|
||||
search?: string;
|
||||
dimensions?: string[];
|
||||
sortBy: 'updated' | 'edges';
|
||||
}
|
||||
|
||||
interface PopularDimension {
|
||||
dimension: string;
|
||||
count: number;
|
||||
isPriority: boolean;
|
||||
}
|
||||
|
||||
const LIMIT = 50;
|
||||
|
||||
export default function DatabaseViewer() {
|
||||
const [nodes, setNodes] = useState<NodeWithMetrics[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const [dimensionInput, setDimensionInput] = useState('');
|
||||
const [dimensionFilters, setDimensionFilters] = useState<string[]>([]);
|
||||
const [sortBy, setSortBy] = useState<'updated' | 'edges'>('updated');
|
||||
|
||||
const [appliedFilters, setAppliedFilters] = useState<AppliedFilters>({ sortBy: 'updated' });
|
||||
const [lockedDimensionSet, setLockedDimensionSet] = useState<Set<string>>(new Set());
|
||||
const [contextHubIds, setContextHubIds] = useState<Set<number>>(new Set());
|
||||
|
||||
const filtersActive = useMemo(
|
||||
() => Boolean(appliedFilters.search || (appliedFilters.dimensions && appliedFilters.dimensions.length > 0)),
|
||||
[appliedFilters]
|
||||
);
|
||||
|
||||
const fetchNodes = useCallback(
|
||||
async (pageNumber: number, filters: AppliedFilters) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.set('limit', LIMIT.toString());
|
||||
params.set('offset', ((pageNumber - 1) * LIMIT).toString());
|
||||
params.set('sortBy', filters.sortBy);
|
||||
if (filters.search) params.set('search', filters.search);
|
||||
if (filters.dimensions && filters.dimensions.length > 0) {
|
||||
params.set('dimensions', filters.dimensions.join(','));
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/nodes?${params.toString()}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch nodes');
|
||||
}
|
||||
const data = await response.json();
|
||||
setNodes(data.data || []);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
setNodes([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchNodes(page, appliedFilters);
|
||||
}, [page, appliedFilters, fetchNodes]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadLockedDimensions = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/dimensions/popular');
|
||||
if (!response.ok) return;
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
const priorityDimensions: PopularDimension[] = result.data;
|
||||
setLockedDimensionSet(new Set(priorityDimensions.filter((d) => d.isPriority).map((d) => d.dimension)));
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to load locked dimensions', err);
|
||||
}
|
||||
};
|
||||
|
||||
loadLockedDimensions();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const loadContextHubs = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/nodes?sortBy=edges&limit=10');
|
||||
if (!response.ok) return;
|
||||
const payload = await response.json();
|
||||
const ids = new Set<number>((payload.data || []).map((node: Node) => node.id));
|
||||
setContextHubIds(ids);
|
||||
} catch (err) {
|
||||
console.warn('Failed to load auto-context hubs', err);
|
||||
}
|
||||
};
|
||||
|
||||
loadContextHubs();
|
||||
}, []);
|
||||
|
||||
const handleApplyFilters = () => {
|
||||
const payload: AppliedFilters = {
|
||||
sortBy,
|
||||
};
|
||||
|
||||
if (searchInput.trim()) payload.search = searchInput.trim();
|
||||
if (dimensionFilters.length > 0) payload.dimensions = dimensionFilters;
|
||||
|
||||
setAppliedFilters(payload);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handleClearFilters = () => {
|
||||
setSearchInput('');
|
||||
setDimensionInput('');
|
||||
setDimensionFilters([]);
|
||||
setSortBy('updated');
|
||||
setAppliedFilters({ sortBy: 'updated' });
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handleAddDimension = () => {
|
||||
const next = dimensionInput.trim();
|
||||
if (!next) return;
|
||||
setDimensionFilters((prev) => (prev.includes(next) ? prev : [...prev, next]));
|
||||
setDimensionInput('');
|
||||
};
|
||||
|
||||
const handleDimensionKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
handleAddDimension();
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveDimension = (dimension: string) => {
|
||||
setDimensionFilters((prev) => prev.filter((dim) => dim !== dimension));
|
||||
};
|
||||
|
||||
const handleSortChange = (event: ChangeEvent<HTMLSelectElement>) => {
|
||||
const value = event.target.value === 'edges' ? 'edges' : 'updated';
|
||||
setSortBy(value);
|
||||
setAppliedFilters((prev) => ({ ...prev, sortBy: value }));
|
||||
};
|
||||
|
||||
const handlePrevious = () => {
|
||||
setPage((prev) => (prev > 1 ? prev - 1 : prev));
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
if (nodes.length === LIMIT) {
|
||||
setPage((prev) => prev + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const isFirstPage = page === 1;
|
||||
const isLastPage = nodes.length < LIMIT;
|
||||
const filterStatus = filtersActive
|
||||
? 'Filtered results'
|
||||
: `Showing ${(page - 1) * LIMIT + 1}-${(page - 1) * LIMIT + nodes.length}`;
|
||||
|
||||
const formatTimestamp = (value?: string) => {
|
||||
if (!value) return '—';
|
||||
const date = new Date(value);
|
||||
return date.toLocaleString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
const formatRelative = (value?: string) => {
|
||||
if (!value) return '';
|
||||
const diffMs = Date.now() - new Date(value).getTime();
|
||||
const diffMinutes = Math.round(diffMs / (1000 * 60));
|
||||
if (diffMinutes < 60) return `${diffMinutes}m ago`;
|
||||
const diffHours = Math.round(diffMinutes / 60);
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
const diffDays = Math.round(diffHours / 24);
|
||||
return `${diffDays}d ago`;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ padding: '40px', textAlign: 'center', color: '#888' }}>
|
||||
Loading database...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{ padding: '40px', textAlign: 'center', color: '#ef4444' }}>
|
||||
Error: {error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (nodes.length === 0) {
|
||||
return (
|
||||
<div style={{ padding: '40px', textAlign: 'center', color: '#888' }}>
|
||||
No nodes found
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div
|
||||
style={{
|
||||
padding: '16px 24px',
|
||||
borderBottom: '1px solid #2a2a2a',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: '12px', flexWrap: 'wrap' }}>
|
||||
<label style={{ display: 'flex', flexDirection: 'column', fontSize: '11px', color: '#888', gap: '4px' }}>
|
||||
Search
|
||||
<input
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
placeholder="title or content"
|
||||
style={{
|
||||
background: '#0f0f0f',
|
||||
border: '1px solid #2a2a2a',
|
||||
color: '#ddd',
|
||||
padding: '6px 10px',
|
||||
borderRadius: '4px',
|
||||
minWidth: '220px',
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ display: 'flex', flexDirection: 'column', fontSize: '11px', color: '#888', gap: '4px' }}>
|
||||
Dimension
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<input
|
||||
value={dimensionInput}
|
||||
onChange={(e) => setDimensionInput(e.target.value)}
|
||||
onKeyDown={handleDimensionKeyDown}
|
||||
placeholder="e.g. research"
|
||||
style={{
|
||||
background: '#0f0f0f',
|
||||
border: '1px solid #2a2a2a',
|
||||
color: '#ddd',
|
||||
padding: '6px 10px',
|
||||
borderRadius: '4px',
|
||||
minWidth: '180px',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={handleAddDimension}
|
||||
style={{
|
||||
padding: '6px 10px',
|
||||
background: '#1f3529',
|
||||
border: '1px solid #264333',
|
||||
borderRadius: '4px',
|
||||
color: '#c4f5d2',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
<label style={{ display: 'flex', flexDirection: 'column', fontSize: '11px', color: '#888', gap: '4px' }}>
|
||||
Sort by
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={handleSortChange}
|
||||
style={{
|
||||
background: '#0f0f0f',
|
||||
border: '1px solid #2a2a2a',
|
||||
color: '#ddd',
|
||||
padding: '6px 10px',
|
||||
borderRadius: '4px',
|
||||
}}
|
||||
>
|
||||
<option value="updated">Recently updated</option>
|
||||
<option value="edges">Most connected</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{dimensionFilters.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||
{dimensionFilters.map((dimension) => (
|
||||
<span
|
||||
key={dimension}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
padding: '3px 10px',
|
||||
borderRadius: '999px',
|
||||
background: '#142817',
|
||||
color: '#c4f5d2',
|
||||
fontSize: '11px',
|
||||
border: '1px solid #1f3b23',
|
||||
}}
|
||||
>
|
||||
<Folder size={12} />
|
||||
{dimension}
|
||||
<button
|
||||
onClick={() => handleRemoveDimension(dimension)}
|
||||
style={{
|
||||
marginLeft: '2px',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: '#7de8a5',
|
||||
cursor: 'pointer',
|
||||
fontSize: '11px',
|
||||
}}
|
||||
aria-label={`Remove ${dimension}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>{filterStatus}</div>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button
|
||||
onClick={handleApplyFilters}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: '#22c55e33',
|
||||
border: '1px solid #22c55e66',
|
||||
borderRadius: '4px',
|
||||
color: '#22c55e',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
<button
|
||||
onClick={handleClearFilters}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: '#1a1a1a',
|
||||
border: '1px solid #333',
|
||||
borderRadius: '4px',
|
||||
color: '#ccc',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
<button
|
||||
onClick={handlePrevious}
|
||||
disabled={isFirstPage || filtersActive}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
background: isFirstPage || filtersActive ? '#1a1a1a' : '#2a2a2a',
|
||||
border: '1px solid #333',
|
||||
borderRadius: '4px',
|
||||
color: isFirstPage || filtersActive ? '#555' : '#ccc',
|
||||
cursor: isFirstPage || filtersActive ? 'not-allowed' : 'pointer',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={handleNext}
|
||||
disabled={isLastPage || filtersActive}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
background: isLastPage || filtersActive ? '#1a1a1a' : '#2a2a2a',
|
||||
border: '1px solid #333',
|
||||
borderRadius: '4px',
|
||||
color: isLastPage || filtersActive ? '#555' : '#ccc',
|
||||
cursor: isLastPage || filtersActive ? 'not-allowed' : 'pointer',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead style={{ position: 'sticky', top: 0, background: '#1a1a1a', zIndex: 1 }}>
|
||||
<tr>
|
||||
{['Node', 'Categories', 'Edges', 'Highlights', 'Updated', 'Created'].map((column) => (
|
||||
<th
|
||||
key={column}
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
textAlign: 'left',
|
||||
fontSize: '11px',
|
||||
color: '#888',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
fontWeight: 'normal',
|
||||
borderBottom: '1px solid #2a2a2a',
|
||||
}}
|
||||
>
|
||||
{column}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{nodes.map((node, index) => {
|
||||
const belongsToLocked = node.dimensions?.some((dimension) => lockedDimensionSet.has(dimension));
|
||||
return (
|
||||
<tr
|
||||
key={node.id}
|
||||
style={{
|
||||
background: index % 2 === 0 ? '#080808' : '#0d0d0d',
|
||||
borderBottom: '1px solid #111',
|
||||
}}
|
||||
>
|
||||
<td style={{ padding: '12px 16px', verticalAlign: 'top', width: '28%' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
<div style={{ fontWeight: 600, color: '#f5f5f5', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
{node.title || 'Untitled node'}
|
||||
{node.link && (
|
||||
<button
|
||||
onClick={() => window.open(node.link, '_blank')}
|
||||
title="Open original link"
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: '#7de8a5',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<LinkIcon size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<span style={{ fontSize: '11px', color: '#666', fontFamily: 'JetBrains Mono, monospace' }}>ID: {node.id}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', verticalAlign: 'top', width: '24%' }}>
|
||||
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
|
||||
{node.dimensions && node.dimensions.length > 0 ? (
|
||||
node.dimensions.slice(0, 3).map((dimension) => (
|
||||
<span
|
||||
key={`${node.id}-${dimension}`}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
padding: '2px 8px',
|
||||
borderRadius: '999px',
|
||||
background: '#111914',
|
||||
border: '1px solid #1f2f24',
|
||||
fontSize: '11px',
|
||||
color: '#bbf7d0',
|
||||
}}
|
||||
>
|
||||
<Folder size={11} />
|
||||
{dimension}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span style={{ fontSize: '11px', color: '#555' }}>No categories</span>
|
||||
)}
|
||||
{node.dimensions && node.dimensions.length > 3 && (
|
||||
<span style={{ fontSize: '11px', color: '#666' }}>+{node.dimensions.length - 3} more</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', verticalAlign: 'top', width: '10%' }}>
|
||||
<div style={{ fontWeight: 600, color: '#e2e8f0' }}>{node.edge_count ?? 0}</div>
|
||||
<div style={{ fontSize: '11px', color: '#666' }}>connections</div>
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', verticalAlign: 'top', width: '14%' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||||
{contextHubIds.has(node.id) ? (
|
||||
<span style={{ fontSize: '11px', color: '#facc15', fontWeight: 600 }}>
|
||||
Auto-context hub
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ fontSize: '11px', color: '#475569' }}>—</span>
|
||||
)}
|
||||
{belongsToLocked && (
|
||||
<span style={{ fontSize: '11px', color: '#7de8a5' }}>Priority dimension</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', verticalAlign: 'top', width: '12%' }}>
|
||||
<div style={{ fontSize: '12px', color: '#e2e8f0' }}>{formatTimestamp(node.updated_at)}</div>
|
||||
<div style={{ fontSize: '11px', color: '#666' }}>{formatRelative(node.updated_at)}</div>
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', verticalAlign: 'top', width: '12%' }}>
|
||||
<div style={{ fontSize: '12px', color: '#cbd5f5' }}>{formatTimestamp(node.created_at)}</div>
|
||||
<div style={{ fontSize: '11px', color: '#666' }}>{formatRelative(node.created_at)}</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
interface McpStatus {
|
||||
enabled: boolean;
|
||||
url: string | null;
|
||||
port: number | null;
|
||||
last_updated?: string | null;
|
||||
target_base_url?: string | null;
|
||||
last_error?: string | null;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
const initialStatus: McpStatus = {
|
||||
enabled: false,
|
||||
url: null,
|
||||
port: null
|
||||
};
|
||||
|
||||
export default function ExternalAgentsPanel() {
|
||||
const [status, setStatus] = useState<McpStatus>(initialStatus);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [copyState, setCopyState] = useState<'idle' | 'copied'>('idle');
|
||||
|
||||
useEffect(() => {
|
||||
const fetchStatus = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch('/api/system/mcp-status');
|
||||
const data = await response.json();
|
||||
setStatus(data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('Failed to load MCP status', err);
|
||||
setError('Unable to read local MCP status. Open the desktop app to bootstrap it.');
|
||||
setStatus(initialStatus);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchStatus();
|
||||
const timer = setInterval(fetchStatus, 5000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
const connectorUrl = useMemo(() => {
|
||||
if (status?.url) return status.url;
|
||||
if (status?.port) return `http://127.0.0.1:${status.port}/mcp`;
|
||||
return null;
|
||||
}, [status]);
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
if (!connectorUrl) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(connectorUrl);
|
||||
setCopyState('copied');
|
||||
setTimeout(() => setCopyState('idle'), 2000);
|
||||
} catch (err) {
|
||||
console.error('Copy failed', err);
|
||||
}
|
||||
}, [connectorUrl]);
|
||||
|
||||
return (
|
||||
<div style={{ padding: '32px', color: '#e2e8f0', overflowY: 'auto' }}>
|
||||
<h2 style={{ fontSize: '20px', fontWeight: 600, marginBottom: '8px' }}>External Agents</h2>
|
||||
<p style={{ color: '#94a3b8', marginBottom: '24px', lineHeight: 1.5 }}>
|
||||
Connect Claude, ChatGPT, Gemini, or any MCP-compatible assistant to your local RA-H database.
|
||||
Everything stays on device—tools simply call this connector to add or search nodes.
|
||||
</p>
|
||||
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid #1f2937',
|
||||
borderRadius: '10px',
|
||||
padding: '20px',
|
||||
marginBottom: '24px',
|
||||
background: '#090909'
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: '14px', color: '#94a3b8' }}>Connector URL</div>
|
||||
<div style={{ fontSize: '18px', color: connectorUrl ? '#fff' : '#64748b', marginTop: '4px' }}>
|
||||
{loading ? 'Loading…' : connectorUrl ?? 'Unavailable (start the RA-H desktop app)'}
|
||||
</div>
|
||||
{status.last_updated && (
|
||||
<div style={{ fontSize: '12px', color: '#475569', marginTop: '6px' }}>
|
||||
Updated {new Date(status.last_updated).toLocaleTimeString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
disabled={!connectorUrl}
|
||||
style={{
|
||||
background: connectorUrl ? '#22c55e' : '#1f2937',
|
||||
color: connectorUrl ? '#000' : '#475569',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
padding: '10px 16px',
|
||||
cursor: connectorUrl ? 'pointer' : 'not-allowed',
|
||||
fontWeight: 600
|
||||
}}
|
||||
>
|
||||
{copyState === 'copied' ? 'Copied ✓' : 'Copy URL'}
|
||||
</button>
|
||||
</div>
|
||||
{status.last_error && (
|
||||
<div style={{ marginTop: '12px', fontSize: '13px', color: '#fbbf24' }}>
|
||||
⚠️ {status.last_error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid #1f2937',
|
||||
borderRadius: '10px',
|
||||
padding: '20px',
|
||||
marginBottom: '24px',
|
||||
background: '#0c111d'
|
||||
}}
|
||||
>
|
||||
<h3 style={{ marginBottom: '12px', fontSize: '16px', fontWeight: 600 }}>How to use in Claude or ChatGPT</h3>
|
||||
<ol style={{ paddingLeft: '20px', lineHeight: 1.6, color: '#cbd5f5' }}>
|
||||
<li>Open the MCP / connectors settings in your assistant.</li>
|
||||
<li>Select “Add connector” → choose HTTP → paste the URL above.</li>
|
||||
<li>Give the connector a friendly name (e.g., “RA-H”).</li>
|
||||
<li>Ask naturally: “Add this summary to RA-H” or “Search RA-H for my Apollo notes”.</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid #3f1d1d',
|
||||
borderRadius: '10px',
|
||||
padding: '16px',
|
||||
background: '#170e0e',
|
||||
color: '#fca5a5',
|
||||
marginBottom: '24px',
|
||||
lineHeight: 1.5
|
||||
}}
|
||||
>
|
||||
External agents can edit your local graph. Only enable trusted connectors and monitor their output.
|
||||
Disconnect the connector or close RA-H if anything unexpected happens.
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div style={{ color: '#f87171', marginBottom: '16px', fontSize: '14px' }}>{error}</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'grid', gap: '16px' }}>
|
||||
<HelperCard
|
||||
title="Add to RA-H"
|
||||
body={`"Summarize our meeting and add it to RA-H under dimensions Strategy, Q1 Execution."`}
|
||||
/>
|
||||
<HelperCard
|
||||
title="Search RA-H"
|
||||
body={`"Search RA-H for what I previously wrote about the Apollo launch delays."`}
|
||||
/>
|
||||
<HelperCard
|
||||
title="Check nodes before writing"
|
||||
body={`"Before adding anything new, call rah.search_nodes to see if the note already exists."`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HelperCard({ title, body }: { title: string; body: string }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid #1f2937',
|
||||
borderRadius: '8px',
|
||||
padding: '14px',
|
||||
background: '#0f172a'
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600, marginBottom: '6px', color: '#f1f5f9' }}>{title}</div>
|
||||
<div style={{ color: '#cbd5f5', fontSize: '14px', lineHeight: 1.5 }}>{body}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from 'react';
|
||||
import { LogEntry } from '@/types/logs';
|
||||
|
||||
interface LogsRowProps {
|
||||
log: LogEntry;
|
||||
isEven: boolean;
|
||||
}
|
||||
|
||||
export default function LogsRow({ log, isEven }: LogsRowProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const formatTimestamp = (ts: string) => {
|
||||
const date = new Date(ts);
|
||||
return date.toISOString().replace('T', ' ').substring(0, 19);
|
||||
};
|
||||
|
||||
const formatJson = (jsonStr: string | null) => {
|
||||
if (!jsonStr) return 'null';
|
||||
try {
|
||||
const parsed = JSON.parse(jsonStr);
|
||||
return JSON.stringify(parsed, null, 2);
|
||||
} catch {
|
||||
return jsonStr;
|
||||
}
|
||||
};
|
||||
|
||||
const highlightJson = (jsonStr: string) => {
|
||||
return jsonStr
|
||||
.replace(/"([^"]+)":/g, '<span style="color: #60a5fa">"$1"</span>:')
|
||||
.replace(/: "([^"]*)"/g, ': <span style="color: #34d399">"$1"</span>')
|
||||
.replace(/: (\d+)/g, ': <span style="color: #fb923c">$1</span>')
|
||||
.replace(/: (true|false|null)/g, ': <span style="color: #a78bfa">$1</span>');
|
||||
};
|
||||
|
||||
const getMetricsFromSnapshot = () => {
|
||||
if (!log.snapshot_json || log.table_name !== 'chats') return null;
|
||||
try {
|
||||
const snapshot = JSON.parse(log.snapshot_json);
|
||||
return {
|
||||
thread: snapshot.thread,
|
||||
trace_id: snapshot.trace_id,
|
||||
input_tokens: snapshot.input_tokens,
|
||||
output_tokens: snapshot.output_tokens,
|
||||
cost_usd: snapshot.cost_usd,
|
||||
cache_hit: snapshot.cache_hit,
|
||||
model: snapshot.model,
|
||||
system_message: snapshot.system_message
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const metrics = getMetricsFromSnapshot();
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
style={{
|
||||
background: isEven ? '#0f0f0f' : '#141414',
|
||||
cursor: 'pointer',
|
||||
borderBottom: '1px solid #2a2a2a'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#1a1a1a';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = isEven ? '#0f0f0f' : '#141414';
|
||||
}}
|
||||
>
|
||||
<td style={{ padding: '12px 16px', fontSize: '12px', fontFamily: 'JetBrains Mono, monospace', width: '60px' }}>
|
||||
{log.id}
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: '12px', fontFamily: 'JetBrains Mono, monospace', width: '180px' }}>
|
||||
{formatTimestamp(log.ts)}
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: '12px', fontFamily: 'JetBrains Mono, monospace', width: '100px' }}>
|
||||
{log.table_name}
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: '12px', fontFamily: 'JetBrains Mono, monospace', width: '80px' }}>
|
||||
{log.action}
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: '12px', fontFamily: 'JetBrains Mono, monospace' }}>
|
||||
<div>{log.summary || '-'}</div>
|
||||
{metrics && (
|
||||
<div style={{ marginTop: '6px', fontSize: '10px', color: '#888', display: 'flex', gap: '12px', flexWrap: 'wrap' }}>
|
||||
{metrics.trace_id && (
|
||||
<span title={`Trace: ${metrics.trace_id}`}>
|
||||
🔗 {metrics.trace_id.substring(0, 8)}
|
||||
</span>
|
||||
)}
|
||||
{metrics.thread && (
|
||||
<span title={`Thread: ${metrics.thread}`}>
|
||||
🧵 {metrics.thread.substring(0, 16)}…
|
||||
</span>
|
||||
)}
|
||||
{metrics.cost_usd !== undefined && (
|
||||
<span style={{ color: '#34d399' }}>
|
||||
💰 ${metrics.cost_usd.toFixed(6)}
|
||||
</span>
|
||||
)}
|
||||
{metrics.input_tokens !== undefined && metrics.output_tokens !== undefined && (
|
||||
<span>
|
||||
📊 {metrics.input_tokens}↓ {metrics.output_tokens}↑
|
||||
</span>
|
||||
)}
|
||||
{metrics.cache_hit !== undefined && metrics.cache_hit === 1 && (
|
||||
<span style={{ color: '#60a5fa' }}>
|
||||
⚡ Cache Hit
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: '12px', fontFamily: 'JetBrains Mono, monospace', width: '80px' }}>
|
||||
{log.row_id}
|
||||
</td>
|
||||
</tr>
|
||||
{expanded && (
|
||||
<tr style={{ background: '#0a0a0a', borderTop: '1px solid #333', borderBottom: '1px solid #333' }}>
|
||||
<td colSpan={6} style={{ padding: '16px 24px' }}>
|
||||
{metrics?.system_message && (
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<div style={{ fontSize: '11px', color: '#888', marginBottom: '8px', textTransform: 'uppercase', letterSpacing: '0.5px' }}>
|
||||
System Message
|
||||
</div>
|
||||
<pre
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
fontFamily: 'JetBrains Mono, monospace',
|
||||
lineHeight: '1.5',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
margin: 0,
|
||||
color: '#60a5fa',
|
||||
background: '#0f0f0f',
|
||||
padding: '12px',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid #1f1f1f',
|
||||
maxHeight: '300px',
|
||||
overflow: 'auto'
|
||||
}}
|
||||
>
|
||||
{metrics.system_message}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<div style={{ fontSize: '11px', color: '#888', marginBottom: '8px', textTransform: 'uppercase', letterSpacing: '0.5px' }}>
|
||||
Snapshot JSON
|
||||
</div>
|
||||
<pre
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
fontFamily: 'JetBrains Mono, monospace',
|
||||
lineHeight: '1.6',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
margin: 0
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: highlightJson(formatJson(log.snapshot_json)) }}
|
||||
/>
|
||||
</div>
|
||||
{log.enriched_summary && (
|
||||
<div>
|
||||
<div style={{ fontSize: '11px', color: '#888', marginBottom: '8px', textTransform: 'uppercase', letterSpacing: '0.5px' }}>
|
||||
Enriched Summary
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#ccc', lineHeight: '1.6' }}>
|
||||
{log.enriched_summary}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { LogEntry, LogsResponse } from '@/types/logs';
|
||||
import LogsRow from './LogsRow';
|
||||
|
||||
type AppliedFilters = {
|
||||
threadId?: string;
|
||||
traceId?: string;
|
||||
table?: string;
|
||||
};
|
||||
|
||||
export default function LogsViewer() {
|
||||
const LIMIT = 100;
|
||||
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [inputThreadId, setInputThreadId] = useState('');
|
||||
const [inputTraceId, setInputTraceId] = useState('');
|
||||
const [inputTable, setInputTable] = useState('');
|
||||
const [appliedFilters, setAppliedFilters] = useState<AppliedFilters>({});
|
||||
|
||||
const filtersActive = useMemo(
|
||||
() => Boolean(appliedFilters.threadId || appliedFilters.traceId || appliedFilters.table),
|
||||
[appliedFilters]
|
||||
);
|
||||
|
||||
const fetchLogs = useCallback(
|
||||
async (pageNum: number, filters: AppliedFilters) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.set('page', pageNum.toString());
|
||||
params.set('limit', LIMIT.toString());
|
||||
if (filters.threadId) params.set('threadId', filters.threadId);
|
||||
if (filters.traceId) params.set('traceId', filters.traceId);
|
||||
if (filters.table) params.set('table', filters.table);
|
||||
|
||||
const response = await fetch(`/api/logs?${params.toString()}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch logs');
|
||||
}
|
||||
const data: LogsResponse = await response.json();
|
||||
setLogs(data.logs);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
setLogs([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[LIMIT]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchLogs(page, appliedFilters);
|
||||
}, [page, appliedFilters, fetchLogs]);
|
||||
|
||||
const handleApplyFilters = () => {
|
||||
const nextFilters: AppliedFilters = {
|
||||
threadId: inputThreadId.trim() || undefined,
|
||||
traceId: inputTraceId.trim() || undefined,
|
||||
table: inputTable.trim() || undefined,
|
||||
};
|
||||
setAppliedFilters(nextFilters);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handleClearFilters = () => {
|
||||
setInputThreadId('');
|
||||
setInputTraceId('');
|
||||
setInputTable('');
|
||||
setAppliedFilters({});
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handlePrevious = () => {
|
||||
setPage(prev => (prev > 1 ? prev - 1 : prev));
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
if (logs.length === LIMIT) {
|
||||
setPage(prev => prev + 1);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ padding: '40px', textAlign: 'center', color: '#888' }}>
|
||||
Loading logs...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{ padding: '40px', textAlign: 'center', color: '#ef4444' }}>
|
||||
Error: {error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (logs.length === 0) {
|
||||
return (
|
||||
<div style={{ padding: '40px', textAlign: 'center', color: '#888' }}>
|
||||
No logs found
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isFirstPage = page === 1;
|
||||
const isLastPage = logs.length < LIMIT;
|
||||
const filterStatus = appliedFilters.threadId
|
||||
? `Thread: ${appliedFilters.threadId}`
|
||||
: appliedFilters.traceId
|
||||
? `Trace: ${appliedFilters.traceId}`
|
||||
: appliedFilters.table
|
||||
? `Table: ${appliedFilters.table}`
|
||||
: `Page ${page}`;
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div
|
||||
style={{
|
||||
padding: '16px 24px',
|
||||
borderBottom: '1px solid #2a2a2a',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: '12px', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<label style={{ display: 'flex', flexDirection: 'column', fontSize: '11px', color: '#888', gap: '4px' }}>
|
||||
Thread ID
|
||||
<input
|
||||
value={inputThreadId}
|
||||
onChange={(e) => setInputThreadId(e.target.value)}
|
||||
placeholder="ra-h-node-4326-session_..."
|
||||
style={{
|
||||
background: '#0f0f0f',
|
||||
border: '1px solid #2a2a2a',
|
||||
color: '#ddd',
|
||||
padding: '6px 10px',
|
||||
borderRadius: '4px',
|
||||
fontFamily: 'JetBrains Mono, monospace',
|
||||
fontSize: '12px',
|
||||
minWidth: '240px',
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ display: 'flex', flexDirection: 'column', fontSize: '11px', color: '#888', gap: '4px' }}>
|
||||
Trace ID
|
||||
<input
|
||||
value={inputTraceId}
|
||||
onChange={(e) => setInputTraceId(e.target.value)}
|
||||
placeholder="trace uuid"
|
||||
style={{
|
||||
background: '#0f0f0f',
|
||||
border: '1px solid #2a2a2a',
|
||||
color: '#ddd',
|
||||
padding: '6px 10px',
|
||||
borderRadius: '4px',
|
||||
fontFamily: 'JetBrains Mono, monospace',
|
||||
fontSize: '12px',
|
||||
minWidth: '200px',
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ display: 'flex', flexDirection: 'column', fontSize: '11px', color: '#888', gap: '4px' }}>
|
||||
Table
|
||||
<input
|
||||
value={inputTable}
|
||||
onChange={(e) => setInputTable(e.target.value)}
|
||||
placeholder="nodes | edges | chats"
|
||||
style={{
|
||||
background: '#0f0f0f',
|
||||
border: '1px solid #2a2a2a',
|
||||
color: '#ddd',
|
||||
padding: '6px 10px',
|
||||
borderRadius: '4px',
|
||||
fontFamily: 'JetBrains Mono, monospace',
|
||||
fontSize: '12px',
|
||||
minWidth: '160px',
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button
|
||||
onClick={handleApplyFilters}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: '#22c55e33',
|
||||
border: '1px solid #22c55e66',
|
||||
borderRadius: '4px',
|
||||
color: '#22c55e',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
fontFamily: 'JetBrains Mono, monospace',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#22c55e55';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = '#22c55e33';
|
||||
}}
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
<button
|
||||
onClick={handleClearFilters}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: '#1a1a1a',
|
||||
border: '1px solid #333',
|
||||
borderRadius: '4px',
|
||||
color: '#ccc',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
fontFamily: 'JetBrains Mono, monospace',
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>{filterStatus}</div>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button
|
||||
onClick={handlePrevious}
|
||||
disabled={isFirstPage || filtersActive}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: isFirstPage || filtersActive ? '#1a1a1a' : '#2a2a2a',
|
||||
border: '1px solid #333',
|
||||
borderRadius: '4px',
|
||||
color: isFirstPage || filtersActive ? '#555' : '#ccc',
|
||||
cursor: isFirstPage || filtersActive ? 'not-allowed' : 'pointer',
|
||||
fontSize: '12px',
|
||||
fontFamily: 'JetBrains Mono, monospace',
|
||||
}}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={handleNext}
|
||||
disabled={isLastPage || filtersActive}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: isLastPage || filtersActive ? '#1a1a1a' : '#2a2a2a',
|
||||
border: '1px solid #333',
|
||||
borderRadius: '4px',
|
||||
color: isLastPage || filtersActive ? '#555' : '#ccc',
|
||||
cursor: isLastPage || filtersActive ? 'not-allowed' : 'pointer',
|
||||
fontSize: '12px',
|
||||
fontFamily: 'JetBrains Mono, monospace',
|
||||
}}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead style={{ position: 'sticky', top: 0, background: '#1a1a1a', zIndex: 1 }}>
|
||||
<tr>
|
||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontSize: '11px', color: '#888', textTransform: 'uppercase', letterSpacing: '0.5px', fontWeight: 'normal', borderBottom: '1px solid #2a2a2a', width: '60px' }}>
|
||||
ID
|
||||
</th>
|
||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontSize: '11px', color: '#888', textTransform: 'uppercase', letterSpacing: '0.5px', fontWeight: 'normal', borderBottom: '1px solid #2a2a2a', width: '180px' }}>
|
||||
Timestamp
|
||||
</th>
|
||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontSize: '11px', color: '#888', textTransform: 'uppercase', letterSpacing: '0.5px', fontWeight: 'normal', borderBottom: '1px solid #2a2a2a', width: '100px' }}>
|
||||
Table
|
||||
</th>
|
||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontSize: '11px', color: '#888', textTransform: 'uppercase', letterSpacing: '0.5px', fontWeight: 'normal', borderBottom: '1px solid #2a2a2a', width: '80px' }}>
|
||||
Action
|
||||
</th>
|
||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontSize: '11px', color: '#888', textTransform: 'uppercase', letterSpacing: '0.5px', fontWeight: 'normal', borderBottom: '1px solid #2a2a2a' }}>
|
||||
Summary
|
||||
</th>
|
||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontSize: '11px', color: '#888', textTransform: 'uppercase', letterSpacing: '0.5px', fontWeight: 'normal', borderBottom: '1px solid #2a2a2a', width: '80px' }}>
|
||||
Row ID
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{logs.map((log, index) => (
|
||||
<LogsRow key={log.id} log={log} isEven={index % 2 === 0} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react';
|
||||
import { Folder, Map as MapIcon } from 'lucide-react';
|
||||
import type { Edge, Node } from '@/types/database';
|
||||
|
||||
interface GraphNode extends Node {
|
||||
edge_count?: number;
|
||||
x: number;
|
||||
y: number;
|
||||
radius: number;
|
||||
tier: number;
|
||||
}
|
||||
|
||||
interface PopularDimension {
|
||||
dimension: string;
|
||||
count: number;
|
||||
isPriority: boolean;
|
||||
}
|
||||
|
||||
interface TransformState {
|
||||
x: number;
|
||||
y: number;
|
||||
scale: number;
|
||||
}
|
||||
|
||||
const LIMIT = 400;
|
||||
const PRIMARY_NODE_LIMIT = 150;
|
||||
|
||||
export default function MapViewer() {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [containerSize, setContainerSize] = useState({ width: 800, height: 600 });
|
||||
const [nodes, setNodes] = useState<Node[]>([]);
|
||||
const [edges, setEdges] = useState<Edge[]>([]);
|
||||
const [lockedDimensions, setLockedDimensions] = useState<Set<string>>(new Set());
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [transform, setTransform] = useState<TransformState>({ x: 0, y: 0, scale: 1 });
|
||||
const [hoverNode, setHoverNode] = useState<Node | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new ResizeObserver(entries => {
|
||||
const entry = entries[0];
|
||||
if (entry?.contentRect) {
|
||||
setContainerSize({
|
||||
width: entry.contentRect.width,
|
||||
height: entry.contentRect.height,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (containerRef.current) {
|
||||
observer.observe(containerRef.current);
|
||||
}
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [nodesRes, edgesRes, dimensionsRes] = await Promise.all([
|
||||
fetch(`/api/nodes?limit=${LIMIT}&sortBy=edges`),
|
||||
fetch('/api/edges'),
|
||||
fetch('/api/dimensions/popular'),
|
||||
]);
|
||||
|
||||
if (!nodesRes.ok || !edgesRes.ok) {
|
||||
throw new Error('Failed to load knowledge graph data');
|
||||
}
|
||||
|
||||
const nodesPayload = await nodesRes.json();
|
||||
const edgesPayload = await edgesRes.json();
|
||||
|
||||
setNodes(nodesPayload.data || []);
|
||||
setEdges(edgesPayload.data || []);
|
||||
|
||||
if (dimensionsRes.ok) {
|
||||
const dimPayload = await dimensionsRes.json();
|
||||
if (dimPayload.success) {
|
||||
const priority: PopularDimension[] = dimPayload.data;
|
||||
setLockedDimensions(new Set(priority.filter(d => d.isPriority).map(d => d.dimension)));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const sortedNodes = useMemo(() => {
|
||||
return [...nodes].sort((a, b) => {
|
||||
const aLocked = a.dimensions?.some(dim => lockedDimensions.has(dim));
|
||||
const bLocked = b.dimensions?.some(dim => lockedDimensions.has(dim));
|
||||
if (aLocked !== bLocked) {
|
||||
return aLocked ? -1 : 1;
|
||||
}
|
||||
return (b.edge_count ?? 0) - (a.edge_count ?? 0);
|
||||
});
|
||||
}, [nodes, lockedDimensions]);
|
||||
|
||||
const contextHubIds = useMemo(() => {
|
||||
return new Set(sortedNodes.slice(0, 10).map(node => node.id));
|
||||
}, [sortedNodes]);
|
||||
|
||||
const graphNodes = useMemo<GraphNode[]>(() => {
|
||||
if (sortedNodes.length === 0) return [];
|
||||
const { width, height } = containerSize;
|
||||
const centerX = width / 2;
|
||||
const centerY = height / 2;
|
||||
const primaryNodes = sortedNodes.slice(0, PRIMARY_NODE_LIMIT);
|
||||
const secondaryNodes = sortedNodes.slice(PRIMARY_NODE_LIMIT);
|
||||
|
||||
const jitter = (index: number, span: number) => ((index % span) / span) * 40 - 20;
|
||||
|
||||
const positionedPrimary = primaryNodes.map((node, index) => {
|
||||
const isContextHub = contextHubIds.has(node.id);
|
||||
const isLocked = node.dimensions?.some(dim => lockedDimensions.has(dim));
|
||||
const tier = isContextHub ? 0 : isLocked ? 1 : 2;
|
||||
const baseRadius = [60, 200, 340][tier];
|
||||
const radius = baseRadius + Math.min(node.edge_count || 0, 80);
|
||||
const angle = ((index % 80) / 80) * Math.PI * 2;
|
||||
const x = centerX + Math.cos(angle) * radius + jitter(index, 5);
|
||||
const y = centerY + Math.sin(angle) * radius * 0.7 + jitter(index, 7);
|
||||
const size = tier === 0 ? 16 : tier === 1 ? 12 : 8;
|
||||
const radiusScaled = size + Math.log((node.edge_count || 1) + 1) * (tier === 2 ? 1.5 : 2.5);
|
||||
return {
|
||||
...node,
|
||||
x,
|
||||
y,
|
||||
radius: radiusScaled,
|
||||
tier,
|
||||
};
|
||||
});
|
||||
|
||||
const positionedSecondary = secondaryNodes.map((node, index) => {
|
||||
const angle = (index / secondaryNodes.length) * Math.PI * 2;
|
||||
const outerRadius = Math.max(width, height) * 0.55;
|
||||
return {
|
||||
...node,
|
||||
x: centerX + Math.cos(angle) * outerRadius,
|
||||
y: centerY + Math.sin(angle) * outerRadius,
|
||||
radius: 2,
|
||||
tier: 3,
|
||||
};
|
||||
});
|
||||
|
||||
return [...positionedPrimary, ...positionedSecondary];
|
||||
}, [sortedNodes, lockedDimensions, containerSize]);
|
||||
|
||||
const graphEdges = useMemo(() => {
|
||||
if (graphNodes.length === 0 || edges.length === 0) return [];
|
||||
const nodeMap = new Map<number, GraphNode>();
|
||||
graphNodes.forEach(node => nodeMap.set(node.id, node));
|
||||
|
||||
const weightedEdges = edges
|
||||
.map(edge => {
|
||||
const source = nodeMap.get(edge.from_node_id);
|
||||
const target = nodeMap.get(edge.to_node_id);
|
||||
if (!source || !target) return null;
|
||||
const weight = (source.edge_count || 0) + (target.edge_count || 0);
|
||||
return { id: edge.id, source, target, weight };
|
||||
})
|
||||
.filter(Boolean) as Array<{ id: number; source: GraphNode; target: GraphNode; weight: number }>;
|
||||
|
||||
return weightedEdges
|
||||
.sort((a, b) => b.weight - a.weight)
|
||||
.slice(0, 800);
|
||||
}, [edges, graphNodes]);
|
||||
|
||||
const handleZoom = (direction: 'in' | 'out' | 'reset') => {
|
||||
if (direction === 'reset') {
|
||||
setTransform({ x: 0, y: 0, scale: 1 });
|
||||
return;
|
||||
}
|
||||
setTransform(prev => ({
|
||||
...prev,
|
||||
scale: direction === 'in' ? Math.min(prev.scale + 0.2, 2.5) : Math.max(prev.scale - 0.2, 0.6),
|
||||
}));
|
||||
};
|
||||
|
||||
const handlePanStart = (event: React.PointerEvent<SVGRectElement>) => {
|
||||
const startX = event.clientX;
|
||||
const startY = event.clientY;
|
||||
const originX = transform.x;
|
||||
const originY = transform.y;
|
||||
|
||||
const handleMove = (moveEvent: PointerEvent) => {
|
||||
setTransform(prev => ({
|
||||
...prev,
|
||||
x: originX + (moveEvent.clientX - startX),
|
||||
y: originY + (moveEvent.clientY - startY),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleUp = () => {
|
||||
window.removeEventListener('pointermove', handleMove);
|
||||
window.removeEventListener('pointerup', handleUp);
|
||||
};
|
||||
|
||||
window.addEventListener('pointermove', handleMove);
|
||||
window.addEventListener('pointerup', handleUp);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ padding: '40px', textAlign: 'center', color: '#888' }}>
|
||||
Generating map...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{ padding: '40px', textAlign: 'center', color: '#ef4444' }}>
|
||||
Error: {error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (graphNodes.length === 0) {
|
||||
return (
|
||||
<div style={{ padding: '40px', textAlign: 'center', color: '#888' }}>
|
||||
Not enough nodes to render a map yet
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} style={{ position: 'relative', height: '100%', background: '#050505' }}>
|
||||
{/* Controls */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 16,
|
||||
right: 16,
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
zIndex: 2,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => handleZoom('in')}
|
||||
style={controlButtonStyle}
|
||||
title="Zoom in"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleZoom('out')}
|
||||
style={controlButtonStyle}
|
||||
title="Zoom out"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleZoom('reset')}
|
||||
style={controlButtonStyle}
|
||||
title="Reset view"
|
||||
>
|
||||
⟳
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 16,
|
||||
left: 16,
|
||||
background: 'rgba(8,8,8,0.9)',
|
||||
border: '1px solid #1f1f1f',
|
||||
borderRadius: '8px',
|
||||
padding: '12px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '6px',
|
||||
fontSize: '12px',
|
||||
color: '#bbb',
|
||||
zIndex: 2,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontWeight: 600 }}>
|
||||
<MapIcon size={14} /> Legend
|
||||
</div>
|
||||
<LegendRow color="#fcd34d" label="Auto-context hub" />
|
||||
<LegendRow color="#7de8a5" label="Locked dimension" icon={<Folder size={12} color="#7de8a5" />} />
|
||||
<LegendRow color="#cbd5f5" label="Regular node" />
|
||||
<div style={{ fontSize: '11px', color: '#666' }}>Node size increases with edge count</div>
|
||||
</div>
|
||||
|
||||
{/* Hover tooltip */}
|
||||
{hoverNode && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 16,
|
||||
right: 16,
|
||||
maxWidth: '260px',
|
||||
background: 'rgba(10,10,10,0.95)',
|
||||
border: '1px solid #1f1f1f',
|
||||
borderRadius: '8px',
|
||||
padding: '12px',
|
||||
fontSize: '12px',
|
||||
color: '#eee',
|
||||
zIndex: 2,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600 }}>{hoverNode.title || 'Untitled node'}</div>
|
||||
<div style={{ fontSize: '11px', color: '#666', marginBottom: '6px' }}>ID: {hoverNode.id}</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
<span>Edges: {hoverNode.edge_count ?? 0}</span>
|
||||
<span>Auto-context hub: {contextHubIds.has(hoverNode.id) ? 'Yes' : 'No'}</span>
|
||||
<span style={{ display: 'flex', flexWrap: 'wrap', gap: '4px' }}>
|
||||
{(hoverNode.dimensions || []).slice(0, 3).map(dimension => (
|
||||
<span
|
||||
key={`${hoverNode.id}-${dimension}`}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '999px',
|
||||
background: '#0f1a12',
|
||||
border: '1px solid #1f3425',
|
||||
}}
|
||||
>
|
||||
<Folder size={10} />
|
||||
{dimension}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<svg width="100%" height="100%" style={{ display: 'block' }}>
|
||||
<rect
|
||||
width="100%"
|
||||
height="100%"
|
||||
fill="transparent"
|
||||
style={{ cursor: 'grab' }}
|
||||
onPointerDown={handlePanStart}
|
||||
/>
|
||||
<g transform={`translate(${transform.x} ${transform.y}) scale(${transform.scale})`}>
|
||||
{graphEdges.map(edge => {
|
||||
const thickness = Math.min(3, 0.5 + edge.weight / 300);
|
||||
const opacity = Math.min(0.7, 0.2 + edge.weight / 800);
|
||||
return (
|
||||
<line
|
||||
key={edge.id}
|
||||
x1={edge.source.x}
|
||||
y1={edge.source.y}
|
||||
x2={edge.target.x}
|
||||
y2={edge.target.y}
|
||||
stroke="#1f2933"
|
||||
strokeWidth={thickness}
|
||||
strokeOpacity={opacity}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{graphNodes.map(node => {
|
||||
const isContextHub = contextHubIds.has(node.id);
|
||||
const isLocked = node.dimensions?.some(dim => lockedDimensions.has(dim));
|
||||
const fill = isContextHub ? '#fcd34d' : isLocked ? '#7de8a5' : node.tier === 3 ? '#334155' : '#cbd5f5';
|
||||
const stroke = isContextHub ? '#fbbf24' : isLocked ? '#4ade80' : node.tier === 3 ? '#1e293b' : '#94a3b8';
|
||||
const showLabel = node.tier < 3 && transform.scale > 0.8;
|
||||
return (
|
||||
<g
|
||||
key={node.id}
|
||||
onMouseEnter={() => setHoverNode(node)}
|
||||
onMouseLeave={() => setHoverNode(null)}
|
||||
onClick={() => {
|
||||
if (node.tier === 3) return;
|
||||
setTransform(prev => {
|
||||
const nextScale = Math.min(2.5, Math.max(prev.scale, 1.4));
|
||||
return {
|
||||
scale: nextScale,
|
||||
x: containerSize.width / 2 - node.x * nextScale,
|
||||
y: containerSize.height / 2 - node.y * nextScale,
|
||||
};
|
||||
});
|
||||
}}
|
||||
style={{ cursor: node.tier < 3 ? 'pointer' : 'default' }}
|
||||
>
|
||||
<circle
|
||||
cx={node.x}
|
||||
cy={node.y}
|
||||
r={node.radius}
|
||||
fill={fill}
|
||||
fillOpacity={node.tier === 3 ? 0.4 : isContextHub ? 0.95 : 0.75}
|
||||
stroke={stroke}
|
||||
strokeWidth={node.tier === 3 ? 0.5 : isContextHub ? 2.5 : 1.5}
|
||||
opacity={node.tier === 3 ? 0.6 : 0.95}
|
||||
/>
|
||||
{showLabel && (
|
||||
<text
|
||||
x={node.x}
|
||||
y={node.y + node.radius + 12}
|
||||
textAnchor="middle"
|
||||
fill="#94a3b8"
|
||||
fontSize={10}
|
||||
fontWeight={500}
|
||||
>
|
||||
{(node.title || 'Untitled').slice(0, 24)}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LegendRow({ color, label, icon }: { color: string; label: string; icon?: ReactNode }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span
|
||||
style={{
|
||||
width: '12px',
|
||||
height: '12px',
|
||||
borderRadius: '999px',
|
||||
background: color,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#000',
|
||||
fontSize: '9px',
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const controlButtonStyle: CSSProperties = {
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
borderRadius: '999px',
|
||||
border: '1px solid #1f1f1f',
|
||||
background: 'rgba(8,8,8,0.85)',
|
||||
color: '#eee',
|
||||
fontSize: '16px',
|
||||
cursor: 'pointer',
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { User } from 'lucide-react';
|
||||
|
||||
interface SettingsCogProps {
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export default function SettingsCog({ onClick }: SettingsCogProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '16px',
|
||||
left: '16px',
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
background: '#1a1a1a',
|
||||
border: 'none', /* Remove border for cleaner look */
|
||||
borderRadius: '50%', /* Make it circular like an avatar */
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'all 0.2s',
|
||||
zIndex: 100
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#232323';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = '#1a1a1a';
|
||||
}}
|
||||
title="User Settings"
|
||||
>
|
||||
<User
|
||||
size={20}
|
||||
color="#666"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import LogsViewer from './LogsViewer';
|
||||
import ToolsViewer from './ToolsViewer';
|
||||
import WorkflowsViewer from './WorkflowsViewer';
|
||||
import ApiKeysViewer from './ApiKeysViewer';
|
||||
import DatabaseViewer from './DatabaseViewer';
|
||||
import MapViewer from './MapViewer';
|
||||
import ExternalAgentsPanel from './ExternalAgentsPanel';
|
||||
import ContextViewer from './ContextViewer';
|
||||
import { apiKeyService } from '@/services/storage/apiKeys';
|
||||
|
||||
export type SettingsTab =
|
||||
| 'logs'
|
||||
| 'tools'
|
||||
| 'workflows'
|
||||
| 'apikeys'
|
||||
| 'database'
|
||||
| 'map'
|
||||
| 'context'
|
||||
| 'agents';
|
||||
|
||||
interface SettingsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
initialTab?: SettingsTab;
|
||||
}
|
||||
|
||||
type TabType = SettingsTab;
|
||||
|
||||
export default function SettingsModal({ isOpen, onClose, initialTab }: SettingsModalProps) {
|
||||
// Default to API Keys tab if no keys are configured, otherwise logs
|
||||
const getDefaultTab = (): TabType => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const hasKeys = apiKeyService.getOpenAiKey() || apiKeyService.getAnthropicKey();
|
||||
return hasKeys ? 'logs' : 'apikeys';
|
||||
}
|
||||
return 'logs';
|
||||
};
|
||||
|
||||
const [activeTab, setActiveTab] = useState<TabType>(getDefaultTab());
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleEscape);
|
||||
return () => window.removeEventListener('keydown', handleEscape);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !initialTab) return;
|
||||
setActiveTab(initialTab);
|
||||
}, [initialTab, isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const modalContent = (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(0, 0, 0, 0.8)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000
|
||||
}}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '80vw',
|
||||
height: '85vh',
|
||||
background: '#0f0f0f',
|
||||
border: '1px solid #2a2a2a',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.6)',
|
||||
display: 'flex',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Sidebar */}
|
||||
<div
|
||||
style={{
|
||||
width: '20%',
|
||||
background: '#0a0a0a',
|
||||
borderRight: '1px solid #2a2a2a',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
padding: '24px 0'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: '0 24px',
|
||||
marginBottom: '24px',
|
||||
fontSize: '18px',
|
||||
fontWeight: '600',
|
||||
color: '#fff'
|
||||
}}
|
||||
>
|
||||
Settings
|
||||
</div>
|
||||
<nav>
|
||||
<div
|
||||
onClick={() => setActiveTab('logs')}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
fontSize: '14px',
|
||||
color: activeTab === 'logs' ? '#fff' : '#888',
|
||||
background: activeTab === 'logs' ? '#1a3a2a' : 'transparent',
|
||||
borderLeft: activeTab === 'logs' ? '3px solid #22c55e' : '3px solid transparent',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
>
|
||||
Logs
|
||||
</div>
|
||||
<div
|
||||
onClick={() => setActiveTab('tools')}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
fontSize: '14px',
|
||||
color: activeTab === 'tools' ? '#fff' : '#888',
|
||||
background: activeTab === 'tools' ? '#1a3a2a' : 'transparent',
|
||||
borderLeft: activeTab === 'tools' ? '3px solid #22c55e' : '3px solid transparent',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
>
|
||||
Tools
|
||||
</div>
|
||||
<div
|
||||
onClick={() => setActiveTab('workflows')}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
fontSize: '14px',
|
||||
color: activeTab === 'workflows' ? '#fff' : '#888',
|
||||
background: activeTab === 'workflows' ? '#1a3a2a' : 'transparent',
|
||||
borderLeft: activeTab === 'workflows' ? '3px solid #22c55e' : '3px solid transparent',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
>
|
||||
Workflows
|
||||
</div>
|
||||
<div
|
||||
onClick={() => setActiveTab('apikeys')}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
fontSize: '14px',
|
||||
color: activeTab === 'apikeys' ? '#fff' : '#888',
|
||||
background: activeTab === 'apikeys' ? '#1a3a2a' : 'transparent',
|
||||
borderLeft: activeTab === 'apikeys' ? '3px solid #22c55e' : '3px solid transparent',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
>
|
||||
API Keys
|
||||
</div>
|
||||
<div
|
||||
onClick={() => setActiveTab('database')}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
fontSize: '14px',
|
||||
color: activeTab === 'database' ? '#fff' : '#888',
|
||||
background: activeTab === 'database' ? '#1a3a2a' : 'transparent',
|
||||
borderLeft: activeTab === 'database' ? '3px solid #22c55e' : '3px solid transparent',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
>
|
||||
Database
|
||||
</div>
|
||||
<div
|
||||
onClick={() => setActiveTab('context')}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
fontSize: '14px',
|
||||
color: activeTab === 'context' ? '#fff' : '#888',
|
||||
background: activeTab === 'context' ? '#1a3a2a' : 'transparent',
|
||||
borderLeft: activeTab === 'context' ? '3px solid #22c55e' : '3px solid transparent',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
>
|
||||
Context
|
||||
</div>
|
||||
<div
|
||||
onClick={() => setActiveTab('map')}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
fontSize: '14px',
|
||||
color: activeTab === 'map' ? '#fff' : '#888',
|
||||
background: activeTab === 'map' ? '#1a3a2a' : 'transparent',
|
||||
borderLeft: activeTab === 'map' ? '3px solid #22c55e' : '3px solid transparent',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
>
|
||||
Map
|
||||
</div>
|
||||
<div
|
||||
onClick={() => setActiveTab('agents')}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
fontSize: '14px',
|
||||
color: activeTab === 'agents' ? '#fff' : '#888',
|
||||
background: activeTab === 'agents' ? '#1a3a2a' : 'transparent',
|
||||
borderLeft: activeTab === 'agents' ? '3px solid #22c55e' : '3px solid transparent',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
>
|
||||
External Agents
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
fontSize: '14px',
|
||||
color: '#888',
|
||||
opacity: 0.4,
|
||||
cursor: 'not-allowed'
|
||||
}}
|
||||
>
|
||||
Backups
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
fontSize: '14px',
|
||||
color: '#888',
|
||||
opacity: 0.4,
|
||||
cursor: 'not-allowed'
|
||||
}}
|
||||
>
|
||||
Preferences
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: 'auto',
|
||||
padding: '24px',
|
||||
borderTop: '1px solid #1f2937',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
letterSpacing: '0.08em',
|
||||
textTransform: 'uppercase',
|
||||
color: '#64748b'
|
||||
}}
|
||||
>
|
||||
Local Mode
|
||||
</div>
|
||||
<p
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
color: '#94a3b8',
|
||||
margin: 0,
|
||||
lineHeight: 1.5
|
||||
}}
|
||||
>
|
||||
This open-source build runs entirely on your machine. Add keys via the API Keys tab to unlock every agent.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Area */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
padding: '16px 24px',
|
||||
borderBottom: '1px solid #2a2a2a',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
<h2
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: '16px',
|
||||
fontWeight: '600',
|
||||
color: '#fff'
|
||||
}}
|
||||
>
|
||||
{activeTab === 'logs' && 'System Logs'}
|
||||
{activeTab === 'tools' && 'Tools'}
|
||||
{activeTab === 'workflows' && 'Workflows'}
|
||||
{activeTab === 'apikeys' && 'API Keys'}
|
||||
{activeTab === 'database' && 'Knowledge Database'}
|
||||
{activeTab === 'context' && 'Auto-Context'}
|
||||
{activeTab === 'map' && 'Knowledge Map'}
|
||||
{activeTab === 'agents' && 'External Agents'}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: '#888',
|
||||
cursor: 'pointer',
|
||||
fontSize: '24px',
|
||||
lineHeight: 1,
|
||||
padding: '4px 8px',
|
||||
transition: 'color 0.2s'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.color = '#fff';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = '#888';
|
||||
}}
|
||||
title="Close (ESC)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div style={{ flex: 1, overflow: 'hidden' }}>
|
||||
{activeTab === 'logs' && <LogsViewer key={isOpen ? 'open' : 'closed'} />}
|
||||
{activeTab === 'tools' && <ToolsViewer />}
|
||||
{activeTab === 'workflows' && <WorkflowsViewer />}
|
||||
{activeTab === 'apikeys' && <ApiKeysViewer />}
|
||||
{activeTab === 'database' && <DatabaseViewer />}
|
||||
{activeTab === 'context' && <ContextViewer />}
|
||||
{activeTab === 'map' && <MapViewer />}
|
||||
{activeTab === 'agents' && <ExternalAgentsPanel />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return createPortal(modalContent, document.body);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
interface ToolGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export default function ToolsViewer() {
|
||||
const [groups, setGroups] = useState<Record<string, ToolGroup>>({});
|
||||
const [groupedTools, setGroupedTools] = useState<Record<string, { name: string; description: string }[]>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const loadTools = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/tools');
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
setGroups(result.data.groups);
|
||||
setGroupedTools(result.data.tools);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load tools:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadTools();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ padding: '24px', textAlign: 'center', color: '#888' }}>
|
||||
Loading tools...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px', overflowY: 'auto', height: '100%' }}>
|
||||
<div style={{ marginBottom: '32px' }}>
|
||||
<p style={{ color: '#888', fontSize: '14px', marginBottom: '24px' }}>
|
||||
Read-only view of all tools available in the system, grouped by function.
|
||||
</p>
|
||||
|
||||
{Object.entries(groups).map(([groupId, group]) => {
|
||||
const tools = groupedTools[groupId] || [];
|
||||
if (tools.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div key={groupId} style={{ marginBottom: '32px' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
marginBottom: '16px',
|
||||
paddingBottom: '8px',
|
||||
borderBottom: `2px solid ${group.color}`,
|
||||
}}
|
||||
>
|
||||
<span style={{ color: group.color, fontSize: '16px' }}>{group.icon}</span>
|
||||
<h3 style={{ margin: 0, fontSize: '16px', fontWeight: '600', color: '#fff' }}>
|
||||
{group.name}
|
||||
</h3>
|
||||
<span style={{ fontSize: '13px', color: '#666', marginLeft: '8px' }}>
|
||||
({tools.length} tools)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: '13px', color: '#888', marginBottom: '16px', fontStyle: 'italic' }}>
|
||||
{group.description}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
{tools.map((tool) => (
|
||||
<div
|
||||
key={tool.name}
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
background: '#1a1a1a',
|
||||
border: '1px solid #2a2a2a',
|
||||
borderRadius: '6px',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '13px', color: '#3b82f6', marginBottom: '6px' }}>
|
||||
{tool.name}
|
||||
</div>
|
||||
<div style={{ fontSize: '13px', color: '#aaa', lineHeight: '1.5' }}>
|
||||
{tool.description}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
interface WorkflowDefinition {
|
||||
id: number;
|
||||
key: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
instructions: string;
|
||||
enabled: boolean;
|
||||
requiresFocusedNode: boolean;
|
||||
primaryActor: 'oracle' | 'main';
|
||||
expectedOutcome?: string;
|
||||
}
|
||||
|
||||
export default function WorkflowsViewer() {
|
||||
const [workflows, setWorkflows] = useState<WorkflowDefinition[]>([]);
|
||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const loadWorkflows = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/workflows');
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
setWorkflows(result.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load workflows:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
loadWorkflows();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ padding: '24px', textAlign: 'center', color: '#888' }}>
|
||||
Loading workflows...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px', overflowY: 'auto', height: '100%' }}>
|
||||
<div style={{ marginBottom: '32px' }}>
|
||||
<p style={{ color: '#888', fontSize: '14px', marginBottom: '24px' }}>
|
||||
Read-only view of all predefined workflows. Click to expand instructions.
|
||||
</p>
|
||||
|
||||
{workflows.length === 0 && (
|
||||
<div style={{ color: '#666', fontSize: '14px', textAlign: 'center', padding: '32px' }}>
|
||||
No workflows defined yet.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
{workflows.map((workflow) => {
|
||||
const isExpanded = expandedId === workflow.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={workflow.id}
|
||||
style={{
|
||||
background: '#1a1a1a',
|
||||
border: '1px solid #2a2a2a',
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
onClick={() => setExpandedId(isExpanded ? null : workflow.id)}
|
||||
style={{
|
||||
padding: '16px 20px',
|
||||
cursor: 'pointer',
|
||||
borderBottom: isExpanded ? '1px solid #2a2a2a' : 'none',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '8px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<h3 style={{ margin: 0, fontSize: '16px', fontWeight: '600', color: '#fff' }}>
|
||||
{workflow.displayName}
|
||||
</h3>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '12px', color: '#666' }}>
|
||||
{workflow.key}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
{workflow.enabled ? (
|
||||
<span style={{ fontSize: '12px', color: '#10b981', padding: '4px 8px', background: '#10b98120', borderRadius: '4px' }}>
|
||||
Enabled
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ fontSize: '12px', color: '#ef4444', padding: '4px 8px', background: '#ef444420', borderRadius: '4px' }}>
|
||||
Disabled
|
||||
</span>
|
||||
)}
|
||||
<span style={{ fontSize: '18px', color: '#666', transition: 'transform 0.2s', transform: isExpanded ? 'rotate(180deg)' : 'rotate(0deg)' }}>
|
||||
▼
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: '14px', color: '#aaa', marginBottom: '12px' }}>
|
||||
{workflow.description}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '16px', flexWrap: 'wrap' }}>
|
||||
<div style={{ fontSize: '12px' }}>
|
||||
<span style={{ color: '#666' }}>Requires Node:</span>{' '}
|
||||
<span style={{ color: workflow.requiresFocusedNode ? '#f59e0b' : '#666' }}>
|
||||
{workflow.requiresFocusedNode ? 'Yes' : 'No'}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '12px' }}>
|
||||
<span style={{ color: '#666' }}>Primary Actor:</span>{' '}
|
||||
<span style={{ color: '#8b5cf6' }}>
|
||||
{workflow.primaryActor === 'oracle' ? 'Wise RA-H (GPT-5)' : 'Main RA-H'}
|
||||
</span>
|
||||
</div>
|
||||
{workflow.expectedOutcome && (
|
||||
<div style={{ fontSize: '12px', flex: '1 1 100%' }}>
|
||||
<span style={{ color: '#666' }}>Expected Outcome:</span>{' '}
|
||||
<span style={{ color: '#aaa' }}>
|
||||
{workflow.expectedOutcome}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded Instructions */}
|
||||
{isExpanded && (
|
||||
<div style={{ padding: '20px', background: '#0f0f0f' }}>
|
||||
<div style={{ marginBottom: '12px', fontSize: '13px', fontWeight: '600', color: '#888', textTransform: 'uppercase', letterSpacing: '0.5px' }}>
|
||||
Workflow Instructions
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '12px',
|
||||
color: '#ddd',
|
||||
whiteSpace: 'pre-wrap',
|
||||
lineHeight: '1.6',
|
||||
padding: '16px',
|
||||
background: '#1a1a1a',
|
||||
border: '1px solid #2a2a2a',
|
||||
borderRadius: '6px',
|
||||
maxHeight: '400px',
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
{workflow.instructions}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user