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
This commit is contained in:
331
lib/data/provider/opencode/opencode.dart
Normal file
331
lib/data/provider/opencode/opencode.dart
Normal file
@@ -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<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;
|
||||
}
|
||||
Reference in New Issue
Block a user