mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-13 05:17:14 +00:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 87cf61fabe | |||
| 9eb8683997 | |||
| 19526e1400 | |||
| dfde9578c1 | |||
| ec5b8cc8a8 | |||
| 137bb4fdb3 | |||
| 273b8839b9 | |||
| 9db6650274 | |||
| b594cf23e4 | |||
| 45d77c3926 | |||
| 1ea9cd3f6f | |||
| ff6ad6adc2 |
@@ -2,6 +2,11 @@ name: Build and Publish to PyPI
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
source_ref:
|
||||
description: 'Existing release tag to publish (for example v4.10.11)'
|
||||
required: true
|
||||
type: string
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
@@ -11,13 +16,39 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # Required for trusted publishing to PyPI
|
||||
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.source_ref || github.sha }}
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Validate release source and version
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.source_ref || github.event.release.tag_name }}
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
tag = os.environ['RELEASE_TAG']
|
||||
if not re.fullmatch(r'v[0-9]+\.[0-9]+\.[0-9]+', tag):
|
||||
raise SystemExit('source_ref must be an existing release tag: vX.Y.Z')
|
||||
def revision(ref):
|
||||
return subprocess.check_output(['git', 'rev-parse', '--verify', ref], text=True).strip()
|
||||
if revision('HEAD') != revision(f'refs/tags/{tag}^{{}}'):
|
||||
raise SystemExit('Checked-out commit does not match the release tag')
|
||||
version = tomllib.loads(Path('pyproject.toml').read_text())['project']['version']
|
||||
if version != tag[1:]:
|
||||
raise SystemExit(f'Package version {version} does not match tag {tag}')
|
||||
print(f'Validated {tag} at {revision("HEAD")} (package {version})')
|
||||
PY
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -26,9 +57,9 @@ jobs:
|
||||
- name: Build frontend
|
||||
run: |
|
||||
cd web
|
||||
npm install -g pnpm
|
||||
pnpm install
|
||||
pnpm build
|
||||
# Match the archive/Docker npm path; npm ci rejects older tags' stale npm lockfiles.
|
||||
npm install --include=optional
|
||||
npm run build
|
||||
mkdir -p ../src/langbot/web/dist
|
||||
cp -r dist ../src/langbot/web/
|
||||
|
||||
|
||||
@@ -10,12 +10,16 @@ on:
|
||||
- 'src/langbot/pkg/persistence/**'
|
||||
- 'src/langbot/pkg/entity/persistence/**'
|
||||
- 'tests/integration/persistence/**'
|
||||
- 'tests/unit_tests/api/service/test_monitoring_sessions.py'
|
||||
- '.github/workflows/test-migrations.yml'
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
paths:
|
||||
- 'src/langbot/pkg/persistence/**'
|
||||
- 'src/langbot/pkg/entity/persistence/**'
|
||||
- 'tests/integration/persistence/**'
|
||||
- 'tests/unit_tests/api/service/test_monitoring_sessions.py'
|
||||
- '.github/workflows/test-migrations.yml'
|
||||
|
||||
jobs:
|
||||
test-migrations-sqlite:
|
||||
@@ -80,6 +84,8 @@ jobs:
|
||||
run: >-
|
||||
uv run pytest
|
||||
tests/integration/persistence/test_migrations_postgres.py
|
||||
tests/integration/persistence/test_monitoring_postgres.py
|
||||
tests/unit_tests/api/service/test_monitoring_sessions.py::test_postgres_upgrade_rls_and_concurrent_bot_counts
|
||||
tests/integration/persistence/test_pgvector_postgres.py
|
||||
tests/integration/persistence/test_release_migration_postgres.py
|
||||
tests/integration/persistence/test_plugin_identity_migration.py
|
||||
|
||||
@@ -57,3 +57,6 @@ testsdk/
|
||||
|
||||
# Next.js build cache (legacy)
|
||||
web/.next/
|
||||
web/.pnpm-home
|
||||
.tmp
|
||||
Caddyfile
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "langbot"
|
||||
version = "4.10.10"
|
||||
version = "4.10.11"
|
||||
description = "Production-grade platform for building agentic IM bots"
|
||||
readme = "README.md"
|
||||
license-files = ["LICENSE"]
|
||||
@@ -70,7 +70,7 @@ dependencies = [
|
||||
"langchain-text-splitters>=1.1.2",
|
||||
"chromadb>=1.0.0,<2.0.0",
|
||||
"qdrant-client (>=1.15.1,<2.0.0)",
|
||||
"langbot-plugin==0.5.7",
|
||||
"langbot-plugin==0.5.8",
|
||||
"asyncpg>=0.30.0",
|
||||
"line-bot-sdk>=3.19.0",
|
||||
"matrix-nio>=0.25.2",
|
||||
@@ -81,6 +81,7 @@ dependencies = [
|
||||
"botocore>=1.42.39",
|
||||
"litellm>=1.0.0",
|
||||
"valkey-glide>=2.4.1,<3.0.0; sys_platform != 'win32'", # No Windows wheels are published
|
||||
"webauthn>=3.0.0",
|
||||
]
|
||||
keywords = [
|
||||
"bot",
|
||||
|
||||
@@ -25,7 +25,10 @@ CLI uses. Create one in your Space account (Profile → Personal Access Tokens),
|
||||
then send it as a Bearer token:
|
||||
|
||||
```
|
||||
Authorization: Bearer lbpat_...uests without a valid PAT get `401 Unauthorized`.
|
||||
Authorization: Bearer <your-pat>
|
||||
```
|
||||
|
||||
Requests without a valid PAT get `401 Unauthorized`.
|
||||
|
||||
## Client configuration
|
||||
|
||||
@@ -66,6 +69,36 @@ All tools are read-only.
|
||||
state (available, unprobed, unavailable), then Space recommendation. Each
|
||||
item includes `availability.up`, `last_probed_at`, latency, and HTTP status.
|
||||
|
||||
## Runner usage recommendations
|
||||
|
||||
Use `search_plugins` with `runner_usage: "agent"` for Agent, pipeline, and
|
||||
setup-wizard recommendations, or `runner_usage: "event"` for event processors.
|
||||
The component kind remains `Runner`. Only these two exact values are accepted;
|
||||
omit the optional field to preserve unfiltered browsing.
|
||||
|
||||
```json
|
||||
{"query":"", "runner_usage":"agent", "page":1, "page_size":100}
|
||||
```
|
||||
|
||||
Plugin results include `latest_version` and `runner_usages: string[]`, the
|
||||
explicit union of usages in that latest installable version. Only recommend a
|
||||
plugin when this array explicitly contains the target usage. Missing, empty,
|
||||
malformed, or unknown usages must never mean agent-compatible. Event-only
|
||||
plugins must never enter Agent recommendations. Empty filtered results are
|
||||
valid while legacy packages await corrected releases; never remove the filter
|
||||
to fill a recommendation list.
|
||||
|
||||
REST callers use `runner_usage` on both
|
||||
`POST /api/v1/marketplace/extensions/search` and the compatibility
|
||||
`POST /api/v1/marketplace/plugins/search`; preserve it during fallback. Add
|
||||
`"type_filter":"plugin", "component_filter":"Runner"` on the unified endpoint.
|
||||
Usage is ANDed with other filters before pagination and `total`; MCP/Skill items
|
||||
do not match. Invalid REST values return HTTP 400.
|
||||
|
||||
Open the same filter in the webpage:
|
||||
`https://space.langbot.app/market?type=plugin&component=Runner&runner_usage=agent`
|
||||
(or `runner_usage=event`). Switch All / Agent / Event in the Runner usage row.
|
||||
|
||||
## Implementation & maintenance (for Space developers)
|
||||
|
||||
- Server: `internal/controller/mcp/server.go` (official Go MCP SDK
|
||||
|
||||
@@ -5,6 +5,7 @@ import quart
|
||||
|
||||
from ...authz import Permission
|
||||
from ...context import RequestContext
|
||||
from ...service.monitoring_traffic import get_traffic_series
|
||||
from .. import group
|
||||
|
||||
|
||||
@@ -377,6 +378,14 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
|
||||
return self.success(
|
||||
data={
|
||||
'traffic': await get_traffic_series(
|
||||
self.ap,
|
||||
request_context,
|
||||
bot_ids=bot_ids or None,
|
||||
pipeline_ids=pipeline_ids or None,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
),
|
||||
'overview': overview,
|
||||
'messages': messages,
|
||||
'llmCalls': llm_calls,
|
||||
@@ -405,6 +414,7 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
session_id,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
bot_id=quart.request.args.get('botId'),
|
||||
)
|
||||
|
||||
# Always return success with the analysis data
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import quart
|
||||
import argon2
|
||||
import asyncio
|
||||
import datetime
|
||||
import hmac
|
||||
import time
|
||||
import typing
|
||||
import uuid
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
@@ -64,6 +67,22 @@ class UserRouterGroup(group.RouterGroup):
|
||||
|
||||
return redirect_uri
|
||||
|
||||
def _extract_origin_and_rp_id(self, json_data: dict[str, typing.Any] | None = None) -> tuple[str, str]:
|
||||
origin = ''
|
||||
if json_data and isinstance(json_data, dict):
|
||||
origin = json_data.get('origin', '')
|
||||
if not origin:
|
||||
origin = quart.request.headers.get('Origin', '')
|
||||
if not origin:
|
||||
origin = quart.request.headers.get('Referer', '')
|
||||
if not origin:
|
||||
origin = quart.request.url_root.rstrip('/')
|
||||
|
||||
parsed = urlsplit(origin)
|
||||
rp_id = parsed.hostname or 'localhost'
|
||||
clean_origin = f'{parsed.scheme}://{parsed.netloc}' if parsed.scheme and parsed.netloc else origin.rstrip('/')
|
||||
return clean_origin, rp_id
|
||||
|
||||
async def initialize(self) -> None:
|
||||
@self.route('/init', methods=['GET', 'POST'], auth_type=group.AuthType.NONE)
|
||||
async def _() -> str:
|
||||
@@ -387,6 +406,8 @@ class UserRouterGroup(group.RouterGroup):
|
||||
capabilities['password_login_enabled'] = False
|
||||
capabilities['authenticated_invitation_acceptance_enabled'] = cloud_mode
|
||||
capabilities['invitation_registration_enabled'] = not cloud_mode
|
||||
capabilities['passkey_login_enabled'] = True
|
||||
capabilities['passkey_supported'] = True
|
||||
return self.success(data={'initialized': True, **capabilities})
|
||||
|
||||
@self.route('/set-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||
@@ -477,6 +498,182 @@ class UserRouterGroup(group.RouterGroup):
|
||||
except Exception:
|
||||
raise
|
||||
|
||||
@self.route('/passkey/register/options', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _(user_email: str) -> str:
|
||||
"""Generate WebAuthn registration options for current account."""
|
||||
allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get(
|
||||
'allow_modify_login_info', True
|
||||
)
|
||||
if not allow_modify_login_info:
|
||||
return self.http_status(403, -1, 'Modifying login info is disabled')
|
||||
|
||||
user_obj = await self.ap.user_service.get_user_by_email(user_email)
|
||||
if user_obj is None:
|
||||
return self.http_status(404, -1, 'User not found')
|
||||
|
||||
json_data = (await quart.request.json) or {}
|
||||
origin, rp_id = self._extract_origin_and_rp_id(json_data)
|
||||
|
||||
try:
|
||||
options, challenge_token = await self.ap.user_service.generate_passkey_registration_options(
|
||||
account_uuid=user_obj.uuid,
|
||||
rp_id=rp_id,
|
||||
origin=origin,
|
||||
rp_name='LangBot',
|
||||
)
|
||||
return self.success(data={'options': options, 'challenge_token': challenge_token})
|
||||
except Exception as e:
|
||||
return self.fail(1, str(e))
|
||||
|
||||
@self.route('/passkey/register/verify', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _(user_email: str) -> str:
|
||||
"""Verify WebAuthn registration response and save credential."""
|
||||
allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get(
|
||||
'allow_modify_login_info', True
|
||||
)
|
||||
if not allow_modify_login_info:
|
||||
return self.http_status(403, -1, 'Modifying login info is disabled')
|
||||
|
||||
user_obj = await self.ap.user_service.get_user_by_email(user_email)
|
||||
if user_obj is None:
|
||||
return self.http_status(404, -1, 'User not found')
|
||||
|
||||
json_data = await quart.request.json
|
||||
challenge_token = json_data.get('challenge_token')
|
||||
credential = json_data.get('credential') or json_data.get('response')
|
||||
name = json_data.get('name')
|
||||
|
||||
if not challenge_token or not credential:
|
||||
return self.fail(1, 'Missing challenge_token or credential')
|
||||
|
||||
try:
|
||||
cred = await self.ap.user_service.verify_and_save_passkey_registration(
|
||||
challenge_token=challenge_token,
|
||||
credential_data=credential,
|
||||
name=name,
|
||||
)
|
||||
return self.success(
|
||||
data={
|
||||
'uuid': cred.uuid,
|
||||
'name': cred.name,
|
||||
'created_at': cred.created_at.isoformat() if cred.created_at else None,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
return self.fail(1, str(e))
|
||||
|
||||
@self.route('/passkey/auth/options', methods=['POST'], auth_type=group.AuthType.NONE)
|
||||
async def _() -> str:
|
||||
"""Generate WebAuthn authentication options for passkey login."""
|
||||
json_data = (await quart.request.json) or {}
|
||||
email = json_data.get('email')
|
||||
origin, rp_id = self._extract_origin_and_rp_id(json_data)
|
||||
|
||||
try:
|
||||
options, challenge_token = await self.ap.user_service.generate_passkey_authentication_options(
|
||||
rp_id=rp_id,
|
||||
origin=origin,
|
||||
email=email,
|
||||
)
|
||||
return self.success(data={'options': options, 'challenge_token': challenge_token})
|
||||
except Exception as e:
|
||||
return self.fail(1, str(e))
|
||||
|
||||
@self.route('/passkey/auth/verify', methods=['POST'], auth_type=group.AuthType.NONE)
|
||||
async def _() -> str:
|
||||
"""Verify WebAuthn authentication response and log in."""
|
||||
json_data = await quart.request.json
|
||||
challenge_token = json_data.get('challenge_token')
|
||||
credential = json_data.get('credential') or json_data.get('response')
|
||||
|
||||
if not challenge_token or not credential:
|
||||
return self.fail(1, 'Missing challenge_token or credential')
|
||||
|
||||
try:
|
||||
token, user_obj = await self.ap.user_service.verify_passkey_authentication(
|
||||
challenge_token=challenge_token,
|
||||
credential_data=credential,
|
||||
)
|
||||
return self.success(
|
||||
data={
|
||||
'token': token,
|
||||
'user': user_obj.user,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
return self.fail(1, str(e))
|
||||
|
||||
@self.route('/passkeys', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _(user_email: str) -> str:
|
||||
"""List registered passkeys for the current user."""
|
||||
user_obj = await self.ap.user_service.get_user_by_email(user_email)
|
||||
if user_obj is None:
|
||||
return self.http_status(404, -1, 'User not found')
|
||||
|
||||
passkeys = await self.ap.user_service.get_user_passkeys(user_obj.uuid)
|
||||
return self.success(
|
||||
data=[
|
||||
{
|
||||
'uuid': pk.uuid,
|
||||
'name': pk.name,
|
||||
'aaguid': pk.aaguid,
|
||||
'transports': pk.transports,
|
||||
'backed_up': pk.backed_up,
|
||||
'created_at': pk.created_at.isoformat() if pk.created_at else None,
|
||||
'last_used_at': pk.last_used_at.isoformat() if pk.last_used_at else None,
|
||||
}
|
||||
for pk in passkeys
|
||||
]
|
||||
)
|
||||
|
||||
@self.route('/passkey/<passkey_uuid>', methods=['PATCH'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _(user_email: str, passkey_uuid: str) -> str:
|
||||
"""Rename a registered passkey."""
|
||||
allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get(
|
||||
'allow_modify_login_info', True
|
||||
)
|
||||
if not allow_modify_login_info:
|
||||
return self.http_status(403, -1, 'Modifying login info is disabled')
|
||||
|
||||
user_obj = await self.ap.user_service.get_user_by_email(user_email)
|
||||
if user_obj is None:
|
||||
return self.http_status(404, -1, 'User not found')
|
||||
|
||||
json_data = await quart.request.json
|
||||
name = (json_data.get('name') or '').strip()
|
||||
if not name:
|
||||
return self.fail(1, 'Passkey name cannot be empty')
|
||||
|
||||
updated = await self.ap.user_service.rename_user_passkey(
|
||||
account_uuid=user_obj.uuid,
|
||||
passkey_uuid=passkey_uuid,
|
||||
new_name=name,
|
||||
)
|
||||
if not updated:
|
||||
return self.http_status(404, -1, 'Passkey not found')
|
||||
return self.success(data={'uuid': updated.uuid, 'name': updated.name})
|
||||
|
||||
@self.route('/passkey/<passkey_uuid>', methods=['DELETE'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _(user_email: str, passkey_uuid: str) -> str:
|
||||
"""Delete/revoke a registered passkey."""
|
||||
allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get(
|
||||
'allow_modify_login_info', True
|
||||
)
|
||||
if not allow_modify_login_info:
|
||||
return self.http_status(403, -1, 'Modifying login info is disabled')
|
||||
|
||||
user_obj = await self.ap.user_service.get_user_by_email(user_email)
|
||||
if user_obj is None:
|
||||
return self.http_status(404, -1, 'User not found')
|
||||
|
||||
deleted = await self.ap.user_service.delete_user_passkey(
|
||||
account_uuid=user_obj.uuid,
|
||||
passkey_uuid=passkey_uuid,
|
||||
)
|
||||
if not deleted:
|
||||
return self.http_status(404, -1, 'Passkey not found')
|
||||
return self.success()
|
||||
|
||||
async def _handle_space_direct_launch(
|
||||
self,
|
||||
launch_assertion: str,
|
||||
|
||||
@@ -29,6 +29,19 @@ _DEFAULT_CLEANUP_BATCHES_PER_TABLE = 4
|
||||
_HARD_MAX_CLEANUP_BATCHES_PER_TABLE = 100
|
||||
|
||||
|
||||
def _normalize_user_id(value: str | int | None) -> str | None:
|
||||
"""Convert numeric platform IDs before binding a VARCHAR with asyncpg.
|
||||
|
||||
Opaque string IDs (including whitespace and leading zeros) and missing
|
||||
IDs must remain unchanged. Do not silently stringify unsupported objects.
|
||||
"""
|
||||
if value is None or isinstance(value, str):
|
||||
return value
|
||||
if isinstance(value, int) and not isinstance(value, bool):
|
||||
return str(value)
|
||||
raise TypeError('user_id must be a string, integer, or None')
|
||||
|
||||
|
||||
def _workspace_transaction(method):
|
||||
"""Run an explicit service entrypoint in one Workspace transaction."""
|
||||
|
||||
@@ -281,19 +294,21 @@ class MonitoringService:
|
||||
for _batch_number in range(max_batches):
|
||||
|
||||
async def delete_batch() -> tuple[int, int]:
|
||||
key_columns = list(model_cls.__table__.primary_key.columns)
|
||||
select_result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(pk_column)
|
||||
sqlalchemy.select(*key_columns)
|
||||
.where(model_cls.workspace_uuid == workspace_uuid, ts_column < cutoff)
|
||||
.limit(batch_size)
|
||||
)
|
||||
pk_values = list(select_result.scalars().all())
|
||||
pk_values = [tuple(row) for row in select_result.all()]
|
||||
if not pk_values:
|
||||
return 0, 0
|
||||
|
||||
delete_result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.delete(model_cls).where(
|
||||
model_cls.workspace_uuid == workspace_uuid,
|
||||
pk_column.in_(pk_values),
|
||||
sqlalchemy.tuple_(*key_columns).in_(pk_values),
|
||||
ts_column < cutoff,
|
||||
)
|
||||
)
|
||||
return len(pk_values), int(delete_result.rowcount or 0)
|
||||
@@ -415,7 +430,7 @@ class MonitoringService:
|
||||
status: str = 'success',
|
||||
level: str = 'info',
|
||||
platform: str | None = None,
|
||||
user_id: str | None = None,
|
||||
user_id: str | int | None = None,
|
||||
user_name: str | None = None,
|
||||
runner_name: str | None = None,
|
||||
variables: str | None = None,
|
||||
@@ -437,7 +452,7 @@ class MonitoringService:
|
||||
'status': status,
|
||||
'level': level,
|
||||
'platform': platform,
|
||||
'user_id': user_id,
|
||||
'user_id': _normalize_user_id(user_id),
|
||||
'user_name': user_name,
|
||||
'runner_name': runner_name,
|
||||
'variables': variables,
|
||||
@@ -610,7 +625,7 @@ class MonitoringService:
|
||||
pipeline_id: str,
|
||||
pipeline_name: str,
|
||||
platform: str | None = None,
|
||||
user_id: str | None = None,
|
||||
user_id: str | int | None = None,
|
||||
user_name: str | None = None,
|
||||
) -> None:
|
||||
"""Record a new session"""
|
||||
@@ -622,17 +637,29 @@ class MonitoringService:
|
||||
'bot_name': bot_name,
|
||||
'pipeline_id': pipeline_id,
|
||||
'pipeline_name': pipeline_name,
|
||||
'message_count': 0,
|
||||
'message_count': 1,
|
||||
'start_time': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),
|
||||
'last_activity': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),
|
||||
'is_active': True,
|
||||
'platform': platform,
|
||||
'user_id': user_id,
|
||||
'user_id': _normalize_user_id(user_id),
|
||||
'user_name': user_name,
|
||||
}
|
||||
|
||||
model = persistence_monitoring.MonitoringSession
|
||||
dialect = self.ap.persistence_mgr.get_db_engine().dialect.name
|
||||
insert = postgresql_dialect.insert if dialect == 'postgresql' else sqlite_dialect.insert
|
||||
statement = insert(model).values(session_data)
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.insert(persistence_monitoring.MonitoringSession).values(session_data)
|
||||
statement.on_conflict_do_update(
|
||||
index_elements=['workspace_uuid', 'bot_id', 'session_id'],
|
||||
set_={
|
||||
'message_count': model.message_count + 1,
|
||||
'last_activity': statement.excluded.last_activity,
|
||||
'pipeline_id': statement.excluded.pipeline_id,
|
||||
'pipeline_name': statement.excluded.pipeline_name,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
@_workspace_transaction
|
||||
@@ -642,6 +669,7 @@ class MonitoringService:
|
||||
session_id: str,
|
||||
pipeline_id: str | None = None,
|
||||
pipeline_name: str | None = None,
|
||||
bot_id: str | None = None,
|
||||
) -> bool:
|
||||
"""Update session last activity time and increment message count.
|
||||
|
||||
@@ -651,6 +679,9 @@ class MonitoringService:
|
||||
True if session was found and updated, False if session doesn't exist.
|
||||
"""
|
||||
workspace_uuid = self._require_write_context(context)
|
||||
bot_id = bot_id if bot_id is not None else context.bot_uuid
|
||||
if not bot_id:
|
||||
raise ValueError('Session activity requires a bot_id')
|
||||
update_values = {
|
||||
'last_activity': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),
|
||||
'message_count': persistence_monitoring.MonitoringSession.message_count + 1,
|
||||
@@ -667,6 +698,7 @@ class MonitoringService:
|
||||
.where(
|
||||
persistence_monitoring.MonitoringSession.workspace_uuid == workspace_uuid,
|
||||
persistence_monitoring.MonitoringSession.session_id == session_id,
|
||||
persistence_monitoring.MonitoringSession.bot_id == bot_id,
|
||||
)
|
||||
.values(update_values)
|
||||
)
|
||||
@@ -769,13 +801,13 @@ class MonitoringService:
|
||||
message_conditions.append(persistence_monitoring.MonitoringMessage.timestamp >= start_time)
|
||||
llm_conditions.append(persistence_monitoring.MonitoringLLMCall.timestamp >= start_time)
|
||||
embedding_conditions.append(persistence_monitoring.MonitoringEmbeddingCall.timestamp >= start_time)
|
||||
session_conditions.append(persistence_monitoring.MonitoringSession.start_time >= start_time)
|
||||
session_conditions.append(persistence_monitoring.MonitoringSession.last_activity >= start_time)
|
||||
|
||||
if end_time:
|
||||
message_conditions.append(persistence_monitoring.MonitoringMessage.timestamp <= end_time)
|
||||
llm_conditions.append(persistence_monitoring.MonitoringLLMCall.timestamp <= end_time)
|
||||
embedding_conditions.append(persistence_monitoring.MonitoringEmbeddingCall.timestamp <= end_time)
|
||||
session_conditions.append(persistence_monitoring.MonitoringSession.start_time <= end_time)
|
||||
session_conditions.append(persistence_monitoring.MonitoringSession.last_activity <= end_time)
|
||||
|
||||
# Total messages
|
||||
message_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringMessage.id))
|
||||
@@ -1272,9 +1304,9 @@ class MonitoringService:
|
||||
if pipeline_ids:
|
||||
conditions.append(persistence_monitoring.MonitoringSession.pipeline_id.in_(pipeline_ids))
|
||||
if start_time:
|
||||
conditions.append(persistence_monitoring.MonitoringSession.start_time >= start_time)
|
||||
conditions.append(persistence_monitoring.MonitoringSession.last_activity >= start_time)
|
||||
if end_time:
|
||||
conditions.append(persistence_monitoring.MonitoringSession.start_time <= end_time)
|
||||
conditions.append(persistence_monitoring.MonitoringSession.last_activity <= end_time)
|
||||
if user_query and user_query.strip():
|
||||
user_pattern = f'%{user_query.strip()}%'
|
||||
conditions.append(
|
||||
@@ -1376,6 +1408,7 @@ class MonitoringService:
|
||||
session_id: str,
|
||||
start_time: datetime.datetime | None = None,
|
||||
end_time: datetime.datetime | None = None,
|
||||
bot_id: str | None = None,
|
||||
) -> dict:
|
||||
"""Get bounded session details with full statistics computed in SQL."""
|
||||
workspace_uuid = require_workspace_uuid(context)
|
||||
@@ -1385,8 +1418,13 @@ class MonitoringService:
|
||||
persistence_monitoring.MonitoringSession.workspace_uuid == workspace_uuid,
|
||||
persistence_monitoring.MonitoringSession.session_id == session_id,
|
||||
)
|
||||
session_result = await self.ap.persistence_mgr.execute_async(session_query)
|
||||
session_row = session_result.first()
|
||||
if bot_id is not None:
|
||||
session_query = session_query.where(persistence_monitoring.MonitoringSession.bot_id == bot_id)
|
||||
session_result = await self.ap.persistence_mgr.execute_async(session_query.limit(2))
|
||||
session_rows = session_result.all()
|
||||
if len(session_rows) > 1:
|
||||
return {'session_id': session_id, 'found': False, 'ambiguous': True}
|
||||
session_row = session_rows[0] if session_rows else None
|
||||
|
||||
if not session_row:
|
||||
return {
|
||||
@@ -1395,6 +1433,7 @@ class MonitoringService:
|
||||
}
|
||||
|
||||
session = session_row[0] if isinstance(session_row, tuple) else session_row
|
||||
bot_id = session.bot_id
|
||||
|
||||
message_stats_result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(
|
||||
@@ -1422,6 +1461,7 @@ class MonitoringService:
|
||||
).where(
|
||||
persistence_monitoring.MonitoringMessage.workspace_uuid == workspace_uuid,
|
||||
persistence_monitoring.MonitoringMessage.session_id == session_id,
|
||||
persistence_monitoring.MonitoringMessage.bot_id == bot_id,
|
||||
)
|
||||
)
|
||||
message_stats = message_stats_result.one()
|
||||
@@ -1460,6 +1500,7 @@ class MonitoringService:
|
||||
).where(
|
||||
persistence_monitoring.MonitoringLLMCall.workspace_uuid == workspace_uuid,
|
||||
persistence_monitoring.MonitoringLLMCall.session_id == session_id,
|
||||
persistence_monitoring.MonitoringLLMCall.bot_id == bot_id,
|
||||
)
|
||||
)
|
||||
llm_stats = llm_stats_result.one()
|
||||
@@ -1486,12 +1527,14 @@ class MonitoringService:
|
||||
).where(
|
||||
persistence_monitoring.MonitoringToolCall.workspace_uuid == workspace_uuid,
|
||||
persistence_monitoring.MonitoringToolCall.session_id == session_id,
|
||||
persistence_monitoring.MonitoringToolCall.bot_id == bot_id,
|
||||
)
|
||||
)
|
||||
tool_stats = tool_stats_result.one()
|
||||
tool_conditions = [
|
||||
persistence_monitoring.MonitoringToolCall.workspace_uuid == workspace_uuid,
|
||||
persistence_monitoring.MonitoringToolCall.session_id == session_id,
|
||||
persistence_monitoring.MonitoringToolCall.bot_id == bot_id,
|
||||
]
|
||||
if start_time is not None:
|
||||
tool_conditions.append(persistence_monitoring.MonitoringToolCall.timestamp >= start_time)
|
||||
@@ -1520,6 +1563,7 @@ class MonitoringService:
|
||||
.where(
|
||||
persistence_monitoring.MonitoringError.workspace_uuid == workspace_uuid,
|
||||
persistence_monitoring.MonitoringError.session_id == session_id,
|
||||
persistence_monitoring.MonitoringError.bot_id == bot_id,
|
||||
)
|
||||
.order_by(persistence_monitoring.MonitoringError.timestamp.desc())
|
||||
.limit(detail_limit + 1)
|
||||
@@ -2004,9 +2048,9 @@ class MonitoringService:
|
||||
if pipeline_ids:
|
||||
conditions.append(persistence_monitoring.MonitoringSession.pipeline_id.in_(pipeline_ids))
|
||||
if start_time:
|
||||
conditions.append(persistence_monitoring.MonitoringSession.start_time >= start_time)
|
||||
conditions.append(persistence_monitoring.MonitoringSession.last_activity >= start_time)
|
||||
if end_time:
|
||||
conditions.append(persistence_monitoring.MonitoringSession.start_time <= end_time)
|
||||
conditions.append(persistence_monitoring.MonitoringSession.last_activity <= end_time)
|
||||
|
||||
query = sqlalchemy.select(persistence_monitoring.MonitoringSession).order_by(
|
||||
persistence_monitoring.MonitoringSession.last_activity.desc()
|
||||
@@ -2040,6 +2084,7 @@ class MonitoringService:
|
||||
|
||||
# ========== Feedback Methods ==========
|
||||
|
||||
@_workspace_transaction
|
||||
async def record_feedback(
|
||||
self,
|
||||
context: ExecutionContext,
|
||||
@@ -2054,7 +2099,7 @@ class MonitoringService:
|
||||
session_id: str | None = None,
|
||||
message_id: str | None = None,
|
||||
stream_id: str | None = None,
|
||||
user_id: str | None = None,
|
||||
user_id: str | int | None = None,
|
||||
platform: str | None = None,
|
||||
) -> str | None:
|
||||
"""Record user feedback (like/dislike) from AI Bot conversation.
|
||||
@@ -2110,7 +2155,7 @@ class MonitoringService:
|
||||
'session_id': session_id,
|
||||
'message_id': message_id,
|
||||
'stream_id': stream_id,
|
||||
'user_id': user_id,
|
||||
'user_id': _normalize_user_id(user_id),
|
||||
'platform': platform,
|
||||
}
|
||||
dialect_name = self.ap.persistence_mgr.get_db_engine().dialect.name
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Bounded traffic aggregation, independent of record-list pagination."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import typing
|
||||
|
||||
import sqlalchemy
|
||||
|
||||
from ....entity.persistence.monitoring import MonitoringLLMCall, MonitoringMessage
|
||||
from .tenant import TenantContext, require_workspace_uuid
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from ....core.app import Application
|
||||
|
||||
MAX_TRAFFIC_POINTS = 1000
|
||||
|
||||
|
||||
async def get_traffic_series(
|
||||
ap: Application,
|
||||
context: TenantContext,
|
||||
*,
|
||||
bot_ids: list[str] | None = None,
|
||||
pipeline_ids: list[str] | None = None,
|
||||
start_time: datetime.datetime | None = None,
|
||||
end_time: datetime.datetime | None = None,
|
||||
) -> dict:
|
||||
"""Count all matching records in UTC buckets, returning at most 1000 points."""
|
||||
workspace_uuid = require_workspace_uuid(context)
|
||||
bucket = 'hour' if start_time and end_time and end_time - start_time <= datetime.timedelta(days=7) else 'day'
|
||||
step = datetime.timedelta(hours=1) if bucket == 'hour' else datetime.timedelta(days=1)
|
||||
postgres = ap.persistence_mgr.get_db_engine().dialect.name == 'postgresql'
|
||||
points: dict[datetime.datetime, dict[str, int]] = {}
|
||||
truncated = False
|
||||
for model, field in ((MonitoringMessage, 'messages'), (MonitoringLLMCall, 'llm_calls')):
|
||||
timestamp = model.timestamp
|
||||
if postgres:
|
||||
time_bucket = sqlalchemy.func.date_trunc(bucket, timestamp)
|
||||
else:
|
||||
pattern = '%Y-%m-%dT%H:00:00' if bucket == 'hour' else '%Y-%m-%dT00:00:00'
|
||||
time_bucket = sqlalchemy.func.strftime(pattern, timestamp)
|
||||
conditions = [model.workspace_uuid == workspace_uuid]
|
||||
if bot_ids:
|
||||
conditions.append(model.bot_id.in_(bot_ids))
|
||||
if pipeline_ids:
|
||||
conditions.append(model.pipeline_id.in_(pipeline_ids))
|
||||
if start_time is not None:
|
||||
conditions.append(timestamp >= start_time)
|
||||
if end_time is not None:
|
||||
conditions.append(timestamp <= end_time)
|
||||
statement = (
|
||||
sqlalchemy.select(time_bucket.label('bucket'), sqlalchemy.func.count(model.id).label('count'))
|
||||
.where(*conditions)
|
||||
.group_by(time_bucket)
|
||||
.order_by(time_bucket)
|
||||
.limit(MAX_TRAFFIC_POINTS + 1)
|
||||
)
|
||||
result = await ap.persistence_mgr.execute_async(statement)
|
||||
rows = result.all()
|
||||
truncated = truncated or len(rows) > MAX_TRAFFIC_POINTS
|
||||
for timestamp_value, count in rows[:MAX_TRAFFIC_POINTS]:
|
||||
key = (
|
||||
datetime.datetime.fromisoformat(timestamp_value)
|
||||
if isinstance(timestamp_value, str)
|
||||
else timestamp_value
|
||||
)
|
||||
points.setdefault(key, {'messages': 0, 'llm_calls': 0})[field] = int(count)
|
||||
|
||||
def floor(value: datetime.datetime) -> datetime.datetime:
|
||||
return value.replace(minute=0, second=0, microsecond=0, **({'hour': 0} if bucket == 'day' else {}))
|
||||
|
||||
first = floor(start_time) if start_time is not None else min(points, default=None)
|
||||
last = floor(end_time) if end_time is not None else max(points, default=None)
|
||||
series = []
|
||||
if first is not None and last is not None:
|
||||
cursor = first
|
||||
while cursor <= last and len(series) < MAX_TRAFFIC_POINTS:
|
||||
series.append(
|
||||
{'timestamp': cursor.isoformat() + 'Z', **points.get(cursor, {'messages': 0, 'llm_calls': 0})}
|
||||
)
|
||||
cursor += step
|
||||
truncated = truncated or cursor <= last
|
||||
return {'bucket': bucket, 'points': series, 'truncated': truncated}
|
||||
@@ -4,6 +4,7 @@ import sqlalchemy
|
||||
import argon2
|
||||
import jwt
|
||||
import datetime
|
||||
import json
|
||||
import typing
|
||||
import asyncio
|
||||
import dataclasses
|
||||
@@ -12,10 +13,19 @@ import hashlib
|
||||
import secrets
|
||||
import time
|
||||
import uuid
|
||||
import webauthn
|
||||
from webauthn.helpers import bytes_to_base64url, base64url_to_bytes
|
||||
from webauthn.helpers.structs import (
|
||||
AuthenticatorSelectionCriteria,
|
||||
PublicKeyCredentialDescriptor,
|
||||
ResidentKeyRequirement,
|
||||
UserVerificationRequirement,
|
||||
)
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from ....entity.persistence import user
|
||||
from ....entity.persistence import passkey
|
||||
from ....entity.persistence.workspace import MembershipRole, MembershipStatus, WorkspaceMembership
|
||||
from ....utils import constants
|
||||
from ....entity.errors import account as account_errors
|
||||
@@ -29,6 +39,9 @@ if typing.TYPE_CHECKING:
|
||||
_SPACE_OAUTH_STATE_MAX_ENTRIES = 4096
|
||||
_SPACE_OAUTH_STATE_HEAP_COMPACT_FLOOR = 64
|
||||
_SPACE_OAUTH_STATE_HEAP_MAX_MULTIPLIER = 4
|
||||
_PASSKEY_CHALLENGE_MAX_ENTRIES = 4096
|
||||
_PASSKEY_CHALLENGE_HEAP_COMPACT_FLOOR = 64
|
||||
_PASSKEY_CHALLENGE_HEAP_MAX_MULTIPLIER = 4
|
||||
|
||||
|
||||
class AccountExistsLoginRequiredError(ValueError):
|
||||
@@ -54,6 +67,17 @@ class SpaceOAuthStateConsumption:
|
||||
launch_workspace_uuid: str | None = None
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True, slots=True)
|
||||
class PasskeyChallengeData:
|
||||
challenge: bytes
|
||||
purpose: typing.Literal['register', 'auth']
|
||||
rp_id: str
|
||||
origin: str
|
||||
expires_at: float
|
||||
account_uuid: str | None = None
|
||||
user_email: str | None = None
|
||||
|
||||
|
||||
class UserService:
|
||||
ap: Application
|
||||
_create_user_lock: asyncio.Lock
|
||||
@@ -65,6 +89,9 @@ class UserService:
|
||||
self._space_oauth_state_lock = asyncio.Lock()
|
||||
self._space_oauth_states: dict[str, tuple[str, str | None, float, str | None]] = {}
|
||||
self._space_oauth_state_expiry_heap: list[tuple[float, str]] = []
|
||||
self._passkey_challenge_lock = asyncio.Lock()
|
||||
self._passkey_challenges: dict[str, PasskeyChallengeData] = {}
|
||||
self._passkey_challenge_expiry_heap: list[tuple[float, str]] = []
|
||||
|
||||
@staticmethod
|
||||
def _space_oauth_state_digest(state: str) -> str:
|
||||
@@ -850,3 +877,309 @@ class UserService:
|
||||
await self._update_space_provider_for_account(local_account, api_key)
|
||||
|
||||
return await self.get_user_by_email(space_email)
|
||||
|
||||
def _prune_passkey_challenges(self, now: float) -> None:
|
||||
while self._passkey_challenge_expiry_heap:
|
||||
expires_at, token = self._passkey_challenge_expiry_heap[0]
|
||||
entry = self._passkey_challenges.get(token)
|
||||
if entry is None or entry.expires_at != expires_at:
|
||||
heapq.heappop(self._passkey_challenge_expiry_heap)
|
||||
continue
|
||||
if expires_at > now:
|
||||
break
|
||||
heapq.heappop(self._passkey_challenge_expiry_heap)
|
||||
self._passkey_challenges.pop(token, None)
|
||||
|
||||
max_heap_entries = max(
|
||||
_PASSKEY_CHALLENGE_HEAP_COMPACT_FLOOR,
|
||||
len(self._passkey_challenges) * _PASSKEY_CHALLENGE_HEAP_MAX_MULTIPLIER,
|
||||
)
|
||||
if len(self._passkey_challenge_expiry_heap) > max_heap_entries:
|
||||
self._passkey_challenge_expiry_heap[:] = [
|
||||
(entry.expires_at, token) for token, entry in self._passkey_challenges.items()
|
||||
]
|
||||
heapq.heapify(self._passkey_challenge_expiry_heap)
|
||||
|
||||
async def issue_passkey_challenge(
|
||||
self,
|
||||
purpose: typing.Literal['register', 'auth'],
|
||||
rp_id: str,
|
||||
origin: str,
|
||||
*,
|
||||
account_uuid: str | None = None,
|
||||
user_email: str | None = None,
|
||||
ttl_seconds: int = 300,
|
||||
) -> tuple[str, bytes]:
|
||||
now = time.monotonic()
|
||||
challenge_bytes = secrets.token_bytes(32)
|
||||
challenge_token = secrets.token_urlsafe(32)
|
||||
expires_at = now + ttl_seconds
|
||||
|
||||
async with self._passkey_challenge_lock:
|
||||
self._prune_passkey_challenges(now)
|
||||
while len(self._passkey_challenges) >= _PASSKEY_CHALLENGE_MAX_ENTRIES:
|
||||
if not self._passkey_challenge_expiry_heap:
|
||||
break
|
||||
_, oldest_token = heapq.heappop(self._passkey_challenge_expiry_heap)
|
||||
self._passkey_challenges.pop(oldest_token, None)
|
||||
|
||||
self._passkey_challenges[challenge_token] = PasskeyChallengeData(
|
||||
challenge=challenge_bytes,
|
||||
purpose=purpose,
|
||||
rp_id=rp_id,
|
||||
origin=origin,
|
||||
expires_at=expires_at,
|
||||
account_uuid=account_uuid,
|
||||
user_email=user_email,
|
||||
)
|
||||
heapq.heappush(self._passkey_challenge_expiry_heap, (expires_at, challenge_token))
|
||||
|
||||
return challenge_token, challenge_bytes
|
||||
|
||||
async def consume_passkey_challenge(
|
||||
self,
|
||||
challenge_token: str,
|
||||
purpose: typing.Literal['register', 'auth'],
|
||||
) -> PasskeyChallengeData:
|
||||
now = time.monotonic()
|
||||
async with self._passkey_challenge_lock:
|
||||
self._prune_passkey_challenges(now)
|
||||
data = self._passkey_challenges.pop(challenge_token, None)
|
||||
|
||||
if data is None or data.expires_at < now:
|
||||
raise ValueError('Invalid or expired passkey challenge')
|
||||
if data.purpose != purpose:
|
||||
raise ValueError('Passkey challenge purpose mismatch')
|
||||
return data
|
||||
|
||||
async def get_user_passkeys(self, account_uuid: str) -> list[passkey.PasskeyCredential]:
|
||||
statement = (
|
||||
sqlalchemy.select(passkey.PasskeyCredential)
|
||||
.where(passkey.PasskeyCredential.account_uuid == account_uuid)
|
||||
.order_by(passkey.PasskeyCredential.created_at.desc())
|
||||
)
|
||||
async with self._session_factory()() as session:
|
||||
result = await session.scalars(statement)
|
||||
return list(result.all())
|
||||
|
||||
async def get_passkey_by_credential_id(self, credential_id: str) -> passkey.PasskeyCredential | None:
|
||||
statement = sqlalchemy.select(passkey.PasskeyCredential).where(
|
||||
passkey.PasskeyCredential.credential_id == credential_id
|
||||
)
|
||||
async with self._session_factory()() as session:
|
||||
return await session.scalar(statement)
|
||||
|
||||
async def get_passkey_by_uuid(self, passkey_uuid: str) -> passkey.PasskeyCredential | None:
|
||||
statement = sqlalchemy.select(passkey.PasskeyCredential).where(passkey.PasskeyCredential.uuid == passkey_uuid)
|
||||
async with self._session_factory()() as session:
|
||||
return await session.scalar(statement)
|
||||
|
||||
async def generate_passkey_registration_options(
|
||||
self,
|
||||
account_uuid: str,
|
||||
rp_id: str,
|
||||
origin: str,
|
||||
rp_name: str = 'LangBot',
|
||||
) -> tuple[dict[str, typing.Any], str]:
|
||||
account = await self.get_user_by_uuid(account_uuid)
|
||||
if account is None:
|
||||
raise ValueError('User not found')
|
||||
self._require_active_account(account)
|
||||
|
||||
challenge_token, challenge_bytes = await self.issue_passkey_challenge(
|
||||
purpose='register',
|
||||
rp_id=rp_id,
|
||||
origin=origin,
|
||||
account_uuid=account_uuid,
|
||||
user_email=account.user,
|
||||
)
|
||||
|
||||
existing_passkeys = await self.get_user_passkeys(account_uuid)
|
||||
exclude_credentials = [
|
||||
PublicKeyCredentialDescriptor(id=base64url_to_bytes(pk.credential_id)) for pk in existing_passkeys
|
||||
]
|
||||
|
||||
options = webauthn.generate_registration_options(
|
||||
rp_id=rp_id,
|
||||
rp_name=rp_name,
|
||||
user_name=account.user,
|
||||
user_id=account.uuid.encode('utf-8'),
|
||||
user_display_name=account.user,
|
||||
challenge=challenge_bytes,
|
||||
exclude_credentials=exclude_credentials or None,
|
||||
authenticator_selection=AuthenticatorSelectionCriteria(
|
||||
resident_key=ResidentKeyRequirement.PREFERRED,
|
||||
),
|
||||
)
|
||||
|
||||
options_dict = json.loads(webauthn.options_to_json(options))
|
||||
return options_dict, challenge_token
|
||||
|
||||
async def verify_and_save_passkey_registration(
|
||||
self,
|
||||
challenge_token: str,
|
||||
credential_data: dict[str, typing.Any] | str,
|
||||
name: str | None = None,
|
||||
) -> passkey.PasskeyCredential:
|
||||
challenge_data = await self.consume_passkey_challenge(challenge_token, 'register')
|
||||
if not challenge_data.account_uuid:
|
||||
raise ValueError('Registration challenge must be bound to an account')
|
||||
|
||||
verification = webauthn.verify_registration_response(
|
||||
credential=credential_data,
|
||||
expected_challenge=challenge_data.challenge,
|
||||
expected_rp_id=challenge_data.rp_id,
|
||||
expected_origin=challenge_data.origin,
|
||||
require_user_verification=False,
|
||||
)
|
||||
|
||||
cred_id_str = bytes_to_base64url(verification.credential_id)
|
||||
pub_key_str = bytes_to_base64url(verification.credential_public_key)
|
||||
|
||||
transports = None
|
||||
if isinstance(credential_data, dict):
|
||||
resp = credential_data.get('response', {})
|
||||
if isinstance(resp, dict) and 'transports' in resp:
|
||||
t_list = resp.get('transports')
|
||||
if isinstance(t_list, list):
|
||||
transports = ','.join(str(x) for x in t_list)
|
||||
|
||||
credential_name = (name or '').strip()
|
||||
if not credential_name:
|
||||
credential_name = f'Passkey ({datetime.datetime.now().strftime("%Y-%m-%d %H:%M")})'
|
||||
|
||||
record = passkey.PasskeyCredential(
|
||||
uuid=str(uuid.uuid4()),
|
||||
account_uuid=challenge_data.account_uuid,
|
||||
name=credential_name,
|
||||
credential_id=cred_id_str,
|
||||
public_key=pub_key_str,
|
||||
sign_count=verification.sign_count,
|
||||
aaguid=verification.aaguid,
|
||||
transports=transports,
|
||||
backed_up=verification.credential_backed_up,
|
||||
)
|
||||
|
||||
async with self._session_factory()() as session:
|
||||
async with session.begin():
|
||||
session.add(record)
|
||||
await session.flush()
|
||||
await session.refresh(record)
|
||||
return record
|
||||
|
||||
async def generate_passkey_authentication_options(
|
||||
self,
|
||||
rp_id: str,
|
||||
origin: str,
|
||||
email: str | None = None,
|
||||
) -> tuple[dict[str, typing.Any], str]:
|
||||
challenge_token, challenge_bytes = await self.issue_passkey_challenge(
|
||||
purpose='auth',
|
||||
rp_id=rp_id,
|
||||
origin=origin,
|
||||
user_email=email,
|
||||
)
|
||||
|
||||
allow_credentials: list[PublicKeyCredentialDescriptor] | None = None
|
||||
if email:
|
||||
user_obj = await self.get_user_by_email(email)
|
||||
if user_obj:
|
||||
user_passkeys = await self.get_user_passkeys(user_obj.uuid)
|
||||
if user_passkeys:
|
||||
allow_credentials = [
|
||||
PublicKeyCredentialDescriptor(id=base64url_to_bytes(pk.credential_id)) for pk in user_passkeys
|
||||
]
|
||||
|
||||
options = webauthn.generate_authentication_options(
|
||||
rp_id=rp_id,
|
||||
challenge=challenge_bytes,
|
||||
allow_credentials=allow_credentials or None,
|
||||
user_verification=UserVerificationRequirement.PREFERRED,
|
||||
)
|
||||
|
||||
options_dict = json.loads(webauthn.options_to_json(options))
|
||||
return options_dict, challenge_token
|
||||
|
||||
async def verify_passkey_authentication(
|
||||
self,
|
||||
challenge_token: str,
|
||||
credential_data: dict[str, typing.Any] | str,
|
||||
) -> tuple[str, user.User]:
|
||||
challenge_data = await self.consume_passkey_challenge(challenge_token, 'auth')
|
||||
|
||||
raw_id = credential_data.get('id') if isinstance(credential_data, dict) else None
|
||||
if not raw_id:
|
||||
raise ValueError('Missing credential id')
|
||||
|
||||
stored_credential = await self.get_passkey_by_credential_id(raw_id)
|
||||
if stored_credential is None:
|
||||
raise ValueError('Passkey credential not recognized')
|
||||
|
||||
user_obj = await self.get_user_by_uuid(stored_credential.account_uuid)
|
||||
if user_obj is None:
|
||||
raise ValueError('Associated user not found')
|
||||
self._require_active_account(user_obj)
|
||||
|
||||
verification = webauthn.verify_authentication_response(
|
||||
credential=credential_data,
|
||||
expected_challenge=challenge_data.challenge,
|
||||
expected_rp_id=challenge_data.rp_id,
|
||||
expected_origin=challenge_data.origin,
|
||||
credential_public_key=base64url_to_bytes(stored_credential.public_key),
|
||||
credential_current_sign_count=stored_credential.sign_count,
|
||||
require_user_verification=False,
|
||||
)
|
||||
|
||||
async with self._session_factory()() as session:
|
||||
async with session.begin():
|
||||
record = await session.scalar(
|
||||
sqlalchemy.select(passkey.PasskeyCredential).where(
|
||||
passkey.PasskeyCredential.id == stored_credential.id
|
||||
)
|
||||
)
|
||||
if record:
|
||||
record.sign_count = verification.new_sign_count
|
||||
record.last_used_at = datetime.datetime.now()
|
||||
record.backed_up = verification.credential_backed_up
|
||||
|
||||
token = await self.generate_jwt_token(user_obj)
|
||||
return token, user_obj
|
||||
|
||||
async def rename_user_passkey(
|
||||
self,
|
||||
account_uuid: str,
|
||||
passkey_uuid: str,
|
||||
new_name: str,
|
||||
) -> passkey.PasskeyCredential | None:
|
||||
async with self._session_factory()() as session:
|
||||
async with session.begin():
|
||||
record = await session.scalar(
|
||||
sqlalchemy.select(passkey.PasskeyCredential).where(
|
||||
passkey.PasskeyCredential.uuid == passkey_uuid,
|
||||
passkey.PasskeyCredential.account_uuid == account_uuid,
|
||||
)
|
||||
)
|
||||
if record is None:
|
||||
return None
|
||||
record.name = new_name
|
||||
await session.flush()
|
||||
await session.refresh(record)
|
||||
return record
|
||||
|
||||
async def delete_user_passkey(
|
||||
self,
|
||||
account_uuid: str,
|
||||
passkey_uuid: str,
|
||||
) -> bool:
|
||||
async with self._session_factory()() as session:
|
||||
async with session.begin():
|
||||
record = await session.scalar(
|
||||
sqlalchemy.select(passkey.PasskeyCredential).where(
|
||||
passkey.PasskeyCredential.uuid == passkey_uuid,
|
||||
passkey.PasskeyCredential.account_uuid == account_uuid,
|
||||
)
|
||||
)
|
||||
if record is None:
|
||||
return False
|
||||
await session.delete(record)
|
||||
return True
|
||||
|
||||
@@ -111,8 +111,8 @@ class MonitoringSession(Base):
|
||||
sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
|
||||
primary_key=True,
|
||||
)
|
||||
bot_id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, index=True)
|
||||
session_id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
|
||||
bot_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
|
||||
bot_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
pipeline_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
|
||||
pipeline_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid as uuid_lib
|
||||
|
||||
import sqlalchemy
|
||||
|
||||
from .base import Base
|
||||
|
||||
|
||||
class PasskeyCredential(Base):
|
||||
__tablename__ = 'passkey_credentials'
|
||||
|
||||
id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True)
|
||||
uuid = sqlalchemy.Column(
|
||||
sqlalchemy.String(36),
|
||||
nullable=False,
|
||||
default=lambda: str(uuid_lib.uuid4()),
|
||||
)
|
||||
account_uuid = sqlalchemy.Column(
|
||||
sqlalchemy.String(36),
|
||||
sqlalchemy.ForeignKey('users.uuid', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
)
|
||||
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
credential_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
public_key = sqlalchemy.Column(sqlalchemy.Text, nullable=False)
|
||||
sign_count = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, default=0)
|
||||
aaguid = sqlalchemy.Column(sqlalchemy.String(64), nullable=True)
|
||||
transports = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||
backed_up = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=False)
|
||||
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
|
||||
last_used_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
sqlalchemy.Index('uq_passkey_credentials_uuid', 'uuid', unique=True),
|
||||
sqlalchemy.Index('uq_passkey_credentials_cred_id', 'credential_id', unique=True),
|
||||
sqlalchemy.Index('ix_passkey_credentials_account', 'account_uuid'),
|
||||
)
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Scope monitoring sessions by bot without changing runtime session IDs.
|
||||
|
||||
Revision ID: 0023_bot_scoped_sessions
|
||||
Revises: 0022_codex_credentials
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql, sqlite
|
||||
|
||||
revision = '0023_bot_scoped_sessions'
|
||||
down_revision = '0022_codex_credentials'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_TABLE = 'monitoring_sessions'
|
||||
_KEY = ['workspace_uuid', 'bot_id', 'session_id']
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
if _TABLE not in inspector.get_table_names():
|
||||
return
|
||||
pk = inspector.get_pk_constraint(_TABLE)
|
||||
if pk['constrained_columns'] == _KEY:
|
||||
return
|
||||
# PostgreSQL alters in place, retaining indexes, grants, policies and RLS.
|
||||
# SQLite batch reflection retains all existing indexes and foreign keys.
|
||||
with op.batch_alter_table(_TABLE, naming_convention={'pk': 'pk_%(table_name)s'}) as batch:
|
||||
batch.drop_constraint(pk['name'] or f'pk_{_TABLE}', type_='primary')
|
||||
batch.create_primary_key(f'pk_{_TABLE}', _KEY)
|
||||
|
||||
metadata = sa.MetaData()
|
||||
sessions = sa.Table(_TABLE, metadata, autoload_with=conn)
|
||||
messages = sa.Table('monitoring_messages', metadata, autoload_with=conn)
|
||||
m = messages.c
|
||||
collisions = (
|
||||
sa.select(m.workspace_uuid, m.session_id)
|
||||
.group_by(m.workspace_uuid, m.session_id)
|
||||
.having(sa.func.count(sa.distinct(m.bot_id)) > 1)
|
||||
.subquery()
|
||||
)
|
||||
partition = [m.workspace_uuid, m.bot_id, m.session_id]
|
||||
# Repair only demonstrable collisions. Retention may have removed earlier
|
||||
# evidence; these summaries describe surviving messages, never invented text.
|
||||
ranked = (
|
||||
sa.select(
|
||||
*[m[name] for name in _KEY],
|
||||
m.bot_name,
|
||||
m.pipeline_id,
|
||||
m.pipeline_name,
|
||||
m.platform,
|
||||
m.user_id,
|
||||
m.user_name,
|
||||
sa.func.sum(sa.case((sa.or_(m.role == 'user', m.role.is_(None)), 1), else_=0))
|
||||
.over(partition_by=partition)
|
||||
.label('message_count'),
|
||||
sa.func.min(m.timestamp).over(partition_by=partition).label('start_time'),
|
||||
sa.func.max(m.timestamp).over(partition_by=partition).label('last_activity'),
|
||||
sa.func.row_number().over(partition_by=partition, order_by=[m.timestamp.desc(), m.id.desc()]).label('rank'),
|
||||
)
|
||||
.join(
|
||||
collisions,
|
||||
sa.and_(m.workspace_uuid == collisions.c.workspace_uuid, m.session_id == collisions.c.session_id),
|
||||
)
|
||||
.subquery()
|
||||
)
|
||||
columns = _KEY + [
|
||||
'bot_name',
|
||||
'pipeline_id',
|
||||
'pipeline_name',
|
||||
'platform',
|
||||
'user_id',
|
||||
'user_name',
|
||||
'message_count',
|
||||
'start_time',
|
||||
'last_activity',
|
||||
'is_active',
|
||||
]
|
||||
select = sa.select(*[ranked.c[name] for name in columns[:-1]], sa.literal(True)).where(ranked.c.rank == 1)
|
||||
insert = postgresql.insert if conn.dialect.name == 'postgresql' else sqlite.insert
|
||||
statement = insert(sessions).from_select(columns, select)
|
||||
conn.execute(
|
||||
statement.on_conflict_do_update(
|
||||
index_elements=_KEY,
|
||||
set_={name: statement.excluded[name] for name in columns if name not in _KEY and name != 'is_active'},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
if _TABLE not in sa.inspect(conn).get_table_names():
|
||||
return
|
||||
collisions = conn.execute(
|
||||
sa.text('SELECT 1 FROM monitoring_sessions GROUP BY workspace_uuid, session_id HAVING COUNT(*) > 1 LIMIT 1')
|
||||
).first()
|
||||
if collisions:
|
||||
raise RuntimeError('Cannot downgrade bot-scoped sessions without losing colliding bot records')
|
||||
pk = sa.inspect(conn).get_pk_constraint(_TABLE)
|
||||
with op.batch_alter_table(_TABLE, naming_convention={'pk': 'pk_%(table_name)s'}) as batch:
|
||||
batch.drop_constraint(pk['name'] or f'pk_{_TABLE}', type_='primary')
|
||||
batch.create_primary_key(f'pk_{_TABLE}', ['workspace_uuid', 'session_id'])
|
||||
@@ -0,0 +1,54 @@
|
||||
"""add passkey credentials table
|
||||
|
||||
Revision ID: 0024_passkey_credentials
|
||||
Revises: 0023_bot_scoped_sessions
|
||||
Create Date: 2026-09-12
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = '0024_passkey_credentials'
|
||||
down_revision = '0023_bot_scoped_sessions'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_TABLE_NAME = 'passkey_credentials'
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
existing_tables = set(sa.inspect(conn).get_table_names())
|
||||
if _TABLE_NAME not in existing_tables:
|
||||
op.create_table(
|
||||
_TABLE_NAME,
|
||||
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column('uuid', sa.String(36), nullable=False),
|
||||
sa.Column(
|
||||
'account_uuid',
|
||||
sa.String(36),
|
||||
sa.ForeignKey('users.uuid', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column('name', sa.String(255), nullable=False),
|
||||
sa.Column('credential_id', sa.String(255), nullable=False),
|
||||
sa.Column('public_key', sa.Text(), nullable=False),
|
||||
sa.Column('sign_count', sa.Integer(), nullable=False, server_default='0'),
|
||||
sa.Column('aaguid', sa.String(64), nullable=True),
|
||||
sa.Column('transports', sa.String(255), nullable=True),
|
||||
sa.Column('backed_up', sa.Boolean(), nullable=False, server_default='0'),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column('last_used_at', sa.DateTime(), nullable=True),
|
||||
)
|
||||
op.create_index('uq_passkey_credentials_uuid', _TABLE_NAME, ['uuid'], unique=True)
|
||||
op.create_index('uq_passkey_credentials_cred_id', _TABLE_NAME, ['credential_id'], unique=True)
|
||||
op.create_index('ix_passkey_credentials_account', _TABLE_NAME, ['account_uuid'], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('ix_passkey_credentials_account', table_name=_TABLE_NAME)
|
||||
op.drop_index('uq_passkey_credentials_cred_id', table_name=_TABLE_NAME)
|
||||
op.drop_index('uq_passkey_credentials_uuid', table_name=_TABLE_NAME)
|
||||
op.drop_table(_TABLE_NAME)
|
||||
@@ -63,6 +63,7 @@ _ALEMBIC_TENANT_TABLES = {
|
||||
'mcp_servers',
|
||||
'model_providers',
|
||||
'codex_credentials',
|
||||
'passkey_credentials',
|
||||
'llm_models',
|
||||
'embedding_models',
|
||||
'rerank_models',
|
||||
|
||||
@@ -207,6 +207,8 @@ _SYNC_PROXY_CAPABILITY: contextvars.ContextVar[_ScopedSessionGuardState | None]
|
||||
_ALLOWED_SCOPED_BUILTIN_FUNCTION_TYPES = {
|
||||
'coalesce': sqlalchemy.sql.functions.coalesce,
|
||||
'count': sqlalchemy.sql.functions.count,
|
||||
'min': sqlalchemy.sql.functions.min,
|
||||
'max': sqlalchemy.sql.functions.max,
|
||||
'now': sqlalchemy.sql.functions.now,
|
||||
'sum': sqlalchemy.sql.functions.sum,
|
||||
}
|
||||
|
||||
@@ -79,6 +79,7 @@ class MonitoringHelper:
|
||||
session_updated = await ap.monitoring_service.update_session_activity(
|
||||
get_query_execution_context(query),
|
||||
session_id,
|
||||
bot_id=bot_id,
|
||||
pipeline_id=pipeline_id,
|
||||
pipeline_name=pipeline_name,
|
||||
)
|
||||
|
||||
@@ -48,6 +48,7 @@ from ..utils import constants
|
||||
|
||||
_DEFAULT_BINARY_STORAGE_VALUE_BYTES = 10 * 1024 * 1024
|
||||
_HARD_MAX_BINARY_STORAGE_VALUE_BYTES = 64 * 1024 * 1024
|
||||
_UNSET_INSTALLATION_SCOPE = object()
|
||||
|
||||
|
||||
def _binary_storage_value_limit(ap: Any) -> int:
|
||||
@@ -479,7 +480,6 @@ class RuntimeConnectionHandler(handler.Handler):
|
||||
self._outbound_installation_context: contextvars.ContextVar[InstallationBinding | None] = (
|
||||
contextvars.ContextVar(
|
||||
f'{self.__class__.__name__}_{id(self)}_outbound_installation',
|
||||
default=None,
|
||||
)
|
||||
)
|
||||
self._installation_bindings: dict[
|
||||
@@ -1631,13 +1631,15 @@ class RuntimeConnectionHandler(handler.Handler):
|
||||
) -> InstallationBinding | ActionContext | None:
|
||||
if action_context is not None:
|
||||
return super().resolve_outbound_action_context(action_context)
|
||||
inbound_context = self.current_action_context
|
||||
if inbound_context is not None:
|
||||
return inbound_context
|
||||
return self._outbound_installation_context.get()
|
||||
# An explicit scope targets the nested call, not its inbound caller.
|
||||
# None deliberately clears the context for runtime-scoped actions.
|
||||
scoped_context = self._outbound_installation_context.get(_UNSET_INSTALLATION_SCOPE)
|
||||
if scoped_context is not _UNSET_INSTALLATION_SCOPE:
|
||||
return typing.cast(InstallationBinding | None, scoped_context)
|
||||
return self.current_action_context
|
||||
|
||||
def require_outbound_installation_context(self) -> InstallationBinding:
|
||||
binding = self._outbound_installation_context.get()
|
||||
binding = self._outbound_installation_context.get(None)
|
||||
if not isinstance(binding, InstallationBinding):
|
||||
raise ValueError('Host plugin action requires an InstallationBinding scope')
|
||||
return binding
|
||||
|
||||
@@ -9,7 +9,7 @@ Run: uv run pytest tests/integration/api/test_monitoring.py -q
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, AsyncMock, Mock
|
||||
from unittest.mock import MagicMock, AsyncMock, Mock, patch
|
||||
from types import SimpleNamespace
|
||||
|
||||
from tests.factories import FakeApp
|
||||
@@ -280,13 +280,20 @@ class TestMonitoringAllDataEndpoint:
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_all_data_success(self, quart_test_client):
|
||||
"""GET /api/v1/monitoring/data returns all data."""
|
||||
response = await quart_test_client.get(
|
||||
'/api/v1/monitoring/data', headers={'Authorization': 'Bearer test_token'}
|
||||
)
|
||||
traffic = {'series': [], 'truncated': False}
|
||||
with patch(
|
||||
'langbot.pkg.api.http.controller.groups.monitoring.get_traffic_series',
|
||||
new=AsyncMock(return_value=traffic),
|
||||
) as get_traffic:
|
||||
response = await quart_test_client.get(
|
||||
'/api/v1/monitoring/data', headers={'Authorization': 'Bearer test_token'}
|
||||
)
|
||||
get_traffic.assert_awaited_once()
|
||||
|
||||
assert response.status_code == 200
|
||||
data = await response.get_json()
|
||||
assert 'overview' in data['data']
|
||||
assert data['data']['traffic'] == traffic
|
||||
|
||||
|
||||
@pytest.mark.usefixtures('mock_circular_import_chain')
|
||||
|
||||
@@ -310,6 +310,8 @@ class TestUserInitEndpoint:
|
||||
'invitation_registration_enabled': True,
|
||||
'password_login_enabled': True,
|
||||
'space_login_enabled': False,
|
||||
'passkey_login_enabled': True,
|
||||
'passkey_supported': True,
|
||||
}
|
||||
fake_api_app.user_service.get_login_capabilities.assert_awaited_once_with()
|
||||
fake_api_app.user_service.get_first_user.assert_not_awaited()
|
||||
@@ -334,6 +336,8 @@ class TestUserInitEndpoint:
|
||||
'invitation_registration_enabled': False,
|
||||
'password_login_enabled': False,
|
||||
'space_login_enabled': True,
|
||||
'passkey_login_enabled': True,
|
||||
'passkey_supported': True,
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -355,6 +359,8 @@ class TestUserInitEndpoint:
|
||||
'invitation_registration_enabled': True,
|
||||
'password_login_enabled': False,
|
||||
'space_login_enabled': True,
|
||||
'passkey_login_enabled': True,
|
||||
'passkey_supported': True,
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
"""
|
||||
Integration smoke tests for Passkey API endpoints.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.factories import FakeApp
|
||||
from tests.utils.import_isolation import isolated_sys_modules, MockLifecycleControlScope
|
||||
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures('mock_circular_import_chain')]
|
||||
|
||||
|
||||
@pytest.fixture(scope='module')
|
||||
def mock_circular_import_chain():
|
||||
class FakeMinimalApplication:
|
||||
pass
|
||||
|
||||
mock_app = Mock()
|
||||
mock_app.Application = FakeMinimalApplication
|
||||
|
||||
mock_entities = Mock()
|
||||
mock_entities.LifecycleControlScope = MockLifecycleControlScope
|
||||
|
||||
clear = [
|
||||
'langbot.pkg.api.http.controller.group',
|
||||
'langbot.pkg.api.http.controller.groups',
|
||||
'langbot.pkg.api.http.controller.groups.system',
|
||||
'langbot.pkg.api.http.controller.groups.user',
|
||||
'langbot.pkg.api.http.controller.main',
|
||||
]
|
||||
|
||||
with isolated_sys_modules(
|
||||
mocks={
|
||||
'langbot.pkg.core.app': mock_app,
|
||||
'langbot.pkg.core.entities': mock_entities,
|
||||
},
|
||||
clear=clear,
|
||||
):
|
||||
import langbot.pkg.api.http.controller.groups.user as _user_group # noqa: E402, F401
|
||||
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_api_app():
|
||||
app = FakeApp()
|
||||
app.instance_config.data.update(
|
||||
{
|
||||
'api': {'port': 5300},
|
||||
'system': {'allow_modify_login_info': True},
|
||||
}
|
||||
)
|
||||
app.user_service = Mock()
|
||||
app.user_service.verify_jwt_token = AsyncMock(side_effect=ValueError('Invalid token'))
|
||||
app.user_service.get_user_by_email = AsyncMock(return_value=Mock())
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def quart_test_client(fake_api_app, http_controller_cls):
|
||||
controller = http_controller_cls(fake_api_app)
|
||||
await controller.initialize()
|
||||
|
||||
client = controller.quart_app.test_client()
|
||||
yield client
|
||||
|
||||
|
||||
class TestPasskeyPublicEndpoints:
|
||||
@pytest.mark.asyncio
|
||||
async def test_auth_options_endpoint(self, quart_test_client, fake_api_app):
|
||||
fake_api_app.user_service.generate_passkey_authentication_options = AsyncMock(
|
||||
return_value=({'challenge': 'test_chal', 'rpId': 'localhost'}, 'token_123')
|
||||
)
|
||||
|
||||
response = await quart_test_client.post(
|
||||
'/api/v1/user/passkey/auth/options',
|
||||
json={'origin': 'http://localhost:3000'},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = await response.get_json()
|
||||
assert data['code'] == 0
|
||||
assert data['data']['challenge_token'] == 'token_123'
|
||||
assert data['data']['options']['rpId'] == 'localhost'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auth_verify_missing_payload(self, quart_test_client, fake_api_app):
|
||||
response = await quart_test_client.post(
|
||||
'/api/v1/user/passkey/auth/verify',
|
||||
json={},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = await response.get_json()
|
||||
assert data['code'] != 0
|
||||
assert 'Missing challenge_token or credential' in data['msg']
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auth_verify_success(self, quart_test_client, fake_api_app):
|
||||
fake_api_app.user_service.verify_passkey_authentication = AsyncMock(
|
||||
return_value=('jwt_token_abc', Mock(user='user@example.com'))
|
||||
)
|
||||
|
||||
response = await quart_test_client.post(
|
||||
'/api/v1/user/passkey/auth/verify',
|
||||
json={'challenge_token': 'token_123', 'credential': {'id': 'cred_id'}},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = await response.get_json()
|
||||
assert data['code'] == 0
|
||||
assert data['data']['token'] == 'jwt_token_abc'
|
||||
assert data['data']['user'] == 'user@example.com'
|
||||
|
||||
|
||||
class TestPasskeyProtectedEndpoints:
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_options_requires_auth(self, quart_test_client):
|
||||
response = await quart_test_client.post('/api/v1/user/passkey/register/options', json={})
|
||||
assert response.status_code == 401
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_passkeys_list_requires_auth(self, quart_test_client):
|
||||
response = await quart_test_client.get('/api/v1/user/passkeys')
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
class TestPasskeyReverseProxyScenarios:
|
||||
@pytest.mark.asyncio
|
||||
async def test_auth_options_respects_custom_origin_body_behind_proxy(self, quart_test_client, fake_api_app):
|
||||
fake_api_app.user_service.generate_passkey_authentication_options = AsyncMock(
|
||||
return_value=({'challenge': 'test_chal', 'rpId': 'proxy.company.com'}, 'token_proxy')
|
||||
)
|
||||
|
||||
response = await quart_test_client.post(
|
||||
'/api/v1/user/passkey/auth/options',
|
||||
json={'origin': 'https://proxy.company.com:8443'},
|
||||
headers={'Host': '127.0.0.1:5300'},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = await response.get_json()
|
||||
assert data['code'] == 0
|
||||
fake_api_app.user_service.generate_passkey_authentication_options.assert_awaited_once_with(
|
||||
rp_id='proxy.company.com',
|
||||
origin='https://proxy.company.com:8443',
|
||||
email=None,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auth_options_falls_back_to_origin_header(self, quart_test_client, fake_api_app):
|
||||
fake_api_app.user_service.generate_passkey_authentication_options = AsyncMock(
|
||||
return_value=({'challenge': 'test_chal', 'rpId': 'bot.example.com'}, 'token_header')
|
||||
)
|
||||
|
||||
response = await quart_test_client.post(
|
||||
'/api/v1/user/passkey/auth/options',
|
||||
json={},
|
||||
headers={'Origin': 'https://bot.example.com'},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
fake_api_app.user_service.generate_passkey_authentication_options.assert_awaited_once_with(
|
||||
rp_id='bot.example.com',
|
||||
origin='https://bot.example.com',
|
||||
email=None,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auth_options_falls_back_to_referer_header(self, quart_test_client, fake_api_app):
|
||||
fake_api_app.user_service.generate_passkey_authentication_options = AsyncMock(
|
||||
return_value=({'challenge': 'test_chal', 'rpId': 'bot.example.com'}, 'token_referer')
|
||||
)
|
||||
|
||||
response = await quart_test_client.post(
|
||||
'/api/v1/user/passkey/auth/options',
|
||||
json={},
|
||||
headers={'Referer': 'https://bot.example.com:9000/login'},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
fake_api_app.user_service.generate_passkey_authentication_options.assert_awaited_once_with(
|
||||
rp_id='bot.example.com',
|
||||
origin='https://bot.example.com:9000',
|
||||
email=None,
|
||||
)
|
||||
@@ -193,6 +193,22 @@ async def create_legacy_resource_schema(engine, *, instance_uuid: str) -> None:
|
||||
sa.Column('message_id', sa.String(255), nullable=True),
|
||||
)
|
||||
|
||||
# Include historical monitoring columns consumed by later migrations.
|
||||
for table_name in ('monitoring_messages', 'monitoring_sessions'):
|
||||
table = monitoring_tables[table_name]
|
||||
for name, value in (('bot_name', 'bot'), ('pipeline_id', 'pipeline-1'), ('pipeline_name', 'pipeline')):
|
||||
table.append_column(sa.Column(name, sa.String(255), nullable=False, default=value))
|
||||
for name in ('platform', 'user_id', 'user_name'):
|
||||
table.append_column(sa.Column(name, sa.String(255)))
|
||||
if table_name == 'monitoring_messages':
|
||||
table.append_column(sa.Column('bot_id', sa.String(255), nullable=False, default='bot-1'))
|
||||
table.append_column(sa.Column('role', sa.String(50)))
|
||||
else:
|
||||
table.append_column(sa.Column('message_count', sa.Integer, nullable=False, default=1))
|
||||
table.append_column(
|
||||
sa.Column('start_time', sa.DateTime, nullable=False, default=datetime.datetime(2026, 1, 1))
|
||||
)
|
||||
|
||||
now = datetime.datetime(2026, 1, 1)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(metadata.create_all)
|
||||
|
||||
@@ -17,6 +17,7 @@ from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from langbot.pkg.entity.persistence.base import Base
|
||||
from langbot.pkg.persistence import mgr as persistence_mgr # noqa: F401 -- register all ORM tables
|
||||
from langbot.pkg.persistence.alembic_runner import (
|
||||
run_alembic_downgrade,
|
||||
run_alembic_upgrade,
|
||||
@@ -108,7 +109,6 @@ class TestSQLiteMigrationUpgrade:
|
||||
await run_alembic_upgrade(sqlite_engine, 'head')
|
||||
|
||||
assert await get_alembic_current(sqlite_engine) == _get_script_head()
|
||||
assert _get_script_head() == '0022_codex_credentials'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upgrade_from_reasoning_config_head_to_merged_head(self, sqlite_engine):
|
||||
@@ -119,7 +119,7 @@ class TestSQLiteMigrationUpgrade:
|
||||
await run_alembic_stamp(sqlite_engine, '0018_llm_reasoning_config')
|
||||
await run_alembic_upgrade(sqlite_engine, 'head')
|
||||
|
||||
assert await get_alembic_current(sqlite_engine) == '0022_codex_credentials'
|
||||
assert await get_alembic_current(sqlite_engine) == _get_script_head()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upgrade_from_baseline_to_head(self, sqlite_engine):
|
||||
@@ -280,6 +280,15 @@ class TestSQLiteMigrationUpgrade:
|
||||
class TestSQLiteMigrationFreshDatabase:
|
||||
"""Tests for fresh database workflow."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bot_scoped_sessions_skips_absent_table(self, sqlite_engine):
|
||||
"""A partial schema needs no session key migration in either direction."""
|
||||
await run_alembic_stamp(sqlite_engine, '0022_codex_credentials')
|
||||
await run_alembic_upgrade(sqlite_engine, '0023_bot_scoped_sessions')
|
||||
assert await get_alembic_current(sqlite_engine) == '0023_bot_scoped_sessions'
|
||||
await run_alembic_downgrade(sqlite_engine, '0022_codex_credentials')
|
||||
assert await get_alembic_current(sqlite_engine) == '0022_codex_credentials'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fresh_db_upgrade_from_scratch(self, tmp_path):
|
||||
"""
|
||||
|
||||
@@ -550,11 +550,13 @@ class TestPostgreSQLWorkspaceMigration:
|
||||
)
|
||||
assert 'workspaces' not in tables_before_migration
|
||||
assert 'codex_credentials' not in tables_before_migration
|
||||
assert 'passkey_credentials' not in tables_before_migration
|
||||
|
||||
await manager._initialize_managed_schema()
|
||||
|
||||
async with postgres_engine.connect() as conn:
|
||||
assert 'codex_credentials' in await conn.run_sync(lambda sync: sa.inspect(sync).get_table_names())
|
||||
assert 'passkey_credentials' in await conn.run_sync(lambda sync: sa.inspect(sync).get_table_names())
|
||||
account = (await conn.execute(text('SELECT uuid, status, source FROM users'))).mappings().one()
|
||||
workspace = (
|
||||
(await conn.execute(text('SELECT * FROM workspaces WHERE source = :source'), {'source': 'local'}))
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
"""Monitoring regressions through asyncpg, Cloud UoW guards, and migrated RLS.
|
||||
|
||||
TEST_POSTGRES_URL must identify a disposable PostgreSQL/pgvector test server
|
||||
with permission to create databases and roles. Each run owns a fresh database;
|
||||
no existing tables are dropped. Without that URL these tests are skipped.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from langbot.pkg.api.http.context import ExecutionContext
|
||||
from langbot.pkg.api.http.service.monitoring import MonitoringService
|
||||
from langbot.pkg.entity.persistence import monitoring as models
|
||||
from langbot.pkg.entity.persistence.workspace import Workspace
|
||||
from langbot.pkg.persistence.mgr import PersistenceManager, PersistenceMode
|
||||
from langbot.pkg.persistence.tenant_uow import TenantScopeRequiredError
|
||||
from langbot.pkg.pipeline.monitoring_helper import MonitoringHelper
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.slow, pytest.mark.asyncio(loop_scope='module')]
|
||||
|
||||
WORKSPACE_A = '00000000-0000-0000-0000-00000000000a'
|
||||
WORKSPACE_B = '00000000-0000-0000-0000-00000000000b'
|
||||
RESOURCE = dict(bot_id='same-bot', bot_name='Bot', pipeline_id='same-pipeline', pipeline_name='Pipeline')
|
||||
MONITORING_TABLES = tuple(
|
||||
table for table in models.MonitoringMessage.metadata.sorted_tables if table.name.startswith('monitoring_')
|
||||
)
|
||||
|
||||
|
||||
def _context(workspace_uuid):
|
||||
return ExecutionContext(
|
||||
instance_uuid='monitoring-postgres-test',
|
||||
workspace_uuid=workspace_uuid,
|
||||
placement_generation=1,
|
||||
bot_uuid=RESOURCE['bot_id'],
|
||||
pipeline_uuid=RESOURCE['pipeline_id'],
|
||||
)
|
||||
|
||||
|
||||
def _application(url):
|
||||
return SimpleNamespace(
|
||||
instance_config=SimpleNamespace(
|
||||
data={
|
||||
'database': {
|
||||
'use': 'postgresql',
|
||||
'postgresql': {
|
||||
'host': url.host,
|
||||
'port': url.port,
|
||||
'user': url.username,
|
||||
'password': url.password,
|
||||
'database': url.database,
|
||||
},
|
||||
}
|
||||
}
|
||||
),
|
||||
logger=logging.getLogger('monitoring-postgres-test'),
|
||||
)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope='module', loop_scope='module')
|
||||
async def cloud_database():
|
||||
url = os.environ.get('TEST_POSTGRES_URL')
|
||||
if not url:
|
||||
pytest.skip('TEST_POSTGRES_URL not set')
|
||||
admin_url = sa.engine.make_url(url)
|
||||
admin = create_async_engine(admin_url, isolation_level='AUTOCOMMIT')
|
||||
suffix = uuid.uuid4().hex[:12]
|
||||
database_name = f'lb_monitoring_{suffix}'
|
||||
runtime_role = f'lb_monitoring_{suffix}'
|
||||
password = f'Test{uuid.uuid4().hex}'
|
||||
database_created = role_created = False
|
||||
release_manager = runtime_manager = None
|
||||
quote = admin.dialect.identifier_preparer.quote
|
||||
from langbot.pkg.persistence import mgr as mgr_module
|
||||
from langbot.pkg.persistence.databases.postgresql import PostgreSQLDatabaseManager
|
||||
from langbot.pkg.utils import constants
|
||||
|
||||
with pytest.MonkeyPatch.context() as patch:
|
||||
patch.setattr(mgr_module.database, 'preregistered_managers', [PostgreSQLDatabaseManager])
|
||||
patch.setattr(constants, 'instance_id', 'monitoring-postgres-test')
|
||||
try:
|
||||
async with admin.connect() as conn:
|
||||
await conn.execute(sa.text(f'CREATE DATABASE {quote(database_name)}'))
|
||||
database_created = True
|
||||
await conn.execute(
|
||||
sa.text(f"CREATE ROLE {quote(runtime_role)} LOGIN NOSUPERUSER NOBYPASSRLS PASSWORD '{password}'")
|
||||
)
|
||||
role_created = True
|
||||
release_app = _application(admin_url.set(database=database_name))
|
||||
release_manager = PersistenceManager(release_app, mode=PersistenceMode.RELEASE_MIGRATION)
|
||||
release_app.persistence_mgr = release_manager
|
||||
await release_manager.initialize()
|
||||
async with release_manager.get_db_engine().begin() as conn:
|
||||
for workspace in (WORKSPACE_A, WORKSPACE_B):
|
||||
await conn.execute(
|
||||
sa.insert(Workspace).values(
|
||||
uuid=workspace,
|
||||
instance_uuid='monitoring-postgres-test',
|
||||
name=workspace,
|
||||
slug=workspace,
|
||||
source='cloud_projection',
|
||||
)
|
||||
)
|
||||
tables = release_manager._runtime_business_table_names()
|
||||
quoted_tables = ', '.join(f'public.{quote(name)}' for name in tables)
|
||||
await conn.execute(
|
||||
sa.text(f'GRANT CONNECT ON DATABASE {quote(database_name)} TO {quote(runtime_role)}')
|
||||
)
|
||||
await conn.execute(sa.text(f'GRANT USAGE ON SCHEMA public TO {quote(runtime_role)}'))
|
||||
await conn.execute(
|
||||
sa.text(f'GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE {quoted_tables} TO {quote(runtime_role)}')
|
||||
)
|
||||
await conn.execute(sa.text(f'GRANT SELECT ON public.alembic_version TO {quote(runtime_role)}'))
|
||||
sequences = await release_manager._runtime_business_sequence_names(conn, tables)
|
||||
if sequences:
|
||||
names = ', '.join(f'public.{quote(name)}' for name in sequences)
|
||||
await conn.execute(sa.text(f'GRANT USAGE, SELECT ON SEQUENCE {names} TO {quote(runtime_role)}'))
|
||||
runtime_app = _application(admin_url.set(database=database_name, username=runtime_role, password=password))
|
||||
runtime_manager = PersistenceManager(runtime_app, mode=PersistenceMode.CLOUD_RUNTIME)
|
||||
runtime_app.persistence_mgr = runtime_manager
|
||||
await runtime_manager.initialize()
|
||||
runtime_app.monitoring_service = MonitoringService(runtime_app)
|
||||
yield runtime_app, release_manager.get_db_engine()
|
||||
finally:
|
||||
if runtime_manager is not None:
|
||||
await runtime_manager.shutdown()
|
||||
if release_manager is not None:
|
||||
await release_manager.shutdown()
|
||||
async with admin.connect() as conn:
|
||||
if database_created:
|
||||
await conn.execute(sa.text(f'DROP DATABASE {quote(database_name)} WITH (FORCE)'))
|
||||
if role_created:
|
||||
await conn.execute(sa.text(f'DROP ROLE {quote(runtime_role)}'))
|
||||
await admin.dispose()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(loop_scope='module')
|
||||
async def service(cloud_database):
|
||||
application, admin = cloud_database
|
||||
async with admin.begin() as conn:
|
||||
for table in MONITORING_TABLES:
|
||||
await conn.execute(sa.delete(table))
|
||||
application.instance_config.data.pop('monitoring', None)
|
||||
return application.monitoring_service
|
||||
|
||||
|
||||
async def _read(service, method, context, *args, **kwargs):
|
||||
# HTTP auth binds a tenant scope; exercise that same guard for service reads.
|
||||
async with service.ap.persistence_mgr.tenant_scope(context.workspace_uuid):
|
||||
return await getattr(service, method)(context, *args, **kwargs)
|
||||
|
||||
|
||||
def _query(context, sender_id):
|
||||
return SimpleNamespace(
|
||||
_execution_context=context,
|
||||
launcher_type='person',
|
||||
launcher_id='same-user',
|
||||
sender_id=sender_id,
|
||||
message_chain=SimpleNamespace(model_dump=lambda: [{'type': 'Plain', 'text': 'hello'}]),
|
||||
resp_message_chain=[SimpleNamespace(model_dump=lambda: [{'type': 'Plain', 'text': 'reply'}])],
|
||||
message_event=SimpleNamespace(sender=SimpleNamespace(nickname='Alice')),
|
||||
variables={'public': 'value', '_private': 'hidden'},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('user_id', [123456789, -100123456789, 0, None, '', '00123', ' opaque用户 '])
|
||||
@pytest.mark.parametrize('record_type', ['message', 'session', 'feedback'])
|
||||
async def test_optional_user_ids_round_trip_through_asyncpg(service, user_id, record_type):
|
||||
context = _context(WORKSPACE_A)
|
||||
expected = str(user_id) if isinstance(user_id, int) else user_id
|
||||
if record_type == 'message':
|
||||
record_id = await service.record_message(
|
||||
context,
|
||||
**RESOURCE,
|
||||
message_content='hello',
|
||||
session_id='same-session',
|
||||
user_id=user_id,
|
||||
)
|
||||
details = await _read(service, 'get_message_details', context, record_id)
|
||||
assert details['message']['user_id'] == expected
|
||||
elif record_type == 'session':
|
||||
await service.record_session_start(context, **RESOURCE, session_id='same-session', user_id=user_id)
|
||||
rows, total = await _read(service, 'get_sessions', context)
|
||||
assert total == 1
|
||||
assert rows[0]['user_id'] == expected
|
||||
else:
|
||||
await service.record_feedback(context, feedback_id='same-feedback', feedback_type=1, user_id=user_id)
|
||||
rows, total = await _read(service, 'get_feedback_list', context)
|
||||
assert total == 1
|
||||
assert rows[0]['user_id'] == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize('user_id', [123456789, -100123456789])
|
||||
async def test_query_lifecycle_persists_messages_session_and_llm_link(service, user_id, caplog):
|
||||
context = _context(WORKSPACE_A)
|
||||
query = _query(context, user_id)
|
||||
message_id = await MonitoringHelper.record_query_start(service.ap, query, **RESOURCE)
|
||||
assert message_id, caplog.text
|
||||
await MonitoringHelper.record_llm_call(
|
||||
service.ap,
|
||||
query,
|
||||
**RESOURCE,
|
||||
model_name='model',
|
||||
input_tokens=3,
|
||||
output_tokens=5,
|
||||
duration_ms=25,
|
||||
message_id=message_id,
|
||||
)
|
||||
await MonitoringHelper.record_query_success(service.ap, message_id, query)
|
||||
await MonitoringHelper.record_query_response(service.ap, query, **RESOURCE)
|
||||
rows, total = await _read(service, 'get_messages', context)
|
||||
assert total == 2
|
||||
assert {row['role'] for row in rows} == {'user', 'assistant'}
|
||||
assert {row['user_id'] for row in rows} == {str(user_id)}
|
||||
details = await _read(service, 'get_message_details', context, message_id)
|
||||
assert details['message']['status'] == 'success'
|
||||
assert details['message']['variables'] == '{"public": "value"}'
|
||||
assert details['llm_calls'][0]['message_id'] == message_id
|
||||
assert details['llm_stats']['total_tokens'] == 8
|
||||
sessions, total = await _read(service, 'get_sessions', context)
|
||||
assert total == 1
|
||||
assert sessions[0]['session_id'] == 'person_same-user'
|
||||
assert sessions[0]['user_id'] == str(user_id)
|
||||
assert not [record for record in caplog.records if record.levelno >= logging.ERROR]
|
||||
|
||||
|
||||
@pytest.mark.parametrize('user_id', [123, -123])
|
||||
async def test_query_error_persists_error_message_and_linked_log(service, user_id, caplog):
|
||||
context = _context(WORKSPACE_A)
|
||||
message_id = await MonitoringHelper.record_query_error(
|
||||
service.ap,
|
||||
_query(context, user_id),
|
||||
**RESOURCE,
|
||||
error=ValueError('failed query'),
|
||||
)
|
||||
assert message_id, caplog.text
|
||||
details = await _read(service, 'get_message_details', context, message_id)
|
||||
assert details['message']['user_id'] == str(user_id)
|
||||
assert details['message']['status'] == 'error'
|
||||
assert details['errors'][0]['message_id'] == message_id
|
||||
assert details['errors'][0]['error_type'] == 'ValueError'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('user_id', [True, 1.5, b'123', ['123']])
|
||||
@pytest.mark.parametrize('record_type', ['message', 'session', 'feedback'])
|
||||
async def test_unsupported_user_ids_fail_at_the_write_boundary(service, user_id, record_type):
|
||||
context = _context(WORKSPACE_A)
|
||||
with pytest.raises(TypeError, match='user_id must be a string, integer, or None'):
|
||||
if record_type == 'message':
|
||||
await service.record_message(
|
||||
context,
|
||||
**RESOURCE,
|
||||
message_content='hello',
|
||||
session_id='session',
|
||||
user_id=user_id,
|
||||
)
|
||||
elif record_type == 'session':
|
||||
await service.record_session_start(context, **RESOURCE, session_id='session', user_id=user_id)
|
||||
else:
|
||||
await service.record_feedback(context, feedback_id='feedback', feedback_type=1, user_id=user_id)
|
||||
async with service.ap.persistence_mgr.tenant_scope(WORKSPACE_A):
|
||||
for model in (models.MonitoringMessage, models.MonitoringSession, models.MonitoringFeedback):
|
||||
count = await service.ap.persistence_mgr.execute_async(sa.select(sa.func.count()).select_from(model))
|
||||
assert count.scalar_one() == 0
|
||||
|
||||
|
||||
async def test_session_analysis_aggregates_under_cloud_sql_guard(service):
|
||||
context = _context(WORKSPACE_A)
|
||||
await service.record_session_start(context, **RESOURCE, session_id='same-session')
|
||||
await service.record_message(context, **RESOURCE, session_id='same-session', message_content='hello')
|
||||
result = await _read(service, 'get_session_analysis', context, 'same-session')
|
||||
assert result['found'] is True
|
||||
assert result['message_stats'] == {'total': 1, 'success': 1, 'error': 0, 'pending': 0}
|
||||
assert result['llm_stats']['total_calls'] == 0
|
||||
assert result['tool_stats']['total_calls'] == 0
|
||||
assert result['session_duration_seconds'] == 0
|
||||
|
||||
|
||||
async def test_rls_is_enforced_without_application_workspace_predicates(service, cloud_database):
|
||||
_, admin = cloud_database
|
||||
for workspace in (WORKSPACE_A, WORKSPACE_B):
|
||||
await service.record_message(
|
||||
_context(workspace), **RESOURCE, session_id='same-session', message_content=workspace
|
||||
)
|
||||
async with admin.connect() as conn:
|
||||
states = (
|
||||
await conn.execute(
|
||||
sa.text(
|
||||
'SELECT relname, relrowsecurity, relforcerowsecurity FROM pg_class '
|
||||
"WHERE relname LIKE 'monitoring_%' AND relkind = 'r'"
|
||||
)
|
||||
)
|
||||
).all()
|
||||
assert len(states) == len(MONITORING_TABLES)
|
||||
assert all(enabled and forced for _, enabled, forced in states)
|
||||
engine = service.ap.persistence_mgr.get_db_engine()
|
||||
async with engine.connect() as conn:
|
||||
role = (
|
||||
await conn.execute(sa.text('SELECT rolsuper, rolbypassrls FROM pg_roles WHERE rolname = current_user'))
|
||||
).one()
|
||||
assert role == (False, False)
|
||||
assert (await conn.execute(sa.select(models.MonitoringMessage.id))).all() == []
|
||||
for workspace in (WORKSPACE_A, WORKSPACE_B):
|
||||
async with service.ap.persistence_mgr.tenant_uow(workspace):
|
||||
rows = (
|
||||
await service.ap.persistence_mgr.execute_async(sa.select(models.MonitoringMessage.workspace_uuid))
|
||||
).all()
|
||||
assert rows == [(workspace,)]
|
||||
with pytest.raises(TenantScopeRequiredError):
|
||||
await service.ap.persistence_mgr.execute_async(sa.select(models.MonitoringMessage.id))
|
||||
|
||||
|
||||
async def test_traffic_series_aggregates_all_rows_under_cloud_rls(service):
|
||||
import datetime
|
||||
from langbot.pkg.api.http.service.monitoring_traffic import get_traffic_series
|
||||
|
||||
context = _context(WORKSPACE_A)
|
||||
for workspace, count in ((WORKSPACE_A, 61), (WORKSPACE_B, 2)):
|
||||
async with service.ap.persistence_mgr.tenant_scope(workspace):
|
||||
await service.ap.persistence_mgr.execute_async(
|
||||
sa.insert(models.MonitoringMessage).values(
|
||||
[
|
||||
dict(
|
||||
workspace_uuid=workspace,
|
||||
id=f'{workspace}-m-{i}',
|
||||
**RESOURCE,
|
||||
session_id='shared',
|
||||
message_content='test',
|
||||
status='success',
|
||||
level='info',
|
||||
timestamp=datetime.datetime(2026, 9, 11, 1, 30),
|
||||
)
|
||||
for i in range(count)
|
||||
]
|
||||
)
|
||||
)
|
||||
async with service.ap.persistence_mgr.tenant_uow(WORKSPACE_A):
|
||||
result = await get_traffic_series(
|
||||
service.ap,
|
||||
context,
|
||||
bot_ids=[RESOURCE['bot_id']],
|
||||
start_time=datetime.datetime(2026, 9, 11),
|
||||
end_time=datetime.datetime(2026, 9, 12),
|
||||
)
|
||||
assert result['truncated'] is False
|
||||
assert sum(point['messages'] for point in result['points']) == 61
|
||||
@@ -142,7 +142,7 @@ async def test_legacy_sqlite_resources_are_backfilled_and_contracted(tmp_path):
|
||||
assert pk_columns == {
|
||||
'binary_storages': ('workspace_uuid', 'unique_key'),
|
||||
'plugin_settings': ('workspace_uuid', 'plugin_author', 'plugin_name'),
|
||||
'monitoring_sessions': ('workspace_uuid', 'session_id'),
|
||||
'monitoring_sessions': ('workspace_uuid', 'bot_id', 'session_id'),
|
||||
}
|
||||
|
||||
pipeline_run_foreign_keys = await _inspect(
|
||||
@@ -237,8 +237,10 @@ async def test_sqlite_scoped_keys_allow_cross_workspace_but_reject_same_workspac
|
||||
await conn.execute(
|
||||
sa.text(
|
||||
'INSERT INTO monitoring_sessions '
|
||||
'(workspace_uuid, session_id, bot_id, last_activity, is_active) '
|
||||
"VALUES (:workspace_uuid, 'session-1', 'bot-2', CURRENT_TIMESTAMP, 1)"
|
||||
'(workspace_uuid, session_id, bot_id, bot_name, pipeline_id, pipeline_name, '
|
||||
'start_time, last_activity, message_count, is_active) '
|
||||
"VALUES (:workspace_uuid, 'session-1', 'bot-2', 'bot', 'pipeline-2', 'pipeline', "
|
||||
'CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 1, 1)'
|
||||
),
|
||||
{'workspace_uuid': second_workspace_uuid},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
"""Real Core/SDK protocol regression tests; no subprocesses or external services.
|
||||
|
||||
Run against the intended local SDK (``uv run --no-sync`` after local install).
|
||||
The in-memory transport carries JSON strings through Handler.run on both sides;
|
||||
send_file, envelope validation, base64 decoding and transfer storage are real.
|
||||
Only Core's database/object-storage services, parser dispatch/provider and host
|
||||
sandbox prerequisite probing are doubles. Worker launch/registration is
|
||||
represented by its already-registered state.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from langbot.pkg.plugin.handler import RuntimeConnectionHandler
|
||||
from langbot_plugin.entities.io.actions.enums import CommonAction, LangBotToRuntimeAction, PluginToRuntimeAction
|
||||
from langbot_plugin.entities.io.context import ActionContext, InstallationBinding, PluginWorkerPolicy, RuntimeIdentity
|
||||
from langbot_plugin.runtime.context import RuntimeContext
|
||||
from langbot_plugin.runtime.io.connection import Connection
|
||||
from langbot_plugin.entities.io.errors import ActionCallError, ConnectionClosedError
|
||||
from langbot_plugin.runtime.io.handler import FILE_CHUNK_LENGTH, Handler
|
||||
from langbot_plugin.runtime.io.handlers.control import ControlConnectionHandler
|
||||
from langbot_plugin.runtime.io.handlers.plugin import PluginConnectionHandler
|
||||
from langbot_plugin.runtime.plugin.mgr import PluginManager
|
||||
from langbot_plugin.runtime.security import PLUGIN_FILE_STORAGE_DIR_ENV
|
||||
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
PAYLOAD = bytes(range(256)) * 161 + b'\x00original RAG file\xff'
|
||||
BINDING = InstallationBinding(
|
||||
instance_uuid='instance-a',
|
||||
workspace_uuid='workspace-a',
|
||||
placement_generation=7,
|
||||
installation_uuid='00000000-0000-4000-8000-000000000001',
|
||||
runtime_revision=3,
|
||||
artifact_digest='a' * 64,
|
||||
)
|
||||
LEGACY = ActionContext(**BINDING.model_dump(exclude={'runtime_revision', 'artifact_digest'}))
|
||||
|
||||
|
||||
class QueueConnection(Connection):
|
||||
"""Only the byte transport is replaced, not the request/response machinery."""
|
||||
|
||||
def __init__(self):
|
||||
self.incoming = asyncio.Queue()
|
||||
self.sent = []
|
||||
self.peer = None
|
||||
|
||||
async def send(self, message: str) -> None:
|
||||
assert isinstance(message, str)
|
||||
self.sent.append(json.loads(message))
|
||||
await self.peer.incoming.put(message)
|
||||
|
||||
async def receive(self) -> str:
|
||||
message = await self.incoming.get()
|
||||
if message is None:
|
||||
raise ConnectionClosedError('test transport closed')
|
||||
return message
|
||||
|
||||
async def close(self) -> None:
|
||||
await self.incoming.put(None)
|
||||
await self.peer.incoming.put(None)
|
||||
|
||||
|
||||
def connection_pair():
|
||||
left, right = QueueConnection(), QueueConnection()
|
||||
left.peer, right.peer = right, left
|
||||
return left, right
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def protocol_stack(tmp_path, monkeypatch, profile='oss_dev', binding=LEGACY):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
stored = tmp_path / 'original.bin'
|
||||
stored.write_bytes(PAYLOAD)
|
||||
storage_calls = []
|
||||
|
||||
async def get_file_stream(execution_context, storage_path):
|
||||
storage_calls.append((execution_context, storage_path))
|
||||
assert execution_context.workspace_uuid == BINDING.workspace_uuid
|
||||
assert storage_path == 'knowledge/original.bin'
|
||||
return stored.read_bytes()
|
||||
|
||||
async def get_execution_binding(workspace_uuid, expected_generation):
|
||||
assert workspace_uuid == BINDING.workspace_uuid
|
||||
assert expected_generation == BINDING.placement_generation
|
||||
return BINDING
|
||||
|
||||
setting = SimpleNamespace(
|
||||
plugin_author='tester',
|
||||
plugin_name='engine',
|
||||
installation_uuid=BINDING.installation_uuid,
|
||||
runtime_revision=BINDING.runtime_revision,
|
||||
artifact_digest=BINDING.artifact_digest,
|
||||
)
|
||||
app = SimpleNamespace(
|
||||
deployment=SimpleNamespace(mode='oss' if profile == 'oss_dev' else 'cloud'),
|
||||
logger=logging.getLogger(__name__),
|
||||
persistence_mgr=SimpleNamespace(execute_async=AsyncMock(return_value=SimpleNamespace(first=lambda: setting))),
|
||||
workspace_service=SimpleNamespace(get_execution_binding=get_execution_binding),
|
||||
rag_runtime_service=SimpleNamespace(get_file_stream=get_file_stream),
|
||||
)
|
||||
core_conn, control_conn = connection_pair()
|
||||
monkeypatch.setenv(PLUGIN_FILE_STORAGE_DIR_ENV, str(tmp_path / 'core-transfer'))
|
||||
core = RuntimeConnectionHandler(core_conn, AsyncMock(return_value=False), app)
|
||||
core.register_installation_binding(BINDING, plugin_author='tester', plugin_name='engine')
|
||||
runtime = RuntimeContext()
|
||||
runtime.plugin_mgr = PluginManager(runtime)
|
||||
# No worker is launched: omit only host nsjail/cgroup prerequisite probing.
|
||||
monkeypatch.setattr(runtime.plugin_mgr.worker_launcher, 'configure', lambda policy, profile: None)
|
||||
monkeypatch.setenv(PLUGIN_FILE_STORAGE_DIR_ENV, str(tmp_path / 'runtime-transfer'))
|
||||
control = ControlConnectionHandler(control_conn, runtime)
|
||||
runtime.activate_control_handler(control)
|
||||
bridge_conn, plugin_conn = connection_pair()
|
||||
bridge = PluginConnectionHandler(bridge_conn, runtime, file_storage_dir=str(tmp_path / 'bridge-transfer'))
|
||||
plugin = Handler(plugin_conn, file_storage_dir=str(tmp_path / 'plugin-transfer'))
|
||||
# Trusted state left by registration, not plugin-supplied action data.
|
||||
bridge.bind_action_context(binding)
|
||||
runtime.plugin_mgr.plugin_handlers.append(bridge)
|
||||
runtime.plugin_mgr.plugins.append(SimpleNamespace(_runtime_plugin_handler=bridge))
|
||||
handlers = [core, control, bridge, plugin]
|
||||
tasks = [asyncio.create_task(handler.run()) for handler in handlers]
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
core.set_runtime_config(
|
||||
runtime_identity=RuntimeIdentity(instance_uuid='instance-a', runtime_id='test-runtime'),
|
||||
worker_policy=PluginWorkerPolicy(
|
||||
max_cpus=1,
|
||||
max_memory_mb=128,
|
||||
max_pids=32,
|
||||
max_open_files=64,
|
||||
max_file_size_mb=8,
|
||||
require_hard_limits=False,
|
||||
),
|
||||
runtime_profile=profile,
|
||||
cloud_service_url=None,
|
||||
),
|
||||
5,
|
||||
)
|
||||
if isinstance(binding, InstallationBinding):
|
||||
runtime.activate_installation_binding(binding)
|
||||
else:
|
||||
runtime.bind_workspace(binding)
|
||||
yield SimpleNamespace(
|
||||
core=core,
|
||||
control=control,
|
||||
runtime=runtime,
|
||||
bridge=bridge,
|
||||
plugin=plugin,
|
||||
core_conn=core_conn,
|
||||
control_conn=control_conn,
|
||||
bridge_conn=bridge_conn,
|
||||
plugin_conn=plugin_conn,
|
||||
app=app,
|
||||
storage_calls=storage_calls,
|
||||
)
|
||||
finally:
|
||||
for handler in handlers:
|
||||
await handler.close()
|
||||
await asyncio.wait_for(asyncio.gather(*tasks, return_exceptions=True), 5)
|
||||
|
||||
|
||||
def assert_chunks(connection, binding, payload=PAYLOAD):
|
||||
chunks = [message for message in connection.sent if message.get('action') == CommonAction.FILE_CHUNK.value]
|
||||
expected = (len(payload) + FILE_CHUNK_LENGTH - 1) // FILE_CHUNK_LENGTH
|
||||
assert expected > 1
|
||||
assert len(chunks) == expected
|
||||
assert [chunk['data']['chunk_index'] for chunk in chunks] == list(range(expected))
|
||||
assert {chunk['data']['chunk_amount'] for chunk in chunks} == {expected}
|
||||
assert all(chunk['context'] == binding.model_dump() for chunk in chunks)
|
||||
assert len({chunk['data']['file_key'] for chunk in chunks}) == 1
|
||||
return chunks[0]['data']['file_key']
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'profile,binding',
|
||||
[('oss_dev', LEGACY), ('oss_dev', BINDING), ('shared', BINDING)],
|
||||
ids=['legacy-oss', 'managed-oss', 'managed-shared'],
|
||||
)
|
||||
async def test_knowledge_file_roundtrip_reaches_plugin_original_bytes(tmp_path, monkeypatch, profile, binding):
|
||||
async with protocol_stack(tmp_path, monkeypatch, profile, binding) as stack:
|
||||
# Legacy plugin API sends no authority; Runtime supplies its trusted binding.
|
||||
result = await asyncio.wait_for(
|
||||
stack.plugin.call_action(
|
||||
PluginToRuntimeAction.GET_KNOWLEDEGE_FILE_STREAM,
|
||||
{'storage_path': 'knowledge/original.bin'},
|
||||
),
|
||||
5,
|
||||
)
|
||||
assert await stack.plugin.read_local_file(result['file_key']) == PAYLOAD
|
||||
assert len(stack.storage_calls) == 1
|
||||
core_key = assert_chunks(stack.core_conn, binding)
|
||||
plugin_key = assert_chunks(stack.bridge_conn, binding)
|
||||
assert result['file_key'] == plugin_key != core_key
|
||||
assert not (Path(stack.control.file_storage_dir) / core_key).exists()
|
||||
assert not stack.control._owned_transfer_files
|
||||
callbacks = [
|
||||
message
|
||||
for message in stack.control_conn.sent
|
||||
if message.get('action') == PluginToRuntimeAction.GET_KNOWLEDEGE_FILE_STREAM.value
|
||||
]
|
||||
assert len(callbacks) == 1
|
||||
assert callbacks[0]['context'] == binding.model_dump()
|
||||
assert callbacks[0]['data'] == {'storage_path': 'knowledge/original.bin'}
|
||||
|
||||
|
||||
async def test_shared_control_rejects_legacy_chunks_before_storage(tmp_path, monkeypatch):
|
||||
async with protocol_stack(tmp_path, monkeypatch, 'shared', BINDING) as stack:
|
||||
with stack.core.installation_scope(LEGACY):
|
||||
with pytest.raises(ActionCallError, match='InstallationBinding|Legacy FILE_CHUNK'):
|
||||
await asyncio.wait_for(stack.core.send_file(PAYLOAD, ''), 5)
|
||||
assert not list(Path(stack.control.file_storage_dir).iterdir())
|
||||
assert not stack.control._owned_transfer_files
|
||||
|
||||
|
||||
async def test_candidate_artifact_pretransfer_does_not_require_active_installation(tmp_path, monkeypatch):
|
||||
async with protocol_stack(tmp_path, monkeypatch, 'shared', BINDING) as stack:
|
||||
candidate = BINDING.model_copy(
|
||||
update={'installation_uuid': 'candidate-installation', 'runtime_revision': 1, 'artifact_digest': 'c' * 64}
|
||||
)
|
||||
assert not stack.runtime.is_current_installation_binding(candidate)
|
||||
with stack.core.installation_scope(candidate):
|
||||
key = await asyncio.wait_for(stack.core.send_file(PAYLOAD, 'lbp'), 5)
|
||||
assert_chunks(stack.core_conn, candidate)
|
||||
assert await stack.control.read_local_file(key) == PAYLOAD
|
||||
assert not stack.runtime.is_current_installation_binding(candidate)
|
||||
|
||||
|
||||
async def test_nested_parser_target_owns_file_and_action_envelopes(tmp_path, monkeypatch):
|
||||
async with protocol_stack(tmp_path, monkeypatch, 'shared', BINDING) as stack:
|
||||
target = BINDING.model_copy(
|
||||
update={
|
||||
'installation_uuid': 'parser-installation',
|
||||
'runtime_revision': 2,
|
||||
'artifact_digest': 'b' * 64,
|
||||
}
|
||||
)
|
||||
stack.runtime.activate_installation_binding(target)
|
||||
parser_calls = []
|
||||
restored = []
|
||||
|
||||
async def parse_document(author, name, context_data, file_bytes):
|
||||
parser_calls.append((stack.control.current_action_context, author, name, context_data, file_bytes))
|
||||
return {'documents': [{'text': 'parsed'}]}
|
||||
|
||||
stack.runtime.plugin_mgr.parse_document = parse_document
|
||||
|
||||
class ParserConnector:
|
||||
async def require_workspace_context(self, context):
|
||||
assert context.workspace_uuid == BINDING.workspace_uuid
|
||||
|
||||
async def call_parser(self, plugin_name, context_data, file_bytes):
|
||||
assert plugin_name == 'tester/parser'
|
||||
assert stack.core.current_action_context == BINDING
|
||||
with stack.core.installation_scope(target):
|
||||
result = await stack.core.parse_document('tester', 'parser', context_data, file_bytes)
|
||||
restored.append(stack.core.resolve_outbound_action_context(None))
|
||||
return result
|
||||
|
||||
stack.app.plugin_connector = ParserConnector()
|
||||
result = await asyncio.wait_for(
|
||||
stack.plugin.call_action(
|
||||
PluginToRuntimeAction.INVOKE_PARSER,
|
||||
{
|
||||
'plugin_author': 'tester',
|
||||
'plugin_name': 'parser',
|
||||
'storage_path': 'knowledge/original.bin',
|
||||
'filename': 'original.bin',
|
||||
},
|
||||
),
|
||||
5,
|
||||
)
|
||||
assert result == {'documents': [{'text': 'parsed'}]}
|
||||
key = assert_chunks(stack.core_conn, target)
|
||||
parse_requests = [
|
||||
message
|
||||
for message in stack.core_conn.sent
|
||||
if message.get('action') == LangBotToRuntimeAction.PARSE_DOCUMENT.value
|
||||
]
|
||||
assert len(parse_requests) == 1
|
||||
assert parse_requests[0]['context'] == target.model_dump()
|
||||
assert parse_requests[0]['data']['context']['file_key'] == key
|
||||
assert parser_calls == [
|
||||
(
|
||||
target,
|
||||
'tester',
|
||||
'parser',
|
||||
{
|
||||
'mime_type': 'application/octet-stream',
|
||||
'filename': 'original.bin',
|
||||
'metadata': {},
|
||||
},
|
||||
PAYLOAD,
|
||||
)
|
||||
]
|
||||
assert restored == [BINDING]
|
||||
assert stack.core.current_action_context is None
|
||||
assert stack.core.resolve_outbound_action_context(None) is None
|
||||
assert not (Path(stack.control.file_storage_dir) / key).exists()
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Identifier normalization must not rely on SQLite's permissive codecs."""
|
||||
|
||||
import pytest
|
||||
|
||||
from langbot.pkg.api.http.service import monitoring
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
('value', 'expected'),
|
||||
[(None, None), ('', ''), ('00123', '00123'), (' 用户 ', ' 用户 '), (123, '123'), (-123, '-123'), (0, '0')],
|
||||
)
|
||||
def test_normalize_user_id_preserves_opaque_strings(value, expected):
|
||||
assert monitoring._normalize_user_id(value) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize('value', [True, False, 1.5, b'123', ['123'], {'id': 123}])
|
||||
def test_normalize_user_id_rejects_unsupported_types(value):
|
||||
with pytest.raises(TypeError, match='user_id must be a string, integer, or None'):
|
||||
monitoring._normalize_user_id(value)
|
||||
@@ -0,0 +1,220 @@
|
||||
"""Bot-scoped session regressions exercised against real SQL databases."""
|
||||
|
||||
import datetime as dt
|
||||
import logging
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from langbot.pkg.api.http.context import ExecutionContext
|
||||
from langbot.pkg.api.http.service.monitoring import MonitoringService
|
||||
from langbot.pkg.entity.persistence.base import Base
|
||||
from langbot.pkg.entity.persistence import monitoring as models
|
||||
from langbot.pkg.persistence.mgr import PersistenceManager
|
||||
from langbot.pkg.pipeline.monitoring_helper import MonitoringHelper
|
||||
|
||||
from tests.integration.persistence.test_monitoring_postgres import cloud_database # noqa: F401
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope='module')
|
||||
async def test_postgres_upgrade_rls_and_concurrent_bot_counts(cloud_database): # noqa: F811
|
||||
import asyncio
|
||||
import importlib
|
||||
from alembic.migration import MigrationContext
|
||||
from alembic.operations import Operations
|
||||
from tests.integration.persistence.test_monitoring_postgres import WORKSPACE_A, _context, _read
|
||||
|
||||
ap, admin = cloud_database
|
||||
service = ap.monitoring_service
|
||||
ctx = _context(WORKSPACE_A)
|
||||
await service.record_session_start(ctx, session_id='person_42', **resource('a'))
|
||||
for bot in ['a', 'b']:
|
||||
await service.record_message(ctx, session_id='person_42', message_content=bot, **resource(bot))
|
||||
async with admin.begin() as conn:
|
||||
|
||||
def migrate(connection):
|
||||
migration = importlib.import_module('langbot.pkg.persistence.alembic.versions.0023_bot_scoped_sessions')
|
||||
with Operations.context(MigrationContext.configure(connection)):
|
||||
migration.downgrade()
|
||||
migration.upgrade()
|
||||
rls = connection.execute(
|
||||
sa.text("SELECT relrowsecurity, relforcerowsecurity FROM pg_class WHERE relname='monitoring_sessions'")
|
||||
).one()
|
||||
assert tuple(rls) == (True, True)
|
||||
assert (
|
||||
connection.execute(
|
||||
sa.text("SELECT count(*) FROM pg_policies WHERE tablename='monitoring_sessions'")
|
||||
).scalar_one()
|
||||
== 1
|
||||
)
|
||||
|
||||
await conn.run_sync(migrate)
|
||||
rows, total = await _read(service, 'get_sessions', ctx)
|
||||
assert total == 2
|
||||
assert {r['bot_id']: r['message_count'] for r in rows} == {'a': 1, 'b': 1}
|
||||
await asyncio.gather(*[service.record_session_start(ctx, session_id='race', **resource('a')) for _ in range(10)])
|
||||
result = await _read(service, 'get_session_analysis', ctx, 'race', bot_id='a')
|
||||
assert result['session']['message_count'] == 10
|
||||
assert not (await _read(service, 'get_session_analysis', ctx, 'person_42'))['found']
|
||||
assert (await _read(service, 'get_session_analysis', ctx, 'person_42', bot_id='b'))['message_stats']['total'] == 1
|
||||
|
||||
|
||||
async def test_migration_reconstructs_collisions_and_preserves_indexes(service):
|
||||
import importlib
|
||||
from alembic.migration import MigrationContext
|
||||
from alembic.operations import Operations
|
||||
|
||||
engine = service.ap.persistence_mgr.get_db_engine()
|
||||
async with engine.begin() as conn:
|
||||
|
||||
def upgrade(connection):
|
||||
table = models.MonitoringSession.__table__
|
||||
table.drop(connection)
|
||||
metadata = sa.MetaData()
|
||||
legacy = table.to_metadata(metadata)
|
||||
legacy.primary_key._columns.remove(legacy.c.bot_id)
|
||||
legacy.c.bot_id.primary_key = False
|
||||
# Resolve the unchanged Workspace FK in copied metadata.
|
||||
Base.metadata.tables['workspaces'].to_metadata(metadata)
|
||||
legacy.create(connection)
|
||||
now = dt.datetime(2026, 1, 1)
|
||||
connection.execute(
|
||||
sa.insert(legacy).values(
|
||||
workspace_uuid='workspace',
|
||||
session_id='person_42',
|
||||
**resource('a'),
|
||||
message_count=99,
|
||||
start_time=now,
|
||||
last_activity=now,
|
||||
is_active=True,
|
||||
)
|
||||
)
|
||||
for bot in ['a', 'b']:
|
||||
connection.execute(
|
||||
sa.insert(models.MonitoringMessage).values(
|
||||
id=bot,
|
||||
workspace_uuid='workspace',
|
||||
timestamp=now,
|
||||
**resource(bot),
|
||||
session_id='person_42',
|
||||
message_content=bot,
|
||||
role='user',
|
||||
status='success',
|
||||
level='info',
|
||||
)
|
||||
)
|
||||
indexes = {i['name'] for i in sa.inspect(connection).get_indexes('monitoring_sessions')}
|
||||
migration = importlib.import_module('langbot.pkg.persistence.alembic.versions.0023_bot_scoped_sessions')
|
||||
with Operations.context(MigrationContext.configure(connection)):
|
||||
migration.upgrade()
|
||||
migration.upgrade() # Fresh/already-upgraded schema is safe.
|
||||
assert sa.inspect(connection).get_pk_constraint('monitoring_sessions')['constrained_columns'] == [
|
||||
'workspace_uuid',
|
||||
'bot_id',
|
||||
'session_id',
|
||||
]
|
||||
assert indexes <= {i['name'] for i in sa.inspect(connection).get_indexes('monitoring_sessions')}
|
||||
|
||||
await conn.run_sync(upgrade)
|
||||
rows, total = await service.get_sessions(context())
|
||||
assert total == 2
|
||||
assert {r['bot_id']: r['message_count'] for r in rows} == {'a': 1, 'b': 1}
|
||||
assert {r['pipeline_id'] for r in rows} == {'a', 'b'}
|
||||
|
||||
|
||||
def context(bot=None):
|
||||
return ExecutionContext(instance_uuid='test', workspace_uuid='workspace', placement_generation=1, bot_uuid=bot)
|
||||
|
||||
|
||||
def resource(bot):
|
||||
return dict(bot_id=bot, bot_name=bot, pipeline_id=bot, pipeline_name=bot)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def service():
|
||||
engine = create_async_engine('sqlite+aiosqlite:///:memory:')
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
class Persistence:
|
||||
serialize_model = PersistenceManager.serialize_model
|
||||
|
||||
def get_db_engine(self):
|
||||
return engine
|
||||
|
||||
async def execute_async(self, stmt):
|
||||
async with engine.begin() as conn:
|
||||
return await conn.execute(stmt)
|
||||
|
||||
ap = SimpleNamespace(persistence_mgr=Persistence(), logger=logging.getLogger(__name__))
|
||||
ap.monitoring_service = MonitoringService(ap)
|
||||
yield ap.monitoring_service
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_helper_first_message_count_and_two_bot_isolation(service):
|
||||
for bot in ['a', 'b', 'a']:
|
||||
query = SimpleNamespace(
|
||||
_execution_context=context(bot),
|
||||
launcher_type='person',
|
||||
launcher_id=42,
|
||||
sender_id=42,
|
||||
message_chain=SimpleNamespace(model_dump=lambda: []),
|
||||
)
|
||||
assert await MonitoringHelper.record_query_start(service.ap, query, **resource(bot))
|
||||
rows, total = await service.get_sessions(context())
|
||||
assert total == 2
|
||||
assert {r['bot_id']: r['message_count'] for r in rows} == {'a': 2, 'b': 1}
|
||||
assert {r['pipeline_id'] for r in rows} == {'a', 'b'}
|
||||
assert {r['session_id'] for r in rows} == {'person_42'}
|
||||
|
||||
|
||||
async def test_analysis_fails_closed_and_scopes_statistics(service):
|
||||
for bot in ['a', 'b']:
|
||||
await service.record_session_start(context(bot), session_id='person_42', **resource(bot))
|
||||
await service.record_message(context(bot), session_id='person_42', message_content=bot, **resource(bot))
|
||||
assert (await service.get_session_analysis(context(), 'person_42'))['found'] is False
|
||||
result = await service.get_session_analysis(context(), 'person_42', bot_id='b')
|
||||
assert result['message_stats']['total'] == 1
|
||||
assert result['session']['bot_id'] == 'b'
|
||||
|
||||
|
||||
async def test_activity_requires_bot_and_upsert_counts_racing_first_queries(service):
|
||||
for _ in range(2):
|
||||
await service.record_session_start(context('a'), session_id='person_42', **resource('a'))
|
||||
with pytest.raises(ValueError, match='bot'):
|
||||
await service.update_session_activity(context(), 'person_42')
|
||||
assert await service.update_session_activity(context('a'), 'person_42')
|
||||
assert not await service.update_session_activity(context('b'), 'person_42')
|
||||
rows, _ = await service.get_sessions(context())
|
||||
assert rows[0]['message_count'] == 3
|
||||
|
||||
|
||||
async def test_old_active_sessions_are_listed_exported_and_not_cleaned(service):
|
||||
for bot in ['a', 'b']:
|
||||
await service.record_session_start(context(bot), session_id='person_42', **resource(bot))
|
||||
old = dt.datetime(2000, 1, 1)
|
||||
await service.ap.persistence_mgr.execute_async(sa.update(models.MonitoringSession).values(start_time=old))
|
||||
await service.ap.persistence_mgr.execute_async(
|
||||
sa.update(models.MonitoringSession).where(models.MonitoringSession.bot_id == 'a').values(last_activity=old)
|
||||
)
|
||||
since = dt.datetime.now(dt.timezone.utc).replace(tzinfo=None) - dt.timedelta(days=1)
|
||||
rows, total = await service.get_sessions(context(), start_time=since)
|
||||
assert total == 1 and rows[0]['bot_id'] == 'b'
|
||||
assert len(await service.export_sessions(context(), start_time=since)) == 1
|
||||
count = await service._delete_expired_in_batches(
|
||||
context(),
|
||||
models.MonitoringSession,
|
||||
models.MonitoringSession.last_activity,
|
||||
models.MonitoringSession.session_id,
|
||||
since,
|
||||
1,
|
||||
2,
|
||||
)
|
||||
assert count == 1
|
||||
rows, total = await service.get_sessions(context())
|
||||
assert total == 1 and rows[0]['bot_id'] == 'b'
|
||||
@@ -0,0 +1,125 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import sqlalchemy
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from langbot.pkg.api.http.context import ExecutionContext
|
||||
from langbot.pkg.entity.persistence.base import Base
|
||||
from langbot.pkg.entity.persistence.monitoring import MonitoringLLMCall, MonitoringMessage
|
||||
from langbot.pkg.entity.persistence.workspace import Workspace
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
A = '00000000-0000-0000-0000-00000000000a'
|
||||
B = '00000000-0000-0000-0000-00000000000b'
|
||||
START = datetime.datetime(2026, 1, 1)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def traffic_app():
|
||||
engine = create_async_engine('sqlite+aiosqlite:///:memory:')
|
||||
async with engine.begin() as connection:
|
||||
await connection.run_sync(Base.metadata.create_all)
|
||||
await connection.execute(
|
||||
sqlalchemy.insert(Workspace),
|
||||
[
|
||||
{'uuid': wid, 'instance_uuid': 'instance', 'name': wid, 'slug': wid, 'source': 'cloud_projection'}
|
||||
for wid in (A, B)
|
||||
],
|
||||
)
|
||||
for wid, bot, count in [(A, 'bot-a', 60), (A, 'bot-b', 7), (B, 'bot-a', 9)]:
|
||||
common = {
|
||||
'workspace_uuid': wid,
|
||||
'timestamp': START,
|
||||
'bot_id': bot,
|
||||
'bot_name': bot,
|
||||
'pipeline_id': 'pipeline',
|
||||
'pipeline_name': 'Pipeline',
|
||||
'session_id': 'person_42',
|
||||
'status': 'success',
|
||||
}
|
||||
await connection.execute(
|
||||
sqlalchemy.insert(MonitoringMessage),
|
||||
[
|
||||
dict(common, id=f'{wid}-{bot}-{i}', message_content='test fixture', level='info', role='user')
|
||||
for i in range(count)
|
||||
],
|
||||
)
|
||||
await connection.execute(
|
||||
sqlalchemy.insert(MonitoringLLMCall),
|
||||
[
|
||||
dict(
|
||||
common,
|
||||
id=f'{wid}-{bot}-{i}',
|
||||
model_name='fixture-model',
|
||||
input_tokens=1,
|
||||
output_tokens=1,
|
||||
total_tokens=2,
|
||||
duration=1,
|
||||
)
|
||||
for i in range(count)
|
||||
],
|
||||
)
|
||||
|
||||
class Persistence:
|
||||
def get_db_engine(self):
|
||||
return engine
|
||||
|
||||
async def execute_async(self, statement):
|
||||
async with engine.connect() as connection:
|
||||
return await connection.execute(statement)
|
||||
|
||||
yield SimpleNamespace(persistence_mgr=Persistence())
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_traffic_counts_all_rows_not_just_latest_page(traffic_app):
|
||||
from langbot.pkg.api.http.service.monitoring_traffic import get_traffic_series
|
||||
|
||||
context = ExecutionContext(instance_uuid='instance', workspace_uuid=A, placement_generation=1)
|
||||
result = await get_traffic_series(
|
||||
traffic_app, context, bot_ids=['bot-a'], start_time=START, end_time=START + datetime.timedelta(hours=2)
|
||||
)
|
||||
assert result['bucket'] == 'hour'
|
||||
assert result['truncated'] is False
|
||||
assert sum(point['messages'] for point in result['points']) == 60
|
||||
assert sum(point['llm_calls'] for point in result['points']) == 60
|
||||
assert len(result['points']) == 3
|
||||
assert result['points'][1]['messages'] == result['points'][1]['llm_calls'] == 0
|
||||
assert result['points'][0]['timestamp'] == '2026-01-01T00:00:00Z'
|
||||
|
||||
|
||||
async def test_traffic_workspace_pipeline_and_empty_filters(traffic_app):
|
||||
from langbot.pkg.api.http.service.monitoring_traffic import get_traffic_series
|
||||
|
||||
context = ExecutionContext(instance_uuid='instance', workspace_uuid=B, placement_generation=1)
|
||||
kwargs = dict(start_time=START, end_time=START + datetime.timedelta(hours=2))
|
||||
result = await get_traffic_series(traffic_app, context, **kwargs)
|
||||
assert sum(point['messages'] for point in result['points']) == 9
|
||||
empty = await get_traffic_series(traffic_app, context, pipeline_ids=['missing'], **kwargs)
|
||||
assert sum(point['messages'] for point in empty['points']) == 0
|
||||
assert sum(point['llm_calls'] for point in empty['points']) == 0
|
||||
|
||||
|
||||
async def test_traffic_bounds_large_ranges_and_marks_truncation(traffic_app):
|
||||
from langbot.pkg.api.http.service.monitoring_traffic import get_traffic_series
|
||||
|
||||
context = ExecutionContext(instance_uuid='instance', workspace_uuid=A, placement_generation=1)
|
||||
result = await get_traffic_series(
|
||||
traffic_app, context, start_time=START, end_time=START + datetime.timedelta(days=5000)
|
||||
)
|
||||
assert result['bucket'] == 'day'
|
||||
assert result['truncated'] is True
|
||||
assert len(result['points']) == 1000
|
||||
|
||||
|
||||
async def test_traffic_fails_closed_without_workspace(traffic_app):
|
||||
from langbot.pkg.api.http.authz import WorkspaceRequiredError
|
||||
from langbot.pkg.api.http.service.monitoring_traffic import get_traffic_series
|
||||
|
||||
with pytest.raises(WorkspaceRequiredError):
|
||||
await get_traffic_series(traffic_app, None)
|
||||
@@ -0,0 +1,103 @@
|
||||
"""
|
||||
Unit tests for Passkey WebAuthn service operations in UserService.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from langbot.pkg.api.http.service.user import UserService
|
||||
from langbot.pkg.entity.persistence.user import AccountStatus, User
|
||||
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
class TestPasskeyChallengeLifecycle:
|
||||
async def test_challenge_issuance_and_consumption(self):
|
||||
service = UserService(SimpleNamespace())
|
||||
token, challenge_bytes = await service.issue_passkey_challenge(
|
||||
purpose='register',
|
||||
rp_id='localhost',
|
||||
origin='http://localhost:3000',
|
||||
account_uuid='acc-123',
|
||||
user_email='user@example.com',
|
||||
)
|
||||
|
||||
assert len(token) > 20
|
||||
assert len(challenge_bytes) == 32
|
||||
|
||||
data = await service.consume_passkey_challenge(token, 'register')
|
||||
assert data.challenge == challenge_bytes
|
||||
assert data.rp_id == 'localhost'
|
||||
assert data.origin == 'http://localhost:3000'
|
||||
assert data.account_uuid == 'acc-123'
|
||||
assert data.user_email == 'user@example.com'
|
||||
|
||||
# Replay should fail
|
||||
with pytest.raises(ValueError, match='Invalid or expired passkey challenge'):
|
||||
await service.consume_passkey_challenge(token, 'register')
|
||||
|
||||
async def test_challenge_purpose_mismatch_fails(self):
|
||||
service = UserService(SimpleNamespace())
|
||||
token, _ = await service.issue_passkey_challenge(
|
||||
purpose='register',
|
||||
rp_id='localhost',
|
||||
origin='http://localhost:3000',
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match='Passkey challenge purpose mismatch'):
|
||||
await service.consume_passkey_challenge(token, 'auth')
|
||||
|
||||
async def test_challenge_expiration(self):
|
||||
service = UserService(SimpleNamespace())
|
||||
token, _ = await service.issue_passkey_challenge(
|
||||
purpose='auth',
|
||||
rp_id='localhost',
|
||||
origin='http://localhost:3000',
|
||||
ttl_seconds=0,
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match='Invalid or expired passkey challenge'):
|
||||
await service.consume_passkey_challenge(token, 'auth')
|
||||
|
||||
|
||||
class TestPasskeyOptionsGeneration:
|
||||
async def test_generate_registration_options(self):
|
||||
service = UserService(SimpleNamespace())
|
||||
mock_user = Mock(spec=User)
|
||||
mock_user.uuid = 'acc-test-uuid'
|
||||
mock_user.user = 'test@example.com'
|
||||
mock_user.status = AccountStatus.ACTIVE.value
|
||||
service.get_user_by_uuid = AsyncMock(return_value=mock_user)
|
||||
service.get_user_passkeys = AsyncMock(return_value=[])
|
||||
|
||||
options, token = await service.generate_passkey_registration_options(
|
||||
account_uuid='acc-test-uuid',
|
||||
rp_id='localhost',
|
||||
origin='http://localhost:3000',
|
||||
rp_name='LangBot Test',
|
||||
)
|
||||
|
||||
assert isinstance(options, dict)
|
||||
assert options['rp']['name'] == 'LangBot Test'
|
||||
assert options['rp']['id'] == 'localhost'
|
||||
assert options['user']['name'] == 'test@example.com'
|
||||
assert 'challenge' in options
|
||||
assert len(token) > 0
|
||||
|
||||
async def test_generate_authentication_options_discoverable(self):
|
||||
service = UserService(SimpleNamespace())
|
||||
|
||||
options, token = await service.generate_passkey_authentication_options(
|
||||
rp_id='localhost',
|
||||
origin='http://localhost:3000',
|
||||
)
|
||||
|
||||
assert isinstance(options, dict)
|
||||
assert options['rpId'] == 'localhost'
|
||||
assert 'challenge' in options
|
||||
assert len(token) > 0
|
||||
@@ -958,6 +958,8 @@ async def test_scoped_session_rejects_raw_or_unapproved_sql(
|
||||
[
|
||||
sa.select(sa.literal('set_config(')),
|
||||
sa.select(sa.func.count()),
|
||||
sa.select(sa.func.min(sa.column('timestamp'))),
|
||||
sa.select(sa.func.max(sa.column('timestamp'))),
|
||||
sa.select(sa.func.coalesce(sa.func.sum(sa.literal(1)), sa.literal(0))),
|
||||
sa.select(
|
||||
sa.func.now(),
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
"""Exercise nested installation routing through real Core/SDK wire envelopes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from langbot_plugin.entities.io.actions.enums import CommonAction, LangBotToRuntimeAction, PluginToRuntimeAction
|
||||
from langbot_plugin.entities.io.req import ActionRequest
|
||||
from langbot_plugin.entities.io.resp import ActionResponse
|
||||
from langbot_plugin.runtime.io import handler as sdk_handler
|
||||
|
||||
from langbot.pkg.plugin.connector import PluginRuntimeConnector
|
||||
from tests.unit_tests.plugin.test_handler_tenancy import RecordingConnection, make_handler, workspace_context
|
||||
|
||||
|
||||
class ReplyingConnection(RecordingConnection):
|
||||
"""Replace only the transport, retaining serialization and response routing."""
|
||||
|
||||
async def send(self, message: str) -> None:
|
||||
await super().send(message)
|
||||
request = json.loads(message)
|
||||
if 'action' in request:
|
||||
response = ActionResponse.success({'elements': []})
|
||||
response.seq_id = request['seq_id']
|
||||
await self.handler._route_response(response.seq_id, response.model_dump())
|
||||
|
||||
@property
|
||||
def requests(self):
|
||||
return [request for message in self.sent if 'action' in (request := json.loads(message))]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bridge(monkeypatch):
|
||||
runtime_handler, app, binding_a = make_handler()
|
||||
connection = ReplyingConnection()
|
||||
connection.handler = runtime_handler
|
||||
runtime_handler.conn = connection
|
||||
monkeypatch.setattr(sdk_handler, 'FILE_CHUNK_LENGTH', 4)
|
||||
binding_b = binding_a.model_copy(
|
||||
update={
|
||||
'installation_uuid': '00000000-0000-4000-8000-000000000002',
|
||||
'runtime_revision': 2,
|
||||
'artifact_digest': 'b' * 64,
|
||||
}
|
||||
)
|
||||
return runtime_handler, app, connection, binding_a, binding_b
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('mode', ['managed', 'legacy'])
|
||||
async def test_nested_invoke_parser_uses_target_for_every_chunk_and_parse(bridge, mode):
|
||||
runtime_handler, app, connection, binding_a, binding_b = bridge
|
||||
app.instance_config = SimpleNamespace(data={'plugin': {'enable': True}})
|
||||
app.deployment.mode = 'cloud' if mode == 'managed' else 'oss'
|
||||
connector = PluginRuntimeConnector(app, AsyncMock())
|
||||
connector.handler = runtime_handler
|
||||
app.plugin_connector = connector
|
||||
execution_context = runtime_handler._execution_context(binding_a)
|
||||
setting_b = SimpleNamespace(
|
||||
installation_uuid=binding_b.installation_uuid,
|
||||
runtime_revision=binding_b.runtime_revision,
|
||||
artifact_digest=binding_b.artifact_digest,
|
||||
install_info={'_artifact_storage': 'tenant_binary_storage_v1'} if mode == 'managed' else {},
|
||||
)
|
||||
connector._setting_for_plugin = AsyncMock(return_value=(execution_context, setting_b))
|
||||
connector.require_workspace_context = AsyncMock(return_value=execution_context)
|
||||
file_bytes = b'parser document'
|
||||
app.rag_runtime_service = SimpleNamespace(get_file_stream=AsyncMock(return_value=file_bytes))
|
||||
inbound_context = binding_a
|
||||
if mode == 'legacy':
|
||||
inbound_context = workspace_context().for_installation(binding_a.installation_uuid)
|
||||
setting_a = SimpleNamespace(
|
||||
plugin_author='author-a',
|
||||
plugin_name='plugin-a',
|
||||
installation_uuid=binding_a.installation_uuid,
|
||||
runtime_revision=binding_a.runtime_revision,
|
||||
artifact_digest=binding_a.artifact_digest,
|
||||
)
|
||||
app.persistence_mgr.execute_async.return_value = SimpleNamespace(first=lambda: setting_a)
|
||||
expected = binding_b if mode == 'managed' else connector._legacy_oss_bridge_binding(execution_context)
|
||||
request = ActionRequest.make_request(
|
||||
101,
|
||||
PluginToRuntimeAction.INVOKE_PARSER.value,
|
||||
{'plugin_author': 'author-b', 'plugin_name': 'parser-b', 'storage_path': 'file-a'},
|
||||
inbound_context,
|
||||
)
|
||||
|
||||
await runtime_handler._handle_action(request.model_dump())
|
||||
|
||||
response = json.loads(connection.sent[-1])
|
||||
assert response['code'] == 0, response
|
||||
chunks = connection.requests[:-1]
|
||||
parse = connection.requests[-1]
|
||||
assert len(chunks) == 4
|
||||
assert all(chunk['action'] == CommonAction.FILE_CHUNK.value for chunk in chunks)
|
||||
assert parse['action'] == LangBotToRuntimeAction.PARSE_DOCUMENT.value
|
||||
assert all(request['context'] == expected.model_dump() for request in connection.requests)
|
||||
assert b''.join(base64.b64decode(chunk['data']['chunk_base64']) for chunk in chunks) == file_bytes
|
||||
assert {chunk['data']['file_key'] for chunk in chunks} == {parse['data']['context']['file_key']}
|
||||
connector._setting_for_plugin.assert_awaited_once_with('author-b', 'parser-b', require_enabled=True)
|
||||
assert runtime_handler.current_action_context is None
|
||||
assert runtime_handler.resolve_outbound_action_context(None) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_explicit_argument_overrides_scope_and_inbound_falls_back(bridge):
|
||||
runtime_handler, _, connection, binding_a, binding_b = bridge
|
||||
token = runtime_handler._current_action_context.set(binding_a)
|
||||
try:
|
||||
with runtime_handler.installation_scope(binding_b):
|
||||
await runtime_handler.call_action(
|
||||
LangBotToRuntimeAction.LIST_PARSERS, {}, action_context=binding_a.model_dump()
|
||||
)
|
||||
await runtime_handler.list_parsers()
|
||||
finally:
|
||||
runtime_handler._current_action_context.reset(token)
|
||||
assert [request['context'] for request in connection.requests] == [binding_a.model_dump()] * 2
|
||||
assert runtime_handler.resolve_outbound_action_context(None) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_explicit_none_scope_clears_inbound_and_restores_outer_scope(bridge):
|
||||
runtime_handler, _, connection, binding_a, binding_b = bridge
|
||||
token = runtime_handler._current_action_context.set(binding_a)
|
||||
try:
|
||||
with runtime_handler.installation_scope(binding_b):
|
||||
await runtime_handler.ping()
|
||||
await runtime_handler.list_parsers()
|
||||
await runtime_handler.list_parsers()
|
||||
finally:
|
||||
runtime_handler._current_action_context.reset(token)
|
||||
assert [request.get('context') for request in connection.requests] == [
|
||||
None,
|
||||
binding_b.model_dump(),
|
||||
binding_a.model_dump(),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('failure', [RuntimeError, asyncio.CancelledError])
|
||||
async def test_scope_restores_after_exception_or_cancellation(bridge, failure):
|
||||
runtime_handler, _, connection, binding_a, binding_b = bridge
|
||||
with runtime_handler.installation_scope(binding_a):
|
||||
with pytest.raises(failure):
|
||||
with runtime_handler.installation_scope(binding_b):
|
||||
await runtime_handler.list_parsers()
|
||||
raise failure()
|
||||
await runtime_handler.list_parsers()
|
||||
await runtime_handler.list_parsers()
|
||||
assert [request.get('context') for request in connection.requests] == [
|
||||
binding_b.model_dump(),
|
||||
binding_a.model_dump(),
|
||||
None,
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_nested_scopes_do_not_leak_on_task_cancellation(bridge):
|
||||
runtime_handler, _, connection, binding_a, binding_b = bridge
|
||||
entered = asyncio.Event()
|
||||
release = asyncio.Event()
|
||||
|
||||
async def cancelled_invocation():
|
||||
with runtime_handler.installation_scope(binding_b):
|
||||
await runtime_handler.list_parsers()
|
||||
entered.set()
|
||||
await release.wait()
|
||||
|
||||
token = runtime_handler._current_action_context.set(binding_a)
|
||||
task = asyncio.create_task(cancelled_invocation())
|
||||
try:
|
||||
await asyncio.wait_for(entered.wait(), timeout=2)
|
||||
with runtime_handler.installation_scope(None):
|
||||
await runtime_handler.list_parsers()
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
await runtime_handler.list_parsers()
|
||||
finally:
|
||||
runtime_handler._current_action_context.reset(token)
|
||||
task.cancel()
|
||||
await asyncio.gather(task, return_exceptions=True)
|
||||
assert [request.get('context') for request in connection.requests] == [
|
||||
binding_b.model_dump(),
|
||||
None,
|
||||
binding_a.model_dump(),
|
||||
]
|
||||
assert runtime_handler.resolve_outbound_action_context(None) is None
|
||||
@@ -608,6 +608,54 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/90/45/f458fa2c388e79dd9d8b9b0c99f1d31b568f27388f2fdba7bb66bbc0c6ed/cachetools-6.2.6-py3-none-any.whl", hash = "sha256:8c9717235b3c651603fff0076db52d6acbfd1b338b8ed50256092f7ce9c85bda", size = 11668, upload-time = "2026-01-27T20:32:58.527Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cbor2"
|
||||
version = "6.1.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c6/14/b02446bacfe44351b1689c04937ade007588f44570431880a6937e525e6c/cbor2-6.1.4.tar.gz", hash = "sha256:01ecc79a28f33d17331943ce508fc1e21f4b06553c73f874f4c77120d72b2ef9", size = 90840, upload-time = "2026-08-01T20:41:39.797Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/84/1e363301c06f509963d134f5479e82b3ade87fb1495ddacf9bf7ff24ac42/cbor2-6.1.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8156fdeb73c3ff6c8cf67ad414fb5c887cd708ff0af6d61f62629f41cb4c17b2", size = 414947, upload-time = "2026-08-01T20:40:37.405Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/96/d8e1ed3e79ea20a3423a96b5c89ce794fa02cb428e4429e601f8ebcbac7c/cbor2-6.1.4-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:e1fe2d62c50df290576280b18247ec63486f78be73e285bae269c2456c6ddff0", size = 457343, upload-time = "2026-08-01T20:40:38.868Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/0c/5796c2ed2dcd0696fc4abedf0ea0dfd5361b3f022a311481f977fa51b2b8/cbor2-6.1.4-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:c204a75f91f8cd9ed0881f6b88ec395c59aeac9fcf4d08155e7f899db2a1c46e", size = 464314, upload-time = "2026-08-01T20:40:40.63Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/88/de524c6c2c91b740e5df6e6955a113fb616e979b26fd2e6a0693082d36e0/cbor2-6.1.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:28fa5db05a7eae8fd80709959988d8a7f12838c6d4e5c58ec951414058641195", size = 523053, upload-time = "2026-08-01T20:40:42.602Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/07/cb5fd92834633508d680a5b5695aeaf99d33ca0bdc5b844550d538f335b0/cbor2-6.1.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:316e217a496640418d3137483279d0e70053b000cdd4b52a4dbf20ea478bc40a", size = 532177, upload-time = "2026-08-01T20:40:44.058Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/19/be98721365edfe6fc23e6bcd1385afa0e960b247c5f0b50bb67f5d05e2d9/cbor2-6.1.4-cp311-cp311-win32.whl", hash = "sha256:4903f24e0f9087275a0b6606c8b0aa586277001d51e4844fcdbc5b7211330aa8", size = 281660, upload-time = "2026-08-01T20:40:45.761Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/23/d54f679d4b155918f5a0879dab78203ce4fd514d311b7cfeba27dafe480b/cbor2-6.1.4-cp311-cp311-win_amd64.whl", hash = "sha256:5b99305d4013867e059f147752b95f728680682ab03d75a3f4dcfbb270d8dfe9", size = 303207, upload-time = "2026-08-01T20:40:47.293Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/3c/b3839d6213c88b249ba860525df05ff18b27bdc28ebc09cb1547790f001a/cbor2-6.1.4-cp311-cp311-win_arm64.whl", hash = "sha256:bd20ecc5c8ece24db952e48a91c8c47319eaa6358af707c85ac2bb388a79abc8", size = 296123, upload-time = "2026-08-01T20:40:48.808Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/76/fb64293c19cafb860060310c57b768fd9cfb7cf592449660b756538cc116/cbor2-6.1.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1fc15061553e4494dc10883237501e3402c645fe509248dd698e1faf2460d68b", size = 404608, upload-time = "2026-08-01T20:40:50.219Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/ac/f58b3bafce7c86ada2ad8eaf189453136d2cf5bae526ea0540e1b9bc9d06/cbor2-6.1.4-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:d9ada5a6ccfbb8ea7a3aa2aeb028421b52d8e0cd9323f0a2aeaa9c09d25fbce2", size = 449851, upload-time = "2026-08-01T20:40:51.725Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/a5/10c6c126d59b07f2bd005094dd12a20afa46146f7e2673ed6f61a57641a7/cbor2-6.1.4-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:310f3dfb296ba48fe9b63c5cf26e691e3548a1eae6901d2f0c18e941d151f220", size = 461193, upload-time = "2026-08-01T20:40:53.446Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/e4/4445e6237088d1cca3b8536daeb90d6b4e23776de5609c9fa46773874757/cbor2-6.1.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e6c76004d674ad1c620660cb0bc5a8a0b72a5d8c7b70926d8e09e6d7e87332f", size = 516937, upload-time = "2026-08-01T20:40:54.952Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/87/9c0959510f7a402e5995c81ccfd82cb9f314140dc0cce88c12836e5b93f1/cbor2-6.1.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:32a4663425fbca4a4a7aa918eb5789d844c406439e58424cf34511f79f559242", size = 529229, upload-time = "2026-08-01T20:40:56.365Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/8e/6811e4ee84203ac657f6f461a37c7c9ba0287bde80eb83c7971e9b3fe156/cbor2-6.1.4-cp312-cp312-win32.whl", hash = "sha256:2310f07db3f9ba26f2a623774ff9f3dc7185af54f732ea119785a6b1bf7e1e7e", size = 278810, upload-time = "2026-08-01T20:40:57.76Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/27/87440788fc0d9513534c3c699238e2a9ca6010f8cb72e9c203b7af20a9f6/cbor2-6.1.4-cp312-cp312-win_amd64.whl", hash = "sha256:cc8cd300e236e9797b2e1ce306109dc481fcccf78bfa2682bf36d99e6eab1ec6", size = 299971, upload-time = "2026-08-01T20:40:59.256Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/f9/77981e6e63092de19d7306a09a12b0eb3fd2907dc22c10dd5d389eb27faf/cbor2-6.1.4-cp312-cp312-win_arm64.whl", hash = "sha256:553a46bda7d09552631a714e22b91e6ff2c867ecd91511596ce290d8879b8d5b", size = 290662, upload-time = "2026-08-01T20:41:00.89Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/17/0b20c88e76942ede86c98cdce138681690f95908c540c264fff847729cd4/cbor2-6.1.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c48a7c938fc5fa5300ff82b5df09068dcb4838685ae8556b5ee8279d74f97ab4", size = 403677, upload-time = "2026-08-01T20:41:02.561Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/3d/93eed770864540c5c9ea0841008208e9db686b7335f42520705b7d6dc6b2/cbor2-6.1.4-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:4bd29f21529e279d50fc14f1a811f7b05b4d8e66a7969163cce98983b6817245", size = 449762, upload-time = "2026-08-01T20:41:04.094Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/21/69e4d37f00319b3d37322355aedc83154b4d8b75dc9e9789c06e1fbd8a92/cbor2-6.1.4-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:36ae16d64b1f7b620c1af748e7b6947e20069ef80eee56871c5fbb84cc635905", size = 460420, upload-time = "2026-08-01T20:41:05.891Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/26/2cfdd5ee826205a88a826bb38b7a572c676ec3efa29574be5cdbd04b4859/cbor2-6.1.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:69978901302ecbc8cda57b520487c5c5240ed217de783eb7728fceb258311d76", size = 516490, upload-time = "2026-08-01T20:41:07.52Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/86/d687cd1c2c9f9a986e8552ad1fdbd22411cc86389b5705dba6ec6f7e3226/cbor2-6.1.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad4efa23fee6447e56a269191044e06eb39e809458bcd674e164fe9445feafd0", size = 528810, upload-time = "2026-08-01T20:41:09.144Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/08/88cecf20b8825bdd991c47b317415c08ef9e7d5f05a1def9acd346edabde/cbor2-6.1.4-cp313-cp313-win32.whl", hash = "sha256:d2560c2ba6a95904ba2a0ca257af878c4344409d9b46d8e646d8ebb617b1e0dd", size = 278058, upload-time = "2026-08-01T20:41:10.48Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/67/ba140234a6415c16dcfbe0585ce12f905157b70e9cb1bb63a2b6d5721e70/cbor2-6.1.4-cp313-cp313-win_amd64.whl", hash = "sha256:c08b9c7d2ea013e24a0cb819b872b0119dde404f64a1182c0b24095b7bba781f", size = 299315, upload-time = "2026-08-01T20:41:12.067Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/7f/35d53ff4252a5a85656480d3a81d5a5af823979ccd0c5cac95196a7548a6/cbor2-6.1.4-cp313-cp313-win_arm64.whl", hash = "sha256:598710183daae69cbdeb177a870ec64aa601de8138a61491fd256826d15a860f", size = 289976, upload-time = "2026-08-01T20:41:13.63Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/5d/c5374c76471ab41dff4420a276569a56352e83166374fba6f40fd0bde7ad/cbor2-6.1.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:24da0a481294ac416e1e369e2d204b2b1d993cbd082d0d99fa3d6f5f27ae5e69", size = 407497, upload-time = "2026-08-01T20:41:15.189Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/f9/b9f12a5e24d5ae355e4c0f6d37330a2bbedad3331247a223a51c4cd39d5e/cbor2-6.1.4-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:0859a0837e6e2d4fe5f5b849f6475797e4db545da98c19db4b1d3487bd47aa22", size = 452191, upload-time = "2026-08-01T20:41:16.705Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/22/8224b01f95a6fe07b1a64082aea34d9f49068392b3de93f5f3a10c73c62e/cbor2-6.1.4-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:c0f5f2d6d3b58e44146860c049f3c082207a4005588b8926d51bf937ab66773c", size = 462383, upload-time = "2026-08-01T20:41:18.17Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/52/437e4aa4f5df1fb41020d64b3d99a8239f0f99a3a75eb6ffa5cb66004b7f/cbor2-6.1.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:239db0f92d537fd29eaec4e40195fc3b2b48bc34a5887059658162489a9eb6ae", size = 518700, upload-time = "2026-08-01T20:41:19.592Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/45/2f5ea5bfe0fd800b3739c7df8679bdffa9f7def6b2f2fee064ada1c63e85/cbor2-6.1.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3f4a434c36bb0d33aeb48ddae8e8b673ca7e1f14545ee7cf4a4c7c39380ea9a2", size = 531243, upload-time = "2026-08-01T20:41:21.21Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/c6/0beac64cb74cd3217f295f9bb0d64675e1809c683a31ea2a49ac9d4d1504/cbor2-6.1.4-cp314-cp314-win32.whl", hash = "sha256:6abcf072b8c0fdc8ad7902ee26a906cafbf3427d026b662ff21166a253f85e18", size = 285248, upload-time = "2026-08-01T20:41:22.658Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/7d/4afa096ddc94049f5a514690891b02a18319e146ceb14465ce30c8340a8b/cbor2-6.1.4-cp314-cp314-win_amd64.whl", hash = "sha256:855764e02dc60ab9413acd044e997c3170000fdea6155d6c43a923a1d966dbe6", size = 313044, upload-time = "2026-08-01T20:41:24.066Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/b5/e614cee861772f6b5c4d926b066d2e7dbc11e220b50ba716ba91e430fb0f/cbor2-6.1.4-cp314-cp314-win_arm64.whl", hash = "sha256:c6b28b928c5f2dbf47dffa12dce9c8e36fe6ac1c1358bc326499c0736263b66f", size = 304088, upload-time = "2026-08-01T20:41:25.431Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/41/3b28184154f6cbf7e47c1b7fb4a7a291c54f27a6f3a0a2f64b078c6a13e1/cbor2-6.1.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7336ff4cb7d161ec43b65eef43bf3e9bcab44bd152efb54dd637b7afe711254f", size = 401042, upload-time = "2026-08-01T20:41:26.819Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/1a/a8624023b84b41c43a150a89517c104aed0e467bd258866f13be4c3ac0c6/cbor2-6.1.4-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:8f1019494b0ec81a3df3ebb01b6acb446d5b946fe35845b1726379abd66a71da", size = 445301, upload-time = "2026-08-01T20:41:28.35Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/39/07dd0ea957c1f48673d3947f97ee36826efd4a824053dd0ec4df2f0c89d6/cbor2-6.1.4-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:179a794bf4be1d46ff190695929f65f0b42019c156919846ae539d2a7ec42e54", size = 459816, upload-time = "2026-08-01T20:41:29.839Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/8e/2015175132a27c1daed434f671ac6d9c1311461995df47f201307700e0da/cbor2-6.1.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9b904b8d0f4ddac9259197d21d121fae4cb8b555700d65bc12c5d46a2e6c2025", size = 511565, upload-time = "2026-08-01T20:41:31.939Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/66/420991095d9473614b205d4c4e40b5d3b9f1ee4410eb3c48c1e902947837/cbor2-6.1.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:71fcf4f237d68bf4445bf45070f36f82b333f2e6a62612aa2c256683b51378a9", size = 527709, upload-time = "2026-08-01T20:41:33.413Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/7c/73057e7a38488a816a0d40ff9e7cd9f418800894582e2e48fb2f47ce66a2/cbor2-6.1.4-cp314-cp314t-win32.whl", hash = "sha256:7deccc50fd0b55c4c7dd265b144c5358a645121e457c0ae3722b5ad59832b257", size = 281462, upload-time = "2026-08-01T20:41:35.127Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/5d/d5db22837cb566de733b9d1c418cdf1912ccb1efc7b179e295430b1d81a2/cbor2-6.1.4-cp314-cp314t-win_amd64.whl", hash = "sha256:f3fc7d15cba4174373df2496070faa4a927fe3ed772130d281808120aec7b61c", size = 309165, upload-time = "2026-08-01T20:41:36.716Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/5f/ff2c6da83553a692219a0a62a21b57a27ded4405200e50db758a17fbaf15/cbor2-6.1.4-cp314-cp314t-win_arm64.whl", hash = "sha256:164ca22b509408435b2d8236c80c964e4fc77c085ab034569cd04c40d5cc8883", size = 298386, upload-time = "2026-08-01T20:41:38.392Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2026.1.4"
|
||||
@@ -2008,7 +2056,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langbot"
|
||||
version = "4.10.10"
|
||||
version = "4.10.11"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiocqhttp" },
|
||||
@@ -2085,6 +2133,7 @@ dependencies = [
|
||||
{ name = "urllib3" },
|
||||
{ name = "uv" },
|
||||
{ name = "valkey-glide", marker = "sys_platform != 'win32'" },
|
||||
{ name = "webauthn" },
|
||||
{ name = "websockets" },
|
||||
]
|
||||
|
||||
@@ -2129,7 +2178,7 @@ requires-dist = [
|
||||
{ name = "ebooklib", specifier = ">=0.18" },
|
||||
{ name = "gewechat-client", specifier = ">=0.1.5" },
|
||||
{ name = "html2text", specifier = ">=2024.2.26" },
|
||||
{ name = "langbot-plugin", specifier = "==0.5.7" },
|
||||
{ name = "langbot-plugin", specifier = "==0.5.8" },
|
||||
{ name = "langchain", specifier = ">=1.3.9" },
|
||||
{ name = "langchain-core", specifier = ">=1.3.3" },
|
||||
{ name = "langchain-text-splitters", specifier = ">=1.1.2" },
|
||||
@@ -2180,6 +2229,7 @@ requires-dist = [
|
||||
{ name = "urllib3", specifier = ">=2.7.0" },
|
||||
{ name = "uv", specifier = ">=0.11.15" },
|
||||
{ name = "valkey-glide", marker = "sys_platform != 'win32'", specifier = ">=2.4.1,<3.0.0" },
|
||||
{ name = "webauthn", specifier = ">=3.0.0" },
|
||||
{ name = "websockets", specifier = ">=15.0.1" },
|
||||
]
|
||||
provides-extras = ["seekdb"]
|
||||
@@ -2196,7 +2246,7 @@ dev = [
|
||||
|
||||
[[package]]
|
||||
name = "langbot-plugin"
|
||||
version = "0.5.7"
|
||||
version = "0.5.8"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiofiles" },
|
||||
@@ -2217,9 +2267,9 @@ dependencies = [
|
||||
{ name = "watchdog" },
|
||||
{ name = "websockets" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d2/7d/b024770f1f52c9dc71ddcab79fc07dfb6147ce8e645f0fed170d758e49cb/langbot_plugin-0.5.7.tar.gz", hash = "sha256:faecd566b7ff57dc5f3a5b1be01e2165d25924031c0a65a829c83b51c65255ee", size = 480635, upload-time = "2026-09-04T13:39:22.505Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d0/ab/8d8bd6b8355c5b30b4aab2b5322fd28d8f36158f36d6b4ee33f4df4bc861/langbot_plugin-0.5.8.tar.gz", hash = "sha256:46fbdf948f4a2d110607738ab35633c9ab22a30784edce3a4e684cd19bab84ff", size = 487972, upload-time = "2026-09-11T09:27:58.304Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/25/416745039cacace6a0ca3f719a2eff41dc74cdb30ef7ffaec1de0142bd2e/langbot_plugin-0.5.7-py3-none-any.whl", hash = "sha256:b1a20bcb6a2d482019eafbfe0ac628c106b8e915c7afe89df057b4d8e2015f05", size = 310463, upload-time = "2026-09-04T13:39:21.18Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/13/4939205e2f7922ec09113e390e35f9355ce6d93e1b380a4b3c49441130f5/langbot_plugin-0.5.8-py3-none-any.whl", hash = "sha256:4fbbcfa55f1dcb9af8392b48de8b7877ea79c880dfd268d651404702614d182e", size = 311552, upload-time = "2026-09-11T09:27:57.082Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4073,6 +4123,27 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyasn1"
|
||||
version = "0.6.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a4/9a/23310166d960def5897e91fe20e5b724601b02a22e84ba1f94232c0b7f67/pyasn1-0.6.4.tar.gz", hash = "sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81", size = 151262, upload-time = "2026-07-09T01:12:33.988Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/3b/6163796d69c3977d1e4287bea4a6979161cbbdd170ebb430511e8e1999ce/pyasn1-0.6.4-py3-none-any.whl", hash = "sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b", size = 84410, upload-time = "2026-07-09T01:12:32.92Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyasn1-modules"
|
||||
version = "0.4.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pyasn1" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pybase64"
|
||||
version = "1.4.3"
|
||||
@@ -4490,6 +4561,19 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/29/7d/5945b5af29534641820d3bd7b00962abbbdfee84ec7e19f0d5b3175f9a31/pynacl-1.6.2-cp38-abi3-win_arm64.whl", hash = "sha256:834a43af110f743a754448463e8fd61259cd4ab5bbedcf70f9dabad1d28a394c", size = 184801, upload-time = "2026-01-01T17:32:36.309Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyopenssl"
|
||||
version = "26.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cryptography" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3f/e8/7325d258199b159eb2c03fe32107533e2832e70e63f4fb88a6aa00023201/pyopenssl-26.4.0.tar.gz", hash = "sha256:28dfcce0162b9211413e26dfbfdf1d24317fbeba18fc93c12400a1856b2a0bc7", size = 182046, upload-time = "2026-08-01T19:50:50.512Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/51/ad/2cf6d3fa2fae5c79e1ed9960c0d42badd0f94d81dd12b50604cdc839e648/pyopenssl-26.4.0-py3-none-any.whl", hash = "sha256:f0eb0cb2d581d3ad2b9c489468485e7f2ab6727d08401bcf9d824c3caddf3c1c", size = 56026, upload-time = "2026-08-01T19:50:48.94Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pypdf2"
|
||||
version = "3.0.1"
|
||||
@@ -6166,6 +6250,22 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/d8/0d1d2e9d3fabcf5d6840362adcf05f8cf3cd06a73358140c3a97189238ae/wcmatch-10.1-py3-none-any.whl", hash = "sha256:5848ace7dbb0476e5e55ab63c6bbd529745089343427caa5537f230cc01beb8a", size = 39854, upload-time = "2025-06-22T19:14:00.978Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webauthn"
|
||||
version = "3.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cbor2" },
|
||||
{ name = "cryptography" },
|
||||
{ name = "pyasn1" },
|
||||
{ name = "pyasn1-modules" },
|
||||
{ name = "pyopenssl" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/22/b19c91e850c4578b7d6cdb53453c5fe2f2e99d0c56e322c65c3caf1b3051/webauthn-3.0.0.tar.gz", hash = "sha256:324e54e1f6eeef486623b5d90df6fcd74ae04ff0c137d2b818a8f709b6ca3ab8", size = 160472, upload-time = "2026-06-29T22:40:33.478Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/d3/38d4efaedba74d854f88b60fd7b80ab37869032f9a9ad54d1892dab20241/webauthn-3.0.0-py3-none-any.whl", hash = "sha256:b5d0c02b6efa16be683f8a75abd2073f5e59a15f42623cc22c31f27600259e64", size = 73887, upload-time = "2026-06-29T22:40:32.171Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "websocket-client"
|
||||
version = "1.9.0"
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
"@radix-ui/react-toggle": "^1.1.8",
|
||||
"@radix-ui/react-toggle-group": "^1.1.9",
|
||||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
"@simplewebauthn/browser": "^14.0.0",
|
||||
"@tailwindcss/postcss": "^4.1.5",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
|
||||
Generated
+17
@@ -93,6 +93,9 @@ dependencies:
|
||||
'@radix-ui/react-tooltip':
|
||||
specifier: ^1.2.7
|
||||
version: 1.2.8(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1)
|
||||
'@simplewebauthn/browser':
|
||||
specifier: ^14.0.0
|
||||
version: 14.0.0
|
||||
'@tailwindcss/postcss':
|
||||
specifier: ^4.1.5
|
||||
version: 4.1.18
|
||||
@@ -1846,6 +1849,7 @@ packages:
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
requiresBuild: true
|
||||
dev: false
|
||||
optional: true
|
||||
@@ -1855,6 +1859,7 @@ packages:
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
requiresBuild: true
|
||||
dev: false
|
||||
optional: true
|
||||
@@ -1864,6 +1869,7 @@ packages:
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
requiresBuild: true
|
||||
dev: false
|
||||
optional: true
|
||||
@@ -1873,6 +1879,7 @@ packages:
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
requiresBuild: true
|
||||
dev: false
|
||||
optional: true
|
||||
@@ -1882,6 +1889,7 @@ packages:
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
requiresBuild: true
|
||||
dev: false
|
||||
optional: true
|
||||
@@ -1891,6 +1899,7 @@ packages:
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
requiresBuild: true
|
||||
dev: false
|
||||
optional: true
|
||||
@@ -1942,6 +1951,10 @@ packages:
|
||||
resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
|
||||
dev: false
|
||||
|
||||
/@simplewebauthn/browser@14.0.0:
|
||||
resolution: {integrity: sha512-1odWVqeEBTl7lJ9zMKLEsmTlnyrDO5iRcTvfMKKk1WThUnp/i8JJdffdj2icP+tty159s4PgwE3BiMoEW9NFow==}
|
||||
dev: false
|
||||
|
||||
/@standard-schema/utils@0.3.0:
|
||||
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
|
||||
dev: false
|
||||
@@ -4240,6 +4253,7 @@ packages:
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
requiresBuild: true
|
||||
dev: false
|
||||
optional: true
|
||||
@@ -4259,6 +4273,7 @@ packages:
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
requiresBuild: true
|
||||
dev: false
|
||||
optional: true
|
||||
@@ -4278,6 +4293,7 @@ packages:
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
requiresBuild: true
|
||||
dev: false
|
||||
optional: true
|
||||
@@ -4297,6 +4313,7 @@ packages:
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
requiresBuild: true
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
@@ -157,6 +157,9 @@ const BotSessionMonitor = forwardRef<
|
||||
const [messagePage, setMessagePage] = useState(0);
|
||||
const [loadingSessions, setLoadingSessions] = useState(false);
|
||||
const [loadingMessages, setLoadingMessages] = useState(false);
|
||||
const [sessionError, setSessionError] = useState(false);
|
||||
const [messageError, setMessageError] = useState(false);
|
||||
const [analysisError, setAnalysisError] = useState(false);
|
||||
const [copiedUserId, setCopiedUserId] = useState(false);
|
||||
const [feedbackMap, setFeedbackMap] = useState<
|
||||
Record<string, SessionFeedback>
|
||||
@@ -236,6 +239,8 @@ const BotSessionMonitor = forwardRef<
|
||||
const loadSessions = useCallback(async () => {
|
||||
const requestId = ++sessionRequestIdRef.current;
|
||||
setLoadingSessions(true);
|
||||
setSessionError(false);
|
||||
setSessions([]);
|
||||
try {
|
||||
const response = await httpClient.getBotSessions(botId, {
|
||||
limit: SESSION_PAGE_SIZE,
|
||||
@@ -254,6 +259,7 @@ const BotSessionMonitor = forwardRef<
|
||||
} catch (error) {
|
||||
if (requestId === sessionRequestIdRef.current) {
|
||||
console.error('Failed to load sessions:', error);
|
||||
setSessionError(true);
|
||||
}
|
||||
} finally {
|
||||
if (requestId === sessionRequestIdRef.current) {
|
||||
@@ -274,12 +280,18 @@ const BotSessionMonitor = forwardRef<
|
||||
async (sessionId: string, page: number) => {
|
||||
const requestId = ++messageRequestIdRef.current;
|
||||
setLoadingMessages(true);
|
||||
setMessageError(false);
|
||||
setAnalysisError(false);
|
||||
setMessages([]);
|
||||
setToolCalls([]);
|
||||
setFeedbackMap({});
|
||||
setExpandedToolCallIds({});
|
||||
try {
|
||||
const messagesRes = await httpClient.getSessionMessages(
|
||||
sessionId,
|
||||
MESSAGE_PAGE_SIZE,
|
||||
page * MESSAGE_PAGE_SIZE,
|
||||
botId,
|
||||
);
|
||||
if (requestId !== messageRequestIdRef.current) return;
|
||||
const sorted = (messagesRes.messages ?? []).sort(
|
||||
@@ -290,22 +302,19 @@ const BotSessionMonitor = forwardRef<
|
||||
setMessageTotal(messagesRes.total ?? 0);
|
||||
|
||||
try {
|
||||
const analysisParams = new URLSearchParams();
|
||||
if (sorted.length > 0) {
|
||||
analysisParams.set('startTime', sorted[0].timestamp);
|
||||
analysisParams.set('endTime', sorted[sorted.length - 1].timestamp);
|
||||
}
|
||||
const analysisRes = await httpClient.get<{
|
||||
const analysisRes = await httpClient.getSessionAnalysis<{
|
||||
tool_calls?: SessionToolCall[];
|
||||
}>(
|
||||
`/api/v1/monitoring/sessions/${encodeURIComponent(sessionId)}/analysis?${analysisParams.toString()}`,
|
||||
);
|
||||
}>(sessionId, botId, {
|
||||
startTime: sorted[0]?.timestamp,
|
||||
endTime: sorted[sorted.length - 1]?.timestamp,
|
||||
});
|
||||
if (requestId !== messageRequestIdRef.current) return;
|
||||
setToolCalls(analysisRes?.tool_calls ?? []);
|
||||
} catch (analysisError) {
|
||||
if (requestId !== messageRequestIdRef.current) return;
|
||||
console.error('Failed to load session tool calls:', analysisError);
|
||||
setToolCalls([]);
|
||||
setAnalysisError(true);
|
||||
}
|
||||
|
||||
// Collect user message IDs for feedback matching
|
||||
@@ -337,6 +346,7 @@ const BotSessionMonitor = forwardRef<
|
||||
} catch (error) {
|
||||
if (requestId === messageRequestIdRef.current) {
|
||||
console.error('Failed to load session messages:', error);
|
||||
setMessageError(true);
|
||||
}
|
||||
} finally {
|
||||
if (requestId === messageRequestIdRef.current) {
|
||||
@@ -349,6 +359,9 @@ const BotSessionMonitor = forwardRef<
|
||||
|
||||
useEffect(() => {
|
||||
loadSessions();
|
||||
return () => {
|
||||
sessionRequestIdRef.current += 1;
|
||||
};
|
||||
}, [loadSessions]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -362,12 +375,17 @@ const BotSessionMonitor = forwardRef<
|
||||
} else {
|
||||
messageRequestIdRef.current += 1;
|
||||
setLoadingMessages(false);
|
||||
setMessageError(false);
|
||||
setAnalysisError(false);
|
||||
setMessages([]);
|
||||
setMessageTotal(0);
|
||||
setToolCalls([]);
|
||||
setExpandedToolCallIds({});
|
||||
setFeedbackMap({});
|
||||
}
|
||||
return () => {
|
||||
messageRequestIdRef.current += 1;
|
||||
};
|
||||
}, [selectedSessionId, messagePage, loadMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -728,6 +746,20 @@ const BotSessionMonitor = forwardRef<
|
||||
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
|
||||
{t('bots.sessionMonitor.loading')}
|
||||
</div>
|
||||
) : sessionError ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="p-3 space-y-2 text-sm text-destructive"
|
||||
>
|
||||
<p>{t('monitoring.loadError')}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadSessions}
|
||||
className="rounded border px-2 py-1 text-foreground"
|
||||
>
|
||||
{t('common.retry')}
|
||||
</button>
|
||||
</div>
|
||||
) : sessions.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-12 text-sm">
|
||||
{t('bots.sessionMonitor.noSessions')}
|
||||
@@ -898,10 +930,46 @@ const BotSessionMonitor = forwardRef<
|
||||
className="flex-1 px-4 py-4 overflow-y-auto min-h-0"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{analysisError && !loadingMessages && (
|
||||
<div
|
||||
role="alert"
|
||||
className="text-sm text-destructive space-y-2"
|
||||
>
|
||||
<p>
|
||||
{t('monitoring.toolCalls.title')}:{' '}
|
||||
{t('monitoring.loadError')}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
loadMessages(selectedSessionId, messagePage)
|
||||
}
|
||||
className="rounded border px-2 py-1 text-foreground"
|
||||
>
|
||||
{t('common.retry')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{loadingMessages ? (
|
||||
<div className="text-center text-muted-foreground py-12 text-sm">
|
||||
{t('bots.sessionMonitor.loading')}
|
||||
</div>
|
||||
) : messageError ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="text-sm text-destructive space-y-2"
|
||||
>
|
||||
<p>{t('monitoring.loadError')}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
loadMessages(selectedSessionId, messagePage)
|
||||
}
|
||||
className="rounded border px-2 py-1 text-foreground"
|
||||
>
|
||||
{t('common.retry')}
|
||||
</button>
|
||||
</div>
|
||||
) : timelineItems.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-12 text-sm">
|
||||
{t('bots.sessionMonitor.noMessages')}
|
||||
|
||||
@@ -12,7 +12,17 @@ import {
|
||||
} from '@/components/ui/item';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { systemInfo } from '@/app/infra/http';
|
||||
import { Loader2, ExternalLink, KeyRound, Layers } from 'lucide-react';
|
||||
import {
|
||||
Loader2,
|
||||
ExternalLink,
|
||||
KeyRound,
|
||||
Layers,
|
||||
Fingerprint,
|
||||
Plus,
|
||||
Trash2,
|
||||
Pencil,
|
||||
} from 'lucide-react';
|
||||
import { startRegistration } from '@simplewebauthn/browser';
|
||||
import PasswordChangeDialog from '../password-change-dialog/PasswordChangeDialog';
|
||||
import { PanelBody } from '../settings-dialog/panel-layout';
|
||||
|
||||
@@ -22,6 +32,16 @@ interface AccountSettingsPanelProps {
|
||||
onEmailResolved?: (email: string) => void;
|
||||
}
|
||||
|
||||
interface PasskeyItem {
|
||||
uuid: string;
|
||||
name: string;
|
||||
aaguid?: string;
|
||||
transports?: string;
|
||||
backed_up?: boolean;
|
||||
created_at?: string;
|
||||
last_used_at?: string;
|
||||
}
|
||||
|
||||
export default function AccountSettingsPanel({
|
||||
active,
|
||||
onEmailResolved,
|
||||
@@ -33,10 +53,14 @@ export default function AccountSettingsPanel({
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [spaceBindLoading, setSpaceBindLoading] = useState(false);
|
||||
const [passwordDialogOpen, setPasswordDialogOpen] = useState(false);
|
||||
const [passkeys, setPasskeys] = useState<PasskeyItem[]>([]);
|
||||
const [passkeyLoading, setPasskeyLoading] = useState(false);
|
||||
const [registeringPasskey, setRegisteringPasskey] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (active) {
|
||||
loadUserInfo();
|
||||
loadPasskeys();
|
||||
}
|
||||
}, [active]);
|
||||
|
||||
@@ -55,6 +79,67 @@ export default function AccountSettingsPanel({
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPasskeys() {
|
||||
setPasskeyLoading(true);
|
||||
try {
|
||||
const list = await httpClient.getPasskeys();
|
||||
setPasskeys(list);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setPasskeyLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddPasskey = async () => {
|
||||
setRegisteringPasskey(true);
|
||||
try {
|
||||
const { options, challenge_token } =
|
||||
await httpClient.getPasskeyRegisterOptions(window.location.origin);
|
||||
const regResp = await startRegistration({ optionsJSON: options });
|
||||
const defaultName =
|
||||
prompt(t('account.passkeyNamePlaceholder')) || undefined;
|
||||
await httpClient.verifyPasskeyRegister(
|
||||
challenge_token,
|
||||
regResp,
|
||||
defaultName,
|
||||
);
|
||||
toast.success(t('account.passkeyAddedSuccess'));
|
||||
await loadPasskeys();
|
||||
} catch (error: any) {
|
||||
if (error?.name === 'NotAllowedError') {
|
||||
// User cancelled
|
||||
} else {
|
||||
toast.error(error?.message || t('common.error'));
|
||||
}
|
||||
} finally {
|
||||
setRegisteringPasskey(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeletePasskey = async (uuid: string) => {
|
||||
if (!confirm(t('account.deletePasskeyConfirm'))) return;
|
||||
try {
|
||||
await httpClient.deletePasskey(uuid);
|
||||
toast.success(t('account.passkeyDeleteSuccess'));
|
||||
await loadPasskeys();
|
||||
} catch (error: any) {
|
||||
toast.error(error?.message || t('common.error'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRenamePasskey = async (uuid: string, currentName: string) => {
|
||||
const newName = prompt(t('account.passkeyName'), currentName);
|
||||
if (!newName || !newName.trim() || newName === currentName) return;
|
||||
try {
|
||||
await httpClient.renamePasskey(uuid, newName.trim());
|
||||
toast.success(t('account.passkeyRenameSuccess'));
|
||||
await loadPasskeys();
|
||||
} catch (error: any) {
|
||||
toast.error(error?.message || t('common.error'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleBindSpace = async () => {
|
||||
setSpaceBindLoading(true);
|
||||
try {
|
||||
@@ -148,6 +233,105 @@ export default function AccountSettingsPanel({
|
||||
</ItemActions>
|
||||
)}
|
||||
</Item>
|
||||
|
||||
{/* Passkey Section */}
|
||||
<div className="pt-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium">
|
||||
{t('account.passkeySectionTitle')}
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('account.passkeySectionDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAddPasskey}
|
||||
disabled={
|
||||
registeringPasskey || !systemInfo.allow_modify_login_info
|
||||
}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{registeringPasskey ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{t('account.addPasskey')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{passkeyLoading ? (
|
||||
<div className="flex justify-center py-4">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : passkeys.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed p-4 text-center text-xs text-muted-foreground">
|
||||
{t('account.noPasskeys')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{passkeys.map((pk) => (
|
||||
<Item
|
||||
key={pk.uuid}
|
||||
size="sm"
|
||||
variant="muted"
|
||||
className="rounded-lg"
|
||||
>
|
||||
<ItemMedia variant="icon">
|
||||
<Fingerprint className="h-4 w-4" />
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>{pk.name}</ItemTitle>
|
||||
<ItemDescription>
|
||||
{pk.created_at && (
|
||||
<span>
|
||||
{t('account.passkeyCreated', {
|
||||
date: new Date(
|
||||
pk.created_at,
|
||||
).toLocaleDateString(),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
{pk.last_used_at && (
|
||||
<span className="ml-2">
|
||||
·{' '}
|
||||
{t('account.passkeyLastUsed', {
|
||||
date: new Date(
|
||||
pk.last_used_at,
|
||||
).toLocaleDateString(),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 cursor-pointer"
|
||||
onClick={() => handleRenamePasskey(pk.uuid, pk.name)}
|
||||
disabled={!systemInfo.allow_modify_login_info}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive cursor-pointer hover:text-destructive"
|
||||
onClick={() => handleDeletePasskey(pk.uuid)}
|
||||
disabled={!systemInfo.allow_modify_login_info}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -4,24 +4,18 @@ import { MessageSquare, Sparkles, Check, Users } from 'lucide-react';
|
||||
import MetricCard from './MetricCard';
|
||||
import SystemStatusCard from './SystemStatusCards';
|
||||
import TrafficChart from './TrafficChart';
|
||||
import {
|
||||
OverviewMetrics,
|
||||
MonitoringMessage,
|
||||
LLMCall,
|
||||
} from '../../types/monitoring';
|
||||
import { OverviewMetrics, MonitoringData } from '../../types/monitoring';
|
||||
|
||||
interface OverviewCardsProps {
|
||||
metrics: OverviewMetrics | null;
|
||||
messages?: MonitoringMessage[];
|
||||
llmCalls?: LLMCall[];
|
||||
traffic?: MonitoringData['traffic'];
|
||||
loading?: boolean;
|
||||
refreshKey?: number;
|
||||
}
|
||||
|
||||
export default function OverviewCards({
|
||||
metrics,
|
||||
messages = [],
|
||||
llmCalls = [],
|
||||
traffic,
|
||||
loading,
|
||||
refreshKey,
|
||||
}: OverviewCardsProps) {
|
||||
@@ -100,7 +94,7 @@ export default function OverviewCards({
|
||||
</div>
|
||||
|
||||
{/* Traffic Chart */}
|
||||
<TrafficChart messages={messages} llmCalls={llmCalls} loading={loading} />
|
||||
<TrafficChart traffic={traffic} loading={loading} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,119 +11,33 @@ import {
|
||||
ResponsiveContainer,
|
||||
Legend,
|
||||
} from 'recharts';
|
||||
import { MonitoringMessage, LLMCall } from '../../types/monitoring';
|
||||
import { MonitoringData } from '../../types/monitoring';
|
||||
|
||||
interface TrafficChartProps {
|
||||
messages: MonitoringMessage[];
|
||||
llmCalls: LLMCall[];
|
||||
traffic?: MonitoringData['traffic'];
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
interface ChartDataPoint {
|
||||
time: string;
|
||||
timestamp: number;
|
||||
messages: number;
|
||||
llmCalls: number;
|
||||
}
|
||||
|
||||
export default function TrafficChart({
|
||||
messages,
|
||||
llmCalls,
|
||||
loading,
|
||||
}: TrafficChartProps) {
|
||||
export default function TrafficChart({ traffic, loading }: TrafficChartProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
const safeMessages = Array.isArray(messages) ? messages : [];
|
||||
const safeLlmCalls = Array.isArray(llmCalls) ? llmCalls : [];
|
||||
if (!safeMessages.length && !safeLlmCalls.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Combine all timestamps and find the range
|
||||
const allTimestamps = [
|
||||
...safeMessages.map((m) => m.timestamp.getTime()),
|
||||
...safeLlmCalls.map((c) => c.timestamp.getTime()),
|
||||
];
|
||||
|
||||
if (allTimestamps.length === 0) return [];
|
||||
|
||||
const minTime = Math.min(...allTimestamps);
|
||||
const maxTime = Math.max(...allTimestamps);
|
||||
const timeRange = maxTime - minTime;
|
||||
|
||||
// Determine bucket size based on time range
|
||||
let bucketSize: number;
|
||||
let formatTime: (date: Date) => string;
|
||||
|
||||
if (timeRange <= 60 * 60 * 1000) {
|
||||
// <= 1 hour: 5-minute buckets
|
||||
bucketSize = 5 * 60 * 1000;
|
||||
formatTime = (date) =>
|
||||
date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
} else if (timeRange <= 6 * 60 * 60 * 1000) {
|
||||
// <= 6 hours: 15-minute buckets
|
||||
bucketSize = 15 * 60 * 1000;
|
||||
formatTime = (date) =>
|
||||
date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
} else if (timeRange <= 24 * 60 * 60 * 1000) {
|
||||
// <= 24 hours: 1-hour buckets
|
||||
bucketSize = 60 * 60 * 1000;
|
||||
formatTime = (date) =>
|
||||
date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
} else if (timeRange <= 7 * 24 * 60 * 60 * 1000) {
|
||||
// <= 7 days: 4-hour buckets
|
||||
bucketSize = 4 * 60 * 60 * 1000;
|
||||
formatTime = (date) =>
|
||||
`${date.toLocaleDateString([], {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})} ${date.toLocaleTimeString([], { hour: '2-digit' })}`;
|
||||
} else {
|
||||
// > 7 days: 1-day buckets
|
||||
bucketSize = 24 * 60 * 60 * 1000;
|
||||
formatTime = (date) =>
|
||||
date.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
// Create buckets
|
||||
const buckets: Map<number, ChartDataPoint> = new Map();
|
||||
const startBucket = Math.floor(minTime / bucketSize) * bucketSize;
|
||||
const endBucket = Math.ceil(maxTime / bucketSize) * bucketSize;
|
||||
|
||||
for (let bucket = startBucket; bucket <= endBucket; bucket += bucketSize) {
|
||||
buckets.set(bucket, {
|
||||
time: formatTime(new Date(bucket)),
|
||||
timestamp: bucket,
|
||||
messages: 0,
|
||||
llmCalls: 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Count messages per bucket
|
||||
safeMessages.forEach((msg) => {
|
||||
const bucket =
|
||||
Math.floor(msg.timestamp.getTime() / bucketSize) * bucketSize;
|
||||
const point = buckets.get(bucket);
|
||||
if (point) {
|
||||
point.messages++;
|
||||
}
|
||||
});
|
||||
|
||||
// Count LLM calls per bucket
|
||||
safeLlmCalls.forEach((call) => {
|
||||
const bucket =
|
||||
Math.floor(call.timestamp.getTime() / bucketSize) * bucketSize;
|
||||
const point = buckets.get(bucket);
|
||||
if (point) {
|
||||
point.llmCalls++;
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(buckets.values()).sort(
|
||||
(a, b) => a.timestamp - b.timestamp,
|
||||
);
|
||||
}, [messages, llmCalls]);
|
||||
const chartData = useMemo(
|
||||
() =>
|
||||
(traffic?.points ?? []).map((point) => ({
|
||||
...point,
|
||||
time: point.timestamp.toLocaleString(
|
||||
[],
|
||||
traffic?.bucket === 'day'
|
||||
? { month: 'short', day: 'numeric' }
|
||||
: {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
},
|
||||
),
|
||||
})),
|
||||
[traffic],
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -150,7 +64,13 @@ export default function TrafficChart({
|
||||
</h3>
|
||||
<div className="h-[300px] flex flex-col items-center justify-center text-muted-foreground gap-2">
|
||||
<BarChart3 className="h-[3rem] w-[3rem]" />
|
||||
<div className="text-sm">{t('monitoring.trafficChart.noData')}</div>
|
||||
<div className="text-sm">
|
||||
{t(
|
||||
traffic
|
||||
? 'monitoring.trafficChart.noData'
|
||||
: 'monitoring.trafficChart.unavailable',
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -161,6 +81,11 @@ export default function TrafficChart({
|
||||
<h3 className="text-base font-semibold text-foreground mb-6">
|
||||
{t('monitoring.trafficChart.title')}
|
||||
</h3>
|
||||
{traffic?.truncated && (
|
||||
<p role="status" className="text-sm text-muted-foreground mb-3">
|
||||
{t('monitoring.trafficChart.truncated')}
|
||||
</p>
|
||||
)}
|
||||
<div className="h-[300px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import {
|
||||
FilterState,
|
||||
MonitoringData,
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
LLMCall,
|
||||
EmbeddingCall,
|
||||
} from '../types/monitoring';
|
||||
import { backendClient } from '@/app/infra/http';
|
||||
import { backendClient, useCurrentWorkspace } from '@/app/infra/http';
|
||||
import { getCurrentWorkspaceSnapshot } from '@/app/infra/http/currentWorkspaceStore';
|
||||
import { parseUTCTimestamp } from '../utils/dateUtils';
|
||||
|
||||
/**
|
||||
@@ -16,6 +17,10 @@ export function useMonitoringData(filterState: FilterState) {
|
||||
const [data, setData] = useState<MonitoringData | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const workspaceUuid = useCurrentWorkspace()?.workspace.uuid;
|
||||
const requestIdRef = useRef(0);
|
||||
const scope = JSON.stringify([workspaceUuid, filterState]);
|
||||
const [requestScope, setRequestScope] = useState<string | null>(null);
|
||||
|
||||
// Memoize filter parameters to prevent unnecessary re-renders
|
||||
const selectedBotsStr = useMemo(
|
||||
@@ -72,6 +77,12 @@ export function useMonitoringData(filterState: FilterState) {
|
||||
|
||||
// Fetch data based on filters
|
||||
const fetchData = useCallback(async () => {
|
||||
const requestId = ++requestIdRef.current;
|
||||
const isCurrent = () =>
|
||||
requestId === requestIdRef.current &&
|
||||
getCurrentWorkspaceSnapshot()?.workspace.uuid === workspaceUuid;
|
||||
setRequestScope(scope);
|
||||
setData(null);
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
@@ -91,6 +102,7 @@ export function useMonitoringData(filterState: FilterState) {
|
||||
endTime,
|
||||
limit: 50,
|
||||
});
|
||||
if (!isCurrent()) return;
|
||||
|
||||
const overview = response?.overview ?? {
|
||||
total_messages: 0,
|
||||
@@ -127,6 +139,17 @@ export function useMonitoringData(filterState: FilterState) {
|
||||
|
||||
// Transform the response to match MonitoringData interface
|
||||
const transformedData: MonitoringData = {
|
||||
traffic: response.traffic
|
||||
? {
|
||||
bucket: response.traffic.bucket,
|
||||
truncated: response.traffic.truncated,
|
||||
points: response.traffic.points.map((point) => ({
|
||||
timestamp: parseUTCTimestamp(point.timestamp),
|
||||
messages: point.messages,
|
||||
llmCalls: point.llm_calls,
|
||||
})),
|
||||
}
|
||||
: undefined,
|
||||
overview: {
|
||||
totalMessages: overview.total_messages,
|
||||
llmCalls: overview.llm_calls,
|
||||
@@ -396,22 +419,33 @@ export function useMonitoringData(filterState: FilterState) {
|
||||
|
||||
setData(transformedData);
|
||||
} catch (err) {
|
||||
if (!isCurrent()) return;
|
||||
setError(err as Error);
|
||||
console.error('Failed to fetch monitoring data:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (isCurrent()) setLoading(false);
|
||||
}
|
||||
}, [getTimeRange, filterState.selectedBots, filterState.selectedPipelines]);
|
||||
}, [
|
||||
getTimeRange,
|
||||
filterState.selectedBots,
|
||||
filterState.selectedPipelines,
|
||||
scope,
|
||||
workspaceUuid,
|
||||
]);
|
||||
|
||||
// Fetch data when filter state changes
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
return () => {
|
||||
requestIdRef.current += 1;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
selectedBotsStr,
|
||||
selectedPipelinesStr,
|
||||
filterState.timeRange,
|
||||
customDateRangeStr,
|
||||
workspaceUuid,
|
||||
]);
|
||||
|
||||
// Manual refetch function
|
||||
@@ -420,9 +454,9 @@ export function useMonitoringData(filterState: FilterState) {
|
||||
};
|
||||
|
||||
return {
|
||||
data,
|
||||
loading,
|
||||
error,
|
||||
data: requestScope === scope ? data : null,
|
||||
loading: requestScope !== scope || loading,
|
||||
error: requestScope === scope ? error : null,
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ function MonitoringPageContent() {
|
||||
currentWorkspace?.permissions.includes('data.export') ?? false;
|
||||
const { filterState, setSelectedBots, setSelectedPipelines, setTimeRange } =
|
||||
useMonitoringFilters();
|
||||
const { data, loading, refetch } = useMonitoringData(filterState);
|
||||
const { data, loading, error, refetch } = useMonitoringData(filterState);
|
||||
|
||||
// Counter to force feedbackTimeRange recomputation on manual refresh
|
||||
const [feedbackRefreshKey, setFeedbackRefreshKey] = useState(0);
|
||||
@@ -174,492 +174,556 @@ function MonitoringPageContent() {
|
||||
</div>
|
||||
|
||||
{/* Content Area */}
|
||||
<div className="relative z-0 flex flex-col gap-6 pb-4 pt-3">
|
||||
{/* Overview Section */}
|
||||
<OverviewCards
|
||||
metrics={data?.overview || null}
|
||||
messages={data?.messages || []}
|
||||
llmCalls={data?.llmCalls || []}
|
||||
loading={loading}
|
||||
/>
|
||||
{error ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-xl border border-destructive p-6 space-y-3"
|
||||
>
|
||||
<p>{t('monitoring.loadError')}</p>
|
||||
<Button variant="outline" onClick={handleRefresh}>
|
||||
{t('common.retry')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative z-0 flex flex-col gap-6 pb-4 pt-3">
|
||||
{/* Overview Section */}
|
||||
<OverviewCards
|
||||
metrics={data?.overview || null}
|
||||
traffic={data?.traffic}
|
||||
loading={loading}
|
||||
/>
|
||||
|
||||
{/* Tabs Section */}
|
||||
<div className="bg-card rounded-xl border overflow-hidden">
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
className="w-full"
|
||||
>
|
||||
<div className="px-3 pt-4 sm:px-6">
|
||||
<TabsList className="h-12 w-full justify-start gap-1 overflow-x-auto p-1 sm:w-auto">
|
||||
<TabsTrigger value="messages" className="px-3 py-2 sm:px-6">
|
||||
{t('monitoring.tabs.messages')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="modelCalls" className="px-3 py-2 sm:px-6">
|
||||
{t('monitoring.tabs.modelCalls')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="tokens" className="px-3 py-2 sm:px-6">
|
||||
{t('monitoring.tabs.tokens')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="feedback" className="px-3 py-2 sm:px-6">
|
||||
{t('monitoring.tabs.feedback')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="errors" className="px-3 py-2 sm:px-6">
|
||||
{t('monitoring.tabs.errors')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
{/* Tabs Section */}
|
||||
{!loading && data && (
|
||||
<div
|
||||
className="text-sm text-muted-foreground space-y-1"
|
||||
role="status"
|
||||
>
|
||||
{data.totalCount.messages > data.messages.length && (
|
||||
<p>
|
||||
{t('monitoring.partialMessages', {
|
||||
shown: data.messages.length,
|
||||
total: data.totalCount.messages,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{data.totalCount.llmCalls + data.totalCount.embeddingCalls >
|
||||
data.modelCalls.length && (
|
||||
<p>
|
||||
{t('monitoring.partialModelCalls', {
|
||||
shown: data.modelCalls.length,
|
||||
total:
|
||||
data.totalCount.llmCalls + data.totalCount.embeddingCalls,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{(data.totalCount.toolCalls ?? 0) > data.toolCalls.length && (
|
||||
<p>
|
||||
{t('monitoring.partialToolCalls', {
|
||||
shown: data.toolCalls.length,
|
||||
total: data.totalCount.toolCalls,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{data.totalCount.errors > data.errors.length && (
|
||||
<p>
|
||||
{t('monitoring.partialErrors', {
|
||||
shown: data.errors.length,
|
||||
total: data.totalCount.errors,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TabsContent value="messages" className="p-3 m-0 sm:p-6">
|
||||
<div>
|
||||
{loading && (
|
||||
<div className="py-12 flex justify-center">
|
||||
<LoadingSpinner
|
||||
text={t('monitoring.messageList.loading')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && data && conversationTurns.length > 0 && (
|
||||
<ConversationTurnList
|
||||
turns={conversationTurns}
|
||||
expandedTurnId={expandedTurnId}
|
||||
onToggleTurn={toggleTurnExpand}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!loading && (!data || conversationTurns.length === 0) && (
|
||||
<div className="flex flex-col items-center justify-center text-muted-foreground py-16 gap-2">
|
||||
<MessageSquare className="h-[3rem] w-[3rem]" />
|
||||
<div className="text-sm">
|
||||
{t('monitoring.messageList.noMessages')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
<div className="bg-card rounded-xl border overflow-hidden">
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
className="w-full"
|
||||
>
|
||||
<div className="px-3 pt-4 sm:px-6">
|
||||
<TabsList className="h-12 w-full justify-start gap-1 overflow-x-auto p-1 sm:w-auto">
|
||||
<TabsTrigger value="messages" className="px-3 py-2 sm:px-6">
|
||||
{t('monitoring.tabs.messages')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="modelCalls" className="px-3 py-2 sm:px-6">
|
||||
{t('monitoring.tabs.modelCalls')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="tokens" className="px-3 py-2 sm:px-6">
|
||||
{t('monitoring.tabs.tokens')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="feedback" className="px-3 py-2 sm:px-6">
|
||||
{t('monitoring.tabs.feedback')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="errors" className="px-3 py-2 sm:px-6">
|
||||
{t('monitoring.tabs.errors')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="modelCalls" className="p-3 m-0 sm:p-6">
|
||||
<div>
|
||||
{loading && (
|
||||
<div className="py-12 flex justify-center">
|
||||
<LoadingSpinner text={t('common.loading')} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading &&
|
||||
data &&
|
||||
data.modelCalls &&
|
||||
data.modelCalls.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{data.modelCalls.map((call) => (
|
||||
<div
|
||||
key={call.id}
|
||||
className="border rounded-xl p-3 transition-all duration-200 sm:p-5"
|
||||
>
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<div className="flex-1">
|
||||
{/* Query ID - only show if messageId exists */}
|
||||
{call.messageId && (
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
Query ID: {call.messageId}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 px-1.5 text-xs"
|
||||
onClick={() =>
|
||||
jumpToMessage(call.messageId!)
|
||||
}
|
||||
>
|
||||
<ExternalLink className="w-3 h-3 mr-1" />
|
||||
{t(
|
||||
'monitoring.messageList.viewConversation',
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{/* Model Type Badge */}
|
||||
<span
|
||||
className={`text-xs px-2 py-1 rounded ${
|
||||
call.modelType === 'llm'
|
||||
? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
|
||||
: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200'
|
||||
}`}
|
||||
>
|
||||
{call.modelType === 'llm'
|
||||
? t('monitoring.modelCalls.llmModel')
|
||||
: t('monitoring.modelCalls.embeddingModel')}
|
||||
</span>
|
||||
{/* Call Type Badge for Embedding */}
|
||||
{call.modelType === 'embedding' &&
|
||||
call.callType && (
|
||||
<span
|
||||
className={`text-xs px-2 py-1 rounded ${
|
||||
call.callType === 'retrieve'
|
||||
? 'bg-cyan-100 text-cyan-800 dark:bg-cyan-900 dark:text-cyan-200'
|
||||
: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200'
|
||||
}`}
|
||||
>
|
||||
{call.callType === 'retrieve'
|
||||
? t(
|
||||
'monitoring.modelCalls.retrieveCall',
|
||||
)
|
||||
: t(
|
||||
'monitoring.modelCalls.embeddingCall',
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{/* Status Badge */}
|
||||
<span
|
||||
className={`text-xs px-2 py-1 rounded ${
|
||||
call.status === 'success'
|
||||
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
|
||||
: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
|
||||
}`}
|
||||
>
|
||||
{call.status}
|
||||
</span>
|
||||
</div>
|
||||
{/* Model Name */}
|
||||
<div className="font-medium text-sm text-foreground mb-2">
|
||||
{call.modelName}
|
||||
</div>
|
||||
{/* Context Info - only for LLM calls */}
|
||||
{call.modelType === 'llm' &&
|
||||
call.botName &&
|
||||
call.pipelineName && (
|
||||
<div className="text-xs text-muted-foreground mb-1">
|
||||
{call.botName} → {call.pipelineName}
|
||||
</div>
|
||||
)}
|
||||
{/* Token Info */}
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{call.modelType === 'llm' && call.tokens && (
|
||||
<>
|
||||
<span>
|
||||
{t('monitoring.llmCalls.inputTokens')}:{' '}
|
||||
{call.tokens.input}
|
||||
</span>
|
||||
<span>
|
||||
{t('monitoring.llmCalls.outputTokens')}:{' '}
|
||||
{call.tokens.output}
|
||||
</span>
|
||||
<span>
|
||||
{t('monitoring.llmCalls.totalTokens')}:{' '}
|
||||
{call.tokens.total}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{call.modelType === 'embedding' && (
|
||||
<>
|
||||
<span>
|
||||
{t(
|
||||
'monitoring.embeddingCalls.promptTokens',
|
||||
)}
|
||||
: {call.promptTokens}
|
||||
</span>
|
||||
<span>
|
||||
{t(
|
||||
'monitoring.embeddingCalls.totalTokens',
|
||||
)}
|
||||
: {call.totalTokens}
|
||||
</span>
|
||||
<span>
|
||||
{t(
|
||||
'monitoring.embeddingCalls.inputCount',
|
||||
)}
|
||||
: {call.inputCount}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<span>
|
||||
{t('monitoring.llmCalls.duration')}:{' '}
|
||||
{call.duration}ms
|
||||
</span>
|
||||
{call.cost && (
|
||||
<span>
|
||||
{t('monitoring.llmCalls.cost')}: $
|
||||
{call.cost.toFixed(4)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Knowledge Base Info for Embedding */}
|
||||
{call.modelType === 'embedding' &&
|
||||
call.knowledgeBaseId && (
|
||||
<div>
|
||||
{t(
|
||||
'monitoring.embeddingCalls.knowledgeBase',
|
||||
)}
|
||||
: {call.knowledgeBaseId}
|
||||
</div>
|
||||
)}
|
||||
{/* Query Text for Embedding Retrieve */}
|
||||
{call.modelType === 'embedding' &&
|
||||
call.queryText && (
|
||||
<div className="mt-2 p-2 bg-muted rounded text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{t(
|
||||
'monitoring.embeddingCalls.queryText',
|
||||
)}
|
||||
:{' '}
|
||||
</span>
|
||||
<span className="text-foreground">
|
||||
{call.queryText.length > 100
|
||||
? call.queryText.substring(0, 100) +
|
||||
'...'
|
||||
: call.queryText}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{call.errorMessage && (
|
||||
<div className="mt-2 text-xs text-red-600 dark:text-red-400">
|
||||
Error: {call.errorMessage}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap ml-4">
|
||||
{call.timestamp.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<TabsContent value="messages" className="p-3 m-0 sm:p-6">
|
||||
<div>
|
||||
{loading && (
|
||||
<div className="py-12 flex justify-center">
|
||||
<LoadingSpinner
|
||||
text={t('monitoring.messageList.loading')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading &&
|
||||
(!data ||
|
||||
!data.modelCalls ||
|
||||
data.modelCalls.length === 0) && (
|
||||
{!loading && data && conversationTurns.length > 0 && (
|
||||
<ConversationTurnList
|
||||
turns={conversationTurns}
|
||||
expandedTurnId={expandedTurnId}
|
||||
onToggleTurn={toggleTurnExpand}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!loading && (!data || conversationTurns.length === 0) && (
|
||||
<div className="flex flex-col items-center justify-center text-muted-foreground py-16 gap-2">
|
||||
<Sparkles className="h-[3rem] w-[3rem]" />
|
||||
<MessageSquare className="h-[3rem] w-[3rem]" />
|
||||
<div className="text-sm">
|
||||
{t('monitoring.modelCalls.noData')}
|
||||
{t('monitoring.messageList.noMessages')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="tokens" className="p-3 m-0 sm:p-6">
|
||||
<TokenMonitoring
|
||||
botIds={
|
||||
filterState.selectedBots.length > 0
|
||||
? filterState.selectedBots
|
||||
: undefined
|
||||
}
|
||||
pipelineIds={
|
||||
filterState.selectedPipelines.length > 0
|
||||
? filterState.selectedPipelines
|
||||
: undefined
|
||||
}
|
||||
startTime={feedbackTimeRange.startTime}
|
||||
endTime={feedbackTimeRange.endTime}
|
||||
refreshKey={feedbackRefreshKey}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="feedback" className="p-3 m-0 sm:p-6">
|
||||
<div>
|
||||
{loading && (
|
||||
<div className="py-12 flex justify-center">
|
||||
<LoadingSpinner text={t('common.loading')} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && (
|
||||
<>
|
||||
{/* Feedback Stats Cards */}
|
||||
<div className="mb-6">
|
||||
<FeedbackStatsCards
|
||||
stats={feedbackStats}
|
||||
loading={feedbackLoading}
|
||||
/>
|
||||
<TabsContent value="modelCalls" className="p-3 m-0 sm:p-6">
|
||||
<div>
|
||||
{loading && (
|
||||
<div className="py-12 flex justify-center">
|
||||
<LoadingSpinner text={t('common.loading')} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Feedback List */}
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
{t('monitoring.feedback.feedbackList')}
|
||||
</h3>
|
||||
<FeedbackList
|
||||
feedback={feedbackList}
|
||||
loading={feedbackLoading}
|
||||
onViewMessage={jumpToMessage}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="errors" className="p-3 m-0 sm:p-6">
|
||||
<div>
|
||||
{loading && (
|
||||
<div className="py-12 flex justify-center">
|
||||
<LoadingSpinner text={t('common.loading')} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && data && data.errors && data.errors.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{data.errors.map((error) => (
|
||||
<div
|
||||
key={error.id}
|
||||
className="border border-red-200 dark:border-red-900 rounded-xl overflow-hidden transition-all duration-200"
|
||||
>
|
||||
{/* Error Header - Always Visible */}
|
||||
<div
|
||||
className="p-3 cursor-pointer hover:bg-red-50 dark:hover:bg-red-950/50 transition-colors bg-red-50/50 dark:bg-red-950/30 sm:p-5"
|
||||
onClick={() => toggleErrorExpand(error.id)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start flex-1">
|
||||
{/* Expand Icon */}
|
||||
<div className="mr-3 mt-0.5">
|
||||
{expandedErrorId === error.id ? (
|
||||
<ChevronDown className="w-5 h-5 text-red-500" />
|
||||
) : (
|
||||
<ChevronRight className="w-5 h-5 text-red-500" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error Info */}
|
||||
{!loading &&
|
||||
data &&
|
||||
data.modelCalls &&
|
||||
data.modelCalls.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{data.modelCalls.map((call) => (
|
||||
<div
|
||||
key={call.id}
|
||||
className="border rounded-xl p-3 transition-all duration-200 sm:p-5"
|
||||
>
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<div className="flex-1">
|
||||
{/* Query ID */}
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
Query ID: {error.messageId || '-'}
|
||||
</span>
|
||||
{error.messageId && (
|
||||
{/* Query ID - only show if messageId exists */}
|
||||
{call.messageId && (
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
Query ID: {call.messageId}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 px-1.5 text-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
jumpToMessage(error.messageId!);
|
||||
}}
|
||||
onClick={() =>
|
||||
jumpToMessage(call.messageId!)
|
||||
}
|
||||
>
|
||||
<ExternalLink className="w-3 h-3 mr-1" />
|
||||
{t(
|
||||
'monitoring.messageList.viewConversation',
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="font-medium text-sm text-red-700 dark:text-red-300">
|
||||
{error.errorType}
|
||||
{/* Model Type Badge */}
|
||||
<span
|
||||
className={`text-xs px-2 py-1 rounded ${
|
||||
call.modelType === 'llm'
|
||||
? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
|
||||
: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200'
|
||||
}`}
|
||||
>
|
||||
{call.modelType === 'llm'
|
||||
? t('monitoring.modelCalls.llmModel')
|
||||
: t(
|
||||
'monitoring.modelCalls.embeddingModel',
|
||||
)}
|
||||
</span>
|
||||
<span className="text-red-400">→</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{error.botName}
|
||||
</span>
|
||||
<span className="text-red-400">→</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{error.pipelineName}
|
||||
{/* Call Type Badge for Embedding */}
|
||||
{call.modelType === 'embedding' &&
|
||||
call.callType && (
|
||||
<span
|
||||
className={`text-xs px-2 py-1 rounded ${
|
||||
call.callType === 'retrieve'
|
||||
? 'bg-cyan-100 text-cyan-800 dark:bg-cyan-900 dark:text-cyan-200'
|
||||
: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200'
|
||||
}`}
|
||||
>
|
||||
{call.callType === 'retrieve'
|
||||
? t(
|
||||
'monitoring.modelCalls.retrieveCall',
|
||||
)
|
||||
: t(
|
||||
'monitoring.modelCalls.embeddingCall',
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{/* Status Badge */}
|
||||
<span
|
||||
className={`text-xs px-2 py-1 rounded ${
|
||||
call.status === 'success'
|
||||
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
|
||||
: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
|
||||
}`}
|
||||
>
|
||||
{call.status}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-red-600 dark:text-red-400 line-clamp-2">
|
||||
{error.errorMessage}
|
||||
</p>
|
||||
{/* Model Name */}
|
||||
<div className="font-medium text-sm text-foreground mb-2">
|
||||
{call.modelName}
|
||||
</div>
|
||||
{/* Context Info - only for LLM calls */}
|
||||
{call.modelType === 'llm' &&
|
||||
call.botName &&
|
||||
call.pipelineName && (
|
||||
<div className="text-xs text-muted-foreground mb-1">
|
||||
{call.botName} → {call.pipelineName}
|
||||
</div>
|
||||
)}
|
||||
{/* Token Info */}
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{call.modelType === 'llm' &&
|
||||
call.tokens && (
|
||||
<>
|
||||
<span>
|
||||
{t(
|
||||
'monitoring.llmCalls.inputTokens',
|
||||
)}
|
||||
: {call.tokens.input}
|
||||
</span>
|
||||
<span>
|
||||
{t(
|
||||
'monitoring.llmCalls.outputTokens',
|
||||
)}
|
||||
: {call.tokens.output}
|
||||
</span>
|
||||
<span>
|
||||
{t(
|
||||
'monitoring.llmCalls.totalTokens',
|
||||
)}
|
||||
: {call.tokens.total}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{call.modelType === 'embedding' && (
|
||||
<>
|
||||
<span>
|
||||
{t(
|
||||
'monitoring.embeddingCalls.promptTokens',
|
||||
)}
|
||||
: {call.promptTokens}
|
||||
</span>
|
||||
<span>
|
||||
{t(
|
||||
'monitoring.embeddingCalls.totalTokens',
|
||||
)}
|
||||
: {call.totalTokens}
|
||||
</span>
|
||||
<span>
|
||||
{t(
|
||||
'monitoring.embeddingCalls.inputCount',
|
||||
)}
|
||||
: {call.inputCount}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<span>
|
||||
{t('monitoring.llmCalls.duration')}:{' '}
|
||||
{call.duration}ms
|
||||
</span>
|
||||
{call.cost && (
|
||||
<span>
|
||||
{t('monitoring.llmCalls.cost')}: $
|
||||
{call.cost.toFixed(4)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Knowledge Base Info for Embedding */}
|
||||
{call.modelType === 'embedding' &&
|
||||
call.knowledgeBaseId && (
|
||||
<div>
|
||||
{t(
|
||||
'monitoring.embeddingCalls.knowledgeBase',
|
||||
)}
|
||||
: {call.knowledgeBaseId}
|
||||
</div>
|
||||
)}
|
||||
{/* Query Text for Embedding Retrieve */}
|
||||
{call.modelType === 'embedding' &&
|
||||
call.queryText && (
|
||||
<div className="mt-2 p-2 bg-muted rounded text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{t(
|
||||
'monitoring.embeddingCalls.queryText',
|
||||
)}
|
||||
:{' '}
|
||||
</span>
|
||||
<span className="text-foreground">
|
||||
{call.queryText.length > 100
|
||||
? call.queryText.substring(0, 100) +
|
||||
'...'
|
||||
: call.queryText}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{call.errorMessage && (
|
||||
<div className="mt-2 text-xs text-red-600 dark:text-red-400">
|
||||
Error: {call.errorMessage}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timestamp */}
|
||||
<div className="flex flex-col items-end gap-2 ml-4">
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{error.timestamp.toLocaleString()}
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap ml-4">
|
||||
{call.timestamp.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Expanded Details */}
|
||||
{expandedErrorId === error.id && (
|
||||
<div className="border-t border-red-200 dark:border-red-900 p-5 bg-background">
|
||||
<div className="space-y-4 pl-8 border-l-2 border-red-300 dark:border-red-800 ml-4">
|
||||
{/* Error Details */}
|
||||
<div className="bg-red-50 dark:bg-red-900/20 rounded-lg p-3">
|
||||
<h4 className="text-sm font-semibold text-red-700 dark:text-red-400 mb-3">
|
||||
{t('monitoring.errors.errorMessage')}
|
||||
</h4>
|
||||
<div className="text-sm text-red-600 dark:text-red-400 whitespace-pre-wrap break-words">
|
||||
{error.errorMessage}
|
||||
{!loading &&
|
||||
(!data ||
|
||||
!data.modelCalls ||
|
||||
data.modelCalls.length === 0) && (
|
||||
<div className="flex flex-col items-center justify-center text-muted-foreground py-16 gap-2">
|
||||
<Sparkles className="h-[3rem] w-[3rem]" />
|
||||
<div className="text-sm">
|
||||
{t('monitoring.modelCalls.noData')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="tokens" className="p-3 m-0 sm:p-6">
|
||||
<TokenMonitoring
|
||||
botIds={
|
||||
filterState.selectedBots.length > 0
|
||||
? filterState.selectedBots
|
||||
: undefined
|
||||
}
|
||||
pipelineIds={
|
||||
filterState.selectedPipelines.length > 0
|
||||
? filterState.selectedPipelines
|
||||
: undefined
|
||||
}
|
||||
startTime={feedbackTimeRange.startTime}
|
||||
endTime={feedbackTimeRange.endTime}
|
||||
refreshKey={feedbackRefreshKey}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="feedback" className="p-3 m-0 sm:p-6">
|
||||
<div>
|
||||
{loading && (
|
||||
<div className="py-12 flex justify-center">
|
||||
<LoadingSpinner text={t('common.loading')} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && (
|
||||
<>
|
||||
{/* Feedback Stats Cards */}
|
||||
<div className="mb-6">
|
||||
<FeedbackStatsCards
|
||||
stats={feedbackStats}
|
||||
loading={feedbackLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Feedback List */}
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
{t('monitoring.feedback.feedbackList')}
|
||||
</h3>
|
||||
<FeedbackList
|
||||
feedback={feedbackList}
|
||||
loading={feedbackLoading}
|
||||
onViewMessage={jumpToMessage}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="errors" className="p-3 m-0 sm:p-6">
|
||||
<div>
|
||||
{loading && (
|
||||
<div className="py-12 flex justify-center">
|
||||
<LoadingSpinner text={t('common.loading')} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading &&
|
||||
data &&
|
||||
data.errors &&
|
||||
data.errors.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{data.errors.map((error) => (
|
||||
<div
|
||||
key={error.id}
|
||||
className="border border-red-200 dark:border-red-900 rounded-xl overflow-hidden transition-all duration-200"
|
||||
>
|
||||
{/* Error Header - Always Visible */}
|
||||
<div
|
||||
className="p-3 cursor-pointer hover:bg-red-50 dark:hover:bg-red-950/50 transition-colors bg-red-50/50 dark:bg-red-950/30 sm:p-5"
|
||||
onClick={() => toggleErrorExpand(error.id)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start flex-1">
|
||||
{/* Expand Icon */}
|
||||
<div className="mr-3 mt-0.5">
|
||||
{expandedErrorId === error.id ? (
|
||||
<ChevronDown className="w-5 h-5 text-red-500" />
|
||||
) : (
|
||||
<ChevronRight className="w-5 h-5 text-red-500" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error Info */}
|
||||
<div className="flex-1">
|
||||
{/* Query ID */}
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
Query ID: {error.messageId || '-'}
|
||||
</span>
|
||||
{error.messageId && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 px-1.5 text-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
jumpToMessage(error.messageId!);
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="w-3 h-3 mr-1" />
|
||||
{t(
|
||||
'monitoring.messageList.viewConversation',
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="font-medium text-sm text-red-700 dark:text-red-300">
|
||||
{error.errorType}
|
||||
</span>
|
||||
<span className="text-red-400">→</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{error.botName}
|
||||
</span>
|
||||
<span className="text-red-400">→</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{error.pipelineName}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-red-600 dark:text-red-400 line-clamp-2">
|
||||
{error.errorMessage}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timestamp */}
|
||||
<div className="flex flex-col items-end gap-2 ml-4">
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{error.timestamp.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Context Info */}
|
||||
<div className="bg-muted rounded-lg p-3">
|
||||
<h4 className="text-sm font-semibold text-foreground mb-3">
|
||||
{t('monitoring.messageList.viewDetails')}
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 text-xs">
|
||||
<div className="bg-background rounded p-2">
|
||||
<div className="text-muted-foreground">
|
||||
{t('monitoring.messageList.bot')}
|
||||
</div>
|
||||
<div className="font-medium text-foreground">
|
||||
{error.botName}
|
||||
{/* Expanded Details */}
|
||||
{expandedErrorId === error.id && (
|
||||
<div className="border-t border-red-200 dark:border-red-900 p-5 bg-background">
|
||||
<div className="space-y-4 pl-8 border-l-2 border-red-300 dark:border-red-800 ml-4">
|
||||
{/* Error Details */}
|
||||
<div className="bg-red-50 dark:bg-red-900/20 rounded-lg p-3">
|
||||
<h4 className="text-sm font-semibold text-red-700 dark:text-red-400 mb-3">
|
||||
{t('monitoring.errors.errorMessage')}
|
||||
</h4>
|
||||
<div className="text-sm text-red-600 dark:text-red-400 whitespace-pre-wrap break-words">
|
||||
{error.errorMessage}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-background rounded p-2">
|
||||
<div className="text-muted-foreground">
|
||||
{t('monitoring.messageList.pipeline')}
|
||||
</div>
|
||||
<div className="font-medium text-foreground">
|
||||
{error.pipelineName}
|
||||
|
||||
{/* Context Info */}
|
||||
<div className="bg-muted rounded-lg p-3">
|
||||
<h4 className="text-sm font-semibold text-foreground mb-3">
|
||||
{t('monitoring.messageList.viewDetails')}
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 text-xs">
|
||||
<div className="bg-background rounded p-2">
|
||||
<div className="text-muted-foreground">
|
||||
{t('monitoring.messageList.bot')}
|
||||
</div>
|
||||
<div className="font-medium text-foreground">
|
||||
{error.botName}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-background rounded p-2">
|
||||
<div className="text-muted-foreground">
|
||||
{t('monitoring.messageList.pipeline')}
|
||||
</div>
|
||||
<div className="font-medium text-foreground">
|
||||
{error.pipelineName}
|
||||
</div>
|
||||
</div>
|
||||
{error.sessionId && (
|
||||
<div className="bg-background rounded p-2">
|
||||
<div className="text-muted-foreground">
|
||||
{t('monitoring.sessions.sessionId')}
|
||||
</div>
|
||||
<div className="font-medium text-foreground truncate">
|
||||
{error.sessionId}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{error.sessionId && (
|
||||
<div className="bg-background rounded p-2">
|
||||
<div className="text-muted-foreground">
|
||||
{t('monitoring.sessions.sessionId')}
|
||||
</div>
|
||||
<div className="font-medium text-foreground truncate">
|
||||
{error.sessionId}
|
||||
</div>
|
||||
|
||||
{/* Stack Trace */}
|
||||
{error.stackTrace && (
|
||||
<div className="bg-muted rounded-lg p-3">
|
||||
<h4 className="text-sm font-semibold text-foreground mb-3">
|
||||
{t('monitoring.errors.stackTrace')}
|
||||
</h4>
|
||||
<pre className="text-xs text-muted-foreground overflow-auto max-h-60 bg-background p-3 rounded whitespace-pre-wrap break-words">
|
||||
{error.stackTrace}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stack Trace */}
|
||||
{error.stackTrace && (
|
||||
<div className="bg-muted rounded-lg p-3">
|
||||
<h4 className="text-sm font-semibold text-foreground mb-3">
|
||||
{t('monitoring.errors.stackTrace')}
|
||||
</h4>
|
||||
<pre className="text-xs text-muted-foreground overflow-auto max-h-60 bg-background p-3 rounded whitespace-pre-wrap break-words">
|
||||
{error.stackTrace}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{!loading &&
|
||||
(!data || !data.errors || data.errors.length === 0) && (
|
||||
<div className="flex flex-col items-center justify-center text-muted-foreground py-16 gap-2">
|
||||
<CheckCircle2 className="h-[3rem] w-[3rem] text-green-500 dark:text-green-600" />
|
||||
<div className="text-sm text-green-600 dark:text-green-400">
|
||||
{t('monitoring.errors.noErrors')}
|
||||
{!loading &&
|
||||
(!data || !data.errors || data.errors.length === 0) && (
|
||||
<div className="flex flex-col items-center justify-center text-muted-foreground py-16 gap-2">
|
||||
<CheckCircle2 className="h-[3rem] w-[3rem] text-green-500 dark:text-green-600" />
|
||||
<div className="text-sm text-green-600 dark:text-green-400">
|
||||
{t('monitoring.errors.noErrors')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -217,6 +217,11 @@ export interface FeedbackStats {
|
||||
}
|
||||
|
||||
export interface MonitoringData {
|
||||
traffic?: {
|
||||
bucket: 'hour' | 'day';
|
||||
points: Array<{ timestamp: Date; messages: number; llmCalls: number }>;
|
||||
truncated: boolean;
|
||||
};
|
||||
overview: OverviewMetrics;
|
||||
messages: MonitoringMessage[];
|
||||
llmCalls: LLMCall[];
|
||||
|
||||
@@ -155,17 +155,18 @@ function findTurnBySessionTime(
|
||||
sessionTurns: Map<string, ConversationTurn[]>,
|
||||
sessionId: string | undefined,
|
||||
timestamp: Date,
|
||||
botId: string,
|
||||
): ConversationTurn | undefined {
|
||||
if (!sessionId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const turns = sessionTurns.get(sessionId);
|
||||
const turns = sessionTurns.get(JSON.stringify([botId, sessionId]));
|
||||
if (!turns?.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let nearest = turns[0];
|
||||
let nearest: ConversationTurn | undefined;
|
||||
const targetTime = timestamp.getTime();
|
||||
|
||||
for (const turn of turns) {
|
||||
@@ -203,15 +204,16 @@ export function buildConversationTurns(
|
||||
|
||||
for (const message of visibleMessages) {
|
||||
const role = normalizeRole(message, activityMessageIds);
|
||||
const previousTurn = lastTurnBySession.get(message.sessionId);
|
||||
const sessionKey = JSON.stringify([message.botId, message.sessionId]);
|
||||
const previousTurn = lastTurnBySession.get(sessionKey);
|
||||
const shouldStartTurn = role === 'user' || !previousTurn;
|
||||
const turn = shouldStartTurn ? createTurn(message) : previousTurn;
|
||||
|
||||
if (shouldStartTurn) {
|
||||
const turns = sessionTurns.get(message.sessionId) ?? [];
|
||||
const turns = sessionTurns.get(sessionKey) ?? [];
|
||||
turns.push(turn);
|
||||
sessionTurns.set(message.sessionId, turns);
|
||||
lastTurnBySession.set(message.sessionId, turn);
|
||||
sessionTurns.set(sessionKey, turns);
|
||||
lastTurnBySession.set(sessionKey, turn);
|
||||
}
|
||||
|
||||
addMessageToTurn(turn, message, role);
|
||||
@@ -221,9 +223,14 @@ export function buildConversationTurns(
|
||||
const allTurns = Array.from(sessionTurns.values()).flat();
|
||||
|
||||
for (const call of llmCalls) {
|
||||
const turn =
|
||||
(call.messageId ? messageIdToTurn.get(call.messageId) : undefined) ??
|
||||
findTurnBySessionTime(sessionTurns, call.sessionId, call.timestamp);
|
||||
const turn = call.messageId
|
||||
? messageIdToTurn.get(call.messageId)
|
||||
: findTurnBySessionTime(
|
||||
sessionTurns,
|
||||
call.sessionId,
|
||||
call.timestamp,
|
||||
call.botId,
|
||||
);
|
||||
|
||||
if (!turn) {
|
||||
continue;
|
||||
@@ -243,9 +250,14 @@ export function buildConversationTurns(
|
||||
}
|
||||
|
||||
for (const call of toolCalls) {
|
||||
const turn =
|
||||
(call.messageId ? messageIdToTurn.get(call.messageId) : undefined) ??
|
||||
findTurnBySessionTime(sessionTurns, call.sessionId, call.timestamp);
|
||||
const turn = call.messageId
|
||||
? messageIdToTurn.get(call.messageId)
|
||||
: findTurnBySessionTime(
|
||||
sessionTurns,
|
||||
call.sessionId,
|
||||
call.timestamp,
|
||||
call.botId,
|
||||
);
|
||||
|
||||
if (!turn) {
|
||||
continue;
|
||||
@@ -262,9 +274,14 @@ export function buildConversationTurns(
|
||||
}
|
||||
|
||||
for (const error of errors) {
|
||||
const turn =
|
||||
(error.messageId ? messageIdToTurn.get(error.messageId) : undefined) ??
|
||||
findTurnBySessionTime(sessionTurns, error.sessionId, error.timestamp);
|
||||
const turn = error.messageId
|
||||
? messageIdToTurn.get(error.messageId)
|
||||
: findTurnBySessionTime(
|
||||
sessionTurns,
|
||||
error.sessionId,
|
||||
error.timestamp,
|
||||
error.botId,
|
||||
);
|
||||
|
||||
if (!turn) {
|
||||
continue;
|
||||
|
||||
@@ -563,10 +563,24 @@ export class BackendClient extends BaseHttpClient {
|
||||
return this.get(`/api/v1/monitoring/sessions?${queryParams.toString()}`);
|
||||
}
|
||||
|
||||
public getSessionAnalysis<T>(
|
||||
sessionId: string,
|
||||
botId: string,
|
||||
options: { startTime?: string; endTime?: string } = {},
|
||||
): Promise<T> {
|
||||
const queryParams = new URLSearchParams({ botId });
|
||||
if (options.startTime) queryParams.set('startTime', options.startTime);
|
||||
if (options.endTime) queryParams.set('endTime', options.endTime);
|
||||
return this.get(
|
||||
`/api/v1/monitoring/sessions/${encodeURIComponent(sessionId)}/analysis?${queryParams.toString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
public getSessionMessages(
|
||||
sessionId: string,
|
||||
limit: number = 200,
|
||||
offset: number = 0,
|
||||
botId?: string,
|
||||
): Promise<{
|
||||
messages: Array<{
|
||||
id: string;
|
||||
@@ -590,6 +604,7 @@ export class BackendClient extends BaseHttpClient {
|
||||
}> {
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.append('sessionId', sessionId);
|
||||
if (botId) queryParams.append('botId', botId);
|
||||
queryParams.append('limit', limit.toString());
|
||||
queryParams.append('offset', offset.toString());
|
||||
return this.get(`/api/v1/monitoring/messages?${queryParams.toString()}`);
|
||||
@@ -1289,12 +1304,92 @@ export class BackendClient extends BaseHttpClient {
|
||||
invitation_registration_enabled?: boolean;
|
||||
password_login_enabled?: boolean;
|
||||
space_login_enabled?: boolean;
|
||||
passkey_login_enabled?: boolean;
|
||||
passkey_supported?: boolean;
|
||||
}> {
|
||||
return this.get('/api/v1/user/account-info', undefined, {
|
||||
skipWorkspace: true,
|
||||
});
|
||||
}
|
||||
|
||||
// ============ Passkey (WebAuthn) API ============
|
||||
public getPasskeyAuthOptions(
|
||||
email?: string,
|
||||
origin?: string,
|
||||
): Promise<{ options: any; challenge_token: string }> {
|
||||
return this.post(
|
||||
'/api/v1/user/passkey/auth/options',
|
||||
{ email, origin },
|
||||
{ skipWorkspace: true },
|
||||
);
|
||||
}
|
||||
|
||||
public verifyPasskeyAuth(
|
||||
challenge_token: string,
|
||||
credential: any,
|
||||
): Promise<{ token: string; user: string }> {
|
||||
return this.post(
|
||||
'/api/v1/user/passkey/auth/verify',
|
||||
{ challenge_token, credential },
|
||||
{ skipWorkspace: true },
|
||||
);
|
||||
}
|
||||
|
||||
public getPasskeyRegisterOptions(
|
||||
origin?: string,
|
||||
): Promise<{ options: any; challenge_token: string }> {
|
||||
return this.post(
|
||||
'/api/v1/user/passkey/register/options',
|
||||
{ origin },
|
||||
{ skipWorkspace: true },
|
||||
);
|
||||
}
|
||||
|
||||
public verifyPasskeyRegister(
|
||||
challenge_token: string,
|
||||
credential: any,
|
||||
name?: string,
|
||||
): Promise<{ uuid: string; name: string; created_at?: string }> {
|
||||
return this.post(
|
||||
'/api/v1/user/passkey/register/verify',
|
||||
{ challenge_token, credential, name },
|
||||
{ skipWorkspace: true },
|
||||
);
|
||||
}
|
||||
|
||||
public getPasskeys(): Promise<
|
||||
Array<{
|
||||
uuid: string;
|
||||
name: string;
|
||||
aaguid?: string;
|
||||
transports?: string;
|
||||
backed_up?: boolean;
|
||||
created_at?: string;
|
||||
last_used_at?: string;
|
||||
}>
|
||||
> {
|
||||
return this.get('/api/v1/user/passkeys', undefined, {
|
||||
skipWorkspace: true,
|
||||
});
|
||||
}
|
||||
|
||||
public renamePasskey(
|
||||
uuid: string,
|
||||
name: string,
|
||||
): Promise<{ uuid: string; name: string }> {
|
||||
return this.patch(
|
||||
`/api/v1/user/passkey/${encodeURIComponent(uuid)}`,
|
||||
{ name },
|
||||
{ skipWorkspace: true },
|
||||
);
|
||||
}
|
||||
|
||||
public deletePasskey(uuid: string): Promise<void> {
|
||||
return this.delete(`/api/v1/user/passkey/${encodeURIComponent(uuid)}`, {
|
||||
skipWorkspace: true,
|
||||
});
|
||||
}
|
||||
|
||||
// ============ Workspace API ============
|
||||
public getWorkspaceBootstrap(): Promise<WorkspaceBootstrapResponse> {
|
||||
return this.get('/api/v1/workspaces/bootstrap', undefined, {
|
||||
@@ -1496,6 +1591,11 @@ export class BackendClient extends BaseHttpClient {
|
||||
endTime?: string;
|
||||
limit?: number;
|
||||
}): Promise<{
|
||||
traffic?: {
|
||||
bucket: 'hour' | 'day';
|
||||
points: Array<{ timestamp: string; messages: number; llm_calls: number }>;
|
||||
truncated: boolean;
|
||||
};
|
||||
overview: {
|
||||
total_messages: number;
|
||||
llm_calls: number;
|
||||
|
||||
@@ -35,7 +35,9 @@ import {
|
||||
AlertCircle,
|
||||
RefreshCw,
|
||||
Layers,
|
||||
Fingerprint,
|
||||
} from 'lucide-react';
|
||||
import { startAuthentication } from '@simplewebauthn/browser';
|
||||
import langbotIcon from '@/app/assets/langbot-logo.webp';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -63,6 +65,8 @@ export default function Login() {
|
||||
const [spaceLoading, setSpaceLoading] = useState(false);
|
||||
const [showLocalLogin, setShowLocalLogin] = useState(false);
|
||||
const [showSpaceLogin, setShowSpaceLogin] = useState(false);
|
||||
const [showPasskeyLogin, setShowPasskeyLogin] = useState(false);
|
||||
const [passkeyLoading, setPasskeyLoading] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [retrying, setRetrying] = useState(false);
|
||||
@@ -90,6 +94,9 @@ export default function Login() {
|
||||
}
|
||||
setShowLocalLogin(res.password_login_enabled !== false);
|
||||
setShowSpaceLogin(res.space_login_enabled !== false);
|
||||
setShowPasskeyLogin(
|
||||
res.passkey_login_enabled !== false || Boolean(res.passkey_supported),
|
||||
);
|
||||
setLoading(false);
|
||||
|
||||
// Also check if already logged in
|
||||
@@ -184,6 +191,30 @@ export default function Login() {
|
||||
handleLogin(values.email, values.password);
|
||||
}
|
||||
|
||||
async function handlePasskeyLogin() {
|
||||
setPasskeyLoading(true);
|
||||
try {
|
||||
const { options, challenge_token } =
|
||||
await httpClient.getPasskeyAuthOptions(
|
||||
undefined,
|
||||
window.location.origin,
|
||||
);
|
||||
const authResp = await startAuthentication({ optionsJSON: options });
|
||||
const res = await httpClient.verifyPasskeyAuth(challenge_token, authResp);
|
||||
if (await finishLogin(res.token, res.user)) {
|
||||
toast.success(t('common.passkeyLoginSuccess'));
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error?.name === 'NotAllowedError') {
|
||||
// User cancelled the biometric prompt
|
||||
} else {
|
||||
toast.error(error?.message || t('common.passkeyLoginFailed'));
|
||||
}
|
||||
} finally {
|
||||
setPasskeyLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleLogin(username: string, password: string) {
|
||||
httpClient
|
||||
.authUser(username, password)
|
||||
@@ -324,8 +355,27 @@ export default function Login() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showPasskeyLogin && (
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full cursor-pointer"
|
||||
onClick={handlePasskeyLogin}
|
||||
disabled={passkeyLoading}
|
||||
>
|
||||
{passkeyLoading ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Fingerprint className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{t('common.loginWithPasskey')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Divider - only show if both login methods are available */}
|
||||
{showSpaceLogin && showLocalLogin && (
|
||||
{(showSpaceLogin || showPasskeyLogin) && showLocalLogin && (
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t" />
|
||||
|
||||
@@ -86,6 +86,10 @@ const enUS = {
|
||||
'Recommended: Use official stable model APIs and cloud services',
|
||||
loginLocal: 'Login with local account',
|
||||
loginWithPassword: 'Login with password',
|
||||
loginWithPasskey: 'Sign in with Passkey',
|
||||
passkeyLoginSuccess: 'Passkey verified successfully, signing in...',
|
||||
passkeyLoginFailed: 'Failed to sign in with Passkey',
|
||||
passkeyNotSupported: 'Passkey is not supported on this browser or device',
|
||||
spaceLoginTitle: 'Login with LangBot Account',
|
||||
spaceLoginDescription:
|
||||
'Scan the QR code or visit the link below to authorize',
|
||||
@@ -1339,6 +1343,20 @@ const enUS = {
|
||||
bindSpaceWarning:
|
||||
'After binding, your login email will be changed from {{localEmail}} to the LangBot Account email.',
|
||||
bindSpaceSuccess: 'LangBot Account bound successfully',
|
||||
passkeySectionTitle: 'Passkeys',
|
||||
passkeySectionDesc:
|
||||
'Sign in securely without passwords using biometrics or security keys',
|
||||
addPasskey: 'Add Passkey',
|
||||
passkeyName: 'Key Name',
|
||||
passkeyNamePlaceholder: 'e.g., MacBook Touch ID, YubiKey',
|
||||
passkeyCreated: 'Created on {{date}}',
|
||||
passkeyLastUsed: 'Last used: {{date}}',
|
||||
noPasskeys: 'No passkeys registered yet',
|
||||
deletePasskeyConfirm:
|
||||
'Are you sure you want to delete this passkey? You will no longer be able to use it to sign in.',
|
||||
passkeyAddedSuccess: 'Passkey added successfully',
|
||||
passkeyDeleteSuccess: 'Passkey deleted',
|
||||
passkeyRenameSuccess: 'Passkey renamed successfully',
|
||||
bindSpaceFailed: 'Failed to bind LangBot Account',
|
||||
bindSpaceInvalidState:
|
||||
'Invalid bind request. Please try again from account settings.',
|
||||
@@ -1644,7 +1662,16 @@ const enUS = {
|
||||
queryVariables: {
|
||||
title: 'Query Variables',
|
||||
},
|
||||
loadError: 'Failed to load monitoring data',
|
||||
partialMessages:
|
||||
'Showing {{shown}} of {{total}} messages. Conversation traces may be incomplete.',
|
||||
partialModelCalls: 'Showing {{shown}} of {{total}} model calls.',
|
||||
partialToolCalls:
|
||||
'Showing {{shown}} of {{total}} tool calls. Conversation traces may be incomplete.',
|
||||
partialErrors: 'Showing {{shown}} of {{total}} errors.',
|
||||
trafficChart: {
|
||||
unavailable: 'Traffic aggregation unavailable',
|
||||
truncated: 'Traffic range truncated. Choose a shorter time range.',
|
||||
title: 'Traffic Overview',
|
||||
messages: 'Messages',
|
||||
llmCalls: 'LLM Calls',
|
||||
|
||||
@@ -89,6 +89,11 @@ const esES = {
|
||||
'Recomendado: Usa API de modelos oficiales estables y servicios en la nube',
|
||||
loginLocal: 'Iniciar sesión con cuenta local',
|
||||
loginWithPassword: 'Iniciar sesión con contraseña',
|
||||
loginWithPasskey: 'Iniciar sesión con Passkey',
|
||||
passkeyLoginSuccess: 'Passkey verificada con éxito, iniciando sesión...',
|
||||
passkeyLoginFailed: 'Error al iniciar sesión con Passkey',
|
||||
passkeyNotSupported:
|
||||
'Passkey no es compatible en este navegador o dispositivo',
|
||||
spaceLoginTitle: 'Iniciar sesión con una cuenta de LangBot',
|
||||
spaceLoginDescription:
|
||||
'Escanea el código QR o visita el enlace para autorizar',
|
||||
@@ -1376,6 +1381,20 @@ const esES = {
|
||||
bindSpaceWarning:
|
||||
'Después de vincular, tu correo de inicio de sesión se cambiará de {{localEmail}} al correo de la cuenta de LangBot.',
|
||||
bindSpaceSuccess: 'Cuenta de LangBot vinculada correctamente',
|
||||
passkeySectionTitle: 'Llaves de acceso (Passkeys)',
|
||||
passkeySectionDesc:
|
||||
'Inicia sesión de forma segura sin contraseñas usando biometría o llaves de seguridad',
|
||||
addPasskey: 'Añadir llave de acceso',
|
||||
passkeyName: 'Nombre de la llave',
|
||||
passkeyNamePlaceholder: 'p. ej., MacBook Touch ID, YubiKey',
|
||||
passkeyCreated: 'Creada el {{date}}',
|
||||
passkeyLastUsed: 'Último uso: {{date}}',
|
||||
noPasskeys: 'No hay llaves de acceso registradas',
|
||||
deletePasskeyConfirm:
|
||||
'¿Seguro que deseas eliminar esta llave de acceso? Ya no podrás usarla para iniciar sesión.',
|
||||
passkeyAddedSuccess: 'Llave de acceso añadida con éxito',
|
||||
passkeyDeleteSuccess: 'Llave de acceso eliminada',
|
||||
passkeyRenameSuccess: 'Nombre de llave de acceso modificado con éxito',
|
||||
bindSpaceFailed: 'Error al vincular la cuenta de LangBot',
|
||||
bindSpaceInvalidState:
|
||||
'Solicitud de vinculación no válida. Por favor, inténtalo de nuevo desde la configuración de la cuenta.',
|
||||
@@ -1602,7 +1621,17 @@ const esES = {
|
||||
queryVariables: {
|
||||
title: 'Variables de consulta',
|
||||
},
|
||||
loadError: 'No se pudieron cargar los datos de monitoreo',
|
||||
partialMessages:
|
||||
'Se muestran {{shown}} de {{total}} mensajes. Las trazas de conversación pueden estar incompletas.',
|
||||
partialModelCalls: 'Se muestran {{shown}} de {{total}} llamadas al modelo.',
|
||||
partialToolCalls:
|
||||
'Se muestran {{shown}} de {{total}} llamadas a herramientas. Las trazas de conversación pueden estar incompletas.',
|
||||
partialErrors: 'Se muestran {{shown}} de {{total}} errores.',
|
||||
trafficChart: {
|
||||
unavailable: 'Agregación de tráfico no disponible',
|
||||
truncated:
|
||||
'Rango de tráfico truncado. Selecciona un intervalo más corto.',
|
||||
title: 'Resumen de tráfico',
|
||||
messages: 'Mensajes',
|
||||
llmCalls: 'Llamadas LLM',
|
||||
|
||||
@@ -87,6 +87,11 @@ const jaJP = {
|
||||
'おすすめ:公式の安定したモデル API とクラウドサービスを利用',
|
||||
loginLocal: 'ローカルアカウントでログイン',
|
||||
loginWithPassword: 'パスワードでログイン',
|
||||
loginWithPasskey: 'パスキーでログイン',
|
||||
passkeyLoginSuccess: 'パスキーの認証に成功しました。ログイン中...',
|
||||
passkeyLoginFailed: 'パスキーでのログインに失敗しました',
|
||||
passkeyNotSupported:
|
||||
'お使いのブラウザまたはデバイスはパスキーをサポートしていません',
|
||||
spaceLoginTitle: 'LangBot アカウントでログイン',
|
||||
spaceLoginDescription:
|
||||
'QRコードをスキャンするか、下のリンクにアクセスして認証してください',
|
||||
@@ -1345,6 +1350,20 @@ const jaJP = {
|
||||
bindSpaceWarning:
|
||||
'連携後、ログインメールアドレスは {{localEmail}} から LangBot アカウントのメールアドレスに変更されます。',
|
||||
bindSpaceSuccess: 'LangBot アカウントの連携に成功しました',
|
||||
passkeySectionTitle: 'パスキー (Passkey)',
|
||||
passkeySectionDesc:
|
||||
'生体認証やセキュリティキーを使って、パスワード不要で安全にログインします',
|
||||
addPasskey: 'パスキーを追加',
|
||||
passkeyName: 'キー名',
|
||||
passkeyNamePlaceholder: '例: MacBook Touch ID、YubiKey',
|
||||
passkeyCreated: '作成日: {{date}}',
|
||||
passkeyLastUsed: '最終使用: {{date}}',
|
||||
noPasskeys: '登録されているパスキーはありません',
|
||||
deletePasskeyConfirm:
|
||||
'このパスキーを削除してもよろしいですか?削除後はこのキーでのログインができなくなります。',
|
||||
passkeyAddedSuccess: 'パスキーが正常に追加されました',
|
||||
passkeyDeleteSuccess: 'パスキーを削除しました',
|
||||
passkeyRenameSuccess: 'パスキー名を変更しました',
|
||||
bindSpaceFailed: 'LangBot アカウントの連携に失敗しました',
|
||||
bindSpaceInvalidState:
|
||||
'無効な連携リクエストです。アカウント設定から再度お試しください。',
|
||||
@@ -1653,7 +1672,17 @@ const jaJP = {
|
||||
queryVariables: {
|
||||
title: 'クエリ変数',
|
||||
},
|
||||
loadError: 'モニタリングデータを読み込めませんでした',
|
||||
partialMessages:
|
||||
'全 {{total}} 件中 {{shown}} 件のメッセージを表示。会話トレースは不完全な場合があります。',
|
||||
partialModelCalls: '全 {{total}} 件中 {{shown}} 件のモデル呼び出しを表示。',
|
||||
partialToolCalls:
|
||||
'全 {{total}} 件中 {{shown}} 件のツール呼び出しを表示。会話トレースは不完全な場合があります。',
|
||||
partialErrors: '全 {{total}} 件中 {{shown}} 件のエラーを表示。',
|
||||
trafficChart: {
|
||||
unavailable: 'トラフィック集計を利用できません',
|
||||
truncated:
|
||||
'トラフィック範囲が切り詰められています。短い期間を選択してください。',
|
||||
title: 'トラフィック概要',
|
||||
messages: 'メッセージ',
|
||||
llmCalls: 'LLM呼び出し',
|
||||
|
||||
@@ -86,6 +86,11 @@ const ruRU = {
|
||||
'Рекомендуется: Используйте официальные стабильные API моделей и облачные сервисы',
|
||||
loginLocal: 'Войти с локальной учётной записью',
|
||||
loginWithPassword: 'Войти с паролем',
|
||||
loginWithPasskey: 'Войти с помощью Passkey',
|
||||
passkeyLoginSuccess: 'Passkey успешно подтверждён, вход...',
|
||||
passkeyLoginFailed: 'Не удалось войти с помощью Passkey',
|
||||
passkeyNotSupported:
|
||||
'Passkey не поддерживается в этом браузере или на устройстве',
|
||||
spaceLoginTitle: 'Войти с аккаунтом LangBot',
|
||||
spaceLoginDescription:
|
||||
'Отсканируйте QR-код или перейдите по ссылке ниже для авторизации',
|
||||
@@ -1350,6 +1355,20 @@ const ruRU = {
|
||||
bindSpaceWarning:
|
||||
'После привязки ваш email для входа будет изменён с {{localEmail}} на email аккаунта LangBot.',
|
||||
bindSpaceSuccess: 'Аккаунт LangBot успешно привязан',
|
||||
passkeySectionTitle: 'Ключи доступа (Passkey)',
|
||||
passkeySectionDesc:
|
||||
'Безопасный вход без пароля с помощью биометрии или аппаратного ключа',
|
||||
addPasskey: 'Добавить ключ доступа',
|
||||
passkeyName: 'Название ключа',
|
||||
passkeyNamePlaceholder: 'например, MacBook Touch ID, YubiKey',
|
||||
passkeyCreated: 'Создан {{date}}',
|
||||
passkeyLastUsed: 'Последнее использование: {{date}}',
|
||||
noPasskeys: 'Нет зарегистрированных ключей доступа',
|
||||
deletePasskeyConfirm:
|
||||
'Вы уверены, что хотите удалить этот ключ доступа? Вы больше не сможете использовать его для входа.',
|
||||
passkeyAddedSuccess: 'Ключ доступа успешно добавлен',
|
||||
passkeyDeleteSuccess: 'Ключ доступа удален',
|
||||
passkeyRenameSuccess: 'Ключ доступа успешно переименован',
|
||||
bindSpaceFailed: 'Не удалось привязать аккаунт LangBot',
|
||||
bindSpaceInvalidState:
|
||||
'Недействительный запрос привязки. Повторите попытку из настроек аккаунта.',
|
||||
@@ -1574,7 +1593,16 @@ const ruRU = {
|
||||
queryVariables: {
|
||||
title: 'Переменные запроса',
|
||||
},
|
||||
loadError: 'Не удалось загрузить данные мониторинга',
|
||||
partialMessages:
|
||||
'Показано {{shown}} из {{total}} сообщений. Трассировки диалогов могут быть неполными.',
|
||||
partialModelCalls: 'Показано {{shown}} из {{total}} вызовов модели.',
|
||||
partialToolCalls:
|
||||
'Показано {{shown}} из {{total}} вызовов инструментов. Трассировки диалогов могут быть неполными.',
|
||||
partialErrors: 'Показано {{shown}} из {{total}} ошибок.',
|
||||
trafficChart: {
|
||||
unavailable: 'Агрегированные данные трафика недоступны',
|
||||
truncated: 'Диапазон трафика обрезан. Выберите более короткий период.',
|
||||
title: 'Обзор трафика',
|
||||
messages: 'Сообщения',
|
||||
llmCalls: 'Вызовы LLM',
|
||||
|
||||
@@ -86,6 +86,10 @@ const thTH = {
|
||||
'แนะนำ: ใช้ API โมเดลที่เสถียรอย่างเป็นทางการและบริการคลาวด์',
|
||||
loginLocal: 'เข้าสู่ระบบด้วยบัญชีท้องถิ่น',
|
||||
loginWithPassword: 'เข้าสู่ระบบด้วยรหัสผ่าน',
|
||||
loginWithPasskey: 'เข้าสู่ระบบด้วย Passkey',
|
||||
passkeyLoginSuccess: 'ยืนยัน Passkey สำเร็จ กำลังเข้าสู่ระบบ...',
|
||||
passkeyLoginFailed: 'เข้าสู่ระบบด้วย Passkey ล้มเหลว',
|
||||
passkeyNotSupported: 'เบราว์เซอร์หรืออุปกรณ์นี้ไม่รองรับ Passkey',
|
||||
spaceLoginTitle: 'เข้าสู่ระบบด้วยบัญชี LangBot',
|
||||
spaceLoginDescription:
|
||||
'สแกน QR code หรือเข้าชมลิงก์ด้านล่างเพื่อยืนยันสิทธิ์',
|
||||
@@ -1321,6 +1325,20 @@ const thTH = {
|
||||
bindSpaceWarning:
|
||||
'หลังจากผูกแล้ว อีเมลเข้าสู่ระบบของคุณจะเปลี่ยนจาก {{localEmail}} เป็นอีเมลบัญชี LangBot',
|
||||
bindSpaceSuccess: 'ผูกบัญชี LangBot สำเร็จ',
|
||||
passkeySectionTitle: 'พาสคีย์ (Passkey)',
|
||||
passkeySectionDesc:
|
||||
'เข้าสู่ระบบอย่างปลอดภัยโดยไม่ต้องใช้รหัสผ่านด้วยไบโอเมตริกซ์หรือคีย์ความปลอดภัย',
|
||||
addPasskey: 'เพิ่มพาสคีย์',
|
||||
passkeyName: 'ชื่อคีย์',
|
||||
passkeyNamePlaceholder: 'เช่น MacBook Touch ID, YubiKey',
|
||||
passkeyCreated: 'สร้างเมื่อ {{date}}',
|
||||
passkeyLastUsed: 'ใช้งานล่าสุด: {{date}}',
|
||||
noPasskeys: 'ยังไม่มีพาสคีย์ที่ลงทะเบียน',
|
||||
deletePasskeyConfirm:
|
||||
'คุณแน่ใจหรือไม่ว่าต้องการลบพาสคีย์นี้? คุณจะไม่สามารถใช้คีย์นี้เข้าสู่ระบบได้อีก',
|
||||
passkeyAddedSuccess: 'เพิ่มพาสคีย์สำเร็จ',
|
||||
passkeyDeleteSuccess: 'ลบพาสคีย์แล้ว',
|
||||
passkeyRenameSuccess: 'เปลี่ยนชื่อพาสคีย์สำเร็จ',
|
||||
bindSpaceFailed: 'ผูกบัญชี LangBot ล้มเหลว',
|
||||
bindSpaceInvalidState: 'คำขอผูกไม่ถูกต้อง กรุณาลองใหม่จากการตั้งค่าบัญชี',
|
||||
setPasswordHint: 'ตั้งรหัสผ่านเพื่อเข้าสู่ระบบด้วยอีเมลและรหัสผ่าน',
|
||||
@@ -1543,7 +1561,17 @@ const thTH = {
|
||||
queryVariables: {
|
||||
title: 'ตัวแปรคำค้นหา',
|
||||
},
|
||||
loadError: 'โหลดข้อมูลการตรวจสอบไม่สำเร็จ',
|
||||
partialMessages:
|
||||
'แสดง {{shown}} จาก {{total}} ข้อความ ประวัติการสนทนาอาจไม่ครบถ้วน',
|
||||
partialModelCalls: 'แสดง {{shown}} จาก {{total}} การเรียกโมเดล',
|
||||
partialToolCalls:
|
||||
'แสดง {{shown}} จาก {{total}} การเรียกเครื่องมือ ประวัติการสนทนาอาจไม่ครบถ้วน',
|
||||
partialErrors: 'แสดง {{shown}} จาก {{total}} ข้อผิดพลาด',
|
||||
trafficChart: {
|
||||
unavailable: 'ไม่มีข้อมูลสรุปปริมาณการใช้งาน',
|
||||
truncated:
|
||||
'ช่วงข้อมูลปริมาณการใช้งานถูกตัดทอน โปรดเลือกช่วงเวลาที่สั้นลง',
|
||||
title: 'ภาพรวมปริมาณการใช้งาน',
|
||||
messages: 'ข้อความ',
|
||||
llmCalls: 'การเรียก LLM',
|
||||
|
||||
@@ -87,6 +87,10 @@ const viVN = {
|
||||
'Khuyến nghị: Sử dụng API mô hình ổn định chính thức và dịch vụ đám mây',
|
||||
loginLocal: 'Đăng nhập với tài khoản cục bộ',
|
||||
loginWithPassword: 'Đăng nhập bằng mật khẩu',
|
||||
loginWithPasskey: 'Đăng nhập bằng Passkey',
|
||||
passkeyLoginSuccess: 'Xác thực Passkey thành công, đang đăng nhập...',
|
||||
passkeyLoginFailed: 'Đăng nhập bằng Passkey thất bại',
|
||||
passkeyNotSupported: 'Trình duyệt hoặc thiết bị này không hỗ trợ Passkey',
|
||||
spaceLoginTitle: 'Đăng nhập bằng tài khoản LangBot',
|
||||
spaceLoginDescription:
|
||||
'Quét mã QR hoặc truy cập liên kết bên dưới để ủy quyền',
|
||||
@@ -1344,6 +1348,20 @@ const viVN = {
|
||||
bindSpaceWarning:
|
||||
'Sau khi liên kết, email đăng nhập của bạn sẽ được đổi từ {{localEmail}} sang email tài khoản LangBot.',
|
||||
bindSpaceSuccess: 'Liên kết tài khoản LangBot thành công',
|
||||
passkeySectionTitle: 'Mã khóa truy cập (Passkey)',
|
||||
passkeySectionDesc:
|
||||
'Đăng nhập an toàn không cần mật khẩu bằng sinh trắc học hoặc khóa bảo mật',
|
||||
addPasskey: 'Thêm mã khóa truy cập',
|
||||
passkeyName: 'Tên khóa',
|
||||
passkeyNamePlaceholder: 'ví dụ: MacBook Touch ID, YubiKey',
|
||||
passkeyCreated: 'Được tạo vào {{date}}',
|
||||
passkeyLastUsed: 'Sử dụng lần cuối: {{date}}',
|
||||
noPasskeys: 'Chưa có mã khóa truy cập nào được đăng ký',
|
||||
deletePasskeyConfirm:
|
||||
'Bạn có chắc chắn muốn xóa mã khóa truy cập này? Bạn sẽ không thể sử dụng nó để đăng nhập nữa.',
|
||||
passkeyAddedSuccess: 'Đã thêm mã khóa truy cập thành công',
|
||||
passkeyDeleteSuccess: 'Đã xóa mã khóa truy cập',
|
||||
passkeyRenameSuccess: 'Đã đổi tên mã khóa truy cập thành công',
|
||||
bindSpaceFailed: 'Liên kết tài khoản LangBot thất bại',
|
||||
bindSpaceInvalidState:
|
||||
'Yêu cầu liên kết không hợp lệ. Vui lòng thử lại từ cài đặt tài khoản.',
|
||||
@@ -1567,7 +1585,17 @@ const viVN = {
|
||||
queryVariables: {
|
||||
title: 'Biến truy vấn',
|
||||
},
|
||||
loadError: 'Không thể tải dữ liệu giám sát',
|
||||
partialMessages:
|
||||
'Hiển thị {{shown}} trên {{total}} tin nhắn. Dấu vết hội thoại có thể không đầy đủ.',
|
||||
partialModelCalls: 'Hiển thị {{shown}} trên {{total}} lượt gọi mô hình.',
|
||||
partialToolCalls:
|
||||
'Hiển thị {{shown}} trên {{total}} lượt gọi công cụ. Dấu vết hội thoại có thể không đầy đủ.',
|
||||
partialErrors: 'Hiển thị {{shown}} trên {{total}} lỗi.',
|
||||
trafficChart: {
|
||||
unavailable: 'Không có dữ liệu tổng hợp lưu lượng',
|
||||
truncated:
|
||||
'Phạm vi lưu lượng bị cắt ngắn. Hãy chọn khoảng thời gian ngắn hơn.',
|
||||
title: 'Tổng quan lưu lượng',
|
||||
messages: 'Tin nhắn',
|
||||
llmCalls: 'Cuộc gọi LLM',
|
||||
|
||||
@@ -84,6 +84,10 @@ const zhHans = {
|
||||
spaceLoginRecommended: '推荐:使用官方提供的稳定模型 API 和云服务',
|
||||
loginLocal: '使用本地账号登录',
|
||||
loginWithPassword: '通过密码登录',
|
||||
loginWithPasskey: '使用 Passkey 登录',
|
||||
passkeyLoginSuccess: 'Passkey 验证成功,正在登录...',
|
||||
passkeyLoginFailed: 'Passkey 登录失败',
|
||||
passkeyNotSupported: '当前浏览器或设备不支持 Passkey',
|
||||
spaceLoginTitle: '通过 LangBot 账号登录',
|
||||
spaceLoginDescription: '扫描二维码或访问下方链接进行授权',
|
||||
spaceLoginUserCode: '您的验证码',
|
||||
@@ -1274,6 +1278,19 @@ const zhHans = {
|
||||
bindSpaceWarning:
|
||||
'绑定后,您的登录邮箱将从 {{localEmail}} 更改为 LangBot 账号的邮箱。',
|
||||
bindSpaceSuccess: 'LangBot 账号绑定成功',
|
||||
passkeySectionTitle: '通行密钥 (Passkey)',
|
||||
passkeySectionDesc: '使用指纹、面容或硬件安全密钥免密安全登录',
|
||||
addPasskey: '添加通行密钥',
|
||||
passkeyName: '密钥名称',
|
||||
passkeyNamePlaceholder: '例如:MacBook Touch ID、YubiKey',
|
||||
passkeyCreated: '创建于 {{date}}',
|
||||
passkeyLastUsed: '上次使用: {{date}}',
|
||||
noPasskeys: '暂未绑定任何通行密钥',
|
||||
deletePasskeyConfirm:
|
||||
'确定要删除此通行密钥吗?删除后将无法使用该密钥登录。',
|
||||
passkeyAddedSuccess: '通行密钥添加成功',
|
||||
passkeyDeleteSuccess: '通行密钥已删除',
|
||||
passkeyRenameSuccess: '通行密钥重命名成功',
|
||||
bindSpaceFailed: '绑定 LangBot 账号失败',
|
||||
bindSpaceInvalidState: '无效的绑定请求,请从账户设置重新发起',
|
||||
setPasswordHint: '设置密码后可使用邮箱密码登录',
|
||||
@@ -1572,7 +1589,16 @@ const zhHans = {
|
||||
queryVariables: {
|
||||
title: '查询变量',
|
||||
},
|
||||
loadError: '监控数据加载失败',
|
||||
partialMessages:
|
||||
'显示 {{total}} 条消息中的 {{shown}} 条,对话轨迹可能不完整。',
|
||||
partialModelCalls: '显示 {{total}} 次模型调用中的 {{shown}} 次。',
|
||||
partialToolCalls:
|
||||
'显示 {{total}} 次工具调用中的 {{shown}} 次,对话轨迹可能不完整。',
|
||||
partialErrors: '显示 {{total}} 条错误中的 {{shown}} 条。',
|
||||
trafficChart: {
|
||||
unavailable: '流量聚合数据不可用',
|
||||
truncated: '流量时间范围已截断,请选择更短的时间范围。',
|
||||
title: '流量概览',
|
||||
messages: '消息数',
|
||||
llmCalls: 'LLM调用',
|
||||
|
||||
@@ -84,6 +84,10 @@ const zhHant = {
|
||||
spaceLoginRecommended: '推薦:使用官方提供的穩定模型 API 和雲服務',
|
||||
loginLocal: '使用本地帳號登入',
|
||||
loginWithPassword: '透過密碼登入',
|
||||
loginWithPasskey: '使用 Passkey 登入',
|
||||
passkeyLoginSuccess: 'Passkey 驗證成功,正在登入...',
|
||||
passkeyLoginFailed: 'Passkey 登入失敗',
|
||||
passkeyNotSupported: '目前瀏覽器或裝置不支援 Passkey',
|
||||
spaceLoginTitle: '透過 LangBot 帳號登入',
|
||||
spaceLoginDescription: '掃描二維碼或訪問下方連結進行授權',
|
||||
spaceLoginUserCode: '您的驗證碼',
|
||||
@@ -1275,6 +1279,19 @@ const zhHant = {
|
||||
bindSpaceWarning:
|
||||
'綁定後,您的登入電子郵件將從 {{localEmail}} 更改為 LangBot 帳號的電子郵件。',
|
||||
bindSpaceSuccess: 'LangBot 帳號綁定成功',
|
||||
passkeySectionTitle: '通行密鑰 (Passkey)',
|
||||
passkeySectionDesc: '使用指紋、面容或硬體安全金鑰免密安全登入',
|
||||
addPasskey: '新增通行密鑰',
|
||||
passkeyName: '金鑰名稱',
|
||||
passkeyNamePlaceholder: '例如:MacBook Touch ID、YubiKey',
|
||||
passkeyCreated: '建立於 {{date}}',
|
||||
passkeyLastUsed: '上次使用: {{date}}',
|
||||
noPasskeys: '尚未綁定任何通行密鑰',
|
||||
deletePasskeyConfirm:
|
||||
'確定要刪除此通行密鑰嗎?刪除後將無法使用該金鑰登入。',
|
||||
passkeyAddedSuccess: '通行密鑰新增成功',
|
||||
passkeyDeleteSuccess: '通行密鑰已刪除',
|
||||
passkeyRenameSuccess: '通行密鑰重新命名成功',
|
||||
bindSpaceFailed: '綁定 LangBot 帳號失敗',
|
||||
bindSpaceInvalidState: '無效的綁定請求,請從帳戶設定重新發起',
|
||||
setPasswordHint: '設定密碼後可使用電子郵件密碼登入',
|
||||
@@ -1495,7 +1512,16 @@ const zhHant = {
|
||||
queryVariables: {
|
||||
title: '查詢變數',
|
||||
},
|
||||
loadError: '監控資料載入失敗',
|
||||
partialMessages:
|
||||
'顯示 {{total}} 則訊息中的 {{shown}} 則,對話軌跡可能不完整。',
|
||||
partialModelCalls: '顯示 {{total}} 次模型呼叫中的 {{shown}} 次。',
|
||||
partialToolCalls:
|
||||
'顯示 {{total}} 次工具呼叫中的 {{shown}} 次,對話軌跡可能不完整。',
|
||||
partialErrors: '顯示 {{total}} 筆錯誤中的 {{shown}} 筆。',
|
||||
trafficChart: {
|
||||
unavailable: '流量彙總資料無法使用',
|
||||
truncated: '流量時間範圍已截斷,請選擇較短的時間範圍。',
|
||||
title: '流量概覽',
|
||||
messages: '訊息',
|
||||
llmCalls: 'LLM呼叫',
|
||||
|
||||
@@ -66,7 +66,381 @@ function toolCall(
|
||||
};
|
||||
}
|
||||
|
||||
test.describe('bot session request recovery', () => {
|
||||
for (const failure of [
|
||||
'initial list',
|
||||
'list page',
|
||||
'session switch',
|
||||
'message page',
|
||||
'analysis',
|
||||
]) {
|
||||
test(`${failure} failure is visible and retry recovers`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
let failing = true;
|
||||
await page.route('**/api/v1/monitoring/**', async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const offset = Number(url.searchParams.get('offset') || 0);
|
||||
const second = url.searchParams.get('sessionId') === 'person-second';
|
||||
const list = url.pathname.endsWith('/sessions');
|
||||
const message = url.pathname.endsWith('/messages');
|
||||
const analysis = url.pathname.endsWith('/analysis');
|
||||
if (!list && !message && !analysis) return route.fallback();
|
||||
const fail =
|
||||
failing &&
|
||||
((list && failure === 'initial list') ||
|
||||
(list && failure === 'list page' && offset > 0) ||
|
||||
(message && failure === 'session switch' && second) ||
|
||||
(message && failure === 'message page' && offset > 0) ||
|
||||
(analysis && failure === 'analysis'));
|
||||
if (fail)
|
||||
return route.fulfill({
|
||||
status: 500,
|
||||
json: { code: 500, message: 'fixture failure' },
|
||||
});
|
||||
const data = list
|
||||
? {
|
||||
sessions: [sessionId, 'person-second'].map((id, i) => ({
|
||||
session_id: id,
|
||||
bot_id: botId,
|
||||
bot_name: botName,
|
||||
pipeline_id: pipelineId,
|
||||
pipeline_name: pipelineName,
|
||||
message_count: 51,
|
||||
start_time: at(0),
|
||||
last_activity: at(4),
|
||||
is_active: true,
|
||||
user_name: offset ? `Page two ${i}` : `Recovery user ${i}`,
|
||||
})),
|
||||
total: 21,
|
||||
}
|
||||
: message
|
||||
? {
|
||||
messages: [
|
||||
sessionMessage(
|
||||
'recovery-message',
|
||||
'user',
|
||||
0,
|
||||
second
|
||||
? 'Second session message'
|
||||
: offset
|
||||
? 'Second page message'
|
||||
: 'Successful message',
|
||||
),
|
||||
],
|
||||
total: 51,
|
||||
}
|
||||
: {
|
||||
tool_calls: [
|
||||
toolCall('recovery-tool', 1, 'recovered_tool', 40),
|
||||
],
|
||||
};
|
||||
return route.fulfill({ json: { code: 0, data } });
|
||||
});
|
||||
await page.goto(`/home/bots?id=${botId}`);
|
||||
await page.getByRole('tab', { name: /Sessions/ }).click();
|
||||
if (failure === 'list page') {
|
||||
await page.getByRole('button', { name: 'Next', exact: true }).click();
|
||||
} else if (failure !== 'initial list') {
|
||||
await page.getByRole('button', { name: /Recovery user 0/ }).click();
|
||||
if (failure !== 'analysis') {
|
||||
await expect(
|
||||
page.getByText('Successful message', { exact: true }),
|
||||
).toBeVisible();
|
||||
if (failure === 'session switch')
|
||||
await page.getByRole('button', { name: /Recovery user 1/ }).click();
|
||||
else
|
||||
await page
|
||||
.getByRole('button', { name: 'Next', exact: true })
|
||||
.last()
|
||||
.click();
|
||||
}
|
||||
}
|
||||
await expect(page.getByRole('alert')).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('No sessions found', { exact: true }),
|
||||
).toHaveCount(0);
|
||||
if (failure === 'analysis') {
|
||||
await expect(page.getByRole('alert')).toContainText(/Tool/i);
|
||||
await expect(
|
||||
page.getByText('Successful message', { exact: true }),
|
||||
).toBeVisible();
|
||||
} else {
|
||||
await expect(
|
||||
page.getByText('Successful message', { exact: true }),
|
||||
).toHaveCount(0);
|
||||
}
|
||||
if (failure === 'list page')
|
||||
await expect(
|
||||
page.getByRole('button', { name: /Recovery user 0/ }),
|
||||
).toHaveCount(0);
|
||||
failing = false;
|
||||
await page
|
||||
.getByRole('alert')
|
||||
.getByRole('button', { name: 'Retry', exact: true })
|
||||
.click();
|
||||
await expect(page.getByRole('alert')).toHaveCount(0);
|
||||
if (failure === 'initial list' || failure === 'list page') {
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: failure === 'list page' ? /Page two 0/ : /Recovery user 0/,
|
||||
}),
|
||||
).toBeVisible();
|
||||
} else {
|
||||
await expect(
|
||||
page.getByText(
|
||||
failure === 'session switch'
|
||||
? 'Second session message'
|
||||
: failure === 'message page'
|
||||
? 'Second page message'
|
||||
: 'Successful message',
|
||||
{ exact: true },
|
||||
),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('recovered_tool', { exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test.describe('bot session request races', () => {
|
||||
for (const kind of ['messages', 'analysis', 'sessions']) {
|
||||
for (const status of [200, 500]) {
|
||||
test(`ignores stale ${kind} ${status} after switching`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
let held = false;
|
||||
let released = false;
|
||||
await page.route('**/api/v1/monitoring/**', async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const list = url.pathname.endsWith('/sessions');
|
||||
const message = url.pathname.endsWith('/messages');
|
||||
const analysis = url.pathname.endsWith('/analysis');
|
||||
if (!list && !message && !analysis) return route.fallback();
|
||||
const old =
|
||||
kind === 'sessions'
|
||||
? url.searchParams.get('userQuery') === 'old'
|
||||
: message
|
||||
? url.searchParams.get('sessionId') === sessionId
|
||||
: url.pathname.includes(sessionId);
|
||||
const isHeld = old && url.pathname.endsWith(`/${kind}`);
|
||||
if (isHeld) {
|
||||
held = true;
|
||||
await gate;
|
||||
if (status === 500) {
|
||||
await route.fulfill({ status: 500, json: { code: 500 } });
|
||||
released = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
const data = list
|
||||
? {
|
||||
sessions: [sessionId, 'person-new'].map((id, i) => ({
|
||||
session_id: id,
|
||||
bot_id: botId,
|
||||
bot_name: botName,
|
||||
pipeline_id: pipelineId,
|
||||
pipeline_name: pipelineName,
|
||||
message_count: 1,
|
||||
start_time: at(0),
|
||||
last_activity: at(4),
|
||||
is_active: true,
|
||||
user_name: isHeld ? 'Stale list' : `Race user ${i}`,
|
||||
})),
|
||||
total: 2,
|
||||
}
|
||||
: message
|
||||
? {
|
||||
messages: [
|
||||
sessionMessage(
|
||||
'race-message',
|
||||
'user',
|
||||
0,
|
||||
old ? 'Old message' : 'Current message',
|
||||
),
|
||||
],
|
||||
total: 1,
|
||||
}
|
||||
: {
|
||||
tool_calls: [
|
||||
toolCall(
|
||||
'race-tool',
|
||||
1,
|
||||
old ? 'old_tool' : 'current_tool',
|
||||
40,
|
||||
),
|
||||
],
|
||||
};
|
||||
await route.fulfill({ json: { code: 0, data } });
|
||||
if (isHeld) released = true;
|
||||
});
|
||||
await page.goto(`/home/bots?id=${botId}`);
|
||||
await page.getByRole('tab', { name: /Sessions/ }).click();
|
||||
if (kind === 'sessions') {
|
||||
await page
|
||||
.getByRole('textbox', { name: 'User ID or name' })
|
||||
.fill('old');
|
||||
await page
|
||||
.getByRole('textbox', { name: 'User ID or name' })
|
||||
.press('Enter');
|
||||
} else await page.getByRole('button', { name: /Race user 0/ }).click();
|
||||
await expect.poll(() => held).toBe(true);
|
||||
if (kind === 'sessions') {
|
||||
await page
|
||||
.getByRole('textbox', { name: 'User ID or name' })
|
||||
.fill('new');
|
||||
await page
|
||||
.getByRole('textbox', { name: 'User ID or name' })
|
||||
.press('Enter');
|
||||
await expect(
|
||||
page.getByRole('button', { name: /Race user 0/ }),
|
||||
).toBeVisible();
|
||||
} else {
|
||||
await page.getByRole('button', { name: /Race user 1/ }).click();
|
||||
await expect(
|
||||
page.getByText('Current message', { exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
release();
|
||||
await expect.poll(() => released).toBe(true);
|
||||
// Allow the released HTTP response and React's queued update to settle.
|
||||
await page.waitForTimeout(200);
|
||||
await expect(page.getByRole('alert')).toHaveCount(0);
|
||||
await expect(page.getByText('Stale list', { exact: true })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
if (kind !== 'sessions') {
|
||||
await expect(
|
||||
page.getByText('Current message', { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('current_tool', { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Old message', { exact: true }),
|
||||
).toHaveCount(0);
|
||||
await expect(page.getByText('old_tool', { exact: true })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test.describe('bot session monitor tool timeline', () => {
|
||||
test('isolates messages and analysis for two bots sharing a raw session id', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
const requests: Array<{ bot: string; path: string }> = [];
|
||||
await page.route('**/api/v1/monitoring/**', async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const selectedBot = url.searchParams.get('botId');
|
||||
if (
|
||||
!url.pathname.endsWith('/sessions') &&
|
||||
!url.pathname.endsWith('/messages') &&
|
||||
!url.pathname.endsWith('/analysis')
|
||||
) {
|
||||
return route.fallback();
|
||||
}
|
||||
expect(['bot-shared-a', 'bot-shared-b']).toContain(selectedBot);
|
||||
expect(route.request().headers().authorization).toBe(
|
||||
'Bearer playwright-token',
|
||||
);
|
||||
expect(route.request().headers()['x-workspace-id']).toBe(
|
||||
'workspace-playwright',
|
||||
);
|
||||
requests.push({ bot: selectedBot!, path: url.pathname });
|
||||
const shared = {
|
||||
session_id: sessionId,
|
||||
bot_id: selectedBot,
|
||||
bot_name: selectedBot,
|
||||
pipeline_id: pipelineId,
|
||||
pipeline_name: pipelineName,
|
||||
message_count: 1,
|
||||
start_time: at(0),
|
||||
last_activity: at(4),
|
||||
is_active: true,
|
||||
platform: 'person',
|
||||
user_id: 'shared-user',
|
||||
user_name: 'Shared User',
|
||||
};
|
||||
const data = url.pathname.endsWith('/sessions')
|
||||
? { sessions: [shared], total: 1 }
|
||||
: url.pathname.endsWith('/messages')
|
||||
? {
|
||||
messages: [
|
||||
{
|
||||
...sessionMessage(
|
||||
'shared-message',
|
||||
'user',
|
||||
0,
|
||||
`Message for ${selectedBot}`,
|
||||
),
|
||||
bot_id: selectedBot,
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
}
|
||||
: {
|
||||
session_id: sessionId,
|
||||
found: true,
|
||||
tool_calls: [
|
||||
{
|
||||
...toolCall('shared-tool', 1, `tool_${selectedBot}`, 40),
|
||||
bot_id: selectedBot,
|
||||
},
|
||||
],
|
||||
};
|
||||
if (url.pathname.endsWith('/messages'))
|
||||
expect(url.searchParams.get('sessionId')).toBe(sessionId);
|
||||
if (url.pathname.endsWith('/analysis'))
|
||||
expect(decodeURIComponent(url.pathname)).toContain(
|
||||
`/sessions/${sessionId}/analysis`,
|
||||
);
|
||||
await route.fulfill({ json: { code: 0, data } });
|
||||
});
|
||||
for (const selectedBot of ['bot-shared-a', 'bot-shared-b']) {
|
||||
await page.goto(`/home/bots?id=${selectedBot}`);
|
||||
await page.getByRole('tab', { name: /Sessions/ }).click();
|
||||
await page.getByRole('button', { name: /Shared User/ }).click();
|
||||
await expect(
|
||||
page.getByText(`Message for ${selectedBot}`, { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(`tool_${selectedBot}`, { exact: true }),
|
||||
).toBeVisible();
|
||||
const otherBot =
|
||||
selectedBot === 'bot-shared-a' ? 'bot-shared-b' : 'bot-shared-a';
|
||||
await expect(
|
||||
page.getByText(`Message for ${otherBot}`, { exact: true }),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByText(`tool_${otherBot}`, { exact: true }),
|
||||
).toHaveCount(0);
|
||||
expect(
|
||||
requests.some(
|
||||
(request) =>
|
||||
request.bot === selectedBot && request.path.endsWith('/messages'),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
requests.some(
|
||||
(request) =>
|
||||
request.bot === selectedBot && request.path.endsWith('/analysis'),
|
||||
),
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
test('renders tool calls as left-side agent events interleaved with messages', async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -117,11 +491,41 @@ test.describe('bot session monitor tool timeline', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const monitoringRequests: import('@playwright/test').Request[] = [];
|
||||
page.on('request', (request) => {
|
||||
if (request.url().includes('/api/v1/monitoring/'))
|
||||
monitoringRequests.push(request);
|
||||
});
|
||||
await page.goto(`/home/bots?id=${botId}`);
|
||||
await page.getByRole('tab', { name: /Sessions/ }).click();
|
||||
await page.getByRole('button', { name: /Timeline User/ }).click();
|
||||
|
||||
await expect(page.getByText('Need a timeline check')).toBeVisible();
|
||||
await expect
|
||||
.poll(() =>
|
||||
monitoringRequests.some((request) =>
|
||||
request.url().includes('/analysis?'),
|
||||
),
|
||||
)
|
||||
.toBe(true);
|
||||
for (const request of monitoringRequests.filter((request) =>
|
||||
/\/messages\?|\/analysis\?/.test(request.url()),
|
||||
)) {
|
||||
const url = new URL(request.url());
|
||||
expect(url.searchParams.get('botId')).toBe(botId);
|
||||
if (url.pathname.endsWith('/analysis')) {
|
||||
expect(url.searchParams.get('startTime')).toBe(at(0));
|
||||
expect(url.searchParams.get('endTime')).toBe(at(4));
|
||||
}
|
||||
expect(request.headers().authorization).toBe('Bearer playwright-token');
|
||||
expect(request.headers()['x-workspace-id']).toBe('workspace-playwright');
|
||||
if (url.pathname.endsWith('/messages'))
|
||||
expect(url.searchParams.get('sessionId')).toBe(sessionId);
|
||||
else
|
||||
expect(decodeURIComponent(url.pathname)).toContain(
|
||||
`/sessions/${sessionId}/analysis`,
|
||||
);
|
||||
}
|
||||
await expect(
|
||||
page.getByText('repo_file_read', { exact: true }),
|
||||
).toBeVisible();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { expect, test, Route } from '@playwright/test';
|
||||
|
||||
import { installLangBotApiMocks } from './fixtures/langbot-api';
|
||||
import { buildConversationTurns } from '../../src/app/home/monitoring/utils/conversationTurns';
|
||||
@@ -271,7 +271,198 @@ function rawMonitoringData() {
|
||||
};
|
||||
}
|
||||
|
||||
async function respond(route: Route, label: string) {
|
||||
const data = rawMonitoringData();
|
||||
data.messages = [rawMessage(message(label, 'user', 10, label))];
|
||||
await route.fulfill({ json: { code: 0, data } });
|
||||
}
|
||||
|
||||
test.describe('monitoring request contracts', () => {
|
||||
test('shows failures instead of empty success and retries with auth and Workspace headers', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
let failing = true;
|
||||
await page.route('**/api/v1/monitoring/data?*', async (route) => {
|
||||
expect(route.request().headers().authorization).toBe(
|
||||
'Bearer playwright-token',
|
||||
);
|
||||
expect(route.request().headers()['x-workspace-id']).toBe(
|
||||
'workspace-playwright',
|
||||
);
|
||||
if (failing)
|
||||
await route.fulfill({
|
||||
status: 500,
|
||||
json: { code: 500, msg: 'fixture database unavailable' },
|
||||
});
|
||||
else await respond(route, 'Recovered monitoring');
|
||||
});
|
||||
await page.goto('/home/monitoring');
|
||||
await expect(page.getByRole('alert')).toContainText(
|
||||
'Failed to load monitoring data',
|
||||
);
|
||||
await expect(page.getByText('No message records')).toHaveCount(0);
|
||||
failing = false;
|
||||
await page.getByRole('button', { name: 'Retry', exact: true }).click();
|
||||
await expect(
|
||||
page.getByText('Recovered monitoring', { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole('alert')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('latest filter request wins over delayed data and delayed failures', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
const pending: Route[] = [];
|
||||
await page.route('**/api/v1/monitoring/data?*', (route) => {
|
||||
pending.push(route);
|
||||
});
|
||||
await page.goto('/home/monitoring');
|
||||
await expect.poll(() => pending.length).toBe(2);
|
||||
await page.getByRole('combobox').last().click();
|
||||
await page.getByRole('option', { name: /Last 7 days/i }).click();
|
||||
await expect.poll(() => pending.length).toBe(3);
|
||||
await respond(pending[2], 'Latest filter data');
|
||||
await expect(
|
||||
page.getByText('Latest filter data', { exact: true }),
|
||||
).toBeVisible();
|
||||
await respond(pending[0], 'Obsolete filter data');
|
||||
await respond(pending[1], 'Obsolete filter data');
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise<void>((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
|
||||
),
|
||||
);
|
||||
await expect(
|
||||
page.getByText('Latest filter data', { exact: true }),
|
||||
).toBeVisible();
|
||||
await page
|
||||
.getByRole('button', { name: 'Refresh Data', exact: true })
|
||||
.click();
|
||||
await expect.poll(() => pending.length).toBe(4);
|
||||
await expect(
|
||||
page.getByText('Obsolete filter data', { exact: true }),
|
||||
).toHaveCount(0);
|
||||
await page.getByRole('combobox').last().click();
|
||||
await page.getByRole('option', { name: /Last 24 hours/i }).click();
|
||||
await expect.poll(() => pending.length).toBe(5);
|
||||
await respond(pending[4], 'Current result');
|
||||
await expect(
|
||||
page.getByText('Current result', { exact: true }),
|
||||
).toBeVisible();
|
||||
await pending[3].fulfill({
|
||||
status: 500,
|
||||
json: { code: 500, msg: 'old failure' },
|
||||
});
|
||||
await expect(
|
||||
page.getByText('Current result', { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole('alert')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('uses aggregate traffic rather than the sparse record page and discloses truncation', async ({
|
||||
page,
|
||||
}) => {
|
||||
const data = rawMonitoringData();
|
||||
data.totalCount.messages = 125;
|
||||
await installLangBotApiMocks(page, {
|
||||
authenticated: true,
|
||||
monitoringData: {
|
||||
...data,
|
||||
traffic: {
|
||||
bucket: 'hour',
|
||||
truncated: true,
|
||||
points: [
|
||||
{ timestamp: time(0).toISOString(), messages: 125, llm_calls: 77 },
|
||||
{ timestamp: time(1).toISOString(), messages: 0, llm_calls: 0 },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
await page.goto('/home/monitoring');
|
||||
await expect(
|
||||
page.getByText(
|
||||
'Showing 7 of 125 messages. Conversation traces may be incomplete.',
|
||||
),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Traffic range truncated. Choose a shorter time range.'),
|
||||
).toBeVisible();
|
||||
const chart = page.locator('.recharts-wrapper');
|
||||
await expect(chart).toHaveCount(1);
|
||||
await chart
|
||||
.locator(':scope > .recharts-surface')
|
||||
.hover({ position: { x: 70, y: 100 } });
|
||||
await expect(chart.locator('.recharts-tooltip-wrapper')).toContainText(
|
||||
'125',
|
||||
);
|
||||
await expect(chart.locator('.recharts-tooltip-wrapper')).toContainText(
|
||||
'77',
|
||||
);
|
||||
});
|
||||
|
||||
test('does not invent traffic totals when aggregation is unavailable', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, {
|
||||
authenticated: true,
|
||||
monitoringData: rawMonitoringData(),
|
||||
});
|
||||
await page.goto('/home/monitoring');
|
||||
await expect(
|
||||
page.getByText('Traffic aggregation unavailable'),
|
||||
).toBeVisible();
|
||||
await expect(page.locator('.recharts-wrapper')).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('monitoring conversation turn grouping', () => {
|
||||
test('does not reassign explicitly linked activity outside the visible page', () => {
|
||||
const turns = buildConversationTurns(
|
||||
[message('visible', 'user', 10, 'Visible turn')],
|
||||
[llmCall('older-call', 11, 'off-page', 10, 5, 40)],
|
||||
[errorLog('older-error', 11, 'off-page')],
|
||||
[toolCall('older-tool', 11, 'off-page', 'search', 40)],
|
||||
);
|
||||
expect(turns[0].llmCalls).toEqual([]);
|
||||
expect(turns[0].toolCalls).toEqual([]);
|
||||
expect(turns[0].errors).toEqual([]);
|
||||
});
|
||||
|
||||
test('does not assign unlinked activity before the first visible turn', () => {
|
||||
const turns = buildConversationTurns(
|
||||
[message('visible', 'user', 10, 'Visible turn')],
|
||||
[llmCall('older-call', 1, undefined, 10, 5, 40)],
|
||||
[{ ...errorLog('older-error', 1, ''), messageId: undefined }],
|
||||
[toolCall('older-tool', 1, undefined, 'search', 40)],
|
||||
);
|
||||
expect(turns[0].llmCalls).toEqual([]);
|
||||
expect(turns[0].toolCalls).toEqual([]);
|
||||
expect(turns[0].errors).toEqual([]);
|
||||
});
|
||||
|
||||
test('isolates same-session messages and activity by bot identity', () => {
|
||||
const first = message('first', 'user', 1, 'Bot one');
|
||||
const other = {
|
||||
...message('other', 'user', 2, 'Bot two'),
|
||||
botId: 'other-bot',
|
||||
};
|
||||
const reply = message('reply', 'assistant', 3, 'Bot one reply');
|
||||
const turns = buildConversationTurns(
|
||||
[first, other, reply],
|
||||
[llmCall('call', 3, undefined, 10, 5, 40)],
|
||||
[errorLog('error', 3, first.id)],
|
||||
[toolCall('tool', 3, undefined, 'search', 40)],
|
||||
);
|
||||
const own = turns.find((turn) => turn.id === first.id)!;
|
||||
expect(own.assistantMessages.map((item) => item.id)).toEqual(['reply']);
|
||||
expect(own.llmCalls.map((item) => item.id)).toEqual(['call']);
|
||||
expect(own.toolCalls.map((item) => item.id)).toEqual(['tool']);
|
||||
expect(turns.find((turn) => turn.id === other.id)?.totalTokens).toBe(0);
|
||||
});
|
||||
|
||||
test('keeps a single user message as one observable turn', () => {
|
||||
const userOnly = message(
|
||||
'single-user-only',
|
||||
|
||||
@@ -134,6 +134,22 @@ test('session tool calls are bounded to the visible message page', () => {
|
||||
const monitor = read(
|
||||
'src/app/home/bots/components/bot-session/BotSessionMonitor.tsx',
|
||||
);
|
||||
includes(monitor, "analysisParams.set('startTime'", 'analysis page start');
|
||||
includes(monitor, "analysisParams.set('endTime'", 'analysis page end');
|
||||
includes(monitor, 'startTime: sorted[0]?.timestamp', 'analysis page start');
|
||||
includes(
|
||||
monitor,
|
||||
'endTime: sorted[sorted.length - 1]?.timestamp',
|
||||
'analysis page end',
|
||||
);
|
||||
includes(monitor, 'sessionId, botId, {', 'bot-scoped analysis');
|
||||
const client = read('src/app/infra/http/BackendClient.ts');
|
||||
includes(
|
||||
client,
|
||||
"queryParams.set('startTime', options.startTime)",
|
||||
'analysis start query',
|
||||
);
|
||||
includes(
|
||||
client,
|
||||
"queryParams.set('endTime', options.endTime)",
|
||||
'analysis end query',
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user