import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:dartssh2/dartssh2.dart'; import 'package:flutter/foundation.dart'; import 'package:server_box/data/model/opencode/models.dart'; import 'package:server_box/data/model/server/server_private_info.dart'; /// SSH 隧道管理器 - 用于通过 SSH 连接访问 Opencode API class OpencodeSSHTunnel { SSHClient? _client; final StreamController _outputController = StreamController.broadcast(); final StreamController _responseController = StreamController.broadcast(); Stream get outputStream => _outputController.stream; Stream get responseStream => _responseController.stream; bool get isConnected => _client != null && !_client!.isClosed; /// 建立 SSH 连接 Future connect(Spi spi, {String? privateKey, String? passphrase}) async { try { final socket = await SSHSocket.connect(spi.ip, spi.port); SSHClient? client; if (privateKey != null && privateKey.isNotEmpty) { final keyPair = await _parsePrivateKey(privateKey, passphrase); client = SSHClient( socket, username: spi.user, identities: keyPair != null ? [keyPair] : [], ); } else if (spi.pwd != null && spi.pwd!.isNotEmpty) { client = SSHClient( socket, username: spi.user, onPasswordRequest: () => spi.pwd!, ); } else { throw Exception('No authentication method available'); } _client = client; await client.authenticated; if (kDebugMode) { print('SSH tunnel connected to ${spi.name} (${spi.ip}:${spi.port})'); } } catch (e) { if (kDebugMode) { print('SSH connection error: $e'); } throw Exception('SSH connection failed: $e'); } } /// 解析私钥 Future execute(String command) async { if (_client == null || _client!.isClosed) { throw Exception('SSH client not connected'); } try { final session = await _client!.execute(command); final output = await session.stdout.join(''); final error = await session.stderr.join(''); await session.done; if (error.isNotEmpty && output.isEmpty) { throw Exception('Command failed: $error'); } return output; } catch (e) { throw Exception('Command execution failed: $e'); } } /// 检查 Opencode 是否已安装 Future checkInstallation(String opencodePath) async { try { final result = await execute('which $opencodePath'); if (result.trim().isNotEmpty) { try { final version = await execute('$opencodePath --version'); if (kDebugMode) { print('Opencode version: $version'); } return OpencodeInstallStatus.installed; } catch (e) { return OpencodeInstallStatus.installed; } } return OpencodeInstallStatus.notInstalled; } catch (e) { return OpencodeInstallStatus.notInstalled; } } /// 安装 Opencode Future installOpencode({ required String installPath, String version = 'latest', }) async { try { final unameResult = await execute('uname -s'); final osType = unameResult.trim().toLowerCase(); final archResult = await execute('uname -m'); final arch = archResult.trim(); String downloadUrl; if (osType.contains('linux')) { String archSuffix; if (arch == 'x86_64') { archSuffix = 'linux-x64'; } else if (arch == 'aarch64' || arch == 'arm64') { archSuffix = 'linux-arm64'; } else { throw Exception('Unsupported architecture: $arch'); } downloadUrl = 'https://github.com/opencode-ai/opencode/releases/download/$version/opencode-$archSuffix'; } else if (osType.contains('darwin')) { String archSuffix; if (arch == 'x86_64') { archSuffix = 'darwin-x64'; } else if (arch == 'aarch64' || arch == 'arm64') { archSuffix = 'darwin-arm64'; } else { throw Exception('Unsupported architecture: $arch'); } downloadUrl = 'https://github.com/opencode-ai/opencode/releases/download/$version/opencode-$archSuffix'; } else { throw Exception('Unsupported OS: $osType'); } final installDir = installPath.substring(0, installPath.lastIndexOf('/')); final commands = [ 'mkdir -p $installDir', 'curl -L -o $installPath "$downloadUrl"', 'chmod +x $installPath', ]; for (final cmd in commands) { await execute(cmd); } if (kDebugMode) { print('Opencode installed successfully at $installPath'); } } catch (e) { if (kDebugMode) { print('Opencode installation failed: $e'); } throw Exception('Installation failed: $e'); } } /// 启动 Opencode API 服务器 Future startAPIServer({ required String opencodePath, required String configPath, required String port, String? apiKey, Map? envVars, }) async { try { await execute('mkdir -p $configPath'); // 设置环境变量 final envPrefix = envVars?.entries .map((e) => 'export ${e.key}="${e.value}"') .join(' \u0026\u0026 ') ?? ''; // 配置 API Key if (apiKey != null && apiKey.isNotEmpty) { await execute('mkdir -p $configPath'); final escapedKey = apiKey.replaceAll("'", "'\\''"); await execute("echo '$escapedKey' \u003e $configPath/api_key.txt"); await execute('chmod 600 $configPath/api_key.txt'); } // 启动 API 服务器 final startCmd = envPrefix.isNotEmpty ? '$envPrefix \u0026\u0026 nohup $opencodePath server --port $port \u003e /dev/null 2\u003e\u00261 \u0026' : 'nohup $opencodePath server --port $port \u003e /dev/null 2\u003e\u00261 \u0026'; await execute(startCmd); // 等待服务启动 await Future.delayed(const Duration(seconds: 2)); // 验证服务是否运行 final checkResult = await execute('curl -s http://localhost:$port/health || echo "not_running"'); if (checkResult.contains('not_running')) { throw Exception('API server failed to start'); } if (kDebugMode) { print('Opencode API server started on port $port'); } } catch (e) { if (kDebugMode) { print('Failed to start API server: $e'); } throw Exception('Failed to start API server: $e'); } } /// 停止 Opencode API 服务器 Future stopAPIServer(String port) async { try { await execute('pkill -f "opencode.*--port $port" || true'); if (kDebugMode) { print('Opencode API server stopped'); } } catch (e) { // 忽略错误 } } /// 发送消息到 Opencode API Future sendMessage({ required String port, required OpencodeMessage message, required List history, String? apiKey, }) async { if (_client == null || _client!.isClosed) { throw Exception('SSH client not connected'); } try { // 构建请求体 final requestBody = jsonEncode({ 'message': message.content, 'history': history.map((m) => m.toJson()).toList(), 'role': message.role, if (apiKey != null) 'api_key': apiKey, }); // 构建 curl 命令 final headers = ['Content-Type: application/json']; if (apiKey != null) { headers.add('Authorization: Bearer $apiKey'); } final headerArgs = headers.map((h) => '-H "$h"').join(' '); final command = ''' curl -s -X POST http://localhost:$port/api/v1/chat \\ $headerArgs \\ -d '${requestBody.replaceAll("'", "'\\''")}' '''; final result = await execute(command); try { final responseData = jsonDecode(result); final response = OpencodeResponse.fromJson(responseData); _responseController.add(response); } catch (e) { _outputController.add(result); } } catch (e) { _outputController.addError(Exception('Failed to send message: $e')); } } /// 断开连接 Future disconnect() async { try { _client?.close(); _client = null; if (kDebugMode) { print('SSH tunnel disconnected'); } } catch (e) { if (kDebugMode) { print('Error disconnecting: $e'); } } } /// 释放资源 void dispose() { disconnect(); _outputController.close(); _responseController.close(); } } /// Opencode 安装器 class OpencodeInstaller { final OpencodeSSHTunnel _tunnel; OpencodeInstaller(this._tunnel); /// 完整的安装流程 Future fullInstall({ required OpencodeServerConfig config, void Function(String status)? onStatus, }) async { try { onStatus?.call('Checking current installation...'); final status = await _tunnel.checkInstallation(config.opencodePath); if (status == OpencodeInstallStatus.installed) { onStatus?.call('Opencode already installed'); return true; } onStatus?.call('Installing Opencode...'); await _tunnel.installOpencode( installPath: config.opencodePath, ); onStatus?.call('Creating configuration...'); await _createConfig(config); if (config.autoStart) { onStatus?.call('Starting API server...'); await _tunnel.startAPIServer( opencodePath: config.opencodePath, configPath: config.configPath, port: config.apiPort, apiKey: config.apiKey, envVars: config.envVars, ); } onStatus?.call('Installation complete!'); return true; } catch (e) { onStatus?.call('Installation failed: $e'); return false; } } /// 创建配置文件 Future _createConfig(OpencodeServerConfig config) async { final configContent = ''' # Opencode Configuration server: port: ${config.apiPort} host: 127.0.0.1 # API settings api: enabled: true auth_required: ${config.apiKey != null} # Logging log: level: info path: ${config.configPath}/logs '''; final escapedContent = configContent.replaceAll("'", "'\\''"); await _tunnel.execute('mkdir -p ${config.configPath}'); await _tunnel.execute("echo '$escapedContent' \u003e ${config.configPath}/config.yaml"); } }