mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-03-20 19:03:51 +08:00
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:
208
ccw/frontend/src/components/codexlens/EnvSettingsTab.tsx
Normal file
208
ccw/frontend/src/components/codexlens/EnvSettingsTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
162
ccw/frontend/src/components/codexlens/IndexManagerTab.tsx
Normal file
162
ccw/frontend/src/components/codexlens/IndexManagerTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
105
ccw/frontend/src/components/codexlens/McpConfigTab.tsx
Normal file
105
ccw/frontend/src/components/codexlens/McpConfigTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
109
ccw/frontend/src/components/codexlens/ModelManagerTab.tsx
Normal file
109
ccw/frontend/src/components/codexlens/ModelManagerTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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]);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
214
ccw/frontend/src/hooks/useCodexLens.ts
Normal file
214
ccw/frontend/src/hooks/useCodexLens.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": "状态",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
57
ccw/frontend/src/pages/CodexLensPage.tsx
Normal file
57
ccw/frontend/src/pages/CodexLensPage.tsx
Normal 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;
|
||||
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user