From 414a05c9a0a6d60665ebce85c338abea32652aab Mon Sep 17 00:00:00 2001 From: borhanst Date: Fri, 17 Jul 2026 19:26:15 +0600 Subject: [PATCH 01/42] feat: Add AI Dashboard and related functionality - Implemented AI Dashboard routes for monitoring agent performance, costs, and tool calls. - Created dependency injection for AI agents with AdminDeps. - Developed model-bound agents with auto CRUD tools via inheritance. - Introduced AI Plugin for integrating AI capabilities into the admin panel. - Established a tool system for registration and management of AI tools. - Added usage tracking models and functionality for logging AI interactions. - Designed HTML template for the AI Dashboard. - Updated project dependencies to include pydantic-ai. - Added unit tests for AI agent integration and related components. --- fastapi_admin_kit/admin/core.py | 73 ++++++ fastapi_admin_kit/ai/__init__.py | 16 ++ fastapi_admin_kit/ai/agent.py | 82 ++++++ fastapi_admin_kit/ai/backends/__init__.py | 1 + .../ai/backends/pydantic_ai_backend.py | 150 +++++++++++ fastapi_admin_kit/ai/builtin_tools.py | 108 ++++++++ fastapi_admin_kit/ai/config.py | 36 +++ fastapi_admin_kit/ai/conversation.py | 227 ++++++++++++++++ fastapi_admin_kit/ai/dashboard.py | 245 ++++++++++++++++++ fastapi_admin_kit/ai/deps.py | 40 +++ fastapi_admin_kit/ai/model_agent.py | 129 +++++++++ fastapi_admin_kit/ai/plugin.py | 47 ++++ fastapi_admin_kit/ai/tools.py | 97 +++++++ fastapi_admin_kit/ai/usage.py | 179 +++++++++++++ .../templates/pages/ai/dashboard.html | 153 +++++++++++ pyproject.toml | 3 + tests/test_ai_agent.py | 193 ++++++++++++++ 17 files changed, 1779 insertions(+) create mode 100644 fastapi_admin_kit/ai/__init__.py create mode 100644 fastapi_admin_kit/ai/agent.py create mode 100644 fastapi_admin_kit/ai/backends/__init__.py create mode 100644 fastapi_admin_kit/ai/backends/pydantic_ai_backend.py create mode 100644 fastapi_admin_kit/ai/builtin_tools.py create mode 100644 fastapi_admin_kit/ai/config.py create mode 100644 fastapi_admin_kit/ai/conversation.py create mode 100644 fastapi_admin_kit/ai/dashboard.py create mode 100644 fastapi_admin_kit/ai/deps.py create mode 100644 fastapi_admin_kit/ai/model_agent.py create mode 100644 fastapi_admin_kit/ai/plugin.py create mode 100644 fastapi_admin_kit/ai/tools.py create mode 100644 fastapi_admin_kit/ai/usage.py create mode 100644 fastapi_admin_kit/templates/pages/ai/dashboard.html create mode 100644 tests/test_ai_agent.py diff --git a/fastapi_admin_kit/admin/core.py b/fastapi_admin_kit/admin/core.py index a71cf7e..a06ad5c 100644 --- a/fastapi_admin_kit/admin/core.py +++ b/fastapi_admin_kit/admin/core.py @@ -178,6 +178,9 @@ def __init__( mobile_sidebar: str = "overlay", dashboard_permission: str | None = None, settings_permission: str | None = None, + # AI + ai: Any = None, + ai_enabled: bool = False, ): self.registry = AdminRegistry() self._app: FastAPI | None = app @@ -295,6 +298,10 @@ def __init__( # Built sidebar (populated during setup) self._nav_groups_built: list[Any] = [] + # AI + self._ai_config = ai + self._ai_enabled = ai_enabled + # Internal state (populated during setup) self._session_backend: Any = None self._jinja_env: Environment | None = None @@ -551,6 +558,10 @@ async def setup(self, app: FastAPI | None = None) -> None: if self.config.nav.require_tags: self._validate_tags() + # 9.1 Add AI nav group before building sidebar + if self._ai_enabled: + self._add_ai_nav_group() + # 10. Build sidebar structure (once at startup) self._nav_groups_built = self._build_sidebar() self.template._nav_groups_built = self._nav_groups_built @@ -560,6 +571,10 @@ async def setup(self, app: FastAPI | None = None) -> None: # 11. Build and mount routers self._build_router(app) + # 12. Setup AI routes if enabled + if self._ai_enabled: + self._setup_ai_routes(app) + # ------------------------------------------------------------------ # Register # ------------------------------------------------------------------ @@ -836,6 +851,11 @@ def _get_flash_messages(request) -> list[dict[str, str]]: "bolt": "bolt", "cog-": "settings", "cog-6-tooth": "settings", + "smart_toy": "smart_toy", + "monitoring": "monitoring", + "build": "build", + "sparkles": "auto_awesome", + "robot": "smart_toy", } def _icon(name: str, size: str = "", **kwargs) -> str: @@ -982,6 +1002,59 @@ def _register_builtin_models(self) -> None: if model.__tablename__ not in self.registry._models: self.registry.register(model, admin_class) + # ------------------------------------------------------------------ + # AI Setup + # ------------------------------------------------------------------ + + def _add_ai_nav_group(self) -> None: + """Add the AI nav group to nav_groups before sidebar build.""" + from fastapi_admin_kit.nav import NavGroupConfig, NavItemConfig + + ai_nav = NavGroupConfig( + tag="ai", + label="AI", + icon="smart_toy", + order=900, + collapsed_by_default=False, + extra_items=[ + NavItemConfig( + label="Dashboard", + url="/admin/ai/dashboard", + icon="monitoring", + order=1, + ), + NavItemConfig( + label="Logs", + url="/admin/ai/logs", + icon="description", + order=2, + ), + NavItemConfig( + label="Tools", + url="/admin/ai/tools", + icon="build", + order=3, + ), + NavItemConfig( + label="Agents", + url="/admin/ai/agents", + icon="smart_toy", + order=4, + ), + ], + ) + self.config.nav.nav_groups.append(ai_nav) + + def _setup_ai_routes(self, app: FastAPI) -> None: + """Initialize AI agents and mount AI routes.""" + from fastapi_admin_kit.ai.plugin import AIPlugin + + plugin = AIPlugin(agents=self._ai_config.agents if self._ai_config else []) + plugin.on_startup(self) + + if self._ai_config and self._ai_config.dashboard_enabled: + app.include_router(plugin.get_routes(), prefix=self.router.admin_path) + # ------------------------------------------------------------------ # Tags validation # ------------------------------------------------------------------ diff --git a/fastapi_admin_kit/ai/__init__.py b/fastapi_admin_kit/ai/__init__.py new file mode 100644 index 0000000..367b9b3 --- /dev/null +++ b/fastapi_admin_kit/ai/__init__.py @@ -0,0 +1,16 @@ +"""AI Agent Integration — Pydantic AI (Phase 1).""" + +from fastapi_admin_kit.ai.agent import AIAgent, ChatResult +from fastapi_admin_kit.ai.config import AIAgentConfig, AIConfig +from fastapi_admin_kit.ai.tools import Tool, ToolRegistry, tool, tool_registry + +__all__ = [ + "AIAgent", + "AIConfig", + "AIAgentConfig", + "ChatResult", + "Tool", + "ToolRegistry", + "tool", + "tool_registry", +] diff --git a/fastapi_admin_kit/ai/agent.py b/fastapi_admin_kit/ai/agent.py new file mode 100644 index 0000000..893a68d --- /dev/null +++ b/fastapi_admin_kit/ai/agent.py @@ -0,0 +1,82 @@ +"""AIAgent protocol and ChatResult.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from fastapi_admin_kit.ai.deps import AdminDeps + + +@dataclass +class UsageInfo: + """Token usage and cost information.""" + + request_tokens: int = 0 + response_tokens: int = 0 + total_tokens: int = 0 + cost: float = 0.0 + + @classmethod + def from_pydantic_ai(cls, usage: Any, cost: float) -> UsageInfo: + return cls( + request_tokens=getattr(usage, "request_tokens", None) or 0, + response_tokens=getattr(usage, "response_tokens", None) or 0, + total_tokens=getattr(usage, "total_tokens", None) or 0, + cost=cost, + ) + + +@dataclass +class ToolCallRecord: + """Record of a single tool call within a run.""" + + name: str + args: dict + result: Any = None + is_error: bool = False + + +@dataclass +class ChatResult: + """Result returned from an agent chat call.""" + + output: Any = None + usage: UsageInfo = field(default_factory=UsageInfo) + new_messages: list = field(default_factory=list) + tool_calls: list[ToolCallRecord] = field(default_factory=list) + conversation_id: str | None = None + + +class AIAgent(ABC): + """Provider-agnostic surface used by the dashboard and chat routes. + + Phase 1 ships exactly one implementation: PydanticAIAgent. + """ + + @abstractmethod + async def chat( + self, + message: str, + deps: AdminDeps, + message_history: list | None = None, + ) -> ChatResult: ... + + @abstractmethod + def chat_stream( + self, + message: str, + deps: AdminDeps, + message_history: list | None = None, + ): ... + + @abstractmethod + async def execute_tool(self, tool_name: str, params: dict, deps: AdminDeps) -> Any: ... + + @abstractmethod + def get_tools(self) -> list[dict]: ... + + @abstractmethod + async def get_usage_stats(self, period: str = "day") -> dict: ... diff --git a/fastapi_admin_kit/ai/backends/__init__.py b/fastapi_admin_kit/ai/backends/__init__.py new file mode 100644 index 0000000..fe7b116 --- /dev/null +++ b/fastapi_admin_kit/ai/backends/__init__.py @@ -0,0 +1 @@ +"""Pydantic AI backend for AIAgent.""" diff --git a/fastapi_admin_kit/ai/backends/pydantic_ai_backend.py b/fastapi_admin_kit/ai/backends/pydantic_ai_backend.py new file mode 100644 index 0000000..3c7d2fd --- /dev/null +++ b/fastapi_admin_kit/ai/backends/pydantic_ai_backend.py @@ -0,0 +1,150 @@ +"""Pydantic AI backend implementation of AIAgent.""" + +from __future__ import annotations + +import time +from typing import Any + +from fastapi_admin_kit.ai.agent import ( + AIAgent, + ChatResult, + ToolCallRecord, + UsageInfo, +) +from fastapi_admin_kit.ai.deps import AdminDeps + + +def _extract_tool_calls(result: Any) -> list[ToolCallRecord]: + """Extract tool call records from a Pydantic AI run result.""" + records: list[ToolCallRecord] = [] + messages = getattr(result, "all_messages", lambda: [])() + for msg in messages: + parts = getattr(msg, "parts", []) + for part in parts: + if getattr(part, "part_kind", "") == "tool-call": + records.append( + ToolCallRecord( + name=getattr(part, "tool_name", ""), + args=getattr(part, "args", {}), + ) + ) + elif getattr(part, "part_kind", "") == "tool-return": + if records: + records[-1].result = getattr(part, "content", None) + return records + + +class PydanticAIAgent(AIAgent): + """Phase 1 implementation using Pydantic AI.""" + + def __init__( + self, + config: Any, + deps_factory: Any, + usage_writer: Any, + ) -> None: + self._config = config + self._deps_factory = deps_factory + self._usage_writer = usage_writer + self.name = config.name + + try: + from pydantic_ai import Agent + + self._agent = Agent( + config.model, + deps_type=AdminDeps, + result_type=config.result_type or str, + system_prompt=config.system_prompt, + retries=config.retries, + ) + self._bind_tools(config.tools) + except ImportError: + self._agent = None + + def _bind_tools(self, tools: list[Any]) -> None: + if self._agent is None: + return + for t in tools: + if t.uses_context: + self._agent.tool(t.handler) + else: + self._agent.tool_plain(t.handler) + + async def chat( + self, + message: str, + deps: AdminDeps, + message_history: list | None = None, + ) -> ChatResult: + if self._agent is None: + raise RuntimeError( + "pydantic-ai is not installed. Install with: pip install pydantic-ai" + ) + + start = time.perf_counter() + result = await self._agent.run(message, deps=deps, message_history=message_history) + latency_ms = int((time.perf_counter() - start) * 1000) + + usage = result.usage() + cost = self._compute_cost(usage) + tool_calls = _extract_tool_calls(result) + + await self._usage_writer.write( + agent_name=self._config.name, + model=str(self._config.model), + request_tokens=getattr(usage, "request_tokens", None) or 0, + response_tokens=getattr(usage, "response_tokens", None) or 0, + total_tokens=getattr(usage, "total_tokens", None) or 0, + cost=cost, + user=deps.admin_user, + success=True, + latency_ms=latency_ms, + tool_calls=[ + {"name": tc.name, "args": tc.args, "ok": tc.is_error is False} for tc in tool_calls + ], + session=deps.session, + ) + + return ChatResult( + output=result.data, + usage=UsageInfo.from_pydantic_ai(usage, cost), + new_messages=result.new_messages(), + tool_calls=tool_calls, + ) + + def chat_stream(self, message: str, deps: AdminDeps, message_history: list | None = None): + if self._agent is None: + raise RuntimeError("pydantic-ai is not installed.") + + return self._agent.run_stream(message, deps=deps, message_history=message_history) + + async def execute_tool(self, tool_name: str, params: dict, deps: AdminDeps) -> Any: + tool = self._config.get_tool(tool_name) + if tool is None: + raise ValueError(f"Tool '{tool_name}' not found.") + + if tool.uses_context: + from pydantic_ai import RunContext + + ctx = RunContext(deps=deps, retry=0, tool_name=tool_name) + return await tool.handler(ctx, **params) + return await tool.handler(**params) + + def get_tools(self) -> list[dict]: + return [t.to_schema() for t in self._config.tools] + + async def get_usage_stats(self, period: str = "day") -> dict: + return await self._usage_writer.aggregate( + agent_name=self._config.name, + period=period, + session=None, + ) + + def _compute_cost(self, usage: Any) -> float: + cfg = self._config + req = (getattr(usage, "request_tokens", None) or 0) / 1000 + resp = (getattr(usage, "response_tokens", None) or 0) / 1000 + in_cost = req * cfg.cost_per_1k_input_tokens + out_cost = resp * cfg.cost_per_1k_output_tokens + return round(in_cost + out_cost, 6) diff --git a/fastapi_admin_kit/ai/builtin_tools.py b/fastapi_admin_kit/ai/builtin_tools.py new file mode 100644 index 0000000..991ee35 --- /dev/null +++ b/fastapi_admin_kit/ai/builtin_tools.py @@ -0,0 +1,108 @@ +"""Built-in tools for AI agents.""" + +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel + +from fastapi_admin_kit.ai.tools import tool + + +class QueryResult(BaseModel): + """Result of a database query.""" + + row_count: int + rows: list[dict] + + +@tool( + name="query_database", + description="Query a registered model with filters.", + category="database", +) +async def query_database( + ctx: Any, table_name: str, filters: dict | None = None, limit: int = 50 +) -> QueryResult: + registered = ctx.deps.registry.get(table_name) + if not registered: + raise ValueError(f"'{table_name}' is not a registered model.") + + if not await ctx.deps.permission_checker.has_permission(table_name, "view"): + raise ValueError(f"Not permitted to view {table_name}.") + + model = registered.model + session = ctx.deps.session + + from sqlalchemy import select + + stmt = select(model) + for field_name, value in (filters or {}).items(): + if hasattr(model, field_name): + stmt = stmt.where(getattr(model, field_name) == value) + stmt = stmt.limit(limit) + + result = await session.execute(stmt) + rows = result.scalars().all() + + return QueryResult( + row_count=len(rows), + rows=[{c.name: getattr(row, c.name, None) for c in registered.columns} for row in rows], + ) + + +@tool( + name="create_record", + description="Create a new record on a registered model.", + category="database", +) +async def create_record(ctx: Any, table_name: str, data: dict) -> dict: + registered = ctx.deps.registry.get(table_name) + if not registered: + raise ValueError(f"'{table_name}' is not a registered model.") + + if not await ctx.deps.permission_checker.has_permission(table_name, "create"): + raise ValueError(f"Not permitted to create {table_name}.") + + model = registered.model + session = ctx.deps.session + + obj = model(**data) + session.add(obj) + await session.flush() + + return {"id": getattr(obj, "id", None), "table": table_name} + + +class ReportSpec(BaseModel): + """Specification for generating a report.""" + + report_type: str + filters: dict = {} + + +@tool( + name="generate_report", + description="Generate an analytics report.", + category="analytics", +) +async def generate_report(ctx: Any, spec: ReportSpec) -> dict: + return { + "report_type": spec.report_type, + "filters": spec.filters, + "status": "generated", + "data": [], + } + + +@tool( + name="send_notification", + description="Send a notification to a user.", + category="notifications", +) +async def send_notification(ctx: Any, recipient: str, subject: str, message: str) -> dict: + return { + "recipient": recipient, + "subject": subject, + "status": "sent", + } diff --git a/fastapi_admin_kit/ai/config.py b/fastapi_admin_kit/ai/config.py new file mode 100644 index 0000000..11dc57d --- /dev/null +++ b/fastapi_admin_kit/ai/config.py @@ -0,0 +1,36 @@ +"""AI configuration dataclasses.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from fastapi_admin_kit.ai.tools import Tool + + +@dataclass +class AIAgentConfig: + """Configuration for a single AI agent.""" + + name: str + model: str + system_prompt: str = "" + result_type: type | None = None + tools: list[Tool] = field(default_factory=list) + retries: int = 1 + cost_per_1k_input_tokens: float = 0.0 + cost_per_1k_output_tokens: float = 0.0 + + def get_tool(self, name: str) -> Tool | None: + return next((t for t in self.tools if t.name == name), None) + + +@dataclass +class AIConfig: + """Top-level AI configuration for the admin panel.""" + + agents: list[AIAgentConfig] = field(default_factory=list) + default_agent: str = "default" + dashboard_enabled: bool = True + log_retention_days: int = 30 diff --git a/fastapi_admin_kit/ai/conversation.py b/fastapi_admin_kit/ai/conversation.py new file mode 100644 index 0000000..0f1c737 --- /dev/null +++ b/fastapi_admin_kit/ai/conversation.py @@ -0,0 +1,227 @@ +"""Conversation and message logging for AI agents.""" + +from __future__ import annotations + +import time +import uuid +from functools import wraps +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from fastapi_admin_kit.ai.agent import AIAgent + from fastapi_admin_kit.ai.deps import AdminDeps + + +class ConversationRecorder: + """Handles persistence of conversations and messages.""" + + def __init__(self, session: Any) -> None: + self.session = session + + async def get_or_create( + self, + conversation_id: str | None, + agent_name: str, + user: Any, + ) -> Any: + from fastapi_admin_kit.ai.usage import AIConversation + + if conversation_id: + from sqlalchemy import select + + result = await self.session.execute( + select(AIConversation).where(AIConversation.id == conversation_id) + ) + conv = result.scalar_one_or_none() + if conv: + return conv + + conv_id = str(uuid.uuid4()) + conv = AIConversation( + id=conv_id, + agent_name=agent_name, + user_id=getattr(user, "id", None), + user_email=getattr(user, "email", None), + ) + self.session.add(conv) + await self.session.flush() + return conv + + async def log_message( + self, + conv: Any, + role: str, + content: str, + tokens: int | None = None, + latency_ms: int | None = None, + ) -> None: + from fastapi_admin_kit.ai.usage import AIMessage + + self.session.add( + AIMessage( + conversation_id=conv.id, + role=role, + content=content, + tokens=tokens, + latency_ms=latency_ms, + ) + ) + await self.session.flush() + + async def log_tool_call(self, conv: Any, call: Any) -> None: + from fastapi_admin_kit.ai.usage import AIMessage + + self.session.add( + AIMessage( + conversation_id=conv.id, + role="tool", + tool_name=getattr(call, "name", None), + tool_args=getattr(call, "args", None), + tool_result=getattr(call, "result", None), + content=str(getattr(call, "result", "")), + ) + ) + await self.session.flush() + + async def log_error(self, conv: Any, error: str) -> None: + from fastapi_admin_kit.ai.usage import AIMessage + + self.session.add( + AIMessage( + conversation_id=conv.id, + role="error", + content=error, + error=error, + ) + ) + await self.session.flush() + + async def touch( + self, + conv: Any, + *, + message_history: Any = None, + tokens_delta: int = 0, + cost_delta: float = 0.0, + ) -> None: + from sqlalchemy import func as sqlfunc + + conv.message_history = message_history + conv.total_tokens = (conv.total_tokens or 0) + tokens_delta + conv.total_cost = float(conv.total_cost or 0) + cost_delta + conv.turn_count = (conv.turn_count or 0) + 1 + conv.last_message_at = sqlfunc.now() + await self.session.flush() + + +def _with_conversation_logging(chat_fn: Any) -> Any: + """Wrap a chat() method to automatically log conversations and messages.""" + + @wraps(chat_fn) + async def wrapper( + self: AIAgent, + message: str, + deps: AdminDeps, + message_history: list | None = None, + conversation_id: str | None = None, + **kwargs: Any, + ) -> Any: + recorder = ConversationRecorder(deps.session) + conv = await recorder.get_or_create( + conversation_id, + agent_name=getattr(self, "name", "default"), + user=deps.admin_user, + ) + + await recorder.log_message(conv, role="user", content=message) + + start = time.perf_counter() + try: + result = await chat_fn(self, message, deps, message_history=message_history, **kwargs) + except Exception as exc: + await recorder.log_error(conv, error=str(exc)) + raise + + latency_ms = int((time.perf_counter() - start) * 1000) + cost = getattr(self, "_compute_cost", lambda u: 0.0)(getattr(result, "usage", None)) + + await recorder.log_message( + conv, + role="assistant", + content=str(getattr(result, "output", "")), + tokens=getattr(result, "usage", None) and getattr(result.usage, "total_tokens", None), + latency_ms=latency_ms, + ) + + for call in getattr(result, "tool_calls", []): + await recorder.log_tool_call(conv, call) + + tokens_delta = 0 + usage_obj = getattr(result, "usage", None) + if usage_obj: + tokens_delta = getattr(usage_obj, "total_tokens", 0) or 0 + + await recorder.touch( + conv, + tokens_delta=tokens_delta, + cost_delta=cost, + ) + result.conversation_id = conv.id + return result + + return wrapper + + +def _with_conversation_logging_stream(chat_stream_fn: Any) -> Any: + """Wrap a chat_stream() method to log conversations after stream completes.""" + + @wraps(chat_stream_fn) + async def wrapper( + self: AIAgent, + message: str, + deps: AdminDeps, + message_history: list | None = None, + conversation_id: str | None = None, + **kwargs: Any, + ) -> Any: + recorder = ConversationRecorder(deps.session) + conv = await recorder.get_or_create( + conversation_id, + agent_name=getattr(self, "name", "default"), + user=deps.admin_user, + ) + + await recorder.log_message(conv, role="user", content=message) + + start = time.perf_counter() + accumulated = [] + try: + async for chunk in chat_stream_fn( + self, message, deps, message_history=message_history, **kwargs + ): + accumulated.append(str(chunk)) + yield chunk + except Exception as exc: + await recorder.log_error(conv, error=str(exc)) + raise + + latency_ms = int((time.perf_counter() - start) * 1000) + full_content = "".join(accumulated) + + await recorder.log_message( + conv, + role="assistant", + content=full_content, + latency_ms=latency_ms, + ) + + await recorder.touch(conv) + + return wrapper + + +def patch_agent_with_conversation_logging(agent_cls: type) -> None: + """Apply conversation logging wrappers to an agent class's chat methods.""" + agent_cls.chat = _with_conversation_logging(agent_cls.chat) # type: ignore[assignment] + if hasattr(agent_cls, "chat_stream") and agent_cls.chat_stream is not None: + agent_cls.chat_stream = _with_conversation_logging_stream(agent_cls.chat_stream) # type: ignore[assignment] diff --git a/fastapi_admin_kit/ai/dashboard.py b/fastapi_admin_kit/ai/dashboard.py new file mode 100644 index 0000000..7d26ca4 --- /dev/null +++ b/fastapi_admin_kit/ai/dashboard.py @@ -0,0 +1,245 @@ +"""AI Dashboard routes.""" + +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, HTTPException, Request +from fastapi.responses import HTMLResponse, JSONResponse + +router = APIRouter(prefix="/ai", tags=["ai"]) + + +def _get_jinja(request: Request) -> Any: + return request.app.state.admin_jinja_env + + +def _get_admin(request: Request) -> Any: + return getattr(request.app.state, "admin", None) + + +def _get_ai_agents(request: Request) -> dict[str, Any]: + return getattr(request.app.state, "ai_agents", {}) + + +@router.get("/dashboard", response_class=HTMLResponse) +async def ai_dashboard(request: Request) -> HTMLResponse: + """AI operations dashboard showing costs, logs, and tool calls.""" + agents = _get_ai_agents(request) + admin = _get_admin(request) + jinja = _get_jinja(request) + + stats: list[dict[str, Any]] = [] + for name, agent in agents.items(): + try: + s = await agent.get_usage_stats(period="day") + except Exception: + s = { + "total_tokens": 0, + "total_cost": 0, + "total_runs": 0, + "success_rate": 0, + } + stats.append({"name": name, **s}) + + context = { + "title": "AI Dashboard", + "agent_stats": stats, + "admin_path": admin.admin_path if admin else "/admin", + } + context.update(admin.sidebar_template_kwargs(request) if admin else {}) + rendered = jinja.Template("pages/ai/dashboard.html").render(**context) + return HTMLResponse(rendered) + + +@router.get("/logs") +async def get_ai_logs( + request: Request, + limit: int = 100, + offset: int = 0, + agent: str | None = None, + tool: str | None = None, +) -> JSONResponse: + """Get AI operation logs.""" + from sqlalchemy import select + + from fastapi_admin_kit.ai.usage import AIUsageLog + from fastapi_admin_kit.db import get_db_session + + session = get_db_session(request) + stmt = select(AIUsageLog).order_by(AIUsageLog.timestamp.desc()) + + if agent: + stmt = stmt.where(AIUsageLog.agent_name == agent) + stmt = stmt.offset(offset).limit(limit) + + result = await session.execute(stmt) + rows = result.scalars().all() + + return JSONResponse( + [ + { + "id": r.id, + "agent_name": r.agent_name, + "model": r.model, + "user_email": r.user_email, + "request_tokens": r.request_tokens, + "response_tokens": r.response_tokens, + "total_tokens": r.total_tokens, + "cost": float(r.cost or 0), + "tool_calls": r.tool_calls or [], + "success": r.success, + "error": r.error, + "latency_ms": r.latency_ms, + "timestamp": str(r.timestamp) if r.timestamp else None, + } + for r in rows + ] + ) + + +@router.get("/costs") +async def get_ai_costs( + request: Request, + period: str = "day", + agent: str | None = None, +) -> JSONResponse: + """Get AI cost breakdown.""" + from fastapi_admin_kit.ai.usage import AIUsageWriter + from fastapi_admin_kit.db import get_db_session + + session = get_db_session(request) + writer = AIUsageWriter() + agent_name = agent or "default" + + stats = await writer.aggregate(agent_name=agent_name, period=period, session=session) + return JSONResponse(stats) + + +@router.get("/tools") +async def get_ai_tools(request: Request) -> JSONResponse: + """Get list of available AI tools.""" + from fastapi_admin_kit.ai.tools import tool_registry + + tools = tool_registry.all() + return JSONResponse( + [ + { + "name": t.name, + "description": t.description, + "category": t.category, + "uses_context": t.uses_context, + } + for t in tools + ] + ) + + +@router.post("/tools/{tool_name}/execute") +async def execute_tool_endpoint( + tool_name: str, + request: Request, + params: dict | None = None, +) -> JSONResponse: + """Execute an AI tool directly (bypasses the LLM).""" + agents = _get_ai_agents(request) + if not agents: + raise HTTPException(status_code=400, detail="No AI agents configured.") + + agent_name = request.query_params.get("agent", "default") + agent = agents.get(agent_name) + if agent is None: + raise HTTPException(status_code=404, detail=f"Agent '{agent_name}' not found.") + + from fastapi_admin_kit.auth.dependencies import ( + get_current_admin_user, + get_permission_checker, + ) + from fastapi_admin_kit.db import get_db_session + + session = get_db_session(request) + user = await get_current_admin_user(request) + checker = await get_permission_checker(request, user, session) + + from fastapi_admin_kit.ai.deps import AdminDeps + + deps = AdminDeps( + session=session, + admin_user=user, + request=request, + registry=request.app.state.admin_registry, + permission_checker=checker, + ) + + try: + result = await agent.execute_tool(tool_name, params or {}, deps) + return JSONResponse({"success": True, "result": result}) + except Exception as e: + return JSONResponse({"success": False, "error": str(e)}, status_code=400) + + +@router.get("/agents") +async def get_ai_agents(request: Request) -> JSONResponse: + """Get list of configured AI agents.""" + agents = _get_ai_agents(request) + return JSONResponse( + [ + { + "name": name, + "model": getattr(agent._config, "model", "unknown"), + "tools": len(getattr(agent._config, "tools", [])), + } + for name, agent in agents.items() + ] + ) + + +@router.post("/chat") +async def ai_chat(request: Request) -> JSONResponse: + """Send a message to an AI agent.""" + body = await request.json() + message = body.get("message", "") + agent_name = body.get("agent", "default") + conversation_id = body.get("conversation_id") + + agents = _get_ai_agents(request) + agent = agents.get(agent_name) + if agent is None: + raise HTTPException(status_code=404, detail=f"Agent '{agent_name}' not found.") + + from fastapi_admin_kit.auth.dependencies import ( + get_current_admin_user, + get_permission_checker, + ) + from fastapi_admin_kit.db import get_db_session + + session = get_db_session(request) + user = await get_current_admin_user(request) + checker = await get_permission_checker(request, user, session) + + from fastapi_admin_kit.ai.deps import AdminDeps + + deps = AdminDeps( + session=session, + admin_user=user, + request=request, + registry=request.app.state.admin_registry, + permission_checker=checker, + ) + + try: + result = await agent.chat(message, deps, conversation_id=conversation_id) + return JSONResponse( + { + "output": str(result.output), + "usage": { + "request_tokens": result.usage.request_tokens, + "response_tokens": result.usage.response_tokens, + "total_tokens": result.usage.total_tokens, + "cost": result.usage.cost, + }, + "conversation_id": result.conversation_id, + } + ) + except Exception as e: + return JSONResponse({"error": str(e)}, status_code=400) diff --git a/fastapi_admin_kit/ai/deps.py b/fastapi_admin_kit/ai/deps.py new file mode 100644 index 0000000..0901924 --- /dev/null +++ b/fastapi_admin_kit/ai/deps.py @@ -0,0 +1,40 @@ +"""Dependency injection for AI agents — AdminDeps.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from fastapi import Request + + +@dataclass +class AdminDeps: + """Shared dependencies injected into every tool call and agent run.""" + + session: Any + admin_user: Any + request: Any + registry: Any + permission_checker: Any + + +async def get_admin_deps(request: Request) -> AdminDeps: + """Build AdminDeps from the current request.""" + from fastapi_admin_kit.auth.dependencies import ( + get_current_admin_user, + get_permission_checker, + ) + from fastapi_admin_kit.db import get_db_session + + db_session = get_db_session(request) + admin_user = await get_current_admin_user(request) + permission_checker = await get_permission_checker(request, admin_user, db_session) + + return AdminDeps( + session=db_session, + admin_user=admin_user, + request=request, + registry=request.app.state.admin_registry, + permission_checker=permission_checker, + ) diff --git a/fastapi_admin_kit/ai/model_agent.py b/fastapi_admin_kit/ai/model_agent.py new file mode 100644 index 0000000..72d18a9 --- /dev/null +++ b/fastapi_admin_kit/ai/model_agent.py @@ -0,0 +1,129 @@ +"""Model-bound agents — auto CRUD tools via inheritance.""" + +from __future__ import annotations + +from abc import ABC +from typing import Any + +from fastapi_admin_kit.ai.tools import Tool, tool_registry + + +def _build_query_tool(model: type, table_name: str) -> Tool: + async def _query(ctx: Any, filters: dict | None = None, limit: int = 50) -> Any: + from fastapi_admin_kit.ai.builtin_tools import query_database + + return await query_database(ctx, table_name, filters, limit) + + return tool_registry.register( + name=f"query_{table_name}", + description=f"Query {table_name} records with filters.", + handler=_query, + uses_context=True, + category="database", + ) + + +def _build_create_tool(model: type, table_name: str, exclude_fields: list[str]) -> Tool: + async def _create(ctx: Any, data: dict) -> Any: + from fastapi_admin_kit.ai.builtin_tools import create_record + + for f in exclude_fields: + data.pop(f, None) + return await create_record(ctx, table_name, data) + + return tool_registry.register( + name=f"create_{table_name}", + description=f"Create a new {table_name} record.", + handler=_create, + uses_context=True, + category="database", + ) + + +def _build_update_tool(model: type, table_name: str, exclude_fields: list[str]) -> Tool: + async def _update(ctx: Any, record_id: int, data: dict) -> dict: + if not await ctx.deps.permission_checker.has_permission(table_name, "edit"): + raise ValueError(f"Not permitted to edit {table_name}.") + + for f in exclude_fields: + data.pop(f, None) + + session = ctx.deps.session + from sqlalchemy import select + + result = await session.execute(select(model).where(getattr(model, "id") == record_id)) + obj = result.scalar_one_or_none() + if not obj: + raise ValueError(f"No {table_name} with id {record_id}.") + + for k, v in data.items(): + if hasattr(obj, k): + setattr(obj, k, v) + await session.flush() + return {"id": record_id, "table": table_name, "updated": True} + + return tool_registry.register( + name=f"update_{table_name}", + description=f"Update a {table_name} record by ID.", + handler=_update, + uses_context=True, + category="database", + ) + + +def _build_delete_tool(model: type, table_name: str) -> Tool: + async def _delete(ctx: Any, record_id: int) -> dict: + if not await ctx.deps.permission_checker.has_permission(table_name, "delete"): + raise ValueError(f"Not permitted to delete {table_name}.") + + session = ctx.deps.session + from sqlalchemy import select + + result = await session.execute(select(model).where(getattr(model, "id") == record_id)) + obj = result.scalar_one_or_none() + if not obj: + raise ValueError(f"No {table_name} with id {record_id}.") + + await session.delete(obj) + await session.flush() + return {"id": record_id, "table": table_name, "deleted": True} + + return tool_registry.register( + name=f"delete_{table_name}", + description=f"Delete a {table_name} record by ID.", + handler=_delete, + uses_context=True, + category="database", + ) + + +class ModelAIAgent(ABC): + """Base class for model-bound agents. + + Subclassing and pointing at a model auto-generates CRUD tools. + """ + + model: type + can_view: bool = True + can_create: bool = True + can_edit: bool = True + can_delete: bool = False + exclude_fields: list[str] = [] + + def __init_subclass__(cls, **kwargs: Any) -> None: + super().__init_subclass__(**kwargs) + cls._declared_tools = [m for m in vars(cls).values() if getattr(m, "_ai_tool", False)] + + @classmethod + def build_tools(cls) -> list[Tool]: + table = cls.model.__tablename__ + tools: list[Tool] = [] + if cls.can_view: + tools.append(_build_query_tool(cls.model, table)) + if cls.can_create: + tools.append(_build_create_tool(cls.model, table, cls.exclude_fields)) + if cls.can_edit: + tools.append(_build_update_tool(cls.model, table, cls.exclude_fields)) + if cls.can_delete: + tools.append(_build_delete_tool(cls.model, table)) + return tools + cls._declared_tools diff --git a/fastapi_admin_kit/ai/plugin.py b/fastapi_admin_kit/ai/plugin.py new file mode 100644 index 0000000..7c73022 --- /dev/null +++ b/fastapi_admin_kit/ai/plugin.py @@ -0,0 +1,47 @@ +"""AI Plugin — routes, nav items, and startup wiring.""" + +from __future__ import annotations + +from typing import Any + + +class AIPlugin: + """Plugin that adds AI agent capabilities to the admin panel.""" + + name = "ai" + + def __init__(self, agents: list[Any] | None = None) -> None: + self.agents = agents or [] + + def get_routes(self) -> Any: + from fastapi_admin_kit.ai.dashboard import router + + return router + + def get_nav_items(self) -> list[dict]: + return [{"label": "AI", "url": "/admin/ai/dashboard", "icon": "sparkles"}] + + def get_dashboard_widgets(self) -> list[Any]: + return [] + + def on_startup(self, admin: Any) -> None: + """Initialize AI agents and store on admin state.""" + from fastapi_admin_kit.ai.backends.pydantic_ai_backend import ( + PydanticAIAgent, + ) + from fastapi_admin_kit.ai.deps import get_admin_deps + from fastapi_admin_kit.ai.usage import AIUsageWriter + + writer = AIUsageWriter() + ai_agents: dict[str, Any] = {} + + for cfg in self.agents: + agent = PydanticAIAgent( + config=cfg, + deps_factory=get_admin_deps, + usage_writer=writer, + ) + ai_agents[cfg.name] = agent + + admin._app.state.ai_agents = ai_agents # type: ignore[attr-defined] + admin._app.state.ai_config = self # type: ignore[attr-defined] diff --git a/fastapi_admin_kit/ai/tools.py b/fastapi_admin_kit/ai/tools.py new file mode 100644 index 0000000..8f6c431 --- /dev/null +++ b/fastapi_admin_kit/ai/tools.py @@ -0,0 +1,97 @@ +"""Tool system — registration, registry, and decorator.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass, field + + +@dataclass +class Tool: + """Represents an AI tool with its metadata and handler.""" + + name: str + description: str + handler: Callable + uses_context: bool = True + path: str | None = None + method: str = "POST" + requires_auth: bool = True + category: str = "general" + _schema: dict | None = field(default=None, repr=False) + + def to_schema(self) -> dict: + return self._schema or {} + + +class ToolRegistry: + """Global registry for AI tools.""" + + def __init__(self) -> None: + self._tools: dict[str, Tool] = {} + + def register( + self, + name: str, + description: str, + handler: Callable, + *, + uses_context: bool = True, + path: str | None = None, + method: str = "POST", + requires_auth: bool = True, + category: str = "general", + ) -> Tool: + tool = Tool( + name=name, + description=description, + handler=handler, + uses_context=uses_context, + path=path, + method=method, + requires_auth=requires_auth, + category=category, + ) + self._tools[name] = tool + return tool + + def get(self, name: str) -> Tool | None: + return self._tools.get(name) + + def all(self) -> list[Tool]: + return list(self._tools.values()) + + def by_category(self, category: str) -> list[Tool]: + return [t for t in self._tools.values() if t.category == category] + + +tool_registry = ToolRegistry() + + +def tool( + name: str, + description: str, + *, + uses_context: bool = True, + path: str | None = None, + method: str = "POST", + requires_auth: bool = True, + category: str = "general", +) -> Callable: + """Decorator to register a function as an AI tool.""" + + def decorator(func: Callable) -> Callable: + tool_registry.register( + name=name, + description=description, + handler=func, + uses_context=uses_context, + path=path, + method=method, + requires_auth=requires_auth, + category=category, + ) + func._ai_tool = True + return func + + return decorator diff --git a/fastapi_admin_kit/ai/usage.py b/fastapi_admin_kit/ai/usage.py new file mode 100644 index 0000000..72d7277 --- /dev/null +++ b/fastapi_admin_kit/ai/usage.py @@ -0,0 +1,179 @@ +"""Usage tracking — UsageInfo, AIUsageWriter, AIUsageLog model.""" + +from __future__ import annotations + +from sqlalchemy import ( + JSON, + Boolean, + Column, + DateTime, + ForeignKey, + Index, + Integer, + Numeric, + String, + Text, +) +from sqlalchemy.sql import func + +from fastapi_admin_kit.models.base import Base + + +class AIUsageLog(Base): + """SQLAlchemy model for AI usage logging.""" + + __tablename__ = "admin_ai_usage_log" + __table_args__ = ( + Index("idx_ai_usage_agent", "agent_name", "timestamp"), + Index("idx_ai_usage_user", "user_id"), + ) + + id = Column(Integer, primary_key=True, autoincrement=True) + agent_name = Column(String(100), nullable=False) + model = Column(String(255), nullable=False) + user_id = Column( + Integer, + ForeignKey("admin_users.id", ondelete="SET NULL"), + nullable=True, + ) + user_email = Column(String(255)) + request_tokens = Column(Integer, default=0) + response_tokens = Column(Integer, default=0) + total_tokens = Column(Integer, default=0) + cost = Column(Numeric(12, 6), default=0) + tool_calls = Column(JSON) + success = Column(Boolean, default=True) + error = Column(Text) + latency_ms = Column(Integer) + timestamp = Column(DateTime(timezone=True), server_default=func.now()) + + +class AIConversation(Base): + """SQLAlchemy model for AI conversations.""" + + __tablename__ = "admin_ai_conversations" + __table_args__ = (Index("idx_ai_conv_user", "user_id", "last_message_at"),) + + id = Column(String(36), primary_key=True) + agent_name = Column(String(100), nullable=False) + user_id = Column( + Integer, + ForeignKey("admin_users.id", ondelete="SET NULL"), + nullable=True, + ) + user_email = Column(String(255)) + title = Column(String(255)) + status = Column(String(20), default="active") + message_history = Column(JSON) + total_tokens = Column(Integer, default=0) + total_cost = Column(Numeric(12, 6), default=0) + turn_count = Column(Integer, default=0) + started_at = Column(DateTime(timezone=True), server_default=func.now()) + last_message_at = Column(DateTime(timezone=True)) + + +class AIMessage(Base): + """SQLAlchemy model for AI conversation messages.""" + + __tablename__ = "admin_ai_messages" + __table_args__ = (Index("idx_ai_msg_conv", "conversation_id", "created_at"),) + + id = Column(Integer, primary_key=True, autoincrement=True) + conversation_id = Column( + String(36), + ForeignKey("admin_ai_conversations.id", ondelete="CASCADE"), + nullable=False, + ) + role = Column(String(20), nullable=False) + content = Column(Text) + tool_name = Column(String(100)) + tool_args = Column(JSON) + tool_result = Column(JSON) + tokens = Column(Integer) + latency_ms = Column(Integer) + error = Column(Text) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + +class AIUsageWriter: + """Writes AI usage logs and aggregates statistics.""" + + async def write( + self, + *, + agent_name: str, + model: str, + request_tokens: int, + response_tokens: int, + total_tokens: int, + cost: float, + user: Any, + success: bool, + session: Any, + error: str | None = None, + latency_ms: int | None = None, + tool_calls: list[dict] | None = None, + ) -> None: + session.add( + AIUsageLog( + agent_name=agent_name, + model=model, + user_id=getattr(user, "id", None), + user_email=getattr(user, "email", None), + request_tokens=request_tokens, + response_tokens=response_tokens, + total_tokens=total_tokens, + cost=cost, + tool_calls=tool_calls or [], + success=success, + error=error, + latency_ms=latency_ms, + ) + ) + await session.commit() + + async def aggregate( + self, + agent_name: str, + period: str, + session: Any, + ) -> dict: + from sqlalchemy import func as sqlfunc + from sqlalchemy import select + + from fastapi_admin_kit.ai.usage import AIUsageLog + + interval_map = {"day": "1 day", "week": "7 days", "month": "30 days"} + interval = interval_map.get(period, "1 day") + days = int(interval.split()[0]) + + result = await session.execute( + select( + sqlfunc.sum(AIUsageLog.total_tokens).label("total_tokens"), + sqlfunc.sum(AIUsageLog.cost).label("total_cost"), + sqlfunc.count(AIUsageLog.id).label("total_runs"), + sqlfunc.avg(AIUsageLog.latency_ms).label("avg_latency_ms"), + sqlfunc.sum( + sqlfunc.case( + (AIUsageLog.success == True, 1), # noqa: E712 + else_=0, + ) + ).label("success_count"), + ) + .where(AIUsageLog.agent_name == agent_name) + .where(AIUsageLog.timestamp >= func.now() - func.make_interval(*[0, 0, 0, 0, days])) + ) + row = result.one() + total_runs = row.total_runs or 0 + success_count = row.success_count or 0 + rate = round(success_count / total_runs * 100, 1) if total_runs else 0 + return { + "total_tokens": row.total_tokens or 0, + "total_cost": float(row.total_cost or 0), + "total_runs": total_runs, + "avg_latency_ms": round(row.avg_latency_ms or 0, 2), + "success_rate": rate, + } + + +from typing import Any # noqa: E402 diff --git a/fastapi_admin_kit/templates/pages/ai/dashboard.html b/fastapi_admin_kit/templates/pages/ai/dashboard.html new file mode 100644 index 0000000..df3c6f7 --- /dev/null +++ b/fastapi_admin_kit/templates/pages/ai/dashboard.html @@ -0,0 +1,153 @@ +{# pages/ai/dashboard.html — AI Operations Dashboard #} +{% extends "base.html" %} + +{% block title %}{{ title | default("AI Dashboard") }}{% endblock %} + +{% block content %} +
+ + +
+ {% for stat in agent_stats %} +
+
+
+ smart_toy +
+ {{ stat.name }} +
+
{{ stat.total_runs | default(0) }}
+
+ runs today +
+
+ Tokens: {{ stat.total_tokens | default(0) }} · Cost: ${{ "%.4f" | format(stat.total_cost | default(0)) }} +
+
+ Success: {{ stat.success_rate | default(0) }}% +
+ + + + +
+ {% endfor %} +
+ +
+
+
+
+ build +
+
+

Available Tools

+

Tools bound to AI agents

+
+
+
+

Loading tools...

+
+
+ +
+
+
+ chat +
+
+

Quick Chat

+

Send a message to an agent

+
+
+
+
+ + +
+
+ + +
+ + +
+
+
+
+ + + + +{% endblock %} diff --git a/pyproject.toml b/pyproject.toml index 6332fe9..b0a872d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,6 +63,9 @@ dev = [ sqlmodel = ["sqlmodel>=0.0.39"] postgres = ["asyncpg>=0.29.0"] mysql = ["aiomysql>=0.2.0"] +ai = [ + "pydantic-ai>=0.0.14", +] docs = [ "mkdocs>=1.6.0", "mkdocs-material>=9.5.0", diff --git a/tests/test_ai_agent.py b/tests/test_ai_agent.py new file mode 100644 index 0000000..c6afb8e --- /dev/null +++ b/tests/test_ai_agent.py @@ -0,0 +1,193 @@ +"""Tests for AI Agent Integration (Phase 1).""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +from fastapi_admin_kit.ai.agent import ChatResult, ToolCallRecord, UsageInfo +from fastapi_admin_kit.ai.config import AIAgentConfig, AIConfig +from fastapi_admin_kit.ai.deps import AdminDeps +from fastapi_admin_kit.ai.tools import Tool, ToolRegistry, tool, tool_registry + +# ─── UsageInfo ─── + + +class TestUsageInfo: + def test_defaults(self): + u = UsageInfo() + assert u.request_tokens == 0 + assert u.response_tokens == 0 + assert u.total_tokens == 0 + assert u.cost == 0.0 + + def test_from_pydantic_ai(self): + usage = MagicMock(request_tokens=100, response_tokens=50, total_tokens=150) + info = UsageInfo.from_pydantic_ai(usage, cost=0.005) + assert info.request_tokens == 100 + assert info.response_tokens == 50 + assert info.total_tokens == 150 + assert info.cost == 0.005 + + def test_from_pydantic_ai_none_attrs(self): + usage = MagicMock(request_tokens=None, response_tokens=None, total_tokens=None) + info = UsageInfo.from_pydantic_ai(usage, cost=0.0) + assert info.request_tokens == 0 + assert info.response_tokens == 0 + assert info.total_tokens == 0 + + +# ─── ChatResult ─── + + +class TestChatResult: + def test_defaults(self): + r = ChatResult() + assert r.output is None + assert r.tool_calls == [] + assert r.conversation_id is None + + def test_with_values(self): + r = ChatResult(output="hello", usage=UsageInfo(total_tokens=100)) + assert r.output == "hello" + assert r.usage.total_tokens == 100 + + +# ─── ToolCallRecord ─── + + +class TestToolCallRecord: + def test_record(self): + tc = ToolCallRecord(name="lookup", args={"id": 1}, result={"found": True}) + assert tc.name == "lookup" + assert tc.args == {"id": 1} + assert tc.is_error is False + + +# ─── Tool ─── + + +class TestTool: + def test_tool_dataclass(self): + async def handler(): + pass + + t = Tool(name="test", description="desc", handler=handler) + assert t.name == "test" + assert t.uses_context is True + assert t.category == "general" + + def test_to_schema_empty(self): + async def handler(): + pass + + t = Tool(name="test", description="desc", handler=handler) + assert t.to_schema() == {} + + +# ─── ToolRegistry ─── + + +class TestToolRegistry: + def test_register_and_get(self): + reg = ToolRegistry() + + async def handler(): + pass + + reg.register("my_tool", "does stuff", handler) + t = reg.get("my_tool") + assert t is not None + assert t.name == "my_tool" + + def test_get_missing(self): + reg = ToolRegistry() + assert reg.get("nonexistent") is None + + def test_all(self): + reg = ToolRegistry() + + async def h1(): + pass + + async def h2(): + pass + + reg.register("a", "a tool", h1) + reg.register("b", "b tool", h2) + assert len(reg.all()) == 2 + + def test_by_category(self): + reg = ToolRegistry() + + async def h(): + pass + + reg.register("a", "a", h, category="db") + reg.register("b", "b", h, category="analytics") + assert len(reg.by_category("db")) == 1 + assert len(reg.by_category("analytics")) == 1 + + +# ─── @tool decorator ─── + + +class TestToolDecorator: + def test_decorator_registers(self): + @tool(name="decorated_tool", description="test", uses_context=False) + async def my_func(x: int) -> int: + return x * 2 + + t = tool_registry.get("decorated_tool") + assert t is not None + assert t.uses_context is False + assert t.handler is my_func + assert getattr(my_func, "_ai_tool", False) is True + + +# ─── AIAgentConfig ─── + + +class TestAIAgentConfig: + def test_config(self): + cfg = AIAgentConfig(name="test", model="openai:gpt-4o") + assert cfg.name == "test" + assert cfg.model == "openai:gpt-4o" + assert cfg.retries == 1 + assert cfg.tools == [] + + def test_get_tool(self): + async def h(): + pass + + t = Tool(name="x", description="x", handler=h) + cfg = AIAgentConfig(name="test", model="m", tools=[t]) + assert cfg.get_tool("x") is t + assert cfg.get_tool("y") is None + + +# ─── AIConfig ─── + + +class TestAIConfig: + def test_defaults(self): + cfg = AIConfig() + assert cfg.agents == [] + assert cfg.default_agent == "default" + assert cfg.dashboard_enabled is True + assert cfg.log_retention_days == 30 + + +# ─── AdminDeps ─── + + +class TestAdminDeps: + def test_dataclass(self): + deps = AdminDeps( + session=MagicMock(), + admin_user=MagicMock(), + request=MagicMock(), + registry=MagicMock(), + permission_checker=MagicMock(), + ) + assert deps.session is not None + assert deps.admin_user is not None From 7664d2e916263d50afaf00459b39eb0c181d1293 Mon Sep 17 00:00:00 2001 From: borhanst Date: Mon, 20 Jul 2026 11:51:35 +0600 Subject: [PATCH 02/42] fix: Correct typo in performance counter function name --- fastapi_admin_kit/ai/backends/pydantic_ai_backend.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastapi_admin_kit/ai/backends/pydantic_ai_backend.py b/fastapi_admin_kit/ai/backends/pydantic_ai_backend.py index 3c7d2fd..937a7af 100644 --- a/fastapi_admin_kit/ai/backends/pydantic_ai_backend.py +++ b/fastapi_admin_kit/ai/backends/pydantic_ai_backend.py @@ -82,7 +82,7 @@ async def chat( "pydantic-ai is not installed. Install with: pip install pydantic-ai" ) - start = time.perf_counter() + start = time.perfgit_counter() result = await self._agent.run(message, deps=deps, message_history=message_history) latency_ms = int((time.perf_counter() - start) * 1000) From 95cc2f018e68544d4e85ce8617d8c2cd0f163b04 Mon Sep 17 00:00:00 2001 From: borhanst Date: Tue, 21 Jul 2026 20:03:30 +0600 Subject: [PATCH 03/42] feat: add AI tools registry page and floating chat widget - Implemented a new AI tools registry page with a responsive grid layout to display registered tools. - Added a floating AI chat widget that allows users to interact with the AI assistant from any page. - Enhanced the chat widget with message handling, typing indicators, and input resizing. - Updated dependencies for AI-related packages to support new features and improvements. --- example_ai.py | 536 ++++++++ fastapi_admin_kit/admin/core.py | 18 +- fastapi_admin_kit/ai/agent.py | 1 + .../ai/backends/pydantic_ai_backend.py | 99 +- fastapi_admin_kit/ai/config.py | 1 + fastapi_admin_kit/ai/dashboard.py | 123 +- fastapi_admin_kit/ai/plugin.py | 7 +- fastapi_admin_kit/templates/base.html | 1 + .../templates/pages/ai/agents.html | 228 ++++ .../templates/pages/ai/chat.html | 552 +++++++++ .../templates/pages/ai/dashboard.html | 2 +- .../templates/pages/ai/logs.html | 1098 +++++++++++++++++ .../templates/pages/ai/tools.html | 197 +++ .../templates/partials/ai_chat_widget.html | 524 ++++++++ pyproject.toml | 6 +- 15 files changed, 3348 insertions(+), 45 deletions(-) create mode 100644 example_ai.py create mode 100644 fastapi_admin_kit/templates/pages/ai/agents.html create mode 100644 fastapi_admin_kit/templates/pages/ai/chat.html create mode 100644 fastapi_admin_kit/templates/pages/ai/logs.html create mode 100644 fastapi_admin_kit/templates/pages/ai/tools.html create mode 100644 fastapi_admin_kit/templates/partials/ai_chat_widget.html diff --git a/example_ai.py b/example_ai.py new file mode 100644 index 0000000..4cbe4c1 --- /dev/null +++ b/example_ai.py @@ -0,0 +1,536 @@ +"""Example usage of FastAPI Admin Kit with AI Agent Integration. + +Demonstrates: + - Enabling the AI agent system with AIConfig + - Configuring multiple AI agents (default + specialist) + - Creating custom tools with the @tool decorator + - Using built-in tools (query_database, create_record, etc.) + - Accessing the AI chat UI and dashboard + +Run: + pip install -e ".[ai]" + python example_ai.py + +Then visit: + Admin Panel: http://localhost:8000/admin + AI Chat: http://localhost:8000/admin/ai/chat + AI Dashboard: http://localhost:8000/admin/ai/dashboard + API Docs: http://localhost:8000/docs + +Default admin login: + Email: admin@example.com + Password: admin +""" + +from __future__ import annotations + +import os +from contextlib import asynccontextmanager +from typing import Any + +import bcrypt +from fastapi import FastAPI +from sqlalchemy import ( + Boolean, + Column, + DateTime, + Float, + Integer, + String, + Text, + select, +) +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine +from sqlalchemy.orm import DeclarativeBase, sessionmaker +from sqlalchemy.sql import func + +from fastapi_admin_kit import Admin, ModelAdmin +from fastapi_admin_kit.ai import AIAgentConfig, AIConfig, tool +from fastapi_admin_kit.ai.usage import AIUsageLog # noqa: F401 +from fastapi_admin_kit.audit.models import AuditLog # noqa: F401 +from fastapi_admin_kit.auth.backend import BuiltinAuthBackend +from fastapi_admin_kit.auth.models import User # noqa: F401 +from fastapi_admin_kit.config import ThemeConfig +from fastapi_admin_kit.models import Base as AdminBase +from fastapi_admin_kit.nav import NavGroupConfig +from dotenv import load_dotenv + +load_dotenv() + +# ============================================================================ +# SQLAlchemy Models +# ============================================================================ + + +class Base(DeclarativeBase): + pass + + +class Product(Base): + __tablename__ = "products" + + id = Column(Integer, primary_key=True) + name = Column(String(100), nullable=False) + description = Column(Text, nullable=True) + price = Column(Float, nullable=False) + stock = Column(Integer, default=0) + is_active = Column(Boolean, default=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + def __str__(self) -> str: + return self.name + + +class Customer(Base): + __tablename__ = "customers" + + id = Column(Integer, primary_key=True) + name = Column(String(100), nullable=False) + email = Column(String(255), nullable=False, unique=True) + tier = Column(String(20), default="standard") + total_spent = Column(Float, default=0.0) + is_active = Column(Boolean, default=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + def __str__(self) -> str: + return self.name + + +class Ticket(Base): + __tablename__ = "tickets" + + id = Column(Integer, primary_key=True) + subject = Column(String(200), nullable=False) + body = Column(Text, nullable=True) + status = Column(String(20), default="open") + priority = Column(String(10), default="medium") + customer_id = Column(Integer, nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + def __str__(self) -> str: + return f"#{self.id} {self.subject}" + + +# ============================================================================ +# ModelAdmin +# ============================================================================ + + +class ProductAdmin(ModelAdmin): + list_display = ["id", "name", "price", "stock", "is_active"] + search_fields = ["name"] + list_filter = ["is_active", "created_at"] + ordering = ["-created_at"] + verbose_name = "Product" + verbose_name_plural = "Products" + tag = "catalog" + icon = "cube" + + +class CustomerAdmin(ModelAdmin): + list_display = [ + "id", "name", "email", "tier", "total_spent", "is_active", + ] + search_fields = ["name", "email"] + list_filter = ["tier", "is_active"] + ordering = ["-created_at"] + verbose_name = "Customer" + verbose_name_plural = "Customers" + tag = "crm" + icon = "group" + + +class TicketAdmin(ModelAdmin): + list_display = ["id", "subject", "status", "priority", "created_at"] + search_fields = ["subject"] + list_filter = ["status", "priority"] + ordering = ["-created_at"] + verbose_name = "Ticket" + verbose_name_plural = "Tickets" + tag = "support" + icon = "support_agent" + + +# ============================================================================ +# Custom AI Tools +# ============================================================================ + + +@tool( + name="search_products", + description="Search products by name or description.", + category="ecommerce", +) +async def search_products( + ctx: Any, query: str, limit: int = 10 +) -> dict: + """Search products across name and description fields.""" + session = ctx.deps.session + stmt = ( + select(Product) + .where( + Product.name.ilike(f"%{query}%") + | Product.description.ilike(f"%{query}%") + ) + .limit(limit) + ) + + result = await session.execute(stmt) + products = result.scalars().all() + + return { + "count": len(products), + "products": [ + { + "id": p.id, + "name": p.name, + "price": p.price, + "stock": p.stock, + "is_active": p.is_active, + } + for p in products + ], + } + + +@tool( + name="get_customer_summary", + description="Get customer data summary.", + category="crm", +) +async def get_customer_summary( + ctx: Any, customer_id: int | None = None +) -> dict: + """Get customer summary or aggregate stats.""" + session = ctx.deps.session + + if customer_id: + result = await session.execute( + select(Customer).where(Customer.id == customer_id) + ) + customer = result.scalars().first() + if not customer: + return {"error": f"Customer {customer_id} not found"} + return { + "id": customer.id, + "name": customer.name, + "email": customer.email, + "tier": customer.tier, + "total_spent": customer.total_spent, + } + + result = await session.execute(select(Customer)) + customers = result.scalars().all() + + tiers: dict[str, int] = {} + for c in customers: + tiers.setdefault(c.tier, 0) + tiers[c.tier] += 1 + + return { + "total_customers": len(customers), + "by_tier": tiers, + "total_revenue": sum(c.total_spent for c in customers), + } + + +@tool( + name="get_support_stats", + description="Get support ticket statistics.", + category="support", +) +async def get_support_stats(ctx: Any) -> dict: + """Aggregate support ticket statistics.""" + session = ctx.deps.session + result = await session.execute(select(Ticket)) + tickets = result.scalars().all() + + by_status: dict[str, int] = {} + by_priority: dict[str, int] = {} + for t in tickets: + by_status.setdefault(t.status, 0) + by_status[t.status] += 1 + by_priority.setdefault(t.priority, 0) + by_priority[t.priority] += 1 + + total = len(tickets) + resolved = by_status.get("resolved", 0) + rate = f"{(resolved / total * 100):.1f}%" if total else "N/A" + + return { + "total_tickets": total, + "by_status": by_status, + "by_priority": by_priority, + "resolution_rate": rate, + } + + +@tool( + name="update_ticket_status", + description="Update a support ticket status.", + category="support", +) +async def update_ticket_status( + ctx: Any, ticket_id: int, status: str +) -> dict: + """Update a ticket's status field.""" + valid = {"open", "in_progress", "resolved"} + if status not in valid: + return {"error": f"Invalid status. Must be one of: {valid}"} + + session = ctx.deps.session + result = await session.execute( + select(Ticket).where(Ticket.id == ticket_id) + ) + ticket = result.scalars().first() + if not ticket: + return {"error": f"Ticket {ticket_id} not found"} + + old_status = ticket.status + ticket.status = status + await session.flush() + + return { + "ticket_id": ticket_id, + "old_status": old_status, + "new_status": status, + } + + +# ============================================================================ +# AI Configuration +# +# Supported model strings: +# Groq: "groq:llama-3.3-70b-versatile", "groq:llama-3.1-8b-instant" +# Google: "google:gemini-2.0-flash", "google:gemini-1.5-pro" +# OpenAI: "openai:gpt-4o-mini", "openai:gpt-4o" +# Anthropic: "anthropic:claude-3-5-sonnet-latest" +# ============================================================================ + +ai_config = AIConfig( + agents=[ + AIAgentConfig( + name="default", + model="groq:llama-3.3-70b-versatile", + api_key=os.environ.get("GROQ_API_KEY"), + system_prompt=( + "You are a helpful admin assistant. You can query the " + "database, search products, manage customers, and handle " + "support tickets. Always be concise and accurate." + ), + cost_per_1k_input_tokens=0.00059, + cost_per_1k_output_tokens=0.00079, + ), + ], + default_agent="default", + dashboard_enabled=True, + log_retention_days=30, +) + + +# ============================================================================ +# Database Setup +# ============================================================================ + +DATABASE_URL = os.getenv( + "DATABASE_URL", "sqlite+aiosqlite:///./example_ai.db" +) +SECRET_KEY = os.getenv("SECRET_KEY", "dev-secret-key-change-in-production-at-least-32chars") + +engine = create_async_engine(DATABASE_URL, echo=False) +async_session_maker = sessionmaker( + engine, class_=AsyncSession, expire_on_commit=False +) + + +async def seed_data(session: AsyncSession) -> None: + """Insert sample data if tables are empty.""" + result = await session.execute(select(Product).limit(1)) + if result.scalars().first() is not None: + return + + products = [ + Product( + name="Laptop Pro 16", + description="High-performance laptop", + price=1999.99, stock=25, is_active=True, + ), + Product( + name="Wireless Mouse", + description="Ergonomic wireless mouse", + price=49.99, stock=200, is_active=True, + ), + Product( + name="USB-C Hub", + description="7-in-1 USB-C hub", + price=79.99, stock=150, is_active=True, + ), + Product( + name='Monitor 27"', + description="4K IPS monitor", + price=599.99, stock=30, is_active=True, + ), + Product( + name="Keyboard Mech", + description="Mechanical keyboard RGB", + price=129.99, stock=0, is_active=False, + ), + ] + session.add_all(products) + + customers = [ + Customer( + name="Alice Johnson", + email="alice@example.com", + tier="vip", + total_spent=4500.00, + ), + Customer( + name="Bob Smith", + email="bob@example.com", + tier="premium", + total_spent=1200.00, + ), + Customer( + name="Carol White", + email="carol@example.com", + tier="standard", + total_spent=350.00, + ), + Customer( + name="Dave Brown", + email="dave@example.com", + tier="premium", + total_spent=2100.00, + ), + ] + session.add_all(customers) + + tickets = [ + Ticket( + subject="Order not received", + body="Order #1234 hasn't arrived", + status="open", priority="high", customer_id=1, + ), + Ticket( + subject="Defective product", + body="Mouse scroll not working", + status="in_progress", priority="medium", customer_id=2, + ), + Ticket( + subject="Billing question", + body="Charged twice for order", + status="open", priority="urgent", customer_id=3, + ), + Ticket( + subject="Feature request", + body="Dark mode support", + status="resolved", priority="low", customer_id=4, + ), + ] + session.add_all(tickets) + + await session.commit() + print("Seeded AI example data.") + + +async def seed_admin(session: AsyncSession) -> None: + """Create default admin user.""" + result = await session.execute(select(User).limit(1)) + if result.scalars().first() is not None: + return + + hashed = bcrypt.hashpw(b"admin", bcrypt.gensalt()).decode() + admin_user = User( + email="admin@example.com", + hashed_password=hashed, + full_name="Admin", + is_superuser=True, + is_active=True, + ) + session.add(admin_user) + await session.commit() + print("Created admin: admin@example.com / admin") + + +# ============================================================================ +# FastAPI App +# ============================================================================ + + +@asynccontextmanager +async def lifespan(app: FastAPI): + print("Starting AI Example...") + + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + await conn.run_sync(AdminBase.metadata.create_all) + + async with async_session_maker() as session: + await seed_data(session) + await seed_admin(session) + + await admin.setup(app) + print("Ready! Visit http://localhost:8000/admin") + print("AI Chat: http://localhost:8000/admin/ai/chat") + + yield + + await engine.dispose() + + +app = FastAPI( + title="FastAPI Admin Kit - AI Example", + description="AI agent integration with custom tools", + version="1.0.0", + lifespan=lifespan, +) + +admin = Admin( + app=app, + engine=engine, + base=Base, + title="AI Admin Panel", + admin_path="/admin", + secret_key=SECRET_KEY, + auth_backend=BuiltinAuthBackend(), + # AI + ai_enabled=True, + ai=ai_config, + # Theme + theme=ThemeConfig(preset="paper", primary_color="#6366F1"), + # Navigation + nav_groups=[ + NavGroupConfig( + tag="catalog", label="CATALOG", + icon="inventory_2", order=1, + ), + NavGroupConfig( + tag="crm", label="CRM", + icon="group", order=2, + ), + NavGroupConfig( + tag="support", label="SUPPORT", + icon="support_agent", order=3, + ), + ], +) + +admin.register(Product, ProductAdmin) +admin.register(Customer, CustomerAdmin) +admin.register(Ticket, TicketAdmin) + + +@app.get("/") +async def root(): + return { + "message": "AI Example - visit /admin", + "ai_chat": "/admin/ai/chat", + "ai_dashboard": "/admin/ai/dashboard", + } + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/fastapi_admin_kit/admin/core.py b/fastapi_admin_kit/admin/core.py index 5dc5147..e2fa46c 100644 --- a/fastapi_admin_kit/admin/core.py +++ b/fastapi_admin_kit/admin/core.py @@ -299,6 +299,10 @@ def __init__( self._nav_groups_built: list[Any] = [] # AI + if ai_enabled and ai is None: + from fastapi_admin_kit.ai.config import AIConfig + + ai = AIConfig() self._ai_config = ai self._ai_enabled = ai_enabled @@ -1019,29 +1023,35 @@ def _add_ai_nav_group(self) -> None: order=900, collapsed_by_default=False, extra_items=[ + NavItemConfig( + label="Chat", + url="/admin/ai/chat", + icon="chat", + order=1, + ), NavItemConfig( label="Dashboard", url="/admin/ai/dashboard", icon="monitoring", - order=1, + order=2, ), NavItemConfig( label="Logs", url="/admin/ai/logs", icon="description", - order=2, + order=3, ), NavItemConfig( label="Tools", url="/admin/ai/tools", icon="build", - order=3, + order=4, ), NavItemConfig( label="Agents", url="/admin/ai/agents", icon="smart_toy", - order=4, + order=5, ), ], ) diff --git a/fastapi_admin_kit/ai/agent.py b/fastapi_admin_kit/ai/agent.py index 893a68d..72b09c5 100644 --- a/fastapi_admin_kit/ai/agent.py +++ b/fastapi_admin_kit/ai/agent.py @@ -62,6 +62,7 @@ async def chat( message: str, deps: AdminDeps, message_history: list | None = None, + conversation_id: str | None = None, ) -> ChatResult: ... @abstractmethod diff --git a/fastapi_admin_kit/ai/backends/pydantic_ai_backend.py b/fastapi_admin_kit/ai/backends/pydantic_ai_backend.py index 937a7af..b222809 100644 --- a/fastapi_admin_kit/ai/backends/pydantic_ai_backend.py +++ b/fastapi_admin_kit/ai/backends/pydantic_ai_backend.py @@ -51,10 +51,12 @@ def __init__( try: from pydantic_ai import Agent + model = self._build_model(config) + self._agent = Agent( - config.model, + model, deps_type=AdminDeps, - result_type=config.result_type or str, + output_type=config.result_type or str, system_prompt=config.system_prompt, retries=config.retries, ) @@ -62,6 +64,49 @@ def __init__( except ImportError: self._agent = None + def _build_model(self, config: Any) -> Any: + """Build a pydantic-ai model, injecting api_key if provided.""" + model_str = config.model + + if not config.api_key: + return model_str + + provider_name = model_str.split(":")[0] if ":" in model_str else "" + + if provider_name == "google": + from pydantic_ai.models.google import GoogleModel + from pydantic_ai.providers.google import GoogleProvider + + model_name = model_str.split(":", 1)[1] if ":" in model_str else model_str + provider = GoogleProvider(api_key=config.api_key) + return GoogleModel(model_name, provider=provider) + + if provider_name == "openai": + from pydantic_ai.models.openai import OpenAIModel + from pydantic_ai.providers.openai import OpenAIProvider + + model_name = model_str.split(":", 1)[1] if ":" in model_str else model_str + provider = OpenAIProvider(api_key=config.api_key) + return OpenAIModel(model_name, provider=provider) + + if provider_name == "anthropic": + from pydantic_ai.models.anthropic import AnthropicModel + from pydantic_ai.providers.anthropic import AnthropicProvider + + model_name = model_str.split(":", 1)[1] if ":" in model_str else model_str + provider = AnthropicProvider(api_key=config.api_key) + return AnthropicModel(model_name, provider=provider) + + if provider_name == "groq": + from pydantic_ai.models.groq import GroqModel + from pydantic_ai.providers.groq import GroqProvider + + model_name = model_str.split(":", 1)[1] if ":" in model_str else model_str + provider = GroqProvider(api_key=config.api_key) + return GroqModel(model_name, provider=provider) + + return model_str + def _bind_tools(self, tools: list[Any]) -> None: if self._agent is None: return @@ -76,44 +121,70 @@ async def chat( message: str, deps: AdminDeps, message_history: list | None = None, + conversation_id: str | None = None, ) -> ChatResult: if self._agent is None: raise RuntimeError( "pydantic-ai is not installed. Install with: pip install pydantic-ai" ) - start = time.perfgit_counter() - result = await self._agent.run(message, deps=deps, message_history=message_history) + start = time.perf_counter() + result = await self._agent.run( + message, + deps=deps, + message_history=message_history, + conversation_id=conversation_id, + ) latency_ms = int((time.perf_counter() - start) * 1000) - usage = result.usage() + usage = result.usage cost = self._compute_cost(usage) tool_calls = _extract_tool_calls(result) + input_tokens = getattr(usage, "input_tokens", None) or 0 + output_tokens = getattr(usage, "output_tokens", None) or 0 + total_tokens = input_tokens + output_tokens + await self._usage_writer.write( agent_name=self._config.name, model=str(self._config.model), - request_tokens=getattr(usage, "request_tokens", None) or 0, - response_tokens=getattr(usage, "response_tokens", None) or 0, - total_tokens=getattr(usage, "total_tokens", None) or 0, + request_tokens=input_tokens, + response_tokens=output_tokens, + total_tokens=total_tokens, cost=cost, user=deps.admin_user, success=True, latency_ms=latency_ms, tool_calls=[ - {"name": tc.name, "args": tc.args, "ok": tc.is_error is False} for tc in tool_calls + { + "name": tc.name, + "args": tc.args, + "ok": tc.is_error is False, + } + for tc in tool_calls ], session=deps.session, ) return ChatResult( - output=result.data, - usage=UsageInfo.from_pydantic_ai(usage, cost), + output=result.output, + usage=UsageInfo( + request_tokens=input_tokens, + response_tokens=output_tokens, + total_tokens=total_tokens, + cost=cost, + ), new_messages=result.new_messages(), tool_calls=tool_calls, + conversation_id=result.conversation_id, ) - def chat_stream(self, message: str, deps: AdminDeps, message_history: list | None = None): + def chat_stream( + self, + message: str, + deps: AdminDeps, + message_history: list | None = None, + ): if self._agent is None: raise RuntimeError("pydantic-ai is not installed.") @@ -143,8 +214,8 @@ async def get_usage_stats(self, period: str = "day") -> dict: def _compute_cost(self, usage: Any) -> float: cfg = self._config - req = (getattr(usage, "request_tokens", None) or 0) / 1000 - resp = (getattr(usage, "response_tokens", None) or 0) / 1000 + req = (getattr(usage, "input_tokens", None) or 0) / 1000 + resp = (getattr(usage, "output_tokens", None) or 0) / 1000 in_cost = req * cfg.cost_per_1k_input_tokens out_cost = resp * cfg.cost_per_1k_output_tokens return round(in_cost + out_cost, 6) diff --git a/fastapi_admin_kit/ai/config.py b/fastapi_admin_kit/ai/config.py index 11dc57d..d7cfc99 100644 --- a/fastapi_admin_kit/ai/config.py +++ b/fastapi_admin_kit/ai/config.py @@ -16,6 +16,7 @@ class AIAgentConfig: name: str model: str system_prompt: str = "" + api_key: str | None = None result_type: type | None = None tools: list[Tool] = field(default_factory=list) retries: int = 1 diff --git a/fastapi_admin_kit/ai/dashboard.py b/fastapi_admin_kit/ai/dashboard.py index 7d26ca4..b705f9f 100644 --- a/fastapi_admin_kit/ai/dashboard.py +++ b/fastapi_admin_kit/ai/dashboard.py @@ -5,7 +5,7 @@ from typing import Any from fastapi import APIRouter, HTTPException, Request -from fastapi.responses import HTMLResponse, JSONResponse +from fastapi.responses import JSONResponse router = APIRouter(prefix="/ai", tags=["ai"]) @@ -22,8 +22,65 @@ def _get_ai_agents(request: Request) -> dict[str, Any]: return getattr(request.app.state, "ai_agents", {}) -@router.get("/dashboard", response_class=HTMLResponse) -async def ai_dashboard(request: Request) -> HTMLResponse: +async def _resolve_user(request: Request) -> Any: + """Manually resolve the admin user from the session cookie.""" + from fastapi_admin_kit.auth.dependencies import get_session + from fastapi_admin_kit.auth.identity import resolve_user + + session_payload = get_session(request) + if session_payload is None: + raise HTTPException(status_code=401, detail="Not authenticated.") + + user_id = session_payload.get("user_id") + if user_id is None: + raise HTTPException(status_code=401, detail="Invalid session.") + + user = await resolve_user(request, user_id) + if user is None: + raise HTTPException(status_code=401, detail="User not found.") + return user + + +async def _resolve_checker(request: Request, user: Any) -> Any: + """Manually build a permission checker.""" + from fastapi_admin_kit.auth.permissions import PermissionChecker + from fastapi_admin_kit.db import get_db_session + + session = get_db_session(request) + snapshot = getattr(request.state, "admin_user_snapshot", None) + return PermissionChecker(session=session, user=user, user_snapshot=snapshot) + + +@router.get("/chat") +async def ai_chat_page(request: Request): + """Full-page AI chat interface.""" + admin = _get_admin(request) + jinja = _get_jinja(request) + + context = { + "title": "AI Chat", + "admin_path": admin.admin_path if admin else "/admin", + } + context.update(admin.sidebar_template_kwargs(request) if admin else {}) + return jinja.TemplateResponse(request, "pages/ai/chat.html", context) + + +@router.get("/logs") +async def ai_logs_page(request: Request): + """Full-page AI logs viewer.""" + admin = _get_admin(request) + jinja = _get_jinja(request) + + context = { + "title": "AI Logs", + "admin_path": admin.admin_path if admin else "/admin", + } + context.update(admin.sidebar_template_kwargs(request) if admin else {}) + return jinja.TemplateResponse(request, "pages/ai/logs.html", context) + + +@router.get("/dashboard") +async def ai_dashboard(request: Request): """AI operations dashboard showing costs, logs, and tool calls.""" agents = _get_ai_agents(request) admin = _get_admin(request) @@ -48,11 +105,10 @@ async def ai_dashboard(request: Request) -> HTMLResponse: "admin_path": admin.admin_path if admin else "/admin", } context.update(admin.sidebar_template_kwargs(request) if admin else {}) - rendered = jinja.Template("pages/ai/dashboard.html").render(**context) - return HTMLResponse(rendered) + return jinja.TemplateResponse(request, "pages/ai/dashboard.html", context) -@router.get("/logs") +@router.get("/logs/api") async def get_ai_logs( request: Request, limit: int = 100, @@ -117,6 +173,20 @@ async def get_ai_costs( @router.get("/tools") +async def ai_tools_page(request: Request): + """Full-page AI tools viewer.""" + admin = _get_admin(request) + jinja = _get_jinja(request) + + context = { + "title": "AI Tools", + "admin_path": admin.admin_path if admin else "/admin", + } + context.update(admin.sidebar_template_kwargs(request) if admin else {}) + return jinja.TemplateResponse(request, "pages/ai/tools.html", context) + + +@router.get("/tools/api") async def get_ai_tools(request: Request) -> JSONResponse: """Get list of available AI tools.""" from fastapi_admin_kit.ai.tools import tool_registry @@ -151,17 +221,13 @@ async def execute_tool_endpoint( if agent is None: raise HTTPException(status_code=404, detail=f"Agent '{agent_name}' not found.") - from fastapi_admin_kit.auth.dependencies import ( - get_current_admin_user, - get_permission_checker, - ) + user = await _resolve_user(request) + checker = await _resolve_checker(request, user) + + from fastapi_admin_kit.ai.deps import AdminDeps from fastapi_admin_kit.db import get_db_session session = get_db_session(request) - user = await get_current_admin_user(request) - checker = await get_permission_checker(request, user, session) - - from fastapi_admin_kit.ai.deps import AdminDeps deps = AdminDeps( session=session, @@ -179,6 +245,20 @@ async def execute_tool_endpoint( @router.get("/agents") +async def ai_agents_page(request: Request): + """Full-page AI agents viewer.""" + admin = _get_admin(request) + jinja = _get_jinja(request) + + context = { + "title": "AI Agents", + "admin_path": admin.admin_path if admin else "/admin", + } + context.update(admin.sidebar_template_kwargs(request) if admin else {}) + return jinja.TemplateResponse(request, "pages/ai/agents.html", context) + + +@router.get("/agents/api") async def get_ai_agents(request: Request) -> JSONResponse: """Get list of configured AI agents.""" agents = _get_ai_agents(request) @@ -197,6 +277,9 @@ async def get_ai_agents(request: Request) -> JSONResponse: @router.post("/chat") async def ai_chat(request: Request) -> JSONResponse: """Send a message to an AI agent.""" + from fastapi_admin_kit.ai.deps import AdminDeps + from fastapi_admin_kit.db import get_db_session + body = await request.json() message = body.get("message", "") agent_name = body.get("agent", "default") @@ -207,17 +290,9 @@ async def ai_chat(request: Request) -> JSONResponse: if agent is None: raise HTTPException(status_code=404, detail=f"Agent '{agent_name}' not found.") - from fastapi_admin_kit.auth.dependencies import ( - get_current_admin_user, - get_permission_checker, - ) - from fastapi_admin_kit.db import get_db_session - + user = await _resolve_user(request) session = get_db_session(request) - user = await get_current_admin_user(request) - checker = await get_permission_checker(request, user, session) - - from fastapi_admin_kit.ai.deps import AdminDeps + checker = await _resolve_checker(request, user) deps = AdminDeps( session=session, diff --git a/fastapi_admin_kit/ai/plugin.py b/fastapi_admin_kit/ai/plugin.py index 7c73022..33ea501 100644 --- a/fastapi_admin_kit/ai/plugin.py +++ b/fastapi_admin_kit/ai/plugin.py @@ -19,7 +19,12 @@ def get_routes(self) -> Any: return router def get_nav_items(self) -> list[dict]: - return [{"label": "AI", "url": "/admin/ai/dashboard", "icon": "sparkles"}] + return [ + {"label": "AI Dashboard", "url": "/admin/ai/dashboard", "icon": "sparkles"}, + {"label": "AI Agents", "url": "/admin/ai/agents", "icon": "smart_toy"}, + {"label": "AI Tools", "url": "/admin/ai/tools", "icon": "build"}, + {"label": "AI Logs", "url": "/admin/ai/logs", "icon": "receipt_long"}, + ] def get_dashboard_widgets(self) -> list[Any]: return [] diff --git a/fastapi_admin_kit/templates/base.html b/fastapi_admin_kit/templates/base.html index 4b9f1db..78986e5 100644 --- a/fastapi_admin_kit/templates/base.html +++ b/fastapi_admin_kit/templates/base.html @@ -127,6 +127,7 @@ {% include "partials/command_palette.html" %} + {% include "partials/ai_chat_widget.html" %} {% set _ui2 = ui_config | default({}) %} {% if _ui2.custom_js_url | default('') %} diff --git a/fastapi_admin_kit/templates/pages/ai/agents.html b/fastapi_admin_kit/templates/pages/ai/agents.html new file mode 100644 index 0000000..cede036 --- /dev/null +++ b/fastapi_admin_kit/templates/pages/ai/agents.html @@ -0,0 +1,228 @@ +{# pages/ai/agents.html — AI Agents Registry #} +{% extends "base.html" %} + +{% block title %}{{ title | default("AI Agents") }}{% endblock %} + +{% block head_extra %} + +{% endblock %} + +{% block content %} +
+
+

AI Agents

+

Configured AI agents, their models, and available tool counts.

+
+ +
+ + + + + +
+
+ + +{% endblock %} diff --git a/fastapi_admin_kit/templates/pages/ai/chat.html b/fastapi_admin_kit/templates/pages/ai/chat.html new file mode 100644 index 0000000..f866e24 --- /dev/null +++ b/fastapi_admin_kit/templates/pages/ai/chat.html @@ -0,0 +1,552 @@ +{# pages/ai/chat.html — Full-page AI Chat Interface #} +{% extends "base.html" %} + +{% block title %}AI Chat{% endblock %} + +{% block head_extra %} + +{% endblock %} + +{% block content %} +
+
+
+
+ smart_toy +
+
+

AI Assistant

+

Ask anything about your data

+
+
+
+ + +
+
+ +
+ + + + + +
+ +
+
+ + +
+
+
+ + +{% endblock %} diff --git a/fastapi_admin_kit/templates/pages/ai/dashboard.html b/fastapi_admin_kit/templates/pages/ai/dashboard.html index df3c6f7..7cd1d02 100644 --- a/fastapi_admin_kit/templates/pages/ai/dashboard.html +++ b/fastapi_admin_kit/templates/pages/ai/dashboard.html @@ -95,7 +95,7 @@

+ .ai-logs-page { + display: flex; + flex-direction: column; + gap: 24px; + } + + /* ── Header ─────────────────────────────────────────────────── */ + .ai-logs-header { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; + } + + .ai-logs-header h1 { + font-family: var(--font-display); + font-size: var(--text-2xl); + font-weight: 700; + color: var(--text-primary); + margin: 0; + letter-spacing: -0.02em; + } + + .ai-logs-header p { + font-size: var(--text-sm); + color: var(--text-secondary); + margin: 4px 0 0; + } + + .ai-logs-live-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--success-500); + display: inline-block; + margin-right: 6px; + animation: logsPulse 2s ease-in-out infinite; + } + + @keyframes logsPulse { + 0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(34,197,94,0.4); } + 50% { opacity: 0.7; box-shadow: 0 0 0 6px rgba(34,197,94,0); } + } + + /* ── Stat chips ─────────────────────────────────────────────── */ + .ai-logs-stats { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 12px; + } + + .ai-stat-chip { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 16px; + background: var(--surface-raised); + border: 1px solid var(--surface-border); + border-radius: var(--radius-lg); + transition: border-color var(--duration-fast) var(--easing-out), + box-shadow var(--duration-fast) var(--easing-out); + } + + .ai-stat-chip:hover { + border-color: var(--primary-400); + box-shadow: 0 2px 8px rgba(16, 185, 129, 0.08); + } + + .ai-stat-chip-icon { + width: 38px; + height: 38px; + border-radius: var(--radius-md); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + } + + .ai-stat-chip-icon .material-symbols-outlined { + font-size: 18px; + } + + .ai-stat-chip-icon--emerald { + background: rgba(16, 185, 129, 0.1); + color: var(--primary-600); + } + + .ai-stat-chip-icon--blue { + background: rgba(59, 130, 246, 0.1); + color: var(--info-700); + } + + .ai-stat-chip-icon--amber { + background: rgba(245, 158, 11, 0.1); + color: var(--warning-700); + } + + .ai-stat-chip-icon--red { + background: rgba(239, 68, 68, 0.1); + color: var(--danger-700); + } + + .ai-stat-chip-icon--violet { + background: rgba(139, 92, 246, 0.1); + color: #7C3AED; + } + + .ai-stat-chip-value { + font-family: var(--font-mono); + font-size: var(--text-lg); + font-weight: 600; + color: var(--text-primary); + line-height: 1; + } + + .ai-stat-chip-label { + font-size: var(--text-xs); + color: var(--text-tertiary); + margin-top: 2px; + } + + /* ── Filter bar ─────────────────────────────────────────────── */ + .ai-logs-filters { + display: flex; + align-items: center; + gap: 10px; + padding: 14px 18px; + background: var(--surface-raised); + border: 1px solid var(--surface-border); + border-radius: var(--radius-lg); + flex-wrap: wrap; + } + + .ai-logs-filters label { + font-size: var(--text-xs); + font-weight: 500; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.05em; + } + + .ai-filter-select, + .ai-filter-input { + padding: 7px 12px; + border: 1px solid var(--surface-border); + border-radius: var(--radius-md); + background: var(--surface-base); + color: var(--text-primary); + font-size: var(--text-sm); + font-family: var(--font-body); + outline: none; + transition: border-color var(--duration-fast) var(--easing-out); + } + + .ai-filter-select:focus, + .ai-filter-input:focus { + border-color: var(--primary-500); + box-shadow: var(--shadow-focus); + } + + .ai-filter-select { + cursor: pointer; + padding-right: 30px; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2364748B' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 10px center; + } + + .ai-filter-input { + min-width: 160px; + } + + .ai-filter-divider { + width: 1px; + height: 24px; + background: var(--surface-border); + flex-shrink: 0; + } + + .ai-filter-reset { + padding: 7px 14px; + border: 1px solid var(--surface-border); + border-radius: var(--radius-md); + background: var(--surface-raised); + color: var(--text-secondary); + font-size: var(--text-sm); + cursor: pointer; + transition: all var(--duration-fast) var(--easing-out); + font-family: var(--font-body); + display: flex; + align-items: center; + gap: 6px; + margin-left: auto; + } + + .ai-filter-reset:hover { + border-color: var(--danger-500); + color: var(--danger-700); + background: var(--danger-50); + } + + .ai-filter-reset .material-symbols-outlined { + font-size: 15px; + } + + /* ── Log stream ─────────────────────────────────────────────── */ + .ai-logs-stream { + display: flex; + flex-direction: column; + gap: 2px; + background: var(--surface-raised); + border: 1px solid var(--surface-border); + border-radius: var(--radius-lg); + overflow: hidden; + } + + .ai-log-entry { + display: grid; + grid-template-columns: 40px 1fr auto; + gap: 16px; + align-items: start; + padding: 16px 20px; + border-bottom: 1px solid var(--surface-border); + transition: background var(--duration-fast) var(--easing-out); + cursor: pointer; + position: relative; + } + + .ai-log-entry:last-child { + border-bottom: none; + } + + .ai-log-entry:hover { + background: var(--surface-inset); + } + + .ai-log-entry.expanded { + background: var(--surface-inset); + } + + /* Timeline dot */ + .ai-log-timeline { + display: flex; + flex-direction: column; + align-items: center; + padding-top: 4px; + } + + .ai-log-timeline-dot { + width: 10px; + height: 10px; + border-radius: 50%; + flex-shrink: 0; + position: relative; + } + + .ai-log-timeline-dot--success { + background: var(--success-500); + box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.15); + } + + .ai-log-timeline-dot--error { + background: var(--danger-500); + box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.15); + } + + .ai-log-timeline-line { + width: 1px; + flex: 1; + min-height: 20px; + background: var(--surface-border); + margin-top: 6px; + } + + /* Main content */ + .ai-log-body { + min-width: 0; + } + + .ai-log-title-row { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + } + + .ai-log-agent-name { + font-family: var(--font-display); + font-weight: 600; + font-size: var(--text-sm); + color: var(--text-primary); + } + + .ai-log-model { + font-family: var(--font-mono); + font-size: var(--text-2xs); + padding: 2px 8px; + border-radius: var(--radius-sm); + background: var(--surface-inset); + color: var(--text-secondary); + border: 1px solid var(--surface-border); + } + + .ai-log-status-badge { + font-size: var(--text-2xs); + font-weight: 500; + padding: 2px 8px; + border-radius: var(--radius-full); + text-transform: uppercase; + letter-spacing: 0.04em; + } + + .ai-log-status-badge--success { + background: rgba(34, 197, 94, 0.1); + color: var(--success-700); + } + + .ai-log-status-badge--error { + background: rgba(239, 68, 68, 0.1); + color: var(--danger-700); + } + + .ai-log-meta { + display: flex; + align-items: center; + gap: 12px; + margin-top: 6px; + flex-wrap: wrap; + } + + .ai-log-meta-item { + display: flex; + align-items: center; + gap: 4px; + font-size: var(--text-xs); + color: var(--text-tertiary); + } + + .ai-log-meta-item .material-symbols-outlined { + font-size: 13px; + } + + .ai-log-meta-item--tokens { + font-family: var(--font-mono); + color: var(--primary-600); + font-weight: 500; + } + + .ai-log-meta-item--cost { + font-family: var(--font-mono); + color: var(--warning-700); + font-weight: 500; + } + + .ai-log-user { + font-size: var(--text-xs); + color: var(--text-tertiary); + margin-top: 4px; + } + + /* Right side — timestamp */ + .ai-log-timestamp { + text-align: right; + flex-shrink: 0; + } + + .ai-log-time { + font-size: var(--text-xs); + font-family: var(--font-mono); + color: var(--text-tertiary); + } + + .ai-log-date { + font-size: var(--text-2xs); + color: var(--text-disabled); + margin-top: 2px; + } + + /* ── Expanded detail panel ──────────────────────────────────── */ + .ai-log-detail { + grid-column: 1 / -1; + padding: 16px 0 0; + animation: logDetailIn 0.2s var(--easing-out); + } + + @keyframes logDetailIn { + from { opacity: 0; transform: translateY(-4px); } + to { opacity: 1; transform: translateY(0); } + } + + .ai-log-detail-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 12px; + padding: 14px 16px; + background: var(--surface-base); + border: 1px solid var(--surface-border); + border-radius: var(--radius-md); + } + + .ai-log-detail-field { + display: flex; + flex-direction: column; + gap: 2px; + } + + .ai-log-detail-label { + font-size: var(--text-2xs); + font-weight: 500; + color: var(--text-tertiary); + text-transform: uppercase; + letter-spacing: 0.06em; + } + + .ai-log-detail-value { + font-size: var(--text-sm); + color: var(--text-primary); + font-family: var(--font-mono); + word-break: break-all; + } + + .ai-log-detail-value--text { + font-family: var(--font-body); + } + + .ai-log-tool-calls { + margin-top: 12px; + padding: 14px 16px; + background: var(--surface-base); + border: 1px solid var(--surface-border); + border-radius: var(--radius-md); + } + + .ai-log-tool-calls-title { + font-size: var(--text-xs); + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.04em; + margin-bottom: 10px; + display: flex; + align-items: center; + gap: 6px; + } + + .ai-log-tool-calls-title .material-symbols-outlined { + font-size: 14px; + } + + .ai-log-tool-call { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 8px 10px; + background: var(--surface-raised); + border: 1px solid var(--surface-border); + border-radius: var(--radius-sm); + margin-bottom: 6px; + } + + .ai-log-tool-call:last-child { + margin-bottom: 0; + } + + .ai-log-tool-call-icon { + width: 24px; + height: 24px; + border-radius: var(--radius-sm); + background: rgba(139, 92, 246, 0.1); + color: #7C3AED; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + margin-top: 1px; + } + + .ai-log-tool-call-icon .material-symbols-outlined { + font-size: 14px; + } + + .ai-log-tool-call-body { + flex: 1; + min-width: 0; + } + + .ai-log-tool-call-name { + font-size: var(--text-xs); + font-weight: 600; + color: var(--text-primary); + font-family: var(--font-mono); + } + + .ai-log-tool-call-args { + font-size: var(--text-2xs); + color: var(--text-tertiary); + margin-top: 2px; + font-family: var(--font-mono); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + /* ── Error block ────────────────────────────────────────────── */ + .ai-log-error-block { + margin-top: 12px; + padding: 12px 14px; + background: rgba(239, 68, 68, 0.04); + border: 1px solid rgba(239, 68, 68, 0.15); + border-radius: var(--radius-md); + font-size: var(--text-xs); + color: var(--danger-700); + font-family: var(--font-mono); + line-height: 1.6; + word-break: break-all; + } + + /* ── Empty state ────────────────────────────────────────────── */ + .ai-logs-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 60px 24px; + gap: 12px; + } + + .ai-logs-empty-icon { + width: 56px; + height: 56px; + border-radius: var(--radius-lg); + background: var(--surface-inset); + display: flex; + align-items: center; + justify-content: center; + } + + .ai-logs-empty-icon .material-symbols-outlined { + font-size: 28px; + color: var(--text-disabled); + } + + .ai-logs-empty h3 { + font-family: var(--font-display); + font-size: var(--text-md); + font-weight: 600; + color: var(--text-secondary); + margin: 0; + } + + .ai-logs-empty p { + font-size: var(--text-sm); + color: var(--text-disabled); + margin: 0; + } + + /* ── Pagination ─────────────────────────────────────────────── */ + .ai-logs-pagination { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 18px; + background: var(--surface-raised); + border: 1px solid var(--surface-border); + border-radius: var(--radius-lg); + } + + .ai-logs-page-info { + font-size: var(--text-xs); + color: var(--text-tertiary); + } + + .ai-logs-page-info strong { + color: var(--text-primary); + font-weight: 600; + } + + .ai-logs-page-btns { + display: flex; + gap: 6px; + } + + .ai-logs-page-btn { + padding: 6px 14px; + border: 1px solid var(--surface-border); + border-radius: var(--radius-md); + background: var(--surface-raised); + color: var(--text-secondary); + font-size: var(--text-sm); + cursor: pointer; + transition: all var(--duration-fast) var(--easing-out); + font-family: var(--font-body); + display: flex; + align-items: center; + gap: 4px; + } + + .ai-logs-page-btn:hover:not(:disabled) { + border-color: var(--primary-500); + color: var(--primary-600); + } + + .ai-logs-page-btn:disabled { + opacity: 0.4; + cursor: not-allowed; + } + + .ai-logs-page-btn .material-symbols-outlined { + font-size: 16px; + } + + /* ── Loading skeleton ───────────────────────────────────────── */ + .ai-log-skeleton { + display: grid; + grid-template-columns: 40px 1fr auto; + gap: 16px; + padding: 16px 20px; + border-bottom: 1px solid var(--surface-border); + } + + .ai-skeleton-pulse { + background: linear-gradient(90deg, var(--surface-inset) 25%, var(--surface-border) 50%, var(--surface-inset) 75%); + background-size: 200% 100%; + animation: skeletonPulse 1.5s ease-in-out infinite; + border-radius: var(--radius-sm); + } + + @keyframes skeletonPulse { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } + } + + .ai-skeleton-dot { + width: 10px; + height: 10px; + border-radius: 50%; + margin-top: 6px; + } + + .ai-skeleton-line { + height: 12px; + border-radius: var(--radius-sm); + } + + .ai-skeleton-line--sm { width: 60px; height: 10px; } + .ai-skeleton-line--md { width: 140px; } + .ai-skeleton-line--lg { width: 220px; } + + /* ── Responsive ─────────────────────────────────────────────── */ + @media (max-width: 768px) { + .ai-logs-header { flex-direction: column; align-items: flex-start; } + .ai-logs-filters { flex-direction: column; align-items: stretch; } + .ai-filter-divider { width: 100%; height: 1px; } + .ai-filter-reset { margin-left: 0; } + .ai-log-entry { grid-template-columns: 32px 1fr; gap: 12px; } + .ai-log-timestamp { grid-column: 2; text-align: left; margin-top: 4px; } + .ai-log-detail-grid { grid-template-columns: 1fr; } + .ai-logs-stats { grid-template-columns: repeat(2, 1fr); } + } + +{% endblock %} + +{% block content %} +
+ {# ── Header ────────────────────────────────────────────────── #} +
+
+

AI Logs

+

Trace every AI operation — tokens, latency, tool calls, and costs.

+
+
+ + + Live +
+
+ + {# ── Stats ─────────────────────────────────────────────────── #} +
+
+
+ analytics +
+
+
+
Total Runs
+
+
+
+
+ token +
+
+
+
Total Tokens
+
+
+
+
+ attach_money +
+
+
+
Total Cost
+
+
+
+
+ speed +
+
+
+
Avg Latency
+
+
+
+
+ check_circle +
+
+
+
Success Rate
+
+
+
+ + {# ── Filters ───────────────────────────────────────────────── #} +
+ + + + + + + + +
+ + + + +
+ + {# ── Log stream ────────────────────────────────────────────── #} +
+ {# Loading skeletons #} + + + {# Empty state #} + + + {# Log entries #} + +
+ + {# ── Pagination ────────────────────────────────────────────── #} +
+
+ Showing + of logs +
+
+ + +
+
+
+ + +{% endblock %} diff --git a/fastapi_admin_kit/templates/pages/ai/tools.html b/fastapi_admin_kit/templates/pages/ai/tools.html new file mode 100644 index 0000000..6e8d1df --- /dev/null +++ b/fastapi_admin_kit/templates/pages/ai/tools.html @@ -0,0 +1,197 @@ +{# pages/ai/tools.html — AI Tools Registry #} +{% extends "base.html" %} + +{% block title %}{{ title | default("AI Tools") }}{% endblock %} + +{% block head_extra %} + +{% endblock %} + +{% block content %} +
+
+

AI Tools

+

Registered tools available to AI agents for executing operations.

+
+ +
+ + + + + +
+
+ + +{% endblock %} diff --git a/fastapi_admin_kit/templates/partials/ai_chat_widget.html b/fastapi_admin_kit/templates/partials/ai_chat_widget.html new file mode 100644 index 0000000..91fa385 --- /dev/null +++ b/fastapi_admin_kit/templates/partials/ai_chat_widget.html @@ -0,0 +1,524 @@ +{# partials/ai_chat_widget.html — Floating AI Chat Widget (bottom-right on all pages) #} +{% if admin_config.ai_enabled | default(false) %} +
+ + {# ── Floating toggle button ──────────────────────────────────────────── #} + + + {# ── Chat panel ──────────────────────────────────────────────────────── #} +
+ {# Header #} +
+
+
+ smart_toy +
+
+
AI Assistant
+
+ + Online +
+
+
+
+ + +
+
+ + {# Messages #} +
+ + + + + +
+ + {# Input #} +
+ + +
+
+
+ + + + +{% endif %} diff --git a/pyproject.toml b/pyproject.toml index 7b389d4..ac3ef67 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,7 +63,11 @@ sqlmodel = ["sqlmodel>=0.0.39"] postgres = ["asyncpg>=0.29.0"] mysql = ["aiomysql>=0.2.0"] ai = [ - "pydantic-ai>=0.0.14", + "pydantic-ai>=2.0.0", +] +ai-gemini = [ + "pydantic-ai>=2.0.0", + "google-genai>=1.0.0", ] docs = [ "mkdocs>=1.6.0", From 068d081ef20725db829f79348eb6428f56f1d5b7 Mon Sep 17 00:00:00 2001 From: borhanst Date: Wed, 22 Jul 2026 01:13:57 +0600 Subject: [PATCH 04/42] add proper type hint fix tool calling issue --- example_ai.py | 239 +++++++++++++++++- fastapi_admin_kit/ai/__init__.py | 6 +- fastapi_admin_kit/ai/agent.py | 18 +- .../ai/backends/pydantic_ai_backend.py | 39 +-- fastapi_admin_kit/ai/builtin_tools.py | 26 +- fastapi_admin_kit/ai/conversation.py | 37 ++- fastapi_admin_kit/ai/dashboard.py | 44 ++-- fastapi_admin_kit/ai/deps.py | 19 +- fastapi_admin_kit/ai/model_agent.py | 23 +- fastapi_admin_kit/ai/plugin.py | 21 +- fastapi_admin_kit/ai/tools.py | 17 +- fastapi_admin_kit/ai/usage.py | 20 +- 12 files changed, 397 insertions(+), 112 deletions(-) diff --git a/example_ai.py b/example_ai.py index 4cbe4c1..db80ca8 100644 --- a/example_ai.py +++ b/example_ai.py @@ -26,9 +26,10 @@ import os from contextlib import asynccontextmanager -from typing import Any +from typing import TYPE_CHECKING import bcrypt +from dotenv import load_dotenv from fastapi import FastAPI from sqlalchemy import ( Boolean, @@ -38,14 +39,15 @@ Integer, String, Text, + func, select, ) from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine from sqlalchemy.orm import DeclarativeBase, sessionmaker -from sqlalchemy.sql import func from fastapi_admin_kit import Admin, ModelAdmin from fastapi_admin_kit.ai import AIAgentConfig, AIConfig, tool +from fastapi_admin_kit.ai.tools import tool_registry from fastapi_admin_kit.ai.usage import AIUsageLog # noqa: F401 from fastapi_admin_kit.audit.models import AuditLog # noqa: F401 from fastapi_admin_kit.auth.backend import BuiltinAuthBackend @@ -53,7 +55,10 @@ from fastapi_admin_kit.config import ThemeConfig from fastapi_admin_kit.models import Base as AdminBase from fastapi_admin_kit.nav import NavGroupConfig -from dotenv import load_dotenv + +from pydantic_ai import RunContext +from fastapi_admin_kit.ai.deps import AdminDeps + load_dotenv() @@ -162,8 +167,8 @@ class TicketAdmin(ModelAdmin): category="ecommerce", ) async def search_products( - ctx: Any, query: str, limit: int = 10 -) -> dict: + ctx: RunContext[AdminDeps], query: str, limit: int = 10 +) -> dict[str, object]: """Search products across name and description fields.""" session = ctx.deps.session stmt = ( @@ -193,14 +198,72 @@ async def search_products( } +@tool( + name="get_product", + description="Get a single product by ID.", + category="ecommerce", +) +async def get_product( + ctx: RunContext[AdminDeps], product_id: int +) -> dict[str, object]: + """Look up a product by its ID.""" + session = ctx.deps.session + result = await session.execute( + select(Product).where(Product.id == product_id) + ) + product = result.scalars().first() + if not product: + return {"error": f"Product {product_id} not found"} + return { + "id": product.id, + "name": product.name, + "description": product.description, + "price": product.price, + "stock": product.stock, + "is_active": product.is_active, + } + + +@tool( + name="update_product_stock", + description="Update stock quantity for a product.", + category="ecommerce", +) +async def update_product_stock( + ctx: RunContext[AdminDeps], product_id: int, new_stock: int +) -> dict[str, object]: + """Set the stock level of a product.""" + if new_stock < 0: + return {"error": "Stock cannot be negative"} + + session = ctx.deps.session + result = await session.execute( + select(Product).where(Product.id == product_id) + ) + product = result.scalars().first() + if not product: + return {"error": f"Product {product_id} not found"} + + old_stock = product.stock + product.stock = new_stock + await session.flush() + + return { + "product_id": product_id, + "name": product.name, + "old_stock": old_stock, + "new_stock": new_stock, + } + + @tool( name="get_customer_summary", - description="Get customer data summary.", + description="Get customer data summary or stats for a specific customer.", category="crm", ) async def get_customer_summary( - ctx: Any, customer_id: int | None = None -) -> dict: + ctx: RunContext[AdminDeps], customer_id: int | None = None +) -> dict[str, object]: """Get customer summary or aggregate stats.""" session = ctx.deps.session @@ -234,12 +297,77 @@ async def get_customer_summary( } +@tool( + name="update_customer_tier", + description="Change a customer's membership tier.", + category="crm", +) +async def update_customer_tier( + ctx: RunContext[AdminDeps], customer_id: int, new_tier: str +) -> dict[str, object]: + """Update a customer's tier (standard, premium, vip).""" + valid_tiers = {"standard", "premium", "vip"} + if new_tier not in valid_tiers: + return {"error": f"Invalid tier. Must be one of: {valid_tiers}"} + + session = ctx.deps.session + result = await session.execute( + select(Customer).where(Customer.id == customer_id) + ) + customer = result.scalars().first() + if not customer: + return {"error": f"Customer {customer_id} not found"} + + old_tier = customer.tier + customer.tier = new_tier + await session.flush() + + return { + "customer_id": customer_id, + "name": customer.name, + "old_tier": old_tier, + "new_tier": new_tier, + } + + +@tool( + name="get_revenue_summary", + description="Get revenue breakdown by customer tier.", + category="crm", +) +async def get_revenue_summary( + ctx: RunContext[AdminDeps], +) -> dict[str, object]: + """Aggregate revenue stats across all customers.""" + session = ctx.deps.session + result = await session.execute(select(Customer)) + customers = result.scalars().all() + + by_tier: dict[str, dict[str, object]] = {} + total_revenue = 0.0 + for c in customers: + tier = c.tier + if tier not in by_tier: + by_tier[tier] = {"count": 0, "revenue": 0.0} + by_tier[tier]["count"] = int(by_tier[tier]["count"]) + 1 + by_tier[tier]["revenue"] = float(by_tier[tier]["revenue"]) + c.total_spent + total_revenue += c.total_spent + + return { + "total_customers": len(customers), + "total_revenue": total_revenue, + "by_tier": by_tier, + } + + @tool( name="get_support_stats", description="Get support ticket statistics.", category="support", ) -async def get_support_stats(ctx: Any) -> dict: +async def get_support_stats( + ctx: RunContext[AdminDeps], +) -> dict[str, object]: """Aggregate support ticket statistics.""" session = ctx.deps.session result = await session.execute(select(Ticket)) @@ -265,14 +393,47 @@ async def get_support_stats(ctx: Any) -> dict: } +@tool( + name="search_tickets", + description="Search support tickets by subject keyword.", + category="support", +) +async def search_tickets( + ctx: RunContext[AdminDeps], keyword: str, limit: int = 10 +) -> dict[str, object]: + """Find tickets matching a keyword in the subject.""" + session = ctx.deps.session + stmt = ( + select(Ticket) + .where(Ticket.subject.ilike(f"%{keyword}%")) + .limit(limit) + ) + result = await session.execute(stmt) + tickets = result.scalars().all() + + return { + "count": len(tickets), + "tickets": [ + { + "id": t.id, + "subject": t.subject, + "status": t.status, + "priority": t.priority, + "customer_id": t.customer_id, + } + for t in tickets + ], + } + + @tool( name="update_ticket_status", description="Update a support ticket status.", category="support", ) async def update_ticket_status( - ctx: Any, ticket_id: int, status: str -) -> dict: + ctx: RunContext[AdminDeps], ticket_id: int, status: str +) -> dict[str, object]: """Update a ticket's status field.""" valid = {"open", "in_progress", "resolved"} if status not in valid: @@ -297,6 +458,42 @@ async def update_ticket_status( } +@tool( + name="create_ticket", + description="Create a new support ticket.", + category="support", +) +async def create_ticket( + ctx: RunContext[AdminDeps], + subject: str, + body: str = "", + priority: str = "medium", + customer_id: int | None = None, +) -> dict[str, object]: + """Create a support ticket with subject, body, priority, and optional customer link.""" + valid_priorities = {"low", "medium", "high", "urgent"} + if priority not in valid_priorities: + return {"error": f"Invalid priority. Must be one of: {valid_priorities}"} + + session = ctx.deps.session + ticket = Ticket( + subject=subject, + body=body, + status="open", + priority=priority, + customer_id=customer_id, + ) + session.add(ticket) + await session.flush() + + return { + "ticket_id": ticket.id, + "subject": ticket.subject, + "status": ticket.status, + "priority": ticket.priority, + } + + # ============================================================================ # AI Configuration # @@ -314,12 +511,26 @@ async def update_ticket_status( model="groq:llama-3.3-70b-versatile", api_key=os.environ.get("GROQ_API_KEY"), system_prompt=( - "You are a helpful admin assistant. You can query the " - "database, search products, manage customers, and handle " - "support tickets. Always be concise and accurate." + "You are a helpful admin assistant for an e-commerce admin panel. " + "You have access to tools that query and modify the database. " + "ALWAYS use your tools to answer questions about products, customers, " + "tickets, and revenue. Never make up data — call the appropriate tool. " + "Be concise and accurate." ), cost_per_1k_input_tokens=0.00059, cost_per_1k_output_tokens=0.00079, + tools=[ + tool_registry.get("search_products"), # type: ignore[list-item] + tool_registry.get("get_product"), # type: ignore[list-item] + tool_registry.get("update_product_stock"), # type: ignore[list-item] + tool_registry.get("get_customer_summary"), # type: ignore[list-item] + tool_registry.get("update_customer_tier"), # type: ignore[list-item] + tool_registry.get("get_revenue_summary"), # type: ignore[list-item] + tool_registry.get("get_support_stats"), # type: ignore[list-item] + tool_registry.get("search_tickets"), # type: ignore[list-item] + tool_registry.get("update_ticket_status"), # type: ignore[list-item] + tool_registry.get("create_ticket"), # type: ignore[list-item] + ], ), ], default_agent="default", diff --git a/fastapi_admin_kit/ai/__init__.py b/fastapi_admin_kit/ai/__init__.py index 367b9b3..b2a14bd 100644 --- a/fastapi_admin_kit/ai/__init__.py +++ b/fastapi_admin_kit/ai/__init__.py @@ -1,16 +1,18 @@ """AI Agent Integration — Pydantic AI (Phase 1).""" -from fastapi_admin_kit.ai.agent import AIAgent, ChatResult +from fastapi_admin_kit.ai.agent import AIAgent, ChatResult, ToolCallRecord, UsageInfo from fastapi_admin_kit.ai.config import AIAgentConfig, AIConfig from fastapi_admin_kit.ai.tools import Tool, ToolRegistry, tool, tool_registry __all__ = [ "AIAgent", - "AIConfig", "AIAgentConfig", + "AIConfig", "ChatResult", "Tool", + "ToolCallRecord", "ToolRegistry", + "UsageInfo", "tool", "tool_registry", ] diff --git a/fastapi_admin_kit/ai/agent.py b/fastapi_admin_kit/ai/agent.py index 72b09c5..1168d9b 100644 --- a/fastapi_admin_kit/ai/agent.py +++ b/fastapi_admin_kit/ai/agent.py @@ -3,10 +3,14 @@ from __future__ import annotations from abc import ABC, abstractmethod +from collections.abc import AsyncGenerator from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any if TYPE_CHECKING: + from pydantic_ai.messages import ModelMessage + from pydantic_ai.usage import RunUsage + from fastapi_admin_kit.ai.deps import AdminDeps @@ -20,7 +24,7 @@ class UsageInfo: cost: float = 0.0 @classmethod - def from_pydantic_ai(cls, usage: Any, cost: float) -> UsageInfo: + def from_pydantic_ai(cls, usage: RunUsage, cost: float) -> UsageInfo: return cls( request_tokens=getattr(usage, "request_tokens", None) or 0, response_tokens=getattr(usage, "response_tokens", None) or 0, @@ -34,7 +38,7 @@ class ToolCallRecord: """Record of a single tool call within a run.""" name: str - args: dict + args: dict[str, Any] result: Any = None is_error: bool = False @@ -45,7 +49,7 @@ class ChatResult: output: Any = None usage: UsageInfo = field(default_factory=UsageInfo) - new_messages: list = field(default_factory=list) + new_messages: list[ModelMessage] = field(default_factory=list) tool_calls: list[ToolCallRecord] = field(default_factory=list) conversation_id: str | None = None @@ -71,13 +75,13 @@ def chat_stream( message: str, deps: AdminDeps, message_history: list | None = None, - ): ... + ) -> AsyncGenerator[Any, None]: ... @abstractmethod - async def execute_tool(self, tool_name: str, params: dict, deps: AdminDeps) -> Any: ... + async def execute_tool(self, tool_name: str, params: dict[str, Any], deps: AdminDeps) -> Any: ... @abstractmethod - def get_tools(self) -> list[dict]: ... + def get_tools(self) -> list[dict[str, Any]]: ... @abstractmethod - async def get_usage_stats(self, period: str = "day") -> dict: ... + async def get_usage_stats(self, period: str = "day") -> dict[str, Any]: ... diff --git a/fastapi_admin_kit/ai/backends/pydantic_ai_backend.py b/fastapi_admin_kit/ai/backends/pydantic_ai_backend.py index b222809..a4af933 100644 --- a/fastapi_admin_kit/ai/backends/pydantic_ai_backend.py +++ b/fastapi_admin_kit/ai/backends/pydantic_ai_backend.py @@ -3,7 +3,8 @@ from __future__ import annotations import time -from typing import Any +from collections.abc import AsyncGenerator, Awaitable, Callable +from typing import TYPE_CHECKING, Any from fastapi_admin_kit.ai.agent import ( AIAgent, @@ -13,11 +14,19 @@ ) from fastapi_admin_kit.ai.deps import AdminDeps +if TYPE_CHECKING: + from pydantic_ai import Agent + from pydantic_ai.result import AgentRunResult, RunUsage, StreamedRunResult -def _extract_tool_calls(result: Any) -> list[ToolCallRecord]: + from fastapi_admin_kit.ai.config import AIAgentConfig + from fastapi_admin_kit.ai.tools import Tool + from fastapi_admin_kit.ai.usage import AIUsageWriter + + +def _extract_tool_calls(result: AgentRunResult[Any]) -> list[ToolCallRecord]: """Extract tool call records from a Pydantic AI run result.""" records: list[ToolCallRecord] = [] - messages = getattr(result, "all_messages", lambda: [])() + messages = result.all_messages() for msg in messages: parts = getattr(msg, "parts", []) for part in parts: @@ -39,9 +48,9 @@ class PydanticAIAgent(AIAgent): def __init__( self, - config: Any, - deps_factory: Any, - usage_writer: Any, + config: AIAgentConfig, + deps_factory: Callable[..., Awaitable[AdminDeps]], + usage_writer: AIUsageWriter, ) -> None: self._config = config self._deps_factory = deps_factory @@ -53,7 +62,7 @@ def __init__( model = self._build_model(config) - self._agent = Agent( + self._agent: Agent[AdminDeps, Any] | None = Agent( model, deps_type=AdminDeps, output_type=config.result_type or str, @@ -64,7 +73,7 @@ def __init__( except ImportError: self._agent = None - def _build_model(self, config: Any) -> Any: + def _build_model(self, config: AIAgentConfig) -> Any: """Build a pydantic-ai model, injecting api_key if provided.""" model_str = config.model @@ -107,7 +116,7 @@ def _build_model(self, config: Any) -> Any: return model_str - def _bind_tools(self, tools: list[Any]) -> None: + def _bind_tools(self, tools: list[Tool]) -> None: if self._agent is None: return for t in tools: @@ -184,13 +193,13 @@ def chat_stream( message: str, deps: AdminDeps, message_history: list | None = None, - ): + ) -> AsyncGenerator[StreamedRunResult[AdminDeps, Any], None]: if self._agent is None: raise RuntimeError("pydantic-ai is not installed.") return self._agent.run_stream(message, deps=deps, message_history=message_history) - async def execute_tool(self, tool_name: str, params: dict, deps: AdminDeps) -> Any: + async def execute_tool(self, tool_name: str, params: dict[str, Any], deps: AdminDeps) -> Any: tool = self._config.get_tool(tool_name) if tool is None: raise ValueError(f"Tool '{tool_name}' not found.") @@ -202,17 +211,17 @@ async def execute_tool(self, tool_name: str, params: dict, deps: AdminDeps) -> A return await tool.handler(ctx, **params) return await tool.handler(**params) - def get_tools(self) -> list[dict]: + def get_tools(self) -> list[dict[str, Any]]: return [t.to_schema() for t in self._config.tools] - async def get_usage_stats(self, period: str = "day") -> dict: + async def get_usage_stats(self, period: str = "day") -> dict[str, Any]: return await self._usage_writer.aggregate( agent_name=self._config.name, period=period, - session=None, + session=None, # type: ignore[arg-type] ) - def _compute_cost(self, usage: Any) -> float: + def _compute_cost(self, usage: RunUsage) -> float: cfg = self._config req = (getattr(usage, "input_tokens", None) or 0) / 1000 resp = (getattr(usage, "output_tokens", None) or 0) / 1000 diff --git a/fastapi_admin_kit/ai/builtin_tools.py b/fastapi_admin_kit/ai/builtin_tools.py index 991ee35..43f251a 100644 --- a/fastapi_admin_kit/ai/builtin_tools.py +++ b/fastapi_admin_kit/ai/builtin_tools.py @@ -2,18 +2,23 @@ from __future__ import annotations -from typing import Any +from typing import TYPE_CHECKING from pydantic import BaseModel from fastapi_admin_kit.ai.tools import tool +if TYPE_CHECKING: + from pydantic_ai import RunContext + + from fastapi_admin_kit.ai.deps import AdminDeps + class QueryResult(BaseModel): """Result of a database query.""" row_count: int - rows: list[dict] + rows: list[dict[str, object]] @tool( @@ -22,7 +27,10 @@ class QueryResult(BaseModel): category="database", ) async def query_database( - ctx: Any, table_name: str, filters: dict | None = None, limit: int = 50 + ctx: RunContext[AdminDeps], + table_name: str, + filters: dict[str, object] | None = None, + limit: int = 50, ) -> QueryResult: registered = ctx.deps.registry.get(table_name) if not registered: @@ -56,7 +64,9 @@ async def query_database( description="Create a new record on a registered model.", category="database", ) -async def create_record(ctx: Any, table_name: str, data: dict) -> dict: +async def create_record( + ctx: RunContext[AdminDeps], table_name: str, data: dict[str, object] +) -> dict[str, object]: registered = ctx.deps.registry.get(table_name) if not registered: raise ValueError(f"'{table_name}' is not a registered model.") @@ -78,7 +88,7 @@ class ReportSpec(BaseModel): """Specification for generating a report.""" report_type: str - filters: dict = {} + filters: dict[str, object] = {} @tool( @@ -86,7 +96,7 @@ class ReportSpec(BaseModel): description="Generate an analytics report.", category="analytics", ) -async def generate_report(ctx: Any, spec: ReportSpec) -> dict: +async def generate_report(ctx: RunContext[AdminDeps], spec: ReportSpec) -> dict[str, object]: return { "report_type": spec.report_type, "filters": spec.filters, @@ -100,7 +110,9 @@ async def generate_report(ctx: Any, spec: ReportSpec) -> dict: description="Send a notification to a user.", category="notifications", ) -async def send_notification(ctx: Any, recipient: str, subject: str, message: str) -> dict: +async def send_notification( + ctx: RunContext[AdminDeps], recipient: str, subject: str, message: str +) -> dict[str, str]: return { "recipient": recipient, "subject": subject, diff --git a/fastapi_admin_kit/ai/conversation.py b/fastapi_admin_kit/ai/conversation.py index 0f1c737..52ec3bf 100644 --- a/fastapi_admin_kit/ai/conversation.py +++ b/fastapi_admin_kit/ai/conversation.py @@ -4,26 +4,31 @@ import time import uuid +from collections.abc import AsyncGenerator, Awaitable, Callable from functools import wraps from typing import TYPE_CHECKING, Any if TYPE_CHECKING: - from fastapi_admin_kit.ai.agent import AIAgent + from sqlalchemy.ext.asyncio import AsyncSession + + from fastapi_admin_kit.ai.agent import AIAgent, ChatResult, ToolCallRecord from fastapi_admin_kit.ai.deps import AdminDeps + from fastapi_admin_kit.ai.usage import AIConversation + from fastapi_admin_kit.auth.protocol import AdminUserProtocol class ConversationRecorder: """Handles persistence of conversations and messages.""" - def __init__(self, session: Any) -> None: + def __init__(self, session: AsyncSession) -> None: self.session = session async def get_or_create( self, conversation_id: str | None, agent_name: str, - user: Any, - ) -> Any: + user: AdminUserProtocol, + ) -> AIConversation: from fastapi_admin_kit.ai.usage import AIConversation if conversation_id: @@ -49,7 +54,7 @@ async def get_or_create( async def log_message( self, - conv: Any, + conv: AIConversation, role: str, content: str, tokens: int | None = None, @@ -68,7 +73,7 @@ async def log_message( ) await self.session.flush() - async def log_tool_call(self, conv: Any, call: Any) -> None: + async def log_tool_call(self, conv: AIConversation, call: ToolCallRecord) -> None: from fastapi_admin_kit.ai.usage import AIMessage self.session.add( @@ -83,7 +88,7 @@ async def log_tool_call(self, conv: Any, call: Any) -> None: ) await self.session.flush() - async def log_error(self, conv: Any, error: str) -> None: + async def log_error(self, conv: AIConversation, error: str) -> None: from fastapi_admin_kit.ai.usage import AIMessage self.session.add( @@ -98,7 +103,7 @@ async def log_error(self, conv: Any, error: str) -> None: async def touch( self, - conv: Any, + conv: AIConversation, *, message_history: Any = None, tokens_delta: int = 0, @@ -114,7 +119,9 @@ async def touch( await self.session.flush() -def _with_conversation_logging(chat_fn: Any) -> Any: +def _with_conversation_logging( + chat_fn: Callable[..., Awaitable[ChatResult]], +) -> Callable[..., Awaitable[ChatResult]]: """Wrap a chat() method to automatically log conversations and messages.""" @wraps(chat_fn) @@ -125,7 +132,7 @@ async def wrapper( message_history: list | None = None, conversation_id: str | None = None, **kwargs: Any, - ) -> Any: + ) -> ChatResult: recorder = ConversationRecorder(deps.session) conv = await recorder.get_or_create( conversation_id, @@ -172,7 +179,9 @@ async def wrapper( return wrapper -def _with_conversation_logging_stream(chat_stream_fn: Any) -> Any: +def _with_conversation_logging_stream( + chat_stream_fn: Callable[..., AsyncGenerator[Any, None]], +) -> Callable[..., AsyncGenerator[Any, None]]: """Wrap a chat_stream() method to log conversations after stream completes.""" @wraps(chat_stream_fn) @@ -183,7 +192,7 @@ async def wrapper( message_history: list | None = None, conversation_id: str | None = None, **kwargs: Any, - ) -> Any: + ) -> AsyncGenerator[Any, None]: recorder = ConversationRecorder(deps.session) conv = await recorder.get_or_create( conversation_id, @@ -194,7 +203,7 @@ async def wrapper( await recorder.log_message(conv, role="user", content=message) start = time.perf_counter() - accumulated = [] + accumulated: list[str] = [] try: async for chunk in chat_stream_fn( self, message, deps, message_history=message_history, **kwargs @@ -220,7 +229,7 @@ async def wrapper( return wrapper -def patch_agent_with_conversation_logging(agent_cls: type) -> None: +def patch_agent_with_conversation_logging(agent_cls: type[AIAgent]) -> None: """Apply conversation logging wrappers to an agent class's chat methods.""" agent_cls.chat = _with_conversation_logging(agent_cls.chat) # type: ignore[assignment] if hasattr(agent_cls, "chat_stream") and agent_cls.chat_stream is not None: diff --git a/fastapi_admin_kit/ai/dashboard.py b/fastapi_admin_kit/ai/dashboard.py index b705f9f..563f627 100644 --- a/fastapi_admin_kit/ai/dashboard.py +++ b/fastapi_admin_kit/ai/dashboard.py @@ -2,27 +2,35 @@ from __future__ import annotations -from typing import Any +from typing import TYPE_CHECKING from fastapi import APIRouter, HTTPException, Request from fastapi.responses import JSONResponse +if TYPE_CHECKING: + import jinja2 + + from fastapi_admin_kit.admin.core import Admin + from fastapi_admin_kit.ai.agent import AIAgent + from fastapi_admin_kit.auth.permissions import PermissionChecker + from fastapi_admin_kit.auth.protocol import AdminUserProtocol + router = APIRouter(prefix="/ai", tags=["ai"]) -def _get_jinja(request: Request) -> Any: +def _get_jinja(request: Request) -> jinja2.Environment: return request.app.state.admin_jinja_env -def _get_admin(request: Request) -> Any: +def _get_admin(request: Request) -> Admin | None: return getattr(request.app.state, "admin", None) -def _get_ai_agents(request: Request) -> dict[str, Any]: +def _get_ai_agents(request: Request) -> dict[str, AIAgent]: return getattr(request.app.state, "ai_agents", {}) -async def _resolve_user(request: Request) -> Any: +async def _resolve_user(request: Request) -> AdminUserProtocol: """Manually resolve the admin user from the session cookie.""" from fastapi_admin_kit.auth.dependencies import get_session from fastapi_admin_kit.auth.identity import resolve_user @@ -41,7 +49,7 @@ async def _resolve_user(request: Request) -> Any: return user -async def _resolve_checker(request: Request, user: Any) -> Any: +async def _resolve_checker(request: Request, user: AdminUserProtocol) -> PermissionChecker: """Manually build a permission checker.""" from fastapi_admin_kit.auth.permissions import PermissionChecker from fastapi_admin_kit.db import get_db_session @@ -52,12 +60,12 @@ async def _resolve_checker(request: Request, user: Any) -> Any: @router.get("/chat") -async def ai_chat_page(request: Request): +async def ai_chat_page(request: Request) -> jinja2.TemplateResponse: """Full-page AI chat interface.""" admin = _get_admin(request) jinja = _get_jinja(request) - context = { + context: dict[str, object] = { "title": "AI Chat", "admin_path": admin.admin_path if admin else "/admin", } @@ -66,12 +74,12 @@ async def ai_chat_page(request: Request): @router.get("/logs") -async def ai_logs_page(request: Request): +async def ai_logs_page(request: Request) -> jinja2.TemplateResponse: """Full-page AI logs viewer.""" admin = _get_admin(request) jinja = _get_jinja(request) - context = { + context: dict[str, object] = { "title": "AI Logs", "admin_path": admin.admin_path if admin else "/admin", } @@ -80,13 +88,13 @@ async def ai_logs_page(request: Request): @router.get("/dashboard") -async def ai_dashboard(request: Request): +async def ai_dashboard(request: Request) -> jinja2.TemplateResponse: """AI operations dashboard showing costs, logs, and tool calls.""" agents = _get_ai_agents(request) admin = _get_admin(request) jinja = _get_jinja(request) - stats: list[dict[str, Any]] = [] + stats: list[dict[str, object]] = [] for name, agent in agents.items(): try: s = await agent.get_usage_stats(period="day") @@ -99,7 +107,7 @@ async def ai_dashboard(request: Request): } stats.append({"name": name, **s}) - context = { + context: dict[str, object] = { "title": "AI Dashboard", "agent_stats": stats, "admin_path": admin.admin_path if admin else "/admin", @@ -173,12 +181,12 @@ async def get_ai_costs( @router.get("/tools") -async def ai_tools_page(request: Request): +async def ai_tools_page(request: Request) -> jinja2.TemplateResponse: """Full-page AI tools viewer.""" admin = _get_admin(request) jinja = _get_jinja(request) - context = { + context: dict[str, object] = { "title": "AI Tools", "admin_path": admin.admin_path if admin else "/admin", } @@ -209,7 +217,7 @@ async def get_ai_tools(request: Request) -> JSONResponse: async def execute_tool_endpoint( tool_name: str, request: Request, - params: dict | None = None, + params: dict[str, object] | None = None, ) -> JSONResponse: """Execute an AI tool directly (bypasses the LLM).""" agents = _get_ai_agents(request) @@ -245,12 +253,12 @@ async def execute_tool_endpoint( @router.get("/agents") -async def ai_agents_page(request: Request): +async def ai_agents_page(request: Request) -> jinja2.TemplateResponse: """Full-page AI agents viewer.""" admin = _get_admin(request) jinja = _get_jinja(request) - context = { + context: dict[str, object] = { "title": "AI Agents", "admin_path": admin.admin_path if admin else "/admin", } diff --git a/fastapi_admin_kit/ai/deps.py b/fastapi_admin_kit/ai/deps.py index 0901924..b3a8b6b 100644 --- a/fastapi_admin_kit/ai/deps.py +++ b/fastapi_admin_kit/ai/deps.py @@ -3,20 +3,27 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any +from typing import TYPE_CHECKING from fastapi import Request +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncSession + + from fastapi_admin_kit.auth.permissions import PermissionChecker + from fastapi_admin_kit.auth.protocol import AdminUserProtocol + from fastapi_admin_kit.registry.core import AdminRegistry + @dataclass class AdminDeps: """Shared dependencies injected into every tool call and agent run.""" - session: Any - admin_user: Any - request: Any - registry: Any - permission_checker: Any + session: AsyncSession + admin_user: AdminUserProtocol + request: Request + registry: AdminRegistry + permission_checker: PermissionChecker async def get_admin_deps(request: Request) -> AdminDeps: diff --git a/fastapi_admin_kit/ai/model_agent.py b/fastapi_admin_kit/ai/model_agent.py index 72d18a9..df24ebe 100644 --- a/fastapi_admin_kit/ai/model_agent.py +++ b/fastapi_admin_kit/ai/model_agent.py @@ -3,13 +3,22 @@ from __future__ import annotations from abc import ABC -from typing import Any +from typing import TYPE_CHECKING from fastapi_admin_kit.ai.tools import Tool, tool_registry +if TYPE_CHECKING: + from pydantic_ai import RunContext + + from fastapi_admin_kit.ai.deps import AdminDeps + def _build_query_tool(model: type, table_name: str) -> Tool: - async def _query(ctx: Any, filters: dict | None = None, limit: int = 50) -> Any: + async def _query( + ctx: RunContext[AdminDeps], + filters: dict[str, object] | None = None, + limit: int = 50, + ) -> object: from fastapi_admin_kit.ai.builtin_tools import query_database return await query_database(ctx, table_name, filters, limit) @@ -24,7 +33,7 @@ async def _query(ctx: Any, filters: dict | None = None, limit: int = 50) -> Any: def _build_create_tool(model: type, table_name: str, exclude_fields: list[str]) -> Tool: - async def _create(ctx: Any, data: dict) -> Any: + async def _create(ctx: RunContext[AdminDeps], data: dict[str, object]) -> object: from fastapi_admin_kit.ai.builtin_tools import create_record for f in exclude_fields: @@ -41,7 +50,9 @@ async def _create(ctx: Any, data: dict) -> Any: def _build_update_tool(model: type, table_name: str, exclude_fields: list[str]) -> Tool: - async def _update(ctx: Any, record_id: int, data: dict) -> dict: + async def _update( + ctx: RunContext[AdminDeps], record_id: int, data: dict[str, object] + ) -> dict[str, object]: if not await ctx.deps.permission_checker.has_permission(table_name, "edit"): raise ValueError(f"Not permitted to edit {table_name}.") @@ -72,7 +83,7 @@ async def _update(ctx: Any, record_id: int, data: dict) -> dict: def _build_delete_tool(model: type, table_name: str) -> Tool: - async def _delete(ctx: Any, record_id: int) -> dict: + async def _delete(ctx: RunContext[AdminDeps], record_id: int) -> dict[str, object]: if not await ctx.deps.permission_checker.has_permission(table_name, "delete"): raise ValueError(f"Not permitted to delete {table_name}.") @@ -110,7 +121,7 @@ class ModelAIAgent(ABC): can_delete: bool = False exclude_fields: list[str] = [] - def __init_subclass__(cls, **kwargs: Any) -> None: + def __init_subclass__(cls, **kwargs: object) -> None: super().__init_subclass__(**kwargs) cls._declared_tools = [m for m in vars(cls).values() if getattr(m, "_ai_tool", False)] diff --git a/fastapi_admin_kit/ai/plugin.py b/fastapi_admin_kit/ai/plugin.py index 33ea501..ada1f33 100644 --- a/fastapi_admin_kit/ai/plugin.py +++ b/fastapi_admin_kit/ai/plugin.py @@ -2,7 +2,14 @@ from __future__ import annotations -from typing import Any +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from fastapi import APIRouter + + from fastapi_admin_kit.admin.core import Admin + from fastapi_admin_kit.ai.agent import AIAgent + from fastapi_admin_kit.ai.config import AIAgentConfig class AIPlugin: @@ -10,15 +17,15 @@ class AIPlugin: name = "ai" - def __init__(self, agents: list[Any] | None = None) -> None: + def __init__(self, agents: list[AIAgentConfig] | None = None) -> None: self.agents = agents or [] - def get_routes(self) -> Any: + def get_routes(self) -> APIRouter: from fastapi_admin_kit.ai.dashboard import router return router - def get_nav_items(self) -> list[dict]: + def get_nav_items(self) -> list[dict[str, str]]: return [ {"label": "AI Dashboard", "url": "/admin/ai/dashboard", "icon": "sparkles"}, {"label": "AI Agents", "url": "/admin/ai/agents", "icon": "smart_toy"}, @@ -26,10 +33,10 @@ def get_nav_items(self) -> list[dict]: {"label": "AI Logs", "url": "/admin/ai/logs", "icon": "receipt_long"}, ] - def get_dashboard_widgets(self) -> list[Any]: + def get_dashboard_widgets(self) -> list[dict[str, str]]: return [] - def on_startup(self, admin: Any) -> None: + def on_startup(self, admin: Admin) -> None: """Initialize AI agents and store on admin state.""" from fastapi_admin_kit.ai.backends.pydantic_ai_backend import ( PydanticAIAgent, @@ -38,7 +45,7 @@ def on_startup(self, admin: Any) -> None: from fastapi_admin_kit.ai.usage import AIUsageWriter writer = AIUsageWriter() - ai_agents: dict[str, Any] = {} + ai_agents: dict[str, AIAgent] = {} for cfg in self.agents: agent = PydanticAIAgent( diff --git a/fastapi_admin_kit/ai/tools.py b/fastapi_admin_kit/ai/tools.py index 8f6c431..aa89465 100644 --- a/fastapi_admin_kit/ai/tools.py +++ b/fastapi_admin_kit/ai/tools.py @@ -2,8 +2,9 @@ from __future__ import annotations -from collections.abc import Callable +from collections.abc import Awaitable, Callable from dataclasses import dataclass, field +from typing import Any @dataclass @@ -12,15 +13,15 @@ class Tool: name: str description: str - handler: Callable + handler: Callable[..., Awaitable[Any]] uses_context: bool = True path: str | None = None method: str = "POST" requires_auth: bool = True category: str = "general" - _schema: dict | None = field(default=None, repr=False) + _schema: dict[str, Any] | None = field(default=None, repr=False) - def to_schema(self) -> dict: + def to_schema(self) -> dict[str, Any]: return self._schema or {} @@ -34,7 +35,7 @@ def register( self, name: str, description: str, - handler: Callable, + handler: Callable[..., Awaitable[Any]], *, uses_context: bool = True, path: str | None = None, @@ -77,10 +78,10 @@ def tool( method: str = "POST", requires_auth: bool = True, category: str = "general", -) -> Callable: +) -> Callable[[Callable[..., Awaitable[Any]]], Callable[..., Awaitable[Any]]]: """Decorator to register a function as an AI tool.""" - def decorator(func: Callable) -> Callable: + def decorator(func: Callable[..., Awaitable[Any]]) -> Callable[..., Awaitable[Any]]: tool_registry.register( name=name, description=description, @@ -91,7 +92,7 @@ def decorator(func: Callable) -> Callable: requires_auth=requires_auth, category=category, ) - func._ai_tool = True + func._ai_tool = True # type: ignore[attr-defined] return func return decorator diff --git a/fastapi_admin_kit/ai/usage.py b/fastapi_admin_kit/ai/usage.py index 72d7277..fa0882c 100644 --- a/fastapi_admin_kit/ai/usage.py +++ b/fastapi_admin_kit/ai/usage.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import TYPE_CHECKING + from sqlalchemy import ( JSON, Boolean, @@ -18,6 +20,11 @@ from fastapi_admin_kit.models.base import Base +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncSession + + from fastapi_admin_kit.auth.protocol import AdminUserProtocol + class AIUsageLog(Base): """SQLAlchemy model for AI usage logging.""" @@ -107,12 +114,12 @@ async def write( response_tokens: int, total_tokens: int, cost: float, - user: Any, + user: AdminUserProtocol, success: bool, - session: Any, + session: AsyncSession, error: str | None = None, latency_ms: int | None = None, - tool_calls: list[dict] | None = None, + tool_calls: list[dict[str, object]] | None = None, ) -> None: session.add( AIUsageLog( @@ -136,8 +143,8 @@ async def aggregate( self, agent_name: str, period: str, - session: Any, - ) -> dict: + session: AsyncSession, + ) -> dict[str, object]: from sqlalchemy import func as sqlfunc from sqlalchemy import select @@ -174,6 +181,3 @@ async def aggregate( "avg_latency_ms": round(row.avg_latency_ms or 0, 2), "success_rate": rate, } - - -from typing import Any # noqa: E402 From 928c823bae720f68a59bd9f966c38f094662b1a9 Mon Sep 17 00:00:00 2001 From: borhanst Date: Wed, 22 Jul 2026 15:51:50 +0600 Subject: [PATCH 05/42] feat: Enhance AI chat functionality with conversation history and management - Implemented conversation history retrieval and display in the AI chat interface. - Added endpoints for listing, loading, and deleting conversations. - Introduced a new tool registry method for resolving tool names. - Enhanced message deserialization for better handling of stored messages. - Updated AI usage logging to include latency and error tracking. - Improved chat UI with a sidebar for conversation management and a more responsive design. - Added functionality to create new conversations and delete existing ones. - Refactored chat input handling and message formatting for better user experience. --- example_ai.py | 458 +++++++++-------- fastapi_admin_kit/admin/core.py | 1 + fastapi_admin_kit/ai/agent.py | 8 +- .../ai/backends/pydantic_ai_backend.py | 46 +- fastapi_admin_kit/ai/builtin_tools.py | 21 +- fastapi_admin_kit/ai/config.py | 32 +- fastapi_admin_kit/ai/conversation.py | 69 ++- fastapi_admin_kit/ai/dashboard.py | 362 ++++++++++++- fastapi_admin_kit/ai/deps.py | 1 + fastapi_admin_kit/ai/tools.py | 12 + fastapi_admin_kit/ai/usage.py | 16 +- .../templates/pages/ai/chat.html | 483 +++++++++++++----- .../templates/partials/ai_chat_widget.html | 10 +- 13 files changed, 1115 insertions(+), 404 deletions(-) diff --git a/example_ai.py b/example_ai.py index db80ca8..1e8aecf 100644 --- a/example_ai.py +++ b/example_ai.py @@ -26,11 +26,11 @@ import os from contextlib import asynccontextmanager -from typing import TYPE_CHECKING import bcrypt from dotenv import load_dotenv from fastapi import FastAPI +from pydantic_ai import RunContext from sqlalchemy import ( Boolean, Column, @@ -47,7 +47,7 @@ from fastapi_admin_kit import Admin, ModelAdmin from fastapi_admin_kit.ai import AIAgentConfig, AIConfig, tool -from fastapi_admin_kit.ai.tools import tool_registry +from fastapi_admin_kit.ai.deps import AdminDeps from fastapi_admin_kit.ai.usage import AIUsageLog # noqa: F401 from fastapi_admin_kit.audit.models import AuditLog # noqa: F401 from fastapi_admin_kit.auth.backend import BuiltinAuthBackend @@ -56,10 +56,6 @@ from fastapi_admin_kit.models import Base as AdminBase from fastapi_admin_kit.nav import NavGroupConfig -from pydantic_ai import RunContext -from fastapi_admin_kit.ai.deps import AdminDeps - - load_dotenv() # ============================================================================ @@ -170,32 +166,35 @@ async def search_products( ctx: RunContext[AdminDeps], query: str, limit: int = 10 ) -> dict[str, object]: """Search products across name and description fields.""" - session = ctx.deps.session - stmt = ( - select(Product) - .where( - Product.name.ilike(f"%{query}%") - | Product.description.ilike(f"%{query}%") + try: + session = ctx.deps.session + stmt = ( + select(Product) + .where( + Product.name.ilike(f"%{query}%") + | Product.description.ilike(f"%{query}%") + ) + .limit(limit) ) - .limit(limit) - ) - result = await session.execute(stmt) - products = result.scalars().all() + result = await session.execute(stmt) + products = result.scalars().all() - return { - "count": len(products), - "products": [ - { - "id": p.id, - "name": p.name, - "price": p.price, - "stock": p.stock, - "is_active": p.is_active, - } - for p in products - ], - } + return { + "count": len(products), + "products": [ + { + "id": p.id, + "name": p.name, + "price": p.price, + "stock": p.stock, + "is_active": p.is_active, + } + for p in products + ], + } + except Exception as e: + return {"error": str(e)} @tool( @@ -207,21 +206,24 @@ async def get_product( ctx: RunContext[AdminDeps], product_id: int ) -> dict[str, object]: """Look up a product by its ID.""" - session = ctx.deps.session - result = await session.execute( - select(Product).where(Product.id == product_id) - ) - product = result.scalars().first() - if not product: - return {"error": f"Product {product_id} not found"} - return { - "id": product.id, - "name": product.name, - "description": product.description, - "price": product.price, - "stock": product.stock, - "is_active": product.is_active, - } + try: + session = ctx.deps.session + result = await session.execute( + select(Product).where(Product.id == product_id) + ) + product = result.scalars().first() + if not product: + return {"error": f"Product {product_id} not found"} + return { + "id": product.id, + "name": product.name, + "description": product.description, + "price": product.price, + "stock": product.stock, + "is_active": product.is_active, + } + except Exception as e: + return {"error": str(e)} @tool( @@ -233,27 +235,30 @@ async def update_product_stock( ctx: RunContext[AdminDeps], product_id: int, new_stock: int ) -> dict[str, object]: """Set the stock level of a product.""" - if new_stock < 0: - return {"error": "Stock cannot be negative"} + try: + if new_stock < 0: + return {"error": "Stock cannot be negative"} - session = ctx.deps.session - result = await session.execute( - select(Product).where(Product.id == product_id) - ) - product = result.scalars().first() - if not product: - return {"error": f"Product {product_id} not found"} + session = ctx.deps.session + result = await session.execute( + select(Product).where(Product.id == product_id) + ) + product = result.scalars().first() + if not product: + return {"error": f"Product {product_id} not found"} - old_stock = product.stock - product.stock = new_stock - await session.flush() + old_stock = product.stock + product.stock = new_stock + await session.flush() - return { - "product_id": product_id, - "name": product.name, - "old_stock": old_stock, - "new_stock": new_stock, - } + return { + "product_id": product_id, + "name": product.name, + "old_stock": old_stock, + "new_stock": new_stock, + } + except Exception as e: + return {"error": str(e)} @tool( @@ -265,36 +270,39 @@ async def get_customer_summary( ctx: RunContext[AdminDeps], customer_id: int | None = None ) -> dict[str, object]: """Get customer summary or aggregate stats.""" - session = ctx.deps.session - - if customer_id: - result = await session.execute( - select(Customer).where(Customer.id == customer_id) - ) - customer = result.scalars().first() - if not customer: - return {"error": f"Customer {customer_id} not found"} - return { - "id": customer.id, - "name": customer.name, - "email": customer.email, - "tier": customer.tier, - "total_spent": customer.total_spent, - } + try: + session = ctx.deps.session + + if customer_id: + result = await session.execute( + select(Customer).where(Customer.id == customer_id) + ) + customer = result.scalars().first() + if not customer: + return {"error": f"Customer {customer_id} not found"} + return { + "id": customer.id, + "name": customer.name, + "email": customer.email, + "tier": customer.tier, + "total_spent": customer.total_spent, + } - result = await session.execute(select(Customer)) - customers = result.scalars().all() + result = await session.execute(select(Customer)) + customers = result.scalars().all() - tiers: dict[str, int] = {} - for c in customers: - tiers.setdefault(c.tier, 0) - tiers[c.tier] += 1 + tiers: dict[str, int] = {} + for c in customers: + tiers.setdefault(c.tier, 0) + tiers[c.tier] += 1 - return { - "total_customers": len(customers), - "by_tier": tiers, - "total_revenue": sum(c.total_spent for c in customers), - } + return { + "total_customers": len(customers), + "by_tier": tiers, + "total_revenue": sum(c.total_spent for c in customers), + } + except Exception as e: + return {"error": str(e)} @tool( @@ -306,28 +314,31 @@ async def update_customer_tier( ctx: RunContext[AdminDeps], customer_id: int, new_tier: str ) -> dict[str, object]: """Update a customer's tier (standard, premium, vip).""" - valid_tiers = {"standard", "premium", "vip"} - if new_tier not in valid_tiers: - return {"error": f"Invalid tier. Must be one of: {valid_tiers}"} + try: + valid_tiers = {"standard", "premium", "vip"} + if new_tier not in valid_tiers: + return {"error": f"Invalid tier. Must be one of: {valid_tiers}"} - session = ctx.deps.session - result = await session.execute( - select(Customer).where(Customer.id == customer_id) - ) - customer = result.scalars().first() - if not customer: - return {"error": f"Customer {customer_id} not found"} + session = ctx.deps.session + result = await session.execute( + select(Customer).where(Customer.id == customer_id) + ) + customer = result.scalars().first() + if not customer: + return {"error": f"Customer {customer_id} not found"} - old_tier = customer.tier - customer.tier = new_tier - await session.flush() + old_tier = customer.tier + customer.tier = new_tier + await session.flush() - return { - "customer_id": customer_id, - "name": customer.name, - "old_tier": old_tier, - "new_tier": new_tier, - } + return { + "customer_id": customer_id, + "name": customer.name, + "old_tier": old_tier, + "new_tier": new_tier, + } + except Exception as e: + return {"error": str(e)} @tool( @@ -339,25 +350,28 @@ async def get_revenue_summary( ctx: RunContext[AdminDeps], ) -> dict[str, object]: """Aggregate revenue stats across all customers.""" - session = ctx.deps.session - result = await session.execute(select(Customer)) - customers = result.scalars().all() - - by_tier: dict[str, dict[str, object]] = {} - total_revenue = 0.0 - for c in customers: - tier = c.tier - if tier not in by_tier: - by_tier[tier] = {"count": 0, "revenue": 0.0} - by_tier[tier]["count"] = int(by_tier[tier]["count"]) + 1 - by_tier[tier]["revenue"] = float(by_tier[tier]["revenue"]) + c.total_spent - total_revenue += c.total_spent + try: + session = ctx.deps.session + result = await session.execute(select(Customer)) + customers = result.scalars().all() + + by_tier: dict[str, dict[str, object]] = {} + total_revenue = 0.0 + for c in customers: + tier = c.tier + if tier not in by_tier: + by_tier[tier] = {"count": 0, "revenue": 0.0} + by_tier[tier]["count"] = int(by_tier[tier]["count"]) + 1 + by_tier[tier]["revenue"] = float(by_tier[tier]["revenue"]) + c.total_spent + total_revenue += c.total_spent - return { - "total_customers": len(customers), - "total_revenue": total_revenue, - "by_tier": by_tier, - } + return { + "total_customers": len(customers), + "total_revenue": total_revenue, + "by_tier": by_tier, + } + except Exception as e: + return {"error": str(e)} @tool( @@ -369,28 +383,31 @@ async def get_support_stats( ctx: RunContext[AdminDeps], ) -> dict[str, object]: """Aggregate support ticket statistics.""" - session = ctx.deps.session - result = await session.execute(select(Ticket)) - tickets = result.scalars().all() - - by_status: dict[str, int] = {} - by_priority: dict[str, int] = {} - for t in tickets: - by_status.setdefault(t.status, 0) - by_status[t.status] += 1 - by_priority.setdefault(t.priority, 0) - by_priority[t.priority] += 1 - - total = len(tickets) - resolved = by_status.get("resolved", 0) - rate = f"{(resolved / total * 100):.1f}%" if total else "N/A" + try: + session = ctx.deps.session + result = await session.execute(select(Ticket)) + tickets = result.scalars().all() + + by_status: dict[str, int] = {} + by_priority: dict[str, int] = {} + for t in tickets: + by_status.setdefault(t.status, 0) + by_status[t.status] += 1 + by_priority.setdefault(t.priority, 0) + by_priority[t.priority] += 1 + + total = len(tickets) + resolved = by_status.get("resolved", 0) + rate = f"{(resolved / total * 100):.1f}%" if total else "N/A" - return { - "total_tickets": total, - "by_status": by_status, - "by_priority": by_priority, - "resolution_rate": rate, - } + return { + "total_tickets": total, + "by_status": by_status, + "by_priority": by_priority, + "resolution_rate": rate, + } + except Exception as e: + return {"error": str(e)} @tool( @@ -402,28 +419,31 @@ async def search_tickets( ctx: RunContext[AdminDeps], keyword: str, limit: int = 10 ) -> dict[str, object]: """Find tickets matching a keyword in the subject.""" - session = ctx.deps.session - stmt = ( - select(Ticket) - .where(Ticket.subject.ilike(f"%{keyword}%")) - .limit(limit) - ) - result = await session.execute(stmt) - tickets = result.scalars().all() + try: + session = ctx.deps.session + stmt = ( + select(Ticket) + .where(Ticket.subject.ilike(f"%{keyword}%")) + .limit(limit) + ) + result = await session.execute(stmt) + tickets = result.scalars().all() - return { - "count": len(tickets), - "tickets": [ - { - "id": t.id, - "subject": t.subject, - "status": t.status, - "priority": t.priority, - "customer_id": t.customer_id, - } - for t in tickets - ], - } + return { + "count": len(tickets), + "tickets": [ + { + "id": t.id, + "subject": t.subject, + "status": t.status, + "priority": t.priority, + "customer_id": t.customer_id, + } + for t in tickets + ], + } + except Exception as e: + return {"error": str(e)} @tool( @@ -435,27 +455,30 @@ async def update_ticket_status( ctx: RunContext[AdminDeps], ticket_id: int, status: str ) -> dict[str, object]: """Update a ticket's status field.""" - valid = {"open", "in_progress", "resolved"} - if status not in valid: - return {"error": f"Invalid status. Must be one of: {valid}"} + try: + valid = {"open", "in_progress", "resolved"} + if status not in valid: + return {"error": f"Invalid status. Must be one of: {valid}"} - session = ctx.deps.session - result = await session.execute( - select(Ticket).where(Ticket.id == ticket_id) - ) - ticket = result.scalars().first() - if not ticket: - return {"error": f"Ticket {ticket_id} not found"} + session = ctx.deps.session + result = await session.execute( + select(Ticket).where(Ticket.id == ticket_id) + ) + ticket = result.scalars().first() + if not ticket: + return {"error": f"Ticket {ticket_id} not found"} - old_status = ticket.status - ticket.status = status - await session.flush() + old_status = ticket.status + ticket.status = status + await session.flush() - return { - "ticket_id": ticket_id, - "old_status": old_status, - "new_status": status, - } + return { + "ticket_id": ticket_id, + "old_status": old_status, + "new_status": status, + } + except Exception as e: + return {"error": str(e)} @tool( @@ -471,27 +494,30 @@ async def create_ticket( customer_id: int | None = None, ) -> dict[str, object]: """Create a support ticket with subject, body, priority, and optional customer link.""" - valid_priorities = {"low", "medium", "high", "urgent"} - if priority not in valid_priorities: - return {"error": f"Invalid priority. Must be one of: {valid_priorities}"} - - session = ctx.deps.session - ticket = Ticket( - subject=subject, - body=body, - status="open", - priority=priority, - customer_id=customer_id, - ) - session.add(ticket) - await session.flush() + try: + valid_priorities = {"low", "medium", "high", "urgent"} + if priority not in valid_priorities: + return {"error": f"Invalid priority. Must be one of: {valid_priorities}"} + + session = ctx.deps.session + ticket = Ticket( + subject=subject, + body=body, + status="open", + priority=priority, + customer_id=customer_id, + ) + session.add(ticket) + await session.flush() - return { - "ticket_id": ticket.id, - "subject": ticket.subject, - "status": ticket.status, - "priority": ticket.priority, - } + return { + "ticket_id": ticket.id, + "subject": ticket.subject, + "status": ticket.status, + "priority": ticket.priority, + } + except Exception as e: + return {"error": str(e)} # ============================================================================ @@ -520,16 +546,16 @@ async def create_ticket( cost_per_1k_input_tokens=0.00059, cost_per_1k_output_tokens=0.00079, tools=[ - tool_registry.get("search_products"), # type: ignore[list-item] - tool_registry.get("get_product"), # type: ignore[list-item] - tool_registry.get("update_product_stock"), # type: ignore[list-item] - tool_registry.get("get_customer_summary"), # type: ignore[list-item] - tool_registry.get("update_customer_tier"), # type: ignore[list-item] - tool_registry.get("get_revenue_summary"), # type: ignore[list-item] - tool_registry.get("get_support_stats"), # type: ignore[list-item] - tool_registry.get("search_tickets"), # type: ignore[list-item] - tool_registry.get("update_ticket_status"), # type: ignore[list-item] - tool_registry.get("create_ticket"), # type: ignore[list-item] + "search_products", + "get_product", + "update_product_stock", + "get_customer_summary", + "update_customer_tier", + "get_revenue_summary", + "get_support_stats", + "search_tickets", + "update_ticket_status", + "create_ticket", ], ), ], diff --git a/fastapi_admin_kit/admin/core.py b/fastapi_admin_kit/admin/core.py index e2fa46c..6224bf8 100644 --- a/fastapi_admin_kit/admin/core.py +++ b/fastapi_admin_kit/admin/core.py @@ -882,6 +882,7 @@ def _icon(name: str, size: str = "", **kwargs) -> str: "primary_color_dark": self.config.ui.primary_color_dark, "dark_mode_default": self.config.ui.dark_mode_default, "admin_path": self.router.admin_path, + "ai_enabled": self._ai_enabled, } self._jinja_env.env.globals["admin_config"] = admin_cfg diff --git a/fastapi_admin_kit/ai/agent.py b/fastapi_admin_kit/ai/agent.py index 1168d9b..b52560b 100644 --- a/fastapi_admin_kit/ai/agent.py +++ b/fastapi_admin_kit/ai/agent.py @@ -78,10 +78,14 @@ def chat_stream( ) -> AsyncGenerator[Any, None]: ... @abstractmethod - async def execute_tool(self, tool_name: str, params: dict[str, Any], deps: AdminDeps) -> Any: ... + async def execute_tool( + self, tool_name: str, params: dict[str, Any], deps: AdminDeps + ) -> Any: ... @abstractmethod def get_tools(self) -> list[dict[str, Any]]: ... @abstractmethod - async def get_usage_stats(self, period: str = "day") -> dict[str, Any]: ... + async def get_usage_stats( + self, period: str = "day", session: Any | None = None + ) -> dict[str, Any]: ... diff --git a/fastapi_admin_kit/ai/backends/pydantic_ai_backend.py b/fastapi_admin_kit/ai/backends/pydantic_ai_backend.py index a4af933..c81773f 100644 --- a/fastapi_admin_kit/ai/backends/pydantic_ai_backend.py +++ b/fastapi_admin_kit/ai/backends/pydantic_ai_backend.py @@ -70,6 +70,7 @@ def __init__( retries=config.retries, ) self._bind_tools(config.tools) + self._register_instructions() except ImportError: self._agent = None @@ -125,6 +126,45 @@ def _bind_tools(self, tools: list[Tool]) -> None: else: self._agent.tool_plain(t.handler) + def _register_instructions(self) -> None: + if self._agent is None: + return + from pydantic_ai import RunContext + + @self._agent.instructions + def _page_context(ctx: RunContext[AdminDeps]) -> str: + page_url = ctx.deps.page_url + if not page_url: + return "" + + admin_path = "/" + try: + admin_path = ctx.deps.request.app.state.admin_config.get("admin_path", "/admin") + except Exception: + pass + + path = page_url.rstrip("/") + if not path.startswith(admin_path): + return "" + relative = path[len(admin_path) :].strip("/") + if not relative: + return "" + + table_name = relative.split("/")[0] + registered = ctx.deps.registry.get(table_name) + if registered is None: + return "" + + col_names = [c.name for c in registered.columns] + col_types = {c.name: str(c.type) for c in registered.columns} + cols_desc = ", ".join(f"{name} ({col_types.get(name, '?')})" for name in col_names) + return ( + f"The user is currently on the {registered.verbose_name} page " + f"(table: {table_name}). " + f"Available columns: {cols_desc}. " + f"Use these exact table and column names when querying." + ) + async def chat( self, message: str, @@ -214,11 +254,13 @@ async def execute_tool(self, tool_name: str, params: dict[str, Any], deps: Admin def get_tools(self) -> list[dict[str, Any]]: return [t.to_schema() for t in self._config.tools] - async def get_usage_stats(self, period: str = "day") -> dict[str, Any]: + async def get_usage_stats( + self, period: str = "day", session: Any | None = None + ) -> dict[str, Any]: return await self._usage_writer.aggregate( agent_name=self._config.name, period=period, - session=None, # type: ignore[arg-type] + session=session, # type: ignore[arg-type] ) def _compute_cost(self, usage: RunUsage) -> float: diff --git a/fastapi_admin_kit/ai/builtin_tools.py b/fastapi_admin_kit/ai/builtin_tools.py index 43f251a..f82bb41 100644 --- a/fastapi_admin_kit/ai/builtin_tools.py +++ b/fastapi_admin_kit/ai/builtin_tools.py @@ -42,12 +42,27 @@ async def query_database( model = registered.model session = ctx.deps.session - from sqlalchemy import select + from sqlalchemy import Boolean, Float, Integer, String, select stmt = select(model) for field_name, value in (filters or {}).items(): - if hasattr(model, field_name): - stmt = stmt.where(getattr(model, field_name) == value) + if not hasattr(model, field_name): + continue + if isinstance(value, dict | list): + continue + col = getattr(model, field_name) + col_type = type(col.property.columns[0].type) + if isinstance(value, bool) and col_type not in (Boolean,): + continue + if isinstance(value, int | float) and not isinstance(value, bool): + if col_type not in (Integer, Float): + continue + if isinstance(value, str) and col_type not in (String,): + continue + if value is None: + stmt = stmt.where(col.is_(None)) + else: + stmt = stmt.where(col == value) stmt = stmt.limit(limit) result = await session.execute(stmt) diff --git a/fastapi_admin_kit/ai/config.py b/fastapi_admin_kit/ai/config.py index d7cfc99..d3a4254 100644 --- a/fastapi_admin_kit/ai/config.py +++ b/fastapi_admin_kit/ai/config.py @@ -11,20 +11,46 @@ @dataclass class AIAgentConfig: - """Configuration for a single AI agent.""" + """Configuration for a single AI agent. + + ``tools`` accepts a mixed list of tool names (strings) and Tool objects. + Strings are resolved against the global :data:`tool_registry` at init time. + """ name: str model: str system_prompt: str = "" api_key: str | None = None result_type: type | None = None - tools: list[Tool] = field(default_factory=list) + tools: list[str | Tool] = field(default_factory=list) retries: int = 1 cost_per_1k_input_tokens: float = 0.0 cost_per_1k_output_tokens: float = 0.0 + _resolved_tools: list[Tool] = field(default_factory=list, init=False, repr=False) + + def __post_init__(self) -> None: + from fastapi_admin_kit.ai.tools import Tool, tool_registry + + resolved: list[Tool] = [] + for t in self.tools: + if isinstance(t, str): + found = tool_registry.get(t) + if found is None: + raise KeyError( + f"Tool '{t}' not found in registry. " + f"Available: {[x.name for x in tool_registry.all()]}" + ) + resolved.append(found) + elif isinstance(t, Tool): + resolved.append(t) + else: + raise TypeError(f"Expected str or Tool, got {type(t).__name__}") + self._resolved_tools = resolved + self.tools = self._resolved_tools # type: ignore[assignment] + def get_tool(self, name: str) -> Tool | None: - return next((t for t in self.tools if t.name == name), None) + return next((t for t in self._resolved_tools if t.name == name), None) @dataclass diff --git a/fastapi_admin_kit/ai/conversation.py b/fastapi_admin_kit/ai/conversation.py index 52ec3bf..2615e09 100644 --- a/fastapi_admin_kit/ai/conversation.py +++ b/fastapi_admin_kit/ai/conversation.py @@ -76,6 +76,7 @@ async def log_message( async def log_tool_call(self, conv: AIConversation, call: ToolCallRecord) -> None: from fastapi_admin_kit.ai.usage import AIMessage + start = time.perf_counter() self.session.add( AIMessage( conversation_id=conv.id, @@ -84,6 +85,8 @@ async def log_tool_call(self, conv: AIConversation, call: ToolCallRecord) -> Non tool_args=getattr(call, "args", None), tool_result=getattr(call, "result", None), content=str(getattr(call, "result", "")), + is_error=getattr(call, "is_error", False), + latency_ms=int((time.perf_counter() - start) * 1000), ) ) await self.session.flush() @@ -122,7 +125,11 @@ async def touch( def _with_conversation_logging( chat_fn: Callable[..., Awaitable[ChatResult]], ) -> Callable[..., Awaitable[ChatResult]]: - """Wrap a chat() method to automatically log conversations and messages.""" + """Wrap a chat() method to log tool calls to an existing conversation. + + Conversation creation and user/assistant message logging are handled + by the endpoint (dashboard.py ai_chat) to avoid duplicate conversations. + """ @wraps(chat_fn) async def wrapper( @@ -133,47 +140,25 @@ async def wrapper( conversation_id: str | None = None, **kwargs: Any, ) -> ChatResult: - recorder = ConversationRecorder(deps.session) - conv = await recorder.get_or_create( - conversation_id, - agent_name=getattr(self, "name", "default"), - user=deps.admin_user, - ) - - await recorder.log_message(conv, role="user", content=message) - - start = time.perf_counter() try: result = await chat_fn(self, message, deps, message_history=message_history, **kwargs) - except Exception as exc: - await recorder.log_error(conv, error=str(exc)) + except Exception: raise - latency_ms = int((time.perf_counter() - start) * 1000) - cost = getattr(self, "_compute_cost", lambda u: 0.0)(getattr(result, "usage", None)) - - await recorder.log_message( - conv, - role="assistant", - content=str(getattr(result, "output", "")), - tokens=getattr(result, "usage", None) and getattr(result.usage, "total_tokens", None), - latency_ms=latency_ms, - ) - for call in getattr(result, "tool_calls", []): - await recorder.log_tool_call(conv, call) + if conversation_id: + from sqlalchemy import select - tokens_delta = 0 - usage_obj = getattr(result, "usage", None) - if usage_obj: - tokens_delta = getattr(usage_obj, "total_tokens", 0) or 0 + from fastapi_admin_kit.ai.usage import AIConversation + + recorder = ConversationRecorder(deps.session) + conv_result = await deps.session.execute( + select(AIConversation).where(AIConversation.id == conversation_id) + ) + conv = conv_result.scalar_one_or_none() + if conv: + await recorder.log_tool_call(conv, call) - await recorder.touch( - conv, - tokens_delta=tokens_delta, - cost_delta=cost, - ) - result.conversation_id = conv.id return result return wrapper @@ -204,10 +189,12 @@ async def wrapper( start = time.perf_counter() accumulated: list[str] = [] + stream_result = None try: async for chunk in chat_stream_fn( self, message, deps, message_history=message_history, **kwargs ): + stream_result = chunk accumulated.append(str(chunk)) yield chunk except Exception as exc: @@ -224,6 +211,18 @@ async def wrapper( latency_ms=latency_ms, ) + if stream_result is not None: + try: + from fastapi_admin_kit.ai.backends.pydantic_ai_backend import ( + _extract_tool_calls, + ) + + tool_calls = _extract_tool_calls(stream_result) + for tc in tool_calls: + await recorder.log_tool_call(conv, tc) + except Exception: + pass + await recorder.touch(conv) return wrapper diff --git a/fastapi_admin_kit/ai/dashboard.py b/fastapi_admin_kit/ai/dashboard.py index 563f627..4d26d80 100644 --- a/fastapi_admin_kit/ai/dashboard.py +++ b/fastapi_admin_kit/ai/dashboard.py @@ -18,6 +18,52 @@ router = APIRouter(prefix="/ai", tags=["ai"]) +def _deserialize_messages(raw: list[dict]) -> list: + """Convert stored message dicts back to ModelMessage objects.""" + import dataclasses as _dc + + from pydantic_ai.messages import ( + ModelRequest, + ModelResponse, + TextPart, + ToolCallPart, + ToolReturnPart, + UserPromptPart, + ) + + part_map = { + "user-prompt": UserPromptPart, + "text": TextPart, + "tool-call": ToolCallPart, + "tool-return": ToolReturnPart, + } + + def _build_part(d: dict): + if not isinstance(d, dict): + return d + cls = part_map.get(d.get("part_kind", "")) + if cls and _dc.is_dataclass(cls): + fields = {k: v for k, v in d.items() if k in cls.__dataclass_fields__} + return cls(**fields) + return d + + messages = [] + for item in raw: + if not isinstance(item, dict): + continue + kind = item.get("kind", "request") + data = dict(item) + if "parts" in data and isinstance(data["parts"], list): + data["parts"] = [_build_part(p) for p in data["parts"]] + if kind == "request": + fields = {k: v for k, v in data.items() if k in ModelRequest.__dataclass_fields__} + messages.append(ModelRequest(**fields)) + elif kind == "response": + fields = {k: v for k, v in data.items() if k in ModelResponse.__dataclass_fields__} + messages.append(ModelResponse(**fields)) + return messages + + def _get_jinja(request: Request) -> jinja2.Environment: return request.app.state.admin_jinja_env @@ -90,14 +136,17 @@ async def ai_logs_page(request: Request) -> jinja2.TemplateResponse: @router.get("/dashboard") async def ai_dashboard(request: Request) -> jinja2.TemplateResponse: """AI operations dashboard showing costs, logs, and tool calls.""" + from fastapi_admin_kit.db import get_db_session + agents = _get_ai_agents(request) admin = _get_admin(request) jinja = _get_jinja(request) + session = get_db_session(request) stats: list[dict[str, object]] = [] for name, agent in agents.items(): try: - s = await agent.get_usage_stats(period="day") + s = await agent.get_usage_stats(period="day", session=session) except Exception: s = { "total_tokens": 0, @@ -162,6 +211,50 @@ async def get_ai_logs( ) +@router.get("/tool-calls/api") +async def get_tool_calls( + request: Request, + limit: int = 100, + offset: int = 0, + tool: str | None = None, + success: bool | None = None, +) -> JSONResponse: + """Get tool call history across all conversations.""" + from sqlalchemy import select + + from fastapi_admin_kit.ai.usage import AIMessage + from fastapi_admin_kit.db import get_db_session + + session = get_db_session(request) + stmt = select(AIMessage).where(AIMessage.role == "tool").order_by(AIMessage.created_at.desc()) + + if tool: + stmt = stmt.where(AIMessage.tool_name == tool) + if success is not None: + stmt = stmt.where(AIMessage.is_error == (not success)) + + stmt = stmt.offset(offset).limit(limit) + result = await session.execute(stmt) + msgs = result.scalars().all() + + return JSONResponse( + [ + { + "id": m.id, + "conversation_id": m.conversation_id, + "tool_name": m.tool_name, + "tool_args": m.tool_args, + "tool_result": m.tool_result, + "is_error": m.is_error, + "error": m.error, + "latency_ms": m.latency_ms, + "created_at": str(m.created_at) if m.created_at else None, + } + for m in msgs + ] + ) + + @router.get("/costs") async def get_ai_costs( request: Request, @@ -245,10 +338,52 @@ async def execute_tool_endpoint( permission_checker=checker, ) + import time + + start = time.perf_counter() try: result = await agent.execute_tool(tool_name, params or {}, deps) + latency_ms = int((time.perf_counter() - start) * 1000) + + from fastapi_admin_kit.ai.usage import AIUsageWriter + + writer = AIUsageWriter() + await writer.write( + agent_name=agent_name, + model=getattr(agent._config, "model", "unknown"), + request_tokens=0, + response_tokens=0, + total_tokens=0, + cost=0, + user=user, + success=True, + latency_ms=latency_ms, + tool_calls=[{"name": tool_name, "args": params or {}, "ok": True}], + session=session, + ) + return JSONResponse({"success": True, "result": result}) except Exception as e: + latency_ms = int((time.perf_counter() - start) * 1000) + + from fastapi_admin_kit.ai.usage import AIUsageWriter + + writer = AIUsageWriter() + await writer.write( + agent_name=agent_name, + model=getattr(agent._config, "model", "unknown"), + request_tokens=0, + response_tokens=0, + total_tokens=0, + cost=0, + user=user, + success=False, + error=str(e), + latency_ms=latency_ms, + tool_calls=[{"name": tool_name, "args": params or {}, "ok": False}], + session=session, + ) + return JSONResponse({"success": False, "error": str(e)}, status_code=400) @@ -285,13 +420,19 @@ async def get_ai_agents(request: Request) -> JSONResponse: @router.post("/chat") async def ai_chat(request: Request) -> JSONResponse: """Send a message to an AI agent.""" + from uuid import uuid4 + + from sqlalchemy import select + from fastapi_admin_kit.ai.deps import AdminDeps + from fastapi_admin_kit.ai.usage import AIConversation, AIMessage from fastapi_admin_kit.db import get_db_session body = await request.json() message = body.get("message", "") agent_name = body.get("agent", "default") conversation_id = body.get("conversation_id") + page_url = body.get("page_url") agents = _get_ai_agents(request) agent = agents.get(agent_name) @@ -308,21 +449,234 @@ async def ai_chat(request: Request) -> JSONResponse: request=request, registry=request.app.state.admin_registry, permission_checker=checker, + page_url=page_url, ) try: - result = await agent.chat(message, deps, conversation_id=conversation_id) + message_history = None + + if conversation_id: + result = await session.execute( + select(AIConversation).where(AIConversation.id == conversation_id) + ) + conv = result.scalar_one_or_none() + if conv and conv.message_history: + message_history = _deserialize_messages(conv.message_history) + + result = await agent.chat( + message, + deps, + message_history=message_history, + conversation_id=conversation_id, + ) + + output_text = str(result.output) + import dataclasses as _dc + from datetime import datetime + + def _safe_dict(obj): + d = _dc.asdict(obj) if _dc.is_dataclass(obj) and not isinstance(obj, type) else str(obj) + return _sanitize(d) + + def _sanitize(v): + if isinstance(v, datetime): + return v.isoformat() + if isinstance(v, dict): + return {k: _sanitize(val) for k, val in v.items()} + if isinstance(v, list): + return [_sanitize(item) for item in v] + return v + + new_messages = [_safe_dict(m) for m in result.new_messages] + + if conversation_id: + conv_result = await session.execute( + select(AIConversation).where(AIConversation.id == conversation_id) + ) + conv = conv_result.scalar_one_or_none() + if conv: + existing = conv.message_history or [] + conv.message_history = existing + new_messages + conv.turn_count = (conv.turn_count or 0) + 1 + conv.total_tokens = (conv.total_tokens or 0) + result.usage.total_tokens + conv.total_cost = float(conv.total_cost or 0) + result.usage.cost + from sqlalchemy.sql import func as sqlfunc + + conv.last_message_at = sqlfunc.now() + else: + conversation_id = str(uuid4()) + conv = AIConversation( + id=conversation_id, + agent_name=agent_name, + user_id=getattr(user, "id", None), + user_email=getattr(user, "email", None), + title=message[:80], + message_history=new_messages, + turn_count=1, + total_tokens=result.usage.total_tokens, + total_cost=result.usage.cost, + ) + session.add(conv) + else: + conversation_id = str(uuid4()) + conv = AIConversation( + id=conversation_id, + agent_name=agent_name, + user_id=getattr(user, "id", None), + user_email=getattr(user, "email", None), + title=message[:80], + message_history=new_messages, + turn_count=1, + total_tokens=result.usage.total_tokens, + total_cost=result.usage.cost, + ) + session.add(conv) + + session.add( + AIMessage( + conversation_id=conversation_id, + role="user", + content=message, + ) + ) + session.add( + AIMessage( + conversation_id=conversation_id, + role="assistant", + content=output_text, + tokens=result.usage.total_tokens, + latency_ms=None, + ) + ) + for tc in getattr(result, "tool_calls", []): + session.add( + AIMessage( + conversation_id=conversation_id, + role="tool", + tool_name=getattr(tc, "name", None), + tool_args=getattr(tc, "args", None), + tool_result=getattr(tc, "result", None), + content=str(getattr(tc, "result", "")), + is_error=getattr(tc, "is_error", False), + ) + ) + await session.flush() + return JSONResponse( { - "output": str(result.output), + "output": output_text, "usage": { "request_tokens": result.usage.request_tokens, "response_tokens": result.usage.response_tokens, "total_tokens": result.usage.total_tokens, "cost": result.usage.cost, }, - "conversation_id": result.conversation_id, + "conversation_id": conversation_id, } ) except Exception as e: return JSONResponse({"error": str(e)}, status_code=400) + + +@router.get("/conversations") +async def list_conversations(request: Request) -> JSONResponse: + """List current user's conversations.""" + from sqlalchemy import select + + from fastapi_admin_kit.ai.usage import AIConversation + from fastapi_admin_kit.db import get_db_session + + user = await _resolve_user(request) + session = get_db_session(request) + + result = await session.execute( + select(AIConversation) + .where(AIConversation.user_id == getattr(user, "id", None)) + .order_by(AIConversation.last_message_at.desc().nullslast()) + .limit(50) + ) + convs = result.scalars().all() + + return JSONResponse( + [ + { + "id": c.id, + "title": c.title or "Untitled", + "agent_name": c.agent_name, + "turn_count": c.turn_count or 0, + "started_at": str(c.started_at) if c.started_at else None, + "last_message_at": str(c.last_message_at) if c.last_message_at else None, + } + for c in convs + ] + ) + + +@router.get("/conversations/{conversation_id}") +async def load_conversation(conversation_id: str, request: Request) -> JSONResponse: + """Load messages for a conversation.""" + from sqlalchemy import select + + from fastapi_admin_kit.ai.usage import AIConversation, AIMessage + from fastapi_admin_kit.db import get_db_session + + user = await _resolve_user(request) + session = get_db_session(request) + + conv_result = await session.execute( + select(AIConversation).where( + AIConversation.id == conversation_id, + AIConversation.user_id == getattr(user, "id", None), + ) + ) + conv = conv_result.scalar_one_or_none() + if not conv: + raise HTTPException(status_code=404, detail="Conversation not found.") + + result = await session.execute( + select(AIMessage) + .where(AIMessage.conversation_id == conversation_id) + .order_by(AIMessage.created_at) + ) + msgs = result.scalars().all() + + return JSONResponse( + [ + { + "role": m.role, + "content": m.content, + "created_at": str(m.created_at) if m.created_at else None, + } + for m in msgs + ] + ) + + +@router.delete("/conversations/{conversation_id}") +async def delete_conversation(conversation_id: str, request: Request) -> JSONResponse: + """Delete a conversation and its messages.""" + from sqlalchemy import select + + from fastapi_admin_kit.ai.usage import AIConversation, AIMessage + from fastapi_admin_kit.db import get_db_session + + user = await _resolve_user(request) + session = get_db_session(request) + + result = await session.execute( + select(AIConversation).where( + AIConversation.id == conversation_id, + AIConversation.user_id == getattr(user, "id", None), + ) + ) + conv = result.scalar_one_or_none() + if not conv: + raise HTTPException(status_code=404, detail="Conversation not found.") + + await session.execute(select(AIMessage).where(AIMessage.conversation_id == conversation_id)) + from sqlalchemy import delete as sqldel + + await session.execute(sqldel(AIMessage).where(AIMessage.conversation_id == conversation_id)) + await session.delete(conv) + + return JSONResponse({"success": True}) diff --git a/fastapi_admin_kit/ai/deps.py b/fastapi_admin_kit/ai/deps.py index b3a8b6b..29bf233 100644 --- a/fastapi_admin_kit/ai/deps.py +++ b/fastapi_admin_kit/ai/deps.py @@ -24,6 +24,7 @@ class AdminDeps: request: Request registry: AdminRegistry permission_checker: PermissionChecker + page_url: str | None = None async def get_admin_deps(request: Request) -> AdminDeps: diff --git a/fastapi_admin_kit/ai/tools.py b/fastapi_admin_kit/ai/tools.py index aa89465..8b13432 100644 --- a/fastapi_admin_kit/ai/tools.py +++ b/fastapi_admin_kit/ai/tools.py @@ -65,6 +65,18 @@ def all(self) -> list[Tool]: def by_category(self, category: str) -> list[Tool]: return [t for t in self._tools.values() if t.category == category] + def resolve(self, names: list[str]) -> list[Tool]: + """Resolve a list of tool names to Tool objects, raising if any are unknown.""" + tools: list[Tool] = [] + for name in names: + tool = self._tools.get(name) + if tool is None: + raise KeyError( + f"Tool '{name}' not found in registry. Available: {list(self._tools.keys())}" + ) + tools.append(tool) + return tools + tool_registry = ToolRegistry() diff --git a/fastapi_admin_kit/ai/usage.py b/fastapi_admin_kit/ai/usage.py index fa0882c..9fc64e4 100644 --- a/fastapi_admin_kit/ai/usage.py +++ b/fastapi_admin_kit/ai/usage.py @@ -99,6 +99,7 @@ class AIMessage(Base): tokens = Column(Integer) latency_ms = Column(Integer) error = Column(Text) + is_error = Column(Boolean, default=False) created_at = Column(DateTime(timezone=True), server_default=func.now()) @@ -137,7 +138,7 @@ async def write( latency_ms=latency_ms, ) ) - await session.commit() + await session.flush() async def aggregate( self, @@ -145,14 +146,17 @@ async def aggregate( period: str, session: AsyncSession, ) -> dict[str, object]: + from datetime import UTC, datetime, timedelta + + from sqlalchemy import case as sqlcase from sqlalchemy import func as sqlfunc from sqlalchemy import select from fastapi_admin_kit.ai.usage import AIUsageLog - interval_map = {"day": "1 day", "week": "7 days", "month": "30 days"} - interval = interval_map.get(period, "1 day") - days = int(interval.split()[0]) + days_map = {"day": 1, "week": 7, "month": 30} + days = days_map.get(period, 1) + cutoff = datetime.now(UTC) - timedelta(days=days) result = await session.execute( select( @@ -161,14 +165,14 @@ async def aggregate( sqlfunc.count(AIUsageLog.id).label("total_runs"), sqlfunc.avg(AIUsageLog.latency_ms).label("avg_latency_ms"), sqlfunc.sum( - sqlfunc.case( + sqlcase( (AIUsageLog.success == True, 1), # noqa: E712 else_=0, ) ).label("success_count"), ) .where(AIUsageLog.agent_name == agent_name) - .where(AIUsageLog.timestamp >= func.now() - func.make_interval(*[0, 0, 0, 0, days])) + .where(AIUsageLog.timestamp >= cutoff) ) row = result.one() total_runs = row.total_runs or 0 diff --git a/fastapi_admin_kit/templates/pages/ai/chat.html b/fastapi_admin_kit/templates/pages/ai/chat.html index f866e24..d946d43 100644 --- a/fastapi_admin_kit/templates/pages/ai/chat.html +++ b/fastapi_admin_kit/templates/pages/ai/chat.html @@ -1,23 +1,156 @@ -{# pages/ai/chat.html — Full-page AI Chat Interface #} +{# pages/ai/chat.html — Full-page AI Chat Interface with Conversation History #} {% extends "base.html" %} {% block title %}AI Chat{% endblock %} {% block head_extra %} {% endblock %} {% block content %} -
-
-
-
- smart_toy -
-
-

AI Assistant

-

Ask anything about your data

-
-
-
- -
+
+ + +
-
- - - +
- -
-
- - + +
+ +
+
+ + +
@@ -447,9 +613,11 @@

How can I help?

selectedAgent: 'default', agents: [], conversationId: null, + conversations: [], async init() { await this.loadAgents(); + await this.loadConversations(); this.$nextTick(() => { this.autoResize(this.$refs.chatInput); this.$refs.chatInput?.focus(); @@ -468,6 +636,48 @@

How can I help?

} }, + async loadConversations() { + try { + const resp = await fetch(window.__ADMIN_PATH__ + '/ai/conversations'); + this.conversations = await resp.json(); + } catch (e) { + this.conversations = []; + } + }, + + async loadConversation(id) { + this.conversationId = id; + this.messages = []; + try { + const resp = await fetch(window.__ADMIN_PATH__ + '/ai/conversations/' + id); + const msgs = await resp.json(); + this.messages = msgs.map(m => ({ + role: m.role, + content: m.content, + time: m.created_at ? new Date(m.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '', + })); + this.scrollToBottom(); + } catch (e) { + this.messages = []; + } + }, + + async deleteConversation(id) { + try { + await fetch(window.__ADMIN_PATH__ + '/ai/conversations/' + id, { method: 'DELETE' }); + this.conversations = this.conversations.filter(c => c.id !== id); + if (this.conversationId === id) { + this.newChat(); + } + } catch (e) {} + }, + + newChat() { + this.messages = []; + this.conversationId = null; + this.$refs.chatInput?.focus(); + }, + async sendMessage() { const text = this.userInput.trim(); if (!text || this.isTyping) return; @@ -484,21 +694,23 @@

How can I help?

body: JSON.stringify({ message: text, agent: this.selectedAgent, - conversation_id: this.conversationId + conversation_id: this.conversationId, + page_url: window.location.pathname }) }); const data = await resp.json(); this.isTyping = false; if (data.error) { - this.addMessage('ai', 'Error: ' + data.error); + this.addMessage('assistant', 'Error: ' + data.error); } else { this.conversationId = data.conversation_id || this.conversationId; - this.addMessage('ai', data.output); + this.addMessage('assistant', data.output); + await this.loadConversations(); } } catch (e) { this.isTyping = false; - this.addMessage('ai', 'Request failed: ' + e.message); + this.addMessage('assistant', 'Request failed: ' + e.message); } this.scrollToBottom(); @@ -509,15 +721,10 @@

How can I help?

this.sendMessage(); }, - addMessage(role, text) { + addMessage(role, content) { const now = new Date(); const time = now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); - this.messages.push({ role, text, time }); - }, - - clearChat() { - this.messages = []; - this.conversationId = null; + this.messages.push({ role, content, time }); }, formatMessage(text) { @@ -532,6 +739,20 @@

How can I help?

return html; }, + timeAgo(dateStr) { + if (!dateStr) return ''; + const now = new Date(); + const then = new Date(dateStr); + const diffMs = now - then; + const mins = Math.floor(diffMs / 60000); + if (mins < 1) return 'just now'; + if (mins < 60) return mins + 'm ago'; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return hrs + 'h ago'; + const days = Math.floor(hrs / 24); + return days + 'd ago'; + }, + scrollToBottom() { this.$nextTick(() => { const el = this.$refs.messagesContainer; @@ -543,7 +764,7 @@

How can I help?

if (!el) return; el.addEventListener('input', () => { el.style.height = 'auto'; - el.style.height = Math.min(el.scrollHeight, 160) + 'px'; + el.style.height = Math.min(el.scrollHeight, 140) + 'px'; }); } }; diff --git a/fastapi_admin_kit/templates/partials/ai_chat_widget.html b/fastapi_admin_kit/templates/partials/ai_chat_widget.html index 91fa385..a39b5d2 100644 --- a/fastapi_admin_kit/templates/partials/ai_chat_widget.html +++ b/fastapi_admin_kit/templates/partials/ai_chat_widget.html @@ -1,6 +1,6 @@ {# partials/ai_chat_widget.html — Floating AI Chat Widget (bottom-right on all pages) #} {% if admin_config.ai_enabled | default(false) %} -
+
{# ── Floating toggle button ──────────────────────────────────────────── #}