Compare commits

...

17 Commits

Author SHA1 Message Date
sheetung 60bb67f025 Fnos packaging (#2524)
* feat(packaging): add fnOS FPK packaging and CI workflow

Add packaging/fnos/ shell (manifest, lifecycle cmd scripts, install/
upgrade/uninstall wizards, desktop entry, EULA) plus in-repo build
script. A release now auto-builds langbot-<tag>-fnos.fpk via
.github/workflows/build-fnos-fpk.yaml, uploaded to the release assets.

* ci(fnos): skip release upload on manual dispatch

github.event.release.tag_name is empty when triggered via
workflow_dispatch, causing 'gh release upload' to fail with
'requires at least 2 arg(s)'. Restrict the step to release events.

* ci(fnos): normalize release asset name

Strip a trailing -fnos from the tag-derived version before appending
the suffix, avoiding langbot-<v>-fnos-fnos.fpk when the tag itself
already carries -fnos.

* ci(fnos): use release tag version for auto build, manifest for manual

Auto build (release/tag) reads version from tag like other release
workflows; build.sh strips the v prefix when injecting into manifest.
Manual dispatch falls back to the version maintained in manifest.

* feat(fnos): add post-install deployment notice to install wizard

Last wizard step now informs users that first startup takes about
5-10 minutes for dependency setup before the web UI is ready.

* docs(fnos): add packaging directory README

* refactor(fnos): rename app from ai.langbot to langbot

Rename appname, desktop entry, data share, build artifacts, and all
references from ai.langbot to langbot across packaging files.

---------

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-09-14 00:31:19 +08:00
Type_rBody cbe6844342 Merge pull request #2541 from hedging8563/codex/refresh-tokenlab-logo-20260913
chore(brand): refresh TokenLab logo
2026-09-13 22:18:08 +08:00
hedging8563 940895c541 chore(brand): refresh TokenLab logo 2026-09-13 21:24:38 +08:00
Hyu 9b130680ca ci(discord): announce published stable releases via webhook (#2539)
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-09-13 00:39:21 +08:00
huanghuoguoguo d26d0635c5 fix(vector): correct SeekDB adapter semantics (#2536) 2026-09-12 19:40:30 +08:00
彼方 58cde8c022 Merge pull request #2534 from langbot-app/fix/i18n-passkey-keys
fix(i18n): complete passkey keys across all locale files
2026-09-12 16:47:27 +08:00
彼方 9eb8683997 Merge pull request #2530 from langbot-app/feat/passkey-login
feat(auth): support passkey (webauthn) login and credential management
2026-09-12 16:00:10 +08:00
BiFangKNT 19526e1400 test(api): define explicit fixtures for passkey integration tests 2026-09-12 15:51:56 +08:00
BiFangKNT dfde9578c1 fix(ci): fix ruff lint errors and postgres legacy migration table exclusion 2026-09-12 15:35:47 +08:00
Hyu ec5b8cc8a8 docs(space): sync Runner usage recommendation contract (#2533)
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-09-12 13:37:14 +08:00
Hyu 137bb4fdb3 fix(ci): recover immutable release PyPI builds (#2532)
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-09-12 12:50:37 +08:00
Hyu 273b8839b9 chore(release): prepare LangBot 4.10.11 (#2531)
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-09-12 12:33:26 +08:00
BiFangKNT 9db6650274 style(tests): Remove unused time import from test file 2026-09-12 12:26:51 +08:00
BiFangKNT b594cf23e4 feat(auth): add webauthn authentication support 2026-09-12 12:17:26 +08:00
Hyu 45d77c3926 fix(plugin): preserve explicit nested installation scope (#2528)
* fix(plugin): preserve explicit nested installation scope

* fix(deps): pin released RAG runtime SDK 0.5.8

---------

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-09-11 17:41:33 +08:00
Hyu 1ea9cd3f6f fix(pipelines): show the actual sandbox scope restriction (#2527)
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-09-11 14:42:38 +08:00
Hyu ff6ad6adc2 fix(monitoring): restore Cloud messages and bot-scoped sessions (#2526)
* fix(monitoring): restore Cloud message persistence and bot-scoped sessions

* fix(migrations): support partial monitoring schemas and align regression fixtures

* test(migrations): complete raw bot session fixture values

---------

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-09-11 14:34:37 +08:00
95 changed files with 7384 additions and 808 deletions
+111
View File
@@ -0,0 +1,111 @@
# Discord release announcements
This independent workflow announces new stable LangBot releases in the channel
selected by a dedicated Discord incoming webhook. It does not change the existing
release/build workflows, edit releases, run a persistent service, poll, or backfill.
Announcements run on publication, independently of artifact builds finishing.
## Setup and read-only validation
1. In the intended community **announcement channel**, create a dedicated incoming
webhook (Channel Settings → Integrations → Webhooks). Copy its URL; do not reuse
a webhook belonging to another automation.
2. In `langbot-app/LangBot` → Settings → Secrets and variables → Actions, create the
**repository secret** `DISCORD_RELEASE_WEBHOOK_URL`. Its value must be exactly
`https://discord.com/api/webhooks/<id>/<token>` — no query, trailing slash,
API-version segment, or alternate domain. Treat the entire URL as a password.
3. Once this workflow is on `master`, open Actions → **Discord Release Announcement**
→ Run workflow, choosing `master`. Alternatively:
```sh
gh workflow run discord-release.yml --repo langbot-app/LangBot --ref master
```
4. Inspect **Validate webhook (GET only, no message)**. It checks webhook type `1`
and reports `guild_id` and `channel_id`; compare both with the intended server
and channel using Discord Developer Mode → Copy ID. The secret determines the
destination; no channel ID is guessed or overridden. The URL/token is never
logged. Dispatch cannot send a test message or announce an old release, even
when run again. Missing/invalid secrets fail validation clearly; offline tests
do not need secrets.
GET validation confirms the webhook's identity, not delivery or notification
permissions. Verify those on the first genuine release. `mention_everyone=true`
confirms Discord parsed the mention; it cannot prove every member received a push
notification (member/server notification settings still apply).
## Activation and message
The workflow and `.github/discord-release/` helper **must be in the commit targeted
by each new release tag**. Merging to `master` does not enable announcements for
old tags whose commits lack these files. Manual dispatch becomes available when
the workflow is on the default branch. Only publish release tags from trusted,
reviewed commits: release workflows execute that tag's code with the secret.
Only `release` events with action `published`, `draft=false`, and
`prerelease=false` can send. Drafts and prereleases are skipped; release edits do
not trigger announcements. The helper requires the repository to be exactly
`langbot-app/LangBot`, a stable `vX.Y.Z` tag (ASCII digits, at most 64 characters),
and its exact canonical GitHub release URL. Other naming schemes fail closed.
Example message (the version and URL come from the validated event file):
```text
@everyone LangBot v4.10.11 is now available!
Release notes: https://github.com/langbot-app/LangBot/releases/tag/v4.10.11
```
The release title/body is never copied. There is one literal `@everyone`, explicit
`allowed_mentions.parse=["everyone"]`, empty user/role allowlists, and no reply
mention. TTS and notification-suppressing flags are disabled. Requests use HTTPS
only to `discord.com`, an explicit User-Agent, and no redirects or automatic
retries. After a webhook identity GET, one `POST ?wait=true` obtains a message ID;
an exact `/messages/<id>` GET verifies its ID, webhook/channel, content,
`mention_everyone=true`, and empty user/role mention arrays before success.
## Repeat guard and manual recovery
Production sending requires **`GITHUB_RUN_ATTEMPT == "1"`**. Any Actions rerun
(including “Re-run failed jobs”) refuses to POST and requires manual reconciliation,
even if the first attempt failed before sending. Read-only dispatch may be rerun.
This is a practical repeat guard, **not durable exactly-once delivery**. It cannot
prevent duplicates from a separate new run/event (for example deleting/recreating
a release), separate automation, or manual posting. It stores no durable dedupe
state and never modifies the release to mark delivery.
If a POST times out, returns an error, or readback fails, the message may already
exist. The workflow fails rather than blindly sending again. A returned message ID
is included in the safe error when available. A runner termination can also leave
an ambiguous send without that log line.
1. Inspect the announcement channel and the failed run logs. Locate the canonical
release link and, if available, the returned message ID. A failed verification
does **not** mean the message was absent.
2. If present, reconcile the existing message/mention problem manually; do not
rerun, create another release event, or send a duplicate ping.
3. If an operator has positively confirmed no message exists, fix the secret or
permission issue and use read-only dispatch to validate configuration. A
maintainer may then post the announcement manually once in Discord and record
the message link in the incident/run notes. Do not override the attempt guard
or delete/recreate a release to force recovery.
4. If absence cannot be established, pause and reconcile rather than resending.
To stop future sends, disable **Discord Release Announcement** in Actions. Rotate
or delete the dedicated Discord webhook if the URL is exposed, and update the
secret before validation. No rollback of release artifacts is involved.
## Local checks
Requires Python 3.11+ and the standard library only:
```sh
python3 -m unittest discover -s .github/discord-release -p 'test_*.py' -v
python3 -m py_compile .github/discord-release/announce.py .github/discord-release/test_announce.py
```
Tests exercise policy, CLI/event-file handling, mention payloads, hostile inputs,
HTTP failures, exact message readback, and refusal to retry. Only the HTTPS
transport is mocked for Discord tests; no live Discord requests or messages are
made. Changes to this directory or its workflow run the offline tests on push and
pull request; tests also gate release sending and read-only dispatch validation.
+162
View File
@@ -0,0 +1,162 @@
"""Announce only first-attempt stable releases; dispatch is read-only validation."""
import http.client
import json
import os
from pathlib import Path
import re
import sys
REPOSITORY = 'langbot-app/LangBot'
RELEASE_PREFIX = f'https://github.com/{REPOSITORY}/releases/tag/'
RECONCILE = (
'Do not resend or bypass the run-attempt guard; manual reconciliation is required. '
'Inspect the announcement channel and workflow logs before any manual recovery '
'(see .github/discord-release/README.md).'
)
class AnnouncementError(Exception):
"""A safe, operator-facing error containing no webhook URL or response body."""
def release_payload(event, attempt):
"""Return a bounded, mention-safe payload, or None for draft/preview releases."""
if not isinstance(event, dict) or event.get('action') != 'published':
raise AnnouncementError('Only release.published events are accepted.')
repository = event.get('repository')
if not isinstance(repository, dict) or repository.get('full_name') != REPOSITORY:
raise AnnouncementError('Unexpected release repository.')
release = event.get('release')
if not isinstance(release, dict) or any(type(release.get(key)) is not bool for key in ('draft', 'prerelease')):
raise AnnouncementError('Invalid release flags.')
if release['draft'] or release['prerelease']:
return None
if attempt != '1':
raise AnnouncementError(f'Release reruns or missing run attempts are refused. {RECONCILE}')
tag = release.get('tag_name')
if not isinstance(tag, str) or len(tag) > 64 or not re.fullmatch(r'v[0-9]+\.[0-9]+\.[0-9]+', tag):
raise AnnouncementError('Expected a stable release tag in vX.Y.Z format (at most 64 characters).')
url = RELEASE_PREFIX + tag
if release.get('html_url') != url:
raise AnnouncementError('Release URL must be the canonical LangBot release URL matching its tag.')
return {
'content': f'@everyone LangBot {tag} is now available!\nRelease notes: {url}',
'allowed_mentions': {'parse': ['everyone'], 'users': [], 'roles': [], 'replied_user': False},
'tts': False,
'flags': 0,
}
def is_snowflake(value):
return isinstance(value, str) and re.fullmatch(r'[0-9]{1,20}', value) is not None
class DiscordWebhook:
def __init__(self, url):
if not url:
raise AnnouncementError('DISCORD_RELEASE_WEBHOOK_URL is missing. Set the repository Actions secret.')
match = re.fullmatch(r'https://discord\.com(/api/webhooks/([0-9]{1,20})/[A-Za-z0-9_-]+)', url)
if not match:
raise AnnouncementError('Invalid webhook URL; expected https://discord.com/api/webhooks/<id>/<token>.')
self.path, self.id = match.groups()
def _request(self, method, suffix='', payload=None):
# Direct HTTPS, default certificate verification, no proxies or redirect/retry machinery.
connection = http.client.HTTPSConnection('discord.com', timeout=20)
try:
body = json.dumps(payload).encode('utf-8') if payload is not None else None
connection.request(
method,
self.path + suffix,
body=body,
headers={'Content-Type': 'application/json', 'User-Agent': 'LangBot-Release-Announcements/1.0'},
)
response = connection.getresponse()
if response.status != 200:
raise AnnouncementError(f'Discord {method} returned HTTP {response.status}; no retry was attempted.')
raw = response.read(1_048_577)
if len(raw) > 1_048_576:
raise AnnouncementError('Discord response exceeded the size limit.')
return json.loads(raw)
except (OSError, http.client.HTTPException, ValueError, UnicodeError):
# Exceptions and bodies can contain the token; never print them or chain them.
raise AnnouncementError(
f'Discord {method} failed or returned invalid JSON; no retry was attempted.'
) from None
finally:
connection.close()
def validate(self):
"""GET only: verify an incoming webhook and return safe identifying fields."""
webhook = self._request('GET')
if (
not isinstance(webhook, dict)
or type(webhook.get('type')) is not int
or webhook['type'] != 1
or webhook.get('id') != self.id
or not is_snowflake(webhook.get('guild_id'))
or not is_snowflake(webhook.get('channel_id'))
):
raise AnnouncementError('Expected an incoming (type 1) webhook with matching ID and guild/channel IDs.')
return {key: webhook[key] for key in ('id', 'type', 'guild_id', 'channel_id')}
def send(self, payload):
"""One POST, followed by exact message GET; never automatically retry a send."""
webhook = self.validate()
message_id = None
try:
sent = self._request('POST', '?wait=true', payload)
if not isinstance(sent, dict) or not is_snowflake(sent.get('id')):
raise AnnouncementError('Discord did not return a valid message ID.')
message_id = sent['id']
saved = self._request('GET', f'/messages/{message_id}')
if (
not isinstance(saved, dict)
or saved.get('id') != message_id
or saved.get('webhook_id') != self.id
or saved.get('channel_id') != webhook['channel_id']
or saved.get('content') != payload['content']
or saved.get('mention_everyone') is not True
or saved.get('mentions') != []
or saved.get('mention_roles') != []
):
raise AnnouncementError('Discord message readback did not match content, identity, or mentions.')
except AnnouncementError as error:
reference = f' Returned message ID: {message_id}.' if message_id else ''
raise AnnouncementError(f'Delivery not confirmed. {error}{reference} {RECONCILE}') from None
return message_id
def main(env=None):
env = os.environ if env is None else env
try:
if env.get('GITHUB_REPOSITORY') != REPOSITORY:
raise AnnouncementError('This workflow is restricted to langbot-app/LangBot.')
name = env.get('GITHUB_EVENT_NAME')
if name == 'workflow_dispatch':
webhook = DiscordWebhook(env.get('DISCORD_RELEASE_WEBHOOK_URL')).validate()
print(
f'Validated incoming webhook: guild_id={webhook["guild_id"]} channel_id={webhook["channel_id"]}. No message sent.'
)
return 0
if name != 'release':
raise AnnouncementError('Only release and workflow_dispatch events are accepted by this helper.')
try:
event = json.loads(Path(env.get('GITHUB_EVENT_PATH', '')).read_text(encoding='utf-8'))
except (OSError, ValueError, UnicodeError):
raise AnnouncementError('Cannot read a valid JSON release event from GITHUB_EVENT_PATH.') from None
payload = release_payload(event, env.get('GITHUB_RUN_ATTEMPT'))
if payload is None:
print('Skipped draft or prerelease; no message sent.')
return 0
message_id = DiscordWebhook(env.get('DISCORD_RELEASE_WEBHOOK_URL')).send(payload)
print(f'Announcement verified by exact message readback: message_id={message_id}.')
return 0
except AnnouncementError as error:
print(f'Error: {error}', file=sys.stderr)
return 1
if __name__ == '__main__':
sys.exit(main())
+427
View File
@@ -0,0 +1,427 @@
"""Offline contract tests; no Discord credentials or network required."""
import contextlib
import io
import json
import os
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
from unittest.mock import MagicMock, patch
try:
import announce
except ModuleNotFoundError:
announce = None
WEBHOOK = 'https://discord.com/api/webhooks/123456789012345678/fixture_token-ONLY'
WEBHOOK_ID = '123456789012345678'
GUILD_ID = '234567890123456789'
CHANNEL_ID = '345678901234567890'
MESSAGE_ID = '456789012345678901'
REPO = 'langbot-app/LangBot'
URL = f'https://github.com/{REPO}/releases/tag/v4.10.11'
CONTENT = f'@everyone LangBot v4.10.11 is now available!\nRelease notes: {URL}'
def event():
return {
'action': 'published',
'repository': {'full_name': REPO},
'release': {
'draft': False,
'prerelease': False,
'tag_name': 'v4.10.11',
'html_url': URL,
'name': 'Hostile @everyone <@123> $(touch /tmp/unsafe)',
'body': '@everyone @here <@123> <@&456> `hostile`',
},
}
def metadata():
return {'id': WEBHOOK_ID, 'type': 1, 'guild_id': GUILD_ID, 'channel_id': CHANNEL_ID}
def message():
return {
'id': MESSAGE_ID,
'webhook_id': WEBHOOK_ID,
'channel_id': CHANNEL_ID,
'content': CONTENT,
'mention_everyone': True,
'mentions': [],
'mention_roles': [],
}
class BaseTest(unittest.TestCase):
def setUp(self):
self.assertIsNotNone(announce, 'The release announcement helper must exist')
class PolicyTests(BaseTest):
def test_payload_has_one_literal_everyone_and_no_untrusted_body(self):
payload = announce.release_payload(event(), '1')
self.assertEqual(payload['content'], CONTENT)
self.assertEqual(json.dumps(payload).count('@everyone'), 1)
self.assertEqual(
payload['allowed_mentions'],
{
'parse': ['everyone'],
'users': [],
'roles': [],
'replied_user': False,
},
)
self.assertIs(payload['tts'], False)
self.assertEqual(payload['flags'], 0)
def test_drafts_and_prereleases_are_skipped(self):
for flag in ('draft', 'prerelease'):
with self.subTest(flag=flag):
value = event()
value['release'][flag] = True
self.assertIsNone(announce.release_payload(value, '1'))
def test_only_published_action_is_accepted(self):
for action in ('edited', 'created', 'released', 'deleted', '', None):
with self.subTest(action=action):
value = event()
value['action'] = action
with self.assertRaises(announce.AnnouncementError):
announce.release_payload(value, '1')
def test_reruns_and_missing_attempt_refuse_manual_reconciliation(self):
for attempt in ('2', '3', '', None, '01', '0', '1\n'):
with self.subTest(attempt=attempt):
with self.assertRaisesRegex(announce.AnnouncementError, 'manual reconciliation'):
announce.release_payload(event(), attempt)
def test_repository_must_match_exactly(self):
for repo in ('evil/LangBot', 'langbot-app/langbot', None):
value = event()
value['repository']['full_name'] = repo
with self.assertRaises(announce.AnnouncementError):
announce.release_payload(value, '1')
def test_hostile_and_noncanonical_tags_are_rejected(self):
for tag in (
'v1.2.3 @everyone',
'v1.2.3\n',
'v1.2.3/../../x',
'v1.2.3?x=y',
'$(id)',
'v1.2.3-rc.1',
'v.2.3',
'v1.2.3%0a',
'<@123>',
'v1.2.' + '3' * 100,
'',
None,
123,
):
with self.subTest(tag=tag):
value = event()
value['release']['tag_name'] = tag
value['release']['html_url'] = f'https://github.com/{REPO}/releases/tag/{tag}'
with self.assertRaises(announce.AnnouncementError):
announce.release_payload(value, '1')
def test_release_url_must_be_canonical_and_match_tag(self):
for url in (
'https://evil.example/tag/v4.10.11',
URL + '?x=y',
URL + '#anchor',
URL + '/',
URL.replace('v4.10.11', 'v4.10.12'),
URL.replace('github.com', 'github.com@evil.example'),
URL.replace('https:', 'http:'),
URL + '\n',
None,
):
with self.subTest(url=url):
value = event()
value['release']['html_url'] = url
with self.assertRaises(announce.AnnouncementError):
announce.release_payload(value, '1')
def test_malformed_events_fail_closed(self):
for value in (None, [], {}, {'release': []}, {'repository': None}):
with self.subTest(value=value):
with self.assertRaises(announce.AnnouncementError):
announce.release_payload(value, '1')
for flag in ('draft', 'prerelease'):
for bad in (None, 'false', 0, 1):
value = event()
value['release'][flag] = bad
with self.assertRaises(announce.AnnouncementError):
announce.release_payload(value, '1')
class DiscordTests(BaseTest):
def setUp(self):
super().setUp()
self.patch = patch('announce.http.client.HTTPSConnection')
self.connection_class = self.patch.start()
self.addCleanup(self.patch.stop)
self.connection = self.connection_class.return_value
def respond(self, *values):
responses = []
for value in values:
response = MagicMock()
response.status = 200
response.read.return_value = json.dumps(value).encode()
responses.append(response)
self.connection.getresponse.side_effect = responses
def methods(self):
return [call.args[0] for call in self.connection.request.call_args_list]
def test_webhook_validation_is_get_only_and_reports_ids(self):
self.respond(metadata())
result = announce.DiscordWebhook(WEBHOOK).validate()
self.assertEqual(result, metadata())
self.assertEqual(self.methods(), ['GET'])
self.assertEqual(
self.connection.request.call_args.args[:2], ('GET', f'/api/webhooks/{WEBHOOK_ID}/fixture_token-ONLY')
)
self.connection_class.assert_called_with('discord.com', timeout=20)
self.connection.close.assert_called_once()
def test_invalid_webhook_urls_are_rejected_before_network(self):
for url in (
'',
None,
WEBHOOK + '/',
WEBHOOK + '?wait=true',
WEBHOOK + '#x',
WEBHOOK + '\n',
' ' + WEBHOOK,
WEBHOOK.replace('https:', 'http:'),
WEBHOOK.replace('discord.com', 'discord.com.evil.example'),
WEBHOOK.replace('discord.com', 'discord.com@evil.example'),
WEBHOOK.replace('discord.com', 'discord.com:443'),
WEBHOOK.replace('/api/', '/api/v10/'),
WEBHOOK.replace(WEBHOOK_ID, 'abc'),
WEBHOOK + '/../../x',
WEBHOOK.replace('fixture_token-ONLY', 'a%2Fb'),
):
with self.subTest(url=url):
with self.assertRaises(announce.AnnouncementError):
announce.DiscordWebhook(url)
self.connection_class.assert_not_called()
def test_webhook_metadata_requires_incoming_type_and_ids(self):
invalid = [
None,
[],
{},
dict(metadata(), type=2),
dict(metadata(), type=True),
dict(metadata(), id='999'),
dict(metadata(), channel_id=None),
dict(metadata(), guild_id='::error::hostile'),
]
for value in invalid:
with self.subTest(value=value):
self.respond(value)
with self.assertRaises(announce.AnnouncementError):
announce.DiscordWebhook(WEBHOOK).validate()
self.assertNotIn('POST', self.methods())
def test_send_waits_and_reads_back_exact_returned_message(self):
self.respond(metadata(), message(), message())
result = announce.DiscordWebhook(WEBHOOK).send(announce.release_payload(event(), '1'))
self.assertEqual(result, MESSAGE_ID)
self.assertEqual(self.methods(), ['GET', 'POST', 'GET'])
calls = self.connection.request.call_args_list
self.assertEqual(calls[1].args[:2], ('POST', f'/api/webhooks/{WEBHOOK_ID}/fixture_token-ONLY?wait=true'))
self.assertEqual(json.loads(calls[1].kwargs['body']), announce.release_payload(event(), '1'))
self.assertEqual(
calls[2].args[:2], ('GET', f'/api/webhooks/{WEBHOOK_ID}/fixture_token-ONLY/messages/{MESSAGE_ID}')
)
def test_readback_must_match_content_mentions_and_identity(self):
for field, bad in (
('content', 'wrong'),
('mention_everyone', False),
('mention_everyone', 1),
('mentions', [{'id': '123'}]),
('mention_roles', ['123']),
('id', '999'),
('channel_id', '999'),
('webhook_id', '999'),
):
with self.subTest(field=field, bad=bad):
self.connection.reset_mock()
self.respond(metadata(), message(), dict(message(), **{field: bad}))
with self.assertRaisesRegex(announce.AnnouncementError, 'manual reconciliation'):
announce.DiscordWebhook(WEBHOOK).send(announce.release_payload(event(), '1'))
self.assertEqual(self.methods().count('POST'), 1)
def test_missing_readback_fields_fail_closed(self):
for field in message():
value = message()
del value[field]
self.respond(metadata(), message(), value)
with self.assertRaises(announce.AnnouncementError):
announce.DiscordWebhook(WEBHOOK).send(announce.release_payload(event(), '1'))
def test_unsafe_post_message_id_never_becomes_get_path(self):
for value in (None, {}, dict(message(), id='../evil'), dict(message(), id='123?x=y')):
self.connection.reset_mock()
self.respond(metadata(), value)
with self.assertRaisesRegex(announce.AnnouncementError, 'manual reconciliation'):
announce.DiscordWebhook(WEBHOOK).send(announce.release_payload(event(), '1'))
self.assertEqual(self.methods(), ['GET', 'POST'])
def test_post_failure_never_retries_and_never_logs_secret(self):
for status in (301, 302, 307, 308, 400, 401, 403, 429, 500, 204):
with self.subTest(status=status):
self.connection.reset_mock()
self.respond(metadata(), message())
responses = list(self.connection.getresponse.side_effect)
responses[1].status = status
self.connection.getresponse.side_effect = responses
with self.assertRaisesRegex(announce.AnnouncementError, 'manual reconciliation') as caught:
announce.DiscordWebhook(WEBHOOK).send(announce.release_payload(event(), '1'))
self.assertNotIn('fixture_token', str(caught.exception))
self.assertEqual(self.methods(), ['GET', 'POST'])
def test_ambiguous_timeout_never_retries_or_echoes_exception(self):
self.respond(metadata())
first = next(self.connection.getresponse.side_effect)
self.connection.getresponse.side_effect = [first, TimeoutError(WEBHOOK)]
with self.assertRaisesRegex(announce.AnnouncementError, 'manual reconciliation') as caught:
announce.DiscordWebhook(WEBHOOK).send(announce.release_payload(event(), '1'))
self.assertNotIn('fixture_token', str(caught.exception))
self.assertEqual(self.methods(), ['GET', 'POST'])
def test_malformed_json_response_is_sanitized(self):
self.respond(metadata())
response = next(self.connection.getresponse.side_effect)
response.read.return_value = WEBHOOK.encode()
self.connection.getresponse.side_effect = [response]
with self.assertRaises(announce.AnnouncementError) as caught:
announce.DiscordWebhook(WEBHOOK).validate()
self.assertNotIn('fixture_token', str(caught.exception))
def test_get_redirect_is_not_followed(self):
self.respond(metadata())
response = next(self.connection.getresponse.side_effect)
response.status = 302
response.getheader.return_value = 'https://evil.example/'
self.connection.getresponse.side_effect = [response]
with self.assertRaises(announce.AnnouncementError):
announce.DiscordWebhook(WEBHOOK).validate()
self.assertEqual(self.methods(), ['GET'])
class EntrypointTests(BaseTest):
def run_main(self, data=None, **overrides):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / 'event.json'
path.write_text(json.dumps(event() if data is None else data))
env = {
'GITHUB_EVENT_NAME': 'release',
'GITHUB_EVENT_PATH': str(path),
'GITHUB_REPOSITORY': REPO,
'GITHUB_RUN_ATTEMPT': '1',
'DISCORD_RELEASE_WEBHOOK_URL': WEBHOOK,
}
env.update(overrides)
output = io.StringIO()
with contextlib.redirect_stdout(output), contextlib.redirect_stderr(output):
result = announce.main(env)
return result, output.getvalue()
def test_dispatch_only_validates_even_if_event_contains_release(self):
with patch('announce.DiscordWebhook') as client:
client.return_value.validate.return_value = metadata()
result, output = self.run_main(GITHUB_EVENT_NAME='workflow_dispatch')
self.assertEqual(result, 0)
client.return_value.validate.assert_called_once()
client.return_value.send.assert_not_called()
self.assertIn(GUILD_ID, output)
self.assertIn(CHANNEL_ID, output)
self.assertNotIn('fixture_token', output)
def test_production_release_sends_once(self):
with patch('announce.DiscordWebhook') as client:
client.return_value.send.return_value = MESSAGE_ID
result, output = self.run_main()
self.assertEqual(result, 0)
client.return_value.send.assert_called_once_with(announce.release_payload(event(), '1'))
self.assertIn(MESSAGE_ID, output)
def test_skipped_releases_need_no_secret_or_network(self):
for flag in ('draft', 'prerelease'):
value = event()
value['release'][flag] = True
with patch('announce.DiscordWebhook') as client:
result, _ = self.run_main(value, DISCORD_RELEASE_WEBHOOK_URL='')
self.assertEqual(result, 0)
client.assert_not_called()
def test_rerun_never_constructs_client(self):
with patch('announce.DiscordWebhook') as client:
result, output = self.run_main(GITHUB_RUN_ATTEMPT='2')
self.assertEqual(result, 1)
self.assertIn('manual reconciliation', output)
client.assert_not_called()
def test_unexpected_event_or_repository_cannot_send(self):
for overrides in (
{'GITHUB_EVENT_NAME': 'push'},
{'GITHUB_EVENT_NAME': 'pull_request'},
{'GITHUB_REPOSITORY': 'evil/LangBot'},
):
with patch('announce.DiscordWebhook') as client:
result, _ = self.run_main(**overrides)
self.assertEqual(result, 1)
client.assert_not_called()
def test_missing_secret_fails_clearly_for_send_and_validation(self):
for name in ('release', 'workflow_dispatch'):
result, output = self.run_main(GITHUB_EVENT_NAME=name, DISCORD_RELEASE_WEBHOOK_URL='')
self.assertEqual(result, 1)
self.assertIn('DISCORD_RELEASE_WEBHOOK_URL is missing', output)
def test_cli_reads_event_file_and_redacts_invalid_input(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / 'event.json'
value = event()
value['release']['tag_name'] = '::error::hostile @everyone'
path.write_text(json.dumps(value))
env = dict(
os.environ,
GITHUB_EVENT_NAME='release',
GITHUB_EVENT_PATH=str(path),
GITHUB_REPOSITORY=REPO,
GITHUB_RUN_ATTEMPT='1',
DISCORD_RELEASE_WEBHOOK_URL=WEBHOOK,
)
result = subprocess.run(
[sys.executable, str(Path(__file__).with_name('announce.py'))],
env=env,
text=True,
capture_output=True,
check=False,
)
self.assertEqual(result.returncode, 1)
self.assertNotIn('hostile', result.stderr)
self.assertNotIn('fixture_token', result.stderr)
self.assertNotIn('Traceback', result.stderr)
def test_unreadable_event_fails_safely(self):
result, output = self.run_main(GITHUB_EVENT_PATH='/nonexistent/event.json')
self.assertEqual(result, 1)
self.assertNotIn('Traceback', output)
if __name__ == '__main__':
unittest.main()
+78
View File
@@ -0,0 +1,78 @@
name: Build fnOS FPK
on:
workflow_dispatch:
## 发布release的时候会自动构建
release:
types: [published]
permissions:
contents: write
jobs:
build-fnos-fpk:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
with:
persist-credentials: false
- name: Check version
id: check_version
run: |
echo $GITHUB_REF
# 如果是tag,则去掉refs/tags/前缀(与其他 release workflow 一致,版本号取 tag 名)
if [[ $GITHUB_REF == refs/tags/* ]]; then
echo "It's a tag"
echo "version=$(echo $GITHUB_REF | awk -F '/' '{print $3}')" >> $GITHUB_OUTPUT
else
# 手动触发(workflow_dispatch):读不到 tag,使用 manifest 内维护的版本
echo "It's not a tag"
echo "version=$(grep '^version=' packaging/fnos/manifest | cut -d= -f2)" >> $GITHUB_OUTPUT
fi
- name: Setup Node
uses: actions/setup-node@v2
with:
node-version: '22'
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install build tools
run: |
pip install pillow
# fnpack:飞牛官方打包 CLI(静态二进制)
curl -fsSL -o /usr/local/bin/fnpack \
https://static2.fnnas.com/fnpack/fnpack-1.2.3-linux-amd64
chmod +x /usr/local/bin/fnpack
- name: Build FPK
env:
FPK_VERSION: ${{ steps.check_version.outputs.version }}
run: |
bash packaging/fnos/build.sh
test -f packaging/fnos/langbot.fpk
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
name: langbot-${{ steps.check_version.outputs.version }}-fnos
path: packaging/fnos/langbot.fpk
- name: Upload To Release
# 仅 release 触发时执行;手动/workflow_dispatch 触发时没有 release
# 且 github.event.release.tag_name 为空(否则 gh release upload 缺参数报错)
if: github.event_name == 'release'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# tag 可能已带 -fnos 后缀(如 v4.10.9-fnos),产物名统一为
# langbot-<基础版本>-fnos.fpk,避免出现 -fnos-fnos
VER="${{ steps.check_version.outputs.version }}"
BASE="${VER#v}"; BASE="${BASE%-fnos}"
cp packaging/fnos/langbot.fpk "langbot-${BASE}-fnos.fpk"
gh release upload ${{ github.event.release.tag_name }} "langbot-${BASE}-fnos.fpk"
+64
View File
@@ -0,0 +1,64 @@
name: Discord Release Announcement
on:
release:
types: [published]
workflow_dispatch:
push:
paths:
- '.github/workflows/discord-release.yml'
- '.github/discord-release/**'
pull_request:
paths:
- '.github/workflows/discord-release.yml'
- '.github/discord-release/**'
permissions:
contents: read
jobs:
tests:
name: Offline announcement tests
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
persist-credentials: false
- name: Test helper without secrets or network
run: python3 -m unittest discover -s .github/discord-release -p 'test_*.py' -v
validate:
name: Validate webhook (GET only, no message)
if: github.repository == 'langbot-app/LangBot' && github.event_name == 'workflow_dispatch'
needs: tests
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
persist-credentials: false
- name: Validate incoming webhook and report guild/channel IDs
env:
DISCORD_RELEASE_WEBHOOK_URL: ${{ secrets.DISCORD_RELEASE_WEBHOOK_URL }}
run: python3 .github/discord-release/announce.py
announce:
name: Announce published stable release
if: >-
github.repository == 'langbot-app/LangBot' &&
github.event_name == 'release' && github.event.action == 'published' &&
github.event.release.draft == false && github.event.release.prerelease == false
needs: tests
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
persist-credentials: false
# The helper refuses GITHUB_RUN_ATTEMPT != 1 with recovery guidance.
# Never interpolate release data into a shell command.
- name: Send once and verify the exact Discord message
env:
DISCORD_RELEASE_WEBHOOK_URL: ${{ secrets.DISCORD_RELEASE_WEBHOOK_URL }}
run: python3 .github/discord-release/announce.py
+35 -4
View File
@@ -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/
+6
View File
@@ -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
+12
View File
@@ -57,3 +57,15 @@ testsdk/
# Next.js build cache (legacy)
web/.next/
web/.pnpm-home
.tmp
Caddyfile
# fnOS packaging build artifacts (packaging/fnos/build.sh)
packaging/fnos/app/langbot/
packaging/fnos/app/bin/
packaging/fnos/ICON.PNG
packaging/fnos/ICON_256.PNG
packaging/fnos/app/ui/images/
packaging/fnos/app/desktop/images/
packaging/fnos/*.fpk
+145
View File
@@ -0,0 +1,145 @@
LangBot 用户许可协议
User License Agreement
飞牛 fnOS 平台发行版 | 最后更新:2026 年 8 月
感谢您使用 LangBot!本协议是您(用户)与 LangBot 开源项目(以下简称「LangBot」「我们」)之间,就您在飞牛 fnOS 平台(含飞牛 NAS 设备、飞牛 OS 及其应用中心,以下简称「平台」)上安装、运行、使用 LangBot 应用所订立的合法协议。
您一旦在飞牛应用中心勾选「我接受许可协议的条款」并继续安装、或以其他方式运行 LangBot,即表示您已阅读、理解并同意本协议的全部内容。如您不同意本协议,请不要安装或使用本软件。
---
一、软件性质
1.1 LangBot 是一款基于 LLM 的多平台智能对话机器人开源软件。主程序源代码按照 Apache License, Version 2.0 公开,您可在遵守开源协议的前提下自由使用、修改与再分发。
1.2 本发行版系 LangBot 社区为飞牛 fnOS 平台打包构建的自托管移植版本,与飞牛官方、飞牛硬件厂商不存在从属或关联关系。飞牛应用中心提供的分发渠道不构成对软件功能、可用性的任何担保。
---
二、使用授权
2.1 授予您一份有限的、非排他的、不可转让的个人使用许可:您可在一台或多台您合法拥有或管理的 fnOS 设备上安装、运行本软件,用于个人、家庭或组织内部合法用途。
2.2 您不得:
(a) 将本软件用于违反中国大陆地区法律法规或您所在司法管辖区法律的用途;
(b) 对机器人账号进行骚扰、诈骗、批量营销、发布违法违规内容等滥用行为;
(c) 逆向工程、反编译本软件所包含的第三方二进制(uv 等),但适用法律明确允许或对应开源许可证另作规定的除外;
(d) 试图干扰、过载或损害任何由本软件对外提供的服务或其基础设施。
---
三、服务可用性与「现状」提供
3.1 我们努力提供稳定可靠的软件,但**不保证运行无中断或无错误**。软件可能因维护、升级、不可抗力或其他原因暂时不可用。
3.2 本软件按「现状」「按可用」提供。我们不作任何明示或默示担保,包括但不限于对适销性、特定用途适用性与非侵权性的默示担保。我们不保证:
(a) 软件将满足您的具体需求;
(b) 运行不间断、及时、安全或无错误;
(c) 使用获得的结果准确或可靠;
(d) 任何错误都会被修复。
---
四、责任限制
在适用法律允许的最大范围内:
(a) 我们不对任何**间接、附带、特殊、惩罚性或后果性损失**承担责任,包括但不限于利润损失、数据丢失、商誉损失、业务中断或其他无形损失,无论是否已被告知该等损害发生的可能性;
(b) 无论基于合同、侵权、严格责任或其他任何理论,我们就本软件所引起的所有索赔,向您承担的**累计赔偿总额**不超过 15 美元(或等值当地货币)。
---
五、用户责任
5.1 您对通过本软件发送的所有内容和消息(包括您所配置并运行的机器人发出的消息)承担全部责任。
5.2 您必须遵守所有适用的法律法规,包括但不限于数据保护、隐私、消费者保护和反垃圾邮件法律。
5.3 您有责任保管好自己的账号凭据和各类 API Key,并**自行对重要数据进行备份**。我们对因服务中断、账号终止或系统故障等任何原因造成的数据丢失不承担责任。
5.4 您不得将本软件用于任何非法活动、发送垃圾信息、骚扰他人或侵害他人合法权益。
5.5 您使用机器人接入任何即时通讯平台(QQ、微信、飞书、钉钉、Telegram 等)前,须自行确认已获得该平台授权并遵守其开发者协议与社区规范;因违规接入导致的账号封禁、平台处罚,由您自行承担。
---
六、数据与隐私
6.1 本自托管版本默认情况下,所有配置、对话记录、知识库与插件数据均保存在您所安装的 fnOS 设备本地共享目录(langbot/data)中,不会被自动上传至除您显式配置的模型/服务提供商以外的任何第三方。您对自己的数据及备份负责。
6.2 LangBot 默认启用最少量的匿名遥测,用于帮助改进产品。详细政策见官方文档:
https://docs.langbot.app/zh/insight/data-collection-policy
6.3 启用遥测时仅可能发送:查询事件(适配器类型、运行器类型、模型名称、处理耗时、版本号、匿名工作区 UUID、插件/功能使用计数、不含用户内容的错误追踪)、每日一次的工作区心跳(部署概况、资源对象数量)、完全自愿的问卷回答。
6.4 我们绝对不收集:消息内容、用户名/手机号/平台账号 ID、API 密钥或凭据、IP 地址、文件或媒体内容。
6.5 关闭方式:进入 LangBot Web 管理界面 → 设置 → Space 遥测,关闭开关;或在配置文件 data/config.yaml 中设置 `space.disable_telemetry: true`。关闭后所有功能照常运行。
---
七、不可抗力
因不可抗力事件导致的履约失败或延迟,我们不承担责任。不可抗力包括但不限于:自然灾害(地震、洪水、飓风等)、战争、恐怖主义或内乱、政府行为或法规、网络攻击(DDoS、勒索软件等)、第三方服务故障(AI 模型提供商、即时通讯平台、飞牛平台运行时等)、电力故障或互联网连接中断、流行病或公共卫生紧急事件。
---
八、第三方服务
本软件可能依赖或集成第三方服务,包括但不限于:即时通讯平台(Telegram、Discord、微信、QQ、Slack 等)、AI 模型提供商(OpenAI、Anthropic、Google、深度求索等)、飞牛平台的应用中心运行时与依赖应用(如 Node.js)。我们不对第三方服务的可用性、准确性、可靠性或安全性负责;使用第三方服务须受其各自条款约束,第三方服务的变更可能不经通知即影响本软件功能。
---
九、软件修改与终止
9.1 我们保留随时修改、暂停或终止软件或其中任何部分的权利,无论是否事先通知。
9.2 我们保留随时修改本协议的权利。对重大变更我们会尽力在项目主页公告,继续使用软件即视为接受修订后的协议。
9.3 您可以在飞牛应用中心卸载本软件。卸载时向导会询问是否保留数据,您可自主选择。终止后您继续使用软件的权利立即终止,我们无义务保留您的数据。
---
十、赔偿
您同意赔偿、抗辩并使 LangBot 团队及其关联贡献者、管理人员、代理人、员工免受任何及所有因以下事项引起或与之相关的索赔、损失、损害、责任、成本和费用(包括合理的律师费):
(a) 您对软件的使用;
(b) 您违反本协议;
(c) 您违反任何适用的法律或法规;
(d) 您侵犯任何第三方权利;
(e) 您或您所配置的机器人通过本软件传输的内容。
---
十一、知识产权
11.1 本软件及其设计、代码、文档和品牌归 LangBot 项目所有并受知识产权法保护。
11.2 使用本软件并不授予您对软件的任何所有权。
11.3 您保留通过本软件创建和传输内容的所有权。
---
十二、争议解决
因本协议引起或与之相关的任何争议,应首先通过友好协商解决。协商在 30 日内未达成一致的,任何一方均可依适用法律规定向有管辖权的法院提起诉讼。
---
十三、可分割性
如本协议的任何条款被认定为不可执行或无效,该条款应在最小必要范围内予以限制或剔除,其余条款继续完全有效。
---
十四、完整协议
本协议连同我们的数据收集政策(https://docs.langbot.app/zh/insight/data-collection-policy)构成您与我们之间关于本软件的完整协议,并取代所有先前的协议与谅解。
---
附录:开源许可证声明
LangBot 主程序代码依照 Apache License 2.0 发布。详细条款见:
https://github.com/langbot-app/LangBot/blob/master/LICENSE
或 LangBot 源码包内的 LICENSE 文件。
数据收集政策:https://docs.langbot.app/zh/insight/data-collection-policy
+78
View File
@@ -0,0 +1,78 @@
# LangBot fnOS Packaging
This directory packages LangBot as a `.fpk` app for the fnOS App Store. It is a native deployment: no Docker involved — uv creates a Python virtual environment directly on the NAS, and Node.js v22 from the fnOS App Store provides the Box sandbox and npx MCP capabilities.
## Directory Structure
```
packaging/fnos/
├── manifest # App metadata (appname/version/port/dependency declarations)
├── build.sh # One-shot build script (shared by local and CI)
├── LICENSE
├── config/
│ ├── privilege # Privilege config (run-as: root)
│ └── resource # Persistent data share declaration (langbot/data)
├── cmd/ # Lifecycle scripts (fnOS invokes them with TRIM_* env vars)
│ ├── main # Service start/stop manager (start/stop/status, owns PID/log)
│ ├── install_init # Pre-install hook
│ ├── install_callback # Post-install hook: create venv, uv sync deps, seed config.yaml port
│ ├── upgrade_init # Pre-upgrade hook
│ ├── upgrade_callback # Post-upgrade hook
│ ├── uninstall_init # Pre-uninstall hook
│ ├── uninstall_callback # Post-uninstall hook (keeps data per wizard choice)
│ ├── config_init # Pre-config-change hook
│ └── config_callback # Post-config-change hook (apply new port etc.)
├── wizard/ # Install wizards (JSON forms; values passed as wizard_* env vars)
│ ├── install # On install: Node version, web port, deployment-time notice
│ ├── upgrade # On upgrade: Node version confirmation
│ └── uninstall # On uninstall: whether to keep data
├── app/
│ ├── ui/config # Desktop entry declaration (${wizard_port} placeholder, substituted by fnOS at install)
│ ├── desktop/langbot.main.url
│ ├── langbot/ # [generated] repo source synced via rsync (includes web/dist)
│ └── bin/ # [generated] offline uv binaries (x86_64/aarch64)
├── ICON.PNG / ICON_256.PNG # [generated] derived from res/logo-blue.png
└── langbot.fpk # [generated] final artifact
```
Paths marked `[generated]` are produced by `build.sh`, ignored via `.gitignore`; everything else is a git-tracked source file.
## Building
### Locally
```bash
bash packaging/fnos/build.sh
```
Dependencies: python3 + Pillow, node + npm (or pnpm), fnpack (official fnOS packaging CLI, download from https://developer.fnnas.com/docs/cli/fnpack).
The version comes from `version=` maintained in the manifest; it can also be injected: `FPK_VERSION=4.10.10-1 bash packaging/fnos/build.sh`.
### CI
[`.github/workflows/build-fnos-fpk.yaml`](../../.github/workflows/build-fnos-fpk.yaml) triggers automatically on Release publication and uploads `langbot-<version>-fnos.fpk` to the Release; it can also be triggered manually via workflow_dispatch.
Version sources (consistent with the other release workflows):
| Trigger | Version source |
|---|---|
| Release/tag auto build | Tag name (`v4.10.10-1` → in-package `4.10.10-1`; build.sh strips the `v` prefix on injection) |
| Manual workflow_dispatch / local build | Version maintained in manifest |
## Final Artifact
`langbot.fpk` (gzip + tar archive), containing:
- `manifest` — realigned and appended with a `checksum` field by fnpack
- `app.tgz` — app payload (source, web/dist, uv binaries, entry configs)
- `cmd/`, `config/`, `wizard/` — lifecycle scripts and wizards
- `ICON.PNG`, `ICON_256.PNG`, `LICENSE`
The first startup after installation takes about 5-10 minutes to finish dependency deployment (uv venv + sync); after that the web admin UI is reachable via the desktop icon or `http://<NAS-IP>:<port>` (default port 5300).
## References
- fnOS developer docs: https://developer.fnnas.com/
- fnOS app wizard: https://developer.fnnas.com/docs/core-concepts/wizard/
- fnpack CLI: https://developer.fnnas.com/docs/cli/fnpack
@@ -0,0 +1,9 @@
{
"title": "LangBot",
"icon": "images/icon-256.png",
"type": "url",
"protocol": "http",
"port": "${wizard_port}",
"url": "/",
"allUsers": true
}
+13
View File
@@ -0,0 +1,13 @@
{
".url": {
"langbot.main": {
"title": "LangBot",
"icon": "images/icon-{0}.png",
"type": "url",
"protocol": "http",
"port": "${wizard_port}",
"url": "/",
"allUsers": true
}
}
}
+180
View File
@@ -0,0 +1,180 @@
#!/usr/bin/env bash
# packaging/fnos/build.sh - Build LangBot fnOS FPK package (in-repo version)
# 在 LangBot 仓库内直接打包飞牛 fnOS 应用
# Usage:
# bash packaging/fnos/build.sh # 版本取 manifest 中 version=
# FPK_VERSION=4.10.9 bash packaging/fnos/build.sh # 注入版本(CI 用 release tag
# Dependencies: python3+Pillow, node+npm, fnpack
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SRC_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" # LangBot 仓库根
FPK_DIR="${SCRIPT_DIR}"
echo "==> LangBot fnOS FPK builder (in-repo)"
echo " Source root: ${SRC_ROOT}"
echo " FPK dir: ${FPK_DIR}"
# --- 0. Inject version (release tag) ---
if [ -n "${FPK_VERSION:-}" ]; then
sed -i "s/^version=.*/version=${FPK_VERSION#v}/" "${FPK_DIR}/manifest"
else
# 无注入版本时自动跟进仓库主版本(pyproject.toml
PY_VER=$(grep -m1 '^version = ' "${SRC_ROOT}/pyproject.toml" | cut -d'"' -f2)
if [ -n "${PY_VER}" ]; then
sed -i "s/^version=.*/version=${PY_VER}/" "${FPK_DIR}/manifest"
fi
fi
MANIFEST_VER=$(grep '^version=' "${FPK_DIR}/manifest" | cut -d= -f2)
echo " FPK version: ${MANIFEST_VER}"
# --- 1. Build frontend ---
echo "[1/5] Building frontend (web/dist)..."
cd "${SRC_ROOT}/web"
if command -v pnpm >/dev/null 2>&1; then
pnpm install --frozen-lockfile 2>/dev/null || pnpm install
pnpm build
else
npm install
npx vite build
fi
[ -d dist ] || { echo "ERROR: web/dist missing" >&2; exit 1; }
echo " Frontend built"
# --- 2. Sync source into packaging/fnos/app/langbot/ ---
echo "[2/5] Syncing source to app/langbot/..."
rm -rf "${FPK_DIR}/app/langbot"
mkdir -p "${FPK_DIR}/app/langbot"
cd "${SRC_ROOT}"
rsync -a \
--exclude='.git' \
--exclude='.venv' \
--exclude='__pycache__' \
--exclude='*.pyc' \
--exclude='web/node_modules' \
--exclude='web/.vite' \
--exclude='tests' \
--exclude='packaging' \
--exclude='.pytest_cache' \
--exclude='.mypy_cache' \
--exclude='.ruff_cache' \
--exclude='data' \
--exclude='*.log' \
--exclude='.dockerignore' \
--exclude='Dockerfile' \
--exclude='docker/' \
--exclude='kubernetes.yaml' \
--exclude='.github/' \
--exclude='docs/' \
--exclude='examples/' \
--exclude='res/' \
./ "${FPK_DIR}/app/langbot/"
[ -d "${FPK_DIR}/app/langbot/web/dist" ] || { echo "ERROR: web/dist missing after rsync!" >&2; exit 1; }
echo " Source synced ($(du -sh "${FPK_DIR}/app/langbot" | cut -f1))"
# --- 2.5 Download bundled uv binaries (offline install on NAS) ---
echo "[2.5/5] Downloading bundled uv binaries..."
UV_VERSION="0.12.9"
mkdir -p "${FPK_DIR}/app/bin"
for arch in x86_64 aarch64; do
out="${FPK_DIR}/app/bin/uv-${arch}"
if [ -x "${out}" ]; then
echo " uv-${arch} already present, skip"
continue
fi
tmp="$(mktemp -d)"
if curl -sSL -o "${tmp}/uv.tar.gz" \
"https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/uv-${arch}-unknown-linux-gnu.tar.gz" \
&& tar xzf "${tmp}/uv.tar.gz" -C "${tmp}" \
&& cp "${tmp}/uv-${arch}-unknown-linux-gnu/uv" "${out}"; then
chmod +x "${out}"
echo " uv-${arch} downloaded (${UV_VERSION})"
else
echo " WARNING: failed to download uv for ${arch}, install will fall back to online install" >&2
fi
rm -rf "${tmp}"
done
# --- 3. Regenerate icons from res/logo-blue.png ---
echo "[3/5] Generating icons from res/logo-blue.png..."
export LOGO_SRC="${SRC_ROOT}/res/logo-blue.png"
export OUT_DIR="${FPK_DIR}"
python3 << 'PYEOF'
from PIL import Image
import os, sys
src = os.environ.get("LOGO_SRC")
out_dir = os.environ.get("OUT_DIR")
if not src or not out_dir:
print("ERROR: LOGO_SRC or OUT_DIR not set", file=sys.stderr)
sys.exit(1)
if not os.path.isfile(src):
print(f"ERROR: logo source not found: {src}", file=sys.stderr)
sys.exit(1)
img = Image.open(src).convert("RGBA")
for size, name in [(64, "ICON.PNG"), (256, "ICON_256.PNG")]:
img.resize((size, size), Image.LANCZOS).save(os.path.join(out_dir, name))
ui_dir = os.path.join(out_dir, "app/ui/images")
os.makedirs(ui_dir, exist_ok=True)
for size in [64, 256]:
img.resize((size, size), Image.LANCZOS).save(os.path.join(ui_dir, f"icon-{size}.png"))
desktop_dir = os.path.join(out_dir, "app/desktop/images")
os.makedirs(desktop_dir, exist_ok=True)
for size in [64, 256]:
img.resize((size, size), Image.LANCZOS).save(os.path.join(desktop_dir, f"icon-{size}.png"))
print(" Icons generated")
PYEOF
# --- 4. Validate structure ---
echo "[4/5] Validating FPK structure..."
ERRORS=0
[ -f "${FPK_DIR}/manifest" ] || { echo " MISSING: manifest"; ERRORS=$((ERRORS+1)); }
[ -f "${FPK_DIR}/config/privilege" ] || { echo " MISSING: config/privilege"; ERRORS=$((ERRORS+1)); }
[ -f "${FPK_DIR}/config/resource" ] || { echo " MISSING: config/resource"; ERRORS=$((ERRORS+1)); }
[ -f "${FPK_DIR}/ICON.PNG" ] || { echo " MISSING: ICON.PNG"; ERRORS=$((ERRORS+1)); }
[ -f "${FPK_DIR}/ICON_256.PNG" ] || { echo " MISSING: ICON_256.PNG"; ERRORS=$((ERRORS+1)); }
[ -f "${FPK_DIR}/app/ui/config" ] || { echo " MISSING: app/ui/config"; ERRORS=$((ERRORS+1)); }
[ -f "${FPK_DIR}/app/ui/images/icon-64.png" ] || { echo " MISSING: app/ui/images/icon-64.png"; ERRORS=$((ERRORS+1)); }
[ -f "${FPK_DIR}/app/ui/images/icon-256.png" ] || { echo " MISSING: app/ui/images/icon-256.png"; ERRORS=$((ERRORS+1)); }
[ -f "${FPK_DIR}/app/desktop/langbot.main.url" ] || { echo " MISSING: app/desktop/langbot.main.url"; ERRORS=$((ERRORS+1)); }
[ -f "${FPK_DIR}/app/desktop/images/icon-64.png" ] || { echo " MISSING: app/desktop/images/icon-64.png"; ERRORS=$((ERRORS+1)); }
[ -f "${FPK_DIR}/app/desktop/images/icon-256.png" ] || { echo " MISSING: app/desktop/images/icon-256.png"; ERRORS=$((ERRORS+1)); }
[ -d "${FPK_DIR}/cmd" ] || { echo " MISSING: cmd/"; ERRORS=$((ERRORS+1)); }
[ -d "${FPK_DIR}/wizard" ] || { echo " MISSING: wizard/"; ERRORS=$((ERRORS+1)); }
for script in "${FPK_DIR}/cmd/"*; do
[ -x "${script}" ] || { echo " NOT EXECUTABLE: cmd/$(basename "$script")"; ERRORS=$((ERRORS+1)); }
done
if [ "${ERRORS}" -gt 0 ]; then
echo "FAILED: ${ERRORS} validation errors" >&2
exit 1
fi
echo " Structure OK"
# --- 5. Build FPK ---
echo "[5/5] Building .fpk..."
if ! command -v fnpack >/dev/null 2>&1; then
echo "ERROR: fnpack not found in PATH." >&2
echo " Download from https://developer.fnnas.com/docs/cli/fnpack" >&2
exit 1
fi
cd "${FPK_DIR}"
fnpack build
FPK_FILE=$(ls -t *.fpk 2>/dev/null | head -1)
if [ -n "${FPK_FILE}" ]; then
echo ""
echo "==> Done! FPK: ${FPK_DIR}/${FPK_FILE}"
echo " Size: $(du -sh "${FPK_FILE}" | cut -f1)"
else
echo "WARNING: fnpack finished but no .fpk found in ${FPK_DIR}" >&2
exit 1
fi
+6
View File
@@ -0,0 +1,6 @@
#!/bin/bash
# cmd/config_callback - post-config hook
# Applies wizard-provided settings (e.g. Node.js version) that cmd/main reads.
# Nothing to persist currently; cmd/main reads wizard_node_version at runtime.
exit 0
+4
View File
@@ -0,0 +1,4 @@
#!/bin/bash
# cmd/config_init - pre-config hook
exit 0
+149
View File
@@ -0,0 +1,149 @@
#!/bin/bash
# cmd/install_callback - post-install hook
# Prefers bundled uv binary, creates Python venv, syncs deps, verifies dist.
# Also validates the user-selected Node.js version is actually installed.
APP_DIR="${TRIM_APPDEST}/langbot"
# --- Resolve data directory ---
# LangBot loads config CWD-relative (data/config.yaml); cmd/main replaces
# APP_DIR/data with a symlink to this persistent dir on every start.
DATA_DIR="${TRIM_DATA_SHARE_PATHS%%:*}"
if [ -z "${DATA_DIR}" ]; then
DATA_DIR="${TRIM_PKGVAR}/data"
fi
cd "${APP_DIR}" || {
echo "App directory missing after install" > "${TRIM_TEMP_LOGFILE}"
exit 1
}
# --- Ensure data directory exists ---
mkdir -p "${DATA_DIR}/plugins" "${DATA_DIR}/box" "${DATA_DIR}/logs" 2>/dev/null || true
# --- Pre-seed config.yaml with the user-selected web port ---
# Data root points at the persistent share (LANGBOT_DATA_ROOT is exported by
# cmd/main at start), so this config survives app upgrades.
# LangBot copies templates/config.yaml there on first boot only if missing,
# so we seed it ourselves with the chosen port.
PORT="${wizard_port:-5300}"
case "${PORT}" in
''|*[!0-9]*) PORT="5300" ;;
esac
TEMPLATE_FILE="${APP_DIR}/src/langbot/templates/config.yaml"
_patch_config() {
local cfg_dir="$1"
local cfg_file="${cfg_dir}/config.yaml"
mkdir -p "${cfg_dir}" 2>/dev/null || true
if [ ! -f "${cfg_file}" ] && [ -f "${TEMPLATE_FILE}" ]; then
cp "${TEMPLATE_FILE}" "${cfg_file}"
fi
if [ -f "${cfg_file}" ]; then
sed -i -E "/^api:/,/^[a-z_]+:/ s/^([[:space:]]*port:).*/\1 ${PORT}/" "${cfg_file}"
sed -i "s#webhook_prefix: 'http://127\.0\.0\.1:[0-9]*'#webhook_prefix: 'http://127.0.0.1:${PORT}'#" "${cfg_file}"
fi
}
# Seed the persistent dir AND the CWD-relative APP_DIR/data (cmd/main merges
# the latter into the persistent dir via symlink on first start, so the port
# survives regardless of which path ends up being read).
_patch_config "${DATA_DIR}"
if [ "${APP_DIR}/data" != "${DATA_DIR}" ]; then
_patch_config "${APP_DIR}/data"
fi
# --- Fallback patch for desktop entry port ---
# fnOS natively substitutes ${wizard_port} in ui/config at install time.
# This only kicks in if the placeholder somehow survived (e.g. CLI install).
UI_CONFIG="${TRIM_APPDEST}/ui/config"
if [ -f "${UI_CONFIG}" ] && grep -q 'wizard_port\|{port}' "${UI_CONFIG}"; then
sed -i "s/\${wizard_port}/${PORT}/g; s/{port}/${PORT}/g" "${UI_CONFIG}"
fi
# --- Validate user-selected Node.js version is installed ---
NODE_VERSION="${wizard_node_version:-22}"
if [ ! -d "/var/apps/nodejs_v${NODE_VERSION}" ]; then
echo "Node.js v${NODE_VERSION} 未安装:请先在应用中心安装 nodejs_v${NODE_VERSION},再重新安装本应用。" > "${TRIM_TEMP_LOGFILE}"
exit 1
fi
# --- Python check ---
PYTHON_BIN="python3"
if ! command -v "${PYTHON_BIN}" >/dev/null 2>&1; then
PYTHON_BIN="python"
fi
if ! command -v "${PYTHON_BIN}" >/dev/null 2>&1; then
echo "Python not found on this system" > "${TRIM_TEMP_LOGFILE}"
exit 1
fi
PY_VER=$("${PYTHON_BIN}" -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")' 2>/dev/null)
if [ -z "${PY_VER}" ]; then
echo "Python 3.11+ required but not found on this system" > "${TRIM_TEMP_LOGFILE}"
exit 1
fi
PY_MAJOR=$(echo "${PY_VER}" | cut -d. -f1)
PY_MINOR=$(echo "${PY_VER}" | cut -d. -f2)
if [ "${PY_MAJOR}" -lt 3 ] || { [ "${PY_MAJOR}" -eq 3 ] && [ "${PY_MINOR}" -lt 11 ]; }; then
echo "Python 3.11+ required, found ${PY_VER}" > "${TRIM_TEMP_LOGFILE}"
exit 1
fi
# --- Resolve uv: bundled binary first, then online fallbacks ---
UV_BIN=""
ARCH=$(uname -m)
case "${ARCH}" in
x86_64) BUNDLED_UV="${TRIM_APPDEST}/bin/uv-x86_64" ;;
aarch64) BUNDLED_UV="${TRIM_APPDEST}/bin/uv-aarch64" ;;
*) BUNDLED_UV="" ;;
esac
if [ -n "${BUNDLED_UV}" ] && [ -x "${BUNDLED_UV}" ]; then
mkdir -p "${TRIM_PKGVAR}/bin"
cp "${BUNDLED_UV}" "${TRIM_PKGVAR}/bin/uv" && chmod +x "${TRIM_PKGVAR}/bin/uv"
UV_BIN="${TRIM_PKGVAR}/bin/uv"
fi
if [ -z "${UV_BIN}" ] && command -v uv >/dev/null 2>&1; then
UV_BIN="uv"
fi
if [ -z "${UV_BIN}" ]; then
"${PYTHON_BIN}" -m pip install --user --no-cache-dir uv 2>/dev/null || \
"${PYTHON_BIN}" -m pip install --no-cache-dir uv 2>/dev/null || \
curl -LsSf https://astral.sh/uv/install.sh | sh 2>/dev/null || true
export PATH="${HOME}/.local/bin:${PATH}"
if command -v uv >/dev/null 2>&1; then
UV_BIN="uv"
elif [ -x "${HOME}/.local/bin/uv" ]; then
UV_BIN="${HOME}/.local/bin/uv"
fi
fi
if [ -z "${UV_BIN}" ]; then
echo "无法获取 uv:内置二进制缺失且在线安装失败。请检查网络后重新安装。" > "${TRIM_TEMP_LOGFILE}"
exit 1
fi
# --- Create venv via uv ---
if [ ! -d ".venv" ]; then
"${UV_BIN}" venv .venv --python "${PYTHON_BIN}" || {
echo "Failed to create Python virtual environment via uv" > "${TRIM_TEMP_LOGFILE}"
exit 1
}
fi
# --- Sync dependencies ---
"${UV_BIN}" sync --extra seekdb || {
echo "Dependency sync failed. Check network connectivity." > "${TRIM_TEMP_LOGFILE}"
exit 1
}
# --- Verify frontend dist ---
if [ ! -d "web/dist" ] || [ -z "$(ls -A web/dist 2>/dev/null)" ]; then
echo "Frontend dist missing! Web UI will not be available." > "${TRIM_TEMP_LOGFILE}"
exit 1
fi
exit 0
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
# cmd/install_init - pre-install hook
# Nothing special to do before extraction.
exit 0
+194
View File
@@ -0,0 +1,194 @@
#!/bin/bash
# cmd/main - LangBot lifecycle manager for fnOS
# Handles start / stop / status via standalone-runtime Python process.
# Node.js path is injected into PATH so Box sandbox npx MCP servers can run.
PID_FILE="${TRIM_PKGVAR}/langbot.pid"
APP_DIR="${TRIM_APPDEST}/langbot"
LOG_FILE="${TRIM_PKGVAR}/langbot.log"
# --- Locate fnOS Node.js bin path ---
# fnOS appname-based path: /var/apps/nodejs_vXX/target/bin
# This is a stable symlink regardless of which volume the app is on.
NODE_VERSION="${wizard_node_version:-22}"
NODE_BIN_DIR="/var/apps/nodejs_v${NODE_VERSION}/target/bin"
if [ -d "${NODE_BIN_DIR}" ]; then
export PATH="${NODE_BIN_DIR}:${PATH}"
fi
# --- Persistent data root ---
# LangBot loads data/config.yaml CWD-RELATIVE (see core/stages/load_config.py:
# load_yaml_config('data/config.yaml', ...)) and resolves its data root to
# <CWD>/data in source-install mode — it does NOT honour LANGBOT_DATA_ROOT for
# config.yaml. So the real fix is the symlink below: APP_DIR/data -> DATA_DIR.
DATA_DIR="${TRIM_DATA_SHARE_PATHS%%:*}"
if [ -z "${DATA_DIR}" ]; then
DATA_DIR="${TRIM_PKGVAR}/data"
fi
export LANGBOT_DATA_ROOT="${DATA_DIR}"
mkdir -p "${DATA_DIR}" 2>/dev/null || true
# --- Locate Python ---
PYTHON_BIN="python3"
! command -v "${PYTHON_BIN}" >/dev/null 2>&1 && PYTHON_BIN="python"
# --- Locate uv ---
# install_callback puts the bundled uv binary at ${TRIM_PKGVAR}/bin/uv
UV_BIN="${TRIM_PKGVAR}/bin/uv"
if [ ! -x "${UV_BIN}" ]; then
UV_BIN="uv"
fi
if ! command -v "${UV_BIN}" >/dev/null 2>&1; then
UV_BIN="${HOME}/.local/bin/uv"
fi
if ! command -v "${UV_BIN}" >/dev/null 2>&1 && [ ! -x "${UV_BIN}" ]; then
UV_BIN="${HOME}/.cargo/bin/uv"
fi
if ! command -v "${UV_BIN}" >/dev/null 2>&1 && [ ! -x "${UV_BIN}" ]; then
UV_BIN="${APP_DIR}/.venv/bin/uv"
fi
case $1 in
start)
if [ -f "${PID_FILE}" ]; then
PID=$(cat "${PID_FILE}" | tr -d '[:space:]')
if [ -n "${PID}" ] && kill -0 "${PID}" 2>/dev/null; then
exit 0
fi
rm -f "${PID_FILE}"
fi
if [ ! -d "${APP_DIR}" ]; then
echo "LangBot app directory missing: ${APP_DIR}" > "${TRIM_TEMP_LOGFILE}"
exit 1
fi
cd "${APP_DIR}" || {
echo "Cannot enter app directory: ${APP_DIR}" > "${TRIM_TEMP_LOGFILE}"
exit 1
}
if [ ! -d ".venv" ]; then
echo "Python virtual environment not found. Please reinstall LangBot." > "${TRIM_TEMP_LOGFILE}"
exit 1
fi
# --- Unify data location: APP_DIR/data -> symlink to persistent DATA_DIR ---
# LangBot reads config CWD-relative (data/config.yaml), so without this a
# fresh data/ with a default 5300 config gets recreated inside target/ on
# every install/upgrade. The symlink keeps everything on the persistent
# share; it is recreated here on each start (upgrades wipe target/).
APP_DATA="${APP_DIR}/data"
if [ -L "${APP_DATA}" ]; then
# already a symlink; re-point if the persistent dir changed
[ "$(readlink "${APP_DATA}")" != "${DATA_DIR}" ] && ln -sfn "${DATA_DIR}" "${APP_DATA}"
elif [ -d "${APP_DATA}" ]; then
# legacy real dir (created by LangBot before this fix): merge into the
# persistent dir without overwriting newer files already there
mkdir -p "${DATA_DIR}"
cp -an "${APP_DATA}/." "${DATA_DIR}/" 2>/dev/null || cp -a "${APP_DATA}/." "${DATA_DIR}/"
rm -rf "${APP_DATA}"
ln -s "${DATA_DIR}" "${APP_DATA}"
else
ln -s "${DATA_DIR}" "${APP_DATA}"
fi
# Ensure LangBot's actual listen port always matches what fnOS shows in
# "应用设置 → 访问端口" (which is the single source of truth from the user's
# POV). Two sources, checked in priority order:
# 1. ${wizard_port} — only set during install/upgrade callbacks (not on
# normal `start`; kept for completeness).
# 2. target/ui/config — read the "port" field written by fnOS after
# ${wizard_port} substitution AND any later edit the user made via
# "应用设置 → 自定义 URL" pencil button.
# Without this: user picks 5303 in wizard, LangBot still listens on its
# default 5300, desktop shortcut hits 5303 → connection refused.
CONFIG_FILE="${DATA_DIR}/config.yaml"
_patch_port() {
local _port="$1"
case "${_port}" in
''|*[!0-9]*) return ;;
esac
if [ ! -f "${CONFIG_FILE}" ]; then
local _tmpl="${APP_DIR}/src/langbot/templates/config.yaml"
[ -f "${_tmpl}" ] && cp "${_tmpl}" "${CONFIG_FILE}"
fi
if [ -f "${CONFIG_FILE}" ]; then
sed -i -E "/^api:/,/^[a-z_]+:/ s/^([[:space:]]*port:).*/\1 ${_port}/" "${CONFIG_FILE}"
sed -i "s#webhook_prefix: 'http://127\.0\.0\.1:[0-9]*'#webhook_prefix: 'http://127.0.0.1:${_port}'#" "${CONFIG_FILE}"
fi
}
if [ -n "${wizard_port:-}" ]; then
_patch_port "${wizard_port}"
fi
if [ -f "${TRIM_APPDEST}/ui/config" ]; then
_port_from_ui=$(python3 -c 'import json,sys
try:
d = json.load(open(sys.argv[1]))
for _name, _entry in (d.get(".url") or {}).items():
p = _entry.get("port")
if isinstance(p, (int, float)):
print(int(p))
elif isinstance(p, str) and p.isdigit():
print(int(p))
break
except Exception:
pass
' "${TRIM_APPDEST}/ui/config" 2>/dev/null)
if [ -n "${_port_from_ui}" ]; then
_patch_port "${_port_from_ui}"
fi
fi
# Native deployment: no --standalone-runtime flag, LangBot spawns the
# plugin runtime as a stdio subprocess (same as official `uv run main.py`).
# (--standalone-runtime would require an external runtime at
# ws://langbot_plugin_runtime:5400, which only exists in Docker Compose.)
# --standalone-box omitted: Box sandbox defaults off, users enable via Web UI
nohup "${UV_BIN}" run --no-sync main.py \
> "${LOG_FILE}" 2>&1 &
echo $! > "${PID_FILE}"
sleep 3
if [ -f "${PID_FILE}" ]; then
PID=$(cat "${PID_FILE}" | tr -d '[:space:]')
if [ -n "${PID}" ] && kill -0 "${PID}" 2>/dev/null; then
exit 0
fi
fi
echo "LangBot failed to start. Check ${LOG_FILE}" > "${TRIM_TEMP_LOGFILE}"
exit 1
;;
stop)
if [ -f "${PID_FILE}" ]; then
PID=$(cat "${PID_FILE}" | tr -d '[:space:]')
if [ -n "${PID}" ]; then
kill "${PID}" 2>/dev/null
for _ in 1 2 3 4 5 6 7 8 9 10; do
kill -0 "${PID}" 2>/dev/null || break
sleep 1
done
kill -9 "${PID}" 2>/dev/null
fi
rm -f "${PID_FILE}"
fi
exit 0
;;
status)
if [ -f "${PID_FILE}" ]; then
PID=$(cat "${PID_FILE}" | tr -d '[:space:]')
if [ -n "${PID}" ] && kill -0 "${PID}" 2>/dev/null; then
exit 0
fi
rm -f "${PID_FILE}"
fi
exit 3
;;
*)
exit 1
;;
esac
+21
View File
@@ -0,0 +1,21 @@
#!/bin/bash
# cmd/uninstall_callback - post-uninstall hook
# The system preserves var/ and shares/ by default. Honor the user's
# wizard_keep_data choice: delete data only when explicitly requested.
if [ "${wizard_keep_data:-yes}" = "no" ]; then
# 应用运行数据(pid、日志等)
if [ -n "${TRIM_PKGVAR}" ]; then
rm -rf "${TRIM_PKGVAR:?}"/langbot.pid \
"${TRIM_PKGVAR:?}"/langbot.log \
"${TRIM_PKGVAR:?}"/bin 2>/dev/null || true
fi
# 共享数据目录(langbot/data
DATA_DIR="${TRIM_DATA_SHARE_PATHS%%:*}"
if [ -n "${DATA_DIR}" ]; then
rm -rf "${DATA_DIR:?}" 2>/dev/null || true
fi
fi
exit 0
+20
View File
@@ -0,0 +1,20 @@
#!/bin/bash
# cmd/uninstall_init - pre-uninstall hook
# Stop LangBot before files are removed.
PID_FILE="${TRIM_PKGVAR}/langbot.pid"
if [ -f "${PID_FILE}" ]; then
PID=$(cat "${PID_FILE}" | tr -d '[:space:]')
if [ -n "${PID}" ] && kill -0 "${PID}" 2>/dev/null; then
kill "${PID}" 2>/dev/null
for _ in 1 2 3 4 5 6 7 8 9 10; do
kill -0 "${PID}" 2>/dev/null || break
sleep 1
done
kill -9 "${PID}" 2>/dev/null
fi
rm -f "${PID_FILE}"
fi
exit 0
+77
View File
@@ -0,0 +1,77 @@
#!/bin/bash
# cmd/upgrade_callback - post-upgrade hook
# Re-sync dependencies after code replacement using uv.
APP_DIR="${TRIM_APPDEST}/langbot"
# Persistent data root (must match cmd/main)
DATA_DIR="${TRIM_DATA_SHARE_PATHS%%:*}"
[ -z "${DATA_DIR}" ] && DATA_DIR="${TRIM_PKGVAR}/data"
# Apply port from upgrade wizard (config persists across upgrades; this
# only rewrites it when the user changed the value in the upgrade wizard)
CONFIG_FILE="${DATA_DIR}/config.yaml"
if [ -n "${wizard_port:-}" ] && [ -f "${CONFIG_FILE}" ]; then
case "${wizard_port}" in
''|*[!0-9]*) ;;
*)
sed -i -E "/^api:/,/^[a-z_]+:/ s/^([[:space:]]*port:).*/\1 ${wizard_port}/" "${CONFIG_FILE}"
sed -i "s#webhook_prefix: 'http://127\.0\.0\.1:[0-9]*'#webhook_prefix: 'http://127.0.0.1:${wizard_port}'#" "${CONFIG_FILE}"
;;
esac
fi
cd "${APP_DIR}" || {
echo "App directory missing after upgrade" > "${TRIM_TEMP_LOGFILE}"
exit 1
}
# Find uv (bundled first, then PATH / ~/.local/bin / ~/.cargo/bin)
UV_BIN="${TRIM_PKGVAR}/bin/uv"
if [ ! -x "${UV_BIN}" ]; then
UV_BIN="uv"
fi
if ! command -v "${UV_BIN}" >/dev/null 2>&1; then
UV_BIN="${HOME}/.local/bin/uv"
fi
if ! command -v "${UV_BIN}" >/dev/null 2>&1 && [ ! -x "${UV_BIN}" ]; then
UV_BIN="${HOME}/.cargo/bin/uv"
fi
PYTHON_BIN="python3"
! command -v "${PYTHON_BIN}" >/dev/null 2>&1 && PYTHON_BIN="python"
# Re-sync deps
if [ -d ".venv" ]; then
"${UV_BIN}" sync --extra seekdb 2>/dev/null || {
echo "Dependency sync failed after upgrade" > "${TRIM_TEMP_LOGFILE}"
exit 1
}
else
# Venv was lost, recreate via uv
if ! command -v "${UV_BIN}" >/dev/null 2>&1 && [ ! -x "${UV_BIN}" ]; then
"${PYTHON_BIN}" -m pip install --user --no-cache-dir uv 2>/dev/null || \
"${PYTHON_BIN}" -m pip install --no-cache-dir uv 2>/dev/null || {
echo "Failed to install uv" > "${TRIM_TEMP_LOGFILE}"
exit 1
}
export PATH="${HOME}/.local/bin:${PATH}"
UV_BIN="uv"
fi
"${UV_BIN}" venv .venv --python "${PYTHON_BIN}" || {
echo "Failed to recreate virtual environment" > "${TRIM_TEMP_LOGFILE}"
exit 1
}
"${UV_BIN}" sync --extra seekdb || {
echo "Dependency sync failed" > "${TRIM_TEMP_LOGFILE}"
exit 1
}
fi
# Verify frontend dist still present
if [ ! -d "web/dist" ] || [ -z "$(ls -A web/dist 2>/dev/null)" ]; then
echo "Frontend dist missing after upgrade! Web UI will not be available." > "${TRIM_TEMP_LOGFILE}"
exit 1
fi
exit 0
+20
View File
@@ -0,0 +1,20 @@
#!/bin/bash
# cmd/upgrade_init - pre-upgrade hook
# Stop the running LangBot process before files are replaced.
PID_FILE="${TRIM_PKGVAR}/langbot.pid"
if [ -f "${PID_FILE}" ]; then
PID=$(cat "${PID_FILE}" | tr -d '[:space:]')
if [ -n "${PID}" ] && kill -0 "${PID}" 2>/dev/null; then
kill "${PID}" 2>/dev/null
for _ in 1 2 3 4 5 6 7 8 9 10; do
kill -0 "${PID}" 2>/dev/null || break
sleep 1
done
kill -9 "${PID}" 2>/dev/null
fi
rm -f "${PID_FILE}"
fi
exit 0
+5
View File
@@ -0,0 +1,5 @@
{
"defaults": {
"run-as": "root"
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"data-share": {
"shares": [
{
"name": "langbot/data"
}
]
}
}
+16
View File
@@ -0,0 +1,16 @@
appname=langbot
version=4.10.10
display_name=LangBot
desc=基于 LLM 的多平台智能对话机器人,支持 QQ、微信、飞书、钉钉、Telegram 等十余种即时通讯平台,内置 Web 管理界面和 AI Agent 能力。
platform=all
source=thirdparty
maintainer=LangBot
maintainer_url=https://langbot.app
service_port=5300
checkport=true
os_min_version=0.9.0
desktop_uidir=ui
desktop_applaunchname=langbot.main
ctl_stop=true
install_dep_apps=nodejs_v22
changelog=飞牛 fnOS 增强版首版:原生 Python 部署,依赖 Node.js v22 以启用 Box 沙箱 + npx MCP 能力
+47
View File
@@ -0,0 +1,47 @@
[
{
"stepTitle": "运行环境",
"items": [
{
"type": "tips",
"helpText": "LangBot 依赖 Node.js v22 运行 Box 沙箱和 npx MCP。请先在应用中心安装 Node.js v22。"
},
{
"type": "select",
"field": "wizard_node_version",
"label": "Node.js 版本",
"initValue": "22",
"options": [
{ "label": "Node.js v22 (推荐, LTS)", "value": "22" },
{ "label": "Node.js v24", "value": "24" },
{ "label": "Node.js v20", "value": "20" }
]
}
]
},
{
"stepTitle": "访问配置",
"items": [
{
"type": "text",
"field": "wizard_port",
"label": "Web 访问端口",
"initValue": "5300",
"rules": [
{ "required": true, "message": "请输入访问端口" },
{ "pattern": "^[0-9]+$", "message": "端口只能是数字" },
{ "min": 1, "max": 5, "message": "端口号长度不正确" }
]
}
]
},
{
"stepTitle": "安装说明",
"items": [
{
"type": "tips",
"helpText": "安装完成后,LangBot 首次启动约需 5-10 分钟完成依赖部署与初始化,部署完成后即可打开网页端使用。"
}
]
}
]
+17
View File
@@ -0,0 +1,17 @@
[
{
"stepTitle": "数据保留",
"items": [
{
"type": "radio",
"field": "wizard_keep_data",
"label": "是否保留 LangBot 数据(插件、配置、日志)",
"initValue": "yes",
"options": [
{ "label": "保留数据(重新安装后可继续使用)", "value": "yes" },
{ "label": "彻底删除全部数据", "value": "no" }
]
}
]
}
]
+38
View File
@@ -0,0 +1,38 @@
[
{
"stepTitle": "运行环境",
"items": [
{
"type": "tips",
"helpText": "LangBot 依赖 Node.js v22 运行 Box 沙箱和 npx MCP。如需更换版本,请先在应用中心安装对应版本。"
},
{
"type": "select",
"field": "wizard_node_version",
"label": "Node.js 版本",
"initValue": "22",
"options": [
{ "label": "Node.js v22 (推荐, LTS)", "value": "22" },
{ "label": "Node.js v24", "value": "24" },
{ "label": "Node.js v20", "value": "20" }
]
}
]
},
{
"stepTitle": "访问配置",
"items": [
{
"type": "text",
"field": "wizard_port",
"label": "Web 访问端口",
"initValue": "5300",
"rules": [
{ "required": true, "message": "请输入访问端口" },
{ "pattern": "^[0-9]+$", "message": "端口只能是数字" },
{ "min": 1, "max": 5, "message": "端口号长度不正确" }
]
}
]
}
]
+5 -3
View File
@@ -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",
@@ -109,7 +110,8 @@ classifiers = [
[project.optional-dependencies]
seekdb = [
"pyseekdb==1.1.0.post3",
"pyseekdb==1.4.0.post1",
"pylibseekdb==1.4.0; sys_platform == 'linux' or (sys_platform == 'darwin' and platform_machine == 'arm64')",
]
[project.urls]
+34 -1
View File
@@ -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,
+64 -19
View File
@@ -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}
+333
View File
@@ -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)
+1
View File
@@ -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,
)
+8 -6
View File
@@ -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
@@ -1,5 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="TokenLab">
<rect width="64" height="64" rx="14" fill="#111827"/>
<path fill="#38bdf8" d="M17 14h30v8H36v28h-8V22H17z"/>
<path fill="#a7f3d0" d="M40 30h8v20H28v-8h12z"/>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" role="img" aria-labelledby="tokenlab-symbol-title tokenlab-symbol-desc">
<title id="tokenlab-symbol-title">TokenLab</title>
<desc id="tokenlab-symbol-desc">Specimen Split symbol, positive master for sizes from 32 to 96 pixels.</desc>
<g fill="#151714">
<path d="M5 13h14.25L35 43H5V13Z"/>
<path d="M20.7 5H43v30H35.6L20.7 5Z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 265 B

After

Width:  |  Height:  |  Size: 416 B

+25 -26
View File
@@ -101,18 +101,6 @@ class SeekDBVectorDatabase(VectorDatabase):
self._collection_configs: Dict[str, HNSWConfiguration] = {}
self._runtime_cache_limit = runtime_cache_limit(ap)
self._escape_table = str.maketrans(
{
'\x00': '',
'\\': '\\\\',
"'": "''", # Standard SQL escaping (OceanBase NO_BACKSLASH_ESCAPES)
'"': '\\"',
'\n': '\\n',
'\r': '\\r',
'\t': '\\t',
}
)
async def close(self) -> None:
self._collections.clear()
self._collection_configs.clear()
@@ -192,16 +180,22 @@ class SeekDBVectorDatabase(VectorDatabase):
return coll
def _clean_metadata(self, meta: Dict[str, Any]) -> Dict[str, Any]:
"""SeekDB metadata doesn't support \\ and ", insert will error 3104"""
return {
k: v.translate(self._escape_table)
if isinstance(v, str)
else v
if v is None or isinstance(v, (int, float, bool))
else str(v)
for k, v in meta.items()
if v is not None
}
"""Keep supported scalar metadata values without altering strings."""
return {k: v if isinstance(v, (str, int, float, bool)) else str(v) for k, v in meta.items() if v is not None}
@staticmethod
def _relevance_scores_to_distances(results: Dict[str, Any]) -> None:
"""Convert SeekDB hybrid relevance scores to lower-is-better distances."""
distances = results.get('distances')
if not isinstance(distances, list):
return
results['distances'] = [
[1.0 - float(score) if isinstance(score, (int, float)) else score for score in batch]
if isinstance(batch, list)
else batch
for batch in distances
]
async def get_or_create_collection(self, collection: str):
"""Get or create collection (without vector size - will use default)."""
@@ -236,10 +230,10 @@ class SeekDBVectorDatabase(VectorDatabase):
kwargs: Dict[str, Any] = dict(ids=ids, embeddings=embeddings_list, metadatas=cleaned_metadatas)
if documents is not None:
kwargs['documents'] = [doc.translate(self._escape_table) for doc in documents]
await asyncio.to_thread(coll.add, **kwargs)
kwargs['documents'] = documents
await asyncio.to_thread(coll.upsert, **kwargs)
self.ap.logger.info(f"Added {len(ids)} embeddings to SeekDB collection '{collection}'")
self.ap.logger.info(f"Upserted {len(ids)} embeddings into SeekDB collection '{collection}'")
async def search(
self,
@@ -287,7 +281,8 @@ class SeekDBVectorDatabase(VectorDatabase):
# Route by search type.
# pyseekdb's query() always requires embeddings, so full-text and
# hybrid modes use hybrid_search() which supports text-only queries
# and returns the same nested-list format with distances.
# and returns relevance scores in the nested ``distances`` field.
returns_relevance_scores = False
if search_type == SearchType.FULL_TEXT:
if not query_text:
return {'ids': [[]], 'metadatas': [[]], 'distances': [[]]}
@@ -309,6 +304,7 @@ class SeekDBVectorDatabase(VectorDatabase):
n_results=k,
include=['documents', 'metadatas'],
)
returns_relevance_scores = True
elif search_type == SearchType.HYBRID:
if not query_text:
@@ -352,6 +348,7 @@ class SeekDBVectorDatabase(VectorDatabase):
n_results=k,
include=['documents', 'metadatas'],
)
returns_relevance_scores = True
self.ap.logger.info(
f"SeekDB hybrid search in '{collection}' returned {len(results.get('ids', [[]])[0])} results."
)
@@ -363,6 +360,8 @@ class SeekDBVectorDatabase(VectorDatabase):
results = await asyncio.to_thread(coll.query, **query_kwargs)
results = self._json_safe(results)
if returns_relevance_scores:
self._relevance_scores_to_distances(results)
self.ap.logger.info(
f"SeekDB {search_type} search in '{collection}' returned {len(results.get('ids', [[]])[0])} results"
)
+35 -12
View File
@@ -143,18 +143,41 @@ stages:
operator: eq
value: false
disabled_tooltip:
en_US: >-
Sandbox scope can't be changed: either the Box sandbox is disabled
or unavailable (enable it in config.yaml with box.enabled = true and
ensure the runtime is reachable), or this deployment pins all
pipelines to a fixed scope.
zh_Hans: "无法修改沙箱作用域:Box 沙箱已禁用或不可用(请在配置中启用 box.enabled = true 并确认运行时连接正常),或本部署已将所有流水线固定为统一作用域。"
zh_Hant: "無法修改沙箱作用域:Box 沙箱已停用或無法使用(請在設定中啟用 box.enabled = true 並確認執行時連線正常),或本部署已將所有流水線固定為統一作用域。"
ja_JP: "サンドボックススコープを変更できません:Box サンドボックスが無効/利用不可(設定で box.enabled = true にしてランタイム接続を確認)、またはこのデプロイがすべてのパイプラインを固定スコープに制限しています。"
vi_VN: "Không thể thay đổi phạm vi sandboxBox sandbox bị tắt hoặc không khả dụng (bật box.enabled = true và đảm bảo runtime hoạt động), hoặc bản triển khai này cố định mọi pipeline về một phạm vi."
th_TH: "ไม่สามารถเปลี่ยนขอบเขต Sandbox:Box sandbox ถูกปิดหรือไม่พร้อมใช้งาน (เปิด box.enabled = true และตรวจสอบรันไทม์) หรือการ deploy นี้ล็อกทุก pipeline ไว้ที่ขอบเขตเดียว"
es_ES: "No se puede cambiar el alcance del sandbox: el sandbox de Box está desactivado o no disponible (actívelo con box.enabled = true y verifique el runtime), o este despliegue fija todas las pipelines a un alcance único."
ru_RU: "Невозможно изменить область песочницы: песочница Box отключена или недоступна (включите box.enabled = true и проверьте среду выполнения), либо это развёртывание фиксирует единую область для всех конвейеров."
en_US: "Sandbox is unavailable. Enable Box and check its connection before changing the scope."
zh_Hans: "沙箱未启用,请启用 Box 并确认连接正常后再修改作用域。"
zh_Hant: "沙箱未啟用,請啟用 Box 並確認連線正常後再修改作用域。"
ja_JP: "サンドボックスは利用できません。Box を有効にし、接続を確認してからスコープを変更してください。"
vi_VN: "Sandbox không khả dụng. Hãy bật Box và kiểm tra kết nối trước khi thay đổi phạm vi."
th_TH: "Sandbox ไม่พร้อมใช้งาน โปรดเปิดใช้งาน Box และตรวจสอบการเชื่อมต่อก่อนเปลี่ยนขอบเขต"
es_ES: "El sandbox no está disponible. Active Box y compruebe su conexión antes de cambiar el alcance."
ru_RU: "Песочница недоступна. Включите Box и проверьте подключение, прежде чем менять область."
disabled_tooltip_overrides:
- when:
field: __system.box_scope_forced_global
operator: eq
value: true
tooltip:
en_US: "A global sandbox is enforced; the scope cannot be changed."
zh_Hans: "已强制使用全局沙箱,无法修改作用域。"
zh_Hant: "已強制使用全域沙箱,無法修改作用域。"
ja_JP: "グローバルサンドボックスの使用が強制されているため、スコープを変更できません。"
vi_VN: "Bắt buộc sử dụng sandbox toàn cục; không thể thay đổi phạm vi."
th_TH: "ระบบบังคับใช้ Sandbox ส่วนกลาง จึงไม่สามารถเปลี่ยนขอบเขตได้"
es_ES: "Se impone un sandbox global; no se puede cambiar el alcance."
ru_RU: "Принудительно используется глобальная песочница; изменить область нельзя."
- when:
field: __system.box_scope_forced
operator: eq
value: true
tooltip:
en_US: "A fixed sandbox scope is enforced; the scope cannot be changed."
zh_Hans: "已强制使用固定沙箱作用域,无法修改作用域。"
zh_Hant: "已強制使用固定沙箱作用域,無法修改作用域。"
ja_JP: "固定のサンドボックススコープが強制されているため、スコープを変更できません。"
vi_VN: "Phạm vi sandbox đã được cố định bắt buộc; không thể thay đổi phạm vi."
th_TH: "ระบบบังคับใช้ขอบเขต Sandbox แบบตายตัว จึงไม่สามารถเปลี่ยนขอบเขตได้"
es_ES: "Se impone un alcance fijo del sandbox; no se puede cambiar el alcance."
ru_RU: "Принудительно задана фиксированная область песочницы; изменить её нельзя."
type: select
required: false
default: "{launcher_type}_{launcher_id}"
+11 -4
View File
@@ -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')
+6
View File
@@ -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()
+123
View File
@@ -0,0 +1,123 @@
"""Real embedded SeekDB regression tests.
Install the optional dependency before running these slow tests::
uv sync --dev --extra seekdb
uv run pytest tests/integration/vector/test_seekdb.py -m slow -q
"""
from __future__ import annotations
import asyncio
from types import SimpleNamespace
import uuid
import pytest
pytest.importorskip('pyseekdb')
from langbot.pkg.vector.vdbs.seekdb import SeekDBVectorDatabase
pytestmark = [pytest.mark.integration, pytest.mark.slow]
@pytest.fixture
async def backend(tmp_path):
app = SimpleNamespace(
instance_config=SimpleNamespace(
data={
'vdb': {
'runtime_cache_limit': 16,
'seekdb': {
'mode': 'embedded',
'path': str(tmp_path),
'database': 'langbot_test',
},
}
}
),
logger=SimpleNamespace(
info=lambda *args, **kwargs: None,
warning=lambda *args, **kwargs: None,
),
)
database = SeekDBVectorDatabase(app)
collection = f'test_{uuid.uuid4().hex}'
yield database, collection
await database.delete_collection(collection)
await database.close()
@pytest.mark.asyncio
async def test_upsert_and_text_round_trip(backend) -> None:
database, collection = backend
original = 'He said "hello".\nC:\\notes\\file.txt isn\'t empty. 中文'
updated = f'Updated: {original}'
await database.add_embeddings(
collection,
['document-a'],
[[1.0, 0.0, 0.0]],
[{'file_id': 'file-a', 'text': original}],
[original],
)
await database.add_embeddings(
collection,
['document-a'],
[[0.0, 1.0, 0.0]],
[{'file_id': 'file-a', 'text': updated}],
[updated],
)
items, _ = await database.list_by_filter(collection, {'file_id': 'file-a'})
assert len(items) == 1
assert items[0]['id'] == 'document-a'
assert items[0]['document'] == updated
assert items[0]['metadata']['text'] == updated
@pytest.mark.asyncio
async def test_full_text_and_hybrid_results_keep_relevance_order(backend) -> None:
database, collection = backend
documents = [
'orchid orchid orchid flower',
'orchid grows in a garden with many other beautiful plants',
'a completely unrelated topic',
]
await database.add_embeddings(
collection,
['best', 'weak', 'noise'],
[[1.0, 0.0, 0.0], [0.9, 0.1, 0.0], [0.0, 0.0, 1.0]],
[
{'file_id': item_id, 'document_id': item_id, 'text': document}
for item_id, document in zip(['best', 'weak', 'noise'], documents, strict=True)
],
documents,
)
seekdb_collection = await database.get_or_create_collection(collection)
await asyncio.to_thread(seekdb_collection.refresh_index)
full_text = await database.search(
collection,
[1.0, 0.0, 0.0],
k=3,
search_type='full_text',
query_text='orchid',
)
hybrid = await database.search(
collection,
[1.0, 0.0, 0.0],
k=3,
search_type='hybrid',
query_text='orchid',
vector_weight=0.65,
)
assert full_text['ids'][0][:2] == ['best', 'weak']
assert full_text['distances'][0] == sorted(full_text['distances'][0])
assert hybrid['ids'][0] == ['best', 'weak', 'noise']
assert hybrid['distances'][0] == sorted(hybrid['distances'][0])
@@ -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
@@ -12,4 +12,7 @@ def test_seekdb_is_only_declared_as_an_optional_dependency() -> None:
project = pyproject['project']
base_dependencies = project['dependencies']
assert not any(dependency.lower().startswith('pyseekdb') for dependency in base_dependencies)
assert project['optional-dependencies']['seekdb'] == ['pyseekdb==1.1.0.post3']
assert project['optional-dependencies']['seekdb'] == [
'pyseekdb==1.4.0.post1',
"pylibseekdb==1.4.0; sys_platform == 'linux' or (sys_platform == 'darwin' and platform_machine == 'arm64')",
]
+96
View File
@@ -0,0 +1,96 @@
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
from langbot.pkg.vector.vdbs.seekdb import SeekDBVectorDatabase
def _adapter_with_collection(collection: MagicMock) -> SeekDBVectorDatabase:
adapter = SeekDBVectorDatabase.__new__(SeekDBVectorDatabase)
adapter.ap = SimpleNamespace(logger=MagicMock())
adapter.client = MagicMock()
adapter.client.has_collection.return_value = True
adapter._collections = {'knowledge_base': collection}
adapter._runtime_cache_limit = 16
return adapter
@pytest.mark.asyncio
async def test_add_embeddings_upserts_and_preserves_text() -> None:
collection = MagicMock()
adapter = _adapter_with_collection(collection)
adapter._get_or_create_collection_internal = AsyncMock(return_value=collection)
original = 'He said "hello".\nC:\\notes\\file.txt isn\'t empty. 中文'
await adapter.add_embeddings(
collection='knowledge_base',
ids=['document-a'],
embeddings_list=[[1.0, 0.0, 0.0]],
metadatas=[{'text': original}],
documents=[original],
)
collection.upsert.assert_called_once_with(
ids=['document-a'],
embeddings=[[1.0, 0.0, 0.0]],
metadatas=[{'text': original}],
documents=[original],
)
collection.add.assert_not_called()
@pytest.mark.asyncio
@pytest.mark.parametrize(
('search_type', 'scores', 'expected_distances'),
[
('full_text', [0.4508196721, 0.25], [0.5491803279, 0.75]),
('hybrid', [0.0328, 0.0323, 0.0159], [0.9672, 0.9677, 0.9841]),
],
)
async def test_search_converts_relevance_scores_to_distances(
search_type: str,
scores: list[float],
expected_distances: list[float],
) -> None:
collection = MagicMock()
collection.hybrid_search.return_value = {
'ids': [['best', 'weak', 'noise'][: len(scores)]],
'metadatas': [[{} for _ in scores]],
'distances': [scores],
}
adapter = _adapter_with_collection(collection)
results = await adapter.search(
collection='knowledge_base',
query_embedding=[1.0, 0.0, 0.0],
k=len(scores),
search_type=search_type,
query_text='orchid',
vector_weight=0.65,
)
assert results['distances'][0] == pytest.approx(expected_distances)
assert results['distances'][0] == sorted(results['distances'][0])
@pytest.mark.asyncio
async def test_vector_search_keeps_seekdb_cosine_distances() -> None:
collection = MagicMock()
collection.query.return_value = {
'ids': [['best', 'weak']],
'metadatas': [[{}, {}]],
'distances': [[0.1, 0.25]],
}
adapter = _adapter_with_collection(collection)
results = await adapter.search(
collection='knowledge_base',
query_embedding=[1.0, 0.0, 0.0],
k=2,
search_type='vector',
)
assert results['distances'] == [[0.1, 0.25]]
Generated
+178 -79
View File
@@ -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"
@@ -1018,7 +1066,7 @@ name = "cuda-bindings"
version = "13.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cuda-pathfinder" },
{ name = "cuda-pathfinder", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/51/6b/457ca12dad3ee9bfcc9a545cfd6b64b359ba49de40f776f6e028e678f262/cuda_bindings-13.3.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5879712accf6e14bb01aa5e67440eb84998b8d104b509cc7a6dc0b8f656a474", size = 6053539, upload-time = "2026-05-29T23:11:43.19Z" },
@@ -1051,34 +1099,34 @@ wheels = [
[package.optional-dependencies]
cudart = [
{ name = "nvidia-cuda-runtime" },
{ name = "nvidia-cuda-runtime", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
]
cufft = [
{ name = "nvidia-cufft" },
{ name = "nvidia-cufft", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
]
cufile = [
{ name = "nvidia-cufile" },
{ name = "nvidia-cufile", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
]
cupti = [
{ name = "nvidia-cuda-cupti" },
{ name = "nvidia-cuda-cupti", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
]
curand = [
{ name = "nvidia-curand" },
{ name = "nvidia-curand", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
]
cusolver = [
{ name = "nvidia-cusolver" },
{ name = "nvidia-cusolver", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
]
cusparse = [
{ name = "nvidia-cusparse" },
{ name = "nvidia-cusparse", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
]
nvjitlink = [
{ name = "nvidia-nvjitlink" },
{ name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
]
nvrtc = [
{ name = "nvidia-cuda-nvrtc" },
{ name = "nvidia-cuda-nvrtc", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
]
nvtx = [
{ name = "nvidia-nvtx" },
{ name = "nvidia-nvtx", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
]
[[package]]
@@ -2008,7 +2056,7 @@ wheels = [
[[package]]
name = "langbot"
version = "4.10.10"
version = "4.10.11"
source = { editable = "." }
dependencies = [
{ name = "aiocqhttp" },
@@ -2085,11 +2133,13 @@ dependencies = [
{ name = "urllib3" },
{ name = "uv" },
{ name = "valkey-glide", marker = "sys_platform != 'win32'" },
{ name = "webauthn" },
{ name = "websockets" },
]
[package.optional-dependencies]
seekdb = [
{ name = "pylibseekdb", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or sys_platform == 'linux'" },
{ name = "pyseekdb" },
]
@@ -2129,7 +2179,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" },
@@ -2154,10 +2204,11 @@ requires-dist = [
{ name = "pycryptodome", specifier = ">=3.22.0" },
{ name = "pydantic", specifier = ">2.0" },
{ name = "pyjwt", specifier = ">=2.12.0" },
{ name = "pylibseekdb", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin' and extra == 'seekdb') or (sys_platform == 'linux' and extra == 'seekdb')", specifier = "==1.4.0" },
{ name = "pymilvus", specifier = ">=2.6.4" },
{ name = "pynacl", specifier = ">=1.5.0" },
{ name = "pypdf2", specifier = ">=3.0.1" },
{ name = "pyseekdb", marker = "extra == 'seekdb'", specifier = "==1.1.0.post3" },
{ name = "pyseekdb", marker = "extra == 'seekdb'", specifier = "==1.4.0.post1" },
{ name = "python-docx", specifier = ">=1.1.0" },
{ name = "python-multipart", specifier = ">=0.0.27" },
{ name = "python-socks", specifier = ">=2.7.1" },
@@ -2180,6 +2231,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 +2248,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 +2269,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]]
@@ -3247,7 +3299,7 @@ name = "nvidia-cublas"
version = "13.1.1.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nvidia-cuda-nvrtc" },
{ name = "nvidia-cuda-nvrtc", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/a7/a1/0bd24ee8c8d03adac032fd2909426a00c88f8c57961b1277ded97f91119f/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b7a210458267ac818974c53038fbec2e969d5c99f305ab15c72522fa9f001dd5", size = 542848918, upload-time = "2026-04-08T18:46:22.985Z" },
@@ -3286,7 +3338,7 @@ name = "nvidia-cudnn-cu13"
version = "9.20.0.48"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nvidia-cublas" },
{ name = "nvidia-cublas", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/56/c5/83384d846b2fd17c44bd499b36c75a45ed4f095fbbb2252294e89cea5c5c/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:e31454ae00094b0c55319d9d15b6fa2fc50a9e1c0f5c8c80fb75258234e731e1", size = 444574296, upload-time = "2026-03-09T19:28:27.751Z" },
@@ -3298,7 +3350,7 @@ name = "nvidia-cufft"
version = "12.0.0.61"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nvidia-nvjitlink" },
{ name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" },
@@ -3328,9 +3380,9 @@ name = "nvidia-cusolver"
version = "12.0.4.66"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nvidia-cublas" },
{ name = "nvidia-cusparse" },
{ name = "nvidia-nvjitlink" },
{ name = "nvidia-cublas", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
{ name = "nvidia-cusparse", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
{ name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" },
@@ -3342,7 +3394,7 @@ name = "nvidia-cusparse"
version = "12.6.3.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nvidia-nvjitlink" },
{ name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" },
@@ -4073,6 +4125,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"
@@ -4411,21 +4484,18 @@ crypto = [
[[package]]
name = "pylibseekdb"
version = "1.3.0"
version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pymysql", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/23/1e/5d971387d4bcdcf0f6f3c85d681a207c49f20715cf566a88d2222e5cd4c0/pylibseekdb-1.3.0-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:1d33cf82f34339bc58ac160688fc7d15ac2f7cbb226338d3887fe8350f65b762", size = 142749176, upload-time = "2026-05-25T08:59:18.118Z" },
{ url = "https://files.pythonhosted.org/packages/4d/9e/47f4a1ebad7e95169cfff1b87433b38623cc68426b3dfaac244c2492e5d4/pylibseekdb-1.3.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:77ba6786908cd8ab320ed4e5d5ef352759ef8990d72aff913467db5fe32542c4", size = 140878003, upload-time = "2026-05-25T06:11:51.929Z" },
{ url = "https://files.pythonhosted.org/packages/a7/b1/c772c15444ddec07365c5728624824b7b2137c319398c3cfc44d2e6b09a3/pylibseekdb-1.3.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:4b127c21ac1178ab903735041b6afe25295731d7bcee9813e5e1576c9d384937", size = 160132660, upload-time = "2026-05-25T06:12:02.817Z" },
{ url = "https://files.pythonhosted.org/packages/60/e8/d53bb80f6ed27f19dfb5b2f996cf9bef0e054442d473493e4f2425265762/pylibseekdb-1.3.0-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:23cd6ad60a80543dfccb4dc9500401347b82fddb8cef10f5503e5eb816adb39f", size = 142736028, upload-time = "2026-05-25T08:59:41.571Z" },
{ url = "https://files.pythonhosted.org/packages/2b/e6/3811303e0740e45dd475e6cf8ccea2abb706f047e50455ec1834bdeb6068/pylibseekdb-1.3.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:ec2465e206574f5dee7870bde2434a5ab9a03c2001786b1765fcb5dd790d6f98", size = 140881851, upload-time = "2026-05-25T06:12:11.973Z" },
{ url = "https://files.pythonhosted.org/packages/5d/29/856ea807cbe997c9fe2df6257106b2b2924ef9458bf87db7e4bd0b8dec03/pylibseekdb-1.3.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:1b78f26dfbb80157169b81f22ebb80957e3c6ee7b33e5ff35beaa4d628c33915", size = 160133328, upload-time = "2026-05-25T06:12:22.051Z" },
{ url = "https://files.pythonhosted.org/packages/3d/f1/5ec7782810746e9c065a419e8105a5925b3b04f495296b507706da9dc3b3/pylibseekdb-1.3.0-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:f6f739454aff786beeccfe71b66a0d89d01b5a8a260e0b8c5c30f8e9184bd88a", size = 142743219, upload-time = "2026-05-25T09:00:08.798Z" },
{ url = "https://files.pythonhosted.org/packages/13/8a/4d8150f6ad5f11dca40a6d42df9e2a41ed47125735a49afc7d2528460cd3/pylibseekdb-1.3.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:89069e1aeeb51f61aeaa0cf5d94bedb918f46c3476d7b30183dde7b2101e5954", size = 140884366, upload-time = "2026-05-25T06:12:31.689Z" },
{ url = "https://files.pythonhosted.org/packages/46/29/0583f2e00dbad80efffd7cb7df6431bd086b01a94d8b69688bae15a52e84/pylibseekdb-1.3.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:2515ea14bbac59e6f9f90a43bbaf179050ad7f8ab683d1cb9fd7fe225ccdca4e", size = 160137143, upload-time = "2026-05-25T06:12:43.005Z" },
{ url = "https://files.pythonhosted.org/packages/ad/5d/8c9afc77d32adbb1f7af85c3131419bcc9860677c5d6efb2d8d0ae9a7a66/pylibseekdb-1.3.0-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:a4177a3a6369699c9791cef3a7bfe7b472af301352237ed6e4cea42034fc0047", size = 142739982, upload-time = "2026-05-25T09:00:26.672Z" },
{ url = "https://files.pythonhosted.org/packages/56/91/bd3f9dea464cc22b454bbe384df3423e36e9fcbe7b1779c861f7ca9721e3/pylibseekdb-1.3.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:8651b8e0324fa78a5ed93b9952f4140c968655c344ef11fdb20d754077efeb05", size = 140896377, upload-time = "2026-05-25T06:12:53.468Z" },
{ url = "https://files.pythonhosted.org/packages/1e/f4/fcf930ed8c6d40154f41edfb2054794c786dd66deced3a8cc3fef5898af7/pylibseekdb-1.3.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:e6e58bce51e709c46aae3891e723b786132da925b9b6362db4486c07044d99e8", size = 160135373, upload-time = "2026-05-25T06:13:03.535Z" },
{ url = "https://files.pythonhosted.org/packages/ae/a8/7413d33218aff55a14ec9d20532b49243ffd0579e7a92244922c1885444e/pylibseekdb-1.4.0-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:5cb2efab9f1321cdb4b034d3a2bd92e41a402fc95e7dc9579c7473a426f96e24", size = 52173499, upload-time = "2026-08-27T13:05:09.347Z" },
{ url = "https://files.pythonhosted.org/packages/64/93/e9a13b996b5561f89c9a4f1b62796f8a6230a5dce215869e3cfef8adc4f1/pylibseekdb-1.4.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:e37b931417b7fc7fc88d15fd8b9b0dad499cd05e693cc483aa3743840e85f0c0", size = 49442143, upload-time = "2026-08-27T13:03:41.823Z" },
{ url = "https://files.pythonhosted.org/packages/71/cd/e54bb304512042cac0514fd607175f5425bd0924330e3eb74937c2afe827/pylibseekdb-1.4.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:6aaa3c9e4865d32f533af04eb8eab06d2c10fc581ac38097d618efb44c05dc5b", size = 53975703, upload-time = "2026-08-27T13:04:41.008Z" },
{ url = "https://files.pythonhosted.org/packages/ca/03/4380094699cbd4539971c0b943701f776408c94c28cc3ecdaed7c217bb29/pylibseekdb-1.4.0-cp312-abi3-macosx_15_0_arm64.whl", hash = "sha256:2fee55af299f2992dd5d61c9e239ef8855629f4117ee8b4c21dc877160707004", size = 52171602, upload-time = "2026-08-27T13:05:15.263Z" },
{ url = "https://files.pythonhosted.org/packages/df/b5/ea71acbee58925a51cb1a7137afd2d1c4e3fbb5bf2a0144cd53d299a20df/pylibseekdb-1.4.0-cp312-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f606579904a19bcd7ec96bc117251db9d4202485fc7355f2a289804d1b3b2c1b", size = 49438937, upload-time = "2026-08-27T13:03:48.418Z" },
{ url = "https://files.pythonhosted.org/packages/5c/f3/452485e45676a7d738720a7a3f7abbf4d24a3d272cbacabef41ae8b8e52c/pylibseekdb-1.4.0-cp312-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2d32d6f0d3b92b0c719b6c4230a24748ce10666f67052c696359e69138d8fbe7", size = 53972507, upload-time = "2026-08-27T13:04:46.946Z" },
]
[[package]]
@@ -4490,6 +4560,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"
@@ -4537,7 +4620,7 @@ wheels = [
[[package]]
name = "pyseekdb"
version = "1.1.0.post3"
version = "1.4.0.post1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx", marker = "python_full_version < '3.14'" },
@@ -4551,7 +4634,7 @@ dependencies = [
{ name = "tqdm", marker = "python_full_version < '3.14'" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/58/6e/2373239ab80c35a17aa14e8219727f06567e91d3b7f1b8c36d28ce94d04b/pyseekdb-1.1.0.post3-py3-none-any.whl", hash = "sha256:0437c9a4de72be44eb24b070b2b8099086467c08af10a57191498a61257a4bfb", size = 110985, upload-time = "2026-02-12T14:19:05.402Z" },
{ url = "https://files.pythonhosted.org/packages/22/87/d5dd862faa3d4adf3847c1ce19c3ea5ecd0dcfda9c2584a95bfd2b0fac0f/pyseekdb-1.4.0.post1-py3-none-any.whl", hash = "sha256:a3379f6962a0c01aa029d3e5a8f0c0f5a59b27a689b8aae1931d9ce5563f252c", size = 158375, upload-time = "2026-08-03T08:56:59.501Z" },
]
[[package]]
@@ -5172,10 +5255,10 @@ name = "scikit-learn"
version = "1.8.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "joblib" },
{ name = "numpy" },
{ name = "scipy" },
{ name = "threadpoolctl" },
{ name = "joblib", marker = "python_full_version >= '3.14'" },
{ name = "numpy", marker = "python_full_version >= '3.14'" },
{ name = "scipy", marker = "python_full_version >= '3.14'" },
{ name = "threadpoolctl", marker = "python_full_version >= '3.14'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0e/d4/40988bf3b8e34feec1d0e6a051446b1f66225f8529b9309becaeef62b6c4/scikit_learn-1.8.0.tar.gz", hash = "sha256:9bccbb3b40e3de10351f8f5068e105d0f4083b1a65fa07b6634fbc401a6287fd", size = 7335585, upload-time = "2025-12-10T07:08:53.618Z" }
wheels = [
@@ -5222,7 +5305,7 @@ name = "scipy"
version = "1.17.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
{ name = "numpy", marker = "python_full_version >= '3.14'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" }
wheels = [
@@ -5293,14 +5376,14 @@ name = "sentence-transformers"
version = "5.2.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "huggingface-hub" },
{ name = "numpy" },
{ name = "scikit-learn" },
{ name = "scipy" },
{ name = "torch" },
{ name = "tqdm" },
{ name = "transformers" },
{ name = "typing-extensions" },
{ name = "huggingface-hub", marker = "python_full_version >= '3.14'" },
{ name = "numpy", marker = "python_full_version >= '3.14'" },
{ name = "scikit-learn", marker = "python_full_version >= '3.14'" },
{ name = "scipy", marker = "python_full_version >= '3.14'" },
{ name = "torch", marker = "python_full_version >= '3.14'" },
{ name = "tqdm", marker = "python_full_version >= '3.14'" },
{ name = "transformers", marker = "python_full_version >= '3.14'" },
{ name = "typing-extensions", marker = "python_full_version >= '3.14'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5b/30/21664028fc0776eb1ca024879480bbbab36f02923a8ff9e4cae5a150fa35/sentence_transformers-5.2.3.tar.gz", hash = "sha256:3cd3044e1f3fe859b6a1b66336aac502eaae5d3dd7d5c8fc237f37fbf58137c7", size = 381623, upload-time = "2026-02-17T14:05:20.238Z" }
wheels = [
@@ -5673,21 +5756,21 @@ name = "torch"
version = "2.12.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cuda-bindings", marker = "sys_platform == 'linux'" },
{ name = "cuda-toolkit", extra = ["cudart", "cufft", "cufile", "cupti", "curand", "cusolver", "cusparse", "nvjitlink", "nvrtc", "nvtx"], marker = "sys_platform == 'linux'" },
{ name = "filelock" },
{ name = "fsspec" },
{ name = "jinja2" },
{ name = "networkx" },
{ name = "nvidia-cublas", marker = "sys_platform == 'linux'" },
{ name = "nvidia-cudnn-cu13", marker = "sys_platform == 'linux'" },
{ name = "nvidia-cusparselt-cu13", marker = "sys_platform == 'linux'" },
{ name = "nvidia-nccl-cu13", marker = "sys_platform == 'linux'" },
{ name = "nvidia-nvshmem-cu13", marker = "sys_platform == 'linux'" },
{ name = "setuptools" },
{ name = "sympy" },
{ name = "triton", marker = "sys_platform == 'linux'" },
{ name = "typing-extensions" },
{ name = "cuda-bindings", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
{ name = "cuda-toolkit", extra = ["cudart", "cufft", "cufile", "cupti", "curand", "cusolver", "cusparse", "nvjitlink", "nvrtc", "nvtx"], marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
{ name = "filelock", marker = "python_full_version >= '3.14'" },
{ name = "fsspec", marker = "python_full_version >= '3.14'" },
{ name = "jinja2", marker = "python_full_version >= '3.14'" },
{ name = "networkx", marker = "python_full_version >= '3.14'" },
{ name = "nvidia-cublas", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
{ name = "nvidia-cudnn-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
{ name = "nvidia-cusparselt-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
{ name = "nvidia-nccl-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
{ name = "nvidia-nvshmem-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
{ name = "setuptools", marker = "python_full_version >= '3.14'" },
{ name = "sympy", marker = "python_full_version >= '3.14'" },
{ name = "triton", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
{ name = "typing-extensions", marker = "python_full_version >= '3.14'" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/59/38/7028d3be540f1dcdf41660a2b01d0c51d2cb73915fe370d84e4d277a6d47/torch-2.12.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:ef81f503912effea2ce3d9b12a2e3a6ed488943e91271c90c7a829f60baf6aa2", size = 87975425, upload-time = "2026-06-17T21:08:34.094Z" },
@@ -5729,15 +5812,15 @@ name = "transformers"
version = "5.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "huggingface-hub" },
{ name = "numpy" },
{ name = "packaging" },
{ name = "pyyaml" },
{ name = "regex" },
{ name = "safetensors" },
{ name = "tokenizers" },
{ name = "tqdm" },
{ name = "typer" },
{ name = "huggingface-hub", marker = "python_full_version >= '3.14'" },
{ name = "numpy", marker = "python_full_version >= '3.14'" },
{ name = "packaging", marker = "python_full_version >= '3.14'" },
{ name = "pyyaml", marker = "python_full_version >= '3.14'" },
{ name = "regex", marker = "python_full_version >= '3.14'" },
{ name = "safetensors", marker = "python_full_version >= '3.14'" },
{ name = "tokenizers", marker = "python_full_version >= '3.14'" },
{ name = "tqdm", marker = "python_full_version >= '3.14'" },
{ name = "typer", marker = "python_full_version >= '3.14'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fc/1a/70e830d53ecc96ce69cfa8de38f163712d2b43ac52fbd743f39f56025c31/transformers-5.3.0.tar.gz", hash = "sha256:009555b364029da9e2946d41f1c5de9f15e6b1df46b189b7293f33a161b9c557", size = 8830831, upload-time = "2026-03-04T17:41:46.119Z" }
wheels = [
@@ -5998,9 +6081,9 @@ name = "valkey-glide"
version = "2.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "protobuf" },
{ name = "sniffio" },
{ name = "anyio", marker = "sys_platform != 'win32'" },
{ name = "protobuf", marker = "sys_platform != 'win32'" },
{ name = "sniffio", marker = "sys_platform != 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/72/a2/582b34c6acc8dc857c537f6007459cba48dfa0dc404789a657e5c1a998c0/valkey_glide-2.4.1.tar.gz", hash = "sha256:f1155d84156d11b90488aa67e90102f0bf98a45314f5b99308ac9074c05f7241", size = 898030, upload-time = "2026-05-28T21:41:55.881Z" }
wheels = [
@@ -6166,6 +6249,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"
+1
View File
@@ -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",
+17
View File
@@ -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>
)}
@@ -46,30 +46,10 @@ import {
} from '@/components/ui/tooltip';
import { systemInfo } from '@/app/infra/http';
import { getAdapterDocUrl } from '@/app/infra/entities/adapter-docs';
/**
* Resolve the value referenced by a `show_if.field` string.
*
* Fields prefixed with `__system.` are looked up in the caller-supplied
* `systemContext` dictionary (e.g. `__system.is_wizard` `systemContext.is_wizard`).
* All other field names are resolved from the live form values first, then
* fall back to `externalDependentValues`.
*/
function resolveShowIfValue(
field: string,
watchedValues: Record<string, unknown>,
externalDependentValues?: Record<string, unknown>,
systemContext?: Record<string, unknown>,
): unknown {
if (field.startsWith(SYSTEM_FIELD_PREFIX)) {
const key = field.slice(SYSTEM_FIELD_PREFIX.length);
return systemContext?.[key];
}
if (watchedValues[field] !== undefined) {
return watchedValues[field];
}
return externalDependentValues?.[field];
}
import {
resolveDisabledState,
resolveShowIfValue,
} from './DynamicFormConditions';
type DynamicFormValueSpec = Pick<
IDynamicFormItemSchema,
@@ -675,40 +655,19 @@ export default function DynamicFormComponent({
}
}
// ``disable_if`` mirrors ``show_if``'s evaluator but instead of
// hiding the field, leaves it visible and inert. Use it when the
// operator needs to see that the field exists yet cannot edit it
// under the current runtime state (e.g. sandbox-bound fields when
// Box is disabled).
let isDisabledByCondition = false;
if (config.disable_if) {
const dependValue = resolveShowIfValue(
config.disable_if.field,
// Keep locked fields visible and resolve only the applicable reason.
const { isDisabledByCondition, disabledTooltip: tooltip } =
resolveDisabledState(
config,
watchedValues as Record<string, unknown>,
externalDependentValues,
systemContext,
);
const cond = config.disable_if;
if (cond.operator === 'eq' && dependValue === cond.value) {
isDisabledByCondition = true;
} else if (cond.operator === 'neq' && dependValue !== cond.value) {
isDisabledByCondition = true;
} else if (
cond.operator === 'in' &&
Array.isArray(cond.value) &&
cond.value.includes(dependValue)
) {
isDisabledByCondition = true;
}
}
// All fields are disabled when editing (creation_settings are
// immutable) or when ``disable_if`` matches.
const isFieldDisabled = !!isEditing || isDisabledByCondition;
const disabledTooltip =
isDisabledByCondition && config.disabled_tooltip
? extractI18nObject(config.disabled_tooltip)
: '';
const disabledTooltip = tooltip ? extractI18nObject(tooltip) : '';
const renderDisabledTooltipIcon = () =>
disabledTooltip ? (
<DisabledTooltipIcon text={disabledTooltip} />
@@ -0,0 +1,71 @@
import {
SYSTEM_FIELD_PREFIX,
type IDynamicFormItemSchema,
type IShowIfCondition,
} from '@/app/infra/entities/form/dynamic';
/** System references use caller context; other fields prefer live form values. */
export function resolveShowIfValue(
field: string,
watchedValues: Record<string, unknown>,
externalDependentValues?: Record<string, unknown>,
systemContext?: Record<string, unknown>,
): unknown {
if (field.startsWith(SYSTEM_FIELD_PREFIX)) {
return systemContext?.[field.slice(SYSTEM_FIELD_PREFIX.length)];
}
if (watchedValues[field] !== undefined) {
return watchedValues[field];
}
return externalDependentValues?.[field];
}
export function matchesFormCondition(
condition: IShowIfCondition,
watchedValues: Record<string, unknown>,
externalDependentValues?: Record<string, unknown>,
systemContext?: Record<string, unknown>,
): boolean {
const value = resolveShowIfValue(
condition.field,
watchedValues,
externalDependentValues,
systemContext,
);
switch (condition.operator) {
case 'eq':
return value === condition.value;
case 'neq':
return value !== condition.value;
case 'in':
return Array.isArray(condition.value) && condition.value.includes(value);
default:
return false;
}
}
export function resolveDisabledState(
config: Pick<
IDynamicFormItemSchema,
'disable_if' | 'disabled_tooltip' | 'disabled_tooltip_overrides'
>,
watchedValues: Record<string, unknown>,
externalDependentValues?: Record<string, unknown>,
systemContext?: Record<string, unknown>,
) {
const matches = (condition: IShowIfCondition) =>
matchesFormCondition(
condition,
watchedValues,
externalDependentValues,
systemContext,
);
const isDisabledByCondition =
!!config.disable_if && matches(config.disable_if);
const disabledTooltip = isDisabledByCondition
? (config.disabled_tooltip_overrides?.find((override) =>
matches(override.when),
)?.tooltip ?? config.disabled_tooltip)
: undefined;
return { isDisabledByCondition, disabledTooltip };
}
@@ -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,
};
}
+500 -436
View File
@@ -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;
@@ -0,0 +1,14 @@
/** Unavailability takes priority over the deployment's scope restriction. */
export function getBoxScopeContext(
boxAvailable: boolean,
forcedTemplate?: string,
) {
forcedTemplate = forcedTemplate?.trim();
return {
box_available: boxAvailable,
box_scope_editable: boxAvailable && !forcedTemplate,
// Only expose forced-scope reasons when the sandbox is available.
box_scope_forced: boxAvailable && !!forcedTemplate,
box_scope_forced_global: boxAvailable && forcedTemplate === '{global}',
};
}
@@ -8,6 +8,7 @@ import {
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
import N8nAuthFormComponent from '@/app/home/components/dynamic-form/N8nAuthFormComponent';
import { useBoxStatus } from '@/app/infra/hooks/useBoxStatus';
import { getBoxScopeContext } from './BoxScopeContext';
import { systemInfo } from '@/app/infra/http';
import { Button } from '@/components/ui/button';
import { useForm } from 'react-hook-form';
@@ -425,13 +426,12 @@ export default function PipelineFormComponent({
// 2. the deployment pins all pipelines to a fixed scope via
// ``system.limitation.force_box_session_id_template`` (SaaS).
const forcedBoxTemplate =
systemInfo.limitation?.force_box_session_id_template || '';
systemInfo.limitation?.force_box_session_id_template?.trim() || '';
const boxScopeForced = !!forcedBoxTemplate;
const isLocalAgentStage = formName === 'ai' && stage.name === 'local-agent';
const stageSystemContext = isLocalAgentStage
? {
box_available: boxAvailable,
box_scope_editable: boxAvailable && !boxScopeForced,
...getBoxScopeContext(boxAvailable, forcedBoxTemplate),
pipeline_id: pipelineId,
}
: undefined;
@@ -39,6 +39,13 @@ export interface IDynamicFormItemSchema {
disable_if?: IShowIfCondition;
/** Tooltip shown next to the field label when ``disable_if`` is active. */
disabled_tooltip?: I18nObject;
/** Optional overrides evaluated in order when ``disable_if`` matches.
* The first matching ``when`` wins; otherwise use ``disabled_tooltip``.
* Conditions use the same operators and value lookup as ``disable_if``. */
disabled_tooltip_overrides?: {
when: IShowIfCondition;
tooltip: I18nObject;
}[];
/** when type is PLUGIN_SELECTOR, the scopes is the scopes of components(plugin contains), the default is all */
scopes?: string[];
+100
View File
@@ -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;
+51 -1
View File
@@ -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" />
+27
View File
@@ -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',
+29
View File
@@ -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',
+29
View File
@@ -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呼び出し',
+28
View File
@@ -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',
+28
View File
@@ -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',
+28
View File
@@ -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',
+26
View File
@@ -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调用',
+26
View File
@@ -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();
+192 -1
View File
@@ -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',
+232
View File
@@ -0,0 +1,232 @@
import { readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { resolve } from 'node:path';
import { expect, test, type Page } from '@playwright/test';
import { installLangBotApiMocks } from './fixtures/langbot-api';
// UI fixtures only: real app/components, intercepted APIs, no production Box.
// Load the shipped metadata rather than reproducing its tooltip conditions.
const requireFromTest = createRequire(__filename);
const { load } = createRequire(requireFromTest.resolve('eslint'))(
'js-yaml',
) as {
load: (source: string) => unknown;
};
const aiMetadata = load(
readFileSync(
resolve(
__dirname,
'../../../src/langbot/templates/metadata/pipeline/ai.yaml',
),
'utf8',
),
);
const unavailableHint = '沙箱未启用,请启用 Box 并确认连接正常后再修改作用域。';
const forcedHint = '已强制使用全局沙箱,无法修改作用域。';
interface BoxState {
enabled: boolean;
available: boolean;
}
async function openPipeline(page: Page, box: BoxState, forced = '') {
await installLangBotApiMocks(page, {
authenticated: true,
storage: { langbot_language: 'zh-Hans' },
});
await page.route('**/api/v1/system/info', (route) =>
route.fulfill({
json: {
code: 0,
data: {
debug: false,
version: 'sandbox-scope-ui-fixture',
edition: 'community',
cloud_service_url: 'https://space.langbot.app',
enable_marketplace: true,
allow_modify_login_info: true,
disable_models_service: false,
limitation: {
max_bots: -1,
max_pipelines: -1,
max_extensions: -1,
force_box_session_id_template: forced,
},
outbound_ips: [],
wizard_status: 'completed',
wizard_progress: null,
},
},
}),
);
await page.route('**/api/v1/box/status', (route) =>
route.fulfill({
json: {
code: 0,
data: {
...box,
profile: 'UI fixture only',
recent_error_count: 0,
active_sessions: 0,
managed_processes: 0,
session_ttl_sec: 3600,
backend: { name: 'ui-fixture', available: box.available },
},
},
}),
);
await page.route(/\/api\/v1\/tools(?:\?.*)?$/, (route) =>
route.fulfill({ json: { code: 0, data: { tools: [] } } }),
);
await page.route('**/api/v1/pipelines/_/metadata', (route) =>
route.fulfill({ json: { code: 0, data: { configs: [aiMetadata] } } }),
);
await page.route('**/api/v1/pipelines/sandbox-scope-fixture', (route) =>
route.fulfill({
json: {
code: 0,
data: {
pipeline: {
uuid: 'sandbox-scope-fixture',
name: 'Sandbox scope — UI fixture only',
description: '',
emoji: '⚙️',
is_default: false,
config: {
ai: {
runner: { runner: 'local-agent' },
'local-agent': {
'box-session-id-template': '{launcher_type}_{launcher_id}',
},
},
trigger: {},
safety: {},
output: {},
},
},
},
},
}),
);
await page.goto('/home/pipelines?id=sandbox-scope-fixture');
await page.getByRole('button', { name: 'AI 能力', exact: true }).click();
// DynamicForm gates this control through its wrapper's pointer-events,
// and its label targets that wrapper rather than the nested select.
const scope = page
.locator('[data-slot="form-item"]')
.filter({ has: page.getByText('沙箱作用域', { exact: true }) })
.getByRole('combobox');
await expect(scope).toBeVisible();
return scope;
}
async function expectWarning(page: Page, hint: string) {
const warning = page.getByRole('button', { name: hint, exact: true });
await expect(warning).toBeVisible();
await warning.hover();
await expect(page.getByRole('tooltip')).toHaveText(hint);
}
async function expectNoWarning(page: Page) {
await expect(page.getByRole('button', { name: unavailableHint })).toHaveCount(
0,
);
await expect(page.getByRole('button', { name: forcedHint })).toHaveCount(0);
await expect(page.getByRole('tooltip')).toHaveCount(0);
}
test.describe('sandbox scope disabled reason (UI fixtures only)', () => {
for (const scenario of [
{ name: 'Box disabled', enabled: false, available: false, forced: '' },
{ name: 'Box disconnected', enabled: true, available: false, forced: '' },
{
name: 'unavailable Box takes precedence over forced global',
enabled: true,
available: false,
forced: '{global}',
},
]) {
test(scenario.name, async ({ page }) => {
const scope = await openPipeline(page, scenario, scenario.forced);
await expect(scope).toHaveCSS('pointer-events', 'none');
await expectWarning(page, unavailableHint);
await expect(page.getByRole('tooltip')).not.toContainText('强制');
await expect(page.getByRole('button', { name: forcedHint })).toHaveCount(
0,
);
});
}
for (const forced of ['{global}', ' {global} ']) {
test(`available Box with forced global explains the deployment restriction (${JSON.stringify(forced)})`, async ({
page,
}) => {
const scope = await openPipeline(
page,
{ enabled: true, available: true },
forced,
);
await expect(scope).toHaveCSS('pointer-events', 'none');
await expect(scope).toHaveText('全局(所有人共享)');
await expectWarning(page, forcedHint);
await expect(
page.getByRole('button', { name: unavailableHint }),
).toHaveCount(0);
});
}
for (const forced of ['', ' ']) {
test(`available and unforced Box is editable without a disabled warning (${JSON.stringify(forced)})`, async ({
page,
}) => {
const scope = await openPipeline(
page,
{ enabled: true, available: true },
forced,
);
await expect(scope).toHaveCSS('pointer-events', 'auto');
await expect(scope).toHaveText('每个会话(推荐)');
await expectNoWarning(page);
await scope.click();
await page
.getByRole('option', { name: '全局(所有人共享)', exact: true })
.click();
await expect(scope).toHaveText('全局(所有人共享)');
await expectNoWarning(page);
});
}
for (const forced of ['', '{global}']) {
test(`Box status polls update the warning without remounting (${forced || 'unforced'})`, async ({
page,
}) => {
await page.clock.install();
const box = { enabled: true, available: false };
const scope = await openPipeline(page, box, forced);
await expect(scope).toHaveCSS('pointer-events', 'none');
await expectWarning(page, unavailableHint);
await page.mouse.move(0, 0);
const recovered = page.waitForResponse('**/api/v1/box/status');
box.available = true;
await page.clock.fastForward(31_000);
await recovered;
if (forced) {
await expect(scope).toHaveCSS('pointer-events', 'none');
await expectWarning(page, forcedHint);
} else {
await expect(scope).toHaveCSS('pointer-events', 'auto');
await expectNoWarning(page);
}
await page.mouse.move(0, 0);
const disconnected = page.waitForResponse('**/api/v1/box/status');
box.available = false;
await page.clock.fastForward(31_000);
await disconnected;
await expect(scope).toHaveCSS('pointer-events', 'none');
await expectWarning(page, unavailableHint);
await expect(page.getByRole('tooltip')).not.toContainText('强制');
});
}
});
@@ -0,0 +1,252 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import { createRequire } from 'node:module';
import test from 'node:test';
import ts from 'typescript';
const require = createRequire(import.meta.url);
const { load } = createRequire(require.resolve('eslint'))('js-yaml');
const metadata = load(
fs.readFileSync(
new URL(
'../../../src/langbot/templates/metadata/pipeline/ai.yaml',
import.meta.url,
),
'utf8',
),
);
const scope = metadata.stages
.find((stage) => stage.name === 'local-agent')
.config.find((item) => item.name === 'box-session-id-template');
const unavailable = '沙箱未启用,请启用 Box 并确认连接正常后再修改作用域。';
const globalForced = '已强制使用全局沙箱,无法修改作用域。';
const customForced = '已强制使用固定沙箱作用域,无法修改作用域。';
function loadSource(relativePath) {
const filename = new URL(`../../src/${relativePath}`, import.meta.url);
assert.ok(fs.existsSync(filename), `Missing policy module: ${relativePath}`);
const compiled = ts.transpileModule(fs.readFileSync(filename, 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS },
}).outputText;
const loaded = { exports: {} };
new Function('require', 'module', 'exports', compiled)(
(name) => {
if (name === '@/app/infra/entities/form/dynamic')
return loadSource('app/infra/entities/form/dynamic.ts');
throw new Error(`Unexpected runtime import: ${name}`);
},
loaded,
loaded.exports,
);
return loaded.exports;
}
function policies() {
return {
...loadSource('app/home/components/dynamic-form/DynamicFormConditions.ts'),
...loadSource(
'app/home/pipelines/components/pipeline-form/BoxScopeContext.ts',
),
};
}
function scopeState(available, forcedTemplate) {
const { getBoxScopeContext, resolveDisabledState } = policies();
return resolveDisabledState(
scope,
{},
undefined,
getBoxScopeContext(available, forcedTemplate),
);
}
test('sandbox default tooltip explains only unavailability', () => {
assert.equal(scope.disabled_tooltip.zh_Hans, unavailable);
});
for (const [name, available, template, expected] of [
['Box disabled', false, '', unavailable],
['Box disconnected', false, undefined, unavailable],
[
'unavailable takes precedence over forced global',
false,
'{global}',
unavailable,
],
[
'unavailable takes precedence over forced custom',
false,
'{pipeline_id}',
unavailable,
],
['available forced global', true, '{global}', globalForced],
['available padded forced global', true, ' {global} ', globalForced],
['available whitespace-only editable', true, ' ', undefined],
['available forced custom', true, '{pipeline_id}', customForced],
['available forced literal', true, 'tenant-sandbox', customForced],
['available editable', true, '', undefined],
['available without limitation', true, undefined, undefined],
]) {
test(name, () => {
const state = scopeState(available, template);
assert.equal(state.isDisabledByCondition, expected !== undefined);
assert.equal(state.disabledTooltip?.zh_Hans, expected);
});
}
test('reason follows availability and forced-scope transitions without mutating metadata', () => {
const snapshot = structuredClone(scope);
for (const [available, template, expected] of [
[false, '{global}', unavailable],
[true, '{global}', globalForced],
[true, '{pipeline_id}', customForced],
[true, '', undefined],
[false, '', unavailable],
[true, '', undefined],
]) {
assert.equal(
scopeState(available, template).disabledTooltip?.zh_Hans,
expected,
);
}
assert.deepEqual(scope, snapshot);
});
test('all sandbox reason variants preserve the eight metadata locales', () => {
const locales = [
'en_US',
'zh_Hans',
'zh_Hant',
'ja_JP',
'vi_VN',
'th_TH',
'es_ES',
'ru_RU',
].sort();
assert.equal(scope.disabled_tooltip_overrides?.length, 2);
const messages = [
scope.disabled_tooltip,
...scope.disabled_tooltip_overrides.map((entry) => entry.tooltip),
];
for (const message of messages) {
assert.deepEqual(Object.keys(message).sort(), locales);
for (const locale of locales) assert.ok(message[locale].trim(), locale);
}
for (const locale of locales) {
assert.equal(
new Set(messages.map((message) => message[locale])).size,
3,
locale,
);
assert.equal(
scopeState(false, '{global}').disabledTooltip[locale],
messages[0][locale],
);
assert.equal(
scopeState(true, '{global}').disabledTooltip[locale],
messages[1][locale],
);
assert.equal(
scopeState(true, '{pipeline_id}').disabledTooltip[locale],
messages[2][locale],
);
}
});
test('ordinary static disabled tooltip remains compatible', () => {
const { resolveDisabledState } = policies();
const tooltip = { en_US: 'Read only' };
const config = {
disable_if: { field: 'locked', operator: 'eq', value: true },
disabled_tooltip: tooltip,
};
assert.deepEqual(resolveDisabledState(config, { locked: true }), {
isDisabledByCondition: true,
disabledTooltip: tooltip,
});
assert.deepEqual(resolveDisabledState(config, { locked: false }), {
isDisabledByCondition: false,
disabledTooltip: undefined,
});
assert.equal(
resolveDisabledState({ disabled_tooltip: tooltip }, {}).disabledTooltip,
undefined,
);
assert.equal(
resolveDisabledState({ disable_if: config.disable_if }, { locked: true })
.disabledTooltip,
undefined,
);
});
test('conditional overrides reuse eq, neq, in and live/external/system resolution', () => {
const { matchesFormCondition, resolveDisabledState } = policies();
const watched = { mode: 'live', empty: null, '__system.locked': false };
const external = { mode: 'external', fallback: 3, empty: 'external' };
const system = { locked: true };
for (const [condition, expected] of [
[{ field: 'mode', operator: 'eq', value: 'live' }, true],
[{ field: 'mode', operator: 'eq', value: 'external' }, false],
[{ field: 'fallback', operator: 'neq', value: 4 }, true],
[{ field: 'fallback', operator: 'in', value: [2, 3] }, true],
[{ field: 'fallback', operator: 'in', value: '3' }, false],
[{ field: 'fallback', operator: 'eq', value: '3' }, false],
[{ field: 'empty', operator: 'eq', value: null }, true],
[{ field: '__system.locked', operator: 'eq', value: true }, true],
[{ field: 'absent', operator: 'eq', value: true }, false],
])
assert.equal(
matchesFormCondition(condition, watched, external, system),
expected,
);
const config = {
disable_if: { field: '__system.locked', operator: 'eq', value: true },
disabled_tooltip: { en_US: 'Default' },
disabled_tooltip_overrides: [
{
when: { field: 'mode', operator: 'eq', value: 'external' },
tooltip: { en_US: 'Wrong' },
},
{
when: { field: 'fallback', operator: 'in', value: [3] },
tooltip: { en_US: 'First match' },
},
{
when: { field: 'mode', operator: 'neq', value: 'external' },
tooltip: { en_US: 'Later match' },
},
],
};
assert.equal(
resolveDisabledState(config, watched, external, system).disabledTooltip
.en_US,
'First match',
);
assert.equal(
resolveDisabledState(config, {}, {}, system).disabledTooltip.en_US,
'Later match',
);
assert.equal(
resolveDisabledState(config, watched, external, { locked: false })
.disabledTooltip,
undefined,
);
assert.equal(
resolveDisabledState(
{ ...config, disabled_tooltip_overrides: [] },
watched,
external,
system,
).disabledTooltip.en_US,
'Default',
);
const unmatched = {
...config,
disabled_tooltip_overrides: [config.disabled_tooltip_overrides[0]],
};
assert.equal(
resolveDisabledState(unmatched, watched, external, system).disabledTooltip
.en_US,
'Default',
);
});
@@ -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',
);
});