Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP - dmg/exe installable Letta #2072

Draft
wants to merge 10 commits into
base: main
Choose a base branch
from
Binary file added installable_apps/assets/dark_icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added installable_apps/assets/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added installable_apps/assets/letta.icns
Binary file not shown.
16 changes: 16 additions & 0 deletions installable_apps/installable_image.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from pathlib import Path
import darkdetect

from installable_logger import get_logger

logger = get_logger(__name__)


class InstallableImage:

@classmethod
def get_icon_path(cls) -> Path:
logger.debug("Determining icon path from system settings...")
image_name = ("dark_" if darkdetect.isDark() else "") + "icon.png"
logger.debug(f"Icon path determined to be {image_name} based on system settings.")
return (Path(__file__).parent / "assets") / image_name
6 changes: 6 additions & 0 deletions installable_apps/installable_logger.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from logging import getLogger

def get_logger(name: str):
logger = getLogger("letta_installable_apps")
logger.setLevel("DEBUG")
return logger.getChild(name)
58 changes: 58 additions & 0 deletions installable_apps/logserver/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
from pathlib import Path
from fastapi import FastAPI, WebSocket, Request
from fastapi.responses import JSONResponse
from fastapi.templating import Jinja2Templates
import asyncio

from letta.settings import settings
from installable_image import InstallableImage
from installable_logger import get_logger

logger = get_logger(__name__)

target_file = settings.letta_dir / "logs" / "letta.log"

app = FastAPI()


async def log_reader(n=5):
log_lines = []
with open(target_file, "r") as file:
for line in file.readlines()[-n:]:
if line is None:
continue
if line.__contains__("ERROR"):
log_line = {"content": line, "color": "red"}
elif line.__contains__("WARNING"):
log_line = {"content": line, "color": "yellow"}
else:
log_line = {"content": line, "color": "green"}
log_lines.append(log_line)
return log_lines

@app.get("/log")
async def rest_endpoint_log():
"""used to debug log_reader on the fly"""
logs = await log_reader(30)
return JSONResponse(logs)

@app.websocket("/ws/log")
async def websocket_endpoint_log(websocket: WebSocket):
await websocket.accept()

try:
while True:
await asyncio.sleep(1)
logs = await log_reader(30)
await websocket.send_json(logs)
except Exception as e:
logger.error(f"Error in log websocket: {e}")

finally:
await websocket.close()

@app.get("/")
async def get(request: Request):
context = {"log_file": target_file, "icon": InstallableImage().get_icon_path()}
templates = Jinja2Templates(directory=str((Path(__file__).parent / "templates").absolute()))
return templates.TemplateResponse("index.html", {"request": request, "context": context})
50 changes: 50 additions & 0 deletions installable_apps/logserver/templates/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<!DOCTYPE html>

<html lang="en" class="dark">
<head>
<script src="https://cdn.tailwindcss.com"></script>
<title>Letta Logs</title>
<icon rel="icon" src="{{context.icon}}" />
</head>

<body class="dark:bg-gray-900">


<div class="flex items-center py-2 px-3">
<h1 class="text-3xl text-slate-300">Letta Logs</h1>
</div>
<br />

<div class="flex items-center py-2 px-3">
<i>Logs stored at
<a href="file://{{context.log_file}}">{{context.log_file}}</a>
</i>
</div>

<div class="flex items-center py-2 px-3">
<div
id="logs"
class="block p-2.5 w-full text-sm text-gray-900 bg-gray-50 rounded-lg border border-gray-300 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
>
reading logs...
</div>
</div>

<script>
var ws_log = new WebSocket("ws://" + window.location.host + "/ws/log");

ws_log.onmessage = function (event) {
var logs = document.getElementById("logs");
var log_data = JSON.parse(event.data);
logs.innerHTML = ""; // Clear existing logs
log_data.forEach(function (log) {
var span = document.createElement("span");
span.classList.add("block");
span.style.color = log.color;
span.innerHTML = log.content;
logs.appendChild(span);
});
};
</script>
</body>
</html>
6 changes: 6 additions & 0 deletions installable_apps/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
setuptools==68.2.2 # letta pinned - and anything newer than 70 builds a broken wheel
letta==0.5.5 # this is also the version of the installer
pgserver~=0.1.4
pystray
pillow
darkdetect
31 changes: 31 additions & 0 deletions installable_apps/server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
from typing import TYPE_CHECKING
from uvicorn import Server, Config
from time import sleep
import threading
from contextlib import contextmanager

if TYPE_CHECKING:
from fastapi import WSGIApplication

class ThreadedServer(Server):

@contextmanager
def run_in_thread(self):
thread = threading.Thread(target=self.run)
thread.start()
try:
while not self.started:
sleep(0.1)
yield
finally:
self.should_exit = True
thread.join()

@classmethod
def get_configured_server(cls,
app: "WSGIApplication",
port: int,
host: str,
log_level: str = "info") -> "ThreadedServer":
config = Config(app, host=host, port=port, log_level="info")
return cls(config=config)
24 changes: 24 additions & 0 deletions installable_apps/setup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"""
This is a setup.py script generated by py2applet

Usage:
python setup.py py2app
"""

from setuptools import setup
# there is an import recursion leak somewhere upstream
# this hack gets us past it
import sys
sys.setrecursionlimit(5000)

APP = ['startup.py']
DATA_FILES = ['assets']
OPTIONS = {'iconfile': 'assets/letta.icns',
'includes': ['pgserver']}

setup(
app=APP,
data_files=DATA_FILES,
options={'py2app': OPTIONS, 'plist': {'CFBundleName': 'Letta', 'CFBundleShortVersionString': '0.5.5', 'CFBundleVersion': '0.5.5'}},
setup_requires=['py2app'],
)
51 changes: 51 additions & 0 deletions installable_apps/startup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
#!/usr/bin/env python3
import pgserver
import webbrowser

from letta.settings import settings
from letta.server.rest_api.app import app as letta_app
from letta.server.constants import REST_DEFAULT_PORT

from server import ThreadedServer
from logserver.main import app as log_app
from tray import Tray
from installable_logger import get_logger

logger = get_logger(__name__)

def initialize_database():
"""Initialize the postgres binary database"""
# create the pgdata
logger.info("Initializing database...")
pgdata = settings.letta_dir / "pgdata"
pgdata.mkdir(parents=True, exist_ok=True)

try:
database = pgserver.get_server(pgdata)
# create pg vector extension
database.psql('CREATE EXTENSION IF NOT EXISTS vector')
logger.info("Database initialized at %s", pgdata)
except Exception as e:
logger.error("Database initialization failed: %s", e)
raise e
logger.debug("Configuring app with databsase uri...")
# feed database URI parts to the application
settings.pg_uri = database.get_uri()
logger.debug("Database URI: %s configured in settings", settings.pg_uri)

def run_servers():
"""launch letta and letta logs"""
app_server = ThreadedServer.get_configured_server(letta_app, host="localhost", port=REST_DEFAULT_PORT)
log_server = ThreadedServer.get_configured_server(log_app, host="localhost", port=13774)
with app_server.run_in_thread():
logger.info("App server started")
with log_server.run_in_thread():
logger.info("Log server started")
tray = Tray()
logger.info("Tray created")
webbrowser.open("https://app.letta.com")
tray.create()

## execute
initialize_database()
run_servers()
41 changes: 41 additions & 0 deletions installable_apps/tray.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
from pathlib import Path
import webbrowser
from pystray import Icon, Menu, MenuItem
from PIL import Image

from installable_image import InstallableImage

class Tray:
icon_image: "Path"

def __init__(self):
self.icon_image = InstallableImage.get_icon_path()

def create(self) -> None:
"""creates tray icon in a thread"""

def discord(icon, item):
webbrowser.open("https://discord.gg/letta")

def _on_quit(icon, *args):
icon.stop()

def _log_viewer(icon, item):
webbrowser.open("http://localhost:13774")

icon = Icon("Letta",
Image.open(self.icon_image),
menu=Menu(
MenuItem(
"View Logs",
_log_viewer
),
MenuItem(
"Discord",
discord
),
MenuItem(
"Quit Letta",
_on_quit
)))
icon.run()
Loading