Refactor code structure for improved readability and maintainability

This commit is contained in:
catlog22
2026-02-08 13:47:59 +08:00
parent 54c3234d84
commit 0a04660c80
99 changed files with 4820 additions and 413 deletions

View File

@@ -35,6 +35,35 @@ export interface CliSettingsModalProps {
type ModeType = 'provider-based' | 'direct';
// ========== Helper Functions ==========
function safeStringifyConfig(config: unknown): string {
try {
return JSON.stringify(config ?? {}, null, 2);
} catch {
return '{}';
}
}
function parseConfigJson(
configJson: string
): { ok: true; value: Record<string, unknown> } | { ok: false; errorKey: string } {
const trimmed = configJson.trim();
if (!trimmed) {
return { ok: true, value: {} };
}
try {
const parsed = JSON.parse(trimmed) as unknown;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return { ok: false, errorKey: 'configMustBeObject' };
}
return { ok: true, value: parsed as Record<string, unknown> };
} catch {
return { ok: false, errorKey: 'invalidJson' };
}
}
// ========== Main Component ==========
export function CliSettingsModal({ open, onClose, cliSettings }: CliSettingsModalProps) {
@@ -74,6 +103,10 @@ export function CliSettingsModal({ open, onClose, cliSettings }: CliSettingsModa
const [tags, setTags] = useState<string[]>([]);
const [tagInput, setTagInput] = useState('');
// JSON config state
const [configJson, setConfigJson] = useState('{}');
const [showJsonInput, setShowJsonInput] = useState(false);
// Validation errors
const [errors, setErrors] = useState<Record<string, string>>({});
@@ -123,6 +156,8 @@ export function CliSettingsModal({ open, onClose, cliSettings }: CliSettingsModa
setModelInput('');
setTags([]);
setTagInput('');
setConfigJson('{}');
setShowJsonInput(false);
setErrors({});
}
}, [cliSettings, open, providers]);
@@ -156,6 +191,14 @@ export function CliSettingsModal({ open, onClose, cliSettings }: CliSettingsModa
}
}
// Validate JSON config if shown
if (showJsonInput) {
const parsedConfig = parseConfigJson(configJson);
if (!parsedConfig.ok) {
newErrors.configJson = formatMessage({ id: `apiSettings.cliSettings.${parsedConfig.errorKey}` });
}
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
@@ -191,6 +234,15 @@ export function CliSettingsModal({ open, onClose, cliSettings }: CliSettingsModa
}
}
// Parse and merge JSON config if shown
let extraSettings: Record<string, unknown> = {};
if (showJsonInput) {
const parsedConfig = parseConfigJson(configJson);
if (parsedConfig.ok) {
extraSettings = parsedConfig.value;
}
}
const request = {
id: cliSettings?.id,
name: name.trim(),
@@ -203,6 +255,7 @@ export function CliSettingsModal({ open, onClose, cliSettings }: CliSettingsModa
settingsFile: settingsFile.trim() || undefined,
availableModels,
tags,
...extraSettings,
},
};
@@ -586,6 +639,52 @@ export function CliSettingsModal({ open, onClose, cliSettings }: CliSettingsModa
))}
</div>
</div>
{/* JSON Config Section */}
<div className="space-y-2 pt-4 border-t border-border">
<div className="flex items-center justify-between">
<Label htmlFor="configJson" className="cursor-pointer">
{formatMessage({ id: 'apiSettings.cliSettings.configJson' })}
</Label>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setShowJsonInput(!showJsonInput)}
>
{showJsonInput
? formatMessage({ id: 'common.actions.close' })
: formatMessage({ id: 'common.actions.expand' }, { value: 'JSON' })}
</Button>
</div>
{showJsonInput && (
<div className="space-y-2">
<Textarea
id="configJson"
value={configJson}
onChange={(e) => {
setConfigJson(e.target.value);
if (errors.configJson) {
setErrors((prev) => {
const newErrors = { ...prev };
delete newErrors.configJson;
return newErrors;
});
}
}}
placeholder={formatMessage({ id: 'apiSettings.cliSettings.configJsonPlaceholder' })}
className={errors.configJson ? 'font-mono border-destructive' : 'font-mono'}
rows={8}
/>
{errors.configJson && (
<p className="text-xs text-destructive">{errors.configJson}</p>
)}
<p className="text-xs text-muted-foreground">
{formatMessage({ id: 'apiSettings.cliSettings.configJsonHint' })}
</p>
</div>
)}
</div>
</div>
</div>

View File

@@ -0,0 +1,135 @@
// ========================================
// CodexLens File Watcher Card
// ========================================
// Displays file watcher status, stats, and toggle control
import { useIntl } from 'react-intl';
import {
Eye,
EyeOff,
Activity,
Clock,
FolderOpen,
} from 'lucide-react';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { cn } from '@/lib/utils';
import { useCodexLensWatcher, useCodexLensWatcherMutations } from '@/hooks';
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
function formatUptime(seconds: number): string {
if (seconds < 60) return `${seconds}s`;
if (seconds < 3600) {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}m ${s}s`;
}
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
return `${h}h ${m}m`;
}
interface FileWatcherCardProps {
disabled?: boolean;
}
export function FileWatcherCard({ disabled = false }: FileWatcherCardProps) {
const { formatMessage } = useIntl();
const projectPath = useWorkflowStore(selectProjectPath);
const { running, rootPath, eventsProcessed, uptimeSeconds, isLoading } = useCodexLensWatcher();
const { startWatcher, stopWatcher, isStarting, isStopping } = useCodexLensWatcherMutations();
const isMutating = isStarting || isStopping;
const handleToggle = async () => {
if (running) {
await stopWatcher();
} else {
await startWatcher(projectPath);
}
};
return (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center justify-between">
<div className="flex items-center gap-2">
<Activity className="w-4 h-4" />
<span>{formatMessage({ id: 'codexlens.watcher.title' })}</span>
</div>
<Badge variant={running ? 'success' : 'secondary'}>
{running
? formatMessage({ id: 'codexlens.watcher.status.running' })
: formatMessage({ id: 'codexlens.watcher.status.stopped' })
}
</Badge>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Stats Grid */}
<div className="grid grid-cols-2 gap-3">
<div className="flex items-center gap-2 text-sm">
<Activity className={cn('w-4 h-4', running ? 'text-success' : 'text-muted-foreground')} />
<div>
<p className="text-muted-foreground text-xs">
{formatMessage({ id: 'codexlens.watcher.eventsProcessed' })}
</p>
<p className="font-semibold text-foreground">{eventsProcessed}</p>
</div>
</div>
<div className="flex items-center gap-2 text-sm">
<Clock className={cn('w-4 h-4', running ? 'text-info' : 'text-muted-foreground')} />
<div>
<p className="text-muted-foreground text-xs">
{formatMessage({ id: 'codexlens.watcher.uptime' })}
</p>
<p className="font-semibold text-foreground">
{running ? formatUptime(uptimeSeconds) : '--'}
</p>
</div>
</div>
</div>
{/* Watched Path */}
{running && rootPath && (
<div className="flex items-center gap-2 text-sm">
<FolderOpen className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<span className="text-muted-foreground truncate" title={rootPath}>
{rootPath}
</span>
</div>
)}
{/* Toggle Button */}
<Button
variant={running ? 'outline' : 'default'}
size="sm"
className="w-full"
onClick={handleToggle}
disabled={disabled || isMutating || isLoading}
>
{running ? (
<>
<EyeOff className="w-4 h-4 mr-2" />
{isStopping
? formatMessage({ id: 'codexlens.watcher.stopping' })
: formatMessage({ id: 'codexlens.watcher.stop' })
}
</>
) : (
<>
<Eye className="w-4 h-4 mr-2" />
{isStarting
? formatMessage({ id: 'codexlens.watcher.starting' })
: formatMessage({ id: 'codexlens.watcher.start' })
}
</>
)}
</Button>
</CardContent>
</Card>
);
}
export default FileWatcherCard;

View File

@@ -0,0 +1,256 @@
// ========================================
// CodexLens Install Progress Overlay
// ========================================
// Dialog overlay showing 5-stage simulated progress during CodexLens bootstrap installation
import { useState, useEffect, useRef, useCallback } from 'react';
import { useIntl } from 'react-intl';
import {
Check,
Download,
Info,
Loader2,
} from 'lucide-react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/Dialog';
import { Button } from '@/components/ui/Button';
import { Progress } from '@/components/ui/Progress';
import { Card, CardContent } from '@/components/ui/Card';
// ----------------------------------------
// Types
// ----------------------------------------
interface InstallStage {
progress: number;
messageId: string;
}
interface InstallProgressOverlayProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onInstall: () => Promise<{ success: boolean }>;
onSuccess?: () => void;
}
// ----------------------------------------
// Constants
// ----------------------------------------
const INSTALL_STAGES: InstallStage[] = [
{ progress: 10, messageId: 'codexlens.install.stage.creatingVenv' },
{ progress: 30, messageId: 'codexlens.install.stage.installingPip' },
{ progress: 50, messageId: 'codexlens.install.stage.installingPackage' },
{ progress: 70, messageId: 'codexlens.install.stage.settingUpDeps' },
{ progress: 90, messageId: 'codexlens.install.stage.finalizing' },
];
const STAGE_INTERVAL_MS = 1500;
// ----------------------------------------
// Checklist items
// ----------------------------------------
interface ChecklistItem {
labelId: string;
descId: string;
}
const CHECKLIST_ITEMS: ChecklistItem[] = [
{ labelId: 'codexlens.install.pythonVenv', descId: 'codexlens.install.pythonVenvDesc' },
{ labelId: 'codexlens.install.codexlensPackage', descId: 'codexlens.install.codexlensPackageDesc' },
{ labelId: 'codexlens.install.sqliteFts', descId: 'codexlens.install.sqliteFtsDesc' },
];
// ----------------------------------------
// Component
// ----------------------------------------
export function InstallProgressOverlay({
open,
onOpenChange,
onInstall,
onSuccess,
}: InstallProgressOverlayProps) {
const { formatMessage } = useIntl();
const [isInstalling, setIsInstalling] = useState(false);
const [progress, setProgress] = useState(0);
const [stageText, setStageText] = useState('');
const [isComplete, setIsComplete] = useState(false);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const clearStageInterval = useCallback(() => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
}, []);
// Reset state when dialog closes
useEffect(() => {
if (!open) {
setIsInstalling(false);
setProgress(0);
setStageText('');
setIsComplete(false);
clearStageInterval();
}
}, [open, clearStageInterval]);
// Cleanup on unmount
useEffect(() => {
return () => clearStageInterval();
}, [clearStageInterval]);
const handleInstall = async () => {
setIsInstalling(true);
setProgress(0);
setIsComplete(false);
// Start stage simulation
let currentStage = 0;
setStageText(formatMessage({ id: INSTALL_STAGES[0].messageId }));
setProgress(INSTALL_STAGES[0].progress);
currentStage = 1;
intervalRef.current = setInterval(() => {
if (currentStage < INSTALL_STAGES.length) {
setStageText(formatMessage({ id: INSTALL_STAGES[currentStage].messageId }));
setProgress(INSTALL_STAGES[currentStage].progress);
currentStage++;
}
}, STAGE_INTERVAL_MS);
try {
const result = await onInstall();
clearStageInterval();
if (result.success) {
setProgress(100);
setStageText(formatMessage({ id: 'codexlens.install.stage.complete' }));
setIsComplete(true);
// Auto-close after showing completion
setTimeout(() => {
onOpenChange(false);
onSuccess?.();
}, 1200);
} else {
setIsInstalling(false);
setProgress(0);
setStageText('');
}
} catch {
clearStageInterval();
setIsInstalling(false);
setProgress(0);
setStageText('');
}
};
return (
<Dialog open={open} onOpenChange={isInstalling ? undefined : onOpenChange}>
<DialogContent className="max-w-lg" onPointerDownOutside={isInstalling ? (e) => e.preventDefault() : undefined}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Download className="w-5 h-5 text-primary" />
{formatMessage({ id: 'codexlens.install.title' })}
</DialogTitle>
<DialogDescription>
{formatMessage({ id: 'codexlens.install.description' })}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Install Checklist */}
<div>
<h4 className="text-sm font-medium mb-2">
{formatMessage({ id: 'codexlens.install.checklist' })}
</h4>
<ul className="space-y-2">
{CHECKLIST_ITEMS.map((item) => (
<li key={item.labelId} className="flex items-start gap-2 text-sm">
<Check className="w-4 h-4 text-green-500 mt-0.5 flex-shrink-0" />
<span>
<strong>{formatMessage({ id: item.labelId })}</strong>
{' - '}
{formatMessage({ id: item.descId })}
</span>
</li>
))}
</ul>
</div>
{/* Install Location Info */}
<Card className="bg-primary/5 border-primary/20">
<CardContent className="p-3 flex items-start gap-2">
<Info className="w-4 h-4 text-primary mt-0.5 flex-shrink-0" />
<div className="text-sm text-muted-foreground">
<p className="font-medium text-foreground">
{formatMessage({ id: 'codexlens.install.location' })}
</p>
<p className="mt-1">
<code className="bg-muted px-1.5 py-0.5 rounded text-xs">
{formatMessage({ id: 'codexlens.install.locationPath' })}
</code>
</p>
<p className="mt-1">
{formatMessage({ id: 'codexlens.install.timeEstimate' })}
</p>
</div>
</CardContent>
</Card>
{/* Progress Section - shown during install */}
{isInstalling && (
<div className="space-y-2">
<div className="flex items-center gap-3">
{isComplete ? (
<Check className="w-5 h-5 text-green-500" />
) : (
<Loader2 className="w-5 h-5 text-primary animate-spin" />
)}
<span className="text-sm">{stageText}</span>
</div>
<Progress value={progress} className="h-2" />
</div>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isInstalling}
>
{formatMessage({ id: 'common.actions.cancel' })}
</Button>
<Button
onClick={handleInstall}
disabled={isInstalling}
>
{isInstalling ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
{formatMessage({ id: 'codexlens.install.installing' })}
</>
) : (
<>
<Download className="w-4 h-4 mr-2" />
{formatMessage({ id: 'codexlens.install.installNow' })}
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default InstallProgressOverlay;

View File

@@ -0,0 +1,195 @@
// ========================================
// ModelSelectField Component
// ========================================
// Combobox-style input for selecting models from local + API sources
import { useState, useRef, useEffect, useMemo } from 'react';
import { useIntl } from 'react-intl';
import { ChevronDown } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { EnvVarFieldSchema, ModelGroup } from '@/types/codexlens';
import type { CodexLensModel } from '@/lib/api';
interface ModelSelectFieldProps {
field: EnvVarFieldSchema;
value: string;
onChange: (value: string) => void;
/** Currently loaded local models (installed) */
localModels?: CodexLensModel[];
/** Backend type determines which model list to show */
backendType: 'local' | 'api';
disabled?: boolean;
}
interface ModelOption {
id: string;
label: string;
group: string;
}
export function ModelSelectField({
field,
value,
onChange,
localModels = [],
backendType,
disabled = false,
}: ModelSelectFieldProps) {
const { formatMessage } = useIntl();
const [open, setOpen] = useState(false);
const [search, setSearch] = useState('');
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Close on outside click
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
}
}
if (open) {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}
}, [open]);
// Build model options based on backend type
const options = useMemo<ModelOption[]>(() => {
const result: ModelOption[] = [];
if (backendType === 'api') {
// API mode: show preset API models from schema
const apiGroups: ModelGroup[] = field.apiModels || [];
for (const group of apiGroups) {
for (const item of group.items) {
result.push({ id: item, label: item, group: group.group });
}
}
} else {
// Local mode: show installed local models, then preset profiles as fallback
if (localModels.length > 0) {
for (const model of localModels) {
const modelId = model.profile || model.name;
const displayText =
model.profile && model.name && model.profile !== model.name
? `${model.profile} (${model.name})`
: model.name || model.profile;
result.push({
id: modelId,
label: displayText,
group: formatMessage({ id: 'codexlens.downloadedModels', defaultMessage: 'Downloaded Models' }),
});
}
} else {
// Fallback to preset local models from schema
const localGroups: ModelGroup[] = field.localModels || [];
for (const group of localGroups) {
for (const item of group.items) {
result.push({ id: item, label: item, group: group.group });
}
}
}
}
return result;
}, [backendType, field.apiModels, field.localModels, localModels, formatMessage]);
// Filter by search
const filtered = useMemo(() => {
if (!search) return options;
const q = search.toLowerCase();
return options.filter(
(opt) => opt.id.toLowerCase().includes(q) || opt.label.toLowerCase().includes(q)
);
}, [options, search]);
// Group filtered options
const grouped = useMemo(() => {
const groups: Record<string, ModelOption[]> = {};
for (const opt of filtered) {
if (!groups[opt.group]) groups[opt.group] = [];
groups[opt.group].push(opt);
}
return groups;
}, [filtered]);
const handleSelect = (modelId: string) => {
onChange(modelId);
setOpen(false);
setSearch('');
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value;
setSearch(val);
onChange(val);
if (!open) setOpen(true);
};
return (
<div ref={containerRef} className="relative flex-1">
<div className="relative">
<input
ref={inputRef}
type="text"
value={open ? search || value : value}
onChange={handleInputChange}
onFocus={() => {
setOpen(true);
setSearch('');
}}
placeholder={field.placeholder || 'Select model...'}
disabled={disabled}
className={cn(
'flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 pr-8 text-sm',
'ring-offset-background placeholder:text-muted-foreground',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
'disabled:cursor-not-allowed disabled:opacity-50'
)}
/>
<ChevronDown
className="absolute right-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none"
/>
</div>
{open && !disabled && (
<div className="absolute z-50 mt-1 w-full rounded-md border border-border bg-card shadow-md">
<div className="max-h-56 overflow-y-auto p-1">
{Object.keys(grouped).length === 0 ? (
<div className="py-3 text-center text-xs text-muted-foreground">
{backendType === 'api'
? formatMessage({ id: 'codexlens.noConfiguredModels', defaultMessage: 'No models configured' })
: formatMessage({ id: 'codexlens.noLocalModels', defaultMessage: 'No models downloaded' })}
</div>
) : (
Object.entries(grouped).map(([group, items]) => (
<div key={group}>
<div className="px-2 py-1 text-xs font-medium text-muted-foreground">
{group}
</div>
{items.map((item) => (
<button
key={item.id}
type="button"
onClick={() => handleSelect(item.id)}
className={cn(
'flex w-full items-center rounded-sm px-2 py-1.5 text-xs cursor-pointer',
'hover:bg-accent hover:text-accent-foreground',
value === item.id && 'bg-accent/50'
)}
>
{item.label}
</button>
))}
</div>
))
)}
</div>
</div>
)}
</div>
);
}
export default ModelSelectField;

View File

@@ -15,6 +15,7 @@ import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
import { cn } from '@/lib/utils';
import type { CodexLensVenvStatus, CodexLensConfig } from '@/lib/api';
import { IndexOperations } from './IndexOperations';
import { FileWatcherCard } from './FileWatcherCard';
interface OverviewTabProps {
installed: boolean;
@@ -142,6 +143,9 @@ export function OverviewTab({ installed, status, config, isLoading, onRefresh }:
</Card>
</div>
{/* File Watcher */}
<FileWatcherCard disabled={!isReady} />
{/* Index Operations */}
<IndexOperations disabled={!isReady} onRefresh={onRefresh} />

View File

@@ -0,0 +1,266 @@
// ========================================
// SchemaFormRenderer Component
// ========================================
// Renders structured form groups from EnvVarGroupsSchema definition
// Supports select, number, checkbox, text, and model-select field types
import { useMemo } from 'react';
import { useIntl } from 'react-intl';
import {
Box,
ArrowUpDown,
Cpu,
GitBranch,
Scissors,
type LucideIcon,
} from 'lucide-react';
import { Label } from '@/components/ui/Label';
import { Input } from '@/components/ui/Input';
import { Checkbox } from '@/components/ui/Checkbox';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/Select';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/Collapsible';
import { cn } from '@/lib/utils';
import { evaluateShowWhen } from './envVarSchema';
import { ModelSelectField } from './ModelSelectField';
import type { EnvVarGroupsSchema, EnvVarFieldSchema } from '@/types/codexlens';
import type { CodexLensModel } from '@/lib/api';
// Icon mapping for group icons
const iconMap: Record<string, LucideIcon> = {
box: Box,
'arrow-up-down': ArrowUpDown,
cpu: Cpu,
'git-branch': GitBranch,
scissors: Scissors,
};
interface SchemaFormRendererProps {
/** The schema defining all groups and fields */
groups: EnvVarGroupsSchema;
/** Current form values keyed by env var name */
values: Record<string, string>;
/** Called when a field value changes */
onChange: (key: string, value: string) => void;
/** Whether the form is disabled (loading state) */
disabled?: boolean;
/** Local embedding models (installed) for model-select */
localEmbeddingModels?: CodexLensModel[];
/** Local reranker models (installed) for model-select */
localRerankerModels?: CodexLensModel[];
}
export function SchemaFormRenderer({
groups,
values,
onChange,
disabled = false,
localEmbeddingModels = [],
localRerankerModels = [],
}: SchemaFormRendererProps) {
const { formatMessage } = useIntl();
const groupEntries = useMemo(() => Object.entries(groups), [groups]);
return (
<div className="space-y-3">
{groupEntries.map(([groupKey, group]) => {
const IconComponent = iconMap[group.icon] || Box;
return (
<Collapsible key={groupKey} defaultOpen>
<div className="border border-border rounded-lg">
<CollapsibleTrigger className="flex w-full items-center gap-2 p-3 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors">
<IconComponent className="w-3.5 h-3.5" />
{formatMessage({ id: group.labelKey, defaultMessage: groupKey })}
</CollapsibleTrigger>
<CollapsibleContent>
<div className="px-3 pb-3 space-y-2">
{Object.entries(group.vars).map(([varKey, field]) => {
const visible = evaluateShowWhen(field, values);
if (!visible) return null;
return (
<FieldRenderer
key={varKey}
field={field}
value={values[varKey] ?? field.default ?? ''}
onChange={(val) => onChange(varKey, val)}
allValues={values}
disabled={disabled}
localModels={
varKey.includes('EMBEDDING')
? localEmbeddingModels
: localRerankerModels
}
formatMessage={formatMessage}
/>
);
})}
</div>
</CollapsibleContent>
</div>
</Collapsible>
);
})}
</div>
);
}
// ========================================
// Individual Field Renderer
// ========================================
interface FieldRendererProps {
field: EnvVarFieldSchema;
value: string;
onChange: (value: string) => void;
allValues: Record<string, string>;
disabled: boolean;
localModels: CodexLensModel[];
formatMessage: (descriptor: { id: string; defaultMessage?: string }) => string;
}
function FieldRenderer({
field,
value,
onChange,
allValues,
disabled,
localModels,
formatMessage,
}: FieldRendererProps) {
const label = formatMessage({ id: field.labelKey, defaultMessage: field.key });
switch (field.type) {
case 'select':
return (
<div className="flex items-center gap-2">
<Label
className="text-xs text-muted-foreground w-28 flex-shrink-0"
title={field.key}
>
{label}
</Label>
<Select
value={value}
onValueChange={onChange}
disabled={disabled}
>
<SelectTrigger className={cn('flex-1 h-8 text-xs')}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{(field.options || []).map((opt) => (
<SelectItem key={opt} value={opt} className="text-xs">
{opt}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
case 'number':
return (
<div className="flex items-center gap-2">
<Label
className="text-xs text-muted-foreground w-28 flex-shrink-0"
title={field.key}
>
{label}
</Label>
<Input
type="number"
className="flex-1 h-8 text-xs"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder}
min={field.min}
max={field.max}
step={field.step ?? 1}
disabled={disabled}
/>
</div>
);
case 'checkbox':
return (
<div className="flex items-center gap-2">
<Label
className="text-xs text-muted-foreground w-28 flex-shrink-0"
title={field.key}
>
{label}
</Label>
<div className="flex-1 flex items-center h-8">
<Checkbox
checked={value === 'true'}
onCheckedChange={(checked) => onChange(checked ? 'true' : 'false')}
disabled={disabled}
/>
</div>
</div>
);
case 'model-select': {
// Determine backend type from related backend env var
const isEmbedding = field.key.includes('EMBEDDING');
const backendKey = isEmbedding
? 'CODEXLENS_EMBEDDING_BACKEND'
: 'CODEXLENS_RERANKER_BACKEND';
const backendType = allValues[backendKey] === 'api' ? 'api' : 'local';
return (
<div className="flex items-center gap-2">
<Label
className="text-xs text-muted-foreground w-28 flex-shrink-0"
title={field.key}
>
{label}
</Label>
<ModelSelectField
field={field}
value={value}
onChange={onChange}
localModels={localModels}
backendType={backendType}
disabled={disabled}
/>
</div>
);
}
case 'text':
default:
return (
<div className="flex items-center gap-2">
<Label
className="text-xs text-muted-foreground w-28 flex-shrink-0"
title={field.key}
>
{label}
</Label>
<Input
type="text"
className="flex-1 h-8 text-xs"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder}
disabled={disabled}
/>
</div>
);
}
}
export default SchemaFormRenderer;

View File

@@ -1,7 +1,7 @@
// ========================================
// Settings Tab Component Tests
// ========================================
// Tests for CodexLens Settings Tab component with form validation
// Tests for CodexLens Settings Tab component with schema-driven form
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, waitFor } from '@/test/i18n';
@@ -15,7 +15,9 @@ vi.mock('@/hooks', async (importOriginal) => {
return {
...actual,
useCodexLensConfig: vi.fn(),
useUpdateCodexLensConfig: vi.fn(),
useCodexLensEnv: vi.fn(),
useUpdateCodexLensEnv: vi.fn(),
useCodexLensModels: vi.fn(),
useNotifications: vi.fn(() => ({
toasts: [],
wsStatus: 'disconnected' as const,
@@ -41,7 +43,13 @@ vi.mock('@/hooks', async (importOriginal) => {
};
});
import { useCodexLensConfig, useUpdateCodexLensConfig, useNotifications } from '@/hooks';
import {
useCodexLensConfig,
useCodexLensEnv,
useUpdateCodexLensEnv,
useCodexLensModels,
useNotifications,
} from '@/hooks';
const mockConfig: CodexLensConfig = {
index_dir: '~/.codexlens/indexes',
@@ -50,6 +58,52 @@ const mockConfig: CodexLensConfig = {
api_batch_size: 8,
};
const mockEnv: Record<string, string> = {
CODEXLENS_EMBEDDING_BACKEND: 'local',
CODEXLENS_EMBEDDING_MODEL: 'fast',
CODEXLENS_USE_GPU: 'true',
CODEXLENS_RERANKER_ENABLED: 'true',
CODEXLENS_RERANKER_BACKEND: 'local',
CODEXLENS_API_MAX_WORKERS: '4',
CODEXLENS_API_BATCH_SIZE: '8',
CODEXLENS_CASCADE_STRATEGY: 'dense_rerank',
};
function setupDefaultMocks() {
vi.mocked(useCodexLensConfig).mockReturnValue({
config: mockConfig,
indexDir: mockConfig.index_dir,
indexCount: 100,
apiMaxWorkers: 4,
apiBatchSize: 8,
isLoading: false,
error: null,
refetch: vi.fn(),
});
vi.mocked(useCodexLensEnv).mockReturnValue({
data: { success: true, env: mockEnv, settings: {}, path: '~/.codexlens/.env' },
env: mockEnv,
settings: {},
raw: '',
isLoading: false,
error: null,
refetch: vi.fn(),
});
vi.mocked(useUpdateCodexLensEnv).mockReturnValue({
updateEnv: vi.fn().mockResolvedValue({ success: true, message: 'Saved' }),
isUpdating: false,
error: null,
});
vi.mocked(useCodexLensModels).mockReturnValue({
models: [],
embeddingModels: [],
rerankerModels: [],
isLoading: false,
error: null,
refetch: vi.fn(),
});
}
describe('SettingsTab', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -57,21 +111,7 @@ describe('SettingsTab', () => {
describe('when enabled and config loaded', () => {
beforeEach(() => {
vi.mocked(useCodexLensConfig).mockReturnValue({
config: mockConfig,
indexDir: mockConfig.index_dir,
indexCount: 100,
apiMaxWorkers: 4,
apiBatchSize: 8,
isLoading: false,
error: null,
refetch: vi.fn(),
});
vi.mocked(useUpdateCodexLensConfig).mockReturnValue({
updateConfig: vi.fn().mockResolvedValue({ success: true, message: 'Saved' }),
isUpdating: false,
error: null,
});
setupDefaultMocks();
});
it('should render current info card', () => {
@@ -85,25 +125,29 @@ describe('SettingsTab', () => {
expect(screen.getByText('8')).toBeInTheDocument();
});
it('should render configuration form', () => {
it('should render configuration form with index directory', () => {
render(<SettingsTab enabled={true} />);
expect(screen.getByText(/Basic Configuration/i)).toBeInTheDocument();
expect(screen.getByLabelText(/Index Directory/i)).toBeInTheDocument();
expect(screen.getByLabelText(/Max Workers/i)).toBeInTheDocument();
expect(screen.getByLabelText(/Batch Size/i)).toBeInTheDocument();
});
it('should initialize form with config values', () => {
it('should render env var group sections', () => {
render(<SettingsTab enabled={true} />);
// Schema groups should be rendered (labels come from i18n, check for group icons/sections)
expect(screen.getByText(/Embedding/i)).toBeInTheDocument();
expect(screen.getByText(/Reranker/i)).toBeInTheDocument();
expect(screen.getByText(/Concurrency/i)).toBeInTheDocument();
expect(screen.getByText(/Cascade/i)).toBeInTheDocument();
expect(screen.getByText(/Chunking/i)).toBeInTheDocument();
});
it('should initialize index dir from config', () => {
render(<SettingsTab enabled={true} />);
const indexDirInput = screen.getByLabelText(/Index Directory/i) as HTMLInputElement;
const maxWorkersInput = screen.getByLabelText(/Max Workers/i) as HTMLInputElement;
const batchSizeInput = screen.getByLabelText(/Batch Size/i) as HTMLInputElement;
expect(indexDirInput.value).toBe('~/.codexlens/indexes');
expect(maxWorkersInput.value).toBe('4');
expect(batchSizeInput.value).toBe('8');
});
it('should show save button enabled when changes are made', async () => {
@@ -128,10 +172,10 @@ describe('SettingsTab', () => {
expect(resetButton).toBeDisabled();
});
it('should call updateConfig on save', async () => {
const updateConfig = vi.fn().mockResolvedValue({ success: true, message: 'Saved' });
vi.mocked(useUpdateCodexLensConfig).mockReturnValue({
updateConfig,
it('should call updateEnv on save', async () => {
const updateEnv = vi.fn().mockResolvedValue({ success: true, message: 'Saved' });
vi.mocked(useUpdateCodexLensEnv).mockReturnValue({
updateEnv,
isUpdating: false,
error: null,
});
@@ -171,10 +215,11 @@ describe('SettingsTab', () => {
await user.click(saveButton);
await waitFor(() => {
expect(updateConfig).toHaveBeenCalledWith({
index_dir: '/new/index/path',
api_max_workers: 4,
api_batch_size: 8,
expect(updateEnv).toHaveBeenCalledWith({
env: expect.objectContaining({
CODEXLENS_EMBEDDING_BACKEND: 'local',
CODEXLENS_EMBEDDING_MODEL: 'fast',
}),
});
});
});
@@ -198,21 +243,7 @@ describe('SettingsTab', () => {
describe('form validation', () => {
beforeEach(() => {
vi.mocked(useCodexLensConfig).mockReturnValue({
config: mockConfig,
indexDir: mockConfig.index_dir,
indexCount: 100,
apiMaxWorkers: 4,
apiBatchSize: 8,
isLoading: false,
error: null,
refetch: vi.fn(),
});
vi.mocked(useUpdateCodexLensConfig).mockReturnValue({
updateConfig: vi.fn().mockResolvedValue({ success: true }),
isUpdating: false,
error: null,
});
setupDefaultMocks();
});
it('should validate index dir is required', async () => {
@@ -228,62 +259,6 @@ describe('SettingsTab', () => {
expect(screen.getByText(/Index directory is required/i)).toBeInTheDocument();
});
it('should validate max workers range (1-32)', async () => {
const user = userEvent.setup();
render(<SettingsTab enabled={true} />);
const maxWorkersInput = screen.getByLabelText(/Max Workers/i);
await user.clear(maxWorkersInput);
await user.type(maxWorkersInput, '0');
const saveButton = screen.getByText(/Save/i);
await user.click(saveButton);
expect(screen.getByText(/Workers must be between 1 and 32/i)).toBeInTheDocument();
});
it('should validate max workers upper bound', async () => {
const user = userEvent.setup();
render(<SettingsTab enabled={true} />);
const maxWorkersInput = screen.getByLabelText(/Max Workers/i);
await user.clear(maxWorkersInput);
await user.type(maxWorkersInput, '33');
const saveButton = screen.getByText(/Save/i);
await user.click(saveButton);
expect(screen.getByText(/Workers must be between 1 and 32/i)).toBeInTheDocument();
});
it('should validate batch size range (1-64)', async () => {
const user = userEvent.setup();
render(<SettingsTab enabled={true} />);
const batchSizeInput = screen.getByLabelText(/Batch Size/i);
await user.clear(batchSizeInput);
await user.type(batchSizeInput, '0');
const saveButton = screen.getByText(/Save/i);
await user.click(saveButton);
expect(screen.getByText(/Batch size must be between 1 and 64/i)).toBeInTheDocument();
});
it('should validate batch size upper bound', async () => {
const user = userEvent.setup();
render(<SettingsTab enabled={true} />);
const batchSizeInput = screen.getByLabelText(/Batch Size/i);
await user.clear(batchSizeInput);
await user.type(batchSizeInput, '65');
const saveButton = screen.getByText(/Save/i);
await user.click(saveButton);
expect(screen.getByText(/Batch size must be between 1 and 64/i)).toBeInTheDocument();
});
it('should clear error when user fixes invalid input', async () => {
const user = userEvent.setup();
render(<SettingsTab enabled={true} />);
@@ -304,28 +279,13 @@ describe('SettingsTab', () => {
describe('when disabled', () => {
beforeEach(() => {
vi.mocked(useCodexLensConfig).mockReturnValue({
config: mockConfig,
indexDir: mockConfig.index_dir,
indexCount: 100,
apiMaxWorkers: 4,
apiBatchSize: 8,
isLoading: false,
error: null,
refetch: vi.fn(),
});
vi.mocked(useUpdateCodexLensConfig).mockReturnValue({
updateConfig: vi.fn().mockResolvedValue({ success: true }),
isUpdating: false,
error: null,
});
setupDefaultMocks();
});
it('should not render when enabled is false', () => {
render(<SettingsTab enabled={false} />);
// When not enabled, the component may render nothing or an empty state
// This test documents the expected behavior
// When not enabled, hooks are disabled so no config/env data
expect(screen.queryByText(/Basic Configuration/i)).not.toBeInTheDocument();
});
});
@@ -342,11 +302,28 @@ describe('SettingsTab', () => {
error: null,
refetch: vi.fn(),
});
vi.mocked(useUpdateCodexLensConfig).mockReturnValue({
updateConfig: vi.fn().mockResolvedValue({ success: true }),
vi.mocked(useCodexLensEnv).mockReturnValue({
data: { success: true, env: mockEnv, settings: {}, path: '' },
env: mockEnv,
settings: {},
raw: '',
isLoading: false,
error: null,
refetch: vi.fn(),
});
vi.mocked(useUpdateCodexLensEnv).mockReturnValue({
updateEnv: vi.fn().mockResolvedValue({ success: true }),
isUpdating: false,
error: null,
});
vi.mocked(useCodexLensModels).mockReturnValue({
models: [],
embeddingModels: [],
rerankerModels: [],
isLoading: false,
error: null,
refetch: vi.fn(),
});
render(<SettingsTab enabled={true} />);
@@ -355,18 +332,9 @@ describe('SettingsTab', () => {
});
it('should show saving state when updating', async () => {
vi.mocked(useCodexLensConfig).mockReturnValue({
config: mockConfig,
indexDir: mockConfig.index_dir,
indexCount: 100,
apiMaxWorkers: 4,
apiBatchSize: 8,
isLoading: false,
error: null,
refetch: vi.fn(),
});
vi.mocked(useUpdateCodexLensConfig).mockReturnValue({
updateConfig: vi.fn().mockResolvedValue({ success: true }),
setupDefaultMocks();
vi.mocked(useUpdateCodexLensEnv).mockReturnValue({
updateEnv: vi.fn().mockResolvedValue({ success: true }),
isUpdating: true,
error: null,
});
@@ -385,21 +353,7 @@ describe('SettingsTab', () => {
describe('i18n - Chinese locale', () => {
beforeEach(() => {
vi.mocked(useCodexLensConfig).mockReturnValue({
config: mockConfig,
indexDir: mockConfig.index_dir,
indexCount: 100,
apiMaxWorkers: 4,
apiBatchSize: 8,
isLoading: false,
error: null,
refetch: vi.fn(),
});
vi.mocked(useUpdateCodexLensConfig).mockReturnValue({
updateConfig: vi.fn().mockResolvedValue({ success: true }),
isUpdating: false,
error: null,
});
setupDefaultMocks();
});
it('should display translated labels', () => {
@@ -410,8 +364,6 @@ describe('SettingsTab', () => {
expect(screen.getByText(/当前批次大小/i)).toBeInTheDocument();
expect(screen.getByText(/基本配置/i)).toBeInTheDocument();
expect(screen.getByText(/索引目录/i)).toBeInTheDocument();
expect(screen.getByText(/最大工作线程/i)).toBeInTheDocument();
expect(screen.getByText(/批次大小/i)).toBeInTheDocument();
expect(screen.getByText(/保存/i)).toBeInTheDocument();
expect(screen.getByText(/重置/i)).toBeInTheDocument();
});
@@ -432,6 +384,7 @@ describe('SettingsTab', () => {
describe('error handling', () => {
it('should show error notification on save failure', async () => {
setupDefaultMocks();
const error = vi.fn();
vi.mocked(useNotifications).mockReturnValue({
toasts: [],
@@ -455,18 +408,8 @@ describe('SettingsTab', () => {
removePersistentNotification: vi.fn(),
clearPersistentNotifications: vi.fn(),
});
vi.mocked(useCodexLensConfig).mockReturnValue({
config: mockConfig,
indexDir: mockConfig.index_dir,
indexCount: 100,
apiMaxWorkers: 4,
apiBatchSize: 8,
isLoading: false,
error: null,
refetch: vi.fn(),
});
vi.mocked(useUpdateCodexLensConfig).mockReturnValue({
updateConfig: vi.fn().mockResolvedValue({ success: false, message: 'Save failed' }),
vi.mocked(useUpdateCodexLensEnv).mockReturnValue({
updateEnv: vi.fn().mockResolvedValue({ success: false, message: 'Save failed' }),
isUpdating: false,
error: null,
});

View File

@@ -1,152 +1,197 @@
// ========================================
// CodexLens Settings Tab
// ========================================
// Configuration form for basic CodexLens settings
// Structured form for CodexLens env configuration
// Renders 5 groups: embedding, reranker, concurrency, cascade, chunking
// Plus a general config section (index_dir)
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useIntl } from 'react-intl';
import { Save, RefreshCw } from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button';
import { Label } from '@/components/ui/Label';
import { useCodexLensConfig, useUpdateCodexLensConfig } from '@/hooks';
import {
useCodexLensConfig,
useCodexLensEnv,
useUpdateCodexLensEnv,
useCodexLensModels,
} from '@/hooks';
import { useNotifications } from '@/hooks';
import { cn } from '@/lib/utils';
import { SchemaFormRenderer } from './SchemaFormRenderer';
import { envVarGroupsSchema, getSchemaDefaults } from './envVarSchema';
interface SettingsTabProps {
enabled?: boolean;
}
interface FormErrors {
index_dir?: string;
api_max_workers?: string;
api_batch_size?: string;
}
export function SettingsTab({ enabled = true }: SettingsTabProps) {
const { formatMessage } = useIntl();
const { success, error: showError } = useNotifications();
// Fetch current config (index_dir, workers, batch_size)
const {
config,
indexCount,
apiMaxWorkers,
apiBatchSize,
isLoading: isLoadingConfig,
refetch,
refetch: refetchConfig,
} = useCodexLensConfig({ enabled });
const { updateConfig, isUpdating } = useUpdateCodexLensConfig();
// Fetch env vars and settings
const {
env: serverEnv,
settings: serverSettings,
isLoading: isLoadingEnv,
refetch: refetchEnv,
} = useCodexLensEnv({ enabled });
// Form state
const [formData, setFormData] = useState({
index_dir: '',
api_max_workers: 4,
api_batch_size: 8,
});
const [errors, setErrors] = useState<FormErrors>({});
// Fetch local models for model-select fields
const {
embeddingModels: localEmbeddingModels,
rerankerModels: localRerankerModels,
} = useCodexLensModels({ enabled });
const { updateEnv, isUpdating } = useUpdateCodexLensEnv();
// General form state (index_dir)
const [indexDir, setIndexDir] = useState('');
const [indexDirError, setIndexDirError] = useState('');
// Schema-driven env var form state
const [envValues, setEnvValues] = useState<Record<string, string>>({});
const [hasChanges, setHasChanges] = useState(false);
// Initialize form from config
// Store the initial values for change detection
const [initialEnvValues, setInitialEnvValues] = useState<Record<string, string>>({});
const [initialIndexDir, setInitialIndexDir] = useState('');
// Initialize form from server data
useEffect(() => {
if (config) {
setFormData({
index_dir: config.index_dir || '',
api_max_workers: config.api_max_workers || 4,
api_batch_size: config.api_batch_size || 8,
});
setErrors({});
setHasChanges(false);
setIndexDir(config.index_dir || '');
setInitialIndexDir(config.index_dir || '');
}
}, [config]);
const handleFieldChange = (field: keyof typeof formData, value: string | number) => {
setFormData((prev) => {
const newData = { ...prev, [field]: value };
// Check if there are changes
if (config) {
const changed =
newData.index_dir !== config.index_dir ||
newData.api_max_workers !== config.api_max_workers ||
newData.api_batch_size !== config.api_batch_size;
setHasChanges(changed);
useEffect(() => {
if (serverEnv || serverSettings) {
const defaults = getSchemaDefaults();
const merged: Record<string, string> = { ...defaults };
// Settings.json values override defaults
if (serverSettings) {
for (const [key, val] of Object.entries(serverSettings)) {
if (val) merged[key] = val;
}
}
return newData;
});
// Clear error for this field
if (errors[field as keyof FormErrors]) {
setErrors((prev) => ({ ...prev, [field]: undefined }));
// .env values override settings
if (serverEnv) {
for (const [key, val] of Object.entries(serverEnv)) {
if (val) merged[key] = val;
}
}
setEnvValues(merged);
setInitialEnvValues(merged);
setHasChanges(false);
}
};
}, [serverEnv, serverSettings]);
const validateForm = (): boolean => {
const newErrors: FormErrors = {};
// Check for changes
const detectChanges = useCallback(
(currentEnv: Record<string, string>, currentIndexDir: string) => {
if (currentIndexDir !== initialIndexDir) return true;
for (const key of Object.keys(currentEnv)) {
if (currentEnv[key] !== initialEnvValues[key]) return true;
}
return false;
},
[initialEnvValues, initialIndexDir]
);
// Index dir required
if (!formData.index_dir.trim()) {
newErrors.index_dir = formatMessage({ id: 'codexlens.settings.validation.indexDirRequired' });
}
const handleEnvChange = useCallback(
(key: string, value: string) => {
setEnvValues((prev) => {
const next = { ...prev, [key]: value };
setHasChanges(detectChanges(next, indexDir));
return next;
});
},
[detectChanges, indexDir]
);
// API max workers: 1-32
if (formData.api_max_workers < 1 || formData.api_max_workers > 32) {
newErrors.api_max_workers = formatMessage({ id: 'codexlens.settings.validation.maxWorkersRange' });
}
const handleIndexDirChange = useCallback(
(value: string) => {
setIndexDir(value);
setIndexDirError('');
setHasChanges(detectChanges(envValues, value));
},
[detectChanges, envValues]
);
// API batch size: 1-64
if (formData.api_batch_size < 1 || formData.api_batch_size > 64) {
newErrors.api_batch_size = formatMessage({ id: 'codexlens.settings.validation.batchSizeRange' });
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
// Installed local models filtered to installed-only
const installedEmbeddingModels = useMemo(
() => (localEmbeddingModels || []).filter((m) => m.installed),
[localEmbeddingModels]
);
const installedRerankerModels = useMemo(
() => (localRerankerModels || []).filter((m) => m.installed),
[localRerankerModels]
);
const handleSave = async () => {
if (!validateForm()) {
// Validate index_dir
if (!indexDir.trim()) {
setIndexDirError(
formatMessage({ id: 'codexlens.settings.validation.indexDirRequired' })
);
return;
}
try {
const result = await updateConfig({
index_dir: formData.index_dir,
api_max_workers: formData.api_max_workers,
api_batch_size: formData.api_batch_size,
});
const result = await updateEnv({ env: envValues });
if (result.success) {
success(
formatMessage({ id: 'codexlens.settings.saveSuccess' }),
result.message || formatMessage({ id: 'codexlens.settings.configUpdated' })
result.message ||
formatMessage({ id: 'codexlens.settings.configUpdated' })
);
refetch();
refetchEnv();
refetchConfig();
setHasChanges(false);
setInitialEnvValues(envValues);
setInitialIndexDir(indexDir);
} else {
showError(
formatMessage({ id: 'codexlens.settings.saveFailed' }),
result.message || formatMessage({ id: 'codexlens.settings.saveError' })
result.message ||
formatMessage({ id: 'codexlens.settings.saveError' })
);
}
} catch (err) {
showError(
formatMessage({ id: 'codexlens.settings.saveFailed' }),
err instanceof Error ? err.message : formatMessage({ id: 'codexlens.settings.unknownError' })
err instanceof Error
? err.message
: formatMessage({ id: 'codexlens.settings.unknownError' })
);
}
};
const handleReset = () => {
if (config) {
setFormData({
index_dir: config.index_dir || '',
api_max_workers: config.api_max_workers || 4,
api_batch_size: config.api_batch_size || 8,
});
setErrors({});
setHasChanges(false);
}
setEnvValues(initialEnvValues);
setIndexDir(initialIndexDir);
setIndexDirError('');
setHasChanges(false);
};
const isLoading = isLoadingConfig;
const isLoading = isLoadingConfig || isLoadingEnv;
return (
<div className="space-y-6">
@@ -154,106 +199,77 @@ export function SettingsTab({ enabled = true }: SettingsTabProps) {
<Card className="p-4 bg-muted/30">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
<div>
<span className="text-muted-foreground">{formatMessage({ id: 'codexlens.settings.currentCount' })}</span>
<span className="text-muted-foreground">
{formatMessage({ id: 'codexlens.settings.currentCount' })}
</span>
<p className="text-foreground font-medium">{indexCount}</p>
</div>
<div>
<span className="text-muted-foreground">{formatMessage({ id: 'codexlens.settings.currentWorkers' })}</span>
<span className="text-muted-foreground">
{formatMessage({ id: 'codexlens.settings.currentWorkers' })}
</span>
<p className="text-foreground font-medium">{apiMaxWorkers}</p>
</div>
<div>
<span className="text-muted-foreground">{formatMessage({ id: 'codexlens.settings.currentBatchSize' })}</span>
<span className="text-muted-foreground">
{formatMessage({ id: 'codexlens.settings.currentBatchSize' })}
</span>
<p className="text-foreground font-medium">{apiBatchSize}</p>
</div>
</div>
</Card>
{/* Configuration Form */}
{/* General Configuration */}
<Card className="p-6">
<h3 className="text-lg font-semibold text-foreground mb-4">
{formatMessage({ id: 'codexlens.settings.configTitle' })}
</h3>
<div className="space-y-4">
{/* Index Directory */}
<div className="space-y-2">
<Label htmlFor="index_dir">
{formatMessage({ id: 'codexlens.settings.indexDir.label' })}
</Label>
<Input
id="index_dir"
value={formData.index_dir}
onChange={(e) => handleFieldChange('index_dir', e.target.value)}
placeholder={formatMessage({ id: 'codexlens.settings.indexDir.placeholder' })}
error={!!errors.index_dir}
disabled={isLoading}
/>
{errors.index_dir && (
<p className="text-sm text-destructive">{errors.index_dir}</p>
)}
<p className="text-xs text-muted-foreground">
{formatMessage({ id: 'codexlens.settings.indexDir.hint' })}
</p>
</div>
{/* API Max Workers */}
<div className="space-y-2">
<Label htmlFor="api_max_workers">
{formatMessage({ id: 'codexlens.settings.maxWorkers.label' })}
</Label>
<Input
id="api_max_workers"
type="number"
min="1"
max="32"
value={formData.api_max_workers}
onChange={(e) => handleFieldChange('api_max_workers', parseInt(e.target.value) || 1)}
error={!!errors.api_max_workers}
disabled={isLoading}
/>
{errors.api_max_workers && (
<p className="text-sm text-destructive">{errors.api_max_workers}</p>
)}
<p className="text-xs text-muted-foreground">
{formatMessage({ id: 'codexlens.settings.maxWorkers.hint' })}
</p>
</div>
{/* API Batch Size */}
<div className="space-y-2">
<Label htmlFor="api_batch_size">
{formatMessage({ id: 'codexlens.settings.batchSize.label' })}
</Label>
<Input
id="api_batch_size"
type="number"
min="1"
max="64"
value={formData.api_batch_size}
onChange={(e) => handleFieldChange('api_batch_size', parseInt(e.target.value) || 1)}
error={!!errors.api_batch_size}
disabled={isLoading}
/>
{errors.api_batch_size && (
<p className="text-sm text-destructive">{errors.api_batch_size}</p>
)}
<p className="text-xs text-muted-foreground">
{formatMessage({ id: 'codexlens.settings.batchSize.hint' })}
</p>
</div>
{/* Index Directory */}
<div className="space-y-2 mb-4">
<Label htmlFor="index_dir">
{formatMessage({ id: 'codexlens.settings.indexDir.label' })}
</Label>
<Input
id="index_dir"
value={indexDir}
onChange={(e) => handleIndexDirChange(e.target.value)}
placeholder={formatMessage({
id: 'codexlens.settings.indexDir.placeholder',
})}
error={!!indexDirError}
disabled={isLoading}
/>
{indexDirError && (
<p className="text-sm text-destructive">{indexDirError}</p>
)}
<p className="text-xs text-muted-foreground">
{formatMessage({ id: 'codexlens.settings.indexDir.hint' })}
</p>
</div>
{/* Schema-driven Env Var Groups */}
<SchemaFormRenderer
groups={envVarGroupsSchema}
values={envValues}
onChange={handleEnvChange}
disabled={isLoading}
localEmbeddingModels={installedEmbeddingModels}
localRerankerModels={installedRerankerModels}
/>
{/* Action Buttons */}
<div className="flex items-center gap-2 mt-6">
<Button
onClick={handleSave}
disabled={isLoading || isUpdating || !hasChanges}
>
<Save className={cn('w-4 h-4 mr-2', isUpdating && 'animate-spin')} />
<Save
className={cn('w-4 h-4 mr-2', isUpdating && 'animate-spin')}
/>
{isUpdating
? formatMessage({ id: 'codexlens.settings.saving' })
: formatMessage({ id: 'codexlens.settings.save' })
}
: formatMessage({ id: 'codexlens.settings.save' })}
</Button>
<Button
variant="outline"

View File

@@ -0,0 +1,393 @@
// ========================================
// CodexLens Environment Variable Schema
// ========================================
// TypeScript port of ENV_VAR_GROUPS from codexlens-manager.js
// Defines the 5 structured groups: embedding, reranker, concurrency, cascade, chunking
import type { EnvVarGroupsSchema } from '@/types/codexlens';
export const envVarGroupsSchema: EnvVarGroupsSchema = {
embedding: {
id: 'embedding',
labelKey: 'codexlens.envGroup.embedding',
icon: 'box',
vars: {
CODEXLENS_EMBEDDING_BACKEND: {
key: 'CODEXLENS_EMBEDDING_BACKEND',
labelKey: 'codexlens.envField.backend',
type: 'select',
options: ['local', 'api'],
default: 'local',
settingsPath: 'embedding.backend',
},
CODEXLENS_EMBEDDING_MODEL: {
key: 'CODEXLENS_EMBEDDING_MODEL',
labelKey: 'codexlens.envField.model',
type: 'model-select',
placeholder: 'Select or enter model...',
default: 'fast',
settingsPath: 'embedding.model',
localModels: [
{
group: 'FastEmbed Profiles',
items: ['fast', 'code', 'base', 'minilm', 'multilingual', 'balanced'],
},
],
apiModels: [
{
group: 'OpenAI',
items: ['text-embedding-3-small', 'text-embedding-3-large', 'text-embedding-ada-002'],
},
{
group: 'Cohere',
items: ['embed-english-v3.0', 'embed-multilingual-v3.0', 'embed-english-light-v3.0'],
},
{
group: 'Voyage',
items: ['voyage-3', 'voyage-3-lite', 'voyage-code-3', 'voyage-multilingual-2'],
},
{
group: 'SiliconFlow',
items: ['BAAI/bge-m3', 'BAAI/bge-large-zh-v1.5', 'BAAI/bge-large-en-v1.5'],
},
{
group: 'Jina',
items: ['jina-embeddings-v3', 'jina-embeddings-v2-base-en', 'jina-embeddings-v2-base-zh'],
},
],
},
CODEXLENS_USE_GPU: {
key: 'CODEXLENS_USE_GPU',
labelKey: 'codexlens.envField.useGpu',
type: 'select',
options: ['true', 'false'],
default: 'true',
settingsPath: 'embedding.use_gpu',
showWhen: (env) => env['CODEXLENS_EMBEDDING_BACKEND'] === 'local',
},
CODEXLENS_EMBEDDING_POOL_ENABLED: {
key: 'CODEXLENS_EMBEDDING_POOL_ENABLED',
labelKey: 'codexlens.envField.highAvailability',
type: 'select',
options: ['true', 'false'],
default: 'false',
settingsPath: 'embedding.pool_enabled',
showWhen: (env) => env['CODEXLENS_EMBEDDING_BACKEND'] === 'api',
},
CODEXLENS_EMBEDDING_STRATEGY: {
key: 'CODEXLENS_EMBEDDING_STRATEGY',
labelKey: 'codexlens.envField.loadBalanceStrategy',
type: 'select',
options: ['round_robin', 'latency_aware', 'weighted_random'],
default: 'latency_aware',
settingsPath: 'embedding.strategy',
showWhen: (env) =>
env['CODEXLENS_EMBEDDING_BACKEND'] === 'api' &&
env['CODEXLENS_EMBEDDING_POOL_ENABLED'] === 'true',
},
CODEXLENS_EMBEDDING_COOLDOWN: {
key: 'CODEXLENS_EMBEDDING_COOLDOWN',
labelKey: 'codexlens.envField.rateLimitCooldown',
type: 'number',
placeholder: '60',
default: '60',
settingsPath: 'embedding.cooldown',
min: 0,
max: 300,
showWhen: (env) =>
env['CODEXLENS_EMBEDDING_BACKEND'] === 'api' &&
env['CODEXLENS_EMBEDDING_POOL_ENABLED'] === 'true',
},
},
},
reranker: {
id: 'reranker',
labelKey: 'codexlens.envGroup.reranker',
icon: 'arrow-up-down',
vars: {
CODEXLENS_RERANKER_ENABLED: {
key: 'CODEXLENS_RERANKER_ENABLED',
labelKey: 'codexlens.envField.enabled',
type: 'select',
options: ['true', 'false'],
default: 'true',
settingsPath: 'reranker.enabled',
},
CODEXLENS_RERANKER_BACKEND: {
key: 'CODEXLENS_RERANKER_BACKEND',
labelKey: 'codexlens.envField.backend',
type: 'select',
options: ['local', 'api'],
default: 'local',
settingsPath: 'reranker.backend',
},
CODEXLENS_RERANKER_MODEL: {
key: 'CODEXLENS_RERANKER_MODEL',
labelKey: 'codexlens.envField.model',
type: 'model-select',
placeholder: 'Select or enter model...',
default: 'Xenova/ms-marco-MiniLM-L-6-v2',
settingsPath: 'reranker.model',
localModels: [
{
group: 'FastEmbed/ONNX',
items: [
'Xenova/ms-marco-MiniLM-L-6-v2',
'cross-encoder/ms-marco-MiniLM-L-6-v2',
'BAAI/bge-reranker-base',
],
},
],
apiModels: [
{
group: 'Cohere',
items: ['rerank-english-v3.0', 'rerank-multilingual-v3.0', 'rerank-english-v2.0'],
},
{
group: 'Voyage',
items: ['rerank-2', 'rerank-2-lite', 'rerank-1'],
},
{
group: 'SiliconFlow',
items: ['BAAI/bge-reranker-v2-m3', 'BAAI/bge-reranker-large', 'BAAI/bge-reranker-base'],
},
{
group: 'Jina',
items: ['jina-reranker-v2-base-multilingual', 'jina-reranker-v1-base-en'],
},
],
},
CODEXLENS_RERANKER_TOP_K: {
key: 'CODEXLENS_RERANKER_TOP_K',
labelKey: 'codexlens.envField.topKResults',
type: 'number',
placeholder: '50',
default: '50',
settingsPath: 'reranker.top_k',
min: 5,
max: 200,
},
CODEXLENS_RERANKER_POOL_ENABLED: {
key: 'CODEXLENS_RERANKER_POOL_ENABLED',
labelKey: 'codexlens.envField.highAvailability',
type: 'select',
options: ['true', 'false'],
default: 'false',
settingsPath: 'reranker.pool_enabled',
showWhen: (env) => env['CODEXLENS_RERANKER_BACKEND'] === 'api',
},
CODEXLENS_RERANKER_STRATEGY: {
key: 'CODEXLENS_RERANKER_STRATEGY',
labelKey: 'codexlens.envField.loadBalanceStrategy',
type: 'select',
options: ['round_robin', 'latency_aware', 'weighted_random'],
default: 'latency_aware',
settingsPath: 'reranker.strategy',
showWhen: (env) =>
env['CODEXLENS_RERANKER_BACKEND'] === 'api' &&
env['CODEXLENS_RERANKER_POOL_ENABLED'] === 'true',
},
CODEXLENS_RERANKER_COOLDOWN: {
key: 'CODEXLENS_RERANKER_COOLDOWN',
labelKey: 'codexlens.envField.rateLimitCooldown',
type: 'number',
placeholder: '60',
default: '60',
settingsPath: 'reranker.cooldown',
min: 0,
max: 300,
showWhen: (env) =>
env['CODEXLENS_RERANKER_BACKEND'] === 'api' &&
env['CODEXLENS_RERANKER_POOL_ENABLED'] === 'true',
},
},
},
concurrency: {
id: 'concurrency',
labelKey: 'codexlens.envGroup.concurrency',
icon: 'cpu',
vars: {
CODEXLENS_API_MAX_WORKERS: {
key: 'CODEXLENS_API_MAX_WORKERS',
labelKey: 'codexlens.envField.maxWorkers',
type: 'number',
placeholder: '4',
default: '4',
settingsPath: 'api.max_workers',
min: 1,
max: 32,
},
CODEXLENS_API_BATCH_SIZE: {
key: 'CODEXLENS_API_BATCH_SIZE',
labelKey: 'codexlens.envField.batchSize',
type: 'number',
placeholder: '8',
default: '8',
settingsPath: 'api.batch_size',
min: 1,
max: 64,
showWhen: (env) => env['CODEXLENS_API_BATCH_SIZE_DYNAMIC'] !== 'true',
},
CODEXLENS_API_BATCH_SIZE_DYNAMIC: {
key: 'CODEXLENS_API_BATCH_SIZE_DYNAMIC',
labelKey: 'codexlens.envField.dynamicBatchSize',
type: 'checkbox',
default: 'false',
settingsPath: 'api.batch_size_dynamic',
},
CODEXLENS_API_BATCH_SIZE_UTILIZATION: {
key: 'CODEXLENS_API_BATCH_SIZE_UTILIZATION',
labelKey: 'codexlens.envField.batchSizeUtilization',
type: 'number',
placeholder: '0.8',
default: '0.8',
settingsPath: 'api.batch_size_utilization_factor',
min: 0.1,
max: 0.95,
step: 0.05,
showWhen: (env) => env['CODEXLENS_API_BATCH_SIZE_DYNAMIC'] === 'true',
},
CODEXLENS_API_BATCH_SIZE_MAX: {
key: 'CODEXLENS_API_BATCH_SIZE_MAX',
labelKey: 'codexlens.envField.batchSizeMax',
type: 'number',
placeholder: '2048',
default: '2048',
settingsPath: 'api.batch_size_max',
min: 1,
max: 4096,
showWhen: (env) => env['CODEXLENS_API_BATCH_SIZE_DYNAMIC'] === 'true',
},
CODEXLENS_CHARS_PER_TOKEN: {
key: 'CODEXLENS_CHARS_PER_TOKEN',
labelKey: 'codexlens.envField.charsPerToken',
type: 'number',
placeholder: '4',
default: '4',
settingsPath: 'api.chars_per_token_estimate',
min: 1,
max: 10,
showWhen: (env) => env['CODEXLENS_API_BATCH_SIZE_DYNAMIC'] === 'true',
},
},
},
cascade: {
id: 'cascade',
labelKey: 'codexlens.envGroup.cascade',
icon: 'git-branch',
vars: {
CODEXLENS_CASCADE_STRATEGY: {
key: 'CODEXLENS_CASCADE_STRATEGY',
labelKey: 'codexlens.envField.searchStrategy',
type: 'select',
options: ['binary', 'hybrid', 'binary_rerank', 'dense_rerank'],
default: 'dense_rerank',
settingsPath: 'cascade.strategy',
},
CODEXLENS_CASCADE_COARSE_K: {
key: 'CODEXLENS_CASCADE_COARSE_K',
labelKey: 'codexlens.envField.coarseK',
type: 'number',
placeholder: '100',
default: '100',
settingsPath: 'cascade.coarse_k',
min: 10,
max: 500,
},
CODEXLENS_CASCADE_FINE_K: {
key: 'CODEXLENS_CASCADE_FINE_K',
labelKey: 'codexlens.envField.fineK',
type: 'number',
placeholder: '10',
default: '10',
settingsPath: 'cascade.fine_k',
min: 1,
max: 100,
},
},
},
chunking: {
id: 'chunking',
labelKey: 'codexlens.envGroup.chunking',
icon: 'scissors',
vars: {
CHUNK_STRIP_COMMENTS: {
key: 'CHUNK_STRIP_COMMENTS',
labelKey: 'codexlens.envField.stripComments',
type: 'select',
options: ['true', 'false'],
default: 'true',
settingsPath: 'chunking.strip_comments',
},
CHUNK_STRIP_DOCSTRINGS: {
key: 'CHUNK_STRIP_DOCSTRINGS',
labelKey: 'codexlens.envField.stripDocstrings',
type: 'select',
options: ['true', 'false'],
default: 'true',
settingsPath: 'chunking.strip_docstrings',
},
RERANKER_TEST_FILE_PENALTY: {
key: 'RERANKER_TEST_FILE_PENALTY',
labelKey: 'codexlens.envField.testFilePenalty',
type: 'number',
placeholder: '0.0',
default: '0.0',
settingsPath: 'reranker.test_file_penalty',
min: 0,
max: 1,
step: 0.1,
},
RERANKER_DOCSTRING_WEIGHT: {
key: 'RERANKER_DOCSTRING_WEIGHT',
labelKey: 'codexlens.envField.docstringWeight',
type: 'number',
placeholder: '1.0',
default: '1.0',
settingsPath: 'reranker.docstring_weight',
min: 0,
max: 1,
step: 0.1,
},
},
},
};
/**
* Get all env var keys from the schema
*/
export function getAllEnvVarKeys(): string[] {
const keys: string[] = [];
for (const group of Object.values(envVarGroupsSchema)) {
for (const key of Object.keys(group.vars)) {
keys.push(key);
}
}
return keys;
}
/**
* Evaluate showWhen condition for a field
*/
export function evaluateShowWhen(
field: { showWhen?: (env: Record<string, string>) => boolean },
values: Record<string, string>
): boolean {
if (!field.showWhen) return true;
return field.showWhen(values);
}
/**
* Get default values for all env vars in the schema
*/
export function getSchemaDefaults(): Record<string, string> {
const defaults: Record<string, string> = {};
for (const group of Object.values(envVarGroupsSchema)) {
for (const [key, field] of Object.entries(group.vars)) {
if (field.default !== undefined) {
defaults[key] = field.default;
}
}
}
return defaults;
}

View File

@@ -267,6 +267,8 @@ export {
useRebuildIndex,
useUpdateIndex,
useCancelIndexing,
useCodexLensWatcher,
useCodexLensWatcherMutations,
} from './useCodexLens';
export type {
UseCodexLensDashboardOptions,
@@ -301,4 +303,7 @@ export type {
UseRebuildIndexReturn,
UseUpdateIndexReturn,
UseCancelIndexingReturn,
UseCodexLensWatcherOptions,
UseCodexLensWatcherReturn,
UseCodexLensWatcherMutationsReturn,
} from './useCodexLens';

View File

@@ -38,6 +38,9 @@ import {
updateCodexLensIndex,
cancelCodexLensIndexing,
checkCodexLensIndexingStatus,
fetchCodexLensWatcherStatus,
startCodexLensWatcher,
stopCodexLensWatcher,
type CodexLensDashboardInitResponse,
type CodexLensVenvStatus,
type CodexLensConfig,
@@ -57,6 +60,7 @@ import {
type CodexLensIndexesResponse,
type CodexLensIndexingStatusResponse,
type CodexLensSemanticInstallResponse,
type CodexLensWatcherStatusResponse,
} from '../lib/api';
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
@@ -79,6 +83,7 @@ export const codexLensKeys = {
search: (params: CodexLensSearchParams) => [...codexLensKeys.all, 'search', params] as const,
filesSearch: (params: CodexLensSearchParams) => [...codexLensKeys.all, 'filesSearch', params] as const,
symbolSearch: (params: Pick<CodexLensSearchParams, 'query' | 'limit'>) => [...codexLensKeys.all, 'symbolSearch', params] as const,
watcher: () => [...codexLensKeys.all, 'watcher'] as const,
};
// Default stale times
@@ -1351,3 +1356,108 @@ export function useCodexLensSymbolSearch(
refetch,
};
}
// ========== File Watcher Hooks ==========
export interface UseCodexLensWatcherOptions {
enabled?: boolean;
}
export interface UseCodexLensWatcherReturn {
data: CodexLensWatcherStatusResponse | undefined;
running: boolean;
rootPath: string;
eventsProcessed: number;
uptimeSeconds: number;
isLoading: boolean;
error: Error | null;
}
/**
* Hook for checking CodexLens file watcher status
* Polls every 3 seconds when watcher is running
*/
export function useCodexLensWatcher(options: UseCodexLensWatcherOptions = {}): UseCodexLensWatcherReturn {
const { enabled = true } = options;
const query = useQuery({
queryKey: codexLensKeys.watcher(),
queryFn: fetchCodexLensWatcherStatus,
staleTime: STALE_TIME_SHORT,
enabled,
refetchInterval: (query) => {
const data = query.state.data as CodexLensWatcherStatusResponse | undefined;
return data?.running ? 3000 : false;
},
retry: 2,
});
return {
data: query.data,
running: query.data?.running ?? false,
rootPath: query.data?.root_path ?? '',
eventsProcessed: query.data?.events_processed ?? 0,
uptimeSeconds: query.data?.uptime_seconds ?? 0,
isLoading: query.isLoading,
error: query.error,
};
}
export interface UseCodexLensWatcherMutationsReturn {
startWatcher: (path?: string, debounceMs?: number) => Promise<{ success: boolean; message?: string; error?: string }>;
stopWatcher: () => Promise<{ success: boolean; message?: string; error?: string }>;
isStarting: boolean;
isStopping: boolean;
}
/**
* Hook for file watcher start/stop mutations
*/
export function useCodexLensWatcherMutations(): UseCodexLensWatcherMutationsReturn {
const queryClient = useQueryClient();
const formatMessage = useFormatMessage();
const { success, error: errorToast } = useNotifications();
const startMutation = useMutation({
mutationFn: ({ path, debounceMs }: { path?: string; debounceMs?: number }) =>
startCodexLensWatcher(path, debounceMs),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: codexLensKeys.watcher() });
success(
formatMessage({ id: 'common.success' }),
formatMessage({ id: 'codexlens.watcher.started' })
);
},
onError: (err) => {
const sanitized = sanitizeErrorMessage(err, 'codexLensWatcherStart');
const message = formatMessage({ id: sanitized.messageKey });
const title = formatMessage({ id: 'common.error' });
errorToast(title, message);
},
});
const stopMutation = useMutation({
mutationFn: () => stopCodexLensWatcher(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: codexLensKeys.watcher() });
success(
formatMessage({ id: 'common.success' }),
formatMessage({ id: 'codexlens.watcher.stopped' })
);
},
onError: (err) => {
const sanitized = sanitizeErrorMessage(err, 'codexLensWatcherStop');
const message = formatMessage({ id: sanitized.messageKey });
const title = formatMessage({ id: 'common.error' });
errorToast(title, message);
},
});
return {
startWatcher: (path?: string, debounceMs?: number) =>
startMutation.mutateAsync({ path, debounceMs }),
stopWatcher: () => stopMutation.mutateAsync(),
isStarting: startMutation.isPending,
isStopping: stopMutation.isPending,
};
}

View File

@@ -4449,6 +4449,47 @@ export async function cleanCodexLensIndexes(options: {
});
}
// ========== CodexLens File Watcher API ==========
/**
* CodexLens watcher status response
*/
export interface CodexLensWatcherStatusResponse {
success: boolean;
running: boolean;
root_path: string;
events_processed: number;
start_time: string | null;
uptime_seconds: number;
}
/**
* Fetch CodexLens file watcher status
*/
export async function fetchCodexLensWatcherStatus(): Promise<CodexLensWatcherStatusResponse> {
return fetchApi<CodexLensWatcherStatusResponse>('/api/codexlens/watch/status');
}
/**
* Start CodexLens file watcher
*/
export async function startCodexLensWatcher(path?: string, debounceMs?: number): Promise<{ success: boolean; message?: string; path?: string; pid?: number; error?: string }> {
return fetchApi('/api/codexlens/watch/start', {
method: 'POST',
body: JSON.stringify({ path, debounce_ms: debounceMs }),
});
}
/**
* Stop CodexLens file watcher
*/
export async function stopCodexLensWatcher(): Promise<{ success: boolean; message?: string; events_processed?: number; uptime_seconds?: number; error?: string }> {
return fetchApi('/api/codexlens/watch/stop', {
method: 'POST',
body: JSON.stringify({}),
});
}
// ========== LiteLLM API Settings API ==========
/**

View File

@@ -198,8 +198,9 @@
"cacheSize": "Cache size",
"used": "used",
"total": "total",
"actions": "Cache Actions",
"clearCache": "Clear Cache",
"actions": {
"clearCache": "Clear Cache"
},
"confirmClearCache": "Are you sure you want to clear the cache? This will remove all cached entries."
},
"statistics": {
@@ -295,6 +296,11 @@
"availableModelsHint": "Models shown in CLI dropdown menus. Click × to remove.",
"nameFormatHint": "Letters, numbers, hyphens, underscores only. Used as: ccw cli --tool [name]",
"nameTooLong": "Name must be {max} characters or less",
"configJson": "Configuration (JSON)",
"configJsonPlaceholder": "{\n \"env\": {\n \"CUSTOM_VAR\": \"value\"\n }\n}",
"configJsonHint": "Additional configuration in JSON format. This will be merged with the form fields above.",
"invalidJson": "Invalid JSON",
"configMustBeObject": "Configuration must be a JSON object",
"settingsFile": "Settings File",
"settingsFilePlaceholder": "e.g., /path/to/settings.json",
"settingsFileHint": "Path to external Claude CLI settings file (passed via --settings parameter)",

View File

@@ -238,5 +238,74 @@
"title": "CodexLens Not Installed",
"description": "Please install CodexLens to use semantic code search features."
}
},
"envGroup": {
"embedding": "Embedding",
"reranker": "Reranker",
"concurrency": "Concurrency",
"cascade": "Cascade Search",
"chunking": "Chunking"
},
"envField": {
"backend": "Backend",
"model": "Model",
"useGpu": "Use GPU",
"highAvailability": "High Availability",
"loadBalanceStrategy": "Load Balance Strategy",
"rateLimitCooldown": "Rate Limit Cooldown",
"enabled": "Enabled",
"topKResults": "Top K Results",
"maxWorkers": "Max Workers",
"batchSize": "Batch Size",
"dynamicBatchSize": "Dynamic Batch Size",
"batchSizeUtilization": "Utilization Factor",
"batchSizeMax": "Max Batch Size",
"charsPerToken": "Chars Per Token",
"searchStrategy": "Search Strategy",
"coarseK": "Coarse K",
"fineK": "Fine K",
"stripComments": "Strip Comments",
"stripDocstrings": "Strip Docstrings",
"testFilePenalty": "Test File Penalty",
"docstringWeight": "Docstring Weight"
},
"install": {
"title": "Install CodexLens",
"description": "Set up Python virtual environment and install CodexLens package.",
"checklist": "What will be installed",
"pythonVenv": "Python Virtual Environment",
"pythonVenvDesc": "Isolated Python environment for CodexLens",
"codexlensPackage": "CodexLens Package",
"codexlensPackageDesc": "Core semantic code search engine",
"sqliteFts": "SQLite FTS5",
"sqliteFtsDesc": "Full-text search extension for fast code lookup",
"location": "Install Location",
"locationPath": "~/.codexlens/venv",
"timeEstimate": "Installation may take 1-3 minutes depending on network speed.",
"stage": {
"creatingVenv": "Creating Python virtual environment...",
"installingPip": "Installing pip dependencies...",
"installingPackage": "Installing CodexLens package...",
"settingUpDeps": "Setting up dependencies...",
"finalizing": "Finalizing installation...",
"complete": "Installation complete!"
},
"installNow": "Install Now",
"installing": "Installing..."
},
"watcher": {
"title": "File Watcher",
"status": {
"running": "Running",
"stopped": "Stopped"
},
"eventsProcessed": "Events Processed",
"uptime": "Uptime",
"start": "Start Watcher",
"starting": "Starting...",
"stop": "Stop Watcher",
"stopping": "Stopping...",
"started": "File watcher started",
"stopped": "File watcher stopped"
}
}

View File

@@ -198,8 +198,9 @@
"cacheSize": "缓存大小",
"used": "已使用",
"total": "总计",
"actions": "缓存操作",
"clearCache": "清除缓存",
"actions": {
"clearCache": "清除缓存"
},
"confirmClearCache": "确定要清除缓存吗?这将删除所有缓存条目。"
},
"statistics": {
@@ -295,6 +296,11 @@
"availableModelsHint": "显示在 CLI 下拉菜单中的模型。点击 × 删除。",
"nameFormatHint": "仅限字母、数字、连字符和下划线。用作ccw cli --tool [名称]",
"nameTooLong": "名称必须在 {max} 个字符以内",
"configJson": "配置JSON",
"configJsonPlaceholder": "{\n \"env\": {\n \"CUSTOM_VAR\": \"value\"\n }\n}",
"configJsonHint": "JSON 格式的额外配置。将与上述表单字段合并。",
"invalidJson": "无效的 JSON",
"configMustBeObject": "配置必须是一个 JSON 对象",
"settingsFile": "配置文件路径",
"settingsFilePlaceholder": "例如:/path/to/settings.json",
"settingsFileHint": "外部 Claude CLI 配置文件路径(通过 --settings 参数传递)",

View File

@@ -238,5 +238,73 @@
"title": "CodexLens 未安装",
"description": "请先安装 CodexLens 以使用语义代码搜索功能。"
}
},
"envGroup": {
"embedding": "嵌入模型",
"reranker": "重排序",
"concurrency": "并发",
"cascade": "级联搜索",
"chunking": "分块"
},
"envField": {
"backend": "后端",
"model": "模型",
"useGpu": "使用 GPU",
"highAvailability": "高可用",
"loadBalanceStrategy": "负载均衡策略",
"rateLimitCooldown": "限流冷却时间",
"enabled": "启用",
"topKResults": "Top K 结果数",
"maxWorkers": "最大工作线程",
"batchSize": "批次大小",
"dynamicBatchSize": "动态批次大小",
"batchSizeUtilization": "利用率因子",
"batchSizeMax": "最大批次大小",
"charsPerToken": "每 Token 字符数",
"searchStrategy": "搜索策略",
"coarseK": "粗筛 K 值",
"fineK": "精筛 K 值",
"stripComments": "去除注释",
"stripDocstrings": "去除文档字符串",
"testFilePenalty": "测试文件惩罚",
"docstringWeight": "文档字符串权重"
},
"install": {
"title": "安装 CodexLens",
"description": "设置 Python 虚拟环境并安装 CodexLens 包。",
"checklist": "将要安装的内容",
"pythonVenv": "Python 虚拟环境",
"pythonVenvDesc": "CodexLens 的隔离 Python 环境",
"codexlensPackage": "CodexLens 包",
"codexlensPackageDesc": "核心语义代码搜索引擎",
"sqliteFts": "SQLite FTS5",
"sqliteFtsDesc": "用于快速代码查找的全文搜索扩展",
"location": "安装位置",
"locationPath": "~/.codexlens/venv",
"timeEstimate": "安装可能需要 1-3 分钟,取决于网络速度。",
"stage": {
"creatingVenv": "正在创建 Python 虚拟环境...",
"installingPip": "正在安装 pip 依赖...",
"installingPackage": "正在安装 CodexLens 包...",
"settingUpDeps": "正在设置依赖项...",
"finalizing": "正在完成安装...",
"complete": "安装完成!"
},
"installNow": "立即安装",
"installing": "安装中..."
},
"watcher": {
"status": {
"running": "运行中",
"stopped": "已停止"
},
"eventsProcessed": "已处理事件",
"uptime": "运行时间",
"start": "启动监听",
"starting": "启动中...",
"stop": "停止监听",
"stopping": "停止中...",
"started": "文件监听器已启动",
"stopped": "文件监听器已停止"
}
}

View File

@@ -24,12 +24,11 @@ import {
MultiKeySettingsModal,
ManageModelsModal,
} from '@/components/api-settings';
import { ConfigSync } from '@/components/shared';
import { useProviders, useEndpoints, useModelPools, useCliSettings, useSyncApiConfig } from '@/hooks/useApiSettings';
import { useNotifications } from '@/hooks/useNotifications';
// Tab type definitions
type TabType = 'providers' | 'endpoints' | 'cache' | 'modelPools' | 'cliSettings' | 'configSync';
type TabType = 'providers' | 'endpoints' | 'cache' | 'modelPools' | 'cliSettings';
export function ApiSettingsPage() {
const { formatMessage } = useIntl();
@@ -218,7 +217,6 @@ export function ApiSettingsPage() {
{ value: 'cache', label: formatMessage({ id: 'apiSettings.tabs.cache' }) },
{ value: 'modelPools', label: formatMessage({ id: 'apiSettings.tabs.modelPools' }) },
{ value: 'cliSettings', label: formatMessage({ id: 'apiSettings.tabs.cliSettings' }) },
{ value: 'configSync', label: formatMessage({ id: 'apiSettings.tabs.configSync' }) || 'Config Sync' },
]}
/>
@@ -268,12 +266,6 @@ export function ApiSettingsPage() {
</div>
)}
{activeTab === 'configSync' && (
<div className="mt-4">
<ConfigSync />
</div>
)}
{/* Modals */}
<ProviderModal
open={providerModalOpen}

View File

@@ -34,6 +34,7 @@ import { GpuSelector } from '@/components/codexlens/GpuSelector';
import { ModelsTab } from '@/components/codexlens/ModelsTab';
import { SearchTab } from '@/components/codexlens/SearchTab';
import { SemanticInstallDialog } from '@/components/codexlens/SemanticInstallDialog';
import { InstallProgressOverlay } from '@/components/codexlens/InstallProgressOverlay';
import { useCodexLensDashboard, useCodexLensMutations } from '@/hooks';
import { cn } from '@/lib/utils';
@@ -42,6 +43,7 @@ export function CodexLensManagerPage() {
const [activeTab, setActiveTab] = useState('overview');
const [isUninstallDialogOpen, setIsUninstallDialogOpen] = useState(false);
const [isSemanticInstallOpen, setIsSemanticInstallOpen] = useState(false);
const [isInstallOverlayOpen, setIsInstallOverlayOpen] = useState(false);
const {
installed,
@@ -64,11 +66,13 @@ export function CodexLensManagerPage() {
refetch();
};
const handleBootstrap = async () => {
const handleBootstrap = () => {
setIsInstallOverlayOpen(true);
};
const handleBootstrapInstall = async () => {
const result = await bootstrap();
if (result.success) {
refetch();
}
return result;
};
const handleUninstall = async () => {
@@ -231,6 +235,14 @@ export function CodexLensManagerPage() {
onOpenChange={setIsSemanticInstallOpen}
onSuccess={() => refetch()}
/>
{/* Install Progress Overlay */}
<InstallProgressOverlay
open={isInstallOverlayOpen}
onOpenChange={setIsInstallOverlayOpen}
onInstall={handleBootstrapInstall}
onSuccess={() => refetch()}
/>
</div>
);
}

View File

@@ -151,6 +151,64 @@ const mockMessages: Record<Locale, Record<string, string>> = {
'codexlens.overview.venv.pythonVersion': 'Python Version',
'codexlens.overview.venv.venvPath': 'Virtual Environment Path',
'codexlens.overview.venv.lastCheck': 'Last Check Time',
'codexlens.install.title': 'Install CodexLens',
// Env Groups & Fields
'codexlens.envGroup.embedding': 'Embedding',
'codexlens.envGroup.reranker': 'Reranker',
'codexlens.envGroup.concurrency': 'Concurrency',
'codexlens.envGroup.cascade': 'Cascade Search',
'codexlens.envGroup.chunking': 'Chunking',
'codexlens.envField.backend': 'Backend',
'codexlens.envField.model': 'Model',
'codexlens.envField.useGpu': 'Use GPU',
'codexlens.envField.highAvailability': 'High Availability',
'codexlens.envField.loadBalanceStrategy': 'Load Balance Strategy',
'codexlens.envField.rateLimitCooldown': 'Rate Limit Cooldown',
'codexlens.envField.enabled': 'Enabled',
'codexlens.envField.topKResults': 'Top K Results',
'codexlens.envField.maxWorkers': 'Max Workers',
'codexlens.envField.batchSize': 'Batch Size',
'codexlens.envField.dynamicBatchSize': 'Dynamic Batch Size',
'codexlens.envField.batchSizeUtilization': 'Utilization Factor',
'codexlens.envField.batchSizeMax': 'Max Batch Size',
'codexlens.envField.charsPerToken': 'Chars Per Token',
'codexlens.envField.searchStrategy': 'Search Strategy',
'codexlens.envField.coarseK': 'Coarse K',
'codexlens.envField.fineK': 'Fine K',
'codexlens.envField.stripComments': 'Strip Comments',
'codexlens.envField.stripDocstrings': 'Strip Docstrings',
'codexlens.envField.testFilePenalty': 'Test File Penalty',
'codexlens.envField.docstringWeight': 'Docstring Weight',
'codexlens.install.description': 'Set up Python virtual environment and install CodexLens package.',
'codexlens.install.checklist': 'What will be installed',
'codexlens.install.pythonVenv': 'Python Virtual Environment',
'codexlens.install.pythonVenvDesc': 'Isolated Python environment for CodexLens',
'codexlens.install.codexlensPackage': 'CodexLens Package',
'codexlens.install.codexlensPackageDesc': 'Core semantic code search engine',
'codexlens.install.sqliteFts': 'SQLite FTS5',
'codexlens.install.sqliteFtsDesc': 'Full-text search extension for fast code lookup',
'codexlens.install.location': 'Install Location',
'codexlens.install.locationPath': '~/.codexlens/venv',
'codexlens.install.timeEstimate': 'Installation may take 1-3 minutes depending on network speed.',
'codexlens.install.stage.creatingVenv': 'Creating Python virtual environment...',
'codexlens.install.stage.installingPip': 'Installing pip dependencies...',
'codexlens.install.stage.installingPackage': 'Installing CodexLens package...',
'codexlens.install.stage.settingUpDeps': 'Setting up dependencies...',
'codexlens.install.stage.finalizing': 'Finalizing installation...',
'codexlens.install.stage.complete': 'Installation complete!',
'codexlens.install.installNow': 'Install Now',
'codexlens.install.installing': 'Installing...',
'codexlens.watcher.title': 'File Watcher',
'codexlens.watcher.status.running': 'Running',
'codexlens.watcher.status.stopped': 'Stopped',
'codexlens.watcher.eventsProcessed': 'Events Processed',
'codexlens.watcher.uptime': 'Uptime',
'codexlens.watcher.start': 'Start Watcher',
'codexlens.watcher.starting': 'Starting...',
'codexlens.watcher.stop': 'Stop Watcher',
'codexlens.watcher.stopping': 'Stopping...',
'codexlens.watcher.started': 'File watcher started',
'codexlens.watcher.stopped': 'File watcher stopped',
'codexlens.settings.currentCount': 'Current Index Count',
'codexlens.settings.currentWorkers': 'Current Workers',
'codexlens.settings.currentBatchSize': 'Current Batch Size',
@@ -333,6 +391,64 @@ const mockMessages: Record<Locale, Record<string, string>> = {
'codexlens.overview.venv.pythonVersion': 'Python 版本',
'codexlens.overview.venv.venvPath': '虚拟环境路径',
'codexlens.overview.venv.lastCheck': '最后检查时间',
'codexlens.install.title': '安装 CodexLens',
// Env Groups & Fields
'codexlens.envGroup.embedding': '嵌入模型',
'codexlens.envGroup.reranker': '重排序',
'codexlens.envGroup.concurrency': '并发',
'codexlens.envGroup.cascade': '级联搜索',
'codexlens.envGroup.chunking': '分块',
'codexlens.envField.backend': '后端',
'codexlens.envField.model': '模型',
'codexlens.envField.useGpu': '使用 GPU',
'codexlens.envField.highAvailability': '高可用',
'codexlens.envField.loadBalanceStrategy': '负载均衡策略',
'codexlens.envField.rateLimitCooldown': '限流冷却时间',
'codexlens.envField.enabled': '启用',
'codexlens.envField.topKResults': 'Top K 结果数',
'codexlens.envField.maxWorkers': '最大工作线程',
'codexlens.envField.batchSize': '批次大小',
'codexlens.envField.dynamicBatchSize': '动态批次大小',
'codexlens.envField.batchSizeUtilization': '利用率因子',
'codexlens.envField.batchSizeMax': '最大批次大小',
'codexlens.envField.charsPerToken': '每 Token 字符数',
'codexlens.envField.searchStrategy': '搜索策略',
'codexlens.envField.coarseK': '粗筛 K 值',
'codexlens.envField.fineK': '精筛 K 值',
'codexlens.envField.stripComments': '去除注释',
'codexlens.envField.stripDocstrings': '去除文档字符串',
'codexlens.envField.testFilePenalty': '测试文件惩罚',
'codexlens.envField.docstringWeight': '文档字符串权重',
'codexlens.install.description': '设置 Python 虚拟环境并安装 CodexLens 包。',
'codexlens.install.checklist': '将要安装的内容',
'codexlens.install.pythonVenv': 'Python 虚拟环境',
'codexlens.install.pythonVenvDesc': 'CodexLens 的隔离 Python 环境',
'codexlens.install.codexlensPackage': 'CodexLens 包',
'codexlens.install.codexlensPackageDesc': '核心语义代码搜索引擎',
'codexlens.install.sqliteFts': 'SQLite FTS5',
'codexlens.install.sqliteFtsDesc': '用于快速代码查找的全文搜索扩展',
'codexlens.install.location': '安装位置',
'codexlens.install.locationPath': '~/.codexlens/venv',
'codexlens.install.timeEstimate': '安装可能需要 1-3 分钟,取决于网络速度。',
'codexlens.install.stage.creatingVenv': '正在创建 Python 虚拟环境...',
'codexlens.install.stage.installingPip': '正在安装 pip 依赖...',
'codexlens.install.stage.installingPackage': '正在安装 CodexLens 包...',
'codexlens.install.stage.settingUpDeps': '正在设置依赖项...',
'codexlens.install.stage.finalizing': '正在完成安装...',
'codexlens.install.stage.complete': '安装完成!',
'codexlens.install.installNow': '立即安装',
'codexlens.install.installing': '安装中...',
'codexlens.watcher.title': '文件监听器',
'codexlens.watcher.status.running': '运行中',
'codexlens.watcher.status.stopped': '已停止',
'codexlens.watcher.eventsProcessed': '已处理事件',
'codexlens.watcher.uptime': '运行时间',
'codexlens.watcher.start': '启动监听',
'codexlens.watcher.starting': '启动中...',
'codexlens.watcher.stop': '停止监听',
'codexlens.watcher.stopping': '停止中...',
'codexlens.watcher.started': '文件监听器已启动',
'codexlens.watcher.stopped': '文件监听器已停止',
'codexlens.settings.currentCount': '当前索引数量',
'codexlens.settings.currentWorkers': '当前工作线程',
'codexlens.settings.currentBatchSize': '当前批次大小',

View File

@@ -0,0 +1,63 @@
// ========================================
// CodexLens Type Definitions
// ========================================
// TypeScript interfaces for structured env var form schema
/**
* Model group definition for model-select fields
*/
export interface ModelGroup {
group: string;
items: string[];
}
/**
* Schema for a single environment variable field
*/
export interface EnvVarFieldSchema {
/** Environment variable key (e.g. CODEXLENS_EMBEDDING_BACKEND) */
key: string;
/** i18n label key */
labelKey: string;
/** Field type determines which control to render */
type: 'select' | 'model-select' | 'number' | 'checkbox' | 'text';
/** Options for select type */
options?: string[];
/** Default value */
default?: string;
/** Placeholder text */
placeholder?: string;
/** Conditional visibility based on current env values */
showWhen?: (env: Record<string, string>) => boolean;
/** Mapped path in settings.json (e.g. embedding.backend) */
settingsPath?: string;
/** Min value for number type */
min?: number;
/** Max value for number type */
max?: number;
/** Step value for number type */
step?: number;
/** Preset local models for model-select */
localModels?: ModelGroup[];
/** Preset API models for model-select */
apiModels?: ModelGroup[];
}
/**
* Schema for a group of related environment variables
*/
export interface EnvVarGroup {
/** Unique group identifier */
id: string;
/** i18n label key for group title */
labelKey: string;
/** Lucide icon name */
icon: string;
/** Ordered map of env var key to field schema */
vars: Record<string, EnvVarFieldSchema>;
}
/**
* Complete schema for all env var groups
*/
export type EnvVarGroupsSchema = Record<string, EnvVarGroup>;