536 lines
18 KiB
JavaScript
536 lines
18 KiB
JavaScript
const { spawn, exec } = require('child_process');
|
|
const path = require('path');
|
|
const fs = require('fs').promises;
|
|
const os = require('os');
|
|
|
|
class OpenCodeBackend {
|
|
constructor() {
|
|
this.opencodePath = null;
|
|
this.isInitialized = false;
|
|
this.currentSession = null;
|
|
this.processes = new Map();
|
|
}
|
|
|
|
async initialize() {
|
|
try {
|
|
const isWindows = os.platform() === 'win32';
|
|
const binName = isWindows ? 'opencode.exe' : 'opencode';
|
|
|
|
// Try to find opencode binary in various locations
|
|
const possiblePaths = [
|
|
path.join(__dirname, 'bin', binName),
|
|
path.join(__dirname, binName),
|
|
binName, // Assume it's in PATH
|
|
path.join(os.homedir(), '.opencode', binName),
|
|
path.join(process.env.LOCALAPPDATA || (process.env.HOME + '/.local/share'), 'OpenCode', binName)
|
|
];
|
|
|
|
for (const opencodePath of possiblePaths) {
|
|
try {
|
|
await fs.access(opencodePath);
|
|
this.opencodePath = opencodePath;
|
|
console.log(`✅ Found OpenCode at: ${opencodePath}`);
|
|
break;
|
|
} catch (err) {
|
|
// Continue to next path
|
|
}
|
|
}
|
|
|
|
if (!this.opencodePath) {
|
|
throw new Error('OpenCode binary not found. Please ensure opencode.exe is available.');
|
|
}
|
|
|
|
// Test if OpenCode is working (direct call without initialization check)
|
|
await this.testOpenCode();
|
|
this.isInitialized = true;
|
|
console.log('✅ OpenCode backend initialized successfully');
|
|
|
|
return true;
|
|
} catch (error) {
|
|
console.error('❌ Failed to initialize OpenCode backend:', error.message);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async testOpenCode() {
|
|
return new Promise((resolve, reject) => {
|
|
const child = spawn(this.opencodePath, ['--version'], {
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
cwd: __dirname,
|
|
env: {
|
|
...process.env,
|
|
OPENCODE_NO_TELEMETRY: '1',
|
|
OPENCODE_LOG_LEVEL: 'ERROR',
|
|
FORCE_COLOR: '0' // Disable ANSI color codes
|
|
}
|
|
});
|
|
|
|
let stdout = '';
|
|
let stderr = '';
|
|
|
|
child.stdout.on('data', (data) => {
|
|
stdout += data.toString();
|
|
});
|
|
|
|
child.stderr.on('data', (data) => {
|
|
stderr += data.toString();
|
|
});
|
|
|
|
child.on('close', (code) => {
|
|
if (code === 0) {
|
|
resolve({ success: true, stdout: this.stripAnsiCodes(stdout).trim() });
|
|
} else {
|
|
reject(new Error(`OpenCode test failed with code ${code}: ${this.stripAnsiCodes(stderr)}`));
|
|
}
|
|
});
|
|
|
|
child.on('error', (error) => {
|
|
reject(error);
|
|
});
|
|
|
|
// Set timeout
|
|
const timeout = setTimeout(() => {
|
|
child.kill('SIGTERM');
|
|
reject(new Error('OpenCode test timed out'));
|
|
}, 10000);
|
|
|
|
child.on('close', () => {
|
|
clearTimeout(timeout);
|
|
});
|
|
});
|
|
}
|
|
|
|
async executeCommand(args, options = {}) {
|
|
if (!this.isInitialized) {
|
|
throw new Error('OpenCode backend not initialized');
|
|
}
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const startTime = Date.now();
|
|
const child = spawn(this.opencodePath, args, {
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
cwd: options.cwd || __dirname,
|
|
env: {
|
|
...process.env,
|
|
OPENCODE_NO_TELEMETRY: '1',
|
|
OPENCODE_LOG_LEVEL: 'ERROR',
|
|
FORCE_COLOR: '0' // Disable ANSI color codes
|
|
},
|
|
...options
|
|
});
|
|
|
|
let stdout = '';
|
|
let stderr = '';
|
|
|
|
child.stdout.on('data', (data) => {
|
|
stdout += data.toString();
|
|
});
|
|
|
|
child.stderr.on('data', (data) => {
|
|
stderr += data.toString();
|
|
});
|
|
|
|
child.on('close', (code) => {
|
|
const duration = Date.now() - startTime;
|
|
resolve({
|
|
code,
|
|
stdout: this.stripAnsiCodes(stdout).trim(),
|
|
stderr: this.stripAnsiCodes(stderr).trim(),
|
|
duration,
|
|
command: `${this.opencodePath} ${args.join(' ')}`
|
|
});
|
|
});
|
|
|
|
child.on('error', (error) => {
|
|
reject(error);
|
|
});
|
|
|
|
// Set timeout
|
|
if (options.timeout) {
|
|
setTimeout(() => {
|
|
child.kill('SIGTERM');
|
|
reject(new Error(`Command timed out after ${options.timeout}ms`));
|
|
}, options.timeout);
|
|
}
|
|
});
|
|
}
|
|
|
|
async checkAuth(provider = 'qwen') {
|
|
try {
|
|
// First check if credentials exist
|
|
const result = await this.executeCommand(['auth', 'list']);
|
|
const hasCredentials = result.stdout.includes(provider);
|
|
|
|
if (!hasCredentials) {
|
|
return {
|
|
authenticated: false,
|
|
details: 'No credentials found'
|
|
};
|
|
}
|
|
|
|
// Actually test the token by making a simple request
|
|
// Try running a minimal command to verify the token works
|
|
try {
|
|
const testResult = await this.executeCommand(['run', '-m', `${provider}/coder-model`, 'ping'], {
|
|
timeout: 15000 // 15 seconds timeout for token test
|
|
});
|
|
|
|
// Check if the response indicates token error
|
|
const output = testResult.stdout + testResult.stderr;
|
|
if (output.includes('invalid access token') || output.includes('token expired') || output.includes('unauthorized')) {
|
|
return {
|
|
authenticated: false,
|
|
tokenExpired: true,
|
|
details: 'Token expired or invalid'
|
|
};
|
|
}
|
|
|
|
return {
|
|
authenticated: true,
|
|
details: result.stdout
|
|
};
|
|
} catch (testError) {
|
|
// If test fails, might still be authenticated but network issue
|
|
return {
|
|
authenticated: true, // Assume true to not block, actual call will fail gracefully
|
|
details: result.stdout,
|
|
warning: 'Could not verify token validity'
|
|
};
|
|
}
|
|
} catch (error) {
|
|
return {
|
|
authenticated: false,
|
|
error: error.message
|
|
};
|
|
}
|
|
}
|
|
|
|
async authenticate(provider = 'qwen') {
|
|
try {
|
|
// For Qwen, we open the browser for OAuth
|
|
if (provider === 'qwen') {
|
|
// First try to logout to clear old tokens
|
|
try {
|
|
await this.executeCommand(['auth', 'logout', 'qwen'], { timeout: 5000 });
|
|
} catch (e) {
|
|
// Ignore logout errors
|
|
}
|
|
|
|
// Open qwen.ai for manual authentication
|
|
// The user needs to login at https://chat.qwen.ai and we'll use oauth
|
|
return {
|
|
success: true,
|
|
requiresBrowser: true,
|
|
browserUrl: 'https://chat.qwen.ai',
|
|
message: 'Please login at https://chat.qwen.ai in your browser, then click "Complete Auth"'
|
|
};
|
|
}
|
|
|
|
throw new Error(`Unsupported provider: ${provider}`);
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
error: error.message
|
|
};
|
|
}
|
|
}
|
|
|
|
buildRunArgs(message, model = 'qwen/coder-model', options = {}) {
|
|
const args = ['run'];
|
|
|
|
if (options.lakeview) {
|
|
args.push('--lakeview');
|
|
}
|
|
|
|
if (options.sequentialThinking) {
|
|
args.push('--think');
|
|
}
|
|
|
|
args.push('-m', model);
|
|
args.push(message);
|
|
|
|
return args;
|
|
}
|
|
|
|
async sendMessage(message, model = 'qwen/coder-model', options = {}) {
|
|
try {
|
|
// Use buildRunArgs to pass message directly as argument (non-interactive mode)
|
|
const args = this.buildRunArgs(message, model, options);
|
|
|
|
const sessionId = `session_${Date.now()}`;
|
|
this.currentSession = sessionId;
|
|
|
|
return new Promise((resolve, reject) => {
|
|
let child = null;
|
|
let response = '';
|
|
let errorOutput = '';
|
|
let timeoutHandle = null;
|
|
let settled = false;
|
|
|
|
const cleanup = () => {
|
|
if (timeoutHandle) {
|
|
clearTimeout(timeoutHandle);
|
|
timeoutHandle = null;
|
|
}
|
|
if (this.processes.has(sessionId)) {
|
|
const proc = this.processes.get(sessionId);
|
|
if (proc && !proc.killed) {
|
|
try {
|
|
proc.kill('SIGTERM');
|
|
} catch (e) {
|
|
// Process might already be dead
|
|
}
|
|
}
|
|
this.processes.delete(sessionId);
|
|
}
|
|
if (this.currentSession === sessionId) {
|
|
this.currentSession = null;
|
|
}
|
|
};
|
|
|
|
const finalize = (action) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
cleanup();
|
|
action();
|
|
};
|
|
|
|
try {
|
|
child = spawn(this.opencodePath, args, {
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
cwd: __dirname,
|
|
env: {
|
|
...process.env,
|
|
OPENCODE_NO_TELEMETRY: '1',
|
|
OPENCODE_LOG_LEVEL: 'ERROR',
|
|
FORCE_COLOR: '0'
|
|
}
|
|
});
|
|
|
|
child.stdout.on('data', (data) => {
|
|
response += data.toString();
|
|
});
|
|
|
|
child.stderr.on('data', (data) => {
|
|
errorOutput += data.toString();
|
|
});
|
|
|
|
child.on('close', (code) => {
|
|
finalize(() => {
|
|
// Clean up ANSI codes from response
|
|
const cleanResponse = this.stripAnsiCodes(response.trim());
|
|
|
|
if (code === 0 || cleanResponse.length > 0) {
|
|
resolve({
|
|
success: true,
|
|
response: cleanResponse,
|
|
model,
|
|
sessionId
|
|
});
|
|
} else {
|
|
resolve({
|
|
success: false,
|
|
error: this.stripAnsiCodes(errorOutput) || `Process exited with code ${code}`,
|
|
model,
|
|
sessionId
|
|
});
|
|
}
|
|
});
|
|
});
|
|
|
|
child.on('error', (error) => {
|
|
finalize(() => reject(error));
|
|
});
|
|
|
|
this.processes.set(sessionId, child);
|
|
|
|
// Timeout - default 60 seconds for AI responses
|
|
timeoutHandle = setTimeout(() => {
|
|
if (child && !child.killed) {
|
|
child.kill('SIGTERM');
|
|
}
|
|
finalize(() => reject(new Error('Message processing timed out')));
|
|
}, options.timeout || 60000);
|
|
|
|
} catch (error) {
|
|
finalize(() => reject(error));
|
|
}
|
|
});
|
|
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
error: error.message,
|
|
model
|
|
};
|
|
}
|
|
}
|
|
|
|
async getAvailableModels() {
|
|
try {
|
|
const result = await this.executeCommand(['--help']);
|
|
// Parse the help output to extract available models
|
|
// This is a simplified approach - in reality, you might need to parse more carefully
|
|
const models = [
|
|
'qwen/coder-model',
|
|
'qwen/vision-model',
|
|
'gpt-4',
|
|
'gpt-3.5-turbo'
|
|
];
|
|
|
|
return {
|
|
success: true,
|
|
models
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
error: error.message,
|
|
models: []
|
|
};
|
|
}
|
|
}
|
|
|
|
async getStatus() {
|
|
try {
|
|
const authStatus = await this.checkAuth();
|
|
const versionResult = await this.executeCommand(['--version']);
|
|
|
|
return {
|
|
initialized: this.isInitialized,
|
|
opencodePath: this.opencodePath,
|
|
version: versionResult.stdout,
|
|
auth: authStatus,
|
|
currentSession: this.currentSession
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
initialized: false,
|
|
error: error.message
|
|
};
|
|
}
|
|
}
|
|
|
|
async sendMessageStream(message, model = 'qwen/coder-model', options = {}) {
|
|
const args = this.buildRunArgs(message, model, options);
|
|
const sessionId = `session_${Date.now()}`;
|
|
this.currentSession = sessionId;
|
|
|
|
return new Promise((resolve, reject) => {
|
|
let child = null;
|
|
let response = '';
|
|
let errorOutput = '';
|
|
let timeoutHandle = null;
|
|
let settled = false;
|
|
|
|
const cleanup = () => {
|
|
if (timeoutHandle) {
|
|
clearTimeout(timeoutHandle);
|
|
timeoutHandle = null;
|
|
}
|
|
if (this.processes.has(sessionId)) {
|
|
const proc = this.processes.get(sessionId);
|
|
if (proc && !proc.killed) {
|
|
try {
|
|
proc.kill('SIGTERM');
|
|
} catch (e) {
|
|
// Process might already be dead
|
|
}
|
|
}
|
|
this.processes.delete(sessionId);
|
|
}
|
|
if (this.currentSession === sessionId) {
|
|
this.currentSession = null;
|
|
}
|
|
};
|
|
|
|
const finalize = (action) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
cleanup();
|
|
action();
|
|
};
|
|
|
|
try {
|
|
child = spawn(this.opencodePath, args, {
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
cwd: __dirname,
|
|
env: {
|
|
...process.env,
|
|
OPENCODE_NO_TELEMETRY: '1',
|
|
OPENCODE_LOG_LEVEL: 'ERROR',
|
|
FORCE_COLOR: '0'
|
|
}
|
|
});
|
|
|
|
child.stdout.on('data', (data) => {
|
|
let chunk = data.toString();
|
|
chunk = this.stripAnsiCodes(chunk);
|
|
response += chunk;
|
|
if (options.onChunk) {
|
|
options.onChunk(chunk);
|
|
}
|
|
});
|
|
|
|
child.stderr.on('data', (data) => {
|
|
const errorData = data.toString();
|
|
errorOutput += errorData;
|
|
if (options.onError) {
|
|
options.onError(this.stripAnsiCodes(errorData));
|
|
}
|
|
});
|
|
|
|
child.on('close', (code) => {
|
|
finalize(() => {
|
|
if (code === 0) {
|
|
resolve({
|
|
success: true,
|
|
response: response.trim(),
|
|
sessionId
|
|
});
|
|
} else {
|
|
reject(new Error(`Process exited with code ${code}: ${errorOutput}`));
|
|
}
|
|
});
|
|
});
|
|
|
|
child.on('error', (error) => {
|
|
finalize(() => reject(error));
|
|
});
|
|
|
|
this.processes.set(sessionId, child);
|
|
|
|
const timeoutMs = options.timeout || 300000; // Default to 5 minutes for AI responses
|
|
timeoutHandle = setTimeout(() => {
|
|
if (child && !child.killed) {
|
|
child.kill('SIGTERM');
|
|
}
|
|
finalize(() => reject(new Error(`Stream timed out after ${timeoutMs}ms`)));
|
|
}, timeoutMs);
|
|
} catch (error) {
|
|
finalize(() => reject(error));
|
|
}
|
|
});
|
|
}
|
|
|
|
async cleanup() {
|
|
// Kill any running processes
|
|
for (const [sessionId, process] of this.processes) {
|
|
try {
|
|
process.kill('SIGTERM');
|
|
} catch (error) {
|
|
// Process might already be dead
|
|
}
|
|
}
|
|
this.processes.clear();
|
|
this.currentSession = null;
|
|
}
|
|
|
|
stripAnsiCodes(str) {
|
|
// Comprehensive regular expression to match ANSI escape codes and terminal control sequences
|
|
return str.replace(/[\u001b\u009b][\[\]()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]|[\u001b\u009b][c-u w-y]|\u001b\][^\u0007]*\u0007/g, '');
|
|
}
|
|
}
|
|
|
|
module.exports = OpenCodeBackend;
|