455 lines
13 KiB
Dart
455 lines
13 KiB
Dart
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<ChatBox> createState() => _ChatBoxState();
|
|
}
|
|
|
|
class _ChatBoxState extends State<ChatBox> {
|
|
late TextEditingController _controller;
|
|
late FocusNode _focusNode;
|
|
final List<Attachment> _attachments = [];
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_controller = TextEditingController();
|
|
_focusNode = FocusNode();
|
|
_controller.addListener(_onTextChanged);
|
|
}
|
|
|
|
Future<void> _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<void> _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<SettingsProvider>();
|
|
final session = context.read<SessionProvider>();
|
|
final selectedModel = settings.normalizeModelId(settings.settings.model);
|
|
|
|
final result = await showDialog<String>(
|
|
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<SettingsProvider>();
|
|
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<HomeCoordinator>().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<ChatProvider>();
|
|
context
|
|
.watch<SettingsProvider>(); // 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<HomeCoordinator>().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()
|
|
)
|
|
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
|
|
],
|
|
);
|
|
}
|
|
}
|