Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 44 additions & 2 deletions apps/elara-nexus/backend/app/api/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from sqlalchemy.orm import Session

from app.api.schemas import (
AgentStatusResponse,
BoardCreateRequest,
BoardDetailResponse,
BoardPatchRequest,
Expand Down Expand Up @@ -34,6 +35,7 @@
from app.infra.llm.litellm_client import LiteLlmClient
from app.infra.telemetry.langfuse import LangfuseTracer
from app.repositories.sqlalchemy_repo import SqlAlchemyRepository
from app.services.agent_service import AgentService
from app.services.board_service import BoardService
from app.services.chat_service import ChatService
from app.services.memory_service import MemoryService
Expand Down Expand Up @@ -77,6 +79,18 @@ def me(session: Session = Depends(get_db_session)) -> MeResponse:
return MeResponse(id=user.id, email=user.email, name=user.name)


@router.get("/agent/status", response_model=AgentStatusResponse)
def agent_status(session: Session = Depends(get_db_session)) -> AgentStatusResponse:
service = AgentService(SqlAlchemyRepository(session))
status = service.get_status()
return AgentStatusResponse(
status=status["status"],
subagents=status["subagents"],
activeRuns=status["activeRuns"],
lastRunAt=parse_iso(status["lastRunAt"]) if status["lastRunAt"] is not None else None,
)


@router.get("/boards", response_model=list[BoardResponse])
def list_boards(session: Session = Depends(get_db_session)) -> list[BoardResponse]:
service = BoardService(SqlAlchemyRepository(session))
Expand Down Expand Up @@ -211,6 +225,24 @@ def create_chat_session(
)


@router.get("/chat/sessions", response_model=list[ChatSessionResponse])
def list_chat_sessions(
session: Session = Depends(get_db_session),
settings: Settings = Depends(get_settings),
) -> list[ChatSessionResponse]:
repo = SqlAlchemyRepository(session)
service = ChatService(repo, LiteLlmClient(settings), LangfuseTracer(settings))
sessions = service.list_sessions()
return [
ChatSessionResponse(
id=item["id"],
title=item["title"],
createdAt=parse_iso(item["createdAt"]),
)
for item in sessions
]


@router.post("/chat/sessions/{session_id}/messages", response_model=ChatMessageResponse)
def add_chat_message(
session_id: str,
Expand All @@ -220,7 +252,14 @@ def add_chat_message(
) -> ChatMessageResponse:
repo = SqlAlchemyRepository(session)
service = ChatService(repo, LiteLlmClient(settings), LangfuseTracer(settings))
item = service.add_message(session_id=session_id, role=payload.role, content=payload.content)
try:
item = service.add_message(
session_id=session_id,
role=payload.role,
content=payload.content,
)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
run = item.get("run")
return ChatMessageResponse(
id=item["id"],
Expand All @@ -240,7 +279,10 @@ def list_chat_messages(
) -> list[ChatMessageResponse]:
repo = SqlAlchemyRepository(session)
service = ChatService(repo, LiteLlmClient(settings), LangfuseTracer(settings))
messages = service.list_messages(session_id=session_id)
try:
messages = service.list_messages(session_id=session_id)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
return [
ChatMessageResponse(
id=item["id"],
Expand Down
7 changes: 7 additions & 0 deletions apps/elara-nexus/backend/app/api/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ class MeResponse(BaseModel):
name: str


class AgentStatusResponse(BaseModel):
status: str
subagents: list[str]
activeRuns: int
lastRunAt: datetime | None


class BoardCreateRequest(BaseModel):
name: str = Field(min_length=1, max_length=255)

Expand Down
7 changes: 7 additions & 0 deletions apps/elara-nexus/backend/app/domain/dtos.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,13 @@ class ChatMessageData(TypedDict):
run: NotRequired[ChatRunData | None]


class AgentStatusData(TypedDict):
status: str
subagents: list[str]
activeRuns: int
lastRunAt: str | None


class MemoryDocumentData(TypedDict):
id: str
title: str
Expand Down
17 changes: 10 additions & 7 deletions apps/elara-nexus/backend/app/main.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import logging
from collections.abc import Awaitable, Callable
from collections.abc import AsyncIterator, Awaitable, Callable
from contextlib import asynccontextmanager

from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
Expand All @@ -22,7 +23,14 @@
logger = logging.getLogger(__name__)
settings = get_settings()

app = FastAPI(title="Elara Nexus Backend", version="0.0.1")

@asynccontextmanager
async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
init_db()
yield


app = FastAPI(title="Elara Nexus Backend", version="0.0.1", lifespan=lifespan)

app.add_middleware(RequestTimeoutMiddleware, timeout_seconds=settings.app_request_timeout_seconds)
app.add_middleware(BodySizeLimitMiddleware, max_bytes=settings.app_max_request_body_bytes)
Expand Down Expand Up @@ -54,9 +62,4 @@ async def handle_unexpected(_request: Request, exc: Exception) -> JSONResponse:
return JSONResponse(status_code=500, content={"detail": "Internal server error"})


@app.on_event("startup")
def on_startup() -> None:
init_db()


app.include_router(router)
15 changes: 15 additions & 0 deletions apps/elara-nexus/backend/app/repositories/sqlalchemy_repo.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,13 @@ def create_chat_session(self, title: str) -> ChatSession:
self.session.refresh(session)
return session

def get_chat_session(self, session_id: str) -> ChatSession | None:
return self.session.get(ChatSession, session_id)

def list_chat_sessions(self) -> list[ChatSession]:
stmt = select(ChatSession).order_by(ChatSession.created_at.desc())
return list(self.session.scalars(stmt).all())

def add_chat_message(self, session_id: str, role: str, content: str) -> ChatMessage:
message = ChatMessage(session_id=session_id, role=role, content=content)
self.session.add(message)
Expand Down Expand Up @@ -212,6 +219,14 @@ def update_run_status(self, run_id: str, status: RunStatus) -> Run | None:
self.session.refresh(run)
return run

def list_runs_by_status(self, status: RunStatus) -> list[Run]:
stmt = select(Run).where(Run.status == status.value).order_by(Run.created_at.asc())
return list(self.session.scalars(stmt).all())

def latest_run(self) -> Run | None:
stmt = select(Run).order_by(Run.created_at.desc())
return self.session.scalar(stmt)

def create_memory_document(self, title: str, content: str, source_ref: str) -> MemoryDocument:
doc = MemoryDocument(title=title, content=content, source_ref=source_ref)
self.session.add(doc)
Expand Down
25 changes: 25 additions & 0 deletions apps/elara-nexus/backend/app/services/agent_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from app.domain.dtos import AgentStatusData
from app.domain.types import RunStatus
from app.repositories.sqlalchemy_repo import SqlAlchemyRepository


class AgentService:
def __init__(self, repo: SqlAlchemyRepository) -> None:
self.repo = repo

def get_status(self) -> AgentStatusData:
running_runs = self.repo.list_runs_by_status(RunStatus.running)
latest_run = self.repo.latest_run()
subagents = sorted(
{
f"chat:{run.model}" if run.model else "chat:unknown-model"
for run in running_runs
}
)

return {
"status": "active" if running_runs else "idle",
"subagents": subagents,
"activeRuns": len(running_runs),
"lastRunAt": latest_run.created_at.isoformat() if latest_run is not None else None,
}
26 changes: 23 additions & 3 deletions apps/elara-nexus/backend/app/services/chat_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,20 @@ def create_session(self, title: str) -> ChatSessionData:
"createdAt": session.created_at.isoformat(),
}

def list_sessions(self) -> list[ChatSessionData]:
return [
{
"id": session.id,
"title": session.title,
"createdAt": session.created_at.isoformat(),
}
for session in self.repo.list_chat_sessions()
]

def add_message(self, session_id: str, role: str, content: str) -> ChatMessageData:
if self.repo.get_chat_session(session_id) is None:
raise ValueError("Session not found")

message = self.repo.add_chat_message(session_id=session_id, role=role, content=content)

run_payload: ChatRunData | None = None
Expand All @@ -38,10 +51,14 @@ def add_message(self, session_id: str, role: str, content: str) -> ChatMessageDa
trace_id=trace.trace_id,
)
try:
prior_messages = self.repo.list_chat_messages(session_id)
prompt_messages = [
{"role": prior.role, "content": prior.content}
for prior in prior_messages
if prior.role in {"user", "assistant"}
]
reply = self.llm_client.generate_reply(
[
{"role": "user", "content": content},
]
prompt_messages
)
self.repo.add_chat_message(
session_id=session_id, role="assistant", content=reply.content
Expand Down Expand Up @@ -70,6 +87,9 @@ def add_message(self, session_id: str, role: str, content: str) -> ChatMessageDa
}

def list_messages(self, session_id: str) -> list[ChatMessageData]:
if self.repo.get_chat_session(session_id) is None:
raise ValueError("Session not found")

return [
{
"id": message.id,
Expand Down
6 changes: 2 additions & 4 deletions apps/elara-nexus/backend/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,8 @@


def _configure_env(db_path: Path) -> None:
if "APP_DB_URL" not in os.environ:
os.environ["APP_DB_URL"] = f"sqlite:///{db_path}"
if "APP_DB_ENGINE" not in os.environ:
os.environ["APP_DB_ENGINE"] = "sqlite"
os.environ["APP_DB_URL"] = f"sqlite:///{db_path}"
os.environ["APP_DB_ENGINE"] = "sqlite"
os.environ["APP_AUTH_TOKEN"] = "test-token"
os.environ["APP_CORS_ORIGINS"] = "http://localhost:3000"
os.environ["APP_VECTOR_DIMENSIONS"] = "8"
Expand Down
Loading