652 lines
20 KiB
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)),
|
|
);
|
|
}
|
|
}
|