mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 16:14:31 +00:00
feat(image-analysis): add provider-backed runtime
This commit is contained in:
1 parent
2c006afbb9
commit
813b2dd4d0
24 files changed
+1715
-487
No files matched your search
@@ -0,0 +1,458 @@
|
||||
const fs = require('fs');
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
const path = require('path');
|
||||
|
||||
const IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.heic', '.bmp', '.tiff'];
|
||||
const PDF_EXTENSIONS = ['.pdf'];
|
||||
const DEFAULT_MODEL = 'gemini-2.5-flash';
|
||||
const DEFAULT_TIMEOUT_SEC = 60;
|
||||
const MAX_FILE_SIZE_MB = 10;
|
||||
const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024;
|
||||
const SCREENSHOT_NAME_REGEX =
|
||||
/(screen[-_ ]?shot|screen[-_ ]?capture|screencap|snapshot|snip|clip|capture)/i;
|
||||
const TEMPLATE_FILE_NAMES = {
|
||||
default: 'default.txt',
|
||||
screenshot: 'screenshot.txt',
|
||||
document: 'document.txt',
|
||||
};
|
||||
const FALLBACK_PROMPTS = {
|
||||
default: `Analyze this image/document thoroughly and provide a detailed description.
|
||||
|
||||
Include:
|
||||
1. Overall content and purpose
|
||||
2. Text content (if any) - transcribe important text verbatim
|
||||
3. Visual elements (diagrams, charts, UI components, icons)
|
||||
4. Layout and structure (sections, hierarchy, flow)
|
||||
5. Colors, styling, notable design elements
|
||||
6. Any actionable information (buttons, links, code snippets)
|
||||
|
||||
Be comprehensive - this description replaces direct visual access.
|
||||
The AI assistant reading this cannot see the original image.`,
|
||||
screenshot: `Analyze this screenshot in detail for a developer who cannot see it.
|
||||
|
||||
Focus on:
|
||||
1. Application/website type and state
|
||||
2. UI elements visible (buttons, inputs, menus, modals)
|
||||
3. All text content - transcribe exactly
|
||||
4. Error messages or notifications (quote exactly)
|
||||
5. Layout and component hierarchy
|
||||
6. Interactive elements and their states
|
||||
7. Console output or logs if visible
|
||||
8. Any code snippets shown
|
||||
|
||||
Be precise - this enables the assistant to help debug or understand the UI.`,
|
||||
document: `Analyze this document/PDF thoroughly for a developer.
|
||||
|
||||
Extract and provide:
|
||||
1. Document title, type, and structure
|
||||
2. All text content - transcribe in reading order
|
||||
3. Tables - format as markdown tables
|
||||
4. Lists and bullet points - preserve structure
|
||||
5. Code blocks or technical content
|
||||
6. Diagrams or flowcharts - describe in detail
|
||||
7. Headers and section organization
|
||||
8. Any important metadata visible
|
||||
|
||||
Accuracy in text extraction is critical.`,
|
||||
};
|
||||
|
||||
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 && value !== '') {
|
||||
lines.push(` ${key}: ${value}`);
|
||||
}
|
||||
}
|
||||
console.error(lines.join('\n'));
|
||||
}
|
||||
|
||||
function parseProviderModels(envValue) {
|
||||
if (!envValue) return {};
|
||||
return envValue.split(',').reduce((acc, pair) => {
|
||||
const [provider, ...modelParts] = pair.split(':');
|
||||
const model = modelParts.join(':').trim();
|
||||
if (provider && model) {
|
||||
acc[provider.trim()] = model;
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function normalizeTemplateName(value) {
|
||||
if (typeof value !== 'string') return null;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return Object.prototype.hasOwnProperty.call(TEMPLATE_FILE_NAMES, normalized) ? normalized : null;
|
||||
}
|
||||
|
||||
function selectPromptTemplate(filePath, requestedTemplate) {
|
||||
const explicitTemplate = normalizeTemplateName(requestedTemplate);
|
||||
if (explicitTemplate) {
|
||||
return explicitTemplate;
|
||||
}
|
||||
|
||||
const extension = path.extname(filePath).toLowerCase();
|
||||
if (PDF_EXTENSIONS.includes(extension)) {
|
||||
return 'document';
|
||||
}
|
||||
|
||||
return SCREENSHOT_NAME_REGEX.test(path.basename(filePath)) ? 'screenshot' : 'default';
|
||||
}
|
||||
|
||||
function readPromptFile(filePath) {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf8').trim();
|
||||
return content.length > 0 ? content : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function loadPromptTemplate(filePath, requestedTemplate, focus) {
|
||||
const template = selectPromptTemplate(filePath, requestedTemplate);
|
||||
const promptsDir = process.env.CCS_IMAGE_ANALYSIS_PROMPTS_DIR || '';
|
||||
const promptPath = promptsDir
|
||||
? path.join(promptsDir, TEMPLATE_FILE_NAMES[template])
|
||||
: null;
|
||||
const promptText = (promptPath && readPromptFile(promptPath)) || FALLBACK_PROMPTS[template];
|
||||
|
||||
if (!focus || !focus.trim()) {
|
||||
return {
|
||||
template,
|
||||
promptSource: promptPath ? 'installed-or-fallback' : 'bundled-fallback',
|
||||
prompt: promptText,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
template,
|
||||
promptSource: promptPath ? 'installed-or-fallback' : 'bundled-fallback',
|
||||
prompt: `${promptText}\n\nSpecific focus:\n${focus.trim()}`,
|
||||
};
|
||||
}
|
||||
|
||||
function getCurrentProvider() {
|
||||
return process.env.CCS_CURRENT_PROVIDER || '';
|
||||
}
|
||||
|
||||
function getConfiguredModel() {
|
||||
const explicitModel = process.env.CCS_IMAGE_ANALYSIS_MODEL;
|
||||
if (explicitModel && explicitModel.trim()) {
|
||||
return explicitModel.trim();
|
||||
}
|
||||
|
||||
const providerModels = parseProviderModels(process.env.CCS_IMAGE_ANALYSIS_PROVIDER_MODELS);
|
||||
return providerModels[getCurrentProvider()] || DEFAULT_MODEL;
|
||||
}
|
||||
|
||||
function getModelsToTry() {
|
||||
const models = [];
|
||||
const seen = new Set();
|
||||
|
||||
const explicitModel = process.env.CCS_IMAGE_ANALYSIS_MODEL;
|
||||
if (explicitModel && explicitModel.trim()) {
|
||||
models.push(explicitModel.trim());
|
||||
seen.add(explicitModel.trim());
|
||||
}
|
||||
|
||||
const providerModels = parseProviderModels(process.env.CCS_IMAGE_ANALYSIS_PROVIDER_MODELS);
|
||||
const providerModel = providerModels[getCurrentProvider()];
|
||||
if (providerModel && !seen.has(providerModel)) {
|
||||
models.push(providerModel);
|
||||
seen.add(providerModel);
|
||||
}
|
||||
|
||||
if (models.length === 0) {
|
||||
models.push(DEFAULT_MODEL);
|
||||
}
|
||||
|
||||
return models;
|
||||
}
|
||||
|
||||
function getRuntimeBaseUrl() {
|
||||
const runtimePath = (process.env.CCS_IMAGE_ANALYSIS_RUNTIME_PATH || '')
|
||||
.trim()
|
||||
.replace(/\/+$/, '');
|
||||
const explicitBaseUrl = process.env.CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL;
|
||||
if (explicitBaseUrl && explicitBaseUrl.trim()) {
|
||||
const normalizedBaseUrl = explicitBaseUrl.trim().replace(/\/+$/, '');
|
||||
if (!runtimePath) {
|
||||
return normalizedBaseUrl;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(normalizedBaseUrl);
|
||||
const normalizedPath = parsed.pathname.replace(/\/+$/, '');
|
||||
if (normalizedPath === runtimePath) {
|
||||
return normalizedBaseUrl;
|
||||
}
|
||||
|
||||
if (normalizedPath.length > 0 && normalizedPath !== '/') {
|
||||
return normalizedBaseUrl;
|
||||
}
|
||||
|
||||
parsed.pathname = runtimePath;
|
||||
return parsed.toString().replace(/\/+$/, '');
|
||||
} catch {
|
||||
return `${normalizedBaseUrl}${runtimePath}`;
|
||||
}
|
||||
}
|
||||
|
||||
const port = Number.parseInt(process.env.CCS_CLIPROXY_PORT || '8317', 10);
|
||||
return `http://127.0.0.1:${port}${runtimePath}`;
|
||||
}
|
||||
|
||||
function getRuntimeEndpoint() {
|
||||
return `${getRuntimeBaseUrl()}/v1/messages`;
|
||||
}
|
||||
|
||||
function getApiKey() {
|
||||
if (Object.prototype.hasOwnProperty.call(process.env, 'CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY')) {
|
||||
const explicitApiKey = (process.env.CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY || '').trim();
|
||||
return explicitApiKey || 'ccs-internal-managed';
|
||||
}
|
||||
|
||||
return process.env.CCS_CLIPROXY_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN || 'ccs-internal-managed';
|
||||
}
|
||||
|
||||
function getTimeoutMs(timeoutMs) {
|
||||
if (typeof timeoutMs === 'number' && timeoutMs > 0) {
|
||||
return timeoutMs;
|
||||
}
|
||||
|
||||
const timeoutSec = Number.parseInt(
|
||||
process.env.CCS_IMAGE_ANALYSIS_TIMEOUT || `${DEFAULT_TIMEOUT_SEC}`,
|
||||
10
|
||||
);
|
||||
return Math.max(1, Math.min(600, timeoutSec)) * 1000;
|
||||
}
|
||||
|
||||
function isAnalyzableFile(filePath) {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
return IMAGE_EXTENSIONS.includes(ext) || PDF_EXTENSIONS.includes(ext);
|
||||
}
|
||||
|
||||
function getMediaType(filePath) {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
return (
|
||||
{
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.png': 'image/png',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
'.heic': 'image/heic',
|
||||
'.bmp': 'image/bmp',
|
||||
'.tiff': 'image/tiff',
|
||||
'.pdf': 'application/pdf',
|
||||
}[ext] || 'application/octet-stream'
|
||||
);
|
||||
}
|
||||
|
||||
function encodeFileToBase64(filePath) {
|
||||
return fs.readFileSync(filePath).toString('base64');
|
||||
}
|
||||
|
||||
function buildContentBlock(base64Data, mediaType) {
|
||||
const source = {
|
||||
type: 'base64',
|
||||
media_type: mediaType,
|
||||
data: base64Data,
|
||||
};
|
||||
|
||||
if (mediaType === 'application/pdf') {
|
||||
return {
|
||||
type: 'document',
|
||||
source,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'image',
|
||||
source,
|
||||
};
|
||||
}
|
||||
|
||||
function extractTextContent(response) {
|
||||
if (!response || !Array.isArray(response.content)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const textBlocks = response.content
|
||||
.filter((block) => block && block.type === 'text' && typeof block.text === 'string')
|
||||
.map((block) => block.text)
|
||||
.filter((text) => text.trim());
|
||||
|
||||
return textBlocks.length > 0 ? textBlocks.join('\n\n') : null;
|
||||
}
|
||||
|
||||
function parseCliProxyResponse(data) {
|
||||
const response = JSON.parse(data);
|
||||
const text = extractTextContent(response);
|
||||
if (!text) {
|
||||
throw new Error('No text content in response');
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function analyzeViaCliProxy(base64Data, mediaType, model, prompt, timeoutMs) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const endpoint = new URL(getRuntimeEndpoint());
|
||||
const transport = endpoint.protocol === 'https:' ? https : http;
|
||||
const requestBody = JSON.stringify({
|
||||
model,
|
||||
max_tokens: 4096,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: prompt },
|
||||
buildContentBlock(base64Data, mediaType),
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const req = transport.request(
|
||||
{
|
||||
protocol: endpoint.protocol,
|
||||
hostname: endpoint.hostname,
|
||||
port: endpoint.port,
|
||||
path: `${endpoint.pathname}${endpoint.search}`,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(requestBody),
|
||||
'x-api-key': getApiKey(),
|
||||
},
|
||||
timeout: timeoutMs,
|
||||
},
|
||||
(res) => {
|
||||
let data = '';
|
||||
|
||||
res.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
|
||||
res.on('end', () => {
|
||||
if (res.statusCode === 401 || res.statusCode === 403) {
|
||||
reject(new Error(`AUTH_ERROR:${res.statusCode}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.statusCode === 429) {
|
||||
reject(new Error(`RATE_LIMIT:${res.headers['retry-after'] || ''}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.statusCode !== 200) {
|
||||
reject(new Error(`API_ERROR:${res.statusCode}:${data}`));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
resolve(parseCliProxyResponse(data));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
req.on('error', (error) => reject(error));
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
reject(new Error('TIMEOUT'));
|
||||
});
|
||||
req.write(requestBody);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function analyzeWithRetry(base64Data, mediaType, prompt, timeoutMs) {
|
||||
const models = getModelsToTry();
|
||||
let lastError = null;
|
||||
|
||||
for (const [index, model] of models.entries()) {
|
||||
try {
|
||||
debugLog(`Trying model ${index + 1}/${models.length}`, { model });
|
||||
const description = await analyzeViaCliProxy(base64Data, mediaType, model, prompt, timeoutMs);
|
||||
return { description, model };
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
const message = error.message || '';
|
||||
if (
|
||||
index === models.length - 1 ||
|
||||
['AUTH_ERROR', 'RATE_LIMIT', 'TIMEOUT', 'EACCES', 'EPERM', 'ECONNREFUSED'].some((token) =>
|
||||
message.includes(token)
|
||||
)
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error('No models configured for image analysis');
|
||||
}
|
||||
|
||||
async function analyzeFile(filePath, options = {}) {
|
||||
const stats = fs.statSync(filePath);
|
||||
if (stats.size >= MAX_FILE_SIZE_BYTES) {
|
||||
throw new Error(`FILE_TOO_LARGE:${stats.size}`);
|
||||
}
|
||||
|
||||
const timeoutMs = getTimeoutMs(options.timeoutMs);
|
||||
const { template, prompt, promptSource } = loadPromptTemplate(
|
||||
filePath,
|
||||
options.template,
|
||||
options.focus
|
||||
);
|
||||
const model = getConfiguredModel();
|
||||
|
||||
debugLog('Starting image analysis', {
|
||||
file: path.basename(filePath),
|
||||
size: `${(stats.size / 1024).toFixed(1)} KB`,
|
||||
provider: getCurrentProvider() || 'unknown',
|
||||
model,
|
||||
modelsToTry: getModelsToTry().join(' -> '),
|
||||
timeout: `${timeoutMs / 1000}s`,
|
||||
endpoint: getRuntimeEndpoint(),
|
||||
template,
|
||||
promptSource,
|
||||
});
|
||||
|
||||
const base64Data = encodeFileToBase64(filePath);
|
||||
const mediaType = getMediaType(filePath);
|
||||
debugLog('File encoded', {
|
||||
mediaType,
|
||||
base64Length: `${(base64Data.length / 1024).toFixed(1)}KB`,
|
||||
});
|
||||
|
||||
const result = await analyzeWithRetry(base64Data, mediaType, prompt, timeoutMs);
|
||||
debugLog('Analysis complete', {
|
||||
responseLength: `${result.description.length} chars`,
|
||||
model: result.model,
|
||||
template,
|
||||
});
|
||||
|
||||
return {
|
||||
description: result.description,
|
||||
model: result.model,
|
||||
fileSize: stats.size,
|
||||
mediaType,
|
||||
template,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_MODEL,
|
||||
DEFAULT_TIMEOUT_SEC,
|
||||
MAX_FILE_SIZE_BYTES,
|
||||
analyzeFile,
|
||||
getRuntimeEndpoint,
|
||||
isAnalyzableFile,
|
||||
parseProviderModels,
|
||||
selectPromptTemplate,
|
||||
};
|
||||
@@ -24,7 +24,7 @@
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const http = require('http');
|
||||
const { analyzeFile, isAnalyzableFile, parseProviderModels } = require('./image-analysis-runtime.cjs');
|
||||
|
||||
// ============================================================================
|
||||
// PLATFORM DETECTION
|
||||
@@ -36,19 +36,8 @@ const isWindows = process.platform === 'win32';
|
||||
// CONFIGURATION
|
||||
// ============================================================================
|
||||
|
||||
const IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.heic', '.bmp', '.tiff'];
|
||||
const PDF_EXTENSIONS = ['.pdf'];
|
||||
|
||||
const DEFAULT_MODEL = 'gemini-2.5-flash';
|
||||
const DEFAULT_TIMEOUT_SEC = 60;
|
||||
const MAX_FILE_SIZE_MB = 10;
|
||||
const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024;
|
||||
|
||||
const CLIPROXY_HOST = '127.0.0.1';
|
||||
const CLIPROXY_PORT = parseInt(process.env.CCS_CLIPROXY_PORT || '8317', 10);
|
||||
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)
|
||||
@@ -65,23 +54,6 @@ const ERROR_CODES = {
|
||||
UNKNOWN: 'UNKNOWN',
|
||||
};
|
||||
|
||||
// Default analysis prompt
|
||||
const DEFAULT_PROMPT = `Analyze this image/document thoroughly and provide a detailed description.
|
||||
|
||||
Include:
|
||||
1. Overall content and purpose
|
||||
2. Text content (if any) - transcribe important text
|
||||
3. Visual elements (diagrams, charts, UI components)
|
||||
4. Layout and structure
|
||||
5. Colors, styling, notable design elements
|
||||
6. Any actionable information (buttons, links, code)
|
||||
|
||||
Be comprehensive - this description replaces direct visual access.`;
|
||||
|
||||
// ============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Output debug information to stderr
|
||||
* Only outputs when CCS_DEBUG=1
|
||||
@@ -105,19 +77,19 @@ function debugLog(message, data = {}) {
|
||||
*/
|
||||
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 model =
|
||||
process.env.CCS_IMAGE_ANALYSIS_MODEL ||
|
||||
parseProviderModels(process.env.CCS_IMAGE_ANALYSIS_PROVIDER_MODELS)[currentProvider] ||
|
||||
DEFAULT_MODEL;
|
||||
const timeout = parseInt(process.env.CCS_IMAGE_ANALYSIS_TIMEOUT || DEFAULT_TIMEOUT_SEC, 10);
|
||||
const modelsToTry = getModelsToTry();
|
||||
|
||||
return {
|
||||
file: path.basename(filePath),
|
||||
size: stats ? `${(stats.size / 1024).toFixed(1)} KB` : 'unknown',
|
||||
provider: currentProvider,
|
||||
model: model,
|
||||
modelsToTry: modelsToTry.length > 1 ? modelsToTry.join(' -> ') : model,
|
||||
timeout: `${timeout}s`,
|
||||
endpoint: `http://${CLIPROXY_HOST}:${CLIPROXY_PORT}${CLIPROXY_PATH}`,
|
||||
endpoint: process.env.CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL || '(runtime fallback)',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -126,319 +98,12 @@ function getDebugContext(filePath, stats) {
|
||||
*/
|
||||
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;
|
||||
const model =
|
||||
process.env.CCS_IMAGE_ANALYSIS_MODEL ||
|
||||
parseProviderModels(process.env.CCS_IMAGE_ANALYSIS_PROVIDER_MODELS)[provider] ||
|
||||
DEFAULT_MODEL;
|
||||
return { provider, model };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse provider_models env var to object
|
||||
* Format: provider:model,provider:model
|
||||
*/
|
||||
function parseProviderModels(envValue) {
|
||||
if (!envValue) return {};
|
||||
const result = {};
|
||||
envValue.split(',').forEach((pair) => {
|
||||
const [provider, model] = pair.split(':');
|
||||
if (provider && model && model.trim()) {
|
||||
result[provider.trim()] = model.trim();
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract concatenated text content from CLIProxy response blocks.
|
||||
* Skips thinking and other non-text blocks.
|
||||
*/
|
||||
function extractTextContent(response) {
|
||||
if (!response || !Array.isArray(response.content)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const textBlocks = response.content
|
||||
.filter((block) => block && block.type === 'text' && typeof block.text === 'string')
|
||||
.map((block) => block.text)
|
||||
.filter((text) => text.trim());
|
||||
|
||||
if (textBlocks.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return textBlocks.join('\n\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse raw CLIProxy response body and extract text content.
|
||||
*/
|
||||
function parseCliProxyResponse(data) {
|
||||
let response;
|
||||
try {
|
||||
response = JSON.parse(data);
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to parse response: ${err.message}`);
|
||||
}
|
||||
|
||||
const text = extractTextContent(response);
|
||||
if (!text) {
|
||||
throw new Error('No text content in response');
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get model for current provider from provider_models mapping
|
||||
* Returns primary model only (for display/logging)
|
||||
*/
|
||||
function getModelForProvider() {
|
||||
const currentProvider = process.env.CCS_CURRENT_PROVIDER || '';
|
||||
const providerModels = parseProviderModels(process.env.CCS_IMAGE_ANALYSIS_PROVIDER_MODELS);
|
||||
return providerModels[currentProvider] || DEFAULT_MODEL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of models to try in order:
|
||||
* 1. provider_models[current_provider] (if exists)
|
||||
* 2. DEFAULT_MODEL when no provider-specific vision model is configured
|
||||
*
|
||||
* ANTHROPIC_MODEL is intentionally ignored here because the chat model may
|
||||
* not be vision-capable on the current provider route.
|
||||
*/
|
||||
function getModelsToTry() {
|
||||
const currentProvider = process.env.CCS_CURRENT_PROVIDER || '';
|
||||
const providerModels = parseProviderModels(process.env.CCS_IMAGE_ANALYSIS_PROVIDER_MODELS);
|
||||
|
||||
const models = [];
|
||||
const seen = new Set();
|
||||
|
||||
// 1. Provider-specific model
|
||||
if (providerModels[currentProvider]) {
|
||||
models.push(providerModels[currentProvider]);
|
||||
seen.add(providerModels[currentProvider]);
|
||||
}
|
||||
|
||||
// 2. Default model — only when no provider-specific model is configured,
|
||||
// since DEFAULT_MODEL (gemini-2.5-flash) may not be routable on the
|
||||
// current provider's CLIProxy endpoint (e.g. codex, claude)
|
||||
if (models.length === 0 && !seen.has(DEFAULT_MODEL)) {
|
||||
models.push(DEFAULT_MODEL);
|
||||
seen.add(DEFAULT_MODEL);
|
||||
}
|
||||
|
||||
return models;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze with retry logic - tries models in order until one succeeds
|
||||
*/
|
||||
async function analyzeWithRetry(base64Data, mediaType, timeoutMs) {
|
||||
const models = getModelsToTry();
|
||||
// Defensive check - should never happen but provides clear error
|
||||
if (models.length === 0) {
|
||||
throw new Error('No models configured for image analysis');
|
||||
}
|
||||
let lastError = null;
|
||||
|
||||
for (let i = 0; i < models.length; i++) {
|
||||
const model = models[i];
|
||||
try {
|
||||
debugLog(`Trying model ${i + 1}/${models.length}`, { model });
|
||||
const result = await analyzeViaCliProxy(base64Data, mediaType, model, timeoutMs);
|
||||
if (i > 0) {
|
||||
debugLog('Retry succeeded', { model, attempt: i + 1 });
|
||||
}
|
||||
return { description: result, model };
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
const isLastModel = i === models.length - 1;
|
||||
|
||||
// Don't retry on certain errors (auth, rate limit, timeout, file access, network)
|
||||
const errMsg = err.message || '';
|
||||
const noRetryPatterns = [
|
||||
'AUTH_ERROR', 'RATE_LIMIT', 'TIMEOUT',
|
||||
'EACCES', 'EPERM', 'ECONNREFUSED',
|
||||
'ENOTFOUND', 'ENETUNREACH', 'EAI_AGAIN' // Network errors - no point retrying
|
||||
];
|
||||
const shouldNotRetry = noRetryPatterns.some(p => errMsg.includes(p));
|
||||
|
||||
if (shouldNotRetry || isLastModel) {
|
||||
debugLog('Analysis failed, no more retries', {
|
||||
model,
|
||||
error: errMsg,
|
||||
reason: shouldNotRetry ? 'non-retryable error' : 'last model'
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
|
||||
debugLog('Model failed, trying next', {
|
||||
model,
|
||||
error: errMsg.substring(0, 100),
|
||||
nextModel: models[i + 1]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error('No models available');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if file is an analyzable image or PDF
|
||||
*/
|
||||
function isAnalyzableFile(filePath) {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
return IMAGE_EXTENSIONS.includes(ext) || PDF_EXTENSIONS.includes(ext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get MIME type from file extension
|
||||
*/
|
||||
function getMediaType(filePath) {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
const mimeTypes = {
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.png': 'image/png',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
'.heic': 'image/heic',
|
||||
'.bmp': 'image/bmp',
|
||||
'.tiff': 'image/tiff',
|
||||
'.pdf': 'application/pdf',
|
||||
};
|
||||
return mimeTypes[ext] || 'application/octet-stream';
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode file to base64
|
||||
*/
|
||||
function encodeFileToBase64(filePath) {
|
||||
const content = fs.readFileSync(filePath);
|
||||
return content.toString('base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if CLIProxy is available
|
||||
*/
|
||||
function isCliProxyAvailable() {
|
||||
return new Promise((resolve) => {
|
||||
const req = http.request(
|
||||
{
|
||||
hostname: CLIPROXY_HOST,
|
||||
port: CLIPROXY_PORT,
|
||||
path: '/',
|
||||
method: 'GET',
|
||||
timeout: 2000,
|
||||
},
|
||||
(res) => {
|
||||
resolve(res.statusCode >= 200 && res.statusCode < 500);
|
||||
}
|
||||
);
|
||||
|
||||
req.on('error', () => resolve(false));
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze file via CLIProxy vision API
|
||||
*/
|
||||
function analyzeViaCliProxy(base64Data, mediaType, model, timeoutMs) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const requestBody = JSON.stringify({
|
||||
model: model,
|
||||
max_tokens: 4096,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: DEFAULT_PROMPT },
|
||||
{
|
||||
type: 'image',
|
||||
source: {
|
||||
type: 'base64',
|
||||
media_type: mediaType,
|
||||
data: base64Data,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const req = http.request(
|
||||
{
|
||||
hostname: CLIPROXY_HOST,
|
||||
port: CLIPROXY_PORT,
|
||||
path: CLIPROXY_PATH,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(requestBody),
|
||||
'x-api-key': CLIPROXY_API_KEY,
|
||||
},
|
||||
timeout: timeoutMs,
|
||||
},
|
||||
(res) => {
|
||||
let data = '';
|
||||
|
||||
res.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
|
||||
res.on('error', (err) => {
|
||||
reject(err);
|
||||
});
|
||||
|
||||
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(`API_ERROR:${res.statusCode}:${data}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!data || !data.trim()) {
|
||||
reject(new Error('Empty response from CLIProxy'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const text = parseCliProxyResponse(data);
|
||||
resolve(text);
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
req.on('error', (err) => reject(err));
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
reject(new Error('TIMEOUT'));
|
||||
});
|
||||
|
||||
req.write(requestBody);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Format analysis description for Claude (matches websearch format)
|
||||
*/
|
||||
@@ -520,29 +185,6 @@ function outputFileTooLargeError(filePath, actualSizeMB, maxSizeMB) {
|
||||
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(
|
||||
@@ -758,10 +400,11 @@ function shouldSkipHook() {
|
||||
}
|
||||
|
||||
// Check if current provider has a vision model configured
|
||||
const explicitModel = process.env.CCS_IMAGE_ANALYSIS_MODEL;
|
||||
const currentProvider = process.env.CCS_CURRENT_PROVIDER || '';
|
||||
const providerModels = parseProviderModels(process.env.CCS_IMAGE_ANALYSIS_PROVIDER_MODELS);
|
||||
|
||||
if (!providerModels[currentProvider]) {
|
||||
if (!explicitModel?.trim() && !providerModels[currentProvider]) {
|
||||
debugLog(`Skipping: provider "${currentProvider}" not in provider_models`, {
|
||||
configured_providers: Object.keys(providerModels).join(', ') || 'none',
|
||||
});
|
||||
@@ -832,57 +475,15 @@ async function processHook() {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Check if file exists
|
||||
if (!fs.existsSync(filePath)) {
|
||||
// Let native Read handle the error
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Check file size
|
||||
const stats = fs.statSync(filePath);
|
||||
if (stats.size >= MAX_FILE_SIZE_BYTES) {
|
||||
outputFileTooLargeError(filePath, stats.size / 1024 / 1024, MAX_FILE_SIZE_MB);
|
||||
return;
|
||||
}
|
||||
const debugContext = getDebugContext(filePath, null);
|
||||
debugLog('Image analysis runtime prepared', debugContext);
|
||||
|
||||
// Check CLIProxy availability
|
||||
const cliProxyAvailable = await isCliProxyAvailable();
|
||||
if (!cliProxyAvailable) {
|
||||
debugLog('Blocking: CLIProxy not available', {
|
||||
endpoint: `http://${CLIPROXY_HOST}:${CLIPROXY_PORT}`,
|
||||
action: 'blocking to prevent context overflow',
|
||||
});
|
||||
outputCliProxyUnavailableFallback(filePath);
|
||||
return;
|
||||
}
|
||||
|
||||
const model = getModelForProvider();
|
||||
const timeout = parseInt(process.env.CCS_IMAGE_ANALYSIS_TIMEOUT || DEFAULT_TIMEOUT_SEC, 10);
|
||||
const timeoutMs = Math.max(1, Math.min(600, timeout)) * 1000;
|
||||
|
||||
// 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 with retry logic
|
||||
const { description, model: usedModel } = await analyzeWithRetry(base64Data, mediaType, timeoutMs);
|
||||
|
||||
debugLog('Analysis complete', {
|
||||
responseLength: `${description.length} chars`,
|
||||
model: usedModel,
|
||||
});
|
||||
|
||||
// Output success
|
||||
outputSuccess(filePath, description, usedModel, stats.size);
|
||||
const result = await analyzeFile(filePath);
|
||||
outputSuccess(filePath, result.description, result.model, result.fileSize);
|
||||
} catch (err) {
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error('[CCS Hook] Error:', err.message);
|
||||
@@ -893,7 +494,10 @@ async function processHook() {
|
||||
// Categorize error by message pattern
|
||||
const errMsg = err.message || '';
|
||||
|
||||
if (errMsg.startsWith('AUTH_ERROR:')) {
|
||||
if (errMsg.startsWith('FILE_TOO_LARGE:')) {
|
||||
const fileSizeMb = Number.parseInt(errMsg.split(':')[1], 10) / 1024 / 1024;
|
||||
outputFileTooLargeError(filePath, fileSizeMb, 10);
|
||||
} else if (errMsg.startsWith('AUTH_ERROR:')) {
|
||||
const statusCode = parseInt(errMsg.split(':')[1], 10);
|
||||
outputAuthError(filePath, statusCode);
|
||||
} else if (errMsg.startsWith('RATE_LIMIT:')) {
|
||||
@@ -907,8 +511,13 @@ async function processHook() {
|
||||
} 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 if (
|
||||
errMsg.includes('ECONNREFUSED') ||
|
||||
errMsg.includes('ENOTFOUND') ||
|
||||
errMsg.includes('ENETUNREACH') ||
|
||||
errMsg.includes('EAI_AGAIN')
|
||||
) {
|
||||
outputCliProxyUnavailableFallback(filePath);
|
||||
} else if (errMsg.includes('EACCES') || errMsg.includes('EPERM')) {
|
||||
outputFileAccessError(filePath, errMsg);
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
function loadRuntimeModule() {
|
||||
const candidates = [
|
||||
path.join(__dirname, 'image-analysis-runtime.cjs'),
|
||||
path.join(__dirname, '../hooks/image-analysis-runtime.cjs'),
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(candidate)) {
|
||||
return require(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`ccs-image-analysis runtime not found. Checked: ${candidates.map((candidate) => path.basename(candidate)).join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
const { analyzeFile, isAnalyzableFile } = loadRuntimeModule();
|
||||
|
||||
const PROTOCOL_VERSION = '2024-11-05';
|
||||
const SERVER_NAME = 'ccs-image-analysis';
|
||||
const SERVER_VERSION = '1.0.0';
|
||||
const TOOL_NAME = 'ImageAnalysis';
|
||||
const TOOL_ALIASES = ['AnalyzeImage', 'ReadImage'];
|
||||
const TEMPLATE_NAMES = ['default', 'screenshot', 'document'];
|
||||
const TOOL_DESCRIPTION =
|
||||
'Analyze a local image or PDF file with CCS provider-backed vision. Prefer this tool over Read for image and PDF paths. Use Read for text, code, and other plain files.';
|
||||
|
||||
function isSupportedToolName(name) {
|
||||
return name === TOOL_NAME || TOOL_ALIASES.includes(name);
|
||||
}
|
||||
|
||||
function shouldExposeTools() {
|
||||
return (
|
||||
process.env.CCS_IMAGE_ANALYSIS_ENABLED === '1' &&
|
||||
process.env.CCS_IMAGE_ANALYSIS_SKIP !== '1' &&
|
||||
Boolean(process.env.CCS_CURRENT_PROVIDER || process.env.CCS_IMAGE_ANALYSIS_MODEL)
|
||||
);
|
||||
}
|
||||
|
||||
function getTools() {
|
||||
if (!shouldExposeTools()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
name: TOOL_NAME,
|
||||
description: TOOL_DESCRIPTION,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
filePath: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Absolute or workspace-relative path to a local image or PDF file to analyze.',
|
||||
},
|
||||
focus: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Optional question or area of focus, for example "explain the error dialog" or "transcribe the visible text".',
|
||||
},
|
||||
template: {
|
||||
type: 'string',
|
||||
enum: TEMPLATE_NAMES,
|
||||
description:
|
||||
'Optional prompt template override. Use screenshot for UI captures, document for PDFs/docs, or default for general images.',
|
||||
},
|
||||
},
|
||||
required: ['filePath'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function writeMessage(message) {
|
||||
process.stdout.write(`${JSON.stringify(message)}\n`);
|
||||
}
|
||||
|
||||
function writeResponse(id, result) {
|
||||
writeMessage({
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result,
|
||||
});
|
||||
}
|
||||
|
||||
function writeError(id, code, message) {
|
||||
writeMessage({
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
error: {
|
||||
code,
|
||||
message,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function formatResult(filePath, result, focus) {
|
||||
const lines = [
|
||||
'[Image Analysis via CCS]',
|
||||
'',
|
||||
`File: ${path.basename(filePath)} (${(result.fileSize / 1024).toFixed(1)} KB)`,
|
||||
`Model: ${result.model}`,
|
||||
`Template: ${result.template}`,
|
||||
];
|
||||
|
||||
if (focus && focus.trim()) {
|
||||
lines.push(`Focus: ${focus.trim()}`);
|
||||
}
|
||||
|
||||
lines.push('', '---', '', result.description);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function normalizeTemplate(value) {
|
||||
if (typeof value !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return TEMPLATE_NAMES.includes(normalized) ? normalized : undefined;
|
||||
}
|
||||
|
||||
function resolveFilePath(toolArgs) {
|
||||
if (!toolArgs || typeof toolArgs !== 'object') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const candidates = [toolArgs.filePath, toolArgs.file_path, toolArgs.path];
|
||||
for (const candidate of candidates) {
|
||||
if (typeof candidate === 'string' && candidate.trim().length > 0) {
|
||||
return candidate.trim();
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function resolveFocus(toolArgs) {
|
||||
return typeof toolArgs.focus === 'string' && toolArgs.focus.trim().length > 0
|
||||
? toolArgs.focus.trim()
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function formatErrorDetail(filePath, error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
|
||||
if (message.startsWith('FILE_TOO_LARGE:')) {
|
||||
const fileSizeBytes = Number.parseInt(message.split(':')[1], 10);
|
||||
const sizeMb = Number.isFinite(fileSizeBytes) ? (fileSizeBytes / 1024 / 1024).toFixed(1) : '?';
|
||||
return `ImageAnalysis cannot process ${path.basename(filePath)} because it is too large (${sizeMb} MB). The limit is 10 MB.`;
|
||||
}
|
||||
|
||||
if (message.startsWith('AUTH_ERROR:')) {
|
||||
return `ImageAnalysis failed because CCS vision auth for this provider is unavailable (${message.split(':')[1]}).`;
|
||||
}
|
||||
|
||||
if (message.startsWith('RATE_LIMIT:')) {
|
||||
return `ImageAnalysis hit a provider rate limit while analyzing ${path.basename(filePath)}.`;
|
||||
}
|
||||
|
||||
if (message.startsWith('API_ERROR:')) {
|
||||
return `ImageAnalysis failed at the CCS provider route while analyzing ${path.basename(filePath)}.`;
|
||||
}
|
||||
|
||||
if (
|
||||
message === 'TIMEOUT' ||
|
||||
message.includes('timed out') ||
|
||||
message.includes('timeout') ||
|
||||
message.includes('ECONNREFUSED') ||
|
||||
message.includes('ENOTFOUND') ||
|
||||
message.includes('ENETUNREACH') ||
|
||||
message.includes('EAI_AGAIN')
|
||||
) {
|
||||
return `ImageAnalysis could not reach the configured CCS provider route for ${path.basename(filePath)}.`;
|
||||
}
|
||||
|
||||
if (message.includes('EACCES') || message.includes('EPERM')) {
|
||||
return `ImageAnalysis could not read ${filePath} because access was denied.`;
|
||||
}
|
||||
|
||||
return `ImageAnalysis failed for ${path.basename(filePath)}: ${message}`;
|
||||
}
|
||||
|
||||
async function handleToolCall(message) {
|
||||
const id = message.id;
|
||||
const params = message.params || {};
|
||||
const toolArgs = params.arguments || {};
|
||||
const toolName = params.name || '<missing>';
|
||||
|
||||
if (!isSupportedToolName(toolName)) {
|
||||
writeError(id, -32602, `Unknown tool: ${toolName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!shouldExposeTools()) {
|
||||
writeResponse(id, {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'CCS ImageAnalysis is unavailable for this profile or no provider-backed vision route is ready.',
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const filePath = resolveFilePath(toolArgs);
|
||||
if (!filePath) {
|
||||
writeError(id, -32602, `Tool "${TOOL_NAME}" requires a non-empty filePath.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
writeResponse(id, {
|
||||
content: [{ type: 'text', text: `ImageAnalysis could not find file: ${filePath}` }],
|
||||
isError: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isAnalyzableFile(filePath)) {
|
||||
writeResponse(id, {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `ImageAnalysis only supports image and PDF files. Use Read for ${path.basename(filePath)} instead.`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const focus = resolveFocus(toolArgs);
|
||||
const template = normalizeTemplate(toolArgs.template);
|
||||
|
||||
try {
|
||||
const result = await analyzeFile(filePath, { focus, template });
|
||||
writeResponse(id, {
|
||||
content: [{ type: 'text', text: formatResult(filePath, result, focus) }],
|
||||
});
|
||||
} catch (error) {
|
||||
writeResponse(id, {
|
||||
content: [{ type: 'text', text: formatErrorDetail(filePath, error) }],
|
||||
isError: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMessage(message) {
|
||||
if (!message || message.jsonrpc !== '2.0' || typeof message.method !== 'string') {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (message.method) {
|
||||
case 'initialize':
|
||||
writeResponse(message.id, {
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
capabilities: {
|
||||
tools: {},
|
||||
},
|
||||
serverInfo: {
|
||||
name: SERVER_NAME,
|
||||
version: SERVER_VERSION,
|
||||
},
|
||||
});
|
||||
return;
|
||||
case 'notifications/initialized':
|
||||
return;
|
||||
case 'ping':
|
||||
writeResponse(message.id, {});
|
||||
return;
|
||||
case 'tools/list':
|
||||
writeResponse(message.id, { tools: getTools() });
|
||||
return;
|
||||
case 'tools/call':
|
||||
await handleToolCall(message);
|
||||
return;
|
||||
default:
|
||||
if (message.id !== undefined) {
|
||||
writeError(message.id, -32601, `Method not found: ${message.method}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let inputBuffer = Buffer.alloc(0);
|
||||
|
||||
function processIncomingBuffer() {
|
||||
while (true) {
|
||||
const newlineIndex = inputBuffer.indexOf(0x0a);
|
||||
if (newlineIndex === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lineBuffer = inputBuffer.subarray(0, newlineIndex);
|
||||
inputBuffer = inputBuffer.subarray(newlineIndex + 1);
|
||||
const line = lineBuffer.toString('utf8').replace(/\r$/, '').trim();
|
||||
if (!line) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const message = JSON.parse(line);
|
||||
Promise.resolve(handleMessage(message)).catch((error) => {
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(`[ccs-image-analysis] ${error instanceof Error ? error.stack : error}`);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(
|
||||
`[ccs-image-analysis] Failed to parse JSON-RPC message: ${
|
||||
error instanceof Error ? error.message : error
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
process.stdin.on('data', (chunk) => {
|
||||
inputBuffer = Buffer.concat([inputBuffer, chunk]);
|
||||
processIncomingBuffer();
|
||||
});
|
||||
|
||||
process.stdin.on('end', () => {
|
||||
process.exit(0);
|
||||
});
|
||||
Reference in new issue
Block a user