feat: Implement session creation timestamps and enhance session management in API

This commit is contained in:
2026-06-14 14:26:33 +08:00
parent 5c93e5d7a8
commit 6b94af814c
7 changed files with 387 additions and 11 deletions
+11 -1
View File
@@ -12,7 +12,7 @@ agent_alpha/
│ ├── core/
│ │ ├── agent.py # AgentService (lazy init, capabilities)
│ │ ├── config.py # pydantic-settings from .env
│ │ ├── database.py# SQLAlchemy async engine + Valkey client + migrations
│ │ ├── database.py# SQLAlchemy async engine + Valkey client + migrations; session created_at tracking
│ │ ├── dependencies.py # FastAPI DI providers
│ │ └── models.py # SQLAlchemy ORM models (User)
│ └── routes/
@@ -78,6 +78,7 @@ make compose-down # Stop everything
- **Interfaces** at file top, exported when reused.
- **Tailwind utility classes** inline; no CSS modules or styled-components.
- **API client** isolated in `api.ts` — exports for chat (`sendMessage`, `getHistory`), auth (`login`, `register`, `getMe`, `logout`), users (`listUsers`, `createUser`, `getUserSessions`), and admin (`getAdminStats`, `adminListUsers`, `adminUpdateUser`, `adminListSessions`, `adminDeleteSession`). Auth token managed via `localStorage` + `Authorization: Bearer` header helpers.
- **Session metadata**: Session creation timestamps (`chat:{session_id}:created_at`) are stored in Valkey via `SETNX` on the first message. The `GET /api/users/{user_id}/sessions` endpoint returns `session_id`, `title`, `created_at` (ISO-8601), and `message_count`, sorted newest-first. The frontend displays relative time via `formatSessionTime()` in the sessions sidebar.
## Key Architecture Decisions
@@ -104,3 +105,12 @@ Environment variables loaded from `.env` via `pydantic-settings` (`backend/core/
7. **Container orchestration**: Backend depends on postgres + valkey being healthy. Use `depends_on` with `condition: service_healthy`. Run with `docker compose up -d` or `podman compose up -d`. The Makefile auto-detects the available runtime.
8. **Schema migrations**: `init_db()` in `database.py` runs `Base.metadata.create_all` followed by `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` for new columns. Existing tables are never rebuilt — new columns are added in-place.
9. **bcrypt on container restart**: `bcrypt` is a native dependency. If the container fails to start with an import error, ensure `bcrypt` is in `pyproject.toml` and `uv sync` was run during the container build.
## Key Conventions
- `db.flush()` in repositories, not `commit()`
## More Info
- `docs/architecture.md` - Architecture details
- `docs/patterns.md` - Code patterns
+20 -1
View File
@@ -365,6 +365,7 @@ _MESSAGES_KEY = "chat:{session_id}:messages"
_SESSIONS_SET = "chat:sessions"
_SESSION_USER_KEY = "chat:{session_id}:user_id"
_SESSION_TITLE_KEY = "chat:{session_id}:title"
_SESSION_CREATED_KEY = "chat:{session_id}:created_at"
_USER_SESSIONS_KEY = "user:{user_id}:sessions"
@@ -382,15 +383,19 @@ async def save_message(
if valkey is None:
valkey = await get_valkey()
now = datetime.now(timezone.utc)
msg = {
"role": role,
"content": content,
"timestamp": datetime.now(timezone.utc).isoformat(),
"timestamp": now.isoformat(),
}
key = _MESSAGES_KEY.format(session_id=session_id)
created_key = _SESSION_CREATED_KEY.format(session_id=session_id)
async with valkey.pipeline(transaction=True) as pipe:
pipe.rpush(key, json.dumps(msg))
pipe.sadd(_SESSIONS_SET, session_id)
# Set created_at only for the first message (NX = set if not exists)
pipe.setnx(created_key, now.isoformat())
if user_id is not None:
pipe.set(
_SESSION_USER_KEY.format(session_id=session_id),
@@ -457,6 +462,18 @@ async def get_session_title(
return await valkey.get(key)
async def get_session_created_at(
session_id: str,
valkey: Redis | None = None,
) -> str | None:
"""Return the ISO-8601 creation timestamp for a session, or ``None``."""
if valkey is None:
valkey = await get_valkey()
key = _SESSION_CREATED_KEY.format(session_id=session_id)
return await valkey.get(key)
async def list_sessions(
valkey: Redis | None = None,
) -> list[str]:
@@ -496,10 +513,12 @@ async def delete_session(
key = _MESSAGES_KEY.format(session_id=session_id)
session_user_key = _SESSION_USER_KEY.format(session_id=session_id)
session_title_key = _SESSION_TITLE_KEY.format(session_id=session_id)
session_created_key = _SESSION_CREATED_KEY.format(session_id=session_id)
async with valkey.pipeline(transaction=True) as pipe:
pipe.delete(key)
pipe.delete(session_user_key)
pipe.delete(session_title_key)
pipe.delete(session_created_key)
pipe.srem(_SESSIONS_SET, session_id)
if user_sessions_key is not None:
pipe.srem(user_sessions_key, session_id)
+19 -1
View File
@@ -14,6 +14,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from backend.core.database import (
create_user,
get_session_created_at,
get_session_messages,
get_session_title,
get_user,
get_user_by_username,
@@ -57,6 +59,8 @@ class UserOut(BaseModel):
class UserSessionOut(BaseModel):
session_id: str
title: str | None = None
created_at: str | None = None
message_count: int = 0
# ── Helpers ────────────────────────────────────────────────────────────────
@@ -150,5 +154,19 @@ async def user_sessions_endpoint(
result: list[UserSessionOut] = []
for sid in session_ids:
title = await get_session_title(sid)
result.append(UserSessionOut(session_id=sid, title=title))
created_at = await get_session_created_at(sid)
messages = await get_session_messages(sid)
result.append(
UserSessionOut(
session_id=sid,
title=title,
created_at=created_at,
message_count=len(messages),
)
)
# Sort newest first by created_at (sessions without timestamp go last)
result.sort(
key=lambda s: s.created_at or "",
reverse=True,
)
return result
+88
View File
@@ -0,0 +1,88 @@
# Architecture Guide
The project uses a **hybrid** architecture. Core features (users, auth, chat)
are handled with flat function-based calls from routes into `database.py`.
RAG features follow a proper **Repository + Service** layered pattern.
## Request Flows
### RAG features (layered pattern)
```
HTTP Request → Route → Service → Repository → Database (PostgreSQL)
Response ← Service ← Repository ←
```
Routes delegate to **services** (business logic), which delegate to
**repositories** (data access). Repositories operate on **ORM models**
and return domain objects. Responses are serialized through **schemas**.
```
backend/
├── db/models/ # SQLAlchemy ORM models
├── schemas/ # Pydantic request/response schemas
├── repositories/ # Async CRUD helpers (no business logic)
├── services/ # Business logic layer
└── routes/ # FastAPI endpoints (thin — delegate to services)
```
### Core features (flat pattern — users, auth, chat)
```
HTTP Request → Route → database.py functions → PostgreSQL / Valkey
Response ←
```
Routes in `routes/{auth,users,chat,admin}.py` call functions directly
from `core/database.py`, which contains both PostgreSQL queries and
Valkey (Redis-compatible) data access. There are no intermediate
service or repository layers for these features.
```
backend/
├── core/
│ ├── database.py # All DB + Valkey access functions
│ ├── models.py # User ORM model only
│ └── config.py # pydantic-settings
└── routes/
├── auth.py # Auth endpoints (login, register, logout)
├── users.py # User CRUD + session listing
├── chat.py # Chat send + history
├── admin.py # Admin dashboard (stats, users, sessions)
└── health.py # Health check
```
## Data Stores
| Data | Store | Access |
|------|-------|--------|
| Users (auth, profile) | PostgreSQL | `core/database.py` functions + ORM |
| Auth tokens | Valkey (key-value) | `core/database.py` functions |
| Chat messages & sessions | Valkey (lists, sets) | `core/database.py` functions |
| RAG documents, sync logs, chat files | PostgreSQL | Repository → Service → Route |
| RAG vector embeddings | Milvus | `services/rag/vectorstore.py` |
## Session Lifecycle (Valkey)
Chat sessions are stored entirely in Valkey:
```
chat:{session_id}:messages → List of JSON messages [{role, content, timestamp}]
chat:{session_id}:user_id → Owner user ID string
chat:{session_id}:title → Short human-readable title
chat:{session_id}:created_at → ISO-8601 creation timestamp (SETNX on first msg)
chat:sessions → Set of all known session IDs
user:{user_id}:sessions → Set of session IDs owned by a user
```
## Key Conventions
- **`db.flush()`** in repositories, never `commit()` — the outer service/route
owns the transaction commit.
- **Settings** use `pydantic-settings` (`Settings` class in `config.py`).
Field names are snake_case, env vars are UPPER_SNAKE_CASE.
- **Async-first** — all I/O uses `async`/`await` (routes, DB, Valkey).
- **Error handling** — `HTTPException` in routes; `try/except` with graceful
fallback for optional components (e.g., Logfire).
+199
View File
@@ -0,0 +1,199 @@
# Code Patterns
## Dependency Injection
Use FastAPI's `Depends()` for injecting dependencies:
```python
from app.api.deps import get_db, get_current_user
@router.get("/conversations")
async def list_conversations(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
service = ConversationService(db)
return await service.get_by_user(current_user.id)
```
> **Important:** Routes never contain direct database calls. All data access
> goes through a service, which in turn delegates to a repository.
Available dependencies in `app/api/deps.py`:
- `get_db` - Database session
- `get_current_user` - Authenticated user (raises 401 if not authenticated)
- `get_current_user_optional` - User or None
- `get_redis` - Redis connection
## Service Layer Pattern
Every feature uses the same pattern: a service class receives a DB session,
instantiates its repository, and provides business-level methods. Services
are the **only** layer that raises domain exceptions.
```python
class ConversationService:
def __init__(self, db: AsyncSession):
self.db = db
self.repo = ConversationRepository()
async def create(self, data: ConversationCreate, user_id: UUID) -> Conversation:
# Business validation
return await self.repo.create(self.db, user_id=user_id, **data.model_dump())
async def get_or_raise(self, id: UUID) -> Conversation:
conv = await self.repo.get_by_id(self.db, id)
if not conv:
raise NotFoundError(message="Conversation not found", details={"id": str(id)})
return conv
```
All current services follow this pattern: `UserService`, `ConversationService`,
`FileUploadService`, `FileStorageService`, `RagDocumentService`, `RagSyncService`, `SyncSourceService`.
## Repository Layer Pattern
Repositories handle data access only. They contain **no** business logic and
always use `flush()` instead of `commit()` so the caller controls transactions:
```python
class ConversationRepository:
async def get_by_id(self, db: AsyncSession, id: UUID) -> Conversation | None:
return await db.get(Conversation, id)
async def create(self, db: AsyncSession, **kwargs) -> Conversation:
conv = Conversation(**kwargs)
db.add(conv)
await db.flush() # Not commit! Let dependency manage transaction
await db.refresh(conv)
return conv
async def get_by_user(
self, db: AsyncSession, user_id: UUID, skip: int = 0, limit: int = 100
) -> list[Conversation]:
result = await db.execute(
select(Conversation)
.where(Conversation.user_id == user_id)
.offset(skip).limit(limit)
)
return list(result.scalars().all())
```
## Exception Handling
Use domain exceptions in services:
```python
from app.core.exceptions import NotFoundError, AlreadyExistsError, ValidationError
# In service
if not conversation:
raise NotFoundError(
message="Conversation not found",
details={"id": str(id)}
)
if await self.repo.exists_by_email(self.db, email):
raise AlreadyExistsError(
message="User with this email already exists"
)
```
Exception handlers convert to HTTP responses automatically.
## Schema Patterns
Separate schemas for different operations:
```python
# Base with shared fields
class UserBase(BaseModel):
email: str
full_name: str | None = None
# For creation (input)
class UserCreate(UserBase):
password: str
# For updates (all optional)
class UserUpdate(BaseModel):
full_name: str | None = None
email: str | None = None
# For responses (with DB fields)
class UserResponse(UserBase):
id: UUID
created_at: datetime
updated_at: datetime | None
model_config = ConfigDict(from_attributes=True)
```
## Connector Pattern (RAG Sync)
Remote document sources (Google Drive, S3, etc.) use a pluggable connector
pattern defined in `app/services/rag/connectors/`. Each connector inherits from
`BaseSyncConnector` and is registered in the `CONNECTOR_REGISTRY` dictionary.
### Adding a new connector
1. Create a file in `app/services/rag/connectors/` (e.g. `sharepoint.py`).
2. Subclass `BaseSyncConnector` and implement the required methods.
3. Register the connector in `CONNECTOR_REGISTRY`.
```python
from app.services.rag.connectors import BaseSyncConnector, RemoteFile, CONNECTOR_REGISTRY
class SharePointConnector(BaseSyncConnector):
CONNECTOR_TYPE = "sharepoint"
DISPLAY_NAME = "SharePoint"
CONFIG_SCHEMA = {
"site_url": {"label": "Site URL", "required": True},
"client_id": {"label": "Client ID", "required": True},
}
async def list_files(self, config: dict) -> list[RemoteFile]:
# Return metadata for available files
...
async def download_file(self, file: RemoteFile, dest_dir: Path) -> Path:
# Download file to dest_dir, return local Path
...
# Register so the sync service can discover it
CONNECTOR_REGISTRY["sharepoint"] = SharePointConnector
```
The `RagSyncService` uses `CONNECTOR_REGISTRY` to look up the right connector
by type, validate its config, list remote files, download them, and hand them
off to the ingestion pipeline.
## Frontend Patterns
### Authentication (HTTP-only cookies)
```typescript
import { useAuth } from '@/hooks/use-auth';
function Component() {
const { user, isAuthenticated, login, logout } = useAuth();
}
```
### State Management (Zustand)
```typescript
import { useAuthStore } from '@/stores/auth-store';
const { user, setUser, logout } = useAuthStore();
```
### WebSocket Chat
```typescript
import { useChat } from '@/hooks/use-chat';
function ChatPage() {
const { messages, sendMessage, isStreaming } = useChat();
}
```
+48 -8
View File
@@ -37,6 +37,43 @@ const ROLE_BADGES: Record<string, string> = {
viewer: "bg-gray-600",
};
/* ── Date/time helpers ──────────────────────────────────────────── */
function formatSessionTime(iso: string | null): string {
if (!iso) return "";
const date = new Date(iso);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHr = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHr / 24);
// Within the last minute
if (diffSec < 60) return "Just now";
// Within the last hour
if (diffMin < 60) return `${diffMin}m ago`;
// Within the last 24 hours
if (diffHr < 24) return `${diffHr}h ago`;
// Yesterday
if (diffDay === 1) return "Yesterday";
// Within the last 6 days — show day name
if (diffDay < 7) {
const days = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
return days[date.getDay()];
}
// This year — show "Mon D"
if (date.getFullYear() === now.getFullYear()) {
return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
}
// Older — show "Mon D, YYYY"
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
}
/* ------------------------------------------------------------------ */
/* Auth Page */
/* ------------------------------------------------------------------ */
@@ -556,7 +593,7 @@ export default function App() {
No sessions yet. Start a chat to create one.
</p>
) : (
<div className="max-h-64 space-y-2 overflow-y-auto">
<div className="max-h-80 space-y-2 overflow-y-auto">
{userSessions.map((s) => (
<button
key={s.session_id}
@@ -567,19 +604,22 @@ export default function App() {
: ""
}`}
>
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-gray-800 text-xs text-gray-400">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-gray-800 text-xs text-gray-400">
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
</svg>
</div>
<div className="flex-1 truncate">
<p className="text-sm text-gray-200">
{s.title ?? s.session_id.slice(0, 16) + ""}
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-gray-200">
{s.title ?? "Chat " + s.session_id.slice(0, 8) + ""}
</p>
<p className="mt-0.5 truncate text-[11px] text-gray-500">
{formatSessionTime(s.created_at)}
{s.message_count > 0 && (
<> · {s.message_count} {s.message_count === 1 ? "message" : "messages"}</>
)}
</p>
</div>
<span className="text-xs text-gray-500">
{s.session_id.slice(0, 8)}
</span>
</button>
))}
</div>
+2
View File
@@ -80,6 +80,8 @@ export interface UserCreateData {
export interface UserSessionData {
session_id: string;
title: string | null;
created_at: string | null;
message_count: number;
}
/* ── Auth API ──────────────────────────────────────────────────── */