Version 1.2.2. Added usage overview. Shows token used and cost in $.
This commit is contained in:
@@ -470,7 +470,7 @@ class Agent:
|
||||
confirmed = False
|
||||
|
||||
# Confirmation flow (interactive sessions only)
|
||||
if tool.requires_confirmation and task_id is None:
|
||||
if await tool.should_confirm(**tc.arguments) and task_id is None:
|
||||
description = tool.confirmation_description(**tc.arguments)
|
||||
yield ConfirmationRequiredEvent(
|
||||
call_id=tc.id,
|
||||
@@ -697,12 +697,18 @@ class Agent:
|
||||
# Update in-memory history for multi-turn
|
||||
self._session_history[session_id] = messages
|
||||
|
||||
# Persist conversation to DB
|
||||
# Persist conversation to DB (with accumulated usage for cost tracking)
|
||||
from ..providers.models import compute_cost_usd as _compute_cost
|
||||
_model_str = model or "" # full "provider:model" string for pricing lookup
|
||||
_cost = _compute_cost(_model_str, total_usage.input_tokens, total_usage.output_tokens) if _model_str else None
|
||||
await _save_conversation(
|
||||
session_id=session_id,
|
||||
messages=messages,
|
||||
task_id=task_id,
|
||||
model=response.model or run_model or model or "",
|
||||
input_tokens=total_usage.input_tokens,
|
||||
output_tokens=total_usage.output_tokens,
|
||||
cost_usd=_cost,
|
||||
)
|
||||
|
||||
yield DoneEvent(
|
||||
@@ -735,6 +741,9 @@ async def _save_conversation(
|
||||
messages: list[dict],
|
||||
task_id: str | None,
|
||||
model: str = "",
|
||||
input_tokens: int = 0,
|
||||
output_tokens: int = 0,
|
||||
cost_usd: float | None = None,
|
||||
) -> None:
|
||||
from ..context_vars import current_user as _cu
|
||||
user_id = _cu.get().id if _cu.get() else None
|
||||
@@ -745,26 +754,41 @@ async def _save_conversation(
|
||||
"SELECT id, title FROM conversations WHERE id = $1", session_id
|
||||
)
|
||||
if existing:
|
||||
# Only update title if still unset (don't overwrite a user-renamed title)
|
||||
# Accumulate tokens across turns; only update title if still unset
|
||||
if not existing["title"]:
|
||||
title = _derive_title(messages)
|
||||
await pool.execute(
|
||||
"UPDATE conversations SET messages = $1, ended_at = $2, title = $3, model = $4 WHERE id = $5",
|
||||
messages, now, title, model or None, session_id,
|
||||
"""UPDATE conversations
|
||||
SET messages = $1, ended_at = $2, title = $3, model = $4,
|
||||
input_tokens = COALESCE(input_tokens, 0) + $5,
|
||||
output_tokens = COALESCE(output_tokens, 0) + $6,
|
||||
cost_usd = COALESCE(cost_usd, 0) + COALESCE($7, 0)
|
||||
WHERE id = $8""",
|
||||
messages, now, title, model or None,
|
||||
input_tokens, output_tokens, cost_usd, session_id,
|
||||
)
|
||||
else:
|
||||
await pool.execute(
|
||||
"UPDATE conversations SET messages = $1, ended_at = $2, model = $3 WHERE id = $4",
|
||||
messages, now, model or None, session_id,
|
||||
"""UPDATE conversations
|
||||
SET messages = $1, ended_at = $2, model = $3,
|
||||
input_tokens = COALESCE(input_tokens, 0) + $4,
|
||||
output_tokens = COALESCE(output_tokens, 0) + $5,
|
||||
cost_usd = COALESCE(cost_usd, 0) + COALESCE($6, 0)
|
||||
WHERE id = $7""",
|
||||
messages, now, model or None,
|
||||
input_tokens, output_tokens, cost_usd, session_id,
|
||||
)
|
||||
else:
|
||||
title = _derive_title(messages)
|
||||
await pool.execute(
|
||||
"""
|
||||
INSERT INTO conversations (id, started_at, ended_at, messages, task_id, user_id, title, model)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
INSERT INTO conversations
|
||||
(id, started_at, ended_at, messages, task_id, user_id, title, model,
|
||||
input_tokens, output_tokens, cost_usd)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
""",
|
||||
session_id, now, now, messages, task_id, user_id, title, model or None,
|
||||
input_tokens, output_tokens, cost_usd,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save conversation {session_id}: {e}")
|
||||
|
||||
@@ -277,12 +277,17 @@ class AgentRunner:
|
||||
elif isinstance(event, ErrorEvent):
|
||||
final_text = f"Error: {event.message}"
|
||||
|
||||
run_model = agent_data.get("model") or ""
|
||||
from ..providers.models import compute_cost_usd
|
||||
cost = compute_cost_usd(run_model, input_tokens, output_tokens) if run_model else None
|
||||
await agent_store.finish_run(
|
||||
run_id,
|
||||
status="success",
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
result=final_text,
|
||||
model=run_model or None,
|
||||
cost_usd=cost,
|
||||
)
|
||||
logger.info(
|
||||
f"[agent-runner] Agent '{agent_data['name']}' run={run_id[:8]} completed OK"
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
"""
|
||||
agents/tasks.py — Agent and agent run CRUD operations (async).
|
||||
agents/tasks.py — Agent and agent run CRUD operations (async, PostgreSQL).
|
||||
|
||||
Agents are persistent, named, goal-oriented configurations that can be:
|
||||
- Run manually (via the Agents UI or /api/agents/{id}/run)
|
||||
- Scheduled with a cron expression (managed by AgentRunner + APScheduler)
|
||||
- Triggered by email, Telegram, webhooks, or monitors
|
||||
|
||||
agent_runs records every execution — input/output tokens, status, result text.
|
||||
This is the source of truth for the Agents UI run history and token totals.
|
||||
|
||||
Design note on allowed_tools:
|
||||
- NULL in DB means "all tools" (unlimited access)
|
||||
- [] (empty list) is falsy in Python — treated identically to NULL
|
||||
- Non-empty list restricts the agent to exactly those tools
|
||||
- This is enforced structurally: only declared schemas are sent to the model;
|
||||
it is impossible for the model to call a tool it wasn't given a schema for.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -161,6 +176,8 @@ async def finish_run(
|
||||
output_tokens: int = 0,
|
||||
result: str | None = None,
|
||||
error: str | None = None,
|
||||
model: str | None = None,
|
||||
cost_usd: float | None = None,
|
||||
) -> dict | None:
|
||||
now = _now()
|
||||
pool = await get_pool()
|
||||
@@ -168,10 +185,12 @@ async def finish_run(
|
||||
"""
|
||||
UPDATE agent_runs
|
||||
SET ended_at = $1, status = $2, input_tokens = $3,
|
||||
output_tokens = $4, result = $5, error = $6
|
||||
WHERE id = $7
|
||||
output_tokens = $4, result = $5, error = $6,
|
||||
model = $7, cost_usd = $8
|
||||
WHERE id = $9
|
||||
""",
|
||||
now, status, input_tokens, output_tokens, result, error, run_id,
|
||||
now, status, input_tokens, output_tokens, result, error,
|
||||
model or None, cost_usd, run_id,
|
||||
)
|
||||
return await get_run(run_id)
|
||||
|
||||
|
||||
@@ -465,6 +465,29 @@ _MIGRATIONS: list[list[str]] = [
|
||||
[
|
||||
"ALTER TABLE webhook_targets ADD COLUMN IF NOT EXISTS owner_user_id TEXT REFERENCES users(id) ON DELETE CASCADE",
|
||||
],
|
||||
# v28 — Browser trusted domains (per-user interaction pre-approval)
|
||||
[
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS browser_approved_domains (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
owner_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
domain TEXT NOT NULL,
|
||||
note TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE (owner_user_id, domain)
|
||||
)
|
||||
""",
|
||||
],
|
||||
# v29 — Track model per agent run for usage/cost overview
|
||||
[
|
||||
"ALTER TABLE agent_runs ADD COLUMN IF NOT EXISTS model TEXT",
|
||||
],
|
||||
# v30 — Track token usage and cost per chat conversation
|
||||
[
|
||||
"ALTER TABLE conversations ADD COLUMN IF NOT EXISTS input_tokens INTEGER NOT NULL DEFAULT 0",
|
||||
"ALTER TABLE conversations ADD COLUMN IF NOT EXISTS output_tokens INTEGER NOT NULL DEFAULT 0",
|
||||
"ALTER TABLE conversations ADD COLUMN IF NOT EXISTS cost_usd REAL",
|
||||
],
|
||||
]
|
||||
|
||||
|
||||
@@ -821,7 +844,15 @@ filesystem_whitelist_store = FilesystemWhitelistStore()
|
||||
# ─── Initialisation ───────────────────────────────────────────────────────────
|
||||
|
||||
async def _init_connection(conn: asyncpg.Connection) -> None:
|
||||
"""Register codecs on every new connection so asyncpg handles JSONB ↔ dict."""
|
||||
"""
|
||||
Register codecs on every new connection so asyncpg handles JSONB ↔ dict automatically.
|
||||
|
||||
Critical: asyncpg does NOT auto-serialize Python dicts to PostgreSQL JSONB.
|
||||
Without this, inserting a dict into a JSONB column raises:
|
||||
asyncpg.exceptions.UnsupportedClientFeatureError: ...
|
||||
The codecs must be registered on every connection (not just once on the pool),
|
||||
which is why this is passed as `init=` to create_pool().
|
||||
"""
|
||||
await conn.set_type_codec(
|
||||
"jsonb",
|
||||
encoder=json.dumps,
|
||||
|
||||
@@ -85,6 +85,14 @@ class EmailAccountListener:
|
||||
# ── Main loop ─────────────────────────────────────────────────────────────
|
||||
|
||||
async def _run_loop(self) -> None:
|
||||
"""
|
||||
Outer retry loop. Restarts the inner trigger/handling loop on any error
|
||||
with exponential backoff (5s → 10s → 20s → ... → 60s max).
|
||||
|
||||
CancelledError is propagated cleanly (listener.stop() cancels the task).
|
||||
Any other exception is logged and retried — typical causes: IMAP disconnect,
|
||||
network timeout, credential rotation. Backoff resets on successful cycle.
|
||||
"""
|
||||
backoff = 5
|
||||
while True:
|
||||
try:
|
||||
|
||||
@@ -2,9 +2,38 @@
|
||||
main.py — FastAPI application entry point.
|
||||
|
||||
Provides:
|
||||
- HTML pages: /, /agents, /audit, /settings, /login, /setup, /admin/users
|
||||
- WebSocket: /ws/{session_id} (streaming agent responses)
|
||||
- REST API: /api/*
|
||||
- HTML pages: /, /chats, /agents, /models, /audit, /monitors, /files,
|
||||
/settings, /login, /setup, /admin/users, /help
|
||||
- WebSocket: /ws/{session_id} (streaming agent responses)
|
||||
- REST API: /api/* (see server/web/routes.py)
|
||||
- Brain MCP: /brain-mcp/sse (MCP protocol for 2nd Brain access)
|
||||
|
||||
Startup order (lifespan):
|
||||
1. init_db() — run PostgreSQL migrations, create pool
|
||||
2. _refresh_brand_globals() — load brand name/logo from DB into Jinja2 globals
|
||||
3. _ensure_session_secret() — auto-generate HMAC signing secret if not set
|
||||
4. check user_count() — set _needs_setup flag (redirects to /setup if 0 users)
|
||||
5. cleanup_stale_runs() — mark any interrupted "running" agent_runs as "error"
|
||||
6. init_brain_db() — connect to brain PostgreSQL (pgvector)
|
||||
7. build_registry() — create ToolRegistry with all production tools
|
||||
8. discover_and_register_mcp_tools() — connect to configured MCP servers and add their tools
|
||||
9. Agent(registry=...) — create the singleton agent loop
|
||||
10. agent_runner.init/start() — load agent cron schedules into APScheduler, start scheduler
|
||||
11. page_monitor / rss_monitor — wire into shared APScheduler, load schedules
|
||||
12. _migrate_email_accounts() — one-time migration of old inbox:* credentials
|
||||
13. inbox_listener.start_all() — start IMAP listeners for all email accounts
|
||||
14. telegram_listener.start() — start Telegram long-polling listener
|
||||
15. _session_manager.run() — start Brain MCP session manager
|
||||
|
||||
Key singletons (module-level, shared across all requests):
|
||||
_agent: Agent instance — owns in-memory session history
|
||||
_registry: ToolRegistry — the set of available tools
|
||||
Both are initialised in lifespan() and referenced by the WebSocket handler.
|
||||
|
||||
Auth middleware (_AuthMiddleware):
|
||||
Runs on every request before route handlers. Validates the session cookie or
|
||||
API key, stores the resolved CurrentUser in the current_user ContextVar.
|
||||
Routes use _require_auth() / _require_admin() helpers to enforce access control.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -716,6 +745,22 @@ async def setup_post(request: Request):
|
||||
|
||||
# ── HTML pages ────────────────────────────────────────────────────────────────
|
||||
|
||||
async def _user_can_view_usage(user) -> bool:
|
||||
"""Admins always; non-admins only if they have their own API key and aren't on admin keys."""
|
||||
if not user:
|
||||
return False
|
||||
if user.is_admin:
|
||||
return True
|
||||
from .database import user_settings_store
|
||||
if await user_settings_store.get(user.id, "use_admin_keys"):
|
||||
return False
|
||||
return bool(
|
||||
await user_settings_store.get(user.id, "anthropic_api_key") or
|
||||
await user_settings_store.get(user.id, "openrouter_api_key") or
|
||||
await user_settings_store.get(user.id, "openai_api_key")
|
||||
)
|
||||
|
||||
|
||||
async def _ctx(request: Request, **extra):
|
||||
"""Build template context with current_user and active theme CSS injected."""
|
||||
from .web.themes import get_theme_css, DEFAULT_THEME
|
||||
@@ -734,6 +779,7 @@ async def _ctx(request: Request, **extra):
|
||||
"current_user": user,
|
||||
"theme_css": theme_css,
|
||||
"needs_personality_setup": needs_personality_setup,
|
||||
"can_view_usage": await _user_can_view_usage(user),
|
||||
**extra,
|
||||
}
|
||||
|
||||
@@ -765,6 +811,15 @@ async def models_page(request: Request):
|
||||
return templates.TemplateResponse("models.html", await _ctx(request))
|
||||
|
||||
|
||||
@app.get("/usage", response_class=HTMLResponse)
|
||||
async def usage_page(request: Request):
|
||||
user = _get_current_user(request)
|
||||
if not await _user_can_view_usage(user):
|
||||
from fastapi.responses import RedirectResponse
|
||||
return RedirectResponse("/", status_code=303)
|
||||
return templates.TemplateResponse("usage.html", await _ctx(request))
|
||||
|
||||
|
||||
@app.get("/audit", response_class=HTMLResponse)
|
||||
async def audit_page(request: Request):
|
||||
return templates.TemplateResponse("audit.html", await _ctx(request))
|
||||
|
||||
@@ -372,6 +372,50 @@ async def get_models_info(
|
||||
return results
|
||||
|
||||
|
||||
def get_model_pricing(model_id: str) -> tuple[float | None, float | None]:
|
||||
"""
|
||||
Return (prompt_per_1m, completion_per_1m) in USD for the given model ID.
|
||||
|
||||
Uses only in-memory data (hardcoded Anthropic/OpenAI + cached OpenRouter raw).
|
||||
Returns (None, None) if pricing is unknown for this model.
|
||||
|
||||
model_id format: "anthropic:claude-sonnet-4-6", "openrouter:openai/gpt-4o", "openai:gpt-4o"
|
||||
"""
|
||||
for m in _ANTHROPIC_MODEL_INFO:
|
||||
if m["id"] == model_id:
|
||||
p = m["pricing"]
|
||||
return p["prompt_per_1m"], p["completion_per_1m"]
|
||||
for m in _OPENAI_MODEL_INFO:
|
||||
if m["id"] == model_id:
|
||||
p = m["pricing"]
|
||||
return p["prompt_per_1m"], p["completion_per_1m"]
|
||||
# OpenRouter: strip "openrouter:" prefix to get the bare OR model id
|
||||
if model_id.startswith("openrouter:"):
|
||||
bare = model_id[len("openrouter:"):]
|
||||
for m in _or_raw:
|
||||
if m.get("id", "") == bare and not _is_free_openrouter(m):
|
||||
pricing = m.get("pricing", {})
|
||||
try:
|
||||
prompt = float(pricing.get("prompt", 0)) * 1_000_000
|
||||
completion = float(pricing.get("completion", 0)) * 1_000_000
|
||||
return prompt, completion
|
||||
except (TypeError, ValueError):
|
||||
return None, None
|
||||
return None, None
|
||||
|
||||
|
||||
def compute_cost_usd(
|
||||
model_id: str,
|
||||
input_tokens: int,
|
||||
output_tokens: int,
|
||||
) -> float | None:
|
||||
"""Compute estimated cost in USD for a completed run. Returns None if pricing unknown."""
|
||||
prompt_per_1m, completion_per_1m = get_model_pricing(model_id)
|
||||
if prompt_per_1m is None or completion_per_1m is None:
|
||||
return None
|
||||
return (input_tokens / 1_000_000) * prompt_per_1m + (output_tokens / 1_000_000) * completion_per_1m
|
||||
|
||||
|
||||
async def get_access_tier(
|
||||
user_id: str | None = None,
|
||||
is_admin: bool = True,
|
||||
|
||||
@@ -62,6 +62,14 @@ class BaseTool(ABC):
|
||||
"input_schema": self.input_schema,
|
||||
}
|
||||
|
||||
async def should_confirm(self, **kwargs) -> bool:
|
||||
"""
|
||||
Return True if this specific call requires confirmation.
|
||||
Override in subclasses for per-call logic (e.g. domain allow-lists).
|
||||
Default: return self.requires_confirmation.
|
||||
"""
|
||||
return self.requires_confirmation
|
||||
|
||||
def confirmation_description(self, **kwargs) -> str:
|
||||
"""
|
||||
Human-readable description of the action shown to the user
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
"""
|
||||
tools/browser_tool.py — Playwright headless browser tool.
|
||||
|
||||
For JS-heavy pages that httpx can't render. Enforces the same Tier 1/2
|
||||
web whitelist as WebTool. Browser instance is lazy-initialized and shared
|
||||
across calls.
|
||||
Read operations (fetch_page, screenshot) never require confirmation.
|
||||
Interactive operations (click, fill, select, press) require confirmation
|
||||
unless the target domain is in the user's browser_approved_domains list.
|
||||
|
||||
Sessions are stateful within a session_id: navigate with fetch_page first,
|
||||
then use interactive ops without a url to act on the current page.
|
||||
|
||||
Requires: playwright package + `playwright install chromium`
|
||||
"""
|
||||
@@ -13,7 +16,7 @@ import asyncio
|
||||
import logging
|
||||
from typing import ClassVar
|
||||
|
||||
from ..context_vars import current_task_id, web_tier2_enabled
|
||||
from ..context_vars import current_task_id, current_session_id, web_tier2_enabled
|
||||
from ..security import assert_domain_tier1, sanitize_external_content
|
||||
from .base import BaseTool, ToolResult
|
||||
|
||||
@@ -21,14 +24,39 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
_MAX_TEXT_CHARS = 25_000
|
||||
_TIMEOUT_MS = 30_000
|
||||
_USER_AGENT = (
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
)
|
||||
|
||||
_INTERACTIVE_OPS = {"click", "fill", "select", "press"}
|
||||
|
||||
|
||||
async def _is_domain_approved(user_id: str, hostname: str) -> bool:
|
||||
"""Return True if hostname (or a parent domain) is in the user's approved list."""
|
||||
from ..database import get_pool
|
||||
pool = await get_pool()
|
||||
rows = await pool.fetch(
|
||||
"SELECT domain FROM browser_approved_domains WHERE owner_user_id = $1",
|
||||
user_id,
|
||||
)
|
||||
hostname = hostname.lower()
|
||||
for row in rows:
|
||||
d = row["domain"].lower().lstrip("*.")
|
||||
if hostname == d or hostname.endswith("." + d):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class BrowserTool(BaseTool):
|
||||
name = "browser"
|
||||
description = (
|
||||
"Fetch web pages using a real headless browser (Chromium). "
|
||||
"Use this for JS-heavy pages or single-page apps that the regular 'web' tool cannot read. "
|
||||
"Operations: fetch_page (extract text content), screenshot (base64 PNG). "
|
||||
"Headless Chromium browser for JS-heavy pages and web interactions. "
|
||||
"Read ops: fetch_page (extract text), screenshot (PNG). "
|
||||
"Interactive ops: click, fill (type into field), select (dropdown), press (keyboard key). "
|
||||
"Interactive ops require confirmation unless the domain is in your Browser Trusted Domains list. "
|
||||
"Page state is kept across calls within the same session — navigate with fetch_page first, "
|
||||
"then use interactive ops (omit url to stay on the current page). "
|
||||
"Follows the same domain whitelist rules as the web tool."
|
||||
)
|
||||
input_schema = {
|
||||
@@ -36,87 +64,129 @@ class BrowserTool(BaseTool):
|
||||
"properties": {
|
||||
"operation": {
|
||||
"type": "string",
|
||||
"enum": ["fetch_page", "screenshot"],
|
||||
"description": "fetch_page extracts text; screenshot returns a base64 PNG.",
|
||||
"enum": ["fetch_page", "screenshot", "click", "fill", "select", "press"],
|
||||
"description": (
|
||||
"fetch_page: extract page text. "
|
||||
"screenshot: capture PNG. "
|
||||
"click: click an element (selector required). "
|
||||
"fill: type into a field (selector + value required). "
|
||||
"select: choose a <select> option (selector + value required). "
|
||||
"press: press a keyboard key (key required; selector optional)."
|
||||
),
|
||||
},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "URL to navigate to.",
|
||||
"description": (
|
||||
"URL to navigate to. Required for fetch_page and screenshot. "
|
||||
"For interactive ops, omit to act on the current page."
|
||||
),
|
||||
},
|
||||
"selector": {
|
||||
"type": "string",
|
||||
"description": "CSS selector for click / fill / select / press operations.",
|
||||
},
|
||||
"value": {
|
||||
"type": "string",
|
||||
"description": "Text to type (fill) or option value to select (select).",
|
||||
},
|
||||
"key": {
|
||||
"type": "string",
|
||||
"description": "Key name for press (e.g. 'Enter', 'Tab', 'Escape', 'ArrowDown').",
|
||||
},
|
||||
"wait_for": {
|
||||
"type": "string",
|
||||
"description": "CSS selector to wait for before extracting content (optional).",
|
||||
"description": "CSS selector to wait for before acting (optional).",
|
||||
},
|
||||
"extract_selector": {
|
||||
"type": "string",
|
||||
"description": "CSS selector to extract text from (optional; defaults to full page).",
|
||||
"description": "CSS selector for text extraction in fetch_page (optional; defaults to full page body).",
|
||||
},
|
||||
},
|
||||
"required": ["operation", "url"],
|
||||
"required": ["operation"],
|
||||
}
|
||||
requires_confirmation = False
|
||||
allowed_in_scheduled_tasks = False # Too resource-heavy for scheduled agents
|
||||
requires_confirmation = True # default; read ops override via should_confirm()
|
||||
allowed_in_scheduled_tasks = False
|
||||
|
||||
# Module-level shared browser/playwright (lazy-init, reused)
|
||||
# Shared Playwright/browser instance (lazy-init)
|
||||
_playwright = None
|
||||
_browser = None
|
||||
_lock: ClassVar[asyncio.Lock] = asyncio.Lock()
|
||||
|
||||
async def execute(self, operation: str, url: str = "", wait_for: str = "", extract_selector: str = "", **_) -> ToolResult:
|
||||
if not url:
|
||||
return ToolResult(success=False, error="'url' is required")
|
||||
# Per-session pages: session_id → (context, page)
|
||||
_sessions: ClassVar[dict] = {}
|
||||
|
||||
# Whitelist check (same Tier 1/2 rules as WebTool)
|
||||
denied = await self._check_tier(url)
|
||||
if denied:
|
||||
return denied
|
||||
# ── Confirmation logic ────────────────────────────────────────────────────
|
||||
|
||||
try:
|
||||
from playwright.async_api import async_playwright
|
||||
except ImportError:
|
||||
return ToolResult(
|
||||
success=False,
|
||||
error="Playwright is not installed. Run: pip install playwright && playwright install chromium",
|
||||
)
|
||||
async def should_confirm(self, operation: str = "", url: str = "", **_) -> bool:
|
||||
if operation not in _INTERACTIVE_OPS:
|
||||
return False # read-only ops never need confirmation
|
||||
|
||||
try:
|
||||
# Determine the target hostname
|
||||
target_url = url
|
||||
if not target_url:
|
||||
# Acting on the current page — check its URL
|
||||
sid = current_session_id.get() or "default"
|
||||
data = BrowserTool._sessions.get(sid)
|
||||
if data:
|
||||
try:
|
||||
target_url = data[1].url
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not target_url:
|
||||
return True # Unknown target → confirm to be safe
|
||||
|
||||
from urllib.parse import urlparse
|
||||
hostname = urlparse(target_url).hostname or ""
|
||||
if not hostname:
|
||||
return True
|
||||
|
||||
from ..context_vars import current_user as _cu
|
||||
user = _cu.get()
|
||||
if not user:
|
||||
return True
|
||||
|
||||
return not await _is_domain_approved(user.id, hostname)
|
||||
|
||||
def confirmation_description(self, operation: str = "", url: str = "",
|
||||
selector: str = "", value: str = "", key: str = "", **_) -> str:
|
||||
loc = url or "current page"
|
||||
if operation == "click":
|
||||
return f"Click '{selector}' on {loc}"
|
||||
if operation == "fill":
|
||||
display_val = value[:40] + "…" if len(value) > 40 else value
|
||||
return f"Type \"{display_val}\" into '{selector}' on {loc}"
|
||||
if operation == "select":
|
||||
return f"Select '{value}' in '{selector}' on {loc}"
|
||||
if operation == "press":
|
||||
return f"Press '{key}' on {loc}"
|
||||
return super().confirmation_description(operation=operation, url=url)
|
||||
|
||||
# ── Session management ────────────────────────────────────────────────────
|
||||
|
||||
async def _get_page(self, session_id: str, url: str | None = None):
|
||||
"""Get or create a page for this session; navigate to url if given."""
|
||||
data = BrowserTool._sessions.get(session_id)
|
||||
page = None
|
||||
if data:
|
||||
context, page = data
|
||||
if page.is_closed():
|
||||
try:
|
||||
await context.close()
|
||||
except Exception:
|
||||
pass
|
||||
page = None
|
||||
|
||||
if page is None:
|
||||
browser = await self._get_browser()
|
||||
context = await browser.new_context(
|
||||
user_agent=(
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
)
|
||||
)
|
||||
context = await browser.new_context(user_agent=_USER_AGENT)
|
||||
page = await context.new_page()
|
||||
try:
|
||||
await page.goto(url, timeout=_TIMEOUT_MS, wait_until="domcontentloaded")
|
||||
BrowserTool._sessions[session_id] = (context, page)
|
||||
|
||||
if wait_for:
|
||||
try:
|
||||
await page.wait_for_selector(wait_for, timeout=10_000)
|
||||
except Exception:
|
||||
pass # continue even if selector doesn't appear
|
||||
if url:
|
||||
await page.goto(url, timeout=_TIMEOUT_MS, wait_until="domcontentloaded")
|
||||
|
||||
if operation == "screenshot":
|
||||
data = await page.screenshot(type="png")
|
||||
import base64
|
||||
return ToolResult(success=True, data={"screenshot_base64": base64.b64encode(data).decode()})
|
||||
|
||||
# fetch_page
|
||||
if extract_selector:
|
||||
elements = await page.query_selector_all(extract_selector)
|
||||
text_parts = [await el.inner_text() for el in elements]
|
||||
text = "\n".join(text_parts)
|
||||
else:
|
||||
text = await page.inner_text("body")
|
||||
|
||||
text = text[:_MAX_TEXT_CHARS]
|
||||
text = await sanitize_external_content(text, source="browser")
|
||||
return ToolResult(success=True, data={"url": url, "text": text, "length": len(text)})
|
||||
finally:
|
||||
await context.close()
|
||||
except Exception as e:
|
||||
return ToolResult(success=False, error=f"Browser error: {e}")
|
||||
return page
|
||||
|
||||
async def _get_browser(self):
|
||||
async with BrowserTool._lock:
|
||||
@@ -129,9 +199,9 @@ class BrowserTool(BaseTool):
|
||||
logger.info("[browser] Chromium launched")
|
||||
return BrowserTool._browser
|
||||
|
||||
# ── Domain access check ───────────────────────────────────────────────────
|
||||
|
||||
async def _check_tier(self, url: str) -> ToolResult | None:
|
||||
"""Returns ToolResult(success=False) if denied, None if allowed."""
|
||||
from urllib.parse import urlparse
|
||||
if await assert_domain_tier1(url):
|
||||
return None
|
||||
task_id = current_task_id.get()
|
||||
@@ -139,11 +209,123 @@ class BrowserTool(BaseTool):
|
||||
return None
|
||||
if web_tier2_enabled.get():
|
||||
return None
|
||||
from urllib.parse import urlparse
|
||||
parsed = urlparse(url)
|
||||
return ToolResult(
|
||||
success=False,
|
||||
error=(
|
||||
f"Domain '{parsed.hostname}' is not in the Tier 1 whitelist. "
|
||||
"Ask me to fetch a specific external page to enable Tier 2 access."
|
||||
"Ask me to access a specific external page to enable Tier 2 access."
|
||||
),
|
||||
)
|
||||
|
||||
# ── Execute ───────────────────────────────────────────────────────────────
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
operation: str,
|
||||
url: str = "",
|
||||
selector: str = "",
|
||||
value: str = "",
|
||||
key: str = "",
|
||||
wait_for: str = "",
|
||||
extract_selector: str = "",
|
||||
**_,
|
||||
) -> ToolResult:
|
||||
# Read ops require a url
|
||||
if operation in ("fetch_page", "screenshot") and not url:
|
||||
return ToolResult(success=False, error=f"'url' is required for {operation}")
|
||||
|
||||
try:
|
||||
from playwright.async_api import async_playwright # noqa: F401
|
||||
except ImportError:
|
||||
return ToolResult(
|
||||
success=False,
|
||||
error="Playwright is not installed. Run: pip install playwright && playwright install chromium",
|
||||
)
|
||||
|
||||
# Whitelist check
|
||||
target_url = url
|
||||
if not target_url:
|
||||
sid = current_session_id.get() or "default"
|
||||
data = BrowserTool._sessions.get(sid)
|
||||
if data:
|
||||
try:
|
||||
target_url = data[1].url
|
||||
except Exception:
|
||||
pass
|
||||
if target_url:
|
||||
denied = await self._check_tier(target_url)
|
||||
if denied:
|
||||
return denied
|
||||
|
||||
session_id = current_session_id.get() or "default"
|
||||
|
||||
try:
|
||||
page = await self._get_page(session_id, url or None)
|
||||
|
||||
if wait_for:
|
||||
try:
|
||||
await page.wait_for_selector(wait_for, timeout=10_000)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── Read operations ──────────────────────────────────────────────
|
||||
|
||||
if operation == "fetch_page":
|
||||
if extract_selector:
|
||||
elements = await page.query_selector_all(extract_selector)
|
||||
text_parts = [await el.inner_text() for el in elements]
|
||||
text = "\n".join(text_parts)
|
||||
else:
|
||||
text = await page.inner_text("body")
|
||||
text = text[:_MAX_TEXT_CHARS]
|
||||
text = await sanitize_external_content(text, source="browser")
|
||||
return ToolResult(success=True, data={"url": page.url, "text": text, "length": len(text)})
|
||||
|
||||
if operation == "screenshot":
|
||||
data = await page.screenshot(type="png")
|
||||
import base64
|
||||
return ToolResult(success=True, data={"screenshot_base64": base64.b64encode(data).decode()})
|
||||
|
||||
# ── Interactive operations ───────────────────────────────────────
|
||||
|
||||
if operation == "click":
|
||||
if not selector:
|
||||
return ToolResult(success=False, error="'selector' is required for click")
|
||||
await page.click(selector, timeout=10_000)
|
||||
try:
|
||||
await page.wait_for_load_state("domcontentloaded", timeout=5_000)
|
||||
except Exception:
|
||||
pass
|
||||
preview = (await page.inner_text("body"))[:2000]
|
||||
return ToolResult(success=True, data={"url": page.url, "page_preview": preview})
|
||||
|
||||
if operation == "fill":
|
||||
if not selector:
|
||||
return ToolResult(success=False, error="'selector' is required for fill")
|
||||
await page.fill(selector, value, timeout=10_000)
|
||||
return ToolResult(success=True, data={"url": page.url, "filled": value})
|
||||
|
||||
if operation == "select":
|
||||
if not selector:
|
||||
return ToolResult(success=False, error="'selector' is required for select")
|
||||
await page.select_option(selector, value=value, timeout=10_000)
|
||||
return ToolResult(success=True, data={"url": page.url, "selected": value})
|
||||
|
||||
if operation == "press":
|
||||
if not key:
|
||||
return ToolResult(success=False, error="'key' is required for press")
|
||||
target = selector if selector else "body"
|
||||
await page.press(target, key, timeout=10_000)
|
||||
try:
|
||||
await page.wait_for_load_state("domcontentloaded", timeout=5_000)
|
||||
except Exception:
|
||||
pass
|
||||
preview = (await page.inner_text("body"))[:2000]
|
||||
return ToolResult(success=True, data={"url": page.url, "page_preview": preview})
|
||||
|
||||
return ToolResult(success=False, error=f"Unknown operation: {operation}")
|
||||
|
||||
except Exception as e:
|
||||
return ToolResult(success=False, error=f"Browser error: {e}")
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
"""
|
||||
users.py — User CRUD operations (async, PostgreSQL).
|
||||
|
||||
Design decisions:
|
||||
- User IDs are TEXT (UUID stored as string), NOT the PostgreSQL UUID type.
|
||||
Reason: PostgreSQL UUID type causes FK mismatch errors with asyncpg when columns
|
||||
in other tables store user_id as TEXT. Keeping everything TEXT avoids implicit
|
||||
type casting and makes joins reliable.
|
||||
|
||||
- New users get personality files seeded from the global SOUL.md / USER.md.
|
||||
Admins get the real USER.md verbatim. Non-admins get a blank template so they
|
||||
can fill in their own context without seeing the admin's personal info.
|
||||
|
||||
- User email addresses are automatically added to email_whitelist so the agent
|
||||
can reply to the user without manual whitelist configuration.
|
||||
|
||||
- User folders (provisioned in {users_base_folder}/{username}/) allow non-admin
|
||||
users to have a private filesystem space without configuring the global whitelist.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -137,6 +153,12 @@ async def _sync_email_whitelist(pool, old_email: str | None, new_email: str | No
|
||||
|
||||
|
||||
async def create_user(username: str, password: str, role: str = "user", email: str = "") -> dict:
|
||||
"""
|
||||
Create a new user. Automatically:
|
||||
- Seeds per-user personality from global SOUL.md / USER.md
|
||||
- Adds user's email to email_whitelist (so the agent can reply)
|
||||
- Provisions a user folder if system:users_base_folder is configured
|
||||
"""
|
||||
user_id = str(uuid.uuid4())
|
||||
now = _now()
|
||||
pw_hash = hash_password(password)
|
||||
@@ -222,6 +244,11 @@ async def update_user(user_id: str, **fields) -> bool:
|
||||
|
||||
|
||||
async def delete_user(user_id: str) -> bool:
|
||||
"""
|
||||
Delete a user. Nullifies FK references first to avoid constraint violations.
|
||||
Agents, conversations, and audit entries are preserved (owner set to NULL)
|
||||
rather than cascade-deleted — important for audit trail integrity.
|
||||
"""
|
||||
pool = await get_pool()
|
||||
# Fetch email before delete for whitelist cleanup
|
||||
old_row = await pool.fetchrow("SELECT email FROM users WHERE id = $1", user_id)
|
||||
|
||||
@@ -380,6 +380,58 @@ async def get_queue_status(request: Request):
|
||||
return agent_runner.queue_status
|
||||
|
||||
|
||||
# ── Browser trusted domains ───────────────────────────────────────────────────
|
||||
|
||||
class BrowserDomainIn(BaseModel):
|
||||
domain: str
|
||||
note: Optional[str] = None
|
||||
|
||||
|
||||
@router.get("/my/browser-trusted")
|
||||
async def list_browser_trusted(request: Request):
|
||||
from ..database import get_pool as _gp
|
||||
user = _require_auth(request)
|
||||
pool = await _gp()
|
||||
rows = await pool.fetch(
|
||||
"SELECT id, domain, note, created_at FROM browser_approved_domains "
|
||||
"WHERE owner_user_id = $1 ORDER BY domain",
|
||||
user.id,
|
||||
)
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
@router.post("/my/browser-trusted")
|
||||
async def add_browser_trusted(request: Request, body: BrowserDomainIn):
|
||||
from ..database import get_pool as _gp
|
||||
user = _require_auth(request)
|
||||
domain = body.domain.lower().strip().lstrip("*.")
|
||||
if not domain:
|
||||
raise HTTPException(status_code=400, detail="Invalid domain")
|
||||
pool = await _gp()
|
||||
try:
|
||||
await pool.execute(
|
||||
"INSERT INTO browser_approved_domains (owner_user_id, domain, note) "
|
||||
"VALUES ($1, $2, $3) ON CONFLICT (owner_user_id, domain) DO NOTHING",
|
||||
user.id, domain, body.note or None,
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
return {"ok": True, "domain": domain}
|
||||
|
||||
|
||||
@router.delete("/my/browser-trusted/{domain:path}")
|
||||
async def remove_browser_trusted(request: Request, domain: str):
|
||||
from ..database import get_pool as _gp
|
||||
user = _require_auth(request)
|
||||
domain = domain.lower().strip().lstrip("*.")
|
||||
pool = await _gp()
|
||||
await pool.execute(
|
||||
"DELETE FROM browser_approved_domains WHERE owner_user_id = $1 AND domain = $2",
|
||||
user.id, domain,
|
||||
)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/settings/provider")
|
||||
async def get_default_provider(request: Request):
|
||||
_require_admin(request)
|
||||
@@ -814,6 +866,179 @@ async def stop_run(request: Request, run_id: str):
|
||||
return {"ok": True, "run_id": run_id}
|
||||
|
||||
|
||||
# ── Usage / cost overview ─────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/usage")
|
||||
async def get_usage(
|
||||
request: Request,
|
||||
since: str = "7d",
|
||||
start: str = "",
|
||||
end: str = "",
|
||||
):
|
||||
"""Aggregate token and cost usage by agent for the usage overview page."""
|
||||
user = _require_auth(request)
|
||||
if not user.is_admin:
|
||||
from ..database import user_settings_store as _uss
|
||||
if await _uss.get(user.id, "use_admin_keys"):
|
||||
raise HTTPException(status_code=403, detail="Not available on admin API keys")
|
||||
has_own = (
|
||||
await _uss.get(user.id, "anthropic_api_key") or
|
||||
await _uss.get(user.id, "openrouter_api_key") or
|
||||
await _uss.get(user.id, "openai_api_key")
|
||||
)
|
||||
if not has_own:
|
||||
raise HTTPException(status_code=403, detail="Not available on admin API keys")
|
||||
from ..database import get_pool as _gp
|
||||
pool = await _gp()
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
since_dt: str | None = None
|
||||
|
||||
if start:
|
||||
since_dt = start
|
||||
elif since == "today":
|
||||
since_dt = now.replace(hour=0, minute=0, second=0, microsecond=0).isoformat()
|
||||
elif since == "7d":
|
||||
since_dt = (now - timedelta(days=7)).isoformat()
|
||||
elif since == "30d":
|
||||
since_dt = (now - timedelta(days=30)).isoformat()
|
||||
# since == "all" → no date filter
|
||||
|
||||
# Build WHERE clauses
|
||||
clauses: list[str] = ["ar.status IN ('success', 'error', 'stopped')"]
|
||||
params: list = []
|
||||
n = 1
|
||||
|
||||
# Exclude email handler agents
|
||||
handler_ids_rows = await pool.fetch(
|
||||
"SELECT agent_id FROM email_accounts WHERE agent_id IS NOT NULL"
|
||||
)
|
||||
handler_ids = [str(r["agent_id"]) for r in handler_ids_rows]
|
||||
if handler_ids:
|
||||
placeholders = ", ".join(f"${n + i}" for i in range(len(handler_ids)))
|
||||
clauses.append(f"ar.agent_id NOT IN ({placeholders})")
|
||||
params.extend(handler_ids)
|
||||
n += len(handler_ids)
|
||||
|
||||
if since_dt:
|
||||
clauses.append(f"ar.started_at >= ${n}"); params.append(since_dt); n += 1
|
||||
if end:
|
||||
clauses.append(f"ar.started_at <= ${n}"); params.append(end); n += 1
|
||||
|
||||
# Non-admin sees only their own agents
|
||||
if not user.is_admin:
|
||||
own_agents = await agent_store.list_agents(owner_user_id=user.id)
|
||||
own_ids = [a["id"] for a in own_agents]
|
||||
if own_ids:
|
||||
placeholders = ", ".join(f"${n + i}" for i in range(len(own_ids)))
|
||||
clauses.append(f"ar.agent_id IN ({placeholders})")
|
||||
params.extend(own_ids)
|
||||
n += len(own_ids)
|
||||
else:
|
||||
# No agents → empty result
|
||||
return {"summary": {"runs": 0, "input_tokens": 0, "output_tokens": 0, "cost_usd": None}, "by_agent": []}
|
||||
|
||||
where = "WHERE " + " AND ".join(clauses)
|
||||
|
||||
rows = await pool.fetch(
|
||||
f"""
|
||||
SELECT
|
||||
ar.agent_id,
|
||||
a.name AS agent_name,
|
||||
a.model AS agent_model,
|
||||
COUNT(*) AS runs,
|
||||
SUM(ar.input_tokens) AS input_tokens,
|
||||
SUM(ar.output_tokens) AS output_tokens,
|
||||
SUM(ar.cost_usd) AS cost_usd
|
||||
FROM agent_runs ar
|
||||
LEFT JOIN agents a ON a.id = ar.agent_id
|
||||
{where}
|
||||
GROUP BY ar.agent_id, a.name, a.model
|
||||
ORDER BY cost_usd DESC NULLS LAST, (SUM(ar.input_tokens) + SUM(ar.output_tokens)) DESC
|
||||
""",
|
||||
*params,
|
||||
)
|
||||
|
||||
by_agent = []
|
||||
total_input = 0
|
||||
total_output = 0
|
||||
total_cost: float | None = None
|
||||
total_runs = 0
|
||||
|
||||
for row in rows:
|
||||
inp = int(row["input_tokens"] or 0)
|
||||
out = int(row["output_tokens"] or 0)
|
||||
cost = float(row["cost_usd"]) if row["cost_usd"] is not None else None
|
||||
runs = int(row["runs"])
|
||||
total_input += inp
|
||||
total_output += out
|
||||
total_runs += runs
|
||||
if cost is not None:
|
||||
total_cost = (total_cost or 0.0) + cost
|
||||
by_agent.append({
|
||||
"agent_id": str(row["agent_id"]),
|
||||
"agent_name": row["agent_name"] or "—",
|
||||
"model": row["agent_model"] or "",
|
||||
"runs": runs,
|
||||
"input_tokens": inp,
|
||||
"output_tokens": out,
|
||||
"cost_usd": cost,
|
||||
})
|
||||
|
||||
# ── Chat session usage ────────────────────────────────────────────────────
|
||||
chat_clauses: list[str] = ["task_id IS NULL"] # chat only, not agent/task runs
|
||||
chat_params: list = []
|
||||
cn = 1
|
||||
|
||||
if since_dt:
|
||||
chat_clauses.append(f"started_at >= ${cn}"); chat_params.append(since_dt); cn += 1
|
||||
if end:
|
||||
chat_clauses.append(f"started_at <= ${cn}"); chat_params.append(end); cn += 1
|
||||
if not user.is_admin:
|
||||
chat_clauses.append(f"user_id = ${cn}"); chat_params.append(user.id); cn += 1
|
||||
|
||||
chat_where = "WHERE " + " AND ".join(chat_clauses)
|
||||
chat_row = await pool.fetchrow(
|
||||
f"""
|
||||
SELECT
|
||||
COUNT(*) AS sessions,
|
||||
SUM(input_tokens) AS input_tokens,
|
||||
SUM(output_tokens) AS output_tokens,
|
||||
SUM(cost_usd) AS cost_usd
|
||||
FROM conversations
|
||||
{chat_where}
|
||||
""",
|
||||
*chat_params,
|
||||
)
|
||||
|
||||
chat_inp = int(chat_row["input_tokens"] or 0)
|
||||
chat_out = int(chat_row["output_tokens"] or 0)
|
||||
chat_cost = float(chat_row["cost_usd"]) if chat_row["cost_usd"] is not None else None
|
||||
chat_sessions = int(chat_row["sessions"] or 0)
|
||||
|
||||
total_input += chat_inp
|
||||
total_output += chat_out
|
||||
total_runs += chat_sessions
|
||||
if chat_cost is not None:
|
||||
total_cost = (total_cost or 0.0) + chat_cost
|
||||
|
||||
return {
|
||||
"summary": {
|
||||
"runs": total_runs,
|
||||
"input_tokens": total_input,
|
||||
"output_tokens": total_output,
|
||||
"cost_usd": total_cost,
|
||||
},
|
||||
"by_agent": by_agent,
|
||||
"chat": {
|
||||
"sessions": chat_sessions,
|
||||
"input_tokens": chat_inp,
|
||||
"output_tokens": chat_out,
|
||||
"cost_usd": chat_cost,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ── Inbox triggers ────────────────────────────────────────────────────────────
|
||||
|
||||
class InboxTriggerIn(BaseModel):
|
||||
|
||||
@@ -215,6 +215,7 @@ function _initPage(url) {
|
||||
if (path === "/" || path === "") { initChat(); return; }
|
||||
if (path === "/agents") { initAgents(); return; }
|
||||
if (path.startsWith("/agents/")) { initAgentDetail(); return; }
|
||||
if (path === "/usage") { initUsage(); return; }
|
||||
if (path === "/audit") { initAudit(); return; }
|
||||
if (path === "/monitors") { initMonitors(); return; }
|
||||
if (path === "/models") { initModels(); return; }
|
||||
@@ -1185,6 +1186,122 @@ function respondConfirm(approved) {
|
||||
document.getElementById("confirm-modal").classList.add("hidden");
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════════════════
|
||||
USAGE PAGE
|
||||
══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
let _usageRange = "7d";
|
||||
|
||||
function setUsageRange(range) {
|
||||
_usageRange = range;
|
||||
["today","7d","30d","all"].forEach(r => {
|
||||
const btn = document.getElementById("usage-range-" + r);
|
||||
if (!btn) return;
|
||||
const active = r === range;
|
||||
btn.style.background = active ? "var(--accent)" : "";
|
||||
btn.style.color = active ? "#fff" : "";
|
||||
btn.style.borderColor = active ? "var(--accent)" : "";
|
||||
});
|
||||
loadUsage();
|
||||
}
|
||||
|
||||
function _fmtTokens(n) {
|
||||
if (n === null || n === undefined) return "—";
|
||||
if (n >= 1_000_000) return (n / 1_000_000).toFixed(2) + "M";
|
||||
if (n >= 1_000) return (n / 1_000).toFixed(1) + "k";
|
||||
return String(n);
|
||||
}
|
||||
|
||||
function _fmtCost(c) {
|
||||
if (c === null || c === undefined) return "—";
|
||||
if (c < 0.001) return "< $0.001";
|
||||
return "$" + c.toFixed(4);
|
||||
}
|
||||
|
||||
async function loadUsage() {
|
||||
const tbody = document.getElementById("usage-tbody");
|
||||
if (!tbody) return;
|
||||
tbody.innerHTML = '<tr><td colspan="8" style="text-align:center;color:var(--text-dim);padding:32px">Loading…</td></tr>';
|
||||
|
||||
const resp = await fetch("/api/usage?since=" + encodeURIComponent(_usageRange));
|
||||
if (!resp.ok) {
|
||||
tbody.innerHTML = '<tr><td colspan="8" style="text-align:center;color:var(--danger)">Failed to load usage data</td></tr>';
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
const s = data.summary;
|
||||
|
||||
// Update summary cards
|
||||
const set = (id, v) => { const el = document.getElementById(id); if (el) el.textContent = v; };
|
||||
set("u-total-runs", s.runs.toLocaleString());
|
||||
set("u-input-tokens", _fmtTokens(s.input_tokens));
|
||||
set("u-output-tokens", _fmtTokens(s.output_tokens));
|
||||
set("u-total-tokens", _fmtTokens(s.input_tokens + s.output_tokens));
|
||||
set("u-cost", _fmtCost(s.cost_usd));
|
||||
|
||||
// Find max tokens for bar scaling
|
||||
const agents = data.by_agent;
|
||||
const maxTokens = agents.reduce((m, a) => Math.max(m, a.input_tokens + a.output_tokens), 1);
|
||||
let anyNullCost = false;
|
||||
|
||||
if (!agents.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="8" style="text-align:center;color:var(--text-dim);padding:32px">No runs found for this period</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = agents.map(a => {
|
||||
const total = a.input_tokens + a.output_tokens;
|
||||
const pct = maxTokens > 0 ? Math.round((total / maxTokens) * 100) : 0;
|
||||
const costStr = _fmtCost(a.cost_usd);
|
||||
if (a.cost_usd === null) anyNullCost = true;
|
||||
const modelShort = a.model ? a.model.replace(/^(anthropic|openrouter|openai):/, "") : "—";
|
||||
const modelTitle = a.model || "";
|
||||
return `<tr>
|
||||
<td><a href="/agents/${a.agent_id}" style="color:var(--accent);text-decoration:none">${esc(a.agent_name)}</a></td>
|
||||
<td style="font-size:12px;color:var(--text-dim)" title="${esc(modelTitle)}">${esc(modelShort)}</td>
|
||||
<td style="text-align:right">${a.runs.toLocaleString()}</td>
|
||||
<td style="text-align:right;font-size:13px">${_fmtTokens(a.input_tokens)}</td>
|
||||
<td style="text-align:right;font-size:13px">${_fmtTokens(a.output_tokens)}</td>
|
||||
<td style="text-align:right;font-size:13px">${_fmtTokens(total)}</td>
|
||||
<td style="text-align:right;font-size:13px;font-weight:${a.cost_usd !== null ? "600" : "400"}">${costStr}</td>
|
||||
<td>
|
||||
<div style="background:var(--bg2);border-radius:3px;height:6px;overflow:hidden">
|
||||
<div style="background:var(--accent);height:100%;width:${pct}%;transition:width 0.3s"></div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join("");
|
||||
|
||||
// Chat section
|
||||
const chatTbody = document.getElementById("usage-chat-tbody");
|
||||
if (chatTbody) {
|
||||
const c = data.chat;
|
||||
if (!c || c.sessions === 0) {
|
||||
chatTbody.innerHTML = '<tr><td colspan="6" style="text-align:center;color:var(--text-dim);padding:24px">No chat sessions in this period</td></tr>';
|
||||
} else {
|
||||
const chatTotal = c.input_tokens + c.output_tokens;
|
||||
if (c.cost_usd === null) anyNullCost = true;
|
||||
chatTbody.innerHTML = `<tr>
|
||||
<td>Interactive chat</td>
|
||||
<td style="text-align:right">${c.sessions.toLocaleString()}</td>
|
||||
<td style="text-align:right;font-size:13px">${_fmtTokens(c.input_tokens)}</td>
|
||||
<td style="text-align:right;font-size:13px">${_fmtTokens(c.output_tokens)}</td>
|
||||
<td style="text-align:right;font-size:13px">${_fmtTokens(chatTotal)}</td>
|
||||
<td style="text-align:right;font-size:13px;font-weight:${c.cost_usd !== null ? "600" : "400"}">${_fmtCost(c.cost_usd)}</td>
|
||||
</tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
const note = document.getElementById("usage-no-cost-note");
|
||||
if (note) note.style.display = anyNullCost ? "" : "none";
|
||||
}
|
||||
|
||||
function initUsage() {
|
||||
if (!document.getElementById("usage-container")) return;
|
||||
loadUsage();
|
||||
}
|
||||
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════════════════
|
||||
AUDIT PAGE
|
||||
══════════════════════════════════════════════════════════════════════════ */
|
||||
@@ -1460,6 +1577,7 @@ function switchUserTab(name) {
|
||||
if (name === "brain") { loadBrainKey("ubrain-mcp-key", "ubrain-mcp-cmd"); loadBrainAutoApprove(); }
|
||||
if (name === "mfa") { loadTheme(); loadMyProfile(); loadMfaStatus(); loadDataFolder(); }
|
||||
if (name === "webhooks") { loadMyWebhooks(); loadMyWebhookTargets(); }
|
||||
if (name === "browser") { loadMyBrowserTrusted(); }
|
||||
if (name === "pushover") { loadMyPushover(); }
|
||||
}
|
||||
|
||||
@@ -2426,6 +2544,11 @@ function initUserSettings() {
|
||||
loadMyProviderKeys();
|
||||
loadMyPersonality();
|
||||
loadMyMcpServers();
|
||||
|
||||
document.getElementById("my-browser-trusted-form")?.addEventListener("submit", async e => {
|
||||
e.preventDefault();
|
||||
await addBrowserTrusted("my-bt-domain", "my-bt-note");
|
||||
});
|
||||
}
|
||||
|
||||
function initSettings() {
|
||||
@@ -2452,6 +2575,7 @@ function initSettings() {
|
||||
loadEmailWhitelist();
|
||||
loadWebWhitelist();
|
||||
loadFilesystemWhitelist();
|
||||
loadBrowserTrusted();
|
||||
reloadCredList();
|
||||
loadInboxStatus();
|
||||
loadInboxTriggers();
|
||||
@@ -2530,6 +2654,11 @@ function initSettings() {
|
||||
await addFilesystemPath();
|
||||
});
|
||||
|
||||
document.getElementById("browser-trusted-form")?.addEventListener("submit", async e => {
|
||||
e.preventDefault();
|
||||
await addBrowserTrusted("bt-domain", "bt-note");
|
||||
});
|
||||
|
||||
document.getElementById("brain-capture-form")?.addEventListener("submit", async e => {
|
||||
e.preventDefault();
|
||||
const content = document.getElementById("brain-capture-text").value.trim();
|
||||
@@ -3029,6 +3158,75 @@ const _DEDICATED_CRED_KEYS = new Set([
|
||||
// These are managed by Inbox / Telegram / Brain / Security / Branding tabs
|
||||
]);
|
||||
|
||||
/* ── Browser trusted domains ─────────────────────────────────────────────── */
|
||||
|
||||
function _renderBrowserTrustedTable(rows, tbodyId) {
|
||||
const tbody = document.getElementById(tbodyId);
|
||||
if (!tbody) return;
|
||||
tbody.innerHTML = "";
|
||||
if (!rows.length) {
|
||||
tbody.innerHTML = "<tr><td colspan='3' style='text-align:center;color:var(--text-dim)'>No trusted domains</td></tr>";
|
||||
return;
|
||||
}
|
||||
for (const row of rows) {
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML = `
|
||||
<td><code>${esc(row.domain)}</code></td>
|
||||
<td style="color:var(--text-dim)">${esc(row.note || "")}</td>
|
||||
<td><button class="btn btn-danger" style="padding:4px 10px;font-size:12px"
|
||||
onclick="removeBrowserTrusted('${esc(row.domain)}')">Remove</button></td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBrowserTrusted() {
|
||||
const r = await fetch("/api/my/browser-trusted");
|
||||
if (!r.ok) return;
|
||||
_renderBrowserTrustedTable(await r.json(), "browser-trusted-list");
|
||||
}
|
||||
|
||||
async function loadMyBrowserTrusted() {
|
||||
const r = await fetch("/api/my/browser-trusted");
|
||||
if (!r.ok) return;
|
||||
_renderBrowserTrustedTable(await r.json(), "my-browser-trusted-list");
|
||||
}
|
||||
|
||||
async function addBrowserTrusted(domainInputId, noteInputId) {
|
||||
const domain = document.getElementById(domainInputId)?.value.trim();
|
||||
const note = document.getElementById(noteInputId)?.value.trim();
|
||||
if (!domain) return;
|
||||
const r = await fetch("/api/my/browser-trusted", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ domain, note }),
|
||||
});
|
||||
if (r.ok) {
|
||||
document.getElementById(domainInputId).value = "";
|
||||
document.getElementById(noteInputId).value = "";
|
||||
showFlash("Added ✓");
|
||||
loadBrowserTrusted();
|
||||
loadMyBrowserTrusted();
|
||||
} else {
|
||||
const d = await r.json().catch(() => ({}));
|
||||
alert(d.detail || "Error adding domain");
|
||||
}
|
||||
}
|
||||
|
||||
async function removeBrowserTrusted(domain) {
|
||||
if (!confirm(`Remove "${domain}" from trusted domains?`)) return;
|
||||
const r = await fetch(`/api/my/browser-trusted/${encodeURIComponent(domain)}`, { method: "DELETE" });
|
||||
if (r.ok) {
|
||||
showFlash("Removed ✓");
|
||||
loadBrowserTrusted();
|
||||
loadMyBrowserTrusted();
|
||||
} else {
|
||||
showFlash("Error removing domain");
|
||||
}
|
||||
}
|
||||
|
||||
/* ── end browser trusted domains ─────────────────────────────────────────── */
|
||||
|
||||
async function reloadCredList() {
|
||||
const r = await fetch("/api/credentials");
|
||||
if (!r.ok) return;
|
||||
|
||||
@@ -609,3 +609,8 @@ tr:hover td { background: var(--bg2); }
|
||||
.pm-btn:last-child { border-right: none; }
|
||||
.pm-btn.active { background: var(--accent); color: #fff; }
|
||||
.pm-btn:hover:not(.active) { background: var(--bg3); color: var(--text); }
|
||||
|
||||
/* ── Usage page stat cards ────────────────────────────────────────────────── */
|
||||
.stat-card { background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px 20px; }
|
||||
.stat-label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.6px; color: var(--text-dim); margin-bottom: 6px; }
|
||||
.stat-value { font-size: 22px; font-weight: 700; color: var(--text); font-family: var(--mono); }
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<img src="{{ logo_url }}" alt="logo" class="sidebar-logo-img">
|
||||
<div class="sidebar-logo-text">
|
||||
<div class="sidebar-logo-name">{{ brand_name }}</div>
|
||||
<div class="sidebar-logo-app">oAI-Web <span class="sidebar-logo-version">v1.2.1</span></div>
|
||||
<div class="sidebar-logo-app">oAI-Web <span class="sidebar-logo-version">v1.2.2</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -44,6 +44,12 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M20.188 10.934a8.002 8.002 0 0 1 0 2.132M3.812 13.066a8.002 8.002 0 0 1 0-2.132M15.536 17.121a8 8 0 0 1-1.506.643M9.97 6.236A8 8 0 0 1 11.5 6M17.657 7.757a8 8 0 0 1 .879 1.506M6.343 16.243a8 8 0 0 1-.879-1.506M17.121 15.536a8 8 0 0 1-1.506.879M8.464 6.879A8 8 0 0 1 9.97 6.236"/></svg>
|
||||
Monitors
|
||||
</a>
|
||||
{% if can_view_usage %}
|
||||
<a class="nav-item" data-page="/usage" href="/usage">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>
|
||||
Usage
|
||||
</a>
|
||||
{% endif %}
|
||||
<a class="nav-item" data-page="/audit" href="/audit">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
|
||||
Audit Log
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
<button type="button" class="tab-btn" id="ustab-brain" onclick="switchUserTab('brain')">2nd Brain</button>
|
||||
<button type="button" class="tab-btn" id="ustab-pushover" onclick="switchUserTab('pushover')">Pushover</button>
|
||||
<button type="button" class="tab-btn" id="ustab-webhooks" onclick="switchUserTab('webhooks')">Webhooks</button>
|
||||
<button type="button" class="tab-btn" id="ustab-browser" onclick="switchUserTab('browser')">Browser</button>
|
||||
<button type="button" class="tab-btn" id="ustab-mfa" onclick="switchUserTab('mfa')">Profile</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -417,6 +418,39 @@
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<div style="border-top:1px solid var(--border);margin-bottom:36px"></div>
|
||||
|
||||
<!-- Browser trusted domains -->
|
||||
<section>
|
||||
<h2 class="settings-section-title">Browser Trusted Domains</h2>
|
||||
<p style="font-size:12px;color:var(--text-dim);margin-bottom:12px">
|
||||
Domains where browser <em>interaction</em> operations (click, fill, select, press) run without
|
||||
asking for confirmation. Subdomains are automatically included.
|
||||
Each user manages their own list.
|
||||
</p>
|
||||
<div class="table-wrap" style="margin-bottom:16px">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Domain</th><th>Note</th><th>Actions</th></tr>
|
||||
</thead>
|
||||
<tbody id="browser-trusted-list">
|
||||
<tr><td colspan="3" style="text-align:center;color:var(--text-dim)">Loading…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<form id="browser-trusted-form" style="display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end;max-width:560px">
|
||||
<div class="form-group" style="flex:1;min-width:160px;margin-bottom:0">
|
||||
<label>Add domain</label>
|
||||
<input type="text" id="bt-domain" class="form-input" placeholder="example.com" required autocomplete="off">
|
||||
</div>
|
||||
<div class="form-group" style="flex:2;min-width:180px;margin-bottom:0">
|
||||
<label>Note <span style="color:var(--text-dim)">(optional)</span></label>
|
||||
<input type="text" id="bt-note" class="form-input" placeholder="e.g. Work intranet">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" style="margin-bottom:0">Add</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
</div><!-- /spane-whitelists -->
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════
|
||||
@@ -1696,6 +1730,37 @@
|
||||
<!-- ══════════════════════════════════════════════════════════
|
||||
USER SETTINGS: Profile
|
||||
═══════════════════════════════════════════════════════════ -->
|
||||
<div id="uspane-browser" style="display:none">
|
||||
<section>
|
||||
<h2 class="settings-section-title">Browser Trusted Domains</h2>
|
||||
<p style="font-size:12px;color:var(--text-dim);margin-bottom:12px">
|
||||
Domains where browser <em>interaction</em> operations (click, fill, select, press) run
|
||||
without asking for confirmation each time. Subdomains are automatically included.
|
||||
</p>
|
||||
<div class="table-wrap" style="margin-bottom:16px">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Domain</th><th>Note</th><th>Actions</th></tr>
|
||||
</thead>
|
||||
<tbody id="my-browser-trusted-list">
|
||||
<tr><td colspan="3" style="text-align:center;color:var(--text-dim)">Loading…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<form id="my-browser-trusted-form" style="display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end;max-width:560px">
|
||||
<div class="form-group" style="flex:1;min-width:160px;margin-bottom:0">
|
||||
<label>Add domain</label>
|
||||
<input type="text" id="my-bt-domain" class="form-input" placeholder="example.com" required autocomplete="off">
|
||||
</div>
|
||||
<div class="form-group" style="flex:2;min-width:180px;margin-bottom:0">
|
||||
<label>Note <span style="color:var(--text-dim)">(optional)</span></label>
|
||||
<input type="text" id="my-bt-note" class="form-input" placeholder="e.g. Work intranet">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" style="margin-bottom:0">Add</button>
|
||||
</form>
|
||||
</section>
|
||||
</div><!-- /uspane-browser -->
|
||||
|
||||
<div id="uspane-mfa" style="display:none">
|
||||
|
||||
<!-- Theme picker -->
|
||||
|
||||
95
server/web/templates/usage.html
Normal file
95
server/web/templates/usage.html
Normal file
@@ -0,0 +1,95 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ agent_name }} — Usage{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page" id="usage-container">
|
||||
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:24px">
|
||||
<h1>Usage</h1>
|
||||
<!-- Time range filter -->
|
||||
<div style="display:flex;gap:6px">
|
||||
<button class="btn" id="usage-range-today" type="button" onclick="setUsageRange('today')">Today</button>
|
||||
<button class="btn" id="usage-range-7d" type="button" onclick="setUsageRange('7d')" style="background:var(--accent);color:#fff;border-color:var(--accent)">7 days</button>
|
||||
<button class="btn" id="usage-range-30d" type="button" onclick="setUsageRange('30d')">30 days</button>
|
||||
<button class="btn" id="usage-range-all" type="button" onclick="setUsageRange('all')">All time</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary cards -->
|
||||
<div id="usage-cards" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:16px;margin-bottom:32px">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Runs</div>
|
||||
<div class="stat-value" id="u-total-runs">—</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Input tokens</div>
|
||||
<div class="stat-value" id="u-input-tokens">—</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Output tokens</div>
|
||||
<div class="stat-value" id="u-output-tokens">—</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Total tokens</div>
|
||||
<div class="stat-value" id="u-total-tokens">—</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Est. cost</div>
|
||||
<div class="stat-value" id="u-cost">—</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Per-agent breakdown -->
|
||||
<h2 style="font-size:14px;color:var(--text-dim);font-weight:500;text-transform:uppercase;letter-spacing:0.5px;margin-bottom:12px">
|
||||
By agent
|
||||
</h2>
|
||||
<div class="table-wrap">
|
||||
<table id="usage-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="text-align:left">Agent</th>
|
||||
<th style="text-align:left">Model</th>
|
||||
<th style="text-align:right">Runs</th>
|
||||
<th style="text-align:right">Input</th>
|
||||
<th style="text-align:right">Output</th>
|
||||
<th style="text-align:right">Total tokens</th>
|
||||
<th style="text-align:right">Est. cost</th>
|
||||
<th style="min-width:120px"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="usage-tbody">
|
||||
<tr><td colspan="8" style="text-align:center;color:var(--text-dim);padding:32px">Loading…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!-- Chat sessions summary -->
|
||||
<h2 style="font-size:14px;color:var(--text-dim);font-weight:500;text-transform:uppercase;letter-spacing:0.5px;margin:32px 0 12px">
|
||||
Chat sessions
|
||||
</h2>
|
||||
<div id="usage-chat-section">
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="text-align:left">Source</th>
|
||||
<th style="text-align:right">Sessions</th>
|
||||
<th style="text-align:right">Input</th>
|
||||
<th style="text-align:right">Output</th>
|
||||
<th style="text-align:right">Total tokens</th>
|
||||
<th style="text-align:right">Est. cost</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="usage-chat-tbody">
|
||||
<tr><td colspan="6" style="text-align:center;color:var(--text-dim);padding:24px">Loading…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p id="usage-no-cost-note" style="font-size:12px;color:var(--text-dim);margin-top:12px;display:none">
|
||||
Cost estimates only available for runs recorded after the usage tracking feature was enabled.
|
||||
Runs on free or unknown models show no cost.
|
||||
</p>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user