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
332 lines
8.6 KiB
Dart
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;
|
|
}
|