From 44f0b3589cceca420e20dcf4317a507576d3d536 Mon Sep 17 00:00:00 2001 From: Shivering Ocelot Date: Tue, 6 May 2025 17:50:57 -0500 Subject: [PATCH] backend: separate App and server, add websocket endpoint, accept data directory command argument. initialize sqlalchemy database and models in new module. frontend: create backend class for managing ws connection, move ui to chat_screen, create scaffold chat and agent classes. --- .gitignore | 1 + Makefile | 2 + backend/__pycache__/database.cpython-312.pyc | Bin 0 -> 3938 bytes backend/app.py | 138 ++++++- backend/database.py | 90 +++++ backend/replicate.py | 0 backend/requirements.txt | 1 + diff_output.txt | 399 +++++++++++++++++++ frontend/lib/agent.dart | 9 + frontend/lib/backend.dart | 49 +++ frontend/lib/chat.dart | 31 ++ frontend/lib/chat_screen.dart | 80 ++++ frontend/lib/main.dart | 137 ++----- readme.md | 7 +- run.sh | 37 +- scratchpad.md | 28 ++ testing/create-agent.js | 29 ++ testing/login.js | 13 + testing/signup.sh | 7 + 19 files changed, 936 insertions(+), 122 deletions(-) create mode 100644 backend/__pycache__/database.cpython-312.pyc create mode 100644 backend/database.py create mode 100644 backend/replicate.py create mode 100644 diff_output.txt create mode 100644 frontend/lib/agent.dart create mode 100644 frontend/lib/backend.dart create mode 100644 frontend/lib/chat.dart create mode 100644 frontend/lib/chat_screen.dart create mode 100644 scratchpad.md create mode 100644 testing/create-agent.js create mode 100644 testing/login.js create mode 100644 testing/signup.sh 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/__pycache__/database.cpython-312.pyc b/backend/__pycache__/database.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0e8ab848b0b736028df92253e323b62c1688aeef GIT binary patch literal 3938 zcmcgvO>7&-6`m!Bf6E_<5-nRl{uSVumMRu->pFk6WjU5DLkb!@MX^Cathi%o?cF6a zyK+jy1sK3Zr5n|Y6%+;3OA*64=+Hwi0ebJn0@;=sB#4dV(pylm7oYmxELWmb2?ZR$ z3+y*<-u&*o@4Ywsw{%)1@O=Kx(BhwygnWaQ;E8mEt$%>@Z)TTh2?$Y*2Z3eVGUD|%B?FDVNOPj0q<@^7I8^|jn z{%*xH%Z_g4BO+bmO!rukISZ!4M0BcbE!mEUU3WaTz__3@H{YET>eVu5=7MvBRr6ud zbA^>Go$H=?k6qSX7&WU`D$EdRTb9L=oCH>iy3ArFujGHZk4mSql>*@rVMJ35qJ^qT zJ|rkkHwR0^i$zDbS+OY8V$m)eOBTwhV)4Tz-SS%=lP#R-#aLmnY_r0W%Q#5fU_PEJ z%@KlYH*bK-wwq#CGIBdY}ZO74ks2N%G;j`>Ok)WqRz5LZNU!;eW}5 zF`uU#=af4aahrKMew0u<#?vhe4ljecqG574@iMOpoT_727x#AJ1kXVsvYk;o&3p*- z33%NJfExKSdu)B^)48W(Z~o==r^nxEWUtm{zNGQr&VD@mPdc(mN7l3^J=01adP)z; zquXWR;On>Uz+^iHejj{F3mYMgh6sxoVI%w*ebPBIEy`jnJ_!9;!ic>wW}On#(774@}N`5wZXxE$PTQ9KW@E@j6B7KdGr^FHh(S%)^^BLN^m-HQOfA+6j{ZMKyguFXCl7~Pgv%0J>)#VkTmw_s%JX&-7EF% ztL@#^O1^b=mEALAAJ%aXtk)#4i-4!uYJWZ_B9jY9o`^1ss4U!D{5Xt})_)8|F)YS$CWwqNgWcAbEKjKSOxK(@!Rf1l09P5eP1i-L2rAi_G?-iBrVQC4 zRxwN75(kcAq~oTuAn0Aw0jBotVJbh3yLk;^H!JxW)a4Q42tP!?D96ttoJ06A0!A!B zF{tci>o_(e;OV)a10Y+0sIA<{0c`zZ_{`&>jhRONVq^GHZF>3V^{JH)8uZe$RA2qV z+SKaXo2ioxdh&~7V;hsrW9Mr#^=RE)1)iUOetdl6z2@-?wHx(;weae2gI;*1_SXAX z#v1fcD|>h?zTW>ydNX^vK~J~R{q<{WH&1Y!HOLuA2ZEZKBr5F9r45@wl zN`IlJP0y5JDS3TO9B_L0c$ z(t4&1`91T!xGHHX3A-nVzKvT!zn?r>LEwm0F zU0YmtKe0Ctk2mSK&otcP{!IHFw7d$YVPu0>HR3@84oWQroCz|q{2Ia@7EGZI0bloL z0LX$97@STX6r4u#kI!y=&^SBU7`a@#wtU-9r^XxABiw-3WdLZ;qiD}#2ifz0WYe7hF!7vQxzV79 zyS11=ATc%R5#PqR?p*$mhjp z1zqsB#JvshU06|+ugLJ%Wcu%9`fD<|NhZG{<87)aiRFuJ0>!_hVP&ALCX}J&AGHY- r?SY&!wtS^cplFZxDnCFy6zwxnWm2i<+62pXZ$=qhHvU7Pkf-%u`f+-j literal 0 HcmV?d00001 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/database.py b/backend/database.py new file mode 100644 index 0000000..8ad1968 --- /dev/null +++ b/backend/database.py @@ -0,0 +1,90 @@ +from typing import Optional +from sqlalchemy import create_engine, Column, Integer, JSON, ForeignKey +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column +from sqlalchemy.sql.expression import select + +class Base(DeclarativeBase): + pass + +def create_sql_engine (data_directory): + sql_engine = create_engine(f"sqlite:///{data_directory}/horde.db") + Base.metadata.create_all(sql_engine) + return sql_engine + +class User(Base): + __modelname__ = "user" + __tablename__ = 'users' + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + + email: Mapped[str] + username: Mapped[str] + # Stores the plain password. This must be changed eventually to store a salted hash instead. + password: Mapped[str] + +class Agent(Base): + __modelname__ = "agent" + __tablename__ = 'agents' + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id")) + + handle: Mapped[Optional[str]] + visibility: Mapped[int] = mapped_column(default=0) + # Comma separated list of permission symbols + permissions: Mapped[str] = mapped_column(default="") + + name: Mapped[str] + description: Mapped[Optional[str]] + picture: Mapped[Optional[str]] + ordering: Mapped[Optional[int]] + + # Agents can be any of a set of agent classes + kind: Mapped[str] + # Stores the agent data specific to its class + data = mapped_column(JSON, default={}) + +class Chat(Base): + __modelname__ = 'chat' + __tablename__ = 'chats' + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id")) + + name: Mapped[str] + description: Mapped[Optional[str]] + picture: Mapped[Optional[str]] + ordering: Mapped[Optional[int]] + + # For indexing purposes, some information is taken from the last chat message. + # Preview is the (possibly cropped) content, + # time is the timestamp of the last event. + last_message_preview: Mapped[Optional[str]] + last_message_time: Mapped[Optional[int]] + +# Stores membership of agents in chats. If a record exists linking an agent and a chat, +# it means the agent participates in that chat. +class Membership(Base): + __modelname__ = 'membership' + __tablename__ = 'memberships' + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + agent_id: Mapped[int] = mapped_column(ForeignKey("agents.id")) + chat_id: Mapped[int] = mapped_column(ForeignKey("chats.id")) + +class Message(Base): + __modelname__ = 'message' + __tablename__ = 'messages' + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + chat_id: Mapped[int] = mapped_column(ForeignKey("chats.id")) + + # Timestamp of this message + time: Mapped[int] + # Identifier of the sender: user / system / agent-$ID + sender: Mapped[str] + + # Predefined set of message types, interpreted as 'text' when empty. + kind: Mapped[str] + content: Mapped[str] + diff --git a/backend/replicate.py b/backend/replicate.py new file mode 100644 index 0000000..e69de29 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/diff_output.txt b/diff_output.txt new file mode 100644 index 0000000..70027ce --- /dev/null +++ b/diff_output.txt @@ -0,0 +1,399 @@ +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/frontend/lib/agent.dart b/frontend/lib/agent.dart new file mode 100644 index 0000000..626f138 --- /dev/null +++ b/frontend/lib/agent.dart @@ -0,0 +1,9 @@ +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/backend.dart b/frontend/lib/backend.dart new file mode 100644 index 0000000..f002086 --- /dev/null +++ b/frontend/lib/backend.dart @@ -0,0 +1,49 @@ +import 'dart:html'; +import 'dart:convert'; + +class Backend { + static WebSocket? _socket; + static String? _url; + + static void setup(String url) { + _url = url; + connect(); + } + + static void auth(username) { + _socket!.send( + json.encode({'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'); + _reconnect(); + }); + _socket!.onClose.listen((event) { + print('Disconnected. Attempting to reconnect...'); + _reconnect(); + }); + } + + static void _reconnect() { + Future.delayed(Duration(seconds: 5), () { + connect(); + }); + } + + static void ping() { + if (_socket != null && _socket!.readyState == WebSocket.OPEN) { + final data = json.encode({'path': 'ping'}); + _socket!.send(data); + print('Ping sent: $data'); + } + } +} \ No newline at end of file diff --git a/frontend/lib/chat.dart b/frontend/lib/chat.dart new file mode 100644 index 0000000..a29d379 --- /dev/null +++ b/frontend/lib/chat.dart @@ -0,0 +1,31 @@ +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_screen.dart b/frontend/lib/chat_screen.dart new file mode 100644 index 0000000..85d4375 --- /dev/null +++ b/frontend/lib/chat_screen.dart @@ -0,0 +1,80 @@ +import 'package:flutter/material.dart'; +import 'chat.dart'; // Import the new Chat class + +class ChatScreen extends StatefulWidget { + const ChatScreen({super.key}); + + @override + State createState() => _ChatScreenState(); +} + +class _ChatScreenState extends State { + final Chat _chat = Chat('chat1'); // Create an instance of Chat + final TextEditingController _controller = TextEditingController(); + + void _sendMessage() { + if (_controller.text.isNotEmpty) { + setState(() { + _chat.addUserMessage(_controller.text); // Use the Chat instance to add the message + _controller.clear(); + }); + } + } + + @override + Widget build(BuildContext context) { + final width = MediaQuery.of(context).size.width; + final isBigScreen = width > 600; // Define threshold for small screens + + return Scaffold( + appBar: AppBar( + title: const Text('Chat'), + ), + body: Column( + children: [ + Expanded( + child: ListView.builder( + itemCount: _chat.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), + ), + ), + ], + ); + }, + ), + ), + Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _controller, + decoration: const InputDecoration(hintText: 'Send a message'), + onSubmitted: (_) => _sendMessage(), + ), + ), + IconButton( + icon: const Icon(Icons.send), + onPressed: _sendMessage, + ), + ], + ), + ), + ], + ), + ); + } +} \ 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'), + title: 'Flutter Chat', + theme: ThemeData.dark(), + home: const ChatScreen(), ); } -} - -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. - ); - } -} +} \ 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/scratchpad.md b/scratchpad.md new file mode 100644 index 0000000..e2036e6 --- /dev/null +++ b/scratchpad.md @@ -0,0 +1,28 @@ +# Scratchpad + +This is just loose ideas that need to be consolidated and implemented. + +--- + +There are essentially 2 main classes in the project: Agent, and Chat. +A chat is a fully featured telegram-like chat, with controls for adding messages, searching and retrieving messages, editing existing messages, and upload various data types (audio, image, etc). Tag support is very important too, beyond communication controls. In the future there will also be real time communication functionality, ie. calls, possibly also visibility of draft messages. +An agent is a controller for chats that it is part of. It can be implemented by multiple means, any class that supports the agent interface is an agent. They have the exact same controls over the chat that the user does, any aditional functionality depends on the agent itself. +Unlike chatgpt and the initial horde, chats are not meant to be created for any new conversation. Instead, agents have a singular permanent chat with the user. Chats are only created when a topic needs so much discussion that its better to separate it, or for other purposes based on necessity or organization and with limited frequency, similar to slack/discord groups. +In the same way as with chats, both the user and agents can create and update other agents. They have to be explicitly enabled to do so. Most agents are better not given this capability, but the introduction agent is encouraged to have it enabled. + +**ALPHA MVP Urgently Needed.** + +Such an MVP is a chat with an Agent that responds to messages one by one. It has a simplistic, brief and conversational prompt. System prompt has brief description of the chat system. +Message history is stored, and its infinite. Agent sees the last N messages and a recursive summary of the previous conversation. +Agent is powered by replicate, whose API key is configured via env variables. + +MVP-2 lets the agent (and the user) search in the message history, and the recursive summary will have an enforced size limit. + +MVP-3A has configurable agents, both user and agents can create new agents, also modify their own prompt. Probably allowed by default. +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 diff --git a/testing/create-agent.js b/testing/create-agent.js new file mode 100644 index 0000000..b521427 --- /dev/null +++ b/testing/create-agent.js @@ -0,0 +1,29 @@ +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/login.js b/testing/login.js new file mode 100644 index 0000000..46d7efd --- /dev/null +++ b/testing/login.js @@ -0,0 +1,13 @@ +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); +}); + +ws.on('message', function incoming(data) { + const jsonResponse = JSON.parse(data); + console.log(jsonResponse); +}); \ No newline at end of file diff --git a/testing/signup.sh b/testing/signup.sh new file mode 100644 index 0000000..85a0608 --- /dev/null +++ b/testing/signup.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +URL="http://localhost:5000/user/signup" +DATA='{"username":"test-user","email":"user@test.com","password":"123456"}' + +RESPONSE=$(curl -s -X POST $URL -H "Content-Type: application/json" -d "$DATA") +echo $RESPONSE \ No newline at end of file