mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-11 02:33:51 +08:00
Refactor code structure for improved readability and maintainability
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
135
ccw/frontend/src/components/codexlens/FileWatcherCard.tsx
Normal file
135
ccw/frontend/src/components/codexlens/FileWatcherCard.tsx
Normal 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;
|
||||
256
ccw/frontend/src/components/codexlens/InstallProgressOverlay.tsx
Normal file
256
ccw/frontend/src/components/codexlens/InstallProgressOverlay.tsx
Normal 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;
|
||||
195
ccw/frontend/src/components/codexlens/ModelSelectField.tsx
Normal file
195
ccw/frontend/src/components/codexlens/ModelSelectField.tsx
Normal 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;
|
||||
@@ -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} />
|
||||
|
||||
|
||||
266
ccw/frontend/src/components/codexlens/SchemaFormRenderer.tsx
Normal file
266
ccw/frontend/src/components/codexlens/SchemaFormRenderer.tsx
Normal 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;
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
393
ccw/frontend/src/components/codexlens/envVarSchema.ts
Normal file
393
ccw/frontend/src/components/codexlens/envVarSchema.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user