backend: implement basic functionality in app class. testing: organize and improve javascript test swuite

This commit is contained in:
Shivering Ocelot committed 2025-05-09 09:18:42 -05:00
1 parent 44f0b3589c
commit cbd8452ee5
5 files changed
+288 -476

No files matched your search

+169 -45
View File
@@ -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,29 +159,12 @@ 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:
app.generate_default_user_data(user_id)
# token = auth_manager.dump_token(user_id)
return {"userId": user_id}
@@ -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})
session.add(agent)
session.commit()
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})
print(f"Created user #{agent.id}: {agent.name}")
await send({'content': "agent created", 'agentId': agent.id, 'nonce': nonce})
else:
print("Unknown message: " + action)
-399
View File
@@ -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('/<path:path>')
-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('/<path:path>')
+ 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<MyHomePage> createState() => _MyHomePageState();
-}
-
-class _MyHomePageState extends State<MyHomePage> {
- 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: <Widget>[
- 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
+24 -3
View File
@@ -15,19 +15,40 @@ case "$1" in
cd backend
source ../.venv/bin/activate
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'"
-29
View File
@@ -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
}
+95
View File
@@ -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);
}