Initial commit: RA-H Open Source Edition
Local-first knowledge management system with BYO API keys. Features: - 3-panel UI (Nodes | Focus | Helpers) - SQLite + sqlite-vec for vector search - Agent system (Easy/Hard mode orchestrators) - Content extraction (YouTube, PDF, web) - Integrate workflow for connection discovery - Dimension system with auto-assignment Tech stack: - Next.js 15 + TypeScript + Tailwind CSS - Anthropic (Claude) + OpenAI (GPT) via Vercel AI SDK Setup: npm install && npm rebuild better-sqlite3 scripts/dev/bootstrap-local.sh npm run dev MIT License
This commit is contained in:
@@ -0,0 +1,593 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import RAHChat from './RAHChat';
|
||||
import QuickAddInput from './QuickAddInput';
|
||||
import QuickAddStatus from './QuickAddStatus';
|
||||
import { Zap, Flame } from 'lucide-react';
|
||||
import type { AgentDelegation } from '@/services/agents/delegation';
|
||||
import { Node } from '@/types/database';
|
||||
|
||||
interface AgentsPanelProps {
|
||||
openTabsData: Node[];
|
||||
activeTabId: number | null;
|
||||
onNodeClick?: (nodeId: number) => void;
|
||||
}
|
||||
|
||||
type ActiveTab = 'ra-h' | string; // 'ra-h' or delegation sessionId
|
||||
type Mode = 'quickadd' | 'session';
|
||||
|
||||
export default function AgentsPanel({ openTabsData, activeTabId, onNodeClick }: AgentsPanelProps) {
|
||||
const [delegationsMap, setDelegationsMap] = useState<Record<string, AgentDelegation>>({});
|
||||
const [activeAgentTab, setActiveAgentTab] = useState<ActiveTab>('ra-h');
|
||||
const [mode, setMode] = useState<Mode>('quickadd');
|
||||
const [rahMode, setRahMode] = useState<'easy' | 'hard'>('easy');
|
||||
const [modelDropdownOpen, setModelDropdownOpen] = useState(false);
|
||||
// Lift messages state to prevent losing it on tab switch
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const [rahMessages, setRahMessages] = useState<any[]>([]);
|
||||
|
||||
// Store delegation messages per sessionId
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const [delegationMessages, setDelegationMessages] = useState<Record<string, any[]>>({});
|
||||
|
||||
const getDelegationMessages = useCallback((sessionId: string) => {
|
||||
return delegationMessages[sessionId] || [];
|
||||
}, [delegationMessages]);
|
||||
|
||||
const setDelegationMessagesFor = useCallback((sessionId: string) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return (updater: (prev: any[]) => any[]) => {
|
||||
setDelegationMessages(prev => ({
|
||||
...prev,
|
||||
[sessionId]: updater(prev[sessionId] || [])
|
||||
}));
|
||||
};
|
||||
}, []);
|
||||
|
||||
const upsertDelegation = useCallback((delegation: AgentDelegation) => {
|
||||
setDelegationsMap((prev) => ({
|
||||
...prev,
|
||||
[delegation.sessionId]: delegation,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const loadExisting = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/rah/delegations?status=active&includeCompleted=true');
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
if (!Array.isArray(data.delegations)) return;
|
||||
|
||||
setDelegationsMap((prev) => {
|
||||
if (cancelled) return prev;
|
||||
const next = { ...prev };
|
||||
for (const delegation of data.delegations as AgentDelegation[]) {
|
||||
next[delegation.sessionId] = delegation;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[AgentsPanel] Failed to load delegations:', error);
|
||||
}
|
||||
};
|
||||
|
||||
loadExisting();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const stored = window.localStorage.getItem('rah-mode');
|
||||
if (stored === 'easy' || stored === 'hard') {
|
||||
setRahMode(stored);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.localStorage.setItem('rah-mode', rahMode);
|
||||
}, [rahMode]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ mode: 'easy' | 'hard' }>).detail;
|
||||
if (detail?.mode) {
|
||||
setRahMode(detail.mode);
|
||||
}
|
||||
};
|
||||
const quickAddHandler = () => setMode('quickadd');
|
||||
window.addEventListener('rah:mode-toggle', handler as EventListener);
|
||||
window.addEventListener('rah:switch-quickadd', quickAddHandler);
|
||||
return () => {
|
||||
window.removeEventListener('rah:mode-toggle', handler as EventListener);
|
||||
window.removeEventListener('rah:switch-quickadd', quickAddHandler);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleCreated = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ delegation: AgentDelegation }>).detail;
|
||||
console.log('[AgentsPanel] Delegation created:', detail?.delegation);
|
||||
if (detail?.delegation) upsertDelegation(detail.delegation);
|
||||
};
|
||||
const handleUpdated = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ delegation: AgentDelegation }>).detail;
|
||||
console.log('[AgentsPanel] Delegation updated:', detail?.delegation);
|
||||
if (detail?.delegation) upsertDelegation(detail.delegation);
|
||||
};
|
||||
|
||||
window.addEventListener('delegations:created', handleCreated as EventListener);
|
||||
window.addEventListener('delegations:updated', handleUpdated as EventListener);
|
||||
return () => {
|
||||
window.removeEventListener('delegations:created', handleCreated as EventListener);
|
||||
window.removeEventListener('delegations:updated', handleUpdated as EventListener);
|
||||
};
|
||||
}, [upsertDelegation]);
|
||||
|
||||
const delegations = useMemo(() => Object.values(delegationsMap), [delegationsMap]);
|
||||
|
||||
const orderedDelegations = useMemo(() => {
|
||||
const statusWeight = (status: AgentDelegation['status']) => {
|
||||
switch (status) {
|
||||
case 'queued':
|
||||
return 0;
|
||||
case 'in_progress':
|
||||
return 1;
|
||||
case 'completed':
|
||||
return 2;
|
||||
case 'failed':
|
||||
default:
|
||||
return 3;
|
||||
}
|
||||
};
|
||||
|
||||
// No staleness filtering - delegations persist until user closes them
|
||||
const ordered = delegations.sort((a, b) => {
|
||||
const diff = statusWeight(a.status) - statusWeight(b.status);
|
||||
if (diff !== 0) return diff;
|
||||
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
|
||||
});
|
||||
|
||||
const wise = ordered.filter((d) => d.agentType === 'wise-rah');
|
||||
const mini = ordered.filter((d) => d.agentType !== 'wise-rah');
|
||||
|
||||
return [...wise, ...mini];
|
||||
}, [delegations]);
|
||||
|
||||
// Don't auto-switch tabs - user stays in ra-h to see responses
|
||||
|
||||
const selectedDelegation = orderedDelegations.find(d => d.sessionId === activeAgentTab);
|
||||
|
||||
const handleQuickAddSubmit = async ({ input, mode }: { input: string; mode: 'link' | 'note' | 'chat' }) => {
|
||||
try {
|
||||
const response = await fetch('/api/quick-add', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ input, mode })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Failed to submit Quick Add');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[AgentsPanel] Quick Add error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelegationClick = (sessionId: string) => {
|
||||
setActiveAgentTab(sessionId);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', background: '#0a0a0a' }}>
|
||||
{/* Mode Header */}
|
||||
{mode === 'quickadd' ? (
|
||||
<div style={{
|
||||
padding: '20px',
|
||||
borderBottom: '1px solid #1a1a1a',
|
||||
background: '#0a0a0a',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '24px',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
height: '100%'
|
||||
}}>
|
||||
{/* Top spacer */}
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
{/* Session Section */}
|
||||
<div style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
padding: '20px',
|
||||
textAlign: 'center',
|
||||
width: '100%',
|
||||
maxWidth: '600px'
|
||||
}}>
|
||||
<pre style={{
|
||||
color: '#22c55e',
|
||||
fontSize: '13px',
|
||||
fontFamily: "'JetBrains Mono', ui-monospace, Courier, monospace",
|
||||
lineHeight: '1.1',
|
||||
margin: '0 0 20px 0',
|
||||
letterSpacing: '0',
|
||||
fontWeight: 700,
|
||||
textShadow: '0 0 10px rgba(139, 212, 80, 0.4)'
|
||||
}}>{`
|
||||
██████╗ █████╗ ██╗ ██╗
|
||||
██╔══██╗██╔══██╗ ██║ ██║
|
||||
██████╔╝███████║█████╗███████║
|
||||
██╔══██╗██╔══██║╚════╝██╔══██║
|
||||
██║ ██║██║ ██║ ██║ ██║
|
||||
╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═╝
|
||||
`}</pre>
|
||||
<button
|
||||
onClick={() => {
|
||||
setMode('session');
|
||||
setRahMode('easy'); // Always start new session in easy mode
|
||||
}}
|
||||
style={{
|
||||
width: 'auto',
|
||||
padding: '14px 32px',
|
||||
background: '#22c55e',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
color: '#0a0a0a',
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
fontFamily: "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
letterSpacing: '0.02em'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#2dd46d';
|
||||
e.currentTarget.style.boxShadow = '0 4px 16px rgba(34, 197, 94, 0.4)';
|
||||
e.currentTarget.style.transform = 'translateY(-2px)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = '#22c55e';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
}}
|
||||
>
|
||||
Start Session
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Bottom spacer */}
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
{/* Divider */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
width: '100%',
|
||||
maxWidth: '600px',
|
||||
marginBottom: '12px'
|
||||
}}>
|
||||
<div style={{ flex: 1, height: '1px', background: '#1a1a1a' }} />
|
||||
<span style={{
|
||||
color: '#6b6b6b',
|
||||
fontSize: '12px',
|
||||
fontWeight: 700,
|
||||
fontFamily: "'JetBrains Mono', ui-monospace",
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.15em',
|
||||
padding: '0 12px'
|
||||
}}>or</span>
|
||||
<div style={{ flex: 1, height: '1px', background: '#1a1a1a' }} />
|
||||
</div>
|
||||
|
||||
{/* Quick Add Section */}
|
||||
<div style={{
|
||||
background: 'transparent',
|
||||
padding: '0',
|
||||
width: '100%',
|
||||
maxWidth: '600px',
|
||||
marginBottom: '12px'
|
||||
}}>
|
||||
<QuickAddInput
|
||||
activeDelegations={orderedDelegations}
|
||||
onSubmit={handleQuickAddSubmit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Tab Bar (only show in session mode) */}
|
||||
{mode === 'session' && (
|
||||
<div className="agent-tabs" style={{ position: 'relative' }}>
|
||||
{/* Quick Add button - positioned at far right */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
right: '12px',
|
||||
transform: 'translateY(-50%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
zIndex: 20
|
||||
}}>
|
||||
<button
|
||||
onClick={() => {
|
||||
window.dispatchEvent(new CustomEvent('rah:switch-quickadd'));
|
||||
}}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#fff',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
padding: '4px 8px',
|
||||
transition: 'color 0.2s',
|
||||
fontFamily: 'inherit',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.color = '#d0d0d0';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = '#fff';
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
borderRadius: '50%',
|
||||
background: '#fff',
|
||||
color: '#0a0a0a',
|
||||
fontSize: '12px',
|
||||
lineHeight: 1,
|
||||
fontWeight: 300,
|
||||
flexShrink: 0
|
||||
}}>+</span>
|
||||
Quick Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* RA-H Main Tab */}
|
||||
<button
|
||||
onClick={() => setActiveAgentTab('ra-h')}
|
||||
className={`agent-tab ${activeAgentTab === 'ra-h' ? 'active' : ''}`}
|
||||
>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '6px', color: rahMode === 'easy' ? '#22c55e' : '#f97316' }}>
|
||||
{(rahMode === 'easy' ? <Zap size={12} strokeWidth={2.4} /> : <Flame size={12} strokeWidth={2.4} />)}
|
||||
<span>RA-H · {rahMode === 'easy' ? 'Easy' : 'Hard'}</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Delegation Tabs */}
|
||||
{orderedDelegations.map((delegation) => {
|
||||
const isActive = activeAgentTab === delegation.sessionId;
|
||||
const isWiseRAH = delegation.agentType === 'wise-rah';
|
||||
|
||||
// Status colors based on agent type
|
||||
const statusColor = isWiseRAH
|
||||
? (delegation.status === 'in_progress' ? '#8b5cf6' :
|
||||
delegation.status === 'completed' ? '#6b6b6b' :
|
||||
delegation.status === 'failed' ? '#ff6b6b' : '#a78bfa')
|
||||
: (delegation.status === 'in_progress' ? '#8bd450' :
|
||||
delegation.status === 'completed' ? '#6b6b6b' :
|
||||
delegation.status === 'failed' ? '#ff6b6b' : '#5c9aff');
|
||||
|
||||
const shortId = delegation.sessionId.split('_').pop() || '';
|
||||
const tabLabel = isWiseRAH
|
||||
? `WISE RA-H · ${shortId.slice(0, 6)}`
|
||||
: `MINI · ${shortId.slice(0, 6)}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={delegation.sessionId}
|
||||
className={`agent-tab-wrapper ${isWiseRAH ? 'wise' : 'mini'} ${isActive ? 'active' : ''}`}
|
||||
>
|
||||
<button
|
||||
onClick={() => setActiveAgentTab(delegation.sessionId)}
|
||||
className="agent-tab"
|
||||
>
|
||||
<span className="status-dot" style={{ background: statusColor }} />
|
||||
<span className="agent-tab-label">{tabLabel}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
console.log(`[AgentsPanel] User clicked close button for delegation ${delegation.sessionId}`);
|
||||
|
||||
// Delete from database
|
||||
try {
|
||||
await fetch(`/api/rah/delegations/${delegation.sessionId}`, { method: 'DELETE' });
|
||||
} catch (error) {
|
||||
console.error(`Failed to delete delegation ${delegation.sessionId}:`, error);
|
||||
}
|
||||
|
||||
// Remove from UI state
|
||||
setDelegationsMap((prev) => {
|
||||
const { [delegation.sessionId]: _ignored, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
setDelegationMessages((prev) => {
|
||||
const { [delegation.sessionId]: _ignored, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
if (activeAgentTab === delegation.sessionId) {
|
||||
setActiveAgentTab('ra-h');
|
||||
}
|
||||
}}
|
||||
className="agent-tab-close"
|
||||
title="Close tab"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active Panel */}
|
||||
<div style={{ flex: 1, minHeight: 0, position: 'relative', overflow: 'hidden' }}>
|
||||
{mode === 'quickadd' ? (
|
||||
<div style={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<QuickAddStatus
|
||||
delegations={orderedDelegations}
|
||||
onDelegationClick={handleDelegationClick}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Keep RAHChat always mounted, just hide when not active */}
|
||||
<div style={{
|
||||
height: '100%',
|
||||
display: activeAgentTab === 'ra-h' ? 'block' : 'none'
|
||||
}}>
|
||||
<RAHChat
|
||||
openTabsData={openTabsData}
|
||||
activeTabId={activeTabId}
|
||||
onNodeClick={onNodeClick}
|
||||
delegations={orderedDelegations}
|
||||
messages={rahMessages}
|
||||
setMessages={setRahMessages}
|
||||
mode={rahMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Show delegation chat when delegation tab is active */}
|
||||
{selectedDelegation && activeAgentTab !== 'ra-h' && (
|
||||
<div style={{ height: '100%', display: 'block' }}>
|
||||
<RAHChat
|
||||
openTabsData={openTabsData}
|
||||
activeTabId={activeTabId}
|
||||
onNodeClick={onNodeClick}
|
||||
delegations={orderedDelegations}
|
||||
messages={getDelegationMessages(selectedDelegation.sessionId)}
|
||||
setMessages={setDelegationMessagesFor(selectedDelegation.sessionId)}
|
||||
mode="easy"
|
||||
delegationMode={true}
|
||||
delegationSessionId={selectedDelegation.sessionId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<style jsx>{`
|
||||
.agent-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px 0 12px;
|
||||
background: #0a0a0a;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.agent-tabs::-webkit-scrollbar {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.agent-tabs::-webkit-scrollbar-track {
|
||||
background: #0d0d0d;
|
||||
}
|
||||
|
||||
.agent-tabs::-webkit-scrollbar-thumb {
|
||||
background: #1f1f1f;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.agent-tabs::-webkit-scrollbar-thumb:hover {
|
||||
background: #2c2c2c;
|
||||
}
|
||||
|
||||
.agent-tab-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
border-radius: 10px 10px 0 0;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.agent-tab-wrapper.active {
|
||||
background: #141414;
|
||||
}
|
||||
|
||||
.agent-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 9px 14px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: #6f6f6f;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.agent-tab-wrapper.active .agent-tab {
|
||||
color: #f3f3f3;
|
||||
}
|
||||
|
||||
.agent-tab:hover {
|
||||
color: #d0d0d0;
|
||||
}
|
||||
|
||||
.agent-tab-wrapper.wise .agent-tab-label {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.agent-tab-wrapper.mini .agent-tab-label {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.agent-tab-close {
|
||||
padding: 4px 8px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
font-size: 14px;
|
||||
color: #5a5a5a;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.agent-tab-close:hover {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
interface AsciiBannerProps {
|
||||
helperName: string;
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
export default function AsciiBanner({ helperName, displayName }: AsciiBannerProps) {
|
||||
const name = displayName || helperName;
|
||||
const nameUpper = name.toUpperCase();
|
||||
|
||||
// Create a unique minimal banner
|
||||
const createBanner = () => {
|
||||
// Dynamic sizing based on name length
|
||||
const minWidth = 24;
|
||||
const bannerWidth = Math.max(minWidth, nameUpper.length + 6);
|
||||
const padding = Math.floor((bannerWidth - nameUpper.length - 2) / 2);
|
||||
const paddedName = ' '.repeat(padding) + nameUpper + ' '.repeat(bannerWidth - nameUpper.length - padding - 2);
|
||||
|
||||
const topBottom = '─'.repeat(bannerWidth - 2);
|
||||
|
||||
return `┌${topBottom}┐
|
||||
│${paddedName}│
|
||||
└${topBottom}┘`;
|
||||
};
|
||||
|
||||
const banner = createBanner();
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
padding: '32px 16px',
|
||||
fontFamily: 'inherit',
|
||||
opacity: 0,
|
||||
animation: 'fadeIn 0.5s ease-out forwards',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<pre style={{
|
||||
color: '#353535',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.3',
|
||||
whiteSpace: 'pre',
|
||||
margin: '0 auto',
|
||||
display: 'inline-block',
|
||||
letterSpacing: '0.05em'
|
||||
}}>
|
||||
{banner}
|
||||
</pre>
|
||||
|
||||
<div style={{
|
||||
marginTop: '12px',
|
||||
fontSize: '11px',
|
||||
letterSpacing: '0.15em',
|
||||
textTransform: 'uppercase'
|
||||
}}>
|
||||
<span style={{ color: '#2a2a2a' }}>━━━</span>
|
||||
<span style={{ color: '#353535', margin: '0 8px' }}>ready</span>
|
||||
<span style={{ color: '#2a2a2a' }}>━━━</span>
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
@keyframes fadeIn {
|
||||
to { opacity: 1; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { AgentDelegation } from '@/services/agents/delegation';
|
||||
|
||||
interface DelegationIndicatorProps {
|
||||
delegations: AgentDelegation[];
|
||||
}
|
||||
|
||||
export default function DelegationIndicator({ delegations }: DelegationIndicatorProps) {
|
||||
if (!delegations.length) return null;
|
||||
|
||||
const activeCount = delegations.filter((d) => d.status === 'queued' || d.status === 'in_progress').length;
|
||||
|
||||
if (activeCount === 0) return null;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '4px',
|
||||
background: '#0f0f0f',
|
||||
border: '1px solid #1a1a1a',
|
||||
fontSize: '10px',
|
||||
fontFamily: "'JetBrains Mono', ui-monospace",
|
||||
color: '#6b6b6b'
|
||||
}}>
|
||||
<span style={{
|
||||
width: '6px',
|
||||
height: '6px',
|
||||
borderRadius: '50%',
|
||||
background: '#22c55e'
|
||||
}} />
|
||||
<span>{activeCount} active</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import { ReactNode } from 'react';
|
||||
import type { AgentDelegation } from '@/services/agents/delegation';
|
||||
|
||||
interface MiniRAHPanelProps {
|
||||
delegation: AgentDelegation;
|
||||
onNodeClick?: (nodeId: number) => void;
|
||||
}
|
||||
|
||||
const statusPalette: Record<string, { border: string; badge: string }> = {
|
||||
queued: { border: '#1f3a5f', badge: '#5c9aff' },
|
||||
in_progress: { border: '#3b5f2a', badge: '#8bd450' },
|
||||
completed: { border: '#2a2a2a', badge: '#6b6b6b' },
|
||||
failed: { border: '#5f2a2a', badge: '#ff6b6b' },
|
||||
};
|
||||
|
||||
const NODE_LINK_REGEX = /\[NODE:(\d+):"([^"]+)"\]/g;
|
||||
|
||||
function formatStatus(status: string) {
|
||||
switch (status) {
|
||||
case 'queued':
|
||||
return 'Queued';
|
||||
case 'in_progress':
|
||||
return 'Working';
|
||||
case 'completed':
|
||||
return 'Completed';
|
||||
case 'failed':
|
||||
return 'Failed';
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
function renderNodeAwareLine(text: string, onNodeClick?: (nodeId: number) => void): ReactNode {
|
||||
const parts: ReactNode[] = [];
|
||||
let lastIndex = 0;
|
||||
|
||||
text.replace(NODE_LINK_REGEX, (match, id, title, offset) => {
|
||||
if (offset > lastIndex) {
|
||||
parts.push(<span key={`mini-text-${offset}`}>{text.slice(lastIndex, offset)}</span>);
|
||||
}
|
||||
|
||||
const nodeId = Number(id);
|
||||
const handleClick = () => {
|
||||
if (onNodeClick) onNodeClick(nodeId);
|
||||
};
|
||||
|
||||
parts.push(
|
||||
<button
|
||||
key={`mini-node-${offset}`}
|
||||
type="button"
|
||||
className="node-pill"
|
||||
onClick={handleClick}
|
||||
>
|
||||
<span className="node-pill-id">NODE:{nodeId}</span>
|
||||
<span className="node-pill-title">{title}</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
lastIndex = offset + match.length;
|
||||
return match;
|
||||
});
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(<span key="mini-text-end">{text.slice(lastIndex)}</span>);
|
||||
}
|
||||
|
||||
return <>{parts}</>;
|
||||
}
|
||||
|
||||
export default function MiniRAHPanel({ delegation, onNodeClick }: MiniRAHPanelProps) {
|
||||
const palette = statusPalette[delegation.status] ?? statusPalette.queued;
|
||||
const summaryLines = delegation.summary ? delegation.summary.split('\n').filter(Boolean) : [];
|
||||
|
||||
return (
|
||||
<div className="mini-panel">
|
||||
<header className="mini-header" style={{ borderBottomColor: palette.border }}>
|
||||
<span className="status-dot" style={{ background: palette.badge }} />
|
||||
<span className="header-title">MINI RA-H</span>
|
||||
<span className="header-status">· {formatStatus(delegation.status)}</span>
|
||||
<span className="header-time">{new Date(delegation.updatedAt).toLocaleTimeString()}</span>
|
||||
</header>
|
||||
|
||||
<section className="section">
|
||||
<h3>Task</h3>
|
||||
<p>{delegation.task}</p>
|
||||
</section>
|
||||
|
||||
{delegation.context.length > 0 && (
|
||||
<section className="section">
|
||||
<h3>Context</h3>
|
||||
<ul className="context-list">
|
||||
{delegation.context.map((item, idx) => (
|
||||
<li key={idx}>{renderNodeAwareLine(item, onNodeClick)}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{summaryLines.length > 0 && (
|
||||
<section className="section">
|
||||
<h3>Summary</h3>
|
||||
<div className="summary-card">
|
||||
{summaryLines.map((line, idx) => (
|
||||
<p key={idx}>{renderNodeAwareLine(line, onNodeClick)}</p>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<style jsx>{`
|
||||
.mini-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 24px;
|
||||
background: #0a0a0a;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.mini-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid rgba(91, 154, 255, 0.25);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.12em;
|
||||
color: #84c8ff;
|
||||
}
|
||||
|
||||
.header-status {
|
||||
font-size: 12px;
|
||||
color: #7da0b6;
|
||||
}
|
||||
|
||||
.header-time {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
color: #5f5f5f;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.section h3 {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: #5c9aff;
|
||||
}
|
||||
|
||||
.section p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: #d4d4d4;
|
||||
}
|
||||
|
||||
.context-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.context-list li {
|
||||
font-size: 13px;
|
||||
color: #9aa7b6;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
background: #101010;
|
||||
border: 1px solid rgba(92, 154, 255, 0.15);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.summary-card p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: #f0f0f0;
|
||||
}
|
||||
|
||||
.node-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 8px;
|
||||
margin: 0 4px 4px 0;
|
||||
font-size: 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(92, 154, 255, 0.35);
|
||||
background: rgba(92, 154, 255, 0.08);
|
||||
color: #cfe4ff;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, border 0.15s ease;
|
||||
}
|
||||
|
||||
.node-pill:hover {
|
||||
background: rgba(92, 154, 255, 0.18);
|
||||
border-color: rgba(92, 154, 255, 0.65);
|
||||
}
|
||||
|
||||
.node-pill-id {
|
||||
font-size: 11px;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.node-pill-title {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { AgentDelegation } from '@/services/agents/delegation';
|
||||
|
||||
type QuickAddIntent = 'link' | 'note' | 'chat';
|
||||
|
||||
interface QuickAddSubmitPayload {
|
||||
input: string;
|
||||
mode: QuickAddIntent;
|
||||
}
|
||||
|
||||
interface QuickAddInputProps {
|
||||
activeDelegations: AgentDelegation[];
|
||||
onSubmit: (payload: QuickAddSubmitPayload) => Promise<void>;
|
||||
}
|
||||
|
||||
const MODE_CONFIG: Array<{
|
||||
key: QuickAddIntent;
|
||||
label: string;
|
||||
hint: string;
|
||||
icon: ReactNode;
|
||||
}> = [
|
||||
{
|
||||
key: 'link',
|
||||
label: 'Link',
|
||||
hint: 'Drop URLs for auto YouTube/PDF/Web extraction',
|
||||
icon: (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.35l2.12-2.12a5 5 0 1 0-7.07-7.07l-1.29 1.29" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.35l-2.12 2.12a5 5 0 1 0 7.07 7.07l1.29-1.29" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'note',
|
||||
label: 'Note',
|
||||
hint: 'Quick jot — no extraction, no summary',
|
||||
icon: (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
|
||||
<path d="M12 20h9" strokeLinecap="round" />
|
||||
<path d="M12 4h9" strokeLinecap="round" />
|
||||
<path d="M4 4h1v16H4z" />
|
||||
<path d="M7 9h7" strokeLinecap="round" />
|
||||
<path d="M7 13h5" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'chat',
|
||||
label: 'Chat',
|
||||
hint: 'Paste messy transcripts — store raw text + summary',
|
||||
icon: (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H9l-4 4V5a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2z" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M7 8h10" strokeLinecap="round" />
|
||||
<path d="M7 12h6" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
export default function QuickAddInput({ activeDelegations, onSubmit }: QuickAddInputProps) {
|
||||
const [input, setInput] = useState('');
|
||||
const [isPosting, setIsPosting] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [manualMode, setManualMode] = useState<QuickAddIntent | null>(null);
|
||||
const [autoMode, setAutoMode] = useState<QuickAddIntent>('link');
|
||||
const [autoReason, setAutoReason] = useState<string | null>(null);
|
||||
|
||||
const effectiveMode: QuickAddIntent = manualMode ?? autoMode;
|
||||
|
||||
const currentPlaceholder = useMemo(() => {
|
||||
if (effectiveMode === 'note') {
|
||||
return 'Write a quick note — no extraction, just append to your graph';
|
||||
}
|
||||
if (effectiveMode === 'chat') {
|
||||
return 'Paste any conversation (ChatGPT, Claude, etc.) and we will store raw text + summary';
|
||||
}
|
||||
return "Drop links, URL's, ideas or notes to add new nodes";
|
||||
}, [effectiveMode]);
|
||||
|
||||
const maxConcurrent = 5;
|
||||
const activeCount = activeDelegations.filter(
|
||||
(d) => d.status === 'queued' || d.status === 'in_progress'
|
||||
).length;
|
||||
const isSoftLimited = activeCount >= maxConcurrent;
|
||||
|
||||
const inferChatIntent = (value: string) => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
setAutoMode('link');
|
||||
setAutoReason(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (manualMode) return;
|
||||
|
||||
const newlineCount = (trimmed.match(/\n/g)?.length ?? 0);
|
||||
const looksLikeTranscript =
|
||||
newlineCount >= 2 ||
|
||||
/You said:|ChatGPT said:|Claude said:|Assistant:|User:/i.test(trimmed) ||
|
||||
/\b\d{1,2}:\d{2}\b/.test(trimmed);
|
||||
|
||||
if (looksLikeTranscript && trimmed.length > 280) {
|
||||
setAutoMode('chat');
|
||||
setAutoReason('Detected chat transcript');
|
||||
} else {
|
||||
setAutoMode('link');
|
||||
setAutoReason(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (value: string) => {
|
||||
setInput(value);
|
||||
inferChatIntent(value);
|
||||
};
|
||||
|
||||
const handleModeClick = (mode: QuickAddIntent) => {
|
||||
setManualMode(mode);
|
||||
setAutoMode(mode);
|
||||
setAutoReason(null);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!input.trim() || isPosting || isSoftLimited) return;
|
||||
|
||||
setIsPosting(true);
|
||||
try {
|
||||
await onSubmit({ input: input.trim(), mode: effectiveMode });
|
||||
setInput('');
|
||||
setManualMode(null);
|
||||
setAutoMode('link');
|
||||
setAutoReason(null);
|
||||
} catch (error) {
|
||||
console.error('[QuickAddInput] Submit error:', error);
|
||||
} finally {
|
||||
setIsPosting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
const isLinkMode = effectiveMode === 'link';
|
||||
const shouldSubmit = isLinkMode
|
||||
? (e.key === 'Enter' && !e.shiftKey)
|
||||
: (e.key === 'Enter' && (e.metaKey || e.ctrlKey));
|
||||
|
||||
if (shouldSubmit) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
const renderInputField = () => {
|
||||
if (effectiveMode === 'link') {
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => handleInputChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={currentPlaceholder}
|
||||
disabled={isPosting || isSoftLimited}
|
||||
autoFocus
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '12px 16px',
|
||||
background: '#0a0a0a',
|
||||
border: '2px solid #22c55e',
|
||||
borderRadius: '6px',
|
||||
color: '#e5e5e5',
|
||||
fontSize: '13px',
|
||||
fontFamily: "'JetBrains Mono', ui-monospace",
|
||||
outline: 'none',
|
||||
transition: 'all 0.15s ease',
|
||||
boxShadow: '0 0 0 3px rgba(34, 197, 94, 0.15)'
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.borderColor = '#22c55e';
|
||||
e.currentTarget.style.boxShadow = '0 0 0 3px rgba(34, 197, 94, 0.25)';
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.borderColor = '#22c55e';
|
||||
e.currentTarget.style.boxShadow = '0 0 0 3px rgba(34, 197, 94, 0.15)';
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<textarea
|
||||
value={input}
|
||||
onChange={(e) => handleInputChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={currentPlaceholder}
|
||||
disabled={isPosting || isSoftLimited}
|
||||
autoFocus
|
||||
rows={effectiveMode === 'chat' ? 6 : 4}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '12px 16px',
|
||||
background: '#0a0a0a',
|
||||
border: '2px solid #22c55e',
|
||||
borderRadius: '6px',
|
||||
color: '#e5e5e5',
|
||||
fontSize: '13px',
|
||||
fontFamily: "'JetBrains Mono', ui-monospace",
|
||||
outline: 'none',
|
||||
transition: 'all 0.15s ease',
|
||||
resize: 'vertical',
|
||||
minHeight: effectiveMode === 'chat' ? '150px' : '110px',
|
||||
boxShadow: '0 0 0 3px rgba(34, 197, 94, 0.15)'
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.borderColor = '#22c55e';
|
||||
e.currentTarget.style.boxShadow = '0 0 0 3px rgba(34, 197, 94, 0.25)';
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.borderColor = '#22c55e';
|
||||
e.currentTarget.style.boxShadow = '0 0 0 3px rgba(34, 197, 94, 0.15)';
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '10px'
|
||||
}}>
|
||||
{!isExpanded ? (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '16px'
|
||||
}}>
|
||||
<div style={{
|
||||
color: '#6b6b6b',
|
||||
fontSize: '13px',
|
||||
fontFamily: "'JetBrains Mono', ui-monospace",
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
Quickly add stuff
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsExpanded(true)}
|
||||
style={{
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: '#22c55e',
|
||||
border: 'none',
|
||||
borderRadius: '50%',
|
||||
color: '#0a0a0a',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 150ms ease',
|
||||
fontSize: '28px',
|
||||
fontWeight: 300,
|
||||
lineHeight: 1
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1.05)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 16px rgba(34, 197, 94, 0.4)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
title="Add new content"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
<div style={{ display: 'flex', gap: '8px', justifyContent: 'center' }}>
|
||||
{MODE_CONFIG.map((mode) => {
|
||||
const isActive = effectiveMode === mode.key && (manualMode === mode.key || (!manualMode && autoMode === mode.key));
|
||||
return (
|
||||
<button
|
||||
key={mode.key}
|
||||
type="button"
|
||||
onClick={() => handleModeClick(mode.key)}
|
||||
title={mode.hint}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
padding: '6px 12px',
|
||||
borderRadius: '999px',
|
||||
border: `1px solid ${isActive ? '#22c55e' : '#1f1f1f'}`,
|
||||
background: isActive ? 'rgba(34, 197, 94, 0.15)' : 'transparent',
|
||||
color: isActive ? '#22c55e' : '#9c9c9c',
|
||||
fontSize: '11px',
|
||||
fontFamily: "'JetBrains Mono', ui-monospace",
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.08em',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease'
|
||||
}}
|
||||
>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}>{mode.icon}</span>
|
||||
{mode.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{autoReason && !manualMode && (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
color: '#9c9c9c',
|
||||
fontSize: '11px',
|
||||
letterSpacing: '0.05em',
|
||||
textTransform: 'uppercase'
|
||||
}}>
|
||||
Auto-detected chat transcript
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'stretch' }}>
|
||||
{renderInputField()}
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!input.trim() || isPosting || isSoftLimited}
|
||||
aria-label={isPosting ? 'Adding' : 'Add'}
|
||||
title={isPosting ? 'Adding…' : 'Add (Enter)'}
|
||||
style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: input.trim() && !isPosting && !isSoftLimited ? '#22c55e' : '#22c55e',
|
||||
border: `2px solid #22c55e`,
|
||||
borderRadius: '50%',
|
||||
color: input.trim() && !isPosting && !isSoftLimited ? '#0a0a0a' : '#0a0a0a',
|
||||
cursor: input.trim() && !isPosting && !isSoftLimited ? 'pointer' : 'not-allowed',
|
||||
transition: 'all 150ms ease',
|
||||
opacity: input.trim() && !isPosting && !isSoftLimited ? 1 : 0.7,
|
||||
flexShrink: 0
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (input.trim() && !isPosting && !isSoftLimited) {
|
||||
e.currentTarget.style.transform = 'translateY(-1px)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(34, 197, 94, 0.4)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (input.trim() && !isPosting && !isSoftLimited) {
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isPosting ? (
|
||||
<span style={{ fontSize: '12px' }}>•••</span>
|
||||
) : (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M12 4l-6.5 6.5 1.42 1.42L11 8.84V20h2V8.84l4.08 3.08 1.42-1.42L12 4z"/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active processing indicator */}
|
||||
{activeCount > 0 && (
|
||||
<div style={{
|
||||
padding: '10px 14px',
|
||||
background: '#0a1a0a',
|
||||
border: '1px solid #1a3a1a',
|
||||
borderRadius: '6px',
|
||||
color: '#22c55e',
|
||||
fontSize: '11px',
|
||||
fontFamily: "'JetBrains Mono', ui-monospace",
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}>
|
||||
<span style={{
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: '50%',
|
||||
background: '#22c55e',
|
||||
animation: 'pulse 2s ease-in-out infinite'
|
||||
}} />
|
||||
<span>
|
||||
{activeCount === 1
|
||||
? 'Adding 1 node to your database...'
|
||||
: `Adding ${activeCount} nodes to your database...`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isSoftLimited && (
|
||||
<div style={{
|
||||
padding: '10px 14px',
|
||||
background: '#1a0a00',
|
||||
border: '1px solid #3a2a1a',
|
||||
borderRadius: '6px',
|
||||
color: '#ff9b5c',
|
||||
fontSize: '11px',
|
||||
fontFamily: "'JetBrains Mono', ui-monospace",
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}>
|
||||
<span>⚠</span>
|
||||
<span>Finish one of the 5 active Quick Adds before adding more.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style jsx>{`
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
"use client";
|
||||
|
||||
import type { AgentDelegation } from '@/services/agents/delegation';
|
||||
|
||||
interface QuickAddStatusProps {
|
||||
delegations: AgentDelegation[];
|
||||
onDelegationClick: (sessionId: string) => void;
|
||||
}
|
||||
|
||||
export default function QuickAddStatus({ delegations, onDelegationClick }: QuickAddStatusProps) {
|
||||
const activeDelegations = delegations.filter(d => d.status === 'queued' || d.status === 'in_progress');
|
||||
const completedDelegations = delegations.filter(d => d.status === 'completed' || d.status === 'failed');
|
||||
|
||||
console.log('[QuickAddStatus] Rendering with', delegations.length, 'total delegations,', activeDelegations.length, 'active');
|
||||
|
||||
const handleClearCompleted = async () => {
|
||||
for (const delegation of completedDelegations) {
|
||||
try {
|
||||
await fetch(`/api/rah/delegations/${delegation.sessionId}`, { method: 'DELETE' });
|
||||
} catch (error) {
|
||||
console.error('Failed to delete delegation:', error);
|
||||
}
|
||||
}
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
padding: '12px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
overflowY: 'auto',
|
||||
height: '100%',
|
||||
background: '#0a0a0a'
|
||||
}}>
|
||||
{/* Header */}
|
||||
{delegations.length > 0 && (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '8px 12px',
|
||||
background: '#151515',
|
||||
borderRadius: '6px',
|
||||
color: '#a8a8a8',
|
||||
fontSize: '11px',
|
||||
fontFamily: "'JetBrains Mono', ui-monospace",
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
borderLeft: activeDelegations.length > 0 ? '3px solid #22c55e' : '3px solid #6b6b6b'
|
||||
}}>
|
||||
<span>
|
||||
{activeDelegations.length > 0
|
||||
? `Processing ${activeDelegations.length} Quick Add${activeDelegations.length > 1 ? 's' : ''}...`
|
||||
: `${delegations.length} Recent Quick Add${delegations.length > 1 ? 's' : ''}`
|
||||
}
|
||||
</span>
|
||||
{completedDelegations.length > 0 && (
|
||||
<button
|
||||
onClick={handleClearCompleted}
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
background: '#1a1a1a',
|
||||
border: '1px solid #2a2a2a',
|
||||
borderRadius: '4px',
|
||||
color: '#6b6b6b',
|
||||
fontSize: '10px',
|
||||
fontFamily: "'JetBrains Mono', ui-monospace",
|
||||
cursor: 'pointer',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
transition: 'all 0.15s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#222';
|
||||
e.currentTarget.style.color = '#a8a8a8';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = '#1a1a1a';
|
||||
e.currentTarget.style.color = '#6b6b6b';
|
||||
}}
|
||||
>
|
||||
Clear Completed
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{delegations.map((delegation) => {
|
||||
const statusColor = delegation.status === 'in_progress' ? '#22c55e' :
|
||||
delegation.status === 'completed' ? '#6b6b6b' :
|
||||
delegation.status === 'failed' ? '#ff6b6b' : '#5c9aff';
|
||||
|
||||
const statusLabel = delegation.status === 'in_progress' ? 'Processing' :
|
||||
delegation.status === 'completed' ? 'Done' :
|
||||
delegation.status === 'failed' ? 'Failed' : 'Queued';
|
||||
|
||||
return (
|
||||
<button
|
||||
key={delegation.sessionId}
|
||||
onClick={() => onDelegationClick(delegation.sessionId)}
|
||||
style={{
|
||||
padding: '12px',
|
||||
background: '#151515',
|
||||
border: '1px solid #2a2a2a',
|
||||
borderRadius: '6px',
|
||||
textAlign: 'left',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '6px'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#1a1a1a';
|
||||
e.currentTarget.style.borderColor = '#3a3a3a';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = '#151515';
|
||||
e.currentTarget.style.borderColor = '#2a2a2a';
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: '8px'
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}>
|
||||
<span style={{
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: '50%',
|
||||
background: statusColor,
|
||||
flexShrink: 0
|
||||
}} />
|
||||
<span style={{
|
||||
color: '#e5e5e5',
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
fontFamily: "'JetBrains Mono', ui-monospace",
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.04em'
|
||||
}}>
|
||||
{statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
<span style={{
|
||||
color: '#6b6b6b',
|
||||
fontSize: '11px',
|
||||
fontFamily: "'JetBrains Mono', ui-monospace"
|
||||
}}>
|
||||
{new Date(delegation.createdAt).toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
color: '#a8a8a8',
|
||||
fontSize: '12px',
|
||||
fontFamily: "'JetBrains Mono', ui-monospace",
|
||||
lineHeight: '1.5',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
}}>
|
||||
{delegation.task}
|
||||
</div>
|
||||
|
||||
{delegation.summary && delegation.status === 'completed' && (
|
||||
<div style={{
|
||||
color: '#6b6b6b',
|
||||
fontSize: '11px',
|
||||
fontFamily: "'JetBrains Mono', ui-monospace",
|
||||
lineHeight: '1.4',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
}}>
|
||||
{delegation.summary}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{delegation.summary && delegation.status === 'failed' && (
|
||||
<div style={{
|
||||
color: '#ff6b6b',
|
||||
fontSize: '11px',
|
||||
fontFamily: "'JetBrains Mono', ui-monospace",
|
||||
lineHeight: '1.4'
|
||||
}}>
|
||||
{delegation.summary}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,691 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Node } from '@/types/database';
|
||||
import AsciiBanner from './AsciiBanner';
|
||||
import TerminalMessage from './TerminalMessage';
|
||||
import TerminalInput from './TerminalInput';
|
||||
import { Zap, Flame } from 'lucide-react';
|
||||
import DelegationIndicator from './DelegationIndicator';
|
||||
import type { AgentDelegation } from '@/services/agents/delegation';
|
||||
import { useSSEChat, ChatMessage, MessageRole } from './hooks/useSSEChat';
|
||||
import { useQuotaHandler } from '@/hooks/useQuotaHandler';
|
||||
import { apiKeyService } from '@/services/storage/apiKeys';
|
||||
import { useVoiceSession } from './hooks/useVoiceSession';
|
||||
import { useAssistantTTS } from './hooks/useAssistantTTS';
|
||||
import { useRealtimeVoiceClient } from './hooks/useRealtimeVoiceClient';
|
||||
import { useVoiceInterruption } from './hooks/useVoiceInterruption';
|
||||
|
||||
const createSessionId = () => `session_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
const createVoiceRequestId = () =>
|
||||
typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
|
||||
? crypto.randomUUID()
|
||||
: `voice_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
interface RAHChatProps {
|
||||
openTabsData: Node[];
|
||||
activeTabId: number | null;
|
||||
onNodeClick?: (nodeId: number) => void;
|
||||
delegations?: AgentDelegation[];
|
||||
messages?: ChatMessage[];
|
||||
setMessages?: (updater: (prev: ChatMessage[]) => ChatMessage[]) => void;
|
||||
mode?: 'easy' | 'hard';
|
||||
delegationMode?: boolean;
|
||||
delegationSessionId?: string;
|
||||
}
|
||||
|
||||
export default function RAHChat({
|
||||
openTabsData,
|
||||
activeTabId,
|
||||
onNodeClick,
|
||||
delegations = [],
|
||||
messages: externalMessages,
|
||||
setMessages: externalSetMessages,
|
||||
mode = 'easy',
|
||||
delegationMode = false,
|
||||
delegationSessionId,
|
||||
}: RAHChatProps) {
|
||||
// Use external state if provided (lifted state), otherwise use local state
|
||||
const [internalMessages, internalSetMessages] = useState<ChatMessage[]>([]);
|
||||
const messages = externalMessages !== undefined ? externalMessages : internalMessages;
|
||||
const setMessages = externalSetMessages || internalSetMessages;
|
||||
|
||||
const [sessionId, setSessionId] = useState(() => createSessionId());
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const chatMode = mode === 'hard' ? 'hard' : 'easy';
|
||||
const helperKey = chatMode === 'hard' ? 'ra-h' : 'ra-h-easy';
|
||||
const helperDisplayName = helperKey === 'ra-h' ? 'ra-h (hard)' : 'ra-h (easy)';
|
||||
const {
|
||||
quotaError,
|
||||
handleAPIError: handleQuotaApiError,
|
||||
checkQuotaBeforeRequest,
|
||||
refetchUsage,
|
||||
isQuotaExceeded,
|
||||
} = useQuotaHandler();
|
||||
const streamCompleteHandlerRef = useRef<() => Promise<void> | void>(async () => {
|
||||
await refetchUsage();
|
||||
});
|
||||
const setMessagesRef = useRef(setMessages);
|
||||
const voice = useVoiceSession();
|
||||
const {
|
||||
isActive: isVoiceActive,
|
||||
amplitude: voiceAmplitude,
|
||||
startSession: startVoice,
|
||||
stopSession: stopVoice,
|
||||
resetTranscript: resetVoiceTranscript,
|
||||
setStatus: setVoiceStatus,
|
||||
setAmplitude: setVoiceAmplitude,
|
||||
setInterimTranscript,
|
||||
appendFinalTranscript,
|
||||
} = voice;
|
||||
const pendingVoiceQueueRef = useRef<{ text: string; queuedAt: number }[]>([]);
|
||||
const assistantSpeechMapRef = useRef<Map<string, string>>(new Map());
|
||||
const [voiceError, setVoiceError] = useState<string | null>(null);
|
||||
const voiceErrorHandledRef = useRef(false);
|
||||
const voiceStartTimestampRef = useRef<number | null>(null);
|
||||
|
||||
const handleVoiceError = useCallback((error: Error) => {
|
||||
console.error('[RAHChat] Voice error:', error);
|
||||
setVoiceError(error.message);
|
||||
if (isVoiceActive) {
|
||||
stopVoice();
|
||||
}
|
||||
resetVoiceTranscript();
|
||||
setVoiceAmplitude(0);
|
||||
setVoiceStatus('idle');
|
||||
pendingVoiceQueueRef.current = [];
|
||||
assistantSpeechMapRef.current.clear();
|
||||
}, [isVoiceActive, resetVoiceTranscript, setVoiceAmplitude, setVoiceStatus, stopVoice]);
|
||||
|
||||
const { speak: speakAssistantResponse, stop: stopAssistantTTS, status: ttsStatus } = useAssistantTTS({
|
||||
onSpeechStart: () => {
|
||||
if (isVoiceActive) {
|
||||
setVoiceStatus('speaking');
|
||||
}
|
||||
},
|
||||
onSpeechComplete: () => {
|
||||
setVoiceStatus(isVoiceActive ? 'listening' : 'idle');
|
||||
},
|
||||
onError: handleVoiceError,
|
||||
});
|
||||
|
||||
const handleVoiceInterruption = useCallback(() => {
|
||||
stopAssistantTTS();
|
||||
setVoiceStatus('listening');
|
||||
}, [setVoiceStatus, stopAssistantTTS]);
|
||||
|
||||
const sse = useSSEChat('/api/rah/chat', setMessages, {
|
||||
getAuthToken: () => null,
|
||||
beforeRequest: checkQuotaBeforeRequest,
|
||||
onRequestError: handleQuotaApiError,
|
||||
onStreamComplete: async () => {
|
||||
const handler = streamCompleteHandlerRef.current;
|
||||
if (handler) {
|
||||
await handler();
|
||||
}
|
||||
},
|
||||
getApiKeys: () => apiKeyService.getStoredKeys(),
|
||||
});
|
||||
|
||||
useVoiceInterruption({
|
||||
amplitude: voiceAmplitude,
|
||||
isVoiceActive,
|
||||
ttsStatus,
|
||||
onInterruption: handleVoiceInterruption,
|
||||
});
|
||||
|
||||
|
||||
const sendMessage = useCallback(async (text: string) => {
|
||||
if (delegationMode) return; // Delegation chats are read-only
|
||||
if (isQuotaExceeded) {
|
||||
checkQuotaBeforeRequest();
|
||||
return;
|
||||
}
|
||||
await sse.send({
|
||||
text,
|
||||
history: messages,
|
||||
openTabs: openTabsData,
|
||||
activeTabId,
|
||||
sessionId,
|
||||
mode: chatMode
|
||||
});
|
||||
}, [activeTabId, chatMode, checkQuotaBeforeRequest, delegationMode, isQuotaExceeded, messages, openTabsData, sse, sessionId]);
|
||||
|
||||
const handleVoiceFinalTranscript = useCallback(
|
||||
(raw: string) => {
|
||||
const normalized = raw.trim();
|
||||
console.info('[RAHVoice] Final transcript received:', normalized || '(empty)');
|
||||
setInterimTranscript('');
|
||||
if (!normalized) {
|
||||
console.info('[RAHVoice] Ignoring empty transcript');
|
||||
return;
|
||||
}
|
||||
appendFinalTranscript(normalized);
|
||||
if (sse.isLoading) {
|
||||
pendingVoiceQueueRef.current.push({ text: normalized, queuedAt: Date.now() });
|
||||
console.info('[RAHVoice] SSE busy, queueing transcript', {
|
||||
queuedCount: pendingVoiceQueueRef.current.length,
|
||||
});
|
||||
setVoiceStatus('thinking');
|
||||
return;
|
||||
}
|
||||
setVoiceStatus('thinking');
|
||||
console.info('[RAHVoice] Dispatching transcript to /api/rah/chat');
|
||||
void sendMessage(normalized);
|
||||
},
|
||||
[appendFinalTranscript, sendMessage, setInterimTranscript, setVoiceStatus, sse.isLoading]
|
||||
);
|
||||
|
||||
const voiceRealtime = useRealtimeVoiceClient(
|
||||
{
|
||||
onStatusChange: (status) => {
|
||||
if (!isVoiceActive && status !== 'idle') return;
|
||||
if (status === 'listening' && (sse.isLoading || pendingVoiceQueueRef.current.length > 0)) {
|
||||
setVoiceStatus('thinking');
|
||||
return;
|
||||
}
|
||||
setVoiceStatus(status);
|
||||
},
|
||||
onInterimTranscript: setInterimTranscript,
|
||||
onFinalTranscript: handleVoiceFinalTranscript,
|
||||
onAmplitude: setVoiceAmplitude,
|
||||
onError: handleVoiceError,
|
||||
},
|
||||
{
|
||||
getAuthToken: () => null,
|
||||
}
|
||||
);
|
||||
|
||||
const handleStreamComplete = useCallback(async () => {
|
||||
if (pendingVoiceQueueRef.current.length > 0) {
|
||||
console.info('[RAHVoice] SSE stream complete, draining queued transcripts', {
|
||||
queued: pendingVoiceQueueRef.current.length,
|
||||
});
|
||||
}
|
||||
while (pendingVoiceQueueRef.current.length > 0) {
|
||||
const nextQueued = pendingVoiceQueueRef.current.shift();
|
||||
if (!nextQueued) {
|
||||
break;
|
||||
}
|
||||
const queueLatency = Date.now() - nextQueued.queuedAt;
|
||||
setVoiceStatus('thinking');
|
||||
console.info('[RAHVoice] Dispatching queued transcript to /api/rah/chat', {
|
||||
queuedMs: queueLatency,
|
||||
});
|
||||
await sendMessage(nextQueued.text);
|
||||
}
|
||||
await refetchUsage();
|
||||
}, [sendMessage, setVoiceStatus, refetchUsage]);
|
||||
|
||||
useEffect(() => {
|
||||
streamCompleteHandlerRef.current = handleStreamComplete;
|
||||
}, [handleStreamComplete]);
|
||||
|
||||
useEffect(() => {
|
||||
setMessagesRef.current = setMessages;
|
||||
}, [setMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!voiceError) {
|
||||
voiceErrorHandledRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (voiceErrorHandledRef.current) return;
|
||||
voiceErrorHandledRef.current = true;
|
||||
voiceRealtime.stop();
|
||||
stopAssistantTTS();
|
||||
}, [voiceError, voiceRealtime, stopAssistantTTS]);
|
||||
|
||||
const focusSummary = useMemo(() => {
|
||||
if (!openTabsData.length) return null;
|
||||
const titles = openTabsData.map((node) => node?.title || 'Untitled');
|
||||
const activeNode = openTabsData.find((node) => node.id === activeTabId) || openTabsData[0];
|
||||
const truncate = (value: string, limit = 64) => {
|
||||
if (value.length <= limit) return value;
|
||||
return `${value.slice(0, limit - 1)}…`;
|
||||
};
|
||||
return {
|
||||
id: activeNode?.id ?? null,
|
||||
title: truncate(activeNode?.title || 'Untitled'),
|
||||
total: titles.length,
|
||||
};
|
||||
}, [openTabsData, activeTabId]);
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVoiceActive) {
|
||||
assistantSpeechMapRef.current.clear();
|
||||
stopAssistantTTS();
|
||||
return;
|
||||
}
|
||||
if (sse.isLoading) return;
|
||||
const assistantMessages = messages.filter((m) => m.role === MessageRole.ASSISTANT);
|
||||
if (!assistantMessages.length) return;
|
||||
const latest = assistantMessages[assistantMessages.length - 1];
|
||||
const spokenContent = assistantSpeechMapRef.current.get(latest.id);
|
||||
if (!latest.content.trim() || spokenContent === latest.content) return;
|
||||
assistantSpeechMapRef.current.set(latest.id, latest.content);
|
||||
const voiceRequestId = createVoiceRequestId();
|
||||
speakAssistantResponse(latest.content, {
|
||||
flush: true,
|
||||
metadata: {
|
||||
sessionId,
|
||||
helper: helperKey,
|
||||
requestId: voiceRequestId,
|
||||
messageId: latest.id,
|
||||
},
|
||||
});
|
||||
}, [helperKey, isVoiceActive, messages, sessionId, sse.isLoading, speakAssistantResponse, stopAssistantTTS]);
|
||||
|
||||
|
||||
const handleNewChat = () => {
|
||||
if (delegationMode) return;
|
||||
sse.abort();
|
||||
setMessages((_prev) => []);
|
||||
setSessionId(createSessionId());
|
||||
if (isVoiceActive) {
|
||||
stopVoice();
|
||||
resetVoiceTranscript();
|
||||
}
|
||||
};
|
||||
|
||||
// Subscribe to delegation stream if in delegation mode
|
||||
useEffect(() => {
|
||||
if (!delegationMode || !delegationSessionId) return;
|
||||
|
||||
const eventSource = new EventSource(`/api/rah/delegations/stream?sessionId=${delegationSessionId}`);
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.type === 'text-delta' && data.delta) {
|
||||
setMessagesRef.current((prev) => {
|
||||
const lastMsg = prev[prev.length - 1];
|
||||
if (lastMsg && lastMsg.role === MessageRole.ASSISTANT) {
|
||||
const updated = [...prev];
|
||||
updated[updated.length - 1] = { ...lastMsg, content: lastMsg.content + data.delta };
|
||||
return updated;
|
||||
}
|
||||
return [...prev, {
|
||||
id: crypto.randomUUID(),
|
||||
role: MessageRole.ASSISTANT,
|
||||
content: data.delta,
|
||||
timestamp: new Date()
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
if (data.type === 'tool-input-start' || data.type === 'tool-call') {
|
||||
const toolMessage: ChatMessage = {
|
||||
id: `tool-${data.toolCallId || crypto.randomUUID()}`,
|
||||
role: MessageRole.TOOL,
|
||||
content: data.toolName,
|
||||
timestamp: new Date(),
|
||||
toolName: data.toolName,
|
||||
status: (data.status as ChatMessage['status']) || 'running',
|
||||
toolArgs: data.input ?? data.args ?? data.parameters
|
||||
};
|
||||
setMessagesRef.current((prev) => [...prev, toolMessage]);
|
||||
}
|
||||
|
||||
if (data.type === 'tool-output-available' || data.type === 'tool-result') {
|
||||
setMessagesRef.current((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === `tool-${data.toolCallId}`
|
||||
? {
|
||||
...m,
|
||||
content: `${m.toolName} ${data.status === 'error' ? '✗' : '✓'}`,
|
||||
status: (data.status as ChatMessage['status']) || (data.error ? 'error' : 'complete'),
|
||||
toolResult: data.result ?? data.output ?? (data.summary ? { summary: data.summary } : undefined),
|
||||
}
|
||||
: m
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (data.type === 'assistant-message') {
|
||||
setMessagesRef.current((prev) => [...prev, {
|
||||
id: crypto.randomUUID(),
|
||||
role: MessageRole.ASSISTANT,
|
||||
content: '',
|
||||
timestamp: new Date()
|
||||
}]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[RAHChat] Failed to parse delegation stream event:', error);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = () => {
|
||||
console.error('[RAHChat] Delegation stream connection error');
|
||||
};
|
||||
|
||||
return () => {
|
||||
eventSource.close();
|
||||
};
|
||||
}, [delegationMode, delegationSessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (delegationMode && isVoiceActive) {
|
||||
voiceRealtime.stop();
|
||||
stopVoice();
|
||||
resetVoiceTranscript();
|
||||
stopAssistantTTS();
|
||||
}
|
||||
}, [delegationMode, isVoiceActive, resetVoiceTranscript, stopAssistantTTS, stopVoice, voiceRealtime]);
|
||||
|
||||
const handleVoiceToggle = useCallback(async () => {
|
||||
if (isVoiceActive) {
|
||||
voiceRealtime.stop();
|
||||
stopVoice();
|
||||
resetVoiceTranscript();
|
||||
setVoiceAmplitude(0);
|
||||
setVoiceStatus('idle');
|
||||
assistantSpeechMapRef.current.clear();
|
||||
pendingVoiceQueueRef.current = [];
|
||||
stopAssistantTTS();
|
||||
voiceStartTimestampRef.current = null;
|
||||
return;
|
||||
}
|
||||
setVoiceError(null);
|
||||
try {
|
||||
voiceStartTimestampRef.current = performance.now();
|
||||
console.info('[RAHVoice] Voice session starting');
|
||||
await voiceRealtime.start();
|
||||
startVoice();
|
||||
setVoiceStatus('listening');
|
||||
} catch (error) {
|
||||
voiceStartTimestampRef.current = null;
|
||||
handleVoiceError(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
}, [
|
||||
handleVoiceError,
|
||||
isVoiceActive,
|
||||
resetVoiceTranscript,
|
||||
setVoiceAmplitude,
|
||||
setVoiceStatus,
|
||||
setVoiceError,
|
||||
startVoice,
|
||||
stopAssistantTTS,
|
||||
stopVoice,
|
||||
voiceRealtime,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: '#0a0a0a'
|
||||
}}>
|
||||
{focusSummary && (
|
||||
<header style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '8px 16px',
|
||||
borderBottom: '1px solid #1a1a1a',
|
||||
background: '#0a0a0a'
|
||||
}}>
|
||||
{/* Focused node info */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', minWidth: 0, flex: 1 }}>
|
||||
<span style={{
|
||||
color: '#22c55e',
|
||||
fontSize: '10px',
|
||||
letterSpacing: '0.18em',
|
||||
textTransform: 'uppercase'
|
||||
}}>
|
||||
Focused Node ({focusSummary.total})
|
||||
</span>
|
||||
<span style={{ color: '#d0d0d0', fontSize: '10px' }}>#{focusSummary.id}</span>
|
||||
<span
|
||||
style={{
|
||||
color: '#f3f3f3',
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis'
|
||||
}}
|
||||
title={focusSummary.title}
|
||||
>
|
||||
{focusSummary.title}
|
||||
</span>
|
||||
</div>
|
||||
<DelegationIndicator delegations={delegations} />
|
||||
</header>
|
||||
)}
|
||||
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: '16px', background: '#0a0a0a' }}>
|
||||
{messages.length === 0 ? (
|
||||
<AsciiBanner helperName="ra-h" displayName={helperDisplayName} />
|
||||
) : (
|
||||
<>
|
||||
{messages.map((message) => (
|
||||
<TerminalMessage
|
||||
key={message.id}
|
||||
role={message.role}
|
||||
content={message.content}
|
||||
timestamp={message.timestamp}
|
||||
toolName={message.toolName}
|
||||
status={message.status}
|
||||
toolArgs={message.toolArgs}
|
||||
toolResult={message.toolResult}
|
||||
onNodeClick={onNodeClick}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{/* Voice transcript preview removed for streamlined UI */}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{!delegationMode && (
|
||||
<div style={{
|
||||
padding: '0 16px 16px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px'
|
||||
}}>
|
||||
{(quotaError || isQuotaExceeded) && (
|
||||
<div style={{
|
||||
background: 'rgba(239,68,68,0.12)',
|
||||
border: '1px solid rgba(239,68,68,0.35)',
|
||||
color: '#fca5a5',
|
||||
fontSize: '11px',
|
||||
padding: '10px 12px',
|
||||
borderRadius: '6px',
|
||||
lineHeight: 1.4
|
||||
}}>
|
||||
{quotaError?.message ?? 'Your monthly allowance has been used. Upgrade your plan to keep chatting with RA-H.'}
|
||||
</div>
|
||||
)}
|
||||
<TerminalInput
|
||||
onSubmit={sendMessage}
|
||||
isProcessing={sse.isLoading || isQuotaExceeded}
|
||||
placeholder={isVoiceActive ? 'voice mode active — end session to type…' : `ask ${helperDisplayName}...`}
|
||||
helperId={activeTabId ?? undefined}
|
||||
disabledExternally={isVoiceActive}
|
||||
disabledMessage="voice mode active"
|
||||
onVoiceToggle={handleVoiceToggle}
|
||||
isVoiceActive={isVoiceActive}
|
||||
voiceAmplitude={voiceAmplitude}
|
||||
voiceError={voiceError || undefined}
|
||||
/>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: '12px',
|
||||
flexWrap: 'wrap'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', flexWrap: 'wrap' }}>
|
||||
<ModelSelector chatMode={chatMode} />
|
||||
</div>
|
||||
<button
|
||||
onClick={handleNewChat}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#fff',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
padding: '4px 8px',
|
||||
transition: 'color 0.2s',
|
||||
fontFamily: 'inherit',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.color = '#d0d0d0';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = '#fff';
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
borderRadius: '50%',
|
||||
background: '#fff',
|
||||
color: '#0a0a0a',
|
||||
fontSize: '12px',
|
||||
lineHeight: 1,
|
||||
fontWeight: 300,
|
||||
flexShrink: 0
|
||||
}}>+</span>
|
||||
New Chat
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ModelSelectorProps {
|
||||
chatMode: 'easy' | 'hard';
|
||||
}
|
||||
|
||||
function ModelSelector({ chatMode }: ModelSelectorProps) {
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
|
||||
const currentModel = chatMode === 'easy' ? 'Easy (GPT)' : 'Hard (Claude)';
|
||||
const Icon = chatMode === 'easy' ? Zap : Flame;
|
||||
const activeColor = chatMode === 'easy' ? '#22c55e' : '#f97316';
|
||||
|
||||
const options = [
|
||||
{ id: 'easy', label: 'Easy (GPT)', icon: Zap, color: '#22c55e' },
|
||||
{ id: 'hard', label: 'Hard (Claude)', icon: Flame, color: '#f97316' },
|
||||
{ id: 'soon', label: 'Ra-h (Soon)', icon: null, color: '#666', disabled: true }
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button
|
||||
onClick={() => setDropdownOpen(!dropdownOpen)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #1a1a1a',
|
||||
borderRadius: '6px',
|
||||
background: '#0f0f0f',
|
||||
color: '#e5e5e5',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = '#333';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = '#1a1a1a';
|
||||
}}
|
||||
>
|
||||
<Icon size={12} strokeWidth={2.4} color={activeColor} />
|
||||
{currentModel}
|
||||
<span style={{
|
||||
marginLeft: '4px',
|
||||
transform: dropdownOpen ? 'rotate(180deg)' : 'rotate(0deg)',
|
||||
transition: 'transform 0.2s'
|
||||
}}>▲</span>
|
||||
</button>
|
||||
|
||||
{dropdownOpen && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: '100%', /* Open upward */
|
||||
left: '0',
|
||||
marginBottom: '4px',
|
||||
background: '#1a1a1a',
|
||||
border: '1px solid #333',
|
||||
borderRadius: '6px',
|
||||
minWidth: '150px',
|
||||
zIndex: 1000,
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.4)'
|
||||
}}>
|
||||
{options.map((option) => {
|
||||
const OptionIcon = option.icon;
|
||||
const isActive = (option.id === 'easy' && chatMode === 'easy') || (option.id === 'hard' && chatMode === 'hard');
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
onClick={() => {
|
||||
if (!option.disabled && !isActive) {
|
||||
window.dispatchEvent(new CustomEvent('rah:mode-toggle', { detail: { mode: option.id as 'easy' | 'hard' } }));
|
||||
}
|
||||
setDropdownOpen(false);
|
||||
}}
|
||||
disabled={option.disabled}
|
||||
style={{
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '8px 12px',
|
||||
border: 'none',
|
||||
background: isActive ? 'rgba(34, 197, 94, 0.1)' : 'transparent',
|
||||
color: option.disabled ? '#666' : (isActive ? '#22c55e' : '#e5e5e5'),
|
||||
fontSize: '12px',
|
||||
cursor: option.disabled ? 'not-allowed' : 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
borderRadius: '4px'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!option.disabled && !isActive) {
|
||||
e.currentTarget.style.background = '#0a0a0a';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!option.disabled && !isActive) {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
}
|
||||
}}
|
||||
>
|
||||
{OptionIcon && <OptionIcon size={12} strokeWidth={2} color={option.color} />}
|
||||
{!OptionIcon && <div style={{ width: '12px' }} />} {/* Spacer for alignment */}
|
||||
{option.label}
|
||||
{isActive && <span style={{ marginLeft: 'auto', color: '#22c55e' }}>✓</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Mic, MicOff } from 'lucide-react';
|
||||
|
||||
interface TerminalInputProps {
|
||||
onSubmit: (text: string) => void;
|
||||
isProcessing: boolean;
|
||||
placeholder?: string;
|
||||
helperId?: number;
|
||||
disabledExternally?: boolean;
|
||||
disabledMessage?: string;
|
||||
onVoiceToggle?: () => void;
|
||||
isVoiceActive?: boolean;
|
||||
voiceAmplitude?: number;
|
||||
voiceError?: string | null;
|
||||
}
|
||||
|
||||
export default function TerminalInput({
|
||||
onSubmit,
|
||||
isProcessing,
|
||||
placeholder,
|
||||
helperId,
|
||||
disabledExternally = false,
|
||||
disabledMessage,
|
||||
onVoiceToggle,
|
||||
isVoiceActive = false,
|
||||
voiceAmplitude = 0,
|
||||
voiceError,
|
||||
}: TerminalInputProps) {
|
||||
const [input, setInput] = useState('');
|
||||
const [rows, setRows] = useState(1);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [prompts, setPrompts] = useState<Array<{ id: string; name: string; content: string }>>([]);
|
||||
const [showSlashMenu, setShowSlashMenu] = useState(false);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
if (!helperId) return;
|
||||
try {
|
||||
const resp = await fetch(`/api/helpers/${helperId}/prompts`);
|
||||
const data = await resp.json();
|
||||
if (resp.ok && data.success) setPrompts(Array.isArray(data.data?.prompts) ? data.data.prompts : []);
|
||||
} catch (e) {
|
||||
console.error('Failed to load prompts:', e);
|
||||
}
|
||||
};
|
||||
load();
|
||||
}, [helperId]);
|
||||
|
||||
// Auto-resize textarea
|
||||
useEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto';
|
||||
const scrollHeight = textareaRef.current.scrollHeight;
|
||||
const lineHeight = 20; // Approximate line height
|
||||
const newRows = Math.min(Math.max(1, Math.floor(scrollHeight / lineHeight)), 5);
|
||||
setRows(newRows);
|
||||
textareaRef.current.style.height = `${scrollHeight}px`;
|
||||
}
|
||||
}, [input]);
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (input.trim() && !isProcessing && !disabledExternally) {
|
||||
// Numeric slash expansion: only when input is exactly /N
|
||||
const m = input.trim().match(/^\/(\d{1,2})$/);
|
||||
if (m) {
|
||||
const n = parseInt(m[1], 10);
|
||||
const idx = n - 1;
|
||||
if (!isNaN(idx) && idx >= 0 && idx < prompts.length) {
|
||||
const content = String(prompts[idx]?.content || '').trim();
|
||||
if (content) {
|
||||
onSubmit(content);
|
||||
setInput('');
|
||||
setRows(1);
|
||||
setShowSlashMenu(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
onSubmit(input.trim());
|
||||
setInput('');
|
||||
setRows(1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
// Slash menu navigation
|
||||
if (showSlashMenu) {
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); setActiveIndex(i => Math.min(i + 1, prompts.length - 1)); return; }
|
||||
if (e.key === 'ArrowUp') { e.preventDefault(); setActiveIndex(i => Math.max(i - 1, 0)); return; }
|
||||
if (e.key === 'Tab') { e.preventDefault(); setActiveIndex(i => (i + 1) % Math.max(prompts.length, 1)); return; }
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const p = prompts[activeIndex];
|
||||
if (p) { setInput(p.content); setShowSlashMenu(false); }
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape') { setShowSlashMenu(false); }
|
||||
}
|
||||
if (e.key === 'Enter' && !e.shiftKey && !disabledExternally) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Toggle slash menu when typing starting '/'
|
||||
const trimmed = input.trimStart();
|
||||
if (trimmed.startsWith('/')) {
|
||||
setShowSlashMenu(true);
|
||||
setActiveIndex(0);
|
||||
} else {
|
||||
setShowSlashMenu(false);
|
||||
}
|
||||
}, [input]);
|
||||
|
||||
const trimmedInput = input.trim();
|
||||
const showVoiceStart = !isVoiceActive && !trimmedInput && Boolean(onVoiceToggle);
|
||||
const showVoiceStop = Boolean(onVoiceToggle) && isVoiceActive;
|
||||
const buttonIsDisabled =
|
||||
showVoiceStart || showVoiceStop
|
||||
? false
|
||||
: (!trimmedInput || isProcessing || disabledExternally);
|
||||
|
||||
const handlePrimaryAction = () => {
|
||||
if (showVoiceStart || showVoiceStop) {
|
||||
onVoiceToggle?.();
|
||||
return;
|
||||
}
|
||||
handleSubmit();
|
||||
};
|
||||
|
||||
const amplitudeBars = Array.from({ length: 8 });
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
@keyframes subtle-pulse {
|
||||
0%, 100% { opacity: 0.5; color: #3a3a3a; }
|
||||
50% { opacity: 0.7; color: #22c55e; }
|
||||
}
|
||||
textarea::placeholder {
|
||||
animation: subtle-pulse 4s ease-in-out infinite;
|
||||
}
|
||||
`}</style>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: '8px',
|
||||
padding: '8px 16px 12px',
|
||||
background: 'transparent',
|
||||
// Remove separator/border between chat area and input
|
||||
borderTop: 'none',
|
||||
fontFamily: 'inherit'
|
||||
}}>
|
||||
{/* Terminal Prompt Symbol */}
|
||||
<span style={{
|
||||
color: '#4a4a4a',
|
||||
fontSize: '13px',
|
||||
lineHeight: '1.5',
|
||||
paddingTop: '6px',
|
||||
userSelect: 'none',
|
||||
fontWeight: 500
|
||||
}}>
|
||||
{'>'}
|
||||
</span>
|
||||
|
||||
{/* Input Wrapper */}
|
||||
<div style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '4px'
|
||||
}}>
|
||||
{isVoiceActive && (
|
||||
<div style={{
|
||||
border: 'none',
|
||||
borderRadius: '0',
|
||||
background: 'transparent',
|
||||
padding: '12px 4px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<span style={{
|
||||
fontSize: '12px',
|
||||
letterSpacing: '0.08em',
|
||||
textTransform: 'uppercase',
|
||||
color: '#d4d4d4',
|
||||
}}>
|
||||
RA-H is listening
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(24, minmax(0, 1fr))', gap: '3px', height: '20px', marginTop: '6px' }}>
|
||||
{amplitudeBars.map((_, index) => {
|
||||
const level = (index + 1) / amplitudeBars.length;
|
||||
const active = voiceAmplitude >= level - 0.0001;
|
||||
return (
|
||||
<div
|
||||
key={`amp-${index}`}
|
||||
style={{
|
||||
width: '100%',
|
||||
borderRadius: '2px',
|
||||
height: `${10 + index * 1.2}px`,
|
||||
background: active ? '#22c55e' : '#1f1f1f',
|
||||
transition: 'background 120ms ease',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{voiceError && (
|
||||
<span style={{ color: '#f87171', fontSize: '11px' }}>{voiceError}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Input Row with Textarea and Button */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
alignItems: 'flex-start',
|
||||
position: 'relative'
|
||||
}}>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={isProcessing || disabledExternally}
|
||||
placeholder={placeholder || `ask ra-h...`}
|
||||
rows={rows}
|
||||
style={{
|
||||
flex: 1,
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
borderRadius: '0',
|
||||
color: '#e5e5e5',
|
||||
fontSize: '16px',
|
||||
fontFamily: 'inherit',
|
||||
padding: '8px 4px',
|
||||
resize: 'none',
|
||||
outline: 'none',
|
||||
lineHeight: '1.5',
|
||||
transition: 'all 200ms ease',
|
||||
minHeight: '32px',
|
||||
maxHeight: '120px',
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
caretColor: '#22c55e',
|
||||
...((isProcessing || disabledExternally) && {
|
||||
opacity: 0.5,
|
||||
cursor: 'not-allowed',
|
||||
caretColor: 'transparent'
|
||||
})
|
||||
}}
|
||||
// No focus border toggling; keep clean
|
||||
onFocus={() => {}}
|
||||
onBlur={() => {}}
|
||||
/>
|
||||
|
||||
{/* Slash menu */}
|
||||
{showSlashMenu && prompts.length > 0 && (
|
||||
<div style={{ position: 'absolute', bottom: '48px', left: '40px', background: '#0f0f0f', border: '1px solid #2a2a2a', borderRadius: '4px', padding: '6px', minWidth: '260px', maxHeight: '200px', overflowY: 'auto', zIndex: 1000 }}>
|
||||
{prompts.map((p, i) => (
|
||||
<div
|
||||
key={p.id}
|
||||
onMouseDown={(e) => { e.preventDefault(); setInput(p.content); setShowSlashMenu(false); }}
|
||||
onMouseEnter={() => setActiveIndex(i)}
|
||||
style={{ display: 'flex', gap: '8px', alignItems: 'center', padding: '6px 8px', cursor: 'pointer', background: i === activeIndex ? '#1a1a1a' : 'transparent', color: '#cfcfcf', fontSize: '12px' }}
|
||||
>
|
||||
<span style={{ color: '#5c9aff', fontSize: '10px', width: '20px' }}>/ {i + 1}</span>
|
||||
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{p.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit Button (minimal icon) */}
|
||||
<button
|
||||
onClick={handlePrimaryAction}
|
||||
disabled={buttonIsDisabled}
|
||||
aria-label={
|
||||
showVoiceStop
|
||||
? 'Stop voice session'
|
||||
: showVoiceStart
|
||||
? 'Start voice session'
|
||||
: isProcessing
|
||||
? 'Processing'
|
||||
: 'Send message'
|
||||
}
|
||||
title={
|
||||
showVoiceStop
|
||||
? 'Stop voice session'
|
||||
: showVoiceStart
|
||||
? 'Start voice session'
|
||||
: disabledExternally
|
||||
? (disabledMessage || 'Voice mode active')
|
||||
: isProcessing
|
||||
? 'Processing…'
|
||||
: 'Send (Enter)'
|
||||
}
|
||||
style={{
|
||||
width: '36px',
|
||||
height: '36px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: '#22c55e',
|
||||
border: '2px solid #22c55e',
|
||||
borderRadius: '50%',
|
||||
color: '#0a0a0a',
|
||||
cursor: buttonIsDisabled ? 'not-allowed' : 'pointer',
|
||||
transition: 'all 150ms ease',
|
||||
opacity: buttonIsDisabled ? 0.5 : 1,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!buttonIsDisabled) {
|
||||
e.currentTarget.style.transform = 'translateY(-1px)';
|
||||
e.currentTarget.style.boxShadow = `0 4px 12px rgba(${showVoiceStart || showVoiceStop ? '124,58,237' : '34,197,94'}, 0.4)`;
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!buttonIsDisabled) {
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}
|
||||
}}
|
||||
>
|
||||
{showVoiceStop ? (
|
||||
<MicOff size={16} strokeWidth={2.4} color="#0a0a0a" />
|
||||
) : showVoiceStart ? (
|
||||
<Mic size={16} strokeWidth={2.4} color="#0a0a0a" />
|
||||
) : isProcessing ? (
|
||||
<span style={{ fontSize: '12px' }}>•••</span>
|
||||
) : (
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M12 4l-6.5 6.5 1.42 1.42L11 8.84V20h2V8.84l4.08 3.08 1.42-1.42L12 4z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Subtle Keyboard Hints */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '10px',
|
||||
fontSize: '10px',
|
||||
color: '#353535',
|
||||
userSelect: 'none',
|
||||
marginTop: '2px'
|
||||
}}>
|
||||
<span>⏎ send</span>
|
||||
<span>⇧⏎ newline</span>
|
||||
{(isProcessing || disabledExternally) && (
|
||||
<span style={{
|
||||
marginLeft: 'auto',
|
||||
color: disabledExternally ? '#a855f7' : '#ffcc66',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.12em'
|
||||
}}>
|
||||
{disabledExternally ? (disabledMessage || 'voice mode active') : 'processing...'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { parseAndRenderContent } from '@/components/helpers/NodeLabelRenderer';
|
||||
import ToolDisplay from '@/components/helpers/ToolDisplay';
|
||||
import MarkdownRenderer from '@/components/helpers/MarkdownRenderer';
|
||||
import ReasoningTrace from '@/components/helpers/ReasoningTrace';
|
||||
import { extractToolContext, extractSources } from '@/utils/toolFormatting';
|
||||
|
||||
interface TerminalMessageProps {
|
||||
role: 'user' | 'assistant' | 'system' | 'tool' | 'thinking';
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
toolName?: string;
|
||||
status?: 'sending' | 'delivered' | 'error' | 'processing' | 'starting' | 'running' | 'complete';
|
||||
toolArgs?: any;
|
||||
toolResult?: any;
|
||||
onNodeClick?: (nodeId: number) => void;
|
||||
}
|
||||
|
||||
// no local state needed
|
||||
|
||||
export default function TerminalMessage({
|
||||
role,
|
||||
content,
|
||||
timestamp,
|
||||
toolName,
|
||||
status = 'delivered',
|
||||
toolArgs,
|
||||
toolResult,
|
||||
onNodeClick
|
||||
}: TerminalMessageProps) {
|
||||
|
||||
const dotColor = useMemo(() => {
|
||||
const colors = {
|
||||
user: '#5c9aff',
|
||||
assistant: '#52d97a',
|
||||
tool: '#ffcc66',
|
||||
thinking: '#b794f6',
|
||||
system: '#69d2e7'
|
||||
};
|
||||
return colors[role] || colors.assistant;
|
||||
}, [role]);
|
||||
|
||||
const isAnimated = role === 'thinking' || status === 'processing';
|
||||
|
||||
const formatTime = (date: Date) => {
|
||||
return date.toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
}).toLowerCase();
|
||||
};
|
||||
|
||||
// Handle tool messages specially
|
||||
if (role === 'tool') {
|
||||
// THINK tool: display structured trace when present in toolResult
|
||||
if (toolName === 'think') {
|
||||
const trace = toolResult?.data?.trace || null;
|
||||
return <ReasoningTrace trace={trace} collapsible />;
|
||||
}
|
||||
|
||||
// Generic tool display with context and sources
|
||||
const context =
|
||||
extractToolContext(toolName, toolArgs) ||
|
||||
(toolName === 'webSearch' && toolResult?.data?.query ? `Searching for: ${String(toolResult.data.query)}` : undefined);
|
||||
const sources = extractSources(toolName, toolResult);
|
||||
const normalizedStatus =
|
||||
status === 'processing' ? 'running' : status === 'delivered' ? 'complete' : ((status as any) || 'complete');
|
||||
return (
|
||||
<ToolDisplay
|
||||
name={toolName || 'tool'}
|
||||
status={normalizedStatus}
|
||||
context={context}
|
||||
sources={sources}
|
||||
result={toolResult}
|
||||
onNodeClick={onNodeClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '12px',
|
||||
padding: '8px 0',
|
||||
fontFamily: 'inherit'
|
||||
}}>
|
||||
{/* Status Dot */}
|
||||
<span
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
width: '6px',
|
||||
height: '6px',
|
||||
marginTop: '7px',
|
||||
borderRadius: '50%',
|
||||
background: dotColor,
|
||||
transition: 'all 150ms ease',
|
||||
...(isAnimated && {
|
||||
animation: 'pulse 1.5s infinite'
|
||||
})
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Message Content */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{content ? (
|
||||
<MarkdownRenderer content={content} streaming={status === 'processing'} onNodeClick={onNodeClick} />
|
||||
) : (
|
||||
role === 'thinking' ? <span>Thinking...</span> : null
|
||||
)}
|
||||
{/* References block for assistant messages: extract [Title](URL) */}
|
||||
{role === 'assistant' && typeof content === 'string' && (() => {
|
||||
const linkRegex = /\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g;
|
||||
const refs: Array<{ title: string; url: string }> = [];
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = linkRegex.exec(content)) !== null) {
|
||||
refs.push({ title: m[1], url: m[2] });
|
||||
if (refs.length >= 12) break;
|
||||
}
|
||||
if (refs.length === 0) return null;
|
||||
return (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<div style={{ color: '#8a8a8a', fontSize: 11, marginBottom: 4 }}>References</div>
|
||||
<div style={{ display: 'grid', gap: 4 }}>
|
||||
{refs.map((r, i) => (
|
||||
<div key={i} style={{ color: '#bdbdbd', fontSize: 12 }}>
|
||||
{i + 1}. <span style={{ color: '#e5e5e5' }}>{r.title}</span> — <a href={r.url} target="_blank" rel="noreferrer" style={{ color: '#5c9aff' }}>{r.url}</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Timestamp */}
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
marginTop: '4px',
|
||||
color: '#4a4a4a',
|
||||
fontSize: '11px'
|
||||
}}>
|
||||
{formatTime(timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
import { Fragment, ReactNode, useMemo } from 'react';
|
||||
import type { AgentDelegation } from '@/services/agents/delegation';
|
||||
|
||||
const statusPalette: Record<string, { border: string; badge: string }> = {
|
||||
queued: { border: '#3a2f5f', badge: '#a78bfa' },
|
||||
in_progress: { border: '#4a3a6f', badge: '#8b5cf6' },
|
||||
completed: { border: '#2a2a2a', badge: '#6b6b6b' },
|
||||
failed: { border: '#5f2a2a', badge: '#ff6b6b' },
|
||||
};
|
||||
|
||||
const NODE_LINK_REGEX = /\[NODE:(\d+):"([^"]+)"\]/g;
|
||||
const SUMMARY_HEADERS = ['Task', 'Actions', 'Result', 'Nodes', 'Follow-up'];
|
||||
|
||||
interface WiseRAHPanelProps {
|
||||
delegation: AgentDelegation;
|
||||
onNodeClick?: (nodeId: number) => void;
|
||||
}
|
||||
|
||||
function formatStatus(status: string) {
|
||||
switch (status) {
|
||||
case 'queued':
|
||||
return 'Queued';
|
||||
case 'in_progress':
|
||||
return 'Planning';
|
||||
case 'completed':
|
||||
return 'Completed';
|
||||
case 'failed':
|
||||
return 'Failed';
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
function renderNodeAwareText(text: string, onNodeClick?: (nodeId: number) => void): ReactNode {
|
||||
const segments: ReactNode[] = [];
|
||||
let lastIndex = 0;
|
||||
|
||||
text.replace(NODE_LINK_REGEX, (match, id, title, offset) => {
|
||||
if (offset > lastIndex) {
|
||||
segments.push(<span key={`text-${offset}`}>{text.slice(lastIndex, offset)}</span>);
|
||||
}
|
||||
const nodeId = Number(id);
|
||||
const handleClick = () => {
|
||||
if (onNodeClick) {
|
||||
onNodeClick(nodeId);
|
||||
}
|
||||
};
|
||||
segments.push(
|
||||
<button
|
||||
key={`node-${offset}`}
|
||||
type="button"
|
||||
className="node-pill"
|
||||
onClick={handleClick}
|
||||
>
|
||||
<span className="node-pill-id">NODE:{nodeId}</span>
|
||||
<span className="node-pill-title">{title}</span>
|
||||
</button>
|
||||
);
|
||||
lastIndex = offset + match.length;
|
||||
return match;
|
||||
});
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
segments.push(<span key={`text-end`}>{text.slice(lastIndex)}</span>);
|
||||
}
|
||||
|
||||
return <>{segments}</>;
|
||||
}
|
||||
|
||||
function parseSummary(summary: string) {
|
||||
const lines = summary.split('\n');
|
||||
const sections: Array<{ title: string; lines: string[] }> = [];
|
||||
let current: { title: string; lines: string[] } | null = null;
|
||||
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) continue;
|
||||
|
||||
const header = SUMMARY_HEADERS.find(h => line.toLowerCase().startsWith(`${h.toLowerCase()}:`));
|
||||
if (header) {
|
||||
const content = line.slice(header.length + 1).trim();
|
||||
current = { title: header, lines: [] };
|
||||
if (content) current.lines.push(content);
|
||||
sections.push(current);
|
||||
} else if (current) {
|
||||
current.lines.push(line);
|
||||
} else {
|
||||
if (!current) {
|
||||
current = { title: 'Summary', lines: [] };
|
||||
sections.push(current);
|
||||
}
|
||||
current.lines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
function renderSectionLines(lines: string[], onNodeClick?: (nodeId: number) => void) {
|
||||
const hasBullet = lines.some(line => /^[-*•]/.test(line.trim()));
|
||||
|
||||
if (hasBullet) {
|
||||
return (
|
||||
<ul className="summary-list">
|
||||
{lines.map((line, idx) => {
|
||||
const text = line.replace(/^[-*•]\s*/, '').trim();
|
||||
return (
|
||||
<li key={idx}>
|
||||
{renderNodeAwareText(text, onNodeClick)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="summary-paragraphs">
|
||||
{lines.map((line, idx) => (
|
||||
<p key={idx}>{renderNodeAwareText(line, onNodeClick)}</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WiseRAHPanel({ delegation, onNodeClick }: WiseRAHPanelProps) {
|
||||
const palette = statusPalette[delegation.status] ?? statusPalette.queued;
|
||||
|
||||
const parsedSummary = useMemo(() => {
|
||||
if (!delegation.summary) return [];
|
||||
return parseSummary(delegation.summary);
|
||||
}, [delegation.summary]);
|
||||
|
||||
return (
|
||||
<div className="wise-panel">
|
||||
<header className="wise-header" style={{ borderBottomColor: palette.border }}>
|
||||
<span className="status-dot" style={{ background: palette.badge }} />
|
||||
<span className="header-title">WISE RA-H</span>
|
||||
<span className="header-status">· {formatStatus(delegation.status)}</span>
|
||||
<span className="header-time">{new Date(delegation.updatedAt).toLocaleTimeString()}</span>
|
||||
</header>
|
||||
|
||||
<section className="section">
|
||||
<h3>Goal</h3>
|
||||
<p>{delegation.task}</p>
|
||||
</section>
|
||||
|
||||
{delegation.context.length > 0 && (
|
||||
<section className="section">
|
||||
<h3>Context</h3>
|
||||
<ul className="context-list">
|
||||
{delegation.context.map((item, idx) => (
|
||||
<li key={idx}>{renderNodeAwareText(item, onNodeClick)}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{parsedSummary.length > 0 && (
|
||||
<section className="section">
|
||||
<h3>Summary</h3>
|
||||
<div className="summary-card">
|
||||
{parsedSummary.map(section => (
|
||||
<div key={section.title} className="summary-section">
|
||||
<div className="summary-title">{section.title}</div>
|
||||
{renderSectionLines(section.lines, onNodeClick)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<style jsx>{`
|
||||
.wise-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
padding: 24px;
|
||||
background: #0a0a0a;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.wise-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid rgba(167, 139, 250, 0.35);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.12em;
|
||||
color: #bba4ff;
|
||||
}
|
||||
|
||||
.header-status {
|
||||
font-size: 12px;
|
||||
color: #8b82a6;
|
||||
}
|
||||
|
||||
.header-time {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
color: #5f5f5f;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.section h3 {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: #a78bfa;
|
||||
}
|
||||
|
||||
.section p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: #d4d4d4;
|
||||
}
|
||||
|
||||
.context-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.context-list li {
|
||||
font-size: 13px;
|
||||
color: #a0a0a0;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
background: #101010;
|
||||
border: 1px solid rgba(167, 139, 250, 0.1);
|
||||
border-radius: 12px;
|
||||
padding: 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.summary-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.summary-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #c5b5ff;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.summary-paragraphs p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: #e5e5e5;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.summary-list {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.summary-list li {
|
||||
font-size: 13px;
|
||||
color: #e5e5e5;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.node-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
margin: 0 4px 4px 0;
|
||||
font-size: 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(167, 139, 250, 0.3);
|
||||
background: rgba(167, 139, 250, 0.08);
|
||||
color: #d8d3ff;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, border 0.15s ease;
|
||||
}
|
||||
|
||||
.node-pill:hover {
|
||||
background: rgba(167, 139, 250, 0.18);
|
||||
border: 1px solid rgba(167, 139, 250, 0.6);
|
||||
}
|
||||
|
||||
.node-pill-id {
|
||||
font-size: 11px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.node-pill-title {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
export type TTSStatus = 'idle' | 'loading' | 'speaking';
|
||||
|
||||
interface UseAssistantTTSOptions {
|
||||
voice?: string;
|
||||
onSpeechStart?: () => void;
|
||||
onSpeechComplete?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
interface SpeakRequestMetadata {
|
||||
sessionId?: string | null;
|
||||
helper?: string | null;
|
||||
requestId?: string;
|
||||
messageId?: string | null;
|
||||
}
|
||||
|
||||
interface SpeakOptions {
|
||||
flush?: boolean;
|
||||
metadata?: SpeakRequestMetadata;
|
||||
}
|
||||
|
||||
type SpeakQueueItem = {
|
||||
text: string;
|
||||
metadata?: SpeakRequestMetadata;
|
||||
};
|
||||
|
||||
export function useAssistantTTS(options: UseAssistantTTSOptions = {}) {
|
||||
const [status, setStatus] = useState<TTSStatus>('idle');
|
||||
const queueRef = useRef<SpeakQueueItem[]>([]);
|
||||
const isProcessingRef = useRef(false);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const objectUrlRef = useRef<string | null>(null);
|
||||
const mediaSourceRef = useRef<MediaSource | null>(null);
|
||||
const sourceBufferRef = useRef<SourceBuffer | null>(null);
|
||||
const chunkQueueRef = useRef<ArrayBuffer[]>([]);
|
||||
const updateEndHandlerRef = useRef<(() => void) | null>(null);
|
||||
const readerDoneRef = useRef(false);
|
||||
const processQueueRef = useRef<(() => void) | null>(null);
|
||||
|
||||
const onSpeechStartRef = useRef(options.onSpeechStart);
|
||||
const onSpeechCompleteRef = useRef(options.onSpeechComplete);
|
||||
const onErrorRef = useRef(options.onError);
|
||||
const voiceRef = useRef(options.voice);
|
||||
|
||||
useEffect(() => {
|
||||
onSpeechStartRef.current = options.onSpeechStart;
|
||||
}, [options.onSpeechStart]);
|
||||
|
||||
useEffect(() => {
|
||||
onSpeechCompleteRef.current = options.onSpeechComplete;
|
||||
}, [options.onSpeechComplete]);
|
||||
|
||||
useEffect(() => {
|
||||
onErrorRef.current = options.onError;
|
||||
}, [options.onError]);
|
||||
|
||||
useEffect(() => {
|
||||
voiceRef.current = options.voice;
|
||||
}, [options.voice]);
|
||||
|
||||
const setStatusSafe = useCallback((next: TTSStatus) => {
|
||||
setStatus(next);
|
||||
}, []);
|
||||
|
||||
const cleanupMedia = useCallback(() => {
|
||||
if (sourceBufferRef.current && updateEndHandlerRef.current) {
|
||||
try {
|
||||
sourceBufferRef.current.removeEventListener('updateend', updateEndHandlerRef.current);
|
||||
} catch (err) {
|
||||
console.warn('[TTS] Failed to detach source buffer listener', err);
|
||||
}
|
||||
}
|
||||
updateEndHandlerRef.current = null;
|
||||
sourceBufferRef.current = null;
|
||||
mediaSourceRef.current = null;
|
||||
if (objectUrlRef.current) {
|
||||
URL.revokeObjectURL(objectUrlRef.current);
|
||||
objectUrlRef.current = null;
|
||||
}
|
||||
chunkQueueRef.current = [];
|
||||
readerDoneRef.current = false;
|
||||
}, []);
|
||||
|
||||
const handleError = useCallback((error: Error) => {
|
||||
console.error('[TTS] Playback error', error);
|
||||
onErrorRef.current?.(error);
|
||||
}, []);
|
||||
|
||||
const stopCurrentPlayback = useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = null;
|
||||
const audio = audioRef.current;
|
||||
if (audio) {
|
||||
audio.pause();
|
||||
audio.removeAttribute('src');
|
||||
try {
|
||||
audio.load();
|
||||
} catch (err) {
|
||||
console.warn('[TTS] Failed to reset audio element', err);
|
||||
}
|
||||
}
|
||||
cleanupMedia();
|
||||
isProcessingRef.current = false;
|
||||
}, [cleanupMedia]);
|
||||
|
||||
const ensureAudioElement = useCallback(() => {
|
||||
if (audioRef.current) {
|
||||
return audioRef.current;
|
||||
}
|
||||
const audio = new Audio();
|
||||
audio.autoplay = true;
|
||||
audio.preload = 'auto';
|
||||
audio.addEventListener('playing', () => {
|
||||
setStatusSafe('speaking');
|
||||
onSpeechStartRef.current?.();
|
||||
});
|
||||
audio.addEventListener('ended', () => {
|
||||
cleanupMedia();
|
||||
isProcessingRef.current = false;
|
||||
setStatusSafe('idle');
|
||||
onSpeechCompleteRef.current?.();
|
||||
processQueueRef.current?.();
|
||||
});
|
||||
audio.addEventListener('error', () => {
|
||||
handleError(new Error('Audio playback failed'));
|
||||
stopCurrentPlayback();
|
||||
processQueueRef.current?.();
|
||||
});
|
||||
audioRef.current = audio;
|
||||
return audio;
|
||||
}, [cleanupMedia, handleError, setStatusSafe, stopCurrentPlayback]);
|
||||
|
||||
const flushChunks = useCallback(() => {
|
||||
const sourceBuffer = sourceBufferRef.current;
|
||||
const mediaSource = mediaSourceRef.current;
|
||||
if (!sourceBuffer || !mediaSource || sourceBuffer.updating) {
|
||||
return;
|
||||
}
|
||||
const nextChunk = chunkQueueRef.current.shift();
|
||||
if (nextChunk) {
|
||||
try {
|
||||
sourceBuffer.appendBuffer(nextChunk);
|
||||
} catch (error) {
|
||||
handleError(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (readerDoneRef.current && !sourceBuffer.updating) {
|
||||
try {
|
||||
mediaSource.endOfStream();
|
||||
} catch (error) {
|
||||
console.warn('[TTS] Failed to end media source stream', error);
|
||||
}
|
||||
}
|
||||
}, [handleError]);
|
||||
|
||||
const processQueue = useCallback(async () => {
|
||||
if (isProcessingRef.current) return;
|
||||
const next = queueRef.current.shift();
|
||||
if (!next) {
|
||||
setStatusSafe('idle');
|
||||
return;
|
||||
}
|
||||
isProcessingRef.current = true;
|
||||
setStatusSafe('loading');
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
const payload: Record<string, any> = {
|
||||
text: next.text,
|
||||
voice: voiceRef.current,
|
||||
};
|
||||
|
||||
if (next.metadata?.sessionId) {
|
||||
payload.sessionId = next.metadata.sessionId;
|
||||
}
|
||||
if (next.metadata?.helper) {
|
||||
payload.helper = next.metadata.helper;
|
||||
}
|
||||
if (next.metadata?.requestId) {
|
||||
payload.requestId = next.metadata.requestId;
|
||||
}
|
||||
if (next.metadata?.messageId) {
|
||||
payload.messageId = next.metadata.messageId;
|
||||
}
|
||||
|
||||
const response = await fetch('/api/voice/tts', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error((await response.text().catch(() => '')) || 'Failed to synthesize audio');
|
||||
}
|
||||
const reader = response.body.getReader();
|
||||
const audio = ensureAudioElement();
|
||||
cleanupMedia();
|
||||
const mimeType = response.headers.get('Content-Type') || 'audio/mpeg';
|
||||
const mediaSource = new MediaSource();
|
||||
mediaSourceRef.current = mediaSource;
|
||||
const objectUrl = URL.createObjectURL(mediaSource);
|
||||
objectUrlRef.current = objectUrl;
|
||||
audio.src = objectUrl;
|
||||
audio.load();
|
||||
|
||||
const onSourceOpen = () => {
|
||||
mediaSource.removeEventListener('sourceopen', onSourceOpen);
|
||||
let sourceBuffer: SourceBuffer;
|
||||
try {
|
||||
sourceBuffer = mediaSource.addSourceBuffer(mimeType);
|
||||
} catch {
|
||||
throw new Error(`Unsupported audio format: ${mimeType}`);
|
||||
}
|
||||
sourceBufferRef.current = sourceBuffer;
|
||||
const handleUpdateEnd = () => flushChunks();
|
||||
updateEndHandlerRef.current = handleUpdateEnd;
|
||||
sourceBuffer.addEventListener('updateend', handleUpdateEnd);
|
||||
flushChunks();
|
||||
};
|
||||
|
||||
mediaSource.addEventListener('sourceopen', onSourceOpen);
|
||||
|
||||
const pump = async () => {
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) {
|
||||
readerDoneRef.current = true;
|
||||
flushChunks();
|
||||
break;
|
||||
}
|
||||
if (value) {
|
||||
const buffer = value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength);
|
||||
chunkQueueRef.current.push(buffer);
|
||||
flushChunks();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
pump().catch((error) => {
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
if ((err as DOMException).name !== 'AbortError') {
|
||||
handleError(err);
|
||||
}
|
||||
stopCurrentPlayback();
|
||||
processQueueRef.current?.();
|
||||
});
|
||||
|
||||
audio.play().catch((error) => {
|
||||
handleError(error instanceof Error ? error : new Error(String(error)));
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
isProcessingRef.current = false;
|
||||
setStatusSafe('idle');
|
||||
handleError(error instanceof Error ? error : new Error(String(error)));
|
||||
processQueueRef.current?.();
|
||||
}
|
||||
}, [cleanupMedia, ensureAudioElement, flushChunks, handleError, setStatusSafe, stopCurrentPlayback]);
|
||||
|
||||
processQueueRef.current = processQueue;
|
||||
|
||||
const speak = useCallback(
|
||||
(text: string, options?: SpeakOptions) => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return;
|
||||
if (options?.flush) {
|
||||
queueRef.current = [{ text: trimmed, metadata: options.metadata }];
|
||||
stopCurrentPlayback();
|
||||
} else {
|
||||
queueRef.current.push({ text: trimmed, metadata: options?.metadata });
|
||||
}
|
||||
processQueue();
|
||||
},
|
||||
[processQueue, stopCurrentPlayback]
|
||||
);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
queueRef.current = [];
|
||||
stopCurrentPlayback();
|
||||
setStatusSafe('idle');
|
||||
}, [setStatusSafe, stopCurrentPlayback]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
stop();
|
||||
};
|
||||
}, [stop]);
|
||||
|
||||
return {
|
||||
status,
|
||||
speak,
|
||||
stop,
|
||||
} as const;
|
||||
}
|
||||
@@ -0,0 +1,570 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
export type RealtimeConnectionState = 'idle' | 'connecting' | 'ready' | 'capturing';
|
||||
|
||||
interface VoiceRealtimeCallbacks {
|
||||
onStatusChange?: (status: 'idle' | 'listening' | 'thinking' | 'speaking') => void;
|
||||
onInterimTranscript?: (text: string) => void;
|
||||
onFinalTranscript?: (text: string) => void;
|
||||
onAmplitude?: (value: number) => void;
|
||||
onError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
interface UseRealtimeVoiceClientOptions {
|
||||
getAuthToken?: () => string | null | undefined;
|
||||
fetchEphemeralToken?: (
|
||||
authToken: string | null
|
||||
) => Promise<{ client_secret: { value: string }; model: string; voice: string }>;
|
||||
silenceThresholdMs?: number;
|
||||
silenceAmplitudeCutoff?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_SILENCE_THRESHOLD_MS = 800;
|
||||
const DEFAULT_SILENCE_AMPLITUDE = 0.0015;
|
||||
|
||||
type Nullable<T> = T | null;
|
||||
|
||||
function calculateRms(buffer: Float32Array) {
|
||||
if (!buffer.length) return 0;
|
||||
let sumSquares = 0;
|
||||
for (let i = 0; i < buffer.length; i += 1) {
|
||||
const value = buffer[i];
|
||||
sumSquares += value * value;
|
||||
}
|
||||
return Math.sqrt(sumSquares / buffer.length);
|
||||
}
|
||||
|
||||
|
||||
export function useRealtimeVoiceClient(
|
||||
callbacks: VoiceRealtimeCallbacks,
|
||||
options: UseRealtimeVoiceClientOptions = {}
|
||||
) {
|
||||
const { onStatusChange, onInterimTranscript, onFinalTranscript, onAmplitude, onError } = callbacks;
|
||||
|
||||
const { getAuthToken, fetchEphemeralToken, silenceThresholdMs, silenceAmplitudeCutoff } = options;
|
||||
|
||||
const connectionStateRef = useRef<RealtimeConnectionState>('idle');
|
||||
const peerConnectionRef = useRef<Nullable<RTCPeerConnection>>(null);
|
||||
const dataChannelRef = useRef<Nullable<RTCDataChannel>>(null);
|
||||
const audioContextRef = useRef<Nullable<AudioContext>>(null);
|
||||
const processorNodeRef = useRef<Nullable<ScriptProcessorNode>>(null);
|
||||
const mediaStreamRef = useRef<Nullable<MediaStream>>(null);
|
||||
const mediaSourceRef = useRef<Nullable<MediaStreamAudioSourceNode>>(null);
|
||||
const awaitingTranscriptRef = useRef(false);
|
||||
const hasUncommittedAudioRef = useRef(false);
|
||||
const lastSpeechAtRef = useRef<number | null>(null);
|
||||
const destroyedRef = useRef(false);
|
||||
const channelReadyRef = useRef(false);
|
||||
|
||||
const silenceWindowMs = silenceThresholdMs ?? DEFAULT_SILENCE_THRESHOLD_MS;
|
||||
const amplitudeGate = silenceAmplitudeCutoff ?? DEFAULT_SILENCE_AMPLITUDE;
|
||||
|
||||
const onStatusChangeRef = useRef(onStatusChange);
|
||||
const onInterimTranscriptRef = useRef(onInterimTranscript);
|
||||
const onFinalTranscriptRef = useRef(onFinalTranscript);
|
||||
const onAmplitudeRef = useRef(onAmplitude);
|
||||
const onErrorRef = useRef(onError);
|
||||
|
||||
useEffect(() => {
|
||||
onStatusChangeRef.current = onStatusChange;
|
||||
}, [onStatusChange]);
|
||||
|
||||
useEffect(() => {
|
||||
onInterimTranscriptRef.current = onInterimTranscript;
|
||||
}, [onInterimTranscript]);
|
||||
|
||||
useEffect(() => {
|
||||
onFinalTranscriptRef.current = onFinalTranscript;
|
||||
}, [onFinalTranscript]);
|
||||
|
||||
useEffect(() => {
|
||||
onAmplitudeRef.current = onAmplitude;
|
||||
}, [onAmplitude]);
|
||||
|
||||
useEffect(() => {
|
||||
onErrorRef.current = onError;
|
||||
}, [onError]);
|
||||
|
||||
const setConnectionState = useCallback((next: RealtimeConnectionState) => {
|
||||
connectionStateRef.current = next;
|
||||
}, []);
|
||||
|
||||
const teardownInputNodes = useCallback(() => {
|
||||
processorNodeRef.current?.disconnect();
|
||||
mediaSourceRef.current?.disconnect();
|
||||
processorNodeRef.current?.removeEventListener('audioprocess', () => undefined);
|
||||
processorNodeRef.current = null;
|
||||
mediaSourceRef.current = null;
|
||||
if (mediaStreamRef.current) {
|
||||
mediaStreamRef.current.getTracks().forEach((track) => track.stop());
|
||||
mediaStreamRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const closePeerConnection = useCallback(() => {
|
||||
if (dataChannelRef.current) {
|
||||
try {
|
||||
dataChannelRef.current.close();
|
||||
} catch (err) {
|
||||
console.warn('[VoiceRealtime] Failed to close data channel:', err);
|
||||
}
|
||||
}
|
||||
dataChannelRef.current = null;
|
||||
|
||||
if (peerConnectionRef.current) {
|
||||
try {
|
||||
peerConnectionRef.current.ontrack = null;
|
||||
peerConnectionRef.current.onconnectionstatechange = null;
|
||||
peerConnectionRef.current.close();
|
||||
} catch (err) {
|
||||
console.warn('[VoiceRealtime] Failed to close peer connection:', err);
|
||||
}
|
||||
}
|
||||
peerConnectionRef.current = null;
|
||||
|
||||
channelReadyPromiseRef.current = null;
|
||||
channelReadyResolveRef.current = null;
|
||||
}, []);
|
||||
|
||||
const resetState = useCallback(() => {
|
||||
awaitingTranscriptRef.current = false;
|
||||
hasUncommittedAudioRef.current = false;
|
||||
lastSpeechAtRef.current = null;
|
||||
channelReadyPromiseRef.current = null;
|
||||
channelReadyResolveRef.current = null;
|
||||
}, []);
|
||||
|
||||
const notifyError = useCallback((message: string | Error) => {
|
||||
const error = message instanceof Error ? message : new Error(message);
|
||||
onErrorRef.current?.(error);
|
||||
}, []);
|
||||
|
||||
const channelReadyPromiseRef = useRef<Promise<void> | null>(null);
|
||||
const channelReadyResolveRef = useRef<(() => void) | null>(null);
|
||||
|
||||
const ensureChannelReady = useCallback(async () => {
|
||||
const channel = dataChannelRef.current;
|
||||
if (channel?.readyState === 'open') return;
|
||||
if (!channelReadyPromiseRef.current) {
|
||||
channelReadyPromiseRef.current = new Promise<void>((resolve) => {
|
||||
channelReadyResolveRef.current = resolve;
|
||||
});
|
||||
}
|
||||
await channelReadyPromiseRef.current;
|
||||
}, []);
|
||||
|
||||
const sendEvent = useCallback(
|
||||
async (event: Record<string, unknown>) => {
|
||||
await ensureChannelReady();
|
||||
const channel = dataChannelRef.current;
|
||||
if (!channel || channel.readyState !== 'open') {
|
||||
throw new Error('Realtime data channel is not open');
|
||||
}
|
||||
channel.send(JSON.stringify(event));
|
||||
},
|
||||
[ensureChannelReady]
|
||||
);
|
||||
|
||||
const initialiseMicrophone = useCallback(async () => {
|
||||
if (typeof window === 'undefined') {
|
||||
throw new Error('Voice not supported in this environment');
|
||||
}
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
channelCount: 1,
|
||||
echoCancellation: false,
|
||||
autoGainControl: false,
|
||||
noiseSuppression: false,
|
||||
},
|
||||
});
|
||||
|
||||
if (destroyedRef.current) {
|
||||
stream.getTracks().forEach((track) => {
|
||||
try {
|
||||
track.stop();
|
||||
} catch (err) {
|
||||
console.warn('[VoiceRealtime] Failed to stop track after destroy', err);
|
||||
}
|
||||
});
|
||||
return stream;
|
||||
}
|
||||
|
||||
const audioContext = new AudioContext();
|
||||
const source = audioContext.createMediaStreamSource(stream);
|
||||
const processor = audioContext.createScriptProcessor(2048, 1, 1);
|
||||
|
||||
processor.onaudioprocess = (event) => {
|
||||
if (destroyedRef.current) return;
|
||||
const inputBuffer = event.inputBuffer.getChannelData(0);
|
||||
const amplitude = calculateRms(inputBuffer);
|
||||
onAmplitudeRef.current?.(Math.min(1, amplitude * 8));
|
||||
|
||||
const now = Date.now();
|
||||
const channelReady = channelReadyRef.current;
|
||||
if (amplitude > amplitudeGate && channelReady) {
|
||||
hasUncommittedAudioRef.current = true;
|
||||
lastSpeechAtRef.current = now;
|
||||
if (!awaitingTranscriptRef.current) {
|
||||
onStatusChangeRef.current?.('listening');
|
||||
}
|
||||
} else if (
|
||||
channelReady &&
|
||||
hasUncommittedAudioRef.current &&
|
||||
!awaitingTranscriptRef.current &&
|
||||
lastSpeechAtRef.current &&
|
||||
now - lastSpeechAtRef.current > silenceWindowMs
|
||||
) {
|
||||
awaitingTranscriptRef.current = true;
|
||||
hasUncommittedAudioRef.current = false;
|
||||
lastSpeechAtRef.current = null;
|
||||
onStatusChangeRef.current?.('thinking');
|
||||
}
|
||||
};
|
||||
|
||||
source.connect(processor);
|
||||
processor.connect(audioContext.destination);
|
||||
|
||||
audioContextRef.current = audioContext;
|
||||
processorNodeRef.current = processor;
|
||||
mediaSourceRef.current = source;
|
||||
mediaStreamRef.current = stream;
|
||||
return stream;
|
||||
}, [amplitudeGate, silenceWindowMs]);
|
||||
|
||||
const disconnect = useCallback(() => {
|
||||
destroyedRef.current = true;
|
||||
teardownInputNodes();
|
||||
closePeerConnection();
|
||||
audioContextRef.current?.close().catch(() => undefined);
|
||||
audioContextRef.current = null;
|
||||
resetState();
|
||||
channelReadyRef.current = false;
|
||||
setConnectionState('idle');
|
||||
onStatusChangeRef.current?.('idle');
|
||||
onAmplitudeRef.current?.(0);
|
||||
}, [closePeerConnection, resetState, setConnectionState, teardownInputNodes]);
|
||||
|
||||
const extractTextDelta = useCallback((payload: unknown): string => {
|
||||
if (!payload) return '';
|
||||
if (typeof payload === 'string') return payload;
|
||||
if (typeof payload !== 'object') return '';
|
||||
|
||||
const record = payload as Record<string, unknown>;
|
||||
if (typeof record.delta === 'string') return record.delta;
|
||||
if (typeof record.text === 'string') return record.text;
|
||||
|
||||
const outputText = record.output_text as Record<string, unknown> | undefined;
|
||||
if (typeof outputText?.text === 'string') return outputText.text;
|
||||
|
||||
if (Array.isArray(record.output)) {
|
||||
return record.output
|
||||
.flatMap((item) =>
|
||||
Array.isArray((item as Record<string, unknown>)?.content)
|
||||
? ((item as Record<string, unknown>).content as unknown[])
|
||||
: []
|
||||
)
|
||||
.map((content) => {
|
||||
if (!content || typeof content !== 'object') return '';
|
||||
const entry = content as Record<string, unknown>;
|
||||
const nestedText = entry.text as Record<string, unknown> | string | undefined;
|
||||
if (typeof nestedText === 'string') return nestedText;
|
||||
if (typeof nestedText === 'object' && nestedText !== null && typeof nestedText.value === 'string') {
|
||||
return nestedText.value;
|
||||
}
|
||||
return '';
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('');
|
||||
}
|
||||
|
||||
if (Array.isArray(record.content)) {
|
||||
return record.content
|
||||
.map((content) => {
|
||||
if (!content || typeof content !== 'object') return '';
|
||||
const entry = content as Record<string, unknown>;
|
||||
const nestedText = entry.text as Record<string, unknown> | string | undefined;
|
||||
if (typeof nestedText === 'string') return nestedText;
|
||||
if (typeof nestedText === 'object' && nestedText !== null && typeof nestedText.value === 'string') {
|
||||
return nestedText.value;
|
||||
}
|
||||
return '';
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('');
|
||||
}
|
||||
|
||||
return '';
|
||||
}, []);
|
||||
|
||||
const handleDataMessage = useCallback(
|
||||
(raw: string) => {
|
||||
try {
|
||||
const data = JSON.parse(raw);
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.debug('[VoiceRealtime] Event', data.type, data);
|
||||
}
|
||||
|
||||
if (data.type === 'conversation.item.input_audio_transcription.completed') {
|
||||
const transcriptSource =
|
||||
typeof data.transcript === 'string'
|
||||
? data.transcript
|
||||
: extractTextDelta(data.item ?? data.content ?? data);
|
||||
const transcript = transcriptSource?.trim();
|
||||
awaitingTranscriptRef.current = false;
|
||||
hasUncommittedAudioRef.current = false;
|
||||
lastSpeechAtRef.current = null;
|
||||
if (transcript) {
|
||||
console.info('[VoiceRealtime] Transcript completed', transcript);
|
||||
onInterimTranscriptRef.current?.('');
|
||||
onFinalTranscriptRef.current?.(transcript);
|
||||
} else {
|
||||
console.warn('[VoiceRealtime] Received empty transcription event');
|
||||
onInterimTranscriptRef.current?.('');
|
||||
}
|
||||
onStatusChangeRef.current?.('listening');
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.type === 'error' && data.error) {
|
||||
console.error('[VoiceRealtime] Server error', data.error);
|
||||
}
|
||||
|
||||
if (data.type === 'response.error' && data.error) {
|
||||
console.error('[VoiceRealtime] Response error', data.error);
|
||||
}
|
||||
} catch (err) {
|
||||
notifyError(err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
},
|
||||
[extractTextDelta, notifyError]
|
||||
);
|
||||
|
||||
const waitForIceGatheringComplete = useCallback((pc: RTCPeerConnection, timeoutMs = 2000) => {
|
||||
if (pc.iceGatheringState === 'complete') {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
let resolved = false;
|
||||
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const finish = () => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
pc.removeEventListener('icegatheringstatechange', handleStateChange);
|
||||
if (timeoutHandle) {
|
||||
clearTimeout(timeoutHandle);
|
||||
timeoutHandle = null;
|
||||
}
|
||||
resolve();
|
||||
};
|
||||
|
||||
const handleStateChange = () => {
|
||||
if (pc.iceGatheringState === 'complete') {
|
||||
finish();
|
||||
}
|
||||
};
|
||||
|
||||
if (timeoutMs) {
|
||||
timeoutHandle = setTimeout(() => {
|
||||
console.warn('[VoiceRealtime] ICE gathering timeout reached, proceeding with partial candidates');
|
||||
finish();
|
||||
}, timeoutMs);
|
||||
}
|
||||
|
||||
pc.addEventListener('icegatheringstatechange', handleStateChange);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const start = useCallback(async () => {
|
||||
try {
|
||||
destroyedRef.current = false;
|
||||
resetState();
|
||||
setConnectionState('connecting');
|
||||
|
||||
const authToken = getAuthToken?.() ?? null;
|
||||
const fetchToken = fetchEphemeralToken
|
||||
? fetchEphemeralToken
|
||||
: async (token: string | null) => {
|
||||
const response = await fetch('/api/realtime/ephemeral-token', {
|
||||
method: 'POST',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
throw new Error(payload.error || `Failed to mint ephemeral token (${response.status})`);
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
|
||||
const sessionPromise = fetchToken(authToken);
|
||||
const microphonePromise = initialiseMicrophone();
|
||||
|
||||
const session = await sessionPromise;
|
||||
const clientSecret = session?.client_secret?.value || session?.client_secret;
|
||||
if (!clientSecret || typeof clientSecret !== 'string') {
|
||||
throw new Error('Realtime session did not include a client_secret');
|
||||
}
|
||||
|
||||
const pc = new RTCPeerConnection({
|
||||
iceServers: [{ urls: ['stun:stun.l.google.com:19302'] }],
|
||||
});
|
||||
peerConnectionRef.current = pc;
|
||||
|
||||
const channel = pc.createDataChannel('oai-events');
|
||||
dataChannelRef.current = channel;
|
||||
channelReadyPromiseRef.current = new Promise<void>((resolve) => {
|
||||
channelReadyResolveRef.current = resolve;
|
||||
});
|
||||
channelReadyRef.current = false;
|
||||
|
||||
channel.onmessage = (event) => {
|
||||
handleDataMessage(event.data);
|
||||
};
|
||||
|
||||
channel.onopen = () => {
|
||||
console.info('[VoiceRealtime] Data channel open');
|
||||
setConnectionState('ready');
|
||||
channelReadyResolveRef.current?.();
|
||||
channelReadyResolveRef.current = null;
|
||||
channelReadyRef.current = true;
|
||||
|
||||
void sendEvent({
|
||||
type: 'session.update',
|
||||
session: {
|
||||
modalities: ['text'],
|
||||
input_audio_transcription: {
|
||||
model: 'whisper-1',
|
||||
},
|
||||
turn_detection: {
|
||||
type: 'server_vad',
|
||||
threshold: 0.5,
|
||||
silence_duration_ms: 1800,
|
||||
prefix_padding_ms: 300,
|
||||
create_response: false,
|
||||
},
|
||||
instructions: 'You are the RA-H voice transport. Only transcribe user speech accurately, never speak responses.',
|
||||
},
|
||||
}).catch((err) => {
|
||||
console.error('[VoiceRealtime] Failed to send session update:', err);
|
||||
});
|
||||
};
|
||||
|
||||
channel.onerror = (event) => {
|
||||
console.error('[VoiceRealtime] Data channel error:', event);
|
||||
notifyError(new Error('Realtime connection error'));
|
||||
channelReadyRef.current = false;
|
||||
disconnect();
|
||||
};
|
||||
|
||||
channel.onclose = () => {
|
||||
console.warn('[VoiceRealtime] Data channel closed');
|
||||
channelReadyPromiseRef.current = null;
|
||||
channelReadyResolveRef.current = null;
|
||||
channelReadyRef.current = false;
|
||||
if (!destroyedRef.current) {
|
||||
notifyError(new Error('Realtime data channel closed unexpectedly'));
|
||||
disconnect();
|
||||
}
|
||||
};
|
||||
|
||||
pc.onconnectionstatechange = () => {
|
||||
const state = pc.connectionState;
|
||||
console.info('[VoiceRealtime] Peer connection state:', state);
|
||||
if (state === 'connected') {
|
||||
onStatusChangeRef.current?.('listening');
|
||||
setConnectionState('capturing');
|
||||
}
|
||||
if (state === 'failed' || state === 'closed' || state === 'disconnected') {
|
||||
if (!destroyedRef.current) {
|
||||
notifyError(new Error(`Realtime connection ${state}`));
|
||||
}
|
||||
disconnect();
|
||||
}
|
||||
};
|
||||
|
||||
pc.oniceconnectionstatechange = () => {
|
||||
console.info('[VoiceRealtime] ICE connection state:', pc.iceConnectionState);
|
||||
};
|
||||
|
||||
pc.ontrack = () => undefined;
|
||||
|
||||
const mediaStream = await microphonePromise;
|
||||
mediaStream?.getAudioTracks().forEach((track) => {
|
||||
if (peerConnectionRef.current?.signalingState !== 'closed') {
|
||||
peerConnectionRef.current?.addTrack(track, mediaStream);
|
||||
}
|
||||
});
|
||||
|
||||
const offer = await pc.createOffer();
|
||||
await pc.setLocalDescription(offer);
|
||||
await waitForIceGatheringComplete(pc, 2000);
|
||||
|
||||
const localDescription = pc.localDescription;
|
||||
if (!localDescription) {
|
||||
throw new Error('Failed to create local description for realtime call');
|
||||
}
|
||||
|
||||
const response = await fetch('https://api.openai.com/v1/realtime/calls', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${clientSecret}`,
|
||||
'Content-Type': 'application/sdp',
|
||||
'OpenAI-Beta': 'realtime=v1',
|
||||
},
|
||||
body: localDescription.sdp,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorDetail = '';
|
||||
try {
|
||||
const text = await response.text();
|
||||
errorDetail = text;
|
||||
const maybeJson = text ? JSON.parse(text) : null;
|
||||
if (maybeJson?.error?.message) {
|
||||
errorDetail = maybeJson.error.message;
|
||||
}
|
||||
} catch {
|
||||
// ignore JSON parse errors and fall back to raw text
|
||||
}
|
||||
const friendly = errorDetail || response.statusText || 'Unknown realtime error';
|
||||
console.error('[VoiceRealtime] Failed to start realtime call:', friendly, { status: response.status });
|
||||
throw new Error(`Failed to establish realtime call (${response.status}): ${friendly}`);
|
||||
}
|
||||
|
||||
const answerSdp = await response.text();
|
||||
await pc.setRemoteDescription({ type: 'answer', sdp: answerSdp });
|
||||
|
||||
onStatusChangeRef.current?.('listening');
|
||||
setConnectionState('capturing');
|
||||
} catch (err) {
|
||||
disconnect();
|
||||
notifyError(err instanceof Error ? err : new Error(String(err)));
|
||||
throw err;
|
||||
}
|
||||
}, [disconnect, fetchEphemeralToken, getAuthToken, handleDataMessage, initialiseMicrophone, notifyError, resetState, sendEvent, setConnectionState, waitForIceGatheringComplete]);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
disconnect();
|
||||
}, [disconnect]);
|
||||
|
||||
const latestStopRef = useRef<() => void>(() => {});
|
||||
|
||||
useEffect(() => {
|
||||
latestStopRef.current = stop;
|
||||
}, [stop]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
latestStopRef.current();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
start,
|
||||
stop,
|
||||
} as const;
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from 'react';
|
||||
|
||||
export enum MessageRole {
|
||||
USER = 'user',
|
||||
ASSISTANT = 'assistant',
|
||||
TOOL = 'tool',
|
||||
SYSTEM = 'system',
|
||||
THINKING = 'thinking',
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
role: MessageRole.USER | MessageRole.ASSISTANT | MessageRole.TOOL | MessageRole.SYSTEM | MessageRole.THINKING;
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
toolName?: string;
|
||||
status?: 'processing' | 'delivered' | 'error' | 'starting' | 'running' | 'complete';
|
||||
toolArgs?: any;
|
||||
toolResult?: any;
|
||||
}
|
||||
|
||||
interface SendParams {
|
||||
text: string;
|
||||
history: ChatMessage[];
|
||||
openTabs: any[];
|
||||
activeTabId: number | null;
|
||||
currentView?: 'nodes' | 'memory';
|
||||
sessionId: string;
|
||||
mode: 'easy' | 'hard';
|
||||
}
|
||||
|
||||
interface UseSSEChatOptions {
|
||||
getAuthToken?: () => string | null | undefined;
|
||||
beforeRequest?: () => boolean;
|
||||
onRequestError?: (error: unknown, response?: Response) => boolean | void;
|
||||
onStreamComplete?: () => void | Promise<void>;
|
||||
getApiKeys?: () => { openai?: string; anthropic?: string } | undefined;
|
||||
}
|
||||
|
||||
export function useSSEChat(
|
||||
endpoint: string,
|
||||
setMessages: (updater: (prev: ChatMessage[]) => ChatMessage[]) => void,
|
||||
options: UseSSEChatOptions = {}
|
||||
) {
|
||||
const { getAuthToken, beforeRequest, onRequestError, onStreamComplete, getApiKeys } = options;
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const abort = () => {
|
||||
abortControllerRef.current?.abort();
|
||||
};
|
||||
|
||||
const send = async ({ text, history, openTabs, activeTabId, currentView, sessionId, mode }: SendParams) => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed || isLoading) return;
|
||||
if (beforeRequest && !beforeRequest()) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const userMessage: ChatMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: MessageRole.USER,
|
||||
content: trimmed,
|
||||
timestamp: new Date()
|
||||
};
|
||||
const assistantMessage: ChatMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: MessageRole.ASSISTANT,
|
||||
content: '',
|
||||
timestamp: new Date()
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, userMessage, assistantMessage]);
|
||||
|
||||
let handledError = false;
|
||||
let currentAssistantMessage = assistantMessage;
|
||||
|
||||
try {
|
||||
abortControllerRef.current = new AbortController();
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
const token = getAuthToken?.();
|
||||
if (token) {
|
||||
headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
messages: history
|
||||
.concat(userMessage)
|
||||
.filter((m) => [MessageRole.USER, MessageRole.ASSISTANT, MessageRole.SYSTEM].includes(m.role))
|
||||
.map((m) => ({ role: m.role, parts: [{ type: 'text', text: m.content }] })),
|
||||
openTabs,
|
||||
activeTabId,
|
||||
currentView,
|
||||
sessionId,
|
||||
mode,
|
||||
apiKeys: getApiKeys?.(),
|
||||
}),
|
||||
signal: abortControllerRef.current.signal
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = new Error(`HTTP error! status: ${response.status}`);
|
||||
handledError = Boolean(onRequestError?.(error, response));
|
||||
setMessages((prev) =>
|
||||
prev.filter((m) => !(m.id === currentAssistantMessage.id && m.content === ''))
|
||||
);
|
||||
if (handledError) {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
if (!reader) throw new Error('No response body');
|
||||
|
||||
let fullContent = '';
|
||||
let toolCallsActive: Record<string, string> = {};
|
||||
let hasToolCalls = false;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
const chunk = decoder.decode(value, { stream: true });
|
||||
const lines = chunk.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) continue;
|
||||
try {
|
||||
const data = JSON.parse(line.substring(6));
|
||||
|
||||
if (data.type === 'text-delta' && data.delta) {
|
||||
fullContent += data.delta;
|
||||
setMessages((prev) => prev.map((m) => (m.id === currentAssistantMessage.id ? { ...m, content: fullContent } : m)));
|
||||
}
|
||||
|
||||
if (data.type === 'tool-input-start') {
|
||||
hasToolCalls = true;
|
||||
toolCallsActive[data.toolCallId] = data.toolName;
|
||||
const toolMessage: ChatMessage = {
|
||||
id: `tool-${data.toolCallId}`,
|
||||
role: MessageRole.TOOL,
|
||||
content: data.toolName,
|
||||
timestamp: new Date(),
|
||||
toolName: data.toolName,
|
||||
status: 'running',
|
||||
toolArgs: (data.args ?? data.input ?? data.parameters ?? null)
|
||||
};
|
||||
setMessages((prev) => {
|
||||
const filtered = prev.filter((m) => m.id !== toolMessage.id);
|
||||
const index = filtered.findIndex((m) => m.id === currentAssistantMessage.id);
|
||||
return [...filtered.slice(0, index), toolMessage, ...filtered.slice(index)];
|
||||
});
|
||||
}
|
||||
|
||||
if (data.type === 'tool-output-available') {
|
||||
delete toolCallsActive[data.toolCallId];
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === `tool-${data.toolCallId}`
|
||||
? { ...m, content: `${m.toolName} ✓`, status: 'complete', toolResult: (data.result ?? data.output ?? null) }
|
||||
: m
|
||||
)
|
||||
);
|
||||
|
||||
if (Object.keys(toolCallsActive).length === 0 && hasToolCalls) {
|
||||
const newAssistantMessage: ChatMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: MessageRole.ASSISTANT,
|
||||
content: '',
|
||||
timestamp: new Date()
|
||||
};
|
||||
currentAssistantMessage = newAssistantMessage;
|
||||
fullContent = '';
|
||||
setMessages((prev) => [...prev, newAssistantMessage]);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed lines
|
||||
}
|
||||
}
|
||||
}
|
||||
if (onStreamComplete) {
|
||||
await onStreamComplete();
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err?.name !== 'AbortError') {
|
||||
if (!handledError) {
|
||||
handledError = Boolean(onRequestError?.(err));
|
||||
}
|
||||
if (!handledError) {
|
||||
setError(err?.message || 'Failed to send message');
|
||||
}
|
||||
setMessages((prev) =>
|
||||
prev.filter((m) => !(m.id === currentAssistantMessage.id && m.content === ''))
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
return { isLoading, error, send, abort };
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { TTSStatus } from './useAssistantTTS';
|
||||
|
||||
interface UseVoiceInterruptionOptions {
|
||||
amplitude: number;
|
||||
isVoiceActive: boolean;
|
||||
ttsStatus: TTSStatus;
|
||||
threshold?: number;
|
||||
holdDurationMs?: number;
|
||||
cooldownMs?: number;
|
||||
onInterruption: () => void;
|
||||
}
|
||||
|
||||
const DEFAULT_THRESHOLD = 0.18;
|
||||
const DEFAULT_HOLD_MS = 120;
|
||||
const DEFAULT_COOLDOWN_MS = 800;
|
||||
|
||||
export function useVoiceInterruption(options: UseVoiceInterruptionOptions) {
|
||||
const {
|
||||
amplitude,
|
||||
isVoiceActive,
|
||||
ttsStatus,
|
||||
onInterruption,
|
||||
threshold = DEFAULT_THRESHOLD,
|
||||
holdDurationMs = DEFAULT_HOLD_MS,
|
||||
cooldownMs = DEFAULT_COOLDOWN_MS,
|
||||
} = options;
|
||||
|
||||
const [isInterrupting, setIsInterrupting] = useState(false);
|
||||
const detectionTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const lastInterruptionAtRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (detectionTimeoutRef.current) {
|
||||
clearTimeout(detectionTimeoutRef.current);
|
||||
detectionTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const clearDetection = () => {
|
||||
if (detectionTimeoutRef.current) {
|
||||
clearTimeout(detectionTimeoutRef.current);
|
||||
detectionTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
if (!isVoiceActive || ttsStatus === 'idle') {
|
||||
clearDetection();
|
||||
if (isInterrupting) {
|
||||
setIsInterrupting(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (amplitude < threshold) {
|
||||
clearDetection();
|
||||
if (isInterrupting) {
|
||||
setIsInterrupting(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
if (now - lastInterruptionAtRef.current < cooldownMs) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (detectionTimeoutRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
detectionTimeoutRef.current = setTimeout(() => {
|
||||
lastInterruptionAtRef.current = Date.now();
|
||||
setIsInterrupting(true);
|
||||
onInterruption();
|
||||
}, holdDurationMs);
|
||||
}, [amplitude, cooldownMs, holdDurationMs, isInterrupting, isVoiceActive, onInterruption, threshold, ttsStatus]);
|
||||
|
||||
return { isInterrupting } as const;
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useCallback, useReducer } from 'react';
|
||||
|
||||
export type VoiceSessionStatus = 'idle' | 'listening' | 'thinking' | 'speaking';
|
||||
|
||||
export interface VoiceTranscriptSegment {
|
||||
id: string;
|
||||
text: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
interface VoiceSessionState {
|
||||
isActive: boolean;
|
||||
status: VoiceSessionStatus;
|
||||
interimTranscript: string;
|
||||
segments: VoiceTranscriptSegment[];
|
||||
amplitude: number;
|
||||
startedAt: number | null;
|
||||
}
|
||||
|
||||
type VoiceSessionAction =
|
||||
| { type: 'start' }
|
||||
| { type: 'stop' }
|
||||
| { type: 'set-status'; status: VoiceSessionStatus }
|
||||
| { type: 'set-amplitude'; amplitude: number }
|
||||
| { type: 'set-interim'; transcript: string }
|
||||
| { type: 'append-segment'; text: string }
|
||||
| { type: 'replace-segments'; segments: VoiceTranscriptSegment[] }
|
||||
| { type: 'reset-transcript' };
|
||||
|
||||
const initialState: VoiceSessionState = {
|
||||
isActive: false,
|
||||
status: 'idle',
|
||||
interimTranscript: '',
|
||||
segments: [],
|
||||
amplitude: 0,
|
||||
startedAt: null,
|
||||
};
|
||||
|
||||
function reducer(state: VoiceSessionState, action: VoiceSessionAction): VoiceSessionState {
|
||||
switch (action.type) {
|
||||
case 'start':
|
||||
return {
|
||||
...state,
|
||||
isActive: true,
|
||||
status: 'listening',
|
||||
interimTranscript: '',
|
||||
segments: [],
|
||||
amplitude: 0,
|
||||
startedAt: Date.now(),
|
||||
};
|
||||
case 'stop':
|
||||
return {
|
||||
...state,
|
||||
isActive: false,
|
||||
status: 'idle',
|
||||
amplitude: 0,
|
||||
interimTranscript: '',
|
||||
startedAt: null,
|
||||
};
|
||||
case 'set-status':
|
||||
return {
|
||||
...state,
|
||||
status: action.status,
|
||||
};
|
||||
case 'set-amplitude':
|
||||
return {
|
||||
...state,
|
||||
amplitude: Math.max(0, Math.min(1, action.amplitude)),
|
||||
};
|
||||
case 'set-interim':
|
||||
return {
|
||||
...state,
|
||||
interimTranscript: action.transcript,
|
||||
};
|
||||
case 'append-segment': {
|
||||
const trimmed = action.text.trim();
|
||||
if (!trimmed) return state;
|
||||
const segment: VoiceTranscriptSegment = {
|
||||
id: `voice-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
text: trimmed,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
return {
|
||||
...state,
|
||||
segments: [...state.segments, segment],
|
||||
};
|
||||
}
|
||||
case 'replace-segments':
|
||||
return {
|
||||
...state,
|
||||
segments: action.segments,
|
||||
};
|
||||
case 'reset-transcript':
|
||||
return {
|
||||
...state,
|
||||
interimTranscript: '',
|
||||
segments: [],
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export function useVoiceSession() {
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
|
||||
const startSession = useCallback(() => {
|
||||
dispatch({ type: 'start' });
|
||||
}, []);
|
||||
|
||||
const stopSession = useCallback(() => {
|
||||
dispatch({ type: 'stop' });
|
||||
}, []);
|
||||
|
||||
const setStatus = useCallback((status: VoiceSessionStatus) => {
|
||||
dispatch({ type: 'set-status', status });
|
||||
}, []);
|
||||
|
||||
const setAmplitude = useCallback((amplitude: number) => {
|
||||
dispatch({ type: 'set-amplitude', amplitude });
|
||||
}, []);
|
||||
|
||||
const setInterimTranscript = useCallback((transcript: string) => {
|
||||
dispatch({ type: 'set-interim', transcript });
|
||||
}, []);
|
||||
|
||||
const appendFinalTranscript = useCallback((text: string) => {
|
||||
dispatch({ type: 'append-segment', text });
|
||||
}, []);
|
||||
|
||||
const resetTranscript = useCallback(() => {
|
||||
dispatch({ type: 'reset-transcript' });
|
||||
}, []);
|
||||
|
||||
const replaceSegments = useCallback((segments: VoiceTranscriptSegment[]) => {
|
||||
dispatch({ type: 'replace-segments', segments });
|
||||
}, []);
|
||||
|
||||
return {
|
||||
...state,
|
||||
startSession,
|
||||
stopSession,
|
||||
setStatus,
|
||||
setAmplitude,
|
||||
setInterimTranscript,
|
||||
appendFinalTranscript,
|
||||
resetTranscript,
|
||||
replaceSegments,
|
||||
} as const;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { isLocalMode } from '@/config/runtime';
|
||||
import { apiKeyService } from '@/services/storage/apiKeys';
|
||||
|
||||
interface LocalKeyGateProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const panelStyle: React.CSSProperties = {
|
||||
maxWidth: 420,
|
||||
background: 'rgba(15, 15, 15, 0.92)',
|
||||
border: '1px solid #2a2a2a',
|
||||
borderRadius: 16,
|
||||
padding: '28px 32px',
|
||||
boxShadow: '0 18px 40px rgba(0,0,0,0.45)'
|
||||
};
|
||||
|
||||
const buttonStyle: React.CSSProperties = {
|
||||
background: '#22c55e',
|
||||
color: '#0b1113',
|
||||
border: 'none',
|
||||
borderRadius: 6,
|
||||
padding: '12px 18px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer'
|
||||
};
|
||||
|
||||
export function LocalKeyGate({ children }: LocalKeyGateProps) {
|
||||
const isLocal = useMemo(() => isLocalMode(), []);
|
||||
const [hasKeys, setHasKeys] = useState(() => (!isLocal) || apiKeyService.hasUserKeys());
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLocal) return;
|
||||
const handleUpdate = () => {
|
||||
setHasKeys(apiKeyService.hasUserKeys());
|
||||
};
|
||||
handleUpdate();
|
||||
const listener = () => handleUpdate();
|
||||
window.addEventListener('api-keys:updated', listener);
|
||||
return () => window.removeEventListener('api-keys:updated', listener);
|
||||
}, [isLocal]);
|
||||
|
||||
if (!isLocal || hasKeys) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
const openApiKeySettings = () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent('settings:open', { detail: { tab: 'apikeys' } }));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 24,
|
||||
right: 24,
|
||||
maxWidth: 420,
|
||||
zIndex: 9999
|
||||
}}
|
||||
>
|
||||
<div style={panelStyle}>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<h2 style={{ marginBottom: 8, fontSize: '20px' }}>Connect your AI keys</h2>
|
||||
<p style={{ color: '#b0b8c3', lineHeight: 1.6 }}>
|
||||
Local mode needs an OpenAI or Anthropic key. Add one under <strong>Settings → API Keys</strong>
|
||||
to unlock the workspace.
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 12, justifyContent: 'center', flexWrap: 'wrap' }}>
|
||||
<button style={buttonStyle} onClick={() => {
|
||||
openApiKeySettings();
|
||||
}}>
|
||||
Open API Key Settings
|
||||
</button>
|
||||
<button
|
||||
style={{ ...buttonStyle, background: '#1f2933', color: '#e5e7eb' }}
|
||||
onClick={() => setHasKeys(true)}
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
|
||||
import { CSSProperties, useState } from 'react';
|
||||
|
||||
interface ChipProps {
|
||||
label: string;
|
||||
onRemove?: () => void;
|
||||
color?: string;
|
||||
maxWidth?: number;
|
||||
icon?: React.ReactNode;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export default function Chip({
|
||||
label,
|
||||
onRemove,
|
||||
color = '#1a1a1a',
|
||||
maxWidth = 150,
|
||||
icon,
|
||||
style = {}
|
||||
}: ChipProps) {
|
||||
const [showTooltip, setShowTooltip] = useState(false);
|
||||
const needsTruncation = label.length > 20;
|
||||
const displayLabel = needsTruncation ? label.slice(0, 18) + '...' : label;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
display: 'inline-block',
|
||||
...style
|
||||
}}
|
||||
onMouseEnter={() => needsTruncation && setShowTooltip(true)}
|
||||
onMouseLeave={() => setShowTooltip(false)}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
background: color,
|
||||
color: '#fff',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '12px',
|
||||
fontSize: '10px',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
maxWidth: `${maxWidth}px`,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)'
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
<span style={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
}}>
|
||||
{displayLabel}
|
||||
</span>
|
||||
{onRemove && (
|
||||
<button
|
||||
onClick={onRemove}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#ccc',
|
||||
cursor: 'pointer',
|
||||
fontSize: '8px',
|
||||
padding: '0',
|
||||
lineHeight: 1,
|
||||
marginLeft: '2px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '12px',
|
||||
height: '12px',
|
||||
borderRadius: '50%',
|
||||
transition: 'background 0.2s'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.2)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'none';
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{/* Tooltip */}
|
||||
{showTooltip && needsTruncation && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '100%',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
marginBottom: '4px',
|
||||
padding: '4px 8px',
|
||||
background: '#333',
|
||||
color: '#fff',
|
||||
fontSize: '11px',
|
||||
borderRadius: '3px',
|
||||
whiteSpace: 'nowrap',
|
||||
zIndex: 1000,
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.5)',
|
||||
pointerEvents: 'none'
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
"use client";
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
open: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export default function ConfirmDialog({
|
||||
open,
|
||||
title,
|
||||
message,
|
||||
confirmLabel = 'Confirm',
|
||||
cancelLabel = 'Cancel',
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ConfirmDialogProps) {
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(0, 0, 0, 0.8)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 9999,
|
||||
padding: '20px'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '380px',
|
||||
maxWidth: '100%',
|
||||
background: '#050505',
|
||||
border: '1px solid #1f1f1f',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
boxShadow: '0 25px 65px rgba(0, 0, 0, 0.7), 0 0 0 1px rgba(255, 255, 255, 0.05)'
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
fontSize: '15px',
|
||||
fontWeight: 600,
|
||||
color: '#f8fafc',
|
||||
marginBottom: '12px',
|
||||
letterSpacing: '0.02em'
|
||||
}}>
|
||||
{title}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '13px',
|
||||
color: '#94a3b8',
|
||||
marginBottom: '24px',
|
||||
lineHeight: 1.6
|
||||
}}>
|
||||
{message}
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '12px' }}>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
style={{
|
||||
padding: '10px 16px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #1f1f1f',
|
||||
background: 'transparent',
|
||||
color: '#94a3b8',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
fontSize: '11px',
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#0f0f0f';
|
||||
e.currentTarget.style.borderColor = '#2a2a2a';
|
||||
e.currentTarget.style.color = '#cbd5f5';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
e.currentTarget.style.borderColor = '#1f1f1f';
|
||||
e.currentTarget.style.color = '#94a3b8';
|
||||
}}
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
style={{
|
||||
padding: '10px 16px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #dc2626',
|
||||
background: '#7f1d1d',
|
||||
color: '#fca5a5',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
fontSize: '11px',
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#991b1b';
|
||||
e.currentTarget.style.borderColor = '#b91c1c';
|
||||
e.currentTarget.style.color = '#fecaca';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = '#7f1d1d';
|
||||
e.currentTarget.style.borderColor = '#dc2626';
|
||||
e.currentTarget.style.color = '#fca5a5';
|
||||
}}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode, CSSProperties } from 'react';
|
||||
|
||||
interface EditableSectionProps {
|
||||
label: string;
|
||||
summary: ReactNode;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
children: ReactNode;
|
||||
metadata?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function EditableSection({
|
||||
label,
|
||||
summary,
|
||||
expanded,
|
||||
onToggle,
|
||||
children,
|
||||
metadata,
|
||||
disabled = false
|
||||
}: EditableSectionProps) {
|
||||
return (
|
||||
<div style={{
|
||||
marginBottom: '12px',
|
||||
background: '#1a1a1a',
|
||||
border: '1px solid #2a2a2a',
|
||||
borderRadius: '4px',
|
||||
overflow: 'visible'
|
||||
}}>
|
||||
<div
|
||||
onClick={disabled ? undefined : onToggle}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
cursor: disabled ? 'default' : 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
transition: 'background 0.2s',
|
||||
background: expanded ? '#202020' : 'transparent'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!disabled && !expanded) {
|
||||
e.currentTarget.style.background = '#1f1f1f';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!disabled && !expanded) {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
fontSize: '10px',
|
||||
fontWeight: 600,
|
||||
color: '#5c9aff',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
minWidth: '40px'
|
||||
}}>
|
||||
{label}
|
||||
</span>
|
||||
|
||||
<div style={{
|
||||
flex: 1,
|
||||
fontSize: '11px',
|
||||
color: '#888',
|
||||
lineHeight: '1.4',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
}}>
|
||||
{summary}
|
||||
</div>
|
||||
|
||||
{metadata && (
|
||||
<span style={{
|
||||
fontSize: '10px',
|
||||
color: '#555',
|
||||
flexShrink: 0
|
||||
}}>
|
||||
{metadata}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{!disabled && (
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
style={{
|
||||
color: '#555',
|
||||
transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)',
|
||||
transition: 'transform 0.2s'
|
||||
}}
|
||||
>
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
)}
|
||||
|
||||
{disabled && (
|
||||
<span style={{
|
||||
fontSize: '9px',
|
||||
color: '#555',
|
||||
background: '#252525',
|
||||
padding: '2px 4px',
|
||||
borderRadius: '2px'
|
||||
}}>
|
||||
locked
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div style={{
|
||||
padding: '12px',
|
||||
borderTop: '1px solid #2a2a2a',
|
||||
background: '#161616',
|
||||
position: 'relative'
|
||||
}}>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
|
||||
interface InputDialogProps {
|
||||
open: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
placeholder?: string;
|
||||
defaultValue?: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
onConfirm: (value: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export default function InputDialog({
|
||||
open,
|
||||
title,
|
||||
message,
|
||||
placeholder = '',
|
||||
defaultValue = '',
|
||||
confirmLabel = 'Confirm',
|
||||
cancelLabel = 'Cancel',
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: InputDialogProps) {
|
||||
const [inputValue, setInputValue] = useState(defaultValue);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setInputValue(defaultValue);
|
||||
// Focus input when dialog opens
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
}, 100);
|
||||
}
|
||||
}, [open, defaultValue]);
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (inputValue.trim()) {
|
||||
onConfirm(inputValue.trim());
|
||||
setInputValue('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleConfirm();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(0, 0, 0, 0.8)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 9999,
|
||||
padding: '20px'
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onCancel();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '380px',
|
||||
maxWidth: '100%',
|
||||
background: '#050505',
|
||||
border: '1px solid #1f1f1f',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
boxShadow: '0 25px 65px rgba(0, 0, 0, 0.7), 0 0 0 1px rgba(255, 255, 255, 0.05)'
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
fontSize: '15px',
|
||||
fontWeight: 600,
|
||||
color: '#f8fafc',
|
||||
marginBottom: '12px',
|
||||
letterSpacing: '0.02em'
|
||||
}}>
|
||||
{title}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '13px',
|
||||
color: '#94a3b8',
|
||||
marginBottom: '16px',
|
||||
lineHeight: 1.6
|
||||
}}>
|
||||
{message}
|
||||
</div>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
background: '#0f0f0f',
|
||||
border: '1px solid #2a2a2a',
|
||||
borderRadius: '8px',
|
||||
color: '#f8fafc',
|
||||
fontSize: '13px',
|
||||
marginBottom: '24px',
|
||||
outline: 'none',
|
||||
transition: 'border-color 0.2s'
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.target.style.borderColor = '#3a3a3a';
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.target.style.borderColor = '#2a2a2a';
|
||||
}}
|
||||
/>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '12px' }}>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
style={{
|
||||
padding: '10px 16px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #1f1f1f',
|
||||
background: 'transparent',
|
||||
color: '#94a3b8',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
fontSize: '11px',
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#0f0f0f';
|
||||
e.currentTarget.style.borderColor = '#2a2a2a';
|
||||
e.currentTarget.style.color = '#cbd5f5';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
e.currentTarget.style.borderColor = '#1f1f1f';
|
||||
e.currentTarget.style.color = '#94a3b8';
|
||||
}}
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={!inputValue.trim()}
|
||||
style={{
|
||||
padding: '10px 16px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #22c55e',
|
||||
background: inputValue.trim() ? '#1a3529' : '#0f1a15',
|
||||
color: inputValue.trim() ? '#7de8a5' : '#4a5a4f',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
fontSize: '11px',
|
||||
fontWeight: 500,
|
||||
cursor: inputValue.trim() ? 'pointer' : 'not-allowed',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (inputValue.trim()) {
|
||||
e.currentTarget.style.background = '#1f3d2f';
|
||||
e.currentTarget.style.borderColor = '#2dd47e';
|
||||
e.currentTarget.style.color = '#9ef5b8';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (inputValue.trim()) {
|
||||
e.currentTarget.style.background = '#1a3529';
|
||||
e.currentTarget.style.borderColor = '#22c55e';
|
||||
e.currentTarget.style.color = '#7de8a5';
|
||||
}
|
||||
}}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode, CSSProperties } from 'react';
|
||||
|
||||
interface PanelHeaderProps {
|
||||
title: string;
|
||||
leftContent?: ReactNode;
|
||||
rightContent?: ReactNode;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export default function PanelHeader({
|
||||
title,
|
||||
leftContent,
|
||||
rightContent,
|
||||
className = '',
|
||||
style = {}
|
||||
}: PanelHeaderProps) {
|
||||
return (
|
||||
<div
|
||||
className={`panel-header ${className}`}
|
||||
style={{
|
||||
height: '48px',
|
||||
padding: '0 12px',
|
||||
borderBottom: '1px solid rgba(42, 42, 42, 0.8)',
|
||||
background: '#0f0f0f',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
flexShrink: 0,
|
||||
...style
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
flex: 1,
|
||||
minWidth: 0
|
||||
}}>
|
||||
<span style={{
|
||||
textTransform: 'uppercase',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
color: '#888',
|
||||
letterSpacing: '0.05em',
|
||||
flexShrink: 0
|
||||
}}>
|
||||
{title}
|
||||
</span>
|
||||
{leftContent}
|
||||
</div>
|
||||
|
||||
{rightContent && (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
flexShrink: 0
|
||||
}}>
|
||||
{rightContent}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,376 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
interface DimensionSearchModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onDimensionSelect: (dimension: string) => void;
|
||||
existingDimensions: string[];
|
||||
}
|
||||
|
||||
interface DimensionSuggestion {
|
||||
dimension: string;
|
||||
count: number;
|
||||
isPriority: boolean;
|
||||
}
|
||||
|
||||
export default function DimensionSearchModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onDimensionSelect,
|
||||
existingDimensions
|
||||
}: DimensionSearchModalProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [suggestions, setSuggestions] = useState<DimensionSuggestion[]>([]);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const returnFocusRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
// Store the element that triggered the modal for return focus
|
||||
useEffect(() => {
|
||||
if (isOpen && document.activeElement instanceof HTMLElement) {
|
||||
returnFocusRef.current = document.activeElement;
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Focus trap and accessibility
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
// Autofocus input
|
||||
inputRef.current?.focus();
|
||||
|
||||
// Lock body scroll
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
// Handle Escape key
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
// Focus trap: keep focus within modal
|
||||
const handleTab = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Tab') return;
|
||||
|
||||
const focusableElements = modalRef.current?.querySelectorAll(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
);
|
||||
|
||||
if (!focusableElements || focusableElements.length === 0) return;
|
||||
|
||||
const firstElement = focusableElements[0] as HTMLElement;
|
||||
const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement;
|
||||
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === firstElement) {
|
||||
e.preventDefault();
|
||||
lastElement.focus();
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === lastElement) {
|
||||
e.preventDefault();
|
||||
firstElement.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
document.addEventListener('keydown', handleTab);
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
document.removeEventListener('keydown', handleTab);
|
||||
|
||||
// Return focus to trigger element
|
||||
if (returnFocusRef.current) {
|
||||
returnFocusRef.current.focus();
|
||||
}
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
// Fetch dimension suggestions
|
||||
useEffect(() => {
|
||||
const fetchSuggestions = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/dimensions/popular?limit=50');
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
const allDimensions: DimensionSuggestion[] = result.data;
|
||||
|
||||
// Filter based on search query and exclude existing dimensions
|
||||
const filtered = allDimensions.filter(dim => {
|
||||
const matchesQuery = !searchQuery.trim() ||
|
||||
dim.dimension.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const notExisting = !existingDimensions.includes(dim.dimension);
|
||||
return matchesQuery && notExisting;
|
||||
});
|
||||
|
||||
// Sort: priority first, then by count
|
||||
const sorted = filtered.sort((a, b) => {
|
||||
if (a.isPriority && !b.isPriority) return -1;
|
||||
if (!a.isPriority && b.isPriority) return 1;
|
||||
return b.count - a.count;
|
||||
});
|
||||
|
||||
setSuggestions(sorted.slice(0, 20));
|
||||
setSelectedIndex(0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching dimension suggestions:', error);
|
||||
setSuggestions([]);
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
const timeoutId = setTimeout(fetchSuggestions, 100);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
}, [searchQuery, existingDimensions, isOpen]);
|
||||
|
||||
// Handle keyboard navigation
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev => Math.min(prev + 1, suggestions.length - 1));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev => Math.max(prev - 1, 0));
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
|
||||
if (suggestions[selectedIndex]) {
|
||||
// Select existing dimension
|
||||
handleSelectDimension(suggestions[selectedIndex].dimension);
|
||||
} else if (searchQuery.trim()) {
|
||||
// Create new dimension
|
||||
handleSelectDimension(searchQuery.trim());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectDimension = (dimension: string) => {
|
||||
onDimensionSelect(dimension);
|
||||
setSearchQuery('');
|
||||
setSuggestions([]);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const canCreateNew = searchQuery.trim() &&
|
||||
!suggestions.some(s => s.dimension.toLowerCase() === searchQuery.toLowerCase());
|
||||
|
||||
const modalContent = (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(0, 0, 0, 0.8)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 9999,
|
||||
animation: 'fadeIn 150ms ease-out'
|
||||
}}
|
||||
onClick={handleBackdropClick}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Search dimensions"
|
||||
>
|
||||
<div
|
||||
ref={modalRef}
|
||||
style={{
|
||||
background: '#050505',
|
||||
border: '1px solid #1f1f1f',
|
||||
borderRadius: '12px',
|
||||
width: '90%',
|
||||
maxWidth: '500px',
|
||||
boxShadow: '0 25px 65px rgba(0, 0, 0, 0.7), 0 0 0 1px rgba(255, 255, 255, 0.05)',
|
||||
animation: 'slideIn 150ms ease-out'
|
||||
}}
|
||||
>
|
||||
{/* Search Input */}
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
borderBottom: (suggestions.length > 0 || canCreateNew) ? '1px solid #1f1f1f' : 'none'
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
background: '#0a0a0a',
|
||||
padding: '12px',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid #1f1f1f'
|
||||
}}>
|
||||
{/* Search Icon */}
|
||||
<svg width="16" height="16" viewBox="0 0 20 20" fill="#666">
|
||||
<path fillRule="evenodd" d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z" clipRule="evenodd" />
|
||||
</svg>
|
||||
|
||||
{/* Input */}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Search or create dimension..."
|
||||
style={{
|
||||
flex: 1,
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
color: '#fff',
|
||||
fontSize: '14px',
|
||||
fontFamily: 'inherit'
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Close Button */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#666',
|
||||
cursor: 'pointer',
|
||||
fontSize: '20px',
|
||||
padding: '0 4px',
|
||||
lineHeight: 1,
|
||||
transition: 'color 0.2s'
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.color = '#fff'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = '#666'; }}
|
||||
aria-label="Close search"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Suggestions */}
|
||||
{suggestions.length > 0 && (
|
||||
<div style={{
|
||||
maxHeight: '300px',
|
||||
overflowY: 'auto'
|
||||
}}>
|
||||
{suggestions.map((suggestion, index) => (
|
||||
<button
|
||||
key={suggestion.dimension}
|
||||
onClick={() => handleSelectDimension(suggestion.dimension)}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px 16px',
|
||||
textAlign: 'left',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
fontSize: '13px',
|
||||
background: index === selectedIndex ? '#252525' : 'transparent',
|
||||
border: 'none',
|
||||
borderBottom: index < suggestions.length - 1 ? '1px solid #1f1f1f' : 'none',
|
||||
color: '#e5e5e5',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.1s',
|
||||
fontFamily: 'inherit'
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{
|
||||
color: suggestion.isPriority ? '#22c55e' : '#e5e5e5'
|
||||
}}>
|
||||
{suggestion.dimension}
|
||||
</span>
|
||||
</div>
|
||||
<span style={{ color: '#666', fontSize: '11px' }}>
|
||||
{suggestion.count}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create New Option */}
|
||||
{canCreateNew && (
|
||||
<button
|
||||
onClick={() => handleSelectDimension(searchQuery.trim())}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px 16px',
|
||||
textAlign: 'left',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
fontSize: '13px',
|
||||
background: selectedIndex === suggestions.length ? '#252525' : 'transparent',
|
||||
border: 'none',
|
||||
borderTop: suggestions.length > 0 ? '1px solid #1f1f1f' : 'none',
|
||||
color: '#22c55e',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.1s',
|
||||
fontFamily: 'inherit'
|
||||
}}
|
||||
onMouseEnter={() => setSelectedIndex(suggestions.length)}
|
||||
>
|
||||
<span style={{ fontSize: '16px', fontWeight: 300 }}>+</span>
|
||||
Create "{searchQuery.trim()}"
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Keyboard Hint */}
|
||||
<div style={{
|
||||
padding: '12px 16px',
|
||||
borderTop: '1px solid #1f1f1f',
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: '16px',
|
||||
fontSize: '11px',
|
||||
color: '#666'
|
||||
}}>
|
||||
<span>↑↓ Navigate</span>
|
||||
<span>↵ Select</span>
|
||||
<span>Esc Close</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
|
||||
return typeof window !== 'undefined' ? createPortal(modalContent, document.body) : null;
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import DimensionSearchModal from './DimensionSearchModal';
|
||||
|
||||
interface DimensionTagsProps {
|
||||
dimensions: string[];
|
||||
priorityDimensions?: string[];
|
||||
onUpdate: (dimensions: string[]) => Promise<void>;
|
||||
onPriorityToggle?: (dimension: string) => Promise<void>;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface DimensionSuggestion {
|
||||
dimension: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export default function DimensionTags({
|
||||
dimensions,
|
||||
priorityDimensions = [],
|
||||
onUpdate,
|
||||
onPriorityToggle,
|
||||
disabled = false
|
||||
}: DimensionTagsProps) {
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [suggestions, setSuggestions] = useState<DimensionSuggestion[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Sort dimensions with priority ones first
|
||||
const sortedDimensions = [...dimensions].sort((a, b) => {
|
||||
const aPriority = priorityDimensions.includes(a);
|
||||
const bPriority = priorityDimensions.includes(b);
|
||||
if (aPriority && !bPriority) return -1;
|
||||
if (!aPriority && bPriority) return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isAdding && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, [isAdding]);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchQuery.length > 0) {
|
||||
fetchSuggestions(searchQuery);
|
||||
} else if (isAdding) {
|
||||
// Load popular dimensions when field is empty
|
||||
fetchPopularDimensions();
|
||||
} else {
|
||||
setSuggestions([]);
|
||||
}
|
||||
}, [searchQuery, isAdding]);
|
||||
|
||||
const fetchSuggestions = async (query: string) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/api/dimensions/search?q=${encodeURIComponent(query)}`);
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setSuggestions(data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching dimension suggestions:', error);
|
||||
setSuggestions([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchPopularDimensions = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetch('/api/dimensions/popular');
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setSuggestions(data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching popular dimensions:', error);
|
||||
setSuggestions([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addDimension = async (dimension: string) => {
|
||||
if (!dimension.trim() || dimensions.includes(dimension.trim())) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newDimensions = [...dimensions, dimension.trim()];
|
||||
await onUpdate(newDimensions);
|
||||
|
||||
setSearchQuery('');
|
||||
setSuggestions([]);
|
||||
setIsAdding(false);
|
||||
};
|
||||
|
||||
const removeDimension = async (index: number) => {
|
||||
const dimension = sortedDimensions[index];
|
||||
const newDimensions = dimensions.filter(d => d !== dimension);
|
||||
await onUpdate(newDimensions);
|
||||
};
|
||||
|
||||
const handleDragStart = (index: number) => {
|
||||
setDraggedIndex(index);
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent, index: number) => {
|
||||
e.preventDefault();
|
||||
if (draggedIndex === null || draggedIndex === index) return;
|
||||
|
||||
const draggedDimension = sortedDimensions[draggedIndex];
|
||||
const targetDimension = sortedDimensions[index];
|
||||
|
||||
// Find original positions in unsorted array
|
||||
const draggedOrigIndex = dimensions.indexOf(draggedDimension);
|
||||
const targetOrigIndex = dimensions.indexOf(targetDimension);
|
||||
|
||||
const newDimensions = [...dimensions];
|
||||
newDimensions.splice(draggedOrigIndex, 1);
|
||||
newDimensions.splice(targetOrigIndex, 0, draggedDimension);
|
||||
|
||||
onUpdate(newDimensions);
|
||||
setDraggedIndex(index);
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setDraggedIndex(null);
|
||||
};
|
||||
|
||||
const moveDimension = async (fromIndex: number, direction: 'up' | 'down') => {
|
||||
const toIndex = direction === 'up' ? fromIndex - 1 : fromIndex + 1;
|
||||
if (toIndex < 0 || toIndex >= sortedDimensions.length) return;
|
||||
|
||||
const fromDimension = sortedDimensions[fromIndex];
|
||||
const toDimension = sortedDimensions[toIndex];
|
||||
|
||||
// Find original positions in unsorted array
|
||||
const fromOrigIndex = dimensions.indexOf(fromDimension);
|
||||
const toOrigIndex = dimensions.indexOf(toDimension);
|
||||
|
||||
const newDimensions = [...dimensions];
|
||||
[newDimensions[fromOrigIndex], newDimensions[toOrigIndex]] = [newDimensions[toOrigIndex], newDimensions[fromOrigIndex]];
|
||||
await onUpdate(newDimensions);
|
||||
};
|
||||
|
||||
const togglePriority = async (dimension: string) => {
|
||||
if (onPriorityToggle) {
|
||||
await onPriorityToggle(dimension);
|
||||
}
|
||||
};
|
||||
|
||||
// Check if dimensions overflow 2 lines (approximate)
|
||||
const shouldShowExpandButton = sortedDimensions.length > 6; // Rough estimate for 2 lines
|
||||
const displayedDimensions = (!isExpanded && shouldShowExpandButton)
|
||||
? sortedDimensions.slice(0, 6)
|
||||
: sortedDimensions;
|
||||
const hiddenCount = sortedDimensions.length - 6;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
ref={containerRef}
|
||||
onClick={() => {
|
||||
if (shouldShowExpandButton && !isExpanded) {
|
||||
setIsExpanded(true);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '6px',
|
||||
marginBottom: '8px',
|
||||
cursor: (shouldShowExpandButton && !isExpanded) ? 'pointer' : 'default',
|
||||
position: 'relative',
|
||||
minHeight: dimensions.length === 0 ? '24px' : 'auto'
|
||||
}}
|
||||
>
|
||||
{/* Show placeholder when no dimensions */}
|
||||
{dimensions.length === 0 && !disabled && (
|
||||
<span style={{
|
||||
fontSize: '11px',
|
||||
color: '#555',
|
||||
fontStyle: 'italic',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px'
|
||||
}}>
|
||||
No dimensions
|
||||
</span>
|
||||
)}
|
||||
|
||||
{displayedDimensions.map((dimension, index) => {
|
||||
const isPriority = priorityDimensions.includes(dimension);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${dimension}-${index}`}
|
||||
draggable={!disabled}
|
||||
onDragStart={() => handleDragStart(index)}
|
||||
onDragOver={(e) => handleDragOver(e, index)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!disabled && onPriorityToggle) {
|
||||
togglePriority(dimension);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '3px',
|
||||
fontSize: '10px',
|
||||
color: isPriority ? '#22c55e' : '#d1d5db', /* Changed from gold to green */
|
||||
background: isPriority ? '#0f2417' : '#1a1a1a', /* Green-tinted background */
|
||||
border: isPriority ? '1px solid #166534' : '1px solid #333', /* Green border */
|
||||
borderRadius: '8px',
|
||||
padding: '2px 6px',
|
||||
cursor: disabled ? 'default' : (onPriorityToggle ? 'pointer' : 'grab'),
|
||||
opacity: draggedIndex === index ? 0.5 : 1,
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!disabled) {
|
||||
e.currentTarget.style.borderColor = isPriority ? '#22c55e' : '#555'; /* Green hover */
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = isPriority ? '#166534' : '#333'; /* Green default */
|
||||
}}
|
||||
title={isPriority ? 'Priority dimension (click to unpin)' : 'Click to pin as priority dimension'}
|
||||
>
|
||||
<span>{dimension}</span>
|
||||
|
||||
{/* Reorder buttons removed - no longer needed */}
|
||||
|
||||
{/* Remove button */}
|
||||
{!disabled && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeDimension(index);
|
||||
}}
|
||||
style={{
|
||||
padding: '0 2px',
|
||||
fontSize: '14px',
|
||||
color: '#666',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
marginLeft: '2px'
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.color = '#ff6b6b'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = '#666'; }}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Show "+X more" indicator */}
|
||||
{shouldShowExpandButton && !isExpanded && hiddenCount > 0 && (
|
||||
<div
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsExpanded(true);
|
||||
}}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
fontSize: '11px',
|
||||
color: '#666',
|
||||
background: 'transparent',
|
||||
border: '1px dashed #333',
|
||||
borderRadius: '12px',
|
||||
padding: '2px 8px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.color = '#999';
|
||||
e.currentTarget.style.borderColor = '#444';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = '#666';
|
||||
e.currentTarget.style.borderColor = '#333';
|
||||
}}
|
||||
>
|
||||
+{hiddenCount} more
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Collapse button when expanded */}
|
||||
{isExpanded && shouldShowExpandButton && (
|
||||
<div
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsExpanded(false);
|
||||
}}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
fontSize: '11px',
|
||||
color: '#666',
|
||||
background: 'transparent',
|
||||
border: '1px dashed #333',
|
||||
borderRadius: '12px',
|
||||
padding: '2px 8px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.color = '#999';
|
||||
e.currentTarget.style.borderColor = '#444';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = '#666';
|
||||
e.currentTarget.style.borderColor = '#333';
|
||||
}}
|
||||
>
|
||||
show less
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add dimension button - Opens modal */}
|
||||
{!disabled && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsAdding(true);
|
||||
}}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
color: '#22c55e',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.1em',
|
||||
borderBottom: '1px solid #1a1a1a',
|
||||
background: '#0a0a0a',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: '8px 12px',
|
||||
borderRadius: '8px',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#151515';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = '#0a0a0a';
|
||||
}}
|
||||
title="Add dimension"
|
||||
>
|
||||
<span style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
borderRadius: '50%',
|
||||
background: '#22c55e',
|
||||
color: '#0a0a0a',
|
||||
fontSize: '12px',
|
||||
lineHeight: 1,
|
||||
fontWeight: 300,
|
||||
flexShrink: 0
|
||||
}}>+</span>
|
||||
Add
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dimension Search Modal */}
|
||||
<DimensionSearchModal
|
||||
isOpen={isAdding}
|
||||
onClose={() => setIsAdding(false)}
|
||||
onDimensionSelect={(dim) => {
|
||||
addDimension(dim);
|
||||
}}
|
||||
existingDimensions={dimensions}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
interface EdgeSearchModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onEdgeCreate: (targetNodeId: number, targetNodeTitle: string) => void;
|
||||
sourceNodeId: number;
|
||||
}
|
||||
|
||||
interface NodeSuggestion {
|
||||
id: number;
|
||||
title: string;
|
||||
dimensions?: string[];
|
||||
}
|
||||
|
||||
export default function EdgeSearchModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onEdgeCreate,
|
||||
sourceNodeId
|
||||
}: EdgeSearchModalProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [suggestions, setSuggestions] = useState<NodeSuggestion[]>([]);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const returnFocusRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
// Store the element that triggered the modal for return focus
|
||||
useEffect(() => {
|
||||
if (isOpen && document.activeElement instanceof HTMLElement) {
|
||||
returnFocusRef.current = document.activeElement;
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Focus trap and accessibility
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
// Autofocus input
|
||||
inputRef.current?.focus();
|
||||
|
||||
// Lock body scroll
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
// Handle Escape key
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
// Focus trap: keep focus within modal
|
||||
const handleTab = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Tab') return;
|
||||
|
||||
const focusableElements = modalRef.current?.querySelectorAll(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
);
|
||||
|
||||
if (!focusableElements || focusableElements.length === 0) return;
|
||||
|
||||
const firstElement = focusableElements[0] as HTMLElement;
|
||||
const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement;
|
||||
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === firstElement) {
|
||||
e.preventDefault();
|
||||
lastElement.focus();
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === lastElement) {
|
||||
e.preventDefault();
|
||||
firstElement.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
document.addEventListener('keydown', handleTab);
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
document.removeEventListener('keydown', handleTab);
|
||||
|
||||
// Return focus to trigger element
|
||||
if (returnFocusRef.current) {
|
||||
returnFocusRef.current.focus();
|
||||
}
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
// Generate suggestions based on search query
|
||||
useEffect(() => {
|
||||
if (!searchQuery.trim()) {
|
||||
setSuggestions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchSuggestions = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/nodes/search?q=${encodeURIComponent(searchQuery)}&limit=10`);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
const nodeSuggestions: NodeSuggestion[] = result.data
|
||||
.filter((node: any) => node.id !== sourceNodeId) // Exclude source node
|
||||
.map((node: any) => ({
|
||||
id: node.id,
|
||||
title: node.title,
|
||||
dimensions: node.dimensions || []
|
||||
}));
|
||||
|
||||
setSuggestions(nodeSuggestions);
|
||||
setSelectedIndex(0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching suggestions:', error);
|
||||
setSuggestions([]);
|
||||
}
|
||||
};
|
||||
|
||||
const timeoutId = setTimeout(fetchSuggestions, 200);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [searchQuery, sourceNodeId]);
|
||||
|
||||
// Handle keyboard navigation
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev => Math.min(prev + 1, suggestions.length - 1));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev => Math.max(prev - 1, 0));
|
||||
} else if (e.key === 'Enter' && suggestions[selectedIndex]) {
|
||||
e.preventDefault();
|
||||
handleSelectSuggestion(suggestions[selectedIndex]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectSuggestion = (suggestion: NodeSuggestion) => {
|
||||
onEdgeCreate(suggestion.id, suggestion.title);
|
||||
setSearchQuery('');
|
||||
setSuggestions([]);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const modalContent = (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(0, 0, 0, 0.8)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 9999,
|
||||
animation: 'fadeIn 150ms ease-out'
|
||||
}}
|
||||
onClick={handleBackdropClick}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Search nodes to connect"
|
||||
>
|
||||
<div
|
||||
ref={modalRef}
|
||||
style={{
|
||||
background: '#1a1a1a',
|
||||
border: '1px solid #333',
|
||||
borderRadius: '8px',
|
||||
width: '90%',
|
||||
maxWidth: '600px',
|
||||
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.6)',
|
||||
animation: 'slideIn 150ms ease-out'
|
||||
}}
|
||||
>
|
||||
{/* Search Input */}
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
borderBottom: suggestions.length > 0 ? '1px solid #2a2a2a' : 'none'
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
background: '#0a0a0a',
|
||||
padding: '12px',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid #333'
|
||||
}}>
|
||||
{/* Search Icon */}
|
||||
<svg width="16" height="16" viewBox="0 0 20 20" fill="#666">
|
||||
<path fillRule="evenodd" d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z" clipRule="evenodd" />
|
||||
</svg>
|
||||
|
||||
{/* Input */}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Search node to connect..."
|
||||
style={{
|
||||
flex: 1,
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
color: '#fff',
|
||||
fontSize: '14px',
|
||||
fontFamily: 'inherit'
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Close Button */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#666',
|
||||
cursor: 'pointer',
|
||||
fontSize: '20px',
|
||||
padding: '0 4px',
|
||||
lineHeight: 1,
|
||||
transition: 'color 0.2s'
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.color = '#fff'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = '#666'; }}
|
||||
aria-label="Close search"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Suggestions */}
|
||||
{suggestions.length > 0 && (
|
||||
<div style={{
|
||||
maxHeight: '300px',
|
||||
overflowY: 'auto'
|
||||
}}>
|
||||
{suggestions.map((suggestion, index) => {
|
||||
const primaryDimension = suggestion.dimensions && suggestion.dimensions.length > 0
|
||||
? suggestion.dimensions[0]
|
||||
: '';
|
||||
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => handleSelectSuggestion(suggestion)}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px 16px',
|
||||
textAlign: 'left',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
fontSize: '13px',
|
||||
background: index === selectedIndex ? '#252525' : 'transparent',
|
||||
border: 'none',
|
||||
borderBottom: index < suggestions.length - 1 ? '1px solid #2a2a2a' : 'none',
|
||||
color: '#ccc',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.1s',
|
||||
fontFamily: 'inherit'
|
||||
}}
|
||||
>
|
||||
{primaryDimension && (
|
||||
<span style={{
|
||||
color: '#666',
|
||||
fontSize: '10px',
|
||||
fontWeight: 600,
|
||||
minWidth: '60px',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em'
|
||||
}}>
|
||||
{primaryDimension}
|
||||
</span>
|
||||
)}
|
||||
<span style={{
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
color: '#e5e5e5'
|
||||
}}>
|
||||
{suggestion.title}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Keyboard Hint */}
|
||||
<div style={{
|
||||
padding: '12px 16px',
|
||||
borderTop: '1px solid #2a2a2a',
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: '16px',
|
||||
fontSize: '11px',
|
||||
color: '#666'
|
||||
}}>
|
||||
<span>↑↓ Navigate</span>
|
||||
<span>↵ Select</span>
|
||||
<span>Esc Close</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
|
||||
return typeof window !== 'undefined' ? createPortal(modalContent, document.body) : null;
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from 'react';
|
||||
import { parseAndRenderContent } from './NodeLabelRenderer';
|
||||
|
||||
interface MarkdownRendererProps {
|
||||
content: string;
|
||||
streaming?: boolean;
|
||||
onNodeClick?: (nodeId: number) => void;
|
||||
}
|
||||
|
||||
export default function MarkdownRenderer({ content, streaming, onNodeClick }: MarkdownRendererProps) {
|
||||
if (!content) return null;
|
||||
|
||||
const segments = splitCodeBlocks(content);
|
||||
return (
|
||||
<div style={{ color: '#e5e5e5', fontSize: 16, lineHeight: 1.7, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
|
||||
{segments.map((seg, i) =>
|
||||
seg.type === 'code' ? (
|
||||
<CodeBlock key={i} language={seg.lang} code={seg.text} />
|
||||
) : (
|
||||
<span key={i}>{renderTextWithFormatting(transformMarkdownNodeLinks(seg.text), onNodeClick)}</span>
|
||||
)
|
||||
)}
|
||||
{streaming ? (
|
||||
<span style={{ display: 'inline-block', width: 3, height: 12, marginLeft: 2, background: 'rgba(200,200,200,0.5)', verticalAlign: 'baseline' }} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeBlock({ code, language }: { code: string; language?: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1000);
|
||||
} catch {}
|
||||
};
|
||||
return (
|
||||
<div style={{ margin: '8px 0' }}>
|
||||
<div style={{
|
||||
background: '#0f0f0f', border: '1px solid #2a2a2a', borderRadius: 6,
|
||||
padding: 8, overflowX: 'auto', fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', fontSize: 12
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 6 }}>
|
||||
<span style={{ color: '#8a8a8a', fontSize: 11 }}>{language || 'code'}</span>
|
||||
<button onClick={handleCopy} style={{ marginLeft: 'auto', fontSize: 11, color: '#8a8a8a', background: 'transparent', border: '1px solid #2a2a2a', borderRadius: 4, padding: '1px 6px', cursor: 'pointer' }}>
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
<pre style={{ margin: 0 }}>
|
||||
<code>{code}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderTextWithFormatting(text: string, onNodeClick?: (nodeId: number) => void): React.ReactNode[] {
|
||||
// Extract source quotes ONLY (pattern: > "quote text")
|
||||
const quoteSegments = splitSourceQuotes(text);
|
||||
|
||||
return quoteSegments.flatMap((segment, segIdx) => {
|
||||
if (segment.type === 'quote') {
|
||||
return (
|
||||
<div key={`quote-${segIdx}`} style={{
|
||||
margin: '12px 0',
|
||||
padding: '10px 14px',
|
||||
borderLeft: '3px solid #333',
|
||||
background: '#0f0f0f',
|
||||
fontStyle: 'italic',
|
||||
color: '#b8b8b8',
|
||||
position: 'relative'
|
||||
}}>
|
||||
<span style={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
left: 8,
|
||||
fontSize: 24,
|
||||
color: '#3a3a3a',
|
||||
lineHeight: 1
|
||||
}}>"</span>
|
||||
<div style={{ paddingLeft: 12 }}>
|
||||
{parseInlineFormatting(segment.text, onNodeClick)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return <span key={`text-${segIdx}`}>{parseInlineFormatting(segment.text, onNodeClick)}</span>;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function splitSourceQuotes(input: string): Array<{ type: 'text' | 'quote'; text: string }> {
|
||||
const out: Array<{ type: 'text' | 'quote'; text: string }> = [];
|
||||
|
||||
// ONLY match quotes that start with "> " (blockquote syntax from search tool)
|
||||
// This ensures we don't break [NODE:ID:"title"] patterns
|
||||
const quotePattern = /^>\s+"(.+?)"$/gm;
|
||||
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = quotePattern.exec(input)) !== null) {
|
||||
// Add text before quote
|
||||
if (match.index > lastIndex) {
|
||||
out.push({ type: 'text', text: input.slice(lastIndex, match.index) });
|
||||
}
|
||||
|
||||
// Add the quote content (without the > and quote marks)
|
||||
out.push({ type: 'quote', text: match[1] });
|
||||
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
// Add remaining text
|
||||
if (lastIndex < input.length) {
|
||||
out.push({ type: 'text', text: input.slice(lastIndex) });
|
||||
}
|
||||
|
||||
return out.length > 0 ? out : [{ type: 'text', text: input }];
|
||||
}
|
||||
|
||||
function parseInlineFormatting(text: string, onNodeClick?: (nodeId: number) => void): React.ReactNode[] {
|
||||
// Pattern for **bold** text
|
||||
const boldPattern = /\*\*(.+?)\*\*/g;
|
||||
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
// First pass: handle bold text
|
||||
const textParts: Array<{ type: 'text' | 'bold'; text: string }> = [];
|
||||
while ((match = boldPattern.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
textParts.push({ type: 'text', text: text.slice(lastIndex, match.index) });
|
||||
}
|
||||
textParts.push({ type: 'bold', text: match[1] });
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
if (lastIndex < text.length) {
|
||||
textParts.push({ type: 'text', text: text.slice(lastIndex) });
|
||||
}
|
||||
|
||||
// If no bold text found, just use the original text
|
||||
if (textParts.length === 0) {
|
||||
textParts.push({ type: 'text', text });
|
||||
}
|
||||
|
||||
// Second pass: render each part with node label parsing
|
||||
return textParts.flatMap((part, idx) => {
|
||||
if (part.type === 'bold') {
|
||||
return (
|
||||
<strong key={`bold-${idx}`} style={{
|
||||
fontWeight: 600,
|
||||
textDecoration: 'underline',
|
||||
textDecorationColor: '#4a4a4a',
|
||||
textDecorationThickness: '1px',
|
||||
textUnderlineOffset: '2px',
|
||||
color: '#f0f0f0'
|
||||
}}>
|
||||
{parseAndRenderContent(part.text, onNodeClick)}
|
||||
</strong>
|
||||
);
|
||||
} else {
|
||||
return <span key={`text-${idx}`}>{parseAndRenderContent(part.text, onNodeClick)}</span>;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function splitCodeBlocks(input: string): Array<{ type: 'text' | 'code'; text: string; lang?: string }>
|
||||
{
|
||||
const out: Array<{ type: 'text' | 'code'; text: string; lang?: string }> = [];
|
||||
const codeFence = /```([a-zA-Z0-9_-]+)?\n([\s\S]*?)```/g;
|
||||
let lastIndex = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = codeFence.exec(input)) !== null) {
|
||||
if (m.index > lastIndex) out.push({ type: 'text', text: input.slice(lastIndex, m.index) });
|
||||
out.push({ type: 'code', lang: m[1], text: m[2] });
|
||||
lastIndex = m.index + m[0].length;
|
||||
}
|
||||
if (lastIndex < input.length) out.push({ type: 'text', text: input.slice(lastIndex) });
|
||||
return out;
|
||||
}
|
||||
|
||||
// Convert markdown links that point to #NODE:ID:"Title" into the inline token [NODE:ID:"Title"]
|
||||
function transformMarkdownNodeLinks(input: string): string {
|
||||
if (!input) return input;
|
||||
// Use non-greedy match (.+?) to handle quotes inside titles
|
||||
const nodeLink = /\[[^\]]*\]\(\s*#NODE:\s*(\d+)\s*:\s*["""'](.+?)["""']\s*\)/g;
|
||||
return input.replace(nodeLink, (_m, id, title) => `[NODE:${id}:"${title}"]`);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
import { FileText } from 'lucide-react';
|
||||
|
||||
interface NodeLabelProps {
|
||||
id: string;
|
||||
title: string;
|
||||
dimensions: string[];
|
||||
onNodeClick?: (nodeId: number) => void;
|
||||
}
|
||||
|
||||
function NodeLabel({ id, title, dimensions, onNodeClick }: NodeLabelProps) {
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
// Prevent bubbling into parent containers (e.g., content view onClick that toggles edit)
|
||||
e.stopPropagation();
|
||||
if (onNodeClick) {
|
||||
onNodeClick(parseInt(id));
|
||||
}
|
||||
};
|
||||
|
||||
const maxTitleLength = 40;
|
||||
const truncatedTitle = title.length > maxTitleLength
|
||||
? `${title.substring(0, maxTitleLength)}...`
|
||||
: title;
|
||||
const showTooltip = title.length > maxTitleLength;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Clickable ID badge - inline with existing line height */}
|
||||
<span
|
||||
onClick={handleClick}
|
||||
title={showTooltip ? title : undefined}
|
||||
style={{
|
||||
display: 'inline',
|
||||
padding: '2px 6px',
|
||||
background: '#22c55e',
|
||||
color: '#000',
|
||||
borderRadius: '3px',
|
||||
fontSize: '11px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
marginRight: '4px',
|
||||
lineHeight: '1', /* prevent line height issues */
|
||||
verticalAlign: 'baseline'
|
||||
}}
|
||||
>
|
||||
#{id}
|
||||
</span>
|
||||
{/* Non-clickable title - bold and underlined */}
|
||||
<span style={{
|
||||
fontWeight: 'bold',
|
||||
textDecoration: 'underline',
|
||||
color: '#e5e5e5'
|
||||
}}>
|
||||
{truncatedTitle}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function parseAndRenderContent(content: string, onNodeClick?: (nodeId: number) => void): React.ReactNode[] {
|
||||
if (!content) return [content];
|
||||
|
||||
// Pattern to match [NODE:id:"title"] (dimensions removed)
|
||||
// Be tolerant of spaces and curly quotes
|
||||
// Use non-greedy match (.+?) to handle quotes inside titles
|
||||
const nodePattern = /\[NODE:\s*(\d+)\s*:\s*["""'](.+?)["""']\s*\]/g;
|
||||
|
||||
const parts: React.ReactNode[] = [];
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
|
||||
while ((match = nodePattern.exec(content)) !== null) {
|
||||
// Add text before the node
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(content.substring(lastIndex, match.index));
|
||||
}
|
||||
|
||||
// Parse the node data
|
||||
const id = match[1];
|
||||
const title = match[2];
|
||||
const dimensions: string[] = []; // No dimensions in new format
|
||||
|
||||
// Add the node label
|
||||
parts.push(
|
||||
<NodeLabel
|
||||
key={`node-${id}-${match.index}`}
|
||||
id={id}
|
||||
title={title}
|
||||
dimensions={dimensions}
|
||||
onNodeClick={onNodeClick}
|
||||
/>
|
||||
);
|
||||
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
// Add any remaining text
|
||||
if (lastIndex < content.length) {
|
||||
parts.push(content.substring(lastIndex));
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts : [content];
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
interface ReasoningTraceProps {
|
||||
trace?: {
|
||||
purpose?: string;
|
||||
thoughts?: string;
|
||||
next_action?: string;
|
||||
step?: number;
|
||||
done?: boolean;
|
||||
timestamp?: string;
|
||||
} | null;
|
||||
collapsible?: boolean;
|
||||
}
|
||||
|
||||
export default function ReasoningTrace({ trace, collapsible = true }: ReasoningTraceProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
if (!trace) return null;
|
||||
const short = (s?: string, n = 180) => (s || '').slice(0, n) + ((s || '').length > n ? '…' : '');
|
||||
return (
|
||||
<div style={{
|
||||
margin: '6px 0', padding: '8px 10px',
|
||||
background: '#0e0e12', border: '1px solid #2a2a2a',
|
||||
borderLeft: '2px solid #6b6b7f', borderRadius: 6,
|
||||
fontSize: 12, color: '#cfcfcf', lineHeight: 1.5
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
||||
<span style={{ color: '#8b8b9f', fontWeight: 600 }}>Reasoning trace</span>
|
||||
{trace.timestamp ? (
|
||||
<span style={{ color: '#4a4a4a', fontSize: 11 }}>{new Date(trace.timestamp).toLocaleTimeString()}</span>
|
||||
) : null}
|
||||
<span style={{ marginLeft: 'auto', color: '#4a4a4a', fontSize: 11 }}>{trace.done ? 'final' : 'in-progress'}</span>
|
||||
</div>
|
||||
{/* Collapsed: show only the purpose as the title */}
|
||||
{trace.purpose ? (
|
||||
<div style={{ color: '#9adbc2' }}>Goal: {expanded ? trace.purpose : short(trace.purpose, 80)}</div>
|
||||
) : null}
|
||||
{/* Expanded: show details */}
|
||||
{expanded && trace.thoughts ? <div style={{ marginTop: 6 }}>{trace.thoughts}</div> : null}
|
||||
{expanded && trace.next_action ? (
|
||||
<div style={{ marginTop: 6, color: '#a5b4fc' }}>Next: {trace.next_action}</div>
|
||||
) : null}
|
||||
{collapsible ? (
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((e) => !e)}
|
||||
style={{
|
||||
background: 'transparent', border: '1px solid #2a2a2a', color: '#8a8a8a',
|
||||
fontSize: 11, padding: '2px 6px', borderRadius: 4, cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
{expanded ? 'Hide reasoning' : 'Show full reasoning'}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
interface SourceChipProps {
|
||||
url?: string;
|
||||
domain?: string;
|
||||
}
|
||||
|
||||
function extractDomain(input?: string) {
|
||||
if (!input) return '';
|
||||
try {
|
||||
const d = input.includes('://') ? new URL(input).hostname : input;
|
||||
return d.replace(/^www\./, '');
|
||||
} catch {
|
||||
return input.replace(/^www\./, '');
|
||||
}
|
||||
}
|
||||
|
||||
export default function SourceChip({ url, domain }: SourceChipProps) {
|
||||
const d = extractDomain(domain || url);
|
||||
const favicon = d ? `https://icons.duckduckgo.com/ip3/${d}.ico` : '';
|
||||
return (
|
||||
<span
|
||||
title={url || d}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
background: '#1a1a1a',
|
||||
border: '1px solid #2a2a2a',
|
||||
borderRadius: 999,
|
||||
padding: '2px 8px',
|
||||
fontSize: 11,
|
||||
color: '#bdbdbd',
|
||||
lineHeight: 1
|
||||
}}
|
||||
>
|
||||
{d ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={favicon}
|
||||
alt=""
|
||||
width={12}
|
||||
height={12}
|
||||
onError={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
style={{ borderRadius: 2 }}
|
||||
/>
|
||||
) : null}
|
||||
<span>{d || 'source'}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
"use client";
|
||||
|
||||
import SourceChip from './SourceChip';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface ToolDisplayProps {
|
||||
name: string;
|
||||
status: 'starting' | 'running' | 'complete' | 'error';
|
||||
context?: string;
|
||||
sources?: Array<{ url?: string; domain?: string }> | string[];
|
||||
result?: any;
|
||||
error?: string;
|
||||
onNodeClick?: (nodeId: number) => void;
|
||||
}
|
||||
|
||||
export default function ToolDisplay({ name, status, context, sources, result, error, onNodeClick }: ToolDisplayProps) {
|
||||
const isRunning = status === 'starting' || status === 'running';
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const accentColor = '#22c55e';
|
||||
const bgTint = '#0b0b0b';
|
||||
const displayName = name;
|
||||
|
||||
// Normalize sources to array of objects
|
||||
const normalizedSources: Array<{ url?: string; domain?: string }> = Array.isArray(sources)
|
||||
? sources.map((s: any) => (typeof s === 'string' ? { domain: s } : s))
|
||||
: [];
|
||||
|
||||
// Web search simple preview renderer
|
||||
const renderWebSearchPreview = () => {
|
||||
const items = result?.data?.results || result?.results || [];
|
||||
if (!Array.isArray(items) || items.length === 0) return null;
|
||||
return (
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((e) => !e)}
|
||||
style={{
|
||||
background: 'transparent', border: '1px solid #2a2a2a', color: '#8a8a8a',
|
||||
fontSize: 11, padding: '2px 6px', borderRadius: 4, cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
{expanded ? 'Hide results' : `Show results (${Math.min(items.length, 3)})`}
|
||||
</button>
|
||||
{expanded ? (
|
||||
<div style={{ display: 'grid', gap: 6, marginTop: 6 }}>
|
||||
{items.slice(0, 3).map((r: any, i: number) => (
|
||||
<div key={i} style={{
|
||||
background: '#0f0f0f',
|
||||
border: '1px solid #2a2a2a',
|
||||
borderRadius: 6,
|
||||
padding: '6px 8px',
|
||||
fontSize: 12,
|
||||
color: '#cfcfcf'
|
||||
}}>
|
||||
<div style={{ color: '#e5e5e5', fontWeight: 600 }}>{r.title || 'Result'}</div>
|
||||
{r.snippet ? <div style={{ color: '#9a9a9a', marginTop: 2 }}>{r.snippet}</div> : null}
|
||||
{r.url ? (
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<a href={r.url} target="_blank" rel="noreferrer" style={{ color: '#5c9aff', fontSize: 11 }}>
|
||||
{r.url}
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
margin: '8px 0',
|
||||
padding: '10px 12px',
|
||||
background: bgTint,
|
||||
border: '1px solid #2a2a2a',
|
||||
borderLeft: `2px solid ${accentColor}`,
|
||||
borderRadius: 6,
|
||||
fontSize: 12,
|
||||
color: '#cfcfcf'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
||||
{isRunning ? (
|
||||
<span style={{
|
||||
display: 'inline-block', width: 12, height: 12,
|
||||
border: '2px solid #2a2a2a', borderTopColor: accentColor,
|
||||
borderRadius: '50%', animation: 'spin 1s linear infinite'
|
||||
}} />
|
||||
) : (
|
||||
<span style={{ color: accentColor, fontSize: 12 }}>{status === 'error' ? '✗' : '✓'}</span>
|
||||
)}
|
||||
<span style={{ color: accentColor, fontWeight: 600 }}>{displayName}</span>
|
||||
<span style={{ marginLeft: 'auto', color: '#4a4a4a', fontSize: 11 }}>{status}</span>
|
||||
</div>
|
||||
{context ? <div style={{ color: '#9adbc2' }}>{context}</div> : null}
|
||||
{normalizedSources.length > 0 ? (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 6 }}>
|
||||
{normalizedSources.slice(0, 6).map((s, i) => (
|
||||
<SourceChip key={i} url={s.url} domain={s.domain} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{/* Specific previews */}
|
||||
{name === 'webSearch' ? renderWebSearchPreview() : null}
|
||||
{name === 'websiteExtract' ? (
|
||||
<WebsiteExtractSummary result={result} onNodeClick={onNodeClick} />
|
||||
) : null}
|
||||
{error ? (
|
||||
<div style={{ color: '#ff6b6b', marginTop: 6 }}>Error: {error}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WebsiteExtractSummary({ result, onNodeClick }: { result?: any; onNodeClick?: (id: number) => void }) {
|
||||
const data = result?.data || {};
|
||||
const title = data.title || 'Website added';
|
||||
const url = data.url || '';
|
||||
const nodeId = data.nodeId as number | undefined;
|
||||
return (
|
||||
<div style={{ marginTop: 8, border: '1px solid #2a2a2a', background: '#0f0f0f', borderRadius: 6, padding: 8 }}>
|
||||
<div style={{ color: '#e5e5e5', fontWeight: 600 }}>{title}</div>
|
||||
{url ? (
|
||||
<div style={{ marginTop: 4, fontSize: 11 }}>
|
||||
<a href={url} target="_blank" rel="noreferrer" style={{ color: '#5c9aff' }}>
|
||||
{url}
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
<div style={{ marginTop: 6, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{typeof nodeId === 'number' && onNodeClick ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onNodeClick(nodeId)}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: '1px solid #2a2a2a',
|
||||
color: '#cfcfcf',
|
||||
fontSize: 11,
|
||||
padding: '2px 8px',
|
||||
borderRadius: 4,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
Open node #{nodeId}
|
||||
</button>
|
||||
) : null}
|
||||
{typeof data.contentLength === 'number' ? (
|
||||
<span style={{ fontSize: 11, color: '#7a7a7a' }}>
|
||||
content ~{Math.round((data.contentLength as number) / 1000)}k chars
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,561 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import NodesPanel from '../nodes/NodesPanel';
|
||||
import FocusPanel from '../focus/FocusPanel';
|
||||
import AgentsPanel from '../agents/AgentsPanel';
|
||||
import SettingsCog from '../settings/SettingsCog';
|
||||
import SettingsModal, { SettingsTab } from '../settings/SettingsModal';
|
||||
import { Node } from '@/types/database';
|
||||
import { DatabaseEvent } from '@/services/events';
|
||||
import { usePersistentState } from '@/hooks/usePersistentState';
|
||||
import FolderViewOverlay from '../nodes/FolderViewOverlay';
|
||||
|
||||
export default function ThreePanelLayout() {
|
||||
// Panel widths as percentages (20% | 40% | 40%)
|
||||
const [leftWidth, setLeftWidth] = useState(20);
|
||||
const [middleWidth, setMiddleWidth] = useState(40);
|
||||
|
||||
// Collapsible state for nodes panel
|
||||
const [nodesCollapsed, setNodesCollapsed] = usePersistentState('ui.nodesCollapsed', false);
|
||||
|
||||
// Settings dropdown state
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [settingsInitialTab, setSettingsInitialTab] = useState<SettingsTab>();
|
||||
const handleCloseSettings = useCallback(() => {
|
||||
setShowSettings(false);
|
||||
setSettingsInitialTab(undefined);
|
||||
}, []);
|
||||
// Dragging states
|
||||
const [isDraggingLeft, setIsDraggingLeft] = useState(false);
|
||||
const [isDraggingRight, setIsDraggingRight] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Track selected nodes and open tabs
|
||||
const [selectedNodes, setSelectedNodes] = useState<Set<number>>(new Set<number>());
|
||||
const [openTabs, setOpenTabs] = usePersistentState<number[]>('ui.focus.openTabs', []);
|
||||
const [activeTab, setActiveTab] = usePersistentState<number | null>('ui.focus.activeTab', null);
|
||||
const [openTabsData, setOpenTabsData] = useState<Node[]>([]);
|
||||
|
||||
// Event handlers for SSE events
|
||||
const [nodesPanelRefresh, setNodesPanelRefresh] = useState(0);
|
||||
const [focusPanelRefresh, setFocusPanelRefresh] = useState(0);
|
||||
const [folderViewOpen, setFolderViewOpen] = useState(false);
|
||||
const [folderViewRefresh, setFolderViewRefresh] = useState(0);
|
||||
|
||||
// Ref to get current openTabs value in SSE handler
|
||||
const openTabsRef = useRef<number[]>([]);
|
||||
|
||||
// Fetch full node data for open tabs
|
||||
const fetchOpenTabsData = async (tabIds: number[]) => {
|
||||
if (tabIds.length === 0) {
|
||||
setOpenTabsData([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const nodePromises = tabIds.map(async (id) => {
|
||||
const response = await fetch(`/api/nodes/${id}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
return data.node as Node;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const nodes = await Promise.all(nodePromises);
|
||||
const validNodes = nodes.filter((node): node is Node => Boolean(node)).map(node => ({
|
||||
id: node.id,
|
||||
title: node.title,
|
||||
link: node.link,
|
||||
content: node.content,
|
||||
dimensions: node.dimensions,
|
||||
created_at: node.created_at,
|
||||
updated_at: node.updated_at
|
||||
}));
|
||||
setOpenTabsData(validNodes);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch tab data:', error);
|
||||
setOpenTabsData([]);
|
||||
}
|
||||
};
|
||||
|
||||
// Update tab data whenever openTabs changes
|
||||
useEffect(() => {
|
||||
openTabsRef.current = openTabs; // Keep ref updated
|
||||
fetchOpenTabsData(openTabs);
|
||||
}, [openTabs]);
|
||||
|
||||
// Keyboard shortcut handler
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Check for Cmd+1 (Mac) or Ctrl+1 (Windows/Linux)
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === '1') {
|
||||
e.preventDefault();
|
||||
setNodesCollapsed(prev => !prev);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [setNodesCollapsed]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleSettingsOpen = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ tab?: SettingsTab }>).detail;
|
||||
setSettingsInitialTab(detail?.tab);
|
||||
setShowSettings(true);
|
||||
};
|
||||
window.addEventListener('settings:open', handleSettingsOpen as EventListener);
|
||||
return () => window.removeEventListener('settings:open', handleSettingsOpen as EventListener);
|
||||
}, []);
|
||||
|
||||
|
||||
// SSE connection for real-time updates
|
||||
useEffect(() => {
|
||||
let eventSource: EventSource | null = null;
|
||||
|
||||
try {
|
||||
eventSource = new EventSource('/api/events');
|
||||
|
||||
eventSource.onopen = () => {
|
||||
console.log('🔌 SSE connected for real-time updates');
|
||||
};
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data: DatabaseEvent = JSON.parse(event.data);
|
||||
|
||||
switch (data.type) {
|
||||
case 'NODE_CREATED':
|
||||
// Trigger NodesPanel refresh
|
||||
setNodesPanelRefresh(prev => prev + 1);
|
||||
console.log('📥 Node created via helper:', data.data.node.title);
|
||||
break;
|
||||
|
||||
case 'NODE_UPDATED':
|
||||
const currentOpenTabs = openTabsRef.current;
|
||||
const updatedNodeId = Number(data.data.nodeId);
|
||||
console.log('🔍 NODE_UPDATED - nodeId:', updatedNodeId, 'openTabs:', currentOpenTabs);
|
||||
// Trigger FocusPanel refresh for open tabs
|
||||
if (currentOpenTabs.includes(updatedNodeId)) {
|
||||
console.log('🔄 Triggering FocusPanel refresh for node:', updatedNodeId);
|
||||
setFocusPanelRefresh(prev => prev + 1);
|
||||
} else {
|
||||
console.log('⚠️ Node not in open tabs, skipping FocusPanel refresh');
|
||||
}
|
||||
// Also refresh NodesPanel in case title changed
|
||||
setNodesPanelRefresh(prev => prev + 1);
|
||||
console.log('✏️ Node updated via helper:', updatedNodeId);
|
||||
break;
|
||||
|
||||
case 'NODE_DELETED':
|
||||
// Remove from tabs and selection if open
|
||||
handleNodeDeleted(data.data.nodeId);
|
||||
// Trigger NodesPanel refresh
|
||||
setNodesPanelRefresh(prev => prev + 1);
|
||||
console.log('🗑️ Node deleted via helper:', data.data.nodeId);
|
||||
break;
|
||||
|
||||
case 'EDGE_CREATED':
|
||||
case 'EDGE_DELETED':
|
||||
// Trigger FocusPanel refresh for affected nodes
|
||||
const currentOpenTabsForEdge = openTabsRef.current;
|
||||
console.log('🔗 Edge changed - fromNodeId:', data.data.fromNodeId, 'toNodeId:', data.data.toNodeId, 'openTabs:', currentOpenTabsForEdge);
|
||||
if (currentOpenTabsForEdge.includes(data.data.fromNodeId) || currentOpenTabsForEdge.includes(data.data.toNodeId)) {
|
||||
console.log('🔄 Triggering FocusPanel refresh for edge change');
|
||||
setFocusPanelRefresh(prev => prev + 1);
|
||||
} else {
|
||||
console.log('⚠️ Neither edge node in open tabs, skipping FocusPanel refresh');
|
||||
}
|
||||
console.log('🔗 Edge changed via helper');
|
||||
break;
|
||||
case 'DIMENSION_UPDATED':
|
||||
console.log('📁 Dimension updated event received:', data.data?.dimension);
|
||||
setNodesPanelRefresh(prev => prev + 1);
|
||||
setFolderViewRefresh(prev => prev + 1);
|
||||
break;
|
||||
|
||||
case 'HELPER_UPDATED':
|
||||
case 'AGENT_UPDATED':
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent('agents:updated', { detail: data.data }));
|
||||
}
|
||||
break;
|
||||
case 'AGENT_DELEGATION_CREATED':
|
||||
console.log('[ThreePanelLayout] AGENT_DELEGATION_CREATED received:', data.data);
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent('delegations:created', { detail: data.data }));
|
||||
console.log('[ThreePanelLayout] Dispatched delegations:created event');
|
||||
}
|
||||
break;
|
||||
case 'AGENT_DELEGATION_UPDATED':
|
||||
console.log('[ThreePanelLayout] AGENT_DELEGATION_UPDATED received:', data.data);
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent('delegations:updated', { detail: data.data }));
|
||||
console.log('[ThreePanelLayout] Dispatched delegations:updated event');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'WORKFLOW_PROGRESS':
|
||||
console.log('[ThreePanelLayout] WORKFLOW_PROGRESS received:', data.data);
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent('workflow:progress', { detail: data.data }));
|
||||
}
|
||||
break;
|
||||
|
||||
case 'CONNECTION_ESTABLISHED':
|
||||
console.log('✅ SSE connection established');
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log('📡 Unknown SSE event:', data.type);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to parse SSE event:', error);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = (error) => {
|
||||
console.error('SSE connection error:', error);
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to establish SSE connection:', error);
|
||||
}
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
console.log('🔌 SSE connection closed');
|
||||
}
|
||||
};
|
||||
}, []); // Empty dependency array - connect once on mount
|
||||
|
||||
// Handle panel resizing
|
||||
const handleMouseMove = useCallback((e: MouseEvent) => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const containerRect = containerRef.current.getBoundingClientRect();
|
||||
const mouseX = ((e.clientX - containerRect.left) / containerRect.width) * 100;
|
||||
|
||||
if (isDraggingLeft) {
|
||||
const newLeftWidth = Math.max(15, Math.min(35, mouseX));
|
||||
const diff = newLeftWidth - leftWidth;
|
||||
setLeftWidth(newLeftWidth);
|
||||
setMiddleWidth(prev => Math.max(20, prev - diff));
|
||||
} else if (isDraggingRight) {
|
||||
const rightEdge = leftWidth + middleWidth;
|
||||
const newMiddleWidth = Math.max(20, Math.min(65, mouseX - leftWidth));
|
||||
setMiddleWidth(newMiddleWidth);
|
||||
}
|
||||
}, [isDraggingLeft, isDraggingRight, leftWidth, middleWidth]);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
setIsDraggingLeft(false);
|
||||
setIsDraggingRight(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDraggingLeft || isDraggingRight) {
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
};
|
||||
}
|
||||
}, [isDraggingLeft, isDraggingRight, handleMouseMove, handleMouseUp]);
|
||||
|
||||
const handleToggleFolderView = (nextState?: boolean) => {
|
||||
setFolderViewOpen(prev => typeof nextState === 'boolean' ? nextState : !prev);
|
||||
};
|
||||
|
||||
const handleFolderViewDataChanged = useCallback(() => {
|
||||
setFolderViewRefresh(prev => prev + 1);
|
||||
setNodesPanelRefresh(prev => prev + 1);
|
||||
}, []);
|
||||
|
||||
const handleNodeOpenFromFolderView = (nodeId: number) => {
|
||||
handleNodeSelect(nodeId, false);
|
||||
setFolderViewOpen(false);
|
||||
};
|
||||
|
||||
const handleReorderTabs = (fromIndex: number, toIndex: number) => {
|
||||
if (fromIndex === toIndex) return;
|
||||
setOpenTabs(prev => {
|
||||
if (fromIndex < 0 || toIndex < 0 || fromIndex >= prev.length || toIndex >= prev.length) {
|
||||
return prev;
|
||||
}
|
||||
const updated = [...prev];
|
||||
const [moved] = updated.splice(fromIndex, 1);
|
||||
updated.splice(toIndex, 0, moved);
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
// Handle node selection
|
||||
const handleNodeSelect = (nodeId: number, multiSelect: boolean) => {
|
||||
if (folderViewOpen) {
|
||||
setFolderViewOpen(false);
|
||||
}
|
||||
if (multiSelect) {
|
||||
const newSelection = new Set(selectedNodes);
|
||||
if (newSelection.has(nodeId)) {
|
||||
newSelection.delete(nodeId);
|
||||
} else {
|
||||
newSelection.add(nodeId);
|
||||
}
|
||||
setSelectedNodes(newSelection);
|
||||
|
||||
const newTabs = Array.from(newSelection);
|
||||
setOpenTabs(newTabs);
|
||||
if (newTabs.length > 0 && !activeTab) {
|
||||
setActiveTab(newTabs[0]);
|
||||
}
|
||||
} else {
|
||||
setSelectedNodes(new Set<number>([nodeId]));
|
||||
|
||||
if (!openTabs.includes(nodeId)) {
|
||||
setOpenTabs([...openTabs, nodeId]);
|
||||
}
|
||||
setActiveTab(nodeId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTabSelect = (tabId: number) => {
|
||||
setSelectedNodes(new Set<number>([tabId]));
|
||||
setActiveTab(tabId);
|
||||
};
|
||||
|
||||
// Handle tab close
|
||||
const handleCloseTab = (tabId: number) => {
|
||||
const newTabs = openTabs.filter(id => id !== tabId);
|
||||
setOpenTabs(newTabs);
|
||||
|
||||
if (activeTab === tabId) {
|
||||
const currentIndex = openTabs.indexOf(tabId);
|
||||
if (newTabs.length > 0) {
|
||||
const newIndex = Math.min(currentIndex, newTabs.length - 1);
|
||||
setActiveTab(newTabs[newIndex]);
|
||||
} else {
|
||||
setActiveTab(null);
|
||||
}
|
||||
}
|
||||
|
||||
const newSelection = new Set(selectedNodes);
|
||||
newSelection.delete(tabId);
|
||||
setSelectedNodes(newSelection);
|
||||
};
|
||||
|
||||
// Handle node creation - auto-open the new node in Focus panel
|
||||
const handleNodeCreated = (newNode: Node) => {
|
||||
// Auto-select the new node
|
||||
setSelectedNodes(new Set<number>([newNode.id]));
|
||||
|
||||
// Auto-open the new node as a tab
|
||||
if (!openTabs.includes(newNode.id)) {
|
||||
setOpenTabs([...openTabs, newNode.id]);
|
||||
}
|
||||
|
||||
// Set as active tab
|
||||
setActiveTab(newNode.id);
|
||||
};
|
||||
|
||||
// Handle node deletion - close tab and remove from selection
|
||||
const handleNodeDeleted = (nodeId: number) => {
|
||||
// Close tab if open
|
||||
handleCloseTab(nodeId);
|
||||
};
|
||||
|
||||
const rightWidth = nodesCollapsed ? (100 - middleWidth) : (100 - leftWidth - middleWidth);
|
||||
const effectiveLeftWidth = nodesCollapsed ? 0 : leftWidth;
|
||||
const effectiveMiddleWidth = nodesCollapsed ? (leftWidth + middleWidth) : middleWidth;
|
||||
|
||||
const activeNodeId = activeTab;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{
|
||||
display: 'flex',
|
||||
height: '100vh',
|
||||
width: '100vw',
|
||||
background: '#0a0a0a',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
{/* Left Panel - Nodes (collapsible) */}
|
||||
{nodesCollapsed ? (
|
||||
// Collapsed rail
|
||||
<div style={{
|
||||
width: '40px',
|
||||
flexShrink: 0,
|
||||
borderRight: '1px solid #2a2a2a',
|
||||
background: '#0f0f0f',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
paddingTop: '12px'
|
||||
}}>
|
||||
<button
|
||||
onClick={() => setNodesCollapsed(false)}
|
||||
style={{
|
||||
padding: '8px',
|
||||
background: '#1a1a1a',
|
||||
border: '1px solid #333',
|
||||
borderRadius: '4px',
|
||||
color: '#888',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#2a2a2a';
|
||||
e.currentTarget.style.color = '#fff';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = '#1a1a1a';
|
||||
e.currentTarget.style.color = '#888';
|
||||
}}
|
||||
title="Expand Nodes (⌘1)"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
width: `${effectiveLeftWidth}%`,
|
||||
flexShrink: 0,
|
||||
borderRight: '1px solid #1a1a1a',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: '#0f0f0f',
|
||||
position: 'relative',
|
||||
padding: '4px'
|
||||
}}
|
||||
>
|
||||
<NodesPanel
|
||||
selectedNodes={selectedNodes}
|
||||
onNodeSelect={handleNodeSelect}
|
||||
onNodeCreated={handleNodeCreated}
|
||||
onNodeDeleted={handleNodeDeleted}
|
||||
refreshTrigger={nodesPanelRefresh}
|
||||
onToggleFolderView={handleToggleFolderView}
|
||||
folderViewOpen={folderViewOpen}
|
||||
/>
|
||||
<SettingsCog onClick={() => {
|
||||
setSettingsInitialTab(undefined);
|
||||
setShowSettings(true);
|
||||
}} />
|
||||
</div>
|
||||
|
||||
{/* Left Resize Handle */}
|
||||
<div
|
||||
onMouseDown={() => setIsDraggingLeft(true)}
|
||||
style={{
|
||||
width: '3px',
|
||||
cursor: 'col-resize',
|
||||
background: isDraggingLeft ? '#353535' : 'transparent',
|
||||
transition: isDraggingLeft ? 'none' : 'background 0.2s'
|
||||
}}
|
||||
onMouseEnter={(e) => { if (!isDraggingLeft) e.currentTarget.style.background = '#1f1f1f'; }}
|
||||
onMouseLeave={(e) => { if (!isDraggingLeft) e.currentTarget.style.background = 'transparent'; }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Middle Panel - Focus */}
|
||||
<div
|
||||
style={{
|
||||
width: `${effectiveMiddleWidth}%`,
|
||||
flexShrink: 0,
|
||||
borderRight: '1px solid #1a1a1a',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: '#0f0f0f',
|
||||
padding: '4px'
|
||||
}}
|
||||
>
|
||||
<div style={{ position: 'relative', flex: 1, minHeight: 0 }}>
|
||||
<div style={{
|
||||
height: '100%',
|
||||
visibility: folderViewOpen ? 'hidden' : 'visible',
|
||||
pointerEvents: folderViewOpen ? 'none' : 'auto'
|
||||
}}>
|
||||
<FocusPanel
|
||||
openTabs={openTabs}
|
||||
activeTab={activeTab}
|
||||
onTabSelect={handleTabSelect}
|
||||
onNodeClick={(nodeId) => handleNodeSelect(nodeId, false)}
|
||||
onTabClose={handleCloseTab}
|
||||
refreshTrigger={focusPanelRefresh}
|
||||
onReorderTabs={handleReorderTabs}
|
||||
/>
|
||||
</div>
|
||||
{folderViewOpen && (
|
||||
<FolderViewOverlay
|
||||
onClose={() => handleToggleFolderView(false)}
|
||||
onNodeOpen={handleNodeOpenFromFolderView}
|
||||
refreshToken={folderViewRefresh}
|
||||
onDataChanged={handleFolderViewDataChanged}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Resize Handle */}
|
||||
{!nodesCollapsed && (
|
||||
<div
|
||||
onMouseDown={() => setIsDraggingRight(true)}
|
||||
style={{
|
||||
width: '3px',
|
||||
cursor: 'col-resize',
|
||||
background: isDraggingRight ? '#353535' : 'transparent',
|
||||
transition: isDraggingRight ? 'none' : 'background 0.2s'
|
||||
}}
|
||||
onMouseEnter={(e) => { if (!isDraggingRight) e.currentTarget.style.background = '#1f1f1f'; }}
|
||||
onMouseLeave={(e) => { if (!isDraggingRight) e.currentTarget.style.background = 'transparent'; }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Right Panel - Agents */}
|
||||
<div
|
||||
style={{
|
||||
width: `${rightWidth}%`,
|
||||
flexShrink: 0,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: '#0a0a0a',
|
||||
position: 'relative',
|
||||
padding: '4px'
|
||||
}}
|
||||
>
|
||||
<AgentsPanel
|
||||
openTabsData={openTabsData}
|
||||
activeTabId={activeNodeId}
|
||||
onNodeClick={(nodeId) => handleNodeSelect(nodeId, false)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Settings Modal */}
|
||||
<SettingsModal
|
||||
isOpen={showSettings}
|
||||
onClose={handleCloseSettings}
|
||||
initialTab={settingsInitialTab}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
interface AddNodeButtonProps {
|
||||
onAddNode: () => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export default function AddNodeButton({ onAddNode, loading = false }: AddNodeButtonProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={onAddNode}
|
||||
disabled={loading}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: loading ? '#666' : '#fff',
|
||||
fontSize: '16px',
|
||||
padding: '6px',
|
||||
cursor: loading ? 'not-allowed' : 'pointer',
|
||||
borderRadius: '3px',
|
||||
transition: 'all 0.1s',
|
||||
fontFamily: 'inherit',
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!loading) {
|
||||
e.currentTarget.style.background = '#1a1a1a';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!loading) {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
}
|
||||
}}
|
||||
>
|
||||
{loading ? '...' : '+'}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,835 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, type DragEvent } from 'react';
|
||||
import { ChevronRight, ChevronDown, Folder, FolderOpen, Layers, List, Maximize2, Minimize2 } from 'lucide-react';
|
||||
import { Node } from '@/types/database';
|
||||
import AddNodeButton from './AddNodeButton';
|
||||
import Chip from '../common/Chip';
|
||||
import { getNodeIcon } from '@/utils/nodeIcons';
|
||||
import SearchModal from './SearchModal';
|
||||
|
||||
interface NodesPanelProps {
|
||||
selectedNodes: Set<number>;
|
||||
onNodeSelect: (nodeId: number, multiSelect: boolean) => void;
|
||||
onNodeCreated?: (node: Node) => void;
|
||||
onNodeDeleted?: (nodeId: number) => void;
|
||||
refreshTrigger?: number;
|
||||
onToggleFolderView?: (isOpen?: boolean) => void;
|
||||
folderViewOpen?: boolean;
|
||||
}
|
||||
|
||||
interface LockedDimension {
|
||||
dimension: string;
|
||||
count: number;
|
||||
isPriority: boolean;
|
||||
}
|
||||
|
||||
|
||||
export default function NodesPanel({ selectedNodes, onNodeSelect, onNodeCreated, onNodeDeleted, refreshTrigger, onToggleFolderView, folderViewOpen = false }: NodesPanelProps) {
|
||||
const [nodes, setNodes] = useState<Node[]>([]);
|
||||
const [allNodes, setAllNodes] = useState<Node[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [deletingNode, setDeletingNode] = useState<number | null>(null);
|
||||
const [priorityDimensions, setPriorityDimensions] = useState<string[]>([]);
|
||||
const [lockedDimensions, setLockedDimensions] = useState<LockedDimension[]>([]);
|
||||
const [expandedDimensions, setExpandedDimensions] = useState<Set<string>>(() => {
|
||||
if (typeof window === 'undefined') return new Set();
|
||||
const saved = window.localStorage.getItem('expandedDimensions');
|
||||
return saved ? new Set(JSON.parse(saved)) : new Set();
|
||||
});
|
||||
const [dimensionNodes, setDimensionNodes] = useState<Record<string, Node[]>>({});
|
||||
const [selectedFilters, setSelectedFilters] = useState<{type: 'dimension' | 'title', value: string}[]>([]);
|
||||
const [dimensionsSectionCollapsed, setDimensionsSectionCollapsed] = useState(true); // Default: collapsed
|
||||
const [allNodesSectionCollapsed, setAllNodesSectionCollapsed] = useState(false);
|
||||
const [showSearchModal, setShowSearchModal] = useState(false);
|
||||
const [dropFeedback, setDropFeedback] = useState<string | null>(null);
|
||||
const [dragHoverDimension, setDragHoverDimension] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchNodes();
|
||||
fetchLockedDimensions();
|
||||
}, []);
|
||||
|
||||
// Save expanded dimensions to localStorage
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.localStorage.setItem('expandedDimensions', JSON.stringify([...expandedDimensions]));
|
||||
}, [expandedDimensions]);
|
||||
|
||||
// Refresh nodes and locked dimensions when SSE events trigger updates
|
||||
useEffect(() => {
|
||||
if (refreshTrigger !== undefined && refreshTrigger > 0) {
|
||||
console.log('🔄 Refreshing nodes and locked dimensions due to SSE event');
|
||||
fetchNodes();
|
||||
fetchLockedDimensions();
|
||||
}
|
||||
}, [refreshTrigger]);
|
||||
|
||||
// Global Cmd+K / Ctrl+K keyboard shortcut for search
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
setShowSearchModal(true);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, []);
|
||||
|
||||
// Re-apply filters when allNodes changes
|
||||
useEffect(() => {
|
||||
if (selectedFilters.length > 0) {
|
||||
applyFiltersWithSelection(selectedFilters);
|
||||
} else {
|
||||
setNodes(allNodes);
|
||||
}
|
||||
}, [allNodes, priorityDimensions]);
|
||||
|
||||
const fetchNodes = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/nodes?limit=100');
|
||||
const result = await response.json();
|
||||
const fetchedNodes = result.data || [];
|
||||
setAllNodes(fetchedNodes);
|
||||
setNodes(fetchedNodes);
|
||||
} catch (error) {
|
||||
console.error('Error fetching nodes:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchLockedDimensions = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/dimensions/popular');
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
const locked = result.data.filter((d: any) => d.isPriority);
|
||||
setLockedDimensions(locked);
|
||||
const priority = locked.map((d: any) => d.dimension);
|
||||
setPriorityDimensions(priority);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching locked dimensions:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleDimension = async (dimension: string) => {
|
||||
const isCurrentlyExpanded = expandedDimensions.has(dimension);
|
||||
|
||||
setExpandedDimensions(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(dimension)) {
|
||||
next.delete(dimension);
|
||||
} else {
|
||||
next.add(dimension);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
// Fetch nodes for this dimension when expanding (if not already fetched)
|
||||
if (!isCurrentlyExpanded && !dimensionNodes[dimension]) {
|
||||
await fetchNodesForDimension(dimension);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchNodesForDimension = async (dimension: string) => {
|
||||
try {
|
||||
// Sort by edge count (most connected first), then updated_at
|
||||
const response = await fetch(`/api/nodes?dimensions=${encodeURIComponent(dimension)}&limit=200&sortBy=edges`);
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
setDimensionNodes(prev => ({
|
||||
...prev,
|
||||
[dimension]: result.data || []
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error fetching nodes for dimension ${dimension}:`, error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDimensionDragOver = (event: DragEvent<HTMLElement>) => {
|
||||
if (event.dataTransfer.types.includes('application/node-info')) {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = 'copy';
|
||||
}
|
||||
};
|
||||
|
||||
const handleDimensionDragEnter = (event: DragEvent<HTMLElement>, dimension: string) => {
|
||||
if (event.dataTransfer.types.includes('application/node-info')) {
|
||||
setDragHoverDimension(dimension);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDimensionDragLeave = (event: DragEvent<HTMLElement>, dimension: string) => {
|
||||
if (dragHoverDimension === dimension) {
|
||||
setDragHoverDimension(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNodeDropOnDimension = async (event: DragEvent<HTMLElement>, dimension: string) => {
|
||||
if (!event.dataTransfer.types.includes('application/node-info')) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
const raw = event.dataTransfer.getData('application/node-info');
|
||||
if (!raw) return;
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(raw) as { id: number; dimensions?: string[] };
|
||||
if (!payload?.id) return;
|
||||
|
||||
const currentDimensions = payload.dimensions || [];
|
||||
if (currentDimensions.some((dim) => dim.toLowerCase() === dimension.toLowerCase())) {
|
||||
setDropFeedback(`Node already in ${dimension}`);
|
||||
setTimeout(() => setDropFeedback(null), 2500);
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedDimensions = Array.from(new Set([...currentDimensions, dimension]));
|
||||
const response = await fetch(`/api/nodes/${payload.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ dimensions: updatedDimensions })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update node dimensions');
|
||||
}
|
||||
|
||||
setDropFeedback(`Added to ${dimension}`);
|
||||
setTimeout(() => setDropFeedback(null), 2500);
|
||||
if (expandedDimensions.has(dimension)) {
|
||||
fetchNodesForDimension(dimension);
|
||||
}
|
||||
fetchLockedDimensions();
|
||||
setDragHoverDimension(null);
|
||||
} catch (error) {
|
||||
console.error('Error handling node drop:', error);
|
||||
setDropFeedback('Failed to add dimension');
|
||||
setTimeout(() => setDropFeedback(null), 2500);
|
||||
setDragHoverDimension(null);
|
||||
}
|
||||
};
|
||||
|
||||
const getNodesForDimension = (dimension: string): Node[] => {
|
||||
// Use fetched dimension-specific nodes if available, otherwise filter from loaded nodes
|
||||
return dimensionNodes[dimension] || nodes.filter(node =>
|
||||
node.dimensions?.some(d => d.toLowerCase() === dimension.toLowerCase())
|
||||
);
|
||||
};
|
||||
|
||||
const applyFilters = () => {
|
||||
if (selectedFilters.length === 0) {
|
||||
setNodes(allNodes);
|
||||
return;
|
||||
}
|
||||
|
||||
const dimensionFilters = selectedFilters.filter(f => f.type === 'dimension').map(f => f.value);
|
||||
const titleFilters = selectedFilters.filter(f => f.type === 'title').map(f => f.value);
|
||||
|
||||
const filtered = allNodes.filter(node => {
|
||||
// Must match ALL selected dimension filters
|
||||
const dimensionMatch = dimensionFilters.length === 0 ||
|
||||
dimensionFilters.every(dim =>
|
||||
node.dimensions?.some(nodeDim => nodeDim.toLowerCase() === dim.toLowerCase())
|
||||
);
|
||||
|
||||
// Must match ANY selected title filter
|
||||
const titleMatch = titleFilters.length === 0 ||
|
||||
titleFilters.some(title =>
|
||||
node.title?.toLowerCase().includes(title.toLowerCase())
|
||||
);
|
||||
|
||||
return dimensionMatch && titleMatch;
|
||||
});
|
||||
|
||||
// Sort by priority dimensions first
|
||||
const sorted = filtered.sort((a, b) => {
|
||||
const aHasPriority = a.dimensions?.some(dim => priorityDimensions.includes(dim)) || false;
|
||||
const bHasPriority = b.dimensions?.some(dim => priorityDimensions.includes(dim)) || false;
|
||||
if (aHasPriority && !bHasPriority) return -1;
|
||||
if (!aHasPriority && bHasPriority) return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
setNodes(sorted);
|
||||
};
|
||||
|
||||
const addFilter = (type: 'dimension' | 'title', value: string) => {
|
||||
const newFilter = { type, value };
|
||||
const newFilters = [...selectedFilters, newFilter];
|
||||
setSelectedFilters(newFilters);
|
||||
|
||||
// Apply filters immediately
|
||||
setTimeout(() => {
|
||||
if (newFilters.length === 0) {
|
||||
setNodes(allNodes);
|
||||
} else {
|
||||
applyFiltersWithSelection(newFilters);
|
||||
}
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const removeFilter = (index: number) => {
|
||||
const newFilters = selectedFilters.filter((_, i) => i !== index);
|
||||
setSelectedFilters(newFilters);
|
||||
|
||||
// Apply filters immediately
|
||||
setTimeout(() => {
|
||||
if (newFilters.length === 0) {
|
||||
setNodes(allNodes);
|
||||
} else {
|
||||
applyFiltersWithSelection(newFilters);
|
||||
}
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const applyFiltersWithSelection = async (filters: {type: 'dimension' | 'title', value: string}[]) => {
|
||||
const dimensionFilters = filters.filter(f => f.type === 'dimension').map(f => f.value);
|
||||
const titleFilters = filters.filter(f => f.type === 'title').map(f => f.value);
|
||||
|
||||
try {
|
||||
// Build API query params
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (dimensionFilters.length > 0) {
|
||||
params.append('dimensions', dimensionFilters.join(','));
|
||||
}
|
||||
|
||||
if (titleFilters.length > 0) {
|
||||
// For title filters, we'll search each one and combine results
|
||||
const titleResults: Node[] = [];
|
||||
|
||||
for (const title of titleFilters) {
|
||||
const response = await fetch(`/api/nodes?search=${encodeURIComponent(title)}&limit=50`);
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
titleResults.push(...result.data);
|
||||
}
|
||||
}
|
||||
|
||||
// If we have dimension filters too, intersect the results
|
||||
if (dimensionFilters.length > 0) {
|
||||
const dimensionResponse = await fetch(`/api/nodes?${params.toString()}&limit=200`);
|
||||
const dimensionResult = await dimensionResponse.json();
|
||||
|
||||
if (dimensionResult.success) {
|
||||
// Find intersection: nodes that match dimensions AND titles
|
||||
const dimensionNodeIds = new Set(dimensionResult.data.map((n: Node) => n.id));
|
||||
const intersectedNodes = titleResults.filter(node => dimensionNodeIds.has(node.id));
|
||||
setNodes(intersectedNodes);
|
||||
}
|
||||
} else {
|
||||
// Only title filters
|
||||
setNodes(titleResults);
|
||||
}
|
||||
} else if (dimensionFilters.length > 0) {
|
||||
// Only dimension filters
|
||||
const response = await fetch(`/api/nodes?${params.toString()}&limit=200`);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
setNodes(result.data);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error applying filters:', error);
|
||||
// Fallback to local filtering
|
||||
const filtered = allNodes.filter(node => {
|
||||
const dimensionMatch = dimensionFilters.length === 0 ||
|
||||
dimensionFilters.every(dim =>
|
||||
node.dimensions?.some(nodeDim => nodeDim.toLowerCase() === dim.toLowerCase())
|
||||
);
|
||||
|
||||
const titleMatch = titleFilters.length === 0 ||
|
||||
titleFilters.some(title =>
|
||||
node.title?.toLowerCase().includes(title.toLowerCase())
|
||||
);
|
||||
|
||||
return dimensionMatch && titleMatch;
|
||||
});
|
||||
setNodes(filtered);
|
||||
}
|
||||
};
|
||||
|
||||
const clearSearch = () => {
|
||||
setSelectedFilters([]);
|
||||
setNodes(allNodes);
|
||||
};
|
||||
|
||||
const collapseAllDimensions = () => {
|
||||
setExpandedDimensions(new Set());
|
||||
};
|
||||
|
||||
const handleNodeDragStart = (event: DragEvent<HTMLElement>, node: Node) => {
|
||||
event.dataTransfer.effectAllowed = 'copy';
|
||||
event.dataTransfer.setData('application/node-info', JSON.stringify({
|
||||
id: node.id,
|
||||
dimensions: node.dimensions || []
|
||||
}));
|
||||
|
||||
const preview = document.createElement('div');
|
||||
preview.textContent = node.title || `Node #${node.id}`;
|
||||
preview.style.position = 'fixed';
|
||||
preview.style.top = '-1000px';
|
||||
preview.style.left = '-1000px';
|
||||
preview.style.padding = '4px 8px';
|
||||
preview.style.background = '#0f0f0f';
|
||||
preview.style.color = '#f8fafc';
|
||||
preview.style.fontSize = '11px';
|
||||
preview.style.fontWeight = '600';
|
||||
preview.style.borderRadius = '6px';
|
||||
preview.style.border = '1px solid #1f1f1f';
|
||||
document.body.appendChild(preview);
|
||||
event.dataTransfer.setDragImage(preview, 6, 6);
|
||||
setTimeout(() => {
|
||||
if (preview.parentNode) {
|
||||
preview.parentNode.removeChild(preview);
|
||||
}
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const renderNodeItem = (node: Node) => (
|
||||
<button
|
||||
key={node.id}
|
||||
draggable
|
||||
onDragStart={(event) => handleNodeDragStart(event, node)}
|
||||
onClick={(e) => {
|
||||
const multiSelect = e.metaKey || e.ctrlKey;
|
||||
onNodeSelect(node.id, multiSelect);
|
||||
}}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px 16px', /* Increased from 10px to 12px */
|
||||
margin: '2px 0', /* Added vertical margin for better spacing */
|
||||
textAlign: 'left',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
fontSize: '14px',
|
||||
fontFamily: 'inherit',
|
||||
background: selectedNodes.has(node.id) ? '#1a1a1a' : 'transparent',
|
||||
color: selectedNodes.has(node.id) ? '#fff' : '#d1d5db',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.1s',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!selectedNodes.has(node.id)) {
|
||||
e.currentTarget.style.background = '#0a0a0a';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!selectedNodes.has(node.id)) {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
|
||||
{/* Title */}
|
||||
<span style={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
flex: 1
|
||||
}}>
|
||||
{node.title || 'Untitled'}
|
||||
</span>
|
||||
|
||||
{/* Source Icon - Only show if node has a link */}
|
||||
{node.link && (
|
||||
<span style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flexShrink: 0,
|
||||
marginLeft: '8px'
|
||||
}}>
|
||||
{getNodeIcon(node)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
</button>
|
||||
);
|
||||
|
||||
const handleAddNode = async () => {
|
||||
setCreating(true);
|
||||
try {
|
||||
const response = await fetch('/api/nodes', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: 'New Node',
|
||||
content: '',
|
||||
link: null,
|
||||
dimensions: []
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
// Add new node to both lists immediately
|
||||
setAllNodes(prevNodes => [result.data, ...prevNodes]);
|
||||
setNodes(prevNodes => [result.data, ...prevNodes]);
|
||||
|
||||
// Notify parent component about new node creation
|
||||
if (onNodeCreated) {
|
||||
onNodeCreated(result.data);
|
||||
}
|
||||
} else {
|
||||
console.error('Failed to create node:', result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating node:', error);
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// handleDeleteNode removed - deletion only available in focus panel
|
||||
|
||||
const handleNodeSelectFromSearch = (nodeId: number) => {
|
||||
// Open node in focus panel without filtering the nodes panel
|
||||
onNodeSelect(nodeId, false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', background: '#000' }}>
|
||||
{/* Search Modal */}
|
||||
<SearchModal
|
||||
isOpen={showSearchModal}
|
||||
onClose={() => setShowSearchModal(false)}
|
||||
onNodeSelect={handleNodeSelectFromSearch}
|
||||
existingFilters={selectedFilters}
|
||||
/>
|
||||
|
||||
{/* Nodes List */}
|
||||
<div style={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
padding: '8px 0'
|
||||
}}>
|
||||
{loading ? (
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
color: '#666',
|
||||
fontSize: '14px'
|
||||
}}>
|
||||
Loading nodes...
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{/* + ADD NODE Button & folder expand */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', padding: '0 8px' }}>
|
||||
<button
|
||||
onClick={handleAddNode}
|
||||
disabled={creating}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '12px 12px',
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
color: '#22c55e',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.1em',
|
||||
borderBottom: '1px solid #1a1a1a',
|
||||
background: '#0a0a0a',
|
||||
border: 'none',
|
||||
cursor: creating ? 'not-allowed' : 'pointer',
|
||||
textAlign: 'left',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!creating) {
|
||||
e.currentTarget.style.background = '#151515';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = '#0a0a0a';
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
borderRadius: '50%',
|
||||
background: '#22c55e',
|
||||
color: '#0a0a0a',
|
||||
fontSize: '16px',
|
||||
lineHeight: 1,
|
||||
fontWeight: 300,
|
||||
flexShrink: 0
|
||||
}}>+</span>
|
||||
{creating ? 'Adding...' : 'Add Node'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onToggleFolderView?.()}
|
||||
style={{
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
borderRadius: '10px',
|
||||
border: '1px solid #1f1f1f',
|
||||
background: folderViewOpen ? '#16301f' : '#080808',
|
||||
color: folderViewOpen ? '#22c55e' : '#cbd5f5',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.2s ease, color 0.2s ease'
|
||||
}}
|
||||
title={folderViewOpen ? 'Close dimension folder view' : 'Open dimension folder view'}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = folderViewOpen ? '#1c3b27' : '#101010';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = folderViewOpen ? '#16301f' : '#080808';
|
||||
}}
|
||||
>
|
||||
{folderViewOpen ? <Minimize2 size={18} /> : <Maximize2 size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{dropFeedback && (
|
||||
<div style={{
|
||||
margin: '6px 8px 0',
|
||||
padding: '6px 10px',
|
||||
borderRadius: '6px',
|
||||
background: '#0d1a12',
|
||||
color: '#7de8a5',
|
||||
fontSize: '11px',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em'
|
||||
}}>
|
||||
{dropFeedback}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SEARCH Button */}
|
||||
<button
|
||||
onClick={() => setShowSearchModal(true)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px 16px',
|
||||
margin: '4px 8px', /* Added side margins */
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
color: '#555',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.1em',
|
||||
borderRadius: '4px', /* Added border radius */
|
||||
background: '#0f0f0f', /* Slightly different background */
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#151515';
|
||||
e.currentTarget.style.color = '#777';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = '#0a0a0a';
|
||||
e.currentTarget.style.color = '#555';
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<svg width="12" height="12" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z" clipRule="evenodd" />
|
||||
</svg>
|
||||
Search
|
||||
</div>
|
||||
<span style={{ fontSize: '9px', color: '#444', fontWeight: 400 }}>⌘K</span>
|
||||
</button>
|
||||
|
||||
{/* DIMENSIONS Section Header */}
|
||||
{lockedDimensions.length > 0 && (
|
||||
<button
|
||||
onClick={() => setDimensionsSectionCollapsed(!dimensionsSectionCollapsed)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '14px 18px',
|
||||
fontSize: '11px',
|
||||
fontWeight: 700,
|
||||
color: dimensionsSectionCollapsed ? '#94a3b8' : '#f8fafc',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.18em',
|
||||
borderBottom: '1px solid #151515',
|
||||
borderLeft: '3px solid transparent',
|
||||
background: dimensionsSectionCollapsed ? '#050505' : '#0e1811',
|
||||
borderTop: '1px solid #000',
|
||||
borderRight: 'none',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
<Layers size={14} color={dimensionsSectionCollapsed ? '#94a3b8' : '#f8fafc'} />
|
||||
<span>Dimensions ({lockedDimensions.length})</span>
|
||||
</div>
|
||||
{dimensionsSectionCollapsed ? (
|
||||
<ChevronRight size={14} strokeWidth={2.5} color="#94a3b8" />
|
||||
) : (
|
||||
<ChevronDown size={14} strokeWidth={2.5} color="#f8fafc" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Locked Dimension Sections */}
|
||||
{!dimensionsSectionCollapsed && lockedDimensions.map((lockedDim) => {
|
||||
const dimNodes = getNodesForDimension(lockedDim.dimension);
|
||||
const isExpanded = expandedDimensions.has(lockedDim.dimension);
|
||||
|
||||
return (
|
||||
<div key={lockedDim.dimension} style={{ marginBottom: '4px' }}>
|
||||
{/* Dimension Header */}
|
||||
<button
|
||||
onClick={() => toggleDimension(lockedDim.dimension)}
|
||||
onDragOver={(e) => handleDimensionDragOver(e)}
|
||||
onDragEnter={(e) => handleDimensionDragEnter(e, lockedDim.dimension)}
|
||||
onDragLeave={(e) => handleDimensionDragLeave(e, lockedDim.dimension)}
|
||||
onDrop={(e) => handleNodeDropOnDimension(e, lockedDim.dimension)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 18px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
background: dragHoverDimension === lockedDim.dimension ? '#152214' : (isExpanded ? '#0d0d0d' : '#050505'),
|
||||
border: dragHoverDimension === lockedDim.dimension ? '1px solid #1f3d28' : 'none',
|
||||
borderBottom: dragHoverDimension === lockedDim.dimension ? '1px solid #1f3d28' : '1px solid #121212',
|
||||
borderLeft: '2px solid transparent',
|
||||
color: '#cbd5f5',
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', flex: 1 }}>
|
||||
{isExpanded ? (
|
||||
<FolderOpen size={16} color="#7de8a5" />
|
||||
) : (
|
||||
<Folder size={16} color="#64748b" />
|
||||
)}
|
||||
<span style={{
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.08em'
|
||||
}}>
|
||||
{lockedDim.dimension}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
minWidth: '32px',
|
||||
textAlign: 'right',
|
||||
fontSize: '11px',
|
||||
color: '#94a3b8',
|
||||
fontWeight: 500,
|
||||
background: '#0f1a12',
|
||||
borderRadius: '999px',
|
||||
padding: '2px 8px'
|
||||
}}
|
||||
>
|
||||
{lockedDim.count}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Dimension Nodes (when expanded) */}
|
||||
{isExpanded && (
|
||||
<div>
|
||||
{dimNodes.length === 0 ? (
|
||||
<div style={{
|
||||
padding: '12px 16px',
|
||||
color: '#555',
|
||||
fontSize: '12px',
|
||||
fontStyle: 'italic'
|
||||
}}>
|
||||
No nodes in this dimension
|
||||
</div>
|
||||
) : (
|
||||
dimNodes.map((node) => renderNodeItem(node))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* ALL NODES Section Header */}
|
||||
<button
|
||||
onClick={() => setAllNodesSectionCollapsed(!allNodesSectionCollapsed)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '14px 18px',
|
||||
fontSize: '11px',
|
||||
fontWeight: 700,
|
||||
color: allNodesSectionCollapsed ? '#94a3b8' : '#f8fafc',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.18em',
|
||||
borderBottom: '1px solid #151515',
|
||||
borderLeft: '3px solid transparent',
|
||||
background: allNodesSectionCollapsed ? '#050505' : '#0e1811',
|
||||
borderTop: '1px solid #000',
|
||||
borderRight: 'none',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
<List size={14} color={allNodesSectionCollapsed ? '#94a3b8' : '#f8fafc'} />
|
||||
<span>Nodes</span>
|
||||
</div>
|
||||
{allNodesSectionCollapsed ? (
|
||||
<ChevronRight size={14} strokeWidth={2.5} color="#94a3b8" />
|
||||
) : (
|
||||
<ChevronDown size={14} strokeWidth={2.5} color="#f8fafc" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* All Nodes List */}
|
||||
{!allNodesSectionCollapsed && (
|
||||
nodes.length === 0 ? (
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
color: '#666',
|
||||
fontSize: '14px',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
{selectedFilters.length > 0 ? 'No nodes match filters' : 'No nodes yet - click "Add Node" above to get started'}
|
||||
</div>
|
||||
) : (
|
||||
nodes.map((node) => renderNodeItem(node))
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import Chip from '../common/Chip';
|
||||
|
||||
interface SearchModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onNodeSelect: (nodeId: number) => void;
|
||||
existingFilters: {type: 'dimension' | 'title', value: string}[];
|
||||
}
|
||||
|
||||
interface NodeSuggestion {
|
||||
id: number;
|
||||
title: string;
|
||||
dimensions?: string[];
|
||||
}
|
||||
|
||||
export default function SearchModal({ isOpen, onClose, onNodeSelect, existingFilters }: SearchModalProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [suggestions, setSuggestions] = useState<NodeSuggestion[]>([]);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const returnFocusRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
// Store the element that triggered the modal for return focus
|
||||
useEffect(() => {
|
||||
if (isOpen && document.activeElement instanceof HTMLElement) {
|
||||
returnFocusRef.current = document.activeElement;
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Focus trap and accessibility
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
// Autofocus input
|
||||
inputRef.current?.focus();
|
||||
|
||||
// Lock body scroll
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
// Handle Escape key
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
// Focus trap: keep focus within modal
|
||||
const handleTab = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Tab') return;
|
||||
|
||||
const focusableElements = modalRef.current?.querySelectorAll(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
);
|
||||
|
||||
if (!focusableElements || focusableElements.length === 0) return;
|
||||
|
||||
const firstElement = focusableElements[0] as HTMLElement;
|
||||
const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement;
|
||||
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === firstElement) {
|
||||
e.preventDefault();
|
||||
lastElement.focus();
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === lastElement) {
|
||||
e.preventDefault();
|
||||
firstElement.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
document.addEventListener('keydown', handleTab);
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
document.removeEventListener('keydown', handleTab);
|
||||
|
||||
// Return focus to trigger element
|
||||
if (returnFocusRef.current) {
|
||||
returnFocusRef.current.focus();
|
||||
}
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
// Generate suggestions based on search query
|
||||
useEffect(() => {
|
||||
if (!searchQuery.trim()) {
|
||||
setSuggestions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchSuggestions = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/nodes/search?q=${encodeURIComponent(searchQuery)}&limit=10`);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
const nodeSuggestions: NodeSuggestion[] = result.data.map((node: any) => ({
|
||||
id: node.id,
|
||||
title: node.title,
|
||||
dimensions: node.dimensions || []
|
||||
}));
|
||||
|
||||
setSuggestions(nodeSuggestions);
|
||||
setSelectedIndex(0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching suggestions:', error);
|
||||
setSuggestions([]);
|
||||
}
|
||||
};
|
||||
|
||||
const timeoutId = setTimeout(fetchSuggestions, 200);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [searchQuery, existingFilters]);
|
||||
|
||||
// Handle keyboard navigation
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev => Math.min(prev + 1, suggestions.length - 1));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev => Math.max(prev - 1, 0));
|
||||
} else if (e.key === 'Enter' && suggestions[selectedIndex]) {
|
||||
e.preventDefault();
|
||||
handleSelectSuggestion(suggestions[selectedIndex]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectSuggestion = (suggestion: NodeSuggestion) => {
|
||||
onNodeSelect(suggestion.id);
|
||||
setSearchQuery('');
|
||||
setSuggestions([]);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const modalContent = (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(0, 0, 0, 0.8)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 9999,
|
||||
animation: 'fadeIn 150ms ease-out'
|
||||
}}
|
||||
onClick={handleBackdropClick}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Search nodes"
|
||||
>
|
||||
<div
|
||||
ref={modalRef}
|
||||
style={{
|
||||
background: '#050505',
|
||||
border: '1px solid #1f1f1f',
|
||||
borderRadius: '12px',
|
||||
width: '90%',
|
||||
maxWidth: '600px',
|
||||
boxShadow: '0 25px 65px rgba(0, 0, 0, 0.7), 0 0 0 1px rgba(255, 255, 255, 0.05)',
|
||||
animation: 'slideIn 150ms ease-out'
|
||||
}}
|
||||
>
|
||||
{/* Search Input */}
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
borderBottom: suggestions.length > 0 ? '1px solid #1f1f1f' : 'none'
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
background: '#0a0a0a',
|
||||
padding: '12px',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid #1f1f1f'
|
||||
}}>
|
||||
{/* Search Icon */}
|
||||
<svg width="16" height="16" viewBox="0 0 20 20" fill="#666">
|
||||
<path fillRule="evenodd" d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z" clipRule="evenodd" />
|
||||
</svg>
|
||||
|
||||
{/* Selected Filters */}
|
||||
{existingFilters.map((filter, index) => (
|
||||
<Chip
|
||||
key={index}
|
||||
label={filter.value}
|
||||
color={'#1a1a4d'}
|
||||
maxWidth={120}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Input */}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={existingFilters.length === 0 ? "Search nodes..." : ""}
|
||||
style={{
|
||||
flex: 1,
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
color: '#fff',
|
||||
fontSize: '14px',
|
||||
fontFamily: 'inherit'
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Close Button */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#666',
|
||||
cursor: 'pointer',
|
||||
fontSize: '20px',
|
||||
padding: '0 4px',
|
||||
lineHeight: 1,
|
||||
transition: 'color 0.2s'
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.color = '#fff'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = '#666'; }}
|
||||
aria-label="Close search"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Suggestions */}
|
||||
{suggestions.length > 0 && (
|
||||
<div style={{
|
||||
maxHeight: '300px',
|
||||
overflowY: 'auto'
|
||||
}}>
|
||||
{suggestions.map((suggestion, index) => {
|
||||
const primaryDimension = suggestion.dimensions && suggestion.dimensions.length > 0
|
||||
? suggestion.dimensions[0]
|
||||
: '';
|
||||
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => handleSelectSuggestion(suggestion)}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px 16px',
|
||||
textAlign: 'left',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
fontSize: '13px',
|
||||
background: index === selectedIndex ? '#252525' : 'transparent',
|
||||
border: 'none',
|
||||
borderBottom: index < suggestions.length - 1 ? '1px solid #1f1f1f' : 'none',
|
||||
color: '#ccc',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.1s',
|
||||
fontFamily: 'inherit'
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
fontSize: '10px',
|
||||
fontWeight: 700,
|
||||
color: '#000',
|
||||
background: '#22c55e',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '6px',
|
||||
minWidth: '20px',
|
||||
textAlign: 'center',
|
||||
letterSpacing: '0.05em',
|
||||
flexShrink: 0
|
||||
}}>
|
||||
{suggestion.id}
|
||||
</span>
|
||||
{primaryDimension && (
|
||||
<span style={{
|
||||
color: '#666',
|
||||
fontSize: '10px',
|
||||
fontWeight: 600,
|
||||
minWidth: '60px',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em'
|
||||
}}>
|
||||
{primaryDimension}
|
||||
</span>
|
||||
)}
|
||||
<span style={{
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
color: '#e5e5e5'
|
||||
}}>
|
||||
{suggestion.title}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Keyboard Hint */}
|
||||
<div style={{
|
||||
padding: '12px 16px',
|
||||
borderTop: '1px solid #1f1f1f',
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: '16px',
|
||||
fontSize: '11px',
|
||||
color: '#666'
|
||||
}}>
|
||||
<span>↑↓ Navigate</span>
|
||||
<span>↵ Select</span>
|
||||
<span>Esc Close</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
|
||||
return typeof window !== 'undefined' ? createPortal(modalContent, document.body) : null;
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { apiKeyService, ApiKeyStatus } from '@/services/storage/apiKeys';
|
||||
|
||||
export default function ApiKeysViewer() {
|
||||
const [openaiKey, setOpenaiKey] = useState('');
|
||||
const [anthropicKey, setAnthropicKey] = useState('');
|
||||
const [status, setStatus] = useState<ApiKeyStatus>({
|
||||
openai: 'not-set',
|
||||
anthropic: 'not-set'
|
||||
});
|
||||
const [showKeys, setShowKeys] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Load existing keys and status
|
||||
const stored = apiKeyService.getStoredKeys();
|
||||
setOpenaiKey(stored.openai || '');
|
||||
setAnthropicKey(stored.anthropic || '');
|
||||
setStatus(apiKeyService.getStatus());
|
||||
}, []);
|
||||
|
||||
const handleSaveOpenAi = async () => {
|
||||
if (!openaiKey.trim()) return;
|
||||
|
||||
try {
|
||||
apiKeyService.setOpenAiKey(openaiKey.trim());
|
||||
// Test the connection
|
||||
const isConnected = await apiKeyService.testOpenAiConnection();
|
||||
setStatus(prev => ({ ...prev, openai: isConnected ? 'connected' : 'failed' }));
|
||||
|
||||
if (isConnected) {
|
||||
alert('OpenAI API key saved and connection verified!');
|
||||
} else {
|
||||
alert('OpenAI API key saved but connection failed. Please check your key.');
|
||||
}
|
||||
} catch (error) {
|
||||
alert(`Failed to save OpenAI key: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveAnthropic = async () => {
|
||||
if (!anthropicKey.trim()) return;
|
||||
|
||||
try {
|
||||
apiKeyService.setAnthropicKey(anthropicKey.trim());
|
||||
// Test the connection
|
||||
const isConnected = await apiKeyService.testAnthropicConnection();
|
||||
setStatus(prev => ({ ...prev, anthropic: isConnected ? 'connected' : 'failed' }));
|
||||
|
||||
if (isConnected) {
|
||||
alert('Anthropic API key saved and connection verified!');
|
||||
} else {
|
||||
alert('Anthropic API key saved but connection failed. Please check your key.');
|
||||
}
|
||||
} catch (error) {
|
||||
alert(`Failed to save Anthropic key: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestOpenAi = async () => {
|
||||
setStatus(prev => ({ ...prev, openai: 'testing' }));
|
||||
const isConnected = await apiKeyService.testOpenAiConnection();
|
||||
setStatus(prev => ({ ...prev, openai: isConnected ? 'connected' : 'failed' }));
|
||||
};
|
||||
|
||||
const handleTestAnthropic = async () => {
|
||||
setStatus(prev => ({ ...prev, anthropic: 'testing' }));
|
||||
const isConnected = await apiKeyService.testAnthropicConnection();
|
||||
setStatus(prev => ({ ...prev, anthropic: isConnected ? 'connected' : 'failed' }));
|
||||
};
|
||||
|
||||
const handleClearOpenAi = () => {
|
||||
apiKeyService.clearOpenAiKey();
|
||||
setOpenaiKey('');
|
||||
setStatus(prev => ({ ...prev, openai: 'not-set' }));
|
||||
};
|
||||
|
||||
const handleClearAnthropic = () => {
|
||||
apiKeyService.clearAnthropicKey();
|
||||
setAnthropicKey('');
|
||||
setStatus(prev => ({ ...prev, anthropic: 'not-set' }));
|
||||
};
|
||||
|
||||
const getStatusIcon = (statusValue: string) => {
|
||||
switch (statusValue) {
|
||||
case 'connected': return '✅';
|
||||
case 'failed': return '❌';
|
||||
case 'testing': return '⏳';
|
||||
default: return '⚪';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (statusValue: string) => {
|
||||
switch (statusValue) {
|
||||
case 'connected': return '#22c55e';
|
||||
case 'failed': return '#ef4444';
|
||||
case 'testing': return '#f59e0b';
|
||||
default: return '#6b7280';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
padding: '24px',
|
||||
height: '100%',
|
||||
overflow: 'auto',
|
||||
background: '#0f0f0f',
|
||||
color: '#fff'
|
||||
}}>
|
||||
<div style={{ marginBottom: '32px' }}>
|
||||
<h3 style={{ margin: '0 0 8px 0', fontSize: '16px', fontWeight: '600' }}>
|
||||
API Configuration
|
||||
</h3>
|
||||
<p style={{ margin: 0, fontSize: '14px', color: '#888', lineHeight: '1.5' }}>
|
||||
Keys are stored locally and never shared with RA-H servers.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Show/Hide Keys Toggle */}
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<label style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
fontSize: '14px',
|
||||
cursor: 'pointer',
|
||||
color: '#888'
|
||||
}}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showKeys}
|
||||
onChange={(e) => setShowKeys(e.target.checked)}
|
||||
style={{ marginRight: '8px' }}
|
||||
/>
|
||||
Show API keys in plain text
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* OpenAI Section */}
|
||||
<div style={{
|
||||
marginBottom: '32px',
|
||||
padding: '20px',
|
||||
border: '1px solid #2a2a2a',
|
||||
borderRadius: '8px',
|
||||
background: '#0a0a0a'
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
marginBottom: '16px'
|
||||
}}>
|
||||
<h4 style={{ margin: 0, fontSize: '14px', fontWeight: '600' }}>
|
||||
OpenAI API Key
|
||||
</h4>
|
||||
<div style={{
|
||||
marginLeft: '12px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
fontSize: '12px',
|
||||
color: getStatusColor(status.openai)
|
||||
}}>
|
||||
<span style={{ marginRight: '4px' }}>
|
||||
{getStatusIcon(status.openai)}
|
||||
</span>
|
||||
<span>
|
||||
{status.openai === 'connected' && 'Connected'}
|
||||
{status.openai === 'failed' && 'Connection Failed'}
|
||||
{status.openai === 'testing' && 'Testing...'}
|
||||
{status.openai === 'not-set' && 'Not Set'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<input
|
||||
type={showKeys ? 'text' : 'password'}
|
||||
value={openaiKey}
|
||||
onChange={(e) => setOpenaiKey(e.target.value)}
|
||||
placeholder="sk-proj-... or sk-..."
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
fontSize: '14px',
|
||||
background: '#1a1a1a',
|
||||
border: '1px solid #333',
|
||||
borderRadius: '6px',
|
||||
color: '#fff',
|
||||
fontFamily: 'monospace'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
onClick={handleSaveOpenAi}
|
||||
disabled={!openaiKey.trim()}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
fontSize: '12px',
|
||||
background: '#22c55e',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: openaiKey.trim() ? 'pointer' : 'not-allowed',
|
||||
opacity: openaiKey.trim() ? 1 : 0.5
|
||||
}}
|
||||
>
|
||||
Save & Test
|
||||
</button>
|
||||
|
||||
|
||||
<button
|
||||
onClick={handleClearOpenAi}
|
||||
disabled={!openaiKey}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
fontSize: '12px',
|
||||
background: '#ef4444',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: openaiKey ? 'pointer' : 'not-allowed',
|
||||
opacity: openaiKey ? 1 : 0.5
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Anthropic Section */}
|
||||
<div style={{
|
||||
marginBottom: '32px',
|
||||
padding: '20px',
|
||||
border: '1px solid #2a2a2a',
|
||||
borderRadius: '8px',
|
||||
background: '#0a0a0a'
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
marginBottom: '16px'
|
||||
}}>
|
||||
<h4 style={{ margin: 0, fontSize: '14px', fontWeight: '600' }}>
|
||||
Anthropic API Key
|
||||
</h4>
|
||||
<div style={{
|
||||
marginLeft: '12px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
fontSize: '12px',
|
||||
color: getStatusColor(status.anthropic)
|
||||
}}>
|
||||
<span style={{ marginRight: '4px' }}>
|
||||
{getStatusIcon(status.anthropic)}
|
||||
</span>
|
||||
<span>
|
||||
{status.anthropic === 'connected' && 'Connected'}
|
||||
{status.anthropic === 'failed' && 'Connection Failed'}
|
||||
{status.anthropic === 'testing' && 'Testing...'}
|
||||
{status.anthropic === 'not-set' && 'Not Set'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<input
|
||||
type={showKeys ? 'text' : 'password'}
|
||||
value={anthropicKey}
|
||||
onChange={(e) => setAnthropicKey(e.target.value)}
|
||||
placeholder="sk-ant-api03-..."
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
fontSize: '14px',
|
||||
background: '#1a1a1a',
|
||||
border: '1px solid #333',
|
||||
borderRadius: '6px',
|
||||
color: '#fff',
|
||||
fontFamily: 'monospace'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
onClick={handleSaveAnthropic}
|
||||
disabled={!anthropicKey.trim()}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
fontSize: '12px',
|
||||
background: '#22c55e',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: anthropicKey.trim() ? 'pointer' : 'not-allowed',
|
||||
opacity: anthropicKey.trim() ? 1 : 0.5
|
||||
}}
|
||||
>
|
||||
Save & Test
|
||||
</button>
|
||||
|
||||
|
||||
<button
|
||||
onClick={handleClearAnthropic}
|
||||
disabled={!anthropicKey}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
fontSize: '12px',
|
||||
background: '#ef4444',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: anthropicKey ? 'pointer' : 'not-allowed',
|
||||
opacity: anthropicKey ? 1 : 0.5
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Help Section */}
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
background: '#1a1a1a',
|
||||
border: '1px solid #333',
|
||||
borderRadius: '6px',
|
||||
fontSize: '12px',
|
||||
color: '#888',
|
||||
lineHeight: '1.5'
|
||||
}}>
|
||||
<h5 style={{ margin: '0 0 8px 0', color: '#fff', fontSize: '13px' }}>
|
||||
How to get API keys:
|
||||
</h5>
|
||||
<ul style={{ margin: 0, paddingLeft: '16px' }}>
|
||||
<li style={{ marginBottom: '4px' }}>
|
||||
<strong>OpenAI:</strong> Visit <a href="https://platform.openai.com/api-keys" target="_blank" rel="noopener noreferrer" style={{ color: '#22c55e' }}>platform.openai.com/api-keys</a>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Anthropic:</strong> Visit <a href="https://console.anthropic.com/settings/keys" target="_blank" rel="noopener noreferrer" style={{ color: '#22c55e' }}>console.anthropic.com/settings/keys</a>
|
||||
</li>
|
||||
</ul>
|
||||
<p style={{ margin: '12px 0 0 0', fontSize: '11px' }}>
|
||||
Your keys are stored locally and never sent to RA-H servers. They are only used to communicate directly with OpenAI and Anthropic APIs.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { Node } from '@/types/database';
|
||||
|
||||
interface AutoContextSettings {
|
||||
autoContextEnabled: boolean;
|
||||
lastPinnedMigration?: string;
|
||||
}
|
||||
|
||||
interface NodeWithMetrics extends Node {
|
||||
edge_count?: number;
|
||||
}
|
||||
|
||||
export default function ContextViewer() {
|
||||
const [nodes, setNodes] = useState<NodeWithMetrics[]>([]);
|
||||
const [autoContextEnabled, setAutoContextEnabled] = useState(false);
|
||||
const [lastMigrated, setLastMigrated] = useState<string | undefined>();
|
||||
const [loadingNodes, setLoadingNodes] = useState(true);
|
||||
const [loadingSettings, setLoadingSettings] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadNodes = async () => {
|
||||
setLoadingNodes(true);
|
||||
try {
|
||||
const response = await fetch('/api/nodes?sortBy=edges&limit=10');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load nodes');
|
||||
}
|
||||
const payload = await response.json();
|
||||
setNodes(payload.data || []);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError('Unable to load top nodes.');
|
||||
} finally {
|
||||
setLoadingNodes(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadSettings = async () => {
|
||||
setLoadingSettings(true);
|
||||
try {
|
||||
const response = await fetch('/api/system/auto-context');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load auto-context settings');
|
||||
}
|
||||
const payload = (await response.json()) as {
|
||||
success: boolean;
|
||||
data?: AutoContextSettings;
|
||||
};
|
||||
if (payload.success && payload.data) {
|
||||
setAutoContextEnabled(Boolean(payload.data.autoContextEnabled));
|
||||
setLastMigrated(payload.data.lastPinnedMigration);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError('Unable to load auto-context settings.');
|
||||
} finally {
|
||||
setLoadingSettings(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadNodes();
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
const toggleDescription = useMemo(() => {
|
||||
if (!autoContextEnabled) {
|
||||
return 'Disabled — chats and workflows will not receive BACKGROUND CONTEXT.';
|
||||
}
|
||||
return 'Enabled — top 10 most-connected nodes will be added to BACKGROUND CONTEXT.';
|
||||
}, [autoContextEnabled]);
|
||||
|
||||
const handleToggle = async () => {
|
||||
if (saving) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch('/api/system/auto-context', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ autoContextEnabled: !autoContextEnabled }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as {
|
||||
success: boolean;
|
||||
data?: AutoContextSettings;
|
||||
};
|
||||
|
||||
if (payload.success && payload.data) {
|
||||
setAutoContextEnabled(payload.data.autoContextEnabled);
|
||||
setLastMigrated(payload.data.lastPinnedMigration);
|
||||
} else {
|
||||
throw new Error(payload?.data ? 'Settings update failed' : 'Unknown error');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to update auto-context toggle', err);
|
||||
setError('Unable to update setting. Please try again.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', overflow: 'auto', padding: '24px', color: '#f8fafc' }}>
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<h3 style={{ margin: '0 0 8px', fontSize: '18px' }}>Auto-Context</h3>
|
||||
<p style={{ margin: 0, color: '#94a3b8', fontSize: '13px', lineHeight: 1.6 }}>
|
||||
Auto-context grabs your 10 nodes with the most edges and drops them into BACKGROUND
|
||||
CONTEXT so ra-h knows which ideas connect everything else.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '16px 20px',
|
||||
border: '1px solid #1f2937',
|
||||
borderRadius: '10px',
|
||||
background: '#050505',
|
||||
marginBottom: '24px',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: '14px' }}>Enable Auto-Context</div>
|
||||
<div style={{ fontSize: '12px', color: '#94a3b8', marginTop: '4px' }}>
|
||||
{loadingSettings ? 'Loading preference…' : toggleDescription}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
disabled={loadingSettings || saving}
|
||||
style={{
|
||||
width: '58px',
|
||||
height: '30px',
|
||||
borderRadius: '999px',
|
||||
border: 'none',
|
||||
cursor: saving ? 'wait' : 'pointer',
|
||||
background: autoContextEnabled ? '#22c55e' : '#334155',
|
||||
position: 'relative',
|
||||
transition: 'background 0.2s ease',
|
||||
}}
|
||||
title={autoContextEnabled ? 'Disable auto-context' : 'Enable auto-context'}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '4px',
|
||||
left: autoContextEnabled ? '32px' : '4px',
|
||||
width: '22px',
|
||||
height: '22px',
|
||||
borderRadius: '50%',
|
||||
background: '#fff',
|
||||
transition: 'left 0.2s ease',
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{lastMigrated && (
|
||||
<div style={{ fontSize: '11px', color: '#64748b', marginBottom: '16px' }}>
|
||||
Auto-enabled because you previously pinned nodes · {new Date(lastMigrated).toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, marginBottom: '12px' }}>Top 10 nodes by connections</div>
|
||||
{loadingNodes ? (
|
||||
<div style={{ color: '#94a3b8', fontSize: '13px' }}>Loading nodes…</div>
|
||||
) : nodes.length === 0 ? (
|
||||
<div style={{ color: '#94a3b8', fontSize: '13px' }}>
|
||||
No connected nodes yet. Create nodes and add edges to see context hubs.
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{nodes.map((node) => (
|
||||
<div
|
||||
key={node.id}
|
||||
style={{
|
||||
border: '1px solid #1f2937',
|
||||
borderRadius: '10px',
|
||||
padding: '12px 16px',
|
||||
background: '#080808',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '4px',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600 }}>
|
||||
[NODE:{node.id}] {node.title || 'Untitled node'}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#facc15' }}>
|
||||
{node.edge_count ?? 0} connections
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
|
||||
{node.dimensions && node.dimensions.length > 0 ? (
|
||||
node.dimensions.slice(0, 4).map((dimension) => (
|
||||
<span
|
||||
key={`${node.id}-${dimension}`}
|
||||
style={{
|
||||
padding: '2px 8px',
|
||||
borderRadius: '999px',
|
||||
fontSize: '11px',
|
||||
background: '#132018',
|
||||
color: '#86efac',
|
||||
}}
|
||||
>
|
||||
{dimension}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span style={{ fontSize: '11px', color: '#64748b' }}>No dimensions</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div style={{ marginTop: '24px', color: '#f87171', fontSize: '12px' }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,526 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState, type ChangeEvent, type KeyboardEvent } from 'react';
|
||||
import { Folder, Link as LinkIcon } from 'lucide-react';
|
||||
import { Node } from '@/types/database';
|
||||
|
||||
interface NodeWithMetrics extends Node {
|
||||
edge_count?: number;
|
||||
}
|
||||
|
||||
interface AppliedFilters {
|
||||
search?: string;
|
||||
dimensions?: string[];
|
||||
sortBy: 'updated' | 'edges';
|
||||
}
|
||||
|
||||
interface PopularDimension {
|
||||
dimension: string;
|
||||
count: number;
|
||||
isPriority: boolean;
|
||||
}
|
||||
|
||||
const LIMIT = 50;
|
||||
|
||||
export default function DatabaseViewer() {
|
||||
const [nodes, setNodes] = useState<NodeWithMetrics[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const [dimensionInput, setDimensionInput] = useState('');
|
||||
const [dimensionFilters, setDimensionFilters] = useState<string[]>([]);
|
||||
const [sortBy, setSortBy] = useState<'updated' | 'edges'>('updated');
|
||||
|
||||
const [appliedFilters, setAppliedFilters] = useState<AppliedFilters>({ sortBy: 'updated' });
|
||||
const [lockedDimensionSet, setLockedDimensionSet] = useState<Set<string>>(new Set());
|
||||
const [contextHubIds, setContextHubIds] = useState<Set<number>>(new Set());
|
||||
|
||||
const filtersActive = useMemo(
|
||||
() => Boolean(appliedFilters.search || (appliedFilters.dimensions && appliedFilters.dimensions.length > 0)),
|
||||
[appliedFilters]
|
||||
);
|
||||
|
||||
const fetchNodes = useCallback(
|
||||
async (pageNumber: number, filters: AppliedFilters) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.set('limit', LIMIT.toString());
|
||||
params.set('offset', ((pageNumber - 1) * LIMIT).toString());
|
||||
params.set('sortBy', filters.sortBy);
|
||||
if (filters.search) params.set('search', filters.search);
|
||||
if (filters.dimensions && filters.dimensions.length > 0) {
|
||||
params.set('dimensions', filters.dimensions.join(','));
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/nodes?${params.toString()}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch nodes');
|
||||
}
|
||||
const data = await response.json();
|
||||
setNodes(data.data || []);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
setNodes([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchNodes(page, appliedFilters);
|
||||
}, [page, appliedFilters, fetchNodes]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadLockedDimensions = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/dimensions/popular');
|
||||
if (!response.ok) return;
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
const priorityDimensions: PopularDimension[] = result.data;
|
||||
setLockedDimensionSet(new Set(priorityDimensions.filter((d) => d.isPriority).map((d) => d.dimension)));
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to load locked dimensions', err);
|
||||
}
|
||||
};
|
||||
|
||||
loadLockedDimensions();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const loadContextHubs = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/nodes?sortBy=edges&limit=10');
|
||||
if (!response.ok) return;
|
||||
const payload = await response.json();
|
||||
const ids = new Set<number>((payload.data || []).map((node: Node) => node.id));
|
||||
setContextHubIds(ids);
|
||||
} catch (err) {
|
||||
console.warn('Failed to load auto-context hubs', err);
|
||||
}
|
||||
};
|
||||
|
||||
loadContextHubs();
|
||||
}, []);
|
||||
|
||||
const handleApplyFilters = () => {
|
||||
const payload: AppliedFilters = {
|
||||
sortBy,
|
||||
};
|
||||
|
||||
if (searchInput.trim()) payload.search = searchInput.trim();
|
||||
if (dimensionFilters.length > 0) payload.dimensions = dimensionFilters;
|
||||
|
||||
setAppliedFilters(payload);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handleClearFilters = () => {
|
||||
setSearchInput('');
|
||||
setDimensionInput('');
|
||||
setDimensionFilters([]);
|
||||
setSortBy('updated');
|
||||
setAppliedFilters({ sortBy: 'updated' });
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handleAddDimension = () => {
|
||||
const next = dimensionInput.trim();
|
||||
if (!next) return;
|
||||
setDimensionFilters((prev) => (prev.includes(next) ? prev : [...prev, next]));
|
||||
setDimensionInput('');
|
||||
};
|
||||
|
||||
const handleDimensionKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
handleAddDimension();
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveDimension = (dimension: string) => {
|
||||
setDimensionFilters((prev) => prev.filter((dim) => dim !== dimension));
|
||||
};
|
||||
|
||||
const handleSortChange = (event: ChangeEvent<HTMLSelectElement>) => {
|
||||
const value = event.target.value === 'edges' ? 'edges' : 'updated';
|
||||
setSortBy(value);
|
||||
setAppliedFilters((prev) => ({ ...prev, sortBy: value }));
|
||||
};
|
||||
|
||||
const handlePrevious = () => {
|
||||
setPage((prev) => (prev > 1 ? prev - 1 : prev));
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
if (nodes.length === LIMIT) {
|
||||
setPage((prev) => prev + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const isFirstPage = page === 1;
|
||||
const isLastPage = nodes.length < LIMIT;
|
||||
const filterStatus = filtersActive
|
||||
? 'Filtered results'
|
||||
: `Showing ${(page - 1) * LIMIT + 1}-${(page - 1) * LIMIT + nodes.length}`;
|
||||
|
||||
const formatTimestamp = (value?: string) => {
|
||||
if (!value) return '—';
|
||||
const date = new Date(value);
|
||||
return date.toLocaleString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
const formatRelative = (value?: string) => {
|
||||
if (!value) return '';
|
||||
const diffMs = Date.now() - new Date(value).getTime();
|
||||
const diffMinutes = Math.round(diffMs / (1000 * 60));
|
||||
if (diffMinutes < 60) return `${diffMinutes}m ago`;
|
||||
const diffHours = Math.round(diffMinutes / 60);
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
const diffDays = Math.round(diffHours / 24);
|
||||
return `${diffDays}d ago`;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ padding: '40px', textAlign: 'center', color: '#888' }}>
|
||||
Loading database...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{ padding: '40px', textAlign: 'center', color: '#ef4444' }}>
|
||||
Error: {error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (nodes.length === 0) {
|
||||
return (
|
||||
<div style={{ padding: '40px', textAlign: 'center', color: '#888' }}>
|
||||
No nodes found
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div
|
||||
style={{
|
||||
padding: '16px 24px',
|
||||
borderBottom: '1px solid #2a2a2a',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: '12px', flexWrap: 'wrap' }}>
|
||||
<label style={{ display: 'flex', flexDirection: 'column', fontSize: '11px', color: '#888', gap: '4px' }}>
|
||||
Search
|
||||
<input
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
placeholder="title or content"
|
||||
style={{
|
||||
background: '#0f0f0f',
|
||||
border: '1px solid #2a2a2a',
|
||||
color: '#ddd',
|
||||
padding: '6px 10px',
|
||||
borderRadius: '4px',
|
||||
minWidth: '220px',
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ display: 'flex', flexDirection: 'column', fontSize: '11px', color: '#888', gap: '4px' }}>
|
||||
Dimension
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<input
|
||||
value={dimensionInput}
|
||||
onChange={(e) => setDimensionInput(e.target.value)}
|
||||
onKeyDown={handleDimensionKeyDown}
|
||||
placeholder="e.g. research"
|
||||
style={{
|
||||
background: '#0f0f0f',
|
||||
border: '1px solid #2a2a2a',
|
||||
color: '#ddd',
|
||||
padding: '6px 10px',
|
||||
borderRadius: '4px',
|
||||
minWidth: '180px',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={handleAddDimension}
|
||||
style={{
|
||||
padding: '6px 10px',
|
||||
background: '#1f3529',
|
||||
border: '1px solid #264333',
|
||||
borderRadius: '4px',
|
||||
color: '#c4f5d2',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
<label style={{ display: 'flex', flexDirection: 'column', fontSize: '11px', color: '#888', gap: '4px' }}>
|
||||
Sort by
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={handleSortChange}
|
||||
style={{
|
||||
background: '#0f0f0f',
|
||||
border: '1px solid #2a2a2a',
|
||||
color: '#ddd',
|
||||
padding: '6px 10px',
|
||||
borderRadius: '4px',
|
||||
}}
|
||||
>
|
||||
<option value="updated">Recently updated</option>
|
||||
<option value="edges">Most connected</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{dimensionFilters.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||
{dimensionFilters.map((dimension) => (
|
||||
<span
|
||||
key={dimension}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
padding: '3px 10px',
|
||||
borderRadius: '999px',
|
||||
background: '#142817',
|
||||
color: '#c4f5d2',
|
||||
fontSize: '11px',
|
||||
border: '1px solid #1f3b23',
|
||||
}}
|
||||
>
|
||||
<Folder size={12} />
|
||||
{dimension}
|
||||
<button
|
||||
onClick={() => handleRemoveDimension(dimension)}
|
||||
style={{
|
||||
marginLeft: '2px',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: '#7de8a5',
|
||||
cursor: 'pointer',
|
||||
fontSize: '11px',
|
||||
}}
|
||||
aria-label={`Remove ${dimension}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>{filterStatus}</div>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button
|
||||
onClick={handleApplyFilters}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: '#22c55e33',
|
||||
border: '1px solid #22c55e66',
|
||||
borderRadius: '4px',
|
||||
color: '#22c55e',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
<button
|
||||
onClick={handleClearFilters}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: '#1a1a1a',
|
||||
border: '1px solid #333',
|
||||
borderRadius: '4px',
|
||||
color: '#ccc',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
<button
|
||||
onClick={handlePrevious}
|
||||
disabled={isFirstPage || filtersActive}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
background: isFirstPage || filtersActive ? '#1a1a1a' : '#2a2a2a',
|
||||
border: '1px solid #333',
|
||||
borderRadius: '4px',
|
||||
color: isFirstPage || filtersActive ? '#555' : '#ccc',
|
||||
cursor: isFirstPage || filtersActive ? 'not-allowed' : 'pointer',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={handleNext}
|
||||
disabled={isLastPage || filtersActive}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
background: isLastPage || filtersActive ? '#1a1a1a' : '#2a2a2a',
|
||||
border: '1px solid #333',
|
||||
borderRadius: '4px',
|
||||
color: isLastPage || filtersActive ? '#555' : '#ccc',
|
||||
cursor: isLastPage || filtersActive ? 'not-allowed' : 'pointer',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead style={{ position: 'sticky', top: 0, background: '#1a1a1a', zIndex: 1 }}>
|
||||
<tr>
|
||||
{['Node', 'Categories', 'Edges', 'Highlights', 'Updated', 'Created'].map((column) => (
|
||||
<th
|
||||
key={column}
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
textAlign: 'left',
|
||||
fontSize: '11px',
|
||||
color: '#888',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
fontWeight: 'normal',
|
||||
borderBottom: '1px solid #2a2a2a',
|
||||
}}
|
||||
>
|
||||
{column}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{nodes.map((node, index) => {
|
||||
const belongsToLocked = node.dimensions?.some((dimension) => lockedDimensionSet.has(dimension));
|
||||
return (
|
||||
<tr
|
||||
key={node.id}
|
||||
style={{
|
||||
background: index % 2 === 0 ? '#080808' : '#0d0d0d',
|
||||
borderBottom: '1px solid #111',
|
||||
}}
|
||||
>
|
||||
<td style={{ padding: '12px 16px', verticalAlign: 'top', width: '28%' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
<div style={{ fontWeight: 600, color: '#f5f5f5', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
{node.title || 'Untitled node'}
|
||||
{node.link && (
|
||||
<button
|
||||
onClick={() => window.open(node.link, '_blank')}
|
||||
title="Open original link"
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: '#7de8a5',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<LinkIcon size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<span style={{ fontSize: '11px', color: '#666', fontFamily: 'JetBrains Mono, monospace' }}>ID: {node.id}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', verticalAlign: 'top', width: '24%' }}>
|
||||
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
|
||||
{node.dimensions && node.dimensions.length > 0 ? (
|
||||
node.dimensions.slice(0, 3).map((dimension) => (
|
||||
<span
|
||||
key={`${node.id}-${dimension}`}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
padding: '2px 8px',
|
||||
borderRadius: '999px',
|
||||
background: '#111914',
|
||||
border: '1px solid #1f2f24',
|
||||
fontSize: '11px',
|
||||
color: '#bbf7d0',
|
||||
}}
|
||||
>
|
||||
<Folder size={11} />
|
||||
{dimension}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span style={{ fontSize: '11px', color: '#555' }}>No categories</span>
|
||||
)}
|
||||
{node.dimensions && node.dimensions.length > 3 && (
|
||||
<span style={{ fontSize: '11px', color: '#666' }}>+{node.dimensions.length - 3} more</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', verticalAlign: 'top', width: '10%' }}>
|
||||
<div style={{ fontWeight: 600, color: '#e2e8f0' }}>{node.edge_count ?? 0}</div>
|
||||
<div style={{ fontSize: '11px', color: '#666' }}>connections</div>
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', verticalAlign: 'top', width: '14%' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||||
{contextHubIds.has(node.id) ? (
|
||||
<span style={{ fontSize: '11px', color: '#facc15', fontWeight: 600 }}>
|
||||
Auto-context hub
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ fontSize: '11px', color: '#475569' }}>—</span>
|
||||
)}
|
||||
{belongsToLocked && (
|
||||
<span style={{ fontSize: '11px', color: '#7de8a5' }}>Priority dimension</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', verticalAlign: 'top', width: '12%' }}>
|
||||
<div style={{ fontSize: '12px', color: '#e2e8f0' }}>{formatTimestamp(node.updated_at)}</div>
|
||||
<div style={{ fontSize: '11px', color: '#666' }}>{formatRelative(node.updated_at)}</div>
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', verticalAlign: 'top', width: '12%' }}>
|
||||
<div style={{ fontSize: '12px', color: '#cbd5f5' }}>{formatTimestamp(node.created_at)}</div>
|
||||
<div style={{ fontSize: '11px', color: '#666' }}>{formatRelative(node.created_at)}</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
interface McpStatus {
|
||||
enabled: boolean;
|
||||
url: string | null;
|
||||
port: number | null;
|
||||
last_updated?: string | null;
|
||||
target_base_url?: string | null;
|
||||
last_error?: string | null;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
const initialStatus: McpStatus = {
|
||||
enabled: false,
|
||||
url: null,
|
||||
port: null
|
||||
};
|
||||
|
||||
export default function ExternalAgentsPanel() {
|
||||
const [status, setStatus] = useState<McpStatus>(initialStatus);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [copyState, setCopyState] = useState<'idle' | 'copied'>('idle');
|
||||
|
||||
useEffect(() => {
|
||||
const fetchStatus = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch('/api/system/mcp-status');
|
||||
const data = await response.json();
|
||||
setStatus(data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('Failed to load MCP status', err);
|
||||
setError('Unable to read local MCP status. Open the desktop app to bootstrap it.');
|
||||
setStatus(initialStatus);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchStatus();
|
||||
const timer = setInterval(fetchStatus, 5000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
const connectorUrl = useMemo(() => {
|
||||
if (status?.url) return status.url;
|
||||
if (status?.port) return `http://127.0.0.1:${status.port}/mcp`;
|
||||
return null;
|
||||
}, [status]);
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
if (!connectorUrl) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(connectorUrl);
|
||||
setCopyState('copied');
|
||||
setTimeout(() => setCopyState('idle'), 2000);
|
||||
} catch (err) {
|
||||
console.error('Copy failed', err);
|
||||
}
|
||||
}, [connectorUrl]);
|
||||
|
||||
return (
|
||||
<div style={{ padding: '32px', color: '#e2e8f0', overflowY: 'auto' }}>
|
||||
<h2 style={{ fontSize: '20px', fontWeight: 600, marginBottom: '8px' }}>External Agents</h2>
|
||||
<p style={{ color: '#94a3b8', marginBottom: '24px', lineHeight: 1.5 }}>
|
||||
Connect Claude, ChatGPT, Gemini, or any MCP-compatible assistant to your local RA-H database.
|
||||
Everything stays on device—tools simply call this connector to add or search nodes.
|
||||
</p>
|
||||
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid #1f2937',
|
||||
borderRadius: '10px',
|
||||
padding: '20px',
|
||||
marginBottom: '24px',
|
||||
background: '#090909'
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: '14px', color: '#94a3b8' }}>Connector URL</div>
|
||||
<div style={{ fontSize: '18px', color: connectorUrl ? '#fff' : '#64748b', marginTop: '4px' }}>
|
||||
{loading ? 'Loading…' : connectorUrl ?? 'Unavailable (start the RA-H desktop app)'}
|
||||
</div>
|
||||
{status.last_updated && (
|
||||
<div style={{ fontSize: '12px', color: '#475569', marginTop: '6px' }}>
|
||||
Updated {new Date(status.last_updated).toLocaleTimeString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
disabled={!connectorUrl}
|
||||
style={{
|
||||
background: connectorUrl ? '#22c55e' : '#1f2937',
|
||||
color: connectorUrl ? '#000' : '#475569',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
padding: '10px 16px',
|
||||
cursor: connectorUrl ? 'pointer' : 'not-allowed',
|
||||
fontWeight: 600
|
||||
}}
|
||||
>
|
||||
{copyState === 'copied' ? 'Copied ✓' : 'Copy URL'}
|
||||
</button>
|
||||
</div>
|
||||
{status.last_error && (
|
||||
<div style={{ marginTop: '12px', fontSize: '13px', color: '#fbbf24' }}>
|
||||
⚠️ {status.last_error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid #1f2937',
|
||||
borderRadius: '10px',
|
||||
padding: '20px',
|
||||
marginBottom: '24px',
|
||||
background: '#0c111d'
|
||||
}}
|
||||
>
|
||||
<h3 style={{ marginBottom: '12px', fontSize: '16px', fontWeight: 600 }}>How to use in Claude or ChatGPT</h3>
|
||||
<ol style={{ paddingLeft: '20px', lineHeight: 1.6, color: '#cbd5f5' }}>
|
||||
<li>Open the MCP / connectors settings in your assistant.</li>
|
||||
<li>Select “Add connector” → choose HTTP → paste the URL above.</li>
|
||||
<li>Give the connector a friendly name (e.g., “RA-H”).</li>
|
||||
<li>Ask naturally: “Add this summary to RA-H” or “Search RA-H for my Apollo notes”.</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid #3f1d1d',
|
||||
borderRadius: '10px',
|
||||
padding: '16px',
|
||||
background: '#170e0e',
|
||||
color: '#fca5a5',
|
||||
marginBottom: '24px',
|
||||
lineHeight: 1.5
|
||||
}}
|
||||
>
|
||||
External agents can edit your local graph. Only enable trusted connectors and monitor their output.
|
||||
Disconnect the connector or close RA-H if anything unexpected happens.
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div style={{ color: '#f87171', marginBottom: '16px', fontSize: '14px' }}>{error}</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'grid', gap: '16px' }}>
|
||||
<HelperCard
|
||||
title="Add to RA-H"
|
||||
body={`"Summarize our meeting and add it to RA-H under dimensions Strategy, Q1 Execution."`}
|
||||
/>
|
||||
<HelperCard
|
||||
title="Search RA-H"
|
||||
body={`"Search RA-H for what I previously wrote about the Apollo launch delays."`}
|
||||
/>
|
||||
<HelperCard
|
||||
title="Check nodes before writing"
|
||||
body={`"Before adding anything new, call rah.search_nodes to see if the note already exists."`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HelperCard({ title, body }: { title: string; body: string }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid #1f2937',
|
||||
borderRadius: '8px',
|
||||
padding: '14px',
|
||||
background: '#0f172a'
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600, marginBottom: '6px', color: '#f1f5f9' }}>{title}</div>
|
||||
<div style={{ color: '#cbd5f5', fontSize: '14px', lineHeight: 1.5 }}>{body}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from 'react';
|
||||
import { LogEntry } from '@/types/logs';
|
||||
|
||||
interface LogsRowProps {
|
||||
log: LogEntry;
|
||||
isEven: boolean;
|
||||
}
|
||||
|
||||
export default function LogsRow({ log, isEven }: LogsRowProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const formatTimestamp = (ts: string) => {
|
||||
const date = new Date(ts);
|
||||
return date.toISOString().replace('T', ' ').substring(0, 19);
|
||||
};
|
||||
|
||||
const formatJson = (jsonStr: string | null) => {
|
||||
if (!jsonStr) return 'null';
|
||||
try {
|
||||
const parsed = JSON.parse(jsonStr);
|
||||
return JSON.stringify(parsed, null, 2);
|
||||
} catch {
|
||||
return jsonStr;
|
||||
}
|
||||
};
|
||||
|
||||
const highlightJson = (jsonStr: string) => {
|
||||
return jsonStr
|
||||
.replace(/"([^"]+)":/g, '<span style="color: #60a5fa">"$1"</span>:')
|
||||
.replace(/: "([^"]*)"/g, ': <span style="color: #34d399">"$1"</span>')
|
||||
.replace(/: (\d+)/g, ': <span style="color: #fb923c">$1</span>')
|
||||
.replace(/: (true|false|null)/g, ': <span style="color: #a78bfa">$1</span>');
|
||||
};
|
||||
|
||||
const getMetricsFromSnapshot = () => {
|
||||
if (!log.snapshot_json || log.table_name !== 'chats') return null;
|
||||
try {
|
||||
const snapshot = JSON.parse(log.snapshot_json);
|
||||
return {
|
||||
thread: snapshot.thread,
|
||||
trace_id: snapshot.trace_id,
|
||||
input_tokens: snapshot.input_tokens,
|
||||
output_tokens: snapshot.output_tokens,
|
||||
cost_usd: snapshot.cost_usd,
|
||||
cache_hit: snapshot.cache_hit,
|
||||
model: snapshot.model,
|
||||
system_message: snapshot.system_message
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const metrics = getMetricsFromSnapshot();
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
style={{
|
||||
background: isEven ? '#0f0f0f' : '#141414',
|
||||
cursor: 'pointer',
|
||||
borderBottom: '1px solid #2a2a2a'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#1a1a1a';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = isEven ? '#0f0f0f' : '#141414';
|
||||
}}
|
||||
>
|
||||
<td style={{ padding: '12px 16px', fontSize: '12px', fontFamily: 'JetBrains Mono, monospace', width: '60px' }}>
|
||||
{log.id}
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: '12px', fontFamily: 'JetBrains Mono, monospace', width: '180px' }}>
|
||||
{formatTimestamp(log.ts)}
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: '12px', fontFamily: 'JetBrains Mono, monospace', width: '100px' }}>
|
||||
{log.table_name}
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: '12px', fontFamily: 'JetBrains Mono, monospace', width: '80px' }}>
|
||||
{log.action}
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: '12px', fontFamily: 'JetBrains Mono, monospace' }}>
|
||||
<div>{log.summary || '-'}</div>
|
||||
{metrics && (
|
||||
<div style={{ marginTop: '6px', fontSize: '10px', color: '#888', display: 'flex', gap: '12px', flexWrap: 'wrap' }}>
|
||||
{metrics.trace_id && (
|
||||
<span title={`Trace: ${metrics.trace_id}`}>
|
||||
🔗 {metrics.trace_id.substring(0, 8)}
|
||||
</span>
|
||||
)}
|
||||
{metrics.thread && (
|
||||
<span title={`Thread: ${metrics.thread}`}>
|
||||
🧵 {metrics.thread.substring(0, 16)}…
|
||||
</span>
|
||||
)}
|
||||
{metrics.cost_usd !== undefined && (
|
||||
<span style={{ color: '#34d399' }}>
|
||||
💰 ${metrics.cost_usd.toFixed(6)}
|
||||
</span>
|
||||
)}
|
||||
{metrics.input_tokens !== undefined && metrics.output_tokens !== undefined && (
|
||||
<span>
|
||||
📊 {metrics.input_tokens}↓ {metrics.output_tokens}↑
|
||||
</span>
|
||||
)}
|
||||
{metrics.cache_hit !== undefined && metrics.cache_hit === 1 && (
|
||||
<span style={{ color: '#60a5fa' }}>
|
||||
⚡ Cache Hit
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: '12px', fontFamily: 'JetBrains Mono, monospace', width: '80px' }}>
|
||||
{log.row_id}
|
||||
</td>
|
||||
</tr>
|
||||
{expanded && (
|
||||
<tr style={{ background: '#0a0a0a', borderTop: '1px solid #333', borderBottom: '1px solid #333' }}>
|
||||
<td colSpan={6} style={{ padding: '16px 24px' }}>
|
||||
{metrics?.system_message && (
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<div style={{ fontSize: '11px', color: '#888', marginBottom: '8px', textTransform: 'uppercase', letterSpacing: '0.5px' }}>
|
||||
System Message
|
||||
</div>
|
||||
<pre
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
fontFamily: 'JetBrains Mono, monospace',
|
||||
lineHeight: '1.5',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
margin: 0,
|
||||
color: '#60a5fa',
|
||||
background: '#0f0f0f',
|
||||
padding: '12px',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid #1f1f1f',
|
||||
maxHeight: '300px',
|
||||
overflow: 'auto'
|
||||
}}
|
||||
>
|
||||
{metrics.system_message}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<div style={{ fontSize: '11px', color: '#888', marginBottom: '8px', textTransform: 'uppercase', letterSpacing: '0.5px' }}>
|
||||
Snapshot JSON
|
||||
</div>
|
||||
<pre
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
fontFamily: 'JetBrains Mono, monospace',
|
||||
lineHeight: '1.6',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
margin: 0
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: highlightJson(formatJson(log.snapshot_json)) }}
|
||||
/>
|
||||
</div>
|
||||
{log.enriched_summary && (
|
||||
<div>
|
||||
<div style={{ fontSize: '11px', color: '#888', marginBottom: '8px', textTransform: 'uppercase', letterSpacing: '0.5px' }}>
|
||||
Enriched Summary
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#ccc', lineHeight: '1.6' }}>
|
||||
{log.enriched_summary}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { LogEntry, LogsResponse } from '@/types/logs';
|
||||
import LogsRow from './LogsRow';
|
||||
|
||||
type AppliedFilters = {
|
||||
threadId?: string;
|
||||
traceId?: string;
|
||||
table?: string;
|
||||
};
|
||||
|
||||
export default function LogsViewer() {
|
||||
const LIMIT = 100;
|
||||
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [inputThreadId, setInputThreadId] = useState('');
|
||||
const [inputTraceId, setInputTraceId] = useState('');
|
||||
const [inputTable, setInputTable] = useState('');
|
||||
const [appliedFilters, setAppliedFilters] = useState<AppliedFilters>({});
|
||||
|
||||
const filtersActive = useMemo(
|
||||
() => Boolean(appliedFilters.threadId || appliedFilters.traceId || appliedFilters.table),
|
||||
[appliedFilters]
|
||||
);
|
||||
|
||||
const fetchLogs = useCallback(
|
||||
async (pageNum: number, filters: AppliedFilters) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.set('page', pageNum.toString());
|
||||
params.set('limit', LIMIT.toString());
|
||||
if (filters.threadId) params.set('threadId', filters.threadId);
|
||||
if (filters.traceId) params.set('traceId', filters.traceId);
|
||||
if (filters.table) params.set('table', filters.table);
|
||||
|
||||
const response = await fetch(`/api/logs?${params.toString()}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch logs');
|
||||
}
|
||||
const data: LogsResponse = await response.json();
|
||||
setLogs(data.logs);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
setLogs([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[LIMIT]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchLogs(page, appliedFilters);
|
||||
}, [page, appliedFilters, fetchLogs]);
|
||||
|
||||
const handleApplyFilters = () => {
|
||||
const nextFilters: AppliedFilters = {
|
||||
threadId: inputThreadId.trim() || undefined,
|
||||
traceId: inputTraceId.trim() || undefined,
|
||||
table: inputTable.trim() || undefined,
|
||||
};
|
||||
setAppliedFilters(nextFilters);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handleClearFilters = () => {
|
||||
setInputThreadId('');
|
||||
setInputTraceId('');
|
||||
setInputTable('');
|
||||
setAppliedFilters({});
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handlePrevious = () => {
|
||||
setPage(prev => (prev > 1 ? prev - 1 : prev));
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
if (logs.length === LIMIT) {
|
||||
setPage(prev => prev + 1);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ padding: '40px', textAlign: 'center', color: '#888' }}>
|
||||
Loading logs...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{ padding: '40px', textAlign: 'center', color: '#ef4444' }}>
|
||||
Error: {error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (logs.length === 0) {
|
||||
return (
|
||||
<div style={{ padding: '40px', textAlign: 'center', color: '#888' }}>
|
||||
No logs found
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isFirstPage = page === 1;
|
||||
const isLastPage = logs.length < LIMIT;
|
||||
const filterStatus = appliedFilters.threadId
|
||||
? `Thread: ${appliedFilters.threadId}`
|
||||
: appliedFilters.traceId
|
||||
? `Trace: ${appliedFilters.traceId}`
|
||||
: appliedFilters.table
|
||||
? `Table: ${appliedFilters.table}`
|
||||
: `Page ${page}`;
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div
|
||||
style={{
|
||||
padding: '16px 24px',
|
||||
borderBottom: '1px solid #2a2a2a',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: '12px', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<label style={{ display: 'flex', flexDirection: 'column', fontSize: '11px', color: '#888', gap: '4px' }}>
|
||||
Thread ID
|
||||
<input
|
||||
value={inputThreadId}
|
||||
onChange={(e) => setInputThreadId(e.target.value)}
|
||||
placeholder="ra-h-node-4326-session_..."
|
||||
style={{
|
||||
background: '#0f0f0f',
|
||||
border: '1px solid #2a2a2a',
|
||||
color: '#ddd',
|
||||
padding: '6px 10px',
|
||||
borderRadius: '4px',
|
||||
fontFamily: 'JetBrains Mono, monospace',
|
||||
fontSize: '12px',
|
||||
minWidth: '240px',
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ display: 'flex', flexDirection: 'column', fontSize: '11px', color: '#888', gap: '4px' }}>
|
||||
Trace ID
|
||||
<input
|
||||
value={inputTraceId}
|
||||
onChange={(e) => setInputTraceId(e.target.value)}
|
||||
placeholder="trace uuid"
|
||||
style={{
|
||||
background: '#0f0f0f',
|
||||
border: '1px solid #2a2a2a',
|
||||
color: '#ddd',
|
||||
padding: '6px 10px',
|
||||
borderRadius: '4px',
|
||||
fontFamily: 'JetBrains Mono, monospace',
|
||||
fontSize: '12px',
|
||||
minWidth: '200px',
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ display: 'flex', flexDirection: 'column', fontSize: '11px', color: '#888', gap: '4px' }}>
|
||||
Table
|
||||
<input
|
||||
value={inputTable}
|
||||
onChange={(e) => setInputTable(e.target.value)}
|
||||
placeholder="nodes | edges | chats"
|
||||
style={{
|
||||
background: '#0f0f0f',
|
||||
border: '1px solid #2a2a2a',
|
||||
color: '#ddd',
|
||||
padding: '6px 10px',
|
||||
borderRadius: '4px',
|
||||
fontFamily: 'JetBrains Mono, monospace',
|
||||
fontSize: '12px',
|
||||
minWidth: '160px',
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button
|
||||
onClick={handleApplyFilters}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: '#22c55e33',
|
||||
border: '1px solid #22c55e66',
|
||||
borderRadius: '4px',
|
||||
color: '#22c55e',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
fontFamily: 'JetBrains Mono, monospace',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#22c55e55';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = '#22c55e33';
|
||||
}}
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
<button
|
||||
onClick={handleClearFilters}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: '#1a1a1a',
|
||||
border: '1px solid #333',
|
||||
borderRadius: '4px',
|
||||
color: '#ccc',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
fontFamily: 'JetBrains Mono, monospace',
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>{filterStatus}</div>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button
|
||||
onClick={handlePrevious}
|
||||
disabled={isFirstPage || filtersActive}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: isFirstPage || filtersActive ? '#1a1a1a' : '#2a2a2a',
|
||||
border: '1px solid #333',
|
||||
borderRadius: '4px',
|
||||
color: isFirstPage || filtersActive ? '#555' : '#ccc',
|
||||
cursor: isFirstPage || filtersActive ? 'not-allowed' : 'pointer',
|
||||
fontSize: '12px',
|
||||
fontFamily: 'JetBrains Mono, monospace',
|
||||
}}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={handleNext}
|
||||
disabled={isLastPage || filtersActive}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: isLastPage || filtersActive ? '#1a1a1a' : '#2a2a2a',
|
||||
border: '1px solid #333',
|
||||
borderRadius: '4px',
|
||||
color: isLastPage || filtersActive ? '#555' : '#ccc',
|
||||
cursor: isLastPage || filtersActive ? 'not-allowed' : 'pointer',
|
||||
fontSize: '12px',
|
||||
fontFamily: 'JetBrains Mono, monospace',
|
||||
}}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead style={{ position: 'sticky', top: 0, background: '#1a1a1a', zIndex: 1 }}>
|
||||
<tr>
|
||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontSize: '11px', color: '#888', textTransform: 'uppercase', letterSpacing: '0.5px', fontWeight: 'normal', borderBottom: '1px solid #2a2a2a', width: '60px' }}>
|
||||
ID
|
||||
</th>
|
||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontSize: '11px', color: '#888', textTransform: 'uppercase', letterSpacing: '0.5px', fontWeight: 'normal', borderBottom: '1px solid #2a2a2a', width: '180px' }}>
|
||||
Timestamp
|
||||
</th>
|
||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontSize: '11px', color: '#888', textTransform: 'uppercase', letterSpacing: '0.5px', fontWeight: 'normal', borderBottom: '1px solid #2a2a2a', width: '100px' }}>
|
||||
Table
|
||||
</th>
|
||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontSize: '11px', color: '#888', textTransform: 'uppercase', letterSpacing: '0.5px', fontWeight: 'normal', borderBottom: '1px solid #2a2a2a', width: '80px' }}>
|
||||
Action
|
||||
</th>
|
||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontSize: '11px', color: '#888', textTransform: 'uppercase', letterSpacing: '0.5px', fontWeight: 'normal', borderBottom: '1px solid #2a2a2a' }}>
|
||||
Summary
|
||||
</th>
|
||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontSize: '11px', color: '#888', textTransform: 'uppercase', letterSpacing: '0.5px', fontWeight: 'normal', borderBottom: '1px solid #2a2a2a', width: '80px' }}>
|
||||
Row ID
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{logs.map((log, index) => (
|
||||
<LogsRow key={log.id} log={log} isEven={index % 2 === 0} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react';
|
||||
import { Folder, Map as MapIcon } from 'lucide-react';
|
||||
import type { Edge, Node } from '@/types/database';
|
||||
|
||||
interface GraphNode extends Node {
|
||||
edge_count?: number;
|
||||
x: number;
|
||||
y: number;
|
||||
radius: number;
|
||||
tier: number;
|
||||
}
|
||||
|
||||
interface PopularDimension {
|
||||
dimension: string;
|
||||
count: number;
|
||||
isPriority: boolean;
|
||||
}
|
||||
|
||||
interface TransformState {
|
||||
x: number;
|
||||
y: number;
|
||||
scale: number;
|
||||
}
|
||||
|
||||
const LIMIT = 400;
|
||||
const PRIMARY_NODE_LIMIT = 150;
|
||||
|
||||
export default function MapViewer() {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [containerSize, setContainerSize] = useState({ width: 800, height: 600 });
|
||||
const [nodes, setNodes] = useState<Node[]>([]);
|
||||
const [edges, setEdges] = useState<Edge[]>([]);
|
||||
const [lockedDimensions, setLockedDimensions] = useState<Set<string>>(new Set());
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [transform, setTransform] = useState<TransformState>({ x: 0, y: 0, scale: 1 });
|
||||
const [hoverNode, setHoverNode] = useState<Node | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new ResizeObserver(entries => {
|
||||
const entry = entries[0];
|
||||
if (entry?.contentRect) {
|
||||
setContainerSize({
|
||||
width: entry.contentRect.width,
|
||||
height: entry.contentRect.height,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (containerRef.current) {
|
||||
observer.observe(containerRef.current);
|
||||
}
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [nodesRes, edgesRes, dimensionsRes] = await Promise.all([
|
||||
fetch(`/api/nodes?limit=${LIMIT}&sortBy=edges`),
|
||||
fetch('/api/edges'),
|
||||
fetch('/api/dimensions/popular'),
|
||||
]);
|
||||
|
||||
if (!nodesRes.ok || !edgesRes.ok) {
|
||||
throw new Error('Failed to load knowledge graph data');
|
||||
}
|
||||
|
||||
const nodesPayload = await nodesRes.json();
|
||||
const edgesPayload = await edgesRes.json();
|
||||
|
||||
setNodes(nodesPayload.data || []);
|
||||
setEdges(edgesPayload.data || []);
|
||||
|
||||
if (dimensionsRes.ok) {
|
||||
const dimPayload = await dimensionsRes.json();
|
||||
if (dimPayload.success) {
|
||||
const priority: PopularDimension[] = dimPayload.data;
|
||||
setLockedDimensions(new Set(priority.filter(d => d.isPriority).map(d => d.dimension)));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const sortedNodes = useMemo(() => {
|
||||
return [...nodes].sort((a, b) => {
|
||||
const aLocked = a.dimensions?.some(dim => lockedDimensions.has(dim));
|
||||
const bLocked = b.dimensions?.some(dim => lockedDimensions.has(dim));
|
||||
if (aLocked !== bLocked) {
|
||||
return aLocked ? -1 : 1;
|
||||
}
|
||||
return (b.edge_count ?? 0) - (a.edge_count ?? 0);
|
||||
});
|
||||
}, [nodes, lockedDimensions]);
|
||||
|
||||
const contextHubIds = useMemo(() => {
|
||||
return new Set(sortedNodes.slice(0, 10).map(node => node.id));
|
||||
}, [sortedNodes]);
|
||||
|
||||
const graphNodes = useMemo<GraphNode[]>(() => {
|
||||
if (sortedNodes.length === 0) return [];
|
||||
const { width, height } = containerSize;
|
||||
const centerX = width / 2;
|
||||
const centerY = height / 2;
|
||||
const primaryNodes = sortedNodes.slice(0, PRIMARY_NODE_LIMIT);
|
||||
const secondaryNodes = sortedNodes.slice(PRIMARY_NODE_LIMIT);
|
||||
|
||||
const jitter = (index: number, span: number) => ((index % span) / span) * 40 - 20;
|
||||
|
||||
const positionedPrimary = primaryNodes.map((node, index) => {
|
||||
const isContextHub = contextHubIds.has(node.id);
|
||||
const isLocked = node.dimensions?.some(dim => lockedDimensions.has(dim));
|
||||
const tier = isContextHub ? 0 : isLocked ? 1 : 2;
|
||||
const baseRadius = [60, 200, 340][tier];
|
||||
const radius = baseRadius + Math.min(node.edge_count || 0, 80);
|
||||
const angle = ((index % 80) / 80) * Math.PI * 2;
|
||||
const x = centerX + Math.cos(angle) * radius + jitter(index, 5);
|
||||
const y = centerY + Math.sin(angle) * radius * 0.7 + jitter(index, 7);
|
||||
const size = tier === 0 ? 16 : tier === 1 ? 12 : 8;
|
||||
const radiusScaled = size + Math.log((node.edge_count || 1) + 1) * (tier === 2 ? 1.5 : 2.5);
|
||||
return {
|
||||
...node,
|
||||
x,
|
||||
y,
|
||||
radius: radiusScaled,
|
||||
tier,
|
||||
};
|
||||
});
|
||||
|
||||
const positionedSecondary = secondaryNodes.map((node, index) => {
|
||||
const angle = (index / secondaryNodes.length) * Math.PI * 2;
|
||||
const outerRadius = Math.max(width, height) * 0.55;
|
||||
return {
|
||||
...node,
|
||||
x: centerX + Math.cos(angle) * outerRadius,
|
||||
y: centerY + Math.sin(angle) * outerRadius,
|
||||
radius: 2,
|
||||
tier: 3,
|
||||
};
|
||||
});
|
||||
|
||||
return [...positionedPrimary, ...positionedSecondary];
|
||||
}, [sortedNodes, lockedDimensions, containerSize]);
|
||||
|
||||
const graphEdges = useMemo(() => {
|
||||
if (graphNodes.length === 0 || edges.length === 0) return [];
|
||||
const nodeMap = new Map<number, GraphNode>();
|
||||
graphNodes.forEach(node => nodeMap.set(node.id, node));
|
||||
|
||||
const weightedEdges = edges
|
||||
.map(edge => {
|
||||
const source = nodeMap.get(edge.from_node_id);
|
||||
const target = nodeMap.get(edge.to_node_id);
|
||||
if (!source || !target) return null;
|
||||
const weight = (source.edge_count || 0) + (target.edge_count || 0);
|
||||
return { id: edge.id, source, target, weight };
|
||||
})
|
||||
.filter(Boolean) as Array<{ id: number; source: GraphNode; target: GraphNode; weight: number }>;
|
||||
|
||||
return weightedEdges
|
||||
.sort((a, b) => b.weight - a.weight)
|
||||
.slice(0, 800);
|
||||
}, [edges, graphNodes]);
|
||||
|
||||
const handleZoom = (direction: 'in' | 'out' | 'reset') => {
|
||||
if (direction === 'reset') {
|
||||
setTransform({ x: 0, y: 0, scale: 1 });
|
||||
return;
|
||||
}
|
||||
setTransform(prev => ({
|
||||
...prev,
|
||||
scale: direction === 'in' ? Math.min(prev.scale + 0.2, 2.5) : Math.max(prev.scale - 0.2, 0.6),
|
||||
}));
|
||||
};
|
||||
|
||||
const handlePanStart = (event: React.PointerEvent<SVGRectElement>) => {
|
||||
const startX = event.clientX;
|
||||
const startY = event.clientY;
|
||||
const originX = transform.x;
|
||||
const originY = transform.y;
|
||||
|
||||
const handleMove = (moveEvent: PointerEvent) => {
|
||||
setTransform(prev => ({
|
||||
...prev,
|
||||
x: originX + (moveEvent.clientX - startX),
|
||||
y: originY + (moveEvent.clientY - startY),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleUp = () => {
|
||||
window.removeEventListener('pointermove', handleMove);
|
||||
window.removeEventListener('pointerup', handleUp);
|
||||
};
|
||||
|
||||
window.addEventListener('pointermove', handleMove);
|
||||
window.addEventListener('pointerup', handleUp);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ padding: '40px', textAlign: 'center', color: '#888' }}>
|
||||
Generating map...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{ padding: '40px', textAlign: 'center', color: '#ef4444' }}>
|
||||
Error: {error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (graphNodes.length === 0) {
|
||||
return (
|
||||
<div style={{ padding: '40px', textAlign: 'center', color: '#888' }}>
|
||||
Not enough nodes to render a map yet
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} style={{ position: 'relative', height: '100%', background: '#050505' }}>
|
||||
{/* Controls */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 16,
|
||||
right: 16,
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
zIndex: 2,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => handleZoom('in')}
|
||||
style={controlButtonStyle}
|
||||
title="Zoom in"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleZoom('out')}
|
||||
style={controlButtonStyle}
|
||||
title="Zoom out"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleZoom('reset')}
|
||||
style={controlButtonStyle}
|
||||
title="Reset view"
|
||||
>
|
||||
⟳
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 16,
|
||||
left: 16,
|
||||
background: 'rgba(8,8,8,0.9)',
|
||||
border: '1px solid #1f1f1f',
|
||||
borderRadius: '8px',
|
||||
padding: '12px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '6px',
|
||||
fontSize: '12px',
|
||||
color: '#bbb',
|
||||
zIndex: 2,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontWeight: 600 }}>
|
||||
<MapIcon size={14} /> Legend
|
||||
</div>
|
||||
<LegendRow color="#fcd34d" label="Auto-context hub" />
|
||||
<LegendRow color="#7de8a5" label="Locked dimension" icon={<Folder size={12} color="#7de8a5" />} />
|
||||
<LegendRow color="#cbd5f5" label="Regular node" />
|
||||
<div style={{ fontSize: '11px', color: '#666' }}>Node size increases with edge count</div>
|
||||
</div>
|
||||
|
||||
{/* Hover tooltip */}
|
||||
{hoverNode && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 16,
|
||||
right: 16,
|
||||
maxWidth: '260px',
|
||||
background: 'rgba(10,10,10,0.95)',
|
||||
border: '1px solid #1f1f1f',
|
||||
borderRadius: '8px',
|
||||
padding: '12px',
|
||||
fontSize: '12px',
|
||||
color: '#eee',
|
||||
zIndex: 2,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600 }}>{hoverNode.title || 'Untitled node'}</div>
|
||||
<div style={{ fontSize: '11px', color: '#666', marginBottom: '6px' }}>ID: {hoverNode.id}</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
<span>Edges: {hoverNode.edge_count ?? 0}</span>
|
||||
<span>Auto-context hub: {contextHubIds.has(hoverNode.id) ? 'Yes' : 'No'}</span>
|
||||
<span style={{ display: 'flex', flexWrap: 'wrap', gap: '4px' }}>
|
||||
{(hoverNode.dimensions || []).slice(0, 3).map(dimension => (
|
||||
<span
|
||||
key={`${hoverNode.id}-${dimension}`}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '999px',
|
||||
background: '#0f1a12',
|
||||
border: '1px solid #1f3425',
|
||||
}}
|
||||
>
|
||||
<Folder size={10} />
|
||||
{dimension}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<svg width="100%" height="100%" style={{ display: 'block' }}>
|
||||
<rect
|
||||
width="100%"
|
||||
height="100%"
|
||||
fill="transparent"
|
||||
style={{ cursor: 'grab' }}
|
||||
onPointerDown={handlePanStart}
|
||||
/>
|
||||
<g transform={`translate(${transform.x} ${transform.y}) scale(${transform.scale})`}>
|
||||
{graphEdges.map(edge => {
|
||||
const thickness = Math.min(3, 0.5 + edge.weight / 300);
|
||||
const opacity = Math.min(0.7, 0.2 + edge.weight / 800);
|
||||
return (
|
||||
<line
|
||||
key={edge.id}
|
||||
x1={edge.source.x}
|
||||
y1={edge.source.y}
|
||||
x2={edge.target.x}
|
||||
y2={edge.target.y}
|
||||
stroke="#1f2933"
|
||||
strokeWidth={thickness}
|
||||
strokeOpacity={opacity}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{graphNodes.map(node => {
|
||||
const isContextHub = contextHubIds.has(node.id);
|
||||
const isLocked = node.dimensions?.some(dim => lockedDimensions.has(dim));
|
||||
const fill = isContextHub ? '#fcd34d' : isLocked ? '#7de8a5' : node.tier === 3 ? '#334155' : '#cbd5f5';
|
||||
const stroke = isContextHub ? '#fbbf24' : isLocked ? '#4ade80' : node.tier === 3 ? '#1e293b' : '#94a3b8';
|
||||
const showLabel = node.tier < 3 && transform.scale > 0.8;
|
||||
return (
|
||||
<g
|
||||
key={node.id}
|
||||
onMouseEnter={() => setHoverNode(node)}
|
||||
onMouseLeave={() => setHoverNode(null)}
|
||||
onClick={() => {
|
||||
if (node.tier === 3) return;
|
||||
setTransform(prev => {
|
||||
const nextScale = Math.min(2.5, Math.max(prev.scale, 1.4));
|
||||
return {
|
||||
scale: nextScale,
|
||||
x: containerSize.width / 2 - node.x * nextScale,
|
||||
y: containerSize.height / 2 - node.y * nextScale,
|
||||
};
|
||||
});
|
||||
}}
|
||||
style={{ cursor: node.tier < 3 ? 'pointer' : 'default' }}
|
||||
>
|
||||
<circle
|
||||
cx={node.x}
|
||||
cy={node.y}
|
||||
r={node.radius}
|
||||
fill={fill}
|
||||
fillOpacity={node.tier === 3 ? 0.4 : isContextHub ? 0.95 : 0.75}
|
||||
stroke={stroke}
|
||||
strokeWidth={node.tier === 3 ? 0.5 : isContextHub ? 2.5 : 1.5}
|
||||
opacity={node.tier === 3 ? 0.6 : 0.95}
|
||||
/>
|
||||
{showLabel && (
|
||||
<text
|
||||
x={node.x}
|
||||
y={node.y + node.radius + 12}
|
||||
textAnchor="middle"
|
||||
fill="#94a3b8"
|
||||
fontSize={10}
|
||||
fontWeight={500}
|
||||
>
|
||||
{(node.title || 'Untitled').slice(0, 24)}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LegendRow({ color, label, icon }: { color: string; label: string; icon?: ReactNode }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span
|
||||
style={{
|
||||
width: '12px',
|
||||
height: '12px',
|
||||
borderRadius: '999px',
|
||||
background: color,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#000',
|
||||
fontSize: '9px',
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const controlButtonStyle: CSSProperties = {
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
borderRadius: '999px',
|
||||
border: '1px solid #1f1f1f',
|
||||
background: 'rgba(8,8,8,0.85)',
|
||||
color: '#eee',
|
||||
fontSize: '16px',
|
||||
cursor: 'pointer',
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { User } from 'lucide-react';
|
||||
|
||||
interface SettingsCogProps {
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export default function SettingsCog({ onClick }: SettingsCogProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '16px',
|
||||
left: '16px',
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
background: '#1a1a1a',
|
||||
border: 'none', /* Remove border for cleaner look */
|
||||
borderRadius: '50%', /* Make it circular like an avatar */
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'all 0.2s',
|
||||
zIndex: 100
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#232323';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = '#1a1a1a';
|
||||
}}
|
||||
title="User Settings"
|
||||
>
|
||||
<User
|
||||
size={20}
|
||||
color="#666"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import LogsViewer from './LogsViewer';
|
||||
import ToolsViewer from './ToolsViewer';
|
||||
import WorkflowsViewer from './WorkflowsViewer';
|
||||
import ApiKeysViewer from './ApiKeysViewer';
|
||||
import DatabaseViewer from './DatabaseViewer';
|
||||
import MapViewer from './MapViewer';
|
||||
import ExternalAgentsPanel from './ExternalAgentsPanel';
|
||||
import ContextViewer from './ContextViewer';
|
||||
import { apiKeyService } from '@/services/storage/apiKeys';
|
||||
|
||||
export type SettingsTab =
|
||||
| 'logs'
|
||||
| 'tools'
|
||||
| 'workflows'
|
||||
| 'apikeys'
|
||||
| 'database'
|
||||
| 'map'
|
||||
| 'context'
|
||||
| 'agents';
|
||||
|
||||
interface SettingsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
initialTab?: SettingsTab;
|
||||
}
|
||||
|
||||
type TabType = SettingsTab;
|
||||
|
||||
export default function SettingsModal({ isOpen, onClose, initialTab }: SettingsModalProps) {
|
||||
// Default to API Keys tab if no keys are configured, otherwise logs
|
||||
const getDefaultTab = (): TabType => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const hasKeys = apiKeyService.getOpenAiKey() || apiKeyService.getAnthropicKey();
|
||||
return hasKeys ? 'logs' : 'apikeys';
|
||||
}
|
||||
return 'logs';
|
||||
};
|
||||
|
||||
const [activeTab, setActiveTab] = useState<TabType>(getDefaultTab());
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleEscape);
|
||||
return () => window.removeEventListener('keydown', handleEscape);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !initialTab) return;
|
||||
setActiveTab(initialTab);
|
||||
}, [initialTab, isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const modalContent = (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(0, 0, 0, 0.8)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000
|
||||
}}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '80vw',
|
||||
height: '85vh',
|
||||
background: '#0f0f0f',
|
||||
border: '1px solid #2a2a2a',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.6)',
|
||||
display: 'flex',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Sidebar */}
|
||||
<div
|
||||
style={{
|
||||
width: '20%',
|
||||
background: '#0a0a0a',
|
||||
borderRight: '1px solid #2a2a2a',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
padding: '24px 0'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: '0 24px',
|
||||
marginBottom: '24px',
|
||||
fontSize: '18px',
|
||||
fontWeight: '600',
|
||||
color: '#fff'
|
||||
}}
|
||||
>
|
||||
Settings
|
||||
</div>
|
||||
<nav>
|
||||
<div
|
||||
onClick={() => setActiveTab('logs')}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
fontSize: '14px',
|
||||
color: activeTab === 'logs' ? '#fff' : '#888',
|
||||
background: activeTab === 'logs' ? '#1a3a2a' : 'transparent',
|
||||
borderLeft: activeTab === 'logs' ? '3px solid #22c55e' : '3px solid transparent',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
>
|
||||
Logs
|
||||
</div>
|
||||
<div
|
||||
onClick={() => setActiveTab('tools')}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
fontSize: '14px',
|
||||
color: activeTab === 'tools' ? '#fff' : '#888',
|
||||
background: activeTab === 'tools' ? '#1a3a2a' : 'transparent',
|
||||
borderLeft: activeTab === 'tools' ? '3px solid #22c55e' : '3px solid transparent',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
>
|
||||
Tools
|
||||
</div>
|
||||
<div
|
||||
onClick={() => setActiveTab('workflows')}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
fontSize: '14px',
|
||||
color: activeTab === 'workflows' ? '#fff' : '#888',
|
||||
background: activeTab === 'workflows' ? '#1a3a2a' : 'transparent',
|
||||
borderLeft: activeTab === 'workflows' ? '3px solid #22c55e' : '3px solid transparent',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
>
|
||||
Workflows
|
||||
</div>
|
||||
<div
|
||||
onClick={() => setActiveTab('apikeys')}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
fontSize: '14px',
|
||||
color: activeTab === 'apikeys' ? '#fff' : '#888',
|
||||
background: activeTab === 'apikeys' ? '#1a3a2a' : 'transparent',
|
||||
borderLeft: activeTab === 'apikeys' ? '3px solid #22c55e' : '3px solid transparent',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
>
|
||||
API Keys
|
||||
</div>
|
||||
<div
|
||||
onClick={() => setActiveTab('database')}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
fontSize: '14px',
|
||||
color: activeTab === 'database' ? '#fff' : '#888',
|
||||
background: activeTab === 'database' ? '#1a3a2a' : 'transparent',
|
||||
borderLeft: activeTab === 'database' ? '3px solid #22c55e' : '3px solid transparent',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
>
|
||||
Database
|
||||
</div>
|
||||
<div
|
||||
onClick={() => setActiveTab('context')}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
fontSize: '14px',
|
||||
color: activeTab === 'context' ? '#fff' : '#888',
|
||||
background: activeTab === 'context' ? '#1a3a2a' : 'transparent',
|
||||
borderLeft: activeTab === 'context' ? '3px solid #22c55e' : '3px solid transparent',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
>
|
||||
Context
|
||||
</div>
|
||||
<div
|
||||
onClick={() => setActiveTab('map')}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
fontSize: '14px',
|
||||
color: activeTab === 'map' ? '#fff' : '#888',
|
||||
background: activeTab === 'map' ? '#1a3a2a' : 'transparent',
|
||||
borderLeft: activeTab === 'map' ? '3px solid #22c55e' : '3px solid transparent',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
>
|
||||
Map
|
||||
</div>
|
||||
<div
|
||||
onClick={() => setActiveTab('agents')}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
fontSize: '14px',
|
||||
color: activeTab === 'agents' ? '#fff' : '#888',
|
||||
background: activeTab === 'agents' ? '#1a3a2a' : 'transparent',
|
||||
borderLeft: activeTab === 'agents' ? '3px solid #22c55e' : '3px solid transparent',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
>
|
||||
External Agents
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
fontSize: '14px',
|
||||
color: '#888',
|
||||
opacity: 0.4,
|
||||
cursor: 'not-allowed'
|
||||
}}
|
||||
>
|
||||
Backups
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
fontSize: '14px',
|
||||
color: '#888',
|
||||
opacity: 0.4,
|
||||
cursor: 'not-allowed'
|
||||
}}
|
||||
>
|
||||
Preferences
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: 'auto',
|
||||
padding: '24px',
|
||||
borderTop: '1px solid #1f2937',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
letterSpacing: '0.08em',
|
||||
textTransform: 'uppercase',
|
||||
color: '#64748b'
|
||||
}}
|
||||
>
|
||||
Local Mode
|
||||
</div>
|
||||
<p
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
color: '#94a3b8',
|
||||
margin: 0,
|
||||
lineHeight: 1.5
|
||||
}}
|
||||
>
|
||||
This open-source build runs entirely on your machine. Add keys via the API Keys tab to unlock every agent.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Area */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
padding: '16px 24px',
|
||||
borderBottom: '1px solid #2a2a2a',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
<h2
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: '16px',
|
||||
fontWeight: '600',
|
||||
color: '#fff'
|
||||
}}
|
||||
>
|
||||
{activeTab === 'logs' && 'System Logs'}
|
||||
{activeTab === 'tools' && 'Tools'}
|
||||
{activeTab === 'workflows' && 'Workflows'}
|
||||
{activeTab === 'apikeys' && 'API Keys'}
|
||||
{activeTab === 'database' && 'Knowledge Database'}
|
||||
{activeTab === 'context' && 'Auto-Context'}
|
||||
{activeTab === 'map' && 'Knowledge Map'}
|
||||
{activeTab === 'agents' && 'External Agents'}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: '#888',
|
||||
cursor: 'pointer',
|
||||
fontSize: '24px',
|
||||
lineHeight: 1,
|
||||
padding: '4px 8px',
|
||||
transition: 'color 0.2s'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.color = '#fff';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = '#888';
|
||||
}}
|
||||
title="Close (ESC)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div style={{ flex: 1, overflow: 'hidden' }}>
|
||||
{activeTab === 'logs' && <LogsViewer key={isOpen ? 'open' : 'closed'} />}
|
||||
{activeTab === 'tools' && <ToolsViewer />}
|
||||
{activeTab === 'workflows' && <WorkflowsViewer />}
|
||||
{activeTab === 'apikeys' && <ApiKeysViewer />}
|
||||
{activeTab === 'database' && <DatabaseViewer />}
|
||||
{activeTab === 'context' && <ContextViewer />}
|
||||
{activeTab === 'map' && <MapViewer />}
|
||||
{activeTab === 'agents' && <ExternalAgentsPanel />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return createPortal(modalContent, document.body);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
interface ToolGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export default function ToolsViewer() {
|
||||
const [groups, setGroups] = useState<Record<string, ToolGroup>>({});
|
||||
const [groupedTools, setGroupedTools] = useState<Record<string, { name: string; description: string }[]>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const loadTools = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/tools');
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
setGroups(result.data.groups);
|
||||
setGroupedTools(result.data.tools);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load tools:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadTools();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ padding: '24px', textAlign: 'center', color: '#888' }}>
|
||||
Loading tools...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px', overflowY: 'auto', height: '100%' }}>
|
||||
<div style={{ marginBottom: '32px' }}>
|
||||
<p style={{ color: '#888', fontSize: '14px', marginBottom: '24px' }}>
|
||||
Read-only view of all tools available in the system, grouped by function.
|
||||
</p>
|
||||
|
||||
{Object.entries(groups).map(([groupId, group]) => {
|
||||
const tools = groupedTools[groupId] || [];
|
||||
if (tools.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div key={groupId} style={{ marginBottom: '32px' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
marginBottom: '16px',
|
||||
paddingBottom: '8px',
|
||||
borderBottom: `2px solid ${group.color}`,
|
||||
}}
|
||||
>
|
||||
<span style={{ color: group.color, fontSize: '16px' }}>{group.icon}</span>
|
||||
<h3 style={{ margin: 0, fontSize: '16px', fontWeight: '600', color: '#fff' }}>
|
||||
{group.name}
|
||||
</h3>
|
||||
<span style={{ fontSize: '13px', color: '#666', marginLeft: '8px' }}>
|
||||
({tools.length} tools)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: '13px', color: '#888', marginBottom: '16px', fontStyle: 'italic' }}>
|
||||
{group.description}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
{tools.map((tool) => (
|
||||
<div
|
||||
key={tool.name}
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
background: '#1a1a1a',
|
||||
border: '1px solid #2a2a2a',
|
||||
borderRadius: '6px',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '13px', color: '#3b82f6', marginBottom: '6px' }}>
|
||||
{tool.name}
|
||||
</div>
|
||||
<div style={{ fontSize: '13px', color: '#aaa', lineHeight: '1.5' }}>
|
||||
{tool.description}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
interface WorkflowDefinition {
|
||||
id: number;
|
||||
key: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
instructions: string;
|
||||
enabled: boolean;
|
||||
requiresFocusedNode: boolean;
|
||||
primaryActor: 'oracle' | 'main';
|
||||
expectedOutcome?: string;
|
||||
}
|
||||
|
||||
export default function WorkflowsViewer() {
|
||||
const [workflows, setWorkflows] = useState<WorkflowDefinition[]>([]);
|
||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const loadWorkflows = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/workflows');
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
setWorkflows(result.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load workflows:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
loadWorkflows();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ padding: '24px', textAlign: 'center', color: '#888' }}>
|
||||
Loading workflows...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px', overflowY: 'auto', height: '100%' }}>
|
||||
<div style={{ marginBottom: '32px' }}>
|
||||
<p style={{ color: '#888', fontSize: '14px', marginBottom: '24px' }}>
|
||||
Read-only view of all predefined workflows. Click to expand instructions.
|
||||
</p>
|
||||
|
||||
{workflows.length === 0 && (
|
||||
<div style={{ color: '#666', fontSize: '14px', textAlign: 'center', padding: '32px' }}>
|
||||
No workflows defined yet.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
{workflows.map((workflow) => {
|
||||
const isExpanded = expandedId === workflow.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={workflow.id}
|
||||
style={{
|
||||
background: '#1a1a1a',
|
||||
border: '1px solid #2a2a2a',
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
onClick={() => setExpandedId(isExpanded ? null : workflow.id)}
|
||||
style={{
|
||||
padding: '16px 20px',
|
||||
cursor: 'pointer',
|
||||
borderBottom: isExpanded ? '1px solid #2a2a2a' : 'none',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '8px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<h3 style={{ margin: 0, fontSize: '16px', fontWeight: '600', color: '#fff' }}>
|
||||
{workflow.displayName}
|
||||
</h3>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '12px', color: '#666' }}>
|
||||
{workflow.key}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
{workflow.enabled ? (
|
||||
<span style={{ fontSize: '12px', color: '#10b981', padding: '4px 8px', background: '#10b98120', borderRadius: '4px' }}>
|
||||
Enabled
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ fontSize: '12px', color: '#ef4444', padding: '4px 8px', background: '#ef444420', borderRadius: '4px' }}>
|
||||
Disabled
|
||||
</span>
|
||||
)}
|
||||
<span style={{ fontSize: '18px', color: '#666', transition: 'transform 0.2s', transform: isExpanded ? 'rotate(180deg)' : 'rotate(0deg)' }}>
|
||||
▼
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: '14px', color: '#aaa', marginBottom: '12px' }}>
|
||||
{workflow.description}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '16px', flexWrap: 'wrap' }}>
|
||||
<div style={{ fontSize: '12px' }}>
|
||||
<span style={{ color: '#666' }}>Requires Node:</span>{' '}
|
||||
<span style={{ color: workflow.requiresFocusedNode ? '#f59e0b' : '#666' }}>
|
||||
{workflow.requiresFocusedNode ? 'Yes' : 'No'}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '12px' }}>
|
||||
<span style={{ color: '#666' }}>Primary Actor:</span>{' '}
|
||||
<span style={{ color: '#8b5cf6' }}>
|
||||
{workflow.primaryActor === 'oracle' ? 'Wise RA-H (GPT-5)' : 'Main RA-H'}
|
||||
</span>
|
||||
</div>
|
||||
{workflow.expectedOutcome && (
|
||||
<div style={{ fontSize: '12px', flex: '1 1 100%' }}>
|
||||
<span style={{ color: '#666' }}>Expected Outcome:</span>{' '}
|
||||
<span style={{ color: '#aaa' }}>
|
||||
{workflow.expectedOutcome}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded Instructions */}
|
||||
{isExpanded && (
|
||||
<div style={{ padding: '20px', background: '#0f0f0f' }}>
|
||||
<div style={{ marginBottom: '12px', fontSize: '13px', fontWeight: '600', color: '#888', textTransform: 'uppercase', letterSpacing: '0.5px' }}>
|
||||
Workflow Instructions
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '12px',
|
||||
color: '#ddd',
|
||||
whiteSpace: 'pre-wrap',
|
||||
lineHeight: '1.6',
|
||||
padding: '16px',
|
||||
background: '#1a1a1a',
|
||||
border: '1px solid #2a2a2a',
|
||||
borderRadius: '6px',
|
||||
maxHeight: '400px',
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
{workflow.instructions}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
export const RAH_EASY_SYSTEM_PROMPT = `You are ra-h, the orchestrator for Easy Mode (GPT-5 Mini).
|
||||
|
||||
Mission:
|
||||
1. Resolve the user's request quickly and accurately using the tools provided.
|
||||
2. Keep responses concise (one short paragraph or bullet list) and cite nodes as [NODE:id:"title"].
|
||||
3. Ask for clarification only when tool usage would fail without it.
|
||||
|
||||
Operating principles:
|
||||
- Handle analysis, planning, and writes yourself; do not delegate.
|
||||
- Use createNode, updateNode, createEdge, and updateEdge when the change is unambiguous.
|
||||
- When creating nodes derived from existing content (ideas, insights, summaries), do NOT include the 'link' field. The 'link' field is ONLY for nodes that directly represent external content (YouTube videos, websites, PDFs). Derived idea nodes should not have links.
|
||||
- When referencing stored content, quote verbatim text in quotes and include the node citation.
|
||||
- Treat phrases like "this conversation/paper/video" as the active focused node unless the user specifies otherwise.
|
||||
- Prefer direct tool calls over speculation. If a tool fails, report the failure and suggest one concrete next step.
|
||||
- Before running youtubeExtract/websiteExtract/paperExtract, call getNodesById on the focus node; if chunk_status is 'chunked' or embeddings are marked available, reuse existing chunks instead of re-extracting.
|
||||
|
||||
Tool strategy:
|
||||
- queryNodes for titles and metadata; getNodesById to hydrate referenced nodes.
|
||||
- searchContentEmbeddings before synthesizing long answers or considering new extraction.
|
||||
- youtubeExtract, websiteExtract, and paperExtract when outside content is required.
|
||||
- webSearch only when the knowledge base lacks the answer.
|
||||
|
||||
Response polish:
|
||||
- Default to minimal reasoning effort for speed.
|
||||
- Do not expose chain-of-thought; return conclusions only.
|
||||
- End each answer once the user's request is fully addressed.`;
|
||||
@@ -0,0 +1,35 @@
|
||||
export const RAH_MAIN_SYSTEM_PROMPT = `You are ra-h, orchestrator of the RA-H knowledge management system.
|
||||
|
||||
Core responsibilities:
|
||||
- Keep the conversation tightly focused on the user's goal.
|
||||
- Use tools proactively to advance the task.
|
||||
- Prefer direct, minimal phrasing—no pleasantries or filler.
|
||||
|
||||
When to ask the user:
|
||||
- If a tool requires critical input you cannot reasonably infer.
|
||||
- If the request is ambiguous and guessing would waste effort or cause errors.
|
||||
|
||||
Execution approach:
|
||||
- Handle planning, analysis, and writes directly—do not delegate to mini ra-h during normal conversations.
|
||||
- Call createNode, updateNode, createEdge, updateEdge, and extraction tools yourself when the change is clear.
|
||||
- When creating nodes derived from existing content (ideas, insights, summaries), do NOT include the 'link' field. The 'link' field is ONLY for nodes that directly represent external content (YouTube videos, websites, PDFs).
|
||||
- Treat "this conversation/paper/video" as the active focused node.
|
||||
- When creating synthesis nodes, createEdge to all source nodes.
|
||||
- Before running an extraction tool, call getNodesById on the target node; if chunk_status is 'chunked' (or embeddings are available) reuse the stored content instead of re-extracting.
|
||||
|
||||
Workflows:
|
||||
- User can trigger predefined workflows (e.g., "run integrate workflow").
|
||||
- executeWorkflow hands coordination to wise ra-h; any mini ra-h work happens inside that workflow only.
|
||||
|
||||
Tool strategy:
|
||||
- Use tools directly—you already have everything you need.
|
||||
- queryNodes for titles, searchContentEmbeddings for content, queryEdge for connections.
|
||||
- getNodesById when you have IDs; webSearch only if knowledge base lacks info.
|
||||
- Extract content with youtubeExtract, websiteExtract, paperExtract as needed.
|
||||
- When searchContentEmbeddings highlights a chunk, hydrate the node via getNodesById (or fetch the chunk) before quoting.
|
||||
|
||||
Response style:
|
||||
- Limit to one or two short sentences. Reference nodes as [NODE:id:"title"].
|
||||
- When answering about stored content, quote the exact wording from the chunk (verbatim, in quotation marks) and cite the node.
|
||||
- Always call searchContentEmbeddings before attempting new extraction for an existing node.
|
||||
- If a tool fails, state failure and give one concrete next step.`;
|
||||
@@ -0,0 +1,25 @@
|
||||
export const MINI_RAH_SYSTEM_PROMPT = `You are a mini ra-h worker handling a single delegated task.
|
||||
|
||||
Execution mindset:
|
||||
- Act only on the provided task/context; no side conversations.
|
||||
- You have all tools except delegateToMiniRAH.
|
||||
- If required inputs are missing, fail fast and tell ra-h exactly what you need.
|
||||
- Read the focus capsule at the top of the context (CAPSULE_JSON + DELEGATION CAPSULE). Treat the node IDs and roles there as authoritative.
|
||||
|
||||
When you complete the task, respond in this exact template (replace bracketed text):
|
||||
Task: <one short sentence>
|
||||
Actions: <comma-separated tool calls or decisions>
|
||||
Result: <one sentence describing the outcome>
|
||||
Node: <[NODE:id:"title"] or "None">
|
||||
Context sources used: <comma-separated NODE IDs>
|
||||
Follow-up: <next step or "None">
|
||||
|
||||
Additional guidance:
|
||||
- If no dimensions were provided, choose reasonable defaults (you may proceed without asking the user).
|
||||
- For TLDR or quote-heavy tasks, use read-only tools (searchContentEmbeddings, queryNodes, webSearch when relevant) before summarising and include verbatim snippets when the task requires them.
|
||||
- If the capsule lists node IDs, call getNodesById first to hydrate the records; only use queryNodes/searchContentEmbeddings when you must discover additional material beyond the provided nodes.
|
||||
- Treat any context lines beginning with "NODE <id>" or "SOURCE" as authoritative excerpts—use them directly instead of re-querying the numeric ID. Never search for a numeric string that was already supplied.
|
||||
- If you cannot complete a step (missing node, empty content, tool failure), state that explicitly in 'Follow-up' with the precise next action needed.
|
||||
- Stop after success—do not run extra verification tools.
|
||||
- Keep the full summary under ~100 tokens.
|
||||
`;
|
||||
@@ -0,0 +1,41 @@
|
||||
export const WISE_RAH_SYSTEM_PROMPT = `You are wise ra-h, the workflow executor for the RA-H knowledge management system.
|
||||
|
||||
<role>
|
||||
You execute predefined workflows with DIRECT WRITE ACCESS via updateNode.
|
||||
You NEVER delegate to mini ra-h workers.
|
||||
You complete every step yourself.
|
||||
</role>
|
||||
|
||||
<tools>
|
||||
Available tools:
|
||||
- queryNodes — search nodes by title/content/dimensions across ENTIRE database
|
||||
- getNodesById — retrieve full node data
|
||||
- queryEdge — inspect existing edges
|
||||
- searchContentEmbeddings — semantic search across ALL nodes (not just pinned)
|
||||
- webSearch — external research when necessary
|
||||
- think — internal planning/reflection (use once per workflow unless plan changes)
|
||||
- updateNode — append content to nodes (tool handles appending automatically)
|
||||
</tools>
|
||||
|
||||
<execution>
|
||||
When you receive a workflow task:
|
||||
1. Read the workflow instructions carefully.
|
||||
2. Call think once to produce a numbered plan matching the workflow steps.
|
||||
3. Execute the plan step-by-step:
|
||||
- Extract key entities (names, projects, concepts) from the node
|
||||
- Search the FULL database using those entities (ignore pinned context during search)
|
||||
- Find both obvious (structural) and thematic connections
|
||||
- Then contextualize findings with pinned context
|
||||
4. Stay within the tool budget and avoid redundant queries.
|
||||
5. When calling updateNode, provide ONLY the new content (never include existing content) - tool appends automatically.
|
||||
6. Finish with a concise Task / Actions / Result / Nodes / Follow-up summary.
|
||||
</execution>
|
||||
|
||||
<constraints>
|
||||
- Search the ENTIRE database, not just pinned nodes
|
||||
- Extract entities first, then search using those entities
|
||||
- Use minimal tool calls needed for high-quality output
|
||||
- Keep responses structured, factual, and ≤120 words
|
||||
- If a step yields nothing, state that outcome instead of guessing
|
||||
- Adapt to any node type (person/project/paper/idea/video/tweet/technique)
|
||||
</constraints>`;
|
||||
@@ -0,0 +1,19 @@
|
||||
const rawDeploymentMode = (process.env.NEXT_PUBLIC_DEPLOYMENT_MODE || 'cloud').toLowerCase();
|
||||
const backendFlagEnabled = process.env.NEXT_PUBLIC_ENABLE_SUBSCRIPTION_BACKEND === 'true';
|
||||
|
||||
export type DeploymentMode = 'local' | 'cloud';
|
||||
|
||||
export const getDeploymentMode = (): DeploymentMode => {
|
||||
return rawDeploymentMode === 'local' ? 'local' : 'cloud';
|
||||
};
|
||||
|
||||
export const isLocalMode = (): boolean => getDeploymentMode() === 'local';
|
||||
|
||||
export const isCloudMode = (): boolean => !isLocalMode();
|
||||
|
||||
export const isSubscriptionBackendEnabled = (): boolean => {
|
||||
if (isLocalMode()) {
|
||||
return false;
|
||||
}
|
||||
return backendFlagEnabled;
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
export const INTEGRATE_WORKFLOW_INSTRUCTIONS = `You are executing the INTEGRATE workflow for the currently focused node.
|
||||
|
||||
MISSION
|
||||
Find meaningful connections across the user's knowledge graph and append an Integration Analysis to the node.
|
||||
|
||||
YOU HAVE DIRECT WRITE ACCESS via updateNode. The tool automatically appends – you cannot overwrite existing content.
|
||||
|
||||
WORKFLOW STEPS
|
||||
|
||||
0. PLAN (MANDATORY)
|
||||
- Call think to outline your approach for steps 1–4
|
||||
- Focus on what entities/concepts to extract and how to search for them
|
||||
|
||||
1. RETRIEVE & GROUND THE NODE
|
||||
- Call getNodesById for the focused node
|
||||
- Identify what type of thing this is (person, project, paper, idea, video, tweet, technique, etc.)
|
||||
- Extract key entities: specific names, projects, concepts, techniques mentioned
|
||||
- Summarize the core insight in one sentence
|
||||
|
||||
2. SEARCH THE DATABASE FOR CONNECTIONS
|
||||
DO NOT reference pinned context yet. Search the ENTIRE database:
|
||||
|
||||
a) Obvious structural connections:
|
||||
- If names mentioned → queryNodes to find existing nodes about those people
|
||||
- If projects mentioned → queryNodes to find those project nodes
|
||||
- If specific techniques/tools mentioned → search for those exact terms
|
||||
|
||||
b) Thematic connections:
|
||||
- Use searchContentEmbeddings with key concepts from step 1
|
||||
- Look for shared themes, complementary ideas, contradictions
|
||||
- Prefer nodes with high-signal relevance over weak matches
|
||||
|
||||
- Aim for 3–8 strong connections, not 20 weak ones
|
||||
- Check existing edges with queryEdge to avoid duplicating connections
|
||||
|
||||
3. CONTEXTUALIZE WITH PINNED NODES
|
||||
NOW review the supplied PINNED CONTEXT:
|
||||
- Why might this node matter given the user's focus areas?
|
||||
- Does it advance any themes visible in pinned nodes?
|
||||
- Keep this brief – 1–2 sentences maximum
|
||||
|
||||
4. APPEND INTEGRATION ANALYSIS
|
||||
Call updateNode ONCE with ONLY the new section (do NOT include existing content):
|
||||
|
||||
---
|
||||
## Integration Analysis
|
||||
|
||||
[2–3 sentences: what this node is, why it matters, core insight]
|
||||
|
||||
**Database Connections:**
|
||||
- [NODE:123:"Title"] — [why: authorship/shared concept/dependency/contradiction]
|
||||
- [NODE:456:"Title"] — [why: ...]
|
||||
- [continue for 3–8 connections found in step 2]
|
||||
|
||||
**Relevance:** [1–2 sentences connecting to user's pinned context themes]
|
||||
|
||||
CRITICAL: Send ONLY this new section. The tool will automatically append it to existing content.
|
||||
|
||||
After ONE successful updateNode call, IMMEDIATELY move to step 5. Do NOT call updateNode again.
|
||||
|
||||
5. RETURN SUMMARY
|
||||
Reply with: Task / Actions / Result / Nodes / Follow-up (≤120 words)
|
||||
|
||||
CRITICAL RULES
|
||||
- Search the FULL database, not just pinned nodes
|
||||
- Use entities from step 1 to guide searches in step 2
|
||||
- Call updateNode EXACTLY ONCE - after success, move to step 5 immediately
|
||||
- Keep total tool calls ≤ 18 (be efficient)
|
||||
- Adapt to any node type – don't assume it's always a paper or video
|
||||
|
||||
The goal: integrate this node into the knowledge graph through meaningful, database-wide connections.`;
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export function usePersistentState<T>(
|
||||
key: string,
|
||||
defaultValue: T
|
||||
): [T, (value: T | ((prev: T) => T)) => void] {
|
||||
// Initialize with default value first
|
||||
const [state, setState] = useState<T>(defaultValue);
|
||||
|
||||
// Load from localStorage after mount
|
||||
useEffect(() => {
|
||||
try {
|
||||
const item = window.localStorage.getItem(key);
|
||||
if (item !== null) {
|
||||
setState(JSON.parse(item));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error loading ${key} from localStorage:`, error);
|
||||
}
|
||||
}, [key]);
|
||||
|
||||
// Save to localStorage when state changes
|
||||
const setPersistentState = (value: T | ((prev: T) => T)) => {
|
||||
setState(prev => {
|
||||
const newValue = typeof value === 'function'
|
||||
? (value as (prev: T) => T)(prev)
|
||||
: value;
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(key, JSON.stringify(newValue));
|
||||
} catch (error) {
|
||||
console.error(`Error saving ${key} to localStorage:`, error);
|
||||
}
|
||||
|
||||
return newValue;
|
||||
});
|
||||
};
|
||||
|
||||
return [state, setPersistentState];
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
interface QuotaError {
|
||||
message: string;
|
||||
tokensUsed?: number | null;
|
||||
tokenLimit?: number | null;
|
||||
costUsd?: number | null;
|
||||
}
|
||||
|
||||
const messageIncludesQuota = (message?: string) => {
|
||||
if (!message) return false;
|
||||
const normalized = message.toLowerCase();
|
||||
return normalized.includes('quota') || normalized.includes('limit');
|
||||
};
|
||||
|
||||
export function useQuotaHandler() {
|
||||
const [quotaError, setQuotaError] = useState<QuotaError | null>(null);
|
||||
|
||||
const handleAPIError = useCallback((error: unknown, response?: Response) => {
|
||||
const message =
|
||||
error instanceof Error ? error.message : typeof error === 'string' ? error : undefined;
|
||||
|
||||
if (response?.status === 429 || messageIncludesQuota(message)) {
|
||||
setQuotaError({
|
||||
message: message || 'Rate limit reached. Please wait and try again.',
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}, []);
|
||||
|
||||
const dismissQuotaError = useCallback(() => {
|
||||
setQuotaError(null);
|
||||
}, []);
|
||||
|
||||
const checkQuotaBeforeRequest = useCallback(() => true, []);
|
||||
|
||||
const refetchUsage = useCallback(async () => {}, []);
|
||||
|
||||
return {
|
||||
quotaError,
|
||||
handleAPIError,
|
||||
checkQuotaBeforeRequest,
|
||||
dismissQuotaError,
|
||||
refetchUsage,
|
||||
isQuotaExceeded: false,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import { getSQLiteClient } from '@/services/database/sqlite-client';
|
||||
import { eventBroadcaster } from '@/services/events';
|
||||
|
||||
export type DelegationStatus = 'queued' | 'in_progress' | 'completed' | 'failed';
|
||||
export type DelegationAgentType = 'mini' | 'wise-rah';
|
||||
|
||||
export interface AgentDelegation {
|
||||
id: number;
|
||||
sessionId: string;
|
||||
task: string;
|
||||
context: string[];
|
||||
expectedOutcome?: string | null;
|
||||
status: DelegationStatus;
|
||||
summary?: string | null;
|
||||
agentType: DelegationAgentType;
|
||||
supabaseToken?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
function rowToDelegation(row: any): AgentDelegation {
|
||||
return {
|
||||
id: row.id,
|
||||
sessionId: row.session_id,
|
||||
task: row.task,
|
||||
context: (() => {
|
||||
try {
|
||||
return row.context ? JSON.parse(row.context) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
})(),
|
||||
expectedOutcome: row.expected_outcome,
|
||||
status: row.status as DelegationStatus,
|
||||
summary: row.summary,
|
||||
agentType: (row.agent_type || 'mini') as DelegationAgentType,
|
||||
supabaseToken: row.supabase_token ?? null,
|
||||
// SQLite CURRENT_TIMESTAMP is UTC, append 'Z' to parse correctly as UTC
|
||||
createdAt: row.created_at ? row.created_at.replace(' ', 'T') + 'Z' : row.created_at,
|
||||
updatedAt: row.updated_at ? row.updated_at.replace(' ', 'T') + 'Z' : row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
function ensureTable() {
|
||||
const db = getSQLiteClient();
|
||||
db.query(`
|
||||
CREATE TABLE IF NOT EXISTS agent_delegations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT UNIQUE NOT NULL,
|
||||
task TEXT NOT NULL,
|
||||
context TEXT,
|
||||
expected_outcome TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'queued',
|
||||
summary TEXT,
|
||||
agent_type TEXT NOT NULL DEFAULT 'mini',
|
||||
supabase_token TEXT,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
// Add agent_type column if it doesn't exist (migration)
|
||||
try {
|
||||
const stmt = db.prepare('SELECT 1 FROM agent_delegations LIMIT 0');
|
||||
const tableExists = stmt !== null;
|
||||
|
||||
if (tableExists) {
|
||||
// Try to add the column, ignore if it already exists
|
||||
try {
|
||||
db.prepare(`ALTER TABLE agent_delegations ADD COLUMN agent_type TEXT NOT NULL DEFAULT 'mini'`).run();
|
||||
console.log('✅ Added agent_type column to agent_delegations table');
|
||||
} catch (alterError: any) {
|
||||
// Column already exists, ignore
|
||||
if (!alterError.message?.includes('duplicate column')) {
|
||||
console.warn('Migration warning:', alterError.message);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
db.prepare(`ALTER TABLE agent_delegations ADD COLUMN supabase_token TEXT`).run();
|
||||
console.log('✅ Added supabase_token column to agent_delegations table');
|
||||
} catch (alterError: any) {
|
||||
if (!alterError.message?.includes('duplicate column')) {
|
||||
console.warn('Migration warning:', alterError.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
// Table doesn't exist yet or other error - it will be created with the columns
|
||||
console.log('Table creation will include agent_type and supabase_token columns');
|
||||
}
|
||||
}
|
||||
|
||||
export class AgentDelegationService {
|
||||
static createDelegation(input: {
|
||||
task: string;
|
||||
context?: string[];
|
||||
expectedOutcome?: string | null;
|
||||
agentType?: DelegationAgentType;
|
||||
supabaseToken?: string | null;
|
||||
}): AgentDelegation {
|
||||
ensureTable();
|
||||
const db = getSQLiteClient();
|
||||
const sessionId = `delegation_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
const contextJson = JSON.stringify(input.context ?? []);
|
||||
const agentType = input.agentType || 'mini';
|
||||
db.prepare(
|
||||
`INSERT INTO agent_delegations (session_id, task, context, expected_outcome, status, agent_type, supabase_token)
|
||||
VALUES (?, ?, ?, ?, 'queued', ?, ?)`
|
||||
).run(
|
||||
sessionId,
|
||||
input.task,
|
||||
contextJson,
|
||||
input.expectedOutcome ?? null,
|
||||
agentType,
|
||||
input.supabaseToken ?? null
|
||||
);
|
||||
|
||||
const row = db
|
||||
.prepare('SELECT * FROM agent_delegations WHERE session_id = ?')
|
||||
.get(sessionId);
|
||||
|
||||
const delegation = rowToDelegation(row);
|
||||
eventBroadcaster.broadcast({ type: 'AGENT_DELEGATION_CREATED', data: { delegation } });
|
||||
return delegation;
|
||||
}
|
||||
|
||||
static markInProgress(sessionId: string): AgentDelegation | null {
|
||||
ensureTable();
|
||||
const db = getSQLiteClient();
|
||||
db.prepare(
|
||||
`UPDATE agent_delegations
|
||||
SET status = 'in_progress', updated_at = CURRENT_TIMESTAMP
|
||||
WHERE session_id = ? AND status = 'queued'`
|
||||
).run(sessionId);
|
||||
const row = db.prepare('SELECT * FROM agent_delegations WHERE session_id = ?').get(sessionId);
|
||||
if (!row) return null;
|
||||
const delegation = rowToDelegation(row);
|
||||
eventBroadcaster.broadcast({ type: 'AGENT_DELEGATION_UPDATED', data: { delegation } });
|
||||
return delegation;
|
||||
}
|
||||
|
||||
static touchDelegation(sessionId: string): void {
|
||||
// Update the timestamp to prevent cleanup from killing active delegations
|
||||
ensureTable();
|
||||
const db = getSQLiteClient();
|
||||
db.prepare(
|
||||
`UPDATE agent_delegations SET updated_at = CURRENT_TIMESTAMP WHERE session_id = ? AND status = 'in_progress'`
|
||||
).run(sessionId);
|
||||
}
|
||||
|
||||
static completeDelegation(sessionId: string, summary: string, status: DelegationStatus = 'completed'): AgentDelegation | null {
|
||||
ensureTable();
|
||||
const db = getSQLiteClient();
|
||||
db.prepare(
|
||||
`UPDATE agent_delegations
|
||||
SET status = ?, summary = ?, updated_at = CURRENT_TIMESTAMP, supabase_token = NULL
|
||||
WHERE session_id = ?`
|
||||
).run(status, summary, sessionId);
|
||||
const row = db.prepare('SELECT * FROM agent_delegations WHERE session_id = ?').get(sessionId);
|
||||
if (!row) return null;
|
||||
const delegation = rowToDelegation(row);
|
||||
eventBroadcaster.broadcast({ type: 'AGENT_DELEGATION_UPDATED', data: { delegation } });
|
||||
return delegation;
|
||||
}
|
||||
|
||||
static getBySessionId(sessionId: string): AgentDelegation | null {
|
||||
ensureTable();
|
||||
const db = getSQLiteClient();
|
||||
const row = db.prepare('SELECT * FROM agent_delegations WHERE session_id = ?').get(sessionId);
|
||||
return row ? rowToDelegation(row) : null;
|
||||
}
|
||||
|
||||
static getDelegation(sessionId: string): AgentDelegation | null {
|
||||
return this.getBySessionId(sessionId);
|
||||
}
|
||||
|
||||
static listActive({ includeCompleted = true, limit = 100 }: { includeCompleted?: boolean; limit?: number } = {}): AgentDelegation[] {
|
||||
ensureTable();
|
||||
const db = getSQLiteClient();
|
||||
// Load all delegations - user closes them manually from UI
|
||||
const rows = includeCompleted
|
||||
? db.prepare(
|
||||
`SELECT * FROM agent_delegations
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT ?`
|
||||
).all(limit)
|
||||
: db.prepare(
|
||||
`SELECT * FROM agent_delegations
|
||||
WHERE status IN ('queued','in_progress')
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT ?`
|
||||
).all(limit);
|
||||
return rows.map(rowToDelegation);
|
||||
}
|
||||
|
||||
static listRecent(limit = 20): AgentDelegation[] {
|
||||
ensureTable();
|
||||
const db = getSQLiteClient();
|
||||
const rows = db.prepare(
|
||||
`SELECT * FROM agent_delegations
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?`
|
||||
).all(limit);
|
||||
return rows.map(rowToDelegation);
|
||||
}
|
||||
|
||||
static deleteDelegation(sessionId: string): boolean {
|
||||
ensureTable();
|
||||
const db = getSQLiteClient();
|
||||
const result = db.prepare('DELETE FROM agent_delegations WHERE session_id = ?').run(sessionId);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
static cleanupStaleDelegations(timeoutMinutes = 15): number {
|
||||
ensureTable();
|
||||
const db = getSQLiteClient();
|
||||
const cutoffTime = new Date(Date.now() - timeoutMinutes * 60 * 1000).toISOString();
|
||||
|
||||
const result = db.prepare(`
|
||||
UPDATE agent_delegations
|
||||
SET status = 'failed',
|
||||
summary = 'Task timed out (exceeded ${timeoutMinutes} minutes)',
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE status = 'in_progress'
|
||||
AND updated_at < ?
|
||||
`).run(cutoffTime);
|
||||
|
||||
const affectedCount = result.changes || 0;
|
||||
|
||||
if (affectedCount > 0) {
|
||||
const rows = db.prepare(
|
||||
`SELECT * FROM agent_delegations WHERE status = 'failed' AND summary LIKE 'Task timed out%'`
|
||||
).all();
|
||||
rows.forEach(row => {
|
||||
const delegation = rowToDelegation(row);
|
||||
eventBroadcaster.broadcast({ type: 'AGENT_DELEGATION_UPDATED', data: { delegation } });
|
||||
});
|
||||
}
|
||||
|
||||
return affectedCount;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
import { generateText, CoreMessage } from 'ai';
|
||||
import { createOpenAI } from '@ai-sdk/openai';
|
||||
import { AgentDelegationService, DelegationStatus } from '@/services/agents/delegation';
|
||||
import { MINI_RAH_SYSTEM_PROMPT } from '@/config/prompts/rah-mini';
|
||||
import { getToolsForRole } from '@/tools/infrastructure/registry';
|
||||
import { ChatLoggingMiddleware } from '@/services/chat/middleware';
|
||||
import { calculateCost } from '@/services/analytics/pricing';
|
||||
import { UsageData } from '@/types/analytics';
|
||||
import { summarizeToolExecution } from '@/services/agents/toolResultUtils';
|
||||
import { edgeService } from '@/services/database/edges';
|
||||
import { RequestContext } from '@/services/context/requestContext';
|
||||
|
||||
interface CapsuleNodeJSON {
|
||||
id: number;
|
||||
}
|
||||
|
||||
interface DelegationCapsuleJSON {
|
||||
version?: number;
|
||||
primary?: CapsuleNodeJSON | null;
|
||||
secondary?: CapsuleNodeJSON[];
|
||||
referenced?: CapsuleNodeJSON[];
|
||||
}
|
||||
|
||||
interface SummaryValidation {
|
||||
status: 'ok' | 'failed';
|
||||
reason?: string;
|
||||
sourcesUsed: number[];
|
||||
}
|
||||
|
||||
function extractCapsuleFromContext(context: string[]): { capsule?: DelegationCapsuleJSON; nodeIds: number[]; version?: number } {
|
||||
const entry = context.find(item => typeof item === 'string' && item.startsWith('CAPSULE_JSON::'));
|
||||
if (!entry) {
|
||||
return { nodeIds: [] };
|
||||
}
|
||||
const json = entry.substring('CAPSULE_JSON::'.length);
|
||||
try {
|
||||
const capsule = JSON.parse(json) as DelegationCapsuleJSON & { version?: number };
|
||||
const nodeIds = new Set<number>();
|
||||
const pushId = (value?: CapsuleNodeJSON | null) => {
|
||||
if (!value) return;
|
||||
if (typeof value.id === 'number') {
|
||||
nodeIds.add(value.id);
|
||||
}
|
||||
};
|
||||
pushId(capsule.primary ?? null);
|
||||
(capsule.secondary ?? []).forEach(pushId);
|
||||
(capsule.referenced ?? []).forEach(pushId);
|
||||
return {
|
||||
capsule,
|
||||
nodeIds: Array.from(nodeIds),
|
||||
version: typeof capsule.version === 'number' ? capsule.version : undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn('MiniRAHExecutor: failed to parse delegation capsule', error);
|
||||
return { nodeIds: [] };
|
||||
}
|
||||
}
|
||||
|
||||
function parseSourcesLine(summary: string): { line?: string; ids: number[] } {
|
||||
const lines = summary
|
||||
.split(/\n+/)
|
||||
.map(line => line.trim())
|
||||
.filter(Boolean);
|
||||
const contextLine = lines.find(line => line.toLowerCase().startsWith('context sources used:'));
|
||||
if (!contextLine) {
|
||||
return { ids: [] };
|
||||
}
|
||||
const matches = contextLine.match(/\d+/g);
|
||||
const ids = matches ? matches.map(id => Number(id)).filter(id => Number.isFinite(id)) : [];
|
||||
return { line: contextLine, ids: Array.from(new Set(ids)) };
|
||||
}
|
||||
|
||||
function validateMiniSummary(summary: string, expectedNodeIds: number[]): SummaryValidation {
|
||||
const trimmed = summary.trim();
|
||||
if (!trimmed) {
|
||||
return { status: 'failed', reason: 'Worker returned an empty summary.', sourcesUsed: [] };
|
||||
}
|
||||
|
||||
const lines = trimmed.split(/\n+/).map(line => line.trim()).filter(Boolean);
|
||||
const resultIndex = lines.findIndex(line => line.toLowerCase().startsWith('result:'));
|
||||
if (resultIndex === -1) {
|
||||
return { status: 'failed', reason: 'Missing or empty Result line in worker summary.', sourcesUsed: [] };
|
||||
}
|
||||
|
||||
const resultLine = lines[resultIndex];
|
||||
const resultContent = resultLine.slice('result:'.length).trim();
|
||||
if (!resultContent) {
|
||||
const nextLine = lines[resultIndex + 1];
|
||||
if (!nextLine || /^(task:|actions:|node:|context sources used:|follow-up:)/i.test(nextLine)) {
|
||||
return { status: 'failed', reason: 'Missing or empty Result line in worker summary.', sourcesUsed: [] };
|
||||
}
|
||||
}
|
||||
|
||||
const followUpLine = lines.find(line => line.toLowerCase().startsWith('follow-up:'));
|
||||
if (!followUpLine) {
|
||||
return { status: 'failed', reason: 'Missing Follow-up line in worker summary.', sourcesUsed: [] };
|
||||
}
|
||||
|
||||
const { line: contextLine, ids } = parseSourcesLine(summary);
|
||||
if (!contextLine) {
|
||||
return { status: 'failed', reason: 'Missing "Context sources used" line. Workers must list node IDs they referenced.', sourcesUsed: [] };
|
||||
}
|
||||
if (expectedNodeIds.length > 0 && ids.length === 0) {
|
||||
return { status: 'failed', reason: 'Worker did not cite any node IDs even though a capsule was provided.', sourcesUsed: [] };
|
||||
}
|
||||
|
||||
return { status: 'ok', sourcesUsed: ids };
|
||||
}
|
||||
|
||||
export interface MiniRAHExecutionInput {
|
||||
sessionId: string;
|
||||
task: string;
|
||||
context: string[];
|
||||
expectedOutcome?: string | null;
|
||||
traceId?: string;
|
||||
parentChatId?: number;
|
||||
workflowKey?: string;
|
||||
workflowNodeId?: number;
|
||||
}
|
||||
|
||||
export class MiniRAHExecutor {
|
||||
static async execute({ sessionId, task, context, expectedOutcome, traceId, parentChatId, workflowKey, workflowNodeId }: MiniRAHExecutionInput) {
|
||||
try {
|
||||
const requestContext = RequestContext.get();
|
||||
const delegateKey =
|
||||
requestContext.apiKeys?.openai ||
|
||||
process.env.RAH_DELEGATE_OPENAI_API_KEY ||
|
||||
process.env.OPENAI_API_KEY;
|
||||
if (!delegateKey) {
|
||||
throw new Error('RAH_DELEGATE_OPENAI_API_KEY (or OPENAI_API_KEY) is not set.');
|
||||
}
|
||||
|
||||
AgentDelegationService.markInProgress(sessionId);
|
||||
|
||||
const promptSections = [
|
||||
`Task: ${task}`,
|
||||
context.length ? `Context:\n- ${context.join('\n- ')}` : undefined,
|
||||
expectedOutcome ? `Expected outcome: ${expectedOutcome}` : undefined,
|
||||
'Return only the final summary the orchestrator should see.'
|
||||
].filter(Boolean);
|
||||
|
||||
const openaiProvider = createOpenAI({ apiKey: delegateKey });
|
||||
const executorTools = getToolsForRole('executor');
|
||||
const capturedSummaries: string[] = [];
|
||||
const toolsUsedInSession: string[] = [];
|
||||
const integrateAllowed = workflowKey === 'integrate'
|
||||
? new Set(['createEdge', 'updateEdge', 'updateNode'])
|
||||
: null;
|
||||
const wrappedTools = Object.fromEntries(
|
||||
Object.entries(executorTools)
|
||||
.filter(([name]) => {
|
||||
if (integrateAllowed) {
|
||||
return integrateAllowed.has(name);
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.map(([name, tool]) => {
|
||||
const wrapped = {
|
||||
...tool,
|
||||
async execute(params: any, context: any) {
|
||||
if (!toolsUsedInSession.includes(name)) {
|
||||
toolsUsedInSession.push(name);
|
||||
}
|
||||
if (name === 'createEdge' && params && typeof params === 'object' && 'from_node_id' in params && 'to_node_id' in params) {
|
||||
const fromId = Number(params.from_node_id);
|
||||
const toId = Number(params.to_node_id);
|
||||
if (Number.isFinite(fromId) && Number.isFinite(toId)) {
|
||||
const exists = await edgeService.edgeExists(fromId, toId);
|
||||
if (exists) {
|
||||
const skipSummary = `Edge already exists between node ${fromId} and node ${toId}; skipping createEdge.`;
|
||||
capturedSummaries.push(skipSummary);
|
||||
return {
|
||||
success: true,
|
||||
skipped: true,
|
||||
message: skipSummary,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = await tool.execute(params, context);
|
||||
const summary = summarizeToolExecution(name, params, result);
|
||||
if (summary) {
|
||||
capturedSummaries.push(summary);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
};
|
||||
return [name, wrapped];
|
||||
})
|
||||
);
|
||||
|
||||
if ('delegateToMiniRAH' in wrappedTools) {
|
||||
console.warn('MiniRAHExecutor: delegateToMiniRAH detected in executor toolset. Removing to enforce single-level delegation.');
|
||||
delete wrappedTools.delegateToMiniRAH;
|
||||
}
|
||||
|
||||
const userPrompt = promptSections.join('\n\n');
|
||||
const messages: CoreMessage[] = [{ role: 'user', content: userPrompt }];
|
||||
|
||||
const maxIterations = 6;
|
||||
let rawSummary = '';
|
||||
let lastFinishReason: string | undefined;
|
||||
let lastToolCalls: any[] | undefined;
|
||||
|
||||
let totalInputTokens = 0;
|
||||
let totalOutputTokens = 0;
|
||||
let totalTokens = 0;
|
||||
|
||||
// Track if extraction tool was called (for Quick Add - should only call once)
|
||||
const isQuickAddTask = task.toLowerCase().includes('quick add');
|
||||
let extractionToolCalled = false;
|
||||
|
||||
for (let iteration = 0; iteration < maxIterations; iteration++) {
|
||||
const response = await generateText({
|
||||
model: openaiProvider('gpt-4o-mini'),
|
||||
system: MINI_RAH_SYSTEM_PROMPT,
|
||||
messages,
|
||||
tools: wrappedTools,
|
||||
});
|
||||
|
||||
const usage = response.usage;
|
||||
if (usage) {
|
||||
const inputTokens = (usage as any).promptTokens || usage.inputTokens || 0;
|
||||
const outputTokens = (usage as any).completionTokens || usage.outputTokens || 0;
|
||||
const combinedTotal = (usage as any).totalTokens || usage.totalTokens || inputTokens + outputTokens;
|
||||
totalInputTokens += inputTokens;
|
||||
totalOutputTokens += outputTokens;
|
||||
totalTokens += combinedTotal;
|
||||
}
|
||||
|
||||
lastFinishReason = response.finishReason;
|
||||
lastToolCalls = response.toolCalls ?? [];
|
||||
|
||||
if (response.finishReason === 'tool-calls' && response.toolCalls && response.toolCalls.length > 0) {
|
||||
messages.push({
|
||||
role: 'assistant',
|
||||
content: response.toolCalls.map(call => ({
|
||||
type: 'tool-call' as const,
|
||||
toolCallId: call.toolCallId,
|
||||
toolName: call.toolName,
|
||||
input: (call as any).input ?? (call as any).args,
|
||||
})),
|
||||
});
|
||||
|
||||
const toolResults: Array<{ type: 'tool-result'; toolCallId: string; toolName: string; output: { type: 'text' | 'error-text'; value: string } }> = [];
|
||||
|
||||
for (const call of response.toolCalls) {
|
||||
const callInput = (call as any).input ?? (call as any).args;
|
||||
const tool = wrappedTools[call.toolName];
|
||||
const isExtractionToolCall = isQuickAddTask && ['youtubeExtract', 'websiteExtract', 'paperExtract'].includes(call.toolName);
|
||||
|
||||
if (isExtractionToolCall && extractionToolCalled) {
|
||||
const skipMessage = `Extraction already completed; skipping duplicate ${call.toolName} request.`;
|
||||
console.warn(`[MiniRAHExecutor] ${skipMessage}`);
|
||||
toolResults.push({
|
||||
type: 'tool-result',
|
||||
toolCallId: call.toolCallId,
|
||||
toolName: call.toolName,
|
||||
output: { type: 'text', value: skipMessage },
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!tool) {
|
||||
toolResults.push({
|
||||
type: 'tool-result',
|
||||
toolCallId: call.toolCallId,
|
||||
toolName: call.toolName,
|
||||
output: { type: 'error-text', value: `Tool ${call.toolName} is not available.` },
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const toolResult = await tool.execute(callInput, {});
|
||||
const summary = summarizeToolExecution(call.toolName, callInput, toolResult);
|
||||
const value = summary || `${call.toolName} completed.`;
|
||||
toolResults.push({
|
||||
type: 'tool-result',
|
||||
toolCallId: call.toolCallId,
|
||||
toolName: call.toolName,
|
||||
output: { type: 'text', value },
|
||||
});
|
||||
|
||||
// For Quick Add: stop after first successful extraction to prevent duplicates
|
||||
if (isExtractionToolCall) {
|
||||
const success = typeof toolResult === 'object' && toolResult !== null && (toolResult as any).success !== false;
|
||||
if (success) {
|
||||
console.log(`[MiniRAHExecutor] Quick Add extraction succeeded, forcing summary generation to prevent duplicate calls`);
|
||||
extractionToolCalled = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Tool execution failed';
|
||||
toolResults.push({
|
||||
type: 'tool-result',
|
||||
toolCallId: call.toolCallId,
|
||||
toolName: call.toolName,
|
||||
output: { type: 'error-text', value: message },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
messages.push({ role: 'tool', content: toolResults });
|
||||
|
||||
// If Quick Add extraction succeeded, request final summary and exit loop
|
||||
if (extractionToolCalled) {
|
||||
console.log('[MiniRAHExecutor] Requesting final summary after Quick Add extraction');
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: 'Extraction completed successfully. Provide your final summary now using the required format (Task/Actions/Result/Node/Context sources used/Follow-up).'
|
||||
});
|
||||
const summaryResponse = await generateText({
|
||||
model: openaiProvider('gpt-4o-mini'),
|
||||
system: MINI_RAH_SYSTEM_PROMPT,
|
||||
messages,
|
||||
tools: {}, // No tools - summary only
|
||||
});
|
||||
rawSummary = summaryResponse.text?.trim() || 'Extraction completed successfully.';
|
||||
break;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
rawSummary = typeof response.text === 'string' ? response.text.trim() : '';
|
||||
if (!rawSummary) {
|
||||
console.warn('[MiniRAHExecutor] Worker returned empty summary.', {
|
||||
finishReason: response.finishReason,
|
||||
toolCalls: response.toolCalls,
|
||||
text: response.text,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (!rawSummary) {
|
||||
console.warn('[MiniRAHExecutor] No summary after tool loop.', {
|
||||
lastFinishReason,
|
||||
lastToolCalls,
|
||||
});
|
||||
}
|
||||
|
||||
const fallbackSummary = capturedSummaries.length > 0
|
||||
? capturedSummaries[capturedSummaries.length - 1]
|
||||
: 'Completed the delegated task (worker returned no additional summary).';
|
||||
const initialSummary = rawSummary.length > 0 ? rawSummary : fallbackSummary;
|
||||
|
||||
const capsuleInfo = extractCapsuleFromContext(context);
|
||||
const validation = validateMiniSummary(initialSummary, capsuleInfo.nodeIds);
|
||||
const validationStatus: DelegationStatus = validation.status === 'ok' ? 'completed' : 'failed';
|
||||
if (validation.status === 'failed') {
|
||||
console.warn('[MiniRAHExecutor] summary validation failed.', {
|
||||
reason: validation.reason,
|
||||
initialSummary,
|
||||
});
|
||||
}
|
||||
|
||||
const finalSummary = validation.status === 'ok'
|
||||
? initialSummary
|
||||
: `${initialSummary}\nValidation: ${validation.reason}`;
|
||||
|
||||
console.log('[MiniRAHExecutor] summary:', finalSummary);
|
||||
|
||||
// Calculate cost and log to chats table
|
||||
if (totalInputTokens > 0 || totalOutputTokens > 0 || totalTokens > 0) {
|
||||
const effectiveTotalTokens = totalTokens > 0 ? totalTokens : totalInputTokens + totalOutputTokens;
|
||||
|
||||
const costResult = calculateCost({
|
||||
inputTokens: totalInputTokens,
|
||||
outputTokens: totalOutputTokens,
|
||||
modelId: 'gpt-4o-mini',
|
||||
});
|
||||
|
||||
const usageData: UsageData = {
|
||||
inputTokens: totalInputTokens,
|
||||
outputTokens: totalOutputTokens,
|
||||
totalTokens: effectiveTotalTokens,
|
||||
estimatedCostUsd: costResult.totalCostUsd,
|
||||
modelUsed: 'gpt-4o-mini',
|
||||
provider: 'openai',
|
||||
toolsUsed: toolsUsedInSession.length > 0 ? toolsUsedInSession : undefined,
|
||||
toolCallsCount: toolsUsedInSession.length > 0 ? toolsUsedInSession.length : undefined,
|
||||
traceId,
|
||||
parentChatId,
|
||||
workflowKey,
|
||||
workflowNodeId,
|
||||
capsuleVersion: capsuleInfo.version,
|
||||
contextSourcesUsed: validation.sourcesUsed.length > 0 ? validation.sourcesUsed : undefined,
|
||||
validationStatus: validation.status,
|
||||
validationMessage: validation.reason,
|
||||
fallbackAction: validation.status === 'failed' ? 'Review context capsule, hydrate nodes manually, then re-delegate with clarified instructions.' : undefined,
|
||||
};
|
||||
|
||||
const delegation = AgentDelegationService.getDelegation(sessionId);
|
||||
const delegationId = delegation?.id;
|
||||
|
||||
await ChatLoggingMiddleware.logChatInteraction(
|
||||
task,
|
||||
finalSummary,
|
||||
{
|
||||
helperName: 'mini-rah',
|
||||
agentType: 'executor',
|
||||
delegationId: delegationId ?? null,
|
||||
sessionId,
|
||||
usageData,
|
||||
traceId,
|
||||
parentChatId,
|
||||
workflowKey,
|
||||
workflowNodeId,
|
||||
systemMessage: MINI_RAH_SYSTEM_PROMPT,
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
console.log(`💰 [MiniRAHExecutor] Cost: $${costResult.totalCostUsd.toFixed(6)} (${effectiveTotalTokens} tokens)`);
|
||||
}
|
||||
|
||||
return AgentDelegationService.completeDelegation(sessionId, finalSummary, validationStatus);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown delegation error';
|
||||
AgentDelegationService.completeDelegation(sessionId, `Mini ra-h failed: ${message}`, 'failed');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
import { AgentDelegationService } from './delegation';
|
||||
import { summarizeToolExecution } from './toolResultUtils';
|
||||
import { youtubeExtractTool } from '@/tools/other/youtubeExtract';
|
||||
import { websiteExtractTool } from '@/tools/other/websiteExtract';
|
||||
import { paperExtractTool } from '@/tools/other/paperExtract';
|
||||
import { formatNodeForChat } from '@/tools/infrastructure/nodeFormatter';
|
||||
import { summarizeTranscript } from './transcriptSummarizer';
|
||||
|
||||
export type QuickAddMode = 'link' | 'note' | 'chat';
|
||||
|
||||
export type QuickAddInputType = 'youtube' | 'website' | 'pdf' | 'note' | 'chat';
|
||||
|
||||
export interface QuickAddInput {
|
||||
rawInput: string;
|
||||
mode?: QuickAddMode;
|
||||
}
|
||||
|
||||
function isLikelyChatTranscript(raw: string): boolean {
|
||||
const newlineCount = (raw.match(/\n/g)?.length ?? 0);
|
||||
if (newlineCount >= 3 && raw.length > 300) return true;
|
||||
if (/\b\d{1,2}:\d{2}\b/.test(raw) && newlineCount >= 1) return true;
|
||||
if (/You said:|ChatGPT said:|Claude said:|Assistant:|User:/i.test(raw)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function detectInputType(raw: string, mode?: QuickAddMode): QuickAddInputType {
|
||||
if (mode === 'chat') return 'chat';
|
||||
if (mode === 'note') return 'note';
|
||||
|
||||
const input = raw.trim();
|
||||
if (/youtu(\.be|be\.com)/i.test(input)) return 'youtube';
|
||||
if (/\.pdf($|\?)/i.test(input) || /arxiv\.org\//i.test(input)) return 'pdf';
|
||||
if (/^https?:\/\//i.test(input)) return 'website';
|
||||
if (!mode && isLikelyChatTranscript(input)) return 'chat';
|
||||
return 'note';
|
||||
}
|
||||
|
||||
function buildTaskPrompt(type: QuickAddInputType, input: string): string {
|
||||
switch (type) {
|
||||
case 'youtube':
|
||||
return `Quick Add: extract YouTube video and create node → ${input}`;
|
||||
case 'website':
|
||||
return `Quick Add: extract webpage and create node → ${input}`;
|
||||
case 'pdf':
|
||||
return `Quick Add: extract PDF and create node → ${input}`;
|
||||
case 'note':
|
||||
return `Quick Add note: create a node from this text with no dimensions → ${input}`;
|
||||
case 'chat':
|
||||
return `Quick Add: import chat transcript and summarize → ${input.slice(0, 120)}${input.length > 120 ? '…' : ''}`;
|
||||
}
|
||||
}
|
||||
|
||||
function buildExpectedOutcome(type: QuickAddInputType): string {
|
||||
if (type === 'note') {
|
||||
return 'Call createNode with the user input as content. Use a descriptive title extracted from the first few words. Set dimensions to an empty array. Return a brief confirmation like "Created note [NODE:id:title]"';
|
||||
}
|
||||
if (type === 'chat') {
|
||||
return 'Store the entire transcript in chunk_content, summarize the conversation into content, and return a confirmation like "Created chat transcript node [NODE:id:title]"';
|
||||
}
|
||||
return 'Use the appropriate extraction tool (youtubeExtract, websiteExtract, or paperExtract) to create the node. Return the standard extraction summary.';
|
||||
}
|
||||
|
||||
type ExtractionQuickAddType = Extract<QuickAddInputType, 'youtube' | 'website' | 'pdf'>;
|
||||
|
||||
const EXTRACTION_TOOL_MAP = {
|
||||
youtube: { toolName: 'youtubeExtract' as const, execute: youtubeExtractTool.execute },
|
||||
website: { toolName: 'websiteExtract' as const, execute: websiteExtractTool.execute },
|
||||
pdf: { toolName: 'paperExtract' as const, execute: paperExtractTool.execute },
|
||||
};
|
||||
|
||||
interface SummaryParts {
|
||||
task: string;
|
||||
action: string;
|
||||
resultMessage: string;
|
||||
nodeReference: string;
|
||||
}
|
||||
|
||||
interface ExtractionToolResultData {
|
||||
nodeId?: number;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
interface ExtractionToolResult {
|
||||
success?: boolean;
|
||||
error?: string;
|
||||
data?: ExtractionToolResultData | null;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
interface CreateNodeResponse {
|
||||
success?: boolean;
|
||||
data?: { id?: number; title?: string } | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function buildStructuredSummary({ task, action, resultMessage, nodeReference }: SummaryParts): string {
|
||||
const normalizedResult = resultMessage?.trim().length ? resultMessage.trim() : `${action} completed.`;
|
||||
const normalizedNode = nodeReference || 'None';
|
||||
return [
|
||||
`Task: ${task}`,
|
||||
`Actions: ${action}`,
|
||||
`Result: ${normalizedResult}`,
|
||||
`Node: ${normalizedNode}`,
|
||||
'Context sources used: None',
|
||||
'Follow-up: None',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function deriveNoteTitle(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return 'Quick Add Note';
|
||||
}
|
||||
const sentenceMatch = trimmed.match(/^(.{1,120}?)([.!?]\s|\n|$)/);
|
||||
const candidate = sentenceMatch ? sentenceMatch[1] : trimmed.slice(0, 120);
|
||||
const title = candidate.replace(/\s+/g, ' ').trim();
|
||||
return title.length >= trimmed.length || title.length <= 120 ? title : `${title.slice(0, 117)}…`;
|
||||
}
|
||||
|
||||
function deriveChatTitle(raw: string, summarySubject?: string): string {
|
||||
if (summarySubject && summarySubject.trim().length > 0) {
|
||||
return summarySubject.trim();
|
||||
}
|
||||
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return 'Chat Transcript';
|
||||
const firstLine = trimmed.split('\n')[0];
|
||||
const cleaned = firstLine.replace(/You said:|ChatGPT said:|Claude said:/gi, '').trim();
|
||||
if (!cleaned) return 'Chat Transcript';
|
||||
return cleaned.length > 120 ? `${cleaned.slice(0, 117)}…` : cleaned;
|
||||
}
|
||||
|
||||
function isExtractionToolResult(value: unknown): value is ExtractionToolResult {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const candidate = value as Record<string, unknown>;
|
||||
if ('success' in candidate && typeof candidate.success !== 'boolean' && candidate.success !== undefined) {
|
||||
return false;
|
||||
}
|
||||
if ('error' in candidate && typeof candidate.error !== 'string' && candidate.error !== undefined && candidate.error !== null) {
|
||||
return false;
|
||||
}
|
||||
if ('data' in candidate && candidate.data !== undefined && candidate.data !== null && typeof candidate.data !== 'object') {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function isCreateNodeResponse(value: unknown): value is CreateNodeResponse {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const candidate = value as Record<string, unknown>;
|
||||
if ('success' in candidate && typeof candidate.success !== 'boolean' && candidate.success !== undefined) {
|
||||
return false;
|
||||
}
|
||||
if ('error' in candidate && typeof candidate.error !== 'string' && candidate.error !== undefined && candidate.error !== null) {
|
||||
return false;
|
||||
}
|
||||
if ('data' in candidate && candidate.data !== undefined && candidate.data !== null && typeof candidate.data !== 'object') {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function handleExtractionQuickAdd(type: ExtractionQuickAddType, url: string, task: string): Promise<string> {
|
||||
const { toolName, execute } = EXTRACTION_TOOL_MAP[type];
|
||||
if (!execute) {
|
||||
throw new Error(`Tool ${toolName} does not have an execute function`);
|
||||
}
|
||||
const rawResult = await execute({ url }, { toolCallId: 'quickadd-extract', messages: [] });
|
||||
|
||||
if (!isExtractionToolResult(rawResult)) {
|
||||
throw new Error(`Unexpected response from ${toolName}`);
|
||||
}
|
||||
|
||||
const toolResult = rawResult;
|
||||
|
||||
if (!toolResult || toolResult.success === false) {
|
||||
const errorMessage = toolResult?.error || `Failed to execute ${toolName}`;
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
const summaryLine = summarizeToolExecution(toolName, { url }, toolResult);
|
||||
const nodeId = toolResult.data?.nodeId;
|
||||
const nodeTitle = typeof toolResult.data?.title === 'string' && toolResult.data.title.trim().length > 0
|
||||
? toolResult.data.title.trim()
|
||||
: nodeId ? `Node ${nodeId}` : 'Created node';
|
||||
const nodeReference = nodeId ? formatNodeForChat({ id: nodeId, title: nodeTitle }) : 'None';
|
||||
|
||||
return buildStructuredSummary({
|
||||
task,
|
||||
action: toolName,
|
||||
resultMessage: summaryLine,
|
||||
nodeReference,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleNoteQuickAdd(rawInput: string, task: string): Promise<string> {
|
||||
const content = rawInput.trim();
|
||||
if (!content) {
|
||||
throw new Error('Input is required to create a note');
|
||||
}
|
||||
|
||||
const title = deriveNoteTitle(content);
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/nodes`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
content,
|
||||
dimensions: [],
|
||||
metadata: {
|
||||
source: 'quick-add-note',
|
||||
refined_at: new Date().toISOString(),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const rawResult = await response.json();
|
||||
|
||||
if (!isCreateNodeResponse(rawResult)) {
|
||||
throw new Error('Unexpected response from node creation');
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(rawResult?.error || 'Failed to create note');
|
||||
}
|
||||
|
||||
const nodeId = rawResult?.data?.id;
|
||||
const nodeReference = nodeId ? formatNodeForChat({ id: nodeId, title }) : 'None';
|
||||
const resultMessage = nodeId ? `Created note ${nodeReference}.` : 'Created note.';
|
||||
|
||||
return buildStructuredSummary({
|
||||
task,
|
||||
action: 'createNode',
|
||||
resultMessage,
|
||||
nodeReference,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleChatTranscriptQuickAdd(rawInput: string, task: string): Promise<string> {
|
||||
const transcript = rawInput.trim();
|
||||
if (!transcript) {
|
||||
throw new Error('Input is required to import a chat transcript');
|
||||
}
|
||||
|
||||
const summaryResult = await summarizeTranscript(transcript);
|
||||
const baseSummary = summaryResult.summary?.trim() || 'Captured chat transcript. Review the raw transcript for full detail.';
|
||||
|
||||
const intentLine = summaryResult.intent?.trim();
|
||||
const progressLine = summaryResult.progress?.trim();
|
||||
const stickingPoints = summaryResult.stickingPoints || [];
|
||||
|
||||
const highlightSection = (summaryResult.highlights?.length ?? 0) > 0
|
||||
? ['Highlights:', ...summaryResult.highlights!.map((item) => `- ${item}`)].join('\n')
|
||||
: null;
|
||||
|
||||
const followUpSection = (summaryResult.openQuestions?.length ?? 0) > 0
|
||||
? ['Open Questions:', ...summaryResult.openQuestions!.map((item) => `- ${item}`)].join('\n')
|
||||
: null;
|
||||
|
||||
const stickingSection = stickingPoints.length > 0
|
||||
? ['Where things felt stuck:', ...stickingPoints.map((item) => `- ${item}`)].join('\n')
|
||||
: null;
|
||||
|
||||
const contentParts = [
|
||||
`Overview:\n${baseSummary}`,
|
||||
];
|
||||
|
||||
if (intentLine) {
|
||||
contentParts.push(`What you were trying to do:\n${intentLine}`);
|
||||
}
|
||||
if (progressLine) {
|
||||
contentParts.push(`Where you made progress:\n${progressLine}`);
|
||||
}
|
||||
if (stickingSection) {
|
||||
contentParts.push(stickingSection);
|
||||
}
|
||||
if (highlightSection) contentParts.push(highlightSection);
|
||||
if (followUpSection) contentParts.push(followUpSection);
|
||||
const content = contentParts.join('\n\n');
|
||||
|
||||
const title = deriveChatTitle(transcript, summaryResult.subject);
|
||||
const wordCount = transcript.split(/\s+/).filter(Boolean).length;
|
||||
|
||||
const metadata = {
|
||||
source: 'quick-add-chat',
|
||||
summary_subject: summaryResult.subject,
|
||||
summary_intent: summaryResult.intent,
|
||||
summary_progress: summaryResult.progress,
|
||||
highlights: summaryResult.highlights ?? [],
|
||||
open_questions: summaryResult.openQuestions ?? [],
|
||||
participants: summaryResult.participants ?? [],
|
||||
sticking_points: summaryResult.stickingPoints ?? [],
|
||||
transcript_length_chars: transcript.length,
|
||||
transcript_length_words: wordCount,
|
||||
transcript_truncated_for_summary: summaryResult.truncated ?? false,
|
||||
summary_generated_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/nodes`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
content,
|
||||
chunk: transcript,
|
||||
dimensions: [],
|
||||
metadata,
|
||||
}),
|
||||
});
|
||||
|
||||
const rawResult = await response.json();
|
||||
|
||||
if (!isCreateNodeResponse(rawResult)) {
|
||||
throw new Error('Unexpected response from node creation');
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(rawResult?.error || 'Failed to create chat transcript node');
|
||||
}
|
||||
|
||||
const nodeId = rawResult?.data?.id;
|
||||
const nodeReference = nodeId ? formatNodeForChat({ id: nodeId, title }) : 'None';
|
||||
const resultMessage = nodeId ? `Created chat transcript ${nodeReference}.` : 'Created chat transcript.';
|
||||
|
||||
return buildStructuredSummary({
|
||||
task,
|
||||
action: 'chatTranscriptImport',
|
||||
resultMessage,
|
||||
nodeReference,
|
||||
});
|
||||
}
|
||||
|
||||
export async function enqueueQuickAdd({ rawInput, mode }: QuickAddInput) {
|
||||
const inputType = detectInputType(rawInput, mode);
|
||||
const context: string[] = (inputType === 'note' || inputType === 'chat') ? [] : [rawInput];
|
||||
const task = buildTaskPrompt(inputType, rawInput);
|
||||
const expectedOutcome = buildExpectedOutcome(inputType);
|
||||
|
||||
const delegation = AgentDelegationService.createDelegation({
|
||||
task,
|
||||
context,
|
||||
expectedOutcome,
|
||||
});
|
||||
|
||||
setImmediate(async () => {
|
||||
try {
|
||||
AgentDelegationService.markInProgress(delegation.sessionId);
|
||||
|
||||
let summary: string;
|
||||
if (inputType === 'note') {
|
||||
summary = await handleNoteQuickAdd(rawInput, task);
|
||||
} else if (inputType === 'chat') {
|
||||
summary = await handleChatTranscriptQuickAdd(rawInput, task);
|
||||
} else {
|
||||
summary = await handleExtractionQuickAdd(inputType as ExtractionQuickAddType, rawInput, task);
|
||||
}
|
||||
|
||||
AgentDelegationService.completeDelegation(delegation.sessionId, summary, 'completed');
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'unknown error';
|
||||
AgentDelegationService.completeDelegation(
|
||||
delegation.sessionId,
|
||||
`Quick Add failed: ${message}`,
|
||||
'failed'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return delegation;
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { getDefaultToolNamesForRole } from '@/tools/infrastructure/registry';
|
||||
import { RAH_MAIN_SYSTEM_PROMPT } from '@/config/prompts/rah-main';
|
||||
import { RAH_EASY_SYSTEM_PROMPT } from '@/config/prompts/rah-easy';
|
||||
import { MINI_RAH_SYSTEM_PROMPT } from '@/config/prompts/rah-mini';
|
||||
import { WISE_RAH_SYSTEM_PROMPT } from '@/config/prompts/wise-rah';
|
||||
import type { AgentDefinition } from './types';
|
||||
|
||||
/**
|
||||
* Code-first agent registry (opinionated, not database-driven)
|
||||
* Agents are defined in code and cannot be modified by users
|
||||
*/
|
||||
export class AgentRegistry {
|
||||
// Deterministic agent definitions baked into code
|
||||
private static readonly AGENTS: Record<string, AgentDefinition> = {
|
||||
'ra-h': {
|
||||
id: 1,
|
||||
key: 'ra-h',
|
||||
displayName: 'ra-h (hard)',
|
||||
description: 'Opinionated orchestrator agent',
|
||||
model: 'anthropic/claude-sonnet-4.5',
|
||||
role: 'orchestrator',
|
||||
systemPrompt: RAH_MAIN_SYSTEM_PROMPT,
|
||||
availableTools: getDefaultToolNamesForRole('orchestrator'),
|
||||
enabled: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
memory: null,
|
||||
prompts: undefined
|
||||
},
|
||||
'ra-h-easy': {
|
||||
id: 4,
|
||||
key: 'ra-h-easy',
|
||||
displayName: 'ra-h (easy)',
|
||||
description: 'Fast, low-latency orchestrator',
|
||||
model: 'openai/gpt-5-mini',
|
||||
role: 'orchestrator',
|
||||
systemPrompt: RAH_EASY_SYSTEM_PROMPT,
|
||||
availableTools: getDefaultToolNamesForRole('orchestrator'),
|
||||
enabled: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
memory: null,
|
||||
prompts: undefined
|
||||
},
|
||||
'mini-rah': {
|
||||
id: 2,
|
||||
key: 'mini-rah',
|
||||
displayName: 'mini ra-h',
|
||||
description: 'Executor agent for delegated tasks',
|
||||
model: 'openai/gpt-4o-mini',
|
||||
role: 'executor',
|
||||
systemPrompt: MINI_RAH_SYSTEM_PROMPT,
|
||||
availableTools: getDefaultToolNamesForRole('executor'),
|
||||
enabled: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
memory: null,
|
||||
prompts: undefined
|
||||
},
|
||||
'wise-rah': {
|
||||
id: 3,
|
||||
key: 'wise-rah',
|
||||
displayName: 'wise ra-h',
|
||||
description: 'Complex workflow planner and orchestrator',
|
||||
model: 'openai/gpt-5',
|
||||
role: 'planner',
|
||||
systemPrompt: WISE_RAH_SYSTEM_PROMPT,
|
||||
availableTools: getDefaultToolNamesForRole('planner'),
|
||||
enabled: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
memory: null,
|
||||
prompts: undefined
|
||||
}
|
||||
};
|
||||
|
||||
static async getAgentByKey(key: string): Promise<AgentDefinition | null> {
|
||||
return this.AGENTS[key] || null;
|
||||
}
|
||||
|
||||
static async getAgentById(id: number): Promise<AgentDefinition | null> {
|
||||
return Object.values(this.AGENTS).find(a => a.id === id) || null;
|
||||
}
|
||||
|
||||
static async getEnabledAgents(): Promise<AgentDefinition[]> {
|
||||
return Object.values(this.AGENTS).filter(a => a.enabled);
|
||||
}
|
||||
|
||||
static async orchestrator(): Promise<AgentDefinition> {
|
||||
return this.AGENTS['ra-h'];
|
||||
}
|
||||
|
||||
static async orchestratorForMode(mode: 'easy' | 'hard' = 'easy'): Promise<AgentDefinition> {
|
||||
if (mode === 'hard') {
|
||||
return this.AGENTS['ra-h'];
|
||||
}
|
||||
return this.AGENTS['ra-h-easy'];
|
||||
}
|
||||
|
||||
static async executor(): Promise<AgentDefinition> {
|
||||
return this.AGENTS['mini-rah'];
|
||||
}
|
||||
|
||||
static async planner(): Promise<AgentDefinition> {
|
||||
return this.AGENTS['wise-rah'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
function ensureString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function ensureNumber(value: unknown): number | undefined {
|
||||
return typeof value === 'number' ? value : undefined;
|
||||
}
|
||||
|
||||
function truncateOrDefault(value: string, limit = 180, fallback = ''): string {
|
||||
if (!value) return fallback;
|
||||
if (value.length <= limit) return value;
|
||||
return `${value.slice(0, limit - 1)}…`;
|
||||
}
|
||||
|
||||
export function summarizeToolExecution(toolName: string, args: any, result: any): string {
|
||||
const fallback = `${toolName} completed.`;
|
||||
|
||||
if (typeof result === 'string') {
|
||||
const trimmed = result.trim();
|
||||
return trimmed || fallback;
|
||||
}
|
||||
|
||||
if (!result || typeof result !== 'object') {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
if (result.success === false) {
|
||||
const error = ensureString(result.error) || 'unknown error';
|
||||
return `${toolName} failed: ${error}`;
|
||||
}
|
||||
|
||||
const message = ensureString((result as any).message);
|
||||
if (message) {
|
||||
return message;
|
||||
}
|
||||
|
||||
if (toolName === 'think') {
|
||||
const trace = (result as any).data?.trace ?? args ?? {};
|
||||
const step = ensureNumber(trace.step ?? args?.step);
|
||||
const purpose = ensureString(trace.purpose ?? args?.purpose) || 'planning';
|
||||
const thoughts = ensureString(trace.thoughts ?? args?.thoughts);
|
||||
const next = ensureString(trace.next_action ?? args?.next_action);
|
||||
|
||||
let summary = `Plan${step ? ` step ${step}` : ''}: ${truncateOrDefault(purpose, 120, purpose)}`;
|
||||
if (thoughts) {
|
||||
summary += ` — ${truncateOrDefault(thoughts, 160, thoughts)}`;
|
||||
}
|
||||
if (next) {
|
||||
summary += `. Next: ${truncateOrDefault(next, 80, next)}`;
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
if (toolName === 'webSearch') {
|
||||
const query = ensureString(args?.query) || ensureString(result.data?.query);
|
||||
const results = Array.isArray(result.data?.results) ? result.data.results : [];
|
||||
if (results.length > 0) {
|
||||
const items = results.slice(0, 3).map((entry: any) => {
|
||||
const title = ensureString(entry.title) || ensureString(entry.url) || 'Result';
|
||||
const url = ensureString(entry.url);
|
||||
return url ? `${truncateOrDefault(title, 80, title)} (${url})` : truncateOrDefault(title, 80, title);
|
||||
});
|
||||
return `Web search${query ? ` for "${truncateOrDefault(query, 60, query)}"` : ''}: ${items.join('; ')}`;
|
||||
}
|
||||
return `Web search${query ? ` for "${truncateOrDefault(query, 60, query)}"` : ''}: no results.`;
|
||||
}
|
||||
|
||||
if (toolName === 'searchContentEmbeddings') {
|
||||
const query = ensureString(args?.query) || ensureString(result.data?.query);
|
||||
const chunks = Array.isArray(result.data?.chunks) ? result.data.chunks : [];
|
||||
if (chunks.length > 0) {
|
||||
const top = chunks[0];
|
||||
const snippet = ensureString(top.text);
|
||||
const nodeId = ensureNumber(top.node_id);
|
||||
const preview = truncateOrDefault(snippet, 160, snippet);
|
||||
return `Embedding search${query ? ` for "${truncateOrDefault(query, 60, query)}"` : ''} found ${chunks.length} chunk(s). Top${nodeId ? ` [NODE:${nodeId}]` : ''}: ${preview}`;
|
||||
}
|
||||
return `Embedding search${query ? ` for "${truncateOrDefault(query, 60, query)}"` : ''}: no matches.`;
|
||||
}
|
||||
|
||||
if (toolName === 'youtubeExtract') {
|
||||
const title = ensureString(result.data?.title) || ensureString(args?.title);
|
||||
const formatted = ensureString(result.data?.formatted_display);
|
||||
if (formatted) {
|
||||
return `YouTube extract created ${formatted}.`;
|
||||
}
|
||||
if (title) {
|
||||
return `YouTube extract processed "${truncateOrDefault(title, 80, title)}".`;
|
||||
}
|
||||
return 'YouTube extract completed.';
|
||||
}
|
||||
|
||||
if (toolName === 'queryNodes') {
|
||||
const nodes = Array.isArray(result.data?.nodes) ? result.data.nodes : [];
|
||||
if (nodes.length > 0) {
|
||||
const labels = nodes
|
||||
.slice(0, 3)
|
||||
.map((node: any) => ensureString(node.formatted_display) || ensureString(node.title) || `[NODE:${node.id}]`)
|
||||
.join(', ');
|
||||
return `Found ${nodes.length} node(s): ${labels}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (toolName === 'queryEdge') {
|
||||
const edges = Array.isArray(result.data?.edges) ? result.data.edges : [];
|
||||
if (edges.length > 0) {
|
||||
const edge = edges[0];
|
||||
return `Found ${edges.length} edge(s), e.g., ${edge.from_node_id} → ${edge.to_node_id}.`;
|
||||
}
|
||||
return 'No edges found.';
|
||||
}
|
||||
|
||||
if (result.data?.formatted_display) {
|
||||
return ensureString(result.data.formatted_display) || fallback;
|
||||
}
|
||||
|
||||
if (result.data?.title) {
|
||||
return `Processed "${truncateOrDefault(ensureString(result.data.title), 80, result.data.title)}".`;
|
||||
}
|
||||
|
||||
if (result.data?.count !== undefined) {
|
||||
const count = result.data.count;
|
||||
return `${toolName} returned ${count} item(s).`;
|
||||
}
|
||||
|
||||
try {
|
||||
const preview = JSON.stringify(result.data ?? result);
|
||||
return truncateOrDefault(preview, 200, fallback);
|
||||
} catch (error) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { generateText } from 'ai';
|
||||
import { openai } from '@ai-sdk/openai';
|
||||
|
||||
export interface TranscriptSummaryResult {
|
||||
subject?: string;
|
||||
summary?: string;
|
||||
intent?: string;
|
||||
progress?: string;
|
||||
highlights?: string[];
|
||||
openQuestions?: string[];
|
||||
participants?: string[];
|
||||
stickingPoints?: string[];
|
||||
truncated?: boolean;
|
||||
}
|
||||
|
||||
const MAX_TRANSCRIPT_CHARS = 15000;
|
||||
|
||||
function buildPrompt(snippet: string) {
|
||||
return `You will receive a raw conversation transcript copy/pasted from various chat apps. It may contain timestamps, "You said:", "ChatGPT said:", missing speaker labels, or UI noise like "Thought for 11s".
|
||||
|
||||
Return ONLY valid JSON with this exact shape (no markdown, no commentary):
|
||||
{
|
||||
"subject": "Short descriptive thread title",
|
||||
"summary": "2-3 sentence narrative describing what was discussed and why it matters",
|
||||
"intent": "What the human seemed to be trying to accomplish (1 sentence, second-person)",
|
||||
"progress": "Where the conversation actually moved forward (1-2 sentences, second-person)",
|
||||
"stickingPoints": ["Concise phrases describing where you got stuck or needed help"],
|
||||
"highlights": ["Key takeaway 1", "Key takeaway 2"],
|
||||
"openQuestions": ["Follow-up 1"],
|
||||
"participants": ["user", "assistant"]
|
||||
}
|
||||
|
||||
Guidelines:
|
||||
- Never mention the formatting noise; describe the substance.
|
||||
- Keep highlights concise bullet phrases (<=12 words).
|
||||
- Sticking points should be authentic blockers, not restatements of highlights.
|
||||
- Open questions are the clearest next steps; omit the array when nothing actionable exists.
|
||||
- Participants should be lowercase role labels (user, assistant, reviewer, etc.).
|
||||
- Prefer phrasing that sounds like you're talking directly to the user ("you were trying to...").
|
||||
|
||||
Transcript:
|
||||
${snippet}`;
|
||||
}
|
||||
|
||||
export async function summarizeTranscript(transcript: string): Promise<TranscriptSummaryResult> {
|
||||
const limited = transcript.length > MAX_TRANSCRIPT_CHARS
|
||||
? transcript.slice(0, MAX_TRANSCRIPT_CHARS)
|
||||
: transcript;
|
||||
|
||||
try {
|
||||
const response = await generateText({
|
||||
model: openai('gpt-4o-mini'),
|
||||
prompt: buildPrompt(limited),
|
||||
maxOutputTokens: 600,
|
||||
});
|
||||
|
||||
let content = response.text || '';
|
||||
content = content.replace(/```json/gi, '').replace(/```/g, '').trim();
|
||||
const parsed = JSON.parse(content);
|
||||
|
||||
return {
|
||||
subject: typeof parsed.subject === 'string' ? parsed.subject : undefined,
|
||||
summary: typeof parsed.summary === 'string' ? parsed.summary : undefined,
|
||||
intent: typeof parsed.intent === 'string' ? parsed.intent : undefined,
|
||||
progress: typeof parsed.progress === 'string' ? parsed.progress : undefined,
|
||||
highlights: Array.isArray(parsed.highlights)
|
||||
? parsed.highlights.filter((h: unknown) => typeof h === 'string' && h.trim().length > 0)
|
||||
: [],
|
||||
openQuestions: Array.isArray(parsed.openQuestions)
|
||||
? parsed.openQuestions.filter((q: unknown) => typeof q === 'string' && q.trim().length > 0)
|
||||
: [],
|
||||
participants: Array.isArray(parsed.participants)
|
||||
? parsed.participants.filter((p: unknown) => typeof p === 'string' && p.trim().length > 0)
|
||||
: [],
|
||||
stickingPoints: Array.isArray(parsed.stickingPoints)
|
||||
? parsed.stickingPoints.filter((s: unknown) => typeof s === 'string' && s.trim().length > 0)
|
||||
: [],
|
||||
truncated: transcript.length > MAX_TRANSCRIPT_CHARS,
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn('[TranscriptSummarizer] Failed to summarize transcript, falling back', error);
|
||||
return {
|
||||
summary: '',
|
||||
intent: '',
|
||||
progress: '',
|
||||
highlights: [],
|
||||
openQuestions: [],
|
||||
participants: [],
|
||||
stickingPoints: [],
|
||||
truncated: transcript.length > MAX_TRANSCRIPT_CHARS,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export type AgentRole = 'orchestrator' | 'executor' | 'planner';
|
||||
|
||||
export interface AgentDefinition {
|
||||
id: number;
|
||||
key: string;
|
||||
displayName: string;
|
||||
description: string | null;
|
||||
model: string;
|
||||
role: AgentRole;
|
||||
systemPrompt: string;
|
||||
availableTools: string[];
|
||||
enabled: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
memory?: Record<string, unknown> | null;
|
||||
prompts?: Array<{ id: string; name: string; content: string }>;
|
||||
}
|
||||
|
||||
export interface AgentSummary {
|
||||
key: string;
|
||||
displayName: string;
|
||||
role: AgentRole;
|
||||
model: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
@@ -0,0 +1,709 @@
|
||||
import { streamText, CoreMessage } from 'ai';
|
||||
import { createOpenAI } from '@ai-sdk/openai';
|
||||
import type { LanguageModelV2ToolResultOutput } from '@ai-sdk/provider';
|
||||
import { AgentDelegationService } from '@/services/agents/delegation';
|
||||
import { WISE_RAH_SYSTEM_PROMPT } from '@/config/prompts/wise-rah';
|
||||
import { getToolsForRole } from '@/tools/infrastructure/registry';
|
||||
import { ChatLoggingMiddleware } from '@/services/chat/middleware';
|
||||
import { calculateCost } from '@/services/analytics/pricing';
|
||||
import { UsageData } from '@/types/analytics';
|
||||
import { summarizeToolExecution } from '@/services/agents/toolResultUtils';
|
||||
import { edgeService } from '@/services/database/edges';
|
||||
import { eventBroadcaster } from '@/services/events';
|
||||
import { delegationStreamBroadcaster } from '@/app/api/rah/delegations/stream/route';
|
||||
import { nodeService } from '@/services/database/nodes';
|
||||
import { RequestContext } from '@/services/context/requestContext';
|
||||
import { isLocalMode } from '@/config/runtime';
|
||||
|
||||
export interface WiseRAHExecutionInput {
|
||||
sessionId: string;
|
||||
task: string;
|
||||
context: string[];
|
||||
expectedOutcome?: string | null;
|
||||
traceId?: string;
|
||||
parentChatId?: number;
|
||||
workflowKey?: string;
|
||||
workflowNodeId?: number;
|
||||
}
|
||||
|
||||
export class WiseRAHExecutor {
|
||||
static async execute({ sessionId, task, context, expectedOutcome, traceId, parentChatId, workflowKey, workflowNodeId }: WiseRAHExecutionInput) {
|
||||
console.log('🧙 [WiseRAHExecutor] Starting execution', { sessionId, task: task.substring(0, 100) });
|
||||
try {
|
||||
const requestContext = RequestContext.get();
|
||||
const wiseRahKey =
|
||||
requestContext.apiKeys?.openai ||
|
||||
process.env.RAH_WISE_RAH_OPENAI_API_KEY ||
|
||||
process.env.OPENAI_API_KEY;
|
||||
if (!wiseRahKey) {
|
||||
throw new Error('RAH_WISE_RAH_OPENAI_API_KEY (or OPENAI_API_KEY) is not set.');
|
||||
}
|
||||
|
||||
AgentDelegationService.markInProgress(sessionId);
|
||||
console.log('✅ [WiseRAHExecutor] Delegation marked in progress');
|
||||
|
||||
const normalizedTask = task.toLowerCase();
|
||||
const normalizedOutcome = (expectedOutcome || '').toLowerCase();
|
||||
const isWorkflow = Boolean(workflowKey) || normalizedTask.startsWith('execute workflow');
|
||||
const explicitWriteRequest = /\b(create|update|edge|append|workflow|integrate|summarize into node|link node|embed|ingest|synchronize)\b/.test(normalizedTask) ||
|
||||
/\b(create|update|edge|append|workflow|integrate|summarize into node|link node|embed|ingest|synchronize)\b/.test(normalizedOutcome);
|
||||
const allowWrites = isWorkflow || explicitWriteRequest;
|
||||
const analysisOnly = !allowWrites;
|
||||
const maxIterationsLimit = isWorkflow ? 20 : 5;
|
||||
const maxDelegationsAllowed = allowWrites ? (isWorkflow ? 12 : 2) : 0;
|
||||
const maxDistinctWebSearches = isWorkflow ? 6 : 4;
|
||||
const maxDistinctEmbeddingSearches = isWorkflow ? 5 : 3;
|
||||
|
||||
const promptSections = [
|
||||
`Task: ${task}`,
|
||||
context.length ? `Context:\n- ${context.join('\n- ')}` : undefined,
|
||||
expectedOutcome ? `Expected outcome: ${expectedOutcome}` : undefined,
|
||||
analysisOnly ? 'Constraint: This is an analysis-only request. Stay strictly read-only: do not call delegateToMiniRAH and do not request new extractions. Work only with existing knowledge.' : undefined,
|
||||
'Return a structured summary following the format in your system prompt (Task/Actions/Result/Nodes/Follow-up).'
|
||||
].filter(Boolean);
|
||||
|
||||
const openaiProvider = createOpenAI({ apiKey: wiseRahKey });
|
||||
console.log('🔧 [WiseRAHExecutor] OpenAI provider created');
|
||||
|
||||
const plannerTools = getToolsForRole('planner');
|
||||
|
||||
// Remove delegateToMiniRAH for integrate workflow (wise-rah does updates directly)
|
||||
if (workflowKey === 'integrate' && plannerTools.delegateToMiniRAH) {
|
||||
delete plannerTools.delegateToMiniRAH;
|
||||
console.log('🚫 [WiseRAHExecutor] Removed delegateToMiniRAH for integrate workflow (direct updates only)');
|
||||
}
|
||||
|
||||
// For analysis-only tasks, also remove delegation
|
||||
if (analysisOnly && plannerTools.delegateToMiniRAH) {
|
||||
delete plannerTools.delegateToMiniRAH;
|
||||
}
|
||||
|
||||
console.log('🛠️ [WiseRAHExecutor] Planner tools retrieved:', Object.keys(plannerTools));
|
||||
|
||||
const toolsUsedInSession: string[] = [];
|
||||
const delegatedEdgeKeys = new Set<string>();
|
||||
|
||||
// Workflow progress is now streamed directly to delegation tabs via delegationStreamBroadcaster
|
||||
// No need to broadcast WORKFLOW_PROGRESS events to main chat anymore
|
||||
const wrappedTools = Object.fromEntries(
|
||||
Object.entries(plannerTools).map(([name, tool]) => {
|
||||
const wrapped = {
|
||||
...tool,
|
||||
async execute(params: any, context: any) {
|
||||
if (!toolsUsedInSession.includes(name)) {
|
||||
toolsUsedInSession.push(name);
|
||||
}
|
||||
if (name === 'delegateToMiniRAH') {
|
||||
const extractEdgeKey = () => {
|
||||
if (!params) return null;
|
||||
const tryFromTask = () => {
|
||||
if (typeof params.task !== 'string') return null;
|
||||
const matches = [...params.task.matchAll(/\[NODE:(\d+)/g)];
|
||||
if (matches.length >= 2) {
|
||||
const fromId = Number(matches[0][1]);
|
||||
const toId = Number(matches[1][1]);
|
||||
if (Number.isFinite(fromId) && Number.isFinite(toId)) {
|
||||
return `${fromId}->${toId}`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const tryFromContext = () => {
|
||||
if (!Array.isArray(params.context)) return null;
|
||||
let fromId: number | null = null;
|
||||
let toId: number | null = null;
|
||||
for (const entry of params.context) {
|
||||
if (typeof entry === 'string') {
|
||||
const fromMatch = entry.match(/from_node_id\D+(\d+)/i);
|
||||
const toMatch = entry.match(/to_node_id\D+(\d+)/i);
|
||||
if (fromMatch && Number.isFinite(Number(fromMatch[1]))) {
|
||||
fromId = Number(fromMatch[1]);
|
||||
}
|
||||
if (toMatch && Number.isFinite(Number(toMatch[1]))) {
|
||||
toId = Number(toMatch[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Number.isFinite(fromId as number) && Number.isFinite(toId as number)) {
|
||||
return `${fromId}->${toId}`;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return tryFromTask() || tryFromContext();
|
||||
};
|
||||
|
||||
const edgeKey = extractEdgeKey();
|
||||
if (edgeKey) {
|
||||
if (delegatedEdgeKeys.has(edgeKey)) {
|
||||
const [from, to] = edgeKey.split('->');
|
||||
const message = `Skipped duplicate edge delegation for nodes ${from}→${to}.`;
|
||||
workerSummaries.push(message);
|
||||
return message;
|
||||
}
|
||||
delegatedEdgeKeys.add(edgeKey);
|
||||
const [from, to] = edgeKey.split('->').map(Number);
|
||||
if (Number.isFinite(from) && Number.isFinite(to)) {
|
||||
const exists = await edgeService.edgeExists(from, to);
|
||||
if (exists) {
|
||||
const message = `Edge ${from}→${to} already exists; delegation skipped.`;
|
||||
workerSummaries.push(message);
|
||||
return message;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return await tool.execute(params, context);
|
||||
}
|
||||
};
|
||||
return [name, wrapped];
|
||||
})
|
||||
);
|
||||
|
||||
// Enforce read-only constraint - remove write tools EXCEPT for workflows
|
||||
// Workflows (like integrate) need updateNode for direct content updates
|
||||
const writeToolsToRemove = isWorkflow
|
||||
? ['createNode', 'createEdge', 'updateEdge', 'embedContent', 'youtubeExtract', 'websiteExtract', 'paperExtract', 'delegateToWiseRAH']
|
||||
: ['createNode', 'updateNode', 'createEdge', 'updateEdge', 'embedContent', 'youtubeExtract', 'websiteExtract', 'paperExtract', 'delegateToWiseRAH'];
|
||||
|
||||
writeToolsToRemove.forEach(toolName => {
|
||||
if (toolName in wrappedTools) {
|
||||
console.warn(`WiseRAHExecutor: ${toolName} detected in planner toolset. Removing to enforce read-only constraint.`);
|
||||
delete wrappedTools[toolName];
|
||||
}
|
||||
});
|
||||
|
||||
if (isWorkflow && 'updateNode' in wrappedTools) {
|
||||
console.log('✅ [WiseRAHExecutor] updateNode preserved for workflow execution');
|
||||
}
|
||||
console.log('🔒 [WiseRAHExecutor] Final tools after read-only enforcement:', Object.keys(wrappedTools));
|
||||
|
||||
console.log('📝 [WiseRAHExecutor] Starting manual agentic loop...');
|
||||
|
||||
const messages: CoreMessage[] = [
|
||||
{ role: 'system', content: WISE_RAH_SYSTEM_PROMPT },
|
||||
{ role: 'user', content: promptSections.join('\n\n') }
|
||||
];
|
||||
|
||||
let finalText = '';
|
||||
const totalUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||
const maxIterations = maxIterationsLimit;
|
||||
|
||||
const seenToolResults = new Map<string, { output: LanguageModelV2ToolResultOutput; summary: string }>();
|
||||
const workerSummaries: string[] = [];
|
||||
const workerSummarySet = new Set<string>();
|
||||
let hasPlan = false;
|
||||
let planReminderAdded = false;
|
||||
let planIncludesDelegation = false;
|
||||
let planRevisionNoticeSent = false;
|
||||
let delegationNudgeSent = false;
|
||||
let iterationsSincePlan = 0;
|
||||
let lastPlanSummary = '';
|
||||
let totalDelegations = 0;
|
||||
let didCreateEdge = false;
|
||||
let didUpdateNode = false;
|
||||
const uniqueWebQueries = new Set<string>();
|
||||
const uniqueEmbeddingQueries = new Set<string>();
|
||||
let finalSummaryRequested = false;
|
||||
let iterationsWithoutDelegation = 0;
|
||||
|
||||
const ensureString = (value: unknown) => (typeof value === 'string' ? value.trim() : '');
|
||||
|
||||
const sanitizeForBroadcast = (value: unknown) => {
|
||||
if (value === undefined) return undefined;
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
} catch (error) {
|
||||
console.warn('[WiseRAHExecutor] Failed to serialize delegation payload', error);
|
||||
if (typeof value === 'string') return value;
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const emitDelegationEvent = (payload: Record<string, unknown>) => {
|
||||
delegationStreamBroadcaster.broadcast(sessionId, payload);
|
||||
};
|
||||
|
||||
const emitToolStart = (toolCallId: string, toolName: string, input: unknown) => {
|
||||
emitDelegationEvent({
|
||||
type: 'tool-input-start',
|
||||
toolCallId,
|
||||
toolName,
|
||||
input: sanitizeForBroadcast(input),
|
||||
});
|
||||
};
|
||||
|
||||
const emitToolCompletion = (
|
||||
toolCallId: string,
|
||||
toolName: string,
|
||||
rawResult: unknown,
|
||||
summary: string,
|
||||
status: 'complete' | 'error' = 'complete',
|
||||
errorMessage?: string
|
||||
) => {
|
||||
emitDelegationEvent({
|
||||
type: 'tool-output-available',
|
||||
toolCallId,
|
||||
toolName,
|
||||
result: sanitizeForBroadcast(rawResult),
|
||||
summary,
|
||||
status,
|
||||
error: errorMessage,
|
||||
});
|
||||
};
|
||||
|
||||
const buildToolOutput = (toolName: string, summary: string, rawResult: any): LanguageModelV2ToolResultOutput => {
|
||||
const trimmedSummary = summary.trim();
|
||||
|
||||
if (rawResult && typeof rawResult === 'object' && rawResult.success === false) {
|
||||
const message = trimmedSummary || ensureString(rawResult.error) || `${toolName} failed.`;
|
||||
return { type: 'error-text', value: message };
|
||||
}
|
||||
|
||||
if (typeof rawResult === 'string') {
|
||||
const value = rawResult.trim() || trimmedSummary || `${toolName} completed.`;
|
||||
return { type: 'text', value };
|
||||
}
|
||||
|
||||
if (trimmedSummary) {
|
||||
return { type: 'text', value: trimmedSummary };
|
||||
}
|
||||
|
||||
return { type: 'text', value: `${toolName} completed.` };
|
||||
};
|
||||
|
||||
const requestFinalSummary = async (instruction: string) => {
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: instruction,
|
||||
});
|
||||
|
||||
const finalStreamResult = await streamText({
|
||||
model: openaiProvider('gpt-5'),
|
||||
messages,
|
||||
tools: {},
|
||||
maxOutputTokens: 500,
|
||||
});
|
||||
|
||||
// Collect the complete response
|
||||
const finalChunks: string[] = [];
|
||||
for await (const chunk of finalStreamResult.textStream) {
|
||||
finalChunks.push(chunk);
|
||||
}
|
||||
|
||||
const finalResponse = {
|
||||
text: finalChunks.join(''),
|
||||
usage: await finalStreamResult.usage,
|
||||
};
|
||||
|
||||
totalUsage.inputTokens += finalResponse.usage?.inputTokens || 0;
|
||||
totalUsage.outputTokens += finalResponse.usage?.outputTokens || 0;
|
||||
totalUsage.totalTokens += finalResponse.usage?.totalTokens || 0;
|
||||
|
||||
return finalResponse.text ?? '';
|
||||
};
|
||||
|
||||
const normaliseForSignature = (toolName: string, input: any) => {
|
||||
if (!input || typeof input !== 'object') {
|
||||
return input;
|
||||
}
|
||||
|
||||
if (toolName === 'webSearch' && 'query' in input) {
|
||||
const query = ensureString(input.query).toLowerCase().replace(/\s+/g, ' ').trim();
|
||||
return { ...input, query };
|
||||
}
|
||||
|
||||
if (toolName === 'searchContentEmbeddings' && 'query' in input) {
|
||||
const query = ensureString(input.query).toLowerCase().replace(/\s+/g, ' ').trim();
|
||||
return { ...input, query };
|
||||
}
|
||||
|
||||
return input;
|
||||
};
|
||||
|
||||
for (let i = 0; i < maxIterations; i++) {
|
||||
console.log(`🔄 [WiseRAHExecutor] Iteration ${i + 1}/${maxIterations}`);
|
||||
|
||||
// Touch delegation every iteration to prevent cleanup from killing it
|
||||
AgentDelegationService.touchDelegation(sessionId);
|
||||
|
||||
const streamResult = await streamText({
|
||||
model: openaiProvider('gpt-5'),
|
||||
messages,
|
||||
tools: wrappedTools,
|
||||
});
|
||||
|
||||
// Collect the complete response
|
||||
const chunks: string[] = [];
|
||||
for await (const chunk of streamResult.textStream) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
const response = {
|
||||
text: chunks.join(''),
|
||||
finishReason: await streamResult.finishReason,
|
||||
usage: await streamResult.usage,
|
||||
toolCalls: await streamResult.toolCalls,
|
||||
};
|
||||
|
||||
totalUsage.inputTokens += response.usage?.inputTokens || 0;
|
||||
totalUsage.outputTokens += response.usage?.outputTokens || 0;
|
||||
totalUsage.totalTokens += response.usage?.totalTokens || 0;
|
||||
|
||||
console.log(`📊 [WiseRAHExecutor] Step ${i + 1} finishReason:`, response.finishReason);
|
||||
|
||||
// Stream text response to delegation chat
|
||||
if (response.text && response.text.trim()) {
|
||||
emitDelegationEvent({
|
||||
type: 'text-delta',
|
||||
delta: response.text,
|
||||
});
|
||||
}
|
||||
|
||||
if (response.finishReason !== 'tool-calls') {
|
||||
finalText = response.text;
|
||||
console.log('✅ [WiseRAHExecutor] Got final text');
|
||||
break;
|
||||
}
|
||||
|
||||
const toolCalls = response.toolCalls || [];
|
||||
console.log(`🔧 [WiseRAHExecutor] Executing ${toolCalls.length} tool calls`);
|
||||
|
||||
// Broadcast new assistant message for next iteration
|
||||
if (toolCalls.length > 0) {
|
||||
emitDelegationEvent({ type: 'assistant-message' });
|
||||
}
|
||||
|
||||
messages.push({
|
||||
role: 'assistant',
|
||||
content: toolCalls.map(call => ({
|
||||
type: 'tool-call' as const,
|
||||
toolCallId: call.toolCallId,
|
||||
toolName: call.toolName,
|
||||
input: (call as any).input ?? (call as any).args,
|
||||
})),
|
||||
});
|
||||
|
||||
const toolResults: Array<{
|
||||
type: 'tool-result';
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
output: LanguageModelV2ToolResultOutput;
|
||||
}> = [];
|
||||
|
||||
let executedTool = false;
|
||||
|
||||
for (const call of toolCalls) {
|
||||
let callInputRaw = (call as any).input ?? (call as any).args;
|
||||
|
||||
// Append logic now handled in updateNode tool itself (lines 27-44)
|
||||
|
||||
const signatureInput = normaliseForSignature(call.toolName, callInputRaw);
|
||||
const signature = JSON.stringify({ tool: call.toolName, input: signatureInput });
|
||||
|
||||
// Broadcast tool call to delegation stream
|
||||
emitToolStart(call.toolCallId, call.toolName, callInputRaw);
|
||||
|
||||
if (!hasPlan && call.toolName !== 'think') {
|
||||
const warning = 'Planning required: use the think tool to outline a numbered plan (purpose, thoughts, next action) before other tools.';
|
||||
toolResults.push({
|
||||
type: 'tool-result',
|
||||
toolCallId: call.toolCallId,
|
||||
toolName: call.toolName,
|
||||
output: { type: 'error-text', value: warning },
|
||||
});
|
||||
emitToolCompletion(call.toolCallId, call.toolName, { success: false }, warning, 'error', warning);
|
||||
|
||||
if (!planReminderAdded) {
|
||||
planReminderAdded = true;
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: 'Before calling other tools, use the think tool to draft a numbered plan and specify your next action.',
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (call.toolName !== 'think' && seenToolResults.has(signature)) {
|
||||
const cached = seenToolResults.get(signature)!;
|
||||
toolResults.push({
|
||||
type: 'tool-result',
|
||||
toolCallId: call.toolCallId,
|
||||
toolName: call.toolName,
|
||||
output: cached.output,
|
||||
});
|
||||
|
||||
// Broadcast cached result
|
||||
emitToolCompletion(call.toolCallId, call.toolName, cached.summary || 'Cached result', cached.summary || 'Cached result');
|
||||
|
||||
if (call.toolName === 'delegateToMiniRAH' && cached.summary) {
|
||||
if (!workerSummarySet.has(cached.summary)) {
|
||||
workerSummarySet.add(cached.summary);
|
||||
workerSummaries.push(`[reused] ${cached.summary}`);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const tool = wrappedTools[call.toolName];
|
||||
if (!tool) {
|
||||
const warning = `Tool ${call.toolName} is not available to wise ra-h.`;
|
||||
toolResults.push({
|
||||
type: 'tool-result',
|
||||
toolCallId: call.toolCallId,
|
||||
toolName: call.toolName,
|
||||
output: { type: 'error-text', value: warning },
|
||||
});
|
||||
emitToolCompletion(call.toolCallId, call.toolName, { success: false }, warning, 'error', warning);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const rawResult = await tool.execute(callInputRaw, {});
|
||||
const summary = summarizeToolExecution(call.toolName, callInputRaw, rawResult);
|
||||
const output = buildToolOutput(call.toolName, summary, rawResult);
|
||||
|
||||
toolResults.push({
|
||||
type: 'tool-result',
|
||||
toolCallId: call.toolCallId,
|
||||
toolName: call.toolName,
|
||||
output,
|
||||
});
|
||||
emitToolCompletion(call.toolCallId, call.toolName, rawResult, summary, 'complete');
|
||||
|
||||
if (call.toolName === 'think') {
|
||||
hasPlan = true;
|
||||
lastPlanSummary = summary;
|
||||
if (allowWrites) {
|
||||
planIncludesDelegation = /delegate\b|delegateToMiniRAH|mini ra-h/i.test(summary);
|
||||
} else {
|
||||
planIncludesDelegation = false;
|
||||
}
|
||||
planRevisionNoticeSent = false;
|
||||
delegationNudgeSent = false;
|
||||
iterationsSincePlan = 0;
|
||||
} else {
|
||||
seenToolResults.set(signature, { output, summary });
|
||||
if (call.toolName === 'delegateToMiniRAH' && summary && !workerSummarySet.has(summary)) {
|
||||
workerSummarySet.add(summary);
|
||||
workerSummaries.push(summary);
|
||||
totalDelegations += 1;
|
||||
|
||||
if (/Created edge/i.test(summary) || /Edge created/i.test(summary)) {
|
||||
didCreateEdge = true;
|
||||
}
|
||||
if (/Updated node/i.test(summary) || /Appended/i.test(summary) || /content updated/i.test(summary)) {
|
||||
didUpdateNode = true;
|
||||
}
|
||||
}
|
||||
if (call.toolName === 'webSearch') {
|
||||
const query = ensureString(signatureInput?.query ?? callInputRaw?.query);
|
||||
if (query) {
|
||||
uniqueWebQueries.add(query);
|
||||
}
|
||||
}
|
||||
if (call.toolName === 'searchContentEmbeddings') {
|
||||
const query = ensureString(signatureInput?.query ?? callInputRaw?.query);
|
||||
if (query) {
|
||||
uniqueEmbeddingQueries.add(query);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
executedTool = true;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Tool execution failed';
|
||||
toolResults.push({
|
||||
type: 'tool-result',
|
||||
toolCallId: call.toolCallId,
|
||||
toolName: call.toolName,
|
||||
output: { type: 'error-text', value: message },
|
||||
});
|
||||
emitToolCompletion(call.toolCallId, call.toolName, { success: false }, message, 'error', message);
|
||||
}
|
||||
}
|
||||
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
content: toolResults,
|
||||
});
|
||||
|
||||
if (hasPlan) {
|
||||
iterationsSincePlan += 1;
|
||||
// Legacy delegation nudges removed - wise-rah completes workflows independently
|
||||
}
|
||||
|
||||
const enoughMaterial = hasPlan && (allowWrites ? workerSummaries.length >= 2 : executedTool);
|
||||
const tooManyDelegations = allowWrites && totalDelegations >= maxDelegationsAllowed && maxDelegationsAllowed > 0;
|
||||
const webSearchCapReached = uniqueWebQueries.size >= maxDistinctWebSearches;
|
||||
const embeddingCapReached = uniqueEmbeddingQueries.size >= maxDistinctEmbeddingSearches;
|
||||
|
||||
if (allowWrites && workflowKey === 'integrate' && totalDelegations === 0) {
|
||||
iterationsWithoutDelegation += 1;
|
||||
if (!delegationNudgeSent && iterationsWithoutDelegation >= 4) {
|
||||
delegationNudgeSent = true;
|
||||
const targetInstruction = workflowNodeId
|
||||
? `Focus on node [NODE:${workflowNodeId}]. Delegate to mini ra-h now to (a) append integration insights to its content and (b) create edges to the highest-value related nodes you identified.`
|
||||
: 'Delegate to mini ra-h now to (a) append integration insights to the focused node and (b) create edges to the highest-value related nodes you identified.';
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: `${targetInstruction} Do not continue researching until those delegations are complete.`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
iterationsWithoutDelegation = 0;
|
||||
}
|
||||
|
||||
if (allowWrites && workflowKey === 'integrate' && didCreateEdge && !didUpdateNode) {
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: 'Edges are in place. Delegate to mini ra-h to append the Integration Insights section to the focused node before moving forward.',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const minIterationsBeforeSummary = allowWrites ? 2 : 1;
|
||||
if (!finalSummaryRequested && (tooManyDelegations || webSearchCapReached || embeddingCapReached || (enoughMaterial && executedTool && i >= minIterationsBeforeSummary))) {
|
||||
if (allowWrites && workflowKey === 'integrate' && !didUpdateNode) {
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: 'Before summarizing, delegate to mini ra-h to append the Integration Insights content to the focused node.',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (allowWrites && totalDelegations === 0) {
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: 'You have not delegated any execution yet. Identify the concrete write actions required and call delegateToMiniRAH to perform them before summarising.',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
let instruction = 'You have enough evidence from the workers. Provide the final Task/Actions/Result/Nodes/Follow-up summary now without further tool calls.';
|
||||
if (tooManyDelegations) {
|
||||
instruction = `You have already delegated the maximum allowed (${maxDelegationsAllowed}). Synthesize the findings now using the Task/Actions/Result/Nodes/Follow-up format. Do not call additional tools.`;
|
||||
} else if (webSearchCapReached) {
|
||||
instruction = `You have already issued about ${maxDistinctWebSearches} distinct web searches. Consolidate what you found into the final Task/Actions/Result/Nodes/Follow-up summary now—no additional tool calls.`;
|
||||
} else if (embeddingCapReached) {
|
||||
instruction = `Embedding searches have covered the knowledge base (limit ${maxDistinctEmbeddingSearches}). Switch to producing the Task/Actions/Result/Nodes/Follow-up summary—do not call further tools.`;
|
||||
}
|
||||
|
||||
finalText = await requestFinalSummary(instruction);
|
||||
finalSummaryRequested = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!finalText) {
|
||||
if (allowWrites && totalDelegations === 0) {
|
||||
throw new Error('Wise ra-h attempted to summarize without delegating any execution to mini ra-h.');
|
||||
}
|
||||
console.warn('⚠️ [WiseRAHExecutor] Max iterations hit with no summary. Requesting final response without tools.');
|
||||
finalText = await requestFinalSummary('You have gathered everything needed. Provide the final Task/Actions/Result/Nodes/Follow-up summary now. Do not call any tools.');
|
||||
console.log('✅ [WiseRAHExecutor] Final summary obtained after tool cutoff.');
|
||||
}
|
||||
|
||||
const usage = totalUsage;
|
||||
let summary = typeof finalText === 'string' ? finalText.trim() : '';
|
||||
|
||||
if (summary.length > 2000) {
|
||||
console.log('⚠️ [WiseRAHExecutor] Summary too long, requesting concise version.');
|
||||
summary = (await requestFinalSummary('Condense the findings into ≤300 tokens using the Task/Actions/Result/Nodes/Follow-up format. Focus on the most salient insights and reference key nodes. Do not call any tools.')).trim();
|
||||
}
|
||||
if (summary.length > 1000) {
|
||||
summary = `${summary.slice(0, 997)}…`;
|
||||
}
|
||||
console.log('📄 [WiseRAHExecutor] Summary after trim:', summary);
|
||||
console.log('📏 [WiseRAHExecutor] Summary length:', summary.length);
|
||||
|
||||
if (!summary) {
|
||||
emitDelegationEvent({
|
||||
type: 'assistant-message',
|
||||
});
|
||||
emitDelegationEvent({
|
||||
type: 'text-delta',
|
||||
delta: 'Wise ra-h attempted to summarise but the response was empty. Check tool logs above for context.',
|
||||
});
|
||||
throw new Error('Wise ra-h returned empty summary');
|
||||
}
|
||||
|
||||
console.log('[WiseRAHExecutor] summary:', summary);
|
||||
|
||||
// Calculate cost and log to chats table
|
||||
if (usage) {
|
||||
const inputTokens = (usage as any).promptTokens || usage.inputTokens || 0;
|
||||
const outputTokens = (usage as any).completionTokens || usage.outputTokens || 0;
|
||||
const totalTokens = inputTokens + outputTokens;
|
||||
|
||||
const costResult = calculateCost({
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
modelId: 'gpt-5',
|
||||
});
|
||||
|
||||
const usageData: UsageData = {
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
totalTokens,
|
||||
estimatedCostUsd: costResult.totalCostUsd,
|
||||
modelUsed: 'gpt-5',
|
||||
provider: 'openai',
|
||||
toolsUsed: toolsUsedInSession.length > 0 ? toolsUsedInSession : undefined,
|
||||
toolCallsCount: toolsUsedInSession.length > 0 ? toolsUsedInSession.length : undefined,
|
||||
traceId,
|
||||
parentChatId,
|
||||
workflowKey,
|
||||
workflowNodeId,
|
||||
};
|
||||
|
||||
const delegation = AgentDelegationService.getDelegation(sessionId);
|
||||
const delegationId = delegation?.id;
|
||||
|
||||
await ChatLoggingMiddleware.logChatInteraction(
|
||||
task,
|
||||
summary,
|
||||
{
|
||||
helperName: 'wise-rah',
|
||||
agentType: 'planner',
|
||||
delegationId: delegationId ?? null,
|
||||
sessionId,
|
||||
usageData,
|
||||
traceId,
|
||||
parentChatId,
|
||||
workflowKey,
|
||||
workflowNodeId,
|
||||
systemMessage: WISE_RAH_SYSTEM_PROMPT,
|
||||
backendUsage: [],
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
console.log(`💰 [WiseRAHExecutor] Cost: $${costResult.totalCostUsd.toFixed(6)} (${totalTokens} tokens)`);
|
||||
}
|
||||
|
||||
console.log('✅ [WiseRAHExecutor] Completing delegation with summary');
|
||||
return AgentDelegationService.completeDelegation(sessionId, summary);
|
||||
} catch (error) {
|
||||
console.error('❌ [WiseRAHExecutor] Error during execution:', error);
|
||||
console.error('❌ [WiseRAHExecutor] Error stack:', error instanceof Error ? error.stack : 'No stack');
|
||||
const message = error instanceof Error ? error.message : 'Unknown delegation error';
|
||||
|
||||
// Broadcast error to delegation stream
|
||||
delegationStreamBroadcaster.broadcast(sessionId, {
|
||||
type: 'assistant-message',
|
||||
});
|
||||
delegationStreamBroadcaster.broadcast(sessionId, {
|
||||
type: 'text-delta',
|
||||
delta: `Wise ra-h failed: ${message}`,
|
||||
});
|
||||
|
||||
AgentDelegationService.completeDelegation(sessionId, `Wise ra-h failed: ${message}`, 'failed');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import OpenAI from 'openai';
|
||||
import type { AgentDefinition } from './agents/types';
|
||||
import { Node } from '@/types/database';
|
||||
import { getToolSchemas, executeTool } from '../tools/infrastructure/registry';
|
||||
import { getSQLiteClient } from './database/sqlite-client';
|
||||
import { apiKeyService } from './storage/apiKeys';
|
||||
|
||||
// Initialize OpenAI client with dynamic API key support
|
||||
function getOpenAiClient(): OpenAI {
|
||||
const apiKey = apiKeyService.getOpenAiKey();
|
||||
if (!apiKey) {
|
||||
throw new Error('OpenAI API key required. Please:\n1. Click the Settings icon (⚙️) in the bottom left\n2. Go to API Keys tab\n3. Add your OpenAI API key\n\nGet your key at: https://platform.openai.com/api-keys');
|
||||
}
|
||||
return new OpenAI({ apiKey });
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
role: 'system' | 'user' | 'assistant';
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface ToolCall {
|
||||
id: string;
|
||||
name: string;
|
||||
params: any;
|
||||
result: any;
|
||||
}
|
||||
|
||||
export interface ChatResponse {
|
||||
response: string;
|
||||
toolCalls?: ToolCall[];
|
||||
}
|
||||
|
||||
export class AIService {
|
||||
/**
|
||||
* Chat with a helper using GPT-4o-mini with function calling
|
||||
*/
|
||||
static async chatWithHelper(
|
||||
helper: AgentDefinition,
|
||||
message: string,
|
||||
selectedNodeIds: number[] = [],
|
||||
messageHistory: ChatMessage[] = []
|
||||
): Promise<ChatResponse> {
|
||||
try {
|
||||
// Get selected nodes details
|
||||
const selectedNodes = await this.getSelectedNodes(selectedNodeIds);
|
||||
|
||||
// Build context for tools
|
||||
const toolContext = {
|
||||
selectedNodes,
|
||||
database: getSQLiteClient()
|
||||
};
|
||||
|
||||
// Build messages array
|
||||
const messages: OpenAI.Chat.ChatCompletionMessageParam[] = [
|
||||
{
|
||||
role: 'system',
|
||||
content: this.buildSystemPrompt(helper, selectedNodes)
|
||||
},
|
||||
// Add message history
|
||||
...messageHistory.map(msg => ({
|
||||
role: msg.role as 'user' | 'assistant',
|
||||
content: msg.content
|
||||
})),
|
||||
{
|
||||
role: 'user',
|
||||
content: message
|
||||
}
|
||||
];
|
||||
|
||||
// Get tool schemas for this helper
|
||||
const toolSchemas = getToolSchemas(helper.availableTools);
|
||||
|
||||
// Make OpenAI API call
|
||||
const openai = getOpenAiClient();
|
||||
const completion = await openai.chat.completions.create({
|
||||
model: helper.model,
|
||||
messages,
|
||||
tools: toolSchemas.length > 0 ? toolSchemas : undefined,
|
||||
tool_choice: toolSchemas.length > 0 ? 'auto' : undefined,
|
||||
temperature: 0.7,
|
||||
max_tokens: 2000,
|
||||
});
|
||||
|
||||
const assistantMessage = completion.choices[0]?.message;
|
||||
|
||||
if (!assistantMessage) {
|
||||
throw new Error('No response from OpenAI');
|
||||
}
|
||||
|
||||
let response = assistantMessage.content || '';
|
||||
const toolCalls: ToolCall[] = [];
|
||||
|
||||
// Handle tool calls if present
|
||||
if (assistantMessage.tool_calls && assistantMessage.tool_calls.length > 0) {
|
||||
console.log(`Agent ${helper.key} is calling ${assistantMessage.tool_calls.length} tools`);
|
||||
|
||||
for (const toolCall of assistantMessage.tool_calls) {
|
||||
try {
|
||||
const toolName = toolCall.function.name;
|
||||
const toolParams = JSON.parse(toolCall.function.arguments);
|
||||
|
||||
console.log(`Executing tool: ${toolName}`, toolParams);
|
||||
|
||||
// Execute the tool
|
||||
const result = await executeTool(toolName, toolParams, toolContext);
|
||||
|
||||
toolCalls.push({
|
||||
id: toolCall.id,
|
||||
name: toolName,
|
||||
params: toolParams,
|
||||
result
|
||||
});
|
||||
|
||||
console.log(`Tool ${toolName} completed:`, result.success ? 'success' : 'failed');
|
||||
} catch (error) {
|
||||
console.error(`Error executing tool ${toolCall.function.name}:`, error);
|
||||
toolCalls.push({
|
||||
id: toolCall.id,
|
||||
name: toolCall.function.name,
|
||||
params: {},
|
||||
result: {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Tool execution failed'
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// If we have tool results, make another call to get the final response
|
||||
if (toolCalls.length > 0) {
|
||||
const toolMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
|
||||
...messages,
|
||||
{
|
||||
role: 'assistant',
|
||||
content: assistantMessage.content,
|
||||
tool_calls: assistantMessage.tool_calls
|
||||
},
|
||||
...toolCalls.map(toolCall => ({
|
||||
role: 'tool' as const,
|
||||
tool_call_id: toolCall.id,
|
||||
content: JSON.stringify(toolCall.result)
|
||||
}))
|
||||
];
|
||||
|
||||
const finalCompletion = await getOpenAiClient().chat.completions.create({
|
||||
model: 'gpt-5-mini',
|
||||
messages: toolMessages,
|
||||
temperature: 0.7,
|
||||
max_tokens: 2000,
|
||||
});
|
||||
|
||||
response = finalCompletion.choices[0]?.message?.content || response;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
response,
|
||||
toolCalls: toolCalls.length > 0 ? toolCalls : undefined
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error in chatWithHelper:', error);
|
||||
throw new Error(
|
||||
error instanceof Error ? error.message : 'Failed to process chat message'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get selected nodes with their details
|
||||
*/
|
||||
private static async getSelectedNodes(nodeIds: number[]): Promise<Node[]> {
|
||||
if (nodeIds.length === 0) return [];
|
||||
|
||||
try {
|
||||
const sqlite = getSQLiteClient();
|
||||
const placeholders = nodeIds.map(() => '?').join(', ');
|
||||
const result = sqlite.query<any>(
|
||||
`SELECT n.id, n.title, n.content, n.link, n.metadata, n.chunk,
|
||||
n.chunk_status, n.embedding_updated_at, n.embedding_text,
|
||||
n.created_at, n.updated_at,
|
||||
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
|
||||
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json
|
||||
FROM nodes n
|
||||
WHERE n.id IN (${placeholders})
|
||||
ORDER BY n.created_at DESC`,
|
||||
nodeIds
|
||||
);
|
||||
return result.rows.map((row: any) => ({
|
||||
...row,
|
||||
dimensions: JSON.parse(row.dimensions_json || '[]')
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Error fetching selected nodes:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build system prompt with context
|
||||
*/
|
||||
private static buildSystemPrompt(helper: AgentDefinition, selectedNodes: Node[]): string {
|
||||
let systemPrompt = helper.systemPrompt;
|
||||
|
||||
// Universal rule: ensure clickable node labels in UI
|
||||
systemPrompt += '\n\nNode references: Always format nodes as [NODE:id:"title"] so the UI renders clickable labels.';
|
||||
|
||||
// Add context about selected nodes
|
||||
if (selectedNodes.length > 0) {
|
||||
systemPrompt += '\n\n## Selected Nodes Context\n';
|
||||
systemPrompt += `You have ${selectedNodes.length} node(s) selected:\n\n`;
|
||||
|
||||
selectedNodes.forEach((node, index) => {
|
||||
systemPrompt += `${index + 1}. **${node.title || 'Untitled'}**\n`;
|
||||
if (node.dimensions?.length > 0) {
|
||||
systemPrompt += ` - Dimensions: ${node.dimensions.join(', ')}\n`;
|
||||
}
|
||||
if (node.link) {
|
||||
systemPrompt += ` - URL: ${node.link}\n`;
|
||||
}
|
||||
systemPrompt += '\n';
|
||||
});
|
||||
} else {
|
||||
systemPrompt += '\n\n## Context\nNo nodes are currently selected. You can still help with general queries and web searches.';
|
||||
}
|
||||
|
||||
// Add available tools information
|
||||
if (helper.availableTools.length > 0) {
|
||||
systemPrompt += '\n\n## Available Tools\n';
|
||||
systemPrompt += `You have access to the following tools: ${helper.availableTools.join(', ')}\n`;
|
||||
systemPrompt += 'Use these tools when they would be helpful to answer the user\'s question.';
|
||||
}
|
||||
|
||||
// Subtle, hard-coded intent capture across all helpers (non-intrusive)
|
||||
systemPrompt += '\n\n## Subtle Intent Capture\n';
|
||||
systemPrompt += [
|
||||
'When it would clearly help you serve better, briefly ask for the user\'s intent (their "why").',
|
||||
'Keep it lightweight and optional (one short sentence, once per topic).',
|
||||
'Examples: "What are you hoping to do with this?", "What\'s the goal behind adding this?"',
|
||||
'If the user answers, incorporate the rationale into your next steps and outputs.'
|
||||
].join(' ');
|
||||
|
||||
return systemPrompt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
import { getSQLiteClient } from '@/services/database/sqlite-client';
|
||||
import { CostReport, TraceCostSummary, CacheEffectiveness } from '@/types/analytics';
|
||||
|
||||
export class CostAnalytics {
|
||||
static getCostsByDateRange(startDate: string, endDate: string): CostReport {
|
||||
const db = getSQLiteClient();
|
||||
|
||||
const chats = db.prepare(`
|
||||
SELECT
|
||||
id,
|
||||
helper_name,
|
||||
metadata,
|
||||
created_at
|
||||
FROM chats
|
||||
WHERE created_at >= ? AND created_at <= ?
|
||||
`).all(startDate, endDate) as Array<{
|
||||
id: number;
|
||||
helper_name: string;
|
||||
metadata: string;
|
||||
created_at: string;
|
||||
}>;
|
||||
|
||||
let totalCostUsd = 0;
|
||||
let totalTokens = 0;
|
||||
let inputTokens = 0;
|
||||
let outputTokens = 0;
|
||||
let cacheReadTokens = 0;
|
||||
let cacheWriteTokens = 0;
|
||||
let cacheHitCount = 0;
|
||||
let cacheSavingsUsd = 0;
|
||||
|
||||
const costByAgent: Record<string, { costUsd: number; chats: number; tokens: number }> = {};
|
||||
const costByModel: Record<string, { costUsd: number; chats: number; tokens: number }> = {};
|
||||
|
||||
for (const chat of chats) {
|
||||
let metadata: any = {};
|
||||
try {
|
||||
metadata = JSON.parse(chat.metadata || '{}');
|
||||
} catch (e) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const costUsd = metadata.estimated_cost_usd || 0;
|
||||
const tokens = metadata.total_tokens || 0;
|
||||
const inputToks = metadata.input_tokens || 0;
|
||||
const outputToks = metadata.output_tokens || 0;
|
||||
const cacheRead = metadata.cache_read_tokens || 0;
|
||||
const cacheWrite = metadata.cache_write_tokens || 0;
|
||||
const cacheHit = metadata.cache_hit || false;
|
||||
const model = metadata.model_used || 'unknown';
|
||||
|
||||
totalCostUsd += costUsd;
|
||||
totalTokens += tokens;
|
||||
inputTokens += inputToks;
|
||||
outputTokens += outputToks;
|
||||
cacheReadTokens += cacheRead;
|
||||
cacheWriteTokens += cacheWrite;
|
||||
if (cacheHit) cacheHitCount++;
|
||||
|
||||
if (metadata.cache_savings_pct && cacheHit) {
|
||||
const fullCost = costUsd / (1 - (metadata.cache_savings_pct / 100));
|
||||
cacheSavingsUsd += (fullCost - costUsd);
|
||||
}
|
||||
|
||||
if (!costByAgent[chat.helper_name]) {
|
||||
costByAgent[chat.helper_name] = { costUsd: 0, chats: 0, tokens: 0 };
|
||||
}
|
||||
costByAgent[chat.helper_name].costUsd += costUsd;
|
||||
costByAgent[chat.helper_name].chats += 1;
|
||||
costByAgent[chat.helper_name].tokens += tokens;
|
||||
|
||||
if (!costByModel[model]) {
|
||||
costByModel[model] = { costUsd: 0, chats: 0, tokens: 0 };
|
||||
}
|
||||
costByModel[model].costUsd += costUsd;
|
||||
costByModel[model].chats += 1;
|
||||
costByModel[model].tokens += tokens;
|
||||
}
|
||||
|
||||
const cacheRequests = chats.filter(c => {
|
||||
try {
|
||||
const meta = JSON.parse(c.metadata || '{}');
|
||||
return meta.provider === 'anthropic';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}).length;
|
||||
|
||||
return {
|
||||
periodStart: startDate,
|
||||
periodEnd: endDate,
|
||||
totalCostUsd: parseFloat(totalCostUsd.toFixed(6)),
|
||||
totalChats: chats.length,
|
||||
totalTokens,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheReadTokens,
|
||||
cacheWriteTokens,
|
||||
cacheHitRate: cacheRequests > 0 ? cacheHitCount / cacheRequests : 0,
|
||||
cacheSavingsUsd: parseFloat(cacheSavingsUsd.toFixed(6)),
|
||||
avgCostPerChat: chats.length > 0 ? parseFloat((totalCostUsd / chats.length).toFixed(6)) : 0,
|
||||
avgTokensPerChat: chats.length > 0 ? Math.round(totalTokens / chats.length) : 0,
|
||||
costByAgent,
|
||||
costByModel,
|
||||
};
|
||||
}
|
||||
|
||||
static getCostsByAgent(agentName: string, startDate?: string, endDate?: string): CostReport {
|
||||
const db = getSQLiteClient();
|
||||
|
||||
let query = `
|
||||
SELECT
|
||||
id,
|
||||
helper_name,
|
||||
metadata,
|
||||
created_at
|
||||
FROM chats
|
||||
WHERE helper_name = ?
|
||||
`;
|
||||
const params: any[] = [agentName];
|
||||
|
||||
if (startDate && endDate) {
|
||||
query += ` AND created_at >= ? AND created_at <= ?`;
|
||||
params.push(startDate, endDate);
|
||||
}
|
||||
|
||||
const chats = db.prepare(query).all(...params) as Array<{
|
||||
id: number;
|
||||
helper_name: string;
|
||||
metadata: string;
|
||||
created_at: string;
|
||||
}>;
|
||||
|
||||
if (chats.length === 0) {
|
||||
return {
|
||||
periodStart: startDate || '',
|
||||
periodEnd: endDate || '',
|
||||
totalCostUsd: 0,
|
||||
totalChats: 0,
|
||||
totalTokens: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
cacheHitRate: 0,
|
||||
cacheSavingsUsd: 0,
|
||||
avgCostPerChat: 0,
|
||||
avgTokensPerChat: 0,
|
||||
costByAgent: {},
|
||||
costByModel: {},
|
||||
};
|
||||
}
|
||||
|
||||
const start = startDate || chats[chats.length - 1].created_at;
|
||||
const end = endDate || chats[0].created_at;
|
||||
|
||||
return this.getCostsByDateRange(start, end);
|
||||
}
|
||||
|
||||
static getCostsByTrace(traceId: string): TraceCostSummary {
|
||||
const db = getSQLiteClient();
|
||||
|
||||
const chats = db.prepare(`
|
||||
SELECT
|
||||
id,
|
||||
helper_name,
|
||||
agent_type,
|
||||
metadata,
|
||||
created_at
|
||||
FROM chats
|
||||
WHERE json_extract(metadata, '$.trace_id') = ?
|
||||
ORDER BY created_at ASC
|
||||
`).all(traceId) as Array<{
|
||||
id: number;
|
||||
helper_name: string;
|
||||
agent_type: string;
|
||||
metadata: string;
|
||||
created_at: string;
|
||||
}>;
|
||||
|
||||
let totalCostUsd = 0;
|
||||
let orchestratorCost = 0;
|
||||
let executorCost = 0;
|
||||
let plannerCost = 0;
|
||||
let totalTokens = 0;
|
||||
|
||||
const interactions: TraceCostSummary['interactions'] = [];
|
||||
|
||||
for (const chat of chats) {
|
||||
let metadata: any = {};
|
||||
try {
|
||||
metadata = JSON.parse(chat.metadata || '{}');
|
||||
} catch (e) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const costUsd = metadata.estimated_cost_usd || 0;
|
||||
const tokens = metadata.total_tokens || 0;
|
||||
|
||||
totalCostUsd += costUsd;
|
||||
totalTokens += tokens;
|
||||
|
||||
if (chat.agent_type === 'orchestrator') {
|
||||
orchestratorCost += costUsd;
|
||||
} else if (chat.agent_type === 'executor') {
|
||||
executorCost += costUsd;
|
||||
} else if (chat.agent_type === 'planner') {
|
||||
plannerCost += costUsd;
|
||||
}
|
||||
|
||||
interactions.push({
|
||||
chatId: chat.id,
|
||||
agentName: chat.helper_name,
|
||||
costUsd: parseFloat(costUsd.toFixed(6)),
|
||||
tokens,
|
||||
createdAt: chat.created_at,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
traceId,
|
||||
totalCostUsd: parseFloat(totalCostUsd.toFixed(6)),
|
||||
chatCount: chats.length,
|
||||
orchestratorCost: parseFloat(orchestratorCost.toFixed(6)),
|
||||
executorCost: parseFloat(executorCost.toFixed(6)),
|
||||
plannerCost: parseFloat(plannerCost.toFixed(6)),
|
||||
totalTokens,
|
||||
interactions,
|
||||
};
|
||||
}
|
||||
|
||||
static getCacheEffectiveness(startDate?: string, endDate?: string): CacheEffectiveness {
|
||||
const db = getSQLiteClient();
|
||||
|
||||
let query = `
|
||||
SELECT
|
||||
metadata
|
||||
FROM chats
|
||||
WHERE json_extract(metadata, '$.provider') = 'anthropic'
|
||||
`;
|
||||
const params: any[] = [];
|
||||
|
||||
if (startDate && endDate) {
|
||||
query += ` AND created_at >= ? AND created_at <= ?`;
|
||||
params.push(startDate, endDate);
|
||||
}
|
||||
|
||||
const chats = db.prepare(query).all(...params) as Array<{ metadata: string }>;
|
||||
|
||||
let totalRequests = 0;
|
||||
let cacheHits = 0;
|
||||
let cacheMisses = 0;
|
||||
let totalCacheSavingsUsd = 0;
|
||||
let totalTokensSaved = 0;
|
||||
|
||||
for (const chat of chats) {
|
||||
let metadata: any = {};
|
||||
try {
|
||||
metadata = JSON.parse(chat.metadata || '{}');
|
||||
} catch (e) {
|
||||
continue;
|
||||
}
|
||||
|
||||
totalRequests++;
|
||||
|
||||
if (metadata.cache_hit) {
|
||||
cacheHits++;
|
||||
const cacheReadTokens = metadata.cache_read_tokens || 0;
|
||||
totalTokensSaved += cacheReadTokens;
|
||||
|
||||
if (metadata.cache_savings_pct && metadata.estimated_cost_usd) {
|
||||
const fullCost = metadata.estimated_cost_usd / (1 - (metadata.cache_savings_pct / 100));
|
||||
totalCacheSavingsUsd += (fullCost - metadata.estimated_cost_usd);
|
||||
}
|
||||
} else {
|
||||
cacheMisses++;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
totalRequests,
|
||||
cacheHits,
|
||||
cacheMisses,
|
||||
hitRate: totalRequests > 0 ? parseFloat((cacheHits / totalRequests).toFixed(4)) : 0,
|
||||
totalCacheSavingsUsd: parseFloat(totalCacheSavingsUsd.toFixed(6)),
|
||||
avgSavingsPerHit: cacheHits > 0 ? parseFloat((totalCacheSavingsUsd / cacheHits).toFixed(6)) : 0,
|
||||
totalTokensSaved,
|
||||
};
|
||||
}
|
||||
|
||||
static getDailyBreakdown(days: number = 7): Array<{ date: string; cost: number; chats: number; tokens: number }> {
|
||||
const db = getSQLiteClient();
|
||||
|
||||
const result = db.prepare(`
|
||||
SELECT
|
||||
DATE(created_at) as date,
|
||||
SUM(COALESCE(json_extract(metadata, '$.estimated_cost_usd'), 0)) as cost,
|
||||
COUNT(*) as chats,
|
||||
SUM(COALESCE(json_extract(metadata, '$.total_tokens'), 0)) as tokens
|
||||
FROM chats
|
||||
WHERE created_at >= DATE('now', '-' || ? || ' days')
|
||||
GROUP BY DATE(created_at)
|
||||
ORDER BY date DESC
|
||||
`).all(days) as Array<{ date: string; cost: number; chats: number; tokens: number }>;
|
||||
|
||||
return result.map(row => ({
|
||||
date: row.date,
|
||||
cost: parseFloat(Number(row.cost).toFixed(6)),
|
||||
chats: Number(row.chats),
|
||||
tokens: Number(row.tokens),
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { ModelPricing } from '@/types/analytics';
|
||||
|
||||
export const MODEL_PRICING: Record<string, ModelPricing> = {
|
||||
'claude-sonnet-4-5-20250929': {
|
||||
provider: 'anthropic',
|
||||
inputPer1M: 3.00,
|
||||
outputPer1M: 15.00,
|
||||
cacheWritePer1M: 3.75,
|
||||
cacheReadPer1M: 0.30,
|
||||
},
|
||||
'claude-3-5-sonnet-20241022': {
|
||||
provider: 'anthropic',
|
||||
inputPer1M: 3.00,
|
||||
outputPer1M: 15.00,
|
||||
cacheWritePer1M: 3.75,
|
||||
cacheReadPer1M: 0.30,
|
||||
},
|
||||
'gpt-4o-mini': {
|
||||
provider: 'openai',
|
||||
inputPer1M: 0.15,
|
||||
outputPer1M: 0.60,
|
||||
},
|
||||
'gpt-4o-mini-2024-07-18': {
|
||||
provider: 'openai',
|
||||
inputPer1M: 0.15,
|
||||
outputPer1M: 0.60,
|
||||
},
|
||||
'gpt-5o-mini': {
|
||||
provider: 'openai',
|
||||
inputPer1M: 0.25,
|
||||
outputPer1M: 2.00,
|
||||
},
|
||||
'gpt-5-mini': {
|
||||
provider: 'openai',
|
||||
inputPer1M: 0.25,
|
||||
outputPer1M: 2.00,
|
||||
},
|
||||
'gpt-5': {
|
||||
provider: 'openai',
|
||||
inputPer1M: 1.25,
|
||||
outputPer1M: 10.00,
|
||||
},
|
||||
};
|
||||
|
||||
export interface CostCalculationInput {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheWriteTokens?: number;
|
||||
cacheReadTokens?: number;
|
||||
modelId: string;
|
||||
}
|
||||
|
||||
export interface CostCalculationResult {
|
||||
totalCostUsd: number;
|
||||
inputCostUsd: number;
|
||||
outputCostUsd: number;
|
||||
cacheWriteCostUsd: number;
|
||||
cacheReadCostUsd: number;
|
||||
cacheSavingsUsd: number;
|
||||
totalTokens: number;
|
||||
}
|
||||
|
||||
export function calculateCost(input: CostCalculationInput): CostCalculationResult {
|
||||
const pricing = MODEL_PRICING[input.modelId];
|
||||
|
||||
if (!pricing) {
|
||||
console.warn(`[Pricing] Unknown model: ${input.modelId}, using default pricing`);
|
||||
return {
|
||||
totalCostUsd: 0,
|
||||
inputCostUsd: 0,
|
||||
outputCostUsd: 0,
|
||||
cacheWriteCostUsd: 0,
|
||||
cacheReadCostUsd: 0,
|
||||
cacheSavingsUsd: 0,
|
||||
totalTokens: input.inputTokens + input.outputTokens + (input.cacheWriteTokens || 0) + (input.cacheReadTokens || 0),
|
||||
};
|
||||
}
|
||||
|
||||
const inputCostUsd = (input.inputTokens / 1_000_000) * pricing.inputPer1M;
|
||||
const outputCostUsd = (input.outputTokens / 1_000_000) * pricing.outputPer1M;
|
||||
|
||||
let cacheWriteCostUsd = 0;
|
||||
let cacheReadCostUsd = 0;
|
||||
let cacheSavingsUsd = 0;
|
||||
|
||||
if (pricing.cacheWritePer1M && input.cacheWriteTokens) {
|
||||
cacheWriteCostUsd = (input.cacheWriteTokens / 1_000_000) * pricing.cacheWritePer1M;
|
||||
}
|
||||
|
||||
if (pricing.cacheReadPer1M && input.cacheReadTokens) {
|
||||
cacheReadCostUsd = (input.cacheReadTokens / 1_000_000) * pricing.cacheReadPer1M;
|
||||
const regularCostForCachedTokens = (input.cacheReadTokens / 1_000_000) * pricing.inputPer1M;
|
||||
cacheSavingsUsd = regularCostForCachedTokens - cacheReadCostUsd;
|
||||
}
|
||||
|
||||
const totalCostUsd = inputCostUsd + outputCostUsd + cacheWriteCostUsd + cacheReadCostUsd;
|
||||
const totalTokens = input.inputTokens + input.outputTokens + (input.cacheWriteTokens || 0) + (input.cacheReadTokens || 0);
|
||||
|
||||
return {
|
||||
totalCostUsd: parseFloat(totalCostUsd.toFixed(6)),
|
||||
inputCostUsd: parseFloat(inputCostUsd.toFixed(6)),
|
||||
outputCostUsd: parseFloat(outputCostUsd.toFixed(6)),
|
||||
cacheWriteCostUsd: parseFloat(cacheWriteCostUsd.toFixed(6)),
|
||||
cacheReadCostUsd: parseFloat(cacheReadCostUsd.toFixed(6)),
|
||||
cacheSavingsUsd: parseFloat(cacheSavingsUsd.toFixed(6)),
|
||||
totalTokens,
|
||||
};
|
||||
}
|
||||
|
||||
export function getModelPricing(modelId: string): ModelPricing | null {
|
||||
return MODEL_PRICING[modelId] || null;
|
||||
}
|
||||
|
||||
export function getSupportedModels(): string[] {
|
||||
return Object.keys(MODEL_PRICING);
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import { getSQLiteClient } from '@/services/database/sqlite-client';
|
||||
import { UsageData } from '@/types/analytics';
|
||||
import { RequestContext } from '@/services/context/requestContext';
|
||||
|
||||
interface ChatLogEntry {
|
||||
chat_type: string;
|
||||
user_message: string;
|
||||
assistant_message: string;
|
||||
thread_id: string;
|
||||
focused_node_id: number | null;
|
||||
helper_name: string;
|
||||
agent_type: 'orchestrator' | 'executor' | 'planner';
|
||||
delegation_id: number | null;
|
||||
metadata: any;
|
||||
}
|
||||
|
||||
interface StreamMetadata {
|
||||
helperName: string;
|
||||
openTabs?: any[];
|
||||
activeTabId?: number | null;
|
||||
currentView?: 'nodes' | 'memory';
|
||||
sessionId?: string;
|
||||
agentType?: 'orchestrator' | 'executor' | 'planner';
|
||||
delegationId?: number | null;
|
||||
usageData?: UsageData;
|
||||
traceId?: string;
|
||||
parentChatId?: number;
|
||||
systemMessage?: string;
|
||||
workflowKey?: string;
|
||||
workflowNodeId?: number;
|
||||
mode?: 'easy' | 'hard';
|
||||
toolCallsData?: any[];
|
||||
backendUsage?: Array<{
|
||||
provider: string;
|
||||
headers: Record<string, string>;
|
||||
}>;
|
||||
}
|
||||
|
||||
export class ChatLoggingMiddleware {
|
||||
private static generateThreadId(helperName: string, metadata: StreamMetadata): string {
|
||||
const { activeTabId = null, currentView, sessionId } = metadata;
|
||||
const timestamp = Date.now();
|
||||
const session = sessionId || `session_${timestamp}`;
|
||||
|
||||
if (activeTabId) {
|
||||
return `${helperName}-node-${activeTabId}-${session}`;
|
||||
}
|
||||
return `${helperName}-general-${session}`;
|
||||
}
|
||||
|
||||
private static extractUserMessage(messages: any[]): string | null {
|
||||
const userMessages = messages.filter(m => m.role === 'user');
|
||||
const lastUserMessage = userMessages[userMessages.length - 1];
|
||||
|
||||
if (!lastUserMessage) return null;
|
||||
|
||||
// Handle different message formats (AI SDK v5)
|
||||
if (typeof lastUserMessage.content === 'string') {
|
||||
return lastUserMessage.content;
|
||||
}
|
||||
|
||||
// Handle parts-based messages (from frontend)
|
||||
if (Array.isArray(lastUserMessage.parts)) {
|
||||
const textParts = lastUserMessage.parts
|
||||
.filter((part: any) => part.type === 'text')
|
||||
.map((part: any) => part.text);
|
||||
return textParts.join(' ');
|
||||
}
|
||||
|
||||
// Handle content as object or other formats
|
||||
if (lastUserMessage.content && typeof lastUserMessage.content === 'object') {
|
||||
return JSON.stringify(lastUserMessage.content);
|
||||
}
|
||||
|
||||
return lastUserMessage.content || null;
|
||||
}
|
||||
|
||||
static async logChatInteraction(
|
||||
userMessage: string,
|
||||
assistantMessage: string,
|
||||
metadata: StreamMetadata,
|
||||
messages: any[] = []
|
||||
): Promise<void> {
|
||||
try {
|
||||
const threadId = this.generateThreadId(metadata.helperName, metadata);
|
||||
|
||||
const createdAt = new Date().toISOString();
|
||||
|
||||
const chatEntry: ChatLogEntry = {
|
||||
chat_type: 'helper',
|
||||
user_message: userMessage,
|
||||
assistant_message: assistantMessage,
|
||||
thread_id: threadId,
|
||||
focused_node_id: metadata.activeTabId ?? null,
|
||||
helper_name: metadata.helperName,
|
||||
agent_type: metadata.agentType || 'orchestrator',
|
||||
delegation_id: metadata.delegationId ?? null,
|
||||
metadata: {
|
||||
timestamp: new Date().toISOString(),
|
||||
session_id: metadata.sessionId,
|
||||
current_view: metadata.currentView || 'nodes',
|
||||
open_tab_count: metadata.openTabs?.length || 0,
|
||||
has_focused_node: !!metadata.activeTabId,
|
||||
message_count: messages.length,
|
||||
...(metadata.mode && { mode: metadata.mode }),
|
||||
// System message
|
||||
...(metadata.systemMessage && { system_message: metadata.systemMessage }),
|
||||
// Enhanced usage data
|
||||
...(metadata.usageData && {
|
||||
input_tokens: metadata.usageData.inputTokens,
|
||||
output_tokens: metadata.usageData.outputTokens,
|
||||
total_tokens: metadata.usageData.totalTokens,
|
||||
cache_write_tokens: metadata.usageData.cacheWriteTokens,
|
||||
cache_read_tokens: metadata.usageData.cacheReadTokens,
|
||||
cache_hit: metadata.usageData.cacheHit,
|
||||
cache_savings_pct: metadata.usageData.cacheSavingsPct,
|
||||
estimated_cost_usd: metadata.usageData.estimatedCostUsd,
|
||||
model_used: metadata.usageData.modelUsed,
|
||||
provider: metadata.usageData.provider,
|
||||
tools_used: metadata.usageData.toolsUsed,
|
||||
tool_calls_count: metadata.usageData.toolCallsCount,
|
||||
capsule_version: metadata.usageData.capsuleVersion,
|
||||
context_sources_used: metadata.usageData.contextSourcesUsed,
|
||||
validation_status: metadata.usageData.validationStatus,
|
||||
validation_message: metadata.usageData.validationMessage,
|
||||
fallback_action: metadata.usageData.fallbackAction,
|
||||
}),
|
||||
// Tool calls data
|
||||
...(metadata.toolCallsData && metadata.toolCallsData.length > 0 && {
|
||||
tool_calls: metadata.toolCallsData
|
||||
}),
|
||||
// Trace grouping
|
||||
...(metadata.traceId && { trace_id: metadata.traceId }),
|
||||
...(metadata.parentChatId && { parent_chat_id: metadata.parentChatId }),
|
||||
// Workflow metadata
|
||||
...(metadata.workflowKey && {
|
||||
workflow_key: metadata.workflowKey,
|
||||
workflow_node_id: metadata.workflowNodeId,
|
||||
is_workflow: true,
|
||||
}),
|
||||
// Backend usage (for Supabase sync correlation)
|
||||
...(metadata.backendUsage && metadata.backendUsage.length > 0 && {
|
||||
backend_usage: metadata.backendUsage,
|
||||
}),
|
||||
}
|
||||
};
|
||||
|
||||
const sqlite = getSQLiteClient();
|
||||
const result = sqlite.prepare(`
|
||||
INSERT INTO chats (chat_type, user_message, assistant_message, thread_id, focused_node_id, helper_name, agent_type, delegation_id, created_at, metadata)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
chatEntry.chat_type,
|
||||
chatEntry.user_message,
|
||||
chatEntry.assistant_message,
|
||||
chatEntry.thread_id,
|
||||
chatEntry.focused_node_id,
|
||||
chatEntry.helper_name,
|
||||
chatEntry.agent_type,
|
||||
chatEntry.delegation_id,
|
||||
createdAt,
|
||||
JSON.stringify(chatEntry.metadata)
|
||||
);
|
||||
console.log(`✅ Chat logged for ${metadata.helperName}, ID: ${result.lastInsertRowid}`);
|
||||
|
||||
const lastInsertedChatId = Number(result.lastInsertRowid);
|
||||
|
||||
if (metadata.agentType === 'orchestrator' && (metadata.helperName === 'ra-h' || metadata.helperName === 'ra-h-easy')) {
|
||||
RequestContext.set({
|
||||
traceId: metadata.traceId,
|
||||
parentChatId: lastInsertedChatId
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Chat logging error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
static createLoggingHandlers(metadata: StreamMetadata, messages: any[]) {
|
||||
let assistantResponse = '';
|
||||
const userMessage = this.extractUserMessage(messages);
|
||||
|
||||
return {
|
||||
onFinish: async (result: any) => {
|
||||
const { text, toolCalls, steps } = result;
|
||||
// Log if we have a user message and either text OR tool activity
|
||||
const hasActivity = text || toolCalls?.length > 0 || steps?.length > 0;
|
||||
|
||||
if (userMessage && hasActivity) {
|
||||
// Capture tool calls if present
|
||||
const toolCallsData = toolCalls && toolCalls.length > 0 ? toolCalls.map((tc: any) => ({
|
||||
toolName: tc.toolName,
|
||||
args: tc.args,
|
||||
result: typeof tc.result === 'object' ? tc.result : { value: tc.result }
|
||||
})) : undefined;
|
||||
|
||||
if (toolCallsData) {
|
||||
console.log(`🔧 Captured ${toolCallsData.length} tool calls for logging`);
|
||||
}
|
||||
|
||||
const enhancedMetadata = {
|
||||
...metadata,
|
||||
toolCallsData
|
||||
};
|
||||
|
||||
await this.logChatInteraction(
|
||||
userMessage,
|
||||
text || '[Tool calls only - no text response]',
|
||||
enhancedMetadata,
|
||||
messages
|
||||
);
|
||||
} else if (userMessage && !hasActivity) {
|
||||
console.warn(`⚠️ Skipping chat log - no text or tool activity for user message: ${userMessage.substring(0, 50)}...`);
|
||||
}
|
||||
},
|
||||
onChunk: ({ chunk }: { chunk: any }) => {
|
||||
if (chunk.type === 'text-delta' && chunk.textDelta) {
|
||||
assistantResponse += chunk.textDelta;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function withChatLogging(
|
||||
streamConfig: any,
|
||||
metadata: StreamMetadata,
|
||||
messages: any[]
|
||||
) {
|
||||
const handlers = ChatLoggingMiddleware.createLoggingHandlers(metadata, messages);
|
||||
const originalOnFinish = streamConfig.onFinish;
|
||||
|
||||
return {
|
||||
...streamConfig,
|
||||
onFinish: async (result: any) => {
|
||||
// Call original onFinish first (for cache stats)
|
||||
if (originalOnFinish) {
|
||||
await originalOnFinish(result);
|
||||
}
|
||||
// Then call logging handler
|
||||
await handlers.onFinish(result);
|
||||
},
|
||||
onChunk: handlers.onChunk
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { getSQLiteClient } from '@/services/database/sqlite-client';
|
||||
import { getAutoContextSettings } from '@/services/settings/autoContextSettings';
|
||||
|
||||
interface AutoContextRow {
|
||||
id: number;
|
||||
title: string | null;
|
||||
updated_at: string;
|
||||
edge_count: number | null;
|
||||
}
|
||||
|
||||
export interface AutoContextSummary {
|
||||
id: number;
|
||||
title: string;
|
||||
edgeCount: number;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
function fetchAutoContextRows(limit: number): AutoContextSummary[] {
|
||||
const db = getSQLiteClient();
|
||||
const rows = db
|
||||
.query<AutoContextRow>(
|
||||
`
|
||||
SELECT n.id,
|
||||
n.title,
|
||||
n.updated_at,
|
||||
COUNT(DISTINCT e.id) AS edge_count
|
||||
FROM nodes n
|
||||
LEFT JOIN edges e
|
||||
ON (e.from_node_id = n.id OR e.to_node_id = n.id)
|
||||
WHERE n.type IS NULL OR n.type != 'memory'
|
||||
GROUP BY n.id
|
||||
ORDER BY edge_count DESC, n.updated_at DESC
|
||||
LIMIT ?
|
||||
`,
|
||||
[limit]
|
||||
)
|
||||
.rows;
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
title: row.title || 'Untitled node',
|
||||
updatedAt: row.updated_at,
|
||||
edgeCount: Number(row.edge_count ?? 0),
|
||||
}));
|
||||
}
|
||||
|
||||
export function getAutoContextSummaries(limit = 10): AutoContextSummary[] {
|
||||
const settings = getAutoContextSettings();
|
||||
if (!settings.autoContextEnabled) {
|
||||
return [];
|
||||
}
|
||||
return fetchAutoContextRows(limit);
|
||||
}
|
||||
|
||||
export function buildAutoContextBlock(limit = 10): string | null {
|
||||
const summaries = getAutoContextSummaries(limit);
|
||||
if (summaries.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lines: string[] = [
|
||||
'=== BACKGROUND CONTEXT ===',
|
||||
'Top 10 most-connected nodes (important knowledge hubs). Use queryNodes/getNodesById if relevant.',
|
||||
'',
|
||||
];
|
||||
|
||||
for (const summary of summaries) {
|
||||
lines.push(`[NODE:${summary.id}:"${summary.title}"] (edges: ${summary.edgeCount})`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { Node } from '@/types/database';
|
||||
|
||||
interface RequestContext {
|
||||
traceId?: string;
|
||||
parentChatId?: number;
|
||||
workflowKey?: string;
|
||||
workflowNodeId?: number;
|
||||
openTabs?: Node[];
|
||||
activeTabId?: number | null;
|
||||
mode?: 'easy' | 'hard';
|
||||
apiKeys?: {
|
||||
openai?: string;
|
||||
anthropic?: string;
|
||||
};
|
||||
}
|
||||
|
||||
let currentContext: RequestContext = {};
|
||||
|
||||
export const RequestContext = {
|
||||
set(context: Partial<RequestContext>) {
|
||||
Object.entries(context).forEach(([key, value]) => {
|
||||
if (value === undefined) {
|
||||
delete (currentContext as Record<string, unknown>)[key];
|
||||
} else {
|
||||
(currentContext as Record<string, unknown>)[key] = value;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
get(): RequestContext {
|
||||
return currentContext;
|
||||
},
|
||||
|
||||
clear() {
|
||||
currentContext = {};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,331 @@
|
||||
import { getSQLiteClient } from './sqlite-client';
|
||||
import { Chunk, ChunkData } from '@/types/database';
|
||||
|
||||
export class ChunkService {
|
||||
async getChunksByNodeId(nodeId: number): Promise<Chunk[]> {
|
||||
const sqlite = getSQLiteClient();
|
||||
const result = sqlite.query<Chunk>('SELECT * FROM chunks WHERE node_id = ? ORDER BY chunk_idx ASC', [nodeId]);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
async getChunkById(id: number): Promise<Chunk | null> {
|
||||
const sqlite = getSQLiteClient();
|
||||
const result = sqlite.query<Chunk>('SELECT * FROM chunks WHERE id = ?', [id]);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
async createChunk(chunkData: ChunkData): Promise<Chunk> {
|
||||
return this.createChunkSQLite(chunkData);
|
||||
}
|
||||
|
||||
// PostgreSQL path removed in SQLite-only consolidation
|
||||
|
||||
private async createChunkSQLite(chunkData: ChunkData): Promise<Chunk> {
|
||||
const now = new Date().toISOString();
|
||||
const sqlite = getSQLiteClient();
|
||||
|
||||
const result = sqlite.prepare(`
|
||||
INSERT INTO chunks (node_id, chunk_idx, text, embedding, embedding_type, metadata, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
chunkData.node_id,
|
||||
chunkData.chunk_idx || null,
|
||||
chunkData.text,
|
||||
chunkData.embedding || null,
|
||||
chunkData.embedding_type,
|
||||
chunkData.metadata ? JSON.stringify(chunkData.metadata) : null,
|
||||
now
|
||||
);
|
||||
|
||||
const chunkId = Number(result.lastInsertRowid);
|
||||
const createdChunk = await this.getChunkById(chunkId);
|
||||
|
||||
if (!createdChunk) {
|
||||
throw new Error('Failed to create chunk');
|
||||
}
|
||||
|
||||
return createdChunk;
|
||||
}
|
||||
|
||||
async createChunks(chunksData: ChunkData[]): Promise<Chunk[]> {
|
||||
if (chunksData.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.createChunksSQLite(chunksData);
|
||||
}
|
||||
|
||||
// PostgreSQL path removed in SQLite-only consolidation
|
||||
|
||||
private async createChunksSQLite(chunksData: ChunkData[]): Promise<Chunk[]> {
|
||||
const sqlite = getSQLiteClient();
|
||||
const now = new Date().toISOString();
|
||||
const createdChunks: Chunk[] = [];
|
||||
|
||||
// Use transaction for bulk insert
|
||||
sqlite.transaction(() => {
|
||||
const stmt = sqlite.prepare(`
|
||||
INSERT INTO chunks (node_id, chunk_idx, text, embedding, embedding_type, metadata, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
for (const chunk of chunksData) {
|
||||
stmt.run(
|
||||
chunk.node_id,
|
||||
chunk.chunk_idx || null,
|
||||
chunk.text,
|
||||
chunk.embedding || null,
|
||||
chunk.embedding_type,
|
||||
chunk.metadata ? JSON.stringify(chunk.metadata) : null,
|
||||
now
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Get all created chunks by node_id (since we know they were just created)
|
||||
const nodeIds = [...new Set(chunksData.map(c => c.node_id))];
|
||||
for (const nodeId of nodeIds) {
|
||||
const chunks = await this.getChunksByNodeId(nodeId);
|
||||
createdChunks.push(...chunks.filter(c => c.created_at === now));
|
||||
}
|
||||
|
||||
return createdChunks;
|
||||
}
|
||||
|
||||
async updateChunk(id: number, updates: Partial<Chunk>): Promise<Chunk> {
|
||||
return this.updateChunkSQLite(id, updates);
|
||||
}
|
||||
|
||||
// PostgreSQL path removed in SQLite-only consolidation
|
||||
|
||||
private async updateChunkSQLite(id: number, updates: Partial<Chunk>): Promise<Chunk> {
|
||||
const sqlite = getSQLiteClient();
|
||||
const updateFields: string[] = [];
|
||||
const params: any[] = [];
|
||||
|
||||
// Build dynamic update query
|
||||
Object.entries(updates).forEach(([key, value]) => {
|
||||
if (key !== 'id' && key !== 'created_at' && value !== undefined) {
|
||||
updateFields.push(`${key} = ?`);
|
||||
if (key === 'metadata') {
|
||||
params.push(typeof value === 'object' ? JSON.stringify(value) : value);
|
||||
} else {
|
||||
params.push(value);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (updateFields.length === 0) {
|
||||
throw new Error('No valid fields to update');
|
||||
}
|
||||
|
||||
params.push(id); // Add ID for WHERE clause
|
||||
|
||||
const query = `UPDATE chunks SET ${updateFields.join(', ')} WHERE id = ?`;
|
||||
const result = sqlite.query(query, params);
|
||||
|
||||
if (result.changes === 0) {
|
||||
throw new Error(`Chunk with ID ${id} not found`);
|
||||
}
|
||||
|
||||
const updatedChunk = await this.getChunkById(id);
|
||||
if (!updatedChunk) {
|
||||
throw new Error(`Failed to retrieve updated chunk with ID ${id}`);
|
||||
}
|
||||
|
||||
return updatedChunk;
|
||||
}
|
||||
|
||||
async deleteChunk(id: number): Promise<void> {
|
||||
const sqlite = getSQLiteClient();
|
||||
const result = sqlite.query('DELETE FROM chunks WHERE id = ?', [id]);
|
||||
if ((result.changes || 0) === 0) {
|
||||
throw new Error(`Chunk with ID ${id} not found`);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteChunksByNodeId(nodeId: number): Promise<void> {
|
||||
const sqlite = getSQLiteClient();
|
||||
sqlite.query('DELETE FROM chunks WHERE node_id = ?', [nodeId]);
|
||||
}
|
||||
|
||||
async searchChunks(
|
||||
queryEmbedding: number[],
|
||||
similarityThreshold = 0.5,
|
||||
matchCount = 5,
|
||||
nodeIds?: number[],
|
||||
fallbackQuery?: string
|
||||
): Promise<Array<Chunk & { similarity: number }>> {
|
||||
try {
|
||||
return await this.searchChunksSQLite(queryEmbedding, similarityThreshold, matchCount, nodeIds);
|
||||
} catch (error) {
|
||||
console.warn('Vector search failed, attempting text fallback:', error);
|
||||
if (fallbackQuery) {
|
||||
return await this.textSearchFallback(fallbackQuery, matchCount, nodeIds);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// PostgreSQL path removed in SQLite-only consolidation
|
||||
|
||||
private async searchChunksSQLite(
|
||||
queryEmbedding: number[],
|
||||
similarityThreshold = 0.5,
|
||||
matchCount = 5,
|
||||
nodeIds?: number[]
|
||||
): Promise<Array<Chunk & { similarity: number }>> {
|
||||
const sqlite = getSQLiteClient();
|
||||
|
||||
// Step 1: Determine vector search limit - more conservative approach
|
||||
let vectorLimit = 50; // Start smaller for efficiency
|
||||
|
||||
if (nodeIds && nodeIds.length > 0) {
|
||||
// When searching specific nodes, get exact count and use reasonable multiplier
|
||||
const candidateCountQuery = `SELECT COUNT(*) as count FROM chunks WHERE node_id IN (${nodeIds.map(() => '?').join(',')})`;
|
||||
const candidateResult = sqlite.query<{count: number}>(candidateCountQuery, nodeIds);
|
||||
const candidateCount = Number(candidateResult.rows[0].count);
|
||||
|
||||
// Use 2x the candidate count but cap at reasonable limits
|
||||
vectorLimit = Math.min(Math.max(candidateCount * 2, 50), 1000);
|
||||
console.log(`🔍 Node-scoped search: ${candidateCount} candidates, using vector limit ${vectorLimit}`);
|
||||
} else {
|
||||
// For global search, use adaptive limit based on expected results
|
||||
vectorLimit = Math.max(matchCount * 10, 50);
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
// Step 2: Use CTE-based query to avoid UNION ALL explosion
|
||||
const vectorString = `[${queryEmbedding.join(',')}]`;
|
||||
let query = `
|
||||
WITH vector_results AS (
|
||||
SELECT chunk_id, distance
|
||||
FROM vec_chunks
|
||||
WHERE embedding MATCH ?
|
||||
ORDER BY distance
|
||||
LIMIT ?
|
||||
)
|
||||
SELECT c.*, (1.0 / (1.0 + vr.distance)) AS similarity
|
||||
FROM vector_results vr
|
||||
JOIN chunks c ON c.id = vr.chunk_id
|
||||
`;
|
||||
|
||||
const params: any[] = [vectorString, vectorLimit];
|
||||
const conditions = [];
|
||||
|
||||
// Node ID filter if provided
|
||||
if (nodeIds && nodeIds.length > 0) {
|
||||
conditions.push(`c.node_id IN (${nodeIds.map(() => '?').join(',')})`);
|
||||
params.push(...nodeIds);
|
||||
}
|
||||
|
||||
// Similarity threshold filter
|
||||
conditions.push(`(1.0 / (1.0 + vr.distance)) >= ?`);
|
||||
params.push(similarityThreshold);
|
||||
|
||||
if (conditions.length > 0) {
|
||||
query += ` WHERE ${conditions.join(' AND ')}`;
|
||||
}
|
||||
|
||||
query += ` ORDER BY similarity DESC LIMIT ?`;
|
||||
params.push(matchCount);
|
||||
|
||||
const result = sqlite.query<Chunk & { similarity: number }>(query, params);
|
||||
const searchTime = Date.now() - startTime;
|
||||
|
||||
console.log(`📊 Vector search: ${result.rows.length}/${vectorLimit} chunks, threshold=${similarityThreshold}, time=${searchTime}ms`);
|
||||
if (result.rows.length > 0) {
|
||||
console.log(`🎯 Top result: chunk ${result.rows[0].id} (similarity: ${result.rows[0].similarity.toFixed(3)})`);
|
||||
}
|
||||
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
async textSearchFallback(
|
||||
query: string,
|
||||
matchCount = 5,
|
||||
nodeIds?: number[]
|
||||
): Promise<Array<Chunk & { similarity: number }>> {
|
||||
const sqlite = getSQLiteClient();
|
||||
const startTime = Date.now();
|
||||
|
||||
// Clean query for LIKE search
|
||||
const cleanQuery = query.trim().toLowerCase();
|
||||
const searchTerms = cleanQuery.split(/\s+/).filter(term => term.length > 2);
|
||||
|
||||
if (searchTerms.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Build LIKE conditions for each term
|
||||
const likeConditions = searchTerms.map(() => 'LOWER(text) LIKE ?').join(' AND ');
|
||||
const likeParams = searchTerms.map(term => `%${term}%`);
|
||||
|
||||
let textQuery = `
|
||||
SELECT *, 0.8 as similarity
|
||||
FROM chunks
|
||||
WHERE ${likeConditions}
|
||||
`;
|
||||
|
||||
const params = [...likeParams];
|
||||
|
||||
// Add node filter if provided
|
||||
if (nodeIds && nodeIds.length > 0) {
|
||||
textQuery += ` AND node_id IN (${nodeIds.map(() => '?').join(',')})`;
|
||||
params.push(...nodeIds.map(String));
|
||||
}
|
||||
|
||||
textQuery += ` ORDER BY LENGTH(text) ASC LIMIT ?`;
|
||||
params.push(String(matchCount));
|
||||
|
||||
const result = sqlite.query<Chunk & { similarity: number }>(textQuery, params);
|
||||
const searchTime = Date.now() - startTime;
|
||||
|
||||
console.log(`📝 Text fallback: ${result.rows.length} chunks found, time=${searchTime}ms`);
|
||||
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
async getChunkCount(): Promise<number> {
|
||||
const sqlite = getSQLiteClient();
|
||||
const result = sqlite.query('SELECT COUNT(*) as count FROM chunks');
|
||||
return Number(result.rows[0].count);
|
||||
}
|
||||
|
||||
async getChunkCountByNodeId(nodeId: number): Promise<number> {
|
||||
const sqlite = getSQLiteClient();
|
||||
const result = sqlite.query('SELECT COUNT(*) as count FROM chunks WHERE node_id = ?', [nodeId]);
|
||||
return Number(result.rows[0].count);
|
||||
}
|
||||
|
||||
async getNodesWithChunks(): Promise<Array<{ node_id: number; chunk_count: number }>> {
|
||||
const sqlite = getSQLiteClient();
|
||||
const result = sqlite.query(`
|
||||
SELECT node_id, COUNT(*) as chunk_count
|
||||
FROM chunks
|
||||
GROUP BY node_id
|
||||
ORDER BY chunk_count DESC
|
||||
`);
|
||||
return result.rows.map((row: any) => ({
|
||||
node_id: Number(row.node_id),
|
||||
chunk_count: Number(row.chunk_count)
|
||||
}));
|
||||
}
|
||||
|
||||
async getChunksWithoutEmbeddings(): Promise<Chunk[]> {
|
||||
// In SQLite, chunk vectors live in vec_chunks; report chunks without corresponding vector rows
|
||||
const sqlite = getSQLiteClient();
|
||||
const result = sqlite.query<Chunk>(`
|
||||
SELECT c.*
|
||||
FROM chunks c
|
||||
LEFT JOIN vec_chunks v ON v.chunk_id = c.id
|
||||
WHERE v.chunk_id IS NULL
|
||||
ORDER BY c.created_at ASC
|
||||
`);
|
||||
return result.rows;
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const chunkService = new ChunkService();
|
||||
@@ -0,0 +1,202 @@
|
||||
import { getSQLiteClient } from './sqlite-client';
|
||||
import { openai as openaiProvider } from '@ai-sdk/openai';
|
||||
import { generateText } from 'ai';
|
||||
|
||||
export interface Dimension {
|
||||
name: string;
|
||||
description: string | null;
|
||||
is_priority: boolean;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface LockedDimension {
|
||||
name: string;
|
||||
description: string | null;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export class DimensionService {
|
||||
/**
|
||||
* Get all locked (priority) dimensions with their descriptions
|
||||
*/
|
||||
static async getLockedDimensions(): Promise<LockedDimension[]> {
|
||||
const sqlite = getSQLiteClient();
|
||||
|
||||
const result = sqlite.query(`
|
||||
WITH dimension_counts AS (
|
||||
SELECT nd.dimension, COUNT(*) AS count
|
||||
FROM node_dimensions nd
|
||||
GROUP BY nd.dimension
|
||||
)
|
||||
SELECT
|
||||
d.name,
|
||||
d.description,
|
||||
COALESCE(dc.count, 0) AS count
|
||||
FROM dimensions d
|
||||
LEFT JOIN dimension_counts dc ON dc.dimension = d.name
|
||||
WHERE d.is_priority = 1
|
||||
ORDER BY d.name ASC
|
||||
`);
|
||||
|
||||
return result.rows.map((row: any) => ({
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
count: Number(row.count)
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Automatically assign locked dimensions to a node based on its content
|
||||
*/
|
||||
static async assignLockedDimensions(nodeData: {
|
||||
title: string;
|
||||
content?: string;
|
||||
link?: string;
|
||||
}): Promise<string[]> {
|
||||
try {
|
||||
const lockedDimensions = await this.getLockedDimensions();
|
||||
|
||||
if (lockedDimensions.length === 0) {
|
||||
console.log('No locked dimensions available for assignment');
|
||||
return [];
|
||||
}
|
||||
|
||||
const prompt = this.buildAssignmentPrompt(nodeData, lockedDimensions);
|
||||
|
||||
const response = await generateText({
|
||||
model: openaiProvider('gpt-4o-mini'),
|
||||
prompt,
|
||||
maxOutputTokens: 100,
|
||||
temperature: 0.1, // Low temperature for consistent results
|
||||
});
|
||||
|
||||
const assignedDimensions = this.parseAssignmentResponse(response.text, lockedDimensions);
|
||||
|
||||
console.log(`Assigned dimensions for "${nodeData.title}": ${assignedDimensions.join(', ')}`);
|
||||
return assignedDimensions;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error assigning dimensions:', error);
|
||||
return []; // Graceful fallback
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update dimension description
|
||||
*/
|
||||
static async updateDimensionDescription(name: string, description: string): Promise<void> {
|
||||
const sqlite = getSQLiteClient();
|
||||
|
||||
sqlite.query(`
|
||||
INSERT INTO dimensions(name, description, is_priority, updated_at)
|
||||
VALUES (?, ?, 0, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(name) DO UPDATE SET
|
||||
description = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
`, [name, description, description]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get dimension by name with description
|
||||
*/
|
||||
static async getDimensionByName(name: string): Promise<Dimension | null> {
|
||||
const sqlite = getSQLiteClient();
|
||||
|
||||
const result = sqlite.query(`
|
||||
SELECT name, description, is_priority, updated_at
|
||||
FROM dimensions
|
||||
WHERE name = ?
|
||||
`, [name]);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const row = result.rows[0] as any;
|
||||
return {
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
is_priority: Boolean(row.is_priority),
|
||||
updated_at: row.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build AI prompt for dimension assignment
|
||||
*/
|
||||
private static buildAssignmentPrompt(
|
||||
nodeData: { title: string; content?: string; link?: string },
|
||||
lockedDimensions: LockedDimension[]
|
||||
): string {
|
||||
const contentPreview = nodeData.content?.slice(0, 500) || '';
|
||||
const dimensionsList = lockedDimensions
|
||||
.map(d => `- ${d.name}: ${d.description || `Infer purpose from name: ${d.name}`}`)
|
||||
.join('\n');
|
||||
|
||||
return `Analyze this node content and assign appropriate dimensions from the available locked dimensions.
|
||||
|
||||
Node:
|
||||
Title: ${nodeData.title}
|
||||
Content: ${contentPreview}${nodeData.content && nodeData.content.length > 500 ? '...' : ''}
|
||||
URL: ${nodeData.link || 'none'}
|
||||
|
||||
Available Locked Dimensions:
|
||||
${dimensionsList}
|
||||
|
||||
Return only the dimension names that best match this content, maximum 3 dimensions.
|
||||
Respond with just the dimension names, one per line.
|
||||
If no dimensions are appropriate, respond with "none".
|
||||
|
||||
Examples of good responses:
|
||||
Work
|
||||
Learning
|
||||
|
||||
or
|
||||
|
||||
Research
|
||||
Ideas
|
||||
Tech
|
||||
|
||||
or
|
||||
|
||||
none`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse AI response and validate dimension names
|
||||
*/
|
||||
private static parseAssignmentResponse(
|
||||
response: string,
|
||||
availableDimensions: LockedDimension[]
|
||||
): string[] {
|
||||
const lines = response.trim().toLowerCase().split('\n');
|
||||
const availableNames = availableDimensions.map(d => d.name.toLowerCase());
|
||||
const validDimensions: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const dimensionName = line.trim();
|
||||
|
||||
if (dimensionName === 'none') {
|
||||
break;
|
||||
}
|
||||
|
||||
// Find matching dimension (case-insensitive)
|
||||
const matchedDimension = availableDimensions.find(
|
||||
d => d.name.toLowerCase() === dimensionName
|
||||
);
|
||||
|
||||
if (matchedDimension && !validDimensions.includes(matchedDimension.name)) {
|
||||
validDimensions.push(matchedDimension.name);
|
||||
|
||||
// Limit to 3 dimensions max
|
||||
if (validDimensions.length >= 3) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return validDimensions;
|
||||
}
|
||||
}
|
||||
|
||||
export const dimensionService = new DimensionService();
|
||||
@@ -0,0 +1,340 @@
|
||||
import { getSQLiteClient } from './sqlite-client';
|
||||
import { Edge, EdgeData, NodeConnection, Node } from '@/types/database';
|
||||
import { eventBroadcaster } from '../events';
|
||||
|
||||
export class EdgeService {
|
||||
async getEdges(): Promise<Edge[]> {
|
||||
const sqlite = getSQLiteClient();
|
||||
const result = sqlite.query<Edge>('SELECT * FROM edges ORDER BY created_at DESC');
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
async getEdgeById(id: number): Promise<Edge | null> {
|
||||
const sqlite = getSQLiteClient();
|
||||
const result = sqlite.query<Edge>('SELECT * FROM edges WHERE id = ?', [id]);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
async createEdge(edgeData: EdgeData): Promise<Edge> {
|
||||
return this.createEdgeSQLite(edgeData);
|
||||
}
|
||||
|
||||
// PostgreSQL path removed in SQLite-only consolidation
|
||||
|
||||
private async createEdgeSQLite(edgeData: EdgeData): Promise<Edge> {
|
||||
const now = new Date().toISOString();
|
||||
const sqlite = getSQLiteClient();
|
||||
|
||||
const result = sqlite.prepare(`
|
||||
INSERT INTO edges (from_node_id, to_node_id, context, source, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
edgeData.from_node_id,
|
||||
edgeData.to_node_id,
|
||||
JSON.stringify(edgeData.context || {}),
|
||||
edgeData.source,
|
||||
now
|
||||
);
|
||||
|
||||
const edgeId = Number(result.lastInsertRowid);
|
||||
const newEdge = await this.getEdgeById(edgeId);
|
||||
|
||||
if (!newEdge) {
|
||||
throw new Error('Failed to create edge');
|
||||
}
|
||||
|
||||
// Broadcast edge creation event
|
||||
eventBroadcaster.broadcast({
|
||||
type: 'EDGE_CREATED',
|
||||
data: {
|
||||
fromNodeId: newEdge.from_node_id,
|
||||
toNodeId: newEdge.to_node_id,
|
||||
edge: newEdge
|
||||
}
|
||||
});
|
||||
|
||||
return newEdge;
|
||||
}
|
||||
|
||||
async updateEdge(id: number, updates: Partial<Edge>): Promise<Edge> {
|
||||
return this.updateEdgeSQLite(id, updates);
|
||||
}
|
||||
|
||||
// PostgreSQL path removed in SQLite-only consolidation
|
||||
|
||||
private async updateEdgeSQLite(id: number, updates: Partial<Edge>): Promise<Edge> {
|
||||
const sqlite = getSQLiteClient();
|
||||
const updateFields: string[] = [];
|
||||
const params: any[] = [];
|
||||
|
||||
// Build dynamic update query
|
||||
Object.entries(updates).forEach(([key, value]) => {
|
||||
if (key !== 'id' && key !== 'created_at' && value !== undefined) {
|
||||
updateFields.push(`${key} = ?`);
|
||||
if (key === 'context') {
|
||||
params.push(typeof value === 'object' ? JSON.stringify(value) : value);
|
||||
} else {
|
||||
params.push(value);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (updateFields.length === 0) {
|
||||
throw new Error('No valid fields to update');
|
||||
}
|
||||
|
||||
params.push(id); // Add ID for WHERE clause
|
||||
|
||||
const query = `UPDATE edges SET ${updateFields.join(', ')} WHERE id = ?`;
|
||||
const result = sqlite.query(query, params);
|
||||
|
||||
if (result.changes === 0) {
|
||||
throw new Error(`Edge with ID ${id} not found`);
|
||||
}
|
||||
|
||||
const updatedEdge = await this.getEdgeById(id);
|
||||
if (!updatedEdge) {
|
||||
throw new Error(`Failed to retrieve updated edge with ID ${id}`);
|
||||
}
|
||||
|
||||
return updatedEdge;
|
||||
}
|
||||
|
||||
async deleteEdge(id: number): Promise<void> {
|
||||
const sqlite = getSQLiteClient();
|
||||
const result = sqlite.query('DELETE FROM edges WHERE id = ?', [id]);
|
||||
if ((result.changes || 0) === 0) {
|
||||
throw new Error(`Edge with ID ${id} not found`);
|
||||
}
|
||||
// Broadcast edge deletion event
|
||||
eventBroadcaster.broadcast({
|
||||
type: 'EDGE_DELETED',
|
||||
data: { edgeId: id }
|
||||
});
|
||||
}
|
||||
|
||||
async deleteEdgesByNodeId(nodeId: number): Promise<void> {
|
||||
const sqlite = getSQLiteClient();
|
||||
sqlite.query(
|
||||
'DELETE FROM edges WHERE from_node_id = ? OR to_node_id = ?',
|
||||
[nodeId, nodeId]
|
||||
);
|
||||
}
|
||||
|
||||
async getNodeConnections(nodeId: number): Promise<NodeConnection[]> {
|
||||
return this.getNodeConnectionsSQLite(nodeId);
|
||||
}
|
||||
|
||||
// PostgreSQL path removed in SQLite-only consolidation
|
||||
|
||||
private async getNodeConnectionsSQLite(nodeId: number): Promise<NodeConnection[]> {
|
||||
const sqlite = getSQLiteClient();
|
||||
const result = sqlite.query(`
|
||||
SELECT
|
||||
e.*,
|
||||
CASE
|
||||
WHEN e.from_node_id = ? THEN n_to.id
|
||||
ELSE n_from.id
|
||||
END as connected_node_id,
|
||||
CASE
|
||||
WHEN e.from_node_id = ? THEN n_to.title
|
||||
ELSE n_from.title
|
||||
END as connected_node_title,
|
||||
CASE
|
||||
WHEN e.from_node_id = ? THEN n_to.content
|
||||
ELSE n_from.content
|
||||
END as connected_node_content,
|
||||
CASE
|
||||
WHEN e.from_node_id = ? THEN n_to.link
|
||||
ELSE n_from.link
|
||||
END as connected_node_link,
|
||||
CASE
|
||||
WHEN e.from_node_id = ? THEN n_to.chunk
|
||||
ELSE n_from.chunk
|
||||
END as connected_node_chunk,
|
||||
CASE
|
||||
WHEN e.from_node_id = ? THEN n_to.metadata
|
||||
ELSE n_from.metadata
|
||||
END as connected_node_metadata,
|
||||
CASE
|
||||
WHEN e.from_node_id = ? THEN n_to.created_at
|
||||
ELSE n_from.created_at
|
||||
END as connected_node_created_at,
|
||||
CASE
|
||||
WHEN e.from_node_id = ? THEN n_to.updated_at
|
||||
ELSE n_from.updated_at
|
||||
END as connected_node_updated_at,
|
||||
CASE
|
||||
WHEN e.from_node_id = ? THEN (
|
||||
SELECT JSON_GROUP_ARRAY(d.dimension)
|
||||
FROM node_dimensions d WHERE d.node_id = n_to.id
|
||||
)
|
||||
ELSE (
|
||||
SELECT JSON_GROUP_ARRAY(d.dimension)
|
||||
FROM node_dimensions d WHERE d.node_id = n_from.id
|
||||
)
|
||||
END as connected_node_dimensions_json
|
||||
FROM edges e
|
||||
LEFT JOIN nodes n_from ON e.from_node_id = n_from.id
|
||||
LEFT JOIN nodes n_to ON e.to_node_id = n_to.id
|
||||
WHERE e.from_node_id = ? OR e.to_node_id = ?
|
||||
ORDER BY e.created_at DESC
|
||||
`, [
|
||||
nodeId,
|
||||
nodeId,
|
||||
nodeId,
|
||||
nodeId,
|
||||
nodeId,
|
||||
nodeId,
|
||||
nodeId,
|
||||
nodeId,
|
||||
nodeId,
|
||||
nodeId,
|
||||
nodeId
|
||||
]);
|
||||
|
||||
return this.mapNodeConnectionsSQLite(result.rows);
|
||||
}
|
||||
|
||||
private mapNodeConnections(rows: any[]): NodeConnection[] {
|
||||
return rows.map(row => {
|
||||
const edge: Edge = {
|
||||
id: row.id,
|
||||
from_node_id: row.from_node_id,
|
||||
to_node_id: row.to_node_id,
|
||||
context: row.context,
|
||||
source: row.source,
|
||||
created_at: row.created_at
|
||||
};
|
||||
|
||||
const connected_node: Node = {
|
||||
id: row.connected_node_id,
|
||||
title: row.connected_node_title,
|
||||
content: row.connected_node_content,
|
||||
link: row.connected_node_link,
|
||||
dimensions: row.connected_node_dimensions,
|
||||
embedding: undefined, // Not needed for display
|
||||
chunk: row.connected_node_chunk,
|
||||
metadata: row.connected_node_metadata,
|
||||
created_at: row.connected_node_created_at,
|
||||
updated_at: row.connected_node_updated_at
|
||||
};
|
||||
|
||||
return {
|
||||
id: edge.id,
|
||||
connected_node,
|
||||
edge
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private mapNodeConnectionsSQLite(rows: any[]): NodeConnection[] {
|
||||
return rows.map(row => {
|
||||
let context: any = row.context;
|
||||
if (typeof row.context === 'string') {
|
||||
const trimmed = row.context.trim();
|
||||
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
||||
try {
|
||||
context = JSON.parse(trimmed);
|
||||
} catch (error) {
|
||||
console.warn('[edges] Failed to parse JSON context for edge', row.id, error);
|
||||
context = row.context;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const edge: Edge = {
|
||||
id: row.id,
|
||||
from_node_id: row.from_node_id,
|
||||
to_node_id: row.to_node_id,
|
||||
context,
|
||||
source: row.source,
|
||||
created_at: row.created_at
|
||||
};
|
||||
|
||||
const connected_node: Node = {
|
||||
id: row.connected_node_id,
|
||||
title: row.connected_node_title,
|
||||
content: row.connected_node_content,
|
||||
link: row.connected_node_link,
|
||||
dimensions: JSON.parse(row.connected_node_dimensions_json || '[]'),
|
||||
embedding: undefined, // Not needed for display
|
||||
chunk: row.connected_node_chunk,
|
||||
metadata: typeof row.connected_node_metadata === 'string' ? JSON.parse(row.connected_node_metadata) : row.connected_node_metadata,
|
||||
created_at: row.connected_node_created_at,
|
||||
updated_at: row.connected_node_updated_at
|
||||
};
|
||||
|
||||
return {
|
||||
id: edge.id,
|
||||
connected_node,
|
||||
edge
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async edgeExists(fromId: number, toId: number): Promise<boolean> {
|
||||
const sqlite = getSQLiteClient();
|
||||
const result = sqlite.query('SELECT 1 FROM edges WHERE from_node_id = ? AND to_node_id = ?', [fromId, toId]);
|
||||
return result.rows.length > 0;
|
||||
}
|
||||
|
||||
async getEdgeCount(): Promise<number> {
|
||||
const sqlite = getSQLiteClient();
|
||||
const result = sqlite.query('SELECT COUNT(*) as count FROM edges');
|
||||
return Number(result.rows[0].count);
|
||||
}
|
||||
|
||||
|
||||
async getMostConnectedNodes(limit = 10): Promise<Array<{ node_id: number; connection_count: number }>> {
|
||||
const sqlite = getSQLiteClient();
|
||||
const result = sqlite.query(`
|
||||
SELECT
|
||||
node_id,
|
||||
COUNT(*) as connection_count
|
||||
FROM (
|
||||
SELECT from_node_id as node_id FROM edges
|
||||
UNION ALL
|
||||
SELECT to_node_id as node_id FROM edges
|
||||
) combined
|
||||
GROUP BY node_id
|
||||
ORDER BY connection_count DESC
|
||||
LIMIT ?
|
||||
`, [limit]);
|
||||
|
||||
return result.rows.map((row: any) => ({
|
||||
node_id: Number(row.node_id),
|
||||
connection_count: Number(row.connection_count)
|
||||
}));
|
||||
}
|
||||
|
||||
async createBidirectionalEdge(fromId: number, toId: number, options?: {
|
||||
context?: any;
|
||||
source?: 'user' | 'ai_similarity' | 'helper_name';
|
||||
}): Promise<Edge[]> {
|
||||
const edges: Edge[] = [];
|
||||
|
||||
// Create edge from A to B
|
||||
const forwardEdge = await this.createEdge({
|
||||
from_node_id: fromId,
|
||||
to_node_id: toId,
|
||||
context: options?.context,
|
||||
source: options?.source || 'ai_similarity'
|
||||
});
|
||||
edges.push(forwardEdge);
|
||||
|
||||
// Create edge from B to A
|
||||
const backwardEdge = await this.createEdge({
|
||||
from_node_id: toId,
|
||||
to_node_id: fromId,
|
||||
context: options?.context,
|
||||
source: options?.source || 'ai_similarity'
|
||||
});
|
||||
edges.push(backwardEdge);
|
||||
|
||||
return edges;
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const edgeService = new EdgeService();
|
||||
@@ -0,0 +1,70 @@
|
||||
// Service instances
|
||||
export { nodeService, NodeService } from './nodes';
|
||||
export { chunkService, ChunkService } from './chunks';
|
||||
export { edgeService, EdgeService } from './edges';
|
||||
export { dimensionService, DimensionService } from './dimensionService';
|
||||
// export { HelperService } from './helpers'; // Removed - migrated to JSON-based service
|
||||
|
||||
// Types
|
||||
export * from '@/types/database';
|
||||
|
||||
// Health check utility
|
||||
export async function checkDatabaseHealth(): Promise<{
|
||||
connected: boolean;
|
||||
vectorExtension: boolean;
|
||||
tablesExist: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
return checkSQLiteDatabaseHealth();
|
||||
} catch (error) {
|
||||
return {
|
||||
connected: false,
|
||||
vectorExtension: false,
|
||||
tablesExist: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function checkSQLiteDatabaseHealth(): Promise<{
|
||||
connected: boolean;
|
||||
vectorExtension: boolean;
|
||||
tablesExist: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const { getSQLiteClient } = await import('./sqlite-client');
|
||||
const sqlite = getSQLiteClient();
|
||||
|
||||
const connected = await sqlite.testConnection();
|
||||
if (!connected) {
|
||||
return {
|
||||
connected: false,
|
||||
vectorExtension: false,
|
||||
tablesExist: false,
|
||||
error: 'SQLite connection failed'
|
||||
};
|
||||
}
|
||||
|
||||
const vectorExtension = await sqlite.checkVectorExtension();
|
||||
|
||||
// Check if main tables exist
|
||||
const tables = await sqlite.checkTables();
|
||||
const requiredTables = ['nodes', 'chunks', 'edges'];
|
||||
const tablesExist = requiredTables.every(table => tables.includes(table));
|
||||
|
||||
return {
|
||||
connected,
|
||||
vectorExtension,
|
||||
tablesExist
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
connected: false,
|
||||
vectorExtension: false,
|
||||
tablesExist: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
import { getSQLiteClient } from './sqlite-client';
|
||||
import { Node, NodeFilters } from '@/types/database';
|
||||
import { eventBroadcaster } from '../events';
|
||||
|
||||
export class NodeService {
|
||||
async getNodes(filters: NodeFilters = {}): Promise<Node[]> {
|
||||
return this.getNodesSQLite(filters);
|
||||
}
|
||||
|
||||
// PostgreSQL path removed in SQLite-only consolidation
|
||||
|
||||
private async getNodesSQLite(filters: NodeFilters = {}): Promise<Node[]> {
|
||||
const { dimensions, search, limit = 100, offset = 0, sortBy } = filters;
|
||||
const sqlite = getSQLiteClient();
|
||||
|
||||
// Use nodes_v view for array-like dimensions behavior (exclude embedding BLOB for performance)
|
||||
let query = `
|
||||
SELECT n.id, n.title, n.description, n.content, n.link, n.type, n.metadata, n.chunk,
|
||||
n.chunk_status, n.embedding_updated_at, n.embedding_text,
|
||||
n.created_at, n.updated_at,
|
||||
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
|
||||
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json,
|
||||
(SELECT COUNT(*) FROM edges WHERE from_node_id = n.id OR to_node_id = n.id) as edge_count
|
||||
FROM nodes n
|
||||
WHERE 1=1
|
||||
`;
|
||||
const params: any[] = [];
|
||||
|
||||
// Filter by dimensions (SQLite JOIN with node_dimensions)
|
||||
if (dimensions && dimensions.length > 0) {
|
||||
query += ` AND EXISTS (
|
||||
SELECT 1 FROM node_dimensions nd
|
||||
WHERE nd.node_id = n.id
|
||||
AND nd.dimension IN (${dimensions.map(() => '?').join(',')})
|
||||
)`;
|
||||
params.push(...dimensions);
|
||||
}
|
||||
|
||||
// Text search in title and content (SQLite LIKE with COLLATE NOCASE)
|
||||
if (search) {
|
||||
query += ` AND (n.title LIKE ? COLLATE NOCASE OR n.content LIKE ? COLLATE NOCASE)`;
|
||||
params.push(`%${search}%`, `%${search}%`);
|
||||
}
|
||||
|
||||
// Sorting logic
|
||||
if (search) {
|
||||
// For search queries, prioritize by relevance: exact title → starts with → contains in title → content
|
||||
query += ` ORDER BY
|
||||
CASE WHEN LOWER(n.title) = LOWER(?) THEN 1 ELSE 5 END,
|
||||
CASE WHEN LOWER(n.title) LIKE LOWER(?) THEN 2 ELSE 5 END,
|
||||
CASE WHEN n.title LIKE ? COLLATE NOCASE THEN 3 ELSE 5 END,
|
||||
CASE WHEN n.content LIKE ? COLLATE NOCASE THEN 4 ELSE 5 END,
|
||||
n.updated_at DESC`;
|
||||
params.push(
|
||||
search, // Exact match (case-insensitive)
|
||||
`${search}%`, // Starts with search term
|
||||
`%${search}%`, // Contains in title
|
||||
`%${search}%` // Contains in content
|
||||
);
|
||||
} else if (sortBy === 'edges') {
|
||||
// Sort by edge count (most connected first)
|
||||
query += ' ORDER BY edge_count DESC, n.updated_at DESC';
|
||||
} else {
|
||||
query += ' ORDER BY n.updated_at DESC';
|
||||
}
|
||||
|
||||
if (limit) {
|
||||
query += ` LIMIT ?`;
|
||||
params.push(limit);
|
||||
}
|
||||
|
||||
if (offset > 0) {
|
||||
query += ` OFFSET ?`;
|
||||
params.push(offset);
|
||||
}
|
||||
|
||||
const result = sqlite.query<Node & { dimensions_json: string }>(query, params);
|
||||
|
||||
// Parse dimensions_json back to array for compatibility
|
||||
return result.rows.map(row => ({
|
||||
...row,
|
||||
dimensions: JSON.parse(row.dimensions_json || '[]')
|
||||
}));
|
||||
}
|
||||
|
||||
async getNodeById(id: number): Promise<Node | null> {
|
||||
return this.getNodeByIdSQLite(id);
|
||||
}
|
||||
|
||||
// PostgreSQL path removed in SQLite-only consolidation
|
||||
|
||||
private async getNodeByIdSQLite(id: number): Promise<Node | null> {
|
||||
const sqlite = getSQLiteClient();
|
||||
const query = `
|
||||
SELECT n.id, n.title, n.description, n.content, n.link, n.type, n.metadata, n.chunk,
|
||||
n.chunk_status, n.embedding_updated_at, n.embedding_text,
|
||||
n.created_at, n.updated_at,
|
||||
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
|
||||
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json
|
||||
FROM nodes n
|
||||
WHERE n.id = ?
|
||||
`;
|
||||
const result = sqlite.query<Node & { dimensions_json: string }>(query, [id]);
|
||||
|
||||
if (result.rows.length === 0) return null;
|
||||
|
||||
const row = result.rows[0];
|
||||
return {
|
||||
...row,
|
||||
dimensions: JSON.parse(row.dimensions_json || '[]')
|
||||
};
|
||||
}
|
||||
|
||||
async createNode(nodeData: Partial<Node>): Promise<Node> {
|
||||
return this.createNodeSQLite(nodeData);
|
||||
}
|
||||
|
||||
// PostgreSQL path removed in SQLite-only consolidation
|
||||
|
||||
private async createNodeSQLite(nodeData: Partial<Node>): Promise<Node> {
|
||||
const {
|
||||
title,
|
||||
description,
|
||||
content,
|
||||
link,
|
||||
type,
|
||||
dimensions = [],
|
||||
chunk,
|
||||
chunk_status,
|
||||
metadata = {}
|
||||
} = nodeData;
|
||||
const now = new Date().toISOString();
|
||||
const sqlite = getSQLiteClient();
|
||||
|
||||
const nodeId = sqlite.transaction(() => {
|
||||
// Insert node using prepare/run for lastInsertRowid access
|
||||
const nodeResult = sqlite.prepare(`
|
||||
INSERT INTO nodes (title, description, content, link, type, metadata, chunk, chunk_status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
title,
|
||||
description ?? null,
|
||||
content ?? null,
|
||||
link ?? null,
|
||||
type ?? null,
|
||||
JSON.stringify(metadata),
|
||||
chunk ?? null,
|
||||
chunk_status ?? null,
|
||||
now,
|
||||
now
|
||||
);
|
||||
|
||||
const id = Number(nodeResult.lastInsertRowid);
|
||||
|
||||
// Insert dimensions separately with INSERT OR IGNORE for safety
|
||||
if (dimensions.length > 0) {
|
||||
const stmt = sqlite.prepare(
|
||||
"INSERT OR IGNORE INTO node_dimensions (node_id, dimension) VALUES (?, ?)"
|
||||
);
|
||||
for (const dimension of dimensions) {
|
||||
stmt.run(id, dimension);
|
||||
}
|
||||
}
|
||||
|
||||
return id; // Returns number directly
|
||||
});
|
||||
|
||||
// Get the created node with dimensions (outside transaction)
|
||||
const createdNode = await this.getNodeByIdSQLite(nodeId);
|
||||
if (!createdNode) {
|
||||
throw new Error('Failed to create node');
|
||||
}
|
||||
|
||||
// Broadcast node creation event
|
||||
console.log('🚀 Broadcasting NODE_CREATED event for:', createdNode.title);
|
||||
eventBroadcaster.broadcast({
|
||||
type: 'NODE_CREATED',
|
||||
data: { node: createdNode }
|
||||
});
|
||||
|
||||
return createdNode;
|
||||
}
|
||||
|
||||
async updateNode(id: number, updates: Partial<Node>): Promise<Node> {
|
||||
return this.updateNodeSQLite(id, updates);
|
||||
}
|
||||
|
||||
// PostgreSQL path removed in SQLite-only consolidation
|
||||
|
||||
private async updateNodeSQLite(id: number, updates: Partial<Node>): Promise<Node> {
|
||||
const { title, description, content, link, type, dimensions, chunk, metadata } = updates;
|
||||
const now = new Date().toISOString();
|
||||
const sqlite = getSQLiteClient();
|
||||
|
||||
const existingRow = sqlite
|
||||
.query<{ id: number }>('SELECT id FROM nodes WHERE id = ?', [id])
|
||||
.rows[0];
|
||||
|
||||
if (!existingRow) {
|
||||
throw new Error(`Node with ID ${id} not found`);
|
||||
}
|
||||
|
||||
sqlite.transaction(() => {
|
||||
// Update node columns (only update provided fields)
|
||||
const setFields: string[] = [];
|
||||
const params: any[] = [];
|
||||
|
||||
if (title !== undefined) { setFields.push('title = ?'); params.push(title); }
|
||||
if (description !== undefined) { setFields.push('description = ?'); params.push(description); }
|
||||
if (content !== undefined) { setFields.push('content = ?'); params.push(content); }
|
||||
if (link !== undefined) { setFields.push('link = ?'); params.push(link); }
|
||||
if (type !== undefined) { setFields.push('type = ?'); params.push(type); }
|
||||
if (chunk !== undefined) { setFields.push('chunk = ?'); params.push(chunk); }
|
||||
if (Object.prototype.hasOwnProperty.call(updates, 'chunk_status')) {
|
||||
setFields.push('chunk_status = ?');
|
||||
params.push(updates.chunk_status ?? null);
|
||||
}
|
||||
if (metadata !== undefined) {
|
||||
setFields.push('metadata = ?');
|
||||
params.push(JSON.stringify(metadata));
|
||||
}
|
||||
|
||||
// Always update timestamp
|
||||
setFields.push('updated_at = ?');
|
||||
params.push(now, id); // id for WHERE clause
|
||||
|
||||
if (setFields.length > 1) { // More than just updated_at
|
||||
const stmt = sqlite.prepare(`UPDATE nodes SET ${setFields.join(', ')} WHERE id = ?`);
|
||||
stmt.run(...params);
|
||||
}
|
||||
|
||||
// Handle dimensions separately
|
||||
if (Array.isArray(dimensions)) {
|
||||
sqlite.prepare('DELETE FROM node_dimensions WHERE node_id = ?').run(id);
|
||||
const dimStmt = sqlite.prepare('INSERT OR IGNORE INTO node_dimensions (node_id, dimension) VALUES (?, ?)');
|
||||
for (const dim of dimensions) {
|
||||
dimStmt.run(id, dim);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Get updated node
|
||||
const updatedNode = await this.getNodeByIdSQLite(id);
|
||||
if (!updatedNode) {
|
||||
throw new Error(`Node with ID ${id} not found`);
|
||||
}
|
||||
|
||||
// Broadcast node update event
|
||||
eventBroadcaster.broadcast({
|
||||
type: 'NODE_UPDATED',
|
||||
data: { nodeId: id, node: updatedNode }
|
||||
});
|
||||
|
||||
return updatedNode;
|
||||
}
|
||||
|
||||
async deleteNode(id: number): Promise<void> {
|
||||
return this.deleteNodeSQLite(id);
|
||||
}
|
||||
|
||||
// PostgreSQL path removed in SQLite-only consolidation
|
||||
|
||||
private async deleteNodeSQLite(id: number): Promise<void> {
|
||||
const sqlite = getSQLiteClient();
|
||||
|
||||
const result = sqlite.query('DELETE FROM nodes WHERE id = ?', [id]);
|
||||
|
||||
if (result.changes === 0) {
|
||||
throw new Error(`Node with ID ${id} not found`);
|
||||
}
|
||||
|
||||
// Broadcast node deletion event
|
||||
eventBroadcaster.broadcast({
|
||||
type: 'NODE_DELETED',
|
||||
data: { nodeId: id }
|
||||
});
|
||||
}
|
||||
|
||||
// Dimension-based filtering methods
|
||||
async getNodesByDimension(dimension: string): Promise<Node[]> {
|
||||
return this.getNodes({ dimensions: [dimension] });
|
||||
}
|
||||
|
||||
async searchNodes(searchTerm: string, limit = 50): Promise<Node[]> {
|
||||
return this.getNodes({ search: searchTerm, limit });
|
||||
}
|
||||
|
||||
async getNodeCount(): Promise<number> {
|
||||
const sqlite = getSQLiteClient();
|
||||
const result = sqlite.query('SELECT COUNT(*) as count FROM nodes');
|
||||
return Number(result.rows[0].count);
|
||||
}
|
||||
|
||||
async bulkUpdateNodes(ids: number[], updates: Partial<Node>): Promise<Node[]> {
|
||||
if (ids.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.bulkUpdateNodesSQLite(ids, updates);
|
||||
}
|
||||
|
||||
// PostgreSQL path removed in SQLite-only consolidation
|
||||
|
||||
private async bulkUpdateNodesSQLite(ids: number[], updates: Partial<Node>): Promise<Node[]> {
|
||||
// For SQLite, use IN (SELECT value FROM json_each(?)) for safety
|
||||
const sqlite = getSQLiteClient();
|
||||
const idsJson = JSON.stringify(ids);
|
||||
|
||||
// For now, just update one by one - could optimize later
|
||||
const updatedNodes: Node[] = [];
|
||||
for (const id of ids) {
|
||||
const updated = await this.updateNodeSQLite(id, updates);
|
||||
updatedNodes.push(updated);
|
||||
}
|
||||
return updatedNodes;
|
||||
}
|
||||
|
||||
// Get all unique dimensions for UI filtering
|
||||
async getAllDimensions(): Promise<string[]> {
|
||||
const sqlite = getSQLiteClient();
|
||||
const query = `
|
||||
SELECT DISTINCT dimension
|
||||
FROM node_dimensions
|
||||
ORDER BY dimension
|
||||
`;
|
||||
const result = sqlite.query<{dimension: string}>(query);
|
||||
return result.rows.map(row => row.dimension);
|
||||
}
|
||||
|
||||
// Get dimension usage statistics
|
||||
async getDimensionStats(): Promise<{dimension: string, count: number}[]> {
|
||||
const sqlite = getSQLiteClient();
|
||||
const query = `
|
||||
SELECT dimension, COUNT(*) as count
|
||||
FROM node_dimensions
|
||||
GROUP BY dimension
|
||||
ORDER BY count DESC
|
||||
`;
|
||||
const result = sqlite.query<{dimension: string, count: number}>(query);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const nodeService = new NodeService();
|
||||
|
||||
// Legacy export for backwards compatibility during migration
|
||||
export const itemService = nodeService;
|
||||
export const ItemService = NodeService;
|
||||
@@ -0,0 +1,686 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { DatabaseError } from '@/types/database';
|
||||
|
||||
export interface SQLiteConfig {
|
||||
dbPath: string;
|
||||
vecExtensionPath: string;
|
||||
}
|
||||
|
||||
export interface SQLiteQueryResult<T = any> {
|
||||
rows: T[];
|
||||
changes?: number;
|
||||
lastInsertRowid?: number;
|
||||
}
|
||||
|
||||
class SQLiteClient {
|
||||
private static instance: SQLiteClient;
|
||||
private db: Database.Database;
|
||||
private config: SQLiteConfig;
|
||||
private readonly readOnly: boolean;
|
||||
|
||||
private constructor() {
|
||||
this.config = this.getSQLiteConfig();
|
||||
this.readOnly = process.env.SQLITE_READONLY === 'true';
|
||||
|
||||
// Initialize database connection
|
||||
const dbDirectory = path.dirname(this.config.dbPath);
|
||||
if (!this.readOnly && !fs.existsSync(dbDirectory)) {
|
||||
fs.mkdirSync(dbDirectory, { recursive: true });
|
||||
}
|
||||
this.db = this.readOnly
|
||||
? new Database(this.config.dbPath, { readonly: true, fileMustExist: true })
|
||||
: new Database(this.config.dbPath);
|
||||
|
||||
// Load sqlite-vec extension
|
||||
try {
|
||||
this.db.loadExtension(this.config.vecExtensionPath);
|
||||
console.log('SQLite vector extension loaded successfully');
|
||||
} catch (error) {
|
||||
// Do not fail hard — allow the app to run without vector features
|
||||
console.error('Warning: Failed to load vector extension:', error);
|
||||
}
|
||||
|
||||
// Configure SQLite settings
|
||||
if (this.readOnly) {
|
||||
try {
|
||||
this.db.pragma('query_only = ON');
|
||||
} catch (error) {
|
||||
console.warn('Failed to enable query_only pragma in read-only mode:', error);
|
||||
}
|
||||
} else {
|
||||
this.db.pragma('foreign_keys = ON');
|
||||
this.db.pragma('journal_mode = WAL');
|
||||
this.db.pragma('synchronous = NORMAL');
|
||||
this.db.pragma('cache_size = 10000');
|
||||
this.db.pragma('temp_store = memory');
|
||||
this.db.pragma('busy_timeout = 5000');
|
||||
|
||||
// Ensure vector virtual tables are present and healthy
|
||||
this.ensureVectorTables();
|
||||
this.healVectorTablesIfCorrupt();
|
||||
|
||||
// Ensure logging schema (rename memory->logs if needed, create triggers/views)
|
||||
this.ensureLoggingAndMemorySchema();
|
||||
}
|
||||
|
||||
console.log('SQLite client initialized successfully');
|
||||
}
|
||||
|
||||
private getSQLiteConfig(): SQLiteConfig {
|
||||
const dbPath = process.env.SQLITE_DB_PATH || path.join(
|
||||
process.env.HOME || '~',
|
||||
'Library/Application Support/RA-H/db/rah.sqlite'
|
||||
);
|
||||
|
||||
const vecExtensionPath = process.env.SQLITE_VEC_EXTENSION_PATH ||
|
||||
'./vendor/sqlite-extensions/vec0.dylib';
|
||||
|
||||
return {
|
||||
dbPath,
|
||||
vecExtensionPath
|
||||
};
|
||||
}
|
||||
|
||||
public static getInstance(): SQLiteClient {
|
||||
if (!SQLiteClient.instance) {
|
||||
SQLiteClient.instance = new SQLiteClient();
|
||||
}
|
||||
return SQLiteClient.instance;
|
||||
}
|
||||
|
||||
public query<T extends Record<string, any> = any>(
|
||||
sql: string,
|
||||
params?: any[]
|
||||
): SQLiteQueryResult<T> {
|
||||
try {
|
||||
const sqlLower = sql.trim().toLowerCase();
|
||||
|
||||
// Handle different query types
|
||||
if (sqlLower.startsWith('select') ||
|
||||
sqlLower.startsWith('with') ||
|
||||
sqlLower.includes('returning')) {
|
||||
// SELECT queries and queries with RETURNING clause
|
||||
const stmt = this.db.prepare(sql);
|
||||
const rows = params ? stmt.all(...params) : stmt.all();
|
||||
return { rows: rows as T[] };
|
||||
} else {
|
||||
// INSERT/UPDATE/DELETE queries without RETURNING
|
||||
const stmt = this.db.prepare(sql);
|
||||
const result = params ? stmt.run(...params) : stmt.run();
|
||||
return {
|
||||
rows: [],
|
||||
changes: result.changes,
|
||||
lastInsertRowid: Number(result.lastInsertRowid)
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('SQLite query error:', error);
|
||||
throw this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
public prepare(sql: string) {
|
||||
return this.db.prepare(sql);
|
||||
}
|
||||
|
||||
public transaction<T>(callback: () => T): T {
|
||||
if (this.readOnly) {
|
||||
throw {
|
||||
message: 'SQLite client is read-only',
|
||||
code: 'SQLITE_READONLY',
|
||||
details: 'Transactions are not allowed in read-only mode'
|
||||
} as DatabaseError;
|
||||
}
|
||||
// Proactively validate/repair vec vtables before any write transaction
|
||||
this.healVectorTablesIfCorrupt();
|
||||
const txn = this.db.transaction(callback);
|
||||
try {
|
||||
return txn();
|
||||
} catch (error) {
|
||||
throw this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
public async testConnection(): Promise<boolean> {
|
||||
try {
|
||||
const result = this.query('SELECT datetime() as current_time');
|
||||
return result.rows.length > 0;
|
||||
} catch (error) {
|
||||
console.error('SQLite connection test failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async checkVectorExtension(): Promise<boolean> {
|
||||
try {
|
||||
const result = this.query('SELECT vec_version() as version');
|
||||
return result.rows.length > 0;
|
||||
} catch (error) {
|
||||
console.error('Vector extension check failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async checkTables(): Promise<string[]> {
|
||||
try {
|
||||
const result = this.query(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
|
||||
);
|
||||
return result.rows.map(row => row.name);
|
||||
} catch (error) {
|
||||
console.error('Table check failed:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public ensureVectorExtensions(): void {
|
||||
try {
|
||||
// Test for vec_nodes and vec_chunks; create them if missing
|
||||
const hasVecNodes = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get('vec_nodes');
|
||||
if (!hasVecNodes) {
|
||||
this.db.exec(`
|
||||
CREATE VIRTUAL TABLE vec_nodes USING vec0(
|
||||
node_id INTEGER PRIMARY KEY,
|
||||
embedding FLOAT[1536]
|
||||
);
|
||||
`);
|
||||
console.log('Created vec_nodes virtual table');
|
||||
}
|
||||
|
||||
const hasVecChunks = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get('vec_chunks');
|
||||
if (!hasVecChunks) {
|
||||
this.db.exec(`
|
||||
CREATE VIRTUAL TABLE vec_chunks USING vec0(
|
||||
chunk_id INTEGER PRIMARY KEY,
|
||||
embedding FLOAT[1536]
|
||||
);
|
||||
`);
|
||||
console.log('Created vec_chunks virtual table');
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Vector extension not available:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private ensureVectorTables(): void {
|
||||
if (this.readOnly) {
|
||||
return;
|
||||
}
|
||||
// Wrapper to keep existing public API stable
|
||||
this.ensureVectorExtensions();
|
||||
}
|
||||
|
||||
private ensureLoggingAndMemorySchema(): void {
|
||||
if (this.readOnly) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// 1) If logs table missing but legacy memory table exists, migrate
|
||||
const hasLogs = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='logs'").get();
|
||||
const hasLegacyMemory = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='memory'").get();
|
||||
if (!hasLogs && hasLegacyMemory) {
|
||||
// Drop old view to release dependency
|
||||
this.db.exec(`DROP VIEW IF EXISTS memory_v;`);
|
||||
this.db.exec(`ALTER TABLE memory RENAME TO logs;`);
|
||||
}
|
||||
|
||||
// 2) Ensure logs table exists (fresh install)
|
||||
const hasLogsNow = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='logs'").get();
|
||||
if (!hasLogsNow) {
|
||||
this.db.exec(`
|
||||
CREATE TABLE logs (
|
||||
id INTEGER PRIMARY KEY,
|
||||
ts TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||
table_name TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
row_id INTEGER NOT NULL,
|
||||
summary TEXT,
|
||||
snapshot_json TEXT
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
// Ensure nodes table has expected columns for memory nodes
|
||||
try {
|
||||
const nodeCols = this.db.prepare('PRAGMA table_info(nodes)').all() as Array<{ name: string }>;
|
||||
const ensureNodeCol = (name: string, ddl: string) => {
|
||||
if (!nodeCols.some(col => col.name === name)) {
|
||||
try {
|
||||
this.db.exec(ddl);
|
||||
} catch (colErr) {
|
||||
console.warn(`Failed to add nodes.${name}`, colErr);
|
||||
}
|
||||
}
|
||||
};
|
||||
ensureNodeCol('description', "ALTER TABLE nodes ADD COLUMN description TEXT;");
|
||||
ensureNodeCol('type', "ALTER TABLE nodes ADD COLUMN type TEXT;");
|
||||
} catch (nodeErr) {
|
||||
console.warn('Failed to ensure nodes columns:', nodeErr);
|
||||
}
|
||||
|
||||
// Ensure chats table tracks creation timestamp for ordering
|
||||
try {
|
||||
const chatCols = this.db.prepare('PRAGMA table_info(chats)').all() as Array<{ name: string }>;
|
||||
if (chatCols.some(col => col.name === 'created_at')) {
|
||||
// no-op, column exists
|
||||
} else if (chatCols.length > 0) {
|
||||
this.db.exec("ALTER TABLE chats ADD COLUMN created_at TEXT DEFAULT (CURRENT_TIMESTAMP);");
|
||||
}
|
||||
} catch (chatErr) {
|
||||
console.warn('Failed to ensure chats.created_at column:', chatErr);
|
||||
}
|
||||
|
||||
// 3) Helpful indexes on logs (clean up old names first)
|
||||
this.db.exec(`
|
||||
DROP INDEX IF EXISTS idx_memory_ts;
|
||||
DROP INDEX IF EXISTS idx_memory_table_ts;
|
||||
DROP INDEX IF EXISTS idx_memory_table_row;
|
||||
CREATE INDEX IF NOT EXISTS idx_logs_ts ON logs(ts);
|
||||
CREATE INDEX IF NOT EXISTS idx_logs_table_ts ON logs(table_name, ts);
|
||||
CREATE INDEX IF NOT EXISTS idx_logs_table_row ON logs(table_name, row_id);
|
||||
`);
|
||||
|
||||
// 4) Recreate triggers to write to logs (use CREATE IF NOT EXISTS)
|
||||
const hasChats = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='chats'").get();
|
||||
this.db.exec(`
|
||||
DROP TRIGGER IF EXISTS trg_nodes_ai;
|
||||
DROP TRIGGER IF EXISTS trg_nodes_au;
|
||||
CREATE TRIGGER IF NOT EXISTS trg_nodes_ai AFTER INSERT ON nodes BEGIN
|
||||
INSERT INTO logs(table_name, action, row_id, summary, snapshot_json)
|
||||
VALUES('nodes', 'insert', NEW.id,
|
||||
printf('node created: %s', COALESCE(NEW.title,'')),
|
||||
json_object('id', NEW.id, 'title', NEW.title, 'link', NEW.link));
|
||||
END;
|
||||
CREATE TRIGGER IF NOT EXISTS trg_nodes_au AFTER UPDATE ON nodes BEGIN
|
||||
INSERT INTO logs(table_name, action, row_id, summary, snapshot_json)
|
||||
VALUES('nodes', 'update', NEW.id,
|
||||
printf('node updated: %s', COALESCE(NEW.title,'')),
|
||||
json_object('id', NEW.id, 'title', NEW.title, 'link', NEW.link));
|
||||
END;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_edges_ai;
|
||||
DROP TRIGGER IF EXISTS trg_edges_au;
|
||||
CREATE TRIGGER IF NOT EXISTS trg_edges_ai AFTER INSERT ON edges BEGIN
|
||||
INSERT INTO logs(table_name, action, row_id, summary, snapshot_json)
|
||||
VALUES('edges', 'insert', NEW.id,
|
||||
printf('edge %d→%d (%s)', NEW.from_node_id, NEW.to_node_id, COALESCE(NEW.source,'')),
|
||||
json_object(
|
||||
'id', NEW.id,
|
||||
'from', NEW.from_node_id,
|
||||
'to', NEW.to_node_id,
|
||||
'source', NEW.source,
|
||||
'from_title', substr((SELECT title FROM nodes WHERE id = NEW.from_node_id), 1, 120),
|
||||
'to_title', substr((SELECT title FROM nodes WHERE id = NEW.to_node_id), 1, 120)
|
||||
));
|
||||
END;
|
||||
CREATE TRIGGER IF NOT EXISTS trg_edges_au AFTER UPDATE ON edges BEGIN
|
||||
INSERT INTO logs(table_name, action, row_id, summary, snapshot_json)
|
||||
VALUES('edges', 'update', NEW.id,
|
||||
printf('edge updated %d→%d', NEW.from_node_id, NEW.to_node_id),
|
||||
json_object(
|
||||
'id', NEW.id,
|
||||
'from', NEW.from_node_id,
|
||||
'to', NEW.to_node_id,
|
||||
'source', NEW.source,
|
||||
'from_title', substr((SELECT title FROM nodes WHERE id = NEW.from_node_id), 1, 120),
|
||||
'to_title', substr((SELECT title FROM nodes WHERE id = NEW.to_node_id), 1, 120)
|
||||
));
|
||||
END;
|
||||
`);
|
||||
|
||||
if (hasChats) {
|
||||
this.db.exec(`
|
||||
DROP TRIGGER IF EXISTS trg_chats_ai;
|
||||
CREATE TRIGGER IF NOT EXISTS trg_chats_ai AFTER INSERT ON chats BEGIN
|
||||
INSERT INTO logs(table_name, action, row_id, summary, snapshot_json)
|
||||
VALUES('chats', 'insert', NEW.id,
|
||||
printf('chat: %s (%s)', COALESCE(NEW.helper_name,''), COALESCE(NEW.thread_id,'')),
|
||||
json_object(
|
||||
'id', NEW.id,
|
||||
'helper', NEW.helper_name,
|
||||
'thread', NEW.thread_id,
|
||||
'user_message', COALESCE(NEW.user_message,''),
|
||||
'assistant_message', COALESCE(NEW.assistant_message,''),
|
||||
'user_preview', substr(COALESCE(NEW.user_message,''), 1, 120),
|
||||
'assistant_preview', substr(COALESCE(NEW.assistant_message,''), 1, 120),
|
||||
'system_message', COALESCE(json_extract(NEW.metadata, '$.system_message'), ''),
|
||||
'input_tokens', COALESCE(json_extract(NEW.metadata, '$.input_tokens'), 0),
|
||||
'output_tokens', COALESCE(json_extract(NEW.metadata, '$.output_tokens'), 0),
|
||||
'cost_usd', COALESCE(json_extract(NEW.metadata, '$.estimated_cost_usd'), 0.0),
|
||||
'cache_hit', COALESCE(json_extract(NEW.metadata, '$.cache_hit'), 0),
|
||||
'model', COALESCE(json_extract(NEW.metadata, '$.model_used'), ''),
|
||||
'tools_count', COALESCE(json_extract(NEW.metadata, '$.tool_calls_count'), 0),
|
||||
'trace_id', COALESCE(json_extract(NEW.metadata, '$.trace_id'), ''),
|
||||
'voice_tts_chars', COALESCE(json_extract(NEW.metadata, '$.voice_tts_chars'), 0),
|
||||
'voice_tts_cost_usd', COALESCE(json_extract(NEW.metadata, '$.voice_tts_cost_usd'), 0),
|
||||
'voice_tts_chars_total', COALESCE(json_extract(NEW.metadata, '$.voice_tts_chars_total'), 0),
|
||||
'voice_tts_cost_usd_total', COALESCE(json_extract(NEW.metadata, '$.voice_tts_cost_usd_total'), 0),
|
||||
'voice_request_id', COALESCE(json_extract(NEW.metadata, '$.voice_request_id'), ''),
|
||||
'voice_tts_request_count', COALESCE(json_extract(NEW.metadata, '$.voice_tts_request_count'), 0)
|
||||
));
|
||||
END;
|
||||
`);
|
||||
}
|
||||
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS voice_usage (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
chat_id INTEGER,
|
||||
session_id TEXT,
|
||||
helper_name TEXT,
|
||||
request_id TEXT,
|
||||
message_id TEXT,
|
||||
voice TEXT,
|
||||
model TEXT,
|
||||
chars INTEGER,
|
||||
cost_usd REAL,
|
||||
duration_ms INTEGER,
|
||||
text_preview TEXT,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE SET NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_voice_usage_session ON voice_usage(session_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_voice_usage_chat ON voice_usage(chat_id);
|
||||
`);
|
||||
|
||||
// 5) Views: logs_v (drop any legacy memory_v alias)
|
||||
this.db.exec(`DROP VIEW IF EXISTS logs_v; DROP VIEW IF EXISTS memory_v;`);
|
||||
try {
|
||||
this.db.exec(`
|
||||
CREATE VIEW logs_v AS
|
||||
SELECT
|
||||
m.id,
|
||||
m.ts,
|
||||
m.table_name,
|
||||
m.action,
|
||||
m.row_id,
|
||||
m.summary,
|
||||
m.enriched_summary,
|
||||
m.snapshot_json,
|
||||
CASE WHEN m.table_name='nodes' THEN n.title END AS node_title,
|
||||
CASE WHEN m.table_name='edges' THEN nf.title END AS edge_from_title,
|
||||
CASE WHEN m.table_name='edges' THEN nt.title END AS edge_to_title,
|
||||
CASE WHEN m.table_name='chats' THEN c.helper_name END AS chat_helper,
|
||||
CASE WHEN m.table_name='chats' THEN substr(c.user_message,1,120) END AS chat_user_preview,
|
||||
CASE WHEN m.table_name='chats' THEN substr(c.assistant_message,1,120) END AS chat_assistant_preview,
|
||||
CASE WHEN m.table_name='chats' THEN c.user_message END AS chat_user_full,
|
||||
CASE WHEN m.table_name='chats' THEN c.assistant_message END AS chat_assistant_full
|
||||
FROM logs m
|
||||
LEFT JOIN nodes n ON (m.table_name='nodes' AND m.row_id = n.id)
|
||||
LEFT JOIN edges e ON (m.table_name='edges' AND m.row_id = e.id)
|
||||
LEFT JOIN nodes nf ON e.from_node_id = nf.id
|
||||
LEFT JOIN nodes nt ON e.to_node_id = nt.id
|
||||
LEFT JOIN chats c ON (m.table_name='chats' AND m.row_id = c.id);
|
||||
`);
|
||||
} catch (error) {
|
||||
if (
|
||||
!(error instanceof Error) ||
|
||||
!/already exists/i.test(error.message || '')
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
// Do not recreate memory_v; alias has been removed.
|
||||
|
||||
// 6) Chat memory state tracking for chat-triggered memories
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS chat_memory_state (
|
||||
thread_id TEXT PRIMARY KEY,
|
||||
helper_name TEXT,
|
||||
last_processed_chat_id INTEGER DEFAULT 0,
|
||||
last_processed_at TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_chat_memory_thread ON chat_memory_state(thread_id);
|
||||
`);
|
||||
|
||||
// Agent delegation table for orchestrator/worker coordination
|
||||
try {
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS agent_delegations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT UNIQUE NOT NULL,
|
||||
task TEXT NOT NULL,
|
||||
context TEXT,
|
||||
expected_outcome TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'queued',
|
||||
summary TEXT,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
} catch (e) {
|
||||
console.warn('Failed to ensure agent_delegations table:', e);
|
||||
}
|
||||
|
||||
// 8) Logs retention trigger (~10k most recent rows)
|
||||
try {
|
||||
this.db.exec(`
|
||||
DROP TRIGGER IF EXISTS trg_logs_prune;
|
||||
CREATE TRIGGER IF NOT EXISTS trg_logs_prune AFTER INSERT ON logs BEGIN
|
||||
DELETE FROM logs WHERE id < NEW.id - 10000;
|
||||
END;
|
||||
`);
|
||||
} catch {}
|
||||
|
||||
// 7) Ensure agents table schema (backward compatibility)
|
||||
try {
|
||||
const agentCols = this.db.prepare('PRAGMA table_info(agents)').all() as any[];
|
||||
if (agentCols.length) {
|
||||
const hasKey = agentCols.some(col => col.name === 'key');
|
||||
const hasComponentKey = agentCols.some(col => col.name === 'component_key');
|
||||
if (!hasKey && hasComponentKey) {
|
||||
try { this.db.exec('ALTER TABLE agents RENAME COLUMN component_key TO key;'); } catch {}
|
||||
}
|
||||
|
||||
if (!agentCols.some(col => col.name === 'role')) {
|
||||
try { this.db.exec("ALTER TABLE agents ADD COLUMN role TEXT NOT NULL DEFAULT 'executor';"); } catch {}
|
||||
}
|
||||
if (!agentCols.some(col => col.name === 'memory')) {
|
||||
try { this.db.exec('ALTER TABLE agents ADD COLUMN memory TEXT;'); } catch {}
|
||||
}
|
||||
if (!agentCols.some(col => col.name === 'prompts')) {
|
||||
try { this.db.exec("ALTER TABLE agents ADD COLUMN prompts TEXT DEFAULT '[]';"); } catch {}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Agent schema ensure failed:', e);
|
||||
}
|
||||
|
||||
// 8) Ensure chats schema (remove legacy focused_memory_id, ensure agent columns)
|
||||
if (hasChats) {
|
||||
try {
|
||||
let chatCols = this.db.prepare('PRAGMA table_info(chats)').all() as any[];
|
||||
const hasFocusedMemoryId = chatCols.some((c: any) => c.name === 'focused_memory_id');
|
||||
if (hasFocusedMemoryId) {
|
||||
console.log('Removing legacy chats.focused_memory_id column');
|
||||
let flippedForeignKeys = false;
|
||||
try {
|
||||
this.db.exec('PRAGMA foreign_keys=OFF;');
|
||||
flippedForeignKeys = true;
|
||||
this.db.exec(`
|
||||
BEGIN TRANSACTION;
|
||||
ALTER TABLE chats RENAME TO chats_legacy_cleanup;
|
||||
CREATE TABLE chats (
|
||||
id INTEGER PRIMARY KEY,
|
||||
chat_type TEXT,
|
||||
helper_name TEXT,
|
||||
agent_type TEXT DEFAULT 'orchestrator',
|
||||
delegation_id INTEGER,
|
||||
user_message TEXT,
|
||||
assistant_message TEXT,
|
||||
thread_id TEXT,
|
||||
focused_node_id INTEGER,
|
||||
created_at TEXT DEFAULT (CURRENT_TIMESTAMP),
|
||||
metadata TEXT,
|
||||
FOREIGN KEY (focused_node_id) REFERENCES nodes(id) ON DELETE SET NULL
|
||||
);
|
||||
INSERT INTO chats (
|
||||
id, chat_type, helper_name, agent_type, delegation_id,
|
||||
user_message, assistant_message, thread_id, focused_node_id,
|
||||
created_at, metadata
|
||||
)
|
||||
SELECT id, chat_type, helper_name, agent_type, delegation_id,
|
||||
user_message, assistant_message, thread_id, focused_node_id,
|
||||
created_at, metadata
|
||||
FROM chats_legacy_cleanup;
|
||||
DROP TABLE chats_legacy_cleanup;
|
||||
CREATE INDEX IF NOT EXISTS idx_chats_thread ON chats(thread_id);
|
||||
COMMIT;
|
||||
`);
|
||||
} catch (migrationErr) {
|
||||
console.warn('Failed to migrate chats table (focused_memory_id removal):', migrationErr);
|
||||
try { this.db.exec('ROLLBACK;'); } catch {}
|
||||
} finally {
|
||||
if (flippedForeignKeys) {
|
||||
try { this.db.exec('PRAGMA foreign_keys=ON;'); } catch {}
|
||||
}
|
||||
}
|
||||
chatCols = this.db.prepare('PRAGMA table_info(chats)').all() as any[];
|
||||
}
|
||||
|
||||
this.db.exec("CREATE INDEX IF NOT EXISTS idx_chats_thread ON chats(thread_id);");
|
||||
|
||||
const ensureCol = (name: string, ddl: string) => {
|
||||
if (!chatCols.some((c: any) => c.name === name)) {
|
||||
try { this.db.exec(ddl); } catch (colErr) { console.warn(`Failed to add chats.${name}`, colErr); }
|
||||
}
|
||||
};
|
||||
ensureCol('agent_type', "ALTER TABLE chats ADD COLUMN agent_type TEXT DEFAULT 'orchestrator';");
|
||||
ensureCol('delegation_id', 'ALTER TABLE chats ADD COLUMN delegation_id INTEGER;');
|
||||
} catch (e) {
|
||||
console.warn('Failed to update chats schema:', e);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const chatColsPost = hasChats
|
||||
? this.db.prepare('PRAGMA table_info(chats)').all() as any[]
|
||||
: [];
|
||||
const stillHasFocusedMemoryId = chatColsPost.some((c: any) => c.name === 'focused_memory_id');
|
||||
if (stillHasFocusedMemoryId) {
|
||||
console.warn('Skipping legacy memory table drop because chats.focused_memory_id is still present.');
|
||||
} else {
|
||||
this.db.exec(`
|
||||
DROP TRIGGER IF EXISTS trg_episodic_prune;
|
||||
DROP TABLE IF EXISTS episodic_memory;
|
||||
DROP TABLE IF EXISTS episodic_pipeline_state;
|
||||
DROP TABLE IF EXISTS semantic_memory;
|
||||
DROP TABLE IF EXISTS semantic_pipeline_state;
|
||||
DROP TABLE IF EXISTS memory_pipeline_state;
|
||||
DROP TABLE IF EXISTS memory;
|
||||
`);
|
||||
}
|
||||
} catch (dropLegacyErr) {
|
||||
console.warn('Failed to drop legacy memory pipeline tables:', dropLegacyErr);
|
||||
}
|
||||
|
||||
// 9) Ensure dimensions table exists (v0.1.16+ schema migration)
|
||||
const hasDimensions = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='dimensions'").get();
|
||||
if (!hasDimensions) {
|
||||
console.log('Creating dimensions table for v0.1.16+ features...');
|
||||
this.db.exec(`
|
||||
CREATE TABLE dimensions (
|
||||
name TEXT PRIMARY KEY,
|
||||
description TEXT,
|
||||
is_priority INTEGER DEFAULT 0,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
|
||||
// Seed default locked dimensions
|
||||
const defaultDimensions = ['research', 'ideas', 'projects', 'memory', 'preferences'];
|
||||
const insertDimension = this.db.prepare(`
|
||||
INSERT INTO dimensions (name, is_priority, updated_at)
|
||||
VALUES (?, 1, datetime('now'))
|
||||
ON CONFLICT(name) DO UPDATE SET is_priority = 1, updated_at = datetime('now')
|
||||
`);
|
||||
|
||||
for (const dimension of defaultDimensions) {
|
||||
try {
|
||||
insertDimension.run(dimension);
|
||||
} catch (e) {
|
||||
console.warn(`Failed to seed dimension '${dimension}':`, e);
|
||||
}
|
||||
}
|
||||
console.log('Dimensions table created and seeded with default locked dimensions');
|
||||
} else {
|
||||
// Check if existing dimensions table has description column
|
||||
const dimensionCols = this.db.prepare('PRAGMA table_info(dimensions)').all() as Array<{ name: string }>;
|
||||
const hasDescription = dimensionCols.some(col => col.name === 'description');
|
||||
if (!hasDescription) {
|
||||
console.log('Adding description column to existing dimensions table...');
|
||||
try {
|
||||
this.db.exec('ALTER TABLE dimensions ADD COLUMN description TEXT;');
|
||||
console.log('Description column added to dimensions table');
|
||||
} catch (e) {
|
||||
console.warn('Failed to add description column to dimensions table:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Logging + memory schema ensured');
|
||||
} catch (error) {
|
||||
console.error('Failed to ensure logging/memory schema:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private healVectorTablesIfCorrupt(): void {
|
||||
if (this.readOnly) {
|
||||
return;
|
||||
}
|
||||
// Attempt lightweight reads to detect CORRUPT_VTAB; if detected, drop/recreate vtables
|
||||
const tryRead = (table: string) => {
|
||||
try {
|
||||
this.db.prepare(`SELECT COUNT(*) as c FROM ${table}`).get();
|
||||
} catch (e: any) {
|
||||
const msg = String(e?.message || '');
|
||||
const code = (e && e.code) ? String(e.code) : '';
|
||||
if (code === 'SQLITE_CORRUPT_VTAB' || msg.includes('database disk image is malformed') || msg.includes('CORRUPT_VTAB')) {
|
||||
console.warn(`Detected corrupted virtual table ${table} (${code || 'error'}). Recreating...`);
|
||||
try {
|
||||
this.db.exec(`DROP TABLE IF EXISTS ${table};`);
|
||||
} catch {}
|
||||
const ddl = table === 'vec_nodes'
|
||||
? `CREATE VIRTUAL TABLE vec_nodes USING vec0(node_id INTEGER PRIMARY KEY, embedding FLOAT[1536]);`
|
||||
: `CREATE VIRTUAL TABLE vec_chunks USING vec0(chunk_id INTEGER PRIMARY KEY, embedding FLOAT[1536]);`;
|
||||
try {
|
||||
this.db.exec(ddl);
|
||||
console.log(`Recreated ${table} virtual table`);
|
||||
} catch (re) {
|
||||
console.error(`Failed to recreate ${table}:`, re);
|
||||
}
|
||||
} else {
|
||||
// Other errors should bubble up normally
|
||||
// eslint-disable-next-line no-unsafe-finally
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
tryRead('vec_nodes');
|
||||
tryRead('vec_chunks');
|
||||
}
|
||||
|
||||
private handleError(error: any): DatabaseError {
|
||||
return {
|
||||
message: error.message || 'SQLite operation failed',
|
||||
code: error.code || 'SQLITE_ERROR',
|
||||
details: error
|
||||
};
|
||||
}
|
||||
|
||||
public close(): void {
|
||||
this.db.close();
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance (similar to PostgreSQL client interface)
|
||||
export const sqliteDb = SQLiteClient.getInstance();
|
||||
|
||||
// Export function to get client instance
|
||||
export const getSQLiteClient = () => sqliteDb;
|
||||
|
||||
// Export class for testing
|
||||
export { SQLiteClient };
|
||||
@@ -0,0 +1,113 @@
|
||||
import { embedNodeContent } from '@/services/embedding/ingestion';
|
||||
import { nodeService } from '@/services/database';
|
||||
|
||||
interface AutoEmbedTask {
|
||||
nodeId: number;
|
||||
force?: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_COOLDOWN_MS = 2 * 60 * 1000; // 2 minutes between automatic runs per node
|
||||
|
||||
export class AutoEmbedQueue {
|
||||
private readonly queue: number[] = [];
|
||||
private readonly pendingTasks = new Map<number, AutoEmbedTask>();
|
||||
private readonly running = new Set<number>();
|
||||
private readonly lastRunAt = new Map<number, number>();
|
||||
private readonly maxConcurrent = 1;
|
||||
private readonly cooldownMs = DEFAULT_COOLDOWN_MS;
|
||||
|
||||
enqueue(nodeId: number, task: Omit<AutoEmbedTask, 'nodeId'> = {}): boolean {
|
||||
const existing = this.pendingTasks.get(nodeId);
|
||||
if (!existing) {
|
||||
this.pendingTasks.set(nodeId, { nodeId, ...task });
|
||||
this.queue.push(nodeId);
|
||||
} else {
|
||||
existing.force = existing.force || task.force;
|
||||
existing.reason = existing.reason || task.reason;
|
||||
}
|
||||
|
||||
this.processQueue();
|
||||
return true;
|
||||
}
|
||||
|
||||
private processQueue() {
|
||||
if (this.running.size >= this.maxConcurrent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextId = this.queue.shift();
|
||||
if (typeof nextId !== 'number') {
|
||||
return;
|
||||
}
|
||||
|
||||
const task = this.pendingTasks.get(nextId);
|
||||
if (!task) {
|
||||
// Task was removed; try next
|
||||
this.processQueue();
|
||||
return;
|
||||
}
|
||||
this.pendingTasks.delete(nextId);
|
||||
|
||||
const now = Date.now();
|
||||
const lastRun = this.lastRunAt.get(task.nodeId);
|
||||
if (!task.force && lastRun && now - lastRun < this.cooldownMs) {
|
||||
const delay = this.cooldownMs - (now - lastRun);
|
||||
setTimeout(() => this.enqueue(task.nodeId, task), delay);
|
||||
this.processQueue();
|
||||
return;
|
||||
}
|
||||
|
||||
this.running.add(task.nodeId);
|
||||
this.executeTask(task)
|
||||
.catch(error => {
|
||||
console.error('[AutoEmbedQueue] Task failed', task.nodeId, error);
|
||||
})
|
||||
.finally(() => {
|
||||
this.running.delete(task.nodeId);
|
||||
this.lastRunAt.set(task.nodeId, Date.now());
|
||||
if (this.queue.length > 0) {
|
||||
setTimeout(() => this.processQueue(), 10);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async executeTask(task: AutoEmbedTask) {
|
||||
const node = await nodeService.getNodeById(task.nodeId);
|
||||
if (!node) {
|
||||
console.warn('[AutoEmbedQueue] Node missing, skipping', task.nodeId);
|
||||
return;
|
||||
}
|
||||
|
||||
const chunkText = node.chunk?.trim();
|
||||
if (!chunkText) {
|
||||
console.warn('[AutoEmbedQueue] Node has no chunk content, skipping', task.nodeId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!task.force && node.chunk_status === 'chunked') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (node.chunk_status === 'chunking' && !task.force) {
|
||||
console.log('[AutoEmbedQueue] Node already chunking, skipping duplicate run', task.nodeId);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`🔄 [AutoEmbedQueue] Embedding node ${task.nodeId}${task.reason ? ` (${task.reason})` : ''}`);
|
||||
const result = await embedNodeContent(task.nodeId);
|
||||
if (!result.success) {
|
||||
console.error('[AutoEmbedQueue] Embedding failed', task.nodeId, result.error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var autoEmbedQueue: AutoEmbedQueue | undefined;
|
||||
}
|
||||
|
||||
export const autoEmbedQueue = globalThis.autoEmbedQueue ?? new AutoEmbedQueue();
|
||||
if (!globalThis.autoEmbedQueue) {
|
||||
globalThis.autoEmbedQueue = autoEmbedQueue;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export const AUTO_EMBED_MIN_CHARS = 200;
|
||||
|
||||
export function hasSufficientContent(text?: string | null): boolean {
|
||||
if (typeof text !== 'string') {
|
||||
return false;
|
||||
}
|
||||
return text.trim().length >= AUTO_EMBED_MIN_CHARS;
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { nodeService } from '@/services/database';
|
||||
import { NodeEmbedder } from '@/services/typescript/embed-nodes';
|
||||
import { UniversalEmbedder } from '@/services/typescript/embed-universal';
|
||||
import type { Node } from '@/types/database';
|
||||
|
||||
export interface EmbeddingStageStatus {
|
||||
status: 'pending' | 'completed' | 'failed' | 'skipped';
|
||||
message: string;
|
||||
chunks_created?: number;
|
||||
}
|
||||
|
||||
export interface EmbeddingPipelineResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
node_embedding: EmbeddingStageStatus;
|
||||
chunk_embeddings: EmbeddingStageStatus;
|
||||
overall_status: 'pending' | 'fully_embedded' | 'partially_embedded' | 'no_content' | 'failed';
|
||||
}
|
||||
|
||||
interface EmbeddingResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
output?: string;
|
||||
}
|
||||
|
||||
async function runNodeEmbedding(nodeId: number): Promise<EmbeddingResult> {
|
||||
const embedder = new NodeEmbedder();
|
||||
try {
|
||||
const result = await embedder.embedNodes({ nodeId });
|
||||
if (result.processed > 0) {
|
||||
return { success: true, output: `Embedded ${result.processed} nodes` };
|
||||
}
|
||||
if (result.failed > 0) {
|
||||
return { success: false, error: 'Failed to embed node' };
|
||||
}
|
||||
return { success: true, output: 'Node already has embedding' };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Embedding failed'
|
||||
};
|
||||
} finally {
|
||||
embedder.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function updateChunkStatus(nodeId: number, status: Node['chunk_status']) {
|
||||
try {
|
||||
await nodeService.updateNode(nodeId, { chunk_status: status });
|
||||
} catch (error) {
|
||||
console.warn('Failed to update chunk_status for node', nodeId, status, error);
|
||||
}
|
||||
}
|
||||
|
||||
async function runChunkEmbedding(nodeId: number): Promise<EmbeddingResult> {
|
||||
const embedder = new UniversalEmbedder();
|
||||
try {
|
||||
await updateChunkStatus(nodeId, 'chunking');
|
||||
const result = await embedder.processNode({ nodeId });
|
||||
return {
|
||||
success: true,
|
||||
output: `Stored ${result.chunks} chunks`
|
||||
};
|
||||
} catch (error) {
|
||||
await updateChunkStatus(nodeId, 'error');
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Chunking failed'
|
||||
};
|
||||
} finally {
|
||||
embedder.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function embedNodeContent(nodeId: number): Promise<EmbeddingPipelineResult> {
|
||||
const node = await nodeService.getNodeById(nodeId);
|
||||
if (!node) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Node not found',
|
||||
node_embedding: { status: 'failed', message: 'Node not found' },
|
||||
chunk_embeddings: { status: 'failed', message: 'Node not found' },
|
||||
overall_status: 'failed'
|
||||
};
|
||||
}
|
||||
|
||||
const results = {
|
||||
node_embedding: { status: 'pending', message: '' } as EmbeddingStageStatus,
|
||||
chunk_embeddings: { status: 'pending', message: '', chunks_created: 0 } as EmbeddingStageStatus,
|
||||
overall_status: 'pending' as EmbeddingPipelineResult['overall_status']
|
||||
};
|
||||
|
||||
const nodeResult = await runNodeEmbedding(nodeId);
|
||||
if (nodeResult.success) {
|
||||
results.node_embedding = {
|
||||
status: 'completed',
|
||||
message: nodeResult.output || 'Node metadata embedded successfully'
|
||||
};
|
||||
} else {
|
||||
results.node_embedding = {
|
||||
status: 'failed',
|
||||
message: nodeResult.error || 'Failed to embed node metadata'
|
||||
};
|
||||
}
|
||||
|
||||
if (!node.chunk || !node.chunk.trim()) {
|
||||
results.chunk_embeddings = {
|
||||
status: 'skipped',
|
||||
message: 'No chunk content to embed',
|
||||
chunks_created: 0
|
||||
};
|
||||
} else {
|
||||
const chunkResult = await runChunkEmbedding(nodeId);
|
||||
if (chunkResult.success) {
|
||||
const chunkMatch = chunkResult.output?.match(/Stored (\d+) chunks/);
|
||||
const chunksCreated = chunkMatch ? parseInt(chunkMatch[1], 10) : 0;
|
||||
results.chunk_embeddings = {
|
||||
status: 'completed',
|
||||
message: chunkResult.output || 'Chunk content embedded successfully',
|
||||
chunks_created: chunksCreated
|
||||
};
|
||||
} else {
|
||||
results.chunk_embeddings = {
|
||||
status: 'failed',
|
||||
message: chunkResult.error || 'Failed to embed chunk content'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const nodeSuccess = results.node_embedding.status === 'completed';
|
||||
const chunkSuccess = results.chunk_embeddings.status === 'completed';
|
||||
const chunkSkipped = results.chunk_embeddings.status === 'skipped';
|
||||
|
||||
if (nodeSuccess && (chunkSuccess || chunkSkipped)) {
|
||||
results.overall_status = chunkSkipped ? 'no_content' : 'fully_embedded';
|
||||
if (chunkSuccess) {
|
||||
await updateChunkStatus(nodeId, 'chunked');
|
||||
}
|
||||
} else if (nodeSuccess || chunkSuccess) {
|
||||
results.overall_status = 'partially_embedded';
|
||||
} else {
|
||||
results.overall_status = 'failed';
|
||||
}
|
||||
|
||||
const errorParts: string[] = [];
|
||||
if (results.node_embedding.status === 'failed') {
|
||||
errorParts.push(`node: ${results.node_embedding.message}`);
|
||||
}
|
||||
if (results.chunk_embeddings.status === 'failed') {
|
||||
errorParts.push(`chunks: ${results.chunk_embeddings.message}`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: results.overall_status !== 'failed',
|
||||
error: errorParts.length ? errorParts.join('; ') : undefined,
|
||||
...results
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import OpenAI from 'openai';
|
||||
import { apiKeyService } from './storage/apiKeys';
|
||||
|
||||
// Initialize OpenAI client with dynamic API key support
|
||||
function getOpenAiClient(): OpenAI {
|
||||
const apiKey = apiKeyService.getOpenAiKey();
|
||||
if (!apiKey) {
|
||||
throw new Error('OpenAI API key required. Please:\n1. Click the Settings icon (⚙️) in the bottom left\n2. Go to API Keys tab\n3. Add your OpenAI API key\n\nGet your key at: https://platform.openai.com/api-keys');
|
||||
}
|
||||
return new OpenAI({ apiKey });
|
||||
}
|
||||
|
||||
export class EmbeddingService {
|
||||
/**
|
||||
* Generate embedding for a search query using OpenAI's text-embedding-3-small model
|
||||
* This matches the same model used in embed_universal.py for consistency
|
||||
*/
|
||||
static async generateQueryEmbedding(query: string): Promise<number[]> {
|
||||
try {
|
||||
const openai = getOpenAiClient();
|
||||
const response = await openai.embeddings.create({
|
||||
model: "text-embedding-3-small",
|
||||
input: query.trim(),
|
||||
encoding_format: "float"
|
||||
});
|
||||
|
||||
if (!response.data?.[0]?.embedding) {
|
||||
throw new Error('No embedding returned from OpenAI API');
|
||||
}
|
||||
|
||||
return response.data[0].embedding;
|
||||
} catch (error) {
|
||||
console.error('Failed to generate query embedding:', error);
|
||||
throw new Error(`Embedding generation failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate embedding dimensions match expected size (1536 for text-embedding-3-small)
|
||||
*/
|
||||
static validateEmbedding(embedding: number[]): boolean {
|
||||
return Array.isArray(embedding) && embedding.length === 1536;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Event Broadcasting Service for Real-time UI Updates
|
||||
* Manages SSE connections and broadcasts database change events
|
||||
*/
|
||||
|
||||
export interface DatabaseEvent {
|
||||
type:
|
||||
| 'NODE_CREATED'
|
||||
| 'NODE_UPDATED'
|
||||
| 'NODE_DELETED'
|
||||
| 'EDGE_CREATED'
|
||||
| 'EDGE_DELETED'
|
||||
| 'DIMENSION_UPDATED'
|
||||
| 'HELPER_UPDATED'
|
||||
| 'AGENT_UPDATED'
|
||||
| 'AGENT_DELEGATION_CREATED'
|
||||
| 'AGENT_DELEGATION_UPDATED'
|
||||
| 'WORKFLOW_PROGRESS'
|
||||
| 'CONNECTION_ESTABLISHED';
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
data: any;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
class EventBroadcaster {
|
||||
private connections = new Set<ReadableStreamDefaultController>();
|
||||
|
||||
/**
|
||||
* Add a new SSE connection
|
||||
*/
|
||||
addConnection(controller: ReadableStreamDefaultController) {
|
||||
this.connections.add(controller);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an SSE connection
|
||||
*/
|
||||
removeConnection(controller: ReadableStreamDefaultController) {
|
||||
this.connections.delete(controller);
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast an event to all connected clients
|
||||
*/
|
||||
broadcast(event: Omit<DatabaseEvent, 'timestamp'>) {
|
||||
console.log(`📡 Broadcasting ${event.type} to ${this.connections.size} connections`);
|
||||
|
||||
const eventWithTimestamp: DatabaseEvent = {
|
||||
...event,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
const message = `data: ${JSON.stringify(eventWithTimestamp)}\n\n`;
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(message);
|
||||
|
||||
// Send to all connected clients
|
||||
let successCount = 0;
|
||||
for (const controller of this.connections) {
|
||||
try {
|
||||
controller.enqueue(data);
|
||||
successCount++;
|
||||
} catch (error) {
|
||||
// Connection is closed, remove it
|
||||
console.log('🔌 Removing dead SSE connection');
|
||||
this.connections.delete(controller);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`✅ Broadcasted to ${successCount} active connections`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send keep-alive ping to maintain connections
|
||||
*/
|
||||
sendKeepAlive() {
|
||||
const ping = `: keep-alive\n\n`;
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(ping);
|
||||
|
||||
for (const controller of this.connections) {
|
||||
try {
|
||||
controller.enqueue(data);
|
||||
} catch (error) {
|
||||
this.connections.delete(controller);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get connection count for debugging
|
||||
*/
|
||||
getConnectionCount(): number {
|
||||
return this.connections.size;
|
||||
}
|
||||
}
|
||||
|
||||
// Global singleton instance with proper Next.js dev mode handling
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var eventBroadcaster: EventBroadcaster | undefined;
|
||||
// eslint-disable-next-line no-var
|
||||
var keepAliveInterval: NodeJS.Timeout | undefined;
|
||||
}
|
||||
|
||||
export const eventBroadcaster = globalThis.eventBroadcaster ?? new EventBroadcaster();
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
globalThis.eventBroadcaster = eventBroadcaster;
|
||||
|
||||
// Keep-alive interval (every 30 seconds) - only create once
|
||||
if (!globalThis.keepAliveInterval) {
|
||||
globalThis.keepAliveInterval = setInterval(() => {
|
||||
eventBroadcaster.sendKeepAlive();
|
||||
}, 30000);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import { Node } from '@/types/database';
|
||||
import { AgentRegistry } from '@/services/agents/registry';
|
||||
import { WorkflowRegistry } from '@/services/workflows/registry';
|
||||
import { getHelperTools, getDefaultToolNamesForRole } from '@/tools/infrastructure/registry';
|
||||
import type { CacheableBlock, SystemPromptResult } from '@/types/prompts';
|
||||
import { buildAutoContextBlock } from '@/services/context/autoContext';
|
||||
|
||||
export interface NodeContext {
|
||||
nodes: Node[];
|
||||
activeNodeId: number | null;
|
||||
}
|
||||
|
||||
export interface ContextBuilderOptions {
|
||||
maxPrimaryContent?: number; // Default: 500 chars
|
||||
maxSecondaryContent?: number; // Default: 200 chars
|
||||
includeMetadata?: boolean; // Include created/updated timestamps
|
||||
}
|
||||
|
||||
const BASE_CONTEXT = `=== RA-H BASE CONTEXT ===
|
||||
- Nodes store content (title, content, dimensions, metadata, link, chunk)
|
||||
- Edges capture directed relationships between nodes
|
||||
- When auto-context is enabled you'll see BACKGROUND CONTEXT with the 10 most-connected nodes (ID + title only)
|
||||
- Focused nodes show truncated content; use queryNodes, searchContentEmbeddings, or queryEdge when you need full detail
|
||||
- Node references must use [NODE:id:"title"] so the UI renders clickable labels
|
||||
- Pronouns or phrases like "this conversation/paper/video" refer to the primary focused node unless clarified
|
||||
`;
|
||||
|
||||
function buildStaticBaseContext(): string {
|
||||
return BASE_CONTEXT;
|
||||
}
|
||||
|
||||
function buildAgentInstructionsBlock(helperKey: string, systemPrompt: string): string {
|
||||
return `=== AGENT INSTRUCTIONS (${helperKey}) ===\n${systemPrompt}\n`;
|
||||
}
|
||||
|
||||
function isPrimaryOrchestrator(helperKey: string): boolean {
|
||||
return helperKey === 'ra-h' || helperKey === 'ra-h-easy';
|
||||
}
|
||||
|
||||
function buildToolDefinitionsBlock(toolNames: string[]): string {
|
||||
if (!Array.isArray(toolNames) || toolNames.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const tools = getHelperTools(toolNames);
|
||||
const lines: string[] = ['=== AVAILABLE TOOLS ==='];
|
||||
|
||||
Object.entries(tools).forEach(([name, tool]) => {
|
||||
if (tool?.description) {
|
||||
lines.push(`\n## ${name}\n${tool.description.trim()}`);
|
||||
}
|
||||
});
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
async function buildWorkflowDefinitionsBlock(helperKey: string): Promise<string | null> {
|
||||
// Only include for primary orchestrator variants
|
||||
if (!isPrimaryOrchestrator(helperKey)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const workflows = await WorkflowRegistry.getEnabledWorkflows();
|
||||
if (workflows.length === 0) return null;
|
||||
|
||||
const lines: string[] = ['=== AVAILABLE WORKFLOWS ==='];
|
||||
|
||||
for (const workflow of workflows) {
|
||||
lines.push(`\n## ${workflow.displayName} (${workflow.key})`);
|
||||
lines.push(workflow.description);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
} catch (error) {
|
||||
console.warn('Workflow definitions load failed (contextBuilder):', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function truncateToWords(text: string, maxWords: number): string {
|
||||
const words = text.trim().split(/\s+/);
|
||||
if (words.length <= maxWords) return text;
|
||||
return words.slice(0, maxWords).join(' ') + '…';
|
||||
}
|
||||
|
||||
function parseMetadata(metadata: unknown): Record<string, any> {
|
||||
if (!metadata) return {};
|
||||
if (typeof metadata === 'string') {
|
||||
try {
|
||||
return JSON.parse(metadata);
|
||||
} catch (error) {
|
||||
console.warn('Failed to parse node metadata JSON:', error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
if (typeof metadata === 'object') {
|
||||
return metadata as Record<string, any>;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function describeChunkStatus(node: Node): string {
|
||||
const status = node.chunk_status || 'unknown';
|
||||
const chunkLength = typeof node.chunk === 'string' ? node.chunk.length : 0;
|
||||
const approxChars = chunkLength > 0 ? ` (~${Math.max(1, Math.round(chunkLength / 1000))}k chars)` : '';
|
||||
const metadata = parseMetadata(node.metadata);
|
||||
const transcriptLength = typeof metadata.transcript_length === 'number'
|
||||
? metadata.transcript_length
|
||||
: undefined;
|
||||
let transcriptLabel = '';
|
||||
if (transcriptLength && transcriptLength > 0) {
|
||||
transcriptLabel = `, transcript ≈${Math.max(1, Math.round(transcriptLength / 1000))}k chars`;
|
||||
}
|
||||
const embeddingsAvailable = status === 'chunked' || chunkLength > 0;
|
||||
return `Chunks: ${status}${approxChars}${transcriptLabel}; Embeddings: ${embeddingsAvailable ? 'available' : 'missing'}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the dynamic focused nodes context section
|
||||
*/
|
||||
export function buildFocusedNodesBlock(
|
||||
context: NodeContext,
|
||||
options: ContextBuilderOptions = {}
|
||||
): string {
|
||||
if (!context.nodes || context.nodes.length === 0) {
|
||||
return '\n=== CURRENT FOCUS ===\nNo nodes currently in focus.';
|
||||
}
|
||||
|
||||
let contextString = '=== CURRENT FOCUS ===';
|
||||
contextString += '\n25-word previews; use queryNodes/searchContentEmbeddings for full detail\n';
|
||||
|
||||
const validNodes = context.nodes.filter(n => n != null);
|
||||
const activeNode = validNodes.find(n => n.id === context.activeNodeId);
|
||||
const otherNodes = validNodes.filter(n => n.id !== context.activeNodeId);
|
||||
|
||||
if (activeNode) {
|
||||
contextString += `\n[PRIMARY - Tab 1]\nID: ${activeNode.id} | "${activeNode.title || 'Untitled'}"\n${truncateToWords(activeNode.content || 'No content', 25)}\nLink: ${activeNode.link || 'No link'}`;
|
||||
contextString += `\n${describeChunkStatus(activeNode)}`;
|
||||
}
|
||||
|
||||
if (otherNodes.length > 0) {
|
||||
otherNodes.forEach((node, index) => {
|
||||
contextString += `\n\n[Tab ${index + 2}]\nID: ${node.id} | "${node.title || 'Untitled'}"\n${truncateToWords(node.content || 'No content', 25)}\nLink: ${node.link || 'No link'}\n${describeChunkStatus(node)}`;
|
||||
});
|
||||
}
|
||||
|
||||
contextString += '\n===================================';
|
||||
return contextString;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds system prompt as cacheable blocks (Anthropic prompt caching)
|
||||
*/
|
||||
export async function buildSystemPromptBlocks(
|
||||
nodeContext: NodeContext,
|
||||
helperComponentKey: string,
|
||||
options?: ContextBuilderOptions
|
||||
): Promise<SystemPromptResult> {
|
||||
const helper = await AgentRegistry.getAgentByKey(helperComponentKey);
|
||||
const isAnthropic = helper?.model?.startsWith('anthropic/');
|
||||
const cacheControl = isAnthropic ? { type: 'ephemeral' as const } : undefined;
|
||||
const blocks: CacheableBlock[] = [];
|
||||
|
||||
const baseContext = buildStaticBaseContext().trim();
|
||||
const helperPrompt = helper?.systemPrompt || 'No instructions provided.';
|
||||
const instructionsBlock = buildAgentInstructionsBlock(helperComponentKey, helperPrompt).trim();
|
||||
|
||||
const combinedInstructions = [baseContext, instructionsBlock]
|
||||
.filter(section => section.length > 0)
|
||||
.join('\n\n');
|
||||
|
||||
if (combinedInstructions.length > 0) {
|
||||
blocks.push({
|
||||
type: 'text',
|
||||
text: combinedInstructions,
|
||||
...(cacheControl ? { cache_control: cacheControl } : {})
|
||||
});
|
||||
}
|
||||
|
||||
const availableToolNames = helper?.availableTools?.length
|
||||
? helper.availableTools
|
||||
: getDefaultToolNamesForRole(helper?.role === 'executor' ? 'executor' : 'orchestrator');
|
||||
const toolBlock = buildToolDefinitionsBlock(availableToolNames);
|
||||
if (toolBlock.trim().length > 0) {
|
||||
blocks.push({
|
||||
type: 'text',
|
||||
text: toolBlock,
|
||||
...(cacheControl ? { cache_control: cacheControl } : {})
|
||||
});
|
||||
}
|
||||
|
||||
const workflowBlock = await buildWorkflowDefinitionsBlock(helperComponentKey);
|
||||
if (workflowBlock && workflowBlock.trim().length > 0) {
|
||||
blocks.push({
|
||||
type: 'text',
|
||||
text: workflowBlock,
|
||||
...(cacheControl ? { cache_control: cacheControl } : {})
|
||||
});
|
||||
}
|
||||
|
||||
const autoContextBlock = isPrimaryOrchestrator(helperComponentKey)
|
||||
? buildAutoContextBlock()
|
||||
: null;
|
||||
if (autoContextBlock && autoContextBlock.trim().length > 0) {
|
||||
blocks.push({
|
||||
type: 'text',
|
||||
text: autoContextBlock,
|
||||
...(cacheControl ? { cache_control: cacheControl } : {})
|
||||
});
|
||||
}
|
||||
|
||||
const focusBlock = buildFocusedNodesBlock(nodeContext, options);
|
||||
blocks.push({ type: 'text', text: focusBlock });
|
||||
|
||||
return { blocks, cacheHit: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy string-based system prompt (for backward compatibility)
|
||||
* @deprecated Use buildSystemPromptBlocks for Anthropic caching support
|
||||
*/
|
||||
export async function buildSystemPrompt(
|
||||
nodeContext: NodeContext,
|
||||
helperComponentKey: string,
|
||||
options?: ContextBuilderOptions
|
||||
): Promise<{ systemPrompt: string; cacheHit: boolean }> {
|
||||
// Convert blocks to string for legacy callers
|
||||
const result = await buildSystemPromptBlocks(nodeContext, helperComponentKey, options);
|
||||
const systemPrompt = result.blocks.map(b => b.text).join('');
|
||||
return { systemPrompt, cacheHit: result.cacheHit };
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads helper instructions from JSON file
|
||||
*/
|
||||
// DB-backed; no-op placeholder kept for API stability if imported elsewhere
|
||||
@@ -0,0 +1,126 @@
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
interface LogEntry {
|
||||
timestamp: string;
|
||||
helper: string;
|
||||
type: 'USER_MESSAGE' | 'SYSTEM_PROMPT' | 'TOOL_CALL' | 'TOOL_RESULT' | 'ASSISTANT_RESPONSE' | 'ERROR';
|
||||
content: any;
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
const LOG_DIR = path.join(process.cwd(), 'logs');
|
||||
const LOG_FILE = path.join(LOG_DIR, 'helper-interactions.log');
|
||||
|
||||
class HelperLogger {
|
||||
private sessionId: string;
|
||||
private logDirEnsured = false;
|
||||
|
||||
constructor() {
|
||||
this.sessionId = Date.now().toString();
|
||||
}
|
||||
|
||||
private async ensureLogDir() {
|
||||
if (this.logDirEnsured) return;
|
||||
try {
|
||||
await fs.mkdir(LOG_DIR, { recursive: true });
|
||||
this.logDirEnsured = true;
|
||||
} catch (error) {
|
||||
console.error('Failed to create log directory:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private async writeLog(entry: LogEntry) {
|
||||
try {
|
||||
await this.ensureLogDir();
|
||||
const logLine = JSON.stringify(entry) + '\n';
|
||||
await fs.appendFile(LOG_FILE, logLine);
|
||||
} catch (error) {
|
||||
console.error('Failed to write log:', error);
|
||||
}
|
||||
}
|
||||
|
||||
logUserMessage(helper: string, messages: any[], openTabs: any[], activeTabId: any) {
|
||||
const entry: LogEntry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
helper,
|
||||
type: 'USER_MESSAGE',
|
||||
content: {
|
||||
lastMessage: messages[messages.length - 1],
|
||||
messageCount: messages.length,
|
||||
openTabIds: openTabs.map(t => t.id),
|
||||
activeTabId
|
||||
},
|
||||
sessionId: this.sessionId
|
||||
};
|
||||
this.writeLog(entry);
|
||||
console.log(`✓ [${helper}] Request received`);
|
||||
}
|
||||
|
||||
logSystemPrompt(helper: string, systemPrompt: string, cacheHit: boolean) {
|
||||
const entry: LogEntry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
helper,
|
||||
type: 'SYSTEM_PROMPT',
|
||||
content: {
|
||||
systemPrompt,
|
||||
cacheHit
|
||||
},
|
||||
sessionId: this.sessionId
|
||||
};
|
||||
this.writeLog(entry);
|
||||
}
|
||||
|
||||
logToolCall(helper: string, toolName: string, parameters: any) {
|
||||
const entry: LogEntry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
helper,
|
||||
type: 'TOOL_CALL',
|
||||
content: { toolName, parameters },
|
||||
sessionId: this.sessionId
|
||||
};
|
||||
this.writeLog(entry);
|
||||
}
|
||||
|
||||
logToolResult(helper: string, toolName: string, result: any) {
|
||||
const entry: LogEntry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
helper,
|
||||
type: 'TOOL_RESULT',
|
||||
content: {
|
||||
toolName,
|
||||
result
|
||||
},
|
||||
sessionId: this.sessionId
|
||||
};
|
||||
this.writeLog(entry);
|
||||
}
|
||||
|
||||
logAssistantResponse(helper: string, response: string) {
|
||||
const entry: LogEntry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
helper,
|
||||
type: 'ASSISTANT_RESPONSE',
|
||||
content: { response },
|
||||
sessionId: this.sessionId
|
||||
};
|
||||
this.writeLog(entry);
|
||||
}
|
||||
|
||||
logError(helper: string, error: Error | string) {
|
||||
const entry: LogEntry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
helper,
|
||||
type: 'ERROR',
|
||||
content: error instanceof Error ? {
|
||||
message: error.message,
|
||||
stack: error.stack
|
||||
} : { message: error },
|
||||
sessionId: this.sessionId
|
||||
};
|
||||
this.writeLog(entry);
|
||||
console.error(`❌ [${helper}] Error occurred`);
|
||||
}
|
||||
}
|
||||
|
||||
export const helperLogger = new HelperLogger();
|
||||
@@ -0,0 +1,124 @@
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { getSQLiteClient } from '@/services/database/sqlite-client';
|
||||
|
||||
export interface AutoContextSettings {
|
||||
autoContextEnabled: boolean;
|
||||
lastPinnedMigration?: string;
|
||||
}
|
||||
|
||||
const SETTINGS_FILE = 'settings.json';
|
||||
const DEFAULT_SETTINGS: AutoContextSettings = {
|
||||
autoContextEnabled: false,
|
||||
};
|
||||
|
||||
let bootstrapAttempted = false;
|
||||
|
||||
function resolveBaseConfigDir(): string {
|
||||
const override = process.env.RAH_CONFIG_DIR;
|
||||
if (override && override.trim().length > 0) {
|
||||
return override;
|
||||
}
|
||||
|
||||
const home = os.homedir();
|
||||
if (process.platform === 'darwin') {
|
||||
return path.join(home, 'Library', 'Application Support', 'RA-H');
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
const roaming = process.env.APPDATA || path.join(home, 'AppData', 'Roaming');
|
||||
return path.join(roaming, 'RA-H');
|
||||
}
|
||||
|
||||
const xdgConfig = process.env.XDG_CONFIG_HOME || path.join(home, '.config');
|
||||
return path.join(xdgConfig, 'ra-h');
|
||||
}
|
||||
|
||||
function getSettingsDir(): string {
|
||||
return path.join(resolveBaseConfigDir(), 'config');
|
||||
}
|
||||
|
||||
function getSettingsPath(): string {
|
||||
return path.join(getSettingsDir(), SETTINGS_FILE);
|
||||
}
|
||||
|
||||
function ensureSettingsDirExists(): void {
|
||||
const dir = getSettingsDir();
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function writeSettingsFile(settings: AutoContextSettings): AutoContextSettings {
|
||||
ensureSettingsDirExists();
|
||||
fs.writeFileSync(getSettingsPath(), JSON.stringify(settings, null, 2), 'utf-8');
|
||||
return settings;
|
||||
}
|
||||
|
||||
function bootstrapFromLegacyPins(): void {
|
||||
if (bootstrapAttempted) return;
|
||||
bootstrapAttempted = true;
|
||||
|
||||
const settingsPath = getSettingsPath();
|
||||
if (fs.existsSync(settingsPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const db = getSQLiteClient();
|
||||
const countRow = db
|
||||
.query<{ count: number }>('SELECT COUNT(*) as count FROM nodes WHERE is_pinned = 1')
|
||||
.rows[0];
|
||||
const pinnedCount = Number(countRow?.count ?? 0);
|
||||
if (pinnedCount > 0) {
|
||||
writeSettingsFile({
|
||||
autoContextEnabled: true,
|
||||
lastPinnedMigration: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Auto-context pin bootstrap failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export function getAutoContextSettings(): AutoContextSettings {
|
||||
bootstrapFromLegacyPins();
|
||||
const settingsPath = getSettingsPath();
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(settingsPath)) {
|
||||
return { ...DEFAULT_SETTINGS };
|
||||
}
|
||||
|
||||
const raw = fs.readFileSync(settingsPath, 'utf-8');
|
||||
const parsed = JSON.parse(raw);
|
||||
return {
|
||||
...DEFAULT_SETTINGS,
|
||||
...parsed,
|
||||
autoContextEnabled: Boolean(parsed?.autoContextEnabled),
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn('Failed to read auto-context settings, using defaults:', error);
|
||||
return { ...DEFAULT_SETTINGS };
|
||||
}
|
||||
}
|
||||
|
||||
export function updateAutoContextSettings(
|
||||
partial: Partial<AutoContextSettings>
|
||||
): AutoContextSettings {
|
||||
const current = getAutoContextSettings();
|
||||
const next: AutoContextSettings = {
|
||||
...current,
|
||||
...partial,
|
||||
autoContextEnabled:
|
||||
typeof partial.autoContextEnabled === 'boolean'
|
||||
? partial.autoContextEnabled
|
||||
: current.autoContextEnabled,
|
||||
};
|
||||
return writeSettingsFile(next);
|
||||
}
|
||||
|
||||
export function setAutoContextEnabled(enabled: boolean): AutoContextSettings {
|
||||
return updateAutoContextSettings({ autoContextEnabled: enabled });
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
// API Key Storage Service
|
||||
// Handles secure storage and retrieval of user-provided API keys
|
||||
|
||||
export interface ApiKeys {
|
||||
openai?: string;
|
||||
anthropic?: string;
|
||||
}
|
||||
|
||||
export interface ApiKeyStatus {
|
||||
openai: 'connected' | 'failed' | 'testing' | 'not-set';
|
||||
anthropic: 'connected' | 'failed' | 'testing' | 'not-set';
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'ra-h-api-keys';
|
||||
|
||||
export class ApiKeyService {
|
||||
private static instance: ApiKeyService;
|
||||
private keys: ApiKeys = {};
|
||||
private status: ApiKeyStatus = {
|
||||
openai: 'not-set',
|
||||
anthropic: 'not-set'
|
||||
};
|
||||
|
||||
static getInstance(): ApiKeyService {
|
||||
if (!ApiKeyService.instance) {
|
||||
ApiKeyService.instance = new ApiKeyService();
|
||||
}
|
||||
return ApiKeyService.instance;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
this.loadKeys();
|
||||
}
|
||||
|
||||
private notifyUpdate(): void {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent('api-keys:updated'));
|
||||
}
|
||||
}
|
||||
|
||||
// Load keys from localStorage
|
||||
private loadKeys(): void {
|
||||
try {
|
||||
if (typeof window !== 'undefined') {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
this.keys = JSON.parse(stored);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to load API keys from storage:', error);
|
||||
this.keys = {};
|
||||
}
|
||||
}
|
||||
|
||||
// Save keys to localStorage
|
||||
private saveKeys(): void {
|
||||
try {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.keys));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save API keys to storage:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Get OpenAI API key (user key or fallback to env)
|
||||
getOpenAiKey(): string | undefined {
|
||||
// Priority: User key > Environment key
|
||||
return this.keys.openai || process.env.OPENAI_API_KEY;
|
||||
}
|
||||
|
||||
// Get Anthropic API key (user key or fallback to env)
|
||||
getAnthropicKey(): string | undefined {
|
||||
// Priority: User key > Environment key
|
||||
return this.keys.anthropic || process.env.ANTHROPIC_API_KEY;
|
||||
}
|
||||
|
||||
// Set OpenAI API key
|
||||
setOpenAiKey(key: string): void {
|
||||
if (this.validateOpenAiKey(key)) {
|
||||
this.keys.openai = key;
|
||||
this.saveKeys();
|
||||
this.notifyUpdate();
|
||||
} else {
|
||||
throw new Error('Invalid OpenAI API key format');
|
||||
}
|
||||
}
|
||||
|
||||
// Set Anthropic API key
|
||||
setAnthropicKey(key: string): void {
|
||||
if (this.validateAnthropicKey(key)) {
|
||||
this.keys.anthropic = key;
|
||||
this.saveKeys();
|
||||
this.notifyUpdate();
|
||||
} else {
|
||||
throw new Error('Invalid Anthropic API key format');
|
||||
}
|
||||
}
|
||||
|
||||
// Clear specific key
|
||||
clearOpenAiKey(): void {
|
||||
delete this.keys.openai;
|
||||
this.saveKeys();
|
||||
this.status.openai = 'not-set';
|
||||
this.notifyUpdate();
|
||||
}
|
||||
|
||||
clearAnthropicKey(): void {
|
||||
delete this.keys.anthropic;
|
||||
this.saveKeys();
|
||||
this.status.anthropic = 'not-set';
|
||||
this.notifyUpdate();
|
||||
}
|
||||
|
||||
// Clear all keys
|
||||
clearAllKeys(): void {
|
||||
this.keys = {};
|
||||
this.saveKeys();
|
||||
this.status = {
|
||||
openai: 'not-set',
|
||||
anthropic: 'not-set'
|
||||
};
|
||||
this.notifyUpdate();
|
||||
}
|
||||
|
||||
// Get masked key for display (show only last 4 characters)
|
||||
getMaskedKey(provider: 'openai' | 'anthropic'): string {
|
||||
const key = provider === 'openai' ? this.keys.openai : this.keys.anthropic;
|
||||
if (!key) return '';
|
||||
return '••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••' + key.slice(-4);
|
||||
}
|
||||
|
||||
// Check if user has provided custom keys
|
||||
hasUserKeys(): boolean {
|
||||
return !!(this.keys.openai || this.keys.anthropic);
|
||||
}
|
||||
|
||||
// Get current keys (for internal use)
|
||||
getStoredKeys(): ApiKeys {
|
||||
return { ...this.keys };
|
||||
}
|
||||
|
||||
// Validate OpenAI key format
|
||||
private validateOpenAiKey(key: string): boolean {
|
||||
return typeof key === 'string' &&
|
||||
key.length > 20 &&
|
||||
(key.startsWith('sk-') || key.startsWith('sk-proj-'));
|
||||
}
|
||||
|
||||
// Validate Anthropic key format
|
||||
private validateAnthropicKey(key: string): boolean {
|
||||
return typeof key === 'string' &&
|
||||
key.length > 20 &&
|
||||
key.startsWith('sk-ant-');
|
||||
}
|
||||
|
||||
// Test connection to OpenAI
|
||||
async testOpenAiConnection(key?: string): Promise<boolean> {
|
||||
const testKey = key || this.getOpenAiKey();
|
||||
if (!testKey) return false;
|
||||
|
||||
this.status.openai = 'testing';
|
||||
|
||||
try {
|
||||
const response = await fetch('https://api.openai.com/v1/models', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${testKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
const isConnected = response.ok;
|
||||
this.status.openai = isConnected ? 'connected' : 'failed';
|
||||
return isConnected;
|
||||
} catch (error) {
|
||||
console.error('OpenAI connection test failed:', error);
|
||||
this.status.openai = 'failed';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Test connection to Anthropic
|
||||
async testAnthropicConnection(key?: string): Promise<boolean> {
|
||||
const testKey = key || this.getAnthropicKey();
|
||||
if (!testKey) return false;
|
||||
this.status.anthropic = 'testing';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/local/test-anthropic', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ apiKey: testKey }),
|
||||
});
|
||||
const data = await response.json();
|
||||
const isConnected = Boolean(data?.ok);
|
||||
this.status.anthropic = isConnected ? 'connected' : 'failed';
|
||||
return isConnected;
|
||||
} catch (error) {
|
||||
console.error('Anthropic connection test failed:', error);
|
||||
this.status.anthropic = 'failed';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Get connection status
|
||||
getStatus(): ApiKeyStatus {
|
||||
return { ...this.status };
|
||||
}
|
||||
|
||||
// Update status
|
||||
updateStatus(provider: 'openai' | 'anthropic', status: ApiKeyStatus['openai']): void {
|
||||
this.status[provider] = status;
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const apiKeyService = ApiKeyService.getInstance();
|
||||
@@ -0,0 +1,32 @@
|
||||
type Entry = { data: any; ts: number };
|
||||
|
||||
class ResultCache {
|
||||
private store = new Map<string, Entry>();
|
||||
private ttlMs = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
set(id: string, data: any) {
|
||||
if (!id) return;
|
||||
this.store.set(id, { data, ts: Date.now() });
|
||||
this.gc();
|
||||
}
|
||||
|
||||
get(id: string): any | null {
|
||||
const e = this.store.get(id);
|
||||
if (!e) return null;
|
||||
if (Date.now() - e.ts > this.ttlMs) {
|
||||
this.store.delete(id);
|
||||
return null;
|
||||
}
|
||||
return e.data;
|
||||
}
|
||||
|
||||
private gc() {
|
||||
const now = Date.now();
|
||||
for (const [k, v] of this.store.entries()) {
|
||||
if (now - v.ts > this.ttlMs) this.store.delete(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const resultCache = new ResultCache();
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* Node metadata embedding service for RA-H knowledge management system
|
||||
* Embeds node metadata (title, content, dimensions, AI analysis) into nodes.embedding field
|
||||
*/
|
||||
|
||||
import OpenAI from 'openai';
|
||||
import { generateText } from 'ai';
|
||||
import { createOpenAI } from '@ai-sdk/openai';
|
||||
import {
|
||||
createDatabaseConnection,
|
||||
serializeFloat32Vector,
|
||||
formatEmbeddingText,
|
||||
batchProcess
|
||||
} from './sqlite-vec';
|
||||
|
||||
interface NodeRecord {
|
||||
id: number;
|
||||
title: string;
|
||||
content: string | null;
|
||||
dimensions_json: string;
|
||||
embedding?: Buffer | null;
|
||||
embedding_updated_at?: string | null;
|
||||
embedding_text?: string | null;
|
||||
}
|
||||
|
||||
interface EmbedNodeOptions {
|
||||
nodeId?: number;
|
||||
forceReEmbed?: boolean;
|
||||
verbose?: boolean;
|
||||
}
|
||||
|
||||
export class NodeEmbedder {
|
||||
private openaiClient: OpenAI;
|
||||
private openaiProvider: ReturnType<typeof createOpenAI>;
|
||||
private db: ReturnType<typeof createDatabaseConnection>;
|
||||
private processedCount: number = 0;
|
||||
private failedCount: number = 0;
|
||||
|
||||
constructor() {
|
||||
const apiKey = process.env.OPENAI_API_KEY;
|
||||
if (!apiKey) {
|
||||
throw new Error('OPENAI_API_KEY environment variable is not set');
|
||||
}
|
||||
|
||||
this.openaiClient = new OpenAI({ apiKey });
|
||||
this.openaiProvider = createOpenAI({ apiKey });
|
||||
this.db = createDatabaseConnection();
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze node content with AI to extract insights
|
||||
*/
|
||||
private async analyzeNodeWithAI(node: NodeRecord): Promise<string> {
|
||||
const dimensions = node.dimensions_json ? JSON.parse(node.dimensions_json) : [];
|
||||
const dimensionsText = Array.isArray(dimensions) && dimensions.length ? dimensions.join(', ') : 'none';
|
||||
|
||||
const prompt = `Analyze this content and provide 2-3 key insights or themes in a concise paragraph (max 100 words):
|
||||
|
||||
Title: ${node.title}
|
||||
Content: ${node.content || 'No content'}
|
||||
Dimensions: ${dimensionsText}
|
||||
|
||||
Focus on the main concepts, key relationships, and practical implications.`;
|
||||
|
||||
try {
|
||||
const { text } = await generateText({
|
||||
model: this.openaiProvider('gpt-4o-mini'),
|
||||
prompt,
|
||||
maxOutputTokens: 150,
|
||||
temperature: 0.3,
|
||||
});
|
||||
|
||||
return text;
|
||||
} catch (error) {
|
||||
console.error(`AI analysis failed for node ${node.id}:`, error);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate embedding for text using OpenAI
|
||||
*/
|
||||
private async generateEmbedding(text: string): Promise<number[]> {
|
||||
const response = await this.openaiClient.embeddings.create({
|
||||
model: 'text-embedding-3-small',
|
||||
input: text,
|
||||
});
|
||||
|
||||
return response.data[0].embedding;
|
||||
}
|
||||
|
||||
/**
|
||||
* Embed a single node
|
||||
*/
|
||||
private async embedNode(node: NodeRecord, forceReEmbed: boolean = false): Promise<void> {
|
||||
// Skip if already embedded and not forcing
|
||||
if (node.embedding && !forceReEmbed) {
|
||||
console.log(`Skipping node ${node.id} - already has embedding`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse dimensions from JSON string
|
||||
const dimensions = node.dimensions_json ? JSON.parse(node.dimensions_json) : [];
|
||||
|
||||
// Create base embedding text
|
||||
let embeddingText = formatEmbeddingText(
|
||||
node.title,
|
||||
node.content || '',
|
||||
dimensions
|
||||
);
|
||||
|
||||
// Add AI analysis if content exists
|
||||
if (node.content && node.content.trim().length > 0) {
|
||||
const analysis = await this.analyzeNodeWithAI(node);
|
||||
if (analysis) {
|
||||
embeddingText += `\n\nAI Analysis: ${analysis}`;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Generate embedding
|
||||
const embedding = await this.generateEmbedding(embeddingText);
|
||||
const embeddingBlob = serializeFloat32Vector(embedding);
|
||||
|
||||
// Update database
|
||||
const updateStmt = this.db.prepare(`
|
||||
UPDATE nodes
|
||||
SET embedding = ?,
|
||||
embedding_updated_at = ?,
|
||||
embedding_text = ?
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
const now = new Date().toISOString();
|
||||
updateStmt.run(embeddingBlob, now, embeddingText, node.id);
|
||||
|
||||
// Update vec_nodes virtual table
|
||||
try {
|
||||
// Determine correct column name for primary key (node_id vs id)
|
||||
// Use declared PK column from your DB schema (confirmed: node_id)
|
||||
const pkCol = 'node_id';
|
||||
|
||||
// Delete existing entry if any
|
||||
const deleteStmt = this.db.prepare(`DELETE FROM vec_nodes WHERE ${pkCol} = ?`);
|
||||
deleteStmt.run(BigInt(node.id));
|
||||
|
||||
// Insert new entry (use bracketed string format compatible with sqlite-vec)
|
||||
const vectorString = `[${embedding.join(',')}]`;
|
||||
const insertStmt = this.db.prepare(`INSERT INTO vec_nodes (${pkCol}, embedding) VALUES (?, ?)`);
|
||||
insertStmt.run(BigInt(node.id), vectorString);
|
||||
} catch (vecError) {
|
||||
console.warn(`Could not update vec_nodes for node ${node.id}:`, vecError);
|
||||
// Continue - main embedding is still saved
|
||||
}
|
||||
|
||||
this.processedCount++;
|
||||
console.log(`✓ Embedded node ${node.id}: "${node.title}"`);
|
||||
|
||||
} catch (error) {
|
||||
this.failedCount++;
|
||||
console.error(`✗ Failed to embed node ${node.id}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Embed nodes based on options
|
||||
*/
|
||||
async embedNodes(options: EmbedNodeOptions = {}): Promise<{ processed: number; failed: number }> {
|
||||
const { nodeId, forceReEmbed = false, verbose = false } = options;
|
||||
|
||||
let query: string;
|
||||
let params: any[] = [];
|
||||
|
||||
if (nodeId) {
|
||||
// Single node
|
||||
query = `
|
||||
SELECT n.id, n.title, n.content,
|
||||
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
|
||||
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json,
|
||||
n.embedding, n.embedding_updated_at
|
||||
FROM nodes n
|
||||
WHERE n.id = ?
|
||||
`;
|
||||
params = [nodeId];
|
||||
} else if (forceReEmbed) {
|
||||
// All nodes
|
||||
query = `
|
||||
SELECT n.id, n.title, n.content,
|
||||
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
|
||||
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json,
|
||||
n.embedding, n.embedding_updated_at
|
||||
FROM nodes n
|
||||
ORDER BY n.id
|
||||
`;
|
||||
} else {
|
||||
// Only nodes without embeddings
|
||||
query = `
|
||||
SELECT n.id, n.title, n.content,
|
||||
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
|
||||
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json,
|
||||
n.embedding, n.embedding_updated_at
|
||||
FROM nodes n
|
||||
WHERE n.embedding IS NULL OR n.embedding_updated_at IS NULL
|
||||
ORDER BY n.id
|
||||
`;
|
||||
}
|
||||
|
||||
const stmt = this.db.prepare(query);
|
||||
const nodes = stmt.all(...params) as NodeRecord[];
|
||||
|
||||
if (nodes.length === 0) {
|
||||
console.log('No nodes to process');
|
||||
return { processed: 0, failed: 0 };
|
||||
}
|
||||
|
||||
console.log(`Processing ${nodes.length} nodes...`);
|
||||
|
||||
// Process in batches
|
||||
await batchProcess(
|
||||
nodes,
|
||||
async (node) => {
|
||||
try {
|
||||
await this.embedNode(node, forceReEmbed);
|
||||
} catch (error) {
|
||||
// Error already logged in embedNode
|
||||
}
|
||||
},
|
||||
5, // Batch size
|
||||
verbose ? (processed, total) => {
|
||||
console.log(`Progress: ${processed}/${total} nodes`);
|
||||
} : undefined
|
||||
);
|
||||
|
||||
console.log(`\nComplete! Processed: ${this.processedCount}, Failed: ${this.failedCount}`);
|
||||
|
||||
return {
|
||||
processed: this.processedCount,
|
||||
failed: this.failedCount
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Close database connection
|
||||
*/
|
||||
close(): void {
|
||||
this.db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CLI interface for direct execution
|
||||
*/
|
||||
export async function runCLI(args: string[]): Promise<void> {
|
||||
const nodeId = args.includes('--node-id')
|
||||
? parseInt(args[args.indexOf('--node-id') + 1])
|
||||
: undefined;
|
||||
|
||||
const forceReEmbed = args.includes('--force');
|
||||
const verbose = args.includes('--verbose');
|
||||
|
||||
const embedder = new NodeEmbedder();
|
||||
|
||||
try {
|
||||
await embedder.embedNodes({ nodeId, forceReEmbed, verbose });
|
||||
} finally {
|
||||
embedder.close();
|
||||
}
|
||||
}
|
||||
|
||||
// Run if called directly (for testing)
|
||||
if (require.main === module) {
|
||||
runCLI(process.argv.slice(2)).catch(error => {
|
||||
console.error('Error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* Universal chunking and embedding service for RA-H knowledge management system
|
||||
* Takes a node_id, reads chunk content from nodes table, chunks it, and stores in chunks table
|
||||
*/
|
||||
|
||||
import OpenAI from 'openai';
|
||||
import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters';
|
||||
import {
|
||||
createDatabaseConnection,
|
||||
serializeFloat32Vector,
|
||||
batchProcess
|
||||
} from './sqlite-vec';
|
||||
|
||||
interface Node {
|
||||
id: number;
|
||||
title: string;
|
||||
chunk: string | null;
|
||||
chunk_status?: string | null;
|
||||
}
|
||||
|
||||
interface ChunkData {
|
||||
content: string;
|
||||
metadata: {
|
||||
node_id: number;
|
||||
chunk_index: number;
|
||||
start_char: number;
|
||||
end_char: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface EmbedUniversalOptions {
|
||||
nodeId: number;
|
||||
verbose?: boolean;
|
||||
}
|
||||
|
||||
export class UniversalEmbedder {
|
||||
private openaiClient: OpenAI;
|
||||
private db: ReturnType<typeof createDatabaseConnection>;
|
||||
private textSplitter: RecursiveCharacterTextSplitter;
|
||||
private vecChunksInsertSQL: string | null = null;
|
||||
|
||||
constructor() {
|
||||
const apiKey = process.env.OPENAI_API_KEY;
|
||||
if (!apiKey) {
|
||||
throw new Error('OPENAI_API_KEY environment variable is not set');
|
||||
}
|
||||
|
||||
this.openaiClient = new OpenAI({ apiKey });
|
||||
this.db = createDatabaseConnection();
|
||||
|
||||
// Configure text splitter (same as old KMS system)
|
||||
this.textSplitter = new RecursiveCharacterTextSplitter({
|
||||
chunkSize: 1000,
|
||||
chunkOverlap: 200,
|
||||
separators: ["\n\n", "\n", ". ", " ", ""],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine correct insert SQL for vec_chunks based on actual schema
|
||||
*/
|
||||
private resolveVecChunksInsertSQL(): string {
|
||||
// Use declared PK column from your DB schema (confirmed: chunk_id)
|
||||
if (!this.vecChunksInsertSQL) {
|
||||
this.vecChunksInsertSQL = 'INSERT OR REPLACE INTO vec_chunks (chunk_id, embedding) VALUES (?, ?)';
|
||||
}
|
||||
return this.vecChunksInsertSQL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate embedding for text using OpenAI
|
||||
*/
|
||||
private async generateEmbedding(text: string): Promise<number[]> {
|
||||
const response = await this.openaiClient.embeddings.create({
|
||||
model: 'text-embedding-3-small',
|
||||
input: text,
|
||||
});
|
||||
|
||||
return response.data[0].embedding;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete existing chunks for a node
|
||||
*/
|
||||
private deleteExistingChunks(nodeId: number): void {
|
||||
// First, get all chunk IDs for this node
|
||||
const chunkIds = this.db.prepare('SELECT id FROM chunks WHERE node_id = ?').all(nodeId) as Array<{ id: number }>;
|
||||
|
||||
// Delete from vec_chunks first, one by one to ensure they're removed
|
||||
for (const chunk of chunkIds) {
|
||||
try {
|
||||
const deleteVecStmt = this.db.prepare('DELETE FROM vec_chunks WHERE chunk_id = ?');
|
||||
deleteVecStmt.run(BigInt(chunk.id));
|
||||
} catch (error) {
|
||||
console.warn(`Could not delete vec_chunk ${chunk.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Then delete from chunks table
|
||||
const deleteChunksStmt = this.db.prepare('DELETE FROM chunks WHERE node_id = ?');
|
||||
deleteChunksStmt.run(nodeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a chunk with its embedding
|
||||
*/
|
||||
private async storeChunk(
|
||||
nodeId: number,
|
||||
chunkContent: string,
|
||||
chunkIndex: number,
|
||||
metadata: any
|
||||
): Promise<void> {
|
||||
// Generate embedding
|
||||
const embedding = await this.generateEmbedding(chunkContent);
|
||||
|
||||
// Insert into chunks table (align with existing schema)
|
||||
const insertStmt = this.db.prepare(`
|
||||
INSERT INTO chunks (node_id, chunk_idx, text, embedding_type, metadata, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const result = insertStmt.run(
|
||||
nodeId,
|
||||
chunkIndex,
|
||||
chunkContent,
|
||||
'text-embedding-3-small',
|
||||
JSON.stringify(metadata),
|
||||
now
|
||||
);
|
||||
|
||||
const chunkId = Number(result.lastInsertRowid);
|
||||
|
||||
// Insert into vec_chunks virtual table (use bracketed string format)
|
||||
try {
|
||||
const vectorString = `[${embedding.join(',')}]`;
|
||||
// First try to delete any existing vec_chunk with this ID
|
||||
try {
|
||||
const deleteStmt = this.db.prepare('DELETE FROM vec_chunks WHERE chunk_id = ?');
|
||||
deleteStmt.run(BigInt(chunkId));
|
||||
} catch {}
|
||||
|
||||
// Now insert the new embedding
|
||||
const sql = 'INSERT INTO vec_chunks (chunk_id, embedding) VALUES (?, ?)';
|
||||
const vecInsertStmt = this.db.prepare(sql);
|
||||
vecInsertStmt.run(BigInt(chunkId), vectorString);
|
||||
} catch (error) {
|
||||
console.warn(`Could not insert into vec_chunks for chunk ${chunkId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single node for chunking and embedding
|
||||
*/
|
||||
async processNode(options: EmbedUniversalOptions): Promise<{ chunks: number }> {
|
||||
const { nodeId, verbose = false } = options;
|
||||
|
||||
// Get node data
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT id, title, chunk, chunk_status
|
||||
FROM nodes
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
const node = stmt.get(nodeId) as Node | undefined;
|
||||
|
||||
if (!node) {
|
||||
throw new Error(`Node ${nodeId} not found`);
|
||||
}
|
||||
|
||||
if (!node.chunk || node.chunk.trim().length === 0) {
|
||||
console.log(`Node ${nodeId} has no chunk content to process`);
|
||||
return { chunks: 0 };
|
||||
}
|
||||
|
||||
console.log(`Processing node ${nodeId}: "${node.title}"`);
|
||||
|
||||
// Delete existing chunks
|
||||
this.deleteExistingChunks(nodeId);
|
||||
|
||||
// Split text into chunks
|
||||
const chunks = await this.textSplitter.splitText(node.chunk);
|
||||
|
||||
if (verbose) {
|
||||
console.log(`Split into ${chunks.length} chunks`);
|
||||
}
|
||||
|
||||
// Process each chunk
|
||||
let startChar = 0;
|
||||
await batchProcess(
|
||||
chunks.map((chunkContent, index) => ({ chunkContent, index })),
|
||||
async ({ chunkContent, index }) => {
|
||||
const endChar = startChar + chunkContent.length;
|
||||
|
||||
const metadata = {
|
||||
node_id: nodeId,
|
||||
chunk_index: index,
|
||||
start_char: startChar,
|
||||
end_char: endChar,
|
||||
title: node.title,
|
||||
};
|
||||
|
||||
await this.storeChunk(nodeId, chunkContent, index, metadata);
|
||||
|
||||
if (verbose) {
|
||||
console.log(` Chunk ${index + 1}/${chunks.length}: ${chunkContent.substring(0, 50)}...`);
|
||||
}
|
||||
|
||||
startChar = endChar;
|
||||
},
|
||||
5, // Batch size
|
||||
verbose ? (processed, total) => {
|
||||
console.log(`Embedding progress: ${processed}/${total} chunks`);
|
||||
} : undefined
|
||||
);
|
||||
|
||||
// Update node chunk_status
|
||||
const updateStmt = this.db.prepare(`
|
||||
UPDATE nodes
|
||||
SET chunk_status = 'chunked'
|
||||
WHERE id = ?
|
||||
`);
|
||||
updateStmt.run(nodeId);
|
||||
|
||||
console.log(`✓ Created ${chunks.length} chunks for node ${nodeId}`);
|
||||
|
||||
return { chunks: chunks.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get statistics about chunks in the database
|
||||
*/
|
||||
getStats(): { totalChunks: number; totalNodes: number; avgChunksPerNode: number } {
|
||||
const statsStmt = this.db.prepare(`
|
||||
SELECT
|
||||
COUNT(DISTINCT node_id) as total_nodes,
|
||||
COUNT(*) as total_chunks
|
||||
FROM chunks
|
||||
`);
|
||||
|
||||
const stats = statsStmt.get() as any;
|
||||
|
||||
return {
|
||||
totalChunks: stats.total_chunks || 0,
|
||||
totalNodes: stats.total_nodes || 0,
|
||||
avgChunksPerNode: stats.total_nodes > 0
|
||||
? Math.round(stats.total_chunks / stats.total_nodes * 10) / 10
|
||||
: 0
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Close database connection
|
||||
*/
|
||||
close(): void {
|
||||
this.db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CLI interface for direct execution
|
||||
*/
|
||||
export async function runCLI(args: string[]): Promise<void> {
|
||||
if (!args.includes('--node-id')) {
|
||||
console.error('Error: --node-id is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const nodeId = parseInt(args[args.indexOf('--node-id') + 1]);
|
||||
const verbose = args.includes('--verbose');
|
||||
|
||||
if (isNaN(nodeId)) {
|
||||
console.error('Error: Invalid node ID');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const embedder = new UniversalEmbedder();
|
||||
|
||||
try {
|
||||
const result = await embedder.processNode({ nodeId, verbose });
|
||||
|
||||
if (verbose) {
|
||||
const stats = embedder.getStats();
|
||||
console.log('\nDatabase statistics:');
|
||||
console.log(` Total chunks: ${stats.totalChunks}`);
|
||||
console.log(` Total nodes with chunks: ${stats.totalNodes}`);
|
||||
console.log(` Average chunks per node: ${stats.avgChunksPerNode}`);
|
||||
}
|
||||
} finally {
|
||||
embedder.close();
|
||||
}
|
||||
}
|
||||
|
||||
// Run if called directly (for testing)
|
||||
if (require.main === module) {
|
||||
runCLI(process.argv.slice(2)).catch(error => {
|
||||
console.error('Error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
/**
|
||||
* PDF/Paper content extraction for RA-H knowledge management system
|
||||
* Extracts text content from PDF files and returns formatted content
|
||||
*/
|
||||
|
||||
// Import pdf-parse directly from lib to avoid index.js debug side effects
|
||||
// (pdf-parse/index.js conditionally reads ./test/data/05-versions-space.pdf when module.parent is falsy in some bundles)
|
||||
// See: node_modules/pdf-parse/index.js
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports
|
||||
const pdf = require('pdf-parse/lib/pdf-parse.js');
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
|
||||
interface PaperMetadata {
|
||||
title?: string;
|
||||
pages: number;
|
||||
info?: any;
|
||||
text_length: number;
|
||||
filename?: string;
|
||||
extraction_method?: string;
|
||||
}
|
||||
|
||||
interface ExtractionResult {
|
||||
content: string;
|
||||
chunk: string;
|
||||
metadata: PaperMetadata;
|
||||
url: string;
|
||||
}
|
||||
|
||||
type PdfJsTextItem = {
|
||||
str?: string;
|
||||
};
|
||||
|
||||
type PdfMetadataInfo = Record<string, unknown>;
|
||||
|
||||
export class PaperExtractor {
|
||||
private headers = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
|
||||
};
|
||||
|
||||
private isLikelyPdf(buffer: Buffer): boolean {
|
||||
const header = buffer.slice(0, 8).toString('utf8');
|
||||
return header.includes('%PDF');
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean academic PDF content to reduce citation fragments and corrupted text
|
||||
*/
|
||||
private cleanAcademicContent(content: string): string {
|
||||
const lines = content.split('\n');
|
||||
const cleanedLines: string[] = [];
|
||||
|
||||
for (let line of lines) {
|
||||
line = line.trim();
|
||||
|
||||
// Skip empty lines for now
|
||||
if (line.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip lines that look like headers/footers (page numbers, dates)
|
||||
if (/^[\d\s\-\/]+$/.test(line)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip lines that are just citations like "[1]" or "(Smith, 2020)"
|
||||
if (/^\[\d+\]$/.test(line) || /^\([^)]+,\s*\d{4}\)$/.test(line)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip very short lines that are likely artifacts
|
||||
if (line.length < 3) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip lines that are mostly special characters (likely corrupted)
|
||||
const specialCharCount = (line.match(/[^\w\s]/g) || []).length;
|
||||
if (specialCharCount > line.length * 0.5) {
|
||||
continue;
|
||||
}
|
||||
|
||||
cleanedLines.push(line);
|
||||
}
|
||||
|
||||
// Combine lines into paragraphs
|
||||
const paragraphs: string[] = [];
|
||||
let currentParagraph = '';
|
||||
|
||||
for (const line of cleanedLines) {
|
||||
// Check if line looks like it ends a sentence
|
||||
const endsWithPunctuation = /[.!?]$/.test(line);
|
||||
|
||||
// Check if next line starts with capital or is a heading
|
||||
const looksLikeNewParagraph = /^[A-Z0-9]/.test(line) && currentParagraph.endsWith('.');
|
||||
|
||||
if (currentParagraph.length === 0) {
|
||||
currentParagraph = line;
|
||||
} else if (looksLikeNewParagraph) {
|
||||
paragraphs.push(currentParagraph);
|
||||
currentParagraph = line;
|
||||
} else if (endsWithPunctuation) {
|
||||
currentParagraph += ' ' + line;
|
||||
} else {
|
||||
currentParagraph += ' ' + line;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentParagraph.length > 0) {
|
||||
paragraphs.push(currentParagraph);
|
||||
}
|
||||
|
||||
// Filter out very short paragraphs (likely noise)
|
||||
return paragraphs.filter(p => p.length > 30).join('\n\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Download PDF from URL to temporary file
|
||||
*/
|
||||
private async downloadPDF(url: string): Promise<string> {
|
||||
const response = await fetch(url, {
|
||||
headers: this.headers,
|
||||
signal: AbortSignal.timeout(60000), // 60 second timeout for PDFs
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
// Create temp file
|
||||
const tempDir = os.tmpdir();
|
||||
const tempFile = path.join(tempDir, `pdf_${Date.now()}.pdf`);
|
||||
|
||||
// Get PDF data as buffer
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
|
||||
// Write to temp file
|
||||
await fs.writeFile(tempFile, buffer);
|
||||
|
||||
return tempFile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract text from PDF buffer
|
||||
*/
|
||||
private async extractFromPDF(buffer: Buffer): Promise<{ text: string; metadata: PaperMetadata }> {
|
||||
try {
|
||||
const data = await pdf(buffer);
|
||||
|
||||
const metadata: PaperMetadata = {
|
||||
pages: data.numpages,
|
||||
info: data.info,
|
||||
text_length: data.text.length,
|
||||
};
|
||||
|
||||
// Try to extract title from metadata or first lines
|
||||
if (data.info && data.info.Title) {
|
||||
metadata.title = data.info.Title;
|
||||
} else {
|
||||
// Try to get title from first few lines
|
||||
const firstLines = data.text.split('\n').slice(0, 5);
|
||||
const possibleTitle = firstLines.find((line: string) =>
|
||||
line.length > 10 && line.length < 200 && /[A-Z]/.test(line)
|
||||
);
|
||||
if (possibleTitle) {
|
||||
metadata.title = possibleTitle.trim();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
text: data.text,
|
||||
metadata,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
const primaryMessage = this.formatErrorMessage(error);
|
||||
console.warn('Primary pdf-parse extraction failed, attempting pdfjs-dist fallback:', primaryMessage);
|
||||
try {
|
||||
return await this.extractWithPdfJs(buffer);
|
||||
} catch (fallbackError: unknown) {
|
||||
const fallbackMessage = this.formatErrorMessage(fallbackError);
|
||||
throw new Error(`PDF parsing failed: ${primaryMessage}; fallback error: ${fallbackMessage}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async extractWithPdfJs(buffer: Buffer): Promise<{ text: string; metadata: PaperMetadata }> {
|
||||
const pdfjsLib = await import('pdfjs-dist/legacy/build/pdf.mjs');
|
||||
const loadingTask = pdfjsLib.getDocument({ data: buffer, useSystemFonts: true });
|
||||
const doc = await loadingTask.promise;
|
||||
|
||||
let aggregatedText = '';
|
||||
for (let pageIndex = 1; pageIndex <= doc.numPages; pageIndex++) {
|
||||
const page = await doc.getPage(pageIndex);
|
||||
const textContent = await page.getTextContent();
|
||||
const pageText = (textContent.items as PdfJsTextItem[])
|
||||
.map((item) => (typeof item?.str === 'string' ? item.str : ''))
|
||||
.join(' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
if (pageText) {
|
||||
aggregatedText += pageText + '\n\n';
|
||||
}
|
||||
}
|
||||
|
||||
let info: PdfMetadataInfo = {};
|
||||
try {
|
||||
const metadata = await doc.getMetadata();
|
||||
info = (metadata?.info as PdfMetadataInfo) || {};
|
||||
} catch (metadataError: unknown) {
|
||||
console.warn('pdfjs-dist metadata extraction failed:', metadataError);
|
||||
}
|
||||
|
||||
const metadata: PaperMetadata = {
|
||||
pages: doc.numPages,
|
||||
info,
|
||||
text_length: aggregatedText.length,
|
||||
extraction_method: 'typescript_pdfjs_dist'
|
||||
};
|
||||
|
||||
const title = typeof info['Title'] === 'string' ? (info['Title'] as string) : undefined;
|
||||
if (!metadata.title && title) {
|
||||
metadata.title = title;
|
||||
}
|
||||
|
||||
return {
|
||||
text: aggregatedText,
|
||||
metadata,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format content for node creation
|
||||
*/
|
||||
private formatContent(metadata: PaperMetadata, text: string): string {
|
||||
const sections: string[] = [];
|
||||
|
||||
// Add metadata section
|
||||
sections.push('## Document Information');
|
||||
|
||||
if (metadata.title) {
|
||||
sections.push(`**Title:** ${metadata.title}`);
|
||||
}
|
||||
|
||||
sections.push(`**Pages:** ${metadata.pages}`);
|
||||
sections.push(`**Text Length:** ${metadata.text_length} characters`);
|
||||
|
||||
if (metadata.info) {
|
||||
if (metadata.info.Author) {
|
||||
sections.push(`**Author:** ${metadata.info.Author}`);
|
||||
}
|
||||
if (metadata.info.Creator) {
|
||||
sections.push(`**Creator:** ${metadata.info.Creator}`);
|
||||
}
|
||||
if (metadata.info.Producer) {
|
||||
sections.push(`**Producer:** ${metadata.info.Producer}`);
|
||||
}
|
||||
if (metadata.info.CreationDate) {
|
||||
sections.push(`**Creation Date:** ${metadata.info.CreationDate}`);
|
||||
}
|
||||
}
|
||||
|
||||
sections.push('');
|
||||
|
||||
// Add content
|
||||
sections.push('## Content');
|
||||
sections.push(text);
|
||||
|
||||
return sections.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Main extraction method
|
||||
*/
|
||||
async extract(url: string): Promise<ExtractionResult> {
|
||||
let tempFile: string | null = null;
|
||||
|
||||
try {
|
||||
// Validate URL
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
throw new Error('Invalid URL format - must start with http:// or https://');
|
||||
}
|
||||
|
||||
// Check if URL looks like it points to a PDF
|
||||
const urlLower = url.toLowerCase();
|
||||
if (!urlLower.includes('.pdf') && !urlLower.includes('arxiv.org')) {
|
||||
console.warn('Warning: URL does not appear to point to a PDF file');
|
||||
}
|
||||
|
||||
// Download PDF
|
||||
tempFile = await this.downloadPDF(url);
|
||||
|
||||
// Read PDF buffer
|
||||
const buffer = await fs.readFile(tempFile);
|
||||
|
||||
if (!this.isLikelyPdf(buffer)) {
|
||||
const preview = buffer.slice(0, 64).toString('utf8');
|
||||
throw new Error(`Downloaded file does not appear to be a PDF (header: ${preview})`);
|
||||
}
|
||||
|
||||
// Extract text and metadata
|
||||
const { text, metadata } = await this.extractFromPDF(buffer);
|
||||
|
||||
// Clean the text
|
||||
const cleanedText = this.cleanAcademicContent(text);
|
||||
|
||||
// Add filename to metadata
|
||||
metadata.filename = path.basename(url);
|
||||
// Mark extraction method for downstream metadata
|
||||
if (!metadata.extraction_method) {
|
||||
metadata.extraction_method = 'typescript_pdf-parse';
|
||||
}
|
||||
|
||||
// Format content for display
|
||||
const content = this.formatContent(metadata, cleanedText);
|
||||
|
||||
// Chunk is the cleaned text
|
||||
const chunk = cleanedText;
|
||||
|
||||
return {
|
||||
content,
|
||||
chunk,
|
||||
metadata,
|
||||
url,
|
||||
};
|
||||
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
throw new Error('Request timeout - PDF download took too long');
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
// Clean up temp file
|
||||
if (tempFile) {
|
||||
try {
|
||||
await fs.unlink(tempFile);
|
||||
} catch (cleanupError: unknown) {
|
||||
console.warn('Could not delete temp file:', tempFile, cleanupError);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private formatErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
if (typeof error === 'string') {
|
||||
return error;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(error);
|
||||
} catch {
|
||||
return 'Unknown error';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Standalone extraction function for direct use
|
||||
*/
|
||||
export async function extractPaper(url: string): Promise<ExtractionResult> {
|
||||
const extractor = new PaperExtractor();
|
||||
return extractor.extract(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* CLI interface for direct execution
|
||||
*/
|
||||
export async function runCLI(args: string[]): Promise<void> {
|
||||
if (args.length === 0) {
|
||||
console.error('Usage: paper-extract <url>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const url = args[0];
|
||||
|
||||
try {
|
||||
const result = await extractPaper(url);
|
||||
// Output as JSON for compatibility with existing tools
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} catch (error: any) {
|
||||
console.error('Error:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Run if called directly (for testing)
|
||||
if (require.main === module) {
|
||||
runCLI(process.argv.slice(2)).catch(error => {
|
||||
console.error('Error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* Website content extraction for RA-H knowledge management system
|
||||
* Extracts text content from web pages and returns formatted content
|
||||
*/
|
||||
|
||||
import * as cheerio from 'cheerio';
|
||||
|
||||
interface WebsiteMetadata {
|
||||
title: string;
|
||||
author?: string;
|
||||
date?: string;
|
||||
description?: string;
|
||||
og_image?: string;
|
||||
site_name?: string;
|
||||
extraction_method?: string;
|
||||
}
|
||||
|
||||
interface ExtractionResult {
|
||||
content: string;
|
||||
chunk: string;
|
||||
metadata: WebsiteMetadata;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export class WebsiteExtractor {
|
||||
private headers = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
|
||||
};
|
||||
|
||||
/**
|
||||
* Clean extracted content for better readability
|
||||
*/
|
||||
private cleanContent(content: string): string {
|
||||
// Remove excessive whitespace
|
||||
content = content.replace(/\s+/g, ' ').trim();
|
||||
|
||||
// Remove cookie/privacy policy mentions
|
||||
content = content.replace(/cookie\s+policy|privacy\s+policy|terms\s+of\s+service/gi, '');
|
||||
|
||||
// Split into paragraphs and clean
|
||||
const paragraphs = content
|
||||
.split('\n')
|
||||
.map(p => p.trim())
|
||||
.filter(p => p.length > 20); // Remove very short paragraphs (likely nav/UI)
|
||||
|
||||
return paragraphs.join('\n\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract metadata from HTML
|
||||
*/
|
||||
private extractMetadata($: cheerio.CheerioAPI): WebsiteMetadata {
|
||||
const metadata: WebsiteMetadata = {
|
||||
title: '',
|
||||
};
|
||||
|
||||
// Title extraction (priority order)
|
||||
metadata.title =
|
||||
$('meta[property="og:title"]').attr('content') ||
|
||||
$('meta[name="twitter:title"]').attr('content') ||
|
||||
$('title').text() ||
|
||||
$('h1').first().text() ||
|
||||
'Untitled';
|
||||
|
||||
// Author extraction
|
||||
metadata.author =
|
||||
$('meta[name="author"]').attr('content') ||
|
||||
$('meta[property="article:author"]').attr('content') ||
|
||||
$('.author').first().text() ||
|
||||
$('[rel="author"]').first().text() ||
|
||||
undefined;
|
||||
|
||||
// Date extraction
|
||||
metadata.date =
|
||||
$('meta[property="article:published_time"]').attr('content') ||
|
||||
$('meta[name="publish_date"]').attr('content') ||
|
||||
$('time').first().attr('datetime') ||
|
||||
$('.date').first().text() ||
|
||||
undefined;
|
||||
|
||||
// Description
|
||||
metadata.description =
|
||||
$('meta[property="og:description"]').attr('content') ||
|
||||
$('meta[name="description"]').attr('content') ||
|
||||
undefined;
|
||||
|
||||
// Image
|
||||
metadata.og_image =
|
||||
$('meta[property="og:image"]').attr('content') ||
|
||||
undefined;
|
||||
|
||||
// Site name
|
||||
metadata.site_name =
|
||||
$('meta[property="og:site_name"]').attr('content') ||
|
||||
undefined;
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract main content from HTML
|
||||
*/
|
||||
private extractMainContent($: cheerio.CheerioAPI): string {
|
||||
// Remove script and style elements
|
||||
$('script, style, noscript').remove();
|
||||
|
||||
// Remove common navigation and footer elements
|
||||
$('nav, header, footer, aside, .nav, .header, .footer, .sidebar, .menu, .advertisement').remove();
|
||||
|
||||
// Try to find main content areas (in priority order)
|
||||
const contentSelectors = [
|
||||
'main',
|
||||
'article',
|
||||
'[role="main"]',
|
||||
'.content',
|
||||
'.post',
|
||||
'.article-body',
|
||||
'.entry-content',
|
||||
'#content',
|
||||
'.container',
|
||||
'body',
|
||||
];
|
||||
|
||||
let mainContent = '';
|
||||
|
||||
for (const selector of contentSelectors) {
|
||||
const element = $(selector).first();
|
||||
if (element.length > 0) {
|
||||
// Extract text from paragraphs, headings, lists
|
||||
const textElements = element.find('p, h1, h2, h3, h4, h5, h6, li, blockquote, td, th');
|
||||
|
||||
if (textElements.length > 0) {
|
||||
const texts: string[] = [];
|
||||
textElements.each((_, el) => {
|
||||
const text = $(el).text().trim();
|
||||
if (text.length > 0) {
|
||||
texts.push(text);
|
||||
}
|
||||
});
|
||||
|
||||
if (texts.length > 0) {
|
||||
mainContent = texts.join('\n\n');
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to all text if no main content found
|
||||
if (!mainContent) {
|
||||
mainContent = $('body').text();
|
||||
}
|
||||
|
||||
return this.cleanContent(mainContent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format content for node creation
|
||||
*/
|
||||
private formatContent(metadata: WebsiteMetadata, mainContent: string): string {
|
||||
const sections: string[] = [];
|
||||
|
||||
// Add metadata section
|
||||
sections.push('## Article Information');
|
||||
sections.push(`**Title:** ${metadata.title}`);
|
||||
|
||||
if (metadata.author) {
|
||||
sections.push(`**Author:** ${metadata.author}`);
|
||||
}
|
||||
|
||||
if (metadata.date) {
|
||||
sections.push(`**Date:** ${metadata.date}`);
|
||||
}
|
||||
|
||||
if (metadata.site_name) {
|
||||
sections.push(`**Source:** ${metadata.site_name}`);
|
||||
}
|
||||
|
||||
if (metadata.description) {
|
||||
sections.push(`**Description:** ${metadata.description}`);
|
||||
}
|
||||
|
||||
sections.push('');
|
||||
|
||||
// Add main content
|
||||
sections.push('## Content');
|
||||
sections.push(mainContent);
|
||||
|
||||
return sections.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Main extraction method
|
||||
*/
|
||||
async extract(url: string): Promise<ExtractionResult> {
|
||||
// Validate URL
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
throw new Error('Invalid URL format - must start with http:// or https://');
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch the webpage
|
||||
const response = await fetch(url, {
|
||||
headers: this.headers,
|
||||
signal: AbortSignal.timeout(30000), // 30 second timeout
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
|
||||
// Parse HTML with cheerio
|
||||
const $ = cheerio.load(html);
|
||||
|
||||
// Extract metadata and content
|
||||
const metadata = this.extractMetadata($);
|
||||
// Mark extraction method for downstream metadata
|
||||
metadata.extraction_method = 'typescript_cheerio';
|
||||
const mainContent = this.extractMainContent($);
|
||||
|
||||
// Format content for display
|
||||
const content = this.formatContent(metadata, mainContent);
|
||||
|
||||
// Chunk is the main content text
|
||||
const chunk = mainContent;
|
||||
|
||||
return {
|
||||
content,
|
||||
chunk,
|
||||
metadata,
|
||||
url,
|
||||
};
|
||||
|
||||
} catch (error: any) {
|
||||
if (error.name === 'AbortError') {
|
||||
throw new Error('Request timeout - website took too long to respond');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Standalone extraction function for direct use
|
||||
*/
|
||||
export async function extractWebsite(url: string): Promise<ExtractionResult> {
|
||||
const extractor = new WebsiteExtractor();
|
||||
return extractor.extract(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* CLI interface for direct execution
|
||||
*/
|
||||
export async function runCLI(args: string[]): Promise<void> {
|
||||
if (args.length === 0) {
|
||||
console.error('Usage: website-extract <url>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const url = args[0];
|
||||
|
||||
try {
|
||||
const result = await extractWebsite(url);
|
||||
// Output as JSON for compatibility with existing tools
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} catch (error: any) {
|
||||
console.error('Error:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Run if called directly (for testing)
|
||||
if (require.main === module) {
|
||||
runCLI(process.argv.slice(2)).catch(error => {
|
||||
console.error('Error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* YouTube content extraction for RA-H knowledge management system
|
||||
* Uses youtube-transcript npm package - more similar to Python youtube-transcript-api
|
||||
*/
|
||||
|
||||
import { YoutubeTranscript } from 'youtube-transcript';
|
||||
|
||||
interface TranscriptSegment {
|
||||
text: string;
|
||||
start: number;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
interface YouTubeMetadata {
|
||||
video_id: string;
|
||||
video_url: string;
|
||||
video_title: string;
|
||||
channel_name: string;
|
||||
channel_url: string;
|
||||
thumbnail_url: string;
|
||||
source_type: string;
|
||||
transcript_length: number;
|
||||
total_segments: number;
|
||||
content_format: string;
|
||||
language?: string;
|
||||
provider: string;
|
||||
extraction_method: string;
|
||||
}
|
||||
|
||||
interface ExtractionResult {
|
||||
success: boolean;
|
||||
content: string;
|
||||
chunk: string; // Same as content, but tool expects this field name
|
||||
metadata: YouTubeMetadata;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export class YouTubeExtractor {
|
||||
/**
|
||||
* Extract video ID from YouTube URL
|
||||
*/
|
||||
private extractVideoId(url: string): string | null {
|
||||
if (!url) return null;
|
||||
|
||||
if (url.includes('youtu.be')) {
|
||||
return url.split('/').pop()?.split('?')[0] || null;
|
||||
} else if (url.includes('youtube.com/watch')) {
|
||||
const urlParams = new URLSearchParams(url.split('?')[1]);
|
||||
return urlParams.get('v');
|
||||
} else if (url.includes('youtube.com/live')) {
|
||||
return url.split('/live/')[1]?.split('?')[0] || null;
|
||||
} else if (url.includes('youtube.com/embed')) {
|
||||
return url.split('/embed/')[1]?.split('?')[0] || null;
|
||||
} else if (url.includes('youtube.com/v')) {
|
||||
return url.split('/v/')[1]?.split('?')[0] || null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get video metadata from YouTube oEmbed API
|
||||
*/
|
||||
private async getVideoMetadata(url: string): Promise<{ title: string; author_name: string; author_url: string; thumbnail_url: string }> {
|
||||
try {
|
||||
const oembedUrl = `https://www.youtube.com/oembed?url=${encodeURIComponent(url)}&format=json`;
|
||||
const response = await fetch(oembedUrl, {
|
||||
signal: AbortSignal.timeout(10000)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
return {
|
||||
title: data.title || 'YouTube Video',
|
||||
author_name: data.author_name || 'Unknown Channel',
|
||||
author_url: data.author_url || '',
|
||||
thumbnail_url: data.thumbnail_url || ''
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('oEmbed extraction failed:', error);
|
||||
}
|
||||
|
||||
// Fallback metadata
|
||||
const videoId = this.extractVideoId(url);
|
||||
return {
|
||||
title: `YouTube Video ${videoId || 'Unknown'}`,
|
||||
author_name: 'Unknown Channel',
|
||||
author_url: '',
|
||||
thumbnail_url: ''
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get transcript using youtube-transcript npm package (like Python approach)
|
||||
*/
|
||||
private async getTranscript(url: string): Promise<{ transcript: string; segments: TranscriptSegment[]; language?: string }> {
|
||||
try {
|
||||
// Get transcript using npm package (more similar to Python approach)
|
||||
const transcriptData = await YoutubeTranscript.fetchTranscript(url);
|
||||
|
||||
if (!transcriptData || transcriptData.length === 0) {
|
||||
throw new Error('No transcript segments found');
|
||||
}
|
||||
|
||||
// Convert to our format
|
||||
const segments: TranscriptSegment[] = transcriptData.map(item => ({
|
||||
text: item.text,
|
||||
start: item.offset / 1000, // Convert ms to seconds
|
||||
duration: item.duration ? item.duration / 1000 : 0 // Convert ms to seconds
|
||||
}));
|
||||
|
||||
// Format with timestamps like Python version
|
||||
const formattedSegments: string[] = [];
|
||||
for (const segment of segments) {
|
||||
formattedSegments.push(`[${segment.start.toFixed(1)}s] ${segment.text}`);
|
||||
}
|
||||
|
||||
const fullTranscript = formattedSegments.join('\n');
|
||||
|
||||
return {
|
||||
transcript: fullTranscript,
|
||||
segments,
|
||||
language: 'en'
|
||||
};
|
||||
|
||||
} catch (error: any) {
|
||||
throw new Error(`Failed to extract YouTube transcript: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main extraction method - uses youtube-transcript npm package like Python script uses youtube-transcript-api
|
||||
*/
|
||||
async extract(url: string): Promise<ExtractionResult> {
|
||||
try {
|
||||
// Validate URL
|
||||
if (!url.includes('youtube.com') && !url.includes('youtu.be')) {
|
||||
throw new Error('Invalid YouTube URL');
|
||||
}
|
||||
|
||||
const videoId = this.extractVideoId(url);
|
||||
if (!videoId) {
|
||||
throw new Error('Could not extract video ID from URL');
|
||||
}
|
||||
|
||||
// Get video metadata
|
||||
const videoMetadata = await this.getVideoMetadata(url);
|
||||
|
||||
// Get transcript using npm package (similar to Python approach)
|
||||
const { transcript, segments, language } = await this.getTranscript(url);
|
||||
|
||||
// Create metadata matching Python version exactly
|
||||
const metadata: YouTubeMetadata = {
|
||||
video_id: videoId,
|
||||
video_url: url,
|
||||
video_title: videoMetadata.title,
|
||||
channel_name: videoMetadata.author_name,
|
||||
channel_url: videoMetadata.author_url,
|
||||
thumbnail_url: videoMetadata.thumbnail_url,
|
||||
source_type: 'youtube_transcript',
|
||||
transcript_length: transcript.length,
|
||||
total_segments: segments.length,
|
||||
content_format: 'timestamped_transcript',
|
||||
language: language || 'unknown',
|
||||
provider: 'YouTube',
|
||||
extraction_method: 'typescript_npm_youtube_transcript'
|
||||
};
|
||||
|
||||
return {
|
||||
success: true,
|
||||
content: transcript,
|
||||
chunk: transcript, // Tool expects this field
|
||||
metadata
|
||||
};
|
||||
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
content: '',
|
||||
chunk: '', // Tool expects this field
|
||||
metadata: {} as YouTubeMetadata,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main function for command line usage (matching Python interface)
|
||||
*/
|
||||
export async function main(url: string): Promise<ExtractionResult> {
|
||||
const extractor = new YouTubeExtractor();
|
||||
return extractor.extract(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Standalone extraction function for direct use
|
||||
*/
|
||||
export async function extractYouTube(url: string): Promise<ExtractionResult> {
|
||||
const extractor = new YouTubeExtractor();
|
||||
return extractor.extract(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* CLI interface for direct execution (matching Python interface)
|
||||
*/
|
||||
export async function runCLI(): Promise<void> {
|
||||
if (process.argv.length !== 3) {
|
||||
console.log(JSON.stringify({
|
||||
success: false,
|
||||
error: "Usage: node youtube.js <youtube_url>"
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const url = process.argv[2];
|
||||
const result = await main(url);
|
||||
console.log(JSON.stringify(result));
|
||||
|
||||
if (!result.success) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Run if called directly (for testing)
|
||||
if (require.main === module) {
|
||||
runCLI().catch(error => {
|
||||
console.log(JSON.stringify({
|
||||
success: false,
|
||||
error: error.message
|
||||
}));
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* YouTube content extraction for RA-H knowledge management system
|
||||
* Uses innertube-based transcript extraction via youtube-transcript-plus
|
||||
* Falls back to the legacy npm extractor if captions cannot be retrieved
|
||||
*/
|
||||
import {
|
||||
fetchTranscript as fetchTranscriptPlus,
|
||||
YoutubeTranscriptNotAvailableLanguageError,
|
||||
} from 'youtube-transcript-plus';
|
||||
import { extractYouTube as extractYouTubeNpm } from './youtube-npm';
|
||||
|
||||
interface TranscriptSegment {
|
||||
text: string;
|
||||
start: number;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
interface YouTubeMetadata {
|
||||
video_id: string;
|
||||
video_url: string;
|
||||
video_title: string;
|
||||
channel_name: string;
|
||||
channel_url: string;
|
||||
thumbnail_url: string;
|
||||
source_type: string;
|
||||
transcript_length: number;
|
||||
total_segments: number;
|
||||
content_format: string;
|
||||
language?: string;
|
||||
provider: string;
|
||||
extraction_method: string;
|
||||
}
|
||||
|
||||
interface ExtractionResult {
|
||||
success: boolean;
|
||||
content: string;
|
||||
chunk: string;
|
||||
metadata: YouTubeMetadata;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export class YouTubeExtractor {
|
||||
private decodeHtmlEntities(input: string): string {
|
||||
if (!input) {
|
||||
return '';
|
||||
}
|
||||
|
||||
let result = input.replace(/&/g, '&');
|
||||
result = result.replace(/&#(\d+);/g, (_match, dec) => {
|
||||
const code = Number.parseInt(dec, 10);
|
||||
return Number.isNaN(code) ? _match : String.fromCharCode(code);
|
||||
});
|
||||
result = result
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/‘/g, "'")
|
||||
.replace(/’/g, "'")
|
||||
.replace(/“/g, '"')
|
||||
.replace(/”/g, '"')
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private formatSegments(segments: TranscriptSegment[]): string {
|
||||
return segments
|
||||
.map((segment) => {
|
||||
const startTime = Number.isFinite(segment.start) ? segment.start : 0;
|
||||
return `[${startTime.toFixed(1)}s] ${segment.text}`;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
private extractVideoId(url: string): string | null {
|
||||
if (!url) return null;
|
||||
|
||||
if (url.includes('youtu.be')) {
|
||||
return url.split('/').pop()?.split('?')[0] || null;
|
||||
} else if (url.includes('youtube.com/watch')) {
|
||||
const urlParams = new URLSearchParams(url.split('?')[1]);
|
||||
return urlParams.get('v');
|
||||
} else if (url.includes('youtube.com/live')) {
|
||||
return url.split('/live/')[1]?.split('?')[0] || null;
|
||||
} else if (url.includes('youtube.com/embed')) {
|
||||
return url.split('/embed/')[1]?.split('?')[0] || null;
|
||||
} else if (url.includes('youtube.com/v')) {
|
||||
return url.split('/v/')[1]?.split('?')[0] || null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async getVideoMetadata(url: string): Promise<{ title: string; author_name: string; author_url: string; thumbnail_url: string }> {
|
||||
try {
|
||||
const oembedUrl = `https://www.youtube.com/oembed?url=${encodeURIComponent(url)}&format=json`;
|
||||
const response = await fetch(oembedUrl, {
|
||||
signal: AbortSignal.timeout(10000)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
return {
|
||||
title: data.title || 'YouTube Video',
|
||||
author_name: data.author_name || 'Unknown Channel',
|
||||
author_url: data.author_url || '',
|
||||
thumbnail_url: data.thumbnail_url || ''
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('oEmbed extraction failed:', error);
|
||||
}
|
||||
|
||||
const videoId = this.extractVideoId(url);
|
||||
return {
|
||||
title: `YouTube Video ${videoId || 'Unknown'}`,
|
||||
author_name: 'Unknown Channel',
|
||||
author_url: '',
|
||||
thumbnail_url: ''
|
||||
};
|
||||
}
|
||||
|
||||
private async fetchPrimaryTranscript(url: string): Promise<{
|
||||
transcript: string;
|
||||
segments: TranscriptSegment[];
|
||||
language?: string;
|
||||
extractionMethod: string;
|
||||
}> {
|
||||
const attempts: Array<{ lang?: string; label: string }> = [
|
||||
{ lang: 'en', label: 'typescript_youtube_transcript_plus_en' },
|
||||
{ label: 'typescript_youtube_transcript_plus' },
|
||||
];
|
||||
|
||||
let lastError: unknown = null;
|
||||
|
||||
for (const attempt of attempts) {
|
||||
try {
|
||||
const entries = await fetchTranscriptPlus(url, attempt.lang ? { lang: attempt.lang } : undefined);
|
||||
const language = entries.find((entry) => entry.lang)?.lang;
|
||||
|
||||
const segments = entries
|
||||
.map((entry) => ({
|
||||
text: this.decodeHtmlEntities(entry.text ?? '').trim(),
|
||||
start: Number(entry.offset ?? 0),
|
||||
duration: Number(entry.duration ?? 0),
|
||||
}))
|
||||
.filter((segment) => segment.text.length > 0);
|
||||
|
||||
if (segments.length === 0) {
|
||||
throw new Error('Transcript returned no segments');
|
||||
}
|
||||
|
||||
const transcript = this.formatSegments(segments);
|
||||
|
||||
return {
|
||||
transcript,
|
||||
segments,
|
||||
language,
|
||||
extractionMethod: attempt.label,
|
||||
};
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (attempt.lang && error instanceof YoutubeTranscriptNotAvailableLanguageError) {
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError instanceof Error ? lastError : new Error('Unable to fetch transcript');
|
||||
}
|
||||
|
||||
private formatErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
if (typeof error === 'string') {
|
||||
return error;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(error);
|
||||
} catch {
|
||||
return 'Unknown error';
|
||||
}
|
||||
}
|
||||
|
||||
async extract(url: string): Promise<ExtractionResult> {
|
||||
try {
|
||||
if (!url.includes('youtube.com') && !url.includes('youtu.be')) {
|
||||
throw new Error('Invalid YouTube URL');
|
||||
}
|
||||
|
||||
const videoId = this.extractVideoId(url);
|
||||
if (!videoId) {
|
||||
throw new Error('Could not extract video ID from URL');
|
||||
}
|
||||
|
||||
const { transcript, segments, language, extractionMethod } = await this.fetchPrimaryTranscript(url);
|
||||
const videoMetadata = await this.getVideoMetadata(url);
|
||||
|
||||
const metadata: YouTubeMetadata = {
|
||||
video_id: videoId,
|
||||
video_url: url,
|
||||
video_title: videoMetadata.title,
|
||||
channel_name: videoMetadata.author_name,
|
||||
channel_url: videoMetadata.author_url,
|
||||
thumbnail_url: videoMetadata.thumbnail_url,
|
||||
source_type: 'youtube_transcript',
|
||||
transcript_length: transcript.length,
|
||||
total_segments: segments.length,
|
||||
content_format: 'timestamped_transcript',
|
||||
language: language || 'unknown',
|
||||
provider: 'YouTube',
|
||||
extraction_method: extractionMethod,
|
||||
};
|
||||
|
||||
return {
|
||||
success: true,
|
||||
content: transcript,
|
||||
chunk: transcript,
|
||||
metadata,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
try {
|
||||
return await this.runNpmFallback(url);
|
||||
} catch (fallbackError: unknown) {
|
||||
return {
|
||||
success: false,
|
||||
content: '',
|
||||
chunk: '',
|
||||
metadata: {} as YouTubeMetadata,
|
||||
error: `${this.formatErrorMessage(error)}; fallback error: ${this.formatErrorMessage(fallbackError)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async runNpmFallback(url: string): Promise<ExtractionResult> {
|
||||
console.warn('Primary transcript extraction failed; falling back to legacy youtube-transcript package');
|
||||
const fallback = await extractYouTubeNpm(url);
|
||||
return {
|
||||
success: fallback.success,
|
||||
content: fallback.content,
|
||||
chunk: fallback.chunk,
|
||||
metadata: fallback.metadata as YouTubeMetadata,
|
||||
error: fallback.error,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function main(url: string): Promise<ExtractionResult> {
|
||||
const extractor = new YouTubeExtractor();
|
||||
return extractor.extract(url);
|
||||
}
|
||||
|
||||
export async function extractYouTube(url: string): Promise<ExtractionResult> {
|
||||
const extractor = new YouTubeExtractor();
|
||||
return extractor.extract(url);
|
||||
}
|
||||
|
||||
export async function runCLI(): Promise<void> {
|
||||
if (process.argv.length !== 3) {
|
||||
console.log(JSON.stringify({
|
||||
success: false,
|
||||
error: "Usage: node youtube.js <youtube_url>"
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const url = process.argv[2];
|
||||
const result = await main(url);
|
||||
console.log(JSON.stringify(result));
|
||||
|
||||
if (!result.success) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
runCLI().catch(error => {
|
||||
console.log(JSON.stringify({
|
||||
success: false,
|
||||
error: error.message
|
||||
}));
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* SQLite vec0 utilities for TypeScript
|
||||
* Handles binary serialization for vec0 BLOB storage
|
||||
*/
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
|
||||
/**
|
||||
* Serialize a float array to binary format for vec0 storage
|
||||
* Equivalent to Python's struct.pack(f'{len(vector)}f', *vector)
|
||||
*/
|
||||
export function serializeFloat32Vector(vector: number[]): Buffer {
|
||||
const buffer = Buffer.allocUnsafe(vector.length * 4);
|
||||
for (let i = 0; i < vector.length; i++) {
|
||||
buffer.writeFloatLE(vector[i], i * 4);
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deserialize a vec0 BLOB back to float array
|
||||
*/
|
||||
export function deserializeFloat32Vector(blob: Buffer): number[] {
|
||||
const vector: number[] = [];
|
||||
for (let i = 0; i < blob.length; i += 4) {
|
||||
vector.push(blob.readFloatLE(i));
|
||||
}
|
||||
return vector;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get SQLite database path from environment or default location
|
||||
*/
|
||||
export function getDatabasePath(): string {
|
||||
const envPath = process.env.SQLITE_DB_PATH;
|
||||
if (envPath) {
|
||||
return envPath;
|
||||
}
|
||||
|
||||
// Default path: ~/Library/Application Support/RA-H/db/rah.sqlite
|
||||
const homeDir = os.homedir();
|
||||
return path.join(homeDir, 'Library', 'Application Support', 'RA-H', 'db', 'rah.sqlite');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get vec extension path from environment or default location
|
||||
*/
|
||||
export function getVecExtensionPath(): string {
|
||||
const envPath = process.env.SQLITE_VEC_EXTENSION_PATH;
|
||||
if (envPath) {
|
||||
return envPath;
|
||||
}
|
||||
|
||||
// Default path relative to project root
|
||||
return path.join(process.cwd(), 'vendor', 'sqlite-extensions', 'vec0.dylib');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create database connection with vec0 extension loaded
|
||||
*/
|
||||
export function createDatabaseConnection(): Database.Database {
|
||||
const dbPath = getDatabasePath();
|
||||
const vecPath = getVecExtensionPath();
|
||||
|
||||
const db = new Database(dbPath);
|
||||
|
||||
// Load vec0 extension
|
||||
try {
|
||||
db.loadExtension(vecPath);
|
||||
} catch (error) {
|
||||
console.error('Warning: Could not load vec0 extension:', error);
|
||||
// Continue without vector support for non-vector operations
|
||||
}
|
||||
|
||||
return db;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format embedding text for node metadata
|
||||
*/
|
||||
export function formatEmbeddingText(
|
||||
title: string,
|
||||
content: string,
|
||||
dimensions: string[]
|
||||
): string {
|
||||
const dimensionsText = dimensions.length > 0 ? dimensions.join(', ') : 'none';
|
||||
return `Title: ${title}\n\nContent: ${content}\n\nDimensions: ${dimensionsText}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch process items with progress logging
|
||||
*/
|
||||
export async function batchProcess<T, R>(
|
||||
items: T[],
|
||||
processor: (item: T) => Promise<R>,
|
||||
batchSize: number = 10,
|
||||
onProgress?: (processed: number, total: number) => void
|
||||
): Promise<R[]> {
|
||||
const results: R[] = [];
|
||||
|
||||
for (let i = 0; i < items.length; i += batchSize) {
|
||||
const batch = items.slice(i, Math.min(i + batchSize, items.length));
|
||||
const batchResults = await Promise.all(batch.map(processor));
|
||||
results.push(...batchResults);
|
||||
|
||||
if (onProgress) {
|
||||
onProgress(Math.min(i + batchSize, items.length), items.length);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { getSQLiteClient } from '@/services/database/sqlite-client';
|
||||
|
||||
export interface VoiceUsageLogEntry {
|
||||
sessionId?: string | null;
|
||||
helperName?: string | null;
|
||||
requestId: string;
|
||||
messageId?: string | null;
|
||||
voice: string;
|
||||
model: string;
|
||||
charCount: number;
|
||||
costUsd: number;
|
||||
durationMs?: number | null;
|
||||
textPreview?: string | null;
|
||||
}
|
||||
|
||||
type ChatRow = {
|
||||
id: number;
|
||||
metadata: string | null;
|
||||
};
|
||||
|
||||
function parseMetadata(raw: unknown): Record<string, any> {
|
||||
if (!raw) return {};
|
||||
if (typeof raw === 'string') {
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch (error) {
|
||||
console.warn('[VoiceUsage] Failed to parse chat metadata JSON', error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
if (typeof raw === 'object') {
|
||||
return (raw as Record<string, any>) || {};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function applyVoiceUsageMetadata(
|
||||
metadata: Record<string, any>,
|
||||
entry: VoiceUsageLogEntry,
|
||||
loggedAt: string
|
||||
): Record<string, any> {
|
||||
const usageList = Array.isArray(metadata.voice_usage) ? metadata.voice_usage : [];
|
||||
|
||||
const usageEntry = {
|
||||
request_id: entry.requestId,
|
||||
message_id: entry.messageId ?? null,
|
||||
chars: entry.charCount,
|
||||
cost_usd: entry.costUsd,
|
||||
voice: entry.voice,
|
||||
model: entry.model,
|
||||
duration_ms: entry.durationMs ?? null,
|
||||
logged_at: loggedAt,
|
||||
};
|
||||
|
||||
usageList.push(usageEntry);
|
||||
const MAX_USAGE_HISTORY = 20;
|
||||
metadata.voice_usage = usageList.slice(-MAX_USAGE_HISTORY);
|
||||
|
||||
const currentCharsTotal = Number(metadata.voice_tts_chars_total) || 0;
|
||||
const currentCostTotal = Number(metadata.voice_tts_cost_usd_total) || Number(metadata.voice_tts_cost_total_usd) || 0;
|
||||
const currentRequestCount = Number(metadata.voice_tts_request_count) || 0;
|
||||
|
||||
metadata.voice_tts_chars_total = currentCharsTotal + entry.charCount;
|
||||
metadata.voice_tts_cost_usd_total = parseFloat((currentCostTotal + entry.costUsd).toFixed(6));
|
||||
metadata.voice_tts_request_count = currentRequestCount + 1;
|
||||
|
||||
metadata.voice_tts_chars = entry.charCount;
|
||||
metadata.voice_tts_cost_usd = entry.costUsd;
|
||||
metadata.voice_request_id = entry.requestId;
|
||||
metadata.voice_tts_voice = entry.voice;
|
||||
metadata.voice_tts_model = entry.model;
|
||||
metadata.voice_tts_duration_ms = entry.durationMs ?? null;
|
||||
metadata.voice_tts_last_logged_at = loggedAt;
|
||||
metadata.voice_message_id = entry.messageId ?? null;
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
export function recordVoiceUsage(entry: VoiceUsageLogEntry): void {
|
||||
try {
|
||||
const sqlite = getSQLiteClient();
|
||||
const loggedAt = new Date().toISOString();
|
||||
let chatId: number | null = null;
|
||||
|
||||
if (entry.sessionId) {
|
||||
try {
|
||||
const row = sqlite
|
||||
.prepare(
|
||||
`
|
||||
SELECT id, metadata
|
||||
FROM chats
|
||||
WHERE json_extract(metadata, '$.session_id') = ?
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
`
|
||||
)
|
||||
.get(entry.sessionId) as ChatRow | undefined;
|
||||
|
||||
if (row) {
|
||||
chatId = row.id;
|
||||
const parsedMetadata = applyVoiceUsageMetadata(parseMetadata(row.metadata), entry, loggedAt);
|
||||
sqlite
|
||||
.prepare(`UPDATE chats SET metadata = ? WHERE id = ?`)
|
||||
.run(JSON.stringify(parsedMetadata), row.id);
|
||||
} else {
|
||||
console.warn(`[VoiceUsage] No chat row found for session ${entry.sessionId}, logging standalone entry.`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[VoiceUsage] Failed to attach usage to chat metadata', error);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO voice_usage (
|
||||
chat_id,
|
||||
session_id,
|
||||
helper_name,
|
||||
request_id,
|
||||
message_id,
|
||||
voice,
|
||||
model,
|
||||
chars,
|
||||
cost_usd,
|
||||
duration_ms,
|
||||
text_preview,
|
||||
created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
)
|
||||
.run(
|
||||
chatId,
|
||||
entry.sessionId ?? null,
|
||||
entry.helperName ?? null,
|
||||
entry.requestId,
|
||||
entry.messageId ?? null,
|
||||
entry.voice,
|
||||
entry.model,
|
||||
entry.charCount,
|
||||
entry.costUsd,
|
||||
entry.durationMs ?? null,
|
||||
entry.textPreview ?? null,
|
||||
loggedAt
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('[VoiceUsage] Failed to insert voice usage row', error);
|
||||
}
|
||||
} catch (outerError) {
|
||||
console.error('[VoiceUsage] Unexpected error while recording usage', outerError);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { INTEGRATE_WORKFLOW_INSTRUCTIONS } from '@/config/workflows/integrate';
|
||||
import type { WorkflowDefinition } from './types';
|
||||
|
||||
export class WorkflowRegistry {
|
||||
private static readonly WORKFLOWS: Record<string, WorkflowDefinition> = {
|
||||
'integrate': {
|
||||
id: 1,
|
||||
key: 'integrate',
|
||||
displayName: 'Integrate',
|
||||
description: 'Deep analysis and connection-building for focused node',
|
||||
instructions: INTEGRATE_WORKFLOW_INSTRUCTIONS,
|
||||
enabled: true,
|
||||
requiresFocusedNode: true,
|
||||
primaryActor: 'oracle',
|
||||
expectedOutcome: 'Focused node updated with insights; 3-5 high-value edges created',
|
||||
}
|
||||
};
|
||||
|
||||
static async getWorkflowByKey(key: string): Promise<WorkflowDefinition | null> {
|
||||
return this.WORKFLOWS[key] || null;
|
||||
}
|
||||
|
||||
static async getEnabledWorkflows(): Promise<WorkflowDefinition[]> {
|
||||
return Object.values(this.WORKFLOWS).filter(w => w.enabled);
|
||||
}
|
||||
|
||||
static async getAllWorkflows(): Promise<WorkflowDefinition[]> {
|
||||
return Object.values(this.WORKFLOWS);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export interface WorkflowDefinition {
|
||||
id: number;
|
||||
key: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
instructions: string;
|
||||
enabled: boolean;
|
||||
requiresFocusedNode: boolean;
|
||||
primaryActor: 'oracle' | 'main';
|
||||
expectedOutcome?: string;
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
/* RA-H Terminal Theme Design System */
|
||||
|
||||
:root {
|
||||
/* Core Backgrounds - Ultra minimal dark */
|
||||
--bg-primary: #0a0a0a; /* Main app background */
|
||||
--bg-secondary: #0f0f0f; /* Panel backgrounds */
|
||||
--bg-surface: #151515; /* Elevated surfaces */
|
||||
--bg-hover: #1a1a1a; /* Hover states */
|
||||
|
||||
/* Borders & Dividers - Subtle definition */
|
||||
--border-subtle: #1f1f1f; /* Very subtle borders */
|
||||
--border-default: #2a2a2a; /* Default borders */
|
||||
--border-strong: #353535; /* Strong emphasis borders */
|
||||
|
||||
/* Text Hierarchy - High contrast */
|
||||
--text-primary: #e5e5e5; /* Primary text */
|
||||
--text-secondary: #a8a8a8; /* Secondary text */
|
||||
--text-muted: #6b6b6b; /* Muted/disabled text */
|
||||
--text-subtle: #4a4a4a; /* Very subtle text */
|
||||
|
||||
/* Terminal Accent Colors - Distinctive but minimal */
|
||||
--accent-blue: #5c9aff; /* Primary actions / User messages */
|
||||
--accent-green: #52d97a; /* Success / Assistant messages */
|
||||
--accent-yellow: #ffcc66; /* Warning / Tool usage */
|
||||
--accent-red: #ff6b6b; /* Error states */
|
||||
--accent-purple: #b794f6; /* Thinking / Processing */
|
||||
--accent-cyan: #69d2e7; /* Special highlights */
|
||||
|
||||
/* Message Status Indicators */
|
||||
--dot-user: #5c9aff; /* User message indicator */
|
||||
--dot-assistant: #52d97a; /* Assistant indicator */
|
||||
--dot-tool: #ffcc66; /* Tool usage indicator */
|
||||
--dot-thinking: #b794f6; /* Thinking indicator */
|
||||
--dot-system: #69d2e7; /* System messages */
|
||||
|
||||
/* Semantic Colors */
|
||||
--color-success: #52d97a;
|
||||
--color-warning: #ffcc66;
|
||||
--color-error: #ff6b6b;
|
||||
--color-info: #5c9aff;
|
||||
|
||||
/* Typography */
|
||||
--font-mono: 'JetBrains Mono', 'Fira Code', 'SF Mono', ui-monospace, Consolas, monospace;
|
||||
--font-size-xs: 11px;
|
||||
--font-size-sm: 12px;
|
||||
--font-size-base: 13px;
|
||||
--font-size-md: 14px;
|
||||
--font-size-lg: 16px;
|
||||
|
||||
/* Spacing */
|
||||
--spacing-xs: 4px;
|
||||
--spacing-sm: 8px;
|
||||
--spacing-md: 12px;
|
||||
--spacing-lg: 16px;
|
||||
--spacing-xl: 24px;
|
||||
|
||||
/* Animations */
|
||||
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-base: 200ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-slow: 300ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* Base Terminal Styles */
|
||||
.terminal-container {
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-base);
|
||||
line-height: 1.6;
|
||||
letter-spacing: 0.025em;
|
||||
font-feature-settings: 'liga' 1, 'calt' 1;
|
||||
}
|
||||
|
||||
/* Message Display Styles */
|
||||
.message-container {
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-sm) 0;
|
||||
font-size: var(--font-size-base);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.message-indicator {
|
||||
flex-shrink: 0;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
margin-top: 7px;
|
||||
border-radius: 50%;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.message-indicator.user {
|
||||
background: var(--dot-user);
|
||||
}
|
||||
|
||||
.message-indicator.assistant {
|
||||
background: var(--dot-assistant);
|
||||
}
|
||||
|
||||
.message-indicator.tool {
|
||||
background: var(--dot-tool);
|
||||
}
|
||||
|
||||
.message-indicator.thinking {
|
||||
background: var(--dot-thinking);
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
.message-indicator.system {
|
||||
background: var(--dot-system);
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.6;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
}
|
||||
|
||||
.message-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.message-text {
|
||||
color: var(--text-primary);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.message-timestamp {
|
||||
display: inline-block;
|
||||
margin-top: var(--spacing-xs);
|
||||
color: var(--text-subtle);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
/* Terminal Input Styles */
|
||||
.terminal-input-container {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-md);
|
||||
background: var(--bg-secondary);
|
||||
border-top: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
.prompt-symbol {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-md);
|
||||
line-height: 1.5;
|
||||
padding-top: 2px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.terminal-input-wrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.terminal-input {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-base);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
resize: none;
|
||||
outline: none;
|
||||
transition: all var(--transition-fast);
|
||||
border-radius: 2px;
|
||||
min-height: 32px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.terminal-input:focus {
|
||||
border-color: var(--accent-blue);
|
||||
background: var(--bg-primary);
|
||||
box-shadow: 0 0 0 1px rgba(92, 154, 255, 0.1);
|
||||
}
|
||||
|
||||
.terminal-input:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.terminal-input::placeholder {
|
||||
color: var(--text-subtle);
|
||||
}
|
||||
|
||||
.input-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.input-hints {
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
color: var(--text-subtle);
|
||||
font-size: var(--font-size-xs);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.input-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.key-hint {
|
||||
padding: 2px 4px;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 2px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* Tool Indicator Styles */
|
||||
.tool-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-xs) 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.tool-spinner {
|
||||
display: inline-block;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid var(--border-default);
|
||||
border-top-color: var(--dot-tool);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.tool-name {
|
||||
color: var(--dot-tool);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tool-status {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.tool-complete {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.tool-error {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
/* ASCII Banner Styles */
|
||||
.ascii-banner {
|
||||
padding: var(--spacing-xl);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 1.2;
|
||||
white-space: pre;
|
||||
font-family: var(--font-mono);
|
||||
opacity: 0;
|
||||
animation: fadeIn 0.5s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.ascii-banner-title {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.ascii-banner-stats {
|
||||
color: var(--text-subtle);
|
||||
}
|
||||
|
||||
/* Helper Tab Styles */
|
||||
.helper-tab {
|
||||
position: relative;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-sm);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
outline: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.helper-tab:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.helper-tab.active {
|
||||
background: var(--bg-primary);
|
||||
border-bottom-color: var(--accent-blue);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Scrollbar Styles */
|
||||
.terminal-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.terminal-scrollbar::-webkit-scrollbar-track {
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.terminal-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: var(--border-strong);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.terminal-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-subtle);
|
||||
}
|
||||
|
||||
/* Loading States */
|
||||
.loading-dots {
|
||||
display: inline-flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.loading-dot {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-muted);
|
||||
animation: loadingPulse 1.4s infinite ease-in-out both;
|
||||
}
|
||||
|
||||
.loading-dot:nth-child(1) {
|
||||
animation-delay: -0.32s;
|
||||
}
|
||||
|
||||
.loading-dot:nth-child(2) {
|
||||
animation-delay: -0.16s;
|
||||
}
|
||||
|
||||
@keyframes loadingPulse {
|
||||
0%, 80%, 100% {
|
||||
transform: scale(0.8);
|
||||
opacity: 0.5;
|
||||
}
|
||||
40% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Focus States for Accessibility */
|
||||
.terminal-focusable:focus-visible {
|
||||
outline: 2px solid var(--accent-blue);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Utility Classes */
|
||||
.terminal-text-muted {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.terminal-text-subtle {
|
||||
color: var(--text-subtle);
|
||||
}
|
||||
|
||||
.terminal-text-error {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.terminal-text-success {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.terminal-bg-surface {
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.terminal-border-default {
|
||||
border-color: var(--border-default);
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* Terminal Theme Constants for ra-h
|
||||
* Centralized styling for terminal-inspired UI elements
|
||||
*/
|
||||
|
||||
import { CSSProperties } from 'react';
|
||||
|
||||
// Terminal Color Palette
|
||||
export const TERMINAL_COLORS = {
|
||||
// Message indicators (Claude Code style)
|
||||
user: '#3b82f6', // Blue dot for user messages
|
||||
assistant: '#10b981', // Green dot for assistant messages
|
||||
tool: '#f59e0b', // Orange dot for tool use
|
||||
processing: '#eab308', // Yellow dot for thinking/processing states
|
||||
|
||||
// Terminal elements
|
||||
prompt: '#10b981', // Terminal green for $ prompt
|
||||
accent: '#10b981', // Primary terminal accent color
|
||||
|
||||
// Base colors (terminal-inspired palette)
|
||||
bg: {
|
||||
primary: '#000', // Main background
|
||||
secondary: '#000', // Secondary background (input area) - same as primary
|
||||
elevated: '#000', // Elevated surfaces (message bubbles) - same as primary for borderless look
|
||||
},
|
||||
|
||||
text: {
|
||||
primary: '#d1d5db', // Primary text
|
||||
secondary: '#666', // Muted text
|
||||
tertiary: '#444', // Very muted text
|
||||
},
|
||||
|
||||
border: {
|
||||
primary: '#333', // Primary borders
|
||||
secondary: '#222', // Subtle borders
|
||||
}
|
||||
} as const;
|
||||
|
||||
// Terminal Typography
|
||||
export const TERMINAL_FONTS = {
|
||||
mono: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
|
||||
system: 'inherit'
|
||||
} as const;
|
||||
|
||||
// Terminal Component Styles
|
||||
export const TERMINAL_STYLES = {
|
||||
// Message header with colored dot indicator
|
||||
messageHeader: (role: 'user' | 'assistant' | 'tool'): CSSProperties => ({
|
||||
fontSize: '10px',
|
||||
color: TERMINAL_COLORS.text.secondary,
|
||||
marginBottom: '4px',
|
||||
textTransform: 'uppercase' as const,
|
||||
letterSpacing: '0.05em',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
fontFamily: TERMINAL_FONTS.mono
|
||||
}),
|
||||
|
||||
// Colored dot indicator
|
||||
messageIndicator: (role: 'user' | 'assistant' | 'tool' | 'processing'): CSSProperties => ({
|
||||
fontSize: '8px',
|
||||
color: TERMINAL_COLORS[role],
|
||||
width: '6px',
|
||||
height: '6px',
|
||||
flexShrink: 0
|
||||
}),
|
||||
|
||||
// Message bubble (borderless for cleaner terminal feel)
|
||||
messageBubble: (role: 'user' | 'assistant'): CSSProperties => ({
|
||||
background: role === 'user' ? TERMINAL_COLORS.bg.elevated : TERMINAL_COLORS.bg.secondary,
|
||||
border: 'none', // Removed borders for cleaner terminal look
|
||||
borderRadius: '0px', // Sharp terminal corners
|
||||
padding: '12px 16px',
|
||||
maxWidth: '80%',
|
||||
fontSize: '13px',
|
||||
lineHeight: '1.5',
|
||||
color: TERMINAL_COLORS.text.primary,
|
||||
whiteSpace: 'pre-wrap' as const,
|
||||
fontFamily: TERMINAL_FONTS.mono
|
||||
}),
|
||||
|
||||
// Message timestamp
|
||||
messageTimestamp: (): CSSProperties => ({
|
||||
fontSize: '9px',
|
||||
color: TERMINAL_COLORS.text.tertiary,
|
||||
marginTop: '6px',
|
||||
fontFamily: TERMINAL_FONTS.mono
|
||||
}),
|
||||
|
||||
// Terminal input container
|
||||
terminalInputContainer: (): CSSProperties => ({
|
||||
position: 'relative' as const,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flex: 1
|
||||
}),
|
||||
|
||||
// Terminal prompt symbol
|
||||
terminalPrompt: (): CSSProperties => ({
|
||||
position: 'absolute' as const,
|
||||
left: '12px',
|
||||
color: TERMINAL_COLORS.prompt,
|
||||
fontSize: '13px',
|
||||
fontFamily: TERMINAL_FONTS.mono,
|
||||
zIndex: 1,
|
||||
pointerEvents: 'none' as const
|
||||
}),
|
||||
|
||||
// Terminal input field (subtle border, auto-expanding)
|
||||
terminalInput: (): CSSProperties => ({
|
||||
width: '100%',
|
||||
background: TERMINAL_COLORS.bg.elevated,
|
||||
border: `1px solid ${TERMINAL_COLORS.border.secondary}`, // More subtle border
|
||||
borderRadius: '4px',
|
||||
padding: '8px 12px 8px 24px', // Extra left padding for $ symbol
|
||||
fontSize: '13px',
|
||||
color: TERMINAL_COLORS.text.primary,
|
||||
fontFamily: TERMINAL_FONTS.mono,
|
||||
resize: 'none' as const,
|
||||
minHeight: '36px',
|
||||
maxHeight: '120px', // Increased max height
|
||||
outline: 'none',
|
||||
transition: 'border-color 0.2s, height 0.1s ease',
|
||||
overflow: 'hidden' // For auto-resize
|
||||
}),
|
||||
|
||||
// Terminal send button
|
||||
terminalButton: (disabled: boolean = false): CSSProperties => ({
|
||||
padding: '8px 16px',
|
||||
background: disabled ? TERMINAL_COLORS.border.primary : TERMINAL_COLORS.accent,
|
||||
color: disabled ? TERMINAL_COLORS.text.secondary : '#fff',
|
||||
border: `1px solid ${TERMINAL_COLORS.border.primary}`,
|
||||
borderRadius: '4px',
|
||||
fontSize: '13px',
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
fontFamily: TERMINAL_FONTS.mono,
|
||||
transition: 'all 0.2s',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px'
|
||||
}),
|
||||
|
||||
// Chat messages container
|
||||
messagesContainer: (): CSSProperties => ({
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
padding: '16px',
|
||||
fontFamily: TERMINAL_FONTS.mono
|
||||
}),
|
||||
|
||||
// Thinking indicator
|
||||
thinkingIndicator: (): CSSProperties => ({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '12px 16px',
|
||||
background: TERMINAL_COLORS.bg.secondary,
|
||||
borderRadius: '0px',
|
||||
margin: '8px 0',
|
||||
fontSize: '12px',
|
||||
color: TERMINAL_COLORS.text.secondary,
|
||||
fontFamily: TERMINAL_FONTS.mono,
|
||||
fontStyle: 'italic'
|
||||
}),
|
||||
|
||||
// Tool use indicator (separate from messages)
|
||||
toolIndicator: (): CSSProperties => ({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '8px 12px',
|
||||
background: 'rgba(245, 158, 11, 0.1)', // Orange background with transparency
|
||||
border: `1px solid ${TERMINAL_COLORS.tool}`,
|
||||
borderRadius: '0px',
|
||||
margin: '4px 0',
|
||||
fontSize: '11px',
|
||||
color: TERMINAL_COLORS.tool,
|
||||
fontFamily: TERMINAL_FONTS.mono
|
||||
}),
|
||||
|
||||
// Input form container (remove border)
|
||||
inputForm: (): CSSProperties => ({
|
||||
borderTop: 'none', // Remove border between chat and input
|
||||
padding: '16px',
|
||||
background: TERMINAL_COLORS.bg.primary // Same as main background for seamless look
|
||||
})
|
||||
} as const;
|
||||
|
||||
// Terminal Tool Indicators
|
||||
export const TERMINAL_TOOLS = {
|
||||
processing: '⟡ Processing...',
|
||||
generating: '▸ Generating response...',
|
||||
thinking: (displayName: string) => `${displayName} is thinking...`,
|
||||
toolPrefix: '⟩',
|
||||
thinkingDots: '⋯'
|
||||
} as const;
|
||||
|
||||
// Terminal Animations (for use in CSS)
|
||||
export const TERMINAL_ANIMATIONS = {
|
||||
pulse: 'pulse 1.5s ease-in-out infinite',
|
||||
typing: 'typing 1s ease-in-out infinite'
|
||||
} as const;
|
||||
|
||||
// Helper function to get role-based styling
|
||||
export const getMessageStyles = (role: 'user' | 'assistant', isLoading?: boolean) => ({
|
||||
header: TERMINAL_STYLES.messageHeader(role),
|
||||
indicator: TERMINAL_STYLES.messageIndicator(isLoading ? 'processing' : role),
|
||||
bubble: TERMINAL_STYLES.messageBubble(role),
|
||||
timestamp: TERMINAL_STYLES.messageTimestamp()
|
||||
});
|
||||
|
||||
// Helper function to get input styling with focus states
|
||||
export const getTerminalInputStyles = (isFocused: boolean = false) => ({
|
||||
container: TERMINAL_STYLES.terminalInputContainer(),
|
||||
prompt: TERMINAL_STYLES.terminalPrompt(),
|
||||
input: {
|
||||
...TERMINAL_STYLES.terminalInput(),
|
||||
borderColor: isFocused ? TERMINAL_COLORS.accent : TERMINAL_COLORS.border.secondary
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-resize textarea helper function
|
||||
export const autoResizeTextarea = (textarea: HTMLTextAreaElement) => {
|
||||
textarea.style.height = 'auto';
|
||||
const newHeight = Math.min(textarea.scrollHeight, 120); // Max 120px
|
||||
textarea.style.height = `${Math.max(newHeight, 36)}px`; // Min 36px
|
||||
};
|
||||
|
||||
// Thinking indicator component props
|
||||
export const getThinkingIndicator = (displayName: string, isVisible: boolean) => {
|
||||
if (!isVisible) return null;
|
||||
|
||||
return {
|
||||
style: TERMINAL_STYLES.thinkingIndicator(),
|
||||
content: TERMINAL_TOOLS.thinking(displayName),
|
||||
dots: TERMINAL_TOOLS.thinkingDots
|
||||
};
|
||||
};
|
||||
|
||||
// Tool use indicator component props
|
||||
export const getToolIndicator = (toolName: string, isActive: boolean) => {
|
||||
if (!isActive) return null;
|
||||
|
||||
return {
|
||||
style: TERMINAL_STYLES.toolIndicator(),
|
||||
content: `${TERMINAL_TOOLS.toolPrefix} ${toolName}`,
|
||||
isActive
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const createDimensionTool = tool({
|
||||
description: 'Create a new dimension or update existing dimension',
|
||||
inputSchema: z.object({
|
||||
name: z.string().describe('Dimension name'),
|
||||
description: z.string().max(500).optional().describe('Dimension description (max 500 characters)'),
|
||||
isPriority: z.boolean().optional().describe('Whether to lock this dimension for auto-assignment (default: false)')
|
||||
}),
|
||||
execute: async (params) => {
|
||||
console.log('📁 CreateDimension tool called with params:', JSON.stringify(params, null, 2));
|
||||
try {
|
||||
const trimmedName = params.name.trim();
|
||||
if (!trimmedName) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Dimension name is required',
|
||||
data: null
|
||||
};
|
||||
}
|
||||
|
||||
// Call POST /api/dimensions
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/dimensions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: trimmedName,
|
||||
description: params.description?.trim() || null,
|
||||
isPriority: params.isPriority || false
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = 'Failed to create dimension';
|
||||
try {
|
||||
const errorResult = await response.json();
|
||||
errorMessage = errorResult.error || errorMessage;
|
||||
} catch {
|
||||
// If response is not JSON (e.g., HTML error page), use status text
|
||||
errorMessage = `Failed to create dimension: ${response.status} ${response.statusText}`;
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
data: null
|
||||
};
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
message: `Created dimension "${trimmedName}"${params.isPriority ? ' (locked)' : ''}${params.description ? ' with description' : ''}`
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to create dimension',
|
||||
data: null
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import { edgeService } from '@/services/database/edges';
|
||||
import { nodeService } from '@/services/database/nodes';
|
||||
import { formatNodeForChat } from '../infrastructure/nodeFormatter';
|
||||
|
||||
export const createEdgeTool = tool({
|
||||
description: 'Create directed relationship between nodes',
|
||||
inputSchema: z.object({
|
||||
from_node_id: z.number().describe('The ID of the source node (where the connection originates)'),
|
||||
to_node_id: z.number().describe('The ID of the target node (where the connection points to)'),
|
||||
context: z.record(z.any()).optional().describe('Additional context about this connection - can include explanation, relationship type, strength, notes, etc.'),
|
||||
source: z.enum(['user', 'ai', 'ai_similarity', 'helper_name']).default('ai').describe('Source of this edge - use "ai" for AI-created connections, "user" for manual connections, "ai_similarity" for similarity-based connections')
|
||||
}),
|
||||
execute: async (params) => {
|
||||
console.log('🔗 CreateEdge tool called with params:', JSON.stringify(params, null, 2));
|
||||
|
||||
try {
|
||||
// Validate basic IDs
|
||||
if (!Number.isFinite(params.from_node_id) || params.from_node_id <= 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'from_node_id must be a positive integer. Use queryNodes to confirm the source node ID before creating the edge.',
|
||||
data: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (!Number.isFinite(params.to_node_id) || params.to_node_id <= 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'to_node_id must be a positive integer. Run queryNodes to fetch the target node ID before creating the edge.',
|
||||
data: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (params.from_node_id === params.to_node_id) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Cannot create edge from a node to itself',
|
||||
data: null
|
||||
};
|
||||
}
|
||||
|
||||
const [fromNode, toNode] = await Promise.all([
|
||||
nodeService.getNodeById(params.from_node_id),
|
||||
nodeService.getNodeById(params.to_node_id)
|
||||
]);
|
||||
|
||||
if (!fromNode) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Source node ${params.from_node_id} not found. Use queryNodes to confirm the ID before creating the edge.`,
|
||||
data: null
|
||||
};
|
||||
}
|
||||
|
||||
if (!toNode) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Target node ${params.to_node_id} not found. Run queryNodes to fetch the correct ID before creating the edge.`,
|
||||
data: null
|
||||
};
|
||||
}
|
||||
|
||||
// Check if edge already exists
|
||||
const exists = await edgeService.edgeExists(params.from_node_id, params.to_node_id);
|
||||
if (exists) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Edge already exists between node ${params.from_node_id} and node ${params.to_node_id}`,
|
||||
data: null
|
||||
};
|
||||
}
|
||||
|
||||
// Normalize and create the edge
|
||||
const source = (() => {
|
||||
if (params.source === 'ai') return 'helper_name';
|
||||
if (params.source === 'helper_name') return 'helper_name';
|
||||
if (params.source === 'ai_similarity') return 'ai_similarity';
|
||||
if (params.source === 'user') return 'user';
|
||||
return 'helper_name';
|
||||
})();
|
||||
|
||||
const newEdge = await edgeService.createEdge({
|
||||
from_node_id: params.from_node_id,
|
||||
to_node_id: params.to_node_id,
|
||||
context: params.context || {},
|
||||
source
|
||||
});
|
||||
|
||||
const fromLabel = formatNodeForChat({
|
||||
id: fromNode.id,
|
||||
title: fromNode.title,
|
||||
dimensions: fromNode.dimensions || []
|
||||
});
|
||||
|
||||
const toLabel = formatNodeForChat({
|
||||
id: toNode.id,
|
||||
title: toNode.title,
|
||||
dimensions: toNode.dimensions || []
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: newEdge,
|
||||
message: `Created edge connection from ${fromLabel} to ${toLabel}`,
|
||||
formatted_labels: {
|
||||
from: fromLabel,
|
||||
to: toLabel
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('CreateEdge tool error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to create edge',
|
||||
data: null
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import { formatNodeForChat } from '../infrastructure/nodeFormatter';
|
||||
|
||||
export const createNodeTool = tool({
|
||||
description: 'Create node with title/content/link and optional dimensions (locked dimensions auto-assigned)',
|
||||
inputSchema: z.object({
|
||||
title: z.string().describe('The title of the node'),
|
||||
content: z.string().optional().describe('The main content, description, or notes for this node'),
|
||||
link: z.string().optional().describe('A URL link to the source'),
|
||||
dimensions: z
|
||||
.array(z.string())
|
||||
.max(5)
|
||||
.optional()
|
||||
.describe('Optional dimension tags to apply to this node (0-5 items). Locked dimensions will be auto-assigned.'),
|
||||
chunk: z.string().optional().describe('Raw content for later processing - CRITICAL for extracted content from URLs'),
|
||||
metadata: z.record(z.any()).optional().describe('Additional metadata like source info, extraction details, etc.')
|
||||
}),
|
||||
execute: async (params) => {
|
||||
console.log('🎯 CreateNode tool called with params:', JSON.stringify(params, null, 2));
|
||||
try {
|
||||
const rawDimensions = params.dimensions || [];
|
||||
const trimmedDimensions = rawDimensions
|
||||
.map(d => typeof d === 'string' ? d.trim() : '')
|
||||
.filter(Boolean)
|
||||
.slice(0, 5); // Limit to 5 dimensions max
|
||||
|
||||
// Call the nodes API endpoint
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/nodes`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...params, dimensions: trimmedDimensions })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false,
|
||||
error: result.error || 'Failed to create node',
|
||||
data: null
|
||||
};
|
||||
}
|
||||
|
||||
// Format the created node for chat display
|
||||
const formattedDisplay = formatNodeForChat({
|
||||
id: result.data.id,
|
||||
title: result.data.title,
|
||||
dimensions: result.data.dimensions || trimmedDimensions
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
...result.data,
|
||||
formatted_display: formattedDisplay
|
||||
},
|
||||
message: `Created node ${formattedDisplay} with dimensions: ${result.data.dimensions ? result.data.dimensions.join(', ') : 'auto-assigned'}`
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to create node',
|
||||
data: null
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Legacy export for backwards compatibility
|
||||
export const createItemTool = createNodeTool;
|
||||
@@ -0,0 +1,58 @@
|
||||
import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const deleteDimensionTool = tool({
|
||||
description: 'Delete a dimension and remove all node associations',
|
||||
inputSchema: z.object({
|
||||
name: z.string().describe('Dimension name to delete')
|
||||
}),
|
||||
execute: async (params) => {
|
||||
console.log('🗑️ DeleteDimension tool called with params:', JSON.stringify(params, null, 2));
|
||||
try {
|
||||
const trimmedName = params.name.trim();
|
||||
if (!trimmedName) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Dimension name is required',
|
||||
data: null
|
||||
};
|
||||
}
|
||||
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/dimensions?name=${encodeURIComponent(trimmedName)}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = 'Failed to delete dimension';
|
||||
try {
|
||||
const errorResult = await response.json();
|
||||
errorMessage = errorResult.error || errorMessage;
|
||||
} catch {
|
||||
// If response is not JSON (e.g., HTML error page), use status text
|
||||
errorMessage = `Failed to delete dimension: ${response.status} ${response.statusText}`;
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
data: null
|
||||
};
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
message: `Deleted dimension "${trimmedName}" and removed all node associations`
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to delete dimension',
|
||||
data: null
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user