feat: add CodexLens v2 management features including model management, index operations, env config, and MCP config

- Implemented CodexLens routes for model listing, downloading, and deleting.
- Added hooks for managing CodexLens models, index status, environment variables, and MCP configuration.
- Created frontend components for managing environment settings, index status, and models.
- Developed the main CodexLens page with tab navigation for easy access to different management features.
- Introduced a new file structure for CodexLens related components and hooks.
This commit is contained in:
catlog22
2026-03-18 16:26:42 +08:00
parent b91bdcdfa4
commit 1e036edddc
15 changed files with 1298 additions and 6 deletions

View File

@@ -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<string, string> {
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 (
<div className="relative">
<Input
id={id}
type={show ? 'text' : 'password'}
value={value}
onChange={(e) => onChange(e.target.value)}
className="pr-10"
/>
<button
type="button"
className="absolute inset-y-0 right-2 flex items-center text-muted-foreground hover:text-foreground"
onClick={() => setShow((s) => !s)}
tabIndex={-1}
>
{show ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
);
}
// ========================================
// Main component
// ========================================
export function EnvSettingsTab() {
const { formatMessage } = useIntl();
const { data: serverEnv, isLoading } = useCodexLensEnv();
const { saveEnv, isSaving } = useSaveCodexLensEnv();
const [localEnv, setLocalEnv] = useState<Record<string, string>>(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 <p className="text-sm text-muted-foreground p-4">{formatMessage({ id: 'codexlens.env.loading' })}</p>;
}
return (
<div className="space-y-6">
{ENV_GROUPS.map((group) => (
<Card key={group.title}>
<CardHeader className="pb-3">
<CardTitle className="text-base">{formatMessage({ id: `codexlens.env.sections.${group.title}` })}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{group.fields.map((field) => (
<div key={field.key} className="grid grid-cols-3 gap-3 items-center">
<label
htmlFor={field.key}
className="text-sm text-muted-foreground col-span-1"
>
{field.label}
</label>
<div className="col-span-2">
{field.sensitive ? (
<SensitiveInput
id={field.key}
value={localEnv[field.key] ?? ''}
onChange={(v) => handleChange(field.key, v)}
/>
) : (
<Input
id={field.key}
value={localEnv[field.key] ?? ''}
onChange={(e) => handleChange(field.key, e.target.value)}
/>
)}
</div>
</div>
))}
</CardContent>
</Card>
))}
{/* Action buttons */}
<div className="flex justify-between pt-2">
<Button variant="outline" onClick={handleReset}>
<RotateCcw className="w-4 h-4 mr-2" />
{formatMessage({ id: 'codexlens.env.clearForm' })}
</Button>
<Button onClick={handleSave} disabled={!isDirty || isSaving}>
<Save className="w-4 h-4 mr-2" />
{isSaving ? formatMessage({ id: 'codexlens.env.saving' }) : formatMessage({ id: 'codexlens.env.save' })}
</Button>
</div>
</div>
);
}

View File

@@ -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 (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium truncate">{projectPath}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Status stats */}
{isLoading && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin" />
{formatMessage({ id: 'codexlens.index.loading' })}
</div>
)}
{isError && (
<p className="text-sm text-destructive">{formatMessage({ id: 'codexlens.index.error' })}</p>
)}
{!isLoading && !isError && status && (
<div className="grid grid-cols-3 gap-3">
<div className="text-center">
<p className="text-lg font-semibold">{status.files_tracked ?? 0}</p>
<p className="text-xs text-muted-foreground">{formatMessage({ id: 'codexlens.index.filesTracked' })}</p>
</div>
<div className="text-center">
<p className="text-lg font-semibold">{status.total_chunks ?? 0}</p>
<p className="text-xs text-muted-foreground">{formatMessage({ id: 'codexlens.index.totalChunks' })}</p>
</div>
<div className="text-center">
<p className="text-lg font-semibold">{status.deleted_chunks ?? 0}</p>
<p className="text-xs text-muted-foreground">{formatMessage({ id: 'codexlens.index.deletedChunks' })}</p>
</div>
</div>
)}
{/* Action buttons */}
<div className="flex gap-2 flex-wrap">
<Button
variant="outline"
size="sm"
onClick={() => syncIndex(projectPath)}
disabled={isSyncing}
>
{isSyncing ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : null}
{formatMessage({ id: 'codexlens.index.sync' })}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => rebuildIndex(projectPath)}
disabled={isRebuilding}
>
{isRebuilding ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : null}
{formatMessage({ id: 'codexlens.index.rebuild' })}
</Button>
<Button
variant="outline"
size="sm"
onClick={handleRefresh}
>
<RefreshCw className="w-4 h-4 mr-1" />
{formatMessage({ id: 'codexlens.index.refresh' })}
</Button>
</div>
</CardContent>
</Card>
);
}
export function IndexManagerTab() {
const { formatMessage } = useIntl();
const [paths, setPaths] = useState<string[]>([]);
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<HTMLInputElement>) => {
if (e.key === 'Enter') {
handleAdd();
}
};
return (
<div className="space-y-4">
{/* Add project path */}
<div className="flex gap-2">
<Input
placeholder={formatMessage({ id: 'codexlens.index.pathPlaceholder' })}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
className="flex-1"
/>
<Button onClick={handleAdd} disabled={!inputValue.trim()}>
<Plus className="w-4 h-4 mr-1" />
{formatMessage({ id: 'codexlens.index.add' })}
</Button>
</div>
{paths.length === 0 && (
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'codexlens.index.empty' })}
</p>
)}
{/* Project status cards */}
{paths.map((path) => (
<div key={path} className="relative">
<Button
variant="ghost"
size="sm"
className="absolute top-2 right-2 z-10 h-7 w-7 p-0"
onClick={() => handleRemove(path)}
title={formatMessage({ id: 'codexlens.index.removeProject' })}
>
<Trash2 className="w-4 h-4 text-muted-foreground" />
</Button>
<ProjectStatusCard projectPath={path} />
</div>
))}
</div>
);
}

View File

@@ -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 (
<div className="space-y-4">
{/* Embed mode badge */}
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">{formatMessage({ id: 'codexlens.mcp.embedMode' })}:</span>
<Badge variant={hasApiUrl ? 'success' : 'secondary'}>
{embedMode}
</Badge>
</div>
{/* Config JSON block */}
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base">{formatMessage({ id: 'codexlens.mcp.configTitle' })}</CardTitle>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={handleCopy}
disabled={!configJson || isLoading}
>
{copied ? <Check className="w-4 h-4 mr-1" /> : <Copy className="w-4 h-4 mr-1" />}
{copied ? formatMessage({ id: 'codexlens.mcp.copied' }) : formatMessage({ id: 'codexlens.mcp.copy' })}
</Button>
<Button
variant="outline"
size="sm"
onClick={handleRegenerate}
disabled={isLoading}
>
<RefreshCw className={`w-4 h-4 mr-1 ${isLoading ? 'animate-spin' : ''}`} />
{formatMessage({ id: 'codexlens.mcp.regenerate' })}
</Button>
</div>
</div>
</CardHeader>
<CardContent>
{isLoading && (
<p className="text-sm text-muted-foreground">{formatMessage({ id: 'codexlens.mcp.loading' })}</p>
)}
{isError && (
<p className="text-sm text-destructive">{formatMessage({ id: 'codexlens.mcp.error' })}</p>
)}
{!isLoading && !isError && (
<pre className="bg-muted rounded-md p-4 text-xs overflow-auto max-h-96 font-mono">
{configJson || formatMessage({ id: 'codexlens.mcp.noConfig' })}
</pre>
)}
</CardContent>
</Card>
{/* Installation instructions */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">{formatMessage({ id: 'codexlens.mcp.installTitle' })}</CardTitle>
</CardHeader>
<CardContent>
<ol className="list-decimal list-inside space-y-2 text-sm text-muted-foreground">
<li>{formatMessage({ id: 'codexlens.mcp.installSteps.step1' })}</li>
<li>{formatMessage({ id: 'codexlens.mcp.installSteps.step2' })}</li>
<li>{formatMessage({ id: 'codexlens.mcp.installSteps.step3' })}</li>
<li>{formatMessage({ id: 'codexlens.mcp.installSteps.step4' })}</li>
<li>{formatMessage({ id: 'codexlens.mcp.installSteps.step5' })}</li>
</ol>
</CardContent>
</Card>
</div>
);
}

View File

@@ -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 (
<div className="space-y-4">
{/* Embed mode banner */}
<div className="flex items-center gap-2 p-3 rounded-md bg-muted text-sm">
<span className="text-muted-foreground">{formatMessage({ id: 'codexlens.models.embedMode' })}:</span>
<Badge variant={hasApiUrl ? 'success' : 'secondary'}>{embedMode}</Badge>
</div>
{/* Loading / Error states */}
{isLoading && (
<div className="flex items-center gap-2 text-sm text-muted-foreground p-4">
<Loader2 className="w-4 h-4 animate-spin" />
{formatMessage({ id: 'codexlens.models.loading' })}
</div>
)}
{isError && (
<p className="text-sm text-destructive p-4">{formatMessage({ id: 'codexlens.models.error' })}</p>
)}
{/* Model list */}
{!isLoading && !isError && models.length === 0 && (
<p className="text-sm text-muted-foreground p-4">{formatMessage({ id: 'codexlens.models.noModels' })}</p>
)}
{!isLoading && !isError && models.length > 0 && (
<Card>
<CardContent className="p-0">
<div className="divide-y divide-border">
{models.map((model) => (
<div
key={model.name}
className="flex items-center justify-between px-4 py-3"
>
<div className="flex items-center gap-3">
<span className="text-sm font-medium">{model.name}</span>
<Badge variant={model.installed ? 'success' : 'secondary'}>
{model.installed
? formatMessage({ id: 'codexlens.models.installed' })
: formatMessage({ id: 'codexlens.models.notInstalled' })}
</Badge>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => downloadModel(model.name)}
disabled={isDownloading}
>
{isDownloading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Download className="w-4 h-4" />
)}
<span className="ml-1">{formatMessage({ id: 'codexlens.models.download' })}</span>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => deleteModel(model.name)}
disabled={!model.installed || isDeleting}
>
{isDeleting ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Trash2 className="w-4 h-4" />
)}
<span className="ml-1">{formatMessage({ id: 'codexlens.models.delete' })}</span>
</Button>
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -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 },
],
},
{

View File

@@ -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]);
},
},
];

View File

@@ -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<string, unknown>;
// Internal API response wrappers
interface ModelsResponse { success: boolean; models: ModelEntry[] }
interface IndexStatusResponse { success: boolean; status: IndexStatusData }
interface EnvResponse { success: boolean; env: Record<string, string> }
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<T>(url: string, options: RequestInit = {}): Promise<T> {
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<T>;
}
// ========================================
// Models Hooks
// ========================================
export function useCodexLensModels() {
return useQuery({
queryKey: codexLensKeys.models(),
queryFn: () => fetchApi<ModelsResponse>('/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<IndexStatusResponse>(
`/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<EnvResponse>('/api/codexlens/env').then(r => r.env),
staleTime: 60_000,
});
}
export function useSaveCodexLensEnv() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (env: Record<string, string>) =>
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<McpConfigResponse>('/api/codexlens/mcp-config').then(r => r.config),
staleTime: 30_000,
});
}

View File

@@ -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",

View File

@@ -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": {

View File

@@ -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": "状态",

View File

@@ -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": {

View File

@@ -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<TabType>('mcp');
return (
<div className="space-y-6">
{/* Page Header */}
<div>
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2">
<Search className="w-6 h-6 text-primary" />
{formatMessage({ id: 'codexlens.page.title' })}
</h1>
<p className="text-muted-foreground mt-1">
{formatMessage({ id: 'codexlens.page.description' })}
</p>
</div>
{/* Tab Navigation */}
<TabsNavigation
value={activeTab}
onValueChange={(v) => 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 */}
<div className="mt-4">
{activeTab === 'mcp' && <McpConfigTab />}
{activeTab === 'models' && <ModelManagerTab />}
{activeTab === 'index' && <IndexManagerTab />}
{activeTab === 'env' && <EnvSettingsTab />}
</div>
</div>
);
}
export default CodexLensPage;

View File

@@ -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(<DeepWikiPage />),
},
{
path: 'codexlens',
element: withErrorHandling(<CodexLensPage />),
},
{
path: 'terminal-dashboard',
element: withErrorHandling(<TerminalDashboardPage />),
@@ -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];

View File

@@ -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<string, string> {
const filePath = getEnvFilePath();
if (!existsSync(filePath)) {
return {};
}
try {
const content = readFileSync(filePath, 'utf-8');
return JSON.parse(content) as Record<string, string>;
} catch {
return {};
}
}
/**
* Write the codexlens env config file
*/
function writeEnvFile(env: Record<string, string>): 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<boolean> {
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<string, string> };
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<string, string> = {};
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;
}

View File

@@ -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<http.Ser
if (await handleDeepWikiRoutes(routeContext)) return;
}
// CodexLens routes (/api/codexlens/*)
if (pathname.startsWith('/api/codexlens')) {
if (await handleCodexLensRoutes(routeContext)) return;
}
// Hooks routes (/api/hooks, /api/hook)
if (pathname.startsWith('/api/hook')) {
if (await handleHooksRoutes(routeContext)) return;