mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-04 01:40:45 +08:00
feat: add useApiSettings hook for managing API settings, including providers, endpoints, cache, and model pools
- Implemented hooks for CRUD operations on providers and endpoints. - Added cache management hooks for cache stats and settings. - Introduced model pool management hooks for high availability and load balancing. - Created localization files for English and Chinese translations of API settings.
This commit is contained in:
11
ccw/frontend/package-lock.json
generated
11
ccw/frontend/package-lock.json
generated
@@ -35,6 +35,7 @@
|
||||
"react-router-dom": "^6.28.0",
|
||||
"rehype-highlight": "^7.0.2",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^2.5.0",
|
||||
"zod": "^3.23.8",
|
||||
"zustand": "^5.0.0"
|
||||
@@ -8089,6 +8090,16 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/sonner": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
|
||||
"integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
|
||||
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
"react-router-dom": "^6.28.0",
|
||||
"rehype-highlight": "^7.0.2",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^2.5.0",
|
||||
"zod": "^3.23.8",
|
||||
"zustand": "^5.0.0"
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Save, RefreshCw, AlertTriangle, FileCode } from 'lucide-react';
|
||||
import { Save, RefreshCw, AlertTriangle, FileCode, AlertCircle } from 'lucide-react';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Textarea } from '@/components/ui/Textarea';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
@@ -32,6 +32,7 @@ export function AdvancedTab({ enabled = true }: AdvancedTabProps) {
|
||||
env,
|
||||
settings,
|
||||
isLoading: isLoadingEnv,
|
||||
error: envError,
|
||||
refetch,
|
||||
} = useCodexLensEnv({ enabled });
|
||||
|
||||
@@ -43,23 +44,25 @@ export function AdvancedTab({ enabled = true }: AdvancedTabProps) {
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [showWarning, setShowWarning] = useState(false);
|
||||
|
||||
// Initialize form from env
|
||||
// Initialize form from env - handles both undefined (loading) and empty string (empty file)
|
||||
// The hook returns raw directly, so we check if it's been set (not undefined means data loaded)
|
||||
useEffect(() => {
|
||||
if (raw !== undefined) {
|
||||
setEnvInput(raw);
|
||||
// Initialize when data is loaded (raw may be empty string but not undefined during loading)
|
||||
// Note: During initial load, raw is undefined. After load completes, raw is set (even if empty string)
|
||||
if (!isLoadingEnv) {
|
||||
setEnvInput(raw ?? ''); // Use empty string if raw is undefined/null
|
||||
setErrors({});
|
||||
setHasChanges(false);
|
||||
setShowWarning(false);
|
||||
}
|
||||
}, [raw]);
|
||||
}, [raw, isLoadingEnv]);
|
||||
|
||||
const handleEnvChange = (value: string) => {
|
||||
setEnvInput(value);
|
||||
// Check if there are changes
|
||||
if (raw !== undefined) {
|
||||
setHasChanges(value !== raw);
|
||||
setShowWarning(value !== raw);
|
||||
}
|
||||
// Check if there are changes - compare with raw value (handle undefined as empty)
|
||||
const currentRaw = raw ?? '';
|
||||
setHasChanges(value !== currentRaw);
|
||||
setShowWarning(value !== currentRaw);
|
||||
if (errors.env) {
|
||||
setErrors((prev) => ({ ...prev, env: undefined }));
|
||||
}
|
||||
@@ -132,12 +135,11 @@ export function AdvancedTab({ enabled = true }: AdvancedTabProps) {
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
if (raw !== undefined) {
|
||||
setEnvInput(raw);
|
||||
setErrors({});
|
||||
setHasChanges(false);
|
||||
setShowWarning(false);
|
||||
}
|
||||
// Reset to current raw value (handle undefined as empty)
|
||||
setEnvInput(raw ?? '');
|
||||
setErrors({});
|
||||
setHasChanges(false);
|
||||
setShowWarning(false);
|
||||
};
|
||||
|
||||
const isLoading = isLoadingEnv;
|
||||
@@ -154,6 +156,32 @@ export function AdvancedTab({ enabled = true }: AdvancedTabProps) {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Error Card */}
|
||||
{envError && (
|
||||
<Card className="p-4 bg-destructive/10 border-destructive/20">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-destructive flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<h4 className="text-sm font-medium text-destructive-foreground">
|
||||
{formatMessage({ id: 'codexlens.advanced.loadError' })}
|
||||
</h4>
|
||||
<p className="text-xs text-destructive-foreground/80 mt-1">
|
||||
{envError.message || formatMessage({ id: 'codexlens.advanced.loadErrorDesc' })}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => refetch()}
|
||||
className="mt-2"
|
||||
>
|
||||
<RefreshCw className="w-3 h-3 mr-1" />
|
||||
{formatMessage({ id: 'common.actions.retry' })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Sensitivity Warning Card */}
|
||||
{showWarning && (
|
||||
<Card className="p-4 bg-warning/10 border-warning/20">
|
||||
|
||||
286
ccw/frontend/src/components/codexlens/IndexOperations.tsx
Normal file
286
ccw/frontend/src/components/codexlens/IndexOperations.tsx
Normal file
@@ -0,0 +1,286 @@
|
||||
// ========================================
|
||||
// CodexLens Index Operations Component
|
||||
// ========================================
|
||||
// Index management operations with progress tracking
|
||||
|
||||
import { useIntl } from 'react-intl';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
RotateCw,
|
||||
Zap,
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Progress } from '@/components/ui/Progress';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
|
||||
import {
|
||||
useCodexLensIndexingStatus,
|
||||
useRebuildIndex,
|
||||
useUpdateIndex,
|
||||
useCancelIndexing,
|
||||
} from '@/hooks';
|
||||
import { useNotifications } from '@/hooks/useNotifications';
|
||||
import { useWebSocket } from '@/hooks/useWebSocket';
|
||||
|
||||
interface IndexOperationsProps {
|
||||
disabled?: boolean;
|
||||
onRefresh?: () => void;
|
||||
}
|
||||
|
||||
interface IndexProgress {
|
||||
stage: string;
|
||||
message: string;
|
||||
percent: number;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
type IndexOperation = {
|
||||
id: string;
|
||||
type: 'fts_full' | 'fts_incremental' | 'vector_full' | 'vector_incremental';
|
||||
label: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
};
|
||||
|
||||
export function IndexOperations({ disabled = false, onRefresh }: IndexOperationsProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const { success, error: showError } = useNotifications();
|
||||
const projectPath = useWorkflowStore(selectProjectPath);
|
||||
const { inProgress } = useCodexLensIndexingStatus();
|
||||
const { rebuildIndex, isRebuilding } = useRebuildIndex();
|
||||
const { updateIndex, isUpdating } = useUpdateIndex();
|
||||
const { cancelIndexing, isCancelling } = useCancelIndexing();
|
||||
const { lastMessage } = useWebSocket();
|
||||
|
||||
const [indexProgress, setIndexProgress] = useState<IndexProgress | null>(null);
|
||||
const [activeOperation, setActiveOperation] = useState<string | null>(null);
|
||||
|
||||
// Listen for WebSocket progress updates
|
||||
useEffect(() => {
|
||||
if (lastMessage?.type === 'CODEXLENS_INDEX_PROGRESS') {
|
||||
const progress = lastMessage.payload as IndexProgress;
|
||||
setIndexProgress(progress);
|
||||
|
||||
// Clear active operation when complete or error
|
||||
if (progress.stage === 'complete' || progress.stage === 'error' || progress.stage === 'cancelled') {
|
||||
if (progress.stage === 'complete') {
|
||||
success(
|
||||
formatMessage({ id: 'codexlens.index.operationComplete' }),
|
||||
progress.message
|
||||
);
|
||||
onRefresh?.();
|
||||
} else if (progress.stage === 'error') {
|
||||
showError(
|
||||
formatMessage({ id: 'codexlens.index.operationFailed' }),
|
||||
progress.message
|
||||
);
|
||||
}
|
||||
setActiveOperation(null);
|
||||
setIndexProgress(null);
|
||||
}
|
||||
}
|
||||
}, [lastMessage, formatMessage, success, showError, onRefresh]);
|
||||
|
||||
const isOperating = isRebuilding || isUpdating || inProgress || !!activeOperation;
|
||||
|
||||
const handleOperation = async (operation: IndexOperation) => {
|
||||
if (!projectPath) {
|
||||
showError(
|
||||
formatMessage({ id: 'codexlens.index.noProject' }),
|
||||
formatMessage({ id: 'codexlens.index.noProjectDesc' })
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveOperation(operation.id);
|
||||
setIndexProgress({ stage: 'start', message: formatMessage({ id: 'codexlens.index.starting' }), percent: 0 });
|
||||
|
||||
try {
|
||||
// Determine index type and operation
|
||||
const isVector = operation.type.includes('vector');
|
||||
const isIncremental = operation.type.includes('incremental');
|
||||
|
||||
if (isIncremental) {
|
||||
const result = await updateIndex(projectPath, {
|
||||
indexType: isVector ? 'vector' : 'normal',
|
||||
});
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Update failed');
|
||||
}
|
||||
} else {
|
||||
const result = await rebuildIndex(projectPath, {
|
||||
indexType: isVector ? 'vector' : 'normal',
|
||||
});
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Rebuild failed');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setActiveOperation(null);
|
||||
setIndexProgress(null);
|
||||
showError(
|
||||
formatMessage({ id: 'codexlens.index.operationFailed' }),
|
||||
err instanceof Error ? err.message : formatMessage({ id: 'codexlens.index.unknownError' })
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async () => {
|
||||
const result = await cancelIndexing();
|
||||
if (result.success) {
|
||||
setActiveOperation(null);
|
||||
setIndexProgress(null);
|
||||
} else {
|
||||
showError(
|
||||
formatMessage({ id: 'codexlens.index.cancelFailed' }),
|
||||
result.error || formatMessage({ id: 'codexlens.index.unknownError' })
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const operations: IndexOperation[] = [
|
||||
{
|
||||
id: 'fts_full',
|
||||
type: 'fts_full',
|
||||
label: formatMessage({ id: 'codexlens.overview.actions.ftsFull' }),
|
||||
description: formatMessage({ id: 'codexlens.overview.actions.ftsFullDesc' }),
|
||||
icon: <RotateCw className="w-4 h-4" />,
|
||||
},
|
||||
{
|
||||
id: 'fts_incremental',
|
||||
type: 'fts_incremental',
|
||||
label: formatMessage({ id: 'codexlens.overview.actions.ftsIncremental' }),
|
||||
description: formatMessage({ id: 'codexlens.overview.actions.ftsIncrementalDesc' }),
|
||||
icon: <Zap className="w-4 h-4" />,
|
||||
},
|
||||
{
|
||||
id: 'vector_full',
|
||||
type: 'vector_full',
|
||||
label: formatMessage({ id: 'codexlens.overview.actions.vectorFull' }),
|
||||
description: formatMessage({ id: 'codexlens.overview.actions.vectorFullDesc' }),
|
||||
icon: <RotateCw className="w-4 h-4" />,
|
||||
},
|
||||
{
|
||||
id: 'vector_incremental',
|
||||
type: 'vector_incremental',
|
||||
label: formatMessage({ id: 'codexlens.overview.actions.vectorIncremental' }),
|
||||
description: formatMessage({ id: 'codexlens.overview.actions.vectorIncrementalDesc' }),
|
||||
icon: <Zap className="w-4 h-4" />,
|
||||
},
|
||||
];
|
||||
|
||||
if (indexProgress && activeOperation) {
|
||||
const operation = operations.find((op) => op.id === activeOperation);
|
||||
const isComplete = indexProgress.stage === 'complete';
|
||||
const isError = indexProgress.stage === 'error';
|
||||
const isCancelled = indexProgress.stage === 'cancelled';
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center justify-between">
|
||||
<span>{operation?.label}</span>
|
||||
{!isComplete && !isError && !isCancelled && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleCancel}
|
||||
disabled={isCancelling}
|
||||
>
|
||||
<X className="w-4 h-4 mr-1" />
|
||||
{formatMessage({ id: 'common.actions.cancel' })}
|
||||
</Button>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Status Icon */}
|
||||
<div className="flex items-center gap-3">
|
||||
{isComplete ? (
|
||||
<CheckCircle2 className="w-6 h-6 text-success" />
|
||||
) : isError || isCancelled ? (
|
||||
<AlertCircle className="w-6 h-6 text-destructive" />
|
||||
) : (
|
||||
<RotateCw className="w-6 h-6 text-primary animate-spin" />
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{isComplete
|
||||
? formatMessage({ id: 'codexlens.index.complete' })
|
||||
: isError
|
||||
? formatMessage({ id: 'codexlens.index.failed' })
|
||||
: isCancelled
|
||||
? formatMessage({ id: 'codexlens.index.cancelled' })
|
||||
: formatMessage({ id: 'codexlens.index.inProgress' })}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">{indexProgress.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
{!isComplete && !isError && !isCancelled && (
|
||||
<div className="space-y-2">
|
||||
<Progress value={indexProgress.percent} className="h-2" />
|
||||
<p className="text-xs text-muted-foreground text-right">
|
||||
{indexProgress.percent}%
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Close Button */}
|
||||
{(isComplete || isError || isCancelled) && (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setActiveOperation(null);
|
||||
setIndexProgress(null);
|
||||
}}
|
||||
>
|
||||
{formatMessage({ id: 'common.actions.close' })}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
{formatMessage({ id: 'codexlens.overview.actions.title' })}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{operations.map((operation) => (
|
||||
<Button
|
||||
key={operation.id}
|
||||
variant="outline"
|
||||
className="h-auto p-4 flex flex-col items-start gap-2 text-left"
|
||||
onClick={() => handleOperation(operation)}
|
||||
disabled={disabled || isOperating}
|
||||
>
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<span className={cn('text-muted-foreground', (disabled || isOperating) && 'opacity-50')}>
|
||||
{operation.icon}
|
||||
</span>
|
||||
<span className="font-medium">{operation.label}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{operation.description}</p>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default IndexOperations;
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
RefreshCw,
|
||||
Package,
|
||||
Filter,
|
||||
AlertCircle,
|
||||
} from 'lucide-react';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
@@ -69,6 +70,7 @@ export function ModelsTab({ installed = false }: ModelsTabProps) {
|
||||
const {
|
||||
models,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = useCodexLensModels({
|
||||
enabled: installed,
|
||||
@@ -243,7 +245,25 @@ export function ModelsTab({ installed = false }: ModelsTabProps) {
|
||||
/>
|
||||
|
||||
{/* Model List */}
|
||||
{isLoading ? (
|
||||
{error ? (
|
||||
<Card className="p-8 text-center">
|
||||
<AlertCircle className="w-12 h-12 mx-auto text-destructive/50 mb-3" />
|
||||
<h3 className="text-sm font-medium text-destructive-foreground mb-1">
|
||||
{formatMessage({ id: 'codexlens.models.error.title' })}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground mb-3">
|
||||
{error.message || formatMessage({ id: 'codexlens.models.error.description' })}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => refetch()}
|
||||
>
|
||||
<RefreshCw className="w-3 h-3 mr-1" />
|
||||
{formatMessage({ id: 'common.actions.retry' })}
|
||||
</Button>
|
||||
</Card>
|
||||
) : isLoading ? (
|
||||
<Card className="p-8 text-center">
|
||||
<p className="text-muted-foreground">{formatMessage({ id: 'common.actions.loading' })}</p>
|
||||
</Card>
|
||||
@@ -251,10 +271,16 @@ export function ModelsTab({ installed = false }: ModelsTabProps) {
|
||||
<Card className="p-8 text-center">
|
||||
<Package className="w-12 h-12 mx-auto text-muted-foreground/30 mb-3" />
|
||||
<h3 className="text-sm font-medium text-foreground mb-1">
|
||||
{formatMessage({ id: 'codexlens.models.empty.title' })}
|
||||
{models && models.length > 0
|
||||
? formatMessage({ id: 'codexlens.models.empty.filtered' })
|
||||
: formatMessage({ id: 'codexlens.models.empty.title' })
|
||||
}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatMessage({ id: 'codexlens.models.empty.description' })}
|
||||
{models && models.length > 0
|
||||
? formatMessage({ id: 'codexlens.models.empty.filteredDesc' })
|
||||
: formatMessage({ id: 'codexlens.models.empty.description' })
|
||||
}
|
||||
</p>
|
||||
</Card>
|
||||
) : (
|
||||
|
||||
@@ -9,22 +9,22 @@ import {
|
||||
FileText,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
RotateCw,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { CodexLensVenvStatus, CodexLensConfig } from '@/lib/api';
|
||||
import { IndexOperations } from './IndexOperations';
|
||||
|
||||
interface OverviewTabProps {
|
||||
installed: boolean;
|
||||
status?: CodexLensVenvStatus;
|
||||
config?: CodexLensConfig;
|
||||
isLoading: boolean;
|
||||
onRefresh?: () => void;
|
||||
}
|
||||
|
||||
export function OverviewTab({ installed, status, config, isLoading }: OverviewTabProps) {
|
||||
export function OverviewTab({ installed, status, config, isLoading, onRefresh }: OverviewTabProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
if (isLoading) {
|
||||
@@ -142,42 +142,8 @@ export function OverviewTab({ installed, status, config, isLoading }: OverviewTa
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
{formatMessage({ id: 'codexlens.overview.actions.title' })}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<QuickActionButton
|
||||
icon={<RotateCw className="w-4 h-4" />}
|
||||
label={formatMessage({ id: 'codexlens.overview.actions.ftsFull' })}
|
||||
description={formatMessage({ id: 'codexlens.overview.actions.ftsFullDesc' })}
|
||||
disabled={!isReady}
|
||||
/>
|
||||
<QuickActionButton
|
||||
icon={<Zap className="w-4 h-4" />}
|
||||
label={formatMessage({ id: 'codexlens.overview.actions.ftsIncremental' })}
|
||||
description={formatMessage({ id: 'codexlens.overview.actions.ftsIncrementalDesc' })}
|
||||
disabled={!isReady}
|
||||
/>
|
||||
<QuickActionButton
|
||||
icon={<RotateCw className="w-4 h-4" />}
|
||||
label={formatMessage({ id: 'codexlens.overview.actions.vectorFull' })}
|
||||
description={formatMessage({ id: 'codexlens.overview.actions.vectorFullDesc' })}
|
||||
disabled={!isReady}
|
||||
/>
|
||||
<QuickActionButton
|
||||
icon={<Zap className="w-4 h-4" />}
|
||||
label={formatMessage({ id: 'codexlens.overview.actions.vectorIncremental' })}
|
||||
description={formatMessage({ id: 'codexlens.overview.actions.vectorIncrementalDesc' })}
|
||||
disabled={!isReady}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* Index Operations */}
|
||||
<IndexOperations disabled={!isReady} onRefresh={onRefresh} />
|
||||
|
||||
{/* Venv Details */}
|
||||
{status && (
|
||||
@@ -210,37 +176,3 @@ export function OverviewTab({ installed, status, config, isLoading }: OverviewTa
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface QuickActionButtonProps {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
description: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function QuickActionButton({ icon, label, description, disabled }: QuickActionButtonProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
const handleClick = () => {
|
||||
// TODO: Implement index operations in future tasks
|
||||
// For now, show a message that this feature is coming soon
|
||||
alert(formatMessage({ id: 'codexlens.comingSoon' }));
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-auto p-4 flex flex-col items-start gap-2 text-left"
|
||||
onClick={handleClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<span className={cn('text-muted-foreground', disabled && 'opacity-50')}>
|
||||
{icon}
|
||||
</span>
|
||||
<span className="font-medium">{label}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
273
ccw/frontend/src/components/codexlens/SearchTab.tsx
Normal file
273
ccw/frontend/src/components/codexlens/SearchTab.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
// ========================================
|
||||
// CodexLens Search Tab
|
||||
// ========================================
|
||||
// Semantic code search interface with multiple search types
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Search, FileCode, Code } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Label } from '@/components/ui/Label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/Select';
|
||||
import {
|
||||
useCodexLensSearch,
|
||||
useCodexLensFilesSearch,
|
||||
useCodexLensSymbolSearch,
|
||||
} from '@/hooks/useCodexLens';
|
||||
import type { CodexLensSearchParams } from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type SearchType = 'search' | 'search_files' | 'symbol';
|
||||
type SearchMode = 'dense_rerank' | 'fts' | 'fuzzy';
|
||||
|
||||
interface SearchTabProps {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export function SearchTab({ enabled }: SearchTabProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const [searchType, setSearchType] = useState<SearchType>('search');
|
||||
const [searchMode, setSearchMode] = useState<SearchMode>('dense_rerank');
|
||||
const [query, setQuery] = useState('');
|
||||
const [hasSearched, setHasSearched] = useState(false);
|
||||
|
||||
// Build search params based on search type
|
||||
const searchParams: CodexLensSearchParams = {
|
||||
query,
|
||||
limit: 20,
|
||||
mode: searchType !== 'symbol' ? searchMode : undefined,
|
||||
max_content_length: 200,
|
||||
extra_files_count: 10,
|
||||
};
|
||||
|
||||
// Search hooks - only enable when hasSearched is true and query is not empty
|
||||
const contentSearch = useCodexLensSearch(
|
||||
searchParams,
|
||||
{ enabled: enabled && hasSearched && searchType === 'search' && query.trim().length > 0 }
|
||||
);
|
||||
|
||||
const fileSearch = useCodexLensFilesSearch(
|
||||
searchParams,
|
||||
{ enabled: enabled && hasSearched && searchType === 'search_files' && query.trim().length > 0 }
|
||||
);
|
||||
|
||||
const symbolSearch = useCodexLensSymbolSearch(
|
||||
{ query, limit: 20 },
|
||||
{ enabled: enabled && hasSearched && searchType === 'symbol' && query.trim().length > 0 }
|
||||
);
|
||||
|
||||
// Get loading state based on search type
|
||||
const isLoading = searchType === 'search'
|
||||
? contentSearch.isLoading
|
||||
: searchType === 'search_files'
|
||||
? fileSearch.isLoading
|
||||
: symbolSearch.isLoading;
|
||||
|
||||
const handleSearch = () => {
|
||||
if (query.trim()) {
|
||||
setHasSearched(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSearch();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearchTypeChange = (value: SearchType) => {
|
||||
setSearchType(value);
|
||||
setHasSearched(false); // Reset search state when changing type
|
||||
};
|
||||
|
||||
const handleSearchModeChange = (value: SearchMode) => {
|
||||
setSearchMode(value);
|
||||
setHasSearched(false); // Reset search state when changing mode
|
||||
};
|
||||
|
||||
const handleQueryChange = (value: string) => {
|
||||
setQuery(value);
|
||||
setHasSearched(false); // Reset search state when query changes
|
||||
};
|
||||
|
||||
if (!enabled) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-12">
|
||||
<div className="text-center">
|
||||
<Search className="w-12 h-12 mx-auto text-muted-foreground/50 mb-4" />
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||
{formatMessage({ id: 'codexlens.search.notInstalled.title' })}
|
||||
</h3>
|
||||
<p className="text-muted-foreground">
|
||||
{formatMessage({ id: 'codexlens.search.notInstalled.description' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Search Options */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Search Type */}
|
||||
<div className="space-y-2">
|
||||
<Label>{formatMessage({ id: 'codexlens.search.type' })}</Label>
|
||||
<Select value={searchType} onValueChange={handleSearchTypeChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="search">
|
||||
<div className="flex items-center gap-2">
|
||||
<Search className="w-4 h-4" />
|
||||
{formatMessage({ id: 'codexlens.search.content' })}
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="search_files">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileCode className="w-4 h-4" />
|
||||
{formatMessage({ id: 'codexlens.search.files' })}
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="symbol">
|
||||
<div className="flex items-center gap-2">
|
||||
<Code className="w-4 h-4" />
|
||||
{formatMessage({ id: 'codexlens.search.symbol' })}
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Search Mode - only for content and file search */}
|
||||
{searchType !== 'symbol' && (
|
||||
<div className="space-y-2">
|
||||
<Label>{formatMessage({ id: 'codexlens.search.mode' })}</Label>
|
||||
<Select value={searchMode} onValueChange={handleSearchModeChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="dense_rerank">
|
||||
{formatMessage({ id: 'codexlens.search.mode.semantic' })}
|
||||
</SelectItem>
|
||||
<SelectItem value="fts">
|
||||
{formatMessage({ id: 'codexlens.search.mode.exact' })}
|
||||
</SelectItem>
|
||||
<SelectItem value="fuzzy">
|
||||
{formatMessage({ id: 'codexlens.search.mode.fuzzy' })}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Query Input */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="search-query">{formatMessage({ id: 'codexlens.search.query' })}</Label>
|
||||
<Input
|
||||
id="search-query"
|
||||
placeholder={formatMessage({ id: 'codexlens.search.queryPlaceholder' })}
|
||||
value={query}
|
||||
onChange={(e) => handleQueryChange(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Search Button */}
|
||||
<Button
|
||||
onClick={handleSearch}
|
||||
disabled={!query.trim() || isLoading}
|
||||
className="w-full"
|
||||
>
|
||||
<Search className={cn('w-4 h-4 mr-2', isLoading && 'animate-spin')} />
|
||||
{isLoading
|
||||
? formatMessage({ id: 'codexlens.search.searching' })
|
||||
: formatMessage({ id: 'codexlens.search.button' })
|
||||
}
|
||||
</Button>
|
||||
|
||||
{/* Results */}
|
||||
{hasSearched && !isLoading && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium">
|
||||
{formatMessage({ id: 'codexlens.search.results' })}
|
||||
</h3>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{searchType === 'symbol'
|
||||
? (symbolSearch.data?.success
|
||||
? `${symbolSearch.data.symbols?.length ?? 0} ${formatMessage({ id: 'codexlens.search.resultsCount' })}`
|
||||
: ''
|
||||
)
|
||||
: searchType === 'search'
|
||||
? (contentSearch.data?.success
|
||||
? `${contentSearch.data.results?.length ?? 0} ${formatMessage({ id: 'codexlens.search.resultsCount' })}`
|
||||
: ''
|
||||
)
|
||||
: (fileSearch.data?.success
|
||||
? `${fileSearch.data.results?.length ?? 0} ${formatMessage({ id: 'codexlens.search.resultsCount' })}`
|
||||
: ''
|
||||
)
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{searchType === 'symbol' && symbolSearch.data && (
|
||||
symbolSearch.data.success ? (
|
||||
<div className="rounded-lg border bg-muted/50 p-4">
|
||||
<pre className="text-xs overflow-auto max-h-96">
|
||||
{JSON.stringify(symbolSearch.data.symbols, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-destructive">
|
||||
{symbolSearch.data.error || formatMessage({ id: 'common.error' })}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{searchType === 'search' && contentSearch.data && (
|
||||
contentSearch.data.success ? (
|
||||
<div className="rounded-lg border bg-muted/50 p-4">
|
||||
<pre className="text-xs overflow-auto max-h-96">
|
||||
{JSON.stringify(contentSearch.data.results, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-destructive">
|
||||
{contentSearch.data.error || formatMessage({ id: 'common.error' })}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{searchType === 'search_files' && fileSearch.data && (
|
||||
fileSearch.data.success ? (
|
||||
<div className="rounded-lg border bg-muted/50 p-4">
|
||||
<pre className="text-xs overflow-auto max-h-96">
|
||||
{JSON.stringify(fileSearch.data.results, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-destructive">
|
||||
{fileSearch.data.error || formatMessage({ id: 'common.error' })}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SearchTab;
|
||||
@@ -19,6 +19,9 @@ export function ExecutionTab({ execution, isActive, onClick, onClose }: Executio
|
||||
// Simplify tool name (e.g., gemini-2.5-pro -> gemini)
|
||||
const toolNameShort = execution.tool.split('-')[0];
|
||||
|
||||
// Mode display - use icon for visual clarity
|
||||
const modeDisplay = execution.mode === 'write' ? '✏️' : '🔍';
|
||||
|
||||
// Status color mapping - using softer, semantic colors
|
||||
const statusColor = {
|
||||
running: 'bg-indigo-500 shadow-[0_0_6px_rgba(99,102,241,0.4)] animate-pulse',
|
||||
@@ -31,7 +34,7 @@ export function ExecutionTab({ execution, isActive, onClick, onClose }: Executio
|
||||
value={execution.id}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'gap-1.5 text-xs px-2.5 py-1 rounded-md border border-border/50 group',
|
||||
'gap-1.5 text-xs px-2.5 py-1 rounded-md border border-border/50 group shrink-0',
|
||||
isActive
|
||||
? 'bg-slate-100 dark:bg-slate-800 border-slate-300 dark:border-slate-600 shadow-sm'
|
||||
: 'bg-muted/30 hover:bg-muted/50 border-border/30',
|
||||
@@ -41,14 +44,14 @@ export function ExecutionTab({ execution, isActive, onClick, onClose }: Executio
|
||||
{/* Status indicator dot */}
|
||||
<span className={cn('w-1.5 h-1.5 rounded-full shrink-0', statusColor)} />
|
||||
|
||||
{/* Mode indicator */}
|
||||
<span className="text-[10px]" title={execution.mode}>
|
||||
{modeDisplay}
|
||||
</span>
|
||||
|
||||
{/* Simplified tool name */}
|
||||
<span className="font-medium text-[11px]">{toolNameShort}</span>
|
||||
|
||||
{/* Execution mode - show on hover */}
|
||||
<span className="opacity-0 group-hover:opacity-70 text-[10px] transition-opacity">
|
||||
{execution.mode}
|
||||
</span>
|
||||
|
||||
{/* Line count statistics - show on hover */}
|
||||
<span className="opacity-0 group-hover:opacity-50 text-[9px] tabular-nums transition-opacity">
|
||||
{execution.output.length}
|
||||
|
||||
@@ -12,6 +12,114 @@ export interface JsonDetectionResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to recover truncated JSON by completing brackets
|
||||
* This handles cases where JSON is split during streaming
|
||||
*/
|
||||
function tryRecoverTruncatedJson(content: string): Record<string, unknown> | null {
|
||||
const trimmed = content.trim();
|
||||
|
||||
// Must start with { to be recoverable JSON
|
||||
if (!trimmed.startsWith('{')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Count opening vs closing braces
|
||||
let openBraces = 0;
|
||||
let closeBraces = 0;
|
||||
let inString = false;
|
||||
let escapeNext = false;
|
||||
|
||||
for (let i = 0; i < trimmed.length; i++) {
|
||||
const char = trimmed[i];
|
||||
|
||||
if (escapeNext) {
|
||||
escapeNext = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '\\') {
|
||||
escapeNext = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"') {
|
||||
inString = !inString;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!inString) {
|
||||
if (char === '{') openBraces++;
|
||||
if (char === '}') closeBraces++;
|
||||
}
|
||||
}
|
||||
|
||||
// If we're missing closing braces, try to complete them
|
||||
if (openBraces > closeBraces) {
|
||||
const missingBraces = openBraces - closeBraces;
|
||||
const recovered = trimmed + '}'.repeat(missingBraces);
|
||||
|
||||
// Also close any open quote
|
||||
let finalRecovered = recovered;
|
||||
if (inString) {
|
||||
finalRecovered = recovered + '"';
|
||||
// Add closing braces after the quote
|
||||
finalRecovered = finalRecovered + '}'.repeat(missingBraces);
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(finalRecovered) as Record<string, unknown>;
|
||||
} catch {
|
||||
// Recovery failed, try one more approach
|
||||
}
|
||||
}
|
||||
|
||||
// Try parsing as-is first
|
||||
try {
|
||||
return JSON.parse(trimmed) as Record<string, unknown>;
|
||||
} catch {
|
||||
// If still failing, try to close any hanging structures
|
||||
// Remove trailing incomplete key/value and try again
|
||||
const lastCommaIndex = trimmed.lastIndexOf(',');
|
||||
if (lastCommaIndex > 0) {
|
||||
const truncated = trimmed.substring(0, lastCommaIndex) + '}';
|
||||
try {
|
||||
return JSON.parse(truncated) as Record<string, unknown>;
|
||||
} catch {
|
||||
// Still failed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect token usage stats pattern (common in CLI output)
|
||||
* Pattern: {"type":"result","status":"success","stats":{"total_tokens":...,"input_tokens":...,...}
|
||||
*/
|
||||
function detectTokenStats(content: string): Record<string, unknown> | null {
|
||||
// Check for common token stat patterns
|
||||
const patterns = [
|
||||
/"type"\s*:\s*"result"/,
|
||||
/"status"\s*:\s*"success"/,
|
||||
/"stats"\s*:\s*\{/,
|
||||
/"total_tokens"\s*:\s*\d+/,
|
||||
];
|
||||
|
||||
const matchCount = patterns.filter(p => p.test(content)).length;
|
||||
|
||||
// If at least 3 patterns match, this is likely token stats
|
||||
if (matchCount >= 3) {
|
||||
const recovered = tryRecoverTruncatedJson(content);
|
||||
if (recovered) {
|
||||
return recovered;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if a line contains JSON data
|
||||
* Supports multiple formats:
|
||||
@@ -20,17 +128,29 @@ export interface JsonDetectionResult {
|
||||
* - Tool Result: [Tool Result] status: {...}
|
||||
* - Embedded JSON: trailing JSON object
|
||||
* - Code block JSON: ```json ... ```
|
||||
* - Truncated JSON: handles streaming incomplete JSON
|
||||
*/
|
||||
export function detectJsonInLine(content: string): JsonDetectionResult {
|
||||
const trimmed = content.trim();
|
||||
|
||||
// 1. Direct JSON object or array
|
||||
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
||||
// First try normal parse
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
return { isJson: true, parsed: parsed as Record<string, unknown> };
|
||||
} catch {
|
||||
// Continue to other patterns
|
||||
// Normal parse failed, try recovery for truncated JSON
|
||||
const recovered = tryRecoverTruncatedJson(trimmed);
|
||||
if (recovered) {
|
||||
return { isJson: true, parsed: recovered };
|
||||
}
|
||||
|
||||
// Check for token stats pattern specifically
|
||||
const tokenStats = detectTokenStats(trimmed);
|
||||
if (tokenStats) {
|
||||
return { isJson: true, parsed: tokenStats };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// ========================================
|
||||
// Global CLI streaming monitor with multi-execution support
|
||||
|
||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import { useEffect, useRef, useCallback, useState, useMemo, memo } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
X,
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
RefreshCw,
|
||||
Search,
|
||||
ArrowDownToLine,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
@@ -26,8 +27,6 @@ import { useActiveCliExecutions, useInvalidateActiveCliExecutions } from '@/hook
|
||||
|
||||
// New components for Tab + JSON Cards
|
||||
import { ExecutionTab } from './CliStreamMonitor/components/ExecutionTab';
|
||||
import { OutputLine } from './CliStreamMonitor/components/OutputLine';
|
||||
import { JsonCard } from './CliStreamMonitor/components/JsonCard';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
@@ -95,55 +94,99 @@ function getBorderColorForType(type: CliOutputLine['type']): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single output line as a card
|
||||
* Extract content from a line (handle JSON with 'content' field)
|
||||
*/
|
||||
interface OutputLineCardProps {
|
||||
line: CliOutputLine;
|
||||
onCopy?: (content: string) => void;
|
||||
}
|
||||
|
||||
function OutputLineCard({ line, onCopy }: OutputLineCardProps) {
|
||||
const borderColor = getBorderColorForType(line.type);
|
||||
function extractContentFromLine(line: CliOutputLine): { content: string; isMarkdown: boolean } {
|
||||
const trimmed = line.content.trim();
|
||||
|
||||
// Check if line is JSON with 'content' field
|
||||
let contentToRender = trimmed;
|
||||
let isMarkdown = false;
|
||||
|
||||
try {
|
||||
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if ('content' in parsed && typeof parsed.content === 'string') {
|
||||
contentToRender = parsed.content;
|
||||
// Check if content looks like markdown
|
||||
isMarkdown = !!contentToRender.match(/^#{1,6}\s|^\*{3,}$|^\s*[-*+]\s+|^\s*\d+\.\s+|\*\*.*?\*\*|`{3,}/m);
|
||||
const content = parsed.content;
|
||||
const isMarkdown = !!content.match(/^#{1,6}\s|^\*{3,}$|^\s*[-*+]\s+|^\s*\d+\.\s+|\*\*.*?\*\*|`{3,}/m);
|
||||
return { content, isMarkdown };
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Not valid JSON, use original content
|
||||
// Check if original content looks like markdown
|
||||
isMarkdown = !!trimmed.match(/^#{1,6}\s|^\*{3,}$|^\s*[-*+]\s+|^\s*\d+\.\s+|\*\*.*?\*\*|`{3,}/m);
|
||||
}
|
||||
|
||||
// Check if original content looks like markdown
|
||||
const isMarkdown = !!trimmed.match(/^#{1,6}\s|^\*{3,}$|^\s*[-*+]\s+|^\s*\d+\.\s+|\*\*.*?\*\*|`{3,}/m);
|
||||
return { content: trimmed, isMarkdown };
|
||||
}
|
||||
|
||||
/**
|
||||
* Group consecutive output lines by type
|
||||
*/
|
||||
interface OutputLineGroup {
|
||||
type: CliOutputLine['type'];
|
||||
lines: CliOutputLine[];
|
||||
}
|
||||
|
||||
function groupConsecutiveLinesByType(lines: CliOutputLine[]): OutputLineGroup[] {
|
||||
const groups: OutputLineGroup[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
// Start new group if type changes
|
||||
if (groups.length === 0 || groups[groups.length - 1].type !== line.type) {
|
||||
groups.push({
|
||||
type: line.type,
|
||||
lines: [line],
|
||||
});
|
||||
} else {
|
||||
// Append to existing group
|
||||
groups[groups.length - 1].lines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a group of output lines as a merged card
|
||||
*/
|
||||
interface OutputLineCardProps {
|
||||
group: OutputLineGroup;
|
||||
onCopy?: (content: string) => void;
|
||||
}
|
||||
|
||||
function OutputLineCard({ group, onCopy }: OutputLineCardProps) {
|
||||
const borderColor = getBorderColorForType(group.type);
|
||||
|
||||
// Extract content from all lines in the group
|
||||
const lineContents = group.lines.map(line => extractContentFromLine(line));
|
||||
|
||||
// Check if any line has markdown
|
||||
const hasMarkdown = lineContents.some(c => c.isMarkdown);
|
||||
|
||||
return (
|
||||
<div className={`border-l-2 rounded-r my-1 py-1 px-2 group relative bg-background ${borderColor}`}>
|
||||
<div className="pr-6">
|
||||
{isMarkdown ? (
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none text-xs leading-relaxed">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{contentToRender}
|
||||
</ReactMarkdown>
|
||||
<div className={`border-l-2 rounded-r my-1 py-1 px-2 group relative bg-background contain-content ${borderColor}`}>
|
||||
<div className="pr-6 space-y-1">
|
||||
{lineContents.map((item, index) => (
|
||||
<div key={index} className="contain-layout">
|
||||
{item.isMarkdown || hasMarkdown ? (
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none text-xs leading-relaxed contain-layout">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{item.content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs whitespace-pre-wrap break-words leading-relaxed contain-layout">
|
||||
{item.content}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs whitespace-pre-wrap break-words leading-relaxed">
|
||||
{contentToRender}
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Memoize the OutputLineCard component to prevent unnecessary re-renders
|
||||
const MemoizedOutputLineCard = memo(OutputLineCard);
|
||||
|
||||
// ========== Component ==========
|
||||
|
||||
export interface CliStreamMonitorProps {
|
||||
@@ -160,11 +203,15 @@ export function CliStreamMonitor({ isOpen, onClose }: CliStreamMonitorProps) {
|
||||
const [isUserScrolling, setIsUserScrolling] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<'list' | 'blocks'>('list');
|
||||
|
||||
// Track last output length to detect new output
|
||||
const lastOutputLengthRef = useRef<Record<string, number>>({});
|
||||
|
||||
// Store state
|
||||
const executions = useCliStreamStore((state) => state.executions);
|
||||
const currentExecutionId = useCliStreamStore((state) => state.currentExecutionId);
|
||||
const setCurrentExecution = useCliStreamStore((state) => state.setCurrentExecution);
|
||||
const removeExecution = useCliStreamStore((state) => state.removeExecution);
|
||||
const markExecutionClosedByUser = useCliStreamStore((state) => state.markExecutionClosedByUser);
|
||||
|
||||
// Active execution sync
|
||||
const { isLoading: isSyncing, refetch } = useActiveCliExecutions(isOpen);
|
||||
@@ -264,21 +311,42 @@ export function CliStreamMonitor({ isOpen, onClose }: CliStreamMonitorProps) {
|
||||
});
|
||||
invalidateActive();
|
||||
}
|
||||
}, [lastMessage, currentExecutionId, setCurrentExecution, invalidateActive]);
|
||||
}, [lastMessage, invalidateActive]);
|
||||
|
||||
// Auto-scroll to bottom when new output arrives
|
||||
// Auto-scroll to bottom when new output arrives (optimized - only scroll when output length changes)
|
||||
useEffect(() => {
|
||||
if (autoScroll && !isUserScrolling && logsEndRef.current) {
|
||||
logsEndRef.current.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}, [executions, autoScroll, isUserScrolling, currentExecutionId]);
|
||||
if (!currentExecutionId || !autoScroll || isUserScrolling) return;
|
||||
|
||||
// Handle scroll to detect user scrolling
|
||||
const currentExecution = executions[currentExecutionId];
|
||||
if (!currentExecution) return;
|
||||
|
||||
const currentLength = currentExecution.output.length;
|
||||
const lastLength = lastOutputLengthRef.current[currentExecutionId] || 0;
|
||||
|
||||
// Only scroll if new output was added
|
||||
if (currentLength > lastLength) {
|
||||
lastOutputLengthRef.current[currentExecutionId] = currentLength;
|
||||
requestAnimationFrame(() => {
|
||||
if (logsEndRef.current) {
|
||||
logsEndRef.current.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [executions, currentExecutionId, autoScroll, isUserScrolling]);
|
||||
|
||||
// Handle scroll to detect user scrolling (with debounce for performance)
|
||||
const handleScrollRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const handleScroll = useCallback(() => {
|
||||
if (!logsContainerRef.current) return;
|
||||
const { scrollTop, scrollHeight, clientHeight } = logsContainerRef.current;
|
||||
const isAtBottom = scrollHeight - scrollTop - clientHeight < 50;
|
||||
setIsUserScrolling(!isAtBottom);
|
||||
if (handleScrollRef.current) {
|
||||
clearTimeout(handleScrollRef.current);
|
||||
}
|
||||
|
||||
handleScrollRef.current = setTimeout(() => {
|
||||
if (!logsContainerRef.current) return;
|
||||
const { scrollTop, scrollHeight, clientHeight } = logsContainerRef.current;
|
||||
const isAtBottom = scrollHeight - scrollTop - clientHeight < 50;
|
||||
setIsUserScrolling(!isAtBottom);
|
||||
}, 50); // 50ms debounce
|
||||
}, []);
|
||||
|
||||
// Scroll to bottom handler
|
||||
@@ -287,6 +355,28 @@ export function CliStreamMonitor({ isOpen, onClose }: CliStreamMonitorProps) {
|
||||
setIsUserScrolling(false);
|
||||
}, []);
|
||||
|
||||
// Handle closing an execution tab
|
||||
const handleCloseExecution = useCallback((executionId: string) => {
|
||||
// Mark as closed by user so it won't be re-added by server sync
|
||||
markExecutionClosedByUser(executionId);
|
||||
// Remove from local state
|
||||
removeExecution(executionId);
|
||||
// If this was the current execution, clear current selection
|
||||
if (currentExecutionId === executionId) {
|
||||
const remainingIds = Object.keys(executions).filter(id => id !== executionId);
|
||||
setCurrentExecution(remainingIds.length > 0 ? remainingIds[0] : null);
|
||||
}
|
||||
}, [markExecutionClosedByUser, removeExecution, currentExecutionId, executions, setCurrentExecution]);
|
||||
|
||||
// Close all executions
|
||||
const handleCloseAll = useCallback(() => {
|
||||
for (const id of Object.keys(executions)) {
|
||||
markExecutionClosedByUser(id);
|
||||
removeExecution(id);
|
||||
}
|
||||
setCurrentExecution(null);
|
||||
}, [markExecutionClosedByUser, removeExecution, executions, setCurrentExecution]);
|
||||
|
||||
// ESC key to close
|
||||
useEffect(() => {
|
||||
const handleEsc = (e: KeyboardEvent) => {
|
||||
@@ -302,27 +392,67 @@ export function CliStreamMonitor({ isOpen, onClose }: CliStreamMonitorProps) {
|
||||
return () => window.removeEventListener('keydown', handleEsc);
|
||||
}, [isOpen, onClose, searchQuery]);
|
||||
|
||||
// Get sorted execution IDs (running first, then by start time)
|
||||
const sortedExecutionIds = Object.keys(executions).sort((a, b) => {
|
||||
const execA = executions[a];
|
||||
const execB = executions[b];
|
||||
if (execA.status === 'running' && execB.status !== 'running') return -1;
|
||||
if (execA.status !== 'running' && execB.status === 'running') return 1;
|
||||
return execB.startTime - execA.startTime;
|
||||
});
|
||||
// Cleanup scroll handler timeout on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (handleScrollRef.current) {
|
||||
clearTimeout(handleScrollRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Active execution count for badge
|
||||
const activeCount = Object.values(executions).filter(e => e.status === 'running').length;
|
||||
// Get sorted execution IDs (memoized to avoid unnecessary recalculations)
|
||||
const sortedExecutionIds = useMemo(() => {
|
||||
return Object.keys(executions).sort((a, b) => {
|
||||
const execA = executions[a];
|
||||
const execB = executions[b];
|
||||
if (execA.status === 'running' && execB.status !== 'running') return -1;
|
||||
if (execA.status !== 'running' && execB.status === 'running') return 1;
|
||||
return execB.startTime - execA.startTime;
|
||||
});
|
||||
}, [executions]);
|
||||
|
||||
// Current execution
|
||||
const currentExecution = currentExecutionId ? executions[currentExecutionId] : null;
|
||||
// Active execution count for badge (memoized)
|
||||
const activeCount = useMemo(() => {
|
||||
return Object.values(executions).filter(e => e.status === 'running').length;
|
||||
}, [executions]);
|
||||
|
||||
// Filter output lines based on search
|
||||
const filteredOutput = currentExecution && searchQuery
|
||||
? currentExecution.output.filter(line =>
|
||||
// Current execution (memoized)
|
||||
const currentExecution = useMemo(() => {
|
||||
return currentExecutionId ? executions[currentExecutionId] : null;
|
||||
}, [currentExecutionId, executions]);
|
||||
|
||||
// Maximum lines to display (for performance)
|
||||
const MAX_DISPLAY_LINES = 1000;
|
||||
|
||||
// Filter output lines based on search (memoized with limit)
|
||||
const filteredOutput = useMemo(() => {
|
||||
if (!currentExecution) return [];
|
||||
|
||||
let output = currentExecution.output;
|
||||
|
||||
// Apply search filter
|
||||
if (searchQuery) {
|
||||
output = output.filter(line =>
|
||||
line.content.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
: currentExecution?.output || [];
|
||||
);
|
||||
}
|
||||
|
||||
// Limit display for performance
|
||||
if (output.length > MAX_DISPLAY_LINES) {
|
||||
return output.slice(-MAX_DISPLAY_LINES);
|
||||
}
|
||||
|
||||
return output;
|
||||
}, [currentExecution, searchQuery]);
|
||||
|
||||
// Check if output was truncated
|
||||
const isOutputTruncated = currentExecution && currentExecution.output.length > MAX_DISPLAY_LINES;
|
||||
|
||||
// Group output lines by type (memoized for performance)
|
||||
const groupedOutput = useMemo(() => {
|
||||
return groupConsecutiveLinesByType(filteredOutput);
|
||||
}, [filteredOutput]);
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
@@ -367,6 +497,16 @@ export function CliStreamMonitor({ isOpen, onClose }: CliStreamMonitorProps) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{sortedExecutionIds.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleCloseAll}
|
||||
title="Close all executions"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -390,7 +530,7 @@ export function CliStreamMonitor({ isOpen, onClose }: CliStreamMonitorProps) {
|
||||
onValueChange={(v) => setCurrentExecution(v || null)}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="w-full h-auto flex-wrap gap-1 bg-secondary/50 p-1">
|
||||
<TabsList className="w-full h-auto gap-1 bg-secondary/50 p-1 overflow-x-auto overflow-y-hidden no-scrollbar">
|
||||
{sortedExecutionIds.map((id) => (
|
||||
<ExecutionTab
|
||||
key={id}
|
||||
@@ -399,7 +539,7 @@ export function CliStreamMonitor({ isOpen, onClose }: CliStreamMonitorProps) {
|
||||
onClick={() => setCurrentExecution(id)}
|
||||
onClose={(e) => {
|
||||
e.stopPropagation();
|
||||
removeExecution(id);
|
||||
handleCloseExecution(id);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
@@ -472,26 +612,27 @@ export function CliStreamMonitor({ isOpen, onClose }: CliStreamMonitorProps) {
|
||||
) : (
|
||||
<div
|
||||
ref={logsContainerRef}
|
||||
className="h-full overflow-y-auto p-3 font-mono text-xs bg-background"
|
||||
className="h-full overflow-y-auto p-3 font-mono text-xs bg-background contain-strict"
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{isOutputTruncated && (
|
||||
<div className="mb-2 p-2 bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800 rounded text-amber-800 dark:text-amber-200 text-xs">
|
||||
Showing last {MAX_DISPLAY_LINES} lines of {currentExecution?.output.length} total lines. Use search to find specific content.
|
||||
</div>
|
||||
)}
|
||||
{filteredOutput.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
{searchQuery ? 'No matching output found' : 'Waiting for output...'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{(() => {
|
||||
// Group output lines by type
|
||||
const groupedOutput = groupOutputLines(filteredOutput);
|
||||
return groupedOutput.map((group, groupIndex) => (
|
||||
<OutputGroupRenderer
|
||||
key={`group-${group.type}-${groupIndex}`}
|
||||
group={group}
|
||||
onCopy={(content) => navigator.clipboard.writeText(content)}
|
||||
/>
|
||||
));
|
||||
})()}
|
||||
<div>
|
||||
{groupedOutput.map((group, groupIndex) => (
|
||||
<MemoizedOutputLineCard
|
||||
key={`group-${group.type}-${groupIndex}`}
|
||||
group={group}
|
||||
onCopy={(content) => navigator.clipboard.writeText(content)}
|
||||
/>
|
||||
))}
|
||||
<div ref={logsEndRef} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -105,8 +105,12 @@ export function useActiveCliExecutions(
|
||||
refetchInterval: number = 5000
|
||||
) {
|
||||
const upsertExecution = useCliStreamStore(state => state.upsertExecution);
|
||||
const removeExecution = useCliStreamStore(state => state.removeExecution);
|
||||
const executions = useCliStreamStore(state => state.executions);
|
||||
const setCurrentExecution = useCliStreamStore(state => state.setCurrentExecution);
|
||||
const markExecutionClosedByUser = useCliStreamStore(state => state.markExecutionClosedByUser);
|
||||
const isExecutionClosedByUser = useCliStreamStore(state => state.isExecutionClosedByUser);
|
||||
const cleanupUserClosedExecutions = useCliStreamStore(state => state.cleanupUserClosedExecutions);
|
||||
|
||||
return useQuery({
|
||||
queryKey: ACTIVE_CLI_EXECUTIONS_QUERY_KEY,
|
||||
@@ -117,11 +121,33 @@ export function useActiveCliExecutions(
|
||||
}
|
||||
const data: ActiveCliExecutionsResponse = await response.json();
|
||||
|
||||
// Get server execution IDs
|
||||
const serverIds = new Set(data.executions.map(e => e.id));
|
||||
|
||||
// Clean up userClosedExecutions - remove those no longer on server
|
||||
cleanupUserClosedExecutions(serverIds);
|
||||
|
||||
// Remove executions that are no longer on server and were closed by user
|
||||
for (const [id, exec] of Object.entries(executions)) {
|
||||
if (isExecutionClosedByUser(id)) {
|
||||
// User closed this execution, remove from local state
|
||||
removeExecution(id);
|
||||
} else if (exec.status !== 'running' && !serverIds.has(id) && exec.recovered) {
|
||||
// Not running, not on server, and was recovered (not user-created)
|
||||
removeExecution(id);
|
||||
}
|
||||
}
|
||||
|
||||
// Process executions and sync to store
|
||||
let hasNewExecution = false;
|
||||
const now = Date.now();
|
||||
|
||||
for (const exec of data.executions) {
|
||||
// Skip if user closed this execution
|
||||
if (isExecutionClosedByUser(exec.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = executions[exec.id];
|
||||
const historicalOutput = parseHistoricalOutput(exec.output || '', exec.startTime);
|
||||
|
||||
@@ -175,7 +201,7 @@ export function useActiveCliExecutions(
|
||||
|
||||
// Set current execution to first running execution if none selected
|
||||
if (hasNewExecution) {
|
||||
const runningExec = data.executions.find(e => e.status === 'running');
|
||||
const runningExec = data.executions.find(e => e.status === 'running' && !isExecutionClosedByUser(e.id));
|
||||
if (runningExec && !executions[runningExec.id]) {
|
||||
setCurrentExecution(runningExec.id);
|
||||
}
|
||||
|
||||
623
ccw/frontend/src/hooks/useApiSettings.ts
Normal file
623
ccw/frontend/src/hooks/useApiSettings.ts
Normal file
@@ -0,0 +1,623 @@
|
||||
// ========================================
|
||||
// useApiSettings Hook
|
||||
// ========================================
|
||||
// TanStack Query hooks for API Settings management
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
fetchProviders,
|
||||
createProvider,
|
||||
updateProvider,
|
||||
deleteProvider,
|
||||
testProvider,
|
||||
testProviderKey,
|
||||
getProviderHealthStatus,
|
||||
triggerProviderHealthCheck,
|
||||
fetchEndpoints,
|
||||
createEndpoint,
|
||||
updateEndpoint,
|
||||
deleteEndpoint,
|
||||
fetchCacheStats,
|
||||
clearCache,
|
||||
updateCacheSettings,
|
||||
fetchModelPools,
|
||||
fetchModelPool,
|
||||
createModelPool,
|
||||
updateModelPool,
|
||||
deleteModelPool,
|
||||
getAvailableModelsForPool,
|
||||
discoverModelsForPool,
|
||||
fetchApiConfig,
|
||||
syncApiConfig,
|
||||
previewYamlConfig,
|
||||
checkCcwLitellmStatus,
|
||||
installCcwLitellm,
|
||||
uninstallCcwLitellm,
|
||||
type ProviderCredential,
|
||||
type CustomEndpoint,
|
||||
type CacheStats,
|
||||
type GlobalCacheSettings,
|
||||
type ModelPoolConfig,
|
||||
type ModelPoolType,
|
||||
type DiscoveredProvider,
|
||||
} from '../lib/api';
|
||||
|
||||
// Query key factory
|
||||
export const apiSettingsKeys = {
|
||||
all: ['apiSettings'] as const,
|
||||
providers: () => [...apiSettingsKeys.all, 'providers'] as const,
|
||||
provider: (id: string) => [...apiSettingsKeys.providers(), id] as const,
|
||||
endpoints: () => [...apiSettingsKeys.all, 'endpoints'] as const,
|
||||
endpoint: (id: string) => [...apiSettingsKeys.endpoints(), id] as const,
|
||||
cache: () => [...apiSettingsKeys.all, 'cache'] as const,
|
||||
modelPools: () => [...apiSettingsKeys.all, 'modelPools'] as const,
|
||||
modelPool: (id: string) => [...apiSettingsKeys.modelPools(), id] as const,
|
||||
ccwLitellm: () => [...apiSettingsKeys.all, 'ccwLitellm'] as const,
|
||||
};
|
||||
|
||||
const STALE_TIME = 2 * 60 * 1000;
|
||||
|
||||
// ========================================
|
||||
// Provider Hooks
|
||||
// ========================================
|
||||
|
||||
export interface UseProvidersOptions {
|
||||
staleTime?: number;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface UseProvidersReturn {
|
||||
providers: ProviderCredential[];
|
||||
totalCount: number;
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
error: Error | null;
|
||||
refetch: () => Promise<void>;
|
||||
invalidate: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useProviders(options: UseProvidersOptions = {}): UseProvidersReturn {
|
||||
const { staleTime = STALE_TIME, enabled = true } = options;
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: apiSettingsKeys.providers(),
|
||||
queryFn: fetchProviders,
|
||||
staleTime,
|
||||
enabled,
|
||||
retry: 2,
|
||||
});
|
||||
|
||||
const providers = query.data?.providers ?? [];
|
||||
|
||||
const refetch = async () => {
|
||||
await query.refetch();
|
||||
};
|
||||
|
||||
const invalidate = async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: apiSettingsKeys.providers() });
|
||||
};
|
||||
|
||||
return {
|
||||
providers,
|
||||
totalCount: providers.length,
|
||||
isLoading: query.isLoading,
|
||||
isFetching: query.isFetching,
|
||||
error: query.error,
|
||||
refetch,
|
||||
invalidate,
|
||||
};
|
||||
}
|
||||
|
||||
export function useCreateProvider() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (provider: Omit<ProviderCredential, 'id' | 'createdAt' | 'updatedAt'>) =>
|
||||
createProvider(provider),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: apiSettingsKeys.providers() });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
createProvider: mutation.mutateAsync,
|
||||
isCreating: mutation.isPending,
|
||||
error: mutation.error,
|
||||
};
|
||||
}
|
||||
|
||||
export function useUpdateProvider() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: ({ providerId, updates }: { providerId: string; updates: Partial<Omit<ProviderCredential, 'id' | 'createdAt' | 'updatedAt'>> }) =>
|
||||
updateProvider(providerId, updates),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: apiSettingsKeys.providers() });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
updateProvider: (providerId: string, updates: Partial<Omit<ProviderCredential, 'id' | 'createdAt' | 'updatedAt'>>) =>
|
||||
mutation.mutateAsync({ providerId, updates }),
|
||||
isUpdating: mutation.isPending,
|
||||
error: mutation.error,
|
||||
};
|
||||
}
|
||||
|
||||
export function useDeleteProvider() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (providerId: string) => deleteProvider(providerId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: apiSettingsKeys.providers() });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
deleteProvider: mutation.mutateAsync,
|
||||
isDeleting: mutation.isPending,
|
||||
error: mutation.error,
|
||||
};
|
||||
}
|
||||
|
||||
export function useTestProvider() {
|
||||
const mutation = useMutation({
|
||||
mutationFn: (providerId: string) => testProvider(providerId),
|
||||
});
|
||||
|
||||
return {
|
||||
testProvider: mutation.mutateAsync,
|
||||
isTesting: mutation.isPending,
|
||||
error: mutation.error,
|
||||
};
|
||||
}
|
||||
|
||||
export function useTestProviderKey() {
|
||||
const mutation = useMutation({
|
||||
mutationFn: ({ providerId, keyId }: { providerId: string; keyId: string }) =>
|
||||
testProviderKey(providerId, keyId),
|
||||
});
|
||||
|
||||
return {
|
||||
testProviderKey: mutation.mutateAsync,
|
||||
isTesting: mutation.isPending,
|
||||
error: mutation.error,
|
||||
};
|
||||
}
|
||||
|
||||
export function useProviderHealthStatus(providerId: string) {
|
||||
return useQuery({
|
||||
queryKey: [...apiSettingsKeys.provider(providerId), 'health'],
|
||||
queryFn: () => getProviderHealthStatus(providerId),
|
||||
enabled: !!providerId,
|
||||
staleTime: 30000, // 30 seconds
|
||||
refetchInterval: 60000, // Refetch every minute
|
||||
});
|
||||
}
|
||||
|
||||
export function useTriggerProviderHealthCheck() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (providerId: string) => triggerProviderHealthCheck(providerId),
|
||||
onSuccess: (_, providerId) => {
|
||||
queryClient.invalidateQueries({ queryKey: [...apiSettingsKeys.provider(providerId), 'health'] });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
triggerHealthCheck: mutation.mutateAsync,
|
||||
isChecking: mutation.isPending,
|
||||
error: mutation.error,
|
||||
};
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Endpoint Hooks
|
||||
// ========================================
|
||||
|
||||
export interface UseEndpointsOptions {
|
||||
staleTime?: number;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface UseEndpointsReturn {
|
||||
endpoints: CustomEndpoint[];
|
||||
totalCount: number;
|
||||
cachedCount: number;
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
error: Error | null;
|
||||
refetch: () => Promise<void>;
|
||||
invalidate: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useEndpoints(options: UseEndpointsOptions = {}): UseEndpointsReturn {
|
||||
const { staleTime = STALE_TIME, enabled = true } = options;
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: apiSettingsKeys.endpoints(),
|
||||
queryFn: fetchEndpoints,
|
||||
staleTime,
|
||||
enabled,
|
||||
retry: 2,
|
||||
});
|
||||
|
||||
const endpoints = query.data?.endpoints ?? [];
|
||||
const cachedEndpoints = endpoints.filter((e) => e.cacheStrategy.enabled);
|
||||
|
||||
const refetch = async () => {
|
||||
await query.refetch();
|
||||
};
|
||||
|
||||
const invalidate = async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: apiSettingsKeys.endpoints() });
|
||||
};
|
||||
|
||||
return {
|
||||
endpoints,
|
||||
totalCount: endpoints.length,
|
||||
cachedCount: cachedEndpoints.length,
|
||||
isLoading: query.isLoading,
|
||||
isFetching: query.isFetching,
|
||||
error: query.error,
|
||||
refetch,
|
||||
invalidate,
|
||||
};
|
||||
}
|
||||
|
||||
export function useCreateEndpoint() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (endpoint: Omit<CustomEndpoint, 'createdAt' | 'updatedAt'>) =>
|
||||
createEndpoint(endpoint),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: apiSettingsKeys.endpoints() });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
createEndpoint: mutation.mutateAsync,
|
||||
isCreating: mutation.isPending,
|
||||
error: mutation.error,
|
||||
};
|
||||
}
|
||||
|
||||
export function useUpdateEndpoint() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: ({ endpointId, updates }: { endpointId: string; updates: Partial<Omit<CustomEndpoint, 'id' | 'createdAt' | 'updatedAt'>> }) =>
|
||||
updateEndpoint(endpointId, updates),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: apiSettingsKeys.endpoints() });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
updateEndpoint: (endpointId: string, updates: Partial<Omit<CustomEndpoint, 'id' | 'createdAt' | 'updatedAt'>>) =>
|
||||
mutation.mutateAsync({ endpointId, updates }),
|
||||
isUpdating: mutation.isPending,
|
||||
error: mutation.error,
|
||||
};
|
||||
}
|
||||
|
||||
export function useDeleteEndpoint() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (endpointId: string) => deleteEndpoint(endpointId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: apiSettingsKeys.endpoints() });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
deleteEndpoint: mutation.mutateAsync,
|
||||
isDeleting: mutation.isPending,
|
||||
error: mutation.error,
|
||||
};
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Cache Hooks
|
||||
// ========================================
|
||||
|
||||
export interface UseCacheStatsOptions {
|
||||
staleTime?: number;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface UseCacheStatsReturn {
|
||||
stats: CacheStats | null;
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
error: Error | null;
|
||||
refetch: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useCacheStats(options: UseCacheStatsOptions = {}): UseCacheStatsReturn {
|
||||
const { staleTime = 30000, enabled = true } = options; // 30 seconds stale time for cache stats
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: apiSettingsKeys.cache(),
|
||||
queryFn: fetchCacheStats,
|
||||
staleTime,
|
||||
enabled,
|
||||
retry: 2,
|
||||
});
|
||||
|
||||
const refetch = async () => {
|
||||
await query.refetch();
|
||||
};
|
||||
|
||||
return {
|
||||
stats: query.data ?? null,
|
||||
isLoading: query.isLoading,
|
||||
isFetching: query.isFetching,
|
||||
error: query.error,
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
|
||||
export function useClearCache() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => clearCache(),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: apiSettingsKeys.cache() });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
clearCache: mutation.mutateAsync,
|
||||
isClearing: mutation.isPending,
|
||||
error: mutation.error,
|
||||
};
|
||||
}
|
||||
|
||||
export function useUpdateCacheSettings() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (settings: Partial<{ enabled: boolean; cacheDir: string; maxTotalSizeMB: number }>) =>
|
||||
updateCacheSettings(settings),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: apiSettingsKeys.cache() });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
updateCacheSettings: mutation.mutateAsync,
|
||||
isUpdating: mutation.isPending,
|
||||
error: mutation.error,
|
||||
};
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Model Pool Hooks
|
||||
// ========================================
|
||||
|
||||
export interface UseModelPoolsOptions {
|
||||
staleTime?: number;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface UseModelPoolsReturn {
|
||||
pools: ModelPoolConfig[];
|
||||
totalCount: number;
|
||||
enabledCount: number;
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
error: Error | null;
|
||||
refetch: () => Promise<void>;
|
||||
invalidate: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useModelPools(options: UseModelPoolsOptions = {}): UseModelPoolsReturn {
|
||||
const { staleTime = STALE_TIME, enabled = true } = options;
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: apiSettingsKeys.modelPools(),
|
||||
queryFn: fetchModelPools,
|
||||
staleTime,
|
||||
enabled,
|
||||
retry: 2,
|
||||
});
|
||||
|
||||
const pools = query.data?.pools ?? [];
|
||||
const enabledPools = pools.filter((p) => p.enabled);
|
||||
|
||||
const refetch = async () => {
|
||||
await query.refetch();
|
||||
};
|
||||
|
||||
const invalidate = async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: apiSettingsKeys.modelPools() });
|
||||
};
|
||||
|
||||
return {
|
||||
pools,
|
||||
totalCount: pools.length,
|
||||
enabledCount: enabledPools.length,
|
||||
isLoading: query.isLoading,
|
||||
isFetching: query.isFetching,
|
||||
error: query.error,
|
||||
refetch,
|
||||
invalidate,
|
||||
};
|
||||
}
|
||||
|
||||
export function useModelPool(poolId: string) {
|
||||
return useQuery({
|
||||
queryKey: apiSettingsKeys.modelPool(poolId),
|
||||
queryFn: () => fetchModelPool(poolId),
|
||||
enabled: !!poolId,
|
||||
staleTime: STALE_TIME,
|
||||
retry: 2,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateModelPool() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (pool: Omit<ModelPoolConfig, 'id'>) => createModelPool(pool),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: apiSettingsKeys.modelPools() });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
createModelPool: mutation.mutateAsync,
|
||||
isCreating: mutation.isPending,
|
||||
error: mutation.error,
|
||||
};
|
||||
}
|
||||
|
||||
export function useUpdateModelPool() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: ({ poolId, updates }: { poolId: string; updates: Partial<ModelPoolConfig> }) =>
|
||||
updateModelPool(poolId, updates),
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: apiSettingsKeys.modelPools() });
|
||||
queryClient.invalidateQueries({ queryKey: apiSettingsKeys.modelPool(variables.poolId) });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
updateModelPool: (poolId: string, updates: Partial<ModelPoolConfig>) =>
|
||||
mutation.mutateAsync({ poolId, updates }),
|
||||
isUpdating: mutation.isPending,
|
||||
error: mutation.error,
|
||||
};
|
||||
}
|
||||
|
||||
export function useDeleteModelPool() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (poolId: string) => deleteModelPool(poolId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: apiSettingsKeys.modelPools() });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
deleteModelPool: mutation.mutateAsync,
|
||||
isDeleting: mutation.isPending,
|
||||
error: mutation.error,
|
||||
};
|
||||
}
|
||||
|
||||
export function useAvailableModelsForPool(modelType: ModelPoolType) {
|
||||
return useQuery({
|
||||
queryKey: [...apiSettingsKeys.modelPools(), 'available', modelType],
|
||||
queryFn: () => getAvailableModelsForPool(modelType),
|
||||
enabled: !!modelType,
|
||||
staleTime: STALE_TIME,
|
||||
});
|
||||
}
|
||||
|
||||
export function useDiscoverModelsForPool() {
|
||||
const mutation = useMutation({
|
||||
mutationFn: ({ modelType, targetModel }: { modelType: ModelPoolType; targetModel: string }) =>
|
||||
discoverModelsForPool(modelType, targetModel),
|
||||
});
|
||||
|
||||
return {
|
||||
discoverModels: (modelType: ModelPoolType, targetModel: string) =>
|
||||
mutation.mutateAsync({ modelType, targetModel }),
|
||||
isDiscovering: mutation.isPending,
|
||||
error: mutation.error,
|
||||
data: mutation.data,
|
||||
};
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Config Hooks
|
||||
// ========================================
|
||||
|
||||
export function useApiConfig() {
|
||||
return useQuery({
|
||||
queryKey: [...apiSettingsKeys.all, 'config'],
|
||||
queryFn: fetchApiConfig,
|
||||
staleTime: STALE_TIME,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSyncApiConfig() {
|
||||
return useMutation({
|
||||
mutationFn: () => syncApiConfig(),
|
||||
});
|
||||
}
|
||||
|
||||
export function usePreviewYamlConfig() {
|
||||
return useMutation({
|
||||
mutationFn: () => previewYamlConfig(),
|
||||
});
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// CCW-LiteLLM Package Hooks
|
||||
// ========================================
|
||||
|
||||
export interface UseCcwLitellmStatusOptions {
|
||||
staleTime?: number;
|
||||
enabled?: boolean;
|
||||
refresh?: boolean;
|
||||
}
|
||||
|
||||
export function useCcwLitellmStatus(options: UseCcwLitellmStatusOptions = {}) {
|
||||
const { staleTime = 5 * 60 * 1000, enabled = true, refresh = false } = options;
|
||||
|
||||
return useQuery({
|
||||
queryKey: [...apiSettingsKeys.ccwLitellm(), 'status', refresh],
|
||||
queryFn: () => checkCcwLitellmStatus(refresh),
|
||||
staleTime,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useInstallCcwLitellm() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => installCcwLitellm(),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: apiSettingsKeys.ccwLitellm() });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
install: mutation.mutateAsync,
|
||||
isInstalling: mutation.isPending,
|
||||
error: mutation.error,
|
||||
};
|
||||
}
|
||||
|
||||
export function useUninstallCcwLitellm() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => uninstallCcwLitellm(),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: apiSettingsKeys.ccwLitellm() });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
uninstall: mutation.mutateAsync,
|
||||
isUninstalling: mutation.isPending,
|
||||
error: mutation.error,
|
||||
};
|
||||
}
|
||||
@@ -26,6 +26,14 @@ import {
|
||||
resetCodexLensGpu,
|
||||
fetchCodexLensIgnorePatterns,
|
||||
updateCodexLensIgnorePatterns,
|
||||
searchCodexLens,
|
||||
searchFilesCodexLens,
|
||||
searchSymbolCodexLens,
|
||||
fetchCodexLensIndexes,
|
||||
rebuildCodexLensIndex,
|
||||
updateCodexLensIndex,
|
||||
cancelCodexLensIndexing,
|
||||
checkCodexLensIndexingStatus,
|
||||
type CodexLensDashboardInitResponse,
|
||||
type CodexLensVenvStatus,
|
||||
type CodexLensConfig,
|
||||
@@ -39,6 +47,11 @@ import {
|
||||
type CodexLensUpdateEnvRequest,
|
||||
type CodexLensUpdateIgnorePatternsRequest,
|
||||
type CodexLensWorkspaceStatus,
|
||||
type CodexLensSearchParams,
|
||||
type CodexLensSearchResponse,
|
||||
type CodexLensSymbolSearchResponse,
|
||||
type CodexLensIndexesResponse,
|
||||
type CodexLensIndexingStatusResponse,
|
||||
} from '../lib/api';
|
||||
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
|
||||
|
||||
@@ -56,6 +69,11 @@ export const codexLensKeys = {
|
||||
gpuList: () => [...codexLensKeys.gpu(), 'list'] as const,
|
||||
gpuDetect: () => [...codexLensKeys.gpu(), 'detect'] as const,
|
||||
ignorePatterns: () => [...codexLensKeys.all, 'ignorePatterns'] as const,
|
||||
indexes: () => [...codexLensKeys.all, 'indexes'] as const,
|
||||
indexingStatus: () => [...codexLensKeys.all, 'indexingStatus'] as const,
|
||||
search: (params: CodexLensSearchParams) => [...codexLensKeys.all, 'search', params] as const,
|
||||
filesSearch: (params: CodexLensSearchParams) => [...codexLensKeys.all, 'filesSearch', params] as const,
|
||||
symbolSearch: (params: Pick<CodexLensSearchParams, 'query' | 'limit'>) => [...codexLensKeys.all, 'symbolSearch', params] as const,
|
||||
};
|
||||
|
||||
// Default stale times
|
||||
@@ -715,6 +733,189 @@ export function useUpdateIgnorePatterns(): UseUpdateIgnorePatternsReturn {
|
||||
};
|
||||
}
|
||||
|
||||
// ========== Index Management Hooks ==========
|
||||
|
||||
export interface UseCodexLensIndexesOptions {
|
||||
enabled?: boolean;
|
||||
staleTime?: number;
|
||||
}
|
||||
|
||||
export interface UseCodexLensIndexesReturn {
|
||||
data: CodexLensIndexesResponse | undefined;
|
||||
indexes: CodexLensIndexesResponse['indexes'] | undefined;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
refetch: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for fetching CodexLens indexes
|
||||
*/
|
||||
export function useCodexLensIndexes(options: UseCodexLensIndexesOptions = {}): UseCodexLensIndexesReturn {
|
||||
const { enabled = true, staleTime = STALE_TIME_MEDIUM } = options;
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: codexLensKeys.indexes(),
|
||||
queryFn: fetchCodexLensIndexes,
|
||||
staleTime,
|
||||
enabled,
|
||||
retry: 2,
|
||||
});
|
||||
|
||||
const refetch = async () => {
|
||||
await query.refetch();
|
||||
};
|
||||
|
||||
return {
|
||||
data: query.data,
|
||||
indexes: query.data?.indexes,
|
||||
isLoading: query.isLoading,
|
||||
error: query.error,
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
|
||||
export interface UseCodexLensIndexingStatusReturn {
|
||||
data: CodexLensIndexingStatusResponse | undefined;
|
||||
inProgress: boolean;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for checking CodexLens indexing status
|
||||
*/
|
||||
export function useCodexLensIndexingStatus(): UseCodexLensIndexingStatusReturn {
|
||||
const query = useQuery({
|
||||
queryKey: codexLensKeys.indexingStatus(),
|
||||
queryFn: checkCodexLensIndexingStatus,
|
||||
staleTime: STALE_TIME_SHORT,
|
||||
refetchInterval: (data) => (data?.inProgress ? 2000 : false), // Poll every 2s when indexing
|
||||
retry: false,
|
||||
});
|
||||
|
||||
return {
|
||||
data: query.data,
|
||||
inProgress: query.data?.inProgress ?? false,
|
||||
isLoading: query.isLoading,
|
||||
error: query.error,
|
||||
};
|
||||
}
|
||||
|
||||
export interface UseRebuildIndexReturn {
|
||||
rebuildIndex: (projectPath: string, options?: {
|
||||
indexType?: 'normal' | 'vector';
|
||||
embeddingModel?: string;
|
||||
embeddingBackend?: 'fastembed' | 'litellm';
|
||||
maxWorkers?: number;
|
||||
}) => Promise<{ success: boolean; message?: string; error?: string }>;
|
||||
isRebuilding: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for rebuilding CodexLens index (full rebuild)
|
||||
*/
|
||||
export function useRebuildIndex(): UseRebuildIndexReturn {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async ({
|
||||
projectPath,
|
||||
options = {},
|
||||
}: {
|
||||
projectPath: string;
|
||||
options?: {
|
||||
indexType?: 'normal' | 'vector';
|
||||
embeddingModel?: string;
|
||||
embeddingBackend?: 'fastembed' | 'litellm';
|
||||
maxWorkers?: number;
|
||||
};
|
||||
}) => rebuildCodexLensIndex(projectPath, options),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: codexLensKeys.indexes() });
|
||||
queryClient.invalidateQueries({ queryKey: codexLensKeys.dashboard() });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
rebuildIndex: (projectPath, options) =>
|
||||
mutation.mutateAsync({ projectPath, options }),
|
||||
isRebuilding: mutation.isPending,
|
||||
error: mutation.error,
|
||||
};
|
||||
}
|
||||
|
||||
export interface UseUpdateIndexReturn {
|
||||
updateIndex: (projectPath: string, options?: {
|
||||
indexType?: 'normal' | 'vector';
|
||||
embeddingModel?: string;
|
||||
embeddingBackend?: 'fastembed' | 'litellm';
|
||||
maxWorkers?: number;
|
||||
}) => Promise<{ success: boolean; message?: string; error?: string }>;
|
||||
isUpdating: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for updating CodexLens index (incremental update)
|
||||
*/
|
||||
export function useUpdateIndex(): UseUpdateIndexReturn {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async ({
|
||||
projectPath,
|
||||
options = {},
|
||||
}: {
|
||||
projectPath: string;
|
||||
options?: {
|
||||
indexType?: 'normal' | 'vector';
|
||||
embeddingModel?: string;
|
||||
embeddingBackend?: 'fastembed' | 'litellm';
|
||||
maxWorkers?: number;
|
||||
};
|
||||
}) => updateCodexLensIndex(projectPath, options),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: codexLensKeys.indexes() });
|
||||
queryClient.invalidateQueries({ queryKey: codexLensKeys.dashboard() });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
updateIndex: (projectPath, options) =>
|
||||
mutation.mutateAsync({ projectPath, options }),
|
||||
isUpdating: mutation.isPending,
|
||||
error: mutation.error,
|
||||
};
|
||||
}
|
||||
|
||||
export interface UseCancelIndexingReturn {
|
||||
cancelIndexing: () => Promise<{ success: boolean; error?: string }>;
|
||||
isCancelling: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for canceling CodexLens indexing
|
||||
*/
|
||||
export function useCancelIndexing(): UseCancelIndexingReturn {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: cancelCodexLensIndexing,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: codexLensKeys.indexingStatus() });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
cancelIndexing: mutation.mutateAsync,
|
||||
isCancelling: mutation.isPending,
|
||||
error: mutation.error,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Combined hook for all CodexLens mutations
|
||||
*/
|
||||
@@ -727,6 +928,9 @@ export function useCodexLensMutations() {
|
||||
const updateEnv = useUpdateCodexLensEnv();
|
||||
const gpu = useSelectGpu();
|
||||
const updatePatterns = useUpdateIgnorePatterns();
|
||||
const rebuildIndex = useRebuildIndex();
|
||||
const updateIndex = useUpdateIndex();
|
||||
const cancelIndexing = useCancelIndexing();
|
||||
|
||||
return {
|
||||
updateConfig: updateConfig.updateConfig,
|
||||
@@ -748,6 +952,12 @@ export function useCodexLensMutations() {
|
||||
isSelectingGpu: gpu.isSelecting || gpu.isResetting,
|
||||
updatePatterns: updatePatterns.updatePatterns,
|
||||
isUpdatingPatterns: updatePatterns.isUpdating,
|
||||
rebuildIndex: rebuildIndex.rebuildIndex,
|
||||
isRebuildingIndex: rebuildIndex.isRebuilding,
|
||||
updateIndex: updateIndex.updateIndex,
|
||||
isUpdatingIndex: updateIndex.isUpdating,
|
||||
cancelIndexing: cancelIndexing.cancelIndexing,
|
||||
isCancellingIndexing: cancelIndexing.isCancelling,
|
||||
isMutating:
|
||||
updateConfig.isUpdating ||
|
||||
bootstrap.isBootstrapping ||
|
||||
@@ -757,6 +967,119 @@ export function useCodexLensMutations() {
|
||||
updateEnv.isUpdating ||
|
||||
gpu.isSelecting ||
|
||||
gpu.isResetting ||
|
||||
updatePatterns.isUpdating,
|
||||
updatePatterns.isUpdating ||
|
||||
rebuildIndex.isRebuilding ||
|
||||
updateIndex.isUpdating ||
|
||||
cancelIndexing.isCancelling,
|
||||
};
|
||||
}
|
||||
|
||||
// ========== Search Hooks ==========
|
||||
|
||||
export interface UseCodexLensSearchOptions {
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface UseCodexLensSearchReturn {
|
||||
data: CodexLensSearchResponse | undefined;
|
||||
results: CodexLensSearchResponse['results'] | undefined;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
refetch: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for content search using CodexLens
|
||||
*/
|
||||
export function useCodexLensSearch(params: CodexLensSearchParams, options: UseCodexLensSearchOptions = {}): UseCodexLensSearchReturn {
|
||||
const { enabled = false } = options;
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: codexLensKeys.search(params),
|
||||
queryFn: () => searchCodexLens(params),
|
||||
enabled,
|
||||
staleTime: STALE_TIME_SHORT,
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
const refetch = async () => {
|
||||
await query.refetch();
|
||||
};
|
||||
|
||||
return {
|
||||
data: query.data,
|
||||
results: query.data?.results,
|
||||
isLoading: query.isLoading,
|
||||
error: query.error,
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for file search using CodexLens
|
||||
*/
|
||||
export function useCodexLensFilesSearch(params: CodexLensSearchParams, options: UseCodexLensSearchOptions = {}): UseCodexLensSearchReturn {
|
||||
const { enabled = false } = options;
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: codexLensKeys.filesSearch(params),
|
||||
queryFn: () => searchFilesCodexLens(params),
|
||||
enabled,
|
||||
staleTime: STALE_TIME_SHORT,
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
const refetch = async () => {
|
||||
await query.refetch();
|
||||
};
|
||||
|
||||
return {
|
||||
data: query.data,
|
||||
results: query.data?.results,
|
||||
isLoading: query.isLoading,
|
||||
error: query.error,
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
|
||||
export interface UseCodexLensSymbolSearchOptions {
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface UseCodexLensSymbolSearchReturn {
|
||||
data: CodexLensSymbolSearchResponse | undefined;
|
||||
symbols: CodexLensSymbolSearchResponse['symbols'] | undefined;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
refetch: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for symbol search using CodexLens
|
||||
*/
|
||||
export function useCodexLensSymbolSearch(
|
||||
params: Pick<CodexLensSearchParams, 'query' | 'limit'>,
|
||||
options: UseCodexLensSymbolSearchOptions = {}
|
||||
): UseCodexLensSymbolSearchReturn {
|
||||
const { enabled = false } = options;
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: codexLensKeys.symbolSearch(params),
|
||||
queryFn: () => searchSymbolCodexLens(params),
|
||||
enabled,
|
||||
staleTime: STALE_TIME_SHORT,
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
const refetch = async () => {
|
||||
await query.refetch();
|
||||
};
|
||||
|
||||
return {
|
||||
data: query.data,
|
||||
symbols: query.data?.symbols,
|
||||
isLoading: query.isLoading,
|
||||
error: query.error,
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
// ========================================
|
||||
// TanStack Query hook for project overview data
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { fetchProjectOverview } from '../lib/api';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { fetchProjectOverview, updateProjectGuidelines, type ProjectGuidelines } from '../lib/api';
|
||||
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
|
||||
|
||||
// Query key factory
|
||||
@@ -53,3 +53,39 @@ export function useProjectOverview(options: UseProjectOverviewOptions = {}) {
|
||||
refetch: query.refetch,
|
||||
};
|
||||
}
|
||||
|
||||
// ========== Mutations ==========
|
||||
|
||||
export interface UseUpdateGuidelinesReturn {
|
||||
updateGuidelines: (guidelines: ProjectGuidelines) => Promise<{ success: boolean; guidelines?: ProjectGuidelines; error?: string }>;
|
||||
isUpdating: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for updating project guidelines
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const { updateGuidelines, isUpdating } = useUpdateGuidelines();
|
||||
* await updateGuidelines({ conventions: { ... }, constraints: { ... } });
|
||||
* ```
|
||||
*/
|
||||
export function useUpdateGuidelines(): UseUpdateGuidelinesReturn {
|
||||
const queryClient = useQueryClient();
|
||||
const projectPath = useWorkflowStore(selectProjectPath);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (guidelines: ProjectGuidelines) => updateProjectGuidelines(guidelines, projectPath),
|
||||
onSuccess: () => {
|
||||
// Invalidate project overview cache to trigger refetch
|
||||
queryClient.invalidateQueries({ queryKey: projectOverviewKeys.detail() });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
updateGuidelines: mutation.mutateAsync,
|
||||
isUpdating: mutation.isPending,
|
||||
error: mutation.error,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1249,6 +1249,11 @@ export interface ProjectGuidelines {
|
||||
constraints?: Record<string, string[]>;
|
||||
quality_rules?: GuidelineEntry[];
|
||||
learnings?: LearningEntry[];
|
||||
_metadata?: {
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
version?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ProjectOverviewMetadata {
|
||||
@@ -1285,6 +1290,23 @@ export async function fetchProjectOverview(projectPath?: string): Promise<Projec
|
||||
return data.projectOverview ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update project guidelines for a specific workspace
|
||||
*/
|
||||
export async function updateProjectGuidelines(
|
||||
guidelines: ProjectGuidelines,
|
||||
projectPath?: string
|
||||
): Promise<{ success: boolean; guidelines?: ProjectGuidelines; error?: string }> {
|
||||
const url = projectPath
|
||||
? `/api/ccw/guidelines?path=${encodeURIComponent(projectPath)}`
|
||||
: '/api/ccw/guidelines';
|
||||
return fetchApi(url, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(guidelines),
|
||||
});
|
||||
}
|
||||
|
||||
// ========== Session Detail API ==========
|
||||
|
||||
export interface SessionDetailContext {
|
||||
@@ -2939,3 +2961,727 @@ export async function updateCodexLensIgnorePatterns(request: CodexLensUpdateIgno
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
}
|
||||
|
||||
// ========== CodexLens Search API ==========
|
||||
|
||||
/**
|
||||
* CodexLens search request parameters
|
||||
*/
|
||||
export interface CodexLensSearchParams {
|
||||
query: string;
|
||||
limit?: number;
|
||||
mode?: 'dense_rerank' | 'fts' | 'fuzzy';
|
||||
max_content_length?: number;
|
||||
extra_files_count?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* CodexLens search result
|
||||
*/
|
||||
export interface CodexLensSearchResult {
|
||||
path: string;
|
||||
score: number;
|
||||
content?: string;
|
||||
line_start?: number;
|
||||
line_end?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* CodexLens search response
|
||||
*/
|
||||
export interface CodexLensSearchResponse {
|
||||
success: boolean;
|
||||
results: CodexLensSearchResult[];
|
||||
total?: number;
|
||||
query: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* CodexLens symbol search response
|
||||
*/
|
||||
export interface CodexLensSymbolSearchResponse {
|
||||
success: boolean;
|
||||
symbols: Array<{
|
||||
name: string;
|
||||
kind: string;
|
||||
path: string;
|
||||
line: number;
|
||||
[key: string]: unknown;
|
||||
}>;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform content search using CodexLens
|
||||
*/
|
||||
export async function searchCodexLens(params: CodexLensSearchParams): Promise<CodexLensSearchResponse> {
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.append('query', params.query);
|
||||
if (params.limit) queryParams.append('limit', String(params.limit));
|
||||
if (params.mode) queryParams.append('mode', params.mode);
|
||||
if (params.max_content_length) queryParams.append('max_content_length', String(params.max_content_length));
|
||||
if (params.extra_files_count) queryParams.append('extra_files_count', String(params.extra_files_count));
|
||||
|
||||
return fetchApi<CodexLensSearchResponse>(`/api/codexlens/search?${queryParams.toString()}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform file search using CodexLens
|
||||
*/
|
||||
export async function searchFilesCodexLens(params: CodexLensSearchParams): Promise<CodexLensSearchResponse> {
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.append('query', params.query);
|
||||
if (params.limit) queryParams.append('limit', String(params.limit));
|
||||
if (params.mode) queryParams.append('mode', params.mode);
|
||||
if (params.max_content_length) queryParams.append('max_content_length', String(params.max_content_length));
|
||||
if (params.extra_files_count) queryParams.append('extra_files_count', String(params.extra_files_count));
|
||||
|
||||
return fetchApi<CodexLensSearchResponse>(`/api/codexlens/search_files?${queryParams.toString()}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform symbol search using CodexLens
|
||||
*/
|
||||
export async function searchSymbolCodexLens(params: Pick<CodexLensSearchParams, 'query' | 'limit'>): Promise<CodexLensSymbolSearchResponse> {
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.append('query', params.query);
|
||||
if (params.limit) queryParams.append('limit', String(params.limit));
|
||||
|
||||
return fetchApi<CodexLensSymbolSearchResponse>(`/api/codexlens/symbol?${queryParams.toString()}`);
|
||||
}
|
||||
|
||||
// ========== CodexLens Index Management API ==========
|
||||
|
||||
/**
|
||||
* Index operation type
|
||||
*/
|
||||
export type CodexLensIndexOperation = 'fts_full' | 'fts_incremental' | 'vector_full' | 'vector_incremental';
|
||||
|
||||
/**
|
||||
* CodexLens index entry
|
||||
*/
|
||||
export interface CodexLensIndex {
|
||||
id: string;
|
||||
path: string;
|
||||
indexPath: string;
|
||||
size: number;
|
||||
sizeFormatted: string;
|
||||
fileCount: number;
|
||||
dirCount: number;
|
||||
hasVectorIndex: boolean;
|
||||
hasNormalIndex: boolean;
|
||||
status: string;
|
||||
lastModified: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* CodexLens index list response
|
||||
*/
|
||||
export interface CodexLensIndexesResponse {
|
||||
success: boolean;
|
||||
indexDir: string;
|
||||
indexes: CodexLensIndex[];
|
||||
summary: {
|
||||
totalSize: number;
|
||||
totalSizeFormatted: string;
|
||||
vectorIndexCount: number;
|
||||
normalIndexCount: number;
|
||||
totalProjects?: number;
|
||||
totalFiles?: number;
|
||||
totalDirs?: number;
|
||||
indexSizeBytes?: number;
|
||||
indexSizeMb?: number;
|
||||
embeddings?: any;
|
||||
fullIndexDirSize?: number;
|
||||
fullIndexDirSizeFormatted?: string;
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* CodexLens index operation request
|
||||
*/
|
||||
export interface CodexLensIndexOperationRequest {
|
||||
path: string;
|
||||
operation: CodexLensIndexOperation;
|
||||
indexType?: 'normal' | 'vector';
|
||||
embeddingModel?: string;
|
||||
embeddingBackend?: 'fastembed' | 'litellm';
|
||||
maxWorkers?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* CodexLens index operation response
|
||||
*/
|
||||
export interface CodexLensIndexOperationResponse {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
error?: string;
|
||||
result?: any;
|
||||
output?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* CodexLens indexing status response
|
||||
*/
|
||||
export interface CodexLensIndexingStatusResponse {
|
||||
success: boolean;
|
||||
inProgress: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all CodexLens indexes
|
||||
*/
|
||||
export async function fetchCodexLensIndexes(): Promise<CodexLensIndexesResponse> {
|
||||
return fetchApi<CodexLensIndexesResponse>('/api/codexlens/indexes');
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild CodexLens index (full rebuild)
|
||||
* @param projectPath - Project path to index
|
||||
* @param options - Index options
|
||||
*/
|
||||
export async function rebuildCodexLensIndex(
|
||||
projectPath: string,
|
||||
options: {
|
||||
indexType?: 'normal' | 'vector';
|
||||
embeddingModel?: string;
|
||||
embeddingBackend?: 'fastembed' | 'litellm';
|
||||
maxWorkers?: number;
|
||||
} = {}
|
||||
): Promise<CodexLensIndexOperationResponse> {
|
||||
return fetchApi<CodexLensIndexOperationResponse>('/api/codexlens/init', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
path: projectPath,
|
||||
indexType: options.indexType || 'vector',
|
||||
embeddingModel: options.embeddingModel || 'code',
|
||||
embeddingBackend: options.embeddingBackend || 'fastembed',
|
||||
maxWorkers: options.maxWorkers || 1
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Incremental update CodexLens index
|
||||
* @param projectPath - Project path to update
|
||||
* @param options - Index options
|
||||
*/
|
||||
export async function updateCodexLensIndex(
|
||||
projectPath: string,
|
||||
options: {
|
||||
indexType?: 'normal' | 'vector';
|
||||
embeddingModel?: string;
|
||||
embeddingBackend?: 'fastembed' | 'litellm';
|
||||
maxWorkers?: number;
|
||||
} = {}
|
||||
): Promise<CodexLensIndexOperationResponse> {
|
||||
return fetchApi<CodexLensIndexOperationResponse>('/api/codexlens/update', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
path: projectPath,
|
||||
indexType: options.indexType || 'vector',
|
||||
embeddingModel: options.embeddingModel || 'code',
|
||||
embeddingBackend: options.embeddingBackend || 'fastembed',
|
||||
maxWorkers: options.maxWorkers || 1
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel ongoing CodexLens indexing
|
||||
*/
|
||||
export async function cancelCodexLensIndexing(): Promise<{ success: boolean; error?: string }> {
|
||||
return fetchApi('/api/codexlens/cancel', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if CodexLens indexing is in progress
|
||||
*/
|
||||
export async function checkCodexLensIndexingStatus(): Promise<CodexLensIndexingStatusResponse> {
|
||||
return fetchApi<CodexLensIndexingStatusResponse>('/api/codexlens/indexing-status');
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean CodexLens indexes
|
||||
* @param options - Clean options
|
||||
*/
|
||||
export async function cleanCodexLensIndexes(options: {
|
||||
all?: boolean;
|
||||
path?: string;
|
||||
} = {}): Promise<{ success: boolean; message?: string; error?: string }> {
|
||||
return fetchApi('/api/codexlens/clean', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(options),
|
||||
});
|
||||
}
|
||||
|
||||
// ========== LiteLLM API Settings API ==========
|
||||
|
||||
/**
|
||||
* Provider credential types
|
||||
*/
|
||||
export type ProviderType = 'openai' | 'anthropic' | 'custom';
|
||||
|
||||
/**
|
||||
* Advanced provider settings
|
||||
*/
|
||||
export interface ProviderAdvancedSettings {
|
||||
timeout?: number;
|
||||
maxRetries?: number;
|
||||
organization?: string;
|
||||
apiVersion?: string;
|
||||
customHeaders?: Record<string, string>;
|
||||
rpm?: number;
|
||||
tpm?: number;
|
||||
proxy?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Routing strategy types
|
||||
*/
|
||||
export type RoutingStrategy = 'simple-shuffle' | 'weighted' | 'latency-based' | 'cost-based' | 'least-busy';
|
||||
|
||||
/**
|
||||
* Individual API key entry
|
||||
*/
|
||||
export interface ApiKeyEntry {
|
||||
id: string;
|
||||
key: string;
|
||||
label?: string;
|
||||
weight?: number;
|
||||
enabled: boolean;
|
||||
healthStatus?: 'healthy' | 'unhealthy' | 'unknown';
|
||||
lastHealthCheck?: string;
|
||||
lastError?: string;
|
||||
lastLatencyMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check configuration
|
||||
*/
|
||||
export interface HealthCheckConfig {
|
||||
enabled: boolean;
|
||||
intervalSeconds: number;
|
||||
cooldownSeconds: number;
|
||||
failureThreshold: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Model capabilities
|
||||
*/
|
||||
export interface ModelCapabilities {
|
||||
streaming?: boolean;
|
||||
functionCalling?: boolean;
|
||||
vision?: boolean;
|
||||
contextWindow?: number;
|
||||
embeddingDimension?: number;
|
||||
maxOutputTokens?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Model endpoint settings
|
||||
*/
|
||||
export interface ModelEndpointSettings {
|
||||
baseUrl?: string;
|
||||
timeout?: number;
|
||||
maxRetries?: number;
|
||||
customHeaders?: Record<string, string>;
|
||||
cacheStrategy?: CacheStrategy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Model definition
|
||||
*/
|
||||
export interface ModelDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'llm' | 'embedding' | 'reranker';
|
||||
series: string;
|
||||
enabled: boolean;
|
||||
capabilities?: ModelCapabilities;
|
||||
endpointSettings?: ModelEndpointSettings;
|
||||
description?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider credential
|
||||
*/
|
||||
export interface ProviderCredential {
|
||||
id: string;
|
||||
name: string;
|
||||
type: ProviderType;
|
||||
apiKey: string;
|
||||
apiBase?: string;
|
||||
enabled: boolean;
|
||||
advancedSettings?: ProviderAdvancedSettings;
|
||||
apiKeys?: ApiKeyEntry[];
|
||||
routingStrategy?: RoutingStrategy;
|
||||
healthCheck?: HealthCheckConfig;
|
||||
llmModels?: ModelDefinition[];
|
||||
embeddingModels?: ModelDefinition[];
|
||||
rerankerModels?: ModelDefinition[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache strategy
|
||||
*/
|
||||
export interface CacheStrategy {
|
||||
enabled: boolean;
|
||||
ttlMinutes: number;
|
||||
maxSizeKB: number;
|
||||
filePatterns: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom endpoint
|
||||
*/
|
||||
export interface CustomEndpoint {
|
||||
id: string;
|
||||
name: string;
|
||||
providerId: string;
|
||||
model: string;
|
||||
description?: string;
|
||||
cacheStrategy: CacheStrategy;
|
||||
enabled: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Global cache settings
|
||||
*/
|
||||
export interface GlobalCacheSettings {
|
||||
enabled: boolean;
|
||||
cacheDir: string;
|
||||
maxTotalSizeMB: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache statistics
|
||||
*/
|
||||
export interface CacheStats {
|
||||
totalSize: number;
|
||||
maxSize: number;
|
||||
entries: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Model pool type
|
||||
*/
|
||||
export type ModelPoolType = 'embedding' | 'llm' | 'reranker';
|
||||
|
||||
/**
|
||||
* Model pool config
|
||||
*/
|
||||
export interface ModelPoolConfig {
|
||||
id: string;
|
||||
modelType: ModelPoolType;
|
||||
enabled: boolean;
|
||||
targetModel: string;
|
||||
strategy: 'round_robin' | 'latency_aware' | 'weighted_random';
|
||||
autoDiscover: boolean;
|
||||
excludedProviderIds?: string[];
|
||||
defaultCooldown: number;
|
||||
defaultMaxConcurrentPerKey: number;
|
||||
name?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider for model pool discovery
|
||||
*/
|
||||
export interface DiscoveredProvider {
|
||||
providerId: string;
|
||||
providerName: string;
|
||||
models: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* CLI settings mode
|
||||
*/
|
||||
export type CliSettingsMode = 'provider-based' | 'direct';
|
||||
|
||||
/**
|
||||
* CLI settings
|
||||
*/
|
||||
export interface CliSettings {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
enabled: boolean;
|
||||
mode: CliSettingsMode;
|
||||
providerId?: string;
|
||||
settings?: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// ========== Provider Management ==========
|
||||
|
||||
/**
|
||||
* Fetch all providers
|
||||
*/
|
||||
export async function fetchProviders(): Promise<{ providers: ProviderCredential[]; count: number }> {
|
||||
return fetchApi('/api/litellm-api/providers');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create provider
|
||||
*/
|
||||
export async function createProvider(provider: Omit<ProviderCredential, 'id' | 'createdAt' | 'updatedAt'>): Promise<{ success: boolean; provider: ProviderCredential }> {
|
||||
return fetchApi('/api/litellm-api/providers', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(provider),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update provider
|
||||
*/
|
||||
export async function updateProvider(providerId: string, updates: Partial<Omit<ProviderCredential, 'id' | 'createdAt' | 'updatedAt'>>): Promise<{ success: boolean; provider: ProviderCredential }> {
|
||||
return fetchApi(`/api/litellm-api/providers/${encodeURIComponent(providerId)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete provider
|
||||
*/
|
||||
export async function deleteProvider(providerId: string): Promise<{ success: boolean; message: string }> {
|
||||
return fetchApi(`/api/litellm-api/providers/${encodeURIComponent(providerId)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Test provider connection
|
||||
*/
|
||||
export async function testProvider(providerId: string): Promise<{ success: boolean; provider: string; latencyMs?: number; error?: string }> {
|
||||
return fetchApi(`/api/litellm-api/providers/${encodeURIComponent(providerId)}/test`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Test specific API key
|
||||
*/
|
||||
export async function testProviderKey(providerId: string, keyId: string): Promise<{ valid: boolean; error?: string; latencyMs?: number; keyLabel?: string }> {
|
||||
return fetchApi(`/api/litellm-api/providers/${encodeURIComponent(providerId)}/test-key`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ keyId }),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get provider health status
|
||||
*/
|
||||
export async function getProviderHealthStatus(providerId: string): Promise<{ providerId: string; providerName: string; keys: Array<{ keyId: string; label: string; status: string; lastCheck?: string; lastLatencyMs?: number; consecutiveFailures?: number; inCooldown?: boolean; lastError?: string }> }> {
|
||||
return fetchApi(`/api/litellm-api/providers/${encodeURIComponent(providerId)}/health-status`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger health check now
|
||||
*/
|
||||
export async function triggerProviderHealthCheck(providerId: string): Promise<{ success: boolean; providerId: string; providerName?: string; keys: Array<any>; checkedAt: string }> {
|
||||
return fetchApi(`/api/litellm-api/providers/${encodeURIComponent(providerId)}/health-check-now`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
// ========== Endpoint Management ==========
|
||||
|
||||
/**
|
||||
* Fetch all endpoints
|
||||
*/
|
||||
export async function fetchEndpoints(): Promise<{ endpoints: CustomEndpoint[]; count: number }> {
|
||||
return fetchApi('/api/litellm-api/endpoints');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create endpoint
|
||||
*/
|
||||
export async function createEndpoint(endpoint: Omit<CustomEndpoint, 'createdAt' | 'updatedAt'>): Promise<{ success: boolean; endpoint: CustomEndpoint }> {
|
||||
return fetchApi('/api/litellm-api/endpoints', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(endpoint),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update endpoint
|
||||
*/
|
||||
export async function updateEndpoint(endpointId: string, updates: Partial<Omit<CustomEndpoint, 'id' | 'createdAt' | 'updatedAt'>>): Promise<{ success: boolean; endpoint: CustomEndpoint }> {
|
||||
return fetchApi(`/api/litellm-api/endpoints/${encodeURIComponent(endpointId)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete endpoint
|
||||
*/
|
||||
export async function deleteEndpoint(endpointId: string): Promise<{ success: boolean; message: string }> {
|
||||
return fetchApi(`/api/litellm-api/endpoints/${encodeURIComponent(endpointId)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
// ========== Model Discovery ==========
|
||||
|
||||
/**
|
||||
* Get available models for provider type
|
||||
*/
|
||||
export async function getProviderModels(providerType: string): Promise<{ providerType: string; models: Array<{ id: string; name: string; provider: string; description?: string }>; count: number }> {
|
||||
return fetchApi(`/api/litellm-api/models/${encodeURIComponent(providerType)}`);
|
||||
}
|
||||
|
||||
// ========== Cache Management ==========
|
||||
|
||||
/**
|
||||
* Fetch cache statistics
|
||||
*/
|
||||
export async function fetchCacheStats(): Promise<CacheStats> {
|
||||
return fetchApi('/api/litellm-api/cache/stats');
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cache
|
||||
*/
|
||||
export async function clearCache(): Promise<{ success: boolean; removed: number }> {
|
||||
return fetchApi('/api/litellm-api/cache/clear', {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update cache settings
|
||||
*/
|
||||
export async function updateCacheSettings(settings: Partial<{ enabled: boolean; cacheDir: string; maxTotalSizeMB: number }>): Promise<{ success: boolean; settings: GlobalCacheSettings }> {
|
||||
return fetchApi('/api/litellm-api/config/cache', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(settings),
|
||||
});
|
||||
}
|
||||
|
||||
// ========== Model Pool Management ==========
|
||||
|
||||
/**
|
||||
* Fetch all model pools
|
||||
*/
|
||||
export async function fetchModelPools(): Promise<{ pools: ModelPoolConfig[] }> {
|
||||
return fetchApi('/api/litellm-api/model-pools');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch single model pool
|
||||
*/
|
||||
export async function fetchModelPool(poolId: string): Promise<{ pool: ModelPoolConfig }> {
|
||||
return fetchApi(`/api/litellm-api/model-pools/${encodeURIComponent(poolId)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create model pool
|
||||
*/
|
||||
export async function createModelPool(pool: Omit<ModelPoolConfig, 'id'>): Promise<{ success: boolean; poolId: string; syncResult?: any }> {
|
||||
return fetchApi('/api/litellm-api/model-pools', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(pool),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update model pool
|
||||
*/
|
||||
export async function updateModelPool(poolId: string, updates: Partial<ModelPoolConfig>): Promise<{ success: boolean; poolId?: string; syncResult?: any }> {
|
||||
return fetchApi(`/api/litellm-api/model-pools/${encodeURIComponent(poolId)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete model pool
|
||||
*/
|
||||
export async function deleteModelPool(poolId: string): Promise<{ success: boolean; syncResult?: any }> {
|
||||
return fetchApi(`/api/litellm-api/model-pools/${encodeURIComponent(poolId)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available models for pool type
|
||||
*/
|
||||
export async function getAvailableModelsForPool(modelType: ModelPoolType): Promise<{ availableModels: Array<{ modelId: string; modelName: string; providers: string[] }> }> {
|
||||
return fetchApi(`/api/litellm-api/model-pools/available-models/${encodeURIComponent(modelType)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover providers for model
|
||||
*/
|
||||
export async function discoverModelsForPool(modelType: ModelPoolType, targetModel: string): Promise<{ modelType: string; targetModel: string; discovered: DiscoveredProvider[]; count: number }> {
|
||||
return fetchApi(`/api/litellm-api/model-pools/discover/${encodeURIComponent(modelType)}/${encodeURIComponent(targetModel)}`);
|
||||
}
|
||||
|
||||
// ========== Config Management ==========
|
||||
|
||||
/**
|
||||
* Get full config
|
||||
*/
|
||||
export async function fetchApiConfig(): Promise<any> {
|
||||
return fetchApi('/api/litellm-api/config');
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync config to YAML
|
||||
*/
|
||||
export async function syncApiConfig(): Promise<{ success: boolean; message: string; yamlPath?: string }> {
|
||||
return fetchApi('/api/litellm-api/config/sync', {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview YAML config
|
||||
*/
|
||||
export async function previewYamlConfig(): Promise<{ success: boolean; config: string }> {
|
||||
return fetchApi('/api/litellm-api/config/yaml-preview');
|
||||
}
|
||||
|
||||
// ========== CCW-LiteLLM Package Management ==========
|
||||
|
||||
/**
|
||||
* Check ccw-litellm status
|
||||
*/
|
||||
export async function checkCcwLitellmStatus(refresh = false): Promise<{ installed: boolean; version?: string; error?: string }> {
|
||||
return fetchApi(`/api/litellm-api/ccw-litellm/status${refresh ? '?refresh=true' : ''}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Install ccw-litellm
|
||||
*/
|
||||
export async function installCcwLitellm(): Promise<{ success: boolean; message?: string; error?: string; path?: string }> {
|
||||
return fetchApi('/api/litellm-api/ccw-litellm/install', {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Uninstall ccw-litellm
|
||||
*/
|
||||
export async function uninstallCcwLitellm(): Promise<{ success: boolean; message?: string; error?: string }> {
|
||||
return fetchApi('/api/litellm-api/ccw-litellm/uninstall', {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -112,3 +112,19 @@ export const workspaceQueryKeys = {
|
||||
cliExecutionDetail: (projectPath: string, executionId: string) =>
|
||||
[...workspaceQueryKeys.cliHistory(projectPath), 'detail', executionId] as const,
|
||||
};
|
||||
|
||||
// ========== API Settings Keys ==========
|
||||
/**
|
||||
* API Settings query keys (global, not workspace-specific)
|
||||
*/
|
||||
export const apiSettingsKeys = {
|
||||
all: ['apiSettings'] as const,
|
||||
providers: () => [...apiSettingsKeys.all, 'providers'] as const,
|
||||
provider: (id: string) => [...apiSettingsKeys.providers(), id] as const,
|
||||
endpoints: () => [...apiSettingsKeys.all, 'endpoints'] as const,
|
||||
endpoint: (id: string) => [...apiSettingsKeys.endpoints(), id] as const,
|
||||
cache: () => [...apiSettingsKeys.all, 'cache'] as const,
|
||||
modelPools: () => [...apiSettingsKeys.all, 'modelPools'] as const,
|
||||
modelPool: (id: string) => [...apiSettingsKeys.modelPools(), id] as const,
|
||||
ccwLitellm: () => [...apiSettingsKeys.all, 'ccwLitellm'] as const,
|
||||
};
|
||||
|
||||
335
ccw/frontend/src/locales/en/api-settings.json
Normal file
335
ccw/frontend/src/locales/en/api-settings.json
Normal file
@@ -0,0 +1,335 @@
|
||||
{
|
||||
"title": "API Settings",
|
||||
"description": "Manage LiteLLM providers, endpoints, cache settings, and model pools",
|
||||
"tabs": {
|
||||
"providers": "Providers",
|
||||
"endpoints": "Endpoints",
|
||||
"cache": "Cache",
|
||||
"modelPools": "Model Pools",
|
||||
"cliSettings": "CLI Settings"
|
||||
},
|
||||
"providers": {
|
||||
"title": "Providers",
|
||||
"description": "Configure API provider credentials and multi-key settings",
|
||||
"stats": {
|
||||
"total": "Total Providers",
|
||||
"enabled": "Enabled"
|
||||
},
|
||||
"actions": {
|
||||
"add": "Add Provider",
|
||||
"edit": "Edit Provider",
|
||||
"delete": "Delete Provider",
|
||||
"test": "Test Connection",
|
||||
"multiKeySettings": "Multi-Key Settings",
|
||||
"syncToCodexLens": "Sync to CodexLens",
|
||||
"manageModels": "Manage Models",
|
||||
"addModel": "Add Model"
|
||||
},
|
||||
"deleteConfirm": "Are you sure you want to delete the provider \"{name}\"?",
|
||||
"emptyState": {
|
||||
"title": "No Providers Found",
|
||||
"message": "Add a provider to configure API credentials and models."
|
||||
},
|
||||
"apiFormat": "API Format",
|
||||
"openaiCompatible": "OpenAI Compatible",
|
||||
"customFormat": "Custom Format",
|
||||
"apiFormatHint": "Most providers use OpenAI-compatible format",
|
||||
"displayName": "Display Name",
|
||||
"apiKey": "API Key",
|
||||
"useEnvVar": "Use environment variable (e.g., ${OPENAI_API_KEY})",
|
||||
"apiBaseUrl": "API Base URL",
|
||||
"preview": "Preview",
|
||||
"enableProvider": "Enable this provider",
|
||||
"advancedSettings": "Advanced Settings",
|
||||
"timeout": "Timeout",
|
||||
"timeoutHint": "Request timeout in seconds (default: 300)",
|
||||
"maxRetries": "Max Retries",
|
||||
"organization": "Organization",
|
||||
"organizationHint": "OpenAI-specific organization ID",
|
||||
"apiVersion": "API Version",
|
||||
"apiVersionHint": "Azure-specific API version (e.g., 2024-02-01)",
|
||||
"rpm": "RPM",
|
||||
"tpm": "TPM",
|
||||
"unlimited": "Unlimited",
|
||||
"proxy": "Proxy URL",
|
||||
"customHeaders": "Custom Headers",
|
||||
"customHeadersHint": "JSON format, e.g., {\"X-Custom\": \"value\"}",
|
||||
"testConnection": "Test Connection",
|
||||
"connectionSuccess": "Connection successful",
|
||||
"connectionFailed": "Connection failed",
|
||||
"addProviderFirst": "Please add a provider first",
|
||||
"saveProviderFirst": "Please save the provider first",
|
||||
"llmModels": "LLM Models",
|
||||
"embeddingModels": "Embedding Models",
|
||||
"rerankerModels": "Reranker Models",
|
||||
"noModels": "No models configured for this provider",
|
||||
"selectProvider": "Select Provider",
|
||||
"selectProviderHint": "Choose a provider to view and manage their models",
|
||||
"modelSettings": "Model Settings",
|
||||
"deleteModel": "Delete Model",
|
||||
"multiKeySettings": "Multi-Key Settings",
|
||||
"keyLabel": "Key Label",
|
||||
"keyValue": "Key Value",
|
||||
"keyWeight": "Weight",
|
||||
"routingStrategy": "Routing Strategy",
|
||||
"healthCheck": "Health Check",
|
||||
"healthStatus": "Health Status",
|
||||
"lastCheck": "Last check",
|
||||
"lastError": "Last error",
|
||||
"lastLatency": "Latency",
|
||||
"cooldown": "Cooldown",
|
||||
"consecutiveFailures": "Failures",
|
||||
"inCooldown": "In cooldown",
|
||||
"healthy": "Healthy",
|
||||
"unhealthy": "Unhealthy",
|
||||
"unknown": "Unknown",
|
||||
"justNow": "just now",
|
||||
"minutesAgo": "m ago",
|
||||
"hoursAgo": "h ago",
|
||||
"daysAgo": "d ago",
|
||||
"seconds": "seconds",
|
||||
"testKey": "Test Key",
|
||||
"addKey": "Add Key",
|
||||
"enableKey": "Enable",
|
||||
"disableKey": "Disable",
|
||||
"deleteKey": "Delete Key",
|
||||
"simpleShuffle": "Simple Shuffle",
|
||||
"weighted": "Weighted",
|
||||
"latencyBased": "Latency Based",
|
||||
"costBased": "Cost Based",
|
||||
"leastBusy": "Least Busy",
|
||||
"enableHealthCheck": "Enable health checks",
|
||||
"checkInterval": "Check interval (seconds)",
|
||||
"cooldownPeriod": "Cooldown period (seconds)",
|
||||
"failureThreshold": "Failure threshold",
|
||||
"addLlmModel": "Add LLM Model",
|
||||
"addEmbeddingModel": "Add Embedding Model",
|
||||
"addRerankerModel": "Add Reranker Model",
|
||||
"selectFromPresets": "Select from presets",
|
||||
"customModel": "Custom model...",
|
||||
"modelId": "Model ID",
|
||||
"modelName": "Model Name",
|
||||
"modelSeries": "Model Series",
|
||||
"contextWindow": "Context Window",
|
||||
"capabilities": "Capabilities",
|
||||
"streaming": "Streaming",
|
||||
"functionCalling": "Function Calling",
|
||||
"vision": "Vision",
|
||||
"embeddingMaxTokens": "Max Tokens",
|
||||
"rerankerTopK": "Top K",
|
||||
"rerankerTopKHint": "Number of results to return",
|
||||
"embeddingDimensions": "Dimensions",
|
||||
"description": "Description",
|
||||
"optional": "Optional",
|
||||
"modelIdExists": "Model ID already exists",
|
||||
"useModelTreeToManage": "Use provider card to manage models",
|
||||
"endpointPreview": "Endpoint Preview",
|
||||
"modelBaseUrlOverride": "Base URL Override",
|
||||
"modelBaseUrlHint": "Override base URL for this model",
|
||||
"basicInfo": "Basic Information",
|
||||
"endpointSettings": "Endpoint Settings",
|
||||
"apiBaseUpdated": "Base URL updated"
|
||||
},
|
||||
"endpoints": {
|
||||
"title": "Endpoints",
|
||||
"description": "Configure custom API endpoints with caching strategies",
|
||||
"stats": {
|
||||
"totalEndpoints": "Total Endpoints",
|
||||
"cachedEndpoints": "Cached Endpoints"
|
||||
},
|
||||
"actions": {
|
||||
"add": "Add Endpoint",
|
||||
"edit": "Edit Endpoint",
|
||||
"delete": "Delete Endpoint",
|
||||
"enable": "Enable",
|
||||
"disable": "Disable"
|
||||
},
|
||||
"deleteConfirm": "Are you sure you want to delete the endpoint \"{id}\"?",
|
||||
"emptyState": {
|
||||
"title": "No Endpoints Found",
|
||||
"message": "Add an endpoint to configure custom API mappings."
|
||||
},
|
||||
"endpointId": "Endpoint ID",
|
||||
"endpointIdHint": "Unique CLI identifier (e.g., my-gpt4o)",
|
||||
"name": "Name",
|
||||
"provider": "Provider",
|
||||
"model": "Model",
|
||||
"noModelsConfigured": "No models configured for this provider",
|
||||
"selectModel": "Select a model...",
|
||||
"cacheStrategy": "Cache Strategy",
|
||||
"enableContextCaching": "Enable context caching",
|
||||
"cacheTTL": "Cache TTL (minutes)",
|
||||
"cacheMaxSize": "Max Size (KB)",
|
||||
"autoCachePatterns": "Auto-cache patterns",
|
||||
"filePatternsHint": "Comma-separated glob patterns (e.g., *.md,*.ts)",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"noEndpoints": "No Endpoints Configured",
|
||||
"noEndpointsHint": "Add an endpoint to create custom API mappings with caching.",
|
||||
"providerBased": "Provider-based",
|
||||
"direct": "Direct",
|
||||
"off": "Off"
|
||||
},
|
||||
"cache": {
|
||||
"title": "Cache",
|
||||
"description": "Manage global cache settings and view cache statistics",
|
||||
"settings": {
|
||||
"title": "Cache Settings",
|
||||
"globalCache": "Global Cache",
|
||||
"enableGlobalCaching": "Enable global caching",
|
||||
"cacheDirectory": "Cache directory",
|
||||
"maxSize": "Max total size (MB)",
|
||||
"cacheUsage": "Cache usage",
|
||||
"cacheEntries": "Cache entries",
|
||||
"cacheSize": "Cache size",
|
||||
"used": "used",
|
||||
"total": "total",
|
||||
"actions": "Cache Actions",
|
||||
"clearCache": "Clear Cache",
|
||||
"confirmClearCache": "Are you sure you want to clear the cache? This will remove all cached entries."
|
||||
},
|
||||
"statistics": {
|
||||
"title": "Cache Statistics",
|
||||
"cachedEntries": "Cached Entries",
|
||||
"storageUsed": "Storage Used",
|
||||
"totalSize": "Total Size"
|
||||
},
|
||||
"actions": {
|
||||
"clear": "Clear Cache",
|
||||
"confirmClear": "Are you sure you want to clear the cache?"
|
||||
},
|
||||
"messages": {
|
||||
"cacheCleared": "Cache cleared",
|
||||
"cacheSettingsUpdated": "Cache settings updated"
|
||||
}
|
||||
},
|
||||
"modelPools": {
|
||||
"title": "Model Pools",
|
||||
"description": "Configure model pools for high availability and load balancing",
|
||||
"stats": {
|
||||
"total": "Total Pools",
|
||||
"enabled": "Enabled"
|
||||
},
|
||||
"actions": {
|
||||
"add": "Add Model Pool",
|
||||
"edit": "Edit Model Pool",
|
||||
"delete": "Delete Model Pool",
|
||||
"autoDiscover": "Auto-Discover"
|
||||
},
|
||||
"deleteConfirm": "Are you sure you want to delete the model pool \"{name}\"?",
|
||||
"emptyState": {
|
||||
"title": "No Model Pools Found",
|
||||
"message": "Add a model pool to enable high availability and load balancing."
|
||||
},
|
||||
"modelType": "Model Type",
|
||||
"embedding": "Embedding",
|
||||
"llm": "LLM",
|
||||
"reranker": "Reranker",
|
||||
"targetModel": "Target Model",
|
||||
"selectTargetModel": "Select target model...",
|
||||
"strategy": "Strategy",
|
||||
"roundRobin": "Round Robin",
|
||||
"latencyAware": "Latency Aware",
|
||||
"weightedRandom": "Weighted Random",
|
||||
"poolEnabled": "Pool Enabled",
|
||||
"autoDiscover": "Auto-discover providers",
|
||||
"excludedProviders": "Excluded Providers",
|
||||
"defaultCooldown": "Default Cooldown (s)",
|
||||
"defaultConcurrent": "Default Concurrent",
|
||||
"discoveredProviders": "Discovered Providers",
|
||||
"excludeProvider": "Exclude",
|
||||
"includeProvider": "Include",
|
||||
"noProvidersFound": "No providers found offering this model",
|
||||
"poolSaved": "Model pool saved",
|
||||
"embeddingPoolDesc": "Configure model pools for high availability and load balancing across multiple providers",
|
||||
"embeddingPool": "Embedding Pool",
|
||||
"discovered": "Discovered",
|
||||
"providers": "providers"
|
||||
},
|
||||
"cliSettings": {
|
||||
"title": "CLI Settings",
|
||||
"description": "Configure CLI tool settings and modes",
|
||||
"stats": {
|
||||
"total": "Total Settings",
|
||||
"enabled": "Enabled"
|
||||
},
|
||||
"actions": {
|
||||
"add": "Add CLI Settings",
|
||||
"edit": "Edit CLI Settings",
|
||||
"delete": "Delete CLI Settings"
|
||||
},
|
||||
"deleteConfirm": "Are you sure you want to delete the CLI settings \"{name}\"?",
|
||||
"emptyState": {
|
||||
"title": "No CLI Settings Found",
|
||||
"message": "Add CLI settings to configure tool-specific options."
|
||||
},
|
||||
"mode": "Mode",
|
||||
"providerBased": "Provider-based",
|
||||
"direct": "Direct",
|
||||
"authToken": "Auth Token",
|
||||
"baseUrl": "Base URL",
|
||||
"model": "Model"
|
||||
},
|
||||
"ccwLitellm": {
|
||||
"title": "CCW-LiteLLM Package",
|
||||
"description": "Manage ccw-litellm Python package installation",
|
||||
"status": {
|
||||
"installed": "Installed",
|
||||
"notInstalled": "Not Installed",
|
||||
"version": "Version"
|
||||
},
|
||||
"actions": {
|
||||
"install": "Install",
|
||||
"uninstall": "Uninstall",
|
||||
"refreshStatus": "Refresh Status"
|
||||
},
|
||||
"messages": {
|
||||
"installSuccess": "ccw-litellm installed successfully",
|
||||
"installFailed": "Failed to install ccw-litellm",
|
||||
"uninstallSuccess": "ccw-litellm uninstalled successfully",
|
||||
"uninstallFailed": "Failed to uninstall ccw-litellm"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
"delete": "Delete",
|
||||
"edit": "Edit",
|
||||
"add": "Add",
|
||||
"close": "Close",
|
||||
"loading": "Loading...",
|
||||
"error": "Error",
|
||||
"success": "Success",
|
||||
"warning": "Warning",
|
||||
"optional": "Optional",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"searchPlaceholder": "Search...",
|
||||
"noResults": "No results found",
|
||||
"actions": "Actions",
|
||||
"name": "Name",
|
||||
"description": "Description",
|
||||
"type": "Type",
|
||||
"status": "Status"
|
||||
},
|
||||
"messages": {
|
||||
"settingsSaved": "Settings saved successfully",
|
||||
"settingsDeleted": "Settings deleted successfully",
|
||||
"providerSaved": "Provider saved successfully",
|
||||
"providerDeleted": "Provider deleted successfully",
|
||||
"providerUpdated": "Provider updated successfully",
|
||||
"endpointSaved": "Endpoint saved successfully",
|
||||
"endpointDeleted": "Endpoint deleted successfully",
|
||||
"confirmDeleteProvider": "Are you sure you want to delete this provider?",
|
||||
"confirmDeleteEndpoint": "Are you sure you want to delete this endpoint?",
|
||||
"confirmClearCache": "Are you sure you want to clear the cache?",
|
||||
"invalidJsonHeaders": "Invalid JSON format for custom headers",
|
||||
"failedToLoad": "Failed to load data",
|
||||
"noProviders": "No Providers",
|
||||
"noProvidersHint": "Add a provider to get started with API configuration.",
|
||||
"noEndpoints": "No Endpoints",
|
||||
"noEndpointsHint": "Add an endpoint to create custom API mappings.",
|
||||
"configSynced": "Configuration synced to YAML file"
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
"overview": "Overview",
|
||||
"settings": "Settings",
|
||||
"models": "Models",
|
||||
"search": "Search",
|
||||
"advanced": "Advanced"
|
||||
},
|
||||
"overview": {
|
||||
@@ -46,6 +47,19 @@
|
||||
"lastCheck": "Last Check Time"
|
||||
}
|
||||
},
|
||||
"index": {
|
||||
"operationComplete": "Index Operation Complete",
|
||||
"operationFailed": "Index Operation Failed",
|
||||
"noProject": "No Project Selected",
|
||||
"noProjectDesc": "Please open a project to perform index operations.",
|
||||
"starting": "Starting index operation...",
|
||||
"cancelFailed": "Failed to cancel operation",
|
||||
"unknownError": "An unknown error occurred",
|
||||
"complete": "Complete",
|
||||
"failed": "Failed",
|
||||
"cancelled": "Cancelled",
|
||||
"inProgress": "In Progress"
|
||||
},
|
||||
"settings": {
|
||||
"currentCount": "Current Index Count",
|
||||
"currentWorkers": "Current Workers",
|
||||
@@ -114,6 +128,8 @@
|
||||
"advanced": {
|
||||
"warningTitle": "Sensitive Operations Warning",
|
||||
"warningMessage": "Modifying environment variables may affect CodexLens operation. Ensure you understand each variable's purpose.",
|
||||
"loadError": "Failed to load environment variables",
|
||||
"loadErrorDesc": "Unable to fetch environment configuration. Please check if CodexLens is properly installed.",
|
||||
"currentVars": "Current Environment Variables",
|
||||
"settingsVars": "Settings Variables",
|
||||
"customVars": "Custom Variables",
|
||||
@@ -170,9 +186,35 @@
|
||||
"title": "CodexLens Not Installed",
|
||||
"description": "Please install CodexLens to use model management features."
|
||||
},
|
||||
"error": {
|
||||
"title": "Failed to load models",
|
||||
"description": "Unable to fetch model list. Please check if CodexLens is properly installed."
|
||||
},
|
||||
"empty": {
|
||||
"title": "No models found",
|
||||
"description": "Try adjusting your search or filter criteria"
|
||||
"description": "No models are available. Try downloading models from the list.",
|
||||
"filtered": "No models match your filter",
|
||||
"filteredDesc": "Try adjusting your search or filter criteria"
|
||||
}
|
||||
},
|
||||
"search": {
|
||||
"type": "Search Type",
|
||||
"content": "Content Search",
|
||||
"files": "File Search",
|
||||
"symbol": "Symbol Search",
|
||||
"mode": "Mode",
|
||||
"mode.semantic": "Semantic (default)",
|
||||
"mode.exact": "Exact (FTS)",
|
||||
"mode.fuzzy": "Fuzzy",
|
||||
"query": "Query",
|
||||
"queryPlaceholder": "Enter search query...",
|
||||
"button": "Search",
|
||||
"searching": "Searching...",
|
||||
"results": "Results",
|
||||
"resultsCount": "results",
|
||||
"notInstalled": {
|
||||
"title": "CodexLens Not Installed",
|
||||
"description": "Please install CodexLens to use semantic code search features."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
335
ccw/frontend/src/locales/zh/api-settings.json
Normal file
335
ccw/frontend/src/locales/zh/api-settings.json
Normal file
@@ -0,0 +1,335 @@
|
||||
{
|
||||
"title": "API 设置",
|
||||
"description": "管理 LiteLLM 提供商、端点、缓存设置和模型池",
|
||||
"tabs": {
|
||||
"providers": "提供商",
|
||||
"endpoints": "端点",
|
||||
"cache": "缓存",
|
||||
"modelPools": "模型池",
|
||||
"cliSettings": "CLI 设置"
|
||||
},
|
||||
"providers": {
|
||||
"title": "提供商",
|
||||
"description": "配置 API 提供商凭据和多密钥设置",
|
||||
"stats": {
|
||||
"total": "总提供商数",
|
||||
"enabled": "已启用"
|
||||
},
|
||||
"actions": {
|
||||
"add": "添加提供商",
|
||||
"edit": "编辑提供商",
|
||||
"delete": "删除提供商",
|
||||
"test": "测试连接",
|
||||
"multiKeySettings": "多密钥设置",
|
||||
"syncToCodexLens": "同步到 CodexLens",
|
||||
"manageModels": "管理模型",
|
||||
"addModel": "添加模型"
|
||||
},
|
||||
"deleteConfirm": "确定要删除提供商 \"{name}\" 吗?",
|
||||
"emptyState": {
|
||||
"title": "未找到提供商",
|
||||
"message": "添加提供商以配置 API 凭据和模型。"
|
||||
},
|
||||
"apiFormat": "API 格式",
|
||||
"openaiCompatible": "OpenAI 兼容",
|
||||
"customFormat": "自定义格式",
|
||||
"apiFormatHint": "大多数提供商使用 OpenAI 兼容格式",
|
||||
"displayName": "显示名称",
|
||||
"apiKey": "API 密钥",
|
||||
"useEnvVar": "使用环境变量(例如:${OPENAI_API_KEY})",
|
||||
"apiBaseUrl": "API 基础 URL",
|
||||
"preview": "预览",
|
||||
"enableProvider": "启用此提供商",
|
||||
"advancedSettings": "高级设置",
|
||||
"timeout": "超时",
|
||||
"timeoutHint": "请求超时时间(秒)(默认:300)",
|
||||
"maxRetries": "最大重试次数",
|
||||
"organization": "组织",
|
||||
"organizationHint": "OpenAI 特定的组织 ID",
|
||||
"apiVersion": "API 版本",
|
||||
"apiVersionHint": "Azure 特定的 API 版本(例如:2024-02-01)",
|
||||
"rpm": "RPM",
|
||||
"tpm": "TPM",
|
||||
"unlimited": "无限制",
|
||||
"proxy": "代理 URL",
|
||||
"customHeaders": "自定义请求头",
|
||||
"customHeadersHint": "JSON 格式,例如:{\"X-Custom\": \"value\"}",
|
||||
"testConnection": "测试连接",
|
||||
"connectionSuccess": "连接成功",
|
||||
"connectionFailed": "连接失败",
|
||||
"addProviderFirst": "请先添加提供商",
|
||||
"saveProviderFirst": "请先保存提供商",
|
||||
"llmModels": "LLM 模型",
|
||||
"embeddingModels": "嵌入模型",
|
||||
"rerankerModels": "重排序模型",
|
||||
"noModels": "此提供商未配置模型",
|
||||
"selectProvider": "选择提供商",
|
||||
"selectProviderHint": "选择提供商以查看和管理其模型",
|
||||
"modelSettings": "模型设置",
|
||||
"deleteModel": "删除模型",
|
||||
"multiKeySettings": "多密钥设置",
|
||||
"keyLabel": "密钥标签",
|
||||
"keyValue": "密钥值",
|
||||
"keyWeight": "权重",
|
||||
"routingStrategy": "路由策略",
|
||||
"healthCheck": "健康检查",
|
||||
"healthStatus": "健康状态",
|
||||
"lastCheck": "最后检查",
|
||||
"lastError": "最后错误",
|
||||
"lastLatency": "延迟",
|
||||
"cooldown": "冷却",
|
||||
"consecutiveFailures": "失败次数",
|
||||
"inCooldown": "冷却中",
|
||||
"healthy": "健康",
|
||||
"unhealthy": "不健康",
|
||||
"unknown": "未知",
|
||||
"justNow": "刚刚",
|
||||
"minutesAgo": "分钟前",
|
||||
"hoursAgo": "小时前",
|
||||
"daysAgo": "天前",
|
||||
"seconds": "秒",
|
||||
"testKey": "测试密钥",
|
||||
"addKey": "添加密钥",
|
||||
"enableKey": "启用",
|
||||
"disableKey": "禁用",
|
||||
"deleteKey": "删除密钥",
|
||||
"simpleShuffle": "简单随机",
|
||||
"weighted": "加权",
|
||||
"latencyBased": "基于延迟",
|
||||
"costBased": "基于成本",
|
||||
"leastBusy": "最少繁忙",
|
||||
"enableHealthCheck": "启用健康检查",
|
||||
"checkInterval": "检查间隔(秒)",
|
||||
"cooldownPeriod": "冷却周期(秒)",
|
||||
"failureThreshold": "失败阈值",
|
||||
"addLlmModel": "添加 LLM 模型",
|
||||
"addEmbeddingModel": "添加嵌入模型",
|
||||
"addRerankerModel": "添加重排序模型",
|
||||
"selectFromPresets": "从预设选择",
|
||||
"customModel": "自定义模型...",
|
||||
"modelId": "模型 ID",
|
||||
"modelName": "模型名称",
|
||||
"modelSeries": "模型系列",
|
||||
"contextWindow": "上下文窗口",
|
||||
"capabilities": "功能",
|
||||
"streaming": "流式传输",
|
||||
"functionCalling": "函数调用",
|
||||
"vision": "视觉",
|
||||
"embeddingMaxTokens": "最大令牌数",
|
||||
"rerankerTopK": "Top K",
|
||||
"rerankerTopKHint": "返回的结果数量",
|
||||
"embeddingDimensions": "维度",
|
||||
"description": "描述",
|
||||
"optional": "可选",
|
||||
"modelIdExists": "模型 ID 已存在",
|
||||
"useModelTreeToManage": "使用提供商卡片管理模型",
|
||||
"endpointPreview": "端点预览",
|
||||
"modelBaseUrlOverride": "基础 URL 覆盖",
|
||||
"modelBaseUrlHint": "为此模型覆盖基础 URL",
|
||||
"basicInfo": "基本信息",
|
||||
"endpointSettings": "端点设置",
|
||||
"apiBaseUpdated": "基础 URL 已更新"
|
||||
},
|
||||
"endpoints": {
|
||||
"title": "端点",
|
||||
"description": "配置带有缓存策略的自定义 API 端点",
|
||||
"stats": {
|
||||
"totalEndpoints": "总端点数",
|
||||
"cachedEndpoints": "已缓存端点"
|
||||
},
|
||||
"actions": {
|
||||
"add": "添加端点",
|
||||
"edit": "编辑端点",
|
||||
"delete": "删除端点",
|
||||
"enable": "启用",
|
||||
"disable": "禁用"
|
||||
},
|
||||
"deleteConfirm": "确定要删除端点 \"{id}\" 吗?",
|
||||
"emptyState": {
|
||||
"title": "未找到端点",
|
||||
"message": "添加端点以配置自定义 API 映射。"
|
||||
},
|
||||
"endpointId": "端点 ID",
|
||||
"endpointIdHint": "唯一的 CLI 标识符(例如:my-gpt4o)",
|
||||
"name": "名称",
|
||||
"provider": "提供商",
|
||||
"model": "模型",
|
||||
"noModelsConfigured": "此提供商未配置模型",
|
||||
"selectModel": "选择模型...",
|
||||
"cacheStrategy": "缓存策略",
|
||||
"enableContextCaching": "启用上下文缓存",
|
||||
"cacheTTL": "缓存 TTL(分钟)",
|
||||
"cacheMaxSize": "最大大小(KB)",
|
||||
"autoCachePatterns": "自动缓存模式",
|
||||
"filePatternsHint": "逗号分隔的 glob 模式(例如:*.md,*.ts)",
|
||||
"enabled": "已启用",
|
||||
"disabled": "已禁用",
|
||||
"noEndpoints": "未配置端点",
|
||||
"noEndpointsHint": "添加端点以创建带有缓存的自定义 API 映射。",
|
||||
"providerBased": "基于提供商",
|
||||
"direct": "直接",
|
||||
"off": "关闭"
|
||||
},
|
||||
"cache": {
|
||||
"title": "缓存",
|
||||
"description": "管理全局缓存设置和查看缓存统计信息",
|
||||
"settings": {
|
||||
"title": "缓存设置",
|
||||
"globalCache": "全局缓存",
|
||||
"enableGlobalCaching": "启用全局缓存",
|
||||
"cacheDirectory": "缓存目录",
|
||||
"maxSize": "最大总大小(MB)",
|
||||
"cacheUsage": "缓存使用",
|
||||
"cacheEntries": "缓存条目",
|
||||
"cacheSize": "缓存大小",
|
||||
"used": "已使用",
|
||||
"total": "总计",
|
||||
"actions": "缓存操作",
|
||||
"clearCache": "清除缓存",
|
||||
"confirmClearCache": "确定要清除缓存吗?这将删除所有缓存条目。"
|
||||
},
|
||||
"statistics": {
|
||||
"title": "缓存统计",
|
||||
"cachedEntries": "已缓存条目",
|
||||
"storageUsed": "已用存储",
|
||||
"totalSize": "总大小"
|
||||
},
|
||||
"actions": {
|
||||
"clear": "清除缓存",
|
||||
"confirmClear": "确定要清除缓存吗?"
|
||||
},
|
||||
"messages": {
|
||||
"cacheCleared": "缓存已清除",
|
||||
"cacheSettingsUpdated": "缓存设置已更新"
|
||||
}
|
||||
},
|
||||
"modelPools": {
|
||||
"title": "模型池",
|
||||
"description": "配置模型池以实现高可用性和负载均衡",
|
||||
"stats": {
|
||||
"total": "总池数",
|
||||
"enabled": "已启用"
|
||||
},
|
||||
"actions": {
|
||||
"add": "添加模型池",
|
||||
"edit": "编辑模型池",
|
||||
"delete": "删除模型池",
|
||||
"autoDiscover": "自动发现"
|
||||
},
|
||||
"deleteConfirm": "确定要删除模型池 \"{name}\" 吗?",
|
||||
"emptyState": {
|
||||
"title": "未找到模型池",
|
||||
"message": "添加模型池以启用高可用性和负载均衡。"
|
||||
},
|
||||
"modelType": "模型类型",
|
||||
"embedding": "嵌入",
|
||||
"llm": "LLM",
|
||||
"reranker": "重排序",
|
||||
"targetModel": "目标模型",
|
||||
"selectTargetModel": "选择目标模型...",
|
||||
"strategy": "策略",
|
||||
"roundRobin": "轮询",
|
||||
"latencyAware": "延迟感知",
|
||||
"weightedRandom": "加权随机",
|
||||
"poolEnabled": "池已启用",
|
||||
"autoDiscover": "自动发现提供商",
|
||||
"excludedProviders": "排除的提供商",
|
||||
"defaultCooldown": "默认冷却时间(秒)",
|
||||
"defaultConcurrent": "默认并发数",
|
||||
"discoveredProviders": "已发现的提供商",
|
||||
"excludeProvider": "排除",
|
||||
"includeProvider": "包含",
|
||||
"noProvidersFound": "未找到提供此模型的提供商",
|
||||
"poolSaved": "模型池已保存",
|
||||
"embeddingPoolDesc": "配置模型池以实现跨多个提供商的高可用性和负载均衡",
|
||||
"embeddingPool": "嵌入池",
|
||||
"discovered": "已发现",
|
||||
"providers": "提供商"
|
||||
},
|
||||
"cliSettings": {
|
||||
"title": "CLI 设置",
|
||||
"description": "配置 CLI 工具设置和模式",
|
||||
"stats": {
|
||||
"total": "总设置数",
|
||||
"enabled": "已启用"
|
||||
},
|
||||
"actions": {
|
||||
"add": "添加 CLI 设置",
|
||||
"edit": "编辑 CLI 设置",
|
||||
"delete": "删除 CLI 设置"
|
||||
},
|
||||
"deleteConfirm": "确定要删除 CLI 设置 \"{name}\" 吗?",
|
||||
"emptyState": {
|
||||
"title": "未找到 CLI 设置",
|
||||
"message": "添加 CLI 设置以配置工具特定选项。"
|
||||
},
|
||||
"mode": "模式",
|
||||
"providerBased": "基于提供商",
|
||||
"direct": "直接",
|
||||
"authToken": "认证令牌",
|
||||
"baseUrl": "基础 URL",
|
||||
"model": "模型"
|
||||
},
|
||||
"ccwLitellm": {
|
||||
"title": "CCW-LiteLLM 包",
|
||||
"description": "管理 ccw-litellm Python 包安装",
|
||||
"status": {
|
||||
"installed": "已安装",
|
||||
"notInstalled": "未安装",
|
||||
"version": "版本"
|
||||
},
|
||||
"actions": {
|
||||
"install": "安装",
|
||||
"uninstall": "卸载",
|
||||
"refreshStatus": "刷新状态"
|
||||
},
|
||||
"messages": {
|
||||
"installSuccess": "ccw-litellm 安装成功",
|
||||
"installFailed": "ccw-litellm 安装失败",
|
||||
"uninstallSuccess": "ccw-litellm 卸载成功",
|
||||
"uninstallFailed": "ccw-litellm 卸载失败"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"save": "保存",
|
||||
"cancel": "取消",
|
||||
"confirm": "确认",
|
||||
"delete": "删除",
|
||||
"edit": "编辑",
|
||||
"add": "添加",
|
||||
"close": "关闭",
|
||||
"loading": "加载中...",
|
||||
"error": "错误",
|
||||
"success": "成功",
|
||||
"warning": "警告",
|
||||
"optional": "可选",
|
||||
"enabled": "已启用",
|
||||
"disabled": "已禁用",
|
||||
"searchPlaceholder": "搜索...",
|
||||
"noResults": "未找到结果",
|
||||
"actions": "操作",
|
||||
"name": "名称",
|
||||
"description": "描述",
|
||||
"type": "类型",
|
||||
"status": "状态"
|
||||
},
|
||||
"messages": {
|
||||
"settingsSaved": "设置保存成功",
|
||||
"settingsDeleted": "设置删除成功",
|
||||
"providerSaved": "提供商保存成功",
|
||||
"providerDeleted": "提供商删除成功",
|
||||
"providerUpdated": "提供商更新成功",
|
||||
"endpointSaved": "端点保存成功",
|
||||
"endpointDeleted": "端点删除成功",
|
||||
"confirmDeleteProvider": "确定要删除此提供商吗?",
|
||||
"confirmDeleteEndpoint": "确定要删除此端点吗?",
|
||||
"confirmClearCache": "确定要清除缓存吗?",
|
||||
"invalidJsonHeaders": "自定义请求头的 JSON 格式无效",
|
||||
"failedToLoad": "加载数据失败",
|
||||
"noProviders": "无提供商",
|
||||
"noProvidersHint": "添加提供商以开始配置 API。",
|
||||
"noEndpoints": "无端点",
|
||||
"noEndpointsHint": "添加端点以创建自定义 API 映射。",
|
||||
"configSynced": "配置已同步到 YAML 文件"
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
"overview": "概览",
|
||||
"settings": "设置",
|
||||
"models": "模型",
|
||||
"search": "搜索",
|
||||
"advanced": "高级"
|
||||
},
|
||||
"overview": {
|
||||
@@ -114,6 +115,8 @@
|
||||
"advanced": {
|
||||
"warningTitle": "敏感操作警告",
|
||||
"warningMessage": "修改环境变量可能影响 CodexLens 的正常运行。请确保您了解每个变量的作用。",
|
||||
"loadError": "加载环境变量失败",
|
||||
"loadErrorDesc": "无法获取环境配置。请检查 CodexLens 是否正确安装。",
|
||||
"currentVars": "当前环境变量",
|
||||
"settingsVars": "设置变量",
|
||||
"customVars": "自定义变量",
|
||||
@@ -170,9 +173,35 @@
|
||||
"title": "CodexLens 未安装",
|
||||
"description": "请先安装 CodexLens 以使用模型管理功能。"
|
||||
},
|
||||
"error": {
|
||||
"title": "加载模型失败",
|
||||
"description": "无法获取模型列表。请检查 CodexLens 是否正确安装。"
|
||||
},
|
||||
"empty": {
|
||||
"title": "没有找到模型",
|
||||
"description": "尝试调整搜索或筛选条件"
|
||||
"description": "当前没有可用模型。请从列表中下载模型。",
|
||||
"filtered": "没有匹配的模型",
|
||||
"filteredDesc": "尝试调整搜索或筛选条件"
|
||||
}
|
||||
},
|
||||
"search": {
|
||||
"type": "搜索类型",
|
||||
"content": "内容搜索",
|
||||
"files": "文件搜索",
|
||||
"symbol": "符号搜索",
|
||||
"mode": "模式",
|
||||
"mode.semantic": "语义(默认)",
|
||||
"mode.exact": "精确(FTS)",
|
||||
"mode.fuzzy": "模糊",
|
||||
"query": "查询",
|
||||
"queryPlaceholder": "输入搜索查询...",
|
||||
"button": "搜索",
|
||||
"searching": "搜索中...",
|
||||
"results": "结果",
|
||||
"resultsCount": "个结果",
|
||||
"notInstalled": {
|
||||
"title": "CodexLens 未安装",
|
||||
"description": "请先安装 CodexLens 以使用语义代码搜索功能。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ import { SettingsTab } from '@/components/codexlens/SettingsTab';
|
||||
import { AdvancedTab } from '@/components/codexlens/AdvancedTab';
|
||||
import { GpuSelector } from '@/components/codexlens/GpuSelector';
|
||||
import { ModelsTab } from '@/components/codexlens/ModelsTab';
|
||||
import { SearchTab } from '@/components/codexlens/SearchTab';
|
||||
import { useCodexLensDashboard, useCodexLensMutations } from '@/hooks';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -172,6 +173,9 @@ export function CodexLensManagerPage() {
|
||||
<TabsTrigger value="models">
|
||||
{formatMessage({ id: 'codexlens.tabs.models' })}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="search">
|
||||
{formatMessage({ id: 'codexlens.tabs.search' })}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="advanced">
|
||||
{formatMessage({ id: 'codexlens.tabs.advanced' })}
|
||||
</TabsTrigger>
|
||||
@@ -183,6 +187,7 @@ export function CodexLensManagerPage() {
|
||||
status={status}
|
||||
config={config}
|
||||
isLoading={isLoading}
|
||||
onRefresh={handleRefresh}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
@@ -194,6 +199,10 @@ export function CodexLensManagerPage() {
|
||||
<ModelsTab installed={installed} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="search">
|
||||
<SearchTab enabled={installed} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="advanced">
|
||||
<AdvancedTab enabled={installed} />
|
||||
</TabsContent>
|
||||
|
||||
@@ -77,6 +77,7 @@ interface CliStreamState extends BlockCacheState {
|
||||
outputs: Record<string, CliOutputLine[]>;
|
||||
executions: Record<string, CliExecutionState>;
|
||||
currentExecutionId: string | null;
|
||||
userClosedExecutions: Set<string>; // Track executions closed by user
|
||||
|
||||
// Legacy methods
|
||||
addOutput: (executionId: string, line: CliOutputLine) => void;
|
||||
@@ -87,6 +88,9 @@ interface CliStreamState extends BlockCacheState {
|
||||
getAllExecutions: () => CliExecutionState[];
|
||||
upsertExecution: (executionId: string, exec: Partial<CliExecutionState> & { tool?: string; mode?: string }) => void;
|
||||
removeExecution: (executionId: string) => void;
|
||||
markExecutionClosedByUser: (executionId: string) => void;
|
||||
isExecutionClosedByUser: (executionId: string) => boolean;
|
||||
cleanupUserClosedExecutions: (serverIds: Set<string>) => void;
|
||||
setCurrentExecution: (executionId: string | null) => void;
|
||||
|
||||
// Block cache methods
|
||||
@@ -320,6 +324,7 @@ export const useCliStreamStore = create<CliStreamState>()(
|
||||
outputs: {},
|
||||
executions: {},
|
||||
currentExecutionId: null,
|
||||
userClosedExecutions: new Set<string>(),
|
||||
|
||||
// Block cache state
|
||||
blocks: {},
|
||||
@@ -426,6 +431,35 @@ export const useCliStreamStore = create<CliStreamState>()(
|
||||
}, false, 'cliStream/removeExecution');
|
||||
},
|
||||
|
||||
markExecutionClosedByUser: (executionId: string) => {
|
||||
set((state) => {
|
||||
const newUserClosedExecutions = new Set(state.userClosedExecutions);
|
||||
newUserClosedExecutions.add(executionId);
|
||||
return {
|
||||
userClosedExecutions: newUserClosedExecutions,
|
||||
};
|
||||
}, false, 'cliStream/markExecutionClosedByUser');
|
||||
},
|
||||
|
||||
isExecutionClosedByUser: (executionId: string) => {
|
||||
return get().userClosedExecutions.has(executionId);
|
||||
},
|
||||
|
||||
cleanupUserClosedExecutions: (serverIds: Set<string>) => {
|
||||
set((state) => {
|
||||
const newUserClosedExecutions = new Set<string>();
|
||||
for (const executionId of state.userClosedExecutions) {
|
||||
// Only keep if still on server (user might want to keep it closed)
|
||||
if (serverIds.has(executionId)) {
|
||||
newUserClosedExecutions.add(executionId);
|
||||
}
|
||||
}
|
||||
return {
|
||||
userClosedExecutions: newUserClosedExecutions,
|
||||
};
|
||||
}, false, 'cliStream/cleanupUserClosedExecutions');
|
||||
},
|
||||
|
||||
setCurrentExecution: (executionId: string | null) => {
|
||||
set({ currentExecutionId: executionId }, false, 'cliStream/setCurrentExecution');
|
||||
},
|
||||
|
||||
@@ -442,4 +442,573 @@ test.describe('[CodexLens Manager] - CodexLens Management Tests', () => {
|
||||
monitoring.assertClean({ allowWarnings: true });
|
||||
monitoring.stop();
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// Search Tab Tests
|
||||
// ========================================
|
||||
test.describe('[CodexLens Manager] - Search Tab Tests', () => {
|
||||
test('L4.19 - should navigate to Search tab', async ({ page }) => {
|
||||
const monitoring = setupEnhancedMonitoring(page);
|
||||
|
||||
await page.goto('/settings/codexlens', { waitUntil: 'networkidle' as const });
|
||||
|
||||
// Click Search tab
|
||||
const searchTab = page.getByRole('tab', { name: /Search/i });
|
||||
const searchTabVisible = await searchTab.isVisible().catch(() => false);
|
||||
if (searchTabVisible) {
|
||||
await searchTab.click();
|
||||
|
||||
// Verify tab is active
|
||||
await expect(searchTab).toHaveAttribute('data-state', 'active');
|
||||
|
||||
// Verify search content is visible
|
||||
const searchContent = page.getByText(/Search/i).or(
|
||||
page.getByPlaceholder(/Search query/i)
|
||||
);
|
||||
const contentVisible = await searchContent.isVisible().catch(() => false);
|
||||
if (contentVisible) {
|
||||
await expect(searchContent.first()).toBeVisible();
|
||||
}
|
||||
}
|
||||
|
||||
monitoring.assertClean({ allowWarnings: true });
|
||||
monitoring.stop();
|
||||
});
|
||||
|
||||
test('L4.20 - should display all search UI elements', async ({ page }) => {
|
||||
const monitoring = setupEnhancedMonitoring(page);
|
||||
|
||||
await page.goto('/settings/codexlens', { waitUntil: 'networkidle' as const });
|
||||
|
||||
const searchTab = page.getByRole('tab', { name: /Search/i });
|
||||
const searchTabVisible = await searchTab.isVisible().catch(() => false);
|
||||
if (searchTabVisible) {
|
||||
await searchTab.click();
|
||||
|
||||
// Verify search type selector exists
|
||||
const searchTypeSelector = page.getByLabel(/Search Type/i).or(
|
||||
page.getByRole('combobox', { name: /Search Type/i })
|
||||
).or(page.getByText(/Search Type/i));
|
||||
const typeVisible = await searchTypeSelector.isVisible().catch(() => false);
|
||||
if (typeVisible) {
|
||||
await expect(searchTypeSelector.first()).toBeVisible();
|
||||
}
|
||||
|
||||
// Verify search mode selector exists
|
||||
const searchModeSelector = page.getByLabel(/Search Mode/i).or(
|
||||
page.getByRole('combobox', { name: /Search Mode/i })
|
||||
).or(page.getByText(/Search Mode/i));
|
||||
const modeVisible = await searchModeSelector.isVisible().catch(() => false);
|
||||
if (modeVisible) {
|
||||
await expect(searchModeSelector.first()).toBeVisible();
|
||||
}
|
||||
|
||||
// Verify query input field exists
|
||||
const queryInput = page.getByPlaceholder(/Search query/i).or(
|
||||
page.getByLabel(/Query/i)
|
||||
).or(page.getByRole('textbox', { name: /query/i }));
|
||||
const inputVisible = await queryInput.isVisible().catch(() => false);
|
||||
if (inputVisible) {
|
||||
await expect(queryInput.first()).toBeVisible();
|
||||
}
|
||||
|
||||
// Verify search button exists
|
||||
const searchButton = page.getByRole('button', { name: /Search/i });
|
||||
const buttonVisible = await searchButton.isVisible().catch(() => false);
|
||||
if (buttonVisible) {
|
||||
await expect(searchButton.first()).toBeVisible();
|
||||
}
|
||||
}
|
||||
|
||||
monitoring.assertClean({ allowWarnings: true });
|
||||
monitoring.stop();
|
||||
});
|
||||
|
||||
test('L4.21 - should show search type options', async ({ page }) => {
|
||||
const monitoring = setupEnhancedMonitoring(page);
|
||||
|
||||
await page.goto('/settings/codexlens', { waitUntil: 'networkidle' as const });
|
||||
|
||||
const searchTab = page.getByRole('tab', { name: /Search/i });
|
||||
const searchTabVisible = await searchTab.isVisible().catch(() => false);
|
||||
if (searchTabVisible) {
|
||||
await searchTab.click();
|
||||
|
||||
// Click on search type selector to open dropdown
|
||||
const searchTypeSelector = page.getByLabel(/Search Type/i).or(
|
||||
page.getByRole('combobox', { name: /Search Type/i })
|
||||
);
|
||||
const typeVisible = await searchTypeSelector.isVisible().catch(() => false);
|
||||
if (typeVisible) {
|
||||
await searchTypeSelector.first().click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Check for expected search type options
|
||||
const searchTypes = ['Content Search', 'File Search', 'Symbol Search'];
|
||||
for (const type of searchTypes) {
|
||||
const option = page.getByRole('option', { name: new RegExp(type, 'i') });
|
||||
const optionVisible = await option.isVisible().catch(() => false);
|
||||
if (optionVisible) {
|
||||
await expect(option).toBeVisible();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
monitoring.assertClean({ allowWarnings: true });
|
||||
monitoring.stop();
|
||||
});
|
||||
|
||||
test('L4.22 - should show search mode options for Content Search', async ({ page }) => {
|
||||
const monitoring = setupEnhancedMonitoring(page);
|
||||
|
||||
await page.goto('/settings/codexlens', { waitUntil: 'networkidle' as const });
|
||||
|
||||
const searchTab = page.getByRole('tab', { name: /Search/i });
|
||||
const searchTabVisible = await searchTab.isVisible().catch(() => false);
|
||||
if (searchTabVisible) {
|
||||
await searchTab.click();
|
||||
|
||||
// Select Content Search first
|
||||
const searchTypeSelector = page.getByLabel(/Search Type/i).or(
|
||||
page.getByRole('combobox', { name: /Search Type/i })
|
||||
);
|
||||
const typeVisible = await searchTypeSelector.isVisible().catch(() => false);
|
||||
if (typeVisible) {
|
||||
await searchTypeSelector.first().click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
const contentOption = page.getByRole('option', { name: /Content Search/i });
|
||||
const contentVisible = await contentOption.isVisible().catch(() => false);
|
||||
if (contentVisible) {
|
||||
await contentOption.click();
|
||||
|
||||
// Click on search mode selector to open dropdown
|
||||
const searchModeSelector = page.getByLabel(/Search Mode/i).or(
|
||||
page.getByRole('combobox', { name: /Search Mode/i })
|
||||
);
|
||||
const modeVisible = await searchModeSelector.isVisible().catch(() => false);
|
||||
if (modeVisible) {
|
||||
await searchModeSelector.first().click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Check for expected search mode options
|
||||
const searchModes = ['Semantic', 'Exact', 'Fuzzy'];
|
||||
for (const mode of searchModes) {
|
||||
const option = page.getByRole('option', { name: new RegExp(mode, 'i') });
|
||||
const optionVisible = await option.isVisible().catch(() => false);
|
||||
if (optionVisible) {
|
||||
await expect(option).toBeVisible();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
monitoring.assertClean({ allowWarnings: true });
|
||||
monitoring.stop();
|
||||
});
|
||||
|
||||
test('L4.23 - should hide search mode for Symbol Search', async ({ page }) => {
|
||||
const monitoring = setupEnhancedMonitoring(page);
|
||||
|
||||
await page.goto('/settings/codexlens', { waitUntil: 'networkidle' as const });
|
||||
|
||||
const searchTab = page.getByRole('tab', { name: /Search/i });
|
||||
const searchTabVisible = await searchTab.isVisible().catch(() => false);
|
||||
if (searchTabVisible) {
|
||||
await searchTab.click();
|
||||
|
||||
// Select Symbol Search
|
||||
const searchTypeSelector = page.getByLabel(/Search Type/i).or(
|
||||
page.getByRole('combobox', { name: /Search Type/i })
|
||||
);
|
||||
const typeVisible = await searchTypeSelector.isVisible().catch(() => false);
|
||||
if (typeVisible) {
|
||||
await searchTypeSelector.first().click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
const symbolOption = page.getByRole('option', { name: /Symbol Search/i });
|
||||
const symbolVisible = await symbolOption.isVisible().catch(() => false);
|
||||
if (symbolVisible) {
|
||||
await symbolOption.click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Verify search mode selector is hidden or removed
|
||||
const searchModeSelector = page.getByLabel(/Search Mode/i).or(
|
||||
page.getByRole('combobox', { name: /Search Mode/i })
|
||||
);
|
||||
const modeVisible = await searchModeSelector.isVisible().catch(() => false);
|
||||
|
||||
// Search mode should not be visible for Symbol Search
|
||||
expect(modeVisible).toBe(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
monitoring.assertClean({ allowWarnings: true });
|
||||
monitoring.stop();
|
||||
});
|
||||
|
||||
test('L4.24 - should disable search button with empty query', async ({ page }) => {
|
||||
const monitoring = setupEnhancedMonitoring(page);
|
||||
|
||||
await page.goto('/settings/codexlens', { waitUntil: 'networkidle' as const });
|
||||
|
||||
const searchTab = page.getByRole('tab', { name: /Search/i });
|
||||
const searchTabVisible = await searchTab.isVisible().catch(() => false);
|
||||
if (searchTabVisible) {
|
||||
await searchTab.click();
|
||||
|
||||
// Verify search button is disabled with empty query
|
||||
const searchButton = page.getByRole('button', { name: /Search/i });
|
||||
const buttonVisible = await searchButton.isVisible().catch(() => false);
|
||||
if (buttonVisible) {
|
||||
// Check if button is disabled when query is empty
|
||||
const isDisabled = await searchButton.first().isDisabled().catch(() => false);
|
||||
if (isDisabled) {
|
||||
await expect(searchButton.first()).toBeDisabled();
|
||||
} else {
|
||||
// If not disabled, check for validation on click
|
||||
const queryInput = page.getByPlaceholder(/Search query/i).or(
|
||||
page.getByLabel(/Query/i)
|
||||
);
|
||||
const inputVisible = await queryInput.isVisible().catch(() => false);
|
||||
if (inputVisible) {
|
||||
// Ensure input is empty
|
||||
const inputValue = await queryInput.first().inputValue();
|
||||
expect(inputValue || '').toBe('');
|
||||
|
||||
// Try clicking search button
|
||||
await searchButton.first().click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Check for error message
|
||||
const errorMessage = page.getByText(/required|enter a query|empty query/i);
|
||||
const errorVisible = await errorMessage.isVisible().catch(() => false);
|
||||
if (errorVisible) {
|
||||
await expect(errorMessage).toBeVisible();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
monitoring.assertClean({ allowWarnings: true });
|
||||
monitoring.stop();
|
||||
});
|
||||
|
||||
test('L4.25 - should enable search button with valid query', async ({ page }) => {
|
||||
const monitoring = setupEnhancedMonitoring(page);
|
||||
|
||||
await page.goto('/settings/codexlens', { waitUntil: 'networkidle' as const });
|
||||
|
||||
const searchTab = page.getByRole('tab', { name: /Search/i });
|
||||
const searchTabVisible = await searchTab.isVisible().catch(() => false);
|
||||
if (searchTabVisible) {
|
||||
await searchTab.click();
|
||||
|
||||
// Enter query text
|
||||
const queryInput = page.getByPlaceholder(/Search query/i).or(
|
||||
page.getByLabel(/Query/i)
|
||||
);
|
||||
const inputVisible = await queryInput.isVisible().catch(() => false);
|
||||
if (inputVisible) {
|
||||
await queryInput.first().fill('test query');
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Verify search button is enabled
|
||||
const searchButton = page.getByRole('button', { name: /Search/i });
|
||||
const buttonVisible = await searchButton.isVisible().catch(() => false);
|
||||
if (buttonVisible) {
|
||||
const isEnabled = await searchButton.first().isEnabled().catch(() => true);
|
||||
expect(isEnabled).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
monitoring.assertClean({ allowWarnings: true });
|
||||
monitoring.stop();
|
||||
});
|
||||
|
||||
test('L4.26 - should show loading state during search', async ({ page }) => {
|
||||
const monitoring = setupEnhancedMonitoring(page);
|
||||
|
||||
await page.goto('/settings/codexlens', { waitUntil: 'networkidle' as const });
|
||||
|
||||
const searchTab = page.getByRole('tab', { name: /Search/i });
|
||||
const searchTabVisible = await searchTab.isVisible().catch(() => false);
|
||||
if (searchTabVisible) {
|
||||
await searchTab.click();
|
||||
|
||||
// Enter query text
|
||||
const queryInput = page.getByPlaceholder(/Search query/i).or(
|
||||
page.getByLabel(/Query/i)
|
||||
);
|
||||
const inputVisible = await queryInput.isVisible().catch(() => false);
|
||||
if (inputVisible) {
|
||||
await queryInput.first().fill('test query');
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Click search button
|
||||
const searchButton = page.getByRole('button', { name: /Search/i });
|
||||
const buttonVisible = await searchButton.isVisible().catch(() => false);
|
||||
if (buttonVisible) {
|
||||
// Set up route handler to delay response
|
||||
await page.route('**/api/codexlens/search**', async (route) => {
|
||||
// Delay response to observe loading state
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
route.continue();
|
||||
});
|
||||
|
||||
await searchButton.first().click();
|
||||
|
||||
// Check for loading indicator
|
||||
const loadingIndicator = page.getByText(/Searching|Loading/i).or(
|
||||
page.getByRole('button', { name: /Search/i }).filter({ hasText: /Searching|Loading/i })
|
||||
).or(page.locator('[aria-busy="true"]'));
|
||||
|
||||
// Wait briefly to see if loading state appears
|
||||
await page.waitForTimeout(200);
|
||||
const loadingVisible = await loadingIndicator.isVisible().catch(() => false);
|
||||
|
||||
// Clean up route
|
||||
await page.unroute('**/api/codexlens/search**');
|
||||
|
||||
// Loading state may or may not be visible depending on speed
|
||||
if (loadingVisible) {
|
||||
await expect(loadingIndicator.first()).toBeVisible();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
monitoring.assertClean({ allowWarnings: true });
|
||||
monitoring.stop();
|
||||
});
|
||||
|
||||
test('L4.27 - should display search results', async ({ page }) => {
|
||||
const monitoring = setupEnhancedMonitoring(page);
|
||||
|
||||
await page.goto('/settings/codexlens', { waitUntil: 'networkidle' as const });
|
||||
|
||||
const searchTab = page.getByRole('tab', { name: /Search/i });
|
||||
const searchTabVisible = await searchTab.isVisible().catch(() => false);
|
||||
if (searchTabVisible) {
|
||||
await searchTab.click();
|
||||
|
||||
// Select Content Search
|
||||
const searchTypeSelector = page.getByLabel(/Search Type/i).or(
|
||||
page.getByRole('combobox', { name: /Search Type/i })
|
||||
);
|
||||
const typeVisible = await searchTypeSelector.isVisible().catch(() => false);
|
||||
if (typeVisible) {
|
||||
await searchTypeSelector.first().click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
const contentOption = page.getByRole('option', { name: /Content Search/i });
|
||||
const contentVisible = await contentOption.isVisible().catch(() => false);
|
||||
if (contentVisible) {
|
||||
await contentOption.click();
|
||||
|
||||
// Enter query text
|
||||
const queryInput = page.getByPlaceholder(/Search query/i).or(
|
||||
page.getByLabel(/Query/i)
|
||||
);
|
||||
const inputVisible = await queryInput.isVisible().catch(() => false);
|
||||
if (inputVisible) {
|
||||
await queryInput.first().fill('test');
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Click search button
|
||||
const searchButton = page.getByRole('button', { name: /Search/i });
|
||||
const buttonVisible = await searchButton.isVisible().catch(() => false);
|
||||
if (buttonVisible) {
|
||||
await searchButton.first().click();
|
||||
|
||||
// Wait for results
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Check for results area
|
||||
const resultsArea = page.getByText(/Results|No results|Found/i).or(
|
||||
page.locator('[data-testid="search-results"]')
|
||||
).or(page.locator('.search-results'));
|
||||
const resultsVisible = await resultsArea.isVisible().catch(() => false);
|
||||
|
||||
if (resultsVisible) {
|
||||
await expect(resultsArea.first()).toBeVisible();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
monitoring.assertClean({ allowWarnings: true });
|
||||
monitoring.stop();
|
||||
});
|
||||
|
||||
test('L4.28 - should handle search between different types', async ({ page }) => {
|
||||
const monitoring = setupEnhancedMonitoring(page);
|
||||
|
||||
await page.goto('/settings/codexlens', { waitUntil: 'networkidle' as const });
|
||||
|
||||
const searchTab = page.getByRole('tab', { name: /Search/i });
|
||||
const searchTabVisible = await searchTab.isVisible().catch(() => false);
|
||||
if (searchTabVisible) {
|
||||
await searchTab.click();
|
||||
|
||||
// Start with Content Search
|
||||
const searchTypeSelector = page.getByLabel(/Search Type/i).or(
|
||||
page.getByRole('combobox', { name: /Search Type/i })
|
||||
);
|
||||
const typeVisible = await searchTypeSelector.isVisible().catch(() => false);
|
||||
if (typeVisible) {
|
||||
// Select Content Search
|
||||
await searchTypeSelector.first().click();
|
||||
await page.waitForTimeout(300);
|
||||
const contentOption = page.getByRole('option', { name: /Content Search/i });
|
||||
const contentVisible = await contentOption.isVisible().catch(() => false);
|
||||
if (contentVisible) {
|
||||
await contentOption.click();
|
||||
}
|
||||
|
||||
// Verify mode selector is visible
|
||||
const searchModeSelector = page.getByLabel(/Search Mode/i).or(
|
||||
page.getByRole('combobox', { name: /Search Mode/i })
|
||||
);
|
||||
let modeVisible = await searchModeSelector.isVisible().catch(() => false);
|
||||
expect(modeVisible).toBe(true);
|
||||
|
||||
// Switch to Symbol Search
|
||||
await searchTypeSelector.first().click();
|
||||
await page.waitForTimeout(300);
|
||||
const symbolOption = page.getByRole('option', { name: /Symbol Search/i });
|
||||
const symbolVisible = await symbolOption.isVisible().catch(() => false);
|
||||
if (symbolVisible) {
|
||||
await symbolOption.click();
|
||||
}
|
||||
|
||||
// Verify mode selector is hidden
|
||||
modeVisible = await searchModeSelector.isVisible().catch(() => false);
|
||||
expect(modeVisible).toBe(false);
|
||||
|
||||
// Switch to File Search
|
||||
await searchTypeSelector.first().click();
|
||||
await page.waitForTimeout(300);
|
||||
const fileOption = page.getByRole('option', { name: /File Search/i });
|
||||
const fileVisible = await fileOption.isVisible().catch(() => false);
|
||||
if (fileVisible) {
|
||||
await fileOption.click();
|
||||
}
|
||||
|
||||
// Verify mode selector is visible again
|
||||
modeVisible = await searchModeSelector.isVisible().catch(() => false);
|
||||
expect(modeVisible).toBe(true);
|
||||
}
|
||||
}
|
||||
|
||||
monitoring.assertClean({ allowWarnings: true });
|
||||
monitoring.stop();
|
||||
});
|
||||
|
||||
test('L4.29 - should handle empty search results gracefully', async ({ page }) => {
|
||||
const monitoring = setupEnhancedMonitoring(page);
|
||||
|
||||
await page.goto('/settings/codexlens', { waitUntil: 'networkidle' as const });
|
||||
|
||||
const searchTab = page.getByRole('tab', { name: /Search/i });
|
||||
const searchTabVisible = await searchTab.isVisible().catch(() => false);
|
||||
if (searchTabVisible) {
|
||||
await searchTab.click();
|
||||
|
||||
// Enter a unique query that likely has no results
|
||||
const queryInput = page.getByPlaceholder(/Search query/i).or(
|
||||
page.getByLabel(/Query/i)
|
||||
);
|
||||
const inputVisible = await queryInput.isVisible().catch(() => false);
|
||||
if (inputVisible) {
|
||||
await queryInput.first().fill('nonexistent-unique-query-xyz-123');
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Click search button
|
||||
const searchButton = page.getByRole('button', { name: /Search/i });
|
||||
const buttonVisible = await searchButton.isVisible().catch(() => false);
|
||||
if (buttonVisible) {
|
||||
await searchButton.first().click();
|
||||
|
||||
// Wait for results
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Check for empty state message
|
||||
const emptyState = page.getByText(/No results|Found 0|No matches/i);
|
||||
const emptyVisible = await emptyState.isVisible().catch(() => false);
|
||||
if (emptyVisible) {
|
||||
await expect(emptyState.first()).toBeVisible();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
monitoring.assertClean({ allowWarnings: true });
|
||||
monitoring.stop();
|
||||
});
|
||||
|
||||
test('L4.30 - should handle search mode selection', async ({ page }) => {
|
||||
const monitoring = setupEnhancedMonitoring(page);
|
||||
|
||||
await page.goto('/settings/codexlens', { waitUntil: 'networkidle' as const });
|
||||
|
||||
const searchTab = page.getByRole('tab', { name: /Search/i });
|
||||
const searchTabVisible = await searchTab.isVisible().catch(() => false);
|
||||
if (searchTabVisible) {
|
||||
await searchTab.click();
|
||||
|
||||
// Ensure we're on Content or File Search (which have modes)
|
||||
const searchTypeSelector = page.getByLabel(/Search Type/i).or(
|
||||
page.getByRole('combobox', { name: /Search Type/i })
|
||||
);
|
||||
const typeVisible = await searchTypeSelector.isVisible().catch(() => false);
|
||||
if (typeVisible) {
|
||||
await searchTypeSelector.first().click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
const contentOption = page.getByRole('option', { name: /Content Search/i });
|
||||
const contentVisible = await contentOption.isVisible().catch(() => false);
|
||||
if (contentVisible) {
|
||||
await contentOption.click();
|
||||
}
|
||||
|
||||
// Try different search modes
|
||||
const searchModes = ['Semantic', 'Exact'];
|
||||
for (const mode of searchModes) {
|
||||
const searchModeSelector = page.getByLabel(/Search Mode/i).or(
|
||||
page.getByRole('combobox', { name: /Search Mode/i })
|
||||
);
|
||||
const modeVisible = await searchModeSelector.isVisible().catch(() => false);
|
||||
if (modeVisible) {
|
||||
await searchModeSelector.first().click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
const modeOption = page.getByRole('option', { name: new RegExp(mode, 'i') });
|
||||
const optionVisible = await modeOption.isVisible().catch(() => false);
|
||||
if (optionVisible) {
|
||||
await modeOption.click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Verify selection
|
||||
await expect(searchModeSelector.first()).toContainText(mode, { timeout: 2000 }).catch(() => {
|
||||
// Selection may or may not be reflected in selector text
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
monitoring.assertClean({ allowWarnings: true });
|
||||
monitoring.stop();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import { listTools } from '../../tools/index.js';
|
||||
import { loadProjectOverview } from '../data-aggregator.js';
|
||||
import { resolvePath } from '../../utils/path-resolver.js';
|
||||
import { join } from 'path';
|
||||
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
||||
import type { RouteContext } from './types.js';
|
||||
|
||||
/**
|
||||
@@ -45,6 +46,77 @@ export async function handleCcwRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
// API: Get Project Guidelines
|
||||
if (pathname === '/api/ccw/guidelines' && req.method === 'GET') {
|
||||
const projectPath = url.searchParams.get('path') || initialPath;
|
||||
const resolvedPath = resolvePath(projectPath);
|
||||
const guidelinesFile = join(resolvedPath, '.workflow', 'project-guidelines.json');
|
||||
|
||||
if (!existsSync(guidelinesFile)) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ guidelines: null }));
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(guidelinesFile, 'utf-8');
|
||||
const guidelines = JSON.parse(content);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ guidelines }));
|
||||
} catch (err) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Failed to read guidelines file' }));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// API: Update Project Guidelines
|
||||
if (pathname === '/api/ccw/guidelines' && req.method === 'PUT') {
|
||||
handlePostRequest(req, res, async (body) => {
|
||||
const projectPath = url.searchParams.get('path') || initialPath;
|
||||
const resolvedPath = resolvePath(projectPath);
|
||||
const guidelinesFile = join(resolvedPath, '.workflow', 'project-guidelines.json');
|
||||
|
||||
try {
|
||||
const data = body as Record<string, unknown>;
|
||||
|
||||
// Read existing file to preserve _metadata.created_at
|
||||
let existingMetadata: Record<string, unknown> = {};
|
||||
if (existsSync(guidelinesFile)) {
|
||||
try {
|
||||
const existing = JSON.parse(readFileSync(guidelinesFile, 'utf-8'));
|
||||
existingMetadata = existing._metadata || {};
|
||||
} catch { /* ignore parse errors */ }
|
||||
}
|
||||
|
||||
// Build the guidelines object
|
||||
const guidelines = {
|
||||
conventions: data.conventions || { coding_style: [], naming_patterns: [], file_structure: [], documentation: [] },
|
||||
constraints: data.constraints || { architecture: [], tech_stack: [], performance: [], security: [] },
|
||||
quality_rules: data.quality_rules || [],
|
||||
learnings: data.learnings || [],
|
||||
_metadata: {
|
||||
created_at: (existingMetadata.created_at as string) || new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
version: (existingMetadata.version as string) || '1.0.0',
|
||||
},
|
||||
};
|
||||
|
||||
writeFileSync(guidelinesFile, JSON.stringify(guidelines, null, 2), 'utf-8');
|
||||
|
||||
broadcastToClients({
|
||||
type: 'PROJECT_GUIDELINES_UPDATED',
|
||||
payload: { timestamp: new Date().toISOString() },
|
||||
});
|
||||
|
||||
return { success: true, guidelines };
|
||||
} catch (err) {
|
||||
return { error: (err as Error).message, status: 500 };
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// API: CCW Upgrade
|
||||
if (pathname === '/api/ccw/upgrade' && req.method === 'POST') {
|
||||
handlePostRequest(req, res, async (body) => {
|
||||
|
||||
@@ -58,13 +58,24 @@ interface ActiveExecution {
|
||||
mode: string;
|
||||
prompt: string;
|
||||
startTime: number;
|
||||
output: string;
|
||||
output: string[]; // Array-based buffer to limit memory usage
|
||||
status: 'running' | 'completed' | 'error';
|
||||
completedTimestamp?: number; // When execution completed (for 5-minute retention)
|
||||
}
|
||||
|
||||
// API response type with output as string (for backward compatibility)
|
||||
type ActiveExecutionDto = Omit<ActiveExecution, 'output'> & { output: string };
|
||||
|
||||
const activeExecutions = new Map<string, ActiveExecution>();
|
||||
const EXECUTION_RETENTION_MS = 5 * 60 * 1000; // 5 minutes
|
||||
const CLEANUP_INTERVAL_MS = 60 * 1000; // 1 minute - periodic cleanup interval
|
||||
const MAX_OUTPUT_BUFFER_LINES = 1000; // Max lines to keep in memory per execution
|
||||
const MAX_ACTIVE_EXECUTIONS = 200; // Max concurrent executions in memory
|
||||
|
||||
// Enable periodic cleanup to prevent memory buildup
|
||||
setInterval(() => {
|
||||
cleanupStaleExecutions();
|
||||
}, CLEANUP_INTERVAL_MS);
|
||||
|
||||
/**
|
||||
* Cleanup stale completed executions older than retention period
|
||||
@@ -93,9 +104,13 @@ export function cleanupStaleExecutions(): void {
|
||||
/**
|
||||
* Get all active CLI executions
|
||||
* Used by frontend to restore state when view is opened during execution
|
||||
* Note: Converts output array back to string for API compatibility
|
||||
*/
|
||||
export function getActiveExecutions(): ActiveExecution[] {
|
||||
return Array.from(activeExecutions.values());
|
||||
export function getActiveExecutions(): ActiveExecutionDto[] {
|
||||
return Array.from(activeExecutions.values()).map(exec => ({
|
||||
...exec,
|
||||
output: exec.output.join('') // Convert array buffer to string for API
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -122,21 +137,30 @@ export function updateActiveExecution(event: {
|
||||
}
|
||||
|
||||
if (type === 'started') {
|
||||
// Create new active execution
|
||||
// Check map size limit before creating new execution
|
||||
if (activeExecutions.size >= MAX_ACTIVE_EXECUTIONS) {
|
||||
console.warn(`[ActiveExec] Max executions limit reached (${MAX_ACTIVE_EXECUTIONS}), cleanup may be needed`);
|
||||
}
|
||||
|
||||
// Create new active execution with array-based output buffer
|
||||
activeExecutions.set(executionId, {
|
||||
id: executionId,
|
||||
tool: tool || 'unknown',
|
||||
mode: mode || 'analysis',
|
||||
prompt: (prompt || '').substring(0, 500),
|
||||
startTime: Date.now(),
|
||||
output: '',
|
||||
output: [], // Initialize as empty array instead of empty string
|
||||
status: 'running'
|
||||
});
|
||||
} else if (type === 'output') {
|
||||
// Append output to existing execution
|
||||
// Append output to existing execution using array with size limit
|
||||
const activeExec = activeExecutions.get(executionId);
|
||||
if (activeExec && output) {
|
||||
activeExec.output += output;
|
||||
activeExec.output.push(output);
|
||||
// Keep buffer size under limit by shifting old entries
|
||||
if (activeExec.output.length > MAX_OUTPUT_BUFFER_LINES) {
|
||||
activeExec.output.shift(); // Remove oldest entry
|
||||
}
|
||||
}
|
||||
} else if (type === 'completed') {
|
||||
// Mark as completed with timestamp for retention-based cleanup
|
||||
@@ -487,6 +511,7 @@ export async function handleCliRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
const activeExec = activeExecutions.get(executionId);
|
||||
if (activeExec) {
|
||||
// Return active execution data as conversation record format
|
||||
// Note: Convert output array buffer back to string for API compatibility
|
||||
const activeConversation = {
|
||||
id: activeExec.id,
|
||||
tool: activeExec.tool,
|
||||
@@ -497,7 +522,7 @@ export async function handleCliRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
turn: 1,
|
||||
timestamp: new Date(activeExec.startTime).toISOString(),
|
||||
prompt: activeExec.prompt,
|
||||
output: { stdout: activeExec.output, stderr: '' },
|
||||
output: { stdout: activeExec.output.join(''), stderr: '' }, // Convert array to string
|
||||
duration_ms: activeExec.completedTimestamp
|
||||
? activeExec.completedTimestamp - activeExec.startTime
|
||||
: Date.now() - activeExec.startTime
|
||||
@@ -662,13 +687,17 @@ export async function handleCliRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
const executionId = `${Date.now()}-${tool}`;
|
||||
|
||||
// Store active execution for state recovery
|
||||
// Check map size limit before creating new execution
|
||||
if (activeExecutions.size >= MAX_ACTIVE_EXECUTIONS) {
|
||||
console.warn(`[ActiveExec] Max executions limit reached (${MAX_ACTIVE_EXECUTIONS}), cleanup may be needed`);
|
||||
}
|
||||
activeExecutions.set(executionId, {
|
||||
id: executionId,
|
||||
tool,
|
||||
mode: mode || 'analysis',
|
||||
prompt: prompt.substring(0, 500), // Truncate for display
|
||||
startTime: Date.now(),
|
||||
output: '',
|
||||
output: [], // Initialize as empty array for memory-efficient buffering
|
||||
status: 'running'
|
||||
});
|
||||
|
||||
@@ -701,10 +730,14 @@ export async function handleCliRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
// CliOutputUnit handler: use SmartContentFormatter for intelligent formatting (never returns null)
|
||||
const content = SmartContentFormatter.format(unit.content, unit.type);
|
||||
|
||||
// Append to active execution buffer
|
||||
// Append to active execution buffer using array with size limit
|
||||
const activeExec = activeExecutions.get(executionId);
|
||||
if (activeExec) {
|
||||
activeExec.output += content || '';
|
||||
activeExec.output.push(content || '');
|
||||
// Keep buffer size under limit by shifting old entries
|
||||
if (activeExec.output.length > MAX_OUTPUT_BUFFER_LINES) {
|
||||
activeExec.output.shift(); // Remove oldest entry
|
||||
}
|
||||
}
|
||||
|
||||
broadcastToClients({
|
||||
@@ -753,7 +786,9 @@ export async function handleCliRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
|
||||
return {
|
||||
success: result.success,
|
||||
execution: result.execution
|
||||
execution: result.execution,
|
||||
parsedOutput: result.parsedOutput, // Filtered output (excludes metadata/progress)
|
||||
finalOutput: result.finalOutput // Agent message only (for --final flag)
|
||||
};
|
||||
|
||||
} catch (error: unknown) {
|
||||
|
||||
@@ -341,6 +341,108 @@ export async function handleCodexLensIndexRoutes(ctx: RouteContext): Promise<boo
|
||||
return true;
|
||||
}
|
||||
|
||||
// API: CodexLens Update (Incremental index update)
|
||||
if (pathname === '/api/codexlens/update' && req.method === 'POST') {
|
||||
handlePostRequest(req, res, async (body) => {
|
||||
const { path: projectPath, indexType = 'vector', embeddingModel = 'code', embeddingBackend = 'fastembed', maxWorkers = 1 } = body as {
|
||||
path?: unknown;
|
||||
indexType?: unknown;
|
||||
embeddingModel?: unknown;
|
||||
embeddingBackend?: unknown;
|
||||
maxWorkers?: unknown;
|
||||
};
|
||||
const targetPath = typeof projectPath === 'string' && projectPath.trim().length > 0 ? projectPath : initialPath;
|
||||
const resolvedIndexType = indexType === 'normal' ? 'normal' : 'vector';
|
||||
const resolvedEmbeddingModel = typeof embeddingModel === 'string' && embeddingModel.trim().length > 0 ? embeddingModel : 'code';
|
||||
const resolvedEmbeddingBackend = typeof embeddingBackend === 'string' && embeddingBackend.trim().length > 0 ? embeddingBackend : 'fastembed';
|
||||
const resolvedMaxWorkers = typeof maxWorkers === 'number' ? maxWorkers : Number(maxWorkers);
|
||||
|
||||
// Pre-check: Verify embedding backend availability before proceeding with vector indexing
|
||||
if (resolvedIndexType !== 'normal') {
|
||||
if (resolvedEmbeddingBackend === 'litellm') {
|
||||
const installResult = await ensureLiteLLMEmbedderReady();
|
||||
if (!installResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: installResult.error || 'LiteLLM embedding backend is not available. Please install ccw-litellm first.',
|
||||
status: 500
|
||||
};
|
||||
}
|
||||
} else {
|
||||
const semanticStatus = await checkSemanticStatus();
|
||||
if (!semanticStatus.available) {
|
||||
return {
|
||||
success: false,
|
||||
error: semanticStatus.error || 'FastEmbed semantic backend is not available. Please install semantic dependencies first.',
|
||||
status: 500
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build CLI arguments for incremental update using 'index update' subcommand
|
||||
const args = ['index', 'update', targetPath, '--json'];
|
||||
if (resolvedIndexType === 'normal') {
|
||||
args.push('--no-embeddings');
|
||||
} else {
|
||||
args.push('--model', resolvedEmbeddingModel);
|
||||
if (resolvedEmbeddingBackend && resolvedEmbeddingBackend !== 'fastembed') {
|
||||
args.push('--backend', resolvedEmbeddingBackend);
|
||||
}
|
||||
if (!Number.isNaN(resolvedMaxWorkers) && resolvedMaxWorkers > 1) {
|
||||
args.push('--max-workers', String(resolvedMaxWorkers));
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast start event
|
||||
broadcastToClients({
|
||||
type: 'CODEXLENS_INDEX_PROGRESS',
|
||||
payload: { stage: 'start', message: 'Starting incremental index update...', percent: 0, path: targetPath, indexType: resolvedIndexType }
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await executeCodexLens(args, {
|
||||
cwd: targetPath,
|
||||
timeout: 1800000,
|
||||
onProgress: (progress: ProgressInfo) => {
|
||||
broadcastToClients({
|
||||
type: 'CODEXLENS_INDEX_PROGRESS',
|
||||
payload: { ...progress, path: targetPath }
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
broadcastToClients({
|
||||
type: 'CODEXLENS_INDEX_PROGRESS',
|
||||
payload: { stage: 'complete', message: 'Incremental update complete', percent: 100, path: targetPath }
|
||||
});
|
||||
|
||||
try {
|
||||
const parsed = extractJSON(result.output ?? '');
|
||||
return { success: true, result: parsed };
|
||||
} catch {
|
||||
return { success: true, output: result.output ?? '' };
|
||||
}
|
||||
} else {
|
||||
broadcastToClients({
|
||||
type: 'CODEXLENS_INDEX_PROGRESS',
|
||||
payload: { stage: 'error', message: result.error || 'Unknown error', percent: 0, path: targetPath }
|
||||
});
|
||||
return { success: false, error: result.error, status: 500 };
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
broadcastToClients({
|
||||
type: 'CODEXLENS_INDEX_PROGRESS',
|
||||
payload: { stage: 'error', message, percent: 0, path: targetPath }
|
||||
});
|
||||
return { success: false, error: message, status: 500 };
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// API: Check if indexing is in progress
|
||||
if (pathname === '/api/codexlens/indexing-status') {
|
||||
const inProgress = isIndexingInProgress();
|
||||
|
||||
@@ -174,8 +174,11 @@ type Params = z.infer<typeof ParamsSchema>;
|
||||
|
||||
interface ReadyStatus {
|
||||
ready: boolean;
|
||||
installed: boolean;
|
||||
error?: string;
|
||||
version?: string;
|
||||
pythonVersion?: string;
|
||||
venvPath?: string;
|
||||
}
|
||||
|
||||
interface SemanticStatus {
|
||||
@@ -246,28 +249,32 @@ async function checkVenvStatus(force = false): Promise<ReadyStatus> {
|
||||
return venvStatusCache.status;
|
||||
}
|
||||
|
||||
const venvPath = getCodexLensVenvDir();
|
||||
|
||||
// Check venv exists
|
||||
if (!existsSync(getCodexLensVenvDir())) {
|
||||
const result = { ready: false, error: 'Venv not found' };
|
||||
if (!existsSync(venvPath)) {
|
||||
const result = { ready: false, installed: false, error: 'Venv not found', venvPath };
|
||||
venvStatusCache = { status: result, timestamp: Date.now() };
|
||||
console.log(`[PERF][CodexLens] checkVenvStatus (no venv): ${Date.now() - funcStart}ms`);
|
||||
return result;
|
||||
}
|
||||
|
||||
const pythonPath = getCodexLensPython();
|
||||
|
||||
// Check python executable exists
|
||||
if (!existsSync(getCodexLensPython())) {
|
||||
const result = { ready: false, error: 'Python executable not found in venv' };
|
||||
if (!existsSync(pythonPath)) {
|
||||
const result = { ready: false, installed: false, error: 'Python executable not found in venv', venvPath };
|
||||
venvStatusCache = { status: result, timestamp: Date.now() };
|
||||
console.log(`[PERF][CodexLens] checkVenvStatus (no python): ${Date.now() - funcStart}ms`);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Check codexlens and core dependencies are importable
|
||||
// Check codexlens and core dependencies are importable, and get Python version
|
||||
const spawnStart = Date.now();
|
||||
console.log('[PERF][CodexLens] checkVenvStatus spawning Python...');
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(getCodexLensPython(), ['-c', 'import codexlens; import watchdog; print(codexlens.__version__)'], {
|
||||
const child = spawn(pythonPath, ['-c', 'import sys; import codexlens; import watchdog; print(f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"); print(codexlens.__version__)'], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
timeout: 10000,
|
||||
});
|
||||
@@ -285,9 +292,18 @@ async function checkVenvStatus(force = false): Promise<ReadyStatus> {
|
||||
child.on('close', (code) => {
|
||||
let result: ReadyStatus;
|
||||
if (code === 0) {
|
||||
result = { ready: true, version: stdout.trim() };
|
||||
const lines = stdout.trim().split('\n');
|
||||
const pythonVersion = lines[0]?.trim() || '';
|
||||
const codexlensVersion = lines[1]?.trim() || '';
|
||||
result = {
|
||||
ready: true,
|
||||
installed: true,
|
||||
version: codexlensVersion,
|
||||
pythonVersion,
|
||||
venvPath
|
||||
};
|
||||
} else {
|
||||
result = { ready: false, error: `CodexLens not installed: ${stderr}` };
|
||||
result = { ready: false, installed: false, error: `CodexLens not installed: ${stderr}`, venvPath };
|
||||
}
|
||||
// Cache the result
|
||||
venvStatusCache = { status: result, timestamp: Date.now() };
|
||||
@@ -296,7 +312,7 @@ async function checkVenvStatus(force = false): Promise<ReadyStatus> {
|
||||
});
|
||||
|
||||
child.on('error', (err) => {
|
||||
const result = { ready: false, error: `Failed to check venv: ${err.message}` };
|
||||
const result = { ready: false, installed: false, error: `Failed to check venv: ${err.message}`, venvPath };
|
||||
venvStatusCache = { status: result, timestamp: Date.now() };
|
||||
console.log(`[PERF][CodexLens] checkVenvStatus ERROR: ${Date.now() - funcStart}ms`);
|
||||
resolve(result);
|
||||
|
||||
Reference in New Issue
Block a user