diff --git a/dashboard/index.html b/dashboard/index.html
new file mode 100644
index 00000000..21db716b
--- /dev/null
+++ b/dashboard/index.html
@@ -0,0 +1,324 @@
+
+
+
+
+
+ Serena Log Viewer
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/dashboard/serena-logs.png b/dashboard/serena-logs.png
new file mode 100644
index 00000000..8f110b05
Binary files /dev/null and b/dashboard/serena-logs.png differ
diff --git a/src/serena/agent.py b/src/serena/agent.py
index 6ec280f2..957e3bc7 100644
--- a/src/serena/agent.py
+++ b/src/serena/agent.py
@@ -9,6 +9,7 @@ import platform
import shutil
import sys
import traceback
+import webbrowser
from abc import ABC, abstractmethod
from collections import defaultdict
from collections.abc import Callable, Generator, Iterable, Sequence
@@ -32,6 +33,7 @@ from multilspy.multilspy_types import SymbolKind
from serena import serena_root_path, serena_version
from serena.config import SerenaAgentContext, SerenaAgentMode
from serena.constants import SERENA_MANAGED_DIR_NAME
+from serena.dashboard import MemoryLogHandler, SerenaDashboardAPI
from serena.prompt_factory import PromptFactory, SerenaPromptFactory
from serena.symbol import SymbolLocation, SymbolManager
from serena.text_utils import search_files
@@ -480,6 +482,16 @@ class SerenaAgent:
self._gui_log_handler = GuiLogViewerHandler(GuiLogViewer(title="Serena Logs"), level=log_level, format_string=LOG_FORMAT)
Logger.root.addHandler(self._gui_log_handler)
+ # instantiate all tool classes
+ self._all_tools: dict[type[Tool], Tool] = {tool_class: tool_class(self) for tool_class in ToolRegistry.get_all_tool_classes()}
+
+ # start the dashboard (web frontend) and register its log handler
+ memory_log_handler = MemoryLogHandler()
+ Logger.root.addHandler(memory_log_handler)
+ tool_names = [tool.get_name() for tool in self._all_tools.values()]
+ self._dashboard_thread, port = SerenaDashboardAPI(memory_log_handler, tool_names).run_in_thread()
+ webbrowser.open(f"http://localhost:{port}/dashboard/index.html")
+
log.info(f"Starting Serena server (version={serena_version()}, process id={os.getpid()}, parent process id={os.getppid()})")
log.info("Available projects: {}".format(", ".join(self.serena_config.project_names)))
@@ -495,9 +507,6 @@ class SerenaAgent:
self.memories_manager: MemoriesManager | None = None
self.lines_read: LinesRead | None = None
- # instantiate all tool classes
- self._all_tools: dict[type[Tool], Tool] = {tool_class: tool_class(self) for tool_class in ToolRegistry.get_all_tool_classes()}
-
# Apply context and mode tool configurations
if context is None:
context = SerenaAgentContext.load_default()
diff --git a/src/serena/dashboard.py b/src/serena/dashboard.py
new file mode 100644
index 00000000..1144facf
--- /dev/null
+++ b/src/serena/dashboard.py
@@ -0,0 +1,112 @@
+import os
+import queue
+import socket
+import threading
+
+import uvicorn
+from fastapi import FastAPI
+from fastapi.staticfiles import StaticFiles
+from pydantic import BaseModel
+from sensai.util import logging
+
+from serena import serena_root_path
+
+log = logging.getLogger(__name__)
+
+
+class MemoryLogHandler(logging.Handler):
+ def __init__(self, level=logging.NOTSET):
+ super().__init__(level=level)
+ self.setFormatter(logging.Formatter(logging.LOG_DEFAULT_FORMAT))
+ self._log_buffer = LogBuffer()
+ self._log_queue = queue.Queue()
+ self._stop_event = threading.Event()
+
+ # start background thread to process logs
+ self.worker_thread = threading.Thread(target=self._process_queue, daemon=True)
+ self.worker_thread.start()
+
+ def emit(self, record):
+ msg = self.format(record)
+ self._log_queue.put_nowait(msg)
+
+ def _process_queue(self):
+ while not self._stop_event.is_set():
+ try:
+ msg = self._log_queue.get(timeout=1)
+ self._log_buffer.append(msg)
+ self._log_queue.task_done()
+ except queue.Empty:
+ continue
+
+ def get_log_messages(self) -> list[str]:
+ return self._log_buffer.logs
+
+
+class LogBuffer:
+ def __init__(self):
+ self.logs = []
+
+ def append(self, msg: str):
+ self.logs.append(msg)
+
+
+class RequestLog(BaseModel):
+ start_idx: int = 0
+
+
+class ResponseLog(BaseModel):
+ messages: list[str]
+ max_idx: int
+
+
+class ResponseToolNames(BaseModel):
+ tool_names: list[str]
+
+
+class SerenaDashboardAPI:
+ log = logging.getLogger(__qualname__)
+
+ def __init__(self, memory_log_handler: MemoryLogHandler, tool_names: list[str]):
+ self._memory_log_handler = memory_log_handler
+ self._tool_names = tool_names
+ self._app = FastAPI(title="Serena Dashboard")
+ self._setup_routes()
+
+ def _setup_routes(self):
+ static_dir = os.path.join(serena_root_path(), "dashboard")
+ self._app.mount("/dashboard", StaticFiles(directory=static_dir), name="dashboard")
+
+ self._app.add_api_route("/get_log_messages", self._get_log_messages, methods=["POST"], response_model=ResponseLog)
+ self._app.add_api_route("/get_tool_names", self._get_tool_names, methods=["GET"], response_model=ResponseToolNames)
+
+ async def _get_log_messages(self, request: RequestLog) -> ResponseLog:
+ all_messages = self._memory_log_handler.get_log_messages()
+ requested_messages = all_messages[request.start_idx :] if request.start_idx <= len(all_messages) else []
+ return ResponseLog(messages=requested_messages, max_idx=len(all_messages) - 1)
+
+ async def _get_tool_names(self) -> ResponseToolNames:
+ return ResponseToolNames(tool_names=self._tool_names)
+
+ @staticmethod
+ def _find_first_free_port(start_port: int) -> int:
+ port = start_port
+ while port <= 65535:
+ try:
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
+ sock.bind(("localhost", port))
+ return port
+ except OSError:
+ port += 1
+
+ raise RuntimeError(f"No free ports found starting from {start_port}")
+
+ def run(self, host="127.0.0.1", port=0x5EDA):
+ uvicorn.run(self._app, host=host, port=port, workers=1, log_config=None, log_level="critical")
+ return port
+
+ def run_in_thread(self) -> tuple[threading.Thread, int]:
+ port = self._find_first_free_port(0x5EDA)
+ thread = threading.Thread(target=lambda: self.run(port=port), daemon=True)
+ thread.start()
+ return thread, port