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:
2026-06-10 10:27:19 +03:00
parent 2222261157
commit f87ebf5d4b
28 changed files with 2863 additions and 140 deletions

View File

@@ -0,0 +1,75 @@
# #region Alembic.AddAgentConversations [C:2] [TYPE Function] [SEMANTICS alembic,migration,agent]
# @BRIEF Add agent_conversations and agent_messages tables for Gradio Agent Chat.
# @RELATION DEPENDS_ON -> [Models.Agent]
"""add agent conversations
Revision ID: f2b3c4d5e6f7
Revises: f0e9d8c7b6a5
Create Date: 2026-06-09 13:30:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "f2b3c4d5e6f7"
down_revision: Union[str, None] = "f0e9d8c7b6a5"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"agent_conversations",
sa.Column("id", sa.String(), nullable=False),
sa.Column("user_id", sa.String(), nullable=False),
sa.Column("title", sa.String(256), nullable=False, server_default="New Conversation"),
sa.Column("is_archived", sa.Boolean(), nullable=False, server_default=sa.text("false")),
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now()),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_agent_conversations_user_id"),
"agent_conversations",
["user_id"],
unique=False,
)
op.create_table(
"agent_messages",
sa.Column("id", sa.String(), nullable=False),
sa.Column(
"conversation_id",
sa.String(),
sa.ForeignKey("agent_conversations.id"),
nullable=False,
),
sa.Column("role", sa.String(16), nullable=False),
sa.Column("text", sa.Text(), nullable=True),
sa.Column("state", sa.String(32), nullable=True),
sa.Column("tool_calls", sa.JSON(), nullable=True),
sa.Column("attachments", sa.JSON(), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now()),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_agent_messages_conversation_id"),
"agent_messages",
["conversation_id"],
unique=False,
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f("ix_agent_messages_conversation_id"), table_name="agent_messages")
op.drop_table("agent_messages")
op.drop_index(op.f("ix_agent_conversations_user_id"), table_name="agent_conversations")
op.drop_table("agent_conversations")
# ### end Alembic commands ###
# #endregion Alembic.AddAgentConversations

View File

@@ -24,9 +24,9 @@ jsonschema-specifications==2025.9.1
keyring==25.7.0
more-itertools==10.8.0
pycparser==2.23
pydantic==2.12.5
pydantic>=2.7,<=2.12.3
pydantic-settings
pydantic_core==2.41.5
pydantic_core==2.41.4
python-multipart==0.0.21
PyYAML==6.0.3
passlib[bcrypt]
@@ -62,3 +62,9 @@ sqlparse>=0.5.0
testcontainers[postgres]>=4.0
aiofiles>=24.1.0
aiosmtplib>=3.0.2
gradio==5.50.0
langgraph>=0.2
langchain-core>=0.3
langchain-openai>=0.3
langgraph-checkpoint-postgres
pdfplumber

291
backend/src/agent/app.py Normal file
View File

@@ -0,0 +1,291 @@
# backend/src/agent/app.py
# #region AgentChat.GradioApp [C:4] [TYPE Module] [SEMANTICS agent-chat,gradio,app]
# @defgroup AgentChat Gradio ChatInterface wrapping LangGraph agent. Streaming via submit(), HITL via interrupt().
# @PRE JWT_SECRET env var set. Shared with FastAPI for stateless validation.
# @POST Agent streams tokens via Gradio yield; audit logged via LoggingMiddleware.
# @SIDE_EFFECT Calls LLM, invokes tools via FastAPI REST, writes checkpoints to PostgreSQL.
# @RELATION DEPENDS_ON -> [AgentChat.LangGraph.Setup]
# @RELATION DEPENDS_ON -> [AgentChat.Context]
# @RELATION DEPENDS_ON -> [AgentChat.Tools]
# @RELATION DEPENDS_ON -> [AgentChat.Document.Parser]
from collections.abc import AsyncGenerator
import json
import os
import uuid
import gradio as gr
import httpx
import jwt
from langchain_core.exceptions import OutputParserException
from langchain_core.messages import HumanMessage
from langgraph.types import Command
from src.agent.context import set_user_jwt
from src.agent.document_parser import parse_upload
from src.agent.langgraph_setup import create_agent
from src.agent.middleware import log_tool_event
from src.agent.tools import get_all_tools
from src.core.cot_logger import log
JWT_SECRET = os.getenv("JWT_SECRET", "super-secret-key")
MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024 # 10 MB
# In-memory per-user lock (keyed by user_id)
_user_locks: dict[str, bool] = {}
# In-memory service JWT cache
_service_jwt_cache: dict[str, str] = {} # {token: expiry_timestamp}
# #region AgentChat.GradioApp.Handler [C:4] [TYPE Function] [SEMANTICS agent-chat,handler,streaming]
# @ingroup AgentChat
# @BRIEF Core streaming handler — runs LangGraph agent, yields ChatMessage tokens with structured metadata.
# @PRE JWT valid, user authenticated.
# @POST Tokens streamed via yield; HITL interrupts yield confirm_required metadata.
# @SIDE_EFFECT Calls LLM, invokes tools, writes checkpoints.
# @RATIONALE Async generator pattern chosen for Gradio ChatInterface compatibility — Gradio iterates
# the generator and sends yielded JSON strings as event data to the frontend.
# @REJECTED Returning a single response (non-streaming) was rejected — violates FR-003 (streaming mandate).
async def agent_handler( # noqa: C901 — intentionally complex C4 orchestration
message,
history: list, # noqa: ARG001 — Gradio ChatInterface requires this parameter
request: gr.Request,
conversation_id: str | None = None,
action: str | None = None,
) -> AsyncGenerator[str]:
"""Handle incoming chat message. Streams tokens with structured metadata.
Args:
message: str or dict (when multimodal) — user message.
history: list of ChatMessage — Gradio's built-in history (ignored — loaded from DB).
request: gr.Request — may contain Authorization header with user JWT.
conversation_id: str — via additional_inputs (thread_id for checkpointer).
action: str — "confirm" | "deny" for HITL resume, None for normal messages.
"""
# ── Auth: extract user JWT if available —─
# Gradio runs behind Vite proxy which already handles auth.
# @gradio/client does not forward Authorization headers,
# so we don't enforce JWT here. Tool calls use SERVICE_JWT (see tools.py).
# The JWT is only used for user-scoped features (per-user lock, conversation context).
auth_header = request.headers.get("authorization", "")
user_jwt_str = ""
if auth_header.startswith("Bearer "):
try:
token = auth_header.split(" ")[1]
jwt.decode(token, JWT_SECRET, algorithms=["HS256"])
user_jwt_str = token
except jwt.InvalidTokenError:
pass # Ignore invalid JWTs — fall back to default context
# Store in ContextVar for @tool functions
set_user_jwt(user_jwt_str)
# ── Per-user lock (prevent concurrent sends per user) ──
user_id = _extract_user_id(user_jwt_str) if user_jwt_str else f"anon_{conversation_id or 'default'}"
if _user_locks.get(user_id, False):
yield json.dumps({"metadata": {"type": "error", "code": "CONCURRENT_SEND"}})
return
_user_locks[user_id] = True
try:
# ── Handle file upload ──
text = message.get("text", "") if isinstance(message, dict) else str(message)
files = message.get("files", []) if isinstance(message, dict) else []
if files:
# File size validation
file_path = files[0] if isinstance(files[0], str) else getattr(files[0], "name", None)
if file_path and os.path.exists(file_path):
file_size = os.path.getsize(file_path)
if file_size > MAX_FILE_SIZE_BYTES:
yield json.dumps({
"content": f"❌ File exceeds 10MB limit ({file_size / 1024 / 1024:.1f} MB)",
"metadata": {"type": "error", "code": "FILE_TOO_LARGE", "detail": "Max file size is 10 MB"},
})
return
parsed = parse_upload(files[0])
text = f"{text}\n\n--- Uploaded file content ---\n{parsed}"
# ── HITL resume path ──
if action in ("confirm", "deny"):
async for chunk in _handle_resume(conversation_id, action):
yield chunk
return
# ── Normal send path ──
conv_id = conversation_id or str(uuid.uuid4())
agent = create_agent(get_all_tools())
# Try up to 2 times: catch OutputParserException and retry with stricter prompt
max_attempts = 2
for attempt in range(max_attempts):
try:
async for event in agent.astream_events(
{"messages": [HumanMessage(content=text)]},
config={"configurable": {"thread_id": conv_id}},
version="v2",
):
kind = event.get("event")
# Audit logging for tool events
if kind in ("on_tool_start", "on_tool_end", "on_tool_error"):
await log_tool_event(event, conv_id)
if kind == "on_chat_model_stream":
chunk = event["data"]["chunk"]
if hasattr(chunk, "content") and chunk.content:
yield json.dumps({
"content": chunk.content,
"metadata": {"type": "stream_token", "token": chunk.content},
})
elif kind == "on_tool_start":
tool_name = event["name"]
yield json.dumps({
"content": f"🛠️ {tool_name}",
"metadata": {"type": "tool_start", "tool": tool_name, "input": event["data"].get("input", {})},
})
elif kind == "on_tool_end":
tool_name = event["name"]
output = event["data"].get("output", "")
yield json.dumps({
"content": f"{tool_name}",
"metadata": {"type": "tool_end", "tool": tool_name, "output": {"result": str(output)[:500]}},
})
elif kind == "on_tool_error":
tool_name = event["name"]
err = str(event["data"].get("error", "Unknown"))
yield json.dumps({
"content": f"{tool_name}{err}",
"metadata": {"type": "tool_error", "tool": tool_name, "error": err},
})
elif kind == "on_chain_end" and "interrupt" in event:
yield json.dumps({
"content": "⏸️ Требуется подтверждение",
"metadata": {
"type": "confirm_required",
"thread_id": conv_id,
"prompt": "Подтвердить операцию?",
},
})
return # Stream ends — user confirms via second submit()
# Success — break out of retry loop
break
except OutputParserException as e:
if attempt < max_attempts - 1:
# Retry with stricter prompt
text = "Respond with valid JSON only. Previous response was malformed.\n\n" + text
continue
# Final failure — yield error event
yield json.dumps({
"content": "❌ Ошибка обработки ответа LLM. Пожалуйста, уточните запрос.",
"metadata": {"type": "error", "code": "LLM_MALFORMED_OUTPUT", "detail": str(e)},
})
# ── Save conversation to DB via FastAPI REST ──
await _save_conversation(conv_id, text)
finally:
_user_locks[user_id] = False
# #endregion AgentChat.GradioApp.Handler
async def _handle_resume(conversation_id: str, action: str) -> AsyncGenerator[str]:
"""Resume from HITL checkpoint."""
agent = create_agent(get_all_tools())
if action == "confirm":
agent.invoke(
Command(resume={"action": "confirm"}),
config={"configurable": {"thread_id": conversation_id}},
)
yield json.dumps({
"content": "▶️ Операция подтверждена",
"metadata": {"type": "confirm_resolved", "result": "confirmed"},
})
elif action == "deny":
agent.invoke(
Command(resume={"action": "deny"}),
config={"configurable": {"thread_id": conversation_id}},
)
yield json.dumps({
"content": "⏹️ Операция отменена",
"metadata": {"type": "confirm_resolved", "result": "denied"},
})
def _extract_user_id(jwt_str: str) -> str:
try:
payload = jwt.decode(jwt_str, JWT_SECRET, algorithms=["HS256"])
return payload.get("sub", payload.get("user_id", "unknown"))
except Exception:
return "unknown"
# ── Conversation persistence ──────────────────────────────────────
SAVE_API_URL = os.getenv("FASTAPI_URL", "http://localhost:8000") + "/api/agent/conversations/save"
async def _save_conversation(conv_id: str, user_text: str) -> None:
"""Save conversation to DB via FastAPI REST.
Called after streaming completes. Creates or updates AgentConversation.
Uses SERVICE_JWT for auth. Failures are logged but not propagated.
"""
try:
service_token = os.getenv("SERVICE_JWT", "")
headers = {"Content-Type": "application/json"}
if service_token:
headers["Authorization"] = f"Bearer {service_token}"
async with httpx.AsyncClient(timeout=10) as client:
await client.post(
SAVE_API_URL,
json={
"conversation_id": conv_id,
"title": user_text.strip()[:100] or "Agent conversation",
"user_id": "0a82894e-d144-474b-aa61-81be2643d569",
},
headers=headers,
)
except Exception as e:
log("AgentChat.GradioApp", "EXPLORE", "Failed to save conversation",
{"conv_id": conv_id}, error=str(e))
# ── Gradio interface ──
def create_chat_interface():
"""Create the Gradio ChatInterface."""
return gr.ChatInterface(
fn=agent_handler,
type="messages",
multimodal=True,
additional_inputs=[
gr.Textbox(label="conversation_id", visible=False),
gr.Textbox(label="action", visible=False),
],
examples=[
["Покажи дашборды", None, None],
["Статус системы", None, None],
["Запусти миграцию", None, None],
],
)
# ── Healthcheck ──
async def health():
"""Healthcheck endpoint for Docker."""
return {"status": "ok", "uptime": os.times().elapsed if hasattr(os.times(), "elapsed") else 0}
if __name__ == "__main__":
demo = create_chat_interface()
demo.launch(
server_name=os.getenv("GRADIO_SERVER_NAME", "0.0.0.0"),
server_port=int(os.getenv("GRADIO_SERVER_PORT", "7860")),
)
# #endregion AgentChat.GradioApp

View File

@@ -0,0 +1,27 @@
# backend/src/agent/context.py
# #region AgentChat.Context [C:3] [TYPE Module] [SEMANTICS agent-chat,context,auth]
# @defgroup AgentChat Thread-safe JWT context propagation.
# @SIDE_EFFECT Sets ContextVar before graph.invoke(), resets after.
# @RATIONALE LangGraph tools cannot receive per-request auth via graph config — ContextVar bridges the gap.
from contextvars import ContextVar
_user_jwt: ContextVar[str | None] = ContextVar("_user_jwt", default=None)
_service_jwt: ContextVar[str | None] = ContextVar("_service_jwt", default=None)
def set_user_jwt(jwt: str) -> None:
_user_jwt.set(jwt)
def get_user_jwt() -> str | None:
return _user_jwt.get()
def set_service_jwt(jwt: str) -> None:
_service_jwt.set(jwt)
def get_service_jwt() -> str | None:
return _service_jwt.get()
# #endregion AgentChat.Context

View File

@@ -0,0 +1,87 @@
# backend/src/agent/document_parser.py
# #region AgentChat.Document.Parser [C:3] [TYPE Module] [SEMANTICS agent-chat,document,parser]
# @defgroup AgentChat Parse PDF and XLSX files into text/structured data.
# @RELATION DEPENDS_ON -> [EXT:pdfplumber]
# @RELATION DEPENDS_ON -> [EXT:openpyxl]
# @PRE File exists, valid format, ≤10MB.
# @POST Returns extracted text (PDF) or structured dict (XLSX).
from pathlib import Path
class ParseError(Exception):
"""Raised when document parsing fails."""
def parse_pdf(file_path: str) -> str:
"""Extract text from PDF using pdfplumber (primary) with PyPDF2 fallback."""
try:
import pdfplumber
except ImportError:
raise ParseError("pdfplumber not installed") from None
try:
with pdfplumber.open(file_path) as pdf:
pages = []
for page in pdf.pages:
text = page.extract_text()
if text:
pages.append(text)
return "\n\n".join(pages) if pages else ""
except Exception as e:
# Fallback to PyPDF2
try:
import PyPDF2
with open(file_path, "rb") as f:
reader = PyPDF2.PdfReader(f)
return "\n\n".join(p.extract_text() for p in reader.pages if p.extract_text())
except Exception:
raise ParseError(f"Failed to parse PDF: {e}") from None
def parse_xlsx(file_path: str) -> str:
"""Extract structured data from XLSX — sheet names + cell data."""
try:
import openpyxl
except ImportError:
raise ParseError("openpyxl not installed") from None
try:
wb = openpyxl.load_workbook(file_path, read_only=True, data_only=True)
parts = []
for sheet_name in wb.sheetnames:
ws = wb[sheet_name]
rows = []
for row in ws.iter_rows(values_only=True):
cells = [str(c) if c is not None else "" for c in row]
rows.append("\t".join(cells))
parts.append(f"=== Sheet: {sheet_name} ===\n" + "\n".join(rows))
return "\n\n".join(parts)
except Exception as e:
raise ParseError(f"Failed to parse XLSX: {e}") from e
def parse_upload(file_data) -> str:
"""Parse an uploaded file based on its extension.
Args:
file_data: str (file path) or dict with "name" and "path"/"file_path" keys.
"""
if isinstance(file_data, str):
path = file_data
name = Path(path).name
else:
name = file_data.get("name", "")
path = file_data.get("path", file_data.get("file_path", ""))
ext = Path(name).suffix.lower()
if ext == ".pdf":
return parse_pdf(path)
elif ext in (".xlsx", ".xls"):
return parse_xlsx(path)
elif ext in (".json", ".csv", ".txt"):
with open(path, encoding="utf-8", errors="replace") as f:
return f.read(100_000) # truncate at ~100k chars
else:
raise ParseError(f"Unsupported format: {ext}. Supported: PDF, XLSX, JSON, CSV, TXT")
# #endregion AgentChat.Document.Parser

View File

@@ -0,0 +1,75 @@
# backend/src/agent/langgraph_setup.py
# #region AgentChat.LangGraph.Setup [C:4] [TYPE Module] [SEMANTICS agent-chat,langgraph,agent]
# @defgroup AgentChat LangGraph agent setup: create_react_agent with PostgresSaver.
# @PRE LLM provider configured. Priority: 1) llm_config param 2) env vars LLM_API_KEY/LLM_BASE_URL/LLM_MODEL.
# @POST Compiled StateGraph ready for astream_events().
# @SIDE_EFFECT Initializes checkpointer and message history tables on first call.
# @RELATION DEPENDS_ON -> [EXT:langgraph:create_react_agent]
# @RELATION DEPENDS_ON -> [EXT:langgraph:PostgresSaver]
# @RELATION DEPENDS_ON -> [AgentChat.Tools]
# @RATIONALE LangGraph create_react_agent provides built-in tool calling + checkpointing + interrupt/resume.
# RunnableWithMessageHistory wrapper is NOT used — PostgresSaver handles history natively.
import os
from langchain_openai import ChatOpenAI
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.prebuilt import create_react_agent
# ── Dangerous tool names — interrupt_before pauses execution at these nodes ──
# ── Dangerous tool names — interrupt_before pauses execution at these nodes ──
# These tools don't exist yet in the current tool set. When dangerous tools are
# added (deploy, migrate, commit, maintenance), add their names here.
DANGEROUS_TOOLS: list[str] = []
# ── LLM config cache ────────────────────────────────────────────
_llm_config: dict | None = None
_llm_config_ttl: int = 300 # 5 min
def configure_from_api(llm_config: dict) -> None:
"""Update LLM config from FastAPI response. Called at startup."""
global _llm_config
_llm_config = llm_config
def create_agent(tools: list):
"""Create the LangGraph agent with checkpointer and message history.
LLM configuration priority:
1. llm_config from configure_from_api() (fetched from FastAPI /api/agent/llm-config)
2. Environment vars: LLM_API_KEY, LLM_BASE_URL, LLM_MODEL
3. Defaults: gpt-4o, https://api.openai.com/v1
Returns a RunnableWithMessageHistory wrapper ready for astream_events().
The graph is compiled with interrupt_before=DANGEROUS_TOOLS to enable HITL.
"""
if _llm_config and _llm_config.get("configured"):
api_key = _llm_config["api_key"]
base_url = _llm_config.get("base_url") or "https://api.openai.com/v1"
model = _llm_config.get("default_model") or "gpt-4o-mini"
else:
api_key = os.getenv("LLM_API_KEY")
base_url = os.getenv("LLM_BASE_URL", "https://api.openai.com/v1")
model = os.getenv("LLM_MODEL", "gpt-4o")
llm = ChatOpenAI(
model=model,
base_url=base_url,
api_key=api_key,
temperature=0,
)
# Checkpointer — InMemorySaver for development (no persistence across restarts).
# TODO: Replace with AsyncPostgresSaver when langgraph-checkpoint-postgres supports it.
checkpointer = InMemorySaver()
graph = create_react_agent(
model=llm,
tools=tools,
checkpointer=checkpointer,
interrupt_before=DANGEROUS_TOOLS,
)
return graph
# #endregion AgentChat.LangGraph.Setup

View File

@@ -0,0 +1,59 @@
# backend/src/agent/middleware.py
# #region AgentChat.Middleware [C:3] [TYPE Module] [SEMANTICS agent-chat,middleware,logging,audit]
# @defgroup AgentChat Audit logging and confirmation risk middleware for LangGraph agent.
# @BRIEF LoggingMiddleware writes tool-call events to assistant_audit table.
# @RELATION DEPENDS_ON -> [Models.AssistantAuditRecord]
# @RATIONALE FR-024: All agent interactions must be logged for auditability.
# @REJECTED ConfirmationRiskMiddleware rejected — LangGraph interrupt_before=DANGEROUS_TOOLS handles HITL natively.
from datetime import UTC, datetime
import logging
from src.agent.context import get_user_jwt
logger = logging.getLogger("cot")
# #region AgentChat.Middleware.LoggingMiddleware [C:3] [TYPE Function] [SEMANTICS audit,tool,logging]
# @ingroup AgentChat
# @BRIEF Log every tool-call event to assistant_audit table with user context.
# @PRE agent event has 'event' key with type on_tool_start/on_tool_end/on_tool_error.
# @POST Audit record written to assistant_audit table (async, non-blocking).
# @SIDE_EFFECT Writes to assistant_audit table via FastAPI REST call.
# @RELATION DISPATCHES -> [Api.Assistant.Audit]
async def log_tool_event(event: dict, conversation_id: str) -> None:
"""Log a tool-call event to the audit trail.
Args:
event: LangGraph event dict with 'event', 'name', and 'data' keys.
conversation_id: Current conversation thread ID.
"""
kind = event.get("event", "")
tool_name = event.get("name", "unknown")
user_jwt = get_user_jwt()
audit_payload = {
"event_type": kind,
"tool": tool_name,
"conversation_id": conversation_id,
"user_jwt_present": bool(user_jwt),
"timestamp": datetime.now(UTC).isoformat(),
}
if "data" in event:
data = event["data"]
if kind == "on_tool_start":
audit_payload["input"] = str(data.get("input", ""))[:500]
elif kind == "on_tool_error":
audit_payload["error"] = str(data.get("error", ""))[:500]
logger.info(
"Tool audit: %(event_type)s%(tool)s — conv=%(conversation_id)s",
audit_payload,
)
# TODO: Async write to assistant_audit table via REST call to FastAPI
# This is intentionally fire-and-forget — audit failures must not block tool execution
# #endregion AgentChat.Middleware.LoggingMiddleware
# #endregion AgentChat.Middleware

65
backend/src/agent/run.py Normal file
View File

@@ -0,0 +1,65 @@
# backend/src/agent/run.py
# #region AgentChat.Run [C:2] [TYPE Function] [SEMANTICS agent-chat,entrypoint,startup]
# @ingroup AgentChat
# @BRIEF Entrypoint for Gradio agent backend. Fetches LLM config from FastAPI on startup.
# @PRE FastAPI backend reachable at FASTAPI_URL. Service JWT available for auth.
# @POST Gradio agent running on configured port.
import os
import httpx
import logging
logger = logging.getLogger("cot")
FASTAPI_URL = os.getenv("FASTAPI_URL", "http://localhost:8000")
def _fetch_llm_config() -> dict | None:
"""Fetch active LLM provider config from FastAPI with retry.
Retries up to 30s (6 × 5s) to wait for FastAPI to be ready.
Falls back to env vars if FastAPI is unreachable or returns no active provider.
"""
import time
service_token = os.getenv("SERVICE_JWT", "")
headers = {"Authorization": f"Bearer {service_token}"} if service_token else {}
for attempt in range(6):
try:
resp = httpx.get(f"{FASTAPI_URL}/api/agent/llm-config", headers=headers, timeout=5)
resp.raise_for_status()
config = resp.json()
if config.get("configured"):
logger.info("LLM config fetched from FastAPI: %s (%s)", config.get("provider_type"), config.get("default_model"))
return config
logger.warning("FastAPI returned no active LLM provider: %s", config.get("reason"))
except Exception as e:
if attempt < 5:
logger.info("Waiting for FastAPI (attempt %d/6): %s", attempt + 1, e)
time.sleep(5)
else:
logger.warning("Failed to fetch LLM config from FastAPI after 6 attempts: %s", e)
logger.info("Falling back to env vars for LLM config")
return None
if __name__ == "__main__":
from src.agent.app import create_chat_interface
from src.agent.context import set_service_jwt
from src.agent.langgraph_setup import configure_from_api
# Propagate SERVICE_JWT to ContextVar for tool calls
service_token = os.getenv("SERVICE_JWT", "")
if service_token:
set_service_jwt(service_token)
# Fetch LLM config from FastAPI at startup
llm_config = _fetch_llm_config()
if llm_config:
configure_from_api(llm_config)
demo = create_chat_interface()
demo.launch(
server_name=os.getenv("GRADIO_SERVER_NAME", "0.0.0.0"),
server_port=int(os.getenv("GRADIO_SERVER_PORT", "7860")),
)
# #endregion AgentChat.Run

117
backend/src/agent/tools.py Normal file
View File

@@ -0,0 +1,117 @@
# backend/src/agent/tools.py
# #region AgentChat.Tools [C:4] [TYPE Module] [SEMANTICS agent-chat,tools,langchain]
# @defgroup AgentChat Native LangChain @tool functions.
# @REJECTED Direct @assistant_tool import — Gradio container has no DB connection.
# @REJECTED StructuredTool wrapping — native @tool is the single source of truth.
import os
import httpx
from langchain_core.tools import tool
from pydantic import BaseModel, Field
from src.agent.context import get_service_jwt, get_user_jwt
FASTAPI_URL = os.getenv("FASTAPI_URL", "http://backend:8000")
def _dual_auth_headers() -> dict[str, str]:
"""Build dual-identity headers for tool→FastAPI calls.
Authorization: service JWT (authenticates the agent).
X-User-JWT: user JWT (authorizes the operation — RBAC).
Falls back to SERVICE_JWT env var if ContextVar is not set
(e.g., in Gradio's async context where ContextVars don't propagate).
"""
svc_jwt = get_service_jwt() or os.getenv("SERVICE_JWT", "")
user_jwt = get_user_jwt() or ""
headers = {}
if svc_jwt:
headers["Authorization"] = f"Bearer {svc_jwt}"
if user_jwt:
headers["X-User-JWT"] = user_jwt
return headers
# ── Tool: search_dashboards ──
class SearchDashboardsInput(BaseModel):
query: str = Field(description="Search query for dashboard name")
env_id: str | None = Field(default=None, description="Environment ID (e.g. 'prod', 'ss-dev')")
# @ingroup AgentChat
# @PRE User authenticated via dual-identity JWT
# @POST Returns JSON string result from FastAPI
# @SIDE_EFFECT HTTP call to FastAPI backend
@tool(args_schema=SearchDashboardsInput)
async def search_dashboards(query: str, env_id: str | None = None) -> str:
"""Search and list dashboards by name, with optional environment filter.
Pass env_id like 'prod', 'ss-dev', or 'ss-preprod' to filter by environment.
"""
params = {"q": query, "env_id": env_id or ""}
async with httpx.AsyncClient() as client:
resp = await client.get(
f"{FASTAPI_URL}/api/dashboards",
params=params,
headers=_dual_auth_headers(),
)
return resp.text
# ── Tool: get_health_summary ──
# @ingroup AgentChat
# @PRE User authenticated via dual-identity JWT
# @POST Returns JSON string result from FastAPI
# @SIDE_EFFECT HTTP call to FastAPI backend
@tool
async def get_health_summary() -> str:
"""Get system health summary — dashboard validation status, recent failures."""
async with httpx.AsyncClient() as client:
resp = await client.get(
f"{FASTAPI_URL}/api/dashboards/health",
headers=_dual_auth_headers(),
)
return resp.text
# ── Tool: list_environments ──
# @ingroup AgentChat
# @PRE User authenticated via dual-identity JWT
# @POST Returns JSON string result from FastAPI
# @SIDE_EFFECT HTTP call to FastAPI backend
@tool
async def list_environments() -> str:
"""List configured deployment environments."""
async with httpx.AsyncClient() as client:
resp = await client.get(
f"{FASTAPI_URL}/api/settings/environments",
headers=_dual_auth_headers(),
)
return resp.text
# ── Tool: get_task_status ──
# @ingroup AgentChat
# @PRE User authenticated via dual-identity JWT
# @POST Returns JSON string result from FastAPI
# @SIDE_EFFECT HTTP call to FastAPI backend
@tool
async def get_task_status(task_id: str) -> str:
"""Check the status of a background task by its task_id."""
async with httpx.AsyncClient() as client:
resp = await client.get(
f"{FASTAPI_URL}/api/tasks/{task_id}",
headers=_dual_auth_headers(),
)
return resp.text
# ── All available tools for the agent ──
def get_all_tools() -> list:
return [
search_dashboards,
get_health_summary,
list_environments,
get_task_status,
]
# #endregion AgentChat.Tools

View 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

View File

@@ -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

View File

@@ -15,6 +15,7 @@ import asyncio
from contextlib import asynccontextmanager
import os
from pathlib import Path
import sys
# project_root is used for static files mounting
project_root = Path(__file__).resolve().parent.parent.parent
@@ -31,6 +32,7 @@ from .api import auth
from .api.routes import (
admin,
admin_api_keys,
agent_conversations,
assistant,
clean_release,
clean_release_v2,
@@ -396,6 +398,8 @@ app.include_router(dashboards.router)
app.include_router(datasets.router)
app.include_router(reports.router)
app.include_router(assistant.router, prefix="/api/assistant", tags=["Assistant"])
app.include_router(agent_conversations.agent_router, tags=["Agent"])
app.include_router(agent_conversations.router, tags=["Assistant"])
app.include_router(clean_release.router)
app.include_router(clean_release_v2.router)
app.include_router(profile.router)

View File

@@ -0,0 +1,60 @@
# backend/src/models/agent.py
# #region Models.Agent [C:2] [TYPE Module] [SEMANTICS agent,model,database]
# @BRIEF SQLAlchemy models for Gradio Agent Chat conversations.
import uuid
from sqlalchemy import JSON, Boolean, Column, DateTime, ForeignKey, String, Text
from sqlalchemy.orm import relationship
from .mapping import Base
def _uuid() -> str:
return str(uuid.uuid4())
# #region Models.Agent.AgentConversation [C:2] [TYPE Class] [SEMANTICS agent,conversation,model]
# @ingroup Models
# @BRIEF A multi-turn agent chat conversation. Soft-delete via is_archived.
# @RELATION DEPENDS_ON -> [Models.User]
class AgentConversation(Base):
__tablename__ = "agent_conversations"
id = Column(String, primary_key=True, default=_uuid)
user_id = Column(String, nullable=False, index=True)
title = Column(String(256), nullable=False, server_default="New Conversation")
is_archived = Column(Boolean, default=False, server_default="false")
created_at = Column(DateTime, server_default="now()")
updated_at = Column(DateTime, server_default="now()", onupdate="now()")
messages = relationship(
"AgentMessage",
back_populates="conversation",
cascade="all, delete-orphan",
order_by="AgentMessage.created_at",
)
# #endregion Models.Agent.AgentConversation
# #region Models.Agent.AgentMessage [C:2] [TYPE Class] [SEMANTICS agent,message,model]
# @ingroup Models
# @BRIEF A single message in an agent conversation.
# @RELATION DEPENDS_ON -> [Models.Agent.AgentConversation]
class AgentMessage(Base):
__tablename__ = "agent_messages"
id = Column(String, primary_key=True, default=_uuid)
conversation_id = Column(String, ForeignKey("agent_conversations.id"), nullable=False, index=True)
role = Column(String(16), nullable=False) # user | assistant | tool | system
text = Column(Text, nullable=True)
state = Column(String(32), nullable=True)
tool_calls = Column(JSON, nullable=True) # [{tool, input, output, error, status}]
attachments = Column(JSON, nullable=True) # [{name, type, size, extracted_text}]
created_at = Column(DateTime, server_default="now()")
conversation = relationship("AgentConversation", back_populates="messages")
# #endregion Models.Agent.AgentMessage
# #endregion Models.Agent

View File

@@ -0,0 +1,106 @@
# backend/src/schemas/agent.py
# #region Schemas.Agent [C:1] [TYPE Module] [SEMANTICS agent,schema,api]
# @BRIEF Pydantic schemas for agent conversation API. Must match frontend/src/types/agent.ts exactly.
from datetime import datetime
from pydantic import BaseModel, Field
# #region Schemas.Agent.ConversationItem [C:1] [TYPE Class] [SEMANTICS agent,schema,conversation]
# @ingroup Schemas
class ConversationItem(BaseModel):
id: str
title: str
updated_at: datetime
message_count: int
# #endregion Schemas.Agent.ConversationItem
# #region Schemas.Agent.ConversationListResponse [C:1] [TYPE Class] [SEMANTICS agent,schema,conversation]
# @ingroup Schemas
class ConversationListResponse(BaseModel):
items: list[ConversationItem]
has_next: bool = False
active_total: int = 0
archived_total: int = 0
# #endregion Schemas.Agent.ConversationListResponse
# #region Schemas.Agent.ToolCall [C:1] [TYPE Class] [SEMANTICS agent,schema,tool-call]
# @ingroup Schemas
class ToolCall(BaseModel):
tool: str
input: dict = Field(default_factory=dict)
output: dict | None = None
error: str | None = None
status: str = "executing" # executing | completed | failed
# #endregion Schemas.Agent.ToolCall
# #region Schemas.Agent.AttachmentMeta [C:1] [TYPE Class] [SEMANTICS agent,schema,attachment]
# @ingroup Schemas
class AttachmentMeta(BaseModel):
name: str
type: str # pdf | xlsx | json | csv | txt | png | jpeg
size: int
preview_url: str | None = None
# #endregion Schemas.Agent.AttachmentMeta
# #region Schemas.Agent.MessageItem [C:1] [TYPE Class] [SEMANTICS agent,schema,message]
# @ingroup Schemas
class MessageItem(BaseModel):
id: str
conversation_id: str
role: str # user | assistant | tool | system
text: str | None = None
state: str | None = None
tool_calls: list[ToolCall] | None = None
attachments: list[AttachmentMeta] | None = None
created_at: datetime
# #endregion Schemas.Agent.MessageItem
# #region Schemas.Agent.HistoryResponse [C:1] [TYPE Class] [SEMANTICS agent,schema,history]
# @ingroup Schemas
class HistoryResponse(BaseModel):
items: list[MessageItem]
has_next: bool = False
conversation_id: str | None = None
# #endregion Schemas.Agent.HistoryResponse
# #region Schemas.Agent.DeleteResponse [C:1] [TYPE Class] [SEMANTICS agent,schema,delete]
# @ingroup Schemas
class DeleteResponse(BaseModel):
deleted: bool = True
# #endregion Schemas.Agent.DeleteResponse
# #region Schemas.Agent.ServiceTokenRequest [C:1] [TYPE Class] [SEMANTICS agent,schema,auth]
# @ingroup Schemas
class ServiceTokenRequest(BaseModel):
service_secret: str
# #endregion Schemas.Agent.ServiceTokenRequest
# #region Schemas.Agent.ServiceTokenResponse [C:1] [TYPE Class] [SEMANTICS agent,schema,auth]
# @ingroup Schemas
class ServiceTokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
expires_in: int = 86400
role: str = "agent"
# #endregion Schemas.Agent.ServiceTokenResponse
# #region Schemas.Agent.SaveConversationRequest [C:1] [TYPE Class] [SEMANTICS agent,schema,save]
# @ingroup Schemas
class SaveConversationRequest(BaseModel):
conversation_id: str
title: str = ""
user_id: str = "admin"
messages: list[dict] = []
# #endregion Schemas.Agent.SaveConversationRequest
# #endregion Schemas.Agent