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
2 changes: 1 addition & 1 deletion .github/workflows/opencode.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,4 @@ jobs:
env:
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
with:
model: "opencode/MiniMax M2.5 Free"
model: "opencode/MiniMax-M2.5-Free"
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -88,4 +88,8 @@ jspm_packages/
.doc/
.claude

firebaseconfig.ts
firebaseconfig.ts

# Firebase service account keys
backend/privateKey.json
backend/firebase-service-account.json
2 changes: 2 additions & 0 deletions backend/api/routers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
from .databases import router as databases_router
from .queries import router as queries_router
from .schema import router as schema_router
from .chat import router as chat_router

__all__ = [
"databases_router",
"queries_router",
"schema_router",
"chat_router",
]
306 changes: 306 additions & 0 deletions backend/api/routers/chat.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,306 @@
"""Chat history and bookmarks API endpoints."""

from datetime import datetime, UTC
from typing import Optional

from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel

from api.middleware.auth import get_current_user
from database.chat_models import ChatSession, ChatMessage, Bookmark
from database.session import get_db, set_current_user_context


router = APIRouter(prefix="/api/v1", tags=["chat"])


class CreateSessionRequest(BaseModel):
title: Optional[str] = None


class CreateMessageRequest(BaseModel):
role: str
content: str


class CreateBookmarkRequest(BaseModel):
session_id: Optional[int] = None
message_id: Optional[int] = None
note: Optional[str] = None


def _auto_title(content: str) -> str:
text = (content or "").strip()
if not text:
return "New Chat"
return text[:60]


@router.get("/chat/sessions")
async def list_chat_sessions(user: dict = Depends(get_current_user)):
user_id = user.get("uid")
with get_db() as db:
set_current_user_context(db, user_id)
sessions = (
db.query(ChatSession)
.filter(ChatSession.user_id == user_id)
.order_by(ChatSession.updated_at.desc())
.all()
)
return [
{
"id": s.id,
"title": s.title,
"created_at": s.created_at,
"updated_at": s.updated_at,
}
for s in sessions
]


@router.post("/chat/sessions")
async def create_chat_session(
request: CreateSessionRequest, user: dict = Depends(get_current_user)
):
user_id = user.get("uid")
with get_db() as db:
set_current_user_context(db, user_id)
session = ChatSession(user_id=user_id, title=request.title or "New Chat")
db.add(session)
db.flush()
return {
"id": session.id,
"title": session.title,
"created_at": session.created_at,
"updated_at": session.updated_at,
}


@router.get("/chat/sessions/{session_id}")
async def get_chat_session(session_id: int, user: dict = Depends(get_current_user)):
user_id = user.get("uid")
with get_db() as db:
set_current_user_context(db, user_id)
session = (
db.query(ChatSession)
.filter(ChatSession.id == session_id)
.filter(ChatSession.user_id == user_id)
.first()
)
if not session:
raise HTTPException(status_code=404, detail="Session not found")

messages = (
db.query(ChatMessage)
.filter(ChatMessage.session_id == session_id)
.filter(ChatMessage.user_id == user_id)
.order_by(ChatMessage.created_at.asc())
.all()
)
return {
"id": session.id,
"title": session.title,
"created_at": session.created_at,
"updated_at": session.updated_at,
"messages": [
{
"id": m.id,
"role": m.role,
"content": m.content,
"created_at": m.created_at,
}
for m in messages
],
}


@router.delete("/chat/sessions/{session_id}")
async def delete_chat_session(session_id: int, user: dict = Depends(get_current_user)):
user_id = user.get("uid")
with get_db() as db:
set_current_user_context(db, user_id)
session = (
db.query(ChatSession)
.filter(ChatSession.id == session_id)
.filter(ChatSession.user_id == user_id)
.first()
)
if not session:
raise HTTPException(status_code=404, detail="Session not found")

db.delete(session)
return {"success": True}


@router.post("/chat/sessions/{session_id}/messages")
async def add_chat_message(
session_id: int,
request: CreateMessageRequest,
user: dict = Depends(get_current_user),
):
if request.role not in {"user", "assistant"}:
raise HTTPException(
status_code=400, detail="Role must be 'user' or 'assistant'"
)

user_id = user.get("uid")
with get_db() as db:
set_current_user_context(db, user_id)
session = (
db.query(ChatSession)
.filter(ChatSession.id == session_id)
.filter(ChatSession.user_id == user_id)
.first()
)
if not session:
raise HTTPException(status_code=404, detail="Session not found")

message = ChatMessage(
session_id=session_id,
user_id=user_id,
role=request.role,
content=request.content,
)
db.add(message)

if request.role == "user" and (
not session.title or session.title == "New Chat"
):
session.title = _auto_title(request.content)

session.updated_at = datetime.now(UTC)
db.flush()

return {
"id": message.id,
"session_id": session_id,
"role": message.role,
"content": message.content,
"created_at": message.created_at,
}


@router.post("/bookmarks")
async def create_bookmark(
request: CreateBookmarkRequest, user: dict = Depends(get_current_user)
):
if not request.session_id and not request.message_id:
raise HTTPException(
status_code=400, detail="session_id or message_id is required"
)

user_id = user.get("uid")
with get_db() as db:
set_current_user_context(db, user_id)

if request.session_id:
session = (
db.query(ChatSession)
.filter(ChatSession.id == request.session_id)
.filter(ChatSession.user_id == user_id)
.first()
)
if not session:
raise HTTPException(status_code=404, detail="Session not found")

if request.message_id:
message = (
db.query(ChatMessage)
.filter(ChatMessage.id == request.message_id)
.filter(ChatMessage.user_id == user_id)
.first()
)
if not message:
raise HTTPException(status_code=404, detail="Message not found")

Copilot AI Apr 1, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When both session_id and message_id are provided, the endpoint validates each exists, but it doesn’t validate that the message belongs to the session. This allows inconsistent bookmarks (message from session A + session_id B). If both are present, validate message.session_id == session_id (or reject with 400).

Suggested change
if request.session_id and request.message_id:
if message.session_id != request.session_id:
raise HTTPException(
status_code=400,
detail="Message does not belong to the specified session",
)

Copilot uses AI. Check for mistakes.
bookmark = Bookmark(
user_id=user_id,
session_id=request.session_id,
message_id=request.message_id,
note=request.note,
)
db.add(bookmark)
db.flush()

return {
"id": bookmark.id,
"session_id": bookmark.session_id,
"message_id": bookmark.message_id,
"note": bookmark.note,
"bookmarked_at": bookmark.bookmarked_at,
}


@router.get("/bookmarks")
async def list_bookmarks(user: dict = Depends(get_current_user)):
user_id = user.get("uid")
with get_db() as db:
set_current_user_context(db, user_id)
bookmarks = (
db.query(Bookmark)
.filter(Bookmark.user_id == user_id)
.order_by(Bookmark.bookmarked_at.desc())
.all()
)

grouped = {}
for b in bookmarks:
day = b.bookmarked_at.date().isoformat()
grouped.setdefault(day, []).append(
{
"id": b.id,
"session_id": b.session_id,
"message_id": b.message_id,
"note": b.note,
"bookmarked_at": b.bookmarked_at,
}
)
return grouped


@router.delete("/bookmarks/{bookmark_id}")
async def delete_bookmark(bookmark_id: int, user: dict = Depends(get_current_user)):
user_id = user.get("uid")
with get_db() as db:
set_current_user_context(db, user_id)
bookmark = (
db.query(Bookmark)
.filter(Bookmark.id == bookmark_id)
.filter(Bookmark.user_id == user_id)
.first()
)
if not bookmark:
raise HTTPException(status_code=404, detail="Bookmark not found")

db.delete(bookmark)
return {"success": True}


@router.get("/chat/search")
async def search_chat(
q: str = Query(..., min_length=1), user: dict = Depends(get_current_user)
):
user_id = user.get("uid")
with get_db() as db:
set_current_user_context(db, user_id)
messages = (
db.query(ChatMessage)
.join(ChatSession, ChatSession.id == ChatMessage.session_id)
.filter(ChatMessage.user_id == user_id)
.filter(ChatMessage.content.ilike(f"%{q}%"))
.order_by(ChatMessage.created_at.desc())
.limit(50)
.all()
Comment on lines +279 to +293

Copilot AI Apr 1, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The migration adds a generated content_tsv column and a GIN index for full-text search, but the search endpoint uses ILIKE '%q%', which can’t use that index and will degrade as message volume grows. Consider switching the search query to use Postgres FTS (content_tsv @@ plainto_tsquery(...)) so the intended index is utilized.

Copilot uses AI. Check for mistakes.
)

return [
{
"message_id": m.id,
"session_id": m.session_id,
"session_title": m.session.title if m.session else "Untitled",
"role": m.role,
"snippet": m.content[:200],
"created_at": m.created_at,
}
for m in messages
]
Loading