Version 1.2.2. Added usage overview. Shows token used and cost in $.
This commit is contained in:
@@ -348,7 +348,7 @@ Contributions are welcome!
|
||||
|
||||
oAI-Web takes real actions on your behalf — it can send emails, write files, make calendar changes, and post Telegram messages. Review your whitelist and permission settings carefully before use. Content you send is processed by your configured AI provider (Anthropic, OpenRouter, or OpenAI). oAI-Web is provided "as is" without warranty of any kind — the author accepts no responsibility for actions taken by the agent or any consequences thereof. See LICENSE for full terms.
|
||||
|
||||
--
|
||||
---
|
||||
|
||||
**⭐ Star this project if you find it useful!**
|
||||
|
||||
|
||||
574
sbom.cdx.json
Normal file
574
sbom.cdx.json
Normal file
@@ -0,0 +1,574 @@
|
||||
{
|
||||
"components": [
|
||||
{
|
||||
"bom-ref": "requirements-L24",
|
||||
"description": "requirements line 24: aioimaplib>=1.0",
|
||||
"externalReferences": [
|
||||
{
|
||||
"comment": "implicit dist url",
|
||||
"type": "distribution",
|
||||
"url": "https://pypi.org/simple/aioimaplib/"
|
||||
}
|
||||
],
|
||||
"name": "aioimaplib",
|
||||
"purl": "pkg:pypi/aioimaplib",
|
||||
"type": "library"
|
||||
},
|
||||
{
|
||||
"bom-ref": "requirements-L9",
|
||||
"description": "requirements line 9: anthropic==0.40.*",
|
||||
"externalReferences": [
|
||||
{
|
||||
"comment": "implicit dist url",
|
||||
"type": "distribution",
|
||||
"url": "https://pypi.org/simple/anthropic/"
|
||||
}
|
||||
],
|
||||
"name": "anthropic",
|
||||
"purl": "pkg:pypi/anthropic@0.40.%2A",
|
||||
"type": "library",
|
||||
"version": "0.40.*"
|
||||
},
|
||||
{
|
||||
"bom-ref": "requirements-L33",
|
||||
"description": "requirements line 33: apscheduler==3.10.*",
|
||||
"externalReferences": [
|
||||
{
|
||||
"comment": "implicit dist url",
|
||||
"type": "distribution",
|
||||
"url": "https://pypi.org/simple/apscheduler/"
|
||||
}
|
||||
],
|
||||
"name": "apscheduler",
|
||||
"purl": "pkg:pypi/apscheduler@3.10.%2A",
|
||||
"type": "library",
|
||||
"version": "3.10.*"
|
||||
},
|
||||
{
|
||||
"bom-ref": "requirements-L36",
|
||||
"description": "requirements line 36: argon2-cffi==23.*",
|
||||
"externalReferences": [
|
||||
{
|
||||
"comment": "implicit dist url",
|
||||
"type": "distribution",
|
||||
"url": "https://pypi.org/simple/argon2-cffi/"
|
||||
}
|
||||
],
|
||||
"name": "argon2-cffi",
|
||||
"purl": "pkg:pypi/argon2-cffi@23.%2A",
|
||||
"type": "library",
|
||||
"version": "23.*"
|
||||
},
|
||||
{
|
||||
"bom-ref": "requirements-L41",
|
||||
"description": "requirements line 41: asyncpg==0.31.*",
|
||||
"externalReferences": [
|
||||
{
|
||||
"comment": "implicit dist url",
|
||||
"type": "distribution",
|
||||
"url": "https://pypi.org/simple/asyncpg/"
|
||||
}
|
||||
],
|
||||
"name": "asyncpg",
|
||||
"purl": "pkg:pypi/asyncpg@0.31.%2A",
|
||||
"type": "library",
|
||||
"version": "0.31.*"
|
||||
},
|
||||
{
|
||||
"bom-ref": "requirements-L28",
|
||||
"description": "requirements line 28: beautifulsoup4==4.12.*",
|
||||
"externalReferences": [
|
||||
{
|
||||
"comment": "implicit dist url",
|
||||
"type": "distribution",
|
||||
"url": "https://pypi.org/simple/beautifulsoup4/"
|
||||
}
|
||||
],
|
||||
"name": "beautifulsoup4",
|
||||
"purl": "pkg:pypi/beautifulsoup4@4.12.%2A",
|
||||
"type": "library",
|
||||
"version": "4.12.*"
|
||||
},
|
||||
{
|
||||
"bom-ref": "requirements-L19",
|
||||
"description": "requirements line 19: caldav==1.3.*",
|
||||
"externalReferences": [
|
||||
{
|
||||
"comment": "implicit dist url",
|
||||
"type": "distribution",
|
||||
"url": "https://pypi.org/simple/caldav/"
|
||||
}
|
||||
],
|
||||
"name": "caldav",
|
||||
"purl": "pkg:pypi/caldav@1.3.%2A",
|
||||
"type": "library",
|
||||
"version": "1.3.*"
|
||||
},
|
||||
{
|
||||
"bom-ref": "requirements-L13",
|
||||
"description": "requirements line 13: cryptography==43.*",
|
||||
"externalReferences": [
|
||||
{
|
||||
"comment": "implicit dist url",
|
||||
"type": "distribution",
|
||||
"url": "https://pypi.org/simple/cryptography/"
|
||||
}
|
||||
],
|
||||
"name": "cryptography",
|
||||
"purl": "pkg:pypi/cryptography@43.%2A",
|
||||
"type": "library",
|
||||
"version": "43.*"
|
||||
},
|
||||
{
|
||||
"bom-ref": "requirements-L2",
|
||||
"description": "requirements line 2: fastapi==0.115.*",
|
||||
"externalReferences": [
|
||||
{
|
||||
"comment": "implicit dist url",
|
||||
"type": "distribution",
|
||||
"url": "https://pypi.org/simple/fastapi/"
|
||||
}
|
||||
],
|
||||
"name": "fastapi",
|
||||
"purl": "pkg:pypi/fastapi@0.115.%2A",
|
||||
"type": "library",
|
||||
"version": "0.115.*"
|
||||
},
|
||||
{
|
||||
"bom-ref": "requirements-L29",
|
||||
"description": "requirements line 29: feedparser==6.0.*",
|
||||
"externalReferences": [
|
||||
{
|
||||
"comment": "implicit dist url",
|
||||
"type": "distribution",
|
||||
"url": "https://pypi.org/simple/feedparser/"
|
||||
}
|
||||
],
|
||||
"name": "feedparser",
|
||||
"purl": "pkg:pypi/feedparser@6.0.%2A",
|
||||
"type": "library",
|
||||
"version": "6.0.*"
|
||||
},
|
||||
{
|
||||
"bom-ref": "requirements-L27",
|
||||
"description": "requirements line 27: httpx==0.27.*",
|
||||
"externalReferences": [
|
||||
{
|
||||
"comment": "implicit dist url",
|
||||
"type": "distribution",
|
||||
"url": "https://pypi.org/simple/httpx/"
|
||||
}
|
||||
],
|
||||
"name": "httpx",
|
||||
"purl": "pkg:pypi/httpx@0.27.%2A",
|
||||
"type": "library",
|
||||
"version": "0.27.*"
|
||||
},
|
||||
{
|
||||
"bom-ref": "requirements-L23",
|
||||
"description": "requirements line 23: imapclient==3.0.*",
|
||||
"externalReferences": [
|
||||
{
|
||||
"comment": "implicit dist url",
|
||||
"type": "distribution",
|
||||
"url": "https://pypi.org/simple/imapclient/"
|
||||
}
|
||||
],
|
||||
"name": "imapclient",
|
||||
"purl": "pkg:pypi/imapclient@3.0.%2A",
|
||||
"type": "library",
|
||||
"version": "3.0.*"
|
||||
},
|
||||
{
|
||||
"bom-ref": "requirements-L4",
|
||||
"description": "requirements line 4: jinja2==3.1.*",
|
||||
"externalReferences": [
|
||||
{
|
||||
"comment": "implicit dist url",
|
||||
"type": "distribution",
|
||||
"url": "https://pypi.org/simple/jinja2/"
|
||||
}
|
||||
],
|
||||
"name": "jinja2",
|
||||
"purl": "pkg:pypi/jinja2@3.1.%2A",
|
||||
"type": "library",
|
||||
"version": "3.1.*"
|
||||
},
|
||||
{
|
||||
"bom-ref": "requirements-L42",
|
||||
"description": "requirements line 42: mcp==1.26.*",
|
||||
"externalReferences": [
|
||||
{
|
||||
"comment": "implicit dist url",
|
||||
"type": "distribution",
|
||||
"url": "https://pypi.org/simple/mcp/"
|
||||
}
|
||||
],
|
||||
"name": "mcp",
|
||||
"purl": "pkg:pypi/mcp@1.26.%2A",
|
||||
"type": "library",
|
||||
"version": "1.26.*"
|
||||
},
|
||||
{
|
||||
"bom-ref": "requirements-L10",
|
||||
"description": "requirements line 10: openai==1.57.*",
|
||||
"externalReferences": [
|
||||
{
|
||||
"comment": "implicit dist url",
|
||||
"type": "distribution",
|
||||
"url": "https://pypi.org/simple/openai/"
|
||||
}
|
||||
],
|
||||
"name": "openai",
|
||||
"purl": "pkg:pypi/openai@1.57.%2A",
|
||||
"type": "library",
|
||||
"version": "1.57.*"
|
||||
},
|
||||
{
|
||||
"bom-ref": "requirements-L30",
|
||||
"description": "requirements line 30: playwright>=1.40",
|
||||
"externalReferences": [
|
||||
{
|
||||
"comment": "implicit dist url",
|
||||
"type": "distribution",
|
||||
"url": "https://pypi.org/simple/playwright/"
|
||||
}
|
||||
],
|
||||
"name": "playwright",
|
||||
"purl": "pkg:pypi/playwright",
|
||||
"type": "library"
|
||||
},
|
||||
{
|
||||
"bom-ref": "requirements-L37",
|
||||
"description": "requirements line 37: pyotp>=2.9",
|
||||
"externalReferences": [
|
||||
{
|
||||
"comment": "implicit dist url",
|
||||
"type": "distribution",
|
||||
"url": "https://pypi.org/simple/pyotp/"
|
||||
}
|
||||
],
|
||||
"name": "pyotp",
|
||||
"purl": "pkg:pypi/pyotp",
|
||||
"type": "library"
|
||||
},
|
||||
{
|
||||
"bom-ref": "requirements-L45",
|
||||
"description": "requirements line 45: python-dateutil==2.9.*",
|
||||
"externalReferences": [
|
||||
{
|
||||
"comment": "implicit dist url",
|
||||
"type": "distribution",
|
||||
"url": "https://pypi.org/simple/python-dateutil/"
|
||||
}
|
||||
],
|
||||
"name": "python-dateutil",
|
||||
"purl": "pkg:pypi/python-dateutil@2.9.%2A",
|
||||
"type": "library",
|
||||
"version": "2.9.*"
|
||||
},
|
||||
{
|
||||
"bom-ref": "requirements-L16",
|
||||
"description": "requirements line 16: python-dotenv==1.0.*",
|
||||
"externalReferences": [
|
||||
{
|
||||
"comment": "implicit dist url",
|
||||
"type": "distribution",
|
||||
"url": "https://pypi.org/simple/python-dotenv/"
|
||||
}
|
||||
],
|
||||
"name": "python-dotenv",
|
||||
"purl": "pkg:pypi/python-dotenv@1.0.%2A",
|
||||
"type": "library",
|
||||
"version": "1.0.*"
|
||||
},
|
||||
{
|
||||
"bom-ref": "requirements-L5",
|
||||
"description": "requirements line 5: python-multipart==0.0.*",
|
||||
"externalReferences": [
|
||||
{
|
||||
"comment": "implicit dist url",
|
||||
"type": "distribution",
|
||||
"url": "https://pypi.org/simple/python-multipart/"
|
||||
}
|
||||
],
|
||||
"name": "python-multipart",
|
||||
"purl": "pkg:pypi/python-multipart@0.0.%2A",
|
||||
"type": "library",
|
||||
"version": "0.0.*"
|
||||
},
|
||||
{
|
||||
"bom-ref": "requirements-L46",
|
||||
"description": "requirements line 46: pytz==2024.*",
|
||||
"externalReferences": [
|
||||
{
|
||||
"comment": "implicit dist url",
|
||||
"type": "distribution",
|
||||
"url": "https://pypi.org/simple/pytz/"
|
||||
}
|
||||
],
|
||||
"name": "pytz",
|
||||
"purl": "pkg:pypi/pytz@2024.%2A",
|
||||
"type": "library",
|
||||
"version": "2024.*"
|
||||
},
|
||||
{
|
||||
"bom-ref": "requirements-L38",
|
||||
"description": "requirements line 38: qrcode[pil]>=7.4",
|
||||
"externalReferences": [
|
||||
{
|
||||
"comment": "implicit dist url",
|
||||
"type": "distribution",
|
||||
"url": "https://pypi.org/simple/qrcode/"
|
||||
}
|
||||
],
|
||||
"name": "qrcode",
|
||||
"properties": [
|
||||
{
|
||||
"name": "cdx:python:package:required-extra",
|
||||
"value": "pil"
|
||||
}
|
||||
],
|
||||
"purl": "pkg:pypi/qrcode",
|
||||
"type": "library"
|
||||
},
|
||||
{
|
||||
"bom-ref": "requirements-L3",
|
||||
"description": "requirements line 3: uvicorn[standard]==0.32.*",
|
||||
"externalReferences": [
|
||||
{
|
||||
"comment": "implicit dist url",
|
||||
"type": "distribution",
|
||||
"url": "https://pypi.org/simple/uvicorn/"
|
||||
}
|
||||
],
|
||||
"name": "uvicorn",
|
||||
"properties": [
|
||||
{
|
||||
"name": "cdx:python:package:required-extra",
|
||||
"value": "standard"
|
||||
}
|
||||
],
|
||||
"purl": "pkg:pypi/uvicorn@0.32.%2A",
|
||||
"type": "library",
|
||||
"version": "0.32.*"
|
||||
},
|
||||
{
|
||||
"bom-ref": "requirements-L20",
|
||||
"description": "requirements line 20: vobject==0.9.*",
|
||||
"externalReferences": [
|
||||
{
|
||||
"comment": "implicit dist url",
|
||||
"type": "distribution",
|
||||
"url": "https://pypi.org/simple/vobject/"
|
||||
}
|
||||
],
|
||||
"name": "vobject",
|
||||
"purl": "pkg:pypi/vobject@0.9.%2A",
|
||||
"type": "library",
|
||||
"version": "0.9.*"
|
||||
},
|
||||
{
|
||||
"bom-ref": "requirements-L6",
|
||||
"description": "requirements line 6: websockets==13.*",
|
||||
"externalReferences": [
|
||||
{
|
||||
"comment": "implicit dist url",
|
||||
"type": "distribution",
|
||||
"url": "https://pypi.org/simple/websockets/"
|
||||
}
|
||||
],
|
||||
"name": "websockets",
|
||||
"purl": "pkg:pypi/websockets@13.%2A",
|
||||
"type": "library",
|
||||
"version": "13.*"
|
||||
}
|
||||
],
|
||||
"dependencies": [
|
||||
{
|
||||
"ref": "requirements-L10"
|
||||
},
|
||||
{
|
||||
"ref": "requirements-L13"
|
||||
},
|
||||
{
|
||||
"ref": "requirements-L16"
|
||||
},
|
||||
{
|
||||
"ref": "requirements-L19"
|
||||
},
|
||||
{
|
||||
"ref": "requirements-L2"
|
||||
},
|
||||
{
|
||||
"ref": "requirements-L20"
|
||||
},
|
||||
{
|
||||
"ref": "requirements-L23"
|
||||
},
|
||||
{
|
||||
"ref": "requirements-L24"
|
||||
},
|
||||
{
|
||||
"ref": "requirements-L27"
|
||||
},
|
||||
{
|
||||
"ref": "requirements-L28"
|
||||
},
|
||||
{
|
||||
"ref": "requirements-L29"
|
||||
},
|
||||
{
|
||||
"ref": "requirements-L3"
|
||||
},
|
||||
{
|
||||
"ref": "requirements-L30"
|
||||
},
|
||||
{
|
||||
"ref": "requirements-L33"
|
||||
},
|
||||
{
|
||||
"ref": "requirements-L36"
|
||||
},
|
||||
{
|
||||
"ref": "requirements-L37"
|
||||
},
|
||||
{
|
||||
"ref": "requirements-L38"
|
||||
},
|
||||
{
|
||||
"ref": "requirements-L4"
|
||||
},
|
||||
{
|
||||
"ref": "requirements-L41"
|
||||
},
|
||||
{
|
||||
"ref": "requirements-L42"
|
||||
},
|
||||
{
|
||||
"ref": "requirements-L45"
|
||||
},
|
||||
{
|
||||
"ref": "requirements-L46"
|
||||
},
|
||||
{
|
||||
"ref": "requirements-L5"
|
||||
},
|
||||
{
|
||||
"ref": "requirements-L6"
|
||||
},
|
||||
{
|
||||
"ref": "requirements-L9"
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"timestamp": "2026-04-15T07:29:58.838584+00:00",
|
||||
"tools": {
|
||||
"components": [
|
||||
{
|
||||
"description": "CycloneDX Software Bill of Materials (SBOM) generator for Python projects and environments",
|
||||
"externalReferences": [
|
||||
{
|
||||
"type": "build-system",
|
||||
"url": "https://github.com/CycloneDX/cyclonedx-python/actions"
|
||||
},
|
||||
{
|
||||
"type": "distribution",
|
||||
"url": "https://pypi.org/project/cyclonedx-bom/"
|
||||
},
|
||||
{
|
||||
"type": "documentation",
|
||||
"url": "https://cyclonedx-bom-tool.readthedocs.io/"
|
||||
},
|
||||
{
|
||||
"type": "issue-tracker",
|
||||
"url": "https://github.com/CycloneDX/cyclonedx-python/issues"
|
||||
},
|
||||
{
|
||||
"type": "license",
|
||||
"url": "https://github.com/CycloneDX/cyclonedx-python/blob/main/LICENSE"
|
||||
},
|
||||
{
|
||||
"type": "release-notes",
|
||||
"url": "https://github.com/CycloneDX/cyclonedx-python/blob/main/CHANGELOG.md"
|
||||
},
|
||||
{
|
||||
"type": "vcs",
|
||||
"url": "https://github.com/CycloneDX/cyclonedx-python/"
|
||||
},
|
||||
{
|
||||
"type": "website",
|
||||
"url": "https://github.com/CycloneDX/cyclonedx-python/#readme"
|
||||
}
|
||||
],
|
||||
"group": "CycloneDX",
|
||||
"licenses": [
|
||||
{
|
||||
"license": {
|
||||
"acknowledgement": "declared",
|
||||
"id": "Apache-2.0"
|
||||
}
|
||||
}
|
||||
],
|
||||
"name": "cyclonedx-py",
|
||||
"type": "application",
|
||||
"version": "7.3.0"
|
||||
},
|
||||
{
|
||||
"description": "Python library for CycloneDX",
|
||||
"externalReferences": [
|
||||
{
|
||||
"type": "build-system",
|
||||
"url": "https://github.com/CycloneDX/cyclonedx-python-lib/actions"
|
||||
},
|
||||
{
|
||||
"type": "distribution",
|
||||
"url": "https://pypi.org/project/cyclonedx-python-lib/"
|
||||
},
|
||||
{
|
||||
"type": "documentation",
|
||||
"url": "https://cyclonedx-python-library.readthedocs.io/"
|
||||
},
|
||||
{
|
||||
"type": "issue-tracker",
|
||||
"url": "https://github.com/CycloneDX/cyclonedx-python-lib/issues"
|
||||
},
|
||||
{
|
||||
"type": "license",
|
||||
"url": "https://github.com/CycloneDX/cyclonedx-python-lib/blob/main/LICENSE"
|
||||
},
|
||||
{
|
||||
"type": "release-notes",
|
||||
"url": "https://github.com/CycloneDX/cyclonedx-python-lib/blob/main/CHANGELOG.md"
|
||||
},
|
||||
{
|
||||
"type": "vcs",
|
||||
"url": "https://github.com/CycloneDX/cyclonedx-python-lib"
|
||||
},
|
||||
{
|
||||
"type": "website",
|
||||
"url": "https://github.com/CycloneDX/cyclonedx-python-lib/#readme"
|
||||
}
|
||||
],
|
||||
"group": "CycloneDX",
|
||||
"licenses": [
|
||||
{
|
||||
"license": {
|
||||
"acknowledgement": "declared",
|
||||
"id": "Apache-2.0"
|
||||
}
|
||||
}
|
||||
],
|
||||
"name": "cyclonedx-python-lib",
|
||||
"type": "library",
|
||||
"version": "11.7.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"serialNumber": "urn:uuid:2d68f514-7d51-45bc-957f-4df5affd9778",
|
||||
"version": 1,
|
||||
"$schema": "http://cyclonedx.org/schema/bom-1.6.schema.json",
|
||||
"bomFormat": "CycloneDX",
|
||||
"specVersion": "1.6"
|
||||
}
|
||||
@@ -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