feat(session): add title management for sessions in backend and frontend

This commit is contained in:
2026-06-13 00:52:43 +08:00
parent d633617bf1
commit 67ff3e2e4a
7 changed files with 107 additions and 12 deletions
+28
View File
@@ -361,6 +361,7 @@ async def revoke_auth_token(
_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"
_USER_SESSIONS_KEY = "user:{user_id}:sessions"
@@ -428,6 +429,31 @@ async def get_session_user_id(
return await valkey.get(key)
async def set_session_title(
session_id: str,
title: str,
valkey: Redis | None = None,
) -> None:
"""Store a short human-readable title for a session."""
if valkey is None:
valkey = await get_valkey()
key = _SESSION_TITLE_KEY.format(session_id=session_id)
await valkey.set(key, title)
async def get_session_title(
session_id: str,
valkey: Redis | None = None,
) -> str | None:
"""Return the title for a session, or ``None`` if not set."""
if valkey is None:
valkey = await get_valkey()
key = _SESSION_TITLE_KEY.format(session_id=session_id)
return await valkey.get(key)
async def list_sessions(
valkey: Redis | None = None,
) -> list[str]:
@@ -466,9 +492,11 @@ 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)
async with valkey.pipeline(transaction=True) as pipe:
pipe.delete(key)
pipe.delete(session_user_key)
pipe.delete(session_title_key)
pipe.srem(_SESSIONS_SET, session_id)
if user_sessions_key is not None:
pipe.srem(user_sessions_key, session_id)
+4
View File
@@ -16,6 +16,7 @@ from backend.core.database import (
delete_user as db_delete_user,
get_admin_stats,
get_session_messages,
get_session_title,
get_session_user_id,
get_user,
get_user_by_username,
@@ -95,6 +96,7 @@ class AdminUserUpdate(BaseModel):
class AdminSessionOut(BaseModel):
session_id: str
title: str | None = None
message_count: int
user_id: str | None = None
@@ -194,9 +196,11 @@ async def admin_list_sessions(
for sid in ids:
msgs = await get_session_messages(sid)
uid = await get_session_user_id(sid)
title = await get_session_title(sid)
result.append(
AdminSessionOut(
session_id=sid,
title=title,
message_count=len(msgs),
user_id=uid,
)
+16 -1
View File
@@ -14,10 +14,12 @@ from pydantic import BaseModel
from backend.core.agent import AgentService
from backend.core.database import (
get_session_messages,
get_session_title,
get_session_user_id,
list_sessions,
resolve_auth_token,
save_message,
set_session_title,
)
from backend.core.dependencies import get_agent_service
@@ -43,6 +45,7 @@ class MessageOut(BaseModel):
class SessionOut(BaseModel):
session_id: str
title: str | None = None
message_count: int
user_id: str | None = None
@@ -89,6 +92,14 @@ async def chat_endpoint(
user_id=user_id,
)
# Generate a short title from the first user message if not yet set.
existing_title = await get_session_title(session_id)
if existing_title is None:
title = body.message.strip()[:60]
if len(body.message.strip()) > 60:
title += ""
await set_session_title(session_id, title or "New chat")
# Ask the agent.
output = await agent.ask(body.message, session_id=session_id)
@@ -133,9 +144,13 @@ async def sessions_list(
for sid in ids:
msgs = await get_session_messages(sid)
uid = await get_session_user_id(sid)
title = await get_session_title(sid)
result.append(
SessionOut(
session_id=sid, message_count=len(msgs), user_id=uid
session_id=sid,
title=title,
message_count=len(msgs),
user_id=uid,
)
)
return result
+7 -1
View File
@@ -14,6 +14,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from backend.core.database import (
create_user,
get_session_title,
get_user,
get_user_by_username,
list_user_sessions,
@@ -55,6 +56,7 @@ class UserOut(BaseModel):
class UserSessionOut(BaseModel):
session_id: str
title: str | None = None
# ── Helpers ────────────────────────────────────────────────────────────────
@@ -145,4 +147,8 @@ async def user_sessions_endpoint(
) -> list[UserSessionOut]:
"""List all session IDs associated with a user."""
session_ids = await list_user_sessions(str(user_id))
return [UserSessionOut(session_id=sid) for sid in session_ids]
result: list[UserSessionOut] = []
for sid in session_ids:
title = await get_session_title(sid)
result.append(UserSessionOut(session_id=sid, title=title))
return result
+11 -3
View File
@@ -421,6 +421,7 @@ function SessionsTab({
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-gray-800 text-xs uppercase tracking-wider text-gray-500">
<th className="pb-3 pr-4">Title</th>
<th className="pb-3 pr-4">Session ID</th>
<th className="pb-3 pr-4">Messages</th>
<th className="pb-3 pr-4">User ID</th>
@@ -430,7 +431,7 @@ function SessionsTab({
<tbody>
{sessions.length === 0 ? (
<tr>
<td colSpan={4} className="py-8 text-center text-gray-500">
<td colSpan={5} className="py-8 text-center text-gray-500">
No sessions yet.
</td>
</tr>
@@ -440,9 +441,16 @@ function SessionsTab({
key={s.session_id}
className="border-b border-gray-800/50 transition hover:bg-gray-800/30"
>
<td className="max-w-48 py-3 pr-4">
<p className="truncate text-sm text-gray-200">
{s.title ?? (
<span className="text-gray-500 italic">Untitled</span>
)}
</p>
</td>
<td className="py-3 pr-4">
<code className="text-xs text-gray-300">
{s.session_id.slice(0, 16)}
<code className="text-xs text-gray-500">
{s.session_id.slice(0, 12)}
</code>
</td>
<td className="py-3 pr-4 text-gray-400">{s.message_count}</td>
+38 -7
View File
@@ -310,6 +310,17 @@ export default function App() {
/* ── Session handlers ────────────────────────────────────────────── */
const handleNewSession = () => {
localStorage.removeItem(STORAGE_SESSION_KEY);
setSessionId("");
setMessages([
{
role: "assistant",
content: "Hello! I'm **Agent Alpha**. How can I help you today?",
},
]);
};
const handleViewSessions = async () => {
if (!authenticated) return;
try {
@@ -430,6 +441,15 @@ export default function App() {
</span>
</div>
{/* New Chat button */}
<button
onClick={handleNewSession}
className="rounded-lg border border-gray-700 px-3 py-1.5 text-sm text-gray-400 transition hover:border-indigo-500 hover:text-indigo-400"
title="Start a new chat"
>
+ New Chat
</button>
{/* Sessions button */}
<button
onClick={handleViewSessions}
@@ -505,7 +525,7 @@ export default function App() {
</div>
<div className="flex-1 truncate">
<p className="text-sm text-gray-200">
{s.session_id.slice(0, 16)}…
{s.title ?? s.session_id.slice(0, 16) + ""}
</p>
</div>
<span className="text-xs text-gray-500">
@@ -515,12 +535,23 @@ export default function App() {
))}
</div>
)}
<button
onClick={() => setShowSessions(false)}
className="mt-4 w-full rounded-lg border border-gray-700 px-4 py-2 text-sm text-gray-400 transition hover:bg-gray-800"
>
Close
</button>
<div className="mt-4 flex gap-2">
<button
onClick={() => {
setShowSessions(false);
handleNewSession();
}}
className="flex-1 rounded-lg border border-indigo-700 px-4 py-2 text-sm text-indigo-400 transition hover:bg-indigo-900/30"
>
+ New Chat
</button>
<button
onClick={() => setShowSessions(false)}
className="flex-1 rounded-lg border border-gray-700 px-4 py-2 text-sm text-gray-400 transition hover:bg-gray-800"
>
Close
</button>
</div>
</div>
</div>
)}
+3
View File
@@ -36,6 +36,7 @@ export interface MessageData {
export interface SessionData {
session_id: string;
title: string | null;
message_count: number;
user_id: string | null;
}
@@ -74,6 +75,7 @@ export interface UserCreateData {
export interface UserSessionData {
session_id: string;
title: string | null;
}
/* ── Auth API ──────────────────────────────────────────────────── */
@@ -241,6 +243,7 @@ export interface AdminUserData {
export interface AdminSessionData {
session_id: string;
title: string | null;
message_count: number;
user_id: string | null;
}