mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-10 02:24:35 +08:00
- #70: Fix API Key Tester URL handling - normalize trailing slashes before version suffix detection to prevent double-slash URLs like //models - #69: Fix memory embedder ignoring CodexLens config - add error handling for CodexLensConfig.load() with fallback to defaults - #68: Fix ccw cli using wrong Python environment - add getCodexLensVenvPython() to resolve correct venv path on Windows/Unix - #67: Fix LiteLLM API Provider test endpoint - actually test API key connection instead of just checking ccw-litellm installation - #66: Fix help-routes.ts path configuration - use correct 'ccw-help' directory name and refactor getIndexDir to pure function - #63: Fix CodexLens install state refresh - add cache invalidation after config save in codexlens-manager.js Also includes targeted unit tests for the URL normalization logic. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,29 @@ import { join } from 'path';
|
||||
import { homedir } from 'os';
|
||||
import type { RouteContext } from './types.js';
|
||||
|
||||
/**
|
||||
* Get the ccw-help index directory path (pure function)
|
||||
* Priority: project path (.claude/skills/ccw-help/index) > user path (~/.claude/skills/ccw-help/index)
|
||||
* @param projectPath - The project path to check first
|
||||
*/
|
||||
function getIndexDir(projectPath: string | null): string | null {
|
||||
// Try project path first
|
||||
if (projectPath) {
|
||||
const projectIndexDir = join(projectPath, '.claude', 'skills', 'ccw-help', 'index');
|
||||
if (existsSync(projectIndexDir)) {
|
||||
return projectIndexDir;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to user path
|
||||
const userIndexDir = join(homedir(), '.claude', 'skills', 'ccw-help', 'index');
|
||||
if (existsSync(userIndexDir)) {
|
||||
return userIndexDir;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ========== In-Memory Cache ==========
|
||||
interface CacheEntry {
|
||||
data: any;
|
||||
@@ -61,14 +84,15 @@ let watchersInitialized = false;
|
||||
|
||||
/**
|
||||
* Initialize file watchers for JSON indexes
|
||||
* @param projectPath - The project path to resolve index directory
|
||||
*/
|
||||
function initializeFileWatchers(): void {
|
||||
function initializeFileWatchers(projectPath: string | null): void {
|
||||
if (watchersInitialized) return;
|
||||
|
||||
const indexDir = join(homedir(), '.claude', 'skills', 'command-guide', 'index');
|
||||
const indexDir = getIndexDir(projectPath);
|
||||
|
||||
if (!existsSync(indexDir)) {
|
||||
console.warn(`Command guide index directory not found: ${indexDir}`);
|
||||
if (!indexDir) {
|
||||
console.warn(`ccw-help index directory not found in project or user paths`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -152,15 +176,20 @@ function groupCommandsByCategory(commands: any[]): any {
|
||||
* @returns true if route was handled, false otherwise
|
||||
*/
|
||||
export async function handleHelpRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
const { pathname, url, req, res } = ctx;
|
||||
const { pathname, url, req, res, initialPath } = ctx;
|
||||
|
||||
// Initialize file watchers on first request
|
||||
initializeFileWatchers();
|
||||
initializeFileWatchers(initialPath);
|
||||
|
||||
const indexDir = join(homedir(), '.claude', 'skills', 'command-guide', 'index');
|
||||
const indexDir = getIndexDir(initialPath);
|
||||
|
||||
// API: Get all commands with optional search
|
||||
if (pathname === '/api/help/commands') {
|
||||
if (!indexDir) {
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'ccw-help index directory not found' }));
|
||||
return true;
|
||||
}
|
||||
const searchQuery = url.searchParams.get('q') || '';
|
||||
const filePath = join(indexDir, 'all-commands.json');
|
||||
|
||||
@@ -191,6 +220,11 @@ export async function handleHelpRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
|
||||
// API: Get workflow command relationships
|
||||
if (pathname === '/api/help/workflows') {
|
||||
if (!indexDir) {
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'ccw-help index directory not found' }));
|
||||
return true;
|
||||
}
|
||||
const filePath = join(indexDir, 'command-relationships.json');
|
||||
const relationships = getCachedData('command-relationships', filePath);
|
||||
|
||||
@@ -207,6 +241,11 @@ export async function handleHelpRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
|
||||
// API: Get commands by category
|
||||
if (pathname === '/api/help/commands/by-category') {
|
||||
if (!indexDir) {
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'ccw-help index directory not found' }));
|
||||
return true;
|
||||
}
|
||||
const filePath = join(indexDir, 'by-category.json');
|
||||
const byCategory = getCachedData('by-category', filePath);
|
||||
|
||||
|
||||
@@ -334,12 +334,43 @@ export async function handleLiteLLMApiRoutes(ctx: RouteContext): Promise<boolean
|
||||
return true;
|
||||
}
|
||||
|
||||
// Test connection using litellm client
|
||||
const client = getLiteLLMClient();
|
||||
const available = await client.isAvailable();
|
||||
// Get the API key to test (prefer first key from apiKeys array, fall back to default apiKey)
|
||||
let apiKeyValue: string | null = null;
|
||||
if (provider.apiKeys && provider.apiKeys.length > 0) {
|
||||
apiKeyValue = provider.apiKeys[0].key;
|
||||
} else if (provider.apiKey) {
|
||||
apiKeyValue = provider.apiKey;
|
||||
}
|
||||
|
||||
if (!apiKeyValue) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: false, error: 'No API key configured for this provider' }));
|
||||
return true;
|
||||
}
|
||||
|
||||
// Resolve environment variables in the API key
|
||||
const { resolveEnvVar } = await import('../../config/litellm-api-config-manager.js');
|
||||
const resolvedKey = resolveEnvVar(apiKeyValue);
|
||||
|
||||
if (!resolvedKey) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: false, error: 'API key is empty or environment variable not set' }));
|
||||
return true;
|
||||
}
|
||||
|
||||
// Determine API base URL
|
||||
const apiBase = provider.apiBase || getDefaultApiBase(provider.type);
|
||||
|
||||
// Test the API key connection
|
||||
const testResult = await testApiKeyConnection(provider.type, apiBase, resolvedKey);
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: available, provider: provider.type }));
|
||||
res.end(JSON.stringify({
|
||||
success: testResult.valid,
|
||||
provider: provider.type,
|
||||
latencyMs: testResult.latencyMs,
|
||||
error: testResult.error,
|
||||
}));
|
||||
} catch (err) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: false, error: (err as Error).message }));
|
||||
|
||||
@@ -72,6 +72,10 @@ export async function testApiKeyConnection(
|
||||
return { valid: false, error: urlValidation.error };
|
||||
}
|
||||
|
||||
// Normalize apiBase: remove trailing slashes to prevent URL construction issues
|
||||
// e.g., "https://api.openai.com/v1/" -> "https://api.openai.com/v1"
|
||||
const normalizedApiBase = apiBase.replace(/\/+$/, '');
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||
const startTime = Date.now();
|
||||
@@ -80,7 +84,7 @@ export async function testApiKeyConnection(
|
||||
if (providerType === 'anthropic') {
|
||||
// Anthropic format: Use /v1/models endpoint (no cost, no model dependency)
|
||||
// This validates the API key without making a billable request
|
||||
const response = await fetch(`${apiBase}/models`, {
|
||||
const response = await fetch(`${normalizedApiBase}/models`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'x-api-key': apiKey,
|
||||
@@ -114,8 +118,10 @@ export async function testApiKeyConnection(
|
||||
|
||||
return { valid: false, error: errorMessage };
|
||||
} else {
|
||||
// OpenAI-compatible format: GET /v1/models
|
||||
const modelsUrl = apiBase.endsWith('/v1') ? `${apiBase}/models` : `${apiBase}/v1/models`;
|
||||
// OpenAI-compatible format: GET /v{N}/models
|
||||
// Detect if URL already ends with a version pattern like /v1, /v2, /v4, etc.
|
||||
const hasVersionSuffix = /\/v\d+$/.test(normalizedApiBase);
|
||||
const modelsUrl = hasVersionSuffix ? `${normalizedApiBase}/models` : `${normalizedApiBase}/v1/models`;
|
||||
const response = await fetch(modelsUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
|
||||
@@ -1034,6 +1034,15 @@ async function startCodexLensInstall() {
|
||||
progressBar.style.width = '100%';
|
||||
statusText.textContent = 'Installation complete!';
|
||||
|
||||
// 清理缓存以确保刷新后获取最新状态
|
||||
if (window.cacheManager) {
|
||||
window.cacheManager.invalidate('all-status');
|
||||
window.cacheManager.invalidate('dashboard-init');
|
||||
}
|
||||
if (typeof window.invalidateCodexLensCache === 'function') {
|
||||
window.invalidateCodexLensCache();
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
closeCodexLensInstallWizard();
|
||||
showRefreshToast('CodexLens installed successfully!', 'success');
|
||||
@@ -1184,6 +1193,15 @@ async function startCodexLensUninstall() {
|
||||
progressBar.style.width = '100%';
|
||||
statusText.textContent = 'Uninstallation complete!';
|
||||
|
||||
// 清理缓存以确保刷新后获取最新状态
|
||||
if (window.cacheManager) {
|
||||
window.cacheManager.invalidate('all-status');
|
||||
window.cacheManager.invalidate('dashboard-init');
|
||||
}
|
||||
if (typeof window.invalidateCodexLensCache === 'function') {
|
||||
window.invalidateCodexLensCache();
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
closeCodexLensUninstallWizard();
|
||||
showRefreshToast('CodexLens uninstalled successfully!', 'success');
|
||||
|
||||
@@ -415,10 +415,15 @@ function handleNotification(data) {
|
||||
'CodexLens'
|
||||
);
|
||||
}
|
||||
// Invalidate CodexLens page cache to ensure fresh data on next visit
|
||||
// Invalidate all CodexLens related caches to ensure fresh data on refresh
|
||||
// Must clear both codexlens-specific cache AND global status cache
|
||||
if (window.cacheManager) {
|
||||
window.cacheManager.invalidate('all-status');
|
||||
window.cacheManager.invalidate('dashboard-init');
|
||||
}
|
||||
if (typeof window.invalidateCodexLensCache === 'function') {
|
||||
window.invalidateCodexLensCache();
|
||||
console.log('[CodexLens] Cache invalidated after installation');
|
||||
console.log('[CodexLens] All caches invalidated after installation');
|
||||
}
|
||||
// Refresh CLI status if active
|
||||
if (typeof loadCodexLensStatus === 'function') {
|
||||
@@ -443,10 +448,15 @@ function handleNotification(data) {
|
||||
'CodexLens'
|
||||
);
|
||||
}
|
||||
// Invalidate CodexLens page cache to ensure fresh data on next visit
|
||||
// Invalidate all CodexLens related caches to ensure fresh data on refresh
|
||||
// Must clear both codexlens-specific cache AND global status cache
|
||||
if (window.cacheManager) {
|
||||
window.cacheManager.invalidate('all-status');
|
||||
window.cacheManager.invalidate('dashboard-init');
|
||||
}
|
||||
if (typeof window.invalidateCodexLensCache === 'function') {
|
||||
window.invalidateCodexLensCache();
|
||||
console.log('[CodexLens] Cache invalidated after uninstallation');
|
||||
console.log('[CodexLens] All caches invalidated after uninstallation');
|
||||
}
|
||||
// Refresh CLI status if active
|
||||
if (typeof loadCodexLensStatus === 'function') {
|
||||
|
||||
@@ -72,6 +72,10 @@ function invalidateCache(key) {
|
||||
Object.values(CACHE_KEY_MAP).forEach(function(k) {
|
||||
window.cacheManager.invalidate(k);
|
||||
});
|
||||
// 重要:同时清理包含 CodexLens 状态的全局缓存
|
||||
// 这些缓存在 cli-status.js 中使用,包含 codexLens.ready 状态
|
||||
window.cacheManager.invalidate('all-status');
|
||||
window.cacheManager.invalidate('dashboard-init');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -788,6 +792,12 @@ function initCodexLensConfigEvents(currentConfig) {
|
||||
|
||||
if (result.success) {
|
||||
showRefreshToast(t('codexlens.configSaved'), 'success');
|
||||
|
||||
// Invalidate config cache to ensure fresh data on next load
|
||||
if (window.cacheManager) {
|
||||
window.cacheManager.invalidate('codexlens-config');
|
||||
}
|
||||
|
||||
closeModal();
|
||||
|
||||
// Refresh CodexLens status
|
||||
@@ -5385,7 +5395,7 @@ function initCodexLensManagerPageEvents(currentConfig) {
|
||||
try {
|
||||
var response = await csrfFetch('/api/codexlens/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ index_dir: newIndexDir }) });
|
||||
var result = await response.json();
|
||||
if (result.success) { showRefreshToast(t('codexlens.configSaved'), 'success'); renderCodexLensManager(); }
|
||||
if (result.success) { if (window.cacheManager) { window.cacheManager.invalidate('codexlens-config'); } showRefreshToast(t('codexlens.configSaved'), 'success'); renderCodexLensManager(); }
|
||||
else { showRefreshToast(t('common.saveFailed') + ': ' + result.error, 'error'); }
|
||||
} catch (err) { showRefreshToast(t('common.error') + ': ' + err.message, 'error'); }
|
||||
saveBtn.disabled = false;
|
||||
|
||||
@@ -10,14 +10,36 @@
|
||||
*/
|
||||
|
||||
import { spawn } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { homedir } from 'os';
|
||||
|
||||
export interface LiteLLMConfig {
|
||||
pythonPath?: string; // Default 'python'
|
||||
pythonPath?: string; // Default: CodexLens venv Python
|
||||
configPath?: string; // Configuration file path
|
||||
timeout?: number; // Default 60000ms
|
||||
}
|
||||
|
||||
// Platform-specific constants for CodexLens venv
|
||||
const IS_WINDOWS = process.platform === 'win32';
|
||||
const CODEXLENS_VENV = join(homedir(), '.codexlens', 'venv');
|
||||
const VENV_BIN_DIR = IS_WINDOWS ? 'Scripts' : 'bin';
|
||||
const PYTHON_EXECUTABLE = IS_WINDOWS ? 'python.exe' : 'python';
|
||||
|
||||
/**
|
||||
* Get the Python path from CodexLens venv
|
||||
* Falls back to system 'python' if venv doesn't exist
|
||||
* @returns Path to Python executable
|
||||
*/
|
||||
export function getCodexLensVenvPython(): string {
|
||||
const venvPython = join(CODEXLENS_VENV, VENV_BIN_DIR, PYTHON_EXECUTABLE);
|
||||
if (existsSync(venvPython)) {
|
||||
return venvPython;
|
||||
}
|
||||
// Fallback to system Python if venv not available
|
||||
return 'python';
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
role: 'system' | 'user' | 'assistant';
|
||||
content: string;
|
||||
@@ -51,7 +73,7 @@ export class LiteLLMClient {
|
||||
private timeout: number;
|
||||
|
||||
constructor(config: LiteLLMConfig = {}) {
|
||||
this.pythonPath = config.pythonPath || 'python';
|
||||
this.pythonPath = config.pythonPath || getCodexLensVenvPython();
|
||||
this.configPath = config.configPath;
|
||||
this.timeout = config.timeout || 60000;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Integrates with context-cache for file packing and LiteLLM client for API calls
|
||||
*/
|
||||
|
||||
import { getLiteLLMClient } from './litellm-client.js';
|
||||
import { getLiteLLMClient, getCodexLensVenvPython } from './litellm-client.js';
|
||||
import { handler as contextCacheHandler } from './context-cache.js';
|
||||
import {
|
||||
findEndpointById,
|
||||
@@ -179,7 +179,7 @@ export async function executeLiteLLMEndpoint(
|
||||
}
|
||||
|
||||
const client = getLiteLLMClient({
|
||||
pythonPath: 'python',
|
||||
pythonPath: getCodexLensVenvPython(),
|
||||
timeout: 120000, // 2 minutes
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user