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

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