chore: codebase audit — remove dead code, unused deps, clean configs
Phase 1: Delete 19 dead component/service/tool files (4,200+ lines) Phase 2: Remove 13 unused npm packages (Radix UI, shadcn utilities, ytdl-core, etc.) Phase 3: Database schema — drop agent_delegations, add performance indexes Phase 4: Remove 12 dead types from database.ts/analytics.ts/helpers.ts Phase 5: Strip shadcn/ui theme from tailwind config Phase 6: Clean env vars, fix broken imports (LocalKeyGate, MapViewer) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
65d22315e1
commit
9b2db68751
@@ -1,99 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
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());
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
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 = () => {
|
||||
window.dispatchEvent(new CustomEvent('settings:open', { detail: { tab: 'apikeys' } }));
|
||||
};
|
||||
|
||||
const popupContent = (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 24,
|
||||
right: 24,
|
||||
maxWidth: 420,
|
||||
zIndex: 99999,
|
||||
pointerEvents: 'auto'
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
{mounted && createPortal(popupContent, document.body)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,400 +0,0 @@
|
||||
"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
|
||||
className="search-backdrop"
|
||||
onClick={handleBackdropClick}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Search nodes to connect"
|
||||
>
|
||||
<div ref={modalRef} className="search-container">
|
||||
{/* Search Input */}
|
||||
<div className="search-input-wrapper">
|
||||
<svg className="search-icon" 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>
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Connect to node..."
|
||||
className="search-input"
|
||||
/>
|
||||
|
||||
<div className="search-shortcut">
|
||||
<kbd>esc</kbd>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
{suggestions.length > 0 && (
|
||||
<div className="search-results">
|
||||
{suggestions.map((suggestion, index) => (
|
||||
<button
|
||||
key={suggestion.id}
|
||||
onClick={() => handleSelectSuggestion(suggestion)}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
className={`search-result-item ${index === selectedIndex ? 'selected' : ''}`}
|
||||
>
|
||||
<span className="result-id">#{suggestion.id}</span>
|
||||
<span className="result-title">{suggestion.title}</span>
|
||||
{index === selectedIndex && (
|
||||
<span className="result-hint">↵</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{searchQuery && suggestions.length === 0 && (
|
||||
<div className="search-empty">
|
||||
No results for "{searchQuery}"
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
.search-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
backdrop-filter: blur(8px);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding-top: 15vh;
|
||||
z-index: 9999;
|
||||
animation: backdropIn 200ms ease-out;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
max-height: 70vh;
|
||||
animation: containerIn 200ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.search-input-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
background: #141414;
|
||||
border: 1px solid #262626;
|
||||
border-radius: 16px;
|
||||
padding: 20px 24px;
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(255, 255, 255, 0.04),
|
||||
0 24px 48px -12px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
color: #525252;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: #fafafa;
|
||||
font-size: 18px;
|
||||
font-family: inherit;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: #525252;
|
||||
}
|
||||
|
||||
.search-shortcut {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.search-shortcut kbd {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px 8px;
|
||||
background: #262626;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-family: inherit;
|
||||
color: #737373;
|
||||
border: 1px solid #333;
|
||||
}
|
||||
|
||||
.search-results {
|
||||
margin-top: 8px;
|
||||
background: #141414;
|
||||
border: 1px solid #262626;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(255, 255, 255, 0.04),
|
||||
0 24px 48px -12px rgba(0, 0, 0, 0.6);
|
||||
animation: resultsIn 150ms ease-out;
|
||||
}
|
||||
|
||||
.search-result-item {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 16px 20px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 1px solid #1f1f1f;
|
||||
cursor: pointer;
|
||||
transition: background 100ms ease;
|
||||
text-align: left;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.search-result-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.search-result-item:hover,
|
||||
.search-result-item.selected {
|
||||
background: #1a1a1a;
|
||||
}
|
||||
|
||||
.result-id {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
color: #0a0a0a;
|
||||
background: #22c55e;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
min-width: 28px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.result-title {
|
||||
flex: 1;
|
||||
color: #e5e5e5;
|
||||
font-size: 15px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.result-hint {
|
||||
color: #525252;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.search-empty {
|
||||
margin-top: 8px;
|
||||
padding: 32px 24px;
|
||||
background: #141414;
|
||||
border: 1px solid #262626;
|
||||
border-radius: 16px;
|
||||
color: #525252;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@keyframes backdropIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes containerIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.96) translateY(-8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes resultsIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
|
||||
return typeof window !== 'undefined' ? createPortal(modalContent, document.body) : null;
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronLeft, ChevronRight, FileText, MessageSquare, Workflow, FolderOpen, Map, LayoutList } from 'lucide-react';
|
||||
|
||||
type RailPosition = 'left' | 'right';
|
||||
type PaneType = 'nodes' | 'node' | 'chat' | 'workflows' | 'dimensions' | 'map' | 'views';
|
||||
|
||||
interface CollapsedRailProps {
|
||||
position: RailPosition;
|
||||
paneType: PaneType;
|
||||
onExpand: () => void;
|
||||
shortcut?: string;
|
||||
}
|
||||
|
||||
const PANE_ICONS: Record<PaneType, React.ReactNode> = {
|
||||
nodes: <FileText size={18} />,
|
||||
node: <FileText size={18} />,
|
||||
chat: <MessageSquare size={18} />,
|
||||
workflows: <Workflow size={18} />,
|
||||
dimensions: <FolderOpen size={18} />,
|
||||
map: <Map size={18} />,
|
||||
views: <LayoutList size={18} />,
|
||||
};
|
||||
|
||||
const PANE_LABELS: Record<PaneType, string> = {
|
||||
nodes: 'Nodes',
|
||||
node: 'Focus',
|
||||
chat: 'Chat',
|
||||
workflows: 'Workflows',
|
||||
dimensions: 'Dimensions',
|
||||
map: 'Map',
|
||||
views: 'Feed',
|
||||
};
|
||||
|
||||
export default function CollapsedRail({ position, paneType, onExpand, shortcut }: CollapsedRailProps) {
|
||||
const isLeft = position === 'left';
|
||||
const ChevronIcon = isLeft ? ChevronRight : ChevronLeft;
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onExpand}
|
||||
style={{
|
||||
width: '40px',
|
||||
height: '100%',
|
||||
flexShrink: 0,
|
||||
borderLeft: isLeft ? 'none' : '1px solid #1f1f1f',
|
||||
borderRight: isLeft ? '1px solid #1f1f1f' : 'none',
|
||||
background: '#0c0c0c',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
paddingTop: '12px',
|
||||
gap: '8px',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.15s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#141414';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = '#0c0c0c';
|
||||
}}
|
||||
title={`Expand ${PANE_LABELS[paneType]}${shortcut ? ` (${shortcut})` : ''}`}
|
||||
>
|
||||
{/* Icon representing the collapsed panel */}
|
||||
<div
|
||||
style={{
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
borderRadius: '6px',
|
||||
background: '#1a1a1a',
|
||||
border: '1px solid #333',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#888',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
>
|
||||
{PANE_ICONS[paneType]}
|
||||
</div>
|
||||
|
||||
{/* Chevron indicator */}
|
||||
<div
|
||||
style={{
|
||||
color: '#666',
|
||||
marginTop: '4px',
|
||||
}}
|
||||
>
|
||||
<ChevronIcon size={14} />
|
||||
</div>
|
||||
|
||||
{/* Vertical label */}
|
||||
<div
|
||||
style={{
|
||||
writingMode: 'vertical-rl',
|
||||
textOrientation: 'mixed',
|
||||
transform: isLeft ? 'rotate(180deg)' : 'none',
|
||||
fontSize: '10px',
|
||||
fontWeight: 500,
|
||||
color: '#777',
|
||||
letterSpacing: '0.05em',
|
||||
textTransform: 'uppercase',
|
||||
marginTop: '8px',
|
||||
}}
|
||||
>
|
||||
{PANE_LABELS[paneType]}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,892 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, 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';
|
||||
import { DynamicIcon } from '../common/LucideIconPicker';
|
||||
import { usePersistentState } from '@/hooks/usePersistentState';
|
||||
|
||||
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);
|
||||
const draggedNodeRef = useRef<{ id: number; dimensions?: string[] } | null>(null);
|
||||
|
||||
// Dimension icons (shared with FolderViewOverlay via localStorage)
|
||||
const [dimensionIcons] = usePersistentState<Record<string, string>>('ui.dimensionIcons', {});
|
||||
|
||||
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.dataTransfer.types.includes('text/plain')) {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = 'copy';
|
||||
}
|
||||
};
|
||||
|
||||
const handleDimensionDragEnter = (event: DragEvent<HTMLElement>, dimension: string) => {
|
||||
if (event.dataTransfer.types.includes('application/node-info') || event.dataTransfer.types.includes('text/plain')) {
|
||||
setDragHoverDimension(dimension);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDimensionDragLeave = (event: DragEvent<HTMLElement>, dimension: string) => {
|
||||
if (dragHoverDimension === dimension) {
|
||||
setDragHoverDimension(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNodeDropOnDimension = async (event: DragEvent<HTMLElement>, dimension: string) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
// Try to get data from ref first (works in Electron/Tauri webviews)
|
||||
let payload: { id: number; dimensions?: string[] } | null = draggedNodeRef.current;
|
||||
|
||||
// Fallback to dataTransfer for browser compatibility
|
||||
if (!payload) {
|
||||
const raw = event.dataTransfer.getData('application/node-info') || event.dataTransfer.getData('text/plain');
|
||||
if (raw) {
|
||||
try {
|
||||
payload = JSON.parse(raw);
|
||||
} catch (e) {
|
||||
console.error('Failed to parse drag data:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear the ref
|
||||
draggedNodeRef.current = null;
|
||||
|
||||
if (!payload?.id) {
|
||||
console.warn('No valid node data in drop event');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
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 = 'copyMove';
|
||||
const nodeData = {
|
||||
id: node.id,
|
||||
title: node.title || 'Untitled',
|
||||
dimensions: node.dimensions || []
|
||||
};
|
||||
// Store in ref for webview compatibility (dataTransfer.getData can fail in Electron/Tauri)
|
||||
draggedNodeRef.current = nodeData;
|
||||
// Set multiple MIME types for different drop targets
|
||||
event.dataTransfer.setData('application/node-info', JSON.stringify(nodeData));
|
||||
// For chat input drops - includes title for [NODE:id:"title"] token
|
||||
event.dataTransfer.setData('application/x-rah-node', JSON.stringify({ id: node.id, title: node.title || 'Untitled' }));
|
||||
// Fallback for browsers/webviews that only support text/plain
|
||||
event.dataTransfer.setData('text/plain', `[NODE:${node.id}:"${node.title || 'Untitled'}"]`);
|
||||
|
||||
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 handleNodeDragEnd = () => {
|
||||
// Clear ref if drag ends without a drop
|
||||
draggedNodeRef.current = null;
|
||||
};
|
||||
|
||||
const renderNodeItem = (node: Node) => (
|
||||
<button
|
||||
key={node.id}
|
||||
draggable
|
||||
onDragStart={(event) => handleNodeDragStart(event, node)}
|
||||
onDragEnd={handleNodeDragEnd}
|
||||
onClick={(e) => {
|
||||
const multiSelect = e.metaKey || e.ctrlKey;
|
||||
onNodeSelect(node.id, multiSelect);
|
||||
}}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 16px',
|
||||
margin: '0',
|
||||
textAlign: 'left',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
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';
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Node ID Badge */}
|
||||
<span style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: '#22c55e',
|
||||
color: '#0a0a0a',
|
||||
fontSize: '9px',
|
||||
fontWeight: 600,
|
||||
padding: '1px 5px',
|
||||
borderRadius: '3px',
|
||||
flexShrink: 0,
|
||||
fontFamily: 'monospace',
|
||||
lineHeight: 1,
|
||||
height: '16px'
|
||||
}}>
|
||||
#{node.id}
|
||||
</span>
|
||||
|
||||
{/* 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: 'transparent' }}>
|
||||
{/* 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: 'transparent',
|
||||
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: '28px',
|
||||
height: '28px',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid #1f1f1f',
|
||||
background: 'transparent',
|
||||
color: '#666',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease'
|
||||
}}
|
||||
title={folderViewOpen ? 'Close dimension folder view' : 'Open dimension folder view'}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#1a1a1a';
|
||||
e.currentTarget.style.color = '#999';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
e.currentTarget.style.color = '#666';
|
||||
}}
|
||||
>
|
||||
{folderViewOpen ? <Minimize2 size={14} /> : <Maximize2 size={14} />}
|
||||
</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: 'transparent', /* Transparent to show panel color */
|
||||
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: '14px',
|
||||
fontWeight: 600,
|
||||
color: dimensionsSectionCollapsed ? '#94a3b8' : '#f8fafc',
|
||||
letterSpacing: '0.01em',
|
||||
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</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 }}>
|
||||
{dimensionIcons[lockedDim.dimension] ? (
|
||||
<DynamicIcon
|
||||
name={dimensionIcons[lockedDim.dimension]}
|
||||
size={16}
|
||||
style={{ color: isExpanded ? '#7de8a5' : '#64748b' }}
|
||||
/>
|
||||
) : isExpanded ? (
|
||||
<FolderOpen size={16} color="#7de8a5" />
|
||||
) : (
|
||||
<Folder size={16} color="#64748b" />
|
||||
)}
|
||||
<span>
|
||||
{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: '14px',
|
||||
fontWeight: 600,
|
||||
color: allNodesSectionCollapsed ? '#94a3b8' : '#f8fafc',
|
||||
letterSpacing: '0.01em',
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,425 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
|
||||
import type { Edge, Node } from '@/types/database';
|
||||
|
||||
interface GraphNode extends Node {
|
||||
edge_count?: number;
|
||||
x: number;
|
||||
y: number;
|
||||
radius: number;
|
||||
}
|
||||
|
||||
interface LockedDimension {
|
||||
name: string;
|
||||
}
|
||||
|
||||
const NODE_LIMIT = 200;
|
||||
const LABEL_THRESHOLD = 15; // Top N nodes get labels
|
||||
|
||||
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 [selectedNode, setSelectedNode] = useState<Node | null>(null);
|
||||
const [transform, setTransform] = useState({ x: 0, y: 0, scale: 1 });
|
||||
|
||||
// Resize observer
|
||||
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();
|
||||
}, []);
|
||||
|
||||
// Fetch data
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [nodesRes, edgesRes, dimsRes] = await Promise.all([
|
||||
fetch(`/api/nodes?limit=${NODE_LIMIT}&sortBy=edges`),
|
||||
fetch('/api/edges'),
|
||||
fetch('/api/dimensions'),
|
||||
]);
|
||||
|
||||
if (!nodesRes.ok || !edgesRes.ok) {
|
||||
throw new Error('Failed to load data');
|
||||
}
|
||||
|
||||
const nodesPayload = await nodesRes.json();
|
||||
const edgesPayload = await edgesRes.json();
|
||||
|
||||
setNodes(nodesPayload.data || []);
|
||||
setEdges(edgesPayload.data || []);
|
||||
|
||||
// Get locked dimensions
|
||||
if (dimsRes.ok) {
|
||||
const dimsPayload = await dimsRes.json();
|
||||
if (dimsPayload.success && dimsPayload.data) {
|
||||
const locked = (dimsPayload.data as LockedDimension[])
|
||||
.filter((d: LockedDimension & { is_locked?: boolean }) => d.is_locked)
|
||||
.map((d: LockedDimension) => d.name);
|
||||
setLockedDimensions(new Set(locked));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
// Position nodes in a cluster layout
|
||||
const graphNodes = useMemo<GraphNode[]>(() => {
|
||||
if (nodes.length === 0) return [];
|
||||
|
||||
const { width, height } = containerSize;
|
||||
const centerX = width / 2;
|
||||
const centerY = height / 2;
|
||||
|
||||
// Sort by edge count (highest first)
|
||||
const sorted = [...nodes].sort((a, b) => (b.edge_count ?? 0) - (a.edge_count ?? 0));
|
||||
|
||||
// Find max edge count for scaling
|
||||
const maxEdges = Math.max(...sorted.map(n => n.edge_count ?? 0), 1);
|
||||
|
||||
// Position nodes using a spiral/cluster approach
|
||||
// High-edge nodes get placed more centrally with more space
|
||||
return sorted.map((node, index) => {
|
||||
const edgeCount = node.edge_count ?? 0;
|
||||
const edgeRatio = edgeCount / maxEdges;
|
||||
|
||||
// Radius from center - higher edge count = closer to center
|
||||
// Use golden angle for nice distribution
|
||||
const goldenAngle = Math.PI * (3 - Math.sqrt(5));
|
||||
const angle = index * goldenAngle;
|
||||
|
||||
// Distance from center inversely proportional to edge count
|
||||
// Top nodes cluster in center, others spread out
|
||||
// Extra spacing for labeled nodes (top 15) to prevent label overlap
|
||||
const isLabeled = index < LABEL_THRESHOLD;
|
||||
const labelSpacing = isLabeled ? 60 : 0;
|
||||
const baseDistance = 80 + labelSpacing + (1 - edgeRatio) * Math.min(width, height) * 0.35;
|
||||
const distance = baseDistance + (index * 4); // More spread between nodes
|
||||
|
||||
const x = centerX + Math.cos(angle) * distance;
|
||||
const y = centerY + Math.sin(angle) * distance;
|
||||
|
||||
// Node size based on edge count
|
||||
// Min 3px for tiny dots, max ~20px for top nodes
|
||||
const minRadius = 3;
|
||||
const maxRadius = 18;
|
||||
const radius = minRadius + edgeRatio * (maxRadius - minRadius);
|
||||
|
||||
return {
|
||||
...node,
|
||||
x,
|
||||
y,
|
||||
radius,
|
||||
};
|
||||
});
|
||||
}, [nodes, containerSize]);
|
||||
|
||||
// Get edges between visible nodes
|
||||
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));
|
||||
|
||||
return edges
|
||||
.map(edge => {
|
||||
const source = nodeMap.get(edge.from_node_id);
|
||||
const target = nodeMap.get(edge.to_node_id);
|
||||
if (!source || !target) return null;
|
||||
return { id: edge.id, source, target };
|
||||
})
|
||||
.filter(Boolean) as Array<{ id: number; source: GraphNode; target: GraphNode }>;
|
||||
}, [edges, graphNodes]);
|
||||
|
||||
// Get connected node IDs for selected node
|
||||
const connectedNodeIds = useMemo(() => {
|
||||
if (!selectedNode) return new Set<number>();
|
||||
const connected = new Set<number>();
|
||||
edges.forEach(edge => {
|
||||
if (edge.from_node_id === selectedNode.id) connected.add(edge.to_node_id);
|
||||
if (edge.to_node_id === selectedNode.id) connected.add(edge.from_node_id);
|
||||
});
|
||||
return connected;
|
||||
}, [selectedNode, edges]);
|
||||
|
||||
// Pan handling
|
||||
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);
|
||||
};
|
||||
|
||||
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, 3)
|
||||
: Math.max(prev.scale - 0.2, 0.5),
|
||||
}));
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#666' }}>
|
||||
Loading map...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#ef4444' }}>
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (graphNodes.length === 0) {
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#666' }}>
|
||||
No nodes to display
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} style={{ position: 'relative', height: '100%', background: '#080808' }}>
|
||||
{/* Zoom controls */}
|
||||
<div style={{ position: 'absolute', top: 16, right: 16, display: 'flex', gap: 8, zIndex: 10 }}>
|
||||
<button onClick={() => handleZoom('in')} style={controlBtn} title="Zoom in">+</button>
|
||||
<button onClick={() => handleZoom('out')} style={controlBtn} title="Zoom out">−</button>
|
||||
<button onClick={() => handleZoom('reset')} style={controlBtn} title="Reset">⟳</button>
|
||||
</div>
|
||||
|
||||
{/* Selected node info */}
|
||||
{selectedNode && (
|
||||
<div style={infoPanel}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', marginBottom: 8 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 14 }}>
|
||||
{selectedNode.title || 'Untitled'}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSelectedNode(null)}
|
||||
style={{ background: 'none', border: 'none', color: '#666', cursor: 'pointer', fontSize: 16 }}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#666', marginBottom: 8 }}>
|
||||
{connectedNodeIds.size} connected nodes · {selectedNode.edge_count ?? 0} total edges
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: '#22c55e', marginBottom: 8 }}>
|
||||
Click a highlighted node to explore
|
||||
</div>
|
||||
{selectedNode.dimensions && selectedNode.dimensions.length > 0 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||||
{selectedNode.dimensions.slice(0, 5).map(dim => (
|
||||
<span
|
||||
key={dim}
|
||||
style={{
|
||||
padding: '2px 8px',
|
||||
borderRadius: 999,
|
||||
fontSize: 11,
|
||||
background: lockedDimensions.has(dim) ? '#132018' : '#1a1a1a',
|
||||
color: lockedDimensions.has(dim) ? '#86efac' : '#888',
|
||||
}}
|
||||
>
|
||||
{dim}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SVG Graph */}
|
||||
<svg width="100%" height="100%" style={{ display: 'block' }}>
|
||||
<defs />
|
||||
<rect
|
||||
width="100%"
|
||||
height="100%"
|
||||
fill="transparent"
|
||||
style={{ cursor: 'grab' }}
|
||||
onPointerDown={handlePanStart}
|
||||
/>
|
||||
<g transform={`translate(${transform.x} ${transform.y}) scale(${transform.scale})`}>
|
||||
{/* Edges */}
|
||||
{graphEdges.map(edge => {
|
||||
const isConnected = selectedNode && (
|
||||
edge.source.id === selectedNode.id || edge.target.id === selectedNode.id
|
||||
);
|
||||
return (
|
||||
<line
|
||||
key={edge.id}
|
||||
x1={edge.source.x}
|
||||
y1={edge.source.y}
|
||||
x2={edge.target.x}
|
||||
y2={edge.target.y}
|
||||
stroke={isConnected ? '#22c55e' : '#374151'}
|
||||
strokeWidth={isConnected ? 1.5 : 0.75}
|
||||
strokeOpacity={selectedNode ? (isConnected ? 0.9 : 0.15) : 0.6}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Nodes */}
|
||||
{graphNodes.map((node, index) => {
|
||||
const isTop = index < LABEL_THRESHOLD;
|
||||
const isSelected = selectedNode?.id === node.id;
|
||||
const isConnectedToSelected = connectedNodeIds.has(node.id);
|
||||
const isDimmed = selectedNode && !isSelected && !isConnectedToSelected;
|
||||
|
||||
return (
|
||||
<g
|
||||
key={node.id}
|
||||
onClick={() => setSelectedNode(isSelected ? null : node)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
opacity={isDimmed ? 0.25 : 1}
|
||||
>
|
||||
{/* Highlight ring for connected nodes */}
|
||||
{isConnectedToSelected && !isSelected && (
|
||||
<circle
|
||||
cx={node.x}
|
||||
cy={node.y}
|
||||
r={node.radius + 4}
|
||||
fill="none"
|
||||
stroke="#22c55e"
|
||||
strokeWidth={2}
|
||||
strokeOpacity={0.6}
|
||||
/>
|
||||
)}
|
||||
{/* Node circle */}
|
||||
<circle
|
||||
cx={node.x}
|
||||
cy={node.y}
|
||||
r={node.radius}
|
||||
fill={isTop ? '#22c55e' : '#334155'}
|
||||
fillOpacity={isTop ? 0.6 : 0.4}
|
||||
stroke={isSelected ? '#fff' : isTop ? '#166534' : '#1e293b'}
|
||||
strokeWidth={isSelected ? 2 : isTop ? 1.5 : 0.5}
|
||||
/>
|
||||
|
||||
{/* Label for top nodes */}
|
||||
{isTop && (
|
||||
<>
|
||||
{/* Title */}
|
||||
<text
|
||||
x={node.x}
|
||||
y={node.y + node.radius + 14}
|
||||
textAnchor="middle"
|
||||
fill="#e5e7eb"
|
||||
fontSize={11}
|
||||
fontWeight={500}
|
||||
>
|
||||
{(node.title || 'Untitled').slice(0, 20)}
|
||||
{(node.title?.length ?? 0) > 20 ? '…' : ''}
|
||||
</text>
|
||||
|
||||
{/* Top dimensions (max 3) */}
|
||||
{node.dimensions && node.dimensions.length > 0 && (() => {
|
||||
const dims = node.dimensions.slice(0, 3).map(d => d.length > 10 ? d.slice(0, 9) + '…' : d).join(' · ');
|
||||
const labelWidth = dims.length * 5 + 16;
|
||||
return (
|
||||
<g>
|
||||
<rect
|
||||
x={node.x - labelWidth / 2}
|
||||
y={node.y + node.radius + 18}
|
||||
width={labelWidth}
|
||||
height={16}
|
||||
rx={8}
|
||||
fill="#141414"
|
||||
stroke="#262626"
|
||||
strokeWidth={0.5}
|
||||
/>
|
||||
<text
|
||||
x={node.x}
|
||||
y={node.y + node.radius + 29}
|
||||
textAnchor="middle"
|
||||
fill="#a1a1aa"
|
||||
fontSize={9}
|
||||
>
|
||||
{dims}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const controlBtn: CSSProperties = {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 6,
|
||||
border: '1px solid #262626',
|
||||
background: '#141414',
|
||||
color: '#888',
|
||||
fontSize: 16,
|
||||
cursor: 'pointer',
|
||||
};
|
||||
|
||||
const infoPanel: CSSProperties = {
|
||||
position: 'absolute',
|
||||
bottom: 16,
|
||||
left: 16,
|
||||
width: 260,
|
||||
background: '#0a0a0a',
|
||||
border: '1px solid #1f1f1f',
|
||||
borderRadius: 8,
|
||||
padding: 14,
|
||||
zIndex: 10,
|
||||
};
|
||||
@@ -1,44 +0,0 @@
|
||||
"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: '2px solid #22c55e',
|
||||
borderRadius: '50%',
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,6 @@ 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';
|
||||
@@ -18,7 +17,6 @@ export type SettingsTab =
|
||||
| 'workflows'
|
||||
| 'apikeys'
|
||||
| 'database'
|
||||
| 'map'
|
||||
| 'context'
|
||||
| 'agents';
|
||||
|
||||
@@ -197,20 +195,6 @@ export default function SettingsModal({ isOpen, onClose, initialTab }: SettingsM
|
||||
>
|
||||
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={{
|
||||
@@ -315,7 +299,6 @@ export default function SettingsModal({ isOpen, onClose, initialTab }: SettingsM
|
||||
{activeTab === 'apikeys' && 'API Keys'}
|
||||
{activeTab === 'database' && 'Knowledge Database'}
|
||||
{activeTab === 'context' && 'Auto-Context'}
|
||||
{activeTab === 'map' && 'Knowledge Map'}
|
||||
{activeTab === 'agents' && 'External Agents'}
|
||||
</h2>
|
||||
<button
|
||||
@@ -350,7 +333,6 @@ export default function SettingsModal({ isOpen, onClose, initialTab }: SettingsM
|
||||
{activeTab === 'apikeys' && <ApiKeysViewer />}
|
||||
{activeTab === 'database' && <DatabaseViewer />}
|
||||
{activeTab === 'context' && <ContextViewer />}
|
||||
{activeTab === 'map' && <MapViewer />}
|
||||
{activeTab === 'agents' && <ExternalAgentsPanel />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,286 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Eye, List, Columns, LayoutGrid } from 'lucide-react';
|
||||
import { ViewType, ViewConfig, ViewFilter, DEFAULT_VIEW_CONFIG, KanbanColumn } from '@/types/views';
|
||||
import { Node } from '@/types/database';
|
||||
import ListView from './ListView';
|
||||
import KanbanView from './KanbanView';
|
||||
import GridView from './GridView';
|
||||
import ViewFilters from './ViewFilters';
|
||||
|
||||
interface ViewPanelProps {
|
||||
viewMode: ViewType;
|
||||
onViewModeChange: (mode: ViewType) => void;
|
||||
onNodeClick: (nodeId: number) => void;
|
||||
refreshTrigger?: number;
|
||||
}
|
||||
|
||||
export default function ViewPanel({
|
||||
viewMode,
|
||||
onViewModeChange,
|
||||
onNodeClick,
|
||||
refreshTrigger
|
||||
}: ViewPanelProps) {
|
||||
const [nodes, setNodes] = useState<Node[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [config, setConfig] = useState<ViewConfig>(DEFAULT_VIEW_CONFIG);
|
||||
const [dimensions, setDimensions] = useState<string[]>([]);
|
||||
|
||||
// Fetch all nodes
|
||||
const fetchNodes = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Build query params from filters
|
||||
const params = new URLSearchParams();
|
||||
params.set('limit', '500');
|
||||
|
||||
if (config.filters.length > 0) {
|
||||
const includeDimensions = config.filters
|
||||
.filter(f => f.operator === 'includes')
|
||||
.map(f => f.dimension);
|
||||
if (includeDimensions.length > 0) {
|
||||
params.set('dimensions', includeDimensions.join(','));
|
||||
}
|
||||
}
|
||||
|
||||
if (config.sort) {
|
||||
params.set('sortBy', config.sort.field);
|
||||
params.set('sortOrder', config.sort.direction);
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/nodes?${params.toString()}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
let filteredNodes = data.nodes || [];
|
||||
|
||||
// Apply exclude filters client-side
|
||||
const excludeDimensions = config.filters
|
||||
.filter(f => f.operator === 'excludes')
|
||||
.map(f => f.dimension);
|
||||
if (excludeDimensions.length > 0) {
|
||||
filteredNodes = filteredNodes.filter((node: Node) =>
|
||||
!excludeDimensions.some(dim => node.dimensions?.includes(dim))
|
||||
);
|
||||
}
|
||||
|
||||
setNodes(filteredNodes);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch nodes:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [config.filters, config.sort]);
|
||||
|
||||
// Fetch available dimensions
|
||||
const fetchDimensions = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch('/api/dimensions/popular');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const dims = data.data?.map((d: { dimension: string }) => d.dimension) || [];
|
||||
setDimensions(dims);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch dimensions:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchNodes();
|
||||
}, [fetchNodes, refreshTrigger]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDimensions();
|
||||
}, [fetchDimensions]);
|
||||
|
||||
const handleFilterChange = (filters: ViewFilter[]) => {
|
||||
setConfig(prev => ({ ...prev, filters }));
|
||||
};
|
||||
|
||||
const handleFilterLogicChange = (logic: 'and' | 'or') => {
|
||||
setConfig(prev => ({ ...prev, filterLogic: logic }));
|
||||
};
|
||||
|
||||
const handleColumnChange = (columns: KanbanColumn[]) => {
|
||||
setConfig(prev => ({ ...prev, columns }));
|
||||
};
|
||||
|
||||
const handleNodeDimensionUpdate = async (nodeId: number, newDimension: string, oldDimension?: string) => {
|
||||
// Update node's dimensions (add new, remove old if kanban move)
|
||||
try {
|
||||
const node = nodes.find(n => n.id === nodeId);
|
||||
if (!node) return;
|
||||
|
||||
let newDimensions = [...(node.dimensions || [])];
|
||||
|
||||
// Remove old dimension if moving in kanban
|
||||
if (oldDimension) {
|
||||
newDimensions = newDimensions.filter(d => d !== oldDimension);
|
||||
}
|
||||
|
||||
// Add new dimension if not already present
|
||||
if (!newDimensions.includes(newDimension)) {
|
||||
newDimensions.push(newDimension);
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/nodes/${nodeId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ dimensions: newDimensions })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// Refresh nodes to get updated data
|
||||
fetchNodes();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update node dimensions:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', background: '#000' }}>
|
||||
{/* Header with View Mode Selector */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '8px 12px',
|
||||
borderBottom: '1px solid #333',
|
||||
background: '#0a0a0a',
|
||||
flexShrink: 0
|
||||
}}>
|
||||
<div style={{ fontSize: '12px', color: '#888', fontWeight: 500 }}>
|
||||
{nodes.length} nodes
|
||||
</div>
|
||||
|
||||
{/* View Mode Buttons */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '2px'
|
||||
}}>
|
||||
<button
|
||||
onClick={() => onViewModeChange('focus')}
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
background: viewMode === 'focus' ? '#1a1a1a' : 'transparent',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
color: viewMode === 'focus' ? '#22c55e' : '#666',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
title="Focus View"
|
||||
>
|
||||
<Eye size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onViewModeChange('list')}
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
background: viewMode === 'list' ? '#1a1a1a' : 'transparent',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
color: viewMode === 'list' ? '#22c55e' : '#666',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
title="List View"
|
||||
>
|
||||
<List size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onViewModeChange('kanban')}
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
background: viewMode === 'kanban' ? '#1a1a1a' : 'transparent',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
color: viewMode === 'kanban' ? '#22c55e' : '#666',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
title="Kanban View"
|
||||
>
|
||||
<Columns size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onViewModeChange('grid')}
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
background: viewMode === 'grid' ? '#1a1a1a' : 'transparent',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
color: viewMode === 'grid' ? '#22c55e' : '#666',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
title="Grid View"
|
||||
>
|
||||
<LayoutGrid size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters Bar */}
|
||||
<ViewFilters
|
||||
filters={config.filters}
|
||||
filterLogic={config.filterLogic}
|
||||
dimensions={dimensions}
|
||||
onFilterChange={handleFilterChange}
|
||||
onFilterLogicChange={handleFilterLogicChange}
|
||||
/>
|
||||
|
||||
{/* Content Area */}
|
||||
<div style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
|
||||
{loading ? (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
color: '#666'
|
||||
}}>
|
||||
Loading...
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{viewMode === 'list' && (
|
||||
<ListView
|
||||
nodes={nodes}
|
||||
onNodeClick={onNodeClick}
|
||||
/>
|
||||
)}
|
||||
{viewMode === 'kanban' && (
|
||||
<KanbanView
|
||||
nodes={nodes}
|
||||
columns={config.columns || []}
|
||||
dimensions={dimensions}
|
||||
onNodeClick={onNodeClick}
|
||||
onColumnChange={handleColumnChange}
|
||||
onNodeDimensionUpdate={handleNodeDimensionUpdate}
|
||||
/>
|
||||
)}
|
||||
{viewMode === 'grid' && (
|
||||
<GridView
|
||||
nodes={nodes}
|
||||
onNodeClick={onNodeClick}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,313 +0,0 @@
|
||||
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),
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
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();
|
||||
|
||||
@@ -1,398 +0,0 @@
|
||||
/* 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);
|
||||
}
|
||||
@@ -1,250 +0,0 @@
|
||||
/**
|
||||
* 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
|
||||
};
|
||||
};
|
||||
@@ -1,62 +0,0 @@
|
||||
import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const lockDimensionTool = tool({
|
||||
description: 'Lock a dimension to enable auto-assignment to new nodes',
|
||||
inputSchema: z.object({
|
||||
name: z.string().describe('Dimension name to lock')
|
||||
}),
|
||||
execute: async (params) => {
|
||||
console.log('🔒 LockDimension 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`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: trimmedName,
|
||||
isPriority: true
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = 'Failed to lock 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 lock dimension: ${response.status} ${response.statusText}`;
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
data: null
|
||||
};
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
message: `Locked dimension "${trimmedName}" - it will now be auto-assigned to new nodes`
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to lock dimension',
|
||||
data: null
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const unlockDimensionTool = tool({
|
||||
description: 'Unlock a dimension to disable auto-assignment to new nodes',
|
||||
inputSchema: z.object({
|
||||
name: z.string().describe('Dimension name to unlock')
|
||||
}),
|
||||
execute: async (params) => {
|
||||
console.log('🔓 UnlockDimension 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`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: trimmedName,
|
||||
isPriority: false
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = 'Failed to unlock 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 unlock dimension: ${response.status} ${response.statusText}`;
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
data: null
|
||||
};
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
message: `Unlocked dimension "${trimmedName}" - it will no longer be auto-assigned to new nodes`
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to unlock dimension',
|
||||
data: null
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const embedContentTool = tool({
|
||||
description: 'Chunk and embed a node’s content so semantic search stays current',
|
||||
inputSchema: z.object({
|
||||
nodeId: z.number().describe('The ID of the node to process')
|
||||
}),
|
||||
execute: async ({ nodeId }) => {
|
||||
try {
|
||||
// Call the same API endpoint that the manual embed button uses
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/ingestion`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ nodeId })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false,
|
||||
error: result.error || 'Failed to embed content',
|
||||
data: null
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: result.message || `Successfully chunked and embedded content for node ${nodeId}`,
|
||||
data: { nodeId }
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to embed content',
|
||||
data: null
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -23,92 +23,6 @@ export interface UsageData {
|
||||
mode?: 'easy' | 'hard';
|
||||
}
|
||||
|
||||
export interface EnhancedChatMetadata {
|
||||
timestamp: string;
|
||||
session_id: string;
|
||||
current_view: 'nodes' | 'memory';
|
||||
open_tab_count: number;
|
||||
has_focused_node: boolean;
|
||||
message_count: number;
|
||||
input_tokens?: number;
|
||||
output_tokens?: number;
|
||||
total_tokens?: number;
|
||||
cache_write_tokens?: number;
|
||||
cache_read_tokens?: number;
|
||||
cache_hit?: boolean;
|
||||
cache_savings_pct?: number;
|
||||
estimated_cost_usd?: number;
|
||||
model_used?: string;
|
||||
provider?: 'anthropic' | 'openai';
|
||||
tools_used?: string[];
|
||||
tool_calls_count?: number;
|
||||
trace_id?: string;
|
||||
parent_chat_id?: number;
|
||||
voice_tts_chars?: number;
|
||||
voice_tts_cost_usd?: number;
|
||||
voice_tts_chars_total?: number;
|
||||
voice_tts_cost_usd_total?: number;
|
||||
voice_request_id?: string;
|
||||
voice_tts_request_count?: number;
|
||||
voice_usage?: Array<{
|
||||
request_id: string;
|
||||
message_id?: string | null;
|
||||
chars: number;
|
||||
cost_usd: number;
|
||||
voice?: string;
|
||||
model?: string;
|
||||
duration_ms?: number | null;
|
||||
logged_at?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface CostReport {
|
||||
periodStart: string;
|
||||
periodEnd: string;
|
||||
totalCostUsd: number;
|
||||
totalChats: number;
|
||||
totalTokens: number;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheReadTokens: number;
|
||||
cacheWriteTokens: number;
|
||||
cacheHitRate: number;
|
||||
cacheSavingsUsd: number;
|
||||
avgCostPerChat: number;
|
||||
avgTokensPerChat: number;
|
||||
costByAgent: {
|
||||
[agentName: string]: {
|
||||
costUsd: number;
|
||||
chats: number;
|
||||
tokens: number;
|
||||
};
|
||||
};
|
||||
costByModel: {
|
||||
[modelId: string]: {
|
||||
costUsd: number;
|
||||
chats: number;
|
||||
tokens: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface TraceCostSummary {
|
||||
traceId: string;
|
||||
totalCostUsd: number;
|
||||
chatCount: number;
|
||||
orchestratorCost: number;
|
||||
executorCost: number;
|
||||
plannerCost: number;
|
||||
totalTokens: number;
|
||||
interactions: Array<{
|
||||
chatId: number;
|
||||
agentName: string;
|
||||
costUsd: number;
|
||||
tokens: number;
|
||||
createdAt: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ModelPricing {
|
||||
provider: 'anthropic' | 'openai';
|
||||
inputPer1M: number;
|
||||
@@ -117,12 +31,3 @@ export interface ModelPricing {
|
||||
cacheReadPer1M?: number;
|
||||
}
|
||||
|
||||
export interface CacheEffectiveness {
|
||||
totalRequests: number;
|
||||
cacheHits: number;
|
||||
cacheMisses: number;
|
||||
hitRate: number;
|
||||
totalCacheSavingsUsd: number;
|
||||
avgSavingsPerHit: number;
|
||||
totalTokensSaved: number;
|
||||
}
|
||||
|
||||
@@ -21,27 +21,6 @@ export interface Node {
|
||||
chunk_status?: 'not_chunked' | 'chunking' | 'chunked' | 'error' | null;
|
||||
}
|
||||
|
||||
// Legacy Item interface - DEPRECATED, use Node instead
|
||||
// Kept temporarily for migration compatibility
|
||||
export interface Item extends Node {
|
||||
// Legacy fields for backwards compatibility during transition
|
||||
description?: string;
|
||||
abstract?: string;
|
||||
type?: string;
|
||||
legacyType?: string[];
|
||||
stage?: string;
|
||||
segment?: string[];
|
||||
tags?: string[];
|
||||
sub_type?: any;
|
||||
notes?: any;
|
||||
extras?: any;
|
||||
score?: number;
|
||||
content_embedding?: number[];
|
||||
embedding_updated_at?: string;
|
||||
chunk_status?: 'not_chunked' | 'chunking' | 'chunked' | 'error';
|
||||
chunk_updated_at?: string;
|
||||
}
|
||||
|
||||
export interface Chunk {
|
||||
id: number;
|
||||
node_id: number; // Updated from item_id to node_id
|
||||
@@ -85,36 +64,6 @@ export interface EdgeContext {
|
||||
created_via: EdgeCreatedVia;
|
||||
}
|
||||
|
||||
export interface Chat {
|
||||
id: number;
|
||||
user_message?: string;
|
||||
assistant_message?: string;
|
||||
thread_id: string;
|
||||
focused_node_id?: number; // Updated from focused_item_id
|
||||
metadata?: any;
|
||||
embedding?: number[]; // Renamed from content_embedding
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface SessionContext {
|
||||
id: number;
|
||||
session_id: string;
|
||||
focused_node_id: number; // Updated from focused_item_id
|
||||
context_data: any;
|
||||
created_at: string;
|
||||
last_accessed: string;
|
||||
expires_at: string;
|
||||
}
|
||||
|
||||
export interface SessionCache {
|
||||
id: number;
|
||||
session_id: string;
|
||||
cache_key: string;
|
||||
cache_data: any;
|
||||
expires_at: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// New NodeFilters interface replacing rigid ItemFilters
|
||||
export interface NodeFilters {
|
||||
dimensions?: string[]; // Filter by dimensions (replaces stage/type filtering)
|
||||
@@ -124,13 +73,6 @@ export interface NodeFilters {
|
||||
sortBy?: 'updated' | 'edges'; // Sort by updated_at or edge count
|
||||
}
|
||||
|
||||
// Legacy filters - DEPRECATED, use NodeFilters instead
|
||||
export interface ItemFilters extends NodeFilters {
|
||||
stage?: string;
|
||||
type?: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export interface ChunkData {
|
||||
node_id: number; // Updated from item_id
|
||||
chunk_idx?: number;
|
||||
@@ -158,13 +100,6 @@ export interface ChatData {
|
||||
embedding?: number[]; // Renamed from content_embedding
|
||||
}
|
||||
|
||||
export interface CachedContext {
|
||||
sessionId: string;
|
||||
focusedNodeId: number; // Updated from focusedItemId
|
||||
contextData: any;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
// New NodeConnection interface
|
||||
export interface NodeConnection {
|
||||
id: number;
|
||||
@@ -172,25 +107,6 @@ export interface NodeConnection {
|
||||
edge: Edge;
|
||||
}
|
||||
|
||||
// Legacy connection - DEPRECATED, use NodeConnection instead
|
||||
export interface ItemConnection {
|
||||
id: number;
|
||||
connected_item: Item;
|
||||
edge: Edge;
|
||||
}
|
||||
|
||||
export interface DatabaseConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
database: string;
|
||||
user: string;
|
||||
password: string;
|
||||
ssl?: boolean;
|
||||
connectionTimeoutMillis?: number;
|
||||
idleTimeoutMillis?: number;
|
||||
max?: number;
|
||||
}
|
||||
|
||||
export interface DatabaseError {
|
||||
message: string;
|
||||
code?: string;
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
export interface MemoryContext {
|
||||
insights: {
|
||||
user_patterns: string[];
|
||||
preferences: string[];
|
||||
common_queries: string[];
|
||||
knowledge_domains: string[];
|
||||
};
|
||||
context: string;
|
||||
}
|
||||
|
||||
export interface HelperConfig {
|
||||
name: string;
|
||||
display_name: string;
|
||||
description: string;
|
||||
system_prompt: string;
|
||||
component_key: string;
|
||||
available_tools: string[];
|
||||
enabled: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
memory?: MemoryContext;
|
||||
}
|
||||
Reference in New Issue
Block a user