Files
flutter_opencode_client/lib/data/provider/opencode/opencode.dart
root 0f4fe33003 Add Opencode (opencode.ai) integration via SSH tunnel
Features:
- Opencode AI chat interface
- SSH tunnel for secure API communication
- Auto-install Opencode on remote servers
- API key management with server-side storage
- Session history and management
- Integration with flutter_server_box server list
2026-04-03 00:41:32 +08:00

332 lines
8.6 KiB
Dart

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<OpencodeSession> 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<void> 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<void> 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<void> 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<void> disconnect() async {
if (_tunnel != null) {
await _tunnel!.disconnect();
_tunnel = null;
}
_installer = null;
_config = null;
_spi = null;
state = const OpencodeConnectionState();
}
Future<void> sendMessage(OpencodeMessage message, List<OpencodeMessage> 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<void> _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<OpencodeSession> 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<void> loadSession(String sessionId) async {
final session = _store.getSession(sessionId);
if (session != null) {
state = state.copyWith(currentSession: session);
}
}
Future<void> 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<void> 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<void> 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<void> refresh() async {
await _loadSessions();
}
}
@riverpod
Stream<String> opencodeOutputStream(OpencodeOutputStreamRef ref) {
final tunnel = ref.read(opencodeConnectionProvider.notifier).tunnel;
if (tunnel == null) {
return const Stream.empty();
}
return tunnel.outputStream;
}
@riverpod
Stream<OpencodeResponse> opencodeResponseStream(OpencodeResponseStreamRef ref) {
final tunnel = ref.read(opencodeConnectionProvider.notifier).tunnel;
if (tunnel == null) {
return const Stream.empty();
}
return tunnel.responseStream;
}