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
This commit is contained in:
catlog22
2026-02-13 17:14:14 +08:00
parent ad5b35a1a5
commit 31f37751fc
15 changed files with 1363 additions and 14 deletions

View File

@@ -14,6 +14,7 @@ import { Badge } from '@/components/ui/Badge';
import { useCodexLensEnv, useUpdateCodexLensEnv } from '@/hooks';
import { useNotifications } from '@/hooks';
import { cn } from '@/lib/utils';
import { CcwToolsCard } from './CcwToolsCard';
interface AdvancedTabProps {
enabled?: boolean;
@@ -238,6 +239,9 @@ export function AdvancedTab({ enabled = true }: AdvancedTabProps) {
</Card>
)}
{/* CCW Tools Card */}
<CcwToolsCard />
{/* Environment Variables Editor */}
<Card className="p-6">
<div className="flex items-center justify-between mb-4">

View File

@@ -0,0 +1,153 @@
// ========================================
// CCW Tools Card Component
// ========================================
// Displays all registered CCW tools, highlighting codex-lens related tools
import { useIntl } from 'react-intl';
import { Wrench, AlertCircle, Loader2 } from 'lucide-react';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import { useCcwToolsList } from '@/hooks';
import type { CcwToolInfo } from '@/lib/api';
const CODEX_LENS_PREFIX = 'codex_lens';
function isCodexLensTool(tool: CcwToolInfo): boolean {
return tool.name.startsWith(CODEX_LENS_PREFIX);
}
export function CcwToolsCard() {
const { formatMessage } = useIntl();
const { tools, isLoading, error } = useCcwToolsList();
const codexLensTools = tools.filter(isCodexLensTool);
const otherTools = tools.filter((t) => !isCodexLensTool(t));
if (isLoading) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Wrench className="w-4 h-4" />
<span>{formatMessage({ id: 'codexlens.mcp.title' })}</span>
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin" />
<span>{formatMessage({ id: 'codexlens.mcp.loading' })}</span>
</div>
</CardContent>
</Card>
);
}
if (error) {
return (
<Card className="border-destructive/20">
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Wrench className="w-4 h-4" />
<span>{formatMessage({ id: 'codexlens.mcp.title' })}</span>
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-start gap-2 text-sm">
<AlertCircle className="w-4 h-4 text-destructive flex-shrink-0 mt-0.5" />
<div>
<p className="font-medium text-destructive">
{formatMessage({ id: 'codexlens.mcp.error' })}
</p>
<p className="text-xs text-muted-foreground mt-1">
{formatMessage({ id: 'codexlens.mcp.errorDesc' })}
</p>
</div>
</div>
</CardContent>
</Card>
);
}
if (tools.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Wrench className="w-4 h-4" />
<span>{formatMessage({ id: 'codexlens.mcp.title' })}</span>
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'codexlens.mcp.emptyDesc' })}
</p>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center justify-between">
<div className="flex items-center gap-2">
<Wrench className="w-4 h-4" />
<span>{formatMessage({ id: 'codexlens.mcp.title' })}</span>
</div>
<Badge variant="outline" className="text-xs">
{formatMessage({ id: 'codexlens.mcp.totalCount' }, { count: tools.length })}
</Badge>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* CodexLens Tools Section */}
{codexLensTools.length > 0 && (
<div>
<p className="text-xs font-medium text-muted-foreground uppercase mb-2">
{formatMessage({ id: 'codexlens.mcp.codexLensSection' })}
</p>
<div className="space-y-1.5">
{codexLensTools.map((tool) => (
<ToolRow key={tool.name} tool={tool} variant="default" />
))}
</div>
</div>
)}
{/* Other Tools Section */}
{otherTools.length > 0 && (
<div>
<p className="text-xs font-medium text-muted-foreground uppercase mb-2">
{formatMessage({ id: 'codexlens.mcp.otherSection' })}
</p>
<div className="space-y-1.5">
{otherTools.map((tool) => (
<ToolRow key={tool.name} tool={tool} variant="secondary" />
))}
</div>
</div>
)}
</CardContent>
</Card>
);
}
interface ToolRowProps {
tool: CcwToolInfo;
variant: 'default' | 'secondary';
}
function ToolRow({ tool, variant }: ToolRowProps) {
return (
<div className="flex items-center gap-2 py-1 px-2 rounded-md hover:bg-muted/50 transition-colors">
<Badge variant={variant} className="text-xs font-mono shrink-0">
{tool.name}
</Badge>
<span className="text-xs text-muted-foreground truncate" title={tool.description}>
{tool.description}
</span>
</div>
);
}
export default CcwToolsCard;

View File

@@ -0,0 +1,157 @@
// ========================================
// CodexLens LSP Server Card
// ========================================
// Displays LSP server status, stats, and start/stop/restart controls
import { useIntl } from 'react-intl';
import {
Server,
Power,
PowerOff,
RotateCw,
FolderOpen,
Layers,
Cpu,
} 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 { useCodexLensLspStatus, useCodexLensLspMutations } from '@/hooks';
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
interface LspServerCardProps {
disabled?: boolean;
}
export function LspServerCard({ disabled = false }: LspServerCardProps) {
const { formatMessage } = useIntl();
const projectPath = useWorkflowStore(selectProjectPath);
const {
available,
semanticAvailable,
projectCount,
modes,
isLoading,
} = useCodexLensLspStatus();
const { startLsp, stopLsp, restartLsp, isStarting, isStopping, isRestarting } = useCodexLensLspMutations();
const isMutating = isStarting || isStopping || isRestarting;
const handleToggle = async () => {
if (available) {
await stopLsp(projectPath);
} else {
await startLsp(projectPath);
}
};
const handleRestart = async () => {
await restartLsp(projectPath);
};
return (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center justify-between">
<div className="flex items-center gap-2">
<Server className="w-4 h-4" />
<span>{formatMessage({ id: 'codexlens.lsp.title' })}</span>
</div>
<Badge variant={available ? 'success' : 'secondary'}>
{available
? formatMessage({ id: 'codexlens.lsp.status.running' })
: formatMessage({ id: 'codexlens.lsp.status.stopped' })
}
</Badge>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Stats Grid */}
<div className="grid grid-cols-3 gap-3">
<div className="flex items-center gap-2 text-sm">
<FolderOpen className={cn('w-4 h-4', available ? 'text-success' : 'text-muted-foreground')} />
<div>
<p className="text-muted-foreground text-xs">
{formatMessage({ id: 'codexlens.lsp.projects' })}
</p>
<p className="font-semibold text-foreground">
{available ? projectCount : '--'}
</p>
</div>
</div>
<div className="flex items-center gap-2 text-sm">
<Cpu className={cn('w-4 h-4', semanticAvailable ? 'text-info' : 'text-muted-foreground')} />
<div>
<p className="text-muted-foreground text-xs">
{formatMessage({ id: 'codexlens.lsp.semanticAvailable' })}
</p>
<p className="font-semibold text-foreground">
{semanticAvailable
? formatMessage({ id: 'codexlens.lsp.available' })
: formatMessage({ id: 'codexlens.lsp.unavailable' })
}
</p>
</div>
</div>
<div className="flex items-center gap-2 text-sm">
<Layers className={cn('w-4 h-4', available && modes.length > 0 ? 'text-accent' : 'text-muted-foreground')} />
<div>
<p className="text-muted-foreground text-xs">
{formatMessage({ id: 'codexlens.lsp.modes' })}
</p>
<p className="font-semibold text-foreground">
{available ? modes.length : '--'}
</p>
</div>
</div>
</div>
{/* Action Buttons */}
<div className="flex gap-2">
<Button
variant={available ? 'outline' : 'default'}
size="sm"
className="flex-1"
onClick={handleToggle}
disabled={disabled || isMutating || isLoading}
>
{available ? (
<>
<PowerOff className="w-4 h-4 mr-2" />
{isStopping
? formatMessage({ id: 'codexlens.lsp.stopping' })
: formatMessage({ id: 'codexlens.lsp.stop' })
}
</>
) : (
<>
<Power className="w-4 h-4 mr-2" />
{isStarting
? formatMessage({ id: 'codexlens.lsp.starting' })
: formatMessage({ id: 'codexlens.lsp.start' })
}
</>
)}
</Button>
{available && (
<Button
variant="outline"
size="sm"
onClick={handleRestart}
disabled={disabled || isMutating || isLoading}
>
<RotateCw className={cn('w-4 h-4 mr-2', isRestarting && 'animate-spin')} />
{isRestarting
? formatMessage({ id: 'codexlens.lsp.restarting' })
: formatMessage({ id: 'codexlens.lsp.restart' })
}
</Button>
)}
</div>
</CardContent>
</Card>
);
}
export default LspServerCard;

View File

@@ -16,6 +16,7 @@ import { cn } from '@/lib/utils';
import type { CodexLensVenvStatus, CodexLensConfig } from '@/lib/api';
import { IndexOperations } from './IndexOperations';
import { FileWatcherCard } from './FileWatcherCard';
import { LspServerCard } from './LspServerCard';
interface OverviewTabProps {
installed: boolean;
@@ -143,8 +144,11 @@ export function OverviewTab({ installed, status, config, isLoading, onRefresh }:
</Card>
</div>
{/* File Watcher */}
<FileWatcherCard disabled={!isReady} />
{/* Service Management */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FileWatcherCard disabled={!isReady} />
<LspServerCard disabled={!isReady} />
</div>
{/* Index Operations */}
<IndexOperations disabled={!isReady} onRefresh={onRefresh} />

View File

@@ -18,6 +18,8 @@ vi.mock('@/hooks', async (importOriginal) => {
useCodexLensEnv: vi.fn(),
useUpdateCodexLensEnv: vi.fn(),
useCodexLensModels: vi.fn(),
useCodexLensRerankerConfig: vi.fn(),
useUpdateRerankerConfig: vi.fn(),
useNotifications: vi.fn(() => ({
toasts: [],
wsStatus: 'disconnected' as const,
@@ -48,6 +50,8 @@ import {
useCodexLensEnv,
useUpdateCodexLensEnv,
useCodexLensModels,
useCodexLensRerankerConfig,
useUpdateRerankerConfig,
useNotifications,
} from '@/hooks';
@@ -102,6 +106,25 @@ function setupDefaultMocks() {
error: null,
refetch: vi.fn(),
});
vi.mocked(useCodexLensRerankerConfig).mockReturnValue({
data: undefined,
backend: 'fastembed',
modelName: '',
apiProvider: '',
apiKeySet: false,
availableBackends: ['onnx', 'api', 'litellm', 'legacy'],
apiProviders: ['siliconflow', 'cohere', 'jina'],
litellmModels: undefined,
configSource: 'default',
isLoading: false,
error: null,
refetch: vi.fn(),
});
vi.mocked(useUpdateRerankerConfig).mockReturnValue({
updateConfig: vi.fn().mockResolvedValue({ success: true, message: 'Saved' }),
isUpdating: false,
error: null,
});
}
describe('SettingsTab', () => {
@@ -324,6 +347,25 @@ describe('SettingsTab', () => {
error: null,
refetch: vi.fn(),
});
vi.mocked(useCodexLensRerankerConfig).mockReturnValue({
data: undefined,
backend: 'fastembed',
modelName: '',
apiProvider: '',
apiKeySet: false,
availableBackends: [],
apiProviders: [],
litellmModels: undefined,
configSource: 'default',
isLoading: false,
error: null,
refetch: vi.fn(),
});
vi.mocked(useUpdateRerankerConfig).mockReturnValue({
updateConfig: vi.fn().mockResolvedValue({ success: true }),
isUpdating: false,
error: null,
});
render(<SettingsTab enabled={true} />);

View File

@@ -7,22 +7,265 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useIntl } from 'react-intl';
import { Save, RefreshCw } from 'lucide-react';
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;
}
@@ -219,6 +462,9 @@ export function SettingsTab({ enabled = true }: SettingsTabProps) {
</div>
</Card>
{/* Reranker Configuration */}
<RerankerConfigCard enabled={enabled} />
{/* General Configuration */}
<Card className="p-6">
<h3 className="text-lg font-semibold text-foreground mb-4">