diff --git a/README_OPCODE.md b/README_OPCODE.md new file mode 100644 index 00000000..bb2fbc31 --- /dev/null +++ b/README_OPCODE.md @@ -0,0 +1,125 @@ +# Flutter Opencode Client + +基于 [flutter_server_box](https://github.com/lollipopkit/flutter_server_box) 修改的 Android Opencode (opencode.ai) API 访问客户端。 + +## 功能特性 + +- 🔐 **SSH 隧道连接**: 通过 SSH 安全连接到远程服务器 +- 🤖 **Opencode AI**: 集成 opencode.ai API,提供智能助手功能 +- 📲 **自动安装**: 一键自动在服务器上安装 Opencode +- 🔑 **密钥管理**: 安全存储 API 密钥到服务器端(明文存储) +- 💬 **聊天界面**: 现代化的聊天界面,支持会话历史 +- 📱 **Android 支持**: 专为 Android 优化的原生体验 + +## 安装 + +### 要求 + +- Flutter 3.11.0+ +- Dart 3.0+ +- Android SDK 21+ + +### 克隆项目 + +```bash +git clone https://github.com/yourusername/flutter_opencode_client.git +cd flutter_opencode_client +``` + +### 安装依赖 + +```bash +flutter pub get +``` + +### 运行 + +```bash +flutter run +``` + +### 构建 APK + +```bash +flutter build apk --release +``` + +## 使用说明 + +### 1. 添加服务器 + +在 ServerBox 界面添加你的 SSH 服务器信息: +- 服务器 IP/域名 +- 端口 (默认 22) +- 用户名 +- 密码或私钥 + +### 2. 启动 Opencode + +在服务器详情页点击 **Opencode** 按钮: +- 首次使用会显示安装向导 +- 配置 Opencode 安装路径(默认 `/usr/local/bin/opencode`) +- 配置 API 端口(默认 `8080`) +- 输入 Opencode API Key(可选) +- 点击 "Quick Install & Connect" + +### 3. 开始对话 + +安装完成后自动进入聊天界面: +- 输入消息与 Opencode AI 对话 +- 查看会话历史 +- 创建多个独立会话 + +### 4. API 密钥管理 + +在设置中管理 API 密钥: +- 密钥以明文形式存储在服务器 `~/.config/opencode/api_key.txt` +- 文件权限设置为 600(仅所有者可读写) +- 每个服务器可配置独立密钥 + +## 项目结构 + +``` +lib/ +├── data/ +│ ├── model/opencode/ +│ │ ├── models.dart # 数据模型 +│ │ └── ssh_tunnel.dart # SSH 隧道管理 +│ ├── provider/opencode/ +│ │ └── opencode.dart # Riverpod 状态管理 +│ ├── store/ +│ │ └── opencode_store.dart # 本地存储 +│ └── model/app/menu/ +│ └── server_func.dart # 服务器功能按钮 +├── view/page/opencode/ +│ ├── chat_page.dart # 聊天界面 +│ ├── setup_page.dart # 安装向导 +│ └── key_manager_page.dart # 密钥管理 +└── main.dart +``` + +## 安全性说明 + +⚠️ **重要提示** + +1. **API 密钥存储**: 密钥以明文形式存储在服务器端,文件权限设置为 600 +2. **SSH 连接**: 所有通信通过 SSH 隧道加密 +3. **服务器安全**: 请确保您的服务器已妥善保护,仅允许授权访问 +4. **密钥传输**: 密钥通过 SSH 通道传输到服务器,不在公网明文传输 + +## 依赖项 + +- `dartssh2`: SSH 客户端 +- `flutter_riverpod`: 状态管理 +- `hive_ce_flutter`: 本地存储 +- `freezed_annotation`: 代码生成 +- 其他 flutter_server_box 原有依赖 + +## 许可证 + +基于 flutter_server_box 的许可证 (GPL-3.0) + +## 致谢 + +- [flutter_server_box](https://github.com/lollipopkit/flutter_server_box) - 基础项目 +- [Opencode](https://opencode.ai) - AI 助手平台 diff --git a/lib/data/model/app/menu/server_func.dart b/lib/data/model/app/menu/server_func.dart index 9c4832cd..de3ede8e 100644 --- a/lib/data/model/app/menu/server_func.dart +++ b/lib/data/model/app/menu/server_func.dart @@ -13,7 +13,8 @@ enum ServerFuncBtn { iperf(), // pve(), systemd(1058), - portForward(1340); + portForward(1340), + opencode(1352); // 添加 Opencode 按钮 final int? addedVersion; @@ -36,6 +37,13 @@ enum ServerFuncBtn { } } + // 自动添加 Opencode 按钮 + if (opencode.addedVersion != null && cur >= opencode.addedVersion!) { + if (!list.contains(opencode.index)) { + list.add(opencode.index); + } + } + if (list.length > originalLength) { prop.put(list); } @@ -62,6 +70,7 @@ enum ServerFuncBtn { iperf => Icons.speed, systemd => MingCute.plugin_2_fill, portForward => Icons.compare_arrows, + opencode => Icons.smart_toy, // Opencode 图标 }; String get toStr => switch (this) { @@ -74,5 +83,6 @@ enum ServerFuncBtn { iperf => 'iperf', systemd => 'Systemd', portForward => libL10n.portForward, + opencode => 'Opencode', // Opencode 按钮文字 }; } diff --git a/lib/data/model/opencode/models.dart b/lib/data/model/opencode/models.dart new file mode 100644 index 00000000..0b1960f0 --- /dev/null +++ b/lib/data/model/opencode/models.dart @@ -0,0 +1,248 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:dartssh2/dartssh2.dart'; +import 'package:flutter/foundation.dart'; + +/// Opencode API 响应模型 +class OpencodeResponse { + final String id; + final String? content; + final String? error; + final Map? toolCalls; + final bool isComplete; + final DateTime timestamp; + + OpencodeResponse({ + required this.id, + this.content, + this.error, + this.toolCalls, + this.isComplete = false, + DateTime? timestamp, + }) : timestamp = timestamp ?? DateTime.now(); + + factory OpencodeResponse.fromJson(Map json) { + return OpencodeResponse( + id: json['id'] ?? '', + content: json['content'], + error: json['error'], + toolCalls: json['tool_calls'], + isComplete: json['is_complete'] ?? false, + timestamp: json['timestamp'] != null + ? DateTime.parse(json['timestamp']) + : DateTime.now(), + ); + } + + Map toJson() => { + 'id': id, + 'content': content, + 'error': error, + 'tool_calls': toolCalls, + 'is_complete': isComplete, + 'timestamp': timestamp.toIso8601String(), + }; +} + +/// Opencode 消息模型 +class OpencodeMessage { + final String id; + final String role; // 'user', 'assistant', 'system' + final String content; + final DateTime timestamp; + final List>? toolCalls; + final Map? toolResults; + + OpencodeMessage({ + required this.id, + required this.role, + required this.content, + DateTime? timestamp, + this.toolCalls, + this.toolResults, + }) : timestamp = timestamp ?? DateTime.now(); + + factory OpencodeMessage.user(String content) { + return OpencodeMessage( + id: _generateId(), + role: 'user', + content: content, + ); + } + + factory OpencodeMessage.assistant(String content) { + return OpencodeMessage( + id: _generateId(), + role: 'assistant', + content: content, + ); + } + + factory OpencodeMessage.system(String content) { + return OpencodeMessage( + id: _generateId(), + role: 'system', + content: content, + ); + } + + factory OpencodeMessage.fromJson(Map json) { + return OpencodeMessage( + id: json['id'] ?? _generateId(), + role: json['role'] ?? 'user', + content: json['content'] ?? '', + timestamp: json['timestamp'] != null + ? DateTime.parse(json['timestamp']) + : DateTime.now(), + toolCalls: json['tool_calls'] != null + ? List>.from(json['tool_calls']) + : null, + toolResults: json['tool_results'], + ); + } + + Map toJson() => { + 'id': id, + 'role': role, + 'content': content, + 'timestamp': timestamp.toIso8601String(), + 'tool_calls': toolCalls, + 'tool_results': toolResults, + }; + + static String _generateId() { + return DateTime.now().millisecondsSinceEpoch.toString(); + } +} + +/// Opencode 会话配置 +class OpencodeSession { + final String id; + final String serverId; + final String serverName; + final List messages; + final DateTime createdAt; + DateTime updatedAt; + String? title; + + OpencodeSession({ + required this.id, + required this.serverId, + required this.serverName, + this.messages = const [], + DateTime? createdAt, + DateTime? updatedAt, + this.title, + }) : createdAt = createdAt ?? DateTime.now(), + updatedAt = updatedAt ?? DateTime.now(); + + factory OpencodeSession.create({ + required String serverId, + required String serverName, + String? title, + }) { + return OpencodeSession( + id: _generateId(), + serverId: serverId, + serverName: serverName, + title: title, + messages: [ + OpencodeMessage.system( + 'You are Opencode, a helpful AI assistant running inside an SSH session. ' + 'You have access to the server filesystem and can execute commands via tools.', + ), + ], + ); + } + + void addMessage(OpencodeMessage message) { + messages.add(message); + updatedAt = DateTime.now(); + } + + Map toJson() => { + 'id': id, + 'server_id': serverId, + 'server_name': serverName, + 'messages': messages.map((m) => m.toJson()).toList(), + 'created_at': createdAt.toIso8601String(), + 'updated_at': updatedAt.toIso8601String(), + 'title': title, + }; + + factory OpencodeSession.fromJson(Map json) { + return OpencodeSession( + id: json['id'] ?? _generateId(), + serverId: json['server_id'] ?? '', + serverName: json['server_name'] ?? '', + messages: (json['messages'] as List?) + ?.map((m) => OpencodeMessage.fromJson(m)) + .toList() ?? + [], + createdAt: json['created_at'] != null + ? DateTime.parse(json['created_at']) + : DateTime.now(), + updatedAt: json['updated_at'] != null + ? DateTime.parse(json['updated_at']) + : DateTime.now(), + title: json['title'], + ); + } + + static String _generateId() { + return DateTime.now().millisecondsSinceEpoch.toString(); + } +} + +/// Opencode 安装状态 +enum OpencodeInstallStatus { + notInstalled, + installing, + installed, + error, +} + +/// Opencode 服务器配置 +class OpencodeServerConfig { + final String serverId; + String opencodePath; + String configPath; + String apiPort; + bool autoStart; + Map envVars; + String? apiKey; // Opencode API Key + + OpencodeServerConfig({ + required this.serverId, + this.opencodePath = '/usr/local/bin/opencode', + this.configPath = '~/.config/opencode', + this.apiPort = '8080', + this.autoStart = true, + this.envVars = const {}, + this.apiKey, + }); + + Map toJson() => { + 'server_id': serverId, + 'opencode_path': opencodePath, + 'config_path': configPath, + 'api_port': apiPort, + 'auto_start': autoStart, + 'env_vars': envVars, + 'api_key': apiKey, + }; + + factory OpencodeServerConfig.fromJson(Map json) { + return OpencodeServerConfig( + serverId: json['server_id'] ?? '', + opencodePath: json['opencode_path'] ?? '/usr/local/bin/opencode', + configPath: json['config_path'] ?? '~/.config/opencode', + apiPort: json['api_port'] ?? '8080', + autoStart: json['auto_start'] ?? true, + envVars: Map.from(json['env_vars'] ?? {}), + apiKey: json['api_key'], + ); + } +} diff --git a/lib/data/model/opencode/ssh_tunnel.dart b/lib/data/model/opencode/ssh_tunnel.dart new file mode 100644 index 00000000..45a0c6d8 --- /dev/null +++ b/lib/data/model/opencode/ssh_tunnel.dart @@ -0,0 +1,388 @@ +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"); + } +} diff --git a/lib/data/provider/opencode/opencode.dart b/lib/data/provider/opencode/opencode.dart new file mode 100644 index 00000000..ba119281 --- /dev/null +++ b/lib/data/provider/opencode/opencode.dart @@ -0,0 +1,331 @@ +import 'dart:async'; + +import 'package:fl_lib/fl_lib.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:server_box/data/model/opencode/models.dart'; +import 'package:server_box/data/model/opencode/ssh_tunnel.dart'; +import 'package:server_box/data/model/server/server_private_info.dart'; +import 'package:server_box/data/store/opencode_store.dart'; + +part 'opencode.g.dart'; +part 'opencode.freezed.dart'; + +/// Opencode 连接状态 +@freezed +abstract class OpencodeConnectionState with _$OpencodeConnectionState { + const factory OpencodeConnectionState({ + @Default(OpencodeConnectionStatus.disconnected) OpencodeConnectionStatus status, + String? error, + OpencodeInstallStatus? installStatus, + String? serverId, + }) = _OpencodeConnectionState; +} + +enum OpencodeConnectionStatus { + disconnected, + connecting, + connected, + checkingInstallation, + installing, + startingServer, + error, +} + +/// Opencode 会话状态 +@freezed +abstract class OpencodeSessionState with _$OpencodeSessionState { + const factory OpencodeSessionState({ + OpencodeSession? currentSession, + @Default([]) List sessions, + @Default(false) bool isLoading, + String? error, + }) = _OpencodeSessionState; +} + +/// 全局 Opencode 连接管理器 +@Riverpod(keepAlive: true) +class OpencodeConnection extends _$OpencodeConnection { + OpencodeSSHTunnel? _tunnel; + OpencodeInstaller? _installer; + OpencodeServerConfig? _config; + Spi? _spi; + + @override + OpencodeConnectionState build() { + return const OpencodeConnectionState(); + } + + OpencodeSSHTunnel? get tunnel => _tunnel; + bool get isConnected => _tunnel?.isConnected ?? false; + + Future connect(Spi spi, {String? privateKey, String? passphrase}) async { + state = state.copyWith( + status: OpencodeConnectionStatus.connecting, + serverId: spi.id, + ); + + try { + await disconnect(); + + _tunnel = OpencodeSSHTunnel(); + _spi = spi; + + await _tunnel!.connect(spi, privateKey: privateKey, passphrase: passphrase); + _installer = OpencodeInstaller(_tunnel!); + + state = state.copyWith(status: OpencodeConnectionStatus.connected); + } catch (e) { + state = state.copyWith( + status: OpencodeConnectionStatus.error, + error: e.toString(), + ); + } + } + + Future checkAndInstall({OpencodeServerConfig? config}) async { + if (_tunnel == null || !_tunnel!.isConnected) { + state = state.copyWith( + status: OpencodeConnectionStatus.error, + error: 'Not connected', + ); + return; + } + + _config = config ?? OpencodeServerConfig(serverId: _spi!.id); + + state = state.copyWith(status: OpencodeConnectionStatus.checkingInstallation); + + try { + final installStatus = await _tunnel!.checkInstallation(_config!.opencodePath); + + if (installStatus == OpencodeInstallStatus.installed) { + state = state.copyWith( + status: OpencodeConnectionStatus.connected, + installStatus: installStatus, + ); + return; + } + + state = state.copyWith(status: OpencodeConnectionStatus.installing); + + final success = await _installer!.fullInstall( + config: _config!, + onStatus: (status) {}, + ); + + if (success) { + state = state.copyWith( + status: OpencodeConnectionStatus.connected, + installStatus: OpencodeInstallStatus.installed, + ); + + await OpencodeConfigStore().saveConfig(_config!); + } else { + state = state.copyWith( + status: OpencodeConnectionStatus.error, + error: 'Installation failed', + ); + } + } catch (e) { + state = state.copyWith( + status: OpencodeConnectionStatus.error, + error: e.toString(), + ); + } + } + + Future startAPIServer() async { + if (_tunnel == null || _config == null) return; + + state = state.copyWith(status: OpencodeConnectionStatus.startingServer); + + try { + await _tunnel!.startAPIServer( + opencodePath: _config!.opencodePath, + configPath: _config!.configPath, + port: _config!.apiPort, + apiKey: _config!.apiKey, + envVars: _config!.envVars, + ); + + state = state.copyWith(status: OpencodeConnectionStatus.connected); + } catch (e) { + state = state.copyWith( + status: OpencodeConnectionStatus.error, + error: e.toString(), + ); + } + } + + Future disconnect() async { + if (_tunnel != null) { + await _tunnel!.disconnect(); + _tunnel = null; + } + _installer = null; + _config = null; + _spi = null; + + state = const OpencodeConnectionState(); + } + + Future sendMessage(OpencodeMessage message, List history) async { + if (_tunnel == null || _config == null) { + throw Exception('Not connected'); + } + + await _tunnel!.sendMessage( + port: _config!.apiPort, + message: message, + history: history, + apiKey: _config!.apiKey, + ); + } +} + +/// Opencode 会话管理器 +@Riverpod(keepAlive: true) +class OpencodeSessionManager extends _$OpencodeSessionManager { + late final OpencodeSessionStore _store; + + @override + OpencodeSessionState build() { + _store = OpencodeSessionStore(); + _loadSessions(); + return const OpencodeSessionState(); + } + + Future _loadSessions() async { + state = state.copyWith(isLoading: true); + try { + final sessions = _store.getAllSessions(); + state = state.copyWith( + sessions: sessions, + isLoading: false, + ); + } catch (e) { + state = state.copyWith( + isLoading: false, + error: e.toString(), + ); + } + } + + Future createSession({ + required String serverId, + required String serverName, + String? title, + }) async { + final session = OpencodeSession.create( + serverId: serverId, + serverName: serverName, + title: title, + ); + + await _store.saveSession(session); + + final sessions = [...state.sessions, session]; + sessions.sort((a, b) => b.updatedAt.compareTo(a.updatedAt)); + + state = state.copyWith( + sessions: sessions, + currentSession: session, + ); + + return session; + } + + Future loadSession(String sessionId) async { + final session = _store.getSession(sessionId); + if (session != null) { + state = state.copyWith(currentSession: session); + } + } + + Future deleteSession(String sessionId) async { + await _store.deleteSession(sessionId); + + final sessions = state.sessions.where((s) => s.id != sessionId).toList(); + + state = state.copyWith( + sessions: sessions, + currentSession: state.currentSession?.id == sessionId + ? null + : state.currentSession, + ); + } + + Future addMessage(OpencodeMessage message) async { + final currentSession = state.currentSession; + if (currentSession == null) return; + + currentSession.addMessage(message); + await _store.updateSessionMessages(currentSession); + + final sessions = state.sessions.map((s) { + if (s.id == currentSession.id) { + return currentSession; + } + return s; + }).toList(); + + sessions.sort((a, b) => b.updatedAt.compareTo(a.updatedAt)); + + state = state.copyWith( + sessions: sessions, + currentSession: currentSession, + ); + } + + Future updateSessionTitle(String sessionId, String title) async { + final session = _store.getSession(sessionId); + if (session == null) return; + + final updatedSession = OpencodeSession( + id: session.id, + serverId: session.serverId, + serverName: session.serverName, + messages: session.messages, + createdAt: session.createdAt, + updatedAt: DateTime.now(), + title: title, + ); + + await _store.saveSession(updatedSession); + + final sessions = state.sessions.map((s) { + if (s.id == sessionId) return updatedSession; + return s; + }).toList(); + + state = state.copyWith( + sessions: sessions, + currentSession: state.currentSession?.id == sessionId + ? updatedSession + : state.currentSession, + ); + } + + Future refresh() async { + await _loadSessions(); + } +} + +@riverpod +Stream opencodeOutputStream(OpencodeOutputStreamRef ref) { + final tunnel = ref.read(opencodeConnectionProvider.notifier).tunnel; + if (tunnel == null) { + return const Stream.empty(); + } + return tunnel.outputStream; +} + +@riverpod +Stream opencodeResponseStream(OpencodeResponseStreamRef ref) { + final tunnel = ref.read(opencodeConnectionProvider.notifier).tunnel; + if (tunnel == null) { + return const Stream.empty(); + } + return tunnel.responseStream; +} diff --git a/lib/data/store/opencode_store.dart b/lib/data/store/opencode_store.dart new file mode 100644 index 00000000..555e318c --- /dev/null +++ b/lib/data/store/opencode_store.dart @@ -0,0 +1,190 @@ +import 'dart:convert'; + +import 'package:fl_lib/fl_lib.dart'; +import 'package:server_box/data/model/opencode/models.dart'; + +/// Opencode 会话存储 +class OpencodeSessionStore { + static const _boxName = 'opencode_sessions'; + late final Box _box; + + static final OpencodeSessionStore _instance = OpencodeSessionStore._internal(); + factory OpencodeSessionStore() => _instance; + OpencodeSessionStore._internal(); + + Future init() async { + _box = await Hive.openBox(_boxName); + } + + Future saveSession(OpencodeSession session) async { + final key = 'session_${session.id}'; + await _box.put(key, jsonEncode(session.toJson())); + await _updateSessionList(session.id, add: true); + } + + OpencodeSession? getSession(String sessionId) { + final key = 'session_$sessionId'; + final data = _box.get(key); + if (data == null) return null; + + try { + final json = jsonDecode(data) as Map; + return OpencodeSession.fromJson(json); + } catch (e) { + return null; + } + } + + Future deleteSession(String sessionId) async { + final key = 'session_$sessionId'; + await _box.delete(key); + await _updateSessionList(sessionId, add: false); + } + + List getAllSessions() { + final sessionIds = _box.get('session_list')?.split(',') ?? []; + final sessions = []; + + for (final id in sessionIds) { + if (id.isEmpty) continue; + final session = getSession(id); + if (session != null) { + sessions.add(session); + } + } + + sessions.sort((a, b) => b.updatedAt.compareTo(a.updatedAt)); + return sessions; + } + + List getSessionsByServer(String serverId) { + return getAllSessions() + .where((s) => s.serverId == serverId) + .toList(); + } + + Future _updateSessionList(String sessionId, {required bool add}) async { + final listStr = _box.get('session_list') ?? ''; + final list = listStr.split(',').where((s) => s.isNotEmpty).toList(); + + if (add) { + if (!list.contains(sessionId)) { + list.add(sessionId); + } + } else { + list.remove(sessionId); + } + + await _box.put('session_list', list.join(',')); + } + + Future updateSessionMessages(OpencodeSession session) async { + await saveSession(session); + } + + Future clearAllSessions() async { + await _box.clear(); + } +} + +/// Opencode 服务器配置存储 +class OpencodeConfigStore { + static const _boxName = 'opencode_configs'; + late final Box _box; + + static final OpencodeConfigStore _instance = OpencodeConfigStore._internal(); + factory OpencodeConfigStore() => _instance; + OpencodeConfigStore._internal(); + + Future init() async { + _box = await Hive.openBox(_boxName); + } + + Future saveConfig(OpencodeServerConfig config) async { + final key = 'config_${config.serverId}'; + await _box.put(key, jsonEncode(config.toJson())); + } + + OpencodeServerConfig getConfig(String serverId) { + final key = 'config_$serverId'; + final data = _box.get(key); + + if (data == null) { + return OpencodeServerConfig(serverId: serverId); + } + + try { + final json = jsonDecode(data) as Map; + return OpencodeServerConfig.fromJson(json); + } catch (e) { + return OpencodeServerConfig(serverId: serverId); + } + } + + Future deleteConfig(String serverId) async { + final key = 'config_$serverId'; + await _box.delete(key); + } +} + +/// Opencode API 密钥存储 +class OpencodeKeyStore { + static const _boxName = 'opencode_keys'; + late final Box _box; + + static final OpencodeKeyStore _instance = OpencodeKeyStore._internal(); + factory OpencodeKeyStore() => _instance; + OpencodeKeyStore._internal(); + + Future init() async { + _box = await Hive.openBox(_boxName); + } + + /// 保存服务器 API 密钥 + Future saveServerKey(String serverId, { + required String apiKey, + String? description, + }) async { + final key = 'key_$serverId'; + final data = jsonEncode({ + 'server_id': serverId, + 'api_key': apiKey, // 注意:这里存储明文密钥 + 'description': description, + 'updated_at': DateTime.now().toIso8601String(), + }); + await _box.put(key, data); + } + + /// 获取服务器密钥 + String? getServerKey(String serverId) { + final key = 'key_$serverId'; + final data = _box.get(key); + if (data == null) return null; + + try { + final json = jsonDecode(data) as Map; + return json['api_key'] as String?; + } catch (e) { + return null; + } + } + + /// 获取服务器密钥信息 + Map? getServerKeyInfo(String serverId) { + final key = 'key_$serverId'; + final data = _box.get(key); + if (data == null) return null; + + try { + return jsonDecode(data) as Map; + } catch (e) { + return null; + } + } + + /// 删除服务器密钥 + Future deleteServerKey(String serverId) async { + final key = 'key_$serverId'; + await _box.delete(key); + } +} diff --git a/lib/view/page/opencode/chat_page.dart b/lib/view/page/opencode/chat_page.dart new file mode 100644 index 00000000..95de2b23 --- /dev/null +++ b/lib/view/page/opencode/chat_page.dart @@ -0,0 +1,485 @@ +import 'dart:async'; + +import 'package:fl_lib/fl_lib.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:server_box/data/model/opencode/models.dart'; +import 'package:server_box/data/provider/opencode/opencode.dart'; +import 'package:server_box/data/provider/server/single.dart'; +import 'package:server_box/data/res/store.dart'; + +/// Opencode 聊天页面 +class OpencodeChatPage extends ConsumerStatefulWidget { + final String serverId; + final String? sessionId; + + const OpencodeChatPage({ + super.key, + required this.serverId, + this.sessionId, + }); + + @override + ConsumerState createState() => _OpencodeChatPageState(); +} + +class _OpencodeChatPageState extends ConsumerState { + final TextEditingController _messageController = TextEditingController(); + final ScrollController _scrollController = ScrollController(); + final List _messages = []; + bool _isSending = false; + StreamSubscription? _outputSubscription; + String _streamingContent = ''; + + @override + void initState() { + super.initState(); + _initSession(); + _listenToOutput(); + } + + @override + void dispose() { + _messageController.dispose(); + _scrollController.dispose(); + _outputSubscription?.cancel(); + super.dispose(); + } + + void _initSession() { + WidgetsBinding.instance.addPostFrameCallback((_) { + final sessionManager = ref.read(opencodeSessionManagerProvider.notifier); + + if (widget.sessionId != null) { + sessionManager.loadSession(widget.sessionId!); + } else { + final serverState = ref.read(serverProvider(widget.serverId)); + sessionManager.createSession( + serverId: widget.serverId, + serverName: serverState.spi.name, + ); + } + }); + } + + void _listenToOutput() { + final outputStream = ref.read(opencodeOutputStreamProvider); + _outputSubscription = outputStream.listen((data) { + setState(() { + _streamingContent += data; + }); + _scrollToBottom(); + }); + } + + void _scrollToBottom() { + if (_scrollController.hasClients) { + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + } + } + + Future _sendMessage() async { + final text = _messageController.text.trim(); + if (text.isEmpty || _isSending) return; + + _messageController.clear(); + + final message = OpencodeMessage.user(text); + + setState(() { + _messages.add(message); + _isSending = true; + _streamingContent = ''; + }); + + await ref.read(opencodeSessionManagerProvider.notifier).addMessage(message); + + try { + final connection = ref.read(opencodeConnectionProvider.notifier); + await connection.sendMessage(message, _messages); + + await Future.delayed(const Duration(seconds: 2)); + + if (_streamingContent.isNotEmpty) { + final responseMessage = OpencodeMessage.assistant(_streamingContent); + setState(() { + _messages.add(responseMessage); + }); + await ref.read(opencodeSessionManagerProvider.notifier).addMessage(responseMessage); + } + } catch (e) { + final errorMessage = OpencodeMessage.assistant('Error: $e'); + setState(() { + _messages.add(errorMessage); + }); + } finally { + setState(() { + _isSending = false; + _streamingContent = ''; + }); + _scrollToBottom(); + } + } + + @override + Widget build(BuildContext context) { + final sessionState = ref.watch(opencodeSessionManagerProvider); + final connectionState = ref.watch(opencodeConnectionProvider); + + return Scaffold( + appBar: CustomAppBar( + title: Text(sessionState.currentSession?.title ?? 'Opencode Chat'), + actions: [ + IconButton( + icon: const Icon(Icons.history), + onPressed: _showSessionHistory, + ), + IconButton( + icon: const Icon(Icons.settings), + onPressed: _showSettings, + ), + ], + ), + body: Column( + children: [ + if (connectionState.status != OpencodeConnectionStatus.connected) + _buildStatusBar(connectionState), + + Expanded( + child: _buildMessageList(sessionState), + ), + + _buildInputArea(), + ], + ), + ); + } + + Widget _buildStatusBar(OpencodeConnectionState state) { + Color color; + String text; + IconData icon; + + switch (state.status) { + case OpencodeConnectionStatus.connecting: + color = Colors.orange; + text = 'Connecting...'; + icon = Icons.sync; + case OpencodeConnectionStatus.checkingInstallation: + color = Colors.blue; + text = 'Checking Opencode...'; + icon = Icons.search; + case OpencodeConnectionStatus.installing: + color = Colors.purple; + text = 'Installing Opencode...'; + icon = Icons.download; + case OpencodeConnectionStatus.startingServer: + color = Colors.teal; + text = 'Starting API server...'; + icon = Icons.play_arrow; + case OpencodeConnectionStatus.error: + color = Colors.red; + text = state.error ?? 'Connection error'; + icon = Icons.error; + default: + return const SizedBox.shrink(); + } + + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + color: color.withOpacity(0.1), + child: Row( + children: [ + Icon(icon, color: color, size: 18), + const SizedBox(width: 8), + Expanded( + child: Text( + text, + style: TextStyle(color: color, fontSize: 13), + ), + ), + if (state.status == OpencodeConnectionStatus.error) + TextButton( + onPressed: _retryConnection, + child: const Text('Retry'), + ), + ], + ), + ); + } + + Widget _buildMessageList(OpencodeSessionState sessionState) { + final allMessages = [...sessionState.currentSession?.messages ?? [], ..._messages]; + + if (allMessages.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.chat_bubble_outline, + size: 64, + color: Colors.grey[400], + ), + const SizedBox(height: 16), + Text( + 'Start a conversation with Opencode', + style: TextStyle( + color: Colors.grey[600], + fontSize: 16, + ), + ), + ], + ), + ); + } + + return ListView.builder( + controller: _scrollController, + padding: const EdgeInsets.all(16), + itemCount: allMessages.length + (_isSending ? 1 : 0), + itemBuilder: (context, index) { + if (index == allMessages.length) { + return _buildStreamingIndicator(); + } + + final message = allMessages[index]; + return _buildMessageBubble(message); + }, + ); + } + + Widget _buildMessageBubble(OpencodeMessage message) { + final isUser = message.role == 'user'; + final isSystem = message.role == 'system'; + + if (isSystem) { + return const SizedBox.shrink(); + } + + return Align( + alignment: isUser ? Alignment.centerRight : Alignment.centerLeft, + child: Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width * 0.8, + ), + decoration: BoxDecoration( + color: isUser ? UIs.primaryColor : Colors.grey[200], + borderRadius: BorderRadius.circular(16).copyWith( + bottomRight: isUser ? const Radius.circular(4) : null, + bottomLeft: !isUser ? const Radius.circular(4) : null, + ), + ), + child: Text( + message.content, + style: TextStyle( + color: isUser ? Colors.white : Colors.black87, + fontSize: 15, + ), + ), + ), + ); + } + + Widget _buildStreamingIndicator() { + return Align( + alignment: Alignment.centerLeft, + child: Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: Colors.grey[200], + borderRadius: BorderRadius.circular(16).copyWith( + bottomLeft: const Radius.circular(4), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _streamingContent.isEmpty ? 'Thinking' : _streamingContent, + style: TextStyle( + color: Colors.black87, + fontSize: 15, + ), + ), + if (_streamingContent.isEmpty) ...[ + const SizedBox(width: 4), + SizedBox( + width: 12, + height: 12, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + Colors.grey[600]!, + ), + ), + ), + ], + ], + ), + ), + ); + } + + Widget _buildInputArea() { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, -5), + ), + ], + ), + child: SafeArea( + child: Row( + children: [ + Expanded( + child: TextField( + controller: _messageController, + decoration: InputDecoration( + hintText: 'Message Opencode...', + filled: true, + fillColor: Colors.grey[100], + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(24), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 14, + ), + ), + onSubmitted: (_) => _sendMessage(), + ), + ), + const SizedBox(width: 8), + FloatingActionButton( + mini: true, + onPressed: _isSending ? null : _sendMessage, + child: _isSending + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : const Icon(Icons.send), + ), + ], + ), + ), + ); + } + + void _showSessionHistory() { + final sessionManager = ref.read(opencodeSessionManagerProvider.notifier); + final sessions = ref.read(opencodeSessionManagerProvider).sessions + .where((s) => s.serverId == widget.serverId) + .toList(); + + showModalBottomSheet( + context: context, + builder: (context) => Container( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Session History', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 16), + Expanded( + child: ListView.builder( + itemCount: sessions.length, + itemBuilder: (context, index) { + final session = sessions[index]; + return ListTile( + title: Text(session.title ?? 'Untitled Session'), + subtitle: Text( + '${session.messages.length} messages • ${_formatDate(session.updatedAt)}', + ), + onTap: () { + Navigator.pop(context); + sessionManager.loadSession(session.id); + }, + trailing: IconButton( + icon: const Icon(Icons.delete, size: 20), + onPressed: () async { + await sessionManager.deleteSession(session.id); + Navigator.pop(context); + }, + ), + ); + }, + ), + ), + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: () { + Navigator.pop(context); + final serverState = ref.read(serverProvider(widget.serverId)); + sessionManager.createSession( + serverId: widget.serverId, + serverName: serverState.spi.name, + ); + }, + icon: const Icon(Icons.add), + label: const Text('New Session'), + ), + ), + ], + ), + ), + ); + } + + void _showSettings() { + context.push('/opencode/settings/${widget.serverId}'); + } + + void _retryConnection() { + final connection = ref.read(opencodeConnectionProvider.notifier); + final serverState = ref.read(serverProvider(widget.serverId)); + + final pki = serverState.spi.keyId != null + ? Stores.key.get(serverState.spi.keyId!) + : null; + + connection.connect( + serverState.spi, + privateKey: pki?.key, + passphrase: pki?.password, + ); + } + + String _formatDate(DateTime date) { + final now = DateTime.now(); + final diff = now.difference(date); + + if (diff.inDays == 0) { + return '${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}'; + } else if (diff.inDays == 1) { + return 'Yesterday'; + } else { + return '${date.month}/${date.day}'; + } + } +} diff --git a/lib/view/page/opencode/key_manager_page.dart b/lib/view/page/opencode/key_manager_page.dart new file mode 100644 index 00000000..c36bc183 --- /dev/null +++ b/lib/view/page/opencode/key_manager_page.dart @@ -0,0 +1,328 @@ +import 'package:fl_lib/fl_lib.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:server_box/data/provider/server/single.dart'; +import 'package:server_box/data/store/opencode_store.dart'; + +/// Opencode API 密钥管理页面 +class OpencodeKeyManagerPage extends ConsumerStatefulWidget { + final String serverId; + + const OpencodeKeyManagerPage({ + super.key, + required this.serverId, + }); + + @override + ConsumerState createState() => _OpencodeKeyManagerPageState(); +} + +class _OpencodeKeyManagerPageState extends ConsumerState { + final _keyController = TextEditingController(); + final _descController = TextEditingController(); + bool _isSaving = false; + bool _obscureKey = true; + + @override + void initState() { + super.initState(); + _loadExistingKey(); + } + + @override + void dispose() { + _keyController.dispose(); + _descController.dispose(); + super.dispose(); + } + + void _loadExistingKey() { + final keyInfo = OpencodeKeyStore().getServerKeyInfo(widget.serverId); + if (keyInfo != null) { + _descController.text = keyInfo['description'] ?? ''; + } + } + + Future _saveKey() async { + if (_keyController.text.trim().isEmpty) { + context.showSnackBar('Please enter an API key'); + return; + } + + setState(() { + _isSaving = true; + }); + + try { + final apiKey = _keyController.text.trim(); + + // 保存到本地存储(明文) + await OpencodeKeyStore().saveServerKey( + widget.serverId, + apiKey: apiKey, + description: _descController.text, + ); + + // 通过 SSH 上传到服务器 + final tunnel = ref.read(opencodeConnectionProvider.notifier).tunnel; + if (tunnel != null && tunnel.isConnected) { + await tunnel.execute('mkdir -p ~/.config/opencode'); + final escapedKey = apiKey.replaceAll("'", "'\\''"); + await tunnel.execute("echo '$escapedKey' \u003e ~/.config/opencode/api_key.txt"); + await tunnel.execute('chmod 600 ~/.config/opencode/api_key.txt'); + } + + if (mounted) { + context.showSnackBar('API key saved successfully'); + Navigator.pop(context); + } + } catch (e) { + if (mounted) { + context.showSnackBar('Failed to save key: $e'); + } + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Future _deleteKey() async { + final confirmed = await context.showRoundDialog( + title: 'Delete API Key', + child: const Text('Are you sure you want to delete this API key?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: const Text('Delete'), + ), + ], + ); + + if (confirmed == true) { + try { + final tunnel = ref.read(opencodeConnectionProvider.notifier).tunnel; + if (tunnel != null && tunnel.isConnected) { + await tunnel.execute('rm -f ~/.config/opencode/api_key.txt'); + } + + await OpencodeKeyStore().deleteServerKey(widget.serverId); + + if (mounted) { + context.showSnackBar('API key deleted'); + setState(() { + _keyController.clear(); + _descController.clear(); + }); + } + } catch (e) { + if (mounted) { + context.showSnackBar('Failed to delete key: $e'); + } + } + } + } + + @override + Widget build(BuildContext context) { + final serverState = ref.watch(serverProvider(widget.serverId)); + final existingKey = OpencodeKeyStore().getServerKeyInfo(widget.serverId); + + return Scaffold( + appBar: CustomAppBar( + title: Text('Opencode API Key - ${serverState.spi.name}'), + actions: [ + if (existingKey != null) + IconButton( + icon: const Icon(Icons.delete), + onPressed: _deleteKey, + ), + ], + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSecurityCard(), + const SizedBox(height: 24), + + Text( + 'API Key Configuration', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + + TextFormField( + controller: _keyController, + obscureText: _obscureKey, + decoration: InputDecoration( + labelText: 'Opencode API Key', + hintText: existingKey != null + ? 'Enter new key to replace existing one' + : 'opc_...', + prefixIcon: const Icon(Icons.vpn_key), + suffixIcon: IconButton( + icon: Icon( + _obscureKey ? Icons.visibility_off : Icons.visibility, + ), + onPressed: () { + setState(() { + _obscureKey = !_obscureKey; + }); + }, + ), + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: 16), + + TextFormField( + controller: _descController, + decoration: const InputDecoration( + labelText: 'Description (Optional)', + hintText: 'e.g., Production API key', + prefixIcon: Icon(Icons.description), + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 24), + + if (existingKey != null) + _buildStorageInfo(existingKey), + + const SizedBox(height: 32), + + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: _isSaving ? null : _saveKey, + icon: _isSaving + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : const Icon(Icons.save), + label: Text(_isSaving ? 'Saving...' : 'Save API Key'), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildSecurityCard() { + return Card( + elevation: 0, + color: Colors.orange.withOpacity(0.1), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide(color: Colors.orange.withOpacity(0.3)), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.security, color: Colors.orange[700]), + const SizedBox(width: 8), + Text( + 'Security Notice', + style: TextStyle( + fontWeight: FontWeight.bold, + color: Colors.orange[700], + ), + ), + ], + ), + const SizedBox(height: 12), + const Text( + '• API keys are stored in plaintext on the server\n' + '• Keys are saved in ~/.config/opencode/api_key.txt with 600 permissions\n' + '• Make sure your server is secure and only accessible to you\n' + '• Consider using environment-specific keys', + style: TextStyle(fontSize: 13), + ), + ], + ), + ), + ); + } + + Widget _buildStorageInfo(Map keyInfo) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.grey[100], + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Current Key Info', + style: TextStyle( + fontWeight: FontWeight.bold, + color: Colors.grey[700], + ), + ), + const SizedBox(height: 8), + if (keyInfo['description'] != null) + _buildInfoRow('Description', keyInfo['description']), + _buildInfoRow( + 'Last Updated', + keyInfo['updated_at'] != null + ? keyInfo['updated_at'].toString().substring(0, 16) + : 'Unknown', + ), + ], + ), + ); + } + + Widget _buildInfoRow(String label, String value) { + return Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 100, + child: Text( + '$label:', + style: TextStyle( + color: Colors.grey[600], + fontSize: 13, + ), + ), + ), + Expanded( + child: Text( + value, + style: const TextStyle(fontSize: 13), + ), + ), + ], + ), + ); + } +} diff --git a/lib/view/page/opencode/setup_page.dart b/lib/view/page/opencode/setup_page.dart new file mode 100644 index 00000000..b08b5e29 --- /dev/null +++ b/lib/view/page/opencode/setup_page.dart @@ -0,0 +1,390 @@ +import 'package:fl_lib/fl_lib.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:server_box/data/model/opencode/models.dart'; +import 'package:server_box/data/provider/opencode/opencode.dart'; +import 'package:server_box/data/provider/server/single.dart'; +import 'package:server_box/data/res/store.dart'; +import 'package:server_box/data/store/opencode_store.dart'; + +/// Opencode 连接/安装向导页面 +class OpencodeSetupPage extends ConsumerStatefulWidget { + final String serverId; + + const OpencodeSetupPage({ + super.key, + required this.serverId, + }); + + @override + ConsumerState createState() => _OpencodeSetupPageState(); +} + +class _OpencodeSetupPageState extends ConsumerState { + final _formKey = GlobalKey(); + final _pathController = TextEditingController(text: '/usr/local/bin/opencode'); + final _configController = TextEditingController(text: '~/.config/opencode'); + final _portController = TextEditingController(text: '8080'); + final _apiKeyController = TextEditingController(); + + bool _autoStart = true; + bool _isConnecting = false; + String? _statusMessage; + + @override + void initState() { + super.initState(); + _loadExistingConfig(); + } + + @override + void dispose() { + _pathController.dispose(); + _configController.dispose(); + _portController.dispose(); + _apiKeyController.dispose(); + super.dispose(); + } + + void _loadExistingConfig() { + final config = OpencodeConfigStore().getConfig(widget.serverId); + _pathController.text = config.opencodePath; + _configController.text = config.configPath; + _portController.text = config.apiPort; + _autoStart = config.autoStart; + if (config.apiKey != null) { + _apiKeyController.text = config.apiKey!; + } + } + + Future _connect() async { + if (_isConnecting) return; + + setState(() { + _isConnecting = true; + _statusMessage = 'Connecting to server...'; + }); + + try { + final serverState = ref.read(serverProvider(widget.serverId)); + final connection = ref.read(opencodeConnectionProvider.notifier); + + final pki = serverState.spi.keyId != null + ? Stores.key.get(serverState.spi.keyId!) + : null; + + await connection.connect( + serverState.spi, + privateKey: pki?.key, + passphrase: pki?.password, + ); + + if (mounted) { + setState(() { + _statusMessage = 'Connected! Checking Opencode installation...'; + }); + + final config = OpencodeServerConfig( + serverId: widget.serverId, + opencodePath: _pathController.text, + configPath: _configController.text, + apiPort: _portController.text, + autoStart: _autoStart, + apiKey: _apiKeyController.text.isNotEmpty ? _apiKeyController.text : null, + ); + await OpencodeConfigStore().saveConfig(config); + + await connection.checkAndInstall(config: config); + } + } catch (e) { + if (mounted) { + setState(() { + _statusMessage = 'Error: $e'; + }); + context.showSnackBar('Connection failed: $e'); + } + } finally { + if (mounted) { + setState(() { + _isConnecting = false; + }); + } + } + } + + Future _quickInstall() async { + setState(() { + _isConnecting = true; + _statusMessage = 'Starting automatic installation...'; + }); + + try { + final connection = ref.read(opencodeConnectionProvider.notifier); + + final config = OpencodeServerConfig( + serverId: widget.serverId, + opencodePath: _pathController.text, + configPath: _configController.text, + apiPort: _portController.text, + autoStart: _autoStart, + apiKey: _apiKeyController.text.isNotEmpty ? _apiKeyController.text : null, + ); + + await connection.checkAndInstall(config: config); + + if (mounted) { + setState(() { + _statusMessage = 'Installation complete!'; + }); + + await Future.delayed(const Duration(seconds: 1)); + if (mounted) { + context.pushReplacement('/opencode/chat/${widget.serverId}'); + } + } + } catch (e) { + if (mounted) { + setState(() { + _statusMessage = 'Installation failed: $e'; + }); + } + } finally { + if (mounted) { + setState(() { + _isConnecting = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + final connectionState = ref.watch(opencodeConnectionProvider); + final serverState = ref.watch(serverProvider(widget.serverId)); + + if (connectionState.status == OpencodeConnectionStatus.connected) { + WidgetsBinding.instance.addPostFrameCallback((_) { + context.pushReplacement('/opencode/chat/${widget.serverId}'); + }); + } + + return Scaffold( + appBar: CustomAppBar( + title: Text('Setup Opencode - ${serverState.spi.name}'), + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildInfoCard(), + const SizedBox(height: 24), + + Text( + 'Configuration', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + + TextFormField( + controller: _pathController, + decoration: const InputDecoration( + labelText: 'Opencode Binary Path', + hintText: '/usr/local/bin/opencode', + prefixIcon: Icon(Icons.file_present), + border: OutlineInputBorder(), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter the path'; + } + return null; + }, + ), + const SizedBox(height: 16), + + TextFormField( + controller: _configController, + decoration: const InputDecoration( + labelText: 'Config Directory', + hintText: '~/.config/opencode', + prefixIcon: Icon(Icons.folder), + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 16), + + TextFormField( + controller: _portController, + decoration: const InputDecoration( + labelText: 'API Port', + hintText: '8080', + prefixIcon: Icon(Icons.settings_ethernet), + border: OutlineInputBorder(), + ), + keyboardType: TextInputType.number, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter the port'; + } + final port = int.tryParse(value); + if (port == null || port < 1 || port > 65535) { + return 'Invalid port number'; + } + return null; + }, + ), + const SizedBox(height: 16), + + TextFormField( + controller: _apiKeyController, + decoration: const InputDecoration( + labelText: 'Opencode API Key (Optional)', + hintText: 'opc_...', + prefixIcon: Icon(Icons.key), + border: OutlineInputBorder(), + ), + obscureText: true, + ), + const SizedBox(height: 16), + + SwitchListTile( + title: const Text('Auto-start API Server'), + subtitle: const Text('Automatically start Opencode server on connection'), + value: _autoStart, + onChanged: (value) { + setState(() { + _autoStart = value; + }); + }, + ), + + const SizedBox(height: 24), + + if (_statusMessage != null) ...[ + Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: _getStatusColor(connectionState.status).withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + children: [ + if (_isConnecting) + const Padding( + padding: EdgeInsets.only(bottom: 8), + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + Text( + _statusMessage!, + textAlign: TextAlign.center, + style: TextStyle( + color: _getStatusColor(connectionState.status), + ), + ), + ], + ), + ), + const SizedBox(height: 24), + ], + + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: _isConnecting ? null : _quickInstall, + icon: _isConnecting + ? const SizedBox.shrink() + : const Icon(Icons.rocket_launch), + label: Text(_isConnecting ? 'Please wait...' : 'Quick Install & Connect'), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + ), + ), + ), + const SizedBox(height: 12), + + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: _isConnecting ? null : _connect, + icon: const Icon(Icons.link), + label: const Text('Connect Only'), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + ), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildInfoCard() { + return Card( + elevation: 0, + color: UIs.primaryColor.withOpacity(0.1), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.info, color: UIs.primaryColor), + const SizedBox(width: 8), + Text( + 'About Opencode', + style: TextStyle( + fontWeight: FontWeight.bold, + color: UIs.primaryColor, + ), + ), + ], + ), + const SizedBox(height: 12), + const Text( + 'Opencode (opencode.ai) is an AI assistant for developers. ' + 'This app connects to your server via SSH to provide AI assistance.', + style: TextStyle(fontSize: 14), + ), + const SizedBox(height: 12), + const Text( + '• If Opencode is not installed, the app can auto-install it\n' + '• Your API key is saved in plaintext on the server\n' + '• All communication is encrypted via SSH tunnel', + style: TextStyle(fontSize: 13, color: Colors.black87), + ), + ], + ), + ), + ); + } + + Color _getStatusColor(OpencodeConnectionStatus status) { + switch (status) { + case OpencodeConnectionStatus.connected: + return Colors.green; + case OpencodeConnectionStatus.error: + return Colors.red; + case OpencodeConnectionStatus.installing: + case OpencodeConnectionStatus.startingServer: + return Colors.orange; + default: + return UIs.primaryColor; + } + } +} diff --git a/lib/view/widget/server_func_btns.dart b/lib/view/widget/server_func_btns.dart index 3b33a071..2f3c6d31 100644 --- a/lib/view/widget/server_func_btns.dart +++ b/lib/view/widget/server_func_btns.dart @@ -212,6 +212,10 @@ void _onTapMoreBtns(ServerFuncBtn value, Spi spi, BuildContext context, WidgetRe final args = SpiRequiredArgs(spi); PortForwardPage.route.go(context, args); break; + case ServerFuncBtn.opencode: + // Opencode AI Assistant + context.push('/opencode/setup/${spi.id}'); + break; } }