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:
catlog22
2026-02-01 23:14:55 +08:00
parent b76424feef
commit e5252f8a77
27 changed files with 4370 additions and 201 deletions

View File

@@ -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",

View File

@@ -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"

View File

@@ -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">

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

View File

@@ -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>
) : (

View File

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

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

View File

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

View File

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

View File

@@ -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>
)}

View File

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

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

View File

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

View File

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

View File

@@ -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',
});
}

View File

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

View 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"
}
}

View 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."
}
}
}

View 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 文件"
}
}

View File

@@ -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 以使用语义代码搜索功能。"
}
}
}

View File

@@ -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>

View File

@@ -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');
},

View File

@@ -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();
});
});
});

View File

@@ -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) => {

View File

@@ -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) {

View File

@@ -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();

View File

@@ -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);