224 lines
5.6 KiB
Dart
224 lines
5.6 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'package:web_socket_channel/web_socket_channel.dart';
|
|
import '../config/api_config.dart';
|
|
|
|
enum ChatConnectionState {
|
|
disconnected,
|
|
connecting,
|
|
connected,
|
|
reconnecting,
|
|
error,
|
|
}
|
|
|
|
/// Chat session info (v3 - dynamic sessions)
|
|
class ChatSession {
|
|
final String sessionId;
|
|
final String name;
|
|
final String? createdAt;
|
|
|
|
ChatSession({
|
|
required this.sessionId,
|
|
required this.name,
|
|
this.createdAt,
|
|
});
|
|
|
|
factory ChatSession.fromJson(Map<String, dynamic> json) {
|
|
return ChatSession(
|
|
sessionId: json['session_id'] ?? '',
|
|
name: json['name'] ?? 'Session',
|
|
createdAt: json['created_at'],
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Chat service that connects to Claude sessions (v3)
|
|
class ChatService {
|
|
WebSocketChannel? _channel;
|
|
String? _token;
|
|
|
|
final _messagesController = StreamController<Map<String, dynamic>>.broadcast();
|
|
final _stateController = StreamController<ChatConnectionState>.broadcast();
|
|
final _errorController = StreamController<String>.broadcast();
|
|
|
|
ChatConnectionState _currentState = ChatConnectionState.disconnected;
|
|
int _reconnectAttempts = 0;
|
|
Timer? _reconnectTimer;
|
|
Timer? _pingTimer;
|
|
bool _intentionalDisconnect = false;
|
|
|
|
Stream<Map<String, dynamic>> get messages => _messagesController.stream;
|
|
Stream<ChatConnectionState> get connectionState => _stateController.stream;
|
|
Stream<String> get errors => _errorController.stream;
|
|
ChatConnectionState get currentState => _currentState;
|
|
|
|
void setToken(String? token) {
|
|
_token = token;
|
|
}
|
|
|
|
void _setState(ChatConnectionState state) {
|
|
_currentState = state;
|
|
_stateController.add(state);
|
|
}
|
|
|
|
Future<void> connect() async {
|
|
if (_token == null) return;
|
|
if (_currentState == ChatConnectionState.connecting) return;
|
|
|
|
_intentionalDisconnect = false;
|
|
_setState(ChatConnectionState.connecting);
|
|
|
|
try {
|
|
_channel?.sink.close();
|
|
_channel = WebSocketChannel.connect(
|
|
Uri.parse('${ApiConfig.wsUrl}/ws/chat'),
|
|
);
|
|
|
|
// Send auth token immediately
|
|
_channel!.sink.add(jsonEncode({'token': _token}));
|
|
|
|
_channel!.stream.listen(
|
|
(data) {
|
|
try {
|
|
final message = jsonDecode(data);
|
|
_handleMessage(message);
|
|
} catch (e) {
|
|
_errorController.add('Failed to parse message: $e');
|
|
}
|
|
},
|
|
onError: (error) {
|
|
_errorController.add('WebSocket error: $error');
|
|
_setState(ChatConnectionState.error);
|
|
if (!_intentionalDisconnect) {
|
|
_scheduleReconnect();
|
|
}
|
|
},
|
|
onDone: () {
|
|
_setState(ChatConnectionState.disconnected);
|
|
if (!_intentionalDisconnect) {
|
|
_scheduleReconnect();
|
|
}
|
|
},
|
|
cancelOnError: false,
|
|
);
|
|
} catch (e) {
|
|
_errorController.add('Connection failed: $e');
|
|
_setState(ChatConnectionState.error);
|
|
if (!_intentionalDisconnect) {
|
|
_scheduleReconnect();
|
|
}
|
|
}
|
|
}
|
|
|
|
void _handleMessage(Map<String, dynamic> message) {
|
|
final type = message['type'];
|
|
|
|
switch (type) {
|
|
case 'init':
|
|
// Initial state with sessions list
|
|
_setState(ChatConnectionState.connected);
|
|
_reconnectAttempts = 0;
|
|
_startPingTimer();
|
|
_messagesController.add(message);
|
|
break;
|
|
|
|
case 'error':
|
|
final errorMsg = message['message'] ?? message['content'] ?? 'Unknown error';
|
|
_errorController.add(errorMsg);
|
|
break;
|
|
|
|
case 'pong':
|
|
break;
|
|
|
|
default:
|
|
// Forward all other messages (output, done, session_connected, etc.)
|
|
_messagesController.add(message);
|
|
}
|
|
}
|
|
|
|
void _startPingTimer() {
|
|
_pingTimer?.cancel();
|
|
_pingTimer = Timer.periodic(const Duration(seconds: 30), (_) {
|
|
if (_currentState == ChatConnectionState.connected) {
|
|
sendRaw({'type': 'ping'});
|
|
}
|
|
});
|
|
}
|
|
|
|
void _scheduleReconnect() {
|
|
if (_intentionalDisconnect) return;
|
|
if (_reconnectAttempts >= ApiConfig.maxReconnectAttempts) {
|
|
_errorController.add('Max reconnection attempts reached');
|
|
return;
|
|
}
|
|
|
|
_reconnectTimer?.cancel();
|
|
final delay = Duration(
|
|
seconds: ApiConfig.reconnectDelay.inSeconds * (1 << _reconnectAttempts),
|
|
);
|
|
_reconnectAttempts++;
|
|
_setState(ChatConnectionState.reconnecting);
|
|
|
|
_reconnectTimer = Timer(delay, () {
|
|
if (_token != null && !_intentionalDisconnect) {
|
|
connect();
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Create a new chat session
|
|
void createSession(String name) {
|
|
sendRaw({
|
|
'type': 'create_session',
|
|
'name': name,
|
|
});
|
|
}
|
|
|
|
/// Connect to an existing session by session_id
|
|
void connectToSession(String sessionId) {
|
|
sendRaw({
|
|
'type': 'connect_session',
|
|
'session_id': sessionId,
|
|
});
|
|
}
|
|
|
|
/// Send message to connected session
|
|
void sendMessage(String content) {
|
|
if (_currentState != ChatConnectionState.connected) {
|
|
_errorController.add('Not connected');
|
|
return;
|
|
}
|
|
|
|
sendRaw({
|
|
'type': 'message',
|
|
'content': content,
|
|
});
|
|
}
|
|
|
|
/// Request sessions list
|
|
void listSessions() {
|
|
sendRaw({'type': 'list_sessions'});
|
|
}
|
|
|
|
void sendRaw(Map<String, dynamic> data) {
|
|
if (_channel != null) {
|
|
_channel!.sink.add(jsonEncode(data));
|
|
}
|
|
}
|
|
|
|
void disconnect() {
|
|
_intentionalDisconnect = true;
|
|
_pingTimer?.cancel();
|
|
_reconnectTimer?.cancel();
|
|
_channel?.sink.close();
|
|
_setState(ChatConnectionState.disconnected);
|
|
}
|
|
|
|
void dispose() {
|
|
disconnect();
|
|
_messagesController.close();
|
|
_stateController.close();
|
|
_errorController.close();
|
|
}
|
|
}
|