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

652 lines
20 KiB
Dart

import "package:shadcn_flutter/shadcn_flutter.dart";
import 'package:pasteboard/pasteboard.dart';
import 'package:flutter/services.dart';
import 'dart:ui';
import 'package:file_picker/file_picker.dart';
import 'package:provider/provider.dart';
import 'dart:io';
import '../../models/attachment.dart';
import '../../providers/chat_provider.dart';
import '../../providers/home_coordinator.dart';
import '../../providers/projects_provider.dart';
import '../../providers/session_provider.dart';
import '../../../src/project_store.dart';
import '../../providers/settings_provider.dart';
import 'attachment_preview.dart';
import '../common/button.dart';
import '../../constants.dart';
import 'models_panel.dart';
import '../common/pane_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
}
}
}
void _openModelDialog(BuildContext context) {
final bgColor = Theme.of(context).colorScheme.background;
showGeneralDialog(
context: context,
barrierDismissible: true,
barrierLabel: "models",
barrierColor: Colors.transparent,
pageBuilder: (ctx, animation, _) {
return Stack(
children: [
// blurred tinted backdrop
Positioned.fill(
child: FadeTransition(
opacity: animation,
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 3, sigmaY: 3),
child: GestureDetector(
onTap: () => Navigator.of(ctx).pop(),
child: ColoredBox(
color: bgColor.withValues(alpha: 0.35),
),
),
),
),
),
Center(
child: ConstrainedBox(
constraints: const BoxConstraints(minWidth: 420, maxWidth: 420),
child: PaneDialog(
title: "Models",
onClose: () => Navigator.of(ctx).pop(),
child: const ModelsPanel(),
),
),
),
],
);
},
);
}
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),
),
),
);
}
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: const 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,
),
const Gap(8),
const 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(
crossAxisAlignment: CrossAxisAlignment.start,
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(),
],
Stack(
alignment: Alignment.centerRight,
children: [
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()
)
],
),
),
);
},
),
Gap(10),
Row(
children: [
SizedBox(
// width: 130,
child: _PermissionModeSelector()
),
Gap(10),
_ProjectSelector(),
],
)
],
);
}
}
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 currentEntry = _modes.firstWhere(
(m) => m.$1 == current,
orElse: () => ("default", "Ask Always"),
);
return Select<(String, String)>(
itemBuilder: (ctx, item) => Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(_iconFor(item.$1), size: 12),
Gap(8),
Text(item.$2, style: const TextStyle(fontSize: 11)),
],
),
onChanged: (v) {
if (v == null) return;
chat.setThreadPermissionMode(v.$1);
},
value: currentEntry,
popupConstraints: const BoxConstraints(maxWidth: 300, minWidth: 300),
popup: SelectPopup(
items: SelectItemList(
children: [
for (final mode in _modes)
SelectItemButton(
value: mode,
child: Row(
children: [
Icon(_iconFor(mode.$1), size: 12),
Gap(8),
Text(mode.$2, style: const TextStyle(fontSize: 11)),
],
),
),
],
),
),
);
}
static IconData _iconFor(String mode) {
switch (mode) {
case "acceptEdits": return LucideIcons.checkCheck;
default: return LucideIcons.shieldQuestion;
}
}
}
class _ProjectSelector extends StatelessWidget {
const _ProjectSelector();
@override
Widget build(BuildContext context) {
final projectsProvider = context.watch<ProjectsProvider>();
final projects = projectsProvider.projects;
final selected = projectsProvider.selectedProject;
final coordinator = context.read<HomeCoordinator>();
final hasMessages = context.watch<ChatProvider>().messageCount > 0;
return Select<ProjectRecord>(
enabled: !hasMessages,
itemBuilder: (context, item) => Row(
children: [
Icon(LucideIcons.folder).iconSmall,
Gap(8),
Text(item.name, style: const TextStyle(fontSize: 11)),
],
),
popupConstraints: BoxConstraints(
maxWidth: 320,
minWidth: 320,
maxHeight: 200
),
popupWidthConstraint: PopoverConstraint.flexible,
popup: SelectPopup.builder(
searchPlaceholder: const Text("Search projects"),
builder: (context, searchQuery) {
final filtered = searchQuery == null || searchQuery.isEmpty
? projects
: projects.where((p) =>
p.name.toLowerCase().contains(searchQuery.toLowerCase()) ||
p.workingDirectory.toLowerCase().contains(searchQuery.toLowerCase())
).toList();
return SelectItemList(
children: [
for (final project in filtered)
SelectItemButton(
value: project,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(project.name).small,
Text(project.workingDirectory).muted.xSmall,
],
),
),
],
);
},
),
onChanged: (project) {
if (project != null) coordinator.selectProject(project);
},
constraints: const BoxConstraints(minWidth: 180, maxWidth: 240),
value: selected,
placeholder: const Text("Select project", style: TextStyle(fontSize: 11)),
);
}
}