diff --git a/ccw/frontend/src/components/codexlens/EnvSettingsTab.tsx b/ccw/frontend/src/components/codexlens/EnvSettingsTab.tsx new file mode 100644 index 00000000..0d718485 --- /dev/null +++ b/ccw/frontend/src/components/codexlens/EnvSettingsTab.tsx @@ -0,0 +1,208 @@ +// ======================================== +// EnvSettingsTab Component +// ======================================== +// Grouped env var form with save/reset actions + +import { useState, useEffect } from 'react'; +import { useIntl } from 'react-intl'; +import { Eye, EyeOff, Save, RotateCcw } from 'lucide-react'; +import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card'; +import { Button } from '@/components/ui/Button'; +import { Input } from '@/components/ui/Input'; +import { useCodexLensEnv, useSaveCodexLensEnv } from '@/hooks/useCodexLens'; + +// ======================================== +// ENV group definitions +// ======================================== + +interface EnvField { + key: string; + label: string; + sensitive?: boolean; +} + +interface EnvGroup { + title: string; + fields: EnvField[]; +} + +const ENV_GROUPS: EnvGroup[] = [ + { + title: 'embed', + fields: [ + { key: 'CODEXLENS_EMBED_API_URL', label: 'Embed API URL' }, + { key: 'CODEXLENS_EMBED_API_KEY', label: 'Embed API Key', sensitive: true }, + { key: 'CODEXLENS_EMBED_API_MODEL', label: 'Embed API Model' }, + { key: 'CODEXLENS_EMBED_DIM', label: 'Embed Dimension' }, + { key: 'CODEXLENS_EMBED_API_ENDPOINTS', label: 'Embed API Endpoints', sensitive: true }, + { key: 'CODEXLENS_EMBED_BATCH_SIZE', label: 'Embed Batch Size' }, + { key: 'CODEXLENS_EMBED_API_CONCURRENCY', label: 'Embed API Concurrency' }, + ], + }, + { + title: 'reranker', + fields: [ + { key: 'CODEXLENS_RERANKER_API_URL', label: 'Reranker API URL' }, + { key: 'CODEXLENS_RERANKER_API_KEY', label: 'Reranker API Key', sensitive: true }, + { key: 'CODEXLENS_RERANKER_API_MODEL', label: 'Reranker API Model' }, + ], + }, + { + title: 'performance', + fields: [ + { key: 'CODEXLENS_BINARY_TOP_K', label: 'Binary Top K' }, + { key: 'CODEXLENS_ANN_TOP_K', label: 'ANN Top K' }, + { key: 'CODEXLENS_FTS_TOP_K', label: 'FTS Top K' }, + { key: 'CODEXLENS_FUSION_K', label: 'Fusion K' }, + { key: 'CODEXLENS_RERANKER_TOP_K', label: 'Reranker Top K' }, + { key: 'CODEXLENS_RERANKER_BATCH_SIZE', label: 'Reranker Batch Size' }, + ], + }, + { + title: 'index', + fields: [ + { key: 'CODEXLENS_DB_PATH', label: 'DB Path' }, + { key: 'CODEXLENS_INDEX_WORKERS', label: 'Index Workers' }, + { key: 'CODEXLENS_CODE_AWARE_CHUNKING', label: 'Code Aware Chunking' }, + { key: 'CODEXLENS_MAX_FILE_SIZE', label: 'Max File Size' }, + { key: 'CODEXLENS_HNSW_EF', label: 'HNSW EF' }, + { key: 'CODEXLENS_HNSW_M', label: 'HNSW M' }, + ], + }, +]; + +// Collect all keys +const ALL_KEYS = ENV_GROUPS.flatMap((g) => g.fields.map((f) => f.key)); + +function buildEmptyEnv(): Record { + return Object.fromEntries(ALL_KEYS.map((k) => [k, ''])); +} + +// ======================================== +// Sensitive field with show/hide toggle +// ======================================== + +interface SensitiveInputProps { + value: string; + onChange: (v: string) => void; + id: string; +} + +function SensitiveInput({ value, onChange, id }: SensitiveInputProps) { + const [show, setShow] = useState(false); + return ( +
+ onChange(e.target.value)} + className="pr-10" + /> + +
+ ); +} + +// ======================================== +// Main component +// ======================================== + +export function EnvSettingsTab() { + const { formatMessage } = useIntl(); + const { data: serverEnv, isLoading } = useCodexLensEnv(); + const { saveEnv, isSaving } = useSaveCodexLensEnv(); + + const [localEnv, setLocalEnv] = useState>(buildEmptyEnv); + + // Sync server state into local when loaded + useEffect(() => { + if (serverEnv) { + setLocalEnv((prev) => { + const next = { ...prev }; + ALL_KEYS.forEach((k) => { + next[k] = serverEnv[k] ?? ''; + }); + return next; + }); + } + }, [serverEnv]); + + const serverRecord = serverEnv ?? {}; + + const isDirty = ALL_KEYS.some((k) => localEnv[k] !== (serverRecord[k] ?? '')); + + const handleChange = (key: string, value: string) => { + setLocalEnv((prev) => ({ ...prev, [key]: value })); + }; + + const handleSave = async () => { + await saveEnv(localEnv); + }; + + const handleReset = () => { + setLocalEnv(buildEmptyEnv()); + }; + + if (isLoading) { + return

{formatMessage({ id: 'codexlens.env.loading' })}

; + } + + return ( +
+ {ENV_GROUPS.map((group) => ( + + + {formatMessage({ id: `codexlens.env.sections.${group.title}` })} + + + {group.fields.map((field) => ( +
+ +
+ {field.sensitive ? ( + handleChange(field.key, v)} + /> + ) : ( + handleChange(field.key, e.target.value)} + /> + )} +
+
+ ))} +
+
+ ))} + + {/* Action buttons */} +
+ + +
+
+ ); +} diff --git a/ccw/frontend/src/components/codexlens/IndexManagerTab.tsx b/ccw/frontend/src/components/codexlens/IndexManagerTab.tsx new file mode 100644 index 00000000..c984aec2 --- /dev/null +++ b/ccw/frontend/src/components/codexlens/IndexManagerTab.tsx @@ -0,0 +1,162 @@ +// ======================================== +// IndexManagerTab Component +// ======================================== +// Project path input, index status display, and sync/rebuild actions + +import { useState } from 'react'; +import { useIntl } from 'react-intl'; +import { Plus, Trash2, RefreshCw, Loader2 } from 'lucide-react'; +import { useQueryClient } from '@tanstack/react-query'; +import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card'; +import { Button } from '@/components/ui/Button'; +import { Input } from '@/components/ui/Input'; +import { useIndexStatus, useSyncIndex, useRebuildIndex, codexLensKeys, type IndexStatusData } from '@/hooks/useCodexLens'; + +interface ProjectStatusCardProps { + projectPath: string; +} + +function ProjectStatusCard({ projectPath }: ProjectStatusCardProps) { + const { formatMessage } = useIntl(); + const queryClient = useQueryClient(); + const { data: statusData, isLoading, isError } = useIndexStatus(projectPath); + const { syncIndex, isSyncing } = useSyncIndex(); + const { rebuildIndex, isRebuilding } = useRebuildIndex(); + + const status: IndexStatusData | undefined = statusData; + + const handleRefresh = () => { + queryClient.invalidateQueries({ queryKey: codexLensKeys.indexStatus(projectPath) }); + }; + + return ( + + + {projectPath} + + + {/* Status stats */} + {isLoading && ( +
+ + {formatMessage({ id: 'codexlens.index.loading' })} +
+ )} + {isError && ( +

{formatMessage({ id: 'codexlens.index.error' })}

+ )} + {!isLoading && !isError && status && ( +
+
+

{status.files_tracked ?? 0}

+

{formatMessage({ id: 'codexlens.index.filesTracked' })}

+
+
+

{status.total_chunks ?? 0}

+

{formatMessage({ id: 'codexlens.index.totalChunks' })}

+
+
+

{status.deleted_chunks ?? 0}

+

{formatMessage({ id: 'codexlens.index.deletedChunks' })}

+
+
+ )} + + {/* Action buttons */} +
+ + + +
+
+
+ ); +} + +export function IndexManagerTab() { + const { formatMessage } = useIntl(); + const [paths, setPaths] = useState([]); + const [inputValue, setInputValue] = useState(''); + + const handleAdd = () => { + const trimmed = inputValue.trim(); + if (trimmed && !paths.includes(trimmed)) { + setPaths((prev) => [...prev, trimmed]); + } + setInputValue(''); + }; + + const handleRemove = (path: string) => { + setPaths((prev) => prev.filter((p) => p !== path)); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleAdd(); + } + }; + + return ( +
+ {/* Add project path */} +
+ setInputValue(e.target.value)} + onKeyDown={handleKeyDown} + className="flex-1" + /> + +
+ + {paths.length === 0 && ( +

+ {formatMessage({ id: 'codexlens.index.empty' })} +

+ )} + + {/* Project status cards */} + {paths.map((path) => ( +
+ + +
+ ))} +
+ ); +} diff --git a/ccw/frontend/src/components/codexlens/McpConfigTab.tsx b/ccw/frontend/src/components/codexlens/McpConfigTab.tsx new file mode 100644 index 00000000..206d0493 --- /dev/null +++ b/ccw/frontend/src/components/codexlens/McpConfigTab.tsx @@ -0,0 +1,105 @@ +// ======================================== +// McpConfigTab Component +// ======================================== +// Read-only MCP config JSON display with copy-to-clipboard and regenerate + +import { useState } from 'react'; +import { useIntl } from 'react-intl'; +import { Copy, RefreshCw, Check } from 'lucide-react'; +import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card'; +import { Button } from '@/components/ui/Button'; +import { Badge } from '@/components/ui/Badge'; +import { useCodexLensMcpConfig, useCodexLensEnv } from '@/hooks/useCodexLens'; + +export function McpConfigTab() { + const { formatMessage } = useIntl(); + const { data: mcpConfig, isLoading, isError, refetch } = useCodexLensMcpConfig(); + const { data: envData } = useCodexLensEnv(); + const [copied, setCopied] = useState(false); + + const hasApiUrl = !!(envData?.CODEXLENS_EMBED_API_URL); + const embedMode = hasApiUrl ? 'API' : 'Local fastembed'; + + const configJson = mcpConfig ? JSON.stringify(mcpConfig, null, 2) : ''; + + const handleCopy = () => { + navigator.clipboard.writeText(configJson).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }); + }; + + const handleRegenerate = () => { + refetch(); + }; + + return ( +
+ {/* Embed mode badge */} +
+ {formatMessage({ id: 'codexlens.mcp.embedMode' })}: + + {embedMode} + +
+ + {/* Config JSON block */} + + +
+ {formatMessage({ id: 'codexlens.mcp.configTitle' })} +
+ + +
+
+
+ + {isLoading && ( +

{formatMessage({ id: 'codexlens.mcp.loading' })}

+ )} + {isError && ( +

{formatMessage({ id: 'codexlens.mcp.error' })}

+ )} + {!isLoading && !isError && ( +
+              {configJson || formatMessage({ id: 'codexlens.mcp.noConfig' })}
+            
+ )} +
+
+ + {/* Installation instructions */} + + + {formatMessage({ id: 'codexlens.mcp.installTitle' })} + + +
    +
  1. {formatMessage({ id: 'codexlens.mcp.installSteps.step1' })}
  2. +
  3. {formatMessage({ id: 'codexlens.mcp.installSteps.step2' })}
  4. +
  5. {formatMessage({ id: 'codexlens.mcp.installSteps.step3' })}
  6. +
  7. {formatMessage({ id: 'codexlens.mcp.installSteps.step4' })}
  8. +
  9. {formatMessage({ id: 'codexlens.mcp.installSteps.step5' })}
  10. +
+
+
+
+ ); +} diff --git a/ccw/frontend/src/components/codexlens/ModelManagerTab.tsx b/ccw/frontend/src/components/codexlens/ModelManagerTab.tsx new file mode 100644 index 00000000..e079a639 --- /dev/null +++ b/ccw/frontend/src/components/codexlens/ModelManagerTab.tsx @@ -0,0 +1,109 @@ +// ======================================== +// ModelManagerTab Component +// ======================================== +// Model list with download/delete actions and embed mode banner + +import { useIntl } from 'react-intl'; +import { Download, Trash2, Loader2 } from 'lucide-react'; +import { Card, CardContent } from '@/components/ui/Card'; +import { Button } from '@/components/ui/Button'; +import { Badge } from '@/components/ui/Badge'; +import { + useCodexLensModels, + useDownloadModel, + useDeleteModel, + useCodexLensEnv, + type ModelEntry, +} from '@/hooks/useCodexLens'; + +export function ModelManagerTab() { + const { formatMessage } = useIntl(); + const { data: modelsData, isLoading, isError } = useCodexLensModels(); + const { data: envData } = useCodexLensEnv(); + const { downloadModel, isDownloading } = useDownloadModel(); + const { deleteModel, isDeleting } = useDeleteModel(); + + const hasApiUrl = !!(envData?.CODEXLENS_EMBED_API_URL); + const embedMode = hasApiUrl ? 'API' : 'Local fastembed'; + + const models: ModelEntry[] = modelsData ?? []; + + return ( +
+ {/* Embed mode banner */} +
+ {formatMessage({ id: 'codexlens.models.embedMode' })}: + {embedMode} +
+ + {/* Loading / Error states */} + {isLoading && ( +
+ + {formatMessage({ id: 'codexlens.models.loading' })} +
+ )} + + {isError && ( +

{formatMessage({ id: 'codexlens.models.error' })}

+ )} + + {/* Model list */} + {!isLoading && !isError && models.length === 0 && ( +

{formatMessage({ id: 'codexlens.models.noModels' })}

+ )} + + {!isLoading && !isError && models.length > 0 && ( + + +
+ {models.map((model) => ( +
+
+ {model.name} + + {model.installed + ? formatMessage({ id: 'codexlens.models.installed' }) + : formatMessage({ id: 'codexlens.models.notInstalled' })} + +
+
+ + +
+
+ ))} +
+
+
+ )} +
+ ); +} diff --git a/ccw/frontend/src/components/layout/Sidebar.tsx b/ccw/frontend/src/components/layout/Sidebar.tsx index 4b3b5914..c934e9d7 100644 --- a/ccw/frontend/src/components/layout/Sidebar.tsx +++ b/ccw/frontend/src/components/layout/Sidebar.tsx @@ -29,6 +29,7 @@ import { ScrollText, Clock, BookOpen, + Search, } from 'lucide-react'; import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/Button'; @@ -107,6 +108,7 @@ const navGroupDefinitions: NavGroupDef[] = [ { path: '/hooks', labelKey: 'navigation.main.hooks', icon: GitFork }, { path: '/settings/mcp', labelKey: 'navigation.main.mcp', icon: Server }, { path: '/settings/specs', labelKey: 'navigation.main.specs', icon: ScrollText }, + { path: '/codexlens', labelKey: 'navigation.main.codexlens', icon: Search }, ], }, { diff --git a/ccw/frontend/src/components/mcp/RecommendedMcpSection.tsx b/ccw/frontend/src/components/mcp/RecommendedMcpSection.tsx index 58dc87f1..48001dd3 100644 --- a/ccw/frontend/src/components/mcp/RecommendedMcpSection.tsx +++ b/ccw/frontend/src/components/mcp/RecommendedMcpSection.tsx @@ -127,8 +127,9 @@ const RECOMMENDED_MCP_DEFINITIONS: RecommendedMcpDefinition[] = [ }, ], buildConfig: (values) => { - const env = values.apiKey ? { EXA_API_KEY: values.apiKey } : undefined; - return buildCrossPlatformMcpConfig('npx', ['-y', 'exa-mcp-server'], { env }); + const baseUrl = 'https://mcp.exa.ai/mcp'; + const url = values.apiKey ? `${baseUrl}?exaApiKey=${values.apiKey}` : baseUrl; + return buildCrossPlatformMcpConfig('npx', ['-y', 'mcp-remote', url]); }, }, ]; diff --git a/ccw/frontend/src/hooks/useCodexLens.ts b/ccw/frontend/src/hooks/useCodexLens.ts new file mode 100644 index 00000000..3891805b --- /dev/null +++ b/ccw/frontend/src/hooks/useCodexLens.ts @@ -0,0 +1,214 @@ +// ======================================== +// useCodexLens Hook +// ======================================== +// TanStack Query hooks for CodexLens v2 API management + +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; + +// ======================================== +// Domain types (exported for component use) +// ======================================== + +export interface ModelEntry { + name: string; + installed: boolean; + type?: string; + cache_path?: string; +} + +export interface IndexStatusData { + status?: string; + files_tracked?: number; + total_chunks?: number; + deleted_chunks?: number; + db_path?: string; +} + +export type McpConfigData = Record; + +// Internal API response wrappers +interface ModelsResponse { success: boolean; models: ModelEntry[] } +interface IndexStatusResponse { success: boolean; status: IndexStatusData } +interface EnvResponse { success: boolean; env: Record } +interface McpConfigResponse { success: boolean; config: McpConfigData } + +// ======================================== +// Query Key Factory +// ======================================== + +export const codexLensKeys = { + all: ['codexLens'] as const, + models: () => [...codexLensKeys.all, 'models'] as const, + indexStatus: (path: string) => [...codexLensKeys.all, 'indexStatus', path] as const, + env: () => [...codexLensKeys.all, 'env'] as const, + mcpConfig: () => [...codexLensKeys.all, 'mcpConfig'] as const, +}; + +// ======================================== +// Internal fetch helper (mirrors fetchApi pattern from api.ts) +// ======================================== + +async function fetchApi(url: string, options: RequestInit = {}): Promise { + const headers = new Headers(options.headers); + if (options.body && typeof options.body === 'string') { + headers.set('Content-Type', 'application/json'); + } + const response = await fetch(url, { + ...options, + headers, + credentials: 'same-origin', + }); + if (!response.ok) { + const text = await response.text().catch(() => response.statusText); + throw new Error(text || `Request failed: ${response.status}`); + } + return response.json() as Promise; +} + +// ======================================== +// Models Hooks +// ======================================== + +export function useCodexLensModels() { + return useQuery({ + queryKey: codexLensKeys.models(), + queryFn: () => fetchApi('/api/codexlens/models').then(r => r.models), + staleTime: 30_000, + }); +} + +export function useDownloadModel() { + const queryClient = useQueryClient(); + + const mutation = useMutation({ + mutationFn: (modelName: string) => + fetchApi<{ success: boolean }>('/api/codexlens/models/download', { + method: 'POST', + body: JSON.stringify({ name: modelName }), + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: codexLensKeys.models() }); + }, + }); + + return { + downloadModel: mutation.mutateAsync, + isDownloading: mutation.isPending, + error: mutation.error, + }; +} + +export function useDeleteModel() { + const queryClient = useQueryClient(); + + const mutation = useMutation({ + mutationFn: (modelName: string) => + fetchApi<{ success: boolean }>('/api/codexlens/models/delete', { + method: 'POST', + body: JSON.stringify({ name: modelName }), + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: codexLensKeys.models() }); + }, + }); + + return { + deleteModel: mutation.mutateAsync, + isDeleting: mutation.isPending, + error: mutation.error, + }; +} + +// ======================================== +// Index Hooks +// ======================================== + +export function useIndexStatus(projectPath: string) { + return useQuery({ + queryKey: codexLensKeys.indexStatus(projectPath), + queryFn: () => + fetchApi( + `/api/codexlens/index/status?projectPath=${encodeURIComponent(projectPath)}` + ).then(r => r.status), + enabled: !!projectPath, + staleTime: 10_000, + }); +} + +export function useSyncIndex() { + const mutation = useMutation({ + mutationFn: (projectPath: string) => + fetchApi<{ success: boolean }>('/api/codexlens/index/sync', { + method: 'POST', + body: JSON.stringify({ projectPath }), + }), + }); + + return { + syncIndex: mutation.mutateAsync, + isSyncing: mutation.isPending, + error: mutation.error, + }; +} + +export function useRebuildIndex() { + const mutation = useMutation({ + mutationFn: (projectPath: string) => + fetchApi<{ success: boolean }>('/api/codexlens/index/rebuild', { + method: 'POST', + body: JSON.stringify({ projectPath }), + }), + }); + + return { + rebuildIndex: mutation.mutateAsync, + isRebuilding: mutation.isPending, + error: mutation.error, + }; +} + +// ======================================== +// Env Hooks +// ======================================== + +export function useCodexLensEnv() { + return useQuery({ + queryKey: codexLensKeys.env(), + queryFn: () => fetchApi('/api/codexlens/env').then(r => r.env), + staleTime: 60_000, + }); +} + +export function useSaveCodexLensEnv() { + const queryClient = useQueryClient(); + + const mutation = useMutation({ + mutationFn: (env: Record) => + fetchApi<{ success: boolean }>('/api/codexlens/env', { + method: 'POST', + body: JSON.stringify({ env }), + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: codexLensKeys.env() }); + queryClient.invalidateQueries({ queryKey: codexLensKeys.mcpConfig() }); + }, + }); + + return { + saveEnv: mutation.mutateAsync, + isSaving: mutation.isPending, + error: mutation.error, + }; +} + +// ======================================== +// MCP Config Hooks +// ======================================== + +export function useCodexLensMcpConfig() { + return useQuery({ + queryKey: codexLensKeys.mcpConfig(), + queryFn: () => fetchApi('/api/codexlens/mcp-config').then(r => r.config), + staleTime: 30_000, + }); +} diff --git a/ccw/frontend/src/locales/en/codexlens.json b/ccw/frontend/src/locales/en/codexlens.json index c571d606..c44733dc 100644 --- a/ccw/frontend/src/locales/en/codexlens.json +++ b/ccw/frontend/src/locales/en/codexlens.json @@ -1,6 +1,70 @@ { "title": "Search Manager", "description": "V2 semantic search index management", + "page": { + "title": "CodexLens v2", + "description": "Semantic code search engine — manage MCP config, embedding models, index state, and environment settings." + }, + "tabs": { + "mcp": "MCP Config", + "models": "Model Manager", + "index": "Index Manager", + "env": "Env Settings" + }, + "mcp": { + "embedMode": "Embed mode", + "configTitle": "MCP Config JSON", + "copy": "Copy", + "copied": "Copied", + "regenerate": "Regenerate Config", + "loading": "Loading config...", + "error": "Failed to load MCP config.", + "noConfig": "No config available.", + "installTitle": "Installation Instructions", + "installSteps": { + "step1": "Copy the MCP config JSON above using the Copy button.", + "step2": "Open your Claude Desktop or MCP-compatible client configuration file.", + "step3": "Add the copied JSON under the mcpServers key.", + "step4": "Save the configuration file and restart your client.", + "step5": "Verify the CodexLens server appears as an available MCP tool." + } + }, + "models": { + "embedMode": "Current embed mode", + "loading": "Loading models...", + "error": "Failed to load models.", + "noModels": "No models found.", + "installed": "Installed", + "notInstalled": "Not installed", + "download": "Download", + "delete": "Delete" + }, + "index": { + "pathPlaceholder": "Enter project path (e.g. /home/user/myproject)", + "add": "Add", + "loading": "Loading status...", + "error": "Failed to load index status.", + "filesTracked": "Files tracked", + "totalChunks": "Total chunks", + "deletedChunks": "Deleted chunks", + "sync": "Sync", + "rebuild": "Rebuild", + "refresh": "Refresh Status", + "empty": "Enter a project path above to view index status and manage indexing.", + "removeProject": "Remove project" + }, + "env": { + "loading": "Loading environment settings...", + "save": "Save", + "saving": "Saving...", + "clearForm": "Clear Form", + "sections": { + "embed": "Embed Config", + "reranker": "Reranker Config", + "performance": "Performance Tuning", + "index": "Index Params" + } + }, "reindex": "Reindex", "reindexing": "Reindexing...", "statusError": "Failed to load search index status", diff --git a/ccw/frontend/src/locales/en/mcp-manager.json b/ccw/frontend/src/locales/en/mcp-manager.json index 88646d6c..837362bc 100644 --- a/ccw/frontend/src/locales/en/mcp-manager.json +++ b/ccw/frontend/src/locales/en/mcp-manager.json @@ -426,10 +426,10 @@ }, "exa": { "name": "Exa Search", - "desc": "AI-powered web search with real-time crawling capabilities", + "desc": "AI-powered web search via remote MCP server (mcp.exa.ai)", "field": { "apiKey": "API Key", - "apiKey.desc": "Your Exa API key (optional, some features may require it)" + "apiKey.desc": "Your Exa API key (optional, adds to URL to overcome free plan rate limits)" } }, "enterprise": { diff --git a/ccw/frontend/src/locales/zh/codexlens.json b/ccw/frontend/src/locales/zh/codexlens.json index d29dc3c9..19ada54d 100644 --- a/ccw/frontend/src/locales/zh/codexlens.json +++ b/ccw/frontend/src/locales/zh/codexlens.json @@ -4,6 +4,70 @@ "reindex": "重建索引", "reindexing": "重建中...", "statusError": "加载搜索索引状态失败", + "page": { + "title": "CodexLens v2", + "description": "语义代码搜索引擎 — 管理 MCP 配置、嵌入模型、索引状态和环境变量" + }, + "tabs": { + "mcp": "MCP 配置", + "models": "模型管理", + "index": "索引管理", + "env": "环境变量" + }, + "mcp": { + "embedMode": "嵌入模式", + "configTitle": "MCP 配置 JSON", + "copy": "复制", + "copied": "已复制", + "regenerate": "重新生成配置", + "loading": "加载配置中...", + "error": "加载 MCP 配置失败", + "noConfig": "暂无配置", + "installTitle": "安装说明", + "installSteps": { + "step1": "点击复制按钮复制 MCP 配置 JSON。", + "step2": "打开 Claude Desktop 或兼容 MCP 的客户端配置文件。", + "step3": "将复制的 JSON 添加到 mcpServers 键下。", + "step4": "保存配置文件并重启客户端。", + "step5": "验证 CodexLens 服务器是否作为可用 MCP 工具出现。" + } + }, + "models": { + "embedMode": "当前嵌入模式", + "loading": "加载模型中...", + "error": "加载模型失败", + "noModels": "未找到模型", + "installed": "已安装", + "notInstalled": "未安装", + "download": "下载", + "delete": "删除" + }, + "index": { + "pathPlaceholder": "输入项目路径(例如 /home/user/myproject)", + "add": "添加", + "loading": "加载状态中...", + "error": "加载索引状态失败", + "filesTracked": "跟踪文件数", + "totalChunks": "总分块数", + "deletedChunks": "已删除分块", + "sync": "同步", + "rebuild": "重建", + "refresh": "刷新状态", + "empty": "在上方输入项目路径以查看索引状态和管理索引", + "removeProject": "移除项目" + }, + "env": { + "loading": "加载环境设置中...", + "save": "保存", + "saving": "保存中...", + "clearForm": "清空表单", + "sections": { + "embed": "嵌入配置", + "reranker": "重排序配置", + "performance": "性能调优", + "index": "索引参数" + } + }, "indexStatus": { "title": "索引状态", "status": "状态", diff --git a/ccw/frontend/src/locales/zh/mcp-manager.json b/ccw/frontend/src/locales/zh/mcp-manager.json index 311ab20d..fb2554ee 100644 --- a/ccw/frontend/src/locales/zh/mcp-manager.json +++ b/ccw/frontend/src/locales/zh/mcp-manager.json @@ -415,10 +415,10 @@ }, "exa": { "name": "Exa 搜索", - "desc": "AI 驱动的网络搜索,支持实时抓取", + "desc": "通过远程 MCP 服务器 (mcp.exa.ai) 实现 AI 驱动的网络搜索", "field": { "apiKey": "API 密钥", - "apiKey.desc": "您的 Exa API 密钥(可选,部分功能可能需要)" + "apiKey.desc": "您的 Exa API 密钥(可选,填入后将作为 URL 参数传递以突破免费计划限速)" } }, "enterprise": { diff --git a/ccw/frontend/src/pages/CodexLensPage.tsx b/ccw/frontend/src/pages/CodexLensPage.tsx new file mode 100644 index 00000000..2d0165cd --- /dev/null +++ b/ccw/frontend/src/pages/CodexLensPage.tsx @@ -0,0 +1,57 @@ +// ======================================== +// CodexLens Page +// ======================================== +// Main page for CodexLens v2 management: MCP config, models, index, env + +import { useState } from 'react'; +import { useIntl } from 'react-intl'; +import { Search } from 'lucide-react'; +import { TabsNavigation } from '@/components/ui/TabsNavigation'; +import { McpConfigTab } from '@/components/codexlens/McpConfigTab'; +import { ModelManagerTab } from '@/components/codexlens/ModelManagerTab'; +import { IndexManagerTab } from '@/components/codexlens/IndexManagerTab'; +import { EnvSettingsTab } from '@/components/codexlens/EnvSettingsTab'; + +type TabType = 'mcp' | 'models' | 'index' | 'env'; + +export function CodexLensPage() { + const { formatMessage } = useIntl(); + const [activeTab, setActiveTab] = useState('mcp'); + + return ( +
+ {/* Page Header */} +
+

+ + {formatMessage({ id: 'codexlens.page.title' })} +

+

+ {formatMessage({ id: 'codexlens.page.description' })} +

+
+ + {/* Tab Navigation */} + setActiveTab(v as TabType)} + tabs={[ + { value: 'mcp', label: formatMessage({ id: 'codexlens.tabs.mcp' }) }, + { value: 'models', label: formatMessage({ id: 'codexlens.tabs.models' }) }, + { value: 'index', label: formatMessage({ id: 'codexlens.tabs.index' }) }, + { value: 'env', label: formatMessage({ id: 'codexlens.tabs.env' }) }, + ]} + /> + + {/* Tab Content */} +
+ {activeTab === 'mcp' && } + {activeTab === 'models' && } + {activeTab === 'index' && } + {activeTab === 'env' && } +
+
+ ); +} + +export default CodexLensPage; diff --git a/ccw/frontend/src/router.tsx b/ccw/frontend/src/router.tsx index aa7e49aa..d7ed669d 100644 --- a/ccw/frontend/src/router.tsx +++ b/ccw/frontend/src/router.tsx @@ -43,6 +43,7 @@ const TerminalDashboardPage = lazy(() => import('@/pages/TerminalDashboardPage') const AnalysisPage = lazy(() => import('@/pages/AnalysisPage').then(m => ({ default: m.AnalysisPage }))); const SpecsSettingsPage = lazy(() => import('@/pages/SpecsSettingsPage').then(m => ({ default: m.SpecsSettingsPage }))); const DeepWikiPage = lazy(() => import('@/pages/DeepWikiPage').then(m => ({ default: m.DeepWikiPage }))); +const CodexLensPage = lazy(() => import('@/pages/CodexLensPage').then(m => ({ default: m.CodexLensPage }))); /** * Helper to wrap lazy-loaded components with error boundary and suspense @@ -197,6 +198,10 @@ const routes: RouteObject[] = [ path: 'deepwiki', element: withErrorHandling(), }, + { + path: 'codexlens', + element: withErrorHandling(), + }, { path: 'terminal-dashboard', element: withErrorHandling(), @@ -263,6 +268,7 @@ export const ROUTES = { SKILL_HUB: '/skill-hub', ANALYSIS: '/analysis', DEEPWIKI: '/deepwiki', + CODEXLENS: '/codexlens', } as const; export type RoutePath = (typeof ROUTES)[keyof typeof ROUTES]; diff --git a/ccw/src/core/routes/codexlens-routes.ts b/ccw/src/core/routes/codexlens-routes.ts new file mode 100644 index 00000000..661dca53 --- /dev/null +++ b/ccw/src/core/routes/codexlens-routes.ts @@ -0,0 +1,294 @@ +/** + * CodexLens v2 Routes Module + * Handles CodexLens model management, index operations, env config, and MCP config API endpoints + */ + +import { homedir } from 'os'; +import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'; +import { join } from 'path'; +import { spawn } from 'child_process'; + +import type { RouteContext } from './types.js'; + +// ========== HELPERS ========== + +/** + * Spawn a CLI command and collect stdout/stderr + */ +function spawnCli(cmd: string, args: string[]): Promise<{ stdout: string; stderr: string; exitCode: number }> { + return new Promise((resolve, reject) => { + let stdout = ''; + let stderr = ''; + + const child = spawn(cmd, args); + + child.stdout.on('data', (chunk: Buffer) => { + stdout += chunk.toString(); + }); + + child.stderr.on('data', (chunk: Buffer) => { + stderr += chunk.toString(); + }); + + child.on('error', (err: Error) => { + reject(err); + }); + + child.on('close', (code: number | null) => { + resolve({ stdout, stderr, exitCode: code ?? 1 }); + }); + }); +} + +/** + * Return the path to the codexlens env config file + */ +function getEnvFilePath(): string { + return join(homedir(), '.ccw', 'codexlens-env.json'); +} + +/** + * Read the codexlens env config file; returns {} when file does not exist + */ +function readEnvFile(): Record { + const filePath = getEnvFilePath(); + if (!existsSync(filePath)) { + return {}; + } + try { + const content = readFileSync(filePath, 'utf-8'); + return JSON.parse(content) as Record; + } catch { + return {}; + } +} + +/** + * Write the codexlens env config file + */ +function writeEnvFile(env: Record): void { + const filePath = getEnvFilePath(); + const dir = join(homedir(), '.ccw'); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + writeFileSync(filePath, JSON.stringify(env, null, 2), 'utf-8'); +} + +// ========== ROUTE HANDLER ========== + +/** + * Handle CodexLens routes + * @returns true if route was handled, false otherwise + */ +export async function handleCodexLensRoutes(ctx: RouteContext): Promise { + const { pathname, req, res, handlePostRequest, url } = ctx; + + // ========== LIST MODELS ========== + // GET /api/codexlens/models + if (pathname === '/api/codexlens/models' && req.method === 'GET') { + try { + const result = await spawnCli('codexlens-search', ['list-models']); + if (result.exitCode !== 0) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: result.stderr || 'Failed to list models' })); + return true; + } + let models: unknown; + try { + models = JSON.parse(result.stdout); + } catch { + models = result.stdout; + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: true, models })); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: (err as Error).message })); + } + return true; + } + + // ========== DOWNLOAD MODEL ========== + // POST /api/codexlens/models/download + if (pathname === '/api/codexlens/models/download' && req.method === 'POST') { + handlePostRequest(req, res, async (body: unknown) => { + const { name } = body as { name?: string }; + if (!name) { + return { error: 'name is required', status: 400 }; + } + try { + const result = await spawnCli('codexlens-search', ['download-model', name]); + if (result.exitCode !== 0) { + return { error: result.stderr || 'Failed to download model', status: 500 }; + } + return { success: true, output: result.stdout }; + } catch (err) { + return { error: (err as Error).message, status: 500 }; + } + }); + return true; + } + + // ========== DELETE MODEL ========== + // POST /api/codexlens/models/delete + if (pathname === '/api/codexlens/models/delete' && req.method === 'POST') { + handlePostRequest(req, res, async (body: unknown) => { + const { name } = body as { name?: string }; + if (!name) { + return { error: 'name is required', status: 400 }; + } + try { + const result = await spawnCli('codexlens-search', ['delete-model', name]); + if (result.exitCode !== 0) { + return { error: result.stderr || 'Failed to delete model', status: 500 }; + } + return { success: true, output: result.stdout }; + } catch (err) { + return { error: (err as Error).message, status: 500 }; + } + }); + return true; + } + + // ========== INDEX STATUS ========== + // GET /api/codexlens/index/status?projectPath= + if (pathname === '/api/codexlens/index/status' && req.method === 'GET') { + const projectPath = url.searchParams.get('projectPath'); + if (!projectPath) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'projectPath query parameter is required' })); + return true; + } + try { + const dbPath = join(projectPath, '.codexlens'); + const result = await spawnCli('codexlens-search', ['status', '--db-path', dbPath]); + if (result.exitCode !== 0) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: result.stderr || 'Failed to get index status' })); + return true; + } + let status: unknown; + try { + status = JSON.parse(result.stdout); + } catch { + status = { raw: result.stdout }; + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: true, status })); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: (err as Error).message })); + } + return true; + } + + // ========== INDEX SYNC ========== + // POST /api/codexlens/index/sync + if (pathname === '/api/codexlens/index/sync' && req.method === 'POST') { + handlePostRequest(req, res, async (body: unknown) => { + const { projectPath } = body as { projectPath?: string }; + if (!projectPath) { + return { error: 'projectPath is required', status: 400 }; + } + try { + const result = await spawnCli('codexlens-search', ['sync', '--root', projectPath]); + if (result.exitCode !== 0) { + return { error: result.stderr || 'Failed to sync index', status: 500 }; + } + return { success: true, output: result.stdout }; + } catch (err) { + return { error: (err as Error).message, status: 500 }; + } + }); + return true; + } + + // ========== INDEX REBUILD ========== + // POST /api/codexlens/index/rebuild + if (pathname === '/api/codexlens/index/rebuild' && req.method === 'POST') { + handlePostRequest(req, res, async (body: unknown) => { + const { projectPath } = body as { projectPath?: string }; + if (!projectPath) { + return { error: 'projectPath is required', status: 400 }; + } + try { + const initResult = await spawnCli('codexlens-search', ['init', '--root', projectPath]); + if (initResult.exitCode !== 0) { + return { error: initResult.stderr || 'Failed to init index', status: 500 }; + } + const syncResult = await spawnCli('codexlens-search', ['sync', '--root', projectPath]); + if (syncResult.exitCode !== 0) { + return { error: syncResult.stderr || 'Failed to sync after init', status: 500 }; + } + return { success: true, initOutput: initResult.stdout, syncOutput: syncResult.stdout }; + } catch (err) { + return { error: (err as Error).message, status: 500 }; + } + }); + return true; + } + + // ========== GET ENV ========== + // GET /api/codexlens/env + if (pathname === '/api/codexlens/env' && req.method === 'GET') { + try { + const env = readEnvFile(); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: true, env })); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: (err as Error).message })); + } + return true; + } + + // ========== SAVE ENV ========== + // POST /api/codexlens/env + if (pathname === '/api/codexlens/env' && req.method === 'POST') { + handlePostRequest(req, res, async (body: unknown) => { + const { env } = body as { env?: Record }; + if (!env || typeof env !== 'object') { + return { error: 'env object is required', status: 400 }; + } + try { + writeEnvFile(env); + return { success: true }; + } catch (err) { + return { error: (err as Error).message, status: 500 }; + } + }); + return true; + } + + // ========== MCP CONFIG ========== + // GET /api/codexlens/mcp-config + if (pathname === '/api/codexlens/mcp-config' && req.method === 'GET') { + try { + const env = readEnvFile(); + // Filter to non-empty string values only + const filteredEnv: Record = {}; + for (const [key, value] of Object.entries(env)) { + if (typeof value === 'string' && value.trim() !== '') { + filteredEnv[key] = value; + } + } + const mcpConfig = { + mcpServers: { + codexlens: { + command: 'codexlens-mcp', + env: filteredEnv + } + } + }; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: true, config: mcpConfig })); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: (err as Error).message })); + } + return true; + } + + return false; +} diff --git a/ccw/src/core/server.ts b/ccw/src/core/server.ts index 1210c357..35098753 100644 --- a/ccw/src/core/server.ts +++ b/ccw/src/core/server.ts @@ -46,6 +46,7 @@ import { handleNotificationRoutes } from './routes/notification-routes.js'; import { handleAnalysisRoutes } from './routes/analysis-routes.js'; import { handleSpecRoutes } from './routes/spec-routes.js'; import { handleDeepWikiRoutes } from './routes/deepwiki-routes.js'; +import { handleCodexLensRoutes } from './routes/codexlens-routes.js'; // Import WebSocket handling import { handleWebSocketUpgrade, broadcastToClients, extractSessionIdFromPath } from './websocket.js'; @@ -559,6 +560,11 @@ export async function startServer(options: ServerOptions = {}): Promise