feat(assistant): #94-#97 historique permanent, sidebar IA, panneau + et bouton rond
CI / lint (push) Successful in 1m28s
CI / security (push) Successful in 58s
CI / test (push) Successful in 2m9s
CI / build (push) Successful in 1m3s
CI / e2e (push) Successful in 11m12s

This commit is contained in:
2026-09-16 14:56:45 -04:00
parent 0841778b99
commit c9e3240ae2
23 changed files with 1351 additions and 151 deletions
+84
View File
@@ -11,6 +11,7 @@ from pydantic import BaseModel, Field
from backend.agent.loop import run_agent
from backend.ai_chat import chat_completion, stream_completion
from backend.ai_history import delete_session, get_session, list_sessions, upsert_session
from backend.auth.middleware import check_vault_access, require_auth
from backend.bookslm import (
build_general_system_prompt,
@@ -605,3 +606,86 @@ async def api_bookslm_agent(
"X-Accel-Buffering": "no",
},
)
# ── Persistent conversation history (#95) ─────────────────────────────
class BookslmSession(BaseModel):
"""Full assistant conversation persisted server-side (#95).
The shape mirrors the client session object so round-tripping is lossless
(transient fields such as ``confirmation``/``payload`` are stripped
client-side before upload).
"""
id: str = Field(description="Stable session id (s-<ts36>-<rand>)")
title: str = Field(default="", description="Derived human title")
mode: str = Field(default="general", description="'directory', 'documents' or 'general'")
vault: str | None = Field(default=None, description="Vault name for the context")
directory: str = Field(default="", description="Relative directory path")
documents: list[dict[str, str]] = Field(
default_factory=list,
description="Open documents for the 'documents' mode",
)
context: str = Field(default="", description="Client context key of the assistant")
createdAt: int | None = Field(default=None, description="Creation ISO ms timestamp")
updatedAt: int | None = Field(default=None, description="Last update ISO ms timestamp")
messages: list[dict[str, Any]] = Field(
default_factory=list,
description="Conversation turns [{role, content}]",
)
def _session_user(current_user) -> str:
return current_user.get("username") if isinstance(current_user, dict) else "" # type: ignore[return-value]
@router.get("/history", response_model=dict[str, list[dict[str, Any]]])
async def api_bookslm_history_list(current_user=Depends(require_auth)):
"""List the user's assistant conversations (summaries, most recent first).
Messages are not included to keep the list light; fetch the full
conversation with ``GET /history/{id}`` when a session is opened.
"""
return {"sessions": list_sessions(_session_user(current_user))}
@router.get("/history/{session_id}", response_model=dict[str, Any] | None)
async def api_bookslm_history_get(
session_id: str,
current_user=Depends(require_auth),
):
"""Return a single full conversation, or 404 when unknown."""
session = get_session(_session_user(current_user), session_id)
if session is None:
raise HTTPException(status_code=404, detail="Conversation not found")
return session
@router.put("/history/{session_id}", response_model=dict[str, Any])
async def api_bookslm_history_upsert(
session_id: str,
session: BookslmSession,
current_user=Depends(require_auth),
):
"""Create or update a conversation for the current user.
The body id is forced to the path id so a client never writes under a
different key by mistake.
"""
payload = session.model_dump()
payload["id"] = session_id
stored = upsert_session(_session_user(current_user), payload)
if stored is None:
raise HTTPException(status_code=400, detail="Invalid session id")
return stored
@router.delete("/history/{session_id}", response_model=dict[str, bool])
async def api_bookslm_history_delete(
session_id: str,
current_user=Depends(require_auth),
):
"""Delete a conversation for the current user."""
return {"ok": delete_session(_session_user(current_user), session_id)}