mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-15 14:21:20 +00:00
470 lines
13 KiB
JavaScript
470 lines
13 KiB
JavaScript
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 MAX_PROMPT_TEMPLATE_BYTES = 32 * 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 stats = fs.statSync(filePath);
|
|
if (stats.size > MAX_PROMPT_TEMPLATE_BYTES) {
|
|
return null;
|
|
}
|
|
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;
|
|
}
|
|
|
|
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 shouldAllowSelfSigned() {
|
|
const value = `${process.env.CCS_IMAGE_ANALYSIS_RUNTIME_ALLOW_SELF_SIGNED || ''}`.trim().toLowerCase();
|
|
return value === '1' || value === 'true' || value === 'yes';
|
|
}
|
|
|
|
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 apiKey = getApiKey();
|
|
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': apiKey,
|
|
Authorization: `Bearer ${apiKey}`,
|
|
},
|
|
timeout: timeoutMs,
|
|
...(endpoint.protocol === 'https:' && shouldAllowSelfSigned()
|
|
? { rejectUnauthorized: false }
|
|
: {}),
|
|
},
|
|
(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,
|
|
};
|