diff --git a/backend/app.py b/backend/app.py index 9d66ee0..d7eb080 100644 --- a/backend/app.py +++ b/backend/app.py @@ -63,7 +63,7 @@ class App: print(f"Created agent #{agent.id}: {agent.name}") return agent.id - def create_chat(self, user_id, name, description, picture): + def create_chat(self, user_id, name, description, picture, members=[]): with self.sql_session() as session: chat = db.Chat() chat.user_id = user_id @@ -74,7 +74,13 @@ class App: session.add(chat) session.commit() print(f"Created chat #{chat.id}: {chat.name}") - return chat.id + + chat_id = chat.id + + for agent_id in members: + self.add_agent_to_chat(chat_id, agent_id) + + return chat_id def get_chats(self, user_id): with self.sql_session() as session: @@ -96,6 +102,13 @@ class App: "picture": agent.picture } for agent in agents] + def delete_chat(self, chat_id): + with self.sql_session() as session: + session.execute(db.Membership.__table__.delete().where(db.Membership.chat_id == chat_id)) + session.execute(db.Chat.__table__.delete().where(db.Chat.id == chat_id)) + session.commit() + print(f"Deleted chat #{chat_id} and its memberships.") + def get_chat_messages(self, chat_id, earliest_index=0): with self.sql_session() as session: messages = session.execute( @@ -237,12 +250,10 @@ class App: user_id=user_id, name="Initial Chat", description=None, - picture=None + picture=None, + members=[agent_id], ) - # Add the agent to the chat - self.add_agent_to_chat(chat_id, agent_id) - if __name__ == "__main__": if len(sys.argv) > 1: data_directory = sys.argv[1] diff --git a/backend/server.py b/backend/server.py index 1c2fa8b..42f4b2a 100644 --- a/backend/server.py +++ b/backend/server.py @@ -91,7 +91,8 @@ def start_server(app): user_id=user_id, name=body['name'], description=body.get('description'), - picture=body.get('picture') + picture=body.get('picture'), + members=body.get('members', []), ) await send({'message': "chat created", 'chatId': chat_id, 'nonce': nonce}) @@ -99,6 +100,12 @@ def start_server(app): chats_data = app.get_chats(user_id) await send({'message': "chats retrieved", 'chats': chats_data, 'nonce': nonce}) + elif action == "delete-chat": + chat_id = app.delete_chat( + chat_id=body['chatId'] + ) + await send({'message': "chat deleted", 'chatId': chat_id, 'nonce': nonce}) + elif action == "get-chat-messages": messages_data = app.get_chat_messages( body['chatId'], diff --git a/frontend/lib/agent_list_screen.dart b/frontend/lib/agent_list_screen.dart new file mode 100644 index 0000000..0596e65 --- /dev/null +++ b/frontend/lib/agent_list_screen.dart @@ -0,0 +1,80 @@ +import 'package:flutter/material.dart'; +import 'backend.dart'; +import 'chat_screen.dart'; +import 'create_chat_screen.dart'; + +class AgentEntry { + final int id; + final String name; + + AgentEntry({required this.id, required this.name}); +} + +class AgentListScreen extends StatelessWidget { + const AgentListScreen({super.key}); + + static Future> getChats() async { + final response = await Backend.sendAction("get-agents", {}); + List chatObjects = response['agents']; + List chats = chatObjects + .map((msg) => AgentEntry( + id: msg['id'], + name: msg['name']?.isNotEmpty == true ? msg['name'] : '[Agent ${msg['id']}]', + )) + .toList(); + return chats; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Agents')), + body: Column( // Use a Column to stack widgets vertically + children: [ + ListTile( + leading: const Icon(Icons.add), + title: const Text('Create'), + onTap: () { + print("TODO: Create new agents"); + }, + ), + const Divider(height: 1), + Expanded( + child: FutureBuilder>( + future: getChats(), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } else if (snapshot.hasError) { + return Center(child: Text('Error: ${snapshot.error}')); + } else if (!snapshot.hasData || snapshot.data!.isEmpty) { + return const Center(child: Text('No agents available.')); + } else { + final agents = snapshot.data!; + return ListView.builder( + itemCount: agents.length, + itemBuilder: (context, index) { + final agent = agents[index]; + return ListTile( + title: Text(agent.name), + onTap: () { + print("Navigate to agent ${agent.id}"); + /*Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ChatScreen(agentId: agent.id), + ), + );*/ + }, + ); + }, + ); + } + }, + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/frontend/lib/app_menu.dart b/frontend/lib/app_menu.dart index 3c6133a..5306231 100644 --- a/frontend/lib/app_menu.dart +++ b/frontend/lib/app_menu.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'agent_list_screen.dart'; import 'chat_list_screen.dart'; import 'user_checker.dart'; import 'backend.dart'; @@ -13,6 +14,17 @@ class AppMenu extends StatelessWidget { appBar: AppBar(title: const Text('Menu')), body: ListView( children: [ + ListTile( + title: const Text('Agents'), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const AgentListScreen(), + ), + ); + }, + ), ListTile( title: const Text('Chats'), onTap: () { diff --git a/frontend/lib/backend.dart b/frontend/lib/backend.dart index 2eda9d2..60ab817 100644 --- a/frontend/lib/backend.dart +++ b/frontend/lib/backend.dart @@ -3,6 +3,13 @@ import 'dart:html'; import 'dart:convert'; import 'dart:async'; // Import for handling Completers +class ChatInfo { + int id; + String name; + + ChatInfo({required this.id, required this.name}); +} + class Backend { static WebSocket? _socket; static String? _url; @@ -10,6 +17,9 @@ class Backend { static Map>> _chatControllers = {}; static int _nextNonce = 0; // Tracking nonce for each action static StreamController> _packetStreamController = StreamController.broadcast(); + + // Cached Data + static List? _chats; // Authentication static ({String username, String password})? _credentials; @@ -36,6 +46,8 @@ class Backend { _socket = WebSocket(_url!); _socket!.onOpen.listen((event) { print('Connected to the WebSocket server'); + Backend.invalidateCache(chat: true); + print('Logging in...'); if (_credentials != null) { _socket!.send(json.encode({'username': _credentials!.username, 'password': _credentials!.password})); @@ -128,6 +140,33 @@ class Backend { } } + static Future> getChats () async { + if (_chats == null) { + final response = await Backend.sendAction("get-chats", {}); + List chatObjects = response['chats']; + _chats = chatObjects + .map((msg) => ChatInfo( + id: msg['id'], + name: msg['name']?.isNotEmpty == true ? msg['name'] : '[Chat ${msg['id']}]', + )) + .toList(); + } + return _chats!; + } + + static Future getChat (int chatId) async { + final chats = await getChats(); + try { + return chats.firstWhere((chat) => chat.id == chatId); + } on StateError { + return null; + } + } + + static void invalidateCache ({bool chat = false}) { + if (chat) { _chats = null; } + } + static void ping() { if (_socket!= null && _socket!.readyState == WebSocket.OPEN) { final data = json.encode({'path': 'ping'}); diff --git a/frontend/lib/chat_list_screen.dart b/frontend/lib/chat_list_screen.dart index cd52e81..4cae487 100644 --- a/frontend/lib/chat_list_screen.dart +++ b/frontend/lib/chat_list_screen.dart @@ -1,63 +1,103 @@ import 'package:flutter/material.dart'; import 'backend.dart'; import 'chat_screen.dart'; - -class ChatEntry { - final int id; - final String name; - - ChatEntry({required this.id, required this.name}); -} +import 'create_chat_screen.dart'; class ChatListScreen extends StatelessWidget { const ChatListScreen({super.key}); - static Future> getChats() async { - final response = await Backend.sendAction("get-chats", {}); - List chatObjects = response['chats']; - List chats = chatObjects - .map((msg) => ChatEntry( - id: msg['id'], - name: msg['name']?.isNotEmpty == true ? msg['name'] : '[Chat ${msg['id']}]', - )) - .toList(); - return chats; - } - @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('Chats')), - body: FutureBuilder>( - future: getChats(), - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.waiting) { - return const Center(child: CircularProgressIndicator()); - } else if (snapshot.hasError) { - return Center(child: Text('Error: ${snapshot.error}')); - } else if (!snapshot.hasData || snapshot.data!.isEmpty) { - return const Center(child: Text('No chats available.')); - } else { - final chats = snapshot.data!; - return ListView.builder( - itemCount: chats.length, - itemBuilder: (context, index) { - final chat = chats[index]; - return ListTile( - title: Text(chat.name), - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => ChatScreen(chatId: chat.id), - ), - ); - }, - ); + body: Column( // Use a Column to stack widgets vertically + children: [ + ListTile( + leading: const Icon(Icons.add), + title: const Text('Create'), + onTap: () { + Navigator.push(context, MaterialPageRoute(builder: (context) => CreateChatScreen())); + }, + ), + const Divider(height: 1), + Expanded( + child: FutureBuilder>( + future: Backend.getChats(), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } else if (snapshot.hasError) { + return Center(child: Text('Error: ${snapshot.error}')); + } else if (!snapshot.hasData || snapshot.data!.isEmpty) { + return const Center(child: Text('No chats available.')); + } else { + final chats = snapshot.data!; + return ListView.builder( + itemCount: chats.length, + itemBuilder: (context, index) { + final chat = chats[index]; + return ListTile( + title: Text(chat.name), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ChatScreen(chatId: chat.id), + ), + ); + }, + trailing: Row( + mainAxisSize: MainAxisSize.min, // Important to make the row take minimum space + children: [ + IconButton( + icon: const Icon(Icons.settings), + onPressed: () { + print('Config button pressed for chat: ${chat.name}'); + }, + ), + IconButton( + icon: const Icon(Icons.delete), + onPressed: () { + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('Delete Chat'), + content: Text('Are you sure you want to delete "${chat.name}"?'), + actions: [ + TextButton( + child: const Text('Cancel'), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + TextButton( + child: const Text('Delete'), + onPressed: () async { + await Backend.sendAction('delete-chat', {'chatId': chat.id}); + Backend.invalidateCache(chat: true); + Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (context) => const ChatListScreen()), + ); + }, + ), + ], + ); + }, + ); + }, + ), + ], + ), + ); + }, + ); + } }, - ); - } - }, + ), + ), + ], ), ); } diff --git a/frontend/lib/chat_screen.dart b/frontend/lib/chat_screen.dart index 83e309a..7a0c3dd 100644 --- a/frontend/lib/chat_screen.dart +++ b/frontend/lib/chat_screen.dart @@ -33,6 +33,7 @@ class ChatScreen extends StatefulWidget { } class _ChatScreenState extends State { + String? _title; List _messages = []; final TextEditingController _textController = TextEditingController(); StreamSubscription? _chatSubscription; @@ -40,7 +41,7 @@ class _ChatScreenState extends State { @override void initState() { super.initState(); - _loadInitialMessages(); + _loadInitialData(); _chatSubscription = Backend.chatMessages(widget.chatId).listen((data) { final message = Message(sender: data['sender'], text: data['content']); if (!message.isFromUser) { @@ -55,10 +56,12 @@ class _ChatScreenState extends State { super.dispose(); } - Future _loadInitialMessages() async { + Future _loadInitialData() async { + final chatInfo = (await Backend.getChat(widget.chatId))!; final initialMessages = await ChatScreen.getChatMessages(widget.chatId); setState(() { _messages.addAll(initialMessages); + _title = chatInfo.name; }); } @@ -100,7 +103,7 @@ class _ChatScreenState extends State { return Scaffold( appBar: AppBar( - title: Text('Chat Room ${widget.chatId}'), + title: Text(_title ?? 'Chat Room ${widget.chatId}'), actions: [ IconButton(icon: Icon(Icons.settings), onPressed: () { Navigator.push(context, MaterialPageRoute(builder: (_) => AppMenu())); diff --git a/frontend/lib/create_chat_screen.dart b/frontend/lib/create_chat_screen.dart new file mode 100644 index 0000000..b88eac9 --- /dev/null +++ b/frontend/lib/create_chat_screen.dart @@ -0,0 +1,140 @@ +import 'package:flutter/material.dart'; +import 'backend.dart'; +import 'chat_screen.dart'; + +class AgentEntry { + final int id; + final String name; + + AgentEntry({required this.id, required this.name}); +} + +class CreateChatScreen extends StatefulWidget { + @override + _CreateChatScreenState createState() => _CreateChatScreenState(); +} + +class _CreateChatScreenState extends State { + final TextEditingController nameController = TextEditingController(); + + // Track selected participants + final Set selectedParticipants = {}; + + void _submit() async { + Backend.invalidateCache(chat: true); + final response = await Backend.sendAction("create-chat", { + 'name': nameController.text, + 'members': selectedParticipants.toList(), + }); + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ChatScreen(chatId: response['chatId']), + ), + ); + } + + void _toggleParticipant(int id) { + setState(() { + if (selectedParticipants.contains(id)) { + selectedParticipants.remove(id); + } else { + selectedParticipants.add(id); + } + }); + } + + // TODO: Agents should only be retrieved once, then the same future is reused in `build` + static Future> getAgents() async { + final response = await Backend.sendAction("get-agents", {}); + List chatObjects = response['agents']; + List chats = chatObjects + .map((msg) => AgentEntry( + id: msg['id'], + name: msg['name']?.isNotEmpty == true ? msg['name'] : '[Chat ${msg['id']}]', + )) + .toList(); + return chats; + } + + Widget _buildParticipantTile(AgentEntry entry) { + final isSelected = selectedParticipants.contains(entry.id); + return GestureDetector( + onTap: () => _toggleParticipant(entry.id), + child: Container( + padding: EdgeInsets.symmetric(vertical: 12, horizontal: 16), + margin: EdgeInsets.symmetric(vertical: 4), + decoration: BoxDecoration( + color: isSelected ? Colors.blue.shade100 : Colors.grey.shade200, + border: Border.all( + color: isSelected ? Colors.blue : Colors.transparent, + width: 2, + ), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + entry.name, + style: TextStyle( + fontWeight: FontWeight.w600, + color: isSelected ? Colors.blue.shade900 : Colors.black87, + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text('Create Chat')), + body: Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + TextField( + controller: nameController, + decoration: InputDecoration(labelText: 'Title'), + ), + SizedBox(height: 20), + Align( + alignment: Alignment.centerLeft, + child: Text('Select Participants:', + style: TextStyle( + fontSize: 16, fontWeight: FontWeight.bold)), + ), + SizedBox(height: 8), + Expanded( + child: FutureBuilder>( + future: getAgents(), + builder: (context, snapshot) { + if (snapshot.hasData) { + final agents = snapshot.data!; + return ListView.builder( + itemCount: agents.length, + itemBuilder: (context, index) { + return _buildParticipantTile(agents[index]); + }, + ); + } else { + return ListView.builder( + itemCount: 0, + itemBuilder: (context, index) { + return ListTile(); + }, + ); + } + }, + ), + ), + ElevatedButton( + onPressed: _submit, + child: Text('Create Chat'), + ), + ], + ), + ), + ), + ); + } +} diff --git a/scratchpad.md b/scratchpad.md index 5693af3..a944285 100644 --- a/scratchpad.md +++ b/scratchpad.md @@ -82,4 +82,9 @@ Each command must be in it's own separate line, and it's made of these parts: - full command name - json argument For example: `!!query namespace.command_name {"arg1":"some data","arg2":[1,2,false]}` -If you add the query modifier, you will see the output of the commands in a continuation prompt, otherwise it will only be shown to the user. \ No newline at end of file +If you add the query modifier, you will see the output of the commands in a continuation prompt, otherwise it will only be shown to the user. + +# Chat Editor + +Packages useful for the chat editor: +- https://pub.dev/packages/custom_text \ No newline at end of file