The-Agency/lib/ui/widgets/chat/chat_box.dart

570 lines
17 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);
final isLoading = context.watch<ChatProvider>().isLoading;
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: (isLoading && _controller.text.isEmpty && _attachments.isEmpty)
? AgcSecondaryButton(
onPressed: () => context.read<ChatProvider>().stopGenerating(),
child: Icon(LucideIcons.octagonX),
)
: AgcSecondaryButton(
enabled: _controller.text.isNotEmpty || _attachments.isNotEmpty,
onPressed: () {
final text = _controller.text.trim();
if (text.isEmpty && _attachments.isEmpty) return;
final toSend = List.of(_attachments);
context.read<HomeCoordinator>().sendMessage(text, attachments: toSend);
_controller.clear();
setState(() => _attachments.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 || _attachments.isNotEmpty) {
final toSend = List.of(_attachments);
context.read<HomeCoordinator>().sendMessage(text, attachments: toSend);
_controller.clear();
setState(() => _attachments.clear());
}
return KeyEventResult.handled;
}
}
return KeyEventResult.ignored;
},
child: OutlinedContainer(
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.15),
blurRadius: 16,
spreadRadius: 2,
offset: Offset(0, 4),
),
],
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()
)
],
),
),
);
},
),
const SizedBox(height: 6),
_PermissionModeSelector(),
],
);
}
}
class _PermissionModeSelector extends StatelessWidget {
const _PermissionModeSelector();
static const _modes = [
("default", "Ask Always"),
("acceptEdits", "Accept Edits"),
];
@override
Widget build(BuildContext context) {
final chat = context.watch<ChatProvider>();
final current = chat.threadPermissionMode;
final theme = Theme.of(context);
final mutedFg = theme.colorScheme.mutedForeground;
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (int i = 0; i < _modes.length; i++) ...[
if (i > 0) const SizedBox(width: 4),
_ModeChip(
label: _modes[i].$2,
selected: current == _modes[i].$1,
mutedFg: mutedFg,
onTap: () => chat.setThreadPermissionMode(_modes[i].$1),
),
],
],
);
}
}
class _ModeChip extends StatefulWidget {
const _ModeChip({
required this.label,
required this.selected,
required this.mutedFg,
required this.onTap,
});
final String label;
final bool selected;
final Color mutedFg;
final VoidCallback onTap;
@override
State<_ModeChip> createState() => _ModeChipState();
}
class _ModeChipState extends State<_ModeChip> {
bool _hovering = false;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final fg = widget.selected
? theme.colorScheme.foreground
: widget.mutedFg;
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovering = true),
onExit: (_) => setState(() => _hovering = false),
child: GestureDetector(
onTap: widget.onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 120),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: widget.selected
? theme.colorScheme.secondary
: _hovering
? theme.colorScheme.secondary.withValues(alpha: 0.5)
: Colors.transparent,
borderRadius: BorderRadius.circular(theme.radiusSm),
),
child: Text(
widget.label,
style: TextStyle(fontSize: 11, color: fg, fontWeight: widget.selected ? FontWeight.w600 : FontWeight.normal),
),
),
),
);
}
}