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}'; } } }