mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-14 18:18:21 +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,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