From 4a28314b7c240cd6ae5b0b5ee0f626a1049de34f Mon Sep 17 00:00:00 2001 From: Dan Montgomery Date: Fri, 12 Jul 2024 20:58:57 -0400 Subject: [PATCH] Add model selector and add some hints to empty state. --- gai-frontend/.gitignore | 3 + gai-frontend/lib/chat/chat.dart | 192 ++++++++++++++++-- gai-frontend/lib/chat/chat_bubble.dart | 19 +- gai-frontend/lib/chat/chat_button.dart | 6 +- gai-frontend/lib/chat/chat_message.dart | 3 +- gai-frontend/lib/chat/chat_model_button.dart | 167 +++++++++++++++ gai-frontend/lib/config/providers_config.dart | 15 ++ gai-frontend/pubspec.yaml | 1 + 8 files changed, 381 insertions(+), 25 deletions(-) create mode 100644 gai-frontend/lib/chat/chat_model_button.dart create mode 100644 gai-frontend/lib/config/providers_config.dart diff --git a/gai-frontend/.gitignore b/gai-frontend/.gitignore index 0240cb2b3..a3d18e8e5 100644 --- a/gai-frontend/.gitignore +++ b/gai-frontend/.gitignore @@ -46,3 +46,6 @@ app.*.map.json /android/app/profile /android/app/release deploy-pat.sh + +set_providers.sh + diff --git a/gai-frontend/lib/chat/chat.dart b/gai-frontend/lib/chat/chat.dart index 53f7b8512..ce37beba3 100644 --- a/gai-frontend/lib/chat/chat.dart +++ b/gai-frontend/lib/chat/chat.dart @@ -14,11 +14,17 @@ import 'package:orchid/orchid/orchid.dart'; import 'package:orchid/api/orchid_crypto.dart'; import 'package:orchid/orchid/orchid_gradients.dart'; import 'package:orchid/orchid/orchid_titled_panel.dart'; + +import 'package:flutter/gestures.dart'; +import 'package:url_launcher/url_launcher.dart'; + import 'chat_bubble.dart'; import 'chat_button.dart'; import 'chat_message.dart'; import '../provider.dart'; import 'chat_prompt.dart'; +import 'chat_model_button.dart'; +import '../config/providers_config.dart'; class ChatView extends StatefulWidget { const ChatView({super.key}); @@ -29,7 +35,9 @@ class ChatView extends StatefulWidget { class _ChatViewState extends State { List _messages = []; - List _providers = []; +// List _providers = []; +// Map> _providers = {'gpt4': {'url': 'https://nanogenera.danopato.com/ws/', 'name': 'ChatGPT-4'}}; + late final Map> _providers; int _providerIndex = 0; bool _debugMode = false; bool _connected = false; @@ -51,6 +59,7 @@ class _ChatViewState extends State { @override void initState() { super.initState(); + _providers = ProvidersConfig.getProviders(); _bidController.value = _bid; try { _initFromParams(); @@ -59,6 +68,13 @@ class _ChatViewState extends State { } } + bool _emptyState() { + if (_account != null || _connected) { + return false; + } + return true; + } + Account? get _account { if (_funder == null || _signerKey == null) { return null; @@ -106,13 +122,17 @@ class _ChatViewState extends State { _accountChanged(); String? provider = params['provider']; if (provider != null) { - _providers = [provider]; + _providers = {'user-provider': {'url': provider, 'name': 'User Provider'}}; } } - void providerConnected() { + void providerConnected([name = '']) { + String nameTag = ''; _connected = true; - addMessage(ChatMessageSource.system, 'Connected to provider.'); + if (!name.isEmpty) { + nameTag = ' ${name}'; + } + addMessage(ChatMessageSource.system, 'Connected to provider${nameTag}.'); } void providerDisconnected() { @@ -120,12 +140,16 @@ class _ChatViewState extends State { addMessage(ChatMessageSource.system, 'Provider disconnected'); } - void _connectProvider() { + void _connectProvider([provider = '']) { var account = _accountDetail; + String url; + String name; + String providerId = ''; if (account == null) { return; } if (_providers.length == 0) { + log('_connectProvider() -- _providers.length == 0'); return; } if (_connected) { @@ -133,14 +157,23 @@ class _ChatViewState extends State { _providerIndex = (_providerIndex + 1) % _providers.length; _connected = false; } - log('Connecting to provider: ${_providers[_providerIndex]}'); + if (provider.isEmpty) { + _providerIndex += 1; + providerId = _providers.keys.elementAt(_providerIndex); + } else { + providerId = provider; + } + url = _providers[providerId]?['url'] ?? ''; + name = _providers[providerId]?['name'] ?? ''; + + log('Connecting to provider: ${name}'); _providerConnection = ProviderConnection( onMessage: (msg) { addMessage(ChatMessageSource.internal, msg); }, - onConnect: providerConnected, + onConnect: () { providerConnected(name); }, onChat: (msg, metadata) { - addMessage(ChatMessageSource.provider, msg, metadata: metadata); + addMessage(ChatMessageSource.provider, msg, metadata: metadata, sourceName: name); }, onDisconnect: providerDisconnected, onError: (msg) { @@ -155,16 +188,20 @@ class _ChatViewState extends State { accountDetail: account, contract: EthereumAddress.from('0x6dB8381b2B41b74E17F5D4eB82E8d5b04ddA0a82'), - url: _providers[_providerIndex], + url: url, ); log('connected...'); } void addMessage(ChatMessageSource source, String msg, - {Map? metadata}) { + {Map? metadata, String sourceName = ''}) { log('Adding message: ${msg.truncate(64)}'); setState(() { - _messages.add(ChatMessage(source, msg, metadata: metadata)); + if (sourceName.isEmpty) { + _messages.add(ChatMessage(source, msg, metadata: metadata)); + } else { + _messages.add(ChatMessage(source, msg, metadata: metadata, sourceName: sourceName)); + } }); // if (source != ChatMessageSource.internal || _debugMode == true) { scrollMessagesDown(); @@ -249,6 +286,11 @@ class _ChatViewState extends State { ).top(16), // Account card AccountCard(accountDetail: _accountDetail).top(20), + ChatButton( + onPressed: () => _launchURL('https://account.orchid.com'), + text: 'Manage Account', + width: 200, + ).top(20), ], ).pad(24), ); @@ -347,17 +389,11 @@ class _ChatViewState extends State { fit: BoxFit.scaleDown, child: SizedBox( width: minWidth, - child: _buildHeaderRow(showIcons: showIcons))) + child: _buildHeaderRow(showIcons: showIcons, providers: _providers))) else - _buildHeaderRow(showIcons: showIcons), + _buildHeaderRow(showIcons: showIcons, providers: _providers), // Messages area - Flexible( - child: ListView.builder( - controller: messageListController, - itemCount: _messages.length, - itemBuilder: _buildChatBubble, - ).top(16), - ), + _buildChatPane(), // Prompt row AnimatedSize( alignment: Alignment.topCenter, @@ -383,16 +419,89 @@ class _ChatViewState extends State { ); } - Widget _buildHeaderRow({required bool showIcons}) { + Widget _buildChatPane() { + return Flexible( + child: Stack( + children: [ + ListView.builder( + controller: messageListController, + itemCount: _messages.length, + itemBuilder: _buildChatBubble, + ).top(16), + if (_emptyState()) + Positioned( + top: 35, // Adjust this value to align with the Account button + right: 0, + child: CustomPaint( + painter: CalloutPainter(), + child: Container( + width: 390, + padding: EdgeInsets.fromLTRB(16, 16, 16, 16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'This is a demonstration of the application of Orchid Nanopayments within a consolidated Multi-LLM chat service.', + style: OrchidText.normal_14.copyWith(color: Colors.white), + textAlign: TextAlign.center, + ), + SizedBox(height: 16), + Text( + 'To get started, enter or create a funded Orchid account.', + style: OrchidText.normal_14.copyWith(color: Colors.white), + textAlign: TextAlign.center, + ), + ChatButton( + text: 'Enter Account', + onPressed: _popAccountDialog, + width: 200, + ).top(24), + SizedBox(height: 16), + OutlinedButton( + onPressed: () => _launchURL('https://account.orchid.com'), + child: Text('Create Account').button, + style: OutlinedButton.styleFrom( + side: BorderSide(color: Theme.of(context).primaryColor), + minimumSize: Size(200, 50), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(25), + ), + ), + ), + SizedBox(height: 24), + InkWell( + onTap: () => _launchURL('https://docs.orchid.com/en/latest/accounts/'), + child: Text( + 'Learn more about creating an Orchid account', + style: TextStyle(color: Colors.blue[300], decoration: TextDecoration.underline), + ), + ), + ], + ), + ), + ), + ), + ], + ), + ); + } + + Widget _buildHeaderRow({required bool showIcons, required Map> providers}) { return Row( children: [ SizedBox(height: 40, child: OrchidAsset.image.logo), const Spacer(), // Connect button + ChatModelButton( + updateModel: (id) { log(id); _connectProvider(id); }, + providers: providers, + ).left(8), +/* ChatButton( text: 'Reroll', onPressed: _connectProvider, ).left(8), +*/ // Clear button ChatButton(text: 'Clear Chat', onPressed: _clearChat).left(8), // Account button @@ -411,6 +520,47 @@ class _ChatViewState extends State { } } +class CalloutPainter extends CustomPainter { + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = Colors.white.withOpacity(0.8) + ..style = PaintingStyle.stroke + ..strokeWidth = 1.5; + + final radius = 10.0; // Corner radius + final calloutWidth = 25.0; + final calloutHeight = 20.0; + final calloutStart = size.width - 115.0; // Adjust this to position the callout + + final path = Path() + ..moveTo(radius, 0) + ..lineTo(calloutStart, 0) + ..lineTo(calloutStart + (calloutWidth / 2), -calloutHeight) + ..lineTo(calloutStart + calloutWidth, 0) + ..lineTo(size.width - radius, 0) + ..quadraticBezierTo(size.width, 0, size.width, radius) + ..lineTo(size.width, size.height - radius) + ..quadraticBezierTo(size.width, size.height, size.width - radius, size.height) + ..lineTo(radius, size.height) + ..quadraticBezierTo(0, size.height, 0, size.height - radius) + ..lineTo(0, radius) + ..quadraticBezierTo(0, 0, radius, 0); + + canvas.drawPath(path, paint); + } + + @override + bool shouldRepaint(CustomPainter oldDelegate) => false; +} + +Future _launchURL(String urlString) async { + final Uri url = Uri.parse(urlString); + if (!await launchUrl(url)) { + throw 'Could not launch $url'; + } +} + // Rename this in a subclass as prelude to refactoring later. class TransientEthereumKey extends StoredEthereumKey { TransientEthereumKey({required super.imported, required super.private}); diff --git a/gai-frontend/lib/chat/chat_bubble.dart b/gai-frontend/lib/chat/chat_bubble.dart index 61f7ef2a0..26c6653ef 100644 --- a/gai-frontend/lib/chat/chat_bubble.dart +++ b/gai-frontend/lib/chat/chat_bubble.dart @@ -48,6 +48,7 @@ class ChatBubble extends StatelessWidget { ), ); } + return Align( alignment: src == ChatMessageSource.provider ? Alignment.centerLeft @@ -59,8 +60,9 @@ class ChatBubble extends StatelessWidget { children: [ Align( alignment: Alignment.centerLeft, - child: Text(src == ChatMessageSource.provider ? 'Chat' : 'You', - style: OrchidText.normal_14), + child: _chatSourceText(message), +// child: Text(src == ChatMessageSource.provider ? 'Chat' : 'You', +// style: OrchidText.normal_14), ), const SizedBox(height: 2), ClipRRect( @@ -101,5 +103,18 @@ class ChatBubble extends StatelessWidget { ), ); } + + Widget _chatSourceText(ChatMessage msg) { + final String srcText; + if (msg.sourceName.isEmpty) { + srcText = msg.source == ChatMessageSource.provider ? 'Chat' : 'You'; + } else { + srcText = msg.sourceName; + } + return Text( + srcText, + style: OrchidText.normal_14, + ); + } } diff --git a/gai-frontend/lib/chat/chat_button.dart b/gai-frontend/lib/chat/chat_button.dart index c51e9f164..18f245bd6 100644 --- a/gai-frontend/lib/chat/chat_button.dart +++ b/gai-frontend/lib/chat/chat_button.dart @@ -5,15 +5,19 @@ class ChatButton extends StatelessWidget { super.key, required this.text, required this.onPressed, + this.width, + this.height = 40, }); final String text; final VoidCallback onPressed; + final double? width, height; @override Widget build(BuildContext context) { return SizedBox( - height: 40, + height: height, + width: width, child: FilledButton( style: TextButton.styleFrom( backgroundColor: OrchidColors.new_purple, diff --git a/gai-frontend/lib/chat/chat_message.dart b/gai-frontend/lib/chat/chat_message.dart index 812187d74..8f722153b 100644 --- a/gai-frontend/lib/chat/chat_message.dart +++ b/gai-frontend/lib/chat/chat_message.dart @@ -3,10 +3,11 @@ enum ChatMessageSource { client, provider, system, internal } class ChatMessage { final ChatMessageSource source; + final String sourceName; final String msg; final Map? metadata; - ChatMessage(this.source, this.msg, {this.metadata}); + ChatMessage(this.source, this.msg, {this.metadata, this.sourceName = ''}); String get message { return msg; diff --git a/gai-frontend/lib/chat/chat_model_button.dart b/gai-frontend/lib/chat/chat_model_button.dart new file mode 100644 index 000000000..d232ff70f --- /dev/null +++ b/gai-frontend/lib/chat/chat_model_button.dart @@ -0,0 +1,167 @@ +import 'package:orchid/orchid/orchid.dart'; +import 'package:orchid/api/orchid_language.dart'; +import 'package:orchid/api/preferences/user_preferences_ui.dart'; +import 'package:orchid/orchid/menu/expanding_popup_menu_item.dart'; +import 'package:orchid/orchid/menu/orchid_popup_menu_item_utils.dart'; +import 'package:orchid/orchid/menu/submenu_popup_menu_item.dart'; +import 'package:url_launcher/url_launcher_string.dart'; +import '../../../orchid/menu/orchid_popup_menu_button.dart'; +import 'chat_button.dart'; + +class ChatModelButton extends StatefulWidget { +// final bool debugMode; +// final VoidCallback onDebugModeChanged; + final updateModel; + final Map> providers; + + const ChatModelButton({ + Key? key, +// required this.debugMode, +// required this.onDebugModeChanged + required this.providers, + required this.updateModel, + }) : super(key: key); + + @override + State createState() => _ChatModelButtonState(); +} + +class _ChatModelButtonState extends State { + final _width = 273.0; + final _height = 50.0; + final _textStyle = OrchidText.medium_16_025.copyWith(height: 2.0); + bool _buttonSelected = false; + + @override + Widget build(BuildContext context) { + return OrchidPopupMenuButton( + width: 80, + height: 40, + selected: _buttonSelected, + onSelected: (item) { + setState(() { + _buttonSelected = false; + }); + }, + onCanceled: () { + setState(() { + _buttonSelected = false; + }); + }, + itemBuilder: (itemBuilderContext) { + setState(() { + _buttonSelected = true; + }); + + return widget.providers.entries.map((entry) { + final providerId = entry.key; + final providerName = entry.value['name'] ?? providerId; + + return PopupMenuItem( + onTap: () { widget.updateModel(providerId); }, + height: _height, + child: SizedBox( + width: _width, + child: Text(providerName, style: _textStyle), + ), + ); + }).toList(); + }, +/* + itemBuilder: (itemBuilderContext) { + setState(() { + _buttonSelected = true; + }); + + const div = PopupMenuDivider(height: 1.0); + return [ + PopupMenuItem( + onTap: () { widget.updateModel('gpt4'); }, + height: _height, + child: SizedBox( + width: _width, + child: Text('GPT-4', style: _textStyle), + ), + ), + PopupMenuItem( + onTap: () { widget.updateModel('gpt4o'); }, + height: _height, + child: SizedBox( + width: _width, + child: Text('GPT-4o', style: _textStyle), + ), + ), +// div, + PopupMenuItem( + onTap: () { widget.updateModel('mistral'); }, + height: _height, + child: SizedBox( + width: _width, + child: Text('Mistral 7B', style: _textStyle), + ), + ), + PopupMenuItem( + onTap: () { widget.updateModel('mixtral-8x22b'); }, + height: _height, + child: SizedBox( + width: _width, + child: Text('Mixtral 8x22b', style: _textStyle), + ), + ), + PopupMenuItem( + onTap: () { widget.updateModel('gemini'); }, + height: _height, + child: SizedBox( + width: _width, + child: Text('Gemini 1.5', style: _textStyle), + ), + ), + PopupMenuItem( + onTap: () { widget.updateModel('claude-3'); }, + height: _height, + child: SizedBox( + width: _width, + child: Text('Claude 3 Opus', style: _textStyle), + ), + ), + PopupMenuItem( + onTap: () { widget.updateModel('claude35sonnet'); }, + height: _height, + child: SizedBox( + width: _width, + child: Text('Claude 3.5 Sonnet', style: _textStyle), + ), + ), + ]; + }, +*/ + + /* + child: FittedBox( + fit: BoxFit.scaleDown, + child: SizedBox( + width: 80, height: 20, child: Text('Model'))), +*/ +// child: SizedBox( +// width: 120, height: 20, child: Text('Model', style: _textStyle).white), + child: Align( + alignment: Alignment.center, + child: Text('Model', textAlign: TextAlign.center).button.white, + ), + ); + } + + PopupMenuItem _listMenuItem({ + required bool selected, + required String title, + required VoidCallback onTap, + }) { + return OrchidPopupMenuItemUtils.listMenuItem( + context: context, + selected: selected, + title: title, + onTap: onTap, + textStyle: _textStyle, + ); + } +} diff --git a/gai-frontend/lib/config/providers_config.dart b/gai-frontend/lib/config/providers_config.dart new file mode 100644 index 000000000..3fba53352 --- /dev/null +++ b/gai-frontend/lib/config/providers_config.dart @@ -0,0 +1,15 @@ +import 'dart:convert'; + +class ProvidersConfig { + static Map> getProviders() { + final providersJson = const String.fromEnvironment('PROVIDERS', defaultValue: '{}'); + print(providersJson); + try { + final providers = json.decode(providersJson) as Map; + return providers.map((key, value) => MapEntry(key, Map.from(value))); + } catch (e) { + print('Error parsing providers configuration: $e'); + return {}; + } + } +} diff --git a/gai-frontend/pubspec.yaml b/gai-frontend/pubspec.yaml index 2466aca31..2d554748a 100644 --- a/gai-frontend/pubspec.yaml +++ b/gai-frontend/pubspec.yaml @@ -67,3 +67,4 @@ flutter: flutter_intl: enabled: false +