import "package:shadcn_flutter/shadcn_flutter.dart"; import 'package:pasteboard/pasteboard.dart'; import 'package:flutter/services.dart'; import 'package:file_picker/file_picker.dart'; import 'package:provider/provider.dart'; import 'dart:io'; import '../../constants.dart'; import '../../models/attachment.dart'; import '../../providers/chat_provider.dart'; import '../../providers/home_coordinator.dart'; import '../../providers/session_provider.dart'; import '../../providers/settings_provider.dart'; import 'attachment_preview.dart'; import '../common/button.dart'; import 'model_picker_dialog.dart'; class ChatBox extends StatefulWidget { const ChatBox({super.key}); @override State createState() => _ChatBoxState(); } class _ChatBoxState extends State { late TextEditingController _controller; late FocusNode _focusNode; final List _attachments = []; @override void initState() { super.initState(); _controller = TextEditingController(); _focusNode = FocusNode(); _controller.addListener(_onTextChanged); } Future _onPastePressed() async { try { final filePaths = await Pasteboard.files(); if (filePaths.isNotEmpty && mounted) { for (var filePath in filePaths) { try { final file = File(filePath); final fileBytes = await file.readAsBytes(); final fileName = file.path.split('/').last; if (mounted) { setState(() { _attachments.add( Attachment( name: fileName, mimeType: _getMimeType(fileName, fileBytes), data: fileBytes, ), ); }); } } catch (e) { // skip files that cant be read } } return; } } catch (e) { // no files in clipboard } // fallback to raw image data (screenshots etc) try { final imageBytes = await Pasteboard.image; if (imageBytes != null && mounted) { final imageData = Uint8List.fromList(imageBytes); setState(() { _attachments.add( Attachment( name: 'image.png', mimeType: _getMimeType('image.png', imageData), data: imageData, ), ); }); } } catch (e) { // no image in clipboard } } String _getMimeType(String filename, Uint8List data) { if (data.length >= 4) { if (data[0] == 0x25 && data[1] == 0x50 && data[2] == 0x44 && data[3] == 0x46) { return 'application/pdf'; } if (data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47) { return 'image/png'; } if (data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF) { return 'image/jpeg'; } if (data[0] == 0x47 && data[1] == 0x49 && data[2] == 0x46) { return 'image/gif'; } if (data[0] == 0x52 && data[1] == 0x49 && data[2] == 0x46 && data[3] == 0x46) { if (data.length >= 12 && data[8] == 0x57 && data[9] == 0x45 && data[10] == 0x42 && data[11] == 0x50) { return 'image/webp'; } } } final extension = filename.split('.').last.toLowerCase(); switch (extension) { case 'pdf': return 'application/pdf'; case 'txt': return 'text/plain'; case 'json': return 'application/json'; case 'csv': return 'text/csv'; case 'md': return 'text/markdown'; case 'png': return 'image/png'; case 'jpg': case 'jpeg': return 'image/jpeg'; case 'gif': return 'image/gif'; case 'webp': return 'image/webp'; default: return 'application/octet-stream'; } } void _onTextChanged() { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) setState(() {}); }); } Future _onAttachPressed() async { final result = await FilePicker.platform.pickFiles(allowMultiple: true); if (result == null || !mounted) return; for (final file in result.files) { if (file.path == null) continue; try { final f = File(file.path!); final bytes = await f.readAsBytes(); if (!mounted) return; setState(() { _attachments.add( Attachment( name: file.name, mimeType: _getMimeType(file.name, bytes), data: bytes, ), ); }); } catch (e) { // skip unreadable files } } } Widget _left(BuildContext context) { return SizedBox( height: 38, child: AspectRatio( aspectRatio: 1, child: AgcGhostButton( borderRadius: BorderRadius.circular(Theme.of(context).radiusLg - 4), onPressed: _onAttachPressed, child: Icon(LucideIcons.paperclip), ), ), ); } void _openModelDialog(BuildContext context) async { final settings = context.read(); final session = context.read(); final selectedModel = settings.normalizeModelId(settings.settings.model); final result = await showDialog( context: context, builder: (context) => ModelPickerDialog( models: selectableAiModels, selectedModel: selectedModel, ), ); if (result != null) { await settings.updateModel(result); await session.updateSessionModel(result); } } Widget _right(BuildContext context) { final settings = context.read(); final selectedModel = settings.normalizeModelId(settings.settings.model); return SizedBox( height: 38, child: Row( children: [ ConstrainedBox( constraints: BoxConstraints( maxWidth: 150, minHeight: double.infinity, ), child: AgcGhostButton( borderRadius: BorderRadius.circular( Theme.of(context).radiusLg - 4, ), onPressed: () => _openModelDialog(context), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12), child: Row( mainAxisSize: MainAxisSize.min, children: [ Flexible( child: Text( selectableAiModels .where((m) => m.id == selectedModel) .map((m) => m.label) .firstOrNull ?? selectedModel, overflow: TextOverflow.ellipsis, ).small, ), Gap(8), Icon(LucideIcons.chevronsUpDown), ], ), ), ), ), Gap(8), AspectRatio( aspectRatio: 1, child: AgcSecondaryButton( enabled: _controller.text.isNotEmpty, onPressed: () { final text = _controller.text.trim(); if (text.isEmpty) return; context.read().sendMessage(text); _controller.clear(); }, child: Icon(LucideIcons.arrowUp), ), ), ], ), ); } Widget _buildLeading(BuildContext context, int numberOfLines) { if (numberOfLines > 1) return SizedBox.shrink(); return _left(context); } Widget _buildTrailing(int numberOfLines) { if (numberOfLines > 1) return SizedBox.shrink(); return _right(context); } Widget? _buildBottom(BuildContext context, int numberOfLines) { if (numberOfLines <= 1) return null; return Container( margin: EdgeInsets.only(top: 8), height: 32, child: Row(children: [_left(context), Spacer(), _right(context)]), ); } String _fmtTokens(int n) { final s = n.toString(); final buf = StringBuffer(); for (var i = 0; i < s.length; i++) { if (i > 0 && (s.length - i) % 3 == 0) buf.write(","); buf.write(s[i]); } return buf.toString(); } void _removeAttachment(int index) { setState(() { _attachments.removeAt(index); }); _focusNode.requestFocus(); } @override void dispose() { _controller.removeListener(_onTextChanged); _controller.dispose(); _focusNode.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final chat = context.watch(); context .watch(); // needed so model label updates reactively final queuedMessages = chat.queuedMessages; final contextTokens = chat.contextTokens; return Column( children: [ LayoutBuilder( builder: (context, constraints) { final style = DefaultTextStyle.of(context).style; const reservedForIcons = 34; final painter = TextPainter( text: TextSpan(text: _controller.text, style: style), textDirection: TextDirection.ltr, )..layout(maxWidth: constraints.maxWidth - reservedForIcons); final numberOfLines = painter.computeLineMetrics().length; return Focus( onKeyEvent: (node, event) { if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.keyV && (HardwareKeyboard.instance.isControlPressed || HardwareKeyboard.instance.isMetaPressed)) { _onPastePressed(); } if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.enter) { if (HardwareKeyboard.instance.isShiftPressed) { final sel = _controller.selection; final text = _controller.text; final newText = text.replaceRange(sel.start, sel.end, '\n'); _controller.value = TextEditingValue( text: newText, selection: TextSelection.collapsed(offset: sel.start + 1), ); return KeyEventResult.handled; } else { final text = _controller.text.trim(); if (text.isNotEmpty) { context.read().sendMessage(text); _controller.clear(); } return KeyEventResult.handled; } } return KeyEventResult.ignored; }, child: OutlinedContainer( child: ButtonGroup.vertical( expands: true, children: [ for (int i = 0; i < queuedMessages.length; i++) ...[ Container( padding: const EdgeInsets.symmetric( horizontal: 14, vertical: 0, ), child: Row( children: [ Icon( LucideIcons.cornerDownRight, ).iconSmall.iconMutedForeground, Gap(14), Expanded( child: Text(queuedMessages[i]).small.textMuted, ), IconButton.text( onPressed: () => chat.removeQueuedMessage(i), icon: const Icon(LucideIcons.trash2), ).iconSmall, ], ), ), Divider(), ], TextField( controller: _controller, focusNode: _focusNode, borderRadius: Theme.of(context).borderRadiusLg, placeholder: Text("Ask the agency anything"), minLines: 1, maxLines: numberOfLines > 1 ? 5 : 1, clipBehavior: Clip.hardEdge, padding: EdgeInsets.all(8), features: [ if (_attachments.isNotEmpty) InputFeature.above( AttachmentPreview( attachments: _attachments, onRemove: _removeAttachment, ), ), InputFeature.leading( _buildLeading(context, numberOfLines), ), InputFeature.trailing(_buildTrailing(numberOfLines)), InputFeature.below( _buildBottom(context, numberOfLines), ), ], ), if (chat.isLoading) SizedBox( height: 4, child: LinearProgressIndicator() ) ], ), ), ); }, ), ], ); } }