Advanced chat management via UI

This commit is contained in:
Shivering Ocelot committed 2025-05-22 07:48:36 -05:00
1 parent fbee31841d
commit 5351144edf
9 files changed
+368 -31

No files matched your search

+17 -6
View File
@@ -63,7 +63,7 @@ class App:
print(f"Created agent #{agent.id}: {agent.name}") print(f"Created agent #{agent.id}: {agent.name}")
return agent.id 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: with self.sql_session() as session:
chat = db.Chat() chat = db.Chat()
chat.user_id = user_id chat.user_id = user_id
@@ -74,7 +74,13 @@ class App:
session.add(chat) session.add(chat)
session.commit() session.commit()
print(f"Created chat #{chat.id}: {chat.name}") 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): def get_chats(self, user_id):
with self.sql_session() as session: with self.sql_session() as session:
@@ -96,6 +102,13 @@ class App:
"picture": agent.picture "picture": agent.picture
} for agent in agents] } 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): def get_chat_messages(self, chat_id, earliest_index=0):
with self.sql_session() as session: with self.sql_session() as session:
messages = session.execute( messages = session.execute(
@@ -237,12 +250,10 @@ class App:
user_id=user_id, user_id=user_id,
name="Initial Chat", name="Initial Chat",
description=None, 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 __name__ == "__main__":
if len(sys.argv) > 1: if len(sys.argv) > 1:
data_directory = sys.argv[1] data_directory = sys.argv[1]
+8 -1
View File
@@ -91,7 +91,8 @@ def start_server(app):
user_id=user_id, user_id=user_id,
name=body['name'], name=body['name'],
description=body.get('description'), 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}) 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) chats_data = app.get_chats(user_id)
await send({'message': "chats retrieved", 'chats': chats_data, 'nonce': nonce}) 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": elif action == "get-chat-messages":
messages_data = app.get_chat_messages( messages_data = app.get_chat_messages(
body['chatId'], body['chatId'],
+80
View File
@@ -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<List<AgentEntry>> getChats() async {
final response = await Backend.sendAction("get-agents", {});
List<dynamic> chatObjects = response['agents'];
List<AgentEntry> 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<List<AgentEntry>>(
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),
),
);*/
},
);
},
);
}
},
),
),
],
),
);
}
}
+12
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'agent_list_screen.dart';
import 'chat_list_screen.dart'; import 'chat_list_screen.dart';
import 'user_checker.dart'; import 'user_checker.dart';
import 'backend.dart'; import 'backend.dart';
@@ -13,6 +14,17 @@ class AppMenu extends StatelessWidget {
appBar: AppBar(title: const Text('Menu')), appBar: AppBar(title: const Text('Menu')),
body: ListView( body: ListView(
children: <Widget>[ children: <Widget>[
ListTile(
title: const Text('Agents'),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const AgentListScreen(),
),
);
},
),
ListTile( ListTile(
title: const Text('Chats'), title: const Text('Chats'),
onTap: () { onTap: () {
+39
View File
@@ -3,6 +3,13 @@ import 'dart:html';
import 'dart:convert'; import 'dart:convert';
import 'dart:async'; // Import for handling Completers import 'dart:async'; // Import for handling Completers
class ChatInfo {
int id;
String name;
ChatInfo({required this.id, required this.name});
}
class Backend { class Backend {
static WebSocket? _socket; static WebSocket? _socket;
static String? _url; static String? _url;
@@ -11,6 +18,9 @@ class Backend {
static int _nextNonce = 0; // Tracking nonce for each action static int _nextNonce = 0; // Tracking nonce for each action
static StreamController<Map<String, dynamic>> _packetStreamController = StreamController.broadcast(); static StreamController<Map<String, dynamic>> _packetStreamController = StreamController.broadcast();
// Cached Data
static List<ChatInfo>? _chats;
// Authentication // Authentication
static ({String username, String password})? _credentials; static ({String username, String password})? _credentials;
static Completer? _loginCompleter; static Completer? _loginCompleter;
@@ -36,6 +46,8 @@ class Backend {
_socket = WebSocket(_url!); _socket = WebSocket(_url!);
_socket!.onOpen.listen((event) { _socket!.onOpen.listen((event) {
print('Connected to the WebSocket server'); print('Connected to the WebSocket server');
Backend.invalidateCache(chat: true);
print('Logging in...'); print('Logging in...');
if (_credentials != null) { if (_credentials != null) {
_socket!.send(json.encode({'username': _credentials!.username, 'password': _credentials!.password})); _socket!.send(json.encode({'username': _credentials!.username, 'password': _credentials!.password}));
@@ -128,6 +140,33 @@ class Backend {
} }
} }
static Future<List<ChatInfo>> getChats () async {
if (_chats == null) {
final response = await Backend.sendAction("get-chats", {});
List<dynamic> 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<ChatInfo?> 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() { static void ping() {
if (_socket!= null && _socket!.readyState == WebSocket.OPEN) { if (_socket!= null && _socket!.readyState == WebSocket.OPEN) {
final data = json.encode({'path': 'ping'}); final data = json.encode({'path': 'ping'});
+61 -21
View File
@@ -1,35 +1,28 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'backend.dart'; import 'backend.dart';
import 'chat_screen.dart'; import 'chat_screen.dart';
import 'create_chat_screen.dart';
class ChatEntry {
final int id;
final String name;
ChatEntry({required this.id, required this.name});
}
class ChatListScreen extends StatelessWidget { class ChatListScreen extends StatelessWidget {
const ChatListScreen({super.key}); const ChatListScreen({super.key});
static Future<List<ChatEntry>> getChats() async {
final response = await Backend.sendAction("get-chats", {});
List<dynamic> chatObjects = response['chats'];
List<ChatEntry> chats = chatObjects
.map((msg) => ChatEntry(
id: msg['id'],
name: msg['name']?.isNotEmpty == true ? msg['name'] : '[Chat ${msg['id']}]',
))
.toList();
return chats;
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('Chats')), appBar: AppBar(title: const Text('Chats')),
body: FutureBuilder<List<ChatEntry>>( body: Column( // Use a Column to stack widgets vertically
future: getChats(), 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<List<ChatInfo>>(
future: Backend.getChats(),
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) { if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
@@ -53,12 +46,59 @@ class ChatListScreen extends StatelessWidget {
), ),
); );
}, },
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: <Widget>[
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()),
);
},
),
],
);
},
);
},
),
],
),
); );
}, },
); );
} }
}, },
), ),
),
],
),
); );
} }
} }
+6 -3
View File
@@ -33,6 +33,7 @@ class ChatScreen extends StatefulWidget {
} }
class _ChatScreenState extends State<ChatScreen> { class _ChatScreenState extends State<ChatScreen> {
String? _title;
List<Message> _messages = []; List<Message> _messages = [];
final TextEditingController _textController = TextEditingController(); final TextEditingController _textController = TextEditingController();
StreamSubscription? _chatSubscription; StreamSubscription? _chatSubscription;
@@ -40,7 +41,7 @@ class _ChatScreenState extends State<ChatScreen> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_loadInitialMessages(); _loadInitialData();
_chatSubscription = Backend.chatMessages(widget.chatId).listen((data) { _chatSubscription = Backend.chatMessages(widget.chatId).listen((data) {
final message = Message(sender: data['sender'], text: data['content']); final message = Message(sender: data['sender'], text: data['content']);
if (!message.isFromUser) { if (!message.isFromUser) {
@@ -55,10 +56,12 @@ class _ChatScreenState extends State<ChatScreen> {
super.dispose(); super.dispose();
} }
Future<void> _loadInitialMessages() async { Future<void> _loadInitialData() async {
final chatInfo = (await Backend.getChat(widget.chatId))!;
final initialMessages = await ChatScreen.getChatMessages(widget.chatId); final initialMessages = await ChatScreen.getChatMessages(widget.chatId);
setState(() { setState(() {
_messages.addAll(initialMessages); _messages.addAll(initialMessages);
_title = chatInfo.name;
}); });
} }
@@ -100,7 +103,7 @@ class _ChatScreenState extends State<ChatScreen> {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text('Chat Room ${widget.chatId}'), title: Text(_title ?? 'Chat Room ${widget.chatId}'),
actions: [ actions: [
IconButton(icon: Icon(Icons.settings), onPressed: () { IconButton(icon: Icon(Icons.settings), onPressed: () {
Navigator.push(context, MaterialPageRoute(builder: (_) => AppMenu())); Navigator.push(context, MaterialPageRoute(builder: (_) => AppMenu()));
+140
View File
@@ -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<CreateChatScreen> {
final TextEditingController nameController = TextEditingController();
// Track selected participants
final Set<int> 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<List<AgentEntry>> getAgents() async {
final response = await Backend.sendAction("get-agents", {});
List<dynamic> chatObjects = response['agents'];
List<AgentEntry> 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<List<AgentEntry>>(
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'),
),
],
),
),
),
);
}
}
+5
View File
@@ -83,3 +83,8 @@ Each command must be in it's own separate line, and it's made of these parts:
- json argument - json argument
For example: `!!query namespace.command_name {"arg1":"some data","arg2":[1,2,false]}` 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. 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