Files
Claude-Code-Workflow/ccw/frontend/src/components/codexlens/SettingsTab.tsx
catlog22 31f37751fc feat: enhance codexlens frontend integration with reranker config, MCP tools card, and LSP management
Add three integration improvements to the CodexLens management panel:

- Enhance SettingsTab with RerankerConfigCard using /reranker/config endpoint
  for dynamic backend/model/provider dropdowns
- Add CcwToolsCard to AdvancedTab showing CCW registered tools with
  codex-lens tools highlighted
- Add LspServerCard to OverviewTab with start/stop/restart controls
  mirroring the FileWatcherCard pattern
- Create LSP lifecycle backend endpoints (start/stop/restart) bridging
  to Python StandaloneLspManager
- Add corresponding TanStack Query hooks, API functions, and i18n keys
2026-02-13 17:14:14 +08:00

535 lines
17 KiB
TypeScript

// ========================================
// CodexLens Settings Tab
// ========================================
// Structured form for CodexLens env configuration
// Renders 5 groups: embedding, reranker, concurrency, cascade, chunking
// Plus a general config section (index_dir)
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useIntl } from 'react-intl';
import { Save, RefreshCw, Loader2 } 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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
SelectGroup,
SelectLabel,
} from '@/components/ui/Select';
import {
useCodexLensConfig,
useCodexLensEnv,
useUpdateCodexLensEnv,
useCodexLensModels,
useCodexLensRerankerConfig,
useUpdateRerankerConfig,
} from '@/hooks';
import { useNotifications } from '@/hooks';
import { cn } from '@/lib/utils';
import { SchemaFormRenderer } from './SchemaFormRenderer';
import { envVarGroupsSchema, getSchemaDefaults } from './envVarSchema';
// ========== Reranker Configuration Card ==========
interface RerankerConfigCardProps {
enabled?: boolean;
}
function RerankerConfigCard({ enabled = true }: RerankerConfigCardProps) {
const { formatMessage } = useIntl();
const { success: showSuccess, error: showError } = useNotifications();
const {
backend: serverBackend,
modelName: serverModelName,
apiProvider: serverApiProvider,
apiKeySet,
availableBackends,
apiProviders,
litellmModels,
configSource,
isLoading,
} = useCodexLensRerankerConfig({ enabled });
const { updateConfig, isUpdating } = useUpdateRerankerConfig();
const [backend, setBackend] = useState('');
const [modelName, setModelName] = useState('');
const [apiProvider, setApiProvider] = useState('');
const [hasChanges, setHasChanges] = useState(false);
// Initialize form from server data
useEffect(() => {
setBackend(serverBackend);
setModelName(serverModelName);
setApiProvider(serverApiProvider);
setHasChanges(false);
}, [serverBackend, serverModelName, serverApiProvider]);
// Detect changes
useEffect(() => {
const changed =
backend !== serverBackend ||
modelName !== serverModelName ||
apiProvider !== serverApiProvider;
setHasChanges(changed);
}, [backend, modelName, apiProvider, serverBackend, serverModelName, serverApiProvider]);
const handleSave = async () => {
try {
const request: Record<string, string> = {};
if (backend !== serverBackend) request.backend = backend;
if (modelName !== serverModelName) {
// When backend is litellm, model_name is sent as litellm_endpoint
if (backend === 'litellm') {
request.litellm_endpoint = modelName;
} else {
request.model_name = modelName;
}
}
if (apiProvider !== serverApiProvider) request.api_provider = apiProvider;
const result = await updateConfig(request);
if (result.success) {
showSuccess(
formatMessage({ id: 'codexlens.reranker.saveSuccess' }),
result.message || ''
);
} else {
showError(
formatMessage({ id: 'codexlens.reranker.saveFailed' }),
result.error || ''
);
}
} catch (err) {
showError(
formatMessage({ id: 'codexlens.reranker.saveFailed' }),
err instanceof Error ? err.message : ''
);
}
};
// Determine whether to show litellm model dropdown or text input
const showLitellmModelSelect = backend === 'litellm' && litellmModels && litellmModels.length > 0;
// Show provider dropdown only for api backend
const showProviderSelect = backend === 'api';
if (isLoading) {
return (
<Card className="p-6">
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin" />
<span>{formatMessage({ id: 'common.loading' })}</span>
</div>
</Card>
);
}
return (
<Card className="p-6">
<h3 className="text-lg font-semibold text-foreground mb-1">
{formatMessage({ id: 'codexlens.reranker.title' })}
</h3>
<p className="text-sm text-muted-foreground mb-4">
{formatMessage({ id: 'codexlens.reranker.description' })}
</p>
<div className="space-y-4">
{/* Backend Select */}
<div className="space-y-2">
<Label>{formatMessage({ id: 'codexlens.reranker.backend' })}</Label>
<Select value={backend} onValueChange={setBackend}>
<SelectTrigger>
<SelectValue placeholder={formatMessage({ id: 'codexlens.reranker.selectBackend' })} />
</SelectTrigger>
<SelectContent>
{availableBackends.length > 0 ? (
availableBackends.map((b) => (
<SelectItem key={b} value={b}>
{b}
</SelectItem>
))
) : (
<SelectItem value="_none" disabled>
{formatMessage({ id: 'codexlens.reranker.noBackends' })}
</SelectItem>
)}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{formatMessage({ id: 'codexlens.reranker.backendHint' })}
</p>
</div>
{/* Model - Select for litellm, Input for others */}
<div className="space-y-2">
<Label>{formatMessage({ id: 'codexlens.reranker.model' })}</Label>
{showLitellmModelSelect ? (
<Select value={modelName} onValueChange={setModelName}>
<SelectTrigger>
<SelectValue placeholder={formatMessage({ id: 'codexlens.reranker.selectModel' })} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>
{formatMessage({ id: 'codexlens.reranker.litellmModels' })}
</SelectLabel>
{litellmModels!.map((m) => (
<SelectItem key={m.modelId} value={m.modelId}>
{m.modelName}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
) : (
<Input
value={modelName}
onChange={(e) => setModelName(e.target.value)}
placeholder={formatMessage({ id: 'codexlens.reranker.selectModel' })}
/>
)}
<p className="text-xs text-muted-foreground">
{formatMessage({ id: 'codexlens.reranker.modelHint' })}
</p>
</div>
{/* Provider Select (only for api backend) */}
{showProviderSelect && (
<div className="space-y-2">
<Label>{formatMessage({ id: 'codexlens.reranker.provider' })}</Label>
<Select value={apiProvider} onValueChange={setApiProvider}>
<SelectTrigger>
<SelectValue placeholder={formatMessage({ id: 'codexlens.reranker.selectProvider' })} />
</SelectTrigger>
<SelectContent>
{apiProviders.length > 0 ? (
apiProviders.map((p) => (
<SelectItem key={p} value={p}>
{p}
</SelectItem>
))
) : (
<SelectItem value="_none" disabled>
{formatMessage({ id: 'codexlens.reranker.noProviders' })}
</SelectItem>
)}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{formatMessage({ id: 'codexlens.reranker.providerHint' })}
</p>
</div>
)}
{/* Status Row */}
<div className="flex items-center gap-4 text-sm text-muted-foreground pt-2 border-t">
<span>
{formatMessage({ id: 'codexlens.reranker.apiKeyStatus' })}:{' '}
<span className={apiKeySet ? 'text-green-600' : 'text-yellow-600'}>
{apiKeySet
? formatMessage({ id: 'codexlens.reranker.apiKeySet' })
: formatMessage({ id: 'codexlens.reranker.apiKeyNotSet' })}
</span>
</span>
<span>
{formatMessage({ id: 'codexlens.reranker.configSource' })}: {configSource}
</span>
</div>
{/* Save Button */}
<div className="flex items-center gap-2 pt-2">
<Button
onClick={handleSave}
disabled={isUpdating || !hasChanges}
size="sm"
>
{isUpdating ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Save className="w-4 h-4 mr-2" />
)}
{isUpdating
? formatMessage({ id: 'codexlens.reranker.saving' })
: formatMessage({ id: 'codexlens.reranker.save' })}
</Button>
</div>
</div>
</Card>
);
}
// ========== Settings Tab ==========
interface SettingsTabProps {
enabled?: boolean;
}
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: refetchConfig,
} = useCodexLensConfig({ enabled });
// Fetch env vars and settings
const {
env: serverEnv,
settings: serverSettings,
isLoading: isLoadingEnv,
refetch: refetchEnv,
} = useCodexLensEnv({ enabled });
// 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);
// 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) {
setIndexDir(config.index_dir || '');
setInitialIndexDir(config.index_dir || '');
}
}, [config]);
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;
}
}
// .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]);
// 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]
);
const handleEnvChange = useCallback(
(key: string, value: string) => {
setEnvValues((prev) => {
const next = { ...prev, [key]: value };
setHasChanges(detectChanges(next, indexDir));
return next;
});
},
[detectChanges, indexDir]
);
const handleIndexDirChange = useCallback(
(value: string) => {
setIndexDir(value);
setIndexDirError('');
setHasChanges(detectChanges(envValues, value));
},
[detectChanges, envValues]
);
// 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 () => {
// Validate index_dir
if (!indexDir.trim()) {
setIndexDirError(
formatMessage({ id: 'codexlens.settings.validation.indexDirRequired' })
);
return;
}
try {
const result = await updateEnv({ env: envValues });
if (result.success) {
success(
formatMessage({ id: 'codexlens.settings.saveSuccess' }),
result.message ||
formatMessage({ id: 'codexlens.settings.configUpdated' })
);
refetchEnv();
refetchConfig();
setHasChanges(false);
setInitialEnvValues(envValues);
setInitialIndexDir(indexDir);
} else {
showError(
formatMessage({ id: 'codexlens.settings.saveFailed' }),
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' })
);
}
};
const handleReset = () => {
setEnvValues(initialEnvValues);
setIndexDir(initialIndexDir);
setIndexDirError('');
setHasChanges(false);
};
const isLoading = isLoadingConfig || isLoadingEnv;
return (
<div className="space-y-6">
{/* Current Info Card */}
<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>
<p className="text-foreground font-medium">{indexCount}</p>
</div>
<div>
<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>
<p className="text-foreground font-medium">{apiBatchSize}</p>
</div>
</div>
</Card>
{/* Reranker Configuration */}
<RerankerConfigCard enabled={enabled} />
{/* General Configuration */}
<Card className="p-6">
<h3 className="text-lg font-semibold text-foreground mb-4">
{formatMessage({ id: 'codexlens.settings.configTitle' })}
</h3>
{/* 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')}
/>
{isUpdating
? formatMessage({ id: 'codexlens.settings.saving' })
: formatMessage({ id: 'codexlens.settings.save' })}
</Button>
<Button
variant="outline"
onClick={handleReset}
disabled={isLoading || !hasChanges}
>
<RefreshCw className="w-4 h-4 mr-2" />
{formatMessage({ id: 'codexlens.settings.reset' })}
</Button>
</div>
</Card>
</div>
);
}
export default SettingsTab;