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;
}