strands-compose-chat 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- strands_compose_chat/__init__.py +1 -0
- strands_compose_chat/admin/__init__.py +1 -0
- strands_compose_chat/admin/auth.py +132 -0
- strands_compose_chat/admin/templates/dashboard.html +18 -0
- strands_compose_chat/admin/templates/sqladmin/index.html +45 -0
- strands_compose_chat/admin/templates/sqladmin/layout.html +132 -0
- strands_compose_chat/admin/views/__init__.py +23 -0
- strands_compose_chat/admin/views/agent.py +234 -0
- strands_compose_chat/admin/views/api_key.py +134 -0
- strands_compose_chat/admin/views/base.py +84 -0
- strands_compose_chat/admin/views/chat_message.py +161 -0
- strands_compose_chat/admin/views/chat_session.py +148 -0
- strands_compose_chat/admin/views/dashboard.py +44 -0
- strands_compose_chat/admin/views/group.py +49 -0
- strands_compose_chat/admin/views/model_pricing.py +51 -0
- strands_compose_chat/admin/views/token_usage.py +215 -0
- strands_compose_chat/admin/views/user.py +250 -0
- strands_compose_chat/agents/__init__.py +5 -0
- strands_compose_chat/agents/client.py +52 -0
- strands_compose_chat/agents/invocation.py +195 -0
- strands_compose_chat/agents/payload.py +169 -0
- strands_compose_chat/agents/routes.py +45 -0
- strands_compose_chat/agents/service.py +117 -0
- strands_compose_chat/agents/streaming.py +428 -0
- strands_compose_chat/alembic.ini +46 -0
- strands_compose_chat/analytics/__init__.py +3 -0
- strands_compose_chat/analytics/routes_admin.py +135 -0
- strands_compose_chat/analytics/routes_me.py +42 -0
- strands_compose_chat/analytics/service.py +1428 -0
- strands_compose_chat/app.py +160 -0
- strands_compose_chat/auth/__init__.py +1 -0
- strands_compose_chat/auth/api_key.py +167 -0
- strands_compose_chat/auth/current_user.py +190 -0
- strands_compose_chat/auth/jit.py +165 -0
- strands_compose_chat/auth/oidc.py +211 -0
- strands_compose_chat/auth/passwords.py +70 -0
- strands_compose_chat/auth/routes.py +377 -0
- strands_compose_chat/auth/service.py +194 -0
- strands_compose_chat/bootstrap.py +55 -0
- strands_compose_chat/cli.py +95 -0
- strands_compose_chat/config.py +222 -0
- strands_compose_chat/db/__init__.py +1 -0
- strands_compose_chat/db/base.py +65 -0
- strands_compose_chat/db/migrations/env.py +68 -0
- strands_compose_chat/db/migrations/script.py.mako +27 -0
- strands_compose_chat/db/migrations/versions/0001_initial_schema.py +256 -0
- strands_compose_chat/db/models/__init__.py +24 -0
- strands_compose_chat/db/models/agent.py +69 -0
- strands_compose_chat/db/models/api_key.py +53 -0
- strands_compose_chat/db/models/associations.py +63 -0
- strands_compose_chat/db/models/chat_message.py +79 -0
- strands_compose_chat/db/models/chat_session.py +80 -0
- strands_compose_chat/db/models/group.py +39 -0
- strands_compose_chat/db/models/model_pricing.py +33 -0
- strands_compose_chat/db/models/token_usage.py +65 -0
- strands_compose_chat/db/models/user.py +69 -0
- strands_compose_chat/deps.py +37 -0
- strands_compose_chat/errors.py +161 -0
- strands_compose_chat/frontend.py +169 -0
- strands_compose_chat/logging.py +109 -0
- strands_compose_chat/media/__init__.py +6 -0
- strands_compose_chat/media/blocks.py +117 -0
- strands_compose_chat/media/routes.py +26 -0
- strands_compose_chat/media/service.py +44 -0
- strands_compose_chat/middleware/__init__.py +7 -0
- strands_compose_chat/middleware/security_headers.py +45 -0
- strands_compose_chat/schemas/__init__.py +9 -0
- strands_compose_chat/schemas/agents.py +43 -0
- strands_compose_chat/schemas/analytics.py +330 -0
- strands_compose_chat/schemas/auth.py +71 -0
- strands_compose_chat/schemas/invocations.py +44 -0
- strands_compose_chat/schemas/media.py +46 -0
- strands_compose_chat/schemas/sessions.py +138 -0
- strands_compose_chat/sessions/__init__.py +1 -0
- strands_compose_chat/sessions/routes.py +156 -0
- strands_compose_chat/sessions/service.py +227 -0
- strands_compose_chat/templates/app.html +16 -0
- strands_compose_chat/templates/login.html +16 -0
- strands_compose_chat-0.1.0.dist-info/METADATA +25 -0
- strands_compose_chat-0.1.0.dist-info/RECORD +83 -0
- strands_compose_chat-0.1.0.dist-info/WHEEL +4 -0
- strands_compose_chat-0.1.0.dist-info/entry_points.txt +3 -0
- strands_compose_chat-0.1.0.dist-info/licenses/LICENSE +174 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Chat Backend API — async ASGI service replacing proxy.py."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Admin panel: sqladmin views and authentication backend."""
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""sqladmin AuthenticationBackend and shared admin identity resolution.
|
|
2
|
+
|
|
3
|
+
Two independent identities grant access:
|
|
4
|
+
|
|
5
|
+
1. A dedicated admin-panel login (``/admin/login``) that authenticates an active
|
|
6
|
+
local superuser against its password hash. Works regardless of external OIDC
|
|
7
|
+
configuration so an operator can always reach the panel.
|
|
8
|
+
|
|
9
|
+
2. The application session cookie, when it belongs to an active superuser.
|
|
10
|
+
Lets a superuser authenticated via OIDC reach the panel without a second login.
|
|
11
|
+
|
|
12
|
+
``resolve_admin_user`` is the single source of truth for admin identity resolution,
|
|
13
|
+
shared by both ``AdminAuthBackend`` and the FastAPI ``get_admin_user`` dependency.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import structlog
|
|
17
|
+
from fastapi import Request, Response
|
|
18
|
+
from fastapi.responses import RedirectResponse
|
|
19
|
+
from sqladmin.authentication import AuthenticationBackend
|
|
20
|
+
from sqlalchemy import select
|
|
21
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
22
|
+
|
|
23
|
+
from ..auth.passwords import dummy_verify, verify_password
|
|
24
|
+
from ..config import Settings
|
|
25
|
+
from ..db.base import AsyncSessionLocal
|
|
26
|
+
from ..db.models import User
|
|
27
|
+
|
|
28
|
+
logger: structlog.stdlib.BoundLogger = structlog.get_logger(__name__)
|
|
29
|
+
|
|
30
|
+
_ADMIN_SESSION_KEY = "admin_user_id"
|
|
31
|
+
_USER_SESSION_KEY = "user_id"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
async def resolve_admin_user(request: Request, db: AsyncSession) -> User | None:
|
|
35
|
+
"""Return the active superuser from either admin session or app session.
|
|
36
|
+
|
|
37
|
+
Checks request.session["admin_user_id"] first. If absent or stale, falls
|
|
38
|
+
back to request.session["user_id"] + is_superuser check. Returns None
|
|
39
|
+
when neither path yields an active superuser.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
request: The incoming FastAPI/Starlette request.
|
|
43
|
+
db: Async database session.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
Active superuser User instance, or None.
|
|
47
|
+
"""
|
|
48
|
+
admin_user_id = request.session.get(_ADMIN_SESSION_KEY)
|
|
49
|
+
if admin_user_id:
|
|
50
|
+
result = await db.execute(select(User).where(User.id == str(admin_user_id)))
|
|
51
|
+
user = result.scalar_one_or_none()
|
|
52
|
+
if user and user.is_active and user.is_superuser:
|
|
53
|
+
return user
|
|
54
|
+
|
|
55
|
+
user_id = request.session.get(_USER_SESSION_KEY)
|
|
56
|
+
if user_id:
|
|
57
|
+
result = await db.execute(select(User).where(User.id == str(user_id)))
|
|
58
|
+
user = result.scalar_one_or_none()
|
|
59
|
+
if user and user.is_active and user.is_superuser:
|
|
60
|
+
return user
|
|
61
|
+
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class AdminAuthBackend(AuthenticationBackend):
|
|
66
|
+
"""sqladmin AuthenticationBackend enforcing superuser access."""
|
|
67
|
+
|
|
68
|
+
def __init__(self, settings: Settings) -> None:
|
|
69
|
+
super().__init__(secret_key=settings.SESSION_SECRET_KEY)
|
|
70
|
+
# Don't install a second SessionMiddleware on the admin sub-app.
|
|
71
|
+
# Two nested instances share scope["session"]; the inner one emptying it
|
|
72
|
+
# causes the outer send_wrapper to clear the main session cookie on logout.
|
|
73
|
+
self.middlewares = []
|
|
74
|
+
self._settings = settings
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def _login_url(self) -> str:
|
|
78
|
+
return f"{self._settings.URL_PREFIX}/admin/login"
|
|
79
|
+
|
|
80
|
+
async def login(self, request: Request) -> bool:
|
|
81
|
+
"""Authenticate an active local superuser from the admin login form.
|
|
82
|
+
|
|
83
|
+
``dummy_verify`` runs when no matching user is found so all failure
|
|
84
|
+
paths take the same time, preventing username enumeration via timing.
|
|
85
|
+
"""
|
|
86
|
+
form = await request.form()
|
|
87
|
+
username = str(form.get("username") or "")
|
|
88
|
+
password = str(form.get("password") or "")
|
|
89
|
+
|
|
90
|
+
user = await self._load_local_superuser(username)
|
|
91
|
+
if user is None:
|
|
92
|
+
dummy_verify(self._settings)
|
|
93
|
+
password_ok = False
|
|
94
|
+
else:
|
|
95
|
+
password_ok = verify_password(password, user.password_hash or "", self._settings)
|
|
96
|
+
|
|
97
|
+
if user is None or not password_ok:
|
|
98
|
+
logger.warning("Admin login failed", username=username)
|
|
99
|
+
return False
|
|
100
|
+
|
|
101
|
+
request.session[_ADMIN_SESSION_KEY] = user.id
|
|
102
|
+
logger.info("Admin login succeeded", user_id=user.id, username=user.username)
|
|
103
|
+
return True
|
|
104
|
+
|
|
105
|
+
async def logout(self, request: Request) -> Response:
|
|
106
|
+
"""Pop only the admin session entry; the application SSO identity is left intact."""
|
|
107
|
+
request.session.pop(_ADMIN_SESSION_KEY, None)
|
|
108
|
+
return RedirectResponse(url=self._login_url, status_code=302)
|
|
109
|
+
|
|
110
|
+
async def authenticate(self, request: Request) -> Response | bool:
|
|
111
|
+
"""Grant access via the admin session or the app session (superuser only)."""
|
|
112
|
+
async with AsyncSessionLocal() as db:
|
|
113
|
+
user = await resolve_admin_user(request, db)
|
|
114
|
+
return user is not None
|
|
115
|
+
|
|
116
|
+
async def _load_local_superuser(self, username: str) -> User | None:
|
|
117
|
+
"""Return an active local superuser by username, or None."""
|
|
118
|
+
if not username:
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
async with AsyncSessionLocal() as db:
|
|
122
|
+
result = await db.execute(
|
|
123
|
+
select(User).where(
|
|
124
|
+
User.username == username,
|
|
125
|
+
User.auth_provider == "local",
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
user = result.scalar_one_or_none()
|
|
129
|
+
|
|
130
|
+
if user is None or not user.is_active or not user.is_superuser:
|
|
131
|
+
return None
|
|
132
|
+
return user
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="app-base" content="{{ url_prefix }}">
|
|
6
|
+
<link rel="icon" type="image/svg+xml" href="{{ url_prefix }}/static/favicon.svg" />
|
|
7
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
8
|
+
<title>Admin Dashboard</title>
|
|
9
|
+
<link rel="stylesheet" href="{{ url_prefix }}/static/css/index.css" />
|
|
10
|
+
<link rel="stylesheet" href="{{ url_prefix }}/static/css/contexts.css" />
|
|
11
|
+
<link rel="stylesheet" href="{{ url_prefix }}/static/css/components.css" />
|
|
12
|
+
<link rel="stylesheet" href="{{ url_prefix }}/static/css/admin.css" />
|
|
13
|
+
<script type="module" src="{{ url_prefix }}/static/js/admin.js"></script>
|
|
14
|
+
</head>
|
|
15
|
+
<body style="margin:0">
|
|
16
|
+
<div id="admin-root"></div>
|
|
17
|
+
</body>
|
|
18
|
+
</html>
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{% extends "sqladmin/layout.html" %}
|
|
2
|
+
|
|
3
|
+
{% block head %}
|
|
4
|
+
<style>
|
|
5
|
+
/* Remove the page-header container */
|
|
6
|
+
.page-wrapper > .container-fluid:first-child {
|
|
7
|
+
display: none;
|
|
8
|
+
}
|
|
9
|
+
/* Strip padding from body/row so iframe fills everything */
|
|
10
|
+
.page-body {
|
|
11
|
+
padding: 0 !important;
|
|
12
|
+
overflow: hidden;
|
|
13
|
+
margin: 0;
|
|
14
|
+
height: 100vh;
|
|
15
|
+
}
|
|
16
|
+
.page-body > .container-fluid {
|
|
17
|
+
height: 100%;
|
|
18
|
+
padding: 0;
|
|
19
|
+
max-width: 100%;
|
|
20
|
+
}
|
|
21
|
+
.page-body > .container-fluid > .row {
|
|
22
|
+
height: 100%;
|
|
23
|
+
margin: 0;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.iframe-admin {
|
|
27
|
+
width: 100%;
|
|
28
|
+
height: 100vh;
|
|
29
|
+
border: none;
|
|
30
|
+
margin: 0;
|
|
31
|
+
padding: 0;
|
|
32
|
+
}
|
|
33
|
+
</style>
|
|
34
|
+
{% endblock %}
|
|
35
|
+
|
|
36
|
+
{% block content_header %}{% endblock %}
|
|
37
|
+
|
|
38
|
+
{% block content %}
|
|
39
|
+
<iframe
|
|
40
|
+
src="{{ admin.base_url }}/dashboard"
|
|
41
|
+
class="iframe-admin"
|
|
42
|
+
title="Admin Dashboard"
|
|
43
|
+
id="admin-app-iframe"
|
|
44
|
+
></iframe>
|
|
45
|
+
{% endblock %}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
{% extends "sqladmin_original/layout.html" %}
|
|
2
|
+
|
|
3
|
+
{% block head %}
|
|
4
|
+
{{ super() }}
|
|
5
|
+
<script>
|
|
6
|
+
(function () {
|
|
7
|
+
var stored = sessionStorage.getItem("strands_chat_color_mode");
|
|
8
|
+
var theme = stored === "dark" || stored === "light"
|
|
9
|
+
? stored
|
|
10
|
+
: window.matchMedia("(prefers-color-scheme: dark)").matches
|
|
11
|
+
? "dark"
|
|
12
|
+
: "light";
|
|
13
|
+
document.documentElement.setAttribute("data-bs-theme", theme);
|
|
14
|
+
})();
|
|
15
|
+
</script>
|
|
16
|
+
<style>
|
|
17
|
+
/*
|
|
18
|
+
* Select2 dark mode overrides.
|
|
19
|
+
*
|
|
20
|
+
* Select2 renders its own DOM and hardcodes light colours — it does not
|
|
21
|
+
* respond to Bootstrap's data-bs-theme. These rules re-skin every Select2
|
|
22
|
+
* surface to match Tabler's dark palette when data-bs-theme="dark" is on
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/* Selection box — single and multiple */
|
|
26
|
+
[data-bs-theme="dark"] .select2-container--default .select2-selection--single,
|
|
27
|
+
[data-bs-theme="dark"] .select2-container--default .select2-selection--multiple {
|
|
28
|
+
background-color: #1f2937;
|
|
29
|
+
border-color: #374151;
|
|
30
|
+
color: #e5e7eb;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
[data-bs-theme="dark"] .select2-container--default .select2-selection--single .select2-selection__rendered {
|
|
34
|
+
color: #e5e7eb;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
[data-bs-theme="dark"] .select2-container--default .select2-selection--single .select2-selection__placeholder {
|
|
38
|
+
color: #9ca3af;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/* Dropdown arrow */
|
|
42
|
+
[data-bs-theme="dark"] .select2-container--default .select2-selection--single .select2-selection__arrow b {
|
|
43
|
+
border-color: #9ca3af transparent transparent transparent;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
[data-bs-theme="dark"] .select2-container--default.select2-container--open .select2-selection--single .select2-selection__arrow b {
|
|
47
|
+
border-color: transparent transparent #9ca3af transparent;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/* Selected choice tags (multiple) */
|
|
51
|
+
[data-bs-theme="dark"] .select2-container--default .select2-selection--multiple .select2-selection__choice {
|
|
52
|
+
background-color: #374151;
|
|
53
|
+
border-color: #4b5563;
|
|
54
|
+
color: #e5e7eb;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
[data-bs-theme="dark"] .select2-container--default .select2-selection--multiple .select2-selection__choice__remove {
|
|
58
|
+
color: #9ca3af;
|
|
59
|
+
border-color: #4b5563;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
[data-bs-theme="dark"] .select2-container--default .select2-selection--multiple .select2-selection__choice__remove:hover {
|
|
63
|
+
background-color: #4b5563;
|
|
64
|
+
color: #f9fafb;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/* Inline search input inside the multiselect box */
|
|
68
|
+
[data-bs-theme="dark"] .select2-container--default .select2-search--inline .select2-search__field {
|
|
69
|
+
color: #e5e7eb;
|
|
70
|
+
background: transparent;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/* Dropdown panel */
|
|
74
|
+
[data-bs-theme="dark"] .select2-dropdown {
|
|
75
|
+
background-color: #1f2937;
|
|
76
|
+
border-color: #374151;
|
|
77
|
+
color: #e5e7eb;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/* Search box inside the dropdown */
|
|
81
|
+
[data-bs-theme="dark"] .select2-container--default .select2-search--dropdown .select2-search__field {
|
|
82
|
+
background-color: #111827;
|
|
83
|
+
border-color: #374151;
|
|
84
|
+
color: #e5e7eb;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/* Result options in the dropdown list */
|
|
88
|
+
[data-bs-theme="dark"] .select2-container--default .select2-results__option {
|
|
89
|
+
color: #e5e7eb;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
[data-bs-theme="dark"] .select2-container--default .select2-results__option[aria-selected="true"] {
|
|
93
|
+
background-color: #374151;
|
|
94
|
+
color: #e5e7eb;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
[data-bs-theme="dark"] .select2-container--default .select2-results__option--highlighted[aria-selected] {
|
|
98
|
+
background-color: #1d4ed8;
|
|
99
|
+
color: #ffffff;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
[data-bs-theme="dark"] .select2-container--default .select2-results__option[aria-disabled="true"] {
|
|
103
|
+
color: #6b7280;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/* Focus ring on the multiple container */
|
|
107
|
+
[data-bs-theme="dark"] .select2-container--default.select2-container--focus .select2-selection--multiple {
|
|
108
|
+
border-color: #3b82f6;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
[data-bs-theme="dark"] .btn-light {
|
|
112
|
+
background-color: #1f2937;
|
|
113
|
+
border-color: #374151;
|
|
114
|
+
color: #e5e7eb;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
[data-bs-theme="dark"] .btn-light:hover,
|
|
118
|
+
[data-bs-theme="dark"] .btn-light:focus,
|
|
119
|
+
[data-bs-theme="dark"] .btn-light:active,
|
|
120
|
+
[data-bs-theme="dark"] .btn-light.show {
|
|
121
|
+
background-color: #374151;
|
|
122
|
+
border-color: #4b5563;
|
|
123
|
+
color: #f9fafb;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
[data-bs-theme="dark"] .btn-light:disabled {
|
|
127
|
+
background-color: #1f2937;
|
|
128
|
+
border-color: #374151;
|
|
129
|
+
color: #6b7280;
|
|
130
|
+
}
|
|
131
|
+
</style>
|
|
132
|
+
{% endblock %}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Admin ModelView definitions, one class per file."""
|
|
2
|
+
|
|
3
|
+
from .agent import AgentAdmin
|
|
4
|
+
from .api_key import ApiKeyAdmin
|
|
5
|
+
from .chat_message import ChatMessageAdmin
|
|
6
|
+
from .chat_session import ChatSessionAdmin
|
|
7
|
+
from .dashboard import DashboardView
|
|
8
|
+
from .group import GroupAdmin
|
|
9
|
+
from .model_pricing import ModelPricingAdmin
|
|
10
|
+
from .token_usage import TokenUsageAdmin
|
|
11
|
+
from .user import UserAdmin
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"AgentAdmin",
|
|
15
|
+
"ApiKeyAdmin",
|
|
16
|
+
"ChatMessageAdmin",
|
|
17
|
+
"ChatSessionAdmin",
|
|
18
|
+
"DashboardView",
|
|
19
|
+
"GroupAdmin",
|
|
20
|
+
"ModelPricingAdmin",
|
|
21
|
+
"TokenUsageAdmin",
|
|
22
|
+
"UserAdmin",
|
|
23
|
+
]
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""Admin view for the Agent model."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Any
|
|
5
|
+
from urllib.parse import urlparse
|
|
6
|
+
|
|
7
|
+
from markupsafe import Markup
|
|
8
|
+
from wtforms import SelectField, TextAreaField
|
|
9
|
+
from wtforms.validators import DataRequired
|
|
10
|
+
|
|
11
|
+
from ...db.models import Agent
|
|
12
|
+
from ...deps import get_settings
|
|
13
|
+
from .base import BaseModelView, _badge
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _format_description(model: Any, _prop: Any) -> str:
|
|
17
|
+
"""Truncate agent description to 60 characters in the list view."""
|
|
18
|
+
desc: str = getattr(model, "description", "") or ""
|
|
19
|
+
return desc[:60] + "…" if len(desc) > 60 else desc
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _format_agent_kind(model: Any, _prop: Any) -> Markup:
|
|
23
|
+
"""Render agent_kind as a coloured badge."""
|
|
24
|
+
kind: str = getattr(model, "agent_kind", "") or ""
|
|
25
|
+
colour_map = {
|
|
26
|
+
"agentcore_runtime": "#6f42c1",
|
|
27
|
+
"local": "#0d6efd",
|
|
28
|
+
}
|
|
29
|
+
label_map = {
|
|
30
|
+
"agentcore_runtime": "AgentCore",
|
|
31
|
+
"local": "Local",
|
|
32
|
+
}
|
|
33
|
+
return _badge(label_map.get(kind, kind or "—"), colour_map.get(kind, "#6c757d"))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SuggestedQuestionsField(TextAreaField):
|
|
37
|
+
"""WTForms field that stores a JSON list of strings as newline-separated text.
|
|
38
|
+
|
|
39
|
+
The textarea shows one question per line. On submit the value is split on
|
|
40
|
+
newlines, stripped, and empty lines are discarded before being stored as a
|
|
41
|
+
JSON array. On load a ``list`` from the DB JSON column is joined back to
|
|
42
|
+
newline-separated text so the textarea is pre-populated correctly.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def process_formdata(self, valuelist: list[str]) -> None:
|
|
46
|
+
if valuelist:
|
|
47
|
+
lines = [line.strip() for line in valuelist[0].splitlines()]
|
|
48
|
+
self.data = [line for line in lines if line]
|
|
49
|
+
else:
|
|
50
|
+
self.data = None
|
|
51
|
+
|
|
52
|
+
def _value(self) -> str:
|
|
53
|
+
if isinstance(self.data, list):
|
|
54
|
+
return "\n".join(self.data)
|
|
55
|
+
return self.data or ""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class AgentAdmin(BaseModelView, model=Agent):
|
|
59
|
+
"""Admin view for the agents table.
|
|
60
|
+
|
|
61
|
+
Soft-delete is used instead of hard delete to preserve chat_sessions
|
|
62
|
+
foreign-key references: ``delete_model`` sets ``enabled=False``.
|
|
63
|
+
|
|
64
|
+
``agent_kind`` is rendered as a dropdown. Kind-specific fields carry
|
|
65
|
+
description hints so the admin user knows which apply to which type.
|
|
66
|
+
``suggested_questions`` is a newline-separated textarea that serialises
|
|
67
|
+
to a JSON array on the model.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
name = "Agent"
|
|
71
|
+
name_plural = "Agents"
|
|
72
|
+
icon = "fa-solid fa-robot"
|
|
73
|
+
|
|
74
|
+
column_list = [
|
|
75
|
+
Agent.name,
|
|
76
|
+
Agent.description,
|
|
77
|
+
Agent.access_groups,
|
|
78
|
+
Agent.enabled,
|
|
79
|
+
Agent.multimodal,
|
|
80
|
+
Agent.agent_kind,
|
|
81
|
+
Agent.created_at,
|
|
82
|
+
Agent.updated_at,
|
|
83
|
+
]
|
|
84
|
+
column_details_list = [
|
|
85
|
+
Agent.name,
|
|
86
|
+
Agent.description,
|
|
87
|
+
Agent.access_groups,
|
|
88
|
+
Agent.enabled,
|
|
89
|
+
Agent.multimodal,
|
|
90
|
+
Agent.agent_kind,
|
|
91
|
+
Agent.agent_runtime_arn,
|
|
92
|
+
Agent.region,
|
|
93
|
+
Agent.endpoint_url,
|
|
94
|
+
Agent.suggested_questions,
|
|
95
|
+
Agent.created_at,
|
|
96
|
+
Agent.updated_at,
|
|
97
|
+
]
|
|
98
|
+
form_columns = [
|
|
99
|
+
Agent.name,
|
|
100
|
+
Agent.description,
|
|
101
|
+
Agent.access_groups,
|
|
102
|
+
Agent.enabled,
|
|
103
|
+
Agent.multimodal,
|
|
104
|
+
Agent.agent_kind,
|
|
105
|
+
Agent.agent_runtime_arn,
|
|
106
|
+
Agent.region,
|
|
107
|
+
Agent.endpoint_url,
|
|
108
|
+
Agent.suggested_questions,
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
column_searchable_list = [Agent.name, Agent.id]
|
|
112
|
+
column_default_sort = [(Agent.name, False)]
|
|
113
|
+
|
|
114
|
+
form_defaults = {"enabled": True, "multimodal": False}
|
|
115
|
+
|
|
116
|
+
# access_groups renders as badge list via BaseModelView.get_list_value / get_detail_value
|
|
117
|
+
_badge_relation_props = {"access_groups": "#d79750"}
|
|
118
|
+
|
|
119
|
+
column_formatters = { # type: ignore[assignment]
|
|
120
|
+
Agent.description: _format_description,
|
|
121
|
+
Agent.agent_kind: _format_agent_kind,
|
|
122
|
+
}
|
|
123
|
+
column_formatters_detail = { # type: ignore[assignment]
|
|
124
|
+
Agent.description: _format_description,
|
|
125
|
+
Agent.agent_kind: _format_agent_kind,
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
form_overrides = {
|
|
129
|
+
"agent_kind": SelectField,
|
|
130
|
+
"description": TextAreaField,
|
|
131
|
+
"suggested_questions": SuggestedQuestionsField,
|
|
132
|
+
}
|
|
133
|
+
form_args = {
|
|
134
|
+
"agent_kind": {
|
|
135
|
+
"choices": [
|
|
136
|
+
("agentcore_runtime", "Agentcore Runtime"),
|
|
137
|
+
("local", "Local"),
|
|
138
|
+
]
|
|
139
|
+
},
|
|
140
|
+
"description": {
|
|
141
|
+
"label": "Description",
|
|
142
|
+
"validators": [DataRequired()],
|
|
143
|
+
"description": "Shown above the chat composer on the welcome page. Use newlines for multiple lines.",
|
|
144
|
+
"render_kw": {
|
|
145
|
+
"rows": 4,
|
|
146
|
+
"placeholder": "This agent can help you with...",
|
|
147
|
+
"class": "form-control w-100",
|
|
148
|
+
"style": "width:100%!important;box-sizing:border-box;resize:vertical",
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
"agent_runtime_arn": {
|
|
152
|
+
"description": "Required for Agentcore Runtime agents only.",
|
|
153
|
+
},
|
|
154
|
+
"region": {
|
|
155
|
+
"description": "Required for Agentcore Runtime agents only.",
|
|
156
|
+
},
|
|
157
|
+
"endpoint_url": {
|
|
158
|
+
"description": "Required for Local agents only.",
|
|
159
|
+
},
|
|
160
|
+
"suggested_questions": {
|
|
161
|
+
"label": "Suggested Questions",
|
|
162
|
+
"description": "One question per line. Shown as quick-start prompts on the welcome page.",
|
|
163
|
+
"render_kw": {
|
|
164
|
+
"rows": 4,
|
|
165
|
+
"placeholder": "What can you help me with?\nSummarise the latest changes",
|
|
166
|
+
"class": "form-control w-100",
|
|
167
|
+
"style": "width:100%!important;box-sizing:border-box;resize:vertical",
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
form_ajax_refs = {
|
|
173
|
+
"access_groups": {
|
|
174
|
+
"fields": ("name",),
|
|
175
|
+
"order_by": "name",
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async def on_model_delete(self, model: Any, request: Any) -> None:
|
|
180
|
+
"""Soft-delete: set enabled=False instead of deleting the row."""
|
|
181
|
+
model.enabled = False
|
|
182
|
+
|
|
183
|
+
async def on_model_change(self, data: dict, model: Any, is_created: bool, request: Any) -> None:
|
|
184
|
+
"""Validate kind-specific required fields.
|
|
185
|
+
|
|
186
|
+
In dev, ``http://`` is allowed only for localhost/127.0.0.1/*.local endpoints.
|
|
187
|
+
In prod, ``https://`` is enforced on all local-kind agents.
|
|
188
|
+
"""
|
|
189
|
+
kind = data.get("agent_kind") or getattr(model, "agent_kind", None)
|
|
190
|
+
|
|
191
|
+
if kind == "agentcore_runtime":
|
|
192
|
+
arn = (data.get("agent_runtime_arn") or "").strip()
|
|
193
|
+
region = (data.get("region") or "").strip()
|
|
194
|
+
missing = []
|
|
195
|
+
if not arn:
|
|
196
|
+
missing.append("Agent Runtime ARN")
|
|
197
|
+
if not region:
|
|
198
|
+
missing.append("Region")
|
|
199
|
+
if missing:
|
|
200
|
+
raise ValueError(f"Agentcore Runtime agents require: {', '.join(missing)}.")
|
|
201
|
+
|
|
202
|
+
if kind == "local":
|
|
203
|
+
endpoint_url = (data.get("endpoint_url") or "").strip()
|
|
204
|
+
if not endpoint_url:
|
|
205
|
+
raise ValueError("Local agents require an Endpoint URL.")
|
|
206
|
+
app_env = get_settings().APP_ENV
|
|
207
|
+
parsed = urlparse(endpoint_url)
|
|
208
|
+
scheme = parsed.scheme.lower()
|
|
209
|
+
host = parsed.hostname or ""
|
|
210
|
+
if scheme != "https":
|
|
211
|
+
if app_env == "prod":
|
|
212
|
+
raise ValueError(
|
|
213
|
+
f"endpoint_url scheme must be 'https' in prod; got {endpoint_url!r}"
|
|
214
|
+
)
|
|
215
|
+
if scheme != "http" or (
|
|
216
|
+
host not in {"localhost", "127.0.0.1"}
|
|
217
|
+
and not re.match(r"^[a-zA-Z0-9\-]+\.local$", host)
|
|
218
|
+
):
|
|
219
|
+
raise ValueError(
|
|
220
|
+
f"endpoint_url with http:// is only allowed for localhost, 127.0.0.1, "
|
|
221
|
+
f"or *.local hosts in dev; got host={host!r}"
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
async def delete_model(self, request: Any, pk: Any) -> None:
|
|
225
|
+
"""Override hard-delete with a soft-delete (enabled=False)."""
|
|
226
|
+
from sqlalchemy import select # noqa: PLC0415
|
|
227
|
+
|
|
228
|
+
async with self.session_maker() as session:
|
|
229
|
+
stmt = select(Agent).where(Agent.id == pk)
|
|
230
|
+
result = await session.execute(stmt)
|
|
231
|
+
agent = result.scalar_one_or_none()
|
|
232
|
+
if agent is not None:
|
|
233
|
+
agent.enabled = False
|
|
234
|
+
await session.commit()
|