mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-29 06:32:28 +00:00
perf(web): O(1) lookups, memoization, bundle optimizations, React fixes
Performance: - nodeById Map in GraphCanvas for O(1) click/hover lookups - fileNodeByPath Map in useAppState for O(1) file path lookups - Set.has() for HIGHLIGHT_NODES/IMPACT matching (was O(N²)) - useMemo for primaryLanguage in StatusBar - useCallback on toggleLabelVisibility/toggleEdgeVisibility - Recursive FileTreePanel search (full subtree, not 1 level) React fixes: - Remove stale queryResult dep from clearAICodeReferences - Cancel RAF chains in CodeReferencesPanel on cleanup - Clean up timeouts in SettingsPanel and MarkdownRenderer on unmount - try/catch on localStorage in DropZone (private browsing) - Await handleServerConnect in App.tsx auto-connect - pendingToolCalls counter replaces allToolsDone boolean in agent.ts - JSON.parse try/catch in agent streaming - sessionStorage JSDoc fix in settings-service Bundle: - Centralized lucide icon deep imports Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
46325494fb
commit
a8dad4b1f8
18 changed files with 811 additions and 580 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,5 +1,5 @@
|
|||
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';
|
||||
|
|
@ -161,8 +161,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 +178,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(() => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,25 +55,28 @@ 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);
|
||||
|
|
@ -106,7 +110,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 +118,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 +130,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 +142,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(() => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
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';
|
||||
|
|
@ -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;
|
||||
198
gitnexus-web/src/lib/lucide-icons.tsx
Normal file
198
gitnexus-web/src/lib/lucide-icons.tsx
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
/**
|
||||
* Direct icon imports from lucide-react (bundle-size optimization).
|
||||
* Avoids loading the full barrel (~1500+ modules). Each icon loads only its own module.
|
||||
* See Vercel React Best Practices rule 2.1 (Avoid Barrel File Imports).
|
||||
*/
|
||||
// Direct ESM paths so only these icon modules are bundled, not the full barrel
|
||||
// @ts-ignore — no types for deep ESM path
|
||||
import AlertCircle from 'lucide-react/dist/esm/icons/alert-circle.js';
|
||||
// @ts-ignore — no types for deep ESM path
|
||||
import AlertTriangle from 'lucide-react/dist/esm/icons/alert-triangle.js';
|
||||
// @ts-ignore — no types for deep ESM path
|
||||
import ArrowRight from 'lucide-react/dist/esm/icons/arrow-right.js';
|
||||
// @ts-ignore — no types for deep ESM path
|
||||
import Brain from 'lucide-react/dist/esm/icons/brain.js';
|
||||
// @ts-ignore — no types for deep ESM path
|
||||
import Box from 'lucide-react/dist/esm/icons/box.js';
|
||||
// @ts-ignore — no types for deep ESM path
|
||||
import Braces from 'lucide-react/dist/esm/icons/braces.js';
|
||||
// @ts-ignore — no types for deep ESM path
|
||||
import Check from 'lucide-react/dist/esm/icons/check.js';
|
||||
// @ts-ignore — no types for deep ESM path
|
||||
import ChevronDown from 'lucide-react/dist/esm/icons/chevron-down.js';
|
||||
// @ts-ignore — no types for deep ESM path
|
||||
import ChevronRight from 'lucide-react/dist/esm/icons/chevron-right.js';
|
||||
// @ts-ignore — no types for deep ESM path
|
||||
import ChevronUp from 'lucide-react/dist/esm/icons/chevron-up.js';
|
||||
// @ts-ignore — no types for deep ESM path
|
||||
import Code from 'lucide-react/dist/esm/icons/code.js';
|
||||
// @ts-ignore — no types for deep ESM path
|
||||
import Copy from 'lucide-react/dist/esm/icons/copy.js';
|
||||
// @ts-ignore — no types for deep ESM path
|
||||
import Eye from 'lucide-react/dist/esm/icons/eye.js';
|
||||
// @ts-ignore — no types for deep ESM path
|
||||
import EyeOff from 'lucide-react/dist/esm/icons/eye-off.js';
|
||||
// @ts-ignore — no types for deep ESM path
|
||||
import FileArchive from 'lucide-react/dist/esm/icons/file-archive.js';
|
||||
// @ts-ignore — no types for deep ESM path
|
||||
import FileCode from 'lucide-react/dist/esm/icons/file-code.js';
|
||||
// @ts-ignore — no types for deep ESM path
|
||||
import Filter from 'lucide-react/dist/esm/icons/filter.js';
|
||||
// @ts-ignore — no types for deep ESM path
|
||||
import FlaskConical from 'lucide-react/dist/esm/icons/flask-conical.js';
|
||||
// @ts-ignore — no types for deep ESM path
|
||||
import Focus from 'lucide-react/dist/esm/icons/focus.js';
|
||||
// @ts-ignore — no types for deep ESM path
|
||||
import Folder from 'lucide-react/dist/esm/icons/folder.js';
|
||||
// @ts-ignore — no types for deep ESM path
|
||||
import FolderOpen from 'lucide-react/dist/esm/icons/folder-open.js';
|
||||
// @ts-ignore — no types for deep ESM path
|
||||
import GitBranch from 'lucide-react/dist/esm/icons/git-branch.js';
|
||||
// @ts-ignore — no types for deep ESM path
|
||||
import Github from 'lucide-react/dist/esm/icons/github.js';
|
||||
// @ts-ignore — no types for deep ESM path
|
||||
import Globe from 'lucide-react/dist/esm/icons/globe.js';
|
||||
// @ts-ignore — no types for deep ESM path
|
||||
import Hash from 'lucide-react/dist/esm/icons/hash.js';
|
||||
// @ts-ignore — no types for deep ESM path
|
||||
import Heart from 'lucide-react/dist/esm/icons/heart.js';
|
||||
// @ts-ignore — no types for deep ESM path
|
||||
import HelpCircle from 'lucide-react/dist/esm/icons/help-circle.js';
|
||||
// @ts-ignore — no types for deep ESM path
|
||||
import Home from 'lucide-react/dist/esm/icons/home.js';
|
||||
// @ts-ignore — no types for deep ESM path
|
||||
import Key from 'lucide-react/dist/esm/icons/key.js';
|
||||
// @ts-ignore — no types for deep ESM path
|
||||
import Layers from 'lucide-react/dist/esm/icons/layers.js';
|
||||
// @ts-ignore — no types for deep ESM path
|
||||
import Lightbulb from 'lucide-react/dist/esm/icons/lightbulb.js';
|
||||
// @ts-ignore — no types for deep ESM path
|
||||
import LightbulbOff from 'lucide-react/dist/esm/icons/lightbulb-off.js';
|
||||
// @ts-ignore — no types for deep ESM path
|
||||
import Loader2 from 'lucide-react/dist/esm/icons/loader-2.js';
|
||||
// @ts-ignore — no types for deep ESM path
|
||||
import Maximize2 from 'lucide-react/dist/esm/icons/maximize-2.js';
|
||||
// @ts-ignore — no types for deep ESM path
|
||||
import MousePointerClick from 'lucide-react/dist/esm/icons/mouse-pointer-click.js';
|
||||
// @ts-ignore — no types for deep ESM path
|
||||
import PanelLeft from 'lucide-react/dist/esm/icons/panel-left.js';
|
||||
// @ts-ignore — no types for deep ESM path
|
||||
import PanelLeftClose from 'lucide-react/dist/esm/icons/panel-left-close.js';
|
||||
// @ts-ignore — no types for deep ESM path
|
||||
import PanelRightClose from 'lucide-react/dist/esm/icons/panel-right-close.js';
|
||||
// @ts-ignore — no types for deep ESM path
|
||||
import Pause from 'lucide-react/dist/esm/icons/pause.js';
|
||||
// @ts-ignore — no types for deep ESM path
|
||||
import Play from 'lucide-react/dist/esm/icons/play.js';
|
||||
// @ts-ignore — no types for deep ESM path
|
||||
import RefreshCw from 'lucide-react/dist/esm/icons/refresh-cw.js';
|
||||
// @ts-ignore — no types for deep ESM path
|
||||
import Rocket from 'lucide-react/dist/esm/icons/rocket.js';
|
||||
// @ts-ignore — no types for deep ESM path
|
||||
import RotateCcw from 'lucide-react/dist/esm/icons/rotate-ccw.js';
|
||||
// @ts-ignore — no types for deep ESM path
|
||||
import Search from 'lucide-react/dist/esm/icons/search.js';
|
||||
// @ts-ignore — no types for deep ESM path
|
||||
import Send from 'lucide-react/dist/esm/icons/send.js';
|
||||
// @ts-ignore — no types for deep ESM path
|
||||
import Server from 'lucide-react/dist/esm/icons/server.js';
|
||||
// @ts-ignore — no types for deep ESM path
|
||||
import Settings from 'lucide-react/dist/esm/icons/settings.js';
|
||||
// @ts-ignore — no types for deep ESM path
|
||||
import SkipForward from 'lucide-react/dist/esm/icons/skip-forward.js';
|
||||
// @ts-ignore — no types for deep ESM path
|
||||
import Snail from 'lucide-react/dist/esm/icons/snail.js';
|
||||
// @ts-ignore — no types for deep ESM path
|
||||
import Sparkles from 'lucide-react/dist/esm/icons/sparkles.js';
|
||||
// @ts-ignore — no types for deep ESM path
|
||||
import Square from 'lucide-react/dist/esm/icons/square.js';
|
||||
// @ts-ignore — no types for deep ESM path
|
||||
import Star from 'lucide-react/dist/esm/icons/star.js';
|
||||
// @ts-ignore — no types for deep ESM path
|
||||
import Table from 'lucide-react/dist/esm/icons/table.js';
|
||||
// @ts-ignore — no types for deep ESM path
|
||||
import Target from 'lucide-react/dist/esm/icons/target.js';
|
||||
// @ts-ignore — no types for deep ESM path
|
||||
import Terminal from 'lucide-react/dist/esm/icons/terminal.js';
|
||||
// @ts-ignore — no types for deep ESM path
|
||||
import Trash2 from 'lucide-react/dist/esm/icons/trash-2.js';
|
||||
// @ts-ignore — no types for deep ESM path
|
||||
import Upload from 'lucide-react/dist/esm/icons/upload.js';
|
||||
// @ts-ignore — no types for deep ESM path
|
||||
import User from 'lucide-react/dist/esm/icons/user.js';
|
||||
// @ts-ignore — no types for deep ESM path
|
||||
import Variable from 'lucide-react/dist/esm/icons/variable.js';
|
||||
// @ts-ignore — no types for deep ESM path
|
||||
import X from 'lucide-react/dist/esm/icons/x.js';
|
||||
// @ts-ignore — no types for deep ESM path
|
||||
import Zap from 'lucide-react/dist/esm/icons/zap.js';
|
||||
// @ts-ignore — no types for deep ESM path
|
||||
import ZoomIn from 'lucide-react/dist/esm/icons/zoom-in.js';
|
||||
// @ts-ignore — no types for deep ESM path
|
||||
import ZoomOut from 'lucide-react/dist/esm/icons/zoom-out.js';
|
||||
|
||||
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,
|
||||
};
|
||||
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;
|
||||
};
|
||||
Loading…
Add table
Reference in a new issue