Initial commit: RA-H Open Source Edition

Local-first knowledge management system with BYO API keys.

Features:
- 3-panel UI (Nodes | Focus | Helpers)
- SQLite + sqlite-vec for vector search
- Agent system (Easy/Hard mode orchestrators)
- Content extraction (YouTube, PDF, web)
- Integrate workflow for connection discovery
- Dimension system with auto-assignment

Tech stack:
- Next.js 15 + TypeScript + Tailwind CSS
- Anthropic (Claude) + OpenAI (GPT) via Vercel AI SDK

Setup:
  npm install && npm rebuild better-sqlite3
  scripts/dev/bootstrap-local.sh
  npm run dev

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