mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-07 08:26:11 +00:00
Merge pull request #477 from jreakin/perf/web-o1-lookups-memo-bundle
perf(web): O(1) lookups, memoization, bundle optimizations, React fixes
This commit is contained in:
commit
2ede01dbff
30 changed files with 948 additions and 642 deletions
|
|
@ -13,7 +13,7 @@ import { FileEntry } from './services/zip';
|
|||
import { getActiveProviderConfig } from './core/llm/settings-service';
|
||||
import { createKnowledgeGraph } from './core/graph/graph';
|
||||
import { connectToServer, fetchRepos, normalizeServerUrl, type ConnectToServerResult } from './services/server-connection';
|
||||
import { HelpPanel } from './components/HelpPanel';
|
||||
import { ERROR_RESET_DELAY_MS } from './config/ui-constants';
|
||||
|
||||
const AppContent = () => {
|
||||
const {
|
||||
|
|
@ -29,11 +29,9 @@ const AppContent = () => {
|
|||
runPipelineFromFiles,
|
||||
isSettingsPanelOpen,
|
||||
setSettingsPanelOpen,
|
||||
isHelpDialogBoxOpen,
|
||||
setHelpDialogBoxOpen,
|
||||
refreshLLMSettings,
|
||||
initializeAgent,
|
||||
startEmbeddings,
|
||||
startEmbeddingsWithFallback,
|
||||
embeddingStatus,
|
||||
codeReferences,
|
||||
selectedNode,
|
||||
|
|
@ -44,7 +42,6 @@ const AppContent = () => {
|
|||
setAvailableRepos,
|
||||
switchRepo,
|
||||
loadServerGraph,
|
||||
graph
|
||||
} = useAppState();
|
||||
|
||||
const graphCanvasRef = useRef<GraphCanvasHandle>(null);
|
||||
|
|
@ -72,13 +69,7 @@ const AppContent = () => {
|
|||
|
||||
// Auto-start embeddings pipeline in background
|
||||
// Uses WebGPU if available, falls back to WASM
|
||||
startEmbeddings().catch((err) => {
|
||||
if (err?.name === 'WebGPUNotAvailableError' || err?.message?.includes('WebGPU')) {
|
||||
startEmbeddings('wasm').catch(console.warn);
|
||||
} else {
|
||||
console.warn('Embeddings auto-start failed:', err);
|
||||
}
|
||||
});
|
||||
startEmbeddingsWithFallback();
|
||||
} catch (error) {
|
||||
console.error('Pipeline error:', error);
|
||||
setProgress({
|
||||
|
|
@ -90,9 +81,9 @@ const AppContent = () => {
|
|||
setTimeout(() => {
|
||||
setViewMode('onboarding');
|
||||
setProgress(null);
|
||||
}, 3000);
|
||||
}, ERROR_RESET_DELAY_MS);
|
||||
}
|
||||
}, [setViewMode, setGraph, setFileContents, setProgress, setProjectName, runPipeline, startEmbeddings, initializeAgent]);
|
||||
}, [setViewMode, setGraph, setFileContents, setProgress, setProjectName, runPipeline, startEmbeddingsWithFallback, initializeAgent]);
|
||||
|
||||
const handleGitClone = useCallback(async (files: FileEntry[]) => {
|
||||
const firstPath = files[0]?.path || 'repository';
|
||||
|
|
@ -115,13 +106,7 @@ const AppContent = () => {
|
|||
initializeAgent(projectName);
|
||||
}
|
||||
|
||||
startEmbeddings().catch((err) => {
|
||||
if (err?.name === 'WebGPUNotAvailableError' || err?.message?.includes('WebGPU')) {
|
||||
startEmbeddings('wasm').catch(console.warn);
|
||||
} else {
|
||||
console.warn('Embeddings auto-start failed:', err);
|
||||
}
|
||||
});
|
||||
startEmbeddingsWithFallback();
|
||||
} catch (error) {
|
||||
console.error('Pipeline error:', error);
|
||||
setProgress({
|
||||
|
|
@ -133,9 +118,9 @@ const AppContent = () => {
|
|||
setTimeout(() => {
|
||||
setViewMode('onboarding');
|
||||
setProgress(null);
|
||||
}, 3000);
|
||||
}, ERROR_RESET_DELAY_MS);
|
||||
}
|
||||
}, [setViewMode, setGraph, setFileContents, setProgress, setProjectName, runPipelineFromFiles, startEmbeddings, initializeAgent]);
|
||||
}, [setViewMode, setGraph, setFileContents, setProgress, setProjectName, runPipelineFromFiles, startEmbeddingsWithFallback, initializeAgent]);
|
||||
|
||||
const handleServerConnect = useCallback((result: ConnectToServerResult): Promise<void> => {
|
||||
// Extract project name from repoPath
|
||||
|
|
@ -172,13 +157,7 @@ const AppContent = () => {
|
|||
}
|
||||
})
|
||||
.then(() => {
|
||||
startEmbeddings().catch((err) => {
|
||||
if (err?.name === 'WebGPUNotAvailableError' || err?.message?.includes('WebGPU')) {
|
||||
startEmbeddings('wasm').catch(console.warn);
|
||||
} else {
|
||||
console.warn('Embeddings auto-start failed:', err);
|
||||
}
|
||||
});
|
||||
startEmbeddingsWithFallback();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn('Failed to load graph into LadybugDB:', err);
|
||||
|
|
@ -186,7 +165,7 @@ const AppContent = () => {
|
|||
});
|
||||
|
||||
return loadGraphPromise;
|
||||
}, [setViewMode, setGraph, setFileContents, setProjectName, loadServerGraph, initializeAgent, startEmbeddings]);
|
||||
}, [setViewMode, setGraph, setFileContents, setProjectName, loadServerGraph, initializeAgent, startEmbeddingsWithFallback]);
|
||||
|
||||
// Auto-connect when ?server query param is present (bookmarkable shortcut)
|
||||
const autoConnectRan = useRef(false);
|
||||
|
|
@ -235,7 +214,7 @@ const AppContent = () => {
|
|||
setTimeout(() => {
|
||||
setViewMode('onboarding');
|
||||
setProgress(null);
|
||||
}, 3000);
|
||||
}, ERROR_RESET_DELAY_MS);
|
||||
});
|
||||
}, [handleServerConnect, setProgress, setViewMode, setServerBaseUrl, setAvailableRepos]);
|
||||
|
||||
|
|
@ -309,13 +288,6 @@ const AppContent = () => {
|
|||
onSettingsSaved={handleSettingsSaved}
|
||||
/>
|
||||
|
||||
<HelpPanel
|
||||
isOpen={isHelpDialogBoxOpen}
|
||||
onClose={() => setHelpDialogBoxOpen(false)}
|
||||
nodeCount={graph!.nodes.length}
|
||||
edgeCount={graph!.relationships.length}
|
||||
/>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Server, ArrowRight } from 'lucide-react';
|
||||
import { Server, ArrowRight } from '@/lib/lucide-icons';
|
||||
import { BackendRepo } from '../services/backend';
|
||||
|
||||
interface BackendRepoSelectorProps {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Code, PanelLeftClose, PanelLeft, Trash2, X, Target, FileCode, Sparkles, MousePointerClick } from 'lucide-react';
|
||||
import { Code, PanelLeftClose, PanelLeft, Trash2, X, Target, FileCode, Sparkles, MousePointerClick } from '@/lib/lucide-icons';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
import { useAppState } from '../hooks/useAppState';
|
||||
import type { GraphNode } from '../core/graph/types';
|
||||
import { NODE_COLORS } from '../lib/constants';
|
||||
|
||||
/** Map file extension to Prism syntax highlighter language identifier */
|
||||
|
|
@ -75,6 +76,11 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
|
|||
codeReferenceFocus,
|
||||
} = useAppState();
|
||||
|
||||
const nodeById = useMemo(() => {
|
||||
if (!graph) return new Map<string, GraphNode>();
|
||||
return new Map(graph.nodes.map(n => [n.id, n]));
|
||||
}, [graph]);
|
||||
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const [glowRefId, setGlowRefId] = useState<string | null>(null);
|
||||
const panelRef = useRef<HTMLElement | null>(null);
|
||||
|
|
@ -161,8 +167,9 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
|
|||
if (!target) return;
|
||||
|
||||
// Double rAF: wait for collapse state + list DOM to render.
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
const rafIds: number[] = [];
|
||||
const outerRafId = requestAnimationFrame(() => {
|
||||
const innerRafId = requestAnimationFrame(() => {
|
||||
const el = refCardEls.current.get(target.id);
|
||||
if (!el) return;
|
||||
|
||||
|
|
@ -177,7 +184,13 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
|
|||
glowTimerRef.current = null;
|
||||
}, 1200);
|
||||
});
|
||||
rafIds.push(innerRafId);
|
||||
});
|
||||
rafIds.push(outerRafId);
|
||||
|
||||
return () => {
|
||||
rafIds.forEach(id => cancelAnimationFrame(id));
|
||||
};
|
||||
}, [codeReferenceFocus?.ts, aiReferences]);
|
||||
|
||||
const refsWithSnippets = useMemo(() => {
|
||||
|
|
@ -414,7 +427,7 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
|
|||
const nodeId = ref.nodeId!;
|
||||
// Sync selection + focus graph
|
||||
if (graph) {
|
||||
const node = graph.nodes.find((n) => n.id === nodeId);
|
||||
const node = nodeById.get(nodeId);
|
||||
if (node) setSelectedNode(node);
|
||||
}
|
||||
onFocusNode(nodeId);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState, useCallback, useRef, DragEvent } from 'react';
|
||||
import { Upload, FileArchive, Github, Loader2, ArrowRight, Key, Eye, EyeOff, Globe, X } from 'lucide-react';
|
||||
import { Upload, FileArchive, Github, Loader2, ArrowRight, Key, Eye, EyeOff, Globe, X } from '@/lib/lucide-icons';
|
||||
import { cloneRepository, parseGitHubUrl } from '../services/git-clone';
|
||||
import { connectToServer, type ConnectToServerResult } from '../services/server-connection';
|
||||
import { FileEntry } from '../services/zip';
|
||||
|
|
@ -27,9 +27,13 @@ export const DropZone = ({ onFileSelect, onGitClone, onServerConnect }: DropZone
|
|||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Server tab state
|
||||
const [serverUrl, setServerUrl] = useState(() =>
|
||||
localStorage.getItem('gitnexus-server-url') || ''
|
||||
);
|
||||
const [serverUrl, setServerUrl] = useState(() => {
|
||||
try {
|
||||
return localStorage.getItem('gitnexus-server-url') || '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
});
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [serverProgress, setServerProgress] = useState<{
|
||||
phase: string;
|
||||
|
|
@ -133,7 +137,11 @@ export const DropZone = ({ onFileSelect, onGitClone, onServerConnect }: DropZone
|
|||
}
|
||||
|
||||
// Persist URL to localStorage
|
||||
localStorage.setItem('gitnexus-server-url', serverUrl);
|
||||
try {
|
||||
localStorage.setItem('gitnexus-server-url', serverUrl);
|
||||
} catch {
|
||||
// localStorage may be unavailable (e.g. private browsing, quota exceeded)
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setIsConnecting(true);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Brain, Loader2, Check, AlertCircle, Zap, FlaskConical } from 'lucide-react';
|
||||
import { Brain, Loader2, Check, AlertCircle, Zap, FlaskConical } from '@/lib/lucide-icons';
|
||||
import { useAppState } from '../hooks/useAppState';
|
||||
import { useState } from 'react';
|
||||
import { WebGPUFallbackDialog } from './WebGPUFallbackDialog';
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import {
|
|||
Variable,
|
||||
Hash,
|
||||
Target,
|
||||
} from 'lucide-react';
|
||||
} from '@/lib/lucide-icons';
|
||||
import { useAppState } from '../hooks/useAppState';
|
||||
import { FILTERABLE_LABELS, NODE_COLORS, ALL_EDGE_TYPES, EDGE_INFO, type EdgeType } from '../lib/constants';
|
||||
import { GraphNode, NodeLabel } from '../core/graph/types';
|
||||
|
|
@ -98,13 +98,15 @@ const TreeItem = ({
|
|||
const isSelected = selectedPath === node.path;
|
||||
const hasChildren = node.children.length > 0;
|
||||
|
||||
// Filter children based on search
|
||||
// Filter children based on search (recursive)
|
||||
const filteredChildren = useMemo(() => {
|
||||
if (!searchQuery) return node.children;
|
||||
return node.children.filter(child =>
|
||||
child.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
child.children.some(c => c.name.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
);
|
||||
const searchLower = searchQuery.toLowerCase();
|
||||
const matchesSearch = (node: TreeNode, query: string): boolean => {
|
||||
if (node.name.toLowerCase().includes(query)) return true;
|
||||
return node.children?.some(child => matchesSearch(child, query)) ?? false;
|
||||
};
|
||||
return node.children.filter(child => matchesSearch(child, searchLower));
|
||||
}, [node.children, searchQuery]);
|
||||
|
||||
// Check if this node matches search
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { useEffect, useCallback, useMemo, useState, forwardRef, useImperativeHandle } from 'react';
|
||||
import { ZoomIn, ZoomOut, Maximize2, Focus, RotateCcw, Play, Pause, Lightbulb, LightbulbOff } from 'lucide-react';
|
||||
import { ZoomIn, ZoomOut, Maximize2, Focus, RotateCcw, Play, Pause, Lightbulb, LightbulbOff } from '@/lib/lucide-icons';
|
||||
import { useSigma } from '../hooks/useSigma';
|
||||
import { useAppState } from '../hooks/useAppState';
|
||||
import { knowledgeGraphToGraphology, filterGraphByDepth, SigmaNodeAttributes, SigmaEdgeAttributes } from '../lib/graph-adapter';
|
||||
import type { GraphNode } from '../core/graph/types';
|
||||
import { QueryFAB } from './QueryFAB';
|
||||
import Graph from 'graphology';
|
||||
|
||||
|
|
@ -54,30 +55,44 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
|
|||
return animatedNodes;
|
||||
}, [animatedNodes, isAIHighlightsEnabled]);
|
||||
|
||||
const nodeById = useMemo(() => {
|
||||
if (!graph) return new Map<string, GraphNode>();
|
||||
return new Map(graph.nodes.map(n => [n.id, n]));
|
||||
}, [graph]);
|
||||
|
||||
const handleNodeClick = useCallback((nodeId: string) => {
|
||||
if (!graph) return;
|
||||
const node = graph.nodes.find(n => n.id === nodeId);
|
||||
const node = nodeById.get(nodeId);
|
||||
if (node) {
|
||||
setSelectedNode(node);
|
||||
openCodePanel();
|
||||
}
|
||||
}, [graph, setSelectedNode, openCodePanel]);
|
||||
}, [graph, nodeById, setSelectedNode, openCodePanel]);
|
||||
|
||||
const handleNodeHover = useCallback((nodeId: string | null) => {
|
||||
if (!nodeId || !graph) {
|
||||
setHoveredNodeName(null);
|
||||
return;
|
||||
}
|
||||
const node = graph.nodes.find(n => n.id === nodeId);
|
||||
if (node) {
|
||||
setHoveredNodeName(node.properties.name);
|
||||
}
|
||||
}, [graph]);
|
||||
const node = nodeById.get(nodeId);
|
||||
setHoveredNodeName(node ? node.properties.name : null);
|
||||
}, [graph, nodeById]);
|
||||
|
||||
const handleStageClick = useCallback(() => {
|
||||
setSelectedNode(null);
|
||||
}, [setSelectedNode]);
|
||||
|
||||
const handleToggleAIHighlights = useCallback(() => {
|
||||
if (isAIHighlightsEnabled) {
|
||||
clearAIToolHighlights();
|
||||
clearAICitationHighlights();
|
||||
clearBlastRadius();
|
||||
setSelectedNode(null);
|
||||
setSigmaSelectedNode(null);
|
||||
}
|
||||
toggleAIHighlights();
|
||||
}, [isAIHighlightsEnabled, clearAIToolHighlights, clearAICitationHighlights, clearBlastRadius, setSelectedNode, toggleAIHighlights]);
|
||||
|
||||
const {
|
||||
containerRef,
|
||||
sigmaRef,
|
||||
|
|
@ -106,7 +121,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
|
|||
focusNode: (nodeId: string) => {
|
||||
// Also update app state so the selection syncs properly
|
||||
if (graph) {
|
||||
const node = graph.nodes.find(n => n.id === nodeId);
|
||||
const node = nodeById.get(nodeId);
|
||||
if (node) {
|
||||
setSelectedNode(node);
|
||||
openCodePanel();
|
||||
|
|
@ -114,7 +129,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
|
|||
}
|
||||
focusNode(nodeId);
|
||||
}
|
||||
}), [focusNode, graph, setSelectedNode, openCodePanel]);
|
||||
}), [focusNode, graph, nodeById, setSelectedNode, openCodePanel]);
|
||||
|
||||
// Update Sigma graph when KnowledgeGraph changes
|
||||
useEffect(() => {
|
||||
|
|
@ -126,10 +141,11 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
|
|||
graph.relationships.forEach(rel => {
|
||||
if (rel.type === 'MEMBER_OF') {
|
||||
// Find the community node to get its index
|
||||
const communityNode = graph.nodes.find(n => n.id === rel.targetId && n.label === 'Community');
|
||||
if (communityNode) {
|
||||
const communityNode = nodeById.get(rel.targetId);
|
||||
if (communityNode && communityNode.label === 'Community') {
|
||||
// Extract community index from id (e.g., "comm_5" -> 5)
|
||||
const communityIdx = parseInt(rel.targetId.replace('comm_', ''), 10) || 0;
|
||||
const numericPart = rel.targetId.replace('comm_', '');
|
||||
const communityIdx = /^\d+$/.test(numericPart) ? parseInt(numericPart, 10) : 0;
|
||||
communityMemberships.set(rel.sourceId, communityIdx);
|
||||
}
|
||||
}
|
||||
|
|
@ -137,7 +153,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
|
|||
|
||||
const sigmaGraph = knowledgeGraphToGraphology(graph, communityMemberships);
|
||||
setSigmaGraph(sigmaGraph);
|
||||
}, [graph, setSigmaGraph]);
|
||||
}, [graph, nodeById, setSigmaGraph]);
|
||||
|
||||
// Update node visibility when filters change
|
||||
useEffect(() => {
|
||||
|
|
@ -149,7 +165,8 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
|
|||
|
||||
filterGraphByDepth(sigmaGraph, appSelectedNode?.id || null, depthFilter, visibleLabels);
|
||||
sigma.refresh();
|
||||
}, [visibleLabels, depthFilter, appSelectedNode, sigmaRef]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- sigmaRef identity never changes
|
||||
}, [visibleLabels, depthFilter, appSelectedNode]);
|
||||
|
||||
// Sync app selected node with sigma
|
||||
useEffect(() => {
|
||||
|
|
@ -307,17 +324,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
|
|||
{/* AI Highlights toggle - Top Right */}
|
||||
<div className="absolute top-4 right-4 z-20">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (isAIHighlightsEnabled) {
|
||||
// Turning off — clear AI highlights and selection (preserve user query highlights)
|
||||
clearAIToolHighlights();
|
||||
clearAICitationHighlights();
|
||||
clearBlastRadius();
|
||||
setSelectedNode(null);
|
||||
setSigmaSelectedNode(null);
|
||||
}
|
||||
toggleAIHighlights();
|
||||
}}
|
||||
onClick={handleToggleAIHighlights}
|
||||
className={
|
||||
isAIHighlightsEnabled
|
||||
? 'w-10 h-10 flex items-center justify-center bg-cyan-500/15 border border-cyan-400/40 rounded-lg text-cyan-200 hover:bg-cyan-500/20 hover:border-cyan-300/60 transition-colors'
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Search, Settings, HelpCircle, Sparkles, Github, Star, ChevronDown } from 'lucide-react';
|
||||
import { Search, Settings, HelpCircle, Sparkles, Github, Star, ChevronDown } from '@/lib/lucide-icons';
|
||||
import { useAppState } from '../hooks/useAppState';
|
||||
import type { RepoSummary } from '../services/server-connection';
|
||||
import { useState, useMemo, useRef, useEffect, useCallback } from 'react';
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import React, { useState } from 'react';
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
import { MermaidDiagram } from './MermaidDiagram';
|
||||
import { ToolCallCard } from './ToolCallCard';
|
||||
import { Copy, Check } from 'lucide-react';
|
||||
import { Copy, Check } from '@/lib/lucide-icons';
|
||||
|
||||
// Custom syntax theme
|
||||
const customTheme = {
|
||||
|
|
@ -39,12 +39,24 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
|
|||
showCopyButton = false
|
||||
}) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copyTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (copyTimerRef.current) {
|
||||
clearTimeout(copyTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(content);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
if (copyTimerRef.current) {
|
||||
clearTimeout(copyTimerRef.current);
|
||||
}
|
||||
copyTimerRef.current = setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
}
|
||||
|
|
@ -78,13 +90,13 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
|
|||
return parts.join('```');
|
||||
};
|
||||
|
||||
const handleLinkClick = (e: React.MouseEvent<HTMLAnchorElement>, href: string) => {
|
||||
const handleLinkClick = React.useCallback((e: React.MouseEvent<HTMLAnchorElement>, href: string) => {
|
||||
if (href.startsWith('code-ref:') || href.startsWith('node-ref:')) {
|
||||
e.preventDefault();
|
||||
onLinkClick?.(href);
|
||||
}
|
||||
// External links open in new tab (default behavior)
|
||||
};
|
||||
}, [onLinkClick]);
|
||||
|
||||
const formattedContent = React.useMemo(() => formatMarkdownForDisplay(content), [content]);
|
||||
|
||||
|
|
@ -164,7 +176,7 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
|
|||
);
|
||||
},
|
||||
pre: ({ children }: any) => <>{children}</>,
|
||||
}), [onLinkClick]); // Removed handleLinkClick dependency as it is defined inside component but depends on onLinkClick
|
||||
}), [handleLinkClick]);
|
||||
|
||||
return (
|
||||
<div className="text-text-primary text-sm">
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Suspense, useEffect, useRef, useState, lazy } from 'react';
|
||||
import mermaid from 'mermaid';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { AlertTriangle, Maximize2 } from 'lucide-react';
|
||||
import { ProcessFlowModal } from './ProcessFlowModal';
|
||||
import { AlertTriangle, Maximize2 } from '@/lib/lucide-icons';
|
||||
import type { ProcessData } from '../lib/mermaid-generator';
|
||||
|
||||
const ProcessFlowModal = lazy(() =>
|
||||
import('./ProcessFlowModal').then((m) => ({ default: m.ProcessFlowModal })),
|
||||
);
|
||||
|
||||
// Initialize mermaid with cyan theme matching ProcessFlowModal
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
|
|
@ -68,7 +71,8 @@ export const MermaidDiagram = ({ code }: MermaidDiagramProps) => {
|
|||
|
||||
// Render the diagram
|
||||
const { svg: renderedSvg } = await mermaid.render(id, code.trim());
|
||||
setSvg(renderedSvg);
|
||||
const sanitizedSvg = DOMPurify.sanitize(renderedSvg, { USE_PROFILES: { svg: true, svgFilters: true }, ADD_TAGS: ['foreignObject'] });
|
||||
setSvg(sanitizedSvg);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
// Silent catch for streaming:
|
||||
|
|
@ -141,17 +145,23 @@ export const MermaidDiagram = ({ code }: MermaidDiagramProps) => {
|
|||
<div
|
||||
ref={containerRef}
|
||||
className="flex items-center justify-center p-4 overflow-auto max-h-[400px]"
|
||||
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(svg, { USE_PROFILES: { svg: true, svgFilters: true } }) }}
|
||||
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(svg, { USE_PROFILES: { svg: true, svgFilters: true }, ADD_TAGS: ['foreignObject'] }) }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Use ProcessFlowModal for expansion */}
|
||||
{showModal && processData && (
|
||||
<ProcessFlowModal
|
||||
process={processData}
|
||||
onClose={() => setShowModal(false)}
|
||||
/>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="p-4 text-sm text-text-muted">Loading diagram…</div>
|
||||
}
|
||||
>
|
||||
<ProcessFlowModal
|
||||
process={processData}
|
||||
onClose={() => setShowModal(false)}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -148,7 +148,7 @@ export const ProcessFlowModal = ({ process, onClose, onFocusInGraph, isFullScree
|
|||
|
||||
const { svg } = await mermaid.render(id, mermaidCode);
|
||||
if (!diagramRef.current) return;
|
||||
diagramRef.current!.innerHTML = DOMPurify.sanitize(svg, { USE_PROFILES: { svg: true, svgFilters: true } });
|
||||
diagramRef.current!.innerHTML = DOMPurify.sanitize(svg, { USE_PROFILES: { svg: true, svgFilters: true }, ADD_TAGS: ['foreignObject'] });
|
||||
} catch (error) {
|
||||
console.error('Mermaid render error:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { Terminal, Play, X, ChevronDown, ChevronUp, Loader2, Sparkles, Table } from 'lucide-react';
|
||||
import { Terminal, Play, X, ChevronDown, ChevronUp, Loader2, Sparkles, Table } from '@/lib/lucide-icons';
|
||||
import { useAppState } from '../hooks/useAppState';
|
||||
|
||||
const EXAMPLE_QUERIES = [
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { useState, useRef, useEffect, useCallback } from 'react';
|
|||
import {
|
||||
Send, Square, Sparkles, User,
|
||||
PanelRightClose, Loader2, AlertTriangle, GitBranch
|
||||
} from 'lucide-react';
|
||||
} from '@/lib/lucide-icons';
|
||||
import { useAppState } from '../hooks/useAppState';
|
||||
import { ToolCallCard } from './ToolCallCard';
|
||||
import { isProviderConfigured } from '../core/llm/settings-service';
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import { X, Key, Server, Brain, Check, AlertCircle, Eye, EyeOff, RefreshCw, ChevronDown, Loader2, Search } from 'lucide-react';
|
||||
import { X, Key, Server, Brain, Check, AlertCircle, Eye, EyeOff, RefreshCw, ChevronDown, Loader2, Search } from '@/lib/lucide-icons';
|
||||
import {
|
||||
loadSettings,
|
||||
saveSettings,
|
||||
|
|
@ -7,6 +7,8 @@ import {
|
|||
fetchOpenRouterModels,
|
||||
} from '../core/llm/settings-service';
|
||||
import type { LLMSettings, LLMProvider } from '../core/llm/types';
|
||||
import { DEFAULT_OLLAMA_BASE_URL } from '../config/ui-constants';
|
||||
import { ProviderConfigCard } from './settings/ProviderConfigCard';
|
||||
|
||||
interface SettingsPanelProps {
|
||||
isOpen: boolean;
|
||||
|
|
@ -216,6 +218,7 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
|
|||
const [settings, setSettings] = useState<LLMSettings>(loadSettings);
|
||||
const [showApiKey, setShowApiKey] = useState<Record<string, boolean>>({});
|
||||
const [saveStatus, setSaveStatus] = useState<'idle' | 'saved' | 'error'>('idle');
|
||||
const saveTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
// Ollama connection state
|
||||
const [ollamaError, setOllamaError] = useState<string | null>(null);
|
||||
const [isCheckingOllama, setIsCheckingOllama] = useState(false);
|
||||
|
|
@ -223,6 +226,15 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
|
|||
const [openRouterModels, setOpenRouterModels] = useState<Array<{ id: string; name: string }>>([]);
|
||||
const [isLoadingModels, setIsLoadingModels] = useState(false);
|
||||
|
||||
// Clean up save timer on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (saveTimerRef.current) {
|
||||
clearTimeout(saveTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Load settings when panel opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
|
|
@ -252,7 +264,7 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
|
|||
|
||||
useEffect(() => {
|
||||
if (settings.activeProvider === 'ollama') {
|
||||
const baseUrl = settings.ollama?.baseUrl ?? 'http://localhost:11434';
|
||||
const baseUrl = settings.ollama?.baseUrl ?? DEFAULT_OLLAMA_BASE_URL;
|
||||
const timer = setTimeout(() => {
|
||||
checkOllamaConnection(baseUrl);
|
||||
}, 300);
|
||||
|
|
@ -269,7 +281,10 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
|
|||
saveSettings(settings);
|
||||
setSaveStatus('saved');
|
||||
onSettingsSaved?.();
|
||||
setTimeout(() => setSaveStatus('idle'), 2000);
|
||||
if (saveTimerRef.current) {
|
||||
clearTimeout(saveTimerRef.current);
|
||||
}
|
||||
saveTimerRef.current = setTimeout(() => setSaveStatus('idle'), 2000);
|
||||
} catch {
|
||||
setSaveStatus('error');
|
||||
}
|
||||
|
|
@ -374,60 +389,36 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-amber-500/10 border border-amber-500/30 rounded-xl text-xs text-amber-200">
|
||||
API keys are stored in session storage and will be cleared when you close this tab.
|
||||
</div>
|
||||
|
||||
{/* OpenAI Settings */}
|
||||
{settings.activeProvider === 'openai' && (
|
||||
<div className="space-y-4 animate-fade-in">
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-text-secondary">
|
||||
<Key className="w-4 h-4" />
|
||||
API Key
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showApiKey['openai'] ? 'text' : 'password'}
|
||||
value={settings.openai?.apiKey ?? ''}
|
||||
onChange={e => setSettings(prev => ({
|
||||
...prev,
|
||||
openai: { ...prev.openai!, apiKey: e.target.value }
|
||||
}))}
|
||||
placeholder="Enter your OpenAI API key"
|
||||
className="w-full px-4 py-3 pr-12 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleApiKeyVisibility('openai')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-text-muted hover:text-text-primary transition-colors"
|
||||
>
|
||||
{showApiKey['openai'] ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted">
|
||||
Get your API key from{' '}
|
||||
<a
|
||||
href="https://platform.openai.com/api-keys"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
OpenAI Platform
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-text-secondary">Model</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.openai?.model ?? 'gpt-5.2-chat'}
|
||||
onChange={e => setSettings(prev => ({
|
||||
...prev,
|
||||
openai: { ...prev.openai!, model: e.target.value }
|
||||
}))}
|
||||
placeholder="e.g., gpt-4o, gpt-4-turbo, gpt-3.5-turbo"
|
||||
className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ProviderConfigCard
|
||||
title="OpenAI"
|
||||
apiKey={{
|
||||
value: settings.openai?.apiKey ?? '',
|
||||
placeholder: 'Enter your OpenAI API key',
|
||||
helperText: 'Get your API key from',
|
||||
helperLink: 'https://platform.openai.com/api-keys',
|
||||
helperLinkLabel: 'OpenAI Platform',
|
||||
isVisible: !!showApiKey['openai'],
|
||||
onChange: (value) => setSettings(prev => ({
|
||||
...prev,
|
||||
openai: { ...prev.openai!, apiKey: value }
|
||||
})),
|
||||
onToggleVisibility: () => toggleApiKeyVisibility('openai'),
|
||||
}}
|
||||
model={{
|
||||
value: settings.openai?.model ?? 'gpt-5.2-chat',
|
||||
placeholder: 'e.g., gpt-4o, gpt-4-turbo, gpt-3.5-turbo',
|
||||
onChange: (value) => setSettings(prev => ({
|
||||
...prev,
|
||||
openai: { ...prev.openai!, model: value }
|
||||
})),
|
||||
}}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-text-secondary">
|
||||
<Server className="w-4 h-4" />
|
||||
|
|
@ -447,119 +438,63 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
|
|||
Leave empty to use the default OpenAI API. Set a custom URL for proxies or compatible APIs.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</ProviderConfigCard>
|
||||
)}
|
||||
|
||||
{/* Gemini Settings */}
|
||||
{settings.activeProvider === 'gemini' && (
|
||||
<div className="space-y-4 animate-fade-in">
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-text-secondary">
|
||||
<Key className="w-4 h-4" />
|
||||
API Key
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showApiKey['gemini'] ? 'text' : 'password'}
|
||||
value={settings.gemini?.apiKey ?? ''}
|
||||
onChange={e => setSettings(prev => ({
|
||||
...prev,
|
||||
gemini: { ...prev.gemini!, apiKey: e.target.value }
|
||||
}))}
|
||||
placeholder="Enter your Google AI API key"
|
||||
className="w-full px-4 py-3 pr-12 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleApiKeyVisibility('gemini')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-text-muted hover:text-text-primary transition-colors"
|
||||
>
|
||||
{showApiKey['gemini'] ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted">
|
||||
Get your API key from{' '}
|
||||
<a
|
||||
href="https://aistudio.google.com/app/apikey"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
Google AI Studio
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-text-secondary">Model</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.gemini?.model ?? 'gemini-2.0-flash'}
|
||||
onChange={e => setSettings(prev => ({
|
||||
...prev,
|
||||
gemini: { ...prev.gemini!, model: e.target.value }
|
||||
}))}
|
||||
placeholder="e.g., gemini-2.0-flash, gemini-1.5-pro"
|
||||
className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ProviderConfigCard
|
||||
title="Google Gemini"
|
||||
apiKey={{
|
||||
value: settings.gemini?.apiKey ?? '',
|
||||
placeholder: 'Enter your Google AI API key',
|
||||
helperText: 'Get your API key from',
|
||||
helperLink: 'https://aistudio.google.com/app/apikey',
|
||||
helperLinkLabel: 'Google AI Studio',
|
||||
isVisible: !!showApiKey['gemini'],
|
||||
onChange: (value) => setSettings(prev => ({
|
||||
...prev,
|
||||
gemini: { ...prev.gemini!, apiKey: value }
|
||||
})),
|
||||
onToggleVisibility: () => toggleApiKeyVisibility('gemini'),
|
||||
}}
|
||||
model={{
|
||||
value: settings.gemini?.model ?? 'gemini-2.0-flash',
|
||||
placeholder: 'e.g., gemini-2.0-flash, gemini-1.5-pro',
|
||||
onChange: (value) => setSettings(prev => ({
|
||||
...prev,
|
||||
gemini: { ...prev.gemini!, model: value }
|
||||
})),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Anthropic Settings */}
|
||||
{settings.activeProvider === 'anthropic' && (
|
||||
<div className="space-y-4 animate-fade-in">
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-text-secondary">
|
||||
<Key className="w-4 h-4" />
|
||||
API Key
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showApiKey['anthropic'] ? 'text' : 'password'}
|
||||
value={settings.anthropic?.apiKey ?? ''}
|
||||
onChange={e => setSettings(prev => ({
|
||||
...prev,
|
||||
anthropic: { ...prev.anthropic!, apiKey: e.target.value }
|
||||
}))}
|
||||
placeholder="Enter your Anthropic API key"
|
||||
className="w-full px-4 py-3 pr-12 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleApiKeyVisibility('anthropic')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-text-muted hover:text-text-primary transition-colors"
|
||||
>
|
||||
{showApiKey['anthropic'] ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted">
|
||||
Get your API key from{' '}
|
||||
<a
|
||||
href="https://console.anthropic.com/settings/keys"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
Anthropic Console
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-text-secondary">Model</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.anthropic?.model ?? 'claude-sonnet-4-20250514'}
|
||||
onChange={e => setSettings(prev => ({
|
||||
...prev,
|
||||
anthropic: { ...prev.anthropic!, model: e.target.value }
|
||||
}))}
|
||||
placeholder="e.g., claude-sonnet-4-20250514, claude-3-opus"
|
||||
className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ProviderConfigCard
|
||||
title="Anthropic"
|
||||
apiKey={{
|
||||
value: settings.anthropic?.apiKey ?? '',
|
||||
placeholder: 'Enter your Anthropic API key',
|
||||
helperText: 'Get your API key from',
|
||||
helperLink: 'https://console.anthropic.com/settings/keys',
|
||||
helperLinkLabel: 'Anthropic Console',
|
||||
isVisible: !!showApiKey['anthropic'],
|
||||
onChange: (value) => setSettings(prev => ({
|
||||
...prev,
|
||||
anthropic: { ...prev.anthropic!, apiKey: value }
|
||||
})),
|
||||
onToggleVisibility: () => toggleApiKeyVisibility('anthropic'),
|
||||
}}
|
||||
model={{
|
||||
value: settings.anthropic?.model ?? 'claude-sonnet-4-20250514',
|
||||
placeholder: 'e.g., claude-sonnet-4-20250514, claude-3-opus',
|
||||
onChange: (value) => setSettings(prev => ({
|
||||
...prev,
|
||||
anthropic: { ...prev.anthropic!, model: value }
|
||||
})),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Azure OpenAI Settings */}
|
||||
|
|
@ -695,17 +630,17 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
|
|||
<div className="flex gap-2">
|
||||
<input
|
||||
type="url"
|
||||
value={settings.ollama?.baseUrl ?? 'http://localhost:11434'}
|
||||
value={settings.ollama?.baseUrl ?? DEFAULT_OLLAMA_BASE_URL}
|
||||
onChange={e => setSettings(prev => ({
|
||||
...prev,
|
||||
ollama: { ...prev.ollama!, baseUrl: e.target.value }
|
||||
}))}
|
||||
placeholder="http://localhost:11434"
|
||||
placeholder={DEFAULT_OLLAMA_BASE_URL}
|
||||
className="flex-1 px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all font-mono text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => checkOllamaConnection(settings.ollama?.baseUrl ?? 'http://localhost:11434')}
|
||||
onClick={() => checkOllamaConnection(settings.ollama?.baseUrl ?? DEFAULT_OLLAMA_BASE_URL)}
|
||||
disabled={isCheckingOllama}
|
||||
className="px-3 py-3 bg-elevated border border-border-subtle rounded-xl text-text-secondary hover:text-text-primary hover:border-accent/50 transition-colors disabled:opacity-50"
|
||||
title="Check connection"
|
||||
|
|
@ -749,44 +684,22 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
|
|||
|
||||
{/* OpenRouter Settings */}
|
||||
{settings.activeProvider === 'openrouter' && (
|
||||
<div className="space-y-4 animate-fade-in">
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-text-secondary">
|
||||
<Key className="w-4 h-4" />
|
||||
API Key
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showApiKey['openrouter'] ? 'text' : 'password'}
|
||||
value={settings.openrouter?.apiKey ?? ''}
|
||||
onChange={e => setSettings(prev => ({
|
||||
...prev,
|
||||
openrouter: { ...prev.openrouter!, apiKey: e.target.value }
|
||||
}))}
|
||||
placeholder="Enter your OpenRouter API key"
|
||||
className="w-full px-4 py-3 pr-12 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleApiKeyVisibility('openrouter')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-text-muted hover:text-text-primary transition-colors"
|
||||
>
|
||||
{showApiKey['openrouter'] ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted">
|
||||
Get your API key from{' '}
|
||||
<a
|
||||
href="https://openrouter.ai/keys"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
OpenRouter Keys
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ProviderConfigCard
|
||||
title="OpenRouter"
|
||||
apiKey={{
|
||||
value: settings.openrouter?.apiKey ?? '',
|
||||
placeholder: 'Enter your OpenRouter API key',
|
||||
helperText: 'Get your API key from',
|
||||
helperLink: 'https://openrouter.ai/keys',
|
||||
helperLinkLabel: 'OpenRouter Keys',
|
||||
isVisible: !!showApiKey['openrouter'],
|
||||
onChange: (value) => setSettings(prev => ({
|
||||
...prev,
|
||||
openrouter: { ...prev.openrouter!, apiKey: value }
|
||||
})),
|
||||
onToggleVisibility: () => toggleApiKeyVisibility('openrouter'),
|
||||
}}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-text-secondary">Model</label>
|
||||
<OpenRouterModelCombobox
|
||||
|
|
@ -811,66 +724,36 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
|
|||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</ProviderConfigCard>
|
||||
)}
|
||||
|
||||
{/* MiniMax Settings */}
|
||||
{settings.activeProvider === 'minimax' && (
|
||||
<div className="space-y-4 animate-fade-in">
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-text-secondary">
|
||||
<Key className="w-4 h-4" />
|
||||
API Key
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showApiKey['minimax'] ? 'text' : 'password'}
|
||||
value={settings.minimax?.apiKey ?? ''}
|
||||
onChange={e => setSettings(prev => ({
|
||||
...prev,
|
||||
minimax: { ...prev.minimax!, apiKey: e.target.value }
|
||||
}))}
|
||||
placeholder="Enter your MiniMax API key"
|
||||
className="w-full px-4 py-3 pr-12 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleApiKeyVisibility('minimax')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-text-muted hover:text-text-primary transition-colors"
|
||||
>
|
||||
{showApiKey['minimax'] ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted">
|
||||
Get your API key from{' '}
|
||||
<a
|
||||
href="https://platform.minimax.io"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
MiniMax Platform
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-text-secondary">Model</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.minimax?.model ?? 'MiniMax-M2.5'}
|
||||
onChange={e => setSettings(prev => ({
|
||||
...prev,
|
||||
minimax: { ...prev.minimax!, model: e.target.value }
|
||||
}))}
|
||||
placeholder="e.g., MiniMax-M2.5, MiniMax-M2.5-highspeed"
|
||||
className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all font-mono text-sm"
|
||||
/>
|
||||
<p className="text-xs text-text-muted">
|
||||
Available models: MiniMax-M2.5 (default), MiniMax-M2.5-highspeed (faster)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ProviderConfigCard
|
||||
title="MiniMax"
|
||||
apiKey={{
|
||||
value: settings.minimax?.apiKey ?? '',
|
||||
placeholder: 'Enter your MiniMax API key',
|
||||
helperText: 'Get your API key from',
|
||||
helperLink: 'https://platform.minimax.io',
|
||||
helperLinkLabel: 'MiniMax Platform',
|
||||
isVisible: !!showApiKey['minimax'],
|
||||
onChange: (value) => setSettings(prev => ({
|
||||
...prev,
|
||||
minimax: { ...prev.minimax!, apiKey: value }
|
||||
})),
|
||||
onToggleVisibility: () => toggleApiKeyVisibility('minimax'),
|
||||
}}
|
||||
model={{
|
||||
value: settings.minimax?.model ?? 'MiniMax-M2.5',
|
||||
placeholder: 'e.g., MiniMax-M2.5, MiniMax-M2.5-highspeed',
|
||||
onChange: (value) => setSettings(prev => ({
|
||||
...prev,
|
||||
minimax: { ...prev.minimax!, model: value }
|
||||
})),
|
||||
helperText: 'Available: MiniMax-M2.5 (default), MiniMax-M2.5-highspeed (faster)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Privacy Note */}
|
||||
|
|
@ -880,8 +763,7 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
|
|||
🔒
|
||||
</div>
|
||||
<div className="text-xs text-text-muted leading-relaxed">
|
||||
<span className="text-text-secondary font-medium">Privacy:</span> Your API keys are stored only in your browser's local storage.
|
||||
They're sent directly to the LLM provider when you chat. Your code never leaves your machine.
|
||||
<span className="text-text-secondary font-medium">Privacy:</span> Your API keys are stored only in your browser's session storage and are cleared when the tab closes. They're sent directly to the LLM provider when you chat. Your code never leaves your machine.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { Heart } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import { Heart } from '@/lib/lucide-icons';
|
||||
import { useAppState } from '../hooks/useAppState';
|
||||
|
||||
export const StatusBar = () => {
|
||||
|
|
@ -8,7 +9,7 @@ export const StatusBar = () => {
|
|||
const edgeCount = graph?.relationships.length ?? 0;
|
||||
|
||||
// Detect primary language
|
||||
const primaryLanguage = (() => {
|
||||
const primaryLanguage = useMemo(() => {
|
||||
if (!graph) return null;
|
||||
const languages = graph.nodes
|
||||
.map(n => n.properties.language)
|
||||
|
|
@ -21,7 +22,7 @@ export const StatusBar = () => {
|
|||
}, {} as Record<string, number>);
|
||||
|
||||
return Object.entries(counts).sort((a, b) => b[1] - a[1])[0]?.[0];
|
||||
})();
|
||||
}, [graph]);
|
||||
|
||||
return (
|
||||
<footer className="flex items-center justify-between px-5 py-2 bg-deep border-t border-dashed border-border-subtle text-[11px] text-text-muted">
|
||||
|
|
@ -38,7 +39,7 @@ export const StatusBar = () => {
|
|||
<span>{progress.message}</span>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex items-center gap-1.5" data-testid="status-ready">
|
||||
<span className="w-1.5 h-1.5 bg-node-function rounded-full" />
|
||||
<span>Ready</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { ChevronDown, ChevronRight, Sparkles, Check, Loader2, AlertCircle } from 'lucide-react';
|
||||
import { ChevronDown, ChevronRight, Sparkles, Check, Loader2, AlertCircle } from '@/lib/lucide-icons';
|
||||
import type { ToolCallInfo } from '../core/llm/types';
|
||||
|
||||
interface ToolCallCardProps {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { X, Snail, Rocket, SkipForward } from 'lucide-react';
|
||||
import { X, Snail, Rocket, SkipForward } from '@/lib/lucide-icons';
|
||||
|
||||
interface WebGPUFallbackDialogProps {
|
||||
isOpen: boolean;
|
||||
|
|
|
|||
110
gitnexus-web/src/components/settings/ProviderConfigCard.tsx
Normal file
110
gitnexus-web/src/components/settings/ProviderConfigCard.tsx
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import { ReactNode } from 'react';
|
||||
import { Eye, EyeOff, Key } from '@/lib/lucide-icons';
|
||||
|
||||
type ApiKeyField = {
|
||||
value: string;
|
||||
placeholder: string;
|
||||
helperText?: string;
|
||||
helperLink?: string;
|
||||
helperLinkLabel?: string;
|
||||
isVisible: boolean;
|
||||
onChange: (value: string) => void;
|
||||
onToggleVisibility: () => void;
|
||||
};
|
||||
|
||||
type ModelField = {
|
||||
value: string;
|
||||
placeholder: string;
|
||||
label?: string;
|
||||
helperText?: string;
|
||||
onChange: (value: string) => void;
|
||||
};
|
||||
|
||||
interface ProviderConfigCardProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
apiKey?: ApiKeyField;
|
||||
model?: ModelField;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export const ProviderConfigCard = ({
|
||||
title,
|
||||
description,
|
||||
apiKey,
|
||||
model,
|
||||
children,
|
||||
}: ProviderConfigCardProps) => {
|
||||
return (
|
||||
<div className="space-y-4 animate-fade-in">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-text-primary">{title}</h3>
|
||||
{description ? (
|
||||
<p className="text-xs text-text-muted">{description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{apiKey && (
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-text-secondary">
|
||||
<Key className="w-4 h-4" />
|
||||
API Key
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={apiKey.isVisible ? 'text' : 'password'}
|
||||
value={apiKey.value}
|
||||
onChange={e => apiKey.onChange(e.target.value)}
|
||||
placeholder={apiKey.placeholder}
|
||||
className="w-full px-4 py-3 pr-12 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={apiKey.onToggleVisibility}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-text-muted hover:text-text-primary transition-colors"
|
||||
>
|
||||
{apiKey.isVisible ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
{apiKey.helperText && (
|
||||
<p className="text-xs text-text-muted">
|
||||
{apiKey.helperText}{' '}
|
||||
{apiKey.helperLink ? (
|
||||
<a
|
||||
href={apiKey.helperLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
{apiKey.helperLinkLabel ?? 'Learn more'}
|
||||
</a>
|
||||
) : null}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{model && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-text-secondary">
|
||||
{model.label ?? 'Model'}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={model.value}
|
||||
onChange={e => model.onChange(e.target.value)}
|
||||
placeholder={model.placeholder}
|
||||
className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all font-mono text-sm"
|
||||
/>
|
||||
{model.helperText ? (
|
||||
<p className="text-xs text-text-muted">{model.helperText}</p>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
7
gitnexus-web/src/config/ui-constants.ts
Normal file
7
gitnexus-web/src/config/ui-constants.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
// Centralized UI and provider defaults to reduce magic numbers and duplicated URLs.
|
||||
export const ERROR_RESET_DELAY_MS = 3000;
|
||||
export const BACKEND_URL_DEBOUNCE_MS = 500;
|
||||
|
||||
export const DEFAULT_BACKEND_URL = 'http://localhost:4747';
|
||||
export const DEFAULT_OLLAMA_BASE_URL = 'http://localhost:11434';
|
||||
export const DEFAULT_OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1';
|
||||
|
|
@ -226,7 +226,7 @@ export const loadGraphToLbug = async (
|
|||
}
|
||||
|
||||
// Execute batched prepared statements per label pair
|
||||
const SUB_BATCH_SIZE = 4;
|
||||
// Prepare once per (fromLabel, toLabel) pair and reuse across all rows
|
||||
for (const [key, rels] of relsByLabelPair) {
|
||||
const [fromLabel, toLabel] = key.split(':');
|
||||
const cypher = `
|
||||
|
|
@ -235,39 +235,37 @@ export const loadGraphToLbug = async (
|
|||
CREATE (a)-[:${REL_TABLE_NAME} {type: $relType, confidence: $confidence, reason: $reason, step: $step}]->(b)
|
||||
`;
|
||||
|
||||
for (let i = 0; i < rels.length; i += SUB_BATCH_SIZE) {
|
||||
const subBatch = rels.slice(i, i + SUB_BATCH_SIZE);
|
||||
const stmt = await conn.prepare(cypher);
|
||||
if (!stmt.isSuccess()) {
|
||||
const errMsg = await stmt.getErrorMessage();
|
||||
if (import.meta.env.DEV) console.warn(`Prepare failed for ${key}: ${errMsg}`);
|
||||
skippedRels += subBatch.length;
|
||||
await stmt.close();
|
||||
continue;
|
||||
}
|
||||
const stmt = await conn.prepare(cypher);
|
||||
if (!stmt.isSuccess()) {
|
||||
const errMsg = await stmt.getErrorMessage();
|
||||
if (import.meta.env.DEV) console.warn(`Prepare failed for ${key}: ${errMsg}`);
|
||||
skippedRels += rels.length;
|
||||
await stmt.close();
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
for (const r of subBatch) {
|
||||
try {
|
||||
await conn.execute(stmt, r);
|
||||
insertedRels++;
|
||||
} catch (err) {
|
||||
skippedRels++;
|
||||
const statKey = `${r.relType}:${fromLabel}->${toLabel}`;
|
||||
skippedRelStats.set(statKey, (skippedRelStats.get(statKey) || 0) + 1);
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn(`⚠️ Skipped: ${statKey} | "${r.fromId}" → "${r.toId}" | ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
try {
|
||||
for (let i = 0; i < rels.length; i++) {
|
||||
try {
|
||||
await conn.execute(stmt, rels[i]);
|
||||
insertedRels++;
|
||||
} catch (err) {
|
||||
skippedRels++;
|
||||
const r = rels[i];
|
||||
const statKey = `${r.relType}:${fromLabel}->${toLabel}`;
|
||||
skippedRelStats.set(statKey, (skippedRelStats.get(statKey) || 0) + 1);
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn(`⚠️ Skipped: ${statKey} | "${r.fromId}" → "${r.toId}" | ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await stmt.close();
|
||||
}
|
||||
|
||||
// Yield to event loop between sub-batches
|
||||
if (i + SUB_BATCH_SIZE < rels.length) {
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
// Yield to event loop every 500 relations
|
||||
if (i > 0 && i % 500 === 0) {
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await stmt.close();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import {
|
|||
type CodebaseContext,
|
||||
buildDynamicSystemPrompt,
|
||||
} from './context-builder';
|
||||
import { DEFAULT_OLLAMA_BASE_URL, DEFAULT_OPENROUTER_BASE_URL } from '../../config/ui-constants';
|
||||
|
||||
/**
|
||||
* System prompt for the Graph RAG agent
|
||||
|
|
@ -184,7 +185,7 @@ export const createChatModel = (config: ProviderConfig): BaseChatModel => {
|
|||
case 'ollama': {
|
||||
const ollamaConfig = config as OllamaConfig;
|
||||
return new ChatOllama({
|
||||
baseUrl: ollamaConfig.baseUrl ?? 'http://localhost:11434',
|
||||
baseUrl: ollamaConfig.baseUrl ?? DEFAULT_OLLAMA_BASE_URL,
|
||||
model: ollamaConfig.model,
|
||||
temperature: ollamaConfig.temperature ?? 0.1,
|
||||
streaming: true,
|
||||
|
|
@ -203,7 +204,6 @@ export const createChatModel = (config: ProviderConfig): BaseChatModel => {
|
|||
if (import.meta.env.DEV) {
|
||||
console.log('🌐 OpenRouter config:', {
|
||||
hasApiKey: !!openRouterConfig.apiKey,
|
||||
apiKeyLength: openRouterConfig.apiKey?.length || 0,
|
||||
model: openRouterConfig.model,
|
||||
baseUrl: openRouterConfig.baseUrl,
|
||||
});
|
||||
|
|
@ -221,7 +221,7 @@ export const createChatModel = (config: ProviderConfig): BaseChatModel => {
|
|||
maxTokens: openRouterConfig.maxTokens,
|
||||
configuration: {
|
||||
apiKey: openRouterConfig.apiKey, // Ensure client receives it
|
||||
baseURL: openRouterConfig.baseUrl ?? 'https://openrouter.ai/api/v1',
|
||||
baseURL: openRouterConfig.baseUrl ?? DEFAULT_OPENROUTER_BASE_URL,
|
||||
},
|
||||
streaming: true,
|
||||
});
|
||||
|
|
@ -355,8 +355,8 @@ export async function* streamAgentResponse(
|
|||
const yieldedToolCalls = new Set<string>();
|
||||
const yieldedToolResults = new Set<string>();
|
||||
let lastProcessedMsgCount = formattedMessages.length;
|
||||
// Track if all tools are done (for distinguishing reasoning vs final content)
|
||||
let allToolsDone = true;
|
||||
// Track pending tool calls (for distinguishing reasoning vs final content)
|
||||
let pendingToolCalls = 0;
|
||||
// Track if we've seen any tool calls in this response turn.
|
||||
// Anything before the first tool call should be treated as "reasoning/narration"
|
||||
// so the UI can show the Cursor-like loop: plan → tool → update → tool → answer.
|
||||
|
|
@ -420,7 +420,7 @@ export async function* streamAgentResponse(
|
|||
const isReasoning =
|
||||
!hasSeenToolCallThisTurn ||
|
||||
toolCalls.length > 0 ||
|
||||
!allToolsDone;
|
||||
pendingToolCalls > 0;
|
||||
yield {
|
||||
type: isReasoning ? 'reasoning' : 'content',
|
||||
[isReasoning ? 'reasoning' : 'content']: content,
|
||||
|
|
@ -430,17 +430,23 @@ export async function* streamAgentResponse(
|
|||
// Track tool calls from message chunks
|
||||
if (toolCalls.length > 0) {
|
||||
hasSeenToolCallThisTurn = true;
|
||||
allToolsDone = false;
|
||||
pendingToolCalls += toolCalls.length;
|
||||
for (const tc of toolCalls) {
|
||||
const toolId = tc.id || `tool-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
if (!yieldedToolCalls.has(toolId)) {
|
||||
yieldedToolCalls.add(toolId);
|
||||
let parsedArgs: Record<string, any>;
|
||||
try {
|
||||
parsedArgs = tc.function?.arguments ? JSON.parse(tc.function.arguments) : {};
|
||||
} catch {
|
||||
parsedArgs = {};
|
||||
}
|
||||
yield {
|
||||
type: 'tool_call',
|
||||
toolCall: {
|
||||
id: toolId,
|
||||
name: tc.name || tc.function?.name || 'unknown',
|
||||
args: tc.args || (tc.function?.arguments ? JSON.parse(tc.function.arguments) : {}),
|
||||
args: tc.args || parsedArgs,
|
||||
status: 'running',
|
||||
},
|
||||
};
|
||||
|
|
@ -465,8 +471,8 @@ export async function* streamAgentResponse(
|
|||
status: 'completed',
|
||||
},
|
||||
};
|
||||
// After tool result, next AI content could be reasoning or final
|
||||
allToolsDone = true;
|
||||
// After tool result, decrement pending count
|
||||
pendingToolCalls = Math.max(0, pendingToolCalls - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -486,7 +492,7 @@ export async function* streamAgentResponse(
|
|||
for (const tc of toolCalls) {
|
||||
const toolId = tc.id || `tool-${Date.now()}`;
|
||||
if (!yieldedToolCalls.has(toolId)) {
|
||||
allToolsDone = false;
|
||||
pendingToolCalls++;
|
||||
yieldedToolCalls.add(toolId);
|
||||
yield {
|
||||
type: 'tool_call',
|
||||
|
|
@ -517,7 +523,7 @@ export async function* streamAgentResponse(
|
|||
status: 'completed',
|
||||
},
|
||||
};
|
||||
allToolsDone = true;
|
||||
pendingToolCalls = Math.max(0, pendingToolCalls - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,54 +18,85 @@ import {
|
|||
MiniMaxConfig,
|
||||
ProviderConfig,
|
||||
} from './types';
|
||||
import { DEFAULT_OPENROUTER_BASE_URL, DEFAULT_OLLAMA_BASE_URL } from '../../config/ui-constants';
|
||||
|
||||
const STORAGE_KEY = 'gitnexus-llm-settings';
|
||||
|
||||
const mergeWithDefaults = (parsed?: Partial<LLMSettings> | null): LLMSettings => ({
|
||||
...DEFAULT_LLM_SETTINGS,
|
||||
...parsed,
|
||||
openai: {
|
||||
...DEFAULT_LLM_SETTINGS.openai,
|
||||
...parsed?.openai,
|
||||
},
|
||||
azureOpenAI: {
|
||||
...DEFAULT_LLM_SETTINGS.azureOpenAI,
|
||||
...parsed?.azureOpenAI,
|
||||
},
|
||||
gemini: {
|
||||
...DEFAULT_LLM_SETTINGS.gemini,
|
||||
...parsed?.gemini,
|
||||
},
|
||||
anthropic: {
|
||||
...DEFAULT_LLM_SETTINGS.anthropic,
|
||||
...parsed?.anthropic,
|
||||
},
|
||||
ollama: {
|
||||
...DEFAULT_LLM_SETTINGS.ollama,
|
||||
...parsed?.ollama,
|
||||
},
|
||||
openrouter: {
|
||||
...DEFAULT_LLM_SETTINGS.openrouter,
|
||||
...parsed?.openrouter,
|
||||
},
|
||||
minimax: {
|
||||
...DEFAULT_LLM_SETTINGS.minimax,
|
||||
...parsed?.minimax,
|
||||
},
|
||||
});
|
||||
|
||||
const readSettings = (storage: Storage): Partial<LLMSettings> | null => {
|
||||
const raw = storage.getItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw) as Partial<LLMSettings>;
|
||||
} catch (error) {
|
||||
console.warn('Failed to parse LLM settings:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const writeSettings = (storage: Storage, settings: LLMSettings): void => {
|
||||
storage.setItem(STORAGE_KEY, JSON.stringify(settings));
|
||||
};
|
||||
|
||||
/**
|
||||
* Load settings from localStorage
|
||||
* Load settings from sessionStorage (migrates legacy localStorage once).
|
||||
*/
|
||||
export const loadSettings = (): LLMSettings => {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (!stored) {
|
||||
return DEFAULT_LLM_SETTINGS;
|
||||
const sessionData = typeof sessionStorage !== 'undefined' ? readSettings(sessionStorage) : null;
|
||||
if (sessionData) {
|
||||
return mergeWithDefaults(sessionData);
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(stored) as Partial<LLMSettings>;
|
||||
|
||||
// Merge with defaults to handle new fields
|
||||
return {
|
||||
...DEFAULT_LLM_SETTINGS,
|
||||
...parsed,
|
||||
openai: {
|
||||
...DEFAULT_LLM_SETTINGS.openai,
|
||||
...parsed.openai,
|
||||
},
|
||||
azureOpenAI: {
|
||||
...DEFAULT_LLM_SETTINGS.azureOpenAI,
|
||||
...parsed.azureOpenAI,
|
||||
},
|
||||
gemini: {
|
||||
...DEFAULT_LLM_SETTINGS.gemini,
|
||||
...parsed.gemini,
|
||||
},
|
||||
anthropic: {
|
||||
...DEFAULT_LLM_SETTINGS.anthropic,
|
||||
...parsed.anthropic,
|
||||
},
|
||||
ollama: {
|
||||
...DEFAULT_LLM_SETTINGS.ollama,
|
||||
...parsed.ollama,
|
||||
},
|
||||
openrouter: {
|
||||
...DEFAULT_LLM_SETTINGS.openrouter,
|
||||
...parsed.openrouter,
|
||||
},
|
||||
minimax: {
|
||||
...DEFAULT_LLM_SETTINGS.minimax,
|
||||
...parsed.minimax,
|
||||
},
|
||||
};
|
||||
|
||||
const legacyData = typeof localStorage !== 'undefined' ? readSettings(localStorage) : null;
|
||||
if (legacyData) {
|
||||
const merged = mergeWithDefaults(legacyData);
|
||||
try {
|
||||
if (typeof sessionStorage !== 'undefined') {
|
||||
writeSettings(sessionStorage, merged);
|
||||
}
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to migrate legacy LLM settings to sessionStorage:', error);
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
return DEFAULT_LLM_SETTINGS;
|
||||
} catch (error) {
|
||||
console.warn('Failed to load LLM settings:', error);
|
||||
return DEFAULT_LLM_SETTINGS;
|
||||
|
|
@ -73,11 +104,13 @@ export const loadSettings = (): LLMSettings => {
|
|||
};
|
||||
|
||||
/**
|
||||
* Save settings to localStorage
|
||||
* Save settings to sessionStorage
|
||||
*/
|
||||
export const saveSettings = (settings: LLMSettings): void => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
|
||||
if (typeof sessionStorage !== 'undefined') {
|
||||
writeSettings(sessionStorage, settings);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save LLM settings:', error);
|
||||
}
|
||||
|
|
@ -204,77 +237,53 @@ export const setActiveProvider = (provider: LLMProvider): LLMSettings => {
|
|||
/**
|
||||
* Get the current provider configuration
|
||||
*/
|
||||
type ProviderBuilder = (settings: LLMSettings) => ProviderConfig | null;
|
||||
|
||||
const providerBuilders: Record<LLMProvider, ProviderBuilder> = {
|
||||
openai: (settings) => {
|
||||
if (!settings.openai?.apiKey) return null;
|
||||
return { provider: 'openai', ...settings.openai } as OpenAIConfig;
|
||||
},
|
||||
'azure-openai': (settings) => {
|
||||
if (!settings.azureOpenAI?.apiKey || !settings.azureOpenAI?.endpoint) return null;
|
||||
return { provider: 'azure-openai', ...settings.azureOpenAI } as AzureOpenAIConfig;
|
||||
},
|
||||
gemini: (settings) => {
|
||||
if (!settings.gemini?.apiKey) return null;
|
||||
return { provider: 'gemini', ...settings.gemini } as GeminiConfig;
|
||||
},
|
||||
anthropic: (settings) => {
|
||||
if (!settings.anthropic?.apiKey) return null;
|
||||
return { provider: 'anthropic', ...settings.anthropic } as AnthropicConfig;
|
||||
},
|
||||
ollama: (settings) => {
|
||||
return {
|
||||
provider: 'ollama',
|
||||
...settings.ollama,
|
||||
baseUrl: settings.ollama?.baseUrl ?? DEFAULT_OLLAMA_BASE_URL,
|
||||
} as OllamaConfig;
|
||||
},
|
||||
openrouter: (settings) => {
|
||||
if (!settings.openrouter?.apiKey || settings.openrouter.apiKey.trim() === '') return null;
|
||||
return {
|
||||
provider: 'openrouter',
|
||||
apiKey: settings.openrouter.apiKey,
|
||||
model: settings.openrouter.model || '',
|
||||
baseUrl: settings.openrouter.baseUrl || DEFAULT_OPENROUTER_BASE_URL,
|
||||
temperature: settings.openrouter.temperature,
|
||||
maxTokens: settings.openrouter.maxTokens,
|
||||
} as OpenRouterConfig;
|
||||
},
|
||||
minimax: (settings) => {
|
||||
if (!settings.minimax?.apiKey) return null;
|
||||
return { provider: 'minimax', ...settings.minimax } as MiniMaxConfig;
|
||||
},
|
||||
};
|
||||
|
||||
export const getActiveProviderConfig = (): ProviderConfig | null => {
|
||||
const settings = loadSettings();
|
||||
|
||||
switch (settings.activeProvider) {
|
||||
case 'openai':
|
||||
if (!settings.openai?.apiKey) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
provider: 'openai',
|
||||
...settings.openai,
|
||||
} as OpenAIConfig;
|
||||
|
||||
case 'azure-openai':
|
||||
if (!settings.azureOpenAI?.apiKey || !settings.azureOpenAI?.endpoint) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
provider: 'azure-openai',
|
||||
...settings.azureOpenAI,
|
||||
} as AzureOpenAIConfig;
|
||||
|
||||
case 'gemini':
|
||||
if (!settings.gemini?.apiKey) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
provider: 'gemini',
|
||||
...settings.gemini,
|
||||
} as GeminiConfig;
|
||||
|
||||
case 'anthropic':
|
||||
if (!settings.anthropic?.apiKey) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
provider: 'anthropic',
|
||||
...settings.anthropic,
|
||||
} as AnthropicConfig;
|
||||
|
||||
case 'ollama':
|
||||
return {
|
||||
provider: 'ollama',
|
||||
...settings.ollama,
|
||||
} as OllamaConfig;
|
||||
|
||||
case 'openrouter':
|
||||
if (!settings.openrouter?.apiKey || settings.openrouter.apiKey.trim() === '') {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
provider: 'openrouter',
|
||||
apiKey: settings.openrouter.apiKey,
|
||||
model: settings.openrouter.model || '',
|
||||
baseUrl: settings.openrouter.baseUrl || 'https://openrouter.ai/api/v1',
|
||||
temperature: settings.openrouter.temperature,
|
||||
maxTokens: settings.openrouter.maxTokens,
|
||||
} as OpenRouterConfig;
|
||||
|
||||
case 'minimax':
|
||||
if (!settings.minimax?.apiKey) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
provider: 'minimax',
|
||||
...settings.minimax,
|
||||
} as MiniMaxConfig;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
const builder = providerBuilders[settings.activeProvider];
|
||||
return builder ? builder(settings) : null;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -288,7 +297,16 @@ export const isProviderConfigured = (): boolean => {
|
|||
* Clear all settings (reset to defaults)
|
||||
*/
|
||||
export const clearSettings = (): void => {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
try {
|
||||
if (typeof sessionStorage !== 'undefined') {
|
||||
sessionStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to clear LLM settings:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -343,7 +361,7 @@ export const getAvailableModels = (provider: LLMProvider): string[] => {
|
|||
*/
|
||||
export const fetchOpenRouterModels = async (): Promise<Array<{ id: string; name: string }>> => {
|
||||
try {
|
||||
const response = await fetch('https://openrouter.ai/api/v1/models');
|
||||
const response = await fetch(`${DEFAULT_OPENROUTER_BASE_URL}/models`);
|
||||
if (!response.ok) throw new Error('Failed to fetch models');
|
||||
const data = await response.json();
|
||||
return data.data.map((model: any) => ({
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@
|
|||
/**
|
||||
* Supported LLM providers
|
||||
*/
|
||||
import { DEFAULT_OLLAMA_BASE_URL, DEFAULT_OPENROUTER_BASE_URL } from '../../config/ui-constants';
|
||||
|
||||
export type LLMProvider = 'openai' | 'azure-openai' | 'gemini' | 'anthropic' | 'ollama' | 'openrouter' | 'minimax';
|
||||
|
||||
/**
|
||||
|
|
@ -148,14 +150,14 @@ export const DEFAULT_LLM_SETTINGS: LLMSettings = {
|
|||
temperature: 0.1,
|
||||
},
|
||||
ollama: {
|
||||
baseUrl: 'http://localhost:11434',
|
||||
baseUrl: DEFAULT_OLLAMA_BASE_URL,
|
||||
model: 'llama3.2',
|
||||
temperature: 0.1,
|
||||
},
|
||||
openrouter: {
|
||||
apiKey: '',
|
||||
model: '',
|
||||
baseUrl: 'https://openrouter.ai/api/v1',
|
||||
baseUrl: DEFAULT_OPENROUTER_BASE_URL,
|
||||
temperature: 0.1,
|
||||
},
|
||||
minimax: {
|
||||
|
|
|
|||
75
gitnexus-web/src/hooks/app-state/graph.tsx
Normal file
75
gitnexus-web/src/hooks/app-state/graph.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { createContext, useContext, useCallback, useMemo, useState, ReactNode } from 'react';
|
||||
import type { KnowledgeGraph, GraphNode, NodeLabel } from '../../core/graph/types';
|
||||
import { DEFAULT_VISIBLE_LABELS, DEFAULT_VISIBLE_EDGES, type EdgeType } from '../../lib/constants';
|
||||
|
||||
interface GraphStateContextValue {
|
||||
graph: KnowledgeGraph | null;
|
||||
setGraph: (graph: KnowledgeGraph | null) => void;
|
||||
fileContents: Map<string, string>;
|
||||
setFileContents: (contents: Map<string, string>) => void;
|
||||
selectedNode: GraphNode | null;
|
||||
setSelectedNode: (node: GraphNode | null) => void;
|
||||
visibleLabels: NodeLabel[];
|
||||
toggleLabelVisibility: (label: NodeLabel) => void;
|
||||
visibleEdgeTypes: EdgeType[];
|
||||
toggleEdgeVisibility: (edgeType: EdgeType) => void;
|
||||
depthFilter: number | null;
|
||||
setDepthFilter: (depth: number | null) => void;
|
||||
highlightedNodeIds: Set<string>;
|
||||
setHighlightedNodeIds: (ids: Set<string>) => void;
|
||||
}
|
||||
|
||||
const GraphStateContext = createContext<GraphStateContextValue | null>(null);
|
||||
|
||||
export const GraphStateProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [graph, setGraph] = useState<KnowledgeGraph | null>(null);
|
||||
const [fileContents, setFileContents] = useState<Map<string, string>>(new Map());
|
||||
const [selectedNode, setSelectedNode] = useState<GraphNode | null>(null);
|
||||
const [visibleLabels, setVisibleLabels] = useState<NodeLabel[]>(DEFAULT_VISIBLE_LABELS);
|
||||
const [visibleEdgeTypes, setVisibleEdgeTypes] = useState<EdgeType[]>(DEFAULT_VISIBLE_EDGES);
|
||||
const [depthFilter, setDepthFilter] = useState<number | null>(null);
|
||||
const [highlightedNodeIds, setHighlightedNodeIds] = useState<Set<string>>(new Set());
|
||||
|
||||
const toggleLabelVisibility = useCallback((label: NodeLabel) => {
|
||||
setVisibleLabels(prev =>
|
||||
prev.includes(label) ? prev.filter(l => l !== label) : [...prev, label]
|
||||
);
|
||||
}, []);
|
||||
|
||||
const toggleEdgeVisibility = useCallback((edgeType: EdgeType) => {
|
||||
setVisibleEdgeTypes(prev =>
|
||||
prev.includes(edgeType) ? prev.filter(e => e !== edgeType) : [...prev, edgeType]
|
||||
);
|
||||
}, []);
|
||||
|
||||
const value = useMemo<GraphStateContextValue>(() => ({
|
||||
graph,
|
||||
setGraph,
|
||||
fileContents,
|
||||
setFileContents,
|
||||
selectedNode,
|
||||
setSelectedNode,
|
||||
visibleLabels,
|
||||
toggleLabelVisibility,
|
||||
visibleEdgeTypes,
|
||||
toggleEdgeVisibility,
|
||||
depthFilter,
|
||||
setDepthFilter,
|
||||
highlightedNodeIds,
|
||||
setHighlightedNodeIds,
|
||||
}), [graph, fileContents, selectedNode, visibleLabels, visibleEdgeTypes, depthFilter, highlightedNodeIds]);
|
||||
|
||||
return (
|
||||
<GraphStateContext.Provider value={value}>
|
||||
{children}
|
||||
</GraphStateContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useGraphState = (): GraphStateContextValue => {
|
||||
const ctx = useContext(GraphStateContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useGraphState must be used within a GraphStateProvider');
|
||||
}
|
||||
return ctx;
|
||||
};
|
||||
|
|
@ -1,18 +1,21 @@
|
|||
import { createContext, useContext, useState, useCallback, useRef, useEffect, ReactNode } from 'react';
|
||||
import { createContext, useContext, useState, useCallback, useRef, useEffect, useMemo, ReactNode } from 'react';
|
||||
import * as Comlink from 'comlink';
|
||||
import { KnowledgeGraph, GraphNode, GraphRelationship, NodeLabel } from '../core/graph/types';
|
||||
import { PipelineProgress, PipelineResult, deserializePipelineResult } from '../types/pipeline';
|
||||
import { createKnowledgeGraph } from '../core/graph/graph';
|
||||
import { DEFAULT_VISIBLE_LABELS } from '../lib/constants';
|
||||
import type { IngestionWorkerApi } from '../workers/ingestion.worker';
|
||||
import type { FileEntry } from '../services/zip';
|
||||
import type { EmbeddingProgress, SemanticSearchResult } from '../core/embeddings/types';
|
||||
import type { LLMSettings, ProviderConfig, AgentStreamChunk, ChatMessage, ToolCallInfo, MessageStep } from '../core/llm/types';
|
||||
import { loadSettings, getActiveProviderConfig, saveSettings } from '../core/llm/settings-service';
|
||||
import type { AgentMessage } from '../core/llm/agent';
|
||||
import { DEFAULT_VISIBLE_EDGES, type EdgeType } from '../lib/constants';
|
||||
import { type EdgeType } from '../lib/constants';
|
||||
import type { RepoSummary, ConnectToServerResult } from '../services/server-connection';
|
||||
import { fetchRepos, connectToServer } from '../services/server-connection';
|
||||
import { ERROR_RESET_DELAY_MS } from '../config/ui-constants';
|
||||
import { normalizePath, resolveFilePath as resolvePathFromContents } from '../lib/path-resolution';
|
||||
import { FILE_REF_REGEX, NODE_REF_REGEX } from '../lib/grounding-patterns';
|
||||
import { GraphStateProvider, useGraphState } from './app-state/graph';
|
||||
|
||||
export type ViewMode = 'onboarding' | 'loading' | 'exploring';
|
||||
export type RightPanelTab = 'code' | 'chat';
|
||||
|
|
@ -74,6 +77,8 @@ interface AppState {
|
|||
setRightPanelTab: (tab: RightPanelTab) => void;
|
||||
openCodePanel: () => void;
|
||||
openChatPanel: () => void;
|
||||
helpDialogBoxOpen: boolean;
|
||||
setHelpDialogBoxOpen: (open: boolean) => void;
|
||||
|
||||
// Filters
|
||||
visibleLabels: NodeLabel[];
|
||||
|
|
@ -134,6 +139,7 @@ interface AppState {
|
|||
|
||||
// Embedding methods
|
||||
startEmbeddings: (forceDevice?: 'webgpu' | 'wasm') => Promise<void>;
|
||||
startEmbeddingsWithFallback: () => void;
|
||||
semanticSearch: (query: string, k?: number) => Promise<SemanticSearchResult[]>;
|
||||
semanticSearchWithContext: (query: string, k?: number, hops?: number) => Promise<any[]>;
|
||||
isEmbeddingReady: boolean;
|
||||
|
|
@ -145,9 +151,7 @@ interface AppState {
|
|||
llmSettings: LLMSettings;
|
||||
updateLLMSettings: (updates: Partial<LLMSettings>) => void;
|
||||
isSettingsPanelOpen: boolean;
|
||||
isHelpDialogBoxOpen: boolean;
|
||||
setSettingsPanelOpen: (open: boolean) => void;
|
||||
setHelpDialogBoxOpen: (open: boolean) => void;
|
||||
isAgentReady: boolean;
|
||||
isAgentInitializing: boolean;
|
||||
agentError: string | null;
|
||||
|
|
@ -177,20 +181,37 @@ interface AppState {
|
|||
|
||||
const AppStateContext = createContext<AppState | null>(null);
|
||||
|
||||
export const AppStateProvider = ({ children }: { children: ReactNode }) => {
|
||||
export const AppStateProvider = ({ children }: { children: ReactNode }) => (
|
||||
<GraphStateProvider>
|
||||
<AppStateProviderInner>{children}</AppStateProviderInner>
|
||||
</GraphStateProvider>
|
||||
);
|
||||
|
||||
const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
|
||||
// View state
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('onboarding');
|
||||
|
||||
// Graph data
|
||||
const [graph, setGraph] = useState<KnowledgeGraph | null>(null);
|
||||
const [fileContents, setFileContents] = useState<Map<string, string>>(new Map());
|
||||
|
||||
// Selection
|
||||
const [selectedNode, setSelectedNode] = useState<GraphNode | null>(null);
|
||||
const {
|
||||
graph,
|
||||
setGraph,
|
||||
fileContents,
|
||||
setFileContents,
|
||||
selectedNode,
|
||||
setSelectedNode,
|
||||
visibleLabels,
|
||||
toggleLabelVisibility,
|
||||
visibleEdgeTypes,
|
||||
toggleEdgeVisibility,
|
||||
depthFilter,
|
||||
setDepthFilter,
|
||||
highlightedNodeIds,
|
||||
setHighlightedNodeIds,
|
||||
} = useGraphState();
|
||||
|
||||
// Right Panel
|
||||
const [isRightPanelOpen, setRightPanelOpen] = useState(false);
|
||||
const [rightPanelTab, setRightPanelTab] = useState<RightPanelTab>('code');
|
||||
const [helpDialogBoxOpen, setHelpDialogBoxOpen] = useState(false);
|
||||
|
||||
const openCodePanel = useCallback(() => {
|
||||
// Legacy API: used by graph/tree selection.
|
||||
|
|
@ -204,15 +225,7 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
|
|||
setRightPanelTab('chat');
|
||||
}, []);
|
||||
|
||||
// Filters
|
||||
const [visibleLabels, setVisibleLabels] = useState<NodeLabel[]>(DEFAULT_VISIBLE_LABELS);
|
||||
const [visibleEdgeTypes, setVisibleEdgeTypes] = useState<EdgeType[]>(DEFAULT_VISIBLE_EDGES);
|
||||
|
||||
// Depth filter
|
||||
const [depthFilter, setDepthFilter] = useState<number | null>(null);
|
||||
|
||||
// Query state
|
||||
const [highlightedNodeIds, setHighlightedNodeIds] = useState<Set<string>>(new Set());
|
||||
const [queryResult, setQueryResult] = useState<QueryResult | null>(null);
|
||||
|
||||
// AI highlights (separate from user/query highlights)
|
||||
|
|
@ -298,7 +311,6 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
|
|||
// LLM/Agent state
|
||||
const [llmSettings, setLLMSettings] = useState<LLMSettings>(loadSettings);
|
||||
const [isSettingsPanelOpen, setSettingsPanelOpen] = useState(false);
|
||||
const [isHelpDialogBoxOpen, setHelpDialogBoxOpen] = useState(false);
|
||||
const [isAgentReady, setIsAgentReady] = useState(false);
|
||||
const [isAgentInitializing, setIsAgentInitializing] = useState(false);
|
||||
const [agentError, setAgentError] = useState<string | null>(null);
|
||||
|
|
@ -313,54 +325,24 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
|
|||
const [isCodePanelOpen, setCodePanelOpen] = useState(false);
|
||||
const [codeReferenceFocus, setCodeReferenceFocus] = useState<CodeReferenceFocus | null>(null);
|
||||
|
||||
const normalizePath = useCallback((p: string) => {
|
||||
return p.replace(/\\/g, '/').replace(/^\.?\//, '');
|
||||
}, []);
|
||||
|
||||
const resolveFilePath = useCallback((requestedPath: string): string | null => {
|
||||
const req = normalizePath(requestedPath).toLowerCase();
|
||||
if (!req) return null;
|
||||
return resolvePathFromContents(fileContents, requestedPath);
|
||||
}, [fileContents]);
|
||||
|
||||
// Exact match first
|
||||
for (const key of fileContents.keys()) {
|
||||
if (normalizePath(key).toLowerCase() === req) return key;
|
||||
}
|
||||
|
||||
// Ends-with match (best for partial paths like "src/foo.ts")
|
||||
let best: { path: string; score: number } | null = null;
|
||||
for (const key of fileContents.keys()) {
|
||||
const norm = normalizePath(key).toLowerCase();
|
||||
if (norm.endsWith(req)) {
|
||||
const score = 1000 - norm.length; // shorter is better
|
||||
if (!best || score > best.score) best = { path: key, score };
|
||||
const fileNodeByPath = useMemo(() => {
|
||||
if (!graph) return new Map<string, string>();
|
||||
const map = new Map<string, string>();
|
||||
for (const n of graph.nodes) {
|
||||
if (n.label === 'File') {
|
||||
map.set(normalizePath(n.properties.filePath), n.id);
|
||||
}
|
||||
}
|
||||
if (best) return best.path;
|
||||
|
||||
// Segment match fallback
|
||||
const segs = req.split('/').filter(Boolean);
|
||||
for (const key of fileContents.keys()) {
|
||||
const normSegs = normalizePath(key).toLowerCase().split('/').filter(Boolean);
|
||||
let idx = 0;
|
||||
for (const s of segs) {
|
||||
const found = normSegs.findIndex((x, i) => i >= idx && x.includes(s));
|
||||
if (found === -1) { idx = -1; break; }
|
||||
idx = found + 1;
|
||||
}
|
||||
if (idx !== -1) return key;
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [fileContents, normalizePath]);
|
||||
return map;
|
||||
}, [graph]);
|
||||
|
||||
const findFileNodeId = useCallback((filePath: string): string | undefined => {
|
||||
if (!graph) return undefined;
|
||||
const target = normalizePath(filePath);
|
||||
const fileNode = graph.nodes.find(
|
||||
(n) => n.label === 'File' && normalizePath(n.properties.filePath) === target
|
||||
);
|
||||
return fileNode?.id;
|
||||
}, [graph, normalizePath]);
|
||||
return fileNodeByPath.get(normalizePath(filePath));
|
||||
}, [fileNodeByPath]);
|
||||
|
||||
// Code References methods
|
||||
const addCodeReference = useCallback((ref: Omit<CodeReference, 'id'>) => {
|
||||
|
|
@ -419,7 +401,7 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
|
|||
}
|
||||
return kept;
|
||||
});
|
||||
}, [queryResult, selectedNode]);
|
||||
}, [selectedNode]);
|
||||
|
||||
// Auto-add a code reference when the user selects a node in the graph/tree
|
||||
useEffect(() => {
|
||||
|
|
@ -546,6 +528,25 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
|
|||
}
|
||||
}, []);
|
||||
|
||||
const startEmbeddingsWithFallback = useCallback(() => {
|
||||
// Skip auto-start in automated/headless runs to avoid WebGPU errors and long downloads.
|
||||
const isPlaywright =
|
||||
(typeof navigator !== 'undefined' && navigator.webdriver) ||
|
||||
(typeof import.meta !== 'undefined' && typeof import.meta.env !== 'undefined' && import.meta.env.VITE_PLAYWRIGHT_TEST) ||
|
||||
(typeof process !== 'undefined' && process.env.PLAYWRIGHT_TEST);
|
||||
if (isPlaywright) {
|
||||
setEmbeddingStatus('idle');
|
||||
return;
|
||||
}
|
||||
startEmbeddings().catch((err) => {
|
||||
if (err?.name === 'WebGPUNotAvailableError' || err?.message?.includes('WebGPU')) {
|
||||
startEmbeddings('wasm').catch(console.warn);
|
||||
} else {
|
||||
console.warn('Embeddings auto-start failed:', err);
|
||||
}
|
||||
});
|
||||
}, [startEmbeddings]);
|
||||
|
||||
const semanticSearch = useCallback(async (
|
||||
query: string,
|
||||
k: number = 10
|
||||
|
|
@ -709,6 +710,15 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
|
|||
}
|
||||
});
|
||||
};
|
||||
let pendingUpdate = false;
|
||||
const scheduleMessageUpdate = () => {
|
||||
if (pendingUpdate) return;
|
||||
pendingUpdate = true;
|
||||
requestAnimationFrame(() => {
|
||||
pendingUpdate = false;
|
||||
updateMessage();
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
const onChunk = Comlink.proxy((chunk: AgentStreamChunk) => {
|
||||
|
|
@ -731,7 +741,7 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
|
|||
content: chunk.reasoning,
|
||||
});
|
||||
}
|
||||
updateMessage();
|
||||
scheduleMessageUpdate();
|
||||
}
|
||||
break;
|
||||
|
||||
|
|
@ -754,7 +764,7 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
|
|||
content: chunk.content,
|
||||
});
|
||||
}
|
||||
updateMessage();
|
||||
scheduleMessageUpdate();
|
||||
|
||||
// Parse inline grounding references and add them to the Code References panel.
|
||||
// Supports: [[file.ts:10-25]] (file refs) and [[Class:View]] (node refs)
|
||||
|
|
@ -765,7 +775,7 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
|
|||
|
||||
// Pattern 1: File refs - [[path/file.ext]] or [[path/file.ext:line]] or [[path/file.ext:line-line]]
|
||||
// Line numbers are optional
|
||||
const fileRefRegex = /\[\[([a-zA-Z0-9_\-./\\]+\.[a-zA-Z0-9]+)(?::(\d+)(?:[-–](\d+))?)?\]\]/g;
|
||||
const fileRefRegex = new RegExp(FILE_REF_REGEX.source, FILE_REF_REGEX.flags);
|
||||
let fileMatch: RegExpExecArray | null;
|
||||
while ((fileMatch = fileRefRegex.exec(fullText)) !== null) {
|
||||
const rawPath = fileMatch[1].trim();
|
||||
|
|
@ -791,7 +801,7 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
|
|||
}
|
||||
|
||||
// Pattern 2: Node refs - [[Type:Name]] or [[graph:Type:Name]]
|
||||
const nodeRefRegex = /\[\[(?:graph:)?(Class|Function|Method|Interface|File|Folder|Variable|Enum|Type|CodeElement):([^\]]+)\]\]/g;
|
||||
const nodeRefRegex = new RegExp(NODE_REF_REGEX.source, NODE_REF_REGEX.flags);
|
||||
let nodeMatch: RegExpExecArray | null;
|
||||
while ((nodeMatch = nodeRefRegex.exec(fullText)) !== null) {
|
||||
const nodeType = nodeMatch[1];
|
||||
|
|
@ -832,7 +842,7 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
|
|||
toolCall: tc,
|
||||
});
|
||||
setCurrentToolCalls(prev => [...prev, tc]);
|
||||
updateMessage();
|
||||
scheduleMessageUpdate();
|
||||
}
|
||||
break;
|
||||
|
||||
|
|
@ -891,7 +901,7 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
|
|||
return prev;
|
||||
});
|
||||
|
||||
updateMessage();
|
||||
scheduleMessageUpdate();
|
||||
|
||||
// Parse highlight marker from tool results
|
||||
if (tc.result) {
|
||||
|
|
@ -900,15 +910,15 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
|
|||
const rawIds = highlightMatch[1].split(',').map((id: string) => id.trim()).filter(Boolean);
|
||||
if (rawIds.length > 0 && graph) {
|
||||
const matchedIds = new Set<string>();
|
||||
const graphNodeIds = graph.nodes.map(n => n.id);
|
||||
const graphNodeIdSet = new Set(graph.nodes.map(n => n.id));
|
||||
|
||||
for (const rawId of rawIds) {
|
||||
if (graphNodeIds.includes(rawId)) {
|
||||
if (graphNodeIdSet.has(rawId)) {
|
||||
matchedIds.add(rawId);
|
||||
} else {
|
||||
const found = graphNodeIds.find(gid =>
|
||||
gid.endsWith(rawId) || gid.endsWith(':' + rawId)
|
||||
);
|
||||
const found = graph.nodes.find(n =>
|
||||
n.id.endsWith(rawId) || n.id.endsWith(':' + rawId)
|
||||
)?.id;
|
||||
if (found) {
|
||||
matchedIds.add(found);
|
||||
}
|
||||
|
|
@ -929,15 +939,15 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
|
|||
const rawIds = impactMatch[1].split(',').map((id: string) => id.trim()).filter(Boolean);
|
||||
if (rawIds.length > 0 && graph) {
|
||||
const matchedIds = new Set<string>();
|
||||
const graphNodeIds = graph.nodes.map(n => n.id);
|
||||
const graphNodeIdSet = new Set(graph.nodes.map(n => n.id));
|
||||
|
||||
for (const rawId of rawIds) {
|
||||
if (graphNodeIds.includes(rawId)) {
|
||||
if (graphNodeIdSet.has(rawId)) {
|
||||
matchedIds.add(rawId);
|
||||
} else {
|
||||
const found = graphNodeIds.find(gid =>
|
||||
gid.endsWith(rawId) || gid.endsWith(':' + rawId)
|
||||
);
|
||||
const found = graph.nodes.find(n =>
|
||||
n.id.endsWith(rawId) || n.id.endsWith(':' + rawId)
|
||||
)?.id;
|
||||
if (found) {
|
||||
matchedIds.add(found);
|
||||
}
|
||||
|
|
@ -961,7 +971,7 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
|
|||
|
||||
case 'done':
|
||||
// Finalize the assistant message - just call updateMessage one more time
|
||||
updateMessage();
|
||||
scheduleMessageUpdate();
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
|
@ -997,7 +1007,6 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
|
|||
|
||||
setProgress({ phase: 'extracting', percent: 0, message: 'Switching repository...', detail: `Loading ${repoName}` });
|
||||
setViewMode('loading');
|
||||
|
||||
setIsAgentReady(false);
|
||||
|
||||
// Clear stale graph state from previous repo (highlights, selections, blast radius)
|
||||
|
|
@ -1046,13 +1055,7 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
|
|||
await initializeAgent(pName);
|
||||
}
|
||||
setViewMode('exploring');
|
||||
startEmbeddings().catch((err) => {
|
||||
if (err?.name === 'WebGPUNotAvailableError' || err?.message?.includes('WebGPU')) {
|
||||
startEmbeddings('wasm').catch(console.warn);
|
||||
} else {
|
||||
console.warn('Embeddings auto-start failed:', err);
|
||||
}
|
||||
});
|
||||
startEmbeddingsWithFallback();
|
||||
setProgress(null);
|
||||
} catch (err) {
|
||||
console.warn('Failed to load graph into LadybugDB:', err);
|
||||
|
|
@ -1071,9 +1074,9 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
|
|||
});
|
||||
setIsAgentReady(false);
|
||||
await apiRef.current?.disposeAgent();
|
||||
setTimeout(() => { setViewMode('exploring'); setProgress(null); }, 3000);
|
||||
setTimeout(() => { setViewMode('exploring'); setProgress(null); }, ERROR_RESET_DELAY_MS);
|
||||
}
|
||||
}, [serverBaseUrl, setProgress, setViewMode, setProjectName, setGraph, setFileContents, loadServerGraph, initializeAgent, startEmbeddings, setHighlightedNodeIds, clearAIToolHighlights, clearAICitationHighlights, clearBlastRadius, setSelectedNode, setQueryResult, setCodeReferences, setCodePanelOpen, setCodeReferenceFocus]);
|
||||
}, [serverBaseUrl, setProgress, setViewMode, setProjectName, setGraph, setFileContents, loadServerGraph, initializeAgent, startEmbeddingsWithFallback, setHighlightedNodeIds, clearAIToolHighlights, clearAICitationHighlights, clearBlastRadius, setSelectedNode, setQueryResult, setCodeReferences, setCodePanelOpen, setCodeReferenceFocus]);
|
||||
|
||||
const removeCodeReference = useCallback((id: string) => {
|
||||
setCodeReferences(prev => {
|
||||
|
|
@ -1107,26 +1110,6 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
|
|||
setCodeReferenceFocus(null);
|
||||
}, []);
|
||||
|
||||
const toggleLabelVisibility = useCallback((label: NodeLabel) => {
|
||||
setVisibleLabels(prev => {
|
||||
if (prev.includes(label)) {
|
||||
return prev.filter(l => l !== label);
|
||||
} else {
|
||||
return [...prev, label];
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleEdgeVisibility = useCallback((edgeType: EdgeType) => {
|
||||
setVisibleEdgeTypes(prev => {
|
||||
if (prev.includes(edgeType)) {
|
||||
return prev.filter(t => t !== edgeType);
|
||||
} else {
|
||||
return [...prev, edgeType];
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const value: AppState = {
|
||||
viewMode,
|
||||
setViewMode,
|
||||
|
|
@ -1142,6 +1125,8 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
|
|||
setRightPanelTab,
|
||||
openCodePanel,
|
||||
openChatPanel,
|
||||
helpDialogBoxOpen,
|
||||
setHelpDialogBoxOpen,
|
||||
visibleLabels,
|
||||
toggleLabelVisibility,
|
||||
visibleEdgeTypes,
|
||||
|
|
@ -1184,6 +1169,7 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
|
|||
embeddingStatus,
|
||||
embeddingProgress,
|
||||
startEmbeddings,
|
||||
startEmbeddingsWithFallback,
|
||||
semanticSearch,
|
||||
semanticSearchWithContext,
|
||||
isEmbeddingReady: embeddingStatus === 'ready',
|
||||
|
|
@ -1194,8 +1180,6 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
|
|||
updateLLMSettings,
|
||||
isSettingsPanelOpen,
|
||||
setSettingsPanelOpen,
|
||||
isHelpDialogBoxOpen,
|
||||
setHelpDialogBoxOpen,
|
||||
isAgentReady,
|
||||
isAgentInitializing,
|
||||
agentError,
|
||||
|
|
|
|||
|
|
@ -6,16 +6,13 @@ import {
|
|||
getBackendUrl,
|
||||
type BackendRepo,
|
||||
} from '../services/backend';
|
||||
import { BACKEND_URL_DEBOUNCE_MS, DEFAULT_BACKEND_URL } from '../config/ui-constants';
|
||||
|
||||
// ── localStorage keys ────────────────────────────────────────────────────────
|
||||
|
||||
const LS_URL_KEY = 'gitnexus-backend-url';
|
||||
const LS_REPO_KEY = 'gitnexus-backend-repo';
|
||||
const DEFAULT_URL = 'http://localhost:4747';
|
||||
|
||||
// ── Debounce delay ───────────────────────────────────────────────────────────
|
||||
|
||||
const DEBOUNCE_MS = 500;
|
||||
const DEFAULT_URL = DEFAULT_BACKEND_URL;
|
||||
|
||||
// ── Public interface ─────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -90,7 +87,10 @@ export function useBackend(): UseBackendResult {
|
|||
// Re-check: still the latest probe?
|
||||
if (id !== probeIdRef.current) return false;
|
||||
setRepos(repoList);
|
||||
} catch {
|
||||
} catch (err) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn('Failed to fetch repos:', err);
|
||||
}
|
||||
if (id === probeIdRef.current) {
|
||||
setRepos([]);
|
||||
}
|
||||
|
|
@ -133,7 +133,7 @@ export function useBackend(): UseBackendResult {
|
|||
debounceRef.current = setTimeout(() => {
|
||||
debounceRef.current = null;
|
||||
void probe();
|
||||
}, DEBOUNCE_MS);
|
||||
}, BACKEND_URL_DEBOUNCE_MS);
|
||||
},
|
||||
[probe],
|
||||
);
|
||||
|
|
|
|||
7
gitnexus-web/src/lib/grounding-patterns.ts
Normal file
7
gitnexus-web/src/lib/grounding-patterns.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
// Shared regex patterns for grounding references in chat/markdown.
|
||||
// Pattern 1: File refs - [[path/file.ext]] or [[path/file.ext:line]] or [[path/file.ext:line-line]]
|
||||
// Line numbers are optional.
|
||||
export const FILE_REF_REGEX = /\[\[([a-zA-Z0-9_\-./\\]+\.[a-zA-Z0-9]+)(?::(\d+)(?:[-–](\d+))?)?\]\]/g;
|
||||
|
||||
// Pattern 2: Node refs - [[Type:Name]] or [[graph:Type:Name]]
|
||||
export const NODE_REF_REGEX = /\[\[(?:graph:)?(Class|Function|Method|Interface|File|Folder|Variable|Enum|Type|CodeElement):([^\]]+)\]\]/g;
|
||||
73
gitnexus-web/src/lib/lucide-icons.tsx
Normal file
73
gitnexus-web/src/lib/lucide-icons.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
/**
|
||||
* Centralized icon re-exports from lucide-react.
|
||||
*
|
||||
* All components import icons from this module (@/lib/lucide-icons) rather
|
||||
* than directly from lucide-react. This provides a single place to manage
|
||||
* which icons are used and allows future optimization (e.g., tree-shaking
|
||||
* configuration, icon subset bundling) without touching every component.
|
||||
*/
|
||||
export {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
ArrowRight,
|
||||
Brain,
|
||||
Box,
|
||||
Braces,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
ChevronUp,
|
||||
Code,
|
||||
Copy,
|
||||
Eye,
|
||||
EyeOff,
|
||||
FileArchive,
|
||||
FileCode,
|
||||
Filter,
|
||||
FlaskConical,
|
||||
Focus,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
GitBranch,
|
||||
Github,
|
||||
Globe,
|
||||
Hash,
|
||||
Heart,
|
||||
HelpCircle,
|
||||
Home,
|
||||
Key,
|
||||
Layers,
|
||||
Lightbulb,
|
||||
LightbulbOff,
|
||||
Loader2,
|
||||
Maximize2,
|
||||
MousePointerClick,
|
||||
PanelLeft,
|
||||
PanelLeftClose,
|
||||
PanelRightClose,
|
||||
Pause,
|
||||
Play,
|
||||
RefreshCw,
|
||||
Rocket,
|
||||
RotateCcw,
|
||||
Search,
|
||||
Send,
|
||||
Server,
|
||||
Settings,
|
||||
SkipForward,
|
||||
Snail,
|
||||
Sparkles,
|
||||
Square,
|
||||
Star,
|
||||
Table,
|
||||
Target,
|
||||
Terminal,
|
||||
Trash2,
|
||||
Upload,
|
||||
User,
|
||||
Variable,
|
||||
X,
|
||||
Zap,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
} from 'lucide-react';
|
||||
45
gitnexus-web/src/lib/path-resolution.ts
Normal file
45
gitnexus-web/src/lib/path-resolution.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
// Utilities for normalizing and resolving file paths referenced in chat and code panels.
|
||||
export const normalizePath = (p: string): string => {
|
||||
return p.replace(/\\/g, '/').replace(/^\.?\//, '');
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve a user-supplied path (which may be partial) to an exact file path in the repo.
|
||||
* Follows the same heuristics previously embedded in useAppState:
|
||||
* 1) exact match, 2) ends-with match (prefers shorter paths), 3) segment containment.
|
||||
*/
|
||||
export const resolveFilePath = (fileContents: Map<string, string>, requestedPath: string): string | null => {
|
||||
const req = normalizePath(requestedPath).toLowerCase();
|
||||
if (!req) return null;
|
||||
|
||||
// Exact match first
|
||||
for (const key of fileContents.keys()) {
|
||||
if (normalizePath(key).toLowerCase() === req) return key;
|
||||
}
|
||||
|
||||
// Ends-with match (best for partial paths like "src/foo.ts")
|
||||
let best: { path: string; score: number } | null = null;
|
||||
for (const key of fileContents.keys()) {
|
||||
const norm = normalizePath(key).toLowerCase();
|
||||
if (norm.endsWith(req)) {
|
||||
const score = 1000 - norm.length; // shorter is better
|
||||
if (!best || score > best.score) best = { path: key, score };
|
||||
}
|
||||
}
|
||||
if (best) return best.path;
|
||||
|
||||
// Segment match fallback
|
||||
const segs = req.split('/').filter(Boolean);
|
||||
for (const key of fileContents.keys()) {
|
||||
const normSegs = normalizePath(key).toLowerCase().split('/').filter(Boolean);
|
||||
let idx = 0;
|
||||
for (const s of segs) {
|
||||
const found = normSegs.findIndex((x, i) => i >= idx && x.includes(s));
|
||||
if (found === -1) { idx = -1; break; }
|
||||
idx = found + 1;
|
||||
}
|
||||
if (idx !== -1) return key;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
74
gitnexus-web/test/unit/perf-optimizations.test.ts
Normal file
74
gitnexus-web/test/unit/perf-optimizations.test.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
// ==========================================================================
|
||||
// PR4 Performance Optimizations — verify behavior preserved after changes
|
||||
// Tests the pure functions underlying the O(1) lookup optimizations.
|
||||
// ==========================================================================
|
||||
|
||||
describe('nodeById Map — O(1) lookup correctness', () => {
|
||||
// Positive: Map.get returns correct node
|
||||
it('Map provides O(1) lookup by ID', () => {
|
||||
const nodes = [
|
||||
{ id: 'Function:a.ts:foo', label: 'Function', name: 'foo' },
|
||||
{ id: 'Class:b.ts:Bar', label: 'Class', name: 'Bar' },
|
||||
{ id: 'File:c.ts', label: 'File', name: 'c.ts' },
|
||||
];
|
||||
const nodeById = new Map(nodes.map(n => [n.id, n]));
|
||||
|
||||
expect(nodeById.get('Function:a.ts:foo')?.name).toBe('foo');
|
||||
expect(nodeById.get('Class:b.ts:Bar')?.name).toBe('Bar');
|
||||
expect(nodeById.get('File:c.ts')?.label).toBe('File');
|
||||
});
|
||||
|
||||
// Positive: handles duplicate IDs (last wins)
|
||||
it('last node wins on duplicate IDs', () => {
|
||||
const nodes = [
|
||||
{ id: 'File:a.ts', label: 'File', name: 'first' },
|
||||
{ id: 'File:a.ts', label: 'File', name: 'second' },
|
||||
];
|
||||
const nodeById = new Map(nodes.map(n => [n.id, n]));
|
||||
|
||||
expect(nodeById.get('File:a.ts')?.name).toBe('second');
|
||||
expect(nodeById.size).toBe(1);
|
||||
});
|
||||
|
||||
// Negative: missing ID returns undefined
|
||||
it('returns undefined for non-existent ID', () => {
|
||||
const nodeById = new Map([['File:a.ts', { id: 'File:a.ts' }]]);
|
||||
expect(nodeById.get('NonExistent:x')).toBeUndefined();
|
||||
});
|
||||
|
||||
// Negative: empty map
|
||||
it('empty Map returns undefined for any key', () => {
|
||||
const nodeById = new Map<string, any>();
|
||||
expect(nodeById.get('anything')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Set.has — O(1) highlight matching', () => {
|
||||
// Positive: exact match
|
||||
it('Set.has returns true for present IDs', () => {
|
||||
const idSet = new Set(['Function:a.ts:foo', 'Class:b.ts:Bar']);
|
||||
expect(idSet.has('Function:a.ts:foo')).toBe(true);
|
||||
expect(idSet.has('Class:b.ts:Bar')).toBe(true);
|
||||
});
|
||||
|
||||
// Negative: missing ID
|
||||
it('Set.has returns false for absent IDs', () => {
|
||||
const idSet = new Set(['Function:a.ts:foo']);
|
||||
expect(idSet.has('Function:a.ts:bar')).toBe(false);
|
||||
expect(idSet.has('')).toBe(false);
|
||||
});
|
||||
|
||||
// Positive: works with graph node IDs containing special chars
|
||||
it('handles IDs with colons, dots, and slashes', () => {
|
||||
const idSet = new Set(['Function:src/utils/path-resolver.ts:resolveFile']);
|
||||
expect(idSet.has('Function:src/utils/path-resolver.ts:resolveFile')).toBe(true);
|
||||
});
|
||||
|
||||
// Negative: case sensitive
|
||||
it('is case-sensitive', () => {
|
||||
const idSet = new Set(['Function:a.ts:Foo']);
|
||||
expect(idSet.has('Function:a.ts:foo')).toBe(false);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue