mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-14 10:18:14 +00:00
feat(hooks): add UX improvements for image analysis hook
Add comprehensive UX enhancements for the image analysis CLIProxy hook: - Add `ccs config image-analysis` CLI command for managing settings - Enable/disable toggle - Timeout configuration (10-600s) - Per-provider model configuration - Status display with provider models - Add specialized error handlers with actionable messages - File too large (with compression hints) - CLIProxy unavailable (with start instructions) - Auth failure (with re-auth commands) - Timeout (with increase timeout hint) - Rate limit (with retry guidance) - API error (with response body parsing) - Add comprehensive debug output (CCS_DEBUG=1) - Provider name and model - File size and media type - Timeout and endpoint - Skip reasons with context - Add help text updates - `ccs --help` includes Image Analysis section - `ccs config --help` lists image-analysis subcommand - Add doctor integration - Validates image_analysis config - Checks enabled status, providers, timeout - Warns if CLIProxy not running - Add unit tests for new config command
This commit is contained in:
1 parent
0e7b9c9190
commit
2b0717ed53
10 files changed
+953
-48
No files matched your search
@@ -49,6 +49,21 @@ const CLIPROXY_PATH = '/v1/messages';
|
||||
// API key passed via env from cliproxy-executor, defaults to CCS internal key
|
||||
const CLIPROXY_API_KEY = process.env.CCS_CLIPROXY_API_KEY || 'ccs-internal-managed';
|
||||
|
||||
// ============================================================================
|
||||
// ERROR CODES (for categorization)
|
||||
// ============================================================================
|
||||
|
||||
const ERROR_CODES = {
|
||||
FILE_TOO_LARGE: 'FILE_TOO_LARGE',
|
||||
CLIPROXY_UNAVAILABLE: 'CLIPROXY_UNAVAILABLE',
|
||||
AUTH_FAILED: 'AUTH_FAILED',
|
||||
TIMEOUT: 'TIMEOUT',
|
||||
RATE_LIMIT: 'RATE_LIMIT',
|
||||
API_ERROR: 'API_ERROR',
|
||||
PARSE_ERROR: 'PARSE_ERROR',
|
||||
UNKNOWN: 'UNKNOWN',
|
||||
};
|
||||
|
||||
// Default analysis prompt
|
||||
const DEFAULT_PROMPT = `Analyze this image/document thoroughly and provide a detailed description.
|
||||
|
||||
@@ -66,6 +81,55 @@ Be comprehensive - this description replaces direct visual access.`;
|
||||
// HELPER FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Output debug information to stderr
|
||||
* Only outputs when CCS_DEBUG=1
|
||||
*/
|
||||
function debugLog(message, data = {}) {
|
||||
if (!process.env.CCS_DEBUG) return;
|
||||
|
||||
const lines = [`[CCS Hook] ${message}`];
|
||||
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
if (value !== undefined && value !== null) {
|
||||
lines.push(` ${key}: ${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.error(lines.join('\n'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed debug context
|
||||
*/
|
||||
function getDebugContext(filePath, stats) {
|
||||
const currentProvider = process.env.CCS_CURRENT_PROVIDER || 'unknown';
|
||||
const providerModels = parseProviderModels(process.env.CCS_IMAGE_ANALYSIS_PROVIDER_MODELS);
|
||||
const model = providerModels[currentProvider] || DEFAULT_MODEL;
|
||||
const timeout = parseInt(process.env.CCS_IMAGE_ANALYSIS_TIMEOUT || DEFAULT_TIMEOUT_SEC, 10);
|
||||
const isDefaultModel = !providerModels[currentProvider];
|
||||
|
||||
return {
|
||||
file: path.basename(filePath),
|
||||
size: stats ? `${(stats.size / 1024).toFixed(1)} KB` : 'unknown',
|
||||
provider: currentProvider,
|
||||
model: model,
|
||||
config: isDefaultModel ? 'default' : 'user-configured',
|
||||
timeout: `${timeout}s`,
|
||||
endpoint: `http://${CLIPROXY_HOST}:${CLIPROXY_PORT}${CLIPROXY_PATH}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current provider/model context for error messages
|
||||
*/
|
||||
function getProviderContext() {
|
||||
const provider = process.env.CCS_CURRENT_PROVIDER || 'unknown';
|
||||
const providerModels = parseProviderModels(process.env.CCS_IMAGE_ANALYSIS_PROVIDER_MODELS);
|
||||
const model = providerModels[provider] || DEFAULT_MODEL;
|
||||
return { provider, model };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse provider_models env var to object
|
||||
* Format: provider:model,provider:model
|
||||
@@ -205,8 +269,20 @@ function analyzeViaCliProxy(base64Data, mediaType, model, timeoutMs) {
|
||||
});
|
||||
|
||||
res.on('end', () => {
|
||||
// Categorize by status code
|
||||
if (res.statusCode === 401 || res.statusCode === 403) {
|
||||
reject(new Error(`AUTH_ERROR:${res.statusCode}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.statusCode === 429) {
|
||||
const retryAfter = res.headers['retry-after'];
|
||||
reject(new Error(`RATE_LIMIT:${retryAfter || ''}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.statusCode !== 200) {
|
||||
reject(new Error(`CLIProxy returned status ${res.statusCode}: ${data}`));
|
||||
reject(new Error(`API_ERROR:${res.statusCode}:${data}`));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -235,7 +311,7 @@ function analyzeViaCliProxy(base64Data, mediaType, model, timeoutMs) {
|
||||
req.on('error', (err) => reject(err));
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
reject(new Error('Request timed out'));
|
||||
reject(new Error('TIMEOUT'));
|
||||
});
|
||||
|
||||
req.write(requestBody);
|
||||
@@ -263,10 +339,213 @@ function formatDescription(filePath, description, model, fileSize) {
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SPECIALIZED ERROR HANDLERS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Format error output for Claude hook
|
||||
*/
|
||||
function formatErrorOutput(filePath, errorCode, message, troubleshooting) {
|
||||
const { provider, model } = getProviderContext();
|
||||
|
||||
const lines = [
|
||||
`[Image Analysis - Error]`,
|
||||
'',
|
||||
`File: ${path.basename(filePath)}`,
|
||||
`Provider: ${provider} | Model: ${model}`,
|
||||
'',
|
||||
`Error: ${message}`,
|
||||
];
|
||||
|
||||
if (troubleshooting && troubleshooting.length > 0) {
|
||||
lines.push('');
|
||||
lines.push('Troubleshooting:');
|
||||
troubleshooting.forEach((step, i) => {
|
||||
lines.push(` ${i + 1}. ${step}`);
|
||||
});
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push('For help: ccs config image-analysis --help');
|
||||
|
||||
return {
|
||||
decision: 'block',
|
||||
reason: `Image analysis failed: ${errorCode}`,
|
||||
systemMessage: `[Image Analysis] Failed: ${message}`,
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'PreToolUse',
|
||||
permissionDecision: 'deny',
|
||||
permissionDecisionReason: lines.join('\n'),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* File too large error
|
||||
*/
|
||||
function outputFileTooLargeError(filePath, actualSizeMB, maxSizeMB) {
|
||||
const output = formatErrorOutput(
|
||||
filePath,
|
||||
ERROR_CODES.FILE_TOO_LARGE,
|
||||
`File too large (${actualSizeMB.toFixed(2)}MB > ${maxSizeMB}MB limit)`,
|
||||
[
|
||||
'Reduce image resolution or use compression',
|
||||
'For screenshots: use PNG optimizer (pngquant, optipng)',
|
||||
'For photos: resize to max 2048px width',
|
||||
`Current limit: ${maxSizeMB}MB per file`,
|
||||
]
|
||||
);
|
||||
console.log(JSON.stringify(output));
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
/**
|
||||
* CLIProxy unavailable error
|
||||
*/
|
||||
function outputCliProxyUnavailableError(filePath, endpoint) {
|
||||
const output = formatErrorOutput(
|
||||
filePath,
|
||||
ERROR_CODES.CLIPROXY_UNAVAILABLE,
|
||||
`CLIProxy not available at ${endpoint}`,
|
||||
[
|
||||
'CLIProxy service may not be running',
|
||||
'Start with: ccs config (opens dashboard, starts CLIProxy)',
|
||||
'Or manually: ccs cliproxy start',
|
||||
`Verify: curl ${endpoint}`,
|
||||
'Check status: ccs doctor',
|
||||
]
|
||||
);
|
||||
console.log(JSON.stringify(output));
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication error
|
||||
*/
|
||||
function outputAuthError(filePath, statusCode) {
|
||||
const { provider } = getProviderContext();
|
||||
const output = formatErrorOutput(
|
||||
filePath,
|
||||
ERROR_CODES.AUTH_FAILED,
|
||||
`Authentication failed (HTTP ${statusCode})`,
|
||||
[
|
||||
`Re-authenticate: ccs ${provider} --auth`,
|
||||
`Check accounts: ccs ${provider} --accounts`,
|
||||
'Verify OAuth token is valid',
|
||||
'Check: ccs doctor',
|
||||
]
|
||||
);
|
||||
console.log(JSON.stringify(output));
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Timeout error
|
||||
*/
|
||||
function outputTimeoutError(filePath, timeoutSec) {
|
||||
const { model } = getProviderContext();
|
||||
const output = formatErrorOutput(
|
||||
filePath,
|
||||
ERROR_CODES.TIMEOUT,
|
||||
`Request timed out after ${timeoutSec}s`,
|
||||
[
|
||||
'Large files or complex images take longer',
|
||||
`Increase timeout: ccs config image-analysis --timeout ${timeoutSec * 2}`,
|
||||
'Or via env: CCS_IMAGE_ANALYSIS_TIMEOUT=120',
|
||||
`Current model (${model}) may be slow - try a faster variant`,
|
||||
'Check CLIProxy health: curl http://127.0.0.1:8317',
|
||||
]
|
||||
);
|
||||
console.log(JSON.stringify(output));
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate limit error
|
||||
*/
|
||||
function outputRateLimitError(filePath, retryAfterSec) {
|
||||
const { provider } = getProviderContext();
|
||||
const retryHint = retryAfterSec ? `Retry after ${retryAfterSec}s` : 'Wait a moment and retry';
|
||||
const output = formatErrorOutput(
|
||||
filePath,
|
||||
ERROR_CODES.RATE_LIMIT,
|
||||
'Rate limit exceeded',
|
||||
[
|
||||
retryHint,
|
||||
`Provider ${provider} has usage limits`,
|
||||
'Consider switching accounts: ccs ' + provider + ' --accounts',
|
||||
'Check quota: ccs cliproxy doctor',
|
||||
]
|
||||
);
|
||||
console.log(JSON.stringify(output));
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic API error
|
||||
*/
|
||||
function outputApiError(filePath, statusCode, responseBody) {
|
||||
// Try to extract error message from response
|
||||
let errorDetail = `HTTP ${statusCode}`;
|
||||
try {
|
||||
const parsed = JSON.parse(responseBody);
|
||||
if (parsed.error?.message) {
|
||||
errorDetail = parsed.error.message;
|
||||
} else if (parsed.message) {
|
||||
errorDetail = parsed.message;
|
||||
}
|
||||
} catch {
|
||||
// Use raw body if not JSON (truncated)
|
||||
if (responseBody && responseBody.length < 100) {
|
||||
errorDetail = responseBody;
|
||||
}
|
||||
}
|
||||
|
||||
const output = formatErrorOutput(
|
||||
filePath,
|
||||
ERROR_CODES.API_ERROR,
|
||||
`API error: ${errorDetail}`,
|
||||
[
|
||||
'Check CLIProxy logs: ccs cleanup --show-logs',
|
||||
'Verify provider is authenticated: ccs doctor',
|
||||
'Try a different provider or model',
|
||||
'Report persistent issues: https://github.com/kaitranntt/ccs/issues',
|
||||
]
|
||||
);
|
||||
console.log(JSON.stringify(output));
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unknown/fallback error (replaces old outputError)
|
||||
*/
|
||||
function outputUnknownError(filePath, error) {
|
||||
const output = formatErrorOutput(
|
||||
filePath,
|
||||
ERROR_CODES.UNKNOWN,
|
||||
error || 'Unknown error occurred',
|
||||
[
|
||||
'Check CLIProxy is running: curl http://127.0.0.1:8317',
|
||||
'Verify authentication: ccs doctor',
|
||||
'Check file is valid image/PDF',
|
||||
'Enable debug: CCS_DEBUG=1 ccs <provider>',
|
||||
]
|
||||
);
|
||||
console.log(JSON.stringify(output));
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Output success response and exit
|
||||
*/
|
||||
function outputSuccess(filePath, description, model, fileSize) {
|
||||
debugLog('Returning analysis result', {
|
||||
file: path.basename(filePath),
|
||||
model: model,
|
||||
descriptionLength: `${description.length} chars`,
|
||||
});
|
||||
|
||||
const formattedDescription = formatDescription(filePath, description, model, fileSize);
|
||||
|
||||
const output = {
|
||||
@@ -285,55 +564,38 @@ function outputSuccess(filePath, description, model, fileSize) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Output error message
|
||||
*/
|
||||
function outputError(filePath, error) {
|
||||
const message = [
|
||||
`[Image Analysis - Error]`,
|
||||
'',
|
||||
`Failed to analyze: ${path.basename(filePath)}`,
|
||||
'',
|
||||
`Error: ${error}`,
|
||||
'',
|
||||
'Troubleshooting:',
|
||||
' - Check CLIProxy is running: http://127.0.0.1:8317',
|
||||
' - Verify you are authenticated with agy or gemini',
|
||||
' - Check file size is under 10MB',
|
||||
].join('\n');
|
||||
|
||||
const output = {
|
||||
decision: 'block',
|
||||
reason: `Image analysis failed: ${error}`,
|
||||
systemMessage: `[Image Analysis] Failed to analyze ${path.basename(filePath)}`,
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'PreToolUse',
|
||||
permissionDecision: 'deny',
|
||||
permissionDecisionReason: message,
|
||||
},
|
||||
};
|
||||
|
||||
console.log(JSON.stringify(output));
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if hook should skip
|
||||
* Determine if hook should skip, with debug logging
|
||||
*/
|
||||
function shouldSkipHook() {
|
||||
// Explicit skip signal
|
||||
if (process.env.CCS_IMAGE_ANALYSIS_SKIP === '1') return true;
|
||||
if (process.env.CCS_IMAGE_ANALYSIS_SKIP === '1') {
|
||||
debugLog('Skipping: CCS_IMAGE_ANALYSIS_SKIP=1');
|
||||
return true;
|
||||
}
|
||||
|
||||
// Explicit disable
|
||||
if (process.env.CCS_IMAGE_ANALYSIS_ENABLED === '0') return true;
|
||||
if (process.env.CCS_IMAGE_ANALYSIS_ENABLED === '0') {
|
||||
debugLog('Skipping: image analysis disabled (CCS_IMAGE_ANALYSIS_ENABLED=0)');
|
||||
return true;
|
||||
}
|
||||
|
||||
// Account/default profiles - use native Read
|
||||
const profileType = process.env.CCS_PROFILE_TYPE;
|
||||
if (profileType === 'account' || profileType === 'default') return true;
|
||||
if (profileType === 'account' || profileType === 'default') {
|
||||
debugLog(`Skipping: profile type "${profileType}" uses native Read`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if current provider has a vision model configured
|
||||
const currentProvider = process.env.CCS_CURRENT_PROVIDER || '';
|
||||
const providerModels = parseProviderModels(process.env.CCS_IMAGE_ANALYSIS_PROVIDER_MODELS);
|
||||
if (!providerModels[currentProvider]) return true;
|
||||
|
||||
if (!providerModels[currentProvider]) {
|
||||
debugLog(`Skipping: provider "${currentProvider}" not in provider_models`, {
|
||||
configured_providers: Object.keys(providerModels).join(', ') || 'none',
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -394,16 +656,17 @@ async function processHook() {
|
||||
// Check file size
|
||||
const stats = fs.statSync(filePath);
|
||||
if (stats.size >= MAX_FILE_SIZE_BYTES) {
|
||||
outputError(filePath, `File too large (${(stats.size / 1024 / 1024).toFixed(2)}MB >= ${MAX_FILE_SIZE_MB}MB)`);
|
||||
outputFileTooLargeError(filePath, stats.size / 1024 / 1024, MAX_FILE_SIZE_MB);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check CLIProxy availability
|
||||
const cliProxyAvailable = await isCliProxyAvailable();
|
||||
if (!cliProxyAvailable) {
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error('[CCS Hook] CLIProxy not available, passing through');
|
||||
}
|
||||
debugLog('Skipping: CLIProxy not available', {
|
||||
endpoint: `http://${CLIPROXY_HOST}:${CLIPROXY_PORT}`,
|
||||
action: 'passing through to native Read',
|
||||
});
|
||||
// Pass through to native Read
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -412,17 +675,26 @@ async function processHook() {
|
||||
const timeout = parseInt(process.env.CCS_IMAGE_ANALYSIS_TIMEOUT || DEFAULT_TIMEOUT_SEC, 10);
|
||||
const timeoutMs = Math.max(1, Math.min(600, timeout)) * 1000;
|
||||
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(`[CCS Hook] Analyzing ${path.basename(filePath)} via CLIProxy (${model})`);
|
||||
}
|
||||
// Get debug context before analysis
|
||||
const debugContext = getDebugContext(filePath, stats);
|
||||
debugLog('Starting image analysis', debugContext);
|
||||
|
||||
// Encode file to base64
|
||||
const base64Data = encodeFileToBase64(filePath);
|
||||
const mediaType = getMediaType(filePath);
|
||||
|
||||
debugLog('File encoded', {
|
||||
mediaType: mediaType,
|
||||
base64Length: `${(base64Data.length / 1024).toFixed(1)} KB`,
|
||||
});
|
||||
|
||||
// Analyze via CLIProxy
|
||||
const description = await analyzeViaCliProxy(base64Data, mediaType, model, timeoutMs);
|
||||
|
||||
debugLog('Analysis complete', {
|
||||
responseLength: `${description.length} chars`,
|
||||
});
|
||||
|
||||
// Output success
|
||||
outputSuccess(filePath, description, model, stats.size);
|
||||
} catch (err) {
|
||||
@@ -439,7 +711,27 @@ async function processHook() {
|
||||
// Ignore parse errors
|
||||
}
|
||||
|
||||
// Output error
|
||||
outputError(filePath, err.message || 'Unknown error');
|
||||
// Categorize error by message pattern
|
||||
const errMsg = err.message || '';
|
||||
|
||||
if (errMsg.startsWith('AUTH_ERROR:')) {
|
||||
const statusCode = parseInt(errMsg.split(':')[1], 10);
|
||||
outputAuthError(filePath, statusCode);
|
||||
} else if (errMsg.startsWith('RATE_LIMIT:')) {
|
||||
const retryAfter = errMsg.split(':')[1];
|
||||
outputRateLimitError(filePath, retryAfter ? parseInt(retryAfter, 10) : null);
|
||||
} else if (errMsg.startsWith('API_ERROR:')) {
|
||||
const parts = errMsg.split(':');
|
||||
const statusCode = parseInt(parts[1], 10);
|
||||
const body = parts.slice(2).join(':');
|
||||
outputApiError(filePath, statusCode, body);
|
||||
} else if (errMsg === 'TIMEOUT' || errMsg.includes('timed out') || errMsg.includes('timeout')) {
|
||||
const timeout = parseInt(process.env.CCS_IMAGE_ANALYSIS_TIMEOUT || DEFAULT_TIMEOUT_SEC, 10);
|
||||
outputTimeoutError(filePath, timeout);
|
||||
} else if (errMsg.includes('ECONNREFUSED') || errMsg.includes('ENOTFOUND')) {
|
||||
outputCliProxyUnavailableError(filePath, `http://${CLIPROXY_HOST}:${CLIPROXY_PORT}`);
|
||||
} else {
|
||||
outputUnknownError(filePath, errMsg);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user