diff --git a/ccw/frontend/src/components/api-settings/CcwLitellmStatus.tsx b/ccw/frontend/src/components/api-settings/CcwLitellmStatus.tsx index a55ff4f2..27f2c32f 100644 --- a/ccw/frontend/src/components/api-settings/CcwLitellmStatus.tsx +++ b/ccw/frontend/src/components/api-settings/CcwLitellmStatus.tsx @@ -13,6 +13,7 @@ import { XCircle, Loader2, Package, + AlertTriangle, } from 'lucide-react'; import { Button } from '@/components/ui/Button'; import { Card, CardContent } from '@/components/ui/Card'; @@ -71,6 +72,8 @@ export function CcwLitellmStatus() { const installed = status?.installed ?? false; const version = status?.version; + const systemPythonInstalled = status?.checks?.systemPython?.installed === true; + const showSystemPythonMismatch = !isLoading && !installed && systemPythonInstalled; return ( <> @@ -86,6 +89,12 @@ export function CcwLitellmStatus() {

{formatMessage({ id: 'apiSettings.ccwLitellm.description' })}

+ {showSystemPythonMismatch && ( +
+ +

{formatMessage({ id: 'apiSettings.ccwLitellm.systemPythonMismatch' })}

+
+ )} diff --git a/ccw/frontend/src/components/shared/SkillDetailPanel.tsx b/ccw/frontend/src/components/shared/SkillDetailPanel.tsx index d973bdd4..e89775cf 100644 --- a/ccw/frontend/src/components/shared/SkillDetailPanel.tsx +++ b/ccw/frontend/src/components/shared/SkillDetailPanel.tsx @@ -3,7 +3,7 @@ // ======================================== // Right-side slide-out panel for viewing skill details -import { useEffect } from 'react'; +import { useEffect, useState, useMemo, useCallback } from 'react'; import { useIntl } from 'react-intl'; import { X, @@ -15,12 +15,21 @@ import { Tag, MapPin, Code, + ChevronRight, + ChevronDown, + Eye, + Loader2, + Maximize2, } from 'lucide-react'; import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/Button'; import { Badge } from '@/components/ui/Badge'; import { Card } from '@/components/ui/Card'; import type { Skill } from '@/lib/api'; +import { buildSkillFileTree, getDefaultExpandedPaths } from '@/utils/skill-files'; +import type { FileSystemNode } from '@/types/file-explorer'; +import { readSkillFile } from '@/lib/api'; +import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore'; export interface SkillDetailPanelProps { skill: Skill | null; @@ -42,6 +51,15 @@ export function SkillDetailPanel({ isLoading = false, }: SkillDetailPanelProps) { const { formatMessage } = useIntl(); + const projectPath = useWorkflowStore(selectProjectPath); + const [cliMode] = useState<'claude' | 'codex'>('claude'); + + // Tree view state + const [expandedPaths, setExpandedPaths] = useState>(new Set()); + const [selectedFile, setSelectedFile] = useState(null); + const [previewContent, setPreviewContent] = useState(null); + const [isPreviewLoading, setIsPreviewLoading] = useState(false); + const [showPreviewPanel, setShowPreviewPanel] = useState(false); // Prevent body scroll when panel is open useEffect(() => { @@ -55,6 +73,56 @@ export function SkillDetailPanel({ }; }, [isOpen]); + // Build file tree from supportingFiles + const fileTree = useMemo(() => { + if (!skill?.supportingFiles) return []; + return buildSkillFileTree(skill.supportingFiles); + }, [skill?.supportingFiles]); + + // Initialize expanded paths when skill changes + useEffect(() => { + if (fileTree.length > 0) { + setExpandedPaths(getDefaultExpandedPaths(fileTree)); + } + }, [fileTree]); + + // Load file content for preview + const loadFilePreview = useCallback(async (filePath: string) => { + if (!skill) return; + + setIsPreviewLoading(true); + setShowPreviewPanel(true); + setSelectedFile(filePath); + setPreviewContent(null); + + try { + const data = await readSkillFile({ + skillName: skill.folderName || skill.name, + fileName: filePath, + location: skill.location || 'project', + projectPath: projectPath, + cliType: cliMode, + }); + setPreviewContent(data.content); + } catch (error) { + console.error('Failed to load file preview:', error); + setPreviewContent(`Error loading preview: ${error instanceof Error ? error.message : String(error)}`); + } finally { + setIsPreviewLoading(false); + } + }, [skill, projectPath, cliMode]); + + const handleClosePreview = () => { + setShowPreviewPanel(false); + }; + + // Close preview on panel close + useEffect(() => { + if (!isOpen) { + handleClosePreview(); + } + }, [isOpen]); + if (!isOpen || !skill) { return null; } @@ -67,6 +135,19 @@ export function SkillDetailPanel({ onEditFile?.(folderName, fileName, skill.location || 'project'); }; + // Toggle directory expanded state + const togglePath = (path: string) => { + setExpandedPaths(prev => { + const next = new Set(prev); + if (next.has(path)) { + next.delete(path); + } else { + next.add(path); + } + return next; + }); + }; + return ( <> {/* Overlay */} @@ -206,58 +287,67 @@ export function SkillDetailPanel({ {formatMessage({ id: 'skills.files' })} -
+
{/* SKILL.md (main file) */}
SKILL.md
- {onEditFile && ( +
- )} + {onEditFile && ( + + )} +
- {/* Supporting Files */} - {hasSupportingFiles && skill.supportingFiles!.map((file) => { - const isDir = file.endsWith('/'); - const displayName = isDir ? file.slice(0, -1) : file; - return ( -
-
- {isDir ? ( - - ) : ( - - )} - {displayName} -
- {!isDir && onEditFile && ( - - )} -
- ); - })} + {/* Supporting Files Tree */} + {hasSupportingFiles && fileTree.length > 0 && ( +
+ +
+ )} + + {/* Empty state */} + {hasSupportingFiles && fileTree.length === 0 && ( +
+ {formatMessage({ id: 'skills.files.empty' })} +
+ )}
+ {/* File Preview Modal */} + + {/* Path */} {skill.path && (
@@ -309,4 +399,243 @@ export function SkillDetailPanel({ ); } +// ======================================== +// SkillFileTree Component +// ======================================== +// Recursive tree view for skill files + +interface SkillFileTreeNodeProps { + node: FileSystemNode; + depth: number; + expandedPaths: Set; + onTogglePath: (path: string) => void; + onEditFile: (fileName: string) => void; + onPreviewFile: (fileName: string) => void; +} + +function SkillFileTreeNode({ + node, + depth, + expandedPaths, + onTogglePath, + onEditFile, + onPreviewFile, +}: SkillFileTreeNodeProps) { + const isDirectory = node.type === 'directory'; + const isExpanded = expandedPaths.has(node.path); + const hasChildren = node.children && node.children.length > 0; + + const handleClick = (e: React.MouseEvent) => { + e.stopPropagation(); + if (isDirectory) { + onTogglePath(node.path); + } else { + // Preview file on click + onPreviewFile(node.path); + } + }; + + return ( +
+
+ {/* Chevron for directories */} + {isDirectory && ( + + {hasChildren && ( + isExpanded ? ( + + ) : ( + + ) + )} + + )} + + {/* Icon */} + {isDirectory ? ( + + ) : ( + + )} + + {/* Name */} + {node.name} + + {/* Preview button for files */} + {!isDirectory && ( +
+ + +
+ )} +
+ + {/* Children */} + {isDirectory && isExpanded && hasChildren && ( +
+ {node.children!.map((child) => ( + + ))} +
+ )} +
+ ); +} + +interface SkillFileTreeProps { + nodes: FileSystemNode[]; + expandedPaths: Set; + onTogglePath: (path: string) => void; + onEditFile: (fileName: string) => void; + onPreviewFile: (fileName: string) => void; + depth: number; +} + +function SkillFileTree({ + nodes, + expandedPaths, + onTogglePath, + onEditFile, + onPreviewFile, + depth, +}: SkillFileTreeProps) { + return ( +
+ {nodes.map((node) => ( + + ))} +
+ ); +} + +// ======================================== +// File Preview Modal Component +// ======================================== + +interface FilePreviewModalProps { + fileName: string | null; + content: string | null; + isLoading: boolean; + isOpen: boolean; + onClose: () => void; +} + +function FilePreviewModal({ fileName, content, isLoading, isOpen, onClose }: FilePreviewModalProps) { + const { formatMessage } = useIntl(); + + if (!isOpen) return null; + + return ( + <> + {/* Overlay */} +
+ + {/* Modal */} +
+
+ {/* Header */} +
+
+ + {fileName} +
+
+ + +
+
+ + {/* Content */} +
+ {isLoading ? ( +
+ +
+ ) : content ? ( + +
+                  {content}
+                
+
+ ) : ( +
+ {formatMessage({ id: 'skills.files.preview.empty' })} +
+ )} +
+ + {/* Footer */} +
+ +
+
+
+ + ); +} + export default SkillDetailPanel; diff --git a/ccw/frontend/src/lib/api.ts b/ccw/frontend/src/lib/api.ts index b475f564..270c1ace 100644 --- a/ccw/frontend/src/lib/api.ts +++ b/ccw/frontend/src/lib/api.ts @@ -5968,10 +5968,31 @@ export async function previewYamlConfig(): Promise<{ success: boolean; config: s // ========== CCW-LiteLLM Package Management ========== +export interface CcwLitellmEnvCheck { + python: string; + installed: boolean; + version?: string; + error?: string; +} + +export interface CcwLitellmStatus { + /** + * Whether ccw-litellm is installed in the CodexLens venv. + * This is the environment used for the LiteLLM embedding backend. + */ + installed: boolean; + version?: string; + error?: string; + checks?: { + codexLensVenv: CcwLitellmEnvCheck; + systemPython?: CcwLitellmEnvCheck; + }; +} + /** * Check ccw-litellm status */ -export async function checkCcwLitellmStatus(refresh = false): Promise<{ installed: boolean; version?: string; error?: string }> { +export async function checkCcwLitellmStatus(refresh = false): Promise { return fetchApi(`/api/litellm-api/ccw-litellm/status${refresh ? '?refresh=true' : ''}`); } diff --git a/ccw/frontend/src/locales/en/api-settings.json b/ccw/frontend/src/locales/en/api-settings.json index 92c6737e..b8aee0e2 100644 --- a/ccw/frontend/src/locales/en/api-settings.json +++ b/ccw/frontend/src/locales/en/api-settings.json @@ -322,6 +322,7 @@ "ccwLitellm": { "title": "CCW-LiteLLM Package", "description": "Manage ccw-litellm Python package installation", + "systemPythonMismatch": "Detected ccw-litellm installed in system Python, but not in the CodexLens venv (~/.codexlens/venv). Click Install to install it into the venv.", "status": { "installed": "Installed", "notInstalled": "Not Installed", diff --git a/ccw/frontend/src/locales/zh/api-settings.json b/ccw/frontend/src/locales/zh/api-settings.json index 8d526c29..54910428 100644 --- a/ccw/frontend/src/locales/zh/api-settings.json +++ b/ccw/frontend/src/locales/zh/api-settings.json @@ -322,6 +322,7 @@ "ccwLitellm": { "title": "CCW-LiteLLM 包", "description": "管理 ccw-litellm Python 包安装", + "systemPythonMismatch": "检测到 ccw-litellm 已安装在系统 Python 中,但未安装到 CodexLens 虚拟环境(~/.codexlens/venv)里,所以这里显示未安装。点击“安装”即可安装到虚拟环境中。", "status": { "installed": "已安装", "notInstalled": "未安装", diff --git a/ccw/frontend/src/utils/skill-files.ts b/ccw/frontend/src/utils/skill-files.ts new file mode 100644 index 00000000..8540c246 --- /dev/null +++ b/ccw/frontend/src/utils/skill-files.ts @@ -0,0 +1,160 @@ +// ======================================== +// Skill File Tree Utilities +// ======================================== +// Convert flat supportingFiles array to tree structure + +import type { FileSystemNode } from '@/types/file-explorer'; + +/** + * Build a file tree from a flat array of file paths + * + * @param supportingFiles - Array of file paths (e.g., ['components/', 'components/Button.tsx']) + * @returns Tree structure of files and directories + */ +export function buildSkillFileTree(supportingFiles: string[]): FileSystemNode[] { + // Map to store all nodes by their path + const nodeMap = new Map(); + + for (const entry of supportingFiles) { + const isDirectoryMarker = entry.endsWith('/'); + const cleanPath = isDirectoryMarker ? entry.slice(0, -1) : entry; + const parts = cleanPath.split('/'); + + if (!cleanPath || parts.length === 0) continue; + + // Build the path hierarchy for this entry + let currentPath = ''; + + // Process all parts except the last one (these are intermediate directories) + for (let i = 0; i < parts.length - 1; i++) { + const part = parts[i]; + const parentPath = currentPath; + currentPath = currentPath ? `${currentPath}/${part}` : part; + + // Create directory node if it doesn't exist + if (!nodeMap.has(currentPath)) { + nodeMap.set(currentPath, { + name: part, + path: currentPath, + type: 'directory', + children: [], + }); + } + + // Add to parent's children + if (parentPath) { + const parent = nodeMap.get(parentPath); + if (parent && parent.type === 'directory') { + const existingChild = parent.children!.find(c => c.path === currentPath); + if (!existingChild) { + parent.children!.push(nodeMap.get(currentPath)!); + } + } + } + } + + // Process the final part (file or empty directory marker) + const finalName = parts[parts.length - 1]; + const finalPath = cleanPath; + + // Create or update the final node + const existingNode = nodeMap.get(finalPath); + const finalNode: FileSystemNode = existingNode + ? { ...existingNode, name: finalName } // Keep children if already exists + : { + name: finalName, + path: finalPath, + type: isDirectoryMarker ? 'directory' : 'file', + children: isDirectoryMarker ? [] : undefined, + }; + + nodeMap.set(finalPath, finalNode); + + // Add final node to parent's children + const parentPath = parts.length > 1 ? parts.slice(0, -1).join('/') : ''; + if (parentPath) { + const parent = nodeMap.get(parentPath); + if (parent && parent.type === 'directory') { + const existingChild = parent.children!.find(c => c.path === finalPath); + if (!existingChild) { + parent.children!.push(finalNode); + } else { + // Update existing child if this is a file (not just a directory marker) + if (!isDirectoryMarker) { + existingChild.type = 'file'; + existingChild.children = undefined; + } + } + } + } + } + + // Collect root-level nodes (nodes with single-segment paths) + const result: FileSystemNode[] = []; + for (const [path, node] of nodeMap.entries()) { + if (!path.includes('/')) { + result.push(node); + } + } + + // Sort: directories first, then files, alphabetically + result.sort((a, b) => { + if (a.type === 'directory' && b.type === 'file') return -1; + if (a.type === 'file' && b.type === 'directory') return 1; + return a.name.localeCompare(b.name); + }); + + // Sort children recursively + const sortChildren = (node: FileSystemNode) => { + if (node.type === 'directory' && node.children) { + node.children.sort((a, b) => { + if (a.type === 'directory' && b.type === 'file') return -1; + if (a.type === 'file' && b.type === 'directory') return 1; + return a.name.localeCompare(b.name); + }); + node.children.forEach(sortChildren); + } + }; + + result.forEach(sortChildren); + + return result; +} + +/** + * Get default expanded paths for a file tree + * Expands all directories by default for skill details + * + * @param nodes - File tree nodes + * @returns Set of all directory paths + */ +export function getDefaultExpandedPaths(nodes: FileSystemNode[]): Set { + const expanded = new Set(); + + const collectDirectories = (node: FileSystemNode) => { + if (node.type === 'directory') { + expanded.add(node.path); + node.children?.forEach(collectDirectories); + } + }; + + nodes.forEach(collectDirectories); + + return expanded; +} + +/** + * Format file path for display + */ +export function formatFilePath(path: string): string { + const parts = path.split('/'); + return parts[parts.length - 1]; +} + +/** + * Get file extension + */ +export function getFileExtension(path: string): string { + const parts = path.split('.'); + return parts.length > 1 ? parts.pop()! : ''; +} diff --git a/ccw/src/core/routes/litellm-api-routes.ts b/ccw/src/core/routes/litellm-api-routes.ts index b8d84371..e88c6ac4 100644 --- a/ccw/src/core/routes/litellm-api-routes.ts +++ b/ccw/src/core/routes/litellm-api-routes.ts @@ -80,9 +80,89 @@ import { getContextCacheStore } from '../../tools/context-cache-store.js'; import { getLiteLLMClient } from '../../tools/litellm-client.js'; import { testApiKeyConnection, getDefaultApiBase } from '../services/api-key-tester.js'; +interface CcwLitellmEnvCheck { + python: string; + installed: boolean; + version?: string; + error?: string; +} + +interface CcwLitellmStatusResponse { + /** + * Whether ccw-litellm is installed in the CodexLens venv. + * This is the environment used for the LiteLLM embedding backend. + */ + installed: boolean; + version?: string; + error?: string; + checks?: { + codexLensVenv: CcwLitellmEnvCheck; + systemPython?: CcwLitellmEnvCheck; + }; +} + +function checkCcwLitellmImport( + pythonCmd: string, + options: { timeout: number; shell?: boolean } +): Promise { + const { timeout, shell = false } = options; + + const sanitizePythonError = (stderrText: string): string | undefined => { + const trimmed = stderrText.trim(); + if (!trimmed) return undefined; + const lines = trimmed + .split(/\r?\n/g) + .map((line) => line.trim()) + .filter(Boolean); + // Prefer the final exception line (avoids leaking full traceback + file paths) + return lines[lines.length - 1] || undefined; + }; + + return new Promise((resolve) => { + const child = spawn(pythonCmd, ['-c', 'import ccw_litellm; print(ccw_litellm.__version__)'], { + stdio: ['ignore', 'pipe', 'pipe'], + timeout, + windowsHide: true, + shell, + }); + + let stdout = ''; + let stderr = ''; + + child.stdout?.on('data', (data: Buffer) => { + stdout += data.toString(); + }); + + child.stderr?.on('data', (data: Buffer) => { + stderr += data.toString(); + }); + + child.on('close', (code: number | null) => { + const version = stdout.trim(); + const error = sanitizePythonError(stderr); + + if (code === 0 && version) { + resolve({ python: pythonCmd, installed: true, version }); + return; + } + + if (code === null) { + resolve({ python: pythonCmd, installed: false, error: `Timed out after ${timeout}ms` }); + return; + } + + resolve({ python: pythonCmd, installed: false, error: error || undefined }); + }); + + child.on('error', (err) => { + resolve({ python: pythonCmd, installed: false, error: err.message }); + }); + }); +} + // Cache for ccw-litellm status check let ccwLitellmStatusCache: { - data: { installed: boolean; version?: string; error?: string } | null; + data: CcwLitellmStatusResponse | null; timestamp: number; ttl: number; } = { @@ -849,51 +929,29 @@ export async function handleLiteLLMApiRoutes(ctx: RouteContext): Promise((resolve) => { - const child = spawn(venvPython, ['-c', 'import ccw_litellm; print(ccw_litellm.__version__)'], { - stdio: ['ignore', 'pipe', 'pipe'], - timeout: statusTimeout, - windowsHide: true, - }); - let stdout = ''; - child.stdout.on('data', (data: Buffer) => { stdout += data.toString(); }); - child.on('close', (code: number | null) => { - if (code === 0) { - const version = stdout.trim(); - if (version) { - console.log(`[ccw-litellm status] Found in CodexLens venv: ${version}`); - resolve({ installed: true, version }); - return; - } - } - console.log('[ccw-litellm status] Not found in CodexLens venv'); - resolve({ installed: false }); - }); - child.on('error', () => { - console.log('[ccw-litellm status] Spawn error checking venv'); - resolve({ installed: false }); - }); - }); - } catch (venvErr) { - console.log('[ccw-litellm status] Not found in CodexLens venv'); - result = { installed: false }; - } - } else { - console.log('[ccw-litellm status] CodexLens venv not valid'); - result = { installed: false }; - } + // Diagnostics only: if not installed in venv, also check system python so users understand mismatches. + // NOTE: `installed` flag remains the CodexLens venv status (we want isolated venv dependencies). + const systemPython = !codexLensVenv.installed + ? await checkCcwLitellmImport(getSystemPython(), { timeout: statusTimeout, shell: true }) + : undefined; + + const result: CcwLitellmStatusResponse = { + installed: codexLensVenv.installed, + version: codexLensVenv.version, + error: codexLensVenv.error, + checks: { + codexLensVenv, + ...(systemPython ? { systemPython } : {}), + }, + }; // Update cache ccwLitellmStatusCache = { diff --git a/ccw/src/core/routes/skills-routes.ts b/ccw/src/core/routes/skills-routes.ts index 9b2262bc..048b46b3 100644 --- a/ccw/src/core/routes/skills-routes.ts +++ b/ccw/src/core/routes/skills-routes.ts @@ -279,27 +279,37 @@ function parseSkillFrontmatter(content: string): ParsedSkillFrontmatter { } /** - * Get list of supporting files for a skill + * Get list of supporting files for a skill (recursive) * @param {string} skillDir - * @returns {string[]} + * @returns {string[]} Array of relative paths (directories end with '/') */ function getSupportingFiles(skillDir: string): string[] { const files: string[] = []; - try { - const entries = readdirSync(skillDir, { withFileTypes: true }); - for (const entry of entries) { - // Exclude SKILL.md and SKILL.md.disabled from supporting files - if (entry.name !== 'SKILL.md' && entry.name !== 'SKILL.md.disabled') { - if (entry.isFile()) { - files.push(entry.name); - } else if (entry.isDirectory()) { - files.push(entry.name + '/'); + + function readDirRecursive(dirPath: string, relativePath: string = '') { + try { + const entries = readdirSync(dirPath, { withFileTypes: true }); + for (const entry of entries) { + // Exclude SKILL.md and SKILL.md.disabled from supporting files + if (entry.name !== 'SKILL.md' && entry.name !== 'SKILL.md.disabled') { + const entryRelativePath = relativePath ? `${relativePath}/${entry.name}` : entry.name; + + if (entry.isFile()) { + files.push(entryRelativePath); + } else if (entry.isDirectory()) { + // Add directory marker + files.push(entryRelativePath + '/'); + // Recurse into subdirectory + readDirRecursive(join(dirPath, entry.name), entryRelativePath); + } } } + } catch (e) { + // Ignore errors } - } catch (e) { - // Ignore errors } + + readDirRecursive(skillDir); return files; }