diff --git a/backend/app.py b/backend/app.py index 939dc09..734107a 100644 --- a/backend/app.py +++ b/backend/app.py @@ -1,11 +1,10 @@ -import sys -import json -import asyncio - -from database import create_sql_engine, User, Agent +from database import create_sql_engine, User, Agent, Chat, Message, Membership from sqlalchemy.orm import Session from sqlalchemy.sql.expression import select from quart import Quart, Response, request, send_from_directory, jsonify, websocket +import asyncio +import json +import sys class App: def __init__(self, data_directory): @@ -15,7 +14,129 @@ class App: def sql_session(self): return Session(self.sql_engine) -app = App("./") + def create_agent(self, user_id, name, description, picture, kind, data): + with self.sql_session() as session: + agent = Agent() + agent.user_id = user_id + agent.name = name + agent.description = description + agent.picture = picture + agent.kind = kind + agent.data = data + + session.add(agent) + session.commit() + print(f"Created agent #{agent.id}: {agent.name}") + return agent.id + + def create_chat(self, user_id, name, description, picture): + with self.sql_session() as session: + chat = Chat() + chat.user_id = user_id + chat.name = name + chat.description = description + chat.picture = picture + + session.add(chat) + session.commit() + print(f"Created chat #{chat.id}: {chat.name}") + return chat.id + + def get_chats(self, user_id): + with self.sql_session() as session: + chats = session.execute(select(Chat).where(Chat.user_id == user_id)).scalars().all() + return [{ + "id": chat.id, + "name": chat.name, + "description": chat.description, + "picture": chat.picture + } for chat in chats] + + def get_agents(self, user_id): + with self.sql_session() as session: + agents = session.execute(select(Agent).where(Agent.user_id == user_id)).scalars().all() + return [{ + "id": agent.id, + "name": agent.name, + "description": agent.description, + "picture": agent.picture + } for agent in agents] + + def get_chat_messages(self, chat_id, earliest_index=0): + with self.sql_session() as session: + messages = session.execute( + select(Message).where(Message.chat_id == chat_id).order_by(Message.id.desc()) + ).scalars().fetchmany(MESSAGE_PAGE_SIZE) + + return [{ + "id": message.id, + "time": message.time, + "sender": message.sender, + "kind": message.kind, + "content": message.content + } for message in messages] + + def verify_user_credentials(self, username, password): + with self.sql_session() as session: + user = session.execute( + select(User).where(User.username == username) + ).scalars().first() + + if user and user.password == password: + return user.id + return None + + def create_new_user(self, username, email, password): + with self.sql_session() as session: + result = session.execute( + select(User.id).where(User.email == email or User.username == username) + ).first() + + if result is None: + user = User() + user.email = email + user.username = username + user.password = password + + session.add(user) + session.commit() + + user_id = user.id + print(f"Created user #{user_id}: {username} ({email})") + else: + print(f"Signup error: '{email}' or '{username}' are already used") + return user_id + + def add_agent_to_chat(self, chat_id, agent_id): + with self.sql_session() as session: + membership = Membership() + membership.chat_id = chat_id + membership.agent_id = agent_id + + session.add(membership) + session.commit() + print(f"Added agent #{agent_id} to chat #{chat_id}") + + def generate_default_user_data(self, user_id): + agent_id = self.create_agent( + user_id=user_id, + name="Intro", + description="Introduces you to the application.", + picture=None, + kind="intro", + data={} + ) + + # Create a chat with that agent in it + chat_id = self.create_chat( + user_id=user_id, + name="Initial Chat", + description=None, + picture=None + ) + + # Add the agent to the chat + self.add_agent_to_chat(chat_id, agent_id) def start_server(app): server = Quart(__name__, static_folder='../frontend/build/web') @@ -38,31 +159,14 @@ def start_server(app): email = data['email'] password = data['password'] - with app.sql_session() as session: - result = session.execute( - select(User.id).where(User.email == email or User.username == username) - ).first() - - if result is None: - user = User() - user.email = email - user.username = username - user.password = password - - session.add(user) - session.commit() - - user_id = user.id - print(f"Created user #{user_id}: {username} ({email})") - else: - user_id = None - print(f"Signup error: '{email}' or '{username}' are already used") - + user_id = app.create_new_user(username, email, password) if user_id is None: return {"error": "existing credentials"} - else: - # token = auth_manager.dump_token(user_id) - return {"userId": user_id} + + app.generate_default_user_data(user_id) + + # token = auth_manager.dump_token(user_id) + return {"userId": user_id} @server.websocket('/ws') async def ws(): @@ -70,14 +174,15 @@ def start_server(app): # Authenticate before dedicating any resources auth_req = json.loads(await websocket.receive()) - if "username" not in auth_req: + if "username" not in auth_req or "password" not in auth_req: print("Invalid auth request:", auth_req) await websocket.send(json.dumps({'error': 'invalid auth'})) return - - # TODO: Actually authenticate credentials against stored users - # TODO: Get the user id from the credentials, use them in create-agent - print(f"User {auth_req['username']} connected") + + user_id = app.verify_user_credentials(auth_req['username'], auth_req['password']) + if user_id is None: + await websocket.send(json.dumps({'error': 'invalid credentials'})) + return send_queue = asyncio.Queue() @@ -94,30 +199,49 @@ def start_server(app): while True: req = json.loads(await websocket.receive()) - action = req['action'] body = req.get('body', {}) nonce = req.get('nonce', None) if action == "ping": print("ping") - await send({'content': "pong"}) + await send({'message': "pong"}) + elif action == "create-agent": - with app.sql_session() as session: - agent = Agent() + agent_id = app.create_agent( + user_id=user_id, + name=body['name'], + description=body['description'], + picture=body.get('picture'), + kind=body['kind'], + data=body['data'] + ) + await send({'message': "agent created", 'agentId': agent_id, 'nonce': nonce}) - agent.user_id = None - agent.name = body['name'] - agent.description = body['description'] - agent.picture = body.get('picture') - agent.kind = body['kind'] - agent.data = body['data'] + elif action == "get-agents": + agents_data = app.get_agents(user_id) + await send({'message': "agents retrieved", 'agents': agents_data, 'nonce': nonce}) + + elif action == "create-chat": + chat_id = app.create_chat( + user_id=user_id, + name=body['name'], + description=body.get('description'), + picture=body.get('picture') + ) + await send({'message': "chat created", 'chatId': chat_id, 'nonce': nonce}) + + elif action == "get-chats": + chats_data = app.get_chats(user_id) + await send({'message': "chats retrieved", 'chats': chats_data, 'nonce': nonce}) + + elif action == "get-chat-messages": + messages_data = app.get_chat_messages( + body['chatId'], + earliest_index=body.get('earliestIndex', 0) + ) + await send({'message': "chat messages retrieved", 'messages': messages_data, 'nonce': nonce}) - session.add(agent) - session.commit() - - print(f"Created user #{agent.id}: {agent.name}") - await send({'content': "agent created", 'agentId': agent.id, 'nonce': nonce}) else: print("Unknown message: " + action) diff --git a/diff_output.txt b/diff_output.txt deleted file mode 100644 index 70027ce..0000000 --- a/diff_output.txt +++ /dev/null @@ -1,399 +0,0 @@ -diff --git a/.gitignore b/.gitignore -index 1d17dae..32bceab 100644 ---- a/.gitignore -+++ b/.gitignore -@@ -1 +1,2 @@ - .venv -+horde.db -\ No newline at end of file -diff --git a/Makefile b/Makefile -index 02a0261..1f8f801 100644 ---- a/Makefile -+++ b/Makefile -@@ -14,3 +14,5 @@ web: frontend/build/web/index.html - - frontend/build/web/index.html: - cd frontend && flutter build web -+ -+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 22cfb32..939dc09 100644 ---- a/backend/app.py -+++ b/backend/app.py -@@ -1,14 +1,134 @@ --from quart import Quart, send_from_directory -+import sys -+import json -+import asyncio - --app = Quart(__name__, static_folder='../frontend/build/web') -+from database import create_sql_engine, User, Agent -+from sqlalchemy.orm import Session -+from sqlalchemy.sql.expression import select -+from quart import Quart, Response, request, send_from_directory, jsonify, websocket - --@app.route('/') --async def hello(): -- return await send_from_directory(app.static_folder, 'index.html') -+class App: -+ def __init__(self, data_directory): -+ self.data_directory = data_directory -+ self.sql_engine = create_sql_engine(self.data_directory) - --@app.route('/') --async def serve_static(path): -- return await send_from_directory(app.static_folder, path) -+ def sql_session(self): -+ return Session(self.sql_engine) -+ -+app = App("./") -+ -+def start_server(app): -+ server = Quart(__name__, static_folder='../frontend/build/web') -+ -+ @server.route('/') -+ async def index(): -+ return await send_from_directory(server.static_folder, 'index.html') -+ -+ @server.route('/') -+ async def serve_static(path): -+ return await send_from_directory(server.static_folder, path) -+ -+ @server.route('/user/signup', methods=['POST']) -+ async def signup(): -+ data = await request.get_json() -+ -+ user_id = None -+ -+ username = data['username'] -+ email = data['email'] -+ password = data['password'] -+ -+ with app.sql_session() as session: -+ result = session.execute( -+ select(User.id).where(User.email == email or User.username == username) -+ ).first() -+ -+ if result is None: -+ user = User() -+ user.email = email -+ user.username = username -+ user.password = password -+ -+ session.add(user) -+ session.commit() -+ -+ user_id = user.id -+ print(f"Created user #{user_id}: {username} ({email})") -+ else: -+ user_id = None -+ print(f"Signup error: '{email}' or '{username}' are already used") -+ -+ if user_id is None: -+ return {"error": "existing credentials"} -+ else: -+ # token = auth_manager.dump_token(user_id) -+ return {"userId": user_id} -+ -+ @server.websocket('/ws') -+ async def ws(): -+ print("incoming ws") -+ -+ # Authenticate before dedicating any resources -+ auth_req = json.loads(await websocket.receive()) -+ if "username" not in auth_req: -+ print("Invalid auth request:", auth_req) -+ await websocket.send(json.dumps({'error': 'invalid auth'})) -+ return -+ -+ # TODO: Actually authenticate credentials against stored users -+ # TODO: Get the user id from the credentials, use them in create-agent -+ print(f"User {auth_req['username']} connected") -+ -+ send_queue = asyncio.Queue() -+ -+ async def sender_task(): -+ nonlocal send_queue -+ while True: -+ msg = await send_queue.get() -+ await websocket.send(json.dumps(msg)) -+ -+ async def send(msg): -+ await send_queue.put(msg) -+ -+ asyncio.create_task(sender_task()) -+ -+ while True: -+ req = json.loads(await websocket.receive()) -+ -+ action = req['action'] -+ body = req.get('body', {}) -+ nonce = req.get('nonce', None) -+ -+ if action == "ping": -+ print("ping") -+ await send({'content': "pong"}) -+ elif action == "create-agent": -+ with app.sql_session() as session: -+ agent = Agent() -+ -+ agent.user_id = None -+ agent.name = body['name'] -+ agent.description = body['description'] -+ agent.picture = body.get('picture') -+ agent.kind = body['kind'] -+ agent.data = body['data'] -+ -+ session.add(agent) -+ session.commit() -+ -+ print(f"Created user #{agent.id}: {agent.name}") -+ await send({'content': "agent created", 'agentId': agent.id, 'nonce': nonce}) -+ else: -+ print("Unknown message: " + action) -+ -+ server.run(debug=True) - - if __name__ == "__main__": -- app.run(debug=True) -+ if len(sys.argv) > 1: -+ data_directory = sys.argv[1] -+ else: -+ print("No directory specified. Using current directory as default.") -+ data_directory = "." -+ -+ app = App(data_directory) -+ start_server(app) -\ No newline at end of file -diff --git a/backend/requirements.txt b/backend/requirements.txt -index f8d6ab2..00a2728 100644 ---- a/backend/requirements.txt -+++ b/backend/requirements.txt -@@ -1 +1,2 @@ - Quart==0.20.0 -+sqlalchemy -\ No newline at end of file -diff --git a/frontend/lib/main.dart b/frontend/lib/main.dart -index 7b7f5b6..325549e 100644 ---- a/frontend/lib/main.dart -+++ b/frontend/lib/main.dart -@@ -1,122 +1,43 @@ -+import 'dart:html' as html; -+import 'dart:io' as io; - import 'package:flutter/material.dart'; -+import 'chat_screen.dart'; -+import 'backend.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 -+ backendUrl = io.Platform.environment['BACKEND_URL']; -+ } else { -+ // If no value is determined, set it to null -+ backendUrl = null; -+ } -+ } -+} - - void main() { -+ Config.loadConfig(); // Load the configuration before running the app -+ -+ Backend.setup('${Config.backendUrl}/ws'); - runApp(const MyApp()); - } - - class MyApp extends StatelessWidget { - const MyApp({super.key}); - -- // This widget is the root of your application. - @override - Widget build(BuildContext context) { - return MaterialApp( -- title: 'Flutter Demo', -- theme: ThemeData( -- // This is the theme of your application. -- // -- // TRY THIS: Try running your application with "flutter run". You'll see -- // the application has a purple toolbar. Then, without quitting the app, -- // try changing the seedColor in the colorScheme below to Colors.green -- // and then invoke "hot reload" (save your changes or press the "hot -- // reload" button in a Flutter-supported IDE, or press "r" if you used -- // the command line to start the app). -- // -- // Notice that the counter didn't reset back to zero; the application -- // state is not lost during the reload. To reset the state, use hot -- // restart instead. -- // -- // This works for code too, not just values: Most code changes can be -- // tested with just a hot reload. -- colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), -- ), -- home: const MyHomePage(title: 'Flutter Demo Home Page'), -- ); -- } --} -- --class MyHomePage extends StatefulWidget { -- const MyHomePage({super.key, required this.title}); -- -- // This widget is the home page of your application. It is stateful, meaning -- // that it has a State object (defined below) that contains fields that affect -- // how it looks. -- -- // This class is the configuration for the state. It holds the values (in this -- // case the title) provided by the parent (in this case the App widget) and -- // used by the build method of the State. Fields in a Widget subclass are -- // always marked "final". -- -- final String title; -- -- @override -- State createState() => _MyHomePageState(); --} -- --class _MyHomePageState extends State { -- int _counter = 0; -- -- void _incrementCounter() { -- setState(() { -- // This call to setState tells the Flutter framework that something has -- // changed in this State, which causes it to rerun the build method below -- // so that the display can reflect the updated values. If we changed -- // _counter without calling setState(), then the build method would not be -- // called again, and so nothing would appear to happen. -- _counter++; -- }); -- } -- -- @override -- Widget build(BuildContext context) { -- // This method is rerun every time setState is called, for instance as done -- // by the _incrementCounter method above. -- // -- // The Flutter framework has been optimized to make rerunning build methods -- // fast, so that you can just rebuild anything that needs updating rather -- // than having to individually change instances of widgets. -- return Scaffold( -- appBar: AppBar( -- // TRY THIS: Try changing the color here to a specific color (to -- // Colors.amber, perhaps?) and trigger a hot reload to see the AppBar -- // change color while the other colors stay the same. -- backgroundColor: Theme.of(context).colorScheme.inversePrimary, -- // Here we take the value from the MyHomePage object that was created by -- // the App.build method, and use it to set our appbar title. -- title: Text(widget.title), -- ), -- body: Center( -- // Center is a layout widget. It takes a single child and positions it -- // in the middle of the parent. -- child: Column( -- // Column is also a layout widget. It takes a list of children and -- // arranges them vertically. By default, it sizes itself to fit its -- // children horizontally, and tries to be as tall as its parent. -- // -- // Column has various properties to control how it sizes itself and -- // how it positions its children. Here we use mainAxisAlignment to -- // center the children vertically; the main axis here is the vertical -- // axis because Columns are vertical (the cross axis would be -- // horizontal). -- // -- // TRY THIS: Invoke "debug painting" (choose the "Toggle Debug Paint" -- // action in the IDE, or press "p" in the console), to see the -- // wireframe for each widget. -- mainAxisAlignment: MainAxisAlignment.center, -- children: [ -- const Text('You have pushed the button this many times:'), -- Text( -- '$_counter', -- style: Theme.of(context).textTheme.headlineMedium, -- ), -- ], -- ), -- ), -- floatingActionButton: FloatingActionButton( -- onPressed: _incrementCounter, -- tooltip: 'Increment', -- child: const Icon(Icons.add), -- ), // This trailing comma makes auto-formatting nicer for build methods. -+ title: 'Flutter Chat', -+ theme: ThemeData.dark(), -+ home: const ChatScreen(), - ); - } --} -+} -\ No newline at end of file -diff --git a/readme.md b/readme.md -index 6d03df5..daaf485 100644 ---- a/readme.md -+++ b/readme.md -@@ -2,9 +2,9 @@ - - To run the Flask server, follow these steps: - --1. Build and activate the virtual environment and install the required dependencies: -+1. Build and activate the virtual environment, install the required dependencies, and build the gui by running make with the default target: - ```bash -- make install -+ make - ``` - 2. Run the server using the provided script: - ```bash -@@ -17,3 +17,6 @@ Now, visit `http://127.0.0.1:5000` in your web browser to see the application in - - Frontend uses flutter. For Ubuntu you can use [this guideline](https://ubuntu.com/blog/getting-started-with-flutter-on-ubuntu) to install it. - -+## Configuration -+ -+You can set the backend URL for Android and native builds using the environment variable `BACKEND_URL`. Make sure to set this variable before running the application. Defaults to `null` if nothing is set. -diff --git a/run.sh b/run.sh -index 638f34a..f6e6484 100644 ---- a/run.sh -+++ b/run.sh -@@ -1,4 +1,35 @@ - #!/bin/bash --cd backend --source ../.venv/bin/activate --python app.py -+ -+case "$1" in -+ "") -+ cd backend -+ source ../.venv/bin/activate -+ python app.py -+ ;; -+ "test") -+ # Setup test data directory -+ mkdir -p test-data -+ rm -f test-data/horde.db -+ -+ # Start backend in the background -+ cd backend -+ source ../.venv/bin/activate -+ python app.py ../test-data & -+ sleep 2 -+ -+ # Run tests -+ cd ../testing -+ #bash signup.sh -+ #node login.js -+ node create-agent.js -+ -+ # Finalize the backend server -+ kill $! -+ ;; -+ "watch") -+ echo "TODO: Implement watch functionality" -+ ;; -+ *) -+ echo "Error: unknown command '$1'" -+ ;; -+esac diff --git a/run.sh b/run.sh index f6e6484..77d5b0c 100644 --- a/run.sh +++ b/run.sh @@ -14,22 +14,43 @@ case "$1" in # Start backend in the background cd backend source ../.venv/bin/activate - python app.py ../test-data & + python app.py ../test-data & + BACKEND_PID=$! + echo "Started backend: $BACKEND_PID" sleep 2 # Run tests cd ../testing - #bash signup.sh + bash signup.sh #node login.js - node create-agent.js + node suite.js # Finalize the backend server + kill $BACKEND_PID + ;; + "debug") + # Debug doesn't work very well... I don't use it. + cd backend + source ../.venv/bin/activate + python app.py & + + cd ../frontend + # This can be skipped if chrome IS installed + export CHROME_EXECUTABLE=chromium + export BACKEND_URL="ws://localhost:5000" + flutter run -d chrome + + # Close backend when debugger closes kill $! ;; "watch") - echo "TODO: Implement watch functionality" + # requires 'inotify-tools' package + cd frontend + while inotifywait -r -e modify,create,delete lib; do + flutter build web + done ;; *) echo "Error: unknown command '$1'" ;; -esac +esac \ No newline at end of file diff --git a/testing/create-agent.js b/testing/create-agent.js deleted file mode 100644 index b521427..0000000 --- a/testing/create-agent.js +++ /dev/null @@ -1,29 +0,0 @@ -const WebSocket = require('ws'); - -const ws = new WebSocket('ws://localhost:5000/ws'); - -ws.on('open', function open() { - const packet = JSON.stringify({ username: "test-user", password: "123456" }); - ws.send(packet); - - runTest(); -}); - -ws.on('message', function incoming(data) { - const jsonResponse = JSON.parse(data); - console.log(jsonResponse); -}); - -function runTest() { - const message = { - action: 'create-agent', - body: { - name: 'First Agent', - description: 'First agent does nothing', - kind: 'simple', - data: {} - }, - }; - - ws.send(JSON.stringify(message)); // Send the create-agent message -} \ No newline at end of file diff --git a/testing/suite.js b/testing/suite.js new file mode 100644 index 0000000..8a236ac --- /dev/null +++ b/testing/suite.js @@ -0,0 +1,95 @@ +const WebSocket = require('ws'); + +const ws = new WebSocket('ws://localhost:5000/ws'); + +var nonceCounter = 1; +var nonceCallbacks = {}; + +// Setup + +ws.on('open', function open() { + const packet = JSON.stringify({ username: "test-user", password: "123456" }); + ws.send(packet); + + runTest(); +}); + +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); + } + console.log(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 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: {} + }}) + ); + + console.log("Get Agents", + await send('get-agents') + ); + + await testChats(); + + process.exit(); +} + +async function testChats () { + let response = await send('get-chats') + let chat = response.chats[0]; + + let msgRes = await send('chat-send-message', { + chatId: chat.id, + content: "Hello World!", + }); + console.log('message sent', msgRes); + + let nextMessage = await waitMessage(); + console.log('next incoming message', nextMessage); +} \ No newline at end of file