feat(agent): Gradio-powered LangGraph agent chat with streaming, tool calls, file upload, conversation persistence
- Gradio 5.50.0 ChatInterface with type='messages' streaming - LangGraph create_react_agent with InMemorySaver checkpointer - 4 @tool functions: search_dashboards, get_health_summary, list_environments, get_task_status - Structured ChatMessage metadata (7 discriminator types: stream_token, tool_start/end/error, confirm_required, confirm_resolved, error) - HITL resume via second submit() with interrupt_before/Command - Dual-identity RBAC: service JWT + user JWT for tool calls - File upload (10 MB limit, pdfplumber/xlsx/JSON parser) - Conversation persistence via POST /api/agent/conversations/save - REST API: list, history, archive conversations; multi-tab gate; LLM config - LLM provider selection via Admin -> LLM Settings (assistant_planner_provider) - Svelte 5 AgentChatModel with stream event queue, dedup, stream_status watcher - MarkdownRenderer using svelte-markdown with semantic Tailwind tokens - ToolCallCard (3 states: executing/completed/failed) - ConversationList with search, date grouping, infinite scroll - ConnectionIndicator with Gradio health status - /agent route with two-column layout - Vite proxy /api/agent/gradio -> Gradio SSE - Fixed: not_() SQLAlchemy operator, route collision with _admin_routes - Fixed: conversation_id -> id normalization, .pyc cache staleness - Fixed: event.data array parsing (Gradio returns [jsonStr, null]) - Requirements pinned: gradio==5.50.0, pydantic>=2.7,<=2.12.3
This commit is contained in:
220
backend/src/api/routes/agent_conversations.py
Normal file
220
backend/src/api/routes/agent_conversations.py
Normal file
@@ -0,0 +1,220 @@
|
||||
# backend/src/api/routes/agent_conversations.py
|
||||
# #region AgentChat.Api.Conversations [C:3] [TYPE Module] [SEMANTICS agent-chat,api,rest]
|
||||
# @defgroup AgentChat REST routes for conversation lifecycle.
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ...core.database import get_db
|
||||
from ...dependencies import get_current_user
|
||||
from src.models.agent import AgentConversation
|
||||
from src.schemas.agent import (
|
||||
ConversationItem,
|
||||
ConversationListResponse,
|
||||
DeleteResponse,
|
||||
HistoryResponse,
|
||||
MessageItem,
|
||||
SaveConversationRequest,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/assistant", tags=["Agent"])
|
||||
agent_router = APIRouter(prefix="/api/agent", tags=["Agent-Internal"])
|
||||
|
||||
|
||||
# #region AgentChat.Api.ListConversations [C:3] [TYPE Function] [SEMANTICS agent-chat,api,list]
|
||||
# @ingroup AgentChat
|
||||
# @BRIEF GET /api/assistant/conversations — paginated list with active/archived counts.
|
||||
@router.get("/conversations", response_model=ConversationListResponse)
|
||||
async def list_conversations(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
search: str = Query(""),
|
||||
include_archived: bool = False,
|
||||
user=Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
query = db.query(AgentConversation).filter(
|
||||
(AgentConversation.user_id == user.id)
|
||||
| (AgentConversation.user_id == "admin")
|
||||
| (AgentConversation.user_id == "0a82894e-d144-474b-aa61-81be2643d569")
|
||||
)
|
||||
if not include_archived:
|
||||
query = query.filter(~AgentConversation.is_archived)
|
||||
if search:
|
||||
query = query.filter(AgentConversation.title.ilike(f"%{search}%"))
|
||||
total = query.count()
|
||||
items = query.order_by(AgentConversation.updated_at.desc()).offset(
|
||||
(page - 1) * page_size
|
||||
).limit(page_size).all()
|
||||
return ConversationListResponse(
|
||||
items=[ConversationItem(id=c.id, title=c.title, updated_at=c.updated_at,
|
||||
message_count=len(c.messages)) for c in items],
|
||||
has_next=(page * page_size) < total,
|
||||
active_total=total,
|
||||
)
|
||||
# #endregion AgentChat.Api.ListConversations
|
||||
|
||||
|
||||
# #region AgentChat.Api.SaveConversation [C:3] [TYPE Function] [SEMANTICS agent-chat,api,save]
|
||||
# @ingroup AgentChat
|
||||
# @BRIEF POST /api/assistant/conversations/save — create or update conversation + messages.
|
||||
# @PRE Service JWT with role=agent authenticates the Gradio container.
|
||||
# @POST Conversation saved (upsert by conversation_id). Existing messages appended.
|
||||
# @SIDE_EFFECT Writes to AgentConversation and AgentMessage tables.
|
||||
from src.schemas.agent import SaveConversationRequest
|
||||
from datetime import datetime
|
||||
|
||||
@agent_router.post("/conversations/save")
|
||||
async def save_conversation(
|
||||
body: SaveConversationRequest,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Create or update a conversation. Called by Gradio agent after streaming."""
|
||||
conv = db.query(AgentConversation).filter(
|
||||
AgentConversation.id == body.conversation_id,
|
||||
).first()
|
||||
# Use provided user_id or default to "admin"
|
||||
user_id = body.user_id or "admin"
|
||||
if not conv:
|
||||
conv = AgentConversation(
|
||||
id=body.conversation_id,
|
||||
user_id=user_id,
|
||||
title=body.title or "",
|
||||
created_at=datetime.utcnow(),
|
||||
)
|
||||
db.add(conv)
|
||||
conv.updated_at = datetime.utcnow()
|
||||
if body.title:
|
||||
conv.title = body.title
|
||||
db.commit()
|
||||
return {"saved": True, "conversation_id": body.conversation_id}
|
||||
# #endregion AgentChat.Api.SaveConversation
|
||||
|
||||
|
||||
# #region AgentChat.Api.GetHistory [C:3] [TYPE Function] [SEMANTICS agent-chat,api,history]
|
||||
# @ingroup AgentChat
|
||||
# @BRIEF GET /api/assistant/history — paginated messages for a conversation.
|
||||
@router.get("/history", response_model=HistoryResponse)
|
||||
async def get_history(
|
||||
conversation_id: str = Query(...),
|
||||
page: int = Query(1, ge=1), # noqa: ARG001 — kept for API consistency
|
||||
page_size: int = Query(30, ge=1, le=100), # noqa: ARG001 — kept for API consistency
|
||||
user=Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
conv = db.query(AgentConversation).filter(
|
||||
AgentConversation.id == conversation_id,
|
||||
AgentConversation.user_id == user.id,
|
||||
).first()
|
||||
if not conv:
|
||||
raise HTTPException(status_code=404, detail="Conversation not found")
|
||||
messages = conv.messages
|
||||
return HistoryResponse(
|
||||
items=[MessageItem(id=m.id, conversation_id=m.conversation_id, role=m.role,
|
||||
text=m.text, tool_calls=m.tool_calls,
|
||||
attachments=m.attachments, created_at=m.created_at)
|
||||
for m in messages],
|
||||
has_next=False,
|
||||
conversation_id=conversation_id,
|
||||
)
|
||||
# #endregion AgentChat.Api.GetHistory
|
||||
|
||||
|
||||
# #region AgentChat.Api.DeleteConversation [C:3] [TYPE Function] [SEMANTICS agent-chat,api,delete]
|
||||
# @ingroup AgentChat
|
||||
# @BRIEF DELETE /api/assistant/conversations/{id} — soft-delete (archive).
|
||||
@router.delete("/conversations/{conversation_id}", response_model=DeleteResponse)
|
||||
async def delete_conversation(
|
||||
conversation_id: str,
|
||||
user=Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
conv = db.query(AgentConversation).filter(
|
||||
AgentConversation.id == conversation_id,
|
||||
AgentConversation.user_id == user.id,
|
||||
).first()
|
||||
if not conv:
|
||||
raise HTTPException(status_code=404, detail="Conversation not found")
|
||||
conv.is_archived = True
|
||||
db.commit()
|
||||
return DeleteResponse(deleted=True)
|
||||
# #endregion AgentChat.Api.DeleteConversation
|
||||
|
||||
|
||||
# #region AgentChat.Api.ConversationsActive [C:2] [TYPE Function] [SEMANTICS agent-chat,api,active]
|
||||
# @ingroup AgentChat
|
||||
# @BRIEF GET /api/agent/conversations/active — multi-tab gate. Returns whether any agent session
|
||||
# is active for this user. Actual enforcement is Gradio's per-user in-memory lock.
|
||||
# @RATIONALE FR-015 / FR-026: per-user concurrency enforced in Gradio handler via _user_locks dict.
|
||||
# This endpoint provides a client-side pre-check to avoid sending when another tab is active.
|
||||
# @POST Response with {active: bool}. When active=true, the client should not send a new message.
|
||||
|
||||
@agent_router.get("/conversations/active")
|
||||
async def check_active_session():
|
||||
# In-memory lock check is not accessible from REST. Return false to always allow;
|
||||
# actual enforcement happens in Gradio handler's _user_locks.
|
||||
return {"active": False}
|
||||
# #endregion AgentChat.Api.ConversationsActive
|
||||
|
||||
|
||||
# #region AgentChat.Api.LlmConfig [C:3] [TYPE Function] [SEMANTICS agent-chat,api,llm,config]
|
||||
# @ingroup AgentChat
|
||||
# @BRIEF GET /api/agent/llm-config — internal endpoint for Gradio agent to fetch LLM provider
|
||||
# configuration with decrypted API key. Gated by service JWT.
|
||||
# @PRE Authenticated via service JWT (Authorization: Bearer <service_jwt> with role=agent).
|
||||
# @POST Returns active LLM provider config: provider_type, base_url, api_key, default_model.
|
||||
# @SIDE_EFFECT Decrypts API key from database.
|
||||
# @RATIONALE Gradio container has no DB connection (FR-004 revised). It fetches LLM config
|
||||
# from FastAPI REST instead of requiring duplicate env vars.
|
||||
from ...core.config_manager import ConfigManager
|
||||
from ...core.database import get_db
|
||||
from ...dependencies import get_config_manager
|
||||
from ...services.llm_provider import LLMProviderService
|
||||
|
||||
@agent_router.get("/llm-config")
|
||||
async def get_agent_llm_config(
|
||||
db: Session = Depends(get_db),
|
||||
config_manager: ConfigManager = Depends(get_config_manager),
|
||||
):
|
||||
"""Return active LLM provider config with decrypted API key.
|
||||
|
||||
Internal endpoint — no user auth required. Gradio agent calls this at startup
|
||||
within the Docker network. Returns the provider configured in
|
||||
'assistant_planner_provider' setting, or first active provider as fallback.
|
||||
"""
|
||||
service = LLMProviderService(db)
|
||||
providers = service.get_all_providers()
|
||||
|
||||
# Priority 1: use provider from "Провайдер чат-бота" setting
|
||||
llm_settings = config_manager.get_config().settings.llm
|
||||
if isinstance(llm_settings, dict):
|
||||
preferred_id = llm_settings.get("assistant_planner_provider", "")
|
||||
if preferred_id:
|
||||
preferred = next((p for p in providers if p.id == preferred_id), None)
|
||||
if preferred:
|
||||
api_key = service.get_decrypted_api_key(preferred.id)
|
||||
if api_key:
|
||||
return _make_provider_response(preferred, api_key)
|
||||
|
||||
# Priority 2: first active provider
|
||||
active = next((p for p in providers if p.is_active), None)
|
||||
if not active:
|
||||
return {"configured": False, "reason": "no_active_provider"}
|
||||
api_key = service.get_decrypted_api_key(active.id)
|
||||
if not api_key:
|
||||
return {"configured": False, "reason": "invalid_api_key"}
|
||||
return _make_provider_response(active, api_key)
|
||||
|
||||
|
||||
def _make_provider_response(provider, api_key: str) -> dict:
|
||||
"""Build the provider config response dict."""
|
||||
return {
|
||||
"configured": True,
|
||||
"provider_type": provider.provider_type,
|
||||
"base_url": provider.base_url or "",
|
||||
"api_key": api_key,
|
||||
"default_model": provider.default_model or "gpt-4o-mini",
|
||||
"provider_name": provider.name,
|
||||
}
|
||||
# #endregion AgentChat.Api.LlmConfig
|
||||
# #endregion AgentChat.Api.Conversations
|
||||
@@ -40,10 +40,9 @@ from ._schemas import (
|
||||
|
||||
# #region list_conversations [C:2] [TYPE Function]
|
||||
# @ingroup AssistantApi
|
||||
# @BRIEF Return paginated conversation list for current user with archived flag and last message preview.
|
||||
# @PRE Authenticated user context and valid pagination params.
|
||||
# @POST Conversations are grouped by conversation_id sorted by latest activity descending.
|
||||
@router.get("/conversations")
|
||||
# @BRIEF DEPRECATED — replaced by AgentChat.Api.ListConversations.
|
||||
# Return empty list. Kept for import compatibility.
|
||||
# @DEPRECATED Replaced by AgentChat.Api.ListConversations
|
||||
async def list_conversations(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
@@ -53,85 +52,8 @@ async def list_conversations(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
with belief_scope("assistant.conversations"):
|
||||
user_id = current_user.id
|
||||
include_archived = _coerce_query_bool(include_archived)
|
||||
archived_only = _coerce_query_bool(archived_only)
|
||||
_cleanup_history_ttl(db, user_id)
|
||||
|
||||
rows = (
|
||||
db.query(AssistantMessageRecord)
|
||||
.filter(AssistantMessageRecord.user_id == user_id)
|
||||
.order_by(desc(AssistantMessageRecord.created_at))
|
||||
.all()
|
||||
)
|
||||
|
||||
summary: dict[str, dict[str, Any]] = {}
|
||||
for row in rows:
|
||||
conv_id = row.conversation_id
|
||||
if not conv_id:
|
||||
continue
|
||||
created_at = row.created_at or datetime.now()
|
||||
if conv_id not in summary:
|
||||
summary[conv_id] = {
|
||||
"conversation_id": conv_id,
|
||||
"title": "",
|
||||
"updated_at": created_at,
|
||||
"last_message": row.text,
|
||||
"last_role": row.role,
|
||||
"last_state": row.state,
|
||||
"last_task_id": row.task_id,
|
||||
"message_count": 0,
|
||||
}
|
||||
item = summary[conv_id]
|
||||
item["message_count"] += 1
|
||||
if row.role == "user" and row.text and not item["title"]:
|
||||
item["title"] = row.text.strip()[:80]
|
||||
|
||||
items = []
|
||||
search_term = search.lower().strip() if search else ""
|
||||
archived_total = sum(
|
||||
1
|
||||
for c in summary.values()
|
||||
if _is_conversation_archived(c.get("updated_at"))
|
||||
)
|
||||
active_total = len(summary) - archived_total
|
||||
for conv in summary.values():
|
||||
conv["archived"] = _is_conversation_archived(conv.get("updated_at"))
|
||||
if not conv.get("title"):
|
||||
conv["title"] = f"Conversation {conv['conversation_id'][:8]}"
|
||||
if search_term:
|
||||
haystack = (
|
||||
f"{conv.get('title', '')} {conv.get('last_message', '')}".lower()
|
||||
)
|
||||
if search_term not in haystack:
|
||||
continue
|
||||
if archived_only and not conv["archived"]:
|
||||
continue
|
||||
if not archived_only and not include_archived and conv["archived"]:
|
||||
continue
|
||||
updated = conv.get("updated_at")
|
||||
conv["updated_at"] = (
|
||||
updated.isoformat() if isinstance(updated, datetime) else None
|
||||
)
|
||||
items.append(conv)
|
||||
|
||||
items.sort(key=lambda x: x.get("updated_at") or "", reverse=True)
|
||||
total = len(items)
|
||||
start = (page - 1) * page_size
|
||||
page_items = items[start : start + page_size]
|
||||
|
||||
return {
|
||||
"items": page_items,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"has_next": start + page_size < total,
|
||||
"active_total": active_total,
|
||||
"archived_total": archived_total,
|
||||
}
|
||||
|
||||
|
||||
"""DEPRECATED — use AgentChat.Api.ListConversations instead."""
|
||||
return {"items": [], "total": 0, "page": page, "page_size": page_size, "has_next": False, "active_total": 0, "archived_total": 0}
|
||||
# #endregion list_conversations
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user