diff --git a/.gitignore b/.gitignore index 2b108e6..809d61c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .venv +.env __pycache__ horde.db \ No newline at end of file diff --git a/Makefile b/Makefile index 1f8f801..a303e66 100644 --- a/Makefile +++ b/Makefile @@ -13,6 +13,6 @@ install: venv web: frontend/build/web/index.html frontend/build/web/index.html: - cd frontend && flutter build web + cd frontend && flutter build web --release --source-maps --no-web-resources-cdn frontend/build/web/index.html: $(shell find frontend/lib -name '*.dart') \ No newline at end of file diff --git a/backend/app.py b/backend/app.py index 778736c..1bcd174 100644 --- a/backend/app.py +++ b/backend/app.py @@ -209,6 +209,7 @@ class App: user_message = { 'action': 'chat-message', 'id': message.id, + 'chatId': message.chat_id, 'time': message.time, 'sender': message.sender, 'kind': message.kind, diff --git a/cli/main.js b/cli/main.js new file mode 100644 index 0000000..dd49dd8 --- /dev/null +++ b/cli/main.js @@ -0,0 +1,131 @@ +const WebSocket = require('ws'); + +const ws = new WebSocket('ws://localhost:5000/ws'); +var nonceCounter = 1; +var nonceCallbacks = {}; +var messageQueue = []; +var messageResolverQueue = []; + +// Setup + +ws.on('open', function open() { + const packet = JSON.stringify({ username: "test-user", password: "123456" }); + ws.send(packet); + + main(); +}); + +ws.on('message', function incoming(rawMessage) { + const parsed = JSON.parse(rawMessage); + const callback = nonceCallbacks[parsed.nonce]; + if (callback) { + callback(parsed); + } else { + if (messageResolverQueue.length > 0) { + const resolver = messageResolverQueue.shift(); + resolver(parsed); + } else { + messageQueue.push(parsed); + } + } +}); + +async function send (action, message) { + const nonce = nonceCounter++; + let promise = new Promise((resolve, reject) => { + nonceCallbacks[nonce] = res => { + if (res.error) reject(Error(res.error)); + else resolve(res); + };; + }); + + const packet = JSON.stringify({ nonce, action, ...message }); + ws.send(packet); + return promise; +} + +function waitMessage() { + return new Promise((resolve) => { + if (messageQueue.length > 0) { + resolve(messageQueue.shift()); + } else { + messageResolverQueue.push(resolve); + } + }); +} + + +// Testing + +async function main() { + const prompt = process.argv[2] + let agents = (await send('get-agents')).agents; + console.log("Agents", agents); + + console.log("Send prompt:", prompt) + let promptResponse = await send('send-agent-prompt', { + body: { + agentId: agents[0].id, + content: "Hello World!", + } + }); + console.log("[AGENT]") + console.log(promptResponse); +} + +async function runTest() { + console.log("Initial Chats", + await send('get-chats') + ); + + console.log("Create Agent", + await send('create-agent', {body: { + name: 'First Agent', + description: 'First agent does nothing', + kind: 'simple', + data: {} + }}) + ); + + let agents = (await send('get-agents')).agents; + console.log("Get Agents", agents); + + if (USE_LLM) { + let promptResponse = await send('send-agent-prompt', { + body: { + agentId: agents[0].id, + content: "Hello World!", + } + }); + console.log("Prompt Response", promptResponse); + } + + await testChats(); + + process.exit(); +} + +async function testChats () { + let response = await send('get-chats') + let chat = response.chats[0]; + + if (USE_LLM) { + let msgRes = await send('send-chat-message', { + body: { + chatId: chat.id, + content: "Whats up!", + } + }); + console.log('message sent', msgRes); + console.log('Incoming:', await waitMessage()); + + // Multi turn conversation + await send('send-chat-message', { + body: { + chatId: chat.id, + content: "Spell the initials of my first message.", + } + }); + console.log('Incoming:', await waitMessage()); + } +} \ No newline at end of file diff --git a/frontend/lib/agent.dart b/frontend/lib/agent.dart deleted file mode 100644 index 626f138..0000000 --- a/frontend/lib/agent.dart +++ /dev/null @@ -1,9 +0,0 @@ -import 'chat.dart'; - -class Agent { - void receivedChatMessage(Chat chat, String message) { - // Simple response from the agent - String responseMessage = "Agent received: " + message; - chat.addAgentMessage(responseMessage); - } -} \ No newline at end of file diff --git a/frontend/lib/app_menu.dart b/frontend/lib/app_menu.dart new file mode 100644 index 0000000..d8ba687 --- /dev/null +++ b/frontend/lib/app_menu.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; +import 'chat_list_screen.dart'; + +class AppMenu extends StatelessWidget { + const AppMenu({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Menu')), + body: ListView( + children: [ + ListTile( + title: const Text('Chats'), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const ChatListScreen(), + ), + ); + }, + ), + // Add other settings options here + ], + ), + ); + } +} \ No newline at end of file diff --git a/frontend/lib/backend.dart b/frontend/lib/backend.dart index f002086..5fca77d 100644 --- a/frontend/lib/backend.dart +++ b/frontend/lib/backend.dart @@ -1,27 +1,31 @@ + import 'dart:html'; import 'dart:convert'; +import 'dart:async'; // Import for handling Completers class Backend { static WebSocket? _socket; static String? _url; + static String? _username; + static Map _pendingActions = {}; + static Map>> _chatControllers = {}; + static int _nextNonce = 0; // Tracking nonce for each action + static StreamController> _packetStreamController = StreamController.broadcast(); static void setup(String url) { _url = url; connect(); } - static void auth(username) { - _socket!.send( - json.encode({'username': username}) - ); + static void auth(String username, String password) { + _socket!.send(json.encode({'username': username, 'password': password})); + _username = username; } static void connect() { _socket = WebSocket(_url!); _socket!.onOpen.listen((event) { print('Connected to the WebSocket server'); - auth("the-user"); - ping(); }); _socket!.onError.listen((event) { print('Error: $event'); @@ -31,6 +35,9 @@ class Backend { print('Disconnected. Attempting to reconnect...'); _reconnect(); }); + _socket!.onMessage.listen((event) { + _handleIncomingMessage(event.data); + }); } static void _reconnect() { @@ -39,8 +46,65 @@ class Backend { }); } + static Future sendAction(String action, Map body) { + int nonce = _nextNonce++; + Completer completer = Completer(); + _pendingActions[nonce] = completer; + + final packet = { + 'action': action, + 'nonce': nonce, + 'body': body, + }; + _socket!.send(json.encode(packet)); + + return completer.future.then((response) { + _pendingActions.remove(nonce); // Clean up the mapping + return response; + }); + } + + static void _handleIncomingMessage(String data) { + var jsonData = json.decode(data); + print("Incoming Packet: $jsonData"); + + if (jsonData['nonce'] != null) { + int nonce = jsonData['nonce']; + if (_pendingActions.containsKey(nonce)) { + // Complete the response + _pendingActions[nonce]!.complete(jsonData); + } else { + print("Orphan response for action nonce $nonce"); + } + } else if (jsonData['action'] == 'chat-message') { + int chatId = jsonData['chatId']; + final controller = _chatControllers[chatId]; + if (controller != null && !controller.isClosed) { + controller.add(jsonData); + } else { + print("No active listeners for chatId $chatId"); + } + } else { + _packetStreamController.add(jsonData); + } + } + + static Stream> chatMessages(int chatId) { + if (!_chatControllers.containsKey(chatId) || _chatControllers[chatId]!.isClosed) { + _chatControllers[chatId] = StreamController>.broadcast(); + } + return _chatControllers[chatId]!.stream; + } + + static void removeChatListener(int chatId) { + final controller = _chatControllers.remove(chatId); + if (controller != null && !controller.isClosed) { + controller.close(); + } + } + static void ping() { - if (_socket != null && _socket!.readyState == WebSocket.OPEN) { + if (_socket!= null && _socket!.readyState == WebSocket.OPEN) { final data = json.encode({'path': 'ping'}); _socket!.send(data); print('Ping sent: $data'); diff --git a/frontend/lib/chat.dart b/frontend/lib/chat.dart deleted file mode 100644 index a29d379..0000000 --- a/frontend/lib/chat.dart +++ /dev/null @@ -1,31 +0,0 @@ -import 'agent.dart'; // Import the Agent class - -class Message { - final String content; - final String sender; - - Message(this.content, this.sender); -} - -class Chat { - final String id; - final List _messages = []; - final Agent agent = Agent(); // Create an instance of Agent - - Chat(this.id); - - List get messages => List.unmodifiable(_messages); - - void addUserMessage(String message) { - _messages.add(Message(message, 'user')); - agent.receivedChatMessage(this, message); // Call agent's method - } - - void addAgentMessage(String message) { - _messages.add(Message(message, 'agent')); - } - - void clearMessages() { - _messages.clear(); - } -} \ No newline at end of file diff --git a/frontend/lib/chat_list_screen.dart b/frontend/lib/chat_list_screen.dart new file mode 100644 index 0000000..cd52e81 --- /dev/null +++ b/frontend/lib/chat_list_screen.dart @@ -0,0 +1,64 @@ +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}); +} + +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), + ), + ); + }, + ); + }, + ); + } + }, + ), + ); + } +} \ No newline at end of file diff --git a/frontend/lib/chat_screen.dart b/frontend/lib/chat_screen.dart index 85d4375..83e309a 100644 --- a/frontend/lib/chat_screen.dart +++ b/frontend/lib/chat_screen.dart @@ -1,56 +1,133 @@ import 'package:flutter/material.dart'; -import 'chat.dart'; // Import the new Chat class +import 'app_menu.dart'; +import 'styles.dart'; +import 'backend.dart'; +import 'dart:async'; + +class Message { + final String sender; + final String text; + final bool isFromUser; + + Message({required this.sender, required this.text}) : isFromUser = sender == "user"; + + Alignment get alignment => isFromUser ? Alignment.centerRight : Alignment.centerLeft; + EdgeInsets get padding => isFromUser ? EdgeInsets.zero : const EdgeInsets.only(left: 10); +} class ChatScreen extends StatefulWidget { - const ChatScreen({super.key}); + final int chatId; + + ChatScreen({required this.chatId}); + + static Future> getChatMessages(int chatId) async { + final response = await Backend.sendAction("get-chat-messages", {'chatId': chatId}); + print("get-chat-messages response: $response"); + List messageObjects = response['messages']; + List messages = messageObjects.map((msg) => Message(sender: msg['sender'], text: msg['content'] as String)).toList(); + return messages; + } @override - State createState() => _ChatScreenState(); + _ChatScreenState createState() => _ChatScreenState(); } class _ChatScreenState extends State { - final Chat _chat = Chat('chat1'); // Create an instance of Chat - final TextEditingController _controller = TextEditingController(); + List _messages = []; + final TextEditingController _textController = TextEditingController(); + StreamSubscription? _chatSubscription; - void _sendMessage() { - if (_controller.text.isNotEmpty) { + @override + void initState() { + super.initState(); + _loadInitialMessages(); + _chatSubscription = Backend.chatMessages(widget.chatId).listen((data) { + final message = Message(sender: data['sender'], text: data['content']); + if (!message.isFromUser) { + addMessage(message); + } + }); + } + + @override + void dispose() { + Backend.removeChatListener(widget.chatId); + super.dispose(); + } + + Future _loadInitialMessages() async { + final initialMessages = await ChatScreen.getChatMessages(widget.chatId); + setState(() { + _messages.addAll(initialMessages); + }); + } + + void _handleNewMessage(Map message) { + final newMessage = Message(sender: message['sender'], text: message['content'] as String); + if (newMessage.sender!= "user") { setState(() { - _chat.addUserMessage(_controller.text); // Use the Chat instance to add the message - _controller.clear(); + _messages.add(newMessage); }); } } + void addMessage(Message message) { + setState(() { + _messages.add(message); + }); + } + + void sendMessage(String content) { + _textController.clear(); + final newMessage = Message(sender: "user", text: content); + Backend.sendAction("send-chat-message", {'chatId': widget.chatId, 'content': content}).then((response) { + if (response['error'] != null) { + print("Error sending chat message: ${response['error']}"); + } + }); + addMessage(newMessage); + } + + Color getMessageColor(BuildContext context) { + final backgroundColor = Theme.of(context).scaffoldBackgroundColor; + final messageColor = Color.alphaBlend(Colors.white.withOpacity(0.1), backgroundColor); + return messageColor; + } + @override Widget build(BuildContext context) { - final width = MediaQuery.of(context).size.width; - final isBigScreen = width > 600; // Define threshold for small screens + final isWideScreen = MediaQuery.of(context).size.width > 600; return Scaffold( appBar: AppBar( - title: const Text('Chat'), + title: Text('Chat Room ${widget.chatId}'), + actions: [ + IconButton(icon: Icon(Icons.settings), onPressed: () { + Navigator.push(context, MaterialPageRoute(builder: (_) => AppMenu())); + }), + ], ), body: Column( children: [ Expanded( child: ListView.builder( - itemCount: _chat.messages.length, + itemCount: _messages.length, itemBuilder: (context, index) { - final message = _chat.messages[index]; - // Align all messages to left for big screens - final align = message.sender == 'user' && !isBigScreen ? CrossAxisAlignment.end : CrossAxisAlignment.start; - return Row( - mainAxisAlignment: align == CrossAxisAlignment.end ? MainAxisAlignment.end : MainAxisAlignment.start, - children: [ - Container( - padding: const EdgeInsets.all(8.0), - margin: const EdgeInsets.symmetric(vertical: 4.0), - child: Text( - '${message.content}', - style: const TextStyle(color: Colors.white), + final message = _messages[index]; + return Padding( + padding: isWideScreen ? message.padding : EdgeInsets.zero, + child: Align( + alignment: isWideScreen ? Alignment.centerLeft : message.alignment, + child: Container( + margin: EdgeInsets.symmetric(vertical: 2, horizontal: 8), + padding: EdgeInsets.all(10), + decoration: BoxDecoration( + color: getMessageColor(context), + borderRadius: BorderRadius.circular(12), ), + child: Text(message.text), ), - ], + ), ); }, ), @@ -61,14 +138,17 @@ class _ChatScreenState extends State { children: [ Expanded( child: TextField( - controller: _controller, - decoration: const InputDecoration(hintText: 'Send a message'), - onSubmitted: (_) => _sendMessage(), + controller: _textController, + decoration: inputDecoration(), + onSubmitted: sendMessage, // Send message on Enter key ), ), - IconButton( - icon: const Icon(Icons.send), - onPressed: _sendMessage, + SizedBox(width: 8), + ElevatedButton( + onPressed: () { + sendMessage(_textController.text); + }, + child: Text('Send'), ), ], ), diff --git a/frontend/lib/login_screen.dart b/frontend/lib/login_screen.dart new file mode 100644 index 0000000..5be703d --- /dev/null +++ b/frontend/lib/login_screen.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; + +class LoginScreen extends StatelessWidget { + final Function(String, String) onLogin; + + LoginScreen({required this.onLogin}); + + final TextEditingController usernameController = TextEditingController(); + final TextEditingController passwordController = TextEditingController(); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text('Login')), + body: Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextField(controller: usernameController, decoration: InputDecoration(labelText: 'Username')), + SizedBox(height: 20), + TextField( + controller: passwordController, + decoration: InputDecoration(labelText: 'Password'), + obscureText: true, // Hide password characters + ), + SizedBox(height: 20), + ElevatedButton( + onPressed: () { + onLogin(usernameController.text, passwordController.text); + }, + child: Text('Login'), + ), + ], + ), + ), + ), + ); + } +} diff --git a/frontend/lib/main.dart b/frontend/lib/main.dart index 325549e..7f51af8 100644 --- a/frontend/lib/main.dart +++ b/frontend/lib/main.dart @@ -1,43 +1,46 @@ import 'dart:html' as html; import 'dart:io' as io; import 'package:flutter/material.dart'; -import 'chat_screen.dart'; import 'backend.dart'; +import 'user_checker.dart'; class Config { static String? backendUrl; // Declare it as nullable static void loadConfig() { - // Check the platform for web or other if (html.window.location.origin.isNotEmpty) { // For web applications backendUrl = html.window.location.origin; // Get the server URL from window.location - } else if (io.Platform.environment.containsKey('BACKEND_URL')) { - // For Android or other platforms, check for the environment variable + } + + // For Android or other platforms, check for the environment variable + /*if (io.Platform.environment.containsKey('BACKEND_URL')) { backendUrl = io.Platform.environment['BACKEND_URL']; + } else if (html.window.location.origin.isNotEmpty) { + // For web applications + backendUrl = html.window.location.origin; // Get the server URL from window.location } else { // If no value is determined, set it to null backendUrl = null; - } + }*/ } } void main() { + print("Main"); Config.loadConfig(); // Load the configuration before running the app Backend.setup('${Config.backendUrl}/ws'); - runApp(const MyApp()); + runApp(HubApp()); } -class MyApp extends StatelessWidget { - const MyApp({super.key}); - +class HubApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( - title: 'Flutter Chat', + title: 'Horde Hub', theme: ThemeData.dark(), - home: const ChatScreen(), + home: UserChecker(), ); } } \ No newline at end of file diff --git a/frontend/lib/styles.dart b/frontend/lib/styles.dart new file mode 100644 index 0000000..25896c3 --- /dev/null +++ b/frontend/lib/styles.dart @@ -0,0 +1,8 @@ +import 'package:flutter/material.dart'; + +InputDecoration inputDecoration() { + return InputDecoration( + border: OutlineInputBorder(), + hintText: 'Send a message', + ); +} \ No newline at end of file diff --git a/frontend/lib/user_checker.dart b/frontend/lib/user_checker.dart new file mode 100644 index 0000000..31de775 --- /dev/null +++ b/frontend/lib/user_checker.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; +import 'backend.dart'; +import 'login_screen.dart'; +import 'chat_screen.dart'; +import 'chat_list_screen.dart'; + +class UserChecker extends StatefulWidget { + @override + _UserCheckerState createState() => _UserCheckerState(); +} + +class _UserCheckerState extends State { + String? username; + bool isLoading = false; + + @override + void initState() { + super.initState(); + username = null; + } + + static void fetchChats(Function(List) onSuccess) { + Backend.sendAction("get-chats", {}).then((response) { + print("chats response: $response"); + List chatObjects = response['chats']; + List chatIds = chatObjects.map((chat) => chat['id'] as int).toList(); + onSuccess(chatIds); + }); + } + + void _handleLogin(String user, String password) { + Backend.auth(user, password); + + setState(() { + username = user; + isLoading = true; // Show loading spinner + }); + + fetchChats((chatIds) { + if (chatIds.isEmpty) { + Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (_) => ChatListScreen()), + ); + } else { + Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (_) => ChatScreen(chatId: chatIds.first)), + ); + } + }); + + } + + @override + Widget build(BuildContext context) { + print("Build UserChecker: $username $isLoading"); + if (isLoading) { + return Center(child: CircularProgressIndicator()); // Loading state UI + } + + return LoginScreen(onLogin: _handleLogin); + } +} diff --git a/run.sh b/run.sh index 7a60845..16acbf6 100644 --- a/run.sh +++ b/run.sh @@ -13,6 +13,9 @@ case "$1" in cd backend python app.py ../test-data ;; + "cli") + node cli/main.js "$2" + ;; "test") # Setup test data directory mkdir -p test-data diff --git a/scratchpad.md b/scratchpad.md index e2036e6..9529f27 100644 --- a/scratchpad.md +++ b/scratchpad.md @@ -22,7 +22,4 @@ MVP-3A has configurable agents, both user and agents can create new agents, also MVP-3C has multiple chats, users and agents can create chats, modify chat titles, and alter chat participants. MVP-3P is permissions. Modifying chats and agents both need a permission system. -**Current Status**: Frontend and backend connect via websocket. Backend does nothing else. Frontend has basic chat classes and gui, and a basic agent class that repeats the user message. - -# Next Steps -These must be determined by the current system and the next target, currently MVP. \ No newline at end of file +**Current Status**: Frontend and backend connect via websocket. Backend has usable chats and agents with direct prompts and chat messaging. Frontend features user login, manual and automatic chat selection, and live connection with backend chat. diff --git a/testing/suite.js b/testing/suite.js index 395cfe3..eb29e7c 100644 --- a/testing/suite.js +++ b/testing/suite.js @@ -1,6 +1,6 @@ const WebSocket = require('ws'); -const USE_LLM = false; +const USE_LLM = true; const ws = new WebSocket('ws://localhost:5000/ws'); var nonceCounter = 1; @@ -113,5 +113,9 @@ async function testChats () { } }); console.log('Incoming:', await waitMessage()); + + console.log('Messages in Chat', chat.id, await send('get-chat-messages', { + body: {chatId: chat.id} + })); } } \ No newline at end of file