dynamic chat bubbles implemented along with conversation support. currently only hard-coded dummy data.

This commit is contained in:
Joshua
2024-07-08 19:48:14 -06:00
parent 7547fe110f
commit 78e7f4b896
11 changed files with 308 additions and 259 deletions
@@ -1,7 +1,3 @@
import 'dart:convert';
import 'package:json_annotation/json_annotation.dart';
@JsonSerializable()
class Chat {
final String content;
final bool role;
@@ -9,14 +5,5 @@ class Chat {
// Most basic chat, text from server or from user.
Chat(this.content, this.role);
String toJson() {
Map<String, dynamic> c = {
"content": content,
"role": role,
};
return json.encode(c);
}
// TODO: Overload constructors to allow for multimedia.
}
@@ -1,15 +1,40 @@
import 'package:app/util/server.dart';
import 'package:flutter/material.dart';
import 'chat.dart';
class Conversation {
class Conversation with ChangeNotifier {
int conversationID;
String conversationLabel;
String conversationSubLabel;
List<Chat> chats = [];
Conversation(this.conversationID) {
chats = Server().getConversationByID(conversationID);
Conversation(this.conversationID, this.conversationLabel, this.conversationSubLabel);
Conversation.completeConversation(this.conversationID, this.conversationLabel, this.conversationSubLabel, this.chats);
void add(Chat message) {
chats.add(message);
notifyListeners();
}
void loadChats() {
chats = Server.getChatsByConversationID(conversationID);
}
void setLabel() {
// TODO: Need to implement
}
void setSubLabel() {
// TODO: Need to implement
}
List<Chat> getChats() {
return chats;
}
void setLabels(String cLabel, String cSubLabel) {
conversationLabel = cLabel;
conversationSubLabel = cSubLabel;
}
}
@@ -14,16 +14,15 @@ import 'package:cached_network_image/cached_network_image.dart';
class ConversationThreadModel extends FlutterFlowModel<ConversationThreadWidget> {
final formKey = GlobalKey<FormState>();
late PromptBoxModel promptBoxModel;
late int ?conversationID;
late Conversation conversation;
// If there's a conversation ID, retrieve the conversation history.
// Otherwise, start a new conversation with user defaults.
ConversationThreadModel(this.conversationID);
void updateConversation() {
conversation = Server.getLoadedConversation();
}
@override
void initState(BuildContext context) {
conversation = Conversation(conversationID!);
conversation = Server.getLoadedConversation();
promptBoxModel = createModel(context, () => PromptBoxModel());
}
@@ -1,4 +1,6 @@
import 'package:app/components/conversation_thread/conversation.dart';
import 'package:app/util/server.dart';
import 'package:provider/provider.dart';
import '/components/conversation_thread/chat_bubble_widget.dart';
import 'package:app/components/conversation_thread/conversation_thread_model.dart';
@@ -32,7 +34,7 @@ class _ConversationThreadWidgetState extends State<ConversationThreadWidget> {
@override
void initState() {
super.initState();
_model = createModel(context, () => ConversationThreadModel(0)); // TODO
_model = createModel(context, () => ConversationThreadModel());
}
@override
@@ -44,132 +46,140 @@ class _ConversationThreadWidgetState extends State<ConversationThreadWidget> {
@override
Widget build(BuildContext context) {
Conversation c = _model.conversation;
return Container(
width: double.infinity,
height: double.infinity,
decoration: const BoxDecoration(
color: AppTheme.primaryBackground,
),
child: Column(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: ListView.builder(
itemCount: c.chats.length,
padding: const EdgeInsets.fromLTRB(0, 12, 0, 24,),
reverse: false,
scrollDirection: Axis.vertical,
itemBuilder: (BuildContext context, int index) {
return ChatBubbleWidget(c: c.chats[index]);
},
return Container(
width: double.infinity,
height: double.infinity,
decoration: const BoxDecoration(
color: AppTheme.primaryBackground,
),
child: Column(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: ListenableBuilder(
listenable: _model.conversation,
builder: (BuildContext context, Widget? child) {
return ListView.builder(
itemCount: Server.getLoadedConversation().chats.length,
padding: const EdgeInsets.fromLTRB(0, 12, 0, 24,),
reverse: false,
scrollDirection: Axis.vertical,
itemBuilder: (BuildContext context, int index) {
if(Server.getLoadedConversation().chats.last.role) {
return ChatBubbleWidget(c: Server.getLoadedConversation().chats[index]);
} else {
}
},
);
},
),
),
),
Container(
width: double.infinity,
decoration: const BoxDecoration(
color: AppTheme.secondaryBackground,
boxShadow: [
BoxShadow(
blurRadius: 3,
color: Color(0x33000000),
offset: Offset(
0,
-2,
),
)
],
),
// TODO
child: Column(
mainAxisSize: MainAxisSize.max,
children: [
Row(
mainAxisSize: MainAxisSize.max,
children: [
Expanded(
child: Padding(
padding: const EdgeInsetsDirectional.fromSTEB(0, 12, 0, 0),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// FlutterFlowMediaDisplay(
// path: '',
// imageBuilder: (path) => ClipRRect(
// borderRadius: BorderRadius.circular(8),
// child: CachedNetworkImage(
// fadeInDuration: const Duration(milliseconds: 500),
// fadeOutDuration:
// const Duration(milliseconds: 500),
// imageUrl: path,
// width: 120,
// height: 100,
// fit: BoxFit.cover,
// ),
// ),
// TODO
// videoPlayerBuilder: (path) =>
// FlutterFlowVideoPlayer(
// path: path,
// width: 300,
// autoPlay: false,
// looping: true,
// showControls: true,
// allowFullScreen: true,
// allowPlaybackSpeedMenu: false,
// ),
// ),
Align(
alignment: const AlignmentDirectional(-1, -1),
// TODO: Make the delete button conditional upon media upload.
child: FlutterFlowIconButton(
borderColor:
AppTheme.error,
borderRadius: 20,
borderWidth: 2,
buttonSize: 40,
fillColor: AppTheme.primaryBackground,
icon: const Icon(
Icons.delete_outline_rounded,
color: AppTheme.error,
size: 24,
Container(
width: double.infinity,
decoration: const BoxDecoration(
color: AppTheme.secondaryBackground,
boxShadow: [
BoxShadow(
blurRadius: 3,
color: Color(0x33000000),
offset: Offset(
0,
-2,
),
)
],
),
// TODO
child: Column(
mainAxisSize: MainAxisSize.max,
children: [
Row(
mainAxisSize: MainAxisSize.max,
children: [
Expanded(
child: Padding(
padding: const EdgeInsetsDirectional.fromSTEB(0, 12, 0, 0),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// FlutterFlowMediaDisplay(
// path: '',
// imageBuilder: (path) => ClipRRect(
// borderRadius: BorderRadius.circular(8),
// child: CachedNetworkImage(
// fadeInDuration: const Duration(milliseconds: 500),
// fadeOutDuration:
// const Duration(milliseconds: 500),
// imageUrl: path,
// width: 120,
// height: 100,
// fit: BoxFit.cover,
// ),
// ),
// TODO
// videoPlayerBuilder: (path) =>
// FlutterFlowVideoPlayer(
// path: path,
// width: 300,
// autoPlay: false,
// looping: true,
// showControls: true,
// allowFullScreen: true,
// allowPlaybackSpeedMenu: false,
// ),
// ),
Align(
alignment: const AlignmentDirectional(-1, -1),
// TODO: Make the delete button conditional upon media upload.
child: FlutterFlowIconButton(
borderColor:
AppTheme.error,
borderRadius: 20,
borderWidth: 2,
buttonSize: 40,
fillColor: AppTheme.primaryBackground,
icon: const Icon(
Icons.delete_outline_rounded,
color: AppTheme.error,
size: 24,
),
onPressed: () {
print('IconButton pressed ...');
},
),
onPressed: () {
print('IconButton pressed ...');
},
),
),
]
.divide(const SizedBox(width: 8))
.addToStart(const SizedBox(width: 16))
.addToEnd(const SizedBox(width: 16)),
]
.divide(const SizedBox(width: 8))
.addToStart(const SizedBox(width: 16))
.addToEnd(const SizedBox(width: 16)),
),
),
),
),
),
],
),
Form(
key: _model.formKey,
autovalidateMode: AutovalidateMode.disabled,
child: Padding(
padding: const EdgeInsetsDirectional.fromSTEB(0, 0, 0, 8),
child: wrapWithModel(
model: _model.promptBoxModel,
updateCallback: () => setState(() {}),
child: const PromptBoxWidget(),
],
),
Form(
key: _model.formKey,
autovalidateMode: AutovalidateMode.disabled,
child: Padding(
padding: const EdgeInsetsDirectional.fromSTEB(0, 0, 0, 8),
child: wrapWithModel(
model: _model.promptBoxModel,
updateCallback: () => setState(() {}),
child: const PromptBoxWidget(),
),
),
),
),
],
],
),
),
),
],
),
);
],
),
);
}
}
@@ -2,8 +2,6 @@ import 'package:app/util/server.dart';
import 'package:flutterflow_ui/flutterflow_ui.dart';
import 'package:easy_debounce/easy_debounce.dart';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:provider/provider.dart';
import 'package:app/theme.dart';
@@ -221,7 +219,8 @@ class _PromptBoxWidgetState extends State<PromptBoxWidget> {
validator: _model.textControllerValidator.asValidator(context),
textInputAction: TextInputAction.send, // "Send" on keyboard
onFieldSubmitted: (text) {
updateFromLatestInput(text); // TODO: Connect with multimedia
// TODO: Clear text box
Server.respondWhenReady(text); // TODO: Connect with multimedia
},
),
),
@@ -233,9 +232,4 @@ class _PromptBoxWidgetState extends State<PromptBoxWidget> {
),
);
}
void updateFromLatestInput(String input) {
Server().getResponseWhenReady(input);
// TODO: Refresh the page with the prompt & response.
}
}
+1 -2
View File
@@ -53,13 +53,12 @@ void main() {
class MyApp extends ConsumerWidget {
MyApp({super.key});
final selectedIndexProvider = StateProvider<int>((_) => 2); // Initial state
final selectedIndexProvider = StateProvider<int>((_) => 1); // Initial state
// This list holds the pages to navigate between
final List<Widget> _pages = [
const Center(child: SettingsWidget()),
const Center(child: ConversationsListWidget()),
const Center(child: ConversationWidget()),
];
// This widget is the root of your application.
+1 -2
View File
@@ -15,8 +15,7 @@ class ConversationModel extends FlutterFlowModel<ConversationWidget> {
@override
void initState(BuildContext context) {
conversationThreadModel =
createModel(context, () => ConversationThreadModel(0)); // TODO
conversationThreadModel = createModel(context, () => ConversationThreadModel()); // TODO
modelItemModel = createModel(context, () => ModelItemModel());
}
@@ -19,8 +19,9 @@ class ConversationWidget extends StatefulWidget {
class _ConversationWidgetState extends State<ConversationWidget> {
late ConversationModel _model;
final scaffoldKey = GlobalKey<ScaffoldState>();
_ConversationWidgetState();
final scaffoldKey = GlobalKey<ScaffoldState>();
@override
void initState() {
super.initState();
@@ -1,8 +1,6 @@
import 'package:flutterflow_ui/flutterflow_ui.dart';
import 'conversations_list_widget.dart' show ConversationsListWidget;
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:provider/provider.dart';
class ConversationsListModel extends FlutterFlowModel<ConversationsListWidget> {
@@ -1,7 +1,9 @@
import 'package:app/components/conversation_thread/conversation.dart';
import 'package:app/theme.dart';
import 'package:app/pages/conversation_widget.dart';
import 'package:app/components/conversation_options/conversation_options_widget.dart';
import 'package:app/util/server.dart';
import 'package:flutterflow_ui/flutterflow_ui.dart';
import 'package:flutter/material.dart';
@@ -10,19 +12,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'conversations_list_model.dart';
export 'conversations_list_model.dart';
final conversationIndex = ValueNotifier<int>(0);
// State controller using flutter_riverpod API.
// This handles changing between different conversations.
class MyState with ChangeNotifier {
int get selectedIndex => conversationIndex.value;
set selectedIndex(int value) {
conversationIndex.value = value;
notifyListeners();
}
}
class ConversationsListWidget extends StatefulWidget {
const ConversationsListWidget({super.key});
@@ -35,17 +24,24 @@ class _ConversationsListWidgetState extends State<ConversationsListWidget> {
late ConversationsListModel _model;
final selectedIndexProvider = StateProvider<int>((_) => 0); // Initial state
final List<Widget> conversations = [
// TODO: Dynamically generate list of pages based upon existing conversations.
// This requires retrievable conversations from the server side.
const Center(child: ConversationWidget()),
];
late List<Widget> conversations = [];
// = [
// // TODO: Dynamically generate list of pages based upon existing conversations.
// // This requires retrievable conversations from the server side.
// const Center(child: ConversationWidget()),
// ];
final scaffoldKey = GlobalKey<ScaffoldState>();
@override
void initState() {
super.initState();
Server().init();
List<Conversation> convos = Server.conversations;
for(Conversation c in convos) {
conversations.add(const ConversationWidget());
}
_model = createModel(context, () => ConversationsListModel());
}
@@ -57,6 +53,7 @@ class _ConversationsListWidgetState extends State<ConversationsListWidget> {
@override
Widget build(BuildContext context) {
List<Conversation> conversations = Server.getConversationsList();
return GestureDetector(
onTap: () => _model.unfocusNode.canRequestFocus
? FocusScope.of(context).requestFocus(_model.unfocusNode)
@@ -77,12 +74,13 @@ class _ConversationsListWidgetState extends State<ConversationsListWidget> {
Expanded(
child: Align(
alignment: const AlignmentDirectional(0, 0),
child: ListView(
child: ListView.builder(
itemCount: conversations.length,
padding: EdgeInsets.zero,
reverse: true,
scrollDirection: Axis.vertical,
children: [
Align(
itemBuilder: (context, index) {
return Align(
alignment: const AlignmentDirectional(0, 0),
child: InkWell(
splashColor: Colors.transparent,
@@ -101,9 +99,7 @@ class _ConversationsListWidgetState extends State<ConversationsListWidget> {
Container(
width: 100,
height: 100,
decoration: const BoxDecoration(
color: AppTheme.secondaryBackground,
),
decoration: const BoxDecoration(color: AppTheme.secondaryBackground,),
child: const Align(
alignment: AlignmentDirectional(0, 0),
child: Icon(
@@ -120,28 +116,24 @@ class _ConversationsListWidgetState extends State<ConversationsListWidget> {
width: 100,
height: 100,
constraints: BoxConstraints(
minWidth:
MediaQuery.sizeOf(context).width,
minWidth: MediaQuery.sizeOf(context).width,
),
decoration: const BoxDecoration(
color: AppTheme.secondaryBackground,
shape: BoxShape.rectangle,
),
child: const Column(
child: Column(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment:
MainAxisAlignment.spaceEvenly,
crossAxisAlignment:
CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Align(
alignment:
AlignmentDirectional(-1, 0),
alignment: const AlignmentDirectional(-1, 0),
child: Row(
mainAxisSize: MainAxisSize.max,
children: [
Text(
'Hello World', // TODO: Dynamically obtain conversation title from server.
conversations[index].conversationLabel,
style: AppTheme.headlineLarge,
),
],
@@ -149,13 +141,13 @@ class _ConversationsListWidgetState extends State<ConversationsListWidget> {
),
Align(
alignment:
AlignmentDirectional(-1, 0),
const AlignmentDirectional(-1, 0),
child: Row(
mainAxisSize: MainAxisSize.max,
children: [
Expanded(
child: Text(
'Hello World', // TODO: Summary or list of models used
conversations[index].conversationSubLabel,
style: AppTheme.bodyMedium,
),
),
@@ -183,15 +175,23 @@ class _ConversationsListWidgetState extends State<ConversationsListWidget> {
),
onTap: () => {
// TODO: Need to implement.
print("opening existing conversation")
// Get the conversation object based on the index
// Use Navigator to push a new ConversationWidget with conversation data
Server.loadConversation(index),
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const ConversationWidget(),
),
)
},
onLongPress: () => {
// TODO: Need to implement.
print("opening options")
},
),
),
],
);
}
),
),
),
+102 -65
View File
@@ -1,4 +1,5 @@
import 'dart:convert';
import 'package:app/components/conversation_thread/conversation.dart';
import 'package:http/http.dart' as http;
import 'package:app/components/conversation_thread/chat.dart';
@@ -7,12 +8,34 @@ import 'package:app/util/speech_to_text.dart';
// Uses Singleton design pattern to ensure that any server references across the app are using the same instance.
class Server {
// SERVER VARIABLES
static final Server _single_server = Server._internal();
Server._internal();
// TODO: One-time process to establish server connection is required for easy setup/maintenance.
static final url = "http://10.0.2.2:11434/api/";
static String endpoint = "";
static final Map<String, String> headers = {'Content-Type': 'application/json'};
// STT variables
// AI VARIABLES
static final SpeechToText stt = SpeechToText();
static late bool _speechEnabled;
static final Server _single_server = Server._internal();
// PRIVACY VARIABLES
static bool _historyEnabled = true;
static late int conversationID;
static List<Conversation> conversations = [];
static List<Chat> loadedChats = [];
// RUNTIME & STATUS VARIABLES
static String prompt = "";
static String response = "";
static bool initiated = false;
// TODO: Verify all different components of STT, TTS, LLM, etc have been initialized.
// TODO: Handshake w/ server to validate identity
@@ -20,41 +43,83 @@ class Server {
// Speech-To-Text engine activation.
_speechEnabled = stt.isSpeechEnabled();
// loadedChats = conversations[conversationID].chats;
return _single_server;
}
Server._internal();
void init() {
initiated = true;
// TODO: One-time process to establish server connection is required for easy setup/maintenance.
// Server variables
final url = "http://10.0.2.2:11434/api/";
String endpoint = "";
Map<String, String> headers = {'Content-Type': 'application/json'};
// Status variables
bool _historyEnabled = true;
// TODO: Dynamically obtain conversations.
conversations.add(
Conversation.completeConversation(
0,
"Test label",
"Test sub-label",
[
(Chat("This is just a test. Only reply Yes.", true)),
(Chat("Yes.", false)),
]
)
);
// Context variables
List<Chat> chats = [
(Chat("This is just a test. Only reply Yes.", true)),
(Chat("Yes.", false)),
];
// Runtime variables
String prompt = "";
String response = "";
int conversationID = 0;
List<Chat> getConversationByID(conversationID) {
// TODO: Implement conversation IDs & retrieval from server
this.conversationID = conversationID;
return chats;
conversations.add(
Conversation.completeConversation(
1,
"Label test",
"Sub-label Test",
[
(Chat("A different test. Only reply No.", true)),
(Chat("No.", false)),
]
)
);
}
// TODO: Integrate with getConversationByID()
List<Map<String, String>> convertMessages() {
// PRIMARY INTERFACE
static Future<String> sttGetResponseWhenReady() async {
// TODO: In case on-device STT is unavailable, use server-side STT service.
if (!_speechEnabled) "Unable to process Speech-To-Text on-device.";
prompt = stt.getTextWhenReady() as String;
conversations[conversationID].add(Chat(prompt, true));
httpSendRequest();
conversations[conversationID].add(Chat(response, false));
return response;
}
static void respondWhenReady(String p) async {
prompt = p;
conversations[conversationID].add(Chat(prompt, true));
httpSendRequest();
}
// CONVERSATION MANAGEMENT
static List<Conversation> getConversationsList() { return conversations; }
static Conversation getLoadedConversation() { return conversations[conversationID]; }
// Load conversation should be called prior to any other server requests.
static void loadConversation(id) {
conversationID = id;
loadedChats = conversations[conversationID].chats;
}
static List<Chat> getChatsByConversationID(id) { return conversations[id].chats; }
// SERVER UTILS
static List<Map<String, String>> convertMessages() {
// TODO: Integrate with getConversationByID()
List<Map<String, String>> messageList = [];
for (Chat c in chats) {
for (Chat c in loadedChats) {
messageList.add({
"role": c.role ? "user" : "assistant",
"content": c.content,
@@ -64,41 +129,15 @@ class Server {
return messageList;
}
// Using native on-device Speech-To-Text capability, get the server response when ready.
Future<String> sttGetResponseWhenReady() async {
// TODO: In case on-device STT is unavailable, use server-side STT service.
if (!_speechEnabled) "Unable to process Speech-To-Text on-device.";
prompt = stt.getTextWhenReady() as String;
httpSendRequest();
return response;
}
Future<String> getResponseWhenReady(String p) async {
prompt = p;
httpSendRequest();
return response;
}
// Get the last response or request a response from the server.
String getResponse() {
httpSendRequest();
return response;
}
// Get the latest prompt.
String getPrompt() => prompt;
// Formats the prompt in JSON.
String constructPrompt() {
static String constructPrompt() {
// TODO: Allow for additional flags, ie. continuous conversation, images, etc.
// TODO: Allow for toggleable states between stream
// TODO: Allow for different models to be dynamically selected.
Map<String, dynamic> data;
if(_historyEnabled) {
chats = [...chats, Chat(prompt, true)];
loadedChats = [...loadedChats, Chat(prompt, true)];
endpoint = "chat";
List<Map<String, String>> messageList = convertMessages();
@@ -123,9 +162,8 @@ class Server {
}
// Send the HTTP request to the server.
void httpSendRequest() async {
static void httpSendRequest() async {
String json = constructPrompt();
print("\n\nJSON:\n$json\n\n");
// Send the HTTP POST request
final serverResponse = await http.post(
@@ -138,15 +176,14 @@ class Server {
Map<String, dynamic> re = jsonDecode(serverResponse.body);
if(_historyEnabled) {
response = re["message"]["content"];
chats = [...chats, Chat(response, false)];
loadedChats = [...loadedChats, Chat(response, false)];
} else {
response = re["response"];
}
}
print("\n\nresponse:\n$response");
print("\nRaw chats variable:\n");
for(Chat c in chats) {
print(c.content);
}
conversations[conversationID].add(Chat(response, false));
prompt = "";
response = "";
}
}