diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml index 514445f1c..bd0a9c674 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.yml +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -1,5 +1,5 @@ name: 漏洞反馈 -description: 【供中文用户】报错或漏洞请使用这个模板创建,不使用此模板创建的异常、漏洞相关issue将被直接关闭。由于自己操作不当/不甚了解所用技术栈引起的网络连接问题恕无法解决,请勿提 issue。容器间网络连接问题,参考文档 https://link.langbot.app/zh/docs/network +description: 【供中文用户】报错或漏洞请使用这个模板创建,不使用此模板创建的异常、漏洞相关issue将被直接关闭。由于自己操作不当/不甚了解所用技术栈引起的网络连接问题恕无法解决,请勿提 issue。容器间网络连接问题,参考文档 https://langbot.app/docs/zh/workshop/network-details title: "[Bug]: " labels: ["bug?"] body: @@ -22,7 +22,7 @@ body: - type: textarea attributes: label: 异常情况 - description: 完整描述异常情况,什么时候发生的、发生了什么。**请附带日志信息。** + description: 完整描述异常情况,什么时候发生的、发生了什么。**请附带日志信息。** validations: required: true - type: textarea diff --git a/.github/ISSUE_TEMPLATE/bug-report_en.yml b/.github/ISSUE_TEMPLATE/bug-report_en.yml index d2111e097..7edaaefa7 100644 --- a/.github/ISSUE_TEMPLATE/bug-report_en.yml +++ b/.github/ISSUE_TEMPLATE/bug-report_en.yml @@ -1,5 +1,5 @@ name: Bug report -description: Report bugs or vulnerabilities using this template. For container network connection issues, refer to the documentation https://link.langbot.app/en/docs/network +description: Report bugs or vulnerabilities using this template. For container network connection issues, refer to the documentation https://langbot.app/docs/en/workshop/network-details title: "[Bug]: " labels: ["bug?"] body: diff --git a/.github/discord-release/README.md b/.github/discord-release/README.md new file mode 100644 index 000000000..21deee164 --- /dev/null +++ b/.github/discord-release/README.md @@ -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//` — 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/` 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. diff --git a/.github/discord-release/announce.py b/.github/discord-release/announce.py new file mode 100644 index 000000000..c8548d380 --- /dev/null +++ b/.github/discord-release/announce.py @@ -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//.') + 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()) diff --git a/.github/discord-release/test_announce.py b/.github/discord-release/test_announce.py new file mode 100644 index 000000000..f77bae8b0 --- /dev/null +++ b/.github/discord-release/test_announce.py @@ -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', + 'v1.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() diff --git a/.github/workflows/build-fnos-fpk.yaml b/.github/workflows/build-fnos-fpk.yaml new file mode 100644 index 000000000..e24906e39 --- /dev/null +++ b/.github/workflows/build-fnos-fpk.yaml @@ -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" diff --git a/.github/workflows/discord-release.yml b/.github/workflows/discord-release.yml new file mode 100644 index 000000000..23e5d8033 --- /dev/null +++ b/.github/workflows/discord-release.yml @@ -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 diff --git a/.github/workflows/test-migrations.yml b/.github/workflows/test-migrations.yml index 086a29915..811939454 100644 --- a/.github/workflows/test-migrations.yml +++ b/.github/workflows/test-migrations.yml @@ -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 diff --git a/.gitignore b/.gitignore index 97a64ba81..83b4faf7f 100644 --- a/.gitignore +++ b/.gitignore @@ -57,3 +57,17 @@ 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 + +r.ps1 diff --git a/AGENTS.md b/AGENTS.md index 82739895e..760cf06b6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,8 +43,8 @@ Run the narrowest useful test first, then broader checks when confidence is need ## Where to Look - Architecture map: `ARCHITECTURE.md`. -- Dev environment guide: https://docs.langbot.app/zh/develop/dev-config. -- Plugin runtime / CLI / SDK debugging: https://docs.langbot.app/zh/develop/plugin-runtime. +- Dev environment guide: https://langbot.app/docs/zh/develop/dev-config. +- Plugin runtime / CLI / SDK debugging: https://langbot.app/docs/zh/develop/plugin-runtime. - API-key auth: `docs/API_KEY_AUTH.md`. - Box deep-dive notes: `docs/review/box-architecture.md` and related files. - In-repo skills: `skills/` is the single source of truth for LangBot agent skills. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 6f5b39c01..14919cc2d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -171,6 +171,23 @@ The Plugin Runtime supports stdio and WebSocket control transports. Direct local ## Box Runtime and Skills +Runner plugins own sandbox policy: enablement, reuse-key interpolation, acquisition, +binding, and explicit file import/export. The Host exposes authenticated resource +APIs (`get_box_status`, `list_boxes`, `acquire_box`) and run-bound operations +(`bind_box`, `import_box_attachments`, `export_box_files`, `reply_files`). Box Runtime +owns atomic capacity enforcement and container reuse/lifecycle. Reusing an existing +Box is allowed when no additional capacity remains. + +A run binds one Box before native sandbox tools or file transfer. The Host does not +choose a conversation scope or stage attachments before starting the Runner. Input +references retain the original attachment metadata; import yields paths specific to +the run. Output export reads only that run's outbox and returns opaque file handles. +`message.completed.file_ids` attaches explicitly exported files to Pipeline output; +Agents send them explicitly with `ctx.reply_files()`, subject to event reply permission. +The Pipeline wrapper never scans a Box. Binding ends with the run; the reusable Box +remains subject to Runtime idle expiry. Persistent workspace files survive expiry, +but processes and container-local state do not. + Box is the sandbox subsystem used by native agent tools, stdio MCP servers, skill authoring, and managed processes. In this repo: diff --git a/README.md b/README.md index f6eeb0b22..5de92d3ad 100644 --- a/README.md +++ b/README.md @@ -19,9 +19,9 @@ English / [简体中文](README_CN.md) / [繁體中文](README_TW.md) / [日本 [![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers) Website | -Features | -Docs | -API | +Features | +Docs | +API | Cloud | Plugin Market | Roadmap @@ -49,7 +49,7 @@ LangBot is an **open-source, production-grade platform** for building AI-powered - **Web Management Panel** — Configure, manage, and monitor your bots through an intuitive browser interface. No YAML editing required. - **Multi-Pipeline Architecture** — Different bots for different scenarios, with comprehensive monitoring and exception handling. -[→ Learn more about all features](https://link.langbot.app/en/docs/features) +[→ Learn more about all features](https://langbot.app/docs/en/insight/features) 📍 Practical guides: [deploy a multi-platform AI bot in 5 minutes](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [connect DeepSeek to WeChat, Discord, and Telegram](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [run a Dify Agent in Discord, Telegram, and Slack](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/), and [build an n8n-powered chatbot](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/). @@ -89,18 +89,7 @@ docker compose --profile all up -d [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH) [![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) -**More options:** [Docker](https://link.langbot.app/en/docs/docker) · [Manual](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes) - ---- - -## Live Demo - -**Try it now:** https://demo.langbot.dev/ - -- Email: `demo@langbot.app` -- Password: `langbot123456` - -_Note: Public demo environment. Do not enter sensitive information._ +**More options:** [Docker](https://langbot.app/docs/en/deploy/langbot/docker) · [Manual](https://langbot.app/docs/en/deploy/langbot/manual) · [BTPanel](https://langbot.app/docs/en/deploy/langbot/one-click/bt) · [Kubernetes](https://langbot.app/docs/en/deploy/langbot/kubernetes) --- @@ -151,7 +140,7 @@ _Note: Public demo environment. Do not enter sensitive information._ | [302.AI](https://share.302ai.cn/SuTG99) | Gateway | ✅ | | [Qiniu](https://www.qiniu.com/ai/agent) | Gateway | ✅ | -[→ View all integrations](https://link.langbot.app/en/docs/features) +[→ View all integrations](https://langbot.app/docs/en/insight/features) --- diff --git a/README_CN.md b/README_CN.md index 77b448f87..aad5c6efa 100644 --- a/README_CN.md +++ b/README_CN.md @@ -21,9 +21,9 @@ [![star](https://gitcode.com/RockChinQ/LangBot/star/badge.svg)](https://gitcode.com/RockChinQ/LangBot) 官网 | -特性 | -文档 | -API | +特性 | +文档 | +API | Cloud | 扩展市场 | 路线图 @@ -49,7 +49,7 @@ LangBot 是一个**开源的生产级平台**,用于构建 AI 驱动的即时 - **Web 管理面板** — 通过浏览器直观地配置、管理和监控机器人,无需手动编辑配置文件。 - **多流水线架构** — 不同机器人用于不同场景,具备全面的监控和异常处理能力。 -[→ 了解更多功能特性](https://link.langbot.app/zh/docs/features) +[→ 了解更多功能特性](https://langbot.app/docs/zh/insight/features) 📍 实践指南:[5 分钟部署多平台 AI 机器人](https://langbot.app/zh/blog/deploy-ai-bot-in-5-minutes/)、[将 DeepSeek 接入微信、企业微信与 Discord](https://langbot.app/zh/blog/connect-deepseek-to-wechat/)、[让 Dify Agent 跑在 Discord、Telegram 和 Slack 上](https://langbot.app/zh/blog/dify-agent-discord-telegram-slack/),以及[用 n8n 构建多平台 AI 聊天机器人](https://langbot.app/zh/blog/n8n-multi-platform-ai-chatbot/)。 @@ -89,17 +89,7 @@ docker compose --profile all up -d [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/zh-CN/templates/ZKTBDH) [![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) -**更多方式:** [Docker](https://link.langbot.app/zh/docs/docker) · [手动部署](https://link.langbot.app/zh/docs/manual-deploy) · [宝塔面板](https://link.langbot.app/zh/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/zh/deploy/langbot/kubernetes) - ---- - -## 在线演示 - -**立即体验:** https://demo.langbot.dev/ -- 邮箱:`demo@langbot.app` -- 密码:`langbot123456` - -*注意:公开演示环境,请不要在其中填入任何敏感信息。* +**更多方式:** [Docker](https://langbot.app/docs/zh/deploy/langbot/docker) · [手动部署](https://langbot.app/docs/zh/deploy/langbot/manual) · [宝塔面板](https://langbot.app/docs/zh/deploy/langbot/one-click/bt) · [Kubernetes](https://langbot.app/docs/zh/deploy/langbot/kubernetes) --- @@ -152,7 +142,7 @@ docker compose --profile all up -d | [百宝箱Tbox](https://www.tbox.cn/open) | 智能体平台 | ✅ | | [七牛云Qiniu](https://www.qiniu.com/ai/agent) | 聚合平台 | ✅ | -[→ 查看完整集成列表](https://link.langbot.app/zh/docs/features) +[→ 查看完整集成列表](https://langbot.app/docs/zh/insight/features) ### TTS(语音合成) diff --git a/README_ES.md b/README_ES.md index 2f7ec0ce1..04ebc78a3 100644 --- a/README_ES.md +++ b/README_ES.md @@ -19,9 +19,9 @@ [![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers) Inicio | -Características | -Documentación | -API | +Características | +Documentación | +API | Mercado de Plugins | Hoja de Ruta @@ -48,7 +48,7 @@ LangBot es una **plataforma de código abierto y grado de producción** para con - **Panel de Gestión Web** — Configure, gestione y monitoree sus bots a través de una interfaz de navegador intuitiva. Sin necesidad de editar YAML. - **Arquitectura Multi-Pipeline** — Diferentes bots para diferentes escenarios, con monitoreo completo y manejo de excepciones. -[→ Conocer más sobre todas las funcionalidades](https://link.langbot.app/en/docs/features) +[→ Conocer más sobre todas las funcionalidades](https://langbot.app/docs/en/insight/features) 📍 Guías prácticas: [desplegar un bot de IA multiplataforma en 5 minutos](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [conectar DeepSeek a WeChat, Discord y Telegram](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [ejecutar un Dify Agent en Discord, Telegram y Slack](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/) y [crear un chatbot con n8n](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/). @@ -88,17 +88,7 @@ docker compose --profile all up -d [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH) [![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) -**Más opciones:** [Docker](https://link.langbot.app/en/docs/docker) · [Manual](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes) - ---- - -## Demo en Vivo - -**Pruébelo ahora:** https://demo.langbot.dev/ -- Correo electrónico: `demo@langbot.app` -- Contraseña: `langbot123456` - -*Nota: Entorno de demostración público. No ingrese información confidencial.* +**Más opciones:** [Docker](https://langbot.app/docs/en/deploy/langbot/docker) · [Manual](https://langbot.app/docs/en/deploy/langbot/manual) · [BTPanel](https://langbot.app/docs/en/deploy/langbot/one-click/bt) · [Kubernetes](https://langbot.app/docs/en/deploy/langbot/kubernetes) --- @@ -149,7 +139,7 @@ docker compose --profile all up -d | [302.AI](https://share.302ai.cn/SuTG99) | Pasarela | ✅ | | [Qiniu](https://www.qiniu.com/ai/agent) | Pasarela | ✅ | -[→ Ver todas las integraciones](https://link.langbot.app/en/docs/features) +[→ Ver todas las integraciones](https://langbot.app/docs/en/insight/features) --- diff --git a/README_FR.md b/README_FR.md index 66d1f9bb6..78d99c692 100644 --- a/README_FR.md +++ b/README_FR.md @@ -19,9 +19,9 @@ [![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers) Accueil | -Fonctionnalités | -Documentation | -API | +Fonctionnalités | +Documentation | +API | Marché des Plugins | Feuille de Route @@ -48,7 +48,7 @@ LangBot est une **plateforme open-source de niveau production** pour créer des - **Panneau de Gestion Web** — Configurez, gérez et surveillez vos bots via une interface navigateur intuitive. Aucune édition de YAML requise. - **Architecture Multi-Pipeline** — Différents bots pour différents scénarios, avec surveillance complète et gestion des exceptions. -[→ En savoir plus sur toutes les fonctionnalités](https://link.langbot.app/en/docs/features) +[→ En savoir plus sur toutes les fonctionnalités](https://langbot.app/docs/en/insight/features) 📍 Guides pratiques : [déployer un bot IA multiplateforme en 5 minutes](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [connecter DeepSeek à WeChat, Discord et Telegram](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [exécuter un Dify Agent dans Discord, Telegram et Slack](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/) et [créer un chatbot avec n8n](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/). @@ -88,17 +88,7 @@ docker compose --profile all up -d [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH) [![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) -**Plus d'options :** [Docker](https://link.langbot.app/en/docs/docker) · [Manuel](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes) - ---- - -## Démo en Ligne - -**Essayez maintenant :** https://demo.langbot.dev/ -- Email : `demo@langbot.app` -- Mot de passe : `langbot123456` - -*Note : Environnement de démonstration public. Ne saisissez pas d'informations sensibles.* +**Plus d'options :** [Docker](https://langbot.app/docs/en/deploy/langbot/docker) · [Manuel](https://langbot.app/docs/en/deploy/langbot/manual) · [BTPanel](https://langbot.app/docs/en/deploy/langbot/one-click/bt) · [Kubernetes](https://langbot.app/docs/en/deploy/langbot/kubernetes) --- @@ -149,7 +139,7 @@ docker compose --profile all up -d | [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Plateforme GPU | ✅ | | [Qiniu](https://www.qiniu.com/ai/agent) | Passerelle | ✅ | -[→ Voir toutes les intégrations](https://link.langbot.app/en/docs/features) +[→ Voir toutes les intégrations](https://langbot.app/docs/en/insight/features) --- diff --git a/README_JP.md b/README_JP.md index efc6e23b2..b876bd4ee 100644 --- a/README_JP.md +++ b/README_JP.md @@ -19,9 +19,9 @@ [![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers) ホーム | -機能 | -ドキュメント | -API | +機能 | +ドキュメント | +API | プラグインマーケット | ロードマップ @@ -48,7 +48,7 @@ LangBot は、AI搭載のインスタントメッセージングボットを構 - **Web管理パネル** — 直感的なブラウザインターフェースからボットの設定、管理、監視が可能。YAML編集は不要。 - **マルチパイプラインアーキテクチャ** — 異なるシナリオに異なるボットを配置し、包括的な監視と例外処理を実現。 -[→ すべての機能について詳しく見る](https://link.langbot.app/ja/docs/features) +[→ すべての機能について詳しく見る](https://langbot.app/docs/ja/insight/features) 📍 実践ガイド: [5分でマルチプラットフォームAIボットをデプロイ](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/)、[DeepSeekをWeChat・Discord・Telegramに接続](https://langbot.app/en/blog/connect-deepseek-to-wechat/)、[Dify AgentをDiscord・Telegram・Slackで動かす](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/)、[n8n連携チャットボットを構築](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/)。 @@ -88,17 +88,7 @@ docker compose --profile all up -d [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH) [![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) -**その他:** [Docker](https://link.langbot.app/en/docs/docker) · [手動デプロイ](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes) - ---- - -## ライブデモ - -**今すぐ試す:** https://demo.langbot.dev/ -- メール: `demo@langbot.app` -- パスワード: `langbot123456` - -*注意: 公開デモ環境です。機密情報を入力しないでください。* +**その他:** [Docker](https://langbot.app/docs/en/deploy/langbot/docker) · [手動デプロイ](https://langbot.app/docs/en/deploy/langbot/manual) · [BTPanel](https://langbot.app/docs/en/deploy/langbot/one-click/bt) · [Kubernetes](https://langbot.app/docs/en/deploy/langbot/kubernetes) --- @@ -149,7 +139,7 @@ docker compose --profile all up -d | [302.AI](https://share.302ai.cn/SuTG99) | ゲートウェイ | ✅ | | [Qiniu](https://www.qiniu.com/ai/agent) | ゲートウェイ | ✅ | -[→ すべての統合を表示](https://link.langbot.app/en/docs/features) +[→ すべての統合を表示](https://langbot.app/docs/en/insight/features) --- diff --git a/README_KO.md b/README_KO.md index 7e0284788..b28d3ec2c 100644 --- a/README_KO.md +++ b/README_KO.md @@ -19,9 +19,9 @@ [![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers) 홈 | -기능 | -문서 | -API | +기능 | +문서 | +API | 플러그인 마켓 | 로드맵 @@ -48,7 +48,7 @@ LangBot은 AI 기반 인스턴트 메시징 봇을 구축하기 위한 **오픈 - **웹 관리 패널** — 직관적인 브라우저 인터페이스로 봇을 구성, 관리 및 모니터링. YAML 편집 불필요. - **멀티 파이프라인 아키텍처** — 다양한 시나리오에 맞는 다양한 봇 구성, 종합 모니터링 및 예외 처리. -[→ 모든 기능 자세히 보기](https://link.langbot.app/en/docs/features) +[→ 모든 기능 자세히 보기](https://langbot.app/docs/en/insight/features) 📍 실전 가이드: [5분 만에 멀티 플랫폼 AI 봇 배포하기](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [DeepSeek를 WeChat, Discord, Telegram에 연결하기](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [Dify Agent를 Discord, Telegram, Slack에서 실행하기](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/), [n8n 기반 챗봇 만들기](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/). @@ -88,17 +88,7 @@ docker compose --profile all up -d [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH) [![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) -**더 많은 옵션:** [Docker](https://link.langbot.app/en/docs/docker) · [수동 배포](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes) - ---- - -## 라이브 데모 - -**지금 체험:** https://demo.langbot.dev/ -- 이메일: `demo@langbot.app` -- 비밀번호: `langbot123456` - -*참고: 공개 데모 환경입니다. 민감한 정보를 입력하지 마세요.* +**더 많은 옵션:** [Docker](https://langbot.app/docs/en/deploy/langbot/docker) · [수동 배포](https://langbot.app/docs/en/deploy/langbot/manual) · [BTPanel](https://langbot.app/docs/en/deploy/langbot/one-click/bt) · [Kubernetes](https://langbot.app/docs/en/deploy/langbot/kubernetes) --- @@ -149,7 +139,7 @@ docker compose --profile all up -d | [302.AI](https://share.302ai.cn/SuTG99) | 게이트웨이 | ✅ | | [Qiniu](https://www.qiniu.com/ai/agent) | 게이트웨이 | ✅ | -[→ 모든 통합 보기](https://link.langbot.app/en/docs/features) +[→ 모든 통합 보기](https://langbot.app/docs/en/insight/features) --- diff --git a/README_RU.md b/README_RU.md index f779c0c46..f6c1f8bce 100644 --- a/README_RU.md +++ b/README_RU.md @@ -19,9 +19,9 @@ [![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers) Главная | -Возможности | -Документация | -API | +Возможности | +Документация | +API | Магазин плагинов | Дорожная карта @@ -48,7 +48,7 @@ LangBot — это **платформа с открытым исходным к - **Веб-панель управления** — Настраивайте, управляйте и мониторьте ваших ботов через интуитивный браузерный интерфейс. Ручное редактирование YAML не требуется. - **Мультиконвейерная архитектура** — Разные боты для разных сценариев с комплексным мониторингом и обработкой исключений. -[→ Подробнее обо всех возможностях](https://link.langbot.app/en/docs/features) +[→ Подробнее обо всех возможностях](https://langbot.app/docs/en/insight/features) 📍 Практические руководства: [развернуть мультиплатформенного ИИ-бота за 5 минут](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [подключить DeepSeek к WeChat, Discord и Telegram](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [запустить Dify Agent в Discord, Telegram и Slack](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/) и [создать чат-бота на n8n](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/). @@ -88,17 +88,7 @@ docker compose --profile all up -d [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH) [![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) -**Другие варианты:** [Docker](https://link.langbot.app/en/docs/docker) · [Ручная установка](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes) - ---- - -## Демо - -**Попробуйте прямо сейчас:** https://demo.langbot.dev/ -- Email: `demo@langbot.app` -- Пароль: `langbot123456` - -*Примечание: Публичная демо-среда. Не вводите конфиденциальную информацию.* +**Другие варианты:** [Docker](https://langbot.app/docs/en/deploy/langbot/docker) · [Ручная установка](https://langbot.app/docs/en/deploy/langbot/manual) · [BTPanel](https://langbot.app/docs/en/deploy/langbot/one-click/bt) · [Kubernetes](https://langbot.app/docs/en/deploy/langbot/kubernetes) --- @@ -149,7 +139,7 @@ docker compose --profile all up -d | [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Платформа GPU | ✅ | | [Qiniu](https://www.qiniu.com/ai/agent) | Шлюз | ✅ | -[→ Смотреть все интеграции](https://link.langbot.app/en/docs/features) +[→ Смотреть все интеграции](https://langbot.app/docs/en/insight/features) --- diff --git a/README_TW.md b/README_TW.md index 9abb32976..140515650 100644 --- a/README_TW.md +++ b/README_TW.md @@ -21,9 +21,9 @@ [![star](https://gitcode.com/RockChinQ/LangBot/star/badge.svg)](https://gitcode.com/RockChinQ/LangBot) 官網 | -特性 | -文件 | -API | +特性 | +文件 | +API | 外掛市場 | 路線圖 @@ -50,7 +50,7 @@ LangBot 是一個**開源的生產級平台**,用於建構 AI 驅動的即時 - **Web 管理面板** — 透過瀏覽器直觀地配置、管理和監控機器人,無需手動編輯設定檔。 - **多流水線架構** — 不同機器人用於不同場景,具備全面的監控和異常處理能力。 -[→ 了解更多功能特性](https://link.langbot.app/zh/docs/features) +[→ 了解更多功能特性](https://langbot.app/docs/zh/insight/features) 📍 實踐指南:[5 分鐘部署多平台 AI 機器人](https://langbot.app/zh/blog/deploy-ai-bot-in-5-minutes/)、[將 DeepSeek 接入微信、企業微信與 Discord](https://langbot.app/zh/blog/connect-deepseek-to-wechat/)、[讓 Dify Agent 跑在 Discord、Telegram 和 Slack 上](https://langbot.app/zh/blog/dify-agent-discord-telegram-slack/),以及[用 n8n 建構多平台 AI 聊天機器人](https://langbot.app/zh/blog/n8n-multi-platform-ai-chatbot/)。 @@ -90,17 +90,7 @@ docker compose --profile all up -d [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/zh-CN/templates/ZKTBDH) [![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) -**更多方式:** [Docker](https://link.langbot.app/zh/docs/docker) · [手動部署](https://link.langbot.app/zh/docs/manual-deploy) · [寶塔面板](https://link.langbot.app/zh/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/zh/deploy/langbot/kubernetes) - ---- - -## 線上演示 - -**立即體驗:** https://demo.langbot.dev/ -- 信箱:`demo@langbot.app` -- 密碼:`langbot123456` - -*注意:公開演示環境,請不要在其中填入任何敏感資訊。* +**更多方式:** [Docker](https://langbot.app/docs/zh/deploy/langbot/docker) · [手動部署](https://langbot.app/docs/zh/deploy/langbot/manual) · [寶塔面板](https://langbot.app/docs/zh/deploy/langbot/one-click/bt) · [Kubernetes](https://langbot.app/docs/zh/deploy/langbot/kubernetes) --- @@ -165,7 +155,7 @@ docker compose --profile all up -d |-----------|------| | 阿里雲百煉 | [外掛](https://github.com/Thetail001/LangBot_BailianTextToImagePlugin) | -[→ 查看完整整合列表](https://link.langbot.app/zh/docs/features) +[→ 查看完整整合列表](https://langbot.app/docs/zh/insight/features) --- diff --git a/README_VI.md b/README_VI.md index ca9ef667d..356f577b3 100644 --- a/README_VI.md +++ b/README_VI.md @@ -19,9 +19,9 @@ [![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers) Trang chủ | -Tính năng | -Tài liệu | -API | +Tính năng | +Tài liệu | +API | Chợ Plugin | Lộ trình @@ -48,7 +48,7 @@ LangBot là một **nền tảng mã nguồn mở, cấp sản xuất** để x - **Bảng quản lý Web** — Cấu hình, quản lý và giám sát bot thông qua giao diện trình duyệt trực quan. Không cần chỉnh sửa YAML. - **Kiến trúc đa Pipeline** — Các bot khác nhau cho các kịch bản khác nhau, với giám sát toàn diện và xử lý ngoại lệ. -[→ Tìm hiểu thêm về tất cả tính năng](https://link.langbot.app/en/docs/features) +[→ Tìm hiểu thêm về tất cả tính năng](https://langbot.app/docs/en/insight/features) 📍 Hướng dẫn thực hành: [triển khai bot AI đa nền tảng trong 5 phút](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [kết nối DeepSeek với WeChat, Discord và Telegram](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [chạy Dify Agent trên Discord, Telegram và Slack](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/) và [xây dựng chatbot với n8n](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/). @@ -88,17 +88,7 @@ docker compose --profile all up -d [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH) [![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) -**Thêm tùy chọn:** [Docker](https://link.langbot.app/en/docs/docker) · [Thủ công](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes) - ---- - -## Demo trực tuyến - -**Thử ngay:** https://demo.langbot.dev/ -- Email: `demo@langbot.app` -- Mật khẩu: `langbot123456` - -*Lưu ý: Môi trường demo công khai. Không nhập thông tin nhạy cảm.* +**Thêm tùy chọn:** [Docker](https://langbot.app/docs/en/deploy/langbot/docker) · [Thủ công](https://langbot.app/docs/en/deploy/langbot/manual) · [BTPanel](https://langbot.app/docs/en/deploy/langbot/one-click/bt) · [Kubernetes](https://langbot.app/docs/en/deploy/langbot/kubernetes) --- @@ -149,7 +139,7 @@ docker compose --profile all up -d | [302.AI](https://share.302ai.cn/SuTG99) | Cổng | ✅ | | [Qiniu](https://www.qiniu.com/ai/agent) | Cổng | ✅ | -[→ Xem tất cả tích hợp](https://link.langbot.app/en/docs/features) +[→ Xem tất cả tích hợp](https://langbot.app/docs/en/insight/features) --- diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index c2c276ac5..cb5f1c88e 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -1,5 +1,5 @@ # Docker Compose configuration for LangBot -# For Kubernetes deployment, see kubernetes.yaml and the deployment guide at https://docs.langbot.app +# For Kubernetes deployment, see kubernetes.yaml and the deployment guide at https://langbot.app/docs version: "3" services: diff --git a/docker/kubernetes.yaml b/docker/kubernetes.yaml index 5504e5219..7ce145610 100644 --- a/docker/kubernetes.yaml +++ b/docker/kubernetes.yaml @@ -1,7 +1,7 @@ # Kubernetes Deployment for LangBot # This file provides Kubernetes deployment manifests for LangBot based on docker-compose.yaml # -# Full deployment guide (zh/en/ja): https://docs.langbot.app -> Installation -> Kubernetes +# Full deployment guide (zh/en/ja): https://langbot.app/docs -> Installation -> Kubernetes # # Usage: # kubectl -n langbot create secret generic langbot-plugin-runtime-control \ diff --git a/docs/API_KEY_AUTH.md b/docs/API_KEY_AUTH.md index 49d80b6f9..f825ea0a5 100644 --- a/docs/API_KEY_AUTH.md +++ b/docs/API_KEY_AUTH.md @@ -88,6 +88,23 @@ Each endpoint accepts **either**: 1. **User Token** (via `Authorization: Bearer `) - for web UI and authenticated users 2. **API Key** (via `X-API-Key` or `Authorization: Bearer `) - for external services +### Inspecting API Key Identity + +`GET /api/v1/system/context` validates an API key (user JWT not accepted) and returns its bound identity without requiring resource permissions: + +```json +{ + "code": 0, + "msg": "ok", + "data": { + "instance_uuid": "...", + "workspace_uuid": "...", + "api_key_id": "...", + "permissions": ["..."] + } +} +``` + ## Example: Model Management ### List All LLM Models diff --git a/docs/CODEX_SUBSCRIPTION.md b/docs/CODEX_SUBSCRIPTION.md new file mode 100644 index 000000000..b7324e404 --- /dev/null +++ b/docs/CODEX_SUBSCRIPTION.md @@ -0,0 +1,65 @@ +# ChatGPT / Codex subscription + +LangBot's **OpenAI Codex** model provider uses **Sign in with ChatGPT** and the account's Codex entitlement. It is separate from the existing OpenAI API-key provider: subscribing to ChatGPT does not supply an OpenAI Platform API key, and API-key billing is unchanged. + +## Connect an account + +1. Open **Models**, choose **Add Provider**, and select **OpenAI Codex**. +2. Enter a provider name and choose **Save and sign in**. This saves the provider before authorization, so an interrupted login can be retried from its settings. +3. Open the OpenAI authorization link and enter the one-time code displayed in LangBot. Sign in on OpenAI's site, not in LangBot. +4. If OpenAI asks you to enable device-code authorization, enable it in your ChatGPT account's security settings, or contact your workspace administrator. +5. Keep the LangBot dialog open until it confirms the connection, then finish the form. +6. Use the existing **Scan models** or **Add model** controls, test the model, and select it in a pipeline as usual. Only LLM models are supported by this provider. + +The device-code flow also works when LangBot runs remotely or in Docker: the browser does not need to reach a localhost OAuth callback on the server. Serve the LangBot management panel over HTTPS when accessing it remotely. + +The account's model catalog is authoritative. A model listed elsewhere or entered manually is not a guarantee that this account has access. Scan errors are reported rather than replaced with a fabricated available-model list. + +## Reconnect and disconnect + +Open the provider's existing settings to sign in again or disconnect. LangBot refreshes expiring access tokens automatically. A revoked or invalid refresh grant requires another sign-in; transient network failures are not proof that the grant was revoked. + +**Disconnect** removes this provider's locally stored authorization. It does not log the account out of other applications or revoke the account globally. Canceling a pending sign-in is separate from disconnecting an existing account. Removing a provider also removes its authorization; the normal rule that models must be removed first still applies. + +A saved provider can remain disconnected. Scanning or invoking it then returns a sign-in-required error; LangBot does not silently switch to paid API-key billing. + +## Usage and deployment boundary + +Calls consume the connected account's included Codex usage and remain subject to OpenAI's plan limits, model availability, workspace policies, and terms. Token counts recorded by LangBot are request usage, not a measurement of remaining subscription quota or an OpenAI invoice. + +Use this integration for your own authorized account and trusted workflows. Third-party sign-in support is not permission to pool accounts, resell subscription quota, or redistribute one subscription as a shared API service. For a public or commercial multi-user service, use the appropriate OpenAI API or separately authorized enterprise arrangement. The provider remains a Workspace resource in LangBot: consider who can invoke its models before connecting a personal account. + +## Credential handling and API surface + +- OAuth credentials are stored server-side separately from provider API keys. Provider and model reads do not supply OAuth access, refresh, or ID tokens. +- Authorization uses a fixed OpenAI origin. The Codex provider does not accept a custom base URL or manually supplied API keys. +- Authentication controls require an authenticated LangBot browser user with `provider_secret.manage` in the selected Workspace. Pending attempts are scoped to the Workspace, provider, and initiating user. +- Browser storage must not contain OAuth tokens. Treat the server database and its backups as sensitive application data. +- MCP and LangBot API keys do not expose the browser-only OAuth controls. Agents may inspect configured providers and models with the existing tools, but a human connects the subscription in the management panel. + +The provider-scoped authentication routes are under `/api/v1/provider/providers/{uuid}/codex`: + +| Method | Suffix | Purpose | +| --- | --- | --- | +| GET | `/status` | Read local connection state without returning credentials | +| POST | `/device` | Start device authorization | +| POST | `/device/poll` | Poll the initiating user's authorization attempt | +| DELETE | `/device/{authorization_id}` | Cancel only that pending attempt | +| DELETE | `/auth` | Remove local authorization | + +Use the returned polling interval and expiration time. An expired attempt must be restarted. These routes are not a general-purpose subscription-to-API gateway. + +## References + +- [OpenAI Codex authentication](https://developers.openai.com/codex/auth): ChatGPT versus API-key access and device-code login. +- [Hermes Agent providers](https://hermes-agent.nousresearch.com/docs/integrations/providers/): subscription device authentication and refresh recovery. +- [OpenClaw OpenAI provider](https://docs.openclaw.ai/providers/openai): subscription and API-key route distinctions. +- [New API](https://github.com/QuantumNous/new-api): reference for Codex protocol compatibility; its gateway/account-pooling product model is not adopted here. + +## 中文快速说明 + +在「模型」中添加提供商,选择 **OpenAI Codex**,填写名称并点击「保存并登录」。打开 OpenAI 授权页面,输入 LangBot 显示的一次性验证码,完成授权后回到原对话框。随后照常扫描或添加模型、测试模型,并在流水线中选择它。 + +无需填写 API Key,也无需为远程服务器配置 localhost 回调。登录中断后可以从该提供商的设置中重试;断开连接只删除 LangBot 中保存的授权。调用消耗所登录账号的 Codex 额度,受账号实际权限和 OpenAI 限制约束,不会自动转用按量付费的 OpenAI API。 + +此功能用于自己的授权账号及可信工作流,不应将个人订阅作为面向多个用户转售或共享的 API 服务。提供商仍是 LangBot 工作空间内的资源,连接个人账号前请确认模型的使用范围。 diff --git a/docs/HTTP_BOT_ADAPTER_DESIGN.md b/docs/HTTP_BOT_ADAPTER_DESIGN.md index 31e9a4861..d0826f2b5 100644 --- a/docs/HTTP_BOT_ADAPTER_DESIGN.md +++ b/docs/HTTP_BOT_ADAPTER_DESIGN.md @@ -218,8 +218,8 @@ metadata: spec: categories: [popular, global] help_links: - zh: https://docs.langbot.app/zh/platforms/http-bot - en: https://docs.langbot.app/en/platforms/http-bot + zh: https://langbot.app/docs/zh/platforms/http-bot + en: https://langbot.app/docs/en/platforms/http-bot config: - { name: inbound_secret, type: string, required: true, default: "" } - { name: callback_url, type: string, required: false, default: "" } diff --git a/docs/SEEKDB_INTEGRATION.md b/docs/SEEKDB_INTEGRATION.md index a38eb9f08..4a2b55181 100644 --- a/docs/SEEKDB_INTEGRATION.md +++ b/docs/SEEKDB_INTEGRATION.md @@ -243,7 +243,7 @@ For large datasets: - SeekDB GitHub: https://github.com/oceanbase/seekdb - pyseekdb SDK: https://github.com/oceanbase/pyseekdb - OceanBase Documentation: https://oceanbase.ai -- LangBot Documentation: https://docs.langbot.app +- LangBot Documentation: https://langbot.app/docs ## License diff --git a/docs/assets/pr-2363/oauth-required-state.png b/docs/assets/pr-2363/oauth-required-state.png new file mode 100644 index 000000000..a969c3b9f Binary files /dev/null and b/docs/assets/pr-2363/oauth-required-state.png differ diff --git a/docs/pipeline-migration-config-map.zh-CN.md b/docs/pipeline-migration-config-map.zh-CN.md new file mode 100644 index 000000000..d3c90077c --- /dev/null +++ b/docs/pipeline-migration-config-map.zh-CN.md @@ -0,0 +1,504 @@ +# 旧流水线迁移:配置去向与语义变更 + +本文对应迁移规划器 v3。原生行为基线:`9b7ba0d64708496ace30a82866f6dbc185f089dc`。 + +## 迁移边界 + +- 迁移只由用户在 UI 选择流水线并确认后执行。打开页面、预览、刷新、安装插件都不触发自动迁移。 +- 流水线 ID、名称、Bot 绑定以及非 AI 配置保持不变。插件凭证、应用 ID、端点和模型配置仍属于本流水线,不迁入共享插件全局设置。 +- 活动 AI 配置规范化为 `ai.runner` 与 `ai.runner_config`,避免编辑器把迁移结果判为混合旧配置。所有原始 AI 段(包括未选中的 Runner 配置)与完整原始配置一起保存到同一事务的持久化备份;预览会列出被归档的字段。 +- 备份不是公开下载,也不意味着已有一键恢复 UI。未知提交结果不显示为已回滚;激活待重试只在新预览、重新选择并确认后执行,且不重复转换配置。 +- “配置迁移”不等于“运行状态无损迁移”。原有远端会话、历史、Box 文件、待处理表单令牌不自动导入;UI 必须在确认前说明新会话/状态重置。自定义 Box 共享作用域与未结束的交互不能被静默清除。 + +## 功能与默认值的取舍 + +- LocalAgent 保留日期提示、结构化提示词、模型回退、模型专属推理配置、工具/知识库/沙箱能力;保留现代上下文预算、检查点和摘要机制,不恢复旧的 `max-round` 截断算法。 +- `max-round` 退役,不把“10 轮”伪装成“50 条消息”等价换算;新上下文参数使用下面列明的默认值。工具执行仍显式采用 `serial`,不偷偷改变有副作用工具的执行顺序。 +- Dify 旧保存项 `timeout` 并非原生 Runner 实际读取的超时控制。迁移采用新插件 30 秒默认值,并提示用户;旧任意数值只保存在备份中。 +- Dify/Coze/N8n 迁移显式选择 `user-id-source=legacy-session`,Tbox 选择 `legacy-bot`;新建插件配置仍默认 `sender`。旧身份来自 Host 的可信会话/事件/机器人上下文,缺失时失败,不猜测为发送者。 +- Dify/N8n 的旧身份使用原 `query.session`,Coze 使用原 `query`;不能在群聊、不同会话策略或恢复交互时把这两者混同。 +- Coze、Langflow 等现代持久化远端会话机制保留;旧远端会话 ID 不跨账户、端点、Workspace 或 Runner 导入。 +- DashScope 引用字段别名、Langflow 输入输出字段别名、Coze 历史开关别名分别归一化;冲突值阻断,而非靠字段出现顺序决定。 +- N8n 的 ignore 响应模式、认证和保留身份字段要维持明确语义。普通业务变量保留支持的 JSON 值,内部/权限/敏感变量不无条件转发。 +- `remove-think` 保留原输出策略,并与支持该字段的 Runner 参数同步;仅用户实际修改才传播,不把表单挂载默认值视为修改。 +- 空字符串、缺失和显式 null 区分处理。WeKnora 的 agent-id 不得在打开/保存其他字段时被默认值替换;未知字段/类型不能靠 truthiness 静默接受。 +- 工具、KB、MCP 的授权由 Host 校验;表单未显示的授权字段仍必须保留,不能因保存而删除或扩大权限。 + +## 插件版本要求 + +以下是本次本地源码的目标版本;本表不代表插件已经发布。旧版本即使名称相同,也不能作为新迁移能力的证明。 + +- `local-agent` → `LocalAgent` **0.1.6**。 +- `dify-service-api` → `DifyAgent` **0.1.7**。 +- `coze-api` → `CozeAgent` **0.1.7**。 +- `dashscope-app-api` → `DashScopeAgent` **0.1.7**。 +- `n8n-service-api` → `N8nAgent` **0.1.7**。 +- `langflow-api` → `LangflowAgent` **0.1.7**。 +- `deerflow-api` → `DeerFlowAgent` **0.1.7**。 +- `tbox-app-api` → `TboxAgent` **0.1.5**。 +- `weknora-api` → `WeKnoraAgent` **0.1.7**。 + +## 逐字段完整清单 + +共 9 类 Runner、104 条字段条目;按消费者展开共享字段。未知部署自定义键不冒充已穷举字段,遇到时阻断并保留原值。 + +目标路径缩写 `R` 表示 `config.ai.runner_config[当前插件 Runner ID]`;`extensions_preferences` 是流水线的 Host 扩展策略,不是插件全局配置。 + +### LocalAgent(28 项) + +- **`ai.local-agent.box-session-id-template`** → `R.box-session-id-template`。 + - 全局、每会话、每用户、每对话上下文、每消息及有效自定义模板均原样保留;缺失时沿用插件默认 `{launcher_type}_{launcher_id}`。插件清单的 `allow_custom: true` 允许非预制值。空值、错误括号、位置参数、格式转换和属性访问会提示修正;公开请求变量保留,但运行时必须有值。复用范围保留不等于迁移旧容器及文件,新运行器按同样规则申请新的沙箱。 + - 所有权:Runner 管理复用策略、申请、绑定及附件传输;Host 管理连接、工作区隔离和配额。沙箱开关沿用 LocalAgent 插件默认 `true`,仅在有沙箱工具授权且平台支持时使用。 +- **`ai.local-agent.enable-all-tools`** → `R.enable-all-tools`。 + - 原样复制有效授权;mcp 字段缺失时取 extensions_preferences 对应值;显式 null/错误类型不回退扩大权限。 + - 所有权:`host_policy_in_pipeline_runner_config`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:34`。 +- **`ai.local-agent.knowledge-base`** → `R.knowledge-bases`。 + - 有效非空 plural 列表优先;plural 缺失/有效空列表且 singular 非空且非__none__→[原UUID];非法容器/null单独校验,不以truthiness宽容;其他为空→[]。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:280`。 +- **`ai.local-agent.knowledge-bases`** → `R.knowledge-bases`。 + - 有效非空 plural 列表优先;plural 缺失/有效空列表且 singular 非空且非__none__→[原UUID];非法容器/null单独校验,不以truthiness宽容;其他为空→[]。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:278`。 +- **`ai.local-agent.max-round`** → `退役;不生成同名活动参数`。 + - 活动目标省略 max-round;保留回滚备份和弃用通知;不把10轮映射成50条。 + - 所有权:`obsolete_native_context_control`;原生依据:`src/langbot/pkg/pipeline/msgtrun/truncators/round.py:13`。 +- **`ai.local-agent.mcp-resource-agent-read-enabled`** → `R.mcp-resource-agent-read-enabled`。 + - 原样复制有效授权;mcp 字段缺失时取 extensions_preferences 对应值;显式 null/错误类型不回退扩大权限。 + - 所有权:`host_policy_in_pipeline_runner_config`;原生依据:`src/langbot/pkg/pipeline/pipelinemgr.py:133`。 +- **`ai.local-agent.mcp-resources`** → `R.mcp-resources`。 + - 原样复制有效授权;mcp 字段缺失时取 extensions_preferences 对应值;显式 null/错误类型不回退扩大权限。 + - 所有权:`host_policy_in_pipeline_runner_config`;原生依据:`src/langbot/pkg/pipeline/pipelinemgr.py:129`。 +- **`ai.local-agent.model`** → `R.model`。 + - 旧字符串UUID→{primary:原UUID,fallbacks:[],reasoning:{}};对象深拷贝 primary/fallbacks/reasoning;不替换失效UUID、不扩大授权。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/localagent.py:283`。 +- **`ai.local-agent.model.fallbacks`** → `R.model.fallbacks`。 + - 保留有序UUID列表,缺失=[];runtime 去重不是迁移重排。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:93`。 +- **`ai.local-agent.model.primary`** → `R.model.primary`。 + - 逐字复制非空UUID;Host 所有权交集校验,缺失不得选无关模型。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:92`。 +- **`ai.local-agent.model.reasoning`** → `R.model.reasoning`。 + - 复制 UUID→level 映射,缺失={};缺项保留模型持久默认;显式 provider_default 覆盖持久级别。 + - 所有权:`pipeline_selector_with_host_provider_policy`;原生依据:`src/langbot/pkg/provider/runners/localagent.py:284`。 +- **`ai.local-agent.prompt`** → `R.prompt`。 + - 保留有序消息、合法SDK结构化内容、空消息、name/provider metadata;缺失不拿UI短提示词覆盖seed长提示词;空Host提示列表有权威性。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:122`。 +- **`ai.local-agent.prompt[0].content`** → `R.prompt[0].content`。 + - 保留所有prompt记录的content,这里[0]仅默认记录形状;不把seed较长content替换为UI短默认,合法空content保留。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/templates/metadata/pipeline/ai.yaml:121`。 +- **`ai.local-agent.prompt[0].role`** → `R.prompt[0].role`。 + - 保留所有prompt记录的role,这里[0]仅默认记录形状;不把seed较长content替换为UI短默认,合法空content保留。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/templates/metadata/pipeline/ai.yaml:120`。 +- **`ai.local-agent.rerank-model`** → `R.rerank-model`。 + - 复制UUID;空字符串或__none__代表禁用;Host验证 rerank 授权。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/localagent.py:442`。 +- **`ai.local-agent.rerank-top-k`** → `R.rerank-top-k`。 + - 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/localagent.py:456`。 +- **`ai.local-agent.tools`** → `R.tools`。 + - 原样复制有效授权;mcp 字段缺失时取 extensions_preferences 对应值;显式 null/错误类型不回退扩大权限。 + - 所有权:`host_policy_in_pipeline_runner_config`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:37`。 +- **`ai.runner.expire-time`** → `config.ai.runner.expire-time`。 + - 原样保留非负整数;缺失=0,禁止用 token budget 替换。 + - 所有权:`pipeline_host_lifecycle`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:131`。 +- **`ai.runner.runner`** → `config.ai.runner.id`。 + - 所有权:`pipeline_selector`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:73`。 +- **`extensions_preferences.enable_all_mcp_servers`** → `extensions_preferences.enable_all_mcp_servers`。 + - 按原存在性和值保留;显式 false/空名单=不授权;无字段的旧有效缺省只有在同一所有权范围内可保留。 + - 所有权:`host_resource_authorization_shared_all_nine`;原生依据:`src/langbot/pkg/pipeline/pipelinemgr.py:127`。 +- **`extensions_preferences.enable_all_plugins`** → `extensions_preferences.enable_all_plugins`。 + - 按原存在性和值保留;显式 false/空名单=不授权;无字段的旧有效缺省只有在同一所有权范围内可保留。 + - 所有权:`host_resource_authorization_shared_all_nine`;原生依据:`src/langbot/pkg/pipeline/pipelinemgr.py:126`。 +- **`extensions_preferences.enable_all_skills`** → `extensions_preferences.enable_all_skills`。 + - 按原存在性和值保留;显式 false/空名单=不授权;无字段的旧有效缺省只有在同一所有权范围内可保留。 + - 所有权:`host_resource_authorization_shared_all_nine`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:339`。 +- **`extensions_preferences.mcp_resource_agent_read_enabled`** → `extensions_preferences.mcp_resource_agent_read_enabled`。 + - 按原存在性和值保留;显式 false/空名单=不授权;无字段的旧有效缺省只有在同一所有权范围内可保留。 + - 所有权:`host_resource_authorization_shared_all_nine`;原生依据:`src/langbot/pkg/pipeline/pipelinemgr.py:135`。 +- **`extensions_preferences.mcp_resources`** → `extensions_preferences.mcp_resources`。 + - 按原存在性和值保留;显式 false/空名单=不授权;无字段的旧有效缺省只有在同一所有权范围内可保留。 + - 所有权:`host_resource_authorization_shared_all_nine`;原生依据:`src/langbot/pkg/pipeline/pipelinemgr.py:131`。 +- **`extensions_preferences.mcp_servers`** → `extensions_preferences.mcp_servers`。 + - 按原存在性和值保留;显式 false/空名单=不授权;无字段的旧有效缺省只有在同一所有权范围内可保留。 + - 所有权:`host_resource_authorization_shared_all_nine`;原生依据:`src/langbot/pkg/pipeline/pipelinemgr.py:149`。 +- **`extensions_preferences.plugins`** → `extensions_preferences.plugins`。 + - 按原存在性和值保留;显式 false/空名单=不授权;无字段的旧有效缺省只有在同一所有权范围内可保留。 + - 所有权:`host_resource_authorization_shared_all_nine`;原生依据:`src/langbot/pkg/pipeline/pipelinemgr.py:142`。 +- **`extensions_preferences.skills`** → `extensions_preferences.skills`。 + - 按原存在性和值保留;显式 false/空名单=不授权;无字段的旧有效缺省只有在同一所有权范围内可保留。 + - 所有权:`host_resource_authorization_shared_all_nine`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:344`。 +- **`output.misc.remove-think`** → `R.remove-think`。 + - 复制严格 bool 到所选 Runner remove-think;同时保留 config.output.misc.remove-think;缺失=false。 + - 所有权:`pipeline_runner_parameters_plus_retained_host_output`;原生依据:`src/langbot/pkg/provider/runners/localagent.py:539`。 + +### DifyAgent(8 项) + +- **`ai.dify-service-api.api-key`** → `R.api-key`。 + - 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/difysvapi.py:838`。 +- **`ai.dify-service-api.app-type`** → `R.app-type`。 + - 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/difysvapi.py:833`。 +- **`ai.dify-service-api.base-prompt`** → `R.base-prompt`。 + - 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/difysvapi.py:984`。 +- **`ai.dify-service-api.base-url`** → `R.base-url`。 + - 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/difysvapi.py:842`。 +- **`ai.dify-service-api.timeout`** → `R.timeout`。 + - 旧seed timeout=30没有被native Runner读取;native请求显式120。采用新插件30秒默认,旧任意保存值只留备份,不悄悄激活;用户可显式配置新timeout。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/templates/default-pipeline-config.json:65`。 +- **`ai.runner.expire-time`** → `config.ai.runner.expire-time`。 + - 原样保留非负整数;缺失=0,禁止用 token budget 替换。 + - 所有权:`pipeline_host_lifecycle`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:131`。 +- **`ai.runner.runner`** → `config.ai.runner.id`。 + - 所有权:`pipeline_selector`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:73`。 +- **`output.misc.remove-think`** → `R.remove-think`。 + - 复制严格 bool 到所选 Runner remove-think;同时保留 config.output.misc.remove-think;缺失=false。 + - 所有权:`pipeline_runner_parameters_plus_retained_host_output`;原生依据:`src/langbot/pkg/provider/runners/difysvapi.py:859`。 + +### CozeAgent(9 项) + +- **`ai.coze-api.api-base`** → `R.api-base`。 + - 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/cozeapi.py:35`。 +- **`ai.coze-api.api-key`** → `R.api-key`。 + - 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/cozeapi.py:31`。 +- **`ai.coze-api.auto-save-history`** → `R.auto-save-history`。 + - 有效 underscore bool 为旧runtime值;仅hyphen有效bool时明确修复UI旧bug并取该值;两者有效且冲突需用户选择;缺失/null旧值明确弃用后采用目标 true;非bool(含空白字符串)拒绝,不bool()。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/templates/metadata/pipeline/ai.yaml:566`。 +- **`ai.coze-api.auto_save_history`** → `R.auto-save-history`。 + - 有效 underscore bool 为旧runtime值;仅hyphen有效bool时明确修复UI旧bug并取该值;两者有效且冲突需用户选择;缺失/null旧值明确弃用后采用目标 true;非bool(含空白字符串)拒绝,不bool()。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/cozeapi.py:34`。 +- **`ai.coze-api.bot-id`** → `R.bot-id`。 + - 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/cozeapi.py:32`。 +- **`ai.coze-api.timeout`** → `R.timeout`。 + - 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/cozeapi.py:33`。 +- **`ai.runner.expire-time`** → `config.ai.runner.expire-time`。 + - 原样保留非负整数;缺失=0,禁止用 token budget 替换。 + - 所有权:`pipeline_host_lifecycle`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:131`。 +- **`ai.runner.runner`** → `config.ai.runner.id`。 + - 所有权:`pipeline_selector`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:73`。 +- **`output.misc.remove-think`** → `R.remove-think`。 + - 复制严格 bool 到所选 Runner remove-think;同时保留 config.output.misc.remove-think;缺失=false。 + - 所有权:`pipeline_runner_parameters_plus_retained_host_output`;原生依据:`src/langbot/pkg/provider/runners/cozeapi.py:50`。 + +### DashScopeAgent(8 项) + +- **`ai.dashscope-app-api.api-key`** → `R.api-key`。 + - 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/dashscopeapi.py:58`。 +- **`ai.dashscope-app-api.app-id`** → `R.app-id`。 + - 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/dashscopeapi.py:57`。 +- **`ai.dashscope-app-api.app-type`** → `R.app-type`。 + - 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/dashscopeapi.py:51`。 +- **`ai.dashscope-app-api.references-quote`** → `R.references_quote`。 + - 有效 underscore 字符串优先;只有 hyphen 字符串则明确重命名修复;两者冲突需选择;都缺失采用“参考资料来自:”;显式空串和空白原样保留,null非字符串拒绝或用户明确选择默认。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/templates/default-pipeline-config.json:71`。 +- **`ai.dashscope-app-api.references_quote`** → `R.references_quote`。 + - 有效 underscore 字符串优先;只有 hyphen 字符串则明确重命名修复;两者冲突需选择;都缺失采用“参考资料来自:”;显式空串和空白原样保留,null非字符串拒绝或用户明确选择默认。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/dashscopeapi.py:59`。 +- **`ai.runner.expire-time`** → `config.ai.runner.expire-time`。 + - 原样保留非负整数;缺失=0,禁止用 token budget 替换。 + - 所有权:`pipeline_host_lifecycle`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:131`。 +- **`ai.runner.runner`** → `config.ai.runner.id`。 + - 所有权:`pipeline_selector`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:73`。 +- **`output.misc.remove-think`** → `R.remove-think`。 + - 复制严格 bool 到所选 Runner remove-think;同时保留 config.output.misc.remove-think;缺失=false。 + - 所有权:`pipeline_runner_parameters_plus_retained_host_output`;原生依据:`src/langbot/pkg/provider/runners/dashscopeapi.py:121`。 + +### N8nAgent(13 项) + +- **`ai.n8n-service-api.auth-type`** → `R.auth-type`。 + - 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/n8nsvapi.py:47`。 +- **`ai.n8n-service-api.basic-password`** → `R.basic-password`。 + - 凭证字节原样复制;选用basic时同时显式 basic-encoding="latin1"(native aiohttp默认),插件新建默认utf-8不改;不支持编码字符应报安全错误而非改写。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/n8nsvapi.py:52`。 +- **`ai.n8n-service-api.basic-username`** → `R.basic-username`。 + - 凭证字节原样复制;选用basic时同时显式 basic-encoding="latin1"(native aiohttp默认),插件新建默认utf-8不改;不支持编码字符应报安全错误而非改写。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/n8nsvapi.py:51`。 +- **`ai.n8n-service-api.header-name`** → `R.header-name`。 + - 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/n8nsvapi.py:57`。 +- **`ai.n8n-service-api.header-value`** → `R.header-value`。 + - 原样复制包括空字符串;active header必须有合法header-name,不省略合法空value;隐藏/inactive字段仍保留。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/n8nsvapi.py:58`。 +- **`ai.n8n-service-api.jwt-algorithm`** → `R.jwt-algorithm`。 + - 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/n8nsvapi.py:55`。 +- **`ai.n8n-service-api.jwt-secret`** → `R.jwt-secret`。 + - 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/n8nsvapi.py:54`。 +- **`ai.n8n-service-api.output-key`** → `R.output-key`。 + - 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/n8nsvapi.py:41`。 +- **`ai.n8n-service-api.response-handling`** → `R.response-handling`。 + - reply/ignore逐字复制;缺失reply;ignore现已实现2xx只读header、不读body、不输出;非2xx仍失败;不得留旧缺特性blocker。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/n8nsvapi.py:42`。 +- **`ai.n8n-service-api.timeout`** → `R.timeout`。 + - 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/n8nsvapi.py:38`。 +- **`ai.n8n-service-api.webhook-url`** → `R.webhook-url`。 + - 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/n8nsvapi.py:35`。 +- **`ai.runner.expire-time`** → `config.ai.runner.expire-time`。 + - 原样保留非负整数;缺失=0,禁止用 token budget 替换。 + - 所有权:`pipeline_host_lifecycle`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:131`。 +- **`ai.runner.runner`** → `config.ai.runner.id`。 + - 所有权:`pipeline_selector`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:73`。 + +### LangflowAgent(10 项) + +- **`ai.langflow-api.api-key`** → `R.api-key`。 + - 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/langflowapi.py:119`。 +- **`ai.langflow-api.base-url`** → `R.base-url`。 + - 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/langflowapi.py:118`。 +- **`ai.langflow-api.flow-id`** → `R.flow-id`。 + - 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/langflowapi.py:120`。 +- **`ai.langflow-api.input-type`** → `R.input-type`。 + - underscore存在且合法字符串→对应hyphen;underscore缺失且hyphen缺失/chat→chat;仅hyphen非默认属于旧UI被忽略的意图,用户选修复为该值或维持chat;两者冲突需选择;underscore显式null/空白不能被or chat吞掉。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/templates/metadata/pipeline/ai.yaml:692`。 +- **`ai.langflow-api.input_type`** → `R.input-type`。 + - underscore存在且合法字符串→对应hyphen;underscore缺失且hyphen缺失/chat→chat;仅hyphen非默认属于旧UI被忽略的意图,用户选修复为该值或维持chat;两者冲突需选择;underscore显式null/空白不能被or chat吞掉。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/langflowapi.py:81`。 +- **`ai.langflow-api.output-type`** → `R.output-type`。 + - underscore存在且合法字符串→对应hyphen;underscore缺失且hyphen缺失/chat→chat;仅hyphen非默认属于旧UI被忽略的意图,用户选修复为该值或维持chat;两者冲突需选择;underscore显式null/空白不能被or chat吞掉。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/templates/metadata/pipeline/ai.yaml:702`。 +- **`ai.langflow-api.output_type`** → `R.output-type`。 + - underscore存在且合法字符串→对应hyphen;underscore缺失且hyphen缺失/chat→chat;仅hyphen非默认属于旧UI被忽略的意图,用户选修复为该值或维持chat;两者冲突需选择;underscore显式null/空白不能被or chat吞掉。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/langflowapi.py:82`。 +- **`ai.langflow-api.tweaks`** → `R.tweaks`。 + - 目标接受dict或严格JSON object(递归保留null/false/0);缺失/null/空串/JSON null→{};空白串按新便利默认{}并记录;false/0/list拒绝;JSON重复键/NaN/Infinity/过深结构拒绝;坏JSON绝不降级{}。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/langflowapi.py:93`。 +- **`ai.runner.expire-time`** → `config.ai.runner.expire-time`。 + - 原样保留非负整数;缺失=0,禁止用 token budget 替换。 + - 所有权:`pipeline_host_lifecycle`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:131`。 +- **`ai.runner.runner`** → `config.ai.runner.id`。 + - 所有权:`pipeline_selector`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:73`。 + +### DeerFlowAgent(13 项) + +- **`ai.deerflow-api.api-base`** → `R.api-base`。 + - 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/deerflowapi.py:61`。 +- **`ai.deerflow-api.api-key`** → `R.api-key`。 + - 逐字保留字符串包括空白/空串;null或非string拒绝;仅缺失采用target_default。auth-header是完整Authorization值,非空优先于api-key。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/deerflowapi.py:68`。 +- **`ai.deerflow-api.assistant-id`** → `R.assistant-id`。 + - 逐字保留字符串包括空白/空串;null或非string拒绝;仅缺失采用target_default。auth-header是完整Authorization值,非空优先于api-key。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/deerflowapi.py:70`。 +- **`ai.deerflow-api.auth-header`** → `R.auth-header`。 + - 逐字保留字符串包括空白/空串;null或非string拒绝;仅缺失采用target_default。auth-header是完整Authorization值,非空优先于api-key。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/deerflowapi.py:69`。 +- **`ai.deerflow-api.max-concurrent-subagents`** → `R.max-concurrent-subagents`。 + - 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/deerflowapi.py:75`。 +- **`ai.deerflow-api.model-name`** → `R.model-name`。 + - 逐字保留字符串包括空白/空串;null或非string拒绝;仅缺失采用target_default。auth-header是完整Authorization值,非空优先于api-key。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/deerflowapi.py:71`。 +- **`ai.deerflow-api.plan-mode`** → `R.plan-mode`。 + - 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/deerflowapi.py:73`。 +- **`ai.deerflow-api.recursion-limit`** → `R.recursion-limit`。 + - 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/deerflowapi.py:77`。 +- **`ai.deerflow-api.subagent-enabled`** → `R.subagent-enabled`。 + - 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/deerflowapi.py:74`。 +- **`ai.deerflow-api.thinking-enabled`** → `R.thinking-enabled`。 + - 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/deerflowapi.py:72`。 +- **`ai.deerflow-api.timeout`** → `R.timeout`。 + - 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/deerflowapi.py:76`。 +- **`ai.runner.expire-time`** → `config.ai.runner.expire-time`。 + - 原样保留非负整数;缺失=0,禁止用 token budget 替换。 + - 所有权:`pipeline_host_lifecycle`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:131`。 +- **`ai.runner.runner`** → `config.ai.runner.id`。 + - 所有权:`pipeline_selector`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:73`。 + +### TboxAgent(5 项) + +- **`ai.runner.expire-time`** → `config.ai.runner.expire-time`。 + - 原样保留非负整数;缺失=0,禁止用 token budget 替换。 + - 所有权:`pipeline_host_lifecycle`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:131`。 +- **`ai.runner.runner`** → `config.ai.runner.id`。 + - 所有权:`pipeline_selector`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:73`。 +- **`ai.tbox-app-api.api-key`** → `R.api-key`。 + - 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/tboxapi.py:59`。 +- **`ai.tbox-app-api.app-id`** → `R.app-id`。 + - 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/tboxapi.py:58`。 +- **`output.misc.remove-think`** → `R.remove-think`。 + - 复制严格 bool 到所选 Runner remove-think;同时保留 config.output.misc.remove-think;缺失=false。 + - 所有权:`pipeline_runner_parameters_plus_retained_host_output`;原生依据:`src/langbot/pkg/provider/runners/tboxapi.py:114`。 + +### WeKnoraAgent(10 项) + +- **`ai.runner.expire-time`** → `config.ai.runner.expire-time`。 + - 原样保留非负整数;缺失=0,禁止用 token budget 替换。 + - 所有权:`pipeline_host_lifecycle`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:131`。 +- **`ai.runner.runner`** → `config.ai.runner.id`。 + - 所有权:`pipeline_selector`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:73`。 +- **`ai.weknora-api.agent-id`** → `R.agent-id`。 + - 原键缺失:明确写入按旧app-type的默认(chat quick-answer,agent smart-reasoning);显式null/空串保持(线端省略agent_id);其余字符串含空白逐字保留,不能因chat而覆盖历史smart ID。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/weknoraapi.py:89`。 +- **`ai.weknora-api.api-key`** → `R.api-key`。 + - 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/weknoraapi.py:38`。 +- **`ai.weknora-api.app-type`** → `R.app-type`。 + - 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/weknoraapi.py:336`。 +- **`ai.weknora-api.base-prompt`** → `R.base-prompt`。 + - 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/weknoraapi.py:65`。 +- **`ai.weknora-api.base-url`** → `R.base-url`。 + - 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/weknoraapi.py:45`。 +- **`ai.weknora-api.knowledge-base-ids`** → `R.knowledge-base-ids`。 + - 保留远端ID顺序/重复/字节;缺失或null→[];无效元素/空白ID拒绝,不静默丢弃。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/weknoraapi.py:90`。 +- **`ai.weknora-api.timeout`** → `R.timeout`。 + - 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/weknoraapi.py:92`。 +- **`ai.weknora-api.web-search-enabled`** → `R.web-search-enabled`。 + - 复制严格bool,缺失false;仅agent线端使用,chat保存true仍不激活。 + - 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/weknoraapi.py:91`。 + +## 新增参数与明确采用的默认值 + +这些值是新机制的默认值,不声称与旧行为一一等价。已存在且受支持的有效值按映射规则保留;必填凭证/端点不能由模板伪造。 + +### LocalAgent + +```json +{ + "advanced-settings": false, + "date-grounding": true, + "timeout": 300, + "retrieval-top-k": 5, + "rerank-model": "", + "rerank-top-k": 5, + "max-tool-iterations": 100, + "tool-execution-mode": "serial", + "max-tool-result-chars": 20000, + "context-history-fetch-limit": 50, + "context-window-tokens": 200000, + "context-reserve-tokens": 16384, + "context-keep-recent-tokens": 20000, + "context-summary-tokens": 8000, + "enable-all-tools": true, + "tools": [], + "knowledge-bases": [] +} +``` + +### DifyAgent + +```json +{ + "timeout": 30, + "advanced-settings": false +} +``` + +### CozeAgent + +```json +{ + "auto-save-history": true, + "advanced-settings": false +} +``` + +### DashScopeAgent + +```json +{ + "references_quote": "参考资料来自:", + "timeout": 120, + "advanced-settings": false +} +``` + +### N8nAgent + +```json +{ + "auth-type": "none", + "basic-username": "", + "basic-password": "", + "jwt-secret": "", + "jwt-algorithm": "HS256", + "header-name": "", + "header-value": "", + "timeout": 120, + "output-key": "response", + "response-handling": "reply", + "basic-encoding": "latin1", + "advanced-settings": false +} +``` + +### LangflowAgent + +```json +{ + "input-type": "chat", + "output-type": "chat", + "tweaks": {}, + "advanced-settings": false +} +``` + +### DeerFlowAgent + +```json +{ + "api-key": "", + "auth-header": "", + "assistant-id": "lead_agent", + "model-name": "", + "thinking-enabled": false, + "plan-mode": false, + "subagent-enabled": false, + "max-concurrent-subagents": 3, + "timeout": 300, + "recursion-limit": 1000 +} +``` + +### TboxAgent + +```json +{ + "timeout": 120 +} +``` + +### WeKnoraAgent + +```json +{ + "knowledge-base-ids": [], + "web-search-enabled": false, + "timeout": 120, + "base-prompt": "请回答用户的问题。", + "advanced-settings": false +} +``` + +## 验证与交付状态 + +本清单负责说明所有已识别旧字段的去向和语义;不替代最终的集成验收。源码修改、插件打包、OSS/Cloud 持久化与权限、真实 SDK 调用、浏览器测试、独立审查和发布状态必须分别记录,不能以其中一项通过宣称全部完成。 + +源码库存与每条字段的多处原生证据、默认值/表达式及哈希见工作区的 `411-completion-config-audit.json` 和其标注的原始库存;最终验证以 `411-completion-*` 的实际日志和源码哈希为准。 diff --git a/examples/http-bot/README.md b/examples/http-bot/README.md index b04387d78..62a1e469d 100644 --- a/examples/http-bot/README.md +++ b/examples/http-bot/README.md @@ -6,7 +6,7 @@ Minimal, dependency-light clients for the LangBot **HTTP Bot** platform adapter. They show the whole loop: signing a request, pushing a message, and receiving multi-part replies on a callback endpoint. -Full guide: [docs.langbot.app — HTTP Bot](https://docs.langbot.app/en/usage/platforms/http-bot). +Full guide: [docs.langbot.app — HTTP Bot](https://langbot.app/docs/en/usage/platforms/http-bot). Machine-readable contract: [`docs/http-bot-openapi.json`](../../docs/http-bot-openapi.json). ## Files diff --git a/examples/http-bot/README.zh.md b/examples/http-bot/README.zh.md index 1baf81272..fbd2f4394 100644 --- a/examples/http-bot/README.zh.md +++ b/examples/http-bot/README.zh.md @@ -6,7 +6,7 @@ 它们完整展示了整条链路:对请求签名、推送一条消息、在回调端点接收 1→M 的多段回复。 -完整指南:[docs.langbot.app —— HTTP Bot](https://docs.langbot.app/zh/usage/platforms/http-bot)。 +完整指南:[docs.langbot.app —— HTTP Bot](https://langbot.app/docs/zh/usage/platforms/http-bot)。 机器可读的接口契约:[`docs/http-bot-openapi.json`](../../docs/http-bot-openapi.json)。 ## 文件清单 diff --git a/examples/web-page-bot/README.md b/examples/web-page-bot/README.md index e31f41ca2..d52bd9a52 100644 --- a/examples/web-page-bot/README.md +++ b/examples/web-page-bot/README.md @@ -6,7 +6,7 @@ A single self-contained HTML page that demos the LangBot **Page Bot** (`web_page_bot`) embeddable chat widget — the one you drop onto any website with a single ``, + }), + ); + await page.addInitScript((mode) => { + const w = window as any; + w.copyEvents = []; + document.addEventListener('copy', () => { + const el = document.activeElement as HTMLTextAreaElement; + w.copyEvents.push({ + tag: el.tagName, + selected: el.value?.slice(el.selectionStart, el.selectionEnd), + }); + }); + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: + mode === 'unavailable' + ? undefined + : { + writeText: (text: string) => { + if (mode === 'success') { + w.written = text; + return Promise.resolve(); + } + if (mode === 'delayed') + return new Promise((resolve) => { + w.resolveCopy = resolve; + }); + return Promise.reject(new Error('denied')); + }, + }, + }); + if (mode === 'false') document.execCommand = () => false; + if (mode === 'throw') + document.execCommand = () => { + throw new Error('denied'); + }; + }, mode); + await page.goto('/copy-harness'); + await expect( + page.getByRole('button', { name: 'models.codex.copyCode', exact: true }), + ).toBeVisible(); +} +const copy = (page: Page) => + page.getByRole('button', { name: 'models.codex.copyCode', exact: true }); +const copied = (page: Page) => + page.getByRole('button', { name: 'models.codex.copied', exact: true }); + +test('Clipboard API success shows icon, toast and transient feedback', async ({ + page, +}) => { + await mount(page, 'success'); + await expect(copy(page).locator('svg.lucide-copy')).toBeVisible(); + await copy(page).click(); + await expect(copied(page).locator('svg.lucide-check')).toBeVisible(); + await expect( + page.getByText('common.copySuccess', { exact: true }), + ).toBeVisible(); + expect(await page.evaluate(() => (window as any).written)).toBe( + 'FIXTURE-1234', + ); + await expect(copy(page)).toBeVisible({ timeout: 4000 }); +}); +for (const mode of ['unavailable', 'rejected']) + test(`${mode} API performs a real selected-text copy inside modal`, async ({ + page, + }) => { + await mount(page, mode); + await copy(page).click(); + await expect(copied(page)).toBeVisible(); + expect(await page.evaluate(() => (window as any).copyEvents)).toEqual([ + { tag: 'TEXTAREA', selected: 'FIXTURE-1234' }, + ]); + await expect(copied(page)).toBeFocused(); + await expect(page.locator('textarea')).toHaveCount(0); + }); +for (const mode of ['false', 'throw']) + test(`${mode} fallback reports failure and manual guidance`, async ({ + page, + }) => { + await mount(page, mode); + await copy(page).click(); + await expect( + page.getByText('common.copyFailed', { exact: true }), + ).toBeVisible(); + await expect( + page.getByText('models.codex.copyManually', { exact: true }), + ).toBeVisible(); + await expect(copy(page)).toBeVisible(); + await expect(page.locator('textarea')).toHaveCount(0); + await expect(copy(page)).toBeFocused(); + }); +test('new code or attempt clears copied feedback', async ({ page }) => { + await mount(page, 'success'); + await copy(page).click(); + await expect(copied(page)).toBeVisible(); + await page.evaluate(() => + (window as any).renderCode('FIXTURE-5678', 'attempt-2'), + ); + await expect(copy(page)).toBeVisible(); + await copy(page).click(); + await expect(copied(page)).toBeVisible(); + await page.evaluate(() => + (window as any).renderCode('FIXTURE-5678', 'attempt-3'), + ); + await expect(copy(page)).toBeVisible(); +}); +test('completion from an old attempt cannot mark the new code copied', async ({ + page, +}) => { + await mount(page, 'delayed'); + await copy(page).click(); + await page.evaluate(() => + (window as any).renderCode('FIXTURE-5678', 'attempt-2'), + ); + await expect(page.getByText('FIXTURE-5678')).toBeVisible(); + await page.evaluate(() => (window as any).resolveCopy()); + await expect(copy(page)).toBeVisible(); + await expect(copied(page)).toHaveCount(0); +}); diff --git a/web/tests/e2e/codex-subscription.spec.ts b/web/tests/e2e/codex-subscription.spec.ts new file mode 100644 index 000000000..5a67867f9 --- /dev/null +++ b/web/tests/e2e/codex-subscription.spec.ts @@ -0,0 +1,341 @@ +import { writeFileSync } from 'node:fs'; +import { expect, test, type Page, type Route } from '@playwright/test'; +import { installLangBotApiMocks } from './fixtures/langbot-api'; + +// All OAuth, provider and model responses here are explicit UI fixtures. +// These tests never authenticate with OpenAI or use a real subscription. +async function fixture(page: Page) { + await installLangBotApiMocks(page, { authenticated: true }); + const state = { + providers: [] as Record[], + creates: 0, + starts: 0, + polls: 0, + cancels: 0, + disconnects: 0, + connected: false, + failStart: false, + pollStatus: 'pending', + interval: 1, + expiresIn: 600, + }; + const ok = (route: Route, data: unknown) => + route.fulfill({ json: { code: 0, data } }); + await page.route('**/api/v1/provider/**', async (route) => { + const url = new URL(route.request().url()); + const path = url.pathname; + const method = route.request().method(); + if (path.endsWith('/icon')) + return route.fulfill({ + contentType: 'image/svg+xml', + body: '', + }); + if (path.endsWith('/requesters')) + return ok(route, { + requesters: ['openai-codex', 'openai'].map((name) => ({ + name, + label: { + en_US: name === 'openai-codex' ? 'OpenAI Codex' : 'OpenAI API', + }, + description: { en_US: '' }, + spec: { + provider_category: 'manufacturer', + support_type: ['llm'], + config: [ + { name: 'base_url', default: 'https://api.openai.com/v1' }, + ], + }, + })), + }); + if (path.endsWith('/providers')) { + if (method === 'POST') { + state.creates++; + const provider = { + ...route.request().postDataJSON(), + uuid: `provider-${state.creates}`, + }; + state.providers.push(provider); + return ok(route, { uuid: provider.uuid }); + } + return ok(route, { providers: state.providers }); + } + if (path.endsWith('/codex/status')) + return ok(route, { + status: state.connected ? 'connected' : 'disconnected', + connected: state.connected, + expires_at: null, + }); + if (path.endsWith('/codex/device') && method === 'POST') { + state.starts++; + if (state.failStart) + return route.fulfill({ + status: 400, + json: { code: 400, msg: 'Fixture start failure' }, + }); + return ok(route, { + authorization_id: `attempt-${state.starts}`, + user_code: 'TEST-1234', + verification_uri: 'https://auth.openai.com/codex/device', + interval: state.interval, + expires_at: Date.now() / 1000 + state.expiresIn, + }); + } + if (path.endsWith('/codex/device/poll')) { + state.polls++; + expect(route.request().postDataJSON()).toEqual({ + authorization_id: `attempt-${state.starts}`, + }); + if (state.pollStatus === 'connected') state.connected = true; + return ok(route, { status: state.pollStatus, interval: state.interval }); + } + if (path.includes('/codex/device/') && method === 'DELETE') { + state.cancels++; + return ok(route, {}); + } + if (path.endsWith('/codex/auth') && method === 'DELETE') { + state.disconnects++; + state.connected = false; + return ok(route, {}); + } + if (/\/providers\/provider-\d+$/.test(path)) { + const provider = state.providers.find((p) => + path.endsWith(String(p.uuid)), + ); + if (method === 'PUT') + Object.assign(provider!, route.request().postDataJSON()); + return ok(route, { provider }); + } + if (path.includes('/models/')) return ok(route, { models: [] }); + return ok(route, {}); + }); + return state; +} + +async function openModels(page: Page) { + await page.goto('/home/bots'); + await page.getByRole('button', { name: 'Models', exact: true }).click(); + await page.getByRole('button', { name: 'Add Provider', exact: true }).click(); +} +async function choose(page: Page, name: string) { + await page + .getByRole('button', { name: 'Select Provider Type', exact: true }) + .click(); + await page.getByRole('button', { name: new RegExp(name) }).click(); +} + +for (const width of [1280, 390, 320]) { + test(`subscription sign-in in the existing provider dialog (${width}px, UI fixture)`, async ({ + page, + }) => { + const state = await fixture(page); + await page.setViewportSize({ width: 1280, height: 900 }); + await openModels(page); + await page.setViewportSize({ width, height: 900 }); + await page.locator('input[name="name"]').fill('My Codex'); + await choose(page, 'OpenAI Codex'); + await expect(page.locator('input[name="api_key"]')).toHaveCount(0); + await expect(page.locator('input[name="base_url"]')).toHaveCount(0); + await page + .getByRole('button', { name: 'Save and sign in', exact: true }) + .click(); + await expect(page.getByText('TEST-1234')).toBeVisible(); + await page.getByRole('button', { name: 'Copy code', exact: true }).click(); + await expect( + page.getByRole('button', { name: 'Copied', exact: true }), + ).toBeVisible(); + await expect( + page.getByText('Copy Successfully', { exact: true }), + ).toBeInViewport({ ratio: 1 }); + expect(state.creates).toBe(1); + expect(state.providers[0]).toMatchObject({ + requester: 'openai-codex', + api_keys: [], + base_url: 'https://chatgpt.com/backend-api/codex', + }); + await expect( + page.getByRole('link', { name: 'Continue at OpenAI' }), + ).toHaveAttribute('href', 'https://auth.openai.com/codex/device'); + const geometry = await page.getByTestId('codex-account').evaluate((el) => { + const box = el.getBoundingClientRect(); + return { + left: box.left, + right: box.right, + width: innerWidth, + documentWidth: document.documentElement.scrollWidth, + }; + }); + expect(geometry.left).toBeGreaterThanOrEqual(0); + expect(geometry.right).toBeLessThanOrEqual(width); + expect(geometry.documentWidth).toBeLessThanOrEqual(width); + if (process.env.CODEX_EVIDENCE_DIR) { + await page.locator('[data-sonner-toast]').evaluate(async (el) => { + await Promise.all( + el + .getAnimations({ subtree: true }) + .map((animation) => animation.finished.catch(() => undefined)), + ); + }); + const screenshot = `${process.env.CODEX_EVIDENCE_DIR}/codex-${width}.png`; + await page.screenshot({ path: screenshot, fullPage: true }); + writeFileSync( + `${process.env.CODEX_EVIDENCE_DIR}/codex-${width}.json`, + JSON.stringify( + { + evidence: 'UI fixture only; not live OpenAI sign-in', + viewport: { width, height: 900 }, + geometry, + screenshot, + }, + null, + 2, + ), + ); + } + state.pollStatus = 'connected'; + await expect(page.getByText('Connected', { exact: true })).toBeVisible(); + await page.getByRole('button', { name: 'Done', exact: true }).click(); + await expect(page.getByText('My Codex', { exact: true })).toBeVisible(); + await expect( + page.getByRole('button', { name: 'Add Model', exact: true }), + ).toBeVisible(); + expect(state.creates).toBe(1); + expect( + await page.evaluate(() => JSON.stringify({ ...localStorage })), + ).not.toContain('attempt-'); + }); +} + +test('failed start retries reuse saved provider; cancellation refreshes list', async ({ + page, +}) => { + const state = await fixture(page); + state.failStart = true; + await openModels(page); + await page.locator('input[name="name"]').fill('Retry Codex'); + await choose(page, 'OpenAI Codex'); + await page + .getByRole('button', { name: 'Save and sign in', exact: true }) + .click(); + await expect(page.getByRole('alert')).toContainText('Unable to sign in'); + state.failStart = false; + await page.getByRole('button', { name: 'Try again', exact: true }).click(); + await expect(page.getByText('TEST-1234')).toBeVisible(); + await page + .getByRole('button', { name: 'Cancel sign-in', exact: true }) + .click(); + await expect.poll(() => state.cancels).toBe(1); + await page.getByRole('button', { name: 'Cancel', exact: true }).click(); + await expect(page.getByText('Retry Codex', { exact: true })).toBeVisible(); + expect(state.creates).toBe(1); +}); + +test('reconnect cancellation preserves connection and disconnect requires confirmation', async ({ + page, +}) => { + const state = await fixture(page); + state.pollStatus = 'connected'; + await openModels(page); + await page.locator('input[name="name"]').fill('Managed Codex'); + await choose(page, 'OpenAI Codex'); + await page + .getByRole('button', { name: 'Save and sign in', exact: true }) + .click(); + await expect(page.getByText('Connected', { exact: true })).toBeVisible(); + state.pollStatus = 'pending'; + await page.getByRole('button', { name: 'Reconnect', exact: true }).click(); + await expect(page.getByText('TEST-1234')).toBeVisible(); + await page + .getByRole('button', { name: 'Cancel sign-in', exact: true }) + .click(); + await expect(page.getByText('Connected', { exact: true })).toBeVisible(); + expect(state.disconnects).toBe(0); + await page.getByRole('button', { name: 'Disconnect', exact: true }).click(); + expect(state.disconnects).toBe(0); + await page + .getByRole('button', { name: 'Confirm disconnect', exact: true }) + .click(); + await expect(page.getByText('Not connected', { exact: true })).toBeVisible(); + expect(state.disconnects).toBe(1); + expect(state.creates).toBe(1); +}); + +test('expiration permits retry without duplicate provider and closing cancels pending login', async ({ + page, +}) => { + const state = await fixture(page); + state.expiresIn = 1; + await openModels(page); + await page.locator('input[name="name"]').fill('Expired Codex'); + await choose(page, 'OpenAI Codex'); + await page + .getByRole('button', { name: 'Save and sign in', exact: true }) + .click(); + await expect( + page.getByText('Sign-in expired. Start again to get a new code.'), + ).toBeVisible(); + await expect.poll(() => state.cancels).toBe(1); + state.expiresIn = 600; + await page.getByRole('button', { name: 'Try again', exact: true }).click(); + await expect(page.getByText('TEST-1234')).toBeVisible(); + await page.keyboard.press('Escape'); + await expect.poll(() => state.cancels).toBe(2); + await expect(page.getByText('Expired Codex', { exact: true })).toBeVisible(); + expect(state.creates).toBe(1); + await page.getByRole('button', { name: 'Add Provider', exact: true }).click(); + await page.locator('input[name="name"]').fill('Second Codex'); + await choose(page, 'OpenAI Codex'); + await page + .getByRole('button', { name: 'Save and sign in', exact: true }) + .click(); + await expect(page.getByText('TEST-1234')).toBeVisible(); + expect(state.creates).toBe(2); + expect(state.providers.map((provider) => provider.name)).toEqual([ + 'Expired Codex', + 'Second Codex', + ]); + await page.keyboard.press('Escape'); + await expect.poll(() => state.cancels).toBe(3); +}); + +test('model test retains the connected provider identity', async ({ page }) => { + const state = await fixture(page); + state.connected = true; + state.providers.push({ + uuid: 'provider-1', + name: 'Connected Codex', + requester: 'openai-codex', + base_url: 'https://chatgpt.com/backend-api/codex', + api_keys: [], + }); + await page.goto('/home/bots'); + await page.getByRole('button', { name: 'Models', exact: true }).click(); + await page.getByRole('button', { name: 'Add Model', exact: true }).click(); + await page + .getByPlaceholder('Model Name', { exact: true }) + .fill('fixture-codex-model'); + const requestPromise = page.waitForRequest('**/models/llm/_/test'); + await page.getByRole('button', { name: 'Test', exact: true }).click(); + const payload = (await requestPromise).postDataJSON(); + expect(payload.provider_uuid).toBe('provider-1'); + expect(payload.provider.uuid).toBe('provider-1'); + expect(payload.provider.api_keys).toEqual([]); +}); + +test('ordinary API-key provider still saves and closes', async ({ page }) => { + const state = await fixture(page); + await openModels(page); + await page.locator('input[name="name"]').fill('My API'); + await choose(page, 'OpenAI API'); + await page.locator('input[name="api_key"]').fill('fixture-api-key-not-real'); + await page + .locator('input[name="base_url"]') + .fill('https://api.example.test/v1'); + await page.getByRole('button', { name: 'Save', exact: true }).click(); + await expect(page.getByText('My API', { exact: true })).toBeVisible(); + expect(state.providers[0]).toMatchObject({ + requester: 'openai', + api_keys: ['fixture-api-key-not-real'], + base_url: 'https://api.example.test/v1', + }); + expect(state.starts).toBe(0); +}); diff --git a/web/tests/e2e/fixtures/dynamic-form.html b/web/tests/e2e/fixtures/dynamic-form.html new file mode 100644 index 000000000..3b36b4581 --- /dev/null +++ b/web/tests/e2e/fixtures/dynamic-form.html @@ -0,0 +1,11 @@ + + + + + Dynamic form compatibility fixture + + +
+ + + diff --git a/web/tests/e2e/fixtures/dynamic-form.tsx b/web/tests/e2e/fixtures/dynamic-form.tsx new file mode 100644 index 000000000..35a8829ae --- /dev/null +++ b/web/tests/e2e/fixtures/dynamic-form.tsx @@ -0,0 +1,89 @@ +import { useEffect, useState } from 'react'; +import { createRoot } from 'react-dom/client'; +import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent'; +import { getBoxScopeContext } from '@/app/home/pipelines/components/pipeline-form/BoxScopeContext'; +import type { IDynamicFormItemSchema } from '@/app/infra/entities/form/dynamic'; +import scope from '../../fixtures/sandbox-scope-schema.json'; +import '@/app/global.css'; + +// Browser component fixture only. These legacy context keys are NOT supplied by +// PipelineForm in 4.11: actual execution scope remains owned by the Runner Host. +const params = new URLSearchParams(window.location.search); +const initialValues = { + [scope.name]: scope.default, + mode: 'live', + secret: '', + count: 3, +}; +const fields = [ + { ...scope, id: 'scope' }, + { + id: 'mode', + name: 'mode', + type: 'select', + label: { en_US: 'Mode' }, + default: 'live', + options: [ + { name: 'live', label: { en_US: 'Live' } }, + { name: 'hidden', label: { en_US: 'Hidden' } }, + ], + }, + { + id: 'secret', + name: 'secret', + type: 'secret', + label: { en_US: 'Plugin secret' }, + default: '', + show_if: { field: 'mode', operator: 'eq', value: 'live' }, + disable_if: { field: '__system.locked', operator: 'eq', value: true }, + disabled_tooltip: { en_US: 'Locked by context' }, + disabled_tooltip_overrides: [ + { + when: { field: 'mode', operator: 'in', value: ['live'] }, + tooltip: { en_US: 'Live reason wins' }, + }, + ], + }, + { + id: 'count', + name: 'count', + type: 'number', + label: { en_US: 'Plugin count' }, + default: 3, + show_if: { field: 'mode', operator: 'neq', value: 'hidden' }, + }, +] as IDynamicFormItemSchema[]; + +function Fixture() { + const [context, setContext] = useState>({ + ...getBoxScopeContext( + params.get('available') === 'true', + params.get('forced') || '', + ), + locked: false, + }); + const [saved, setSaved] = useState({}); + useEffect(() => { + const update = (event: Event) => + setContext((current) => ({ + ...current, + ...(event as CustomEvent>).detail, + })); + window.addEventListener('test-form-context', update); + return () => window.removeEventListener('test-form-context', update); + }, []); + return ( +
+ + {JSON.stringify(saved)} +
+ ); +} + +createRoot(document.getElementById('root')!).render(); diff --git a/web/tests/e2e/fixtures/langbot-api.ts b/web/tests/e2e/fixtures/langbot-api.ts index c6c137523..b297a2ca5 100644 --- a/web/tests/e2e/fixtures/langbot-api.ts +++ b/web/tests/e2e/fixtures/langbot-api.ts @@ -302,7 +302,7 @@ function makePipeline( }; } -function pipelineMetadata(withRunnerToolSelector = false) { +export function pipelineMetadata(withRunnerToolSelector = false) { const runnerId = 'plugin:langbot-team/LocalAgent/default'; return { configs: [ diff --git a/web/tests/e2e/fixtures/runner-migration-contract.json b/web/tests/e2e/fixtures/runner-migration-contract.json new file mode 100644 index 000000000..4998ea66e --- /dev/null +++ b/web/tests/e2e/fixtures/runner-migration-contract.json @@ -0,0 +1,5800 @@ +{ + "planner_version": "3", + "fixture_only": true, + "legacy_sections": { + "local-agent": { + "model": { + "primary": "synthetic-model", + "fallbacks": [] + }, + "max-round": 10, + "prompt": [ + { + "role": "system", + "content": "Synthetic prompt" + } + ], + "enable-all-tools": false, + "tools": [] + }, + "dify-service-api": { + "base-url": "https://dify.invalid/v1", + "api-key": "synthetic-key", + "app-type": "chat", + "base-prompt": "Synthetic prompt" + }, + "coze-api": { + "api-key": "synthetic-key", + "bot-id": "synthetic-bot", + "api-base": "https://api.coze.cn", + "auto_save_history": false, + "timeout": 120 + }, + "dashscope-app-api": { + "api-key": "synthetic-key", + "app-id": "synthetic-app", + "app-type": "agent", + "references_quote": "Synthetic references" + }, + "n8n-service-api": { + "webhook-url": "https://n8n.invalid/hook?token=synthetic", + "auth-type": "none", + "timeout": 120, + "output-key": "response", + "response-handling": "reply" + }, + "langflow-api": { + "base-url": "https://langflow.invalid", + "api-key": "synthetic-key", + "flow-id": "synthetic-flow", + "input_type": "chat", + "output_type": "chat", + "tweaks": "{}" + }, + "deerflow-api": { + "api-base": "https://deerflow.invalid", + "api-key": "synthetic-key", + "auth-header": "", + "assistant-id": "lead_agent", + "model-name": "synthetic-model", + "thinking-enabled": false, + "plan-mode": false, + "subagent-enabled": false, + "max-concurrent-subagents": 3, + "timeout": 300, + "recursion-limit": 1000 + }, + "tbox-app-api": { + "api-key": "synthetic-key", + "app-id": "synthetic-app" + }, + "weknora-api": { + "base-url": "https://weknora.invalid/api/v1", + "api-key": "synthetic-key", + "app-type": "agent", + "knowledge-base-ids": [ + "remote-kb" + ], + "web-search-enabled": false + } + }, + "plugins": { + "LocalAgent": { + "manifest": { + "apiVersion": "v1", + "kind": "Plugin", + "metadata": { + "author": "langbot-team", + "name": "LocalAgent", + "version": "0.1.7", + "repository": "https://github.com/langbot-app/langbot-plugins/tree/main/Runner/LocalAgent", + "label": { + "en_US": "Local Agent", + "zh_Hans": "内置 Agent", + "zh_Hant": "內建 Agent", + "ja_JP": "組み込みエージェント", + "th_TH": "เอเจนต์ในตัว", + "vi_VN": "Tác nhân tích hợp", + "es_ES": "Agente integrado", + "ru_RU": "Встроенный агент" + }, + "description": { + "en_US": "Built-in agent with model fallback, tools, and knowledge retrieval.", + "zh_Hans": "内置 Agent,支持模型 fallback、工具调用和知识库检索。", + "zh_Hant": "內建 Agent,支援模型備援、工具呼叫與知識庫檢索。", + "ja_JP": "モデルのフォールバック、ツール呼び出し、ナレッジ検索に対応する組み込みエージェントです。", + "th_TH": "เอเจนต์ในตัวที่รองรับโมเดลสำรอง การเรียกใช้เครื่องมือ และการค้นคืนความรู้", + "vi_VN": "Tác nhân tích hợp hỗ trợ mô hình dự phòng, gọi công cụ và truy xuất tri thức.", + "es_ES": "Agente integrado con modelos de respaldo, herramientas y recuperación de conocimiento.", + "ru_RU": "Встроенный агент с резервными моделями, инструментами и поиском по базе знаний." + }, + "icon": "assets/icon.svg" + }, + "spec": { + "config": [], + "components": { + "Runner": { + "fromDirs": [ + { + "path": "components/runner/" + } + ] + } + }, + "pages": [] + }, + "execution": { + "python": { + "path": "main.py", + "attr": "LocalAgentPlugin" + } + } + }, + "component": { + "apiVersion": "langbot/v1", + "kind": "Runner", + "metadata": { + "name": "default", + "label": { + "en_US": "Local Agent", + "zh_Hans": "本地 Agent" + }, + "description": { + "en_US": "Built-in agent with model fallback, tools, and knowledge retrieval.", + "zh_Hans": "内置 Agent,支持模型 fallback、工具调用和知识库检索。" + } + }, + "spec": { + "usages": [ + "agent" + ], + "config": [ + { + "name": "model", + "description": { + "en_US": "Primary/fallback model UUIDs and per-model reasoning levels. Host validates and applies reasoning for every request; this is per-pipeline configuration.", + "zh_Hans": "主模型/备用模型 UUID 与各模型推理等级。由 Host 对每次请求校验并应用,属于流水线独立配置。" + }, + "label": { + "en_US": "Model", + "zh_Hans": "模型" + }, + "type": "model-fallback-selector", + "required": true, + "default": { + "primary": "", + "fallbacks": [], + "reasoning": {} + } + }, + { + "name": "prompt", + "label": { + "en_US": "Prompt", + "zh_Hans": "提示词" + }, + "type": "prompt-editor", + "required": true, + "default": [ + { + "role": "system", + "content": "You are a helpful assistant." + } + ] + }, + { + "name": "knowledge-bases", + "label": { + "en_US": "Knowledge Bases", + "zh_Hans": "知识库" + }, + "type": "knowledge-base-multi-selector", + "required": false, + "default": [] + }, + { + "name": "box-enabled", + "label": { + "en_US": "Sandbox", + "zh_Hans": "沙箱" + }, + "description": { + "en_US": "Use Box for code execution and file processing.", + "zh_Hans": "使用 Box 执行代码和处理文件。" + }, + "type": "boolean", + "required": false, + "default": true + }, + { + "name": "box-session-id-template", + "label": { + "en_US": "Sandbox reuse", + "zh_Hans": "沙箱复用范围" + }, + "description": { + "en_US": "Choose how sandbox files and environments are shared. Custom templates substitute {variable_name}, for example {launcher_type}_{launcher_id}_{sender_id}. Equal results reuse the same sandbox; {global} shares one sandbox throughout the workspace. Available variables: {launcher_type}, {launcher_id}, {sender_id}, {conversation_id}, {bot_id}, {query_id}, {run_id}, {event_id}, {event_type}, and public request variables. Only named variables are supported, not expressions or attribute access; unavailable variables cause an error.", + "zh_Hans": "选择沙箱文件和环境的共享方式。自定义模板使用 {变量名} 插值,如 {launcher_type}_{launcher_id}_{sender_id};插值结果相同就复用同一个沙箱,{global} 表示在工作区内全局共享。可用变量:{launcher_type}(会话类型)、{launcher_id}(会话 ID)、{sender_id}(用户 ID)、{conversation_id}(对话上下文 ID)、{bot_id}(机器人 ID)、{query_id}(请求 ID)、{run_id}(运行 ID)、{event_id}(事件 ID)、{event_type}(事件类型),也可引用公开请求变量。仅支持变量名,不支持运算或属性访问;变量不可用时会报错。" + }, + "type": "select", + "required": true, + "default": "{launcher_type}_{launcher_id}", + "show_if": { + "field": "box-enabled", + "operator": "eq", + "value": true + }, + "allow_custom": true, + "options": [ + { + "name": "{global}", + "label": { + "en_US": "Global (shared by all)", + "zh_Hans": "全局(所有人共享)", + "zh_Hant": "全域(所有人共用)", + "ja_JP": "グローバル(全員共有)", + "vi_VN": "Toàn cục (chia sẻ cho tất cả)", + "th_TH": "ทั่วไป (แชร์ทั้งหมด)", + "es_ES": "Global (compartido por todos)", + "ru_RU": "Глобальный (общий для всех)" + } + }, + { + "name": "{launcher_type}_{launcher_id}", + "label": { + "en_US": "Per chat (Recommended)", + "zh_Hans": "每个会话(推荐)", + "zh_Hant": "每個會話(推薦)", + "ja_JP": "チャットごと(推奨)", + "vi_VN": "Mỗi cuộc trò chuyện (Khuyến nghị)", + "th_TH": "ต่อแชท (แนะนำ)", + "es_ES": "Por chat (Recomendado)", + "ru_RU": "По чату (Рекомендуется)" + } + }, + { + "name": "{launcher_type}_{launcher_id}_{sender_id}", + "label": { + "en_US": "Per user in chat", + "zh_Hans": "会话中每个用户", + "zh_Hant": "會話中每個用戶", + "ja_JP": "チャット内のユーザーごと", + "vi_VN": "Mỗi người dùng trong cuộc trò chuyện", + "th_TH": "ต่อผู้ใช้ในแชท", + "es_ES": "Por usuario en chat", + "ru_RU": "По пользователю в чате" + } + }, + { + "name": "{launcher_type}_{launcher_id}_{conversation_id}", + "label": { + "en_US": "Per conversation context", + "zh_Hans": "每个对话上下文", + "zh_Hant": "每個對話上下文", + "ja_JP": "会話コンテキストごと", + "vi_VN": "Mỗi ngữ cảnh hội thoại", + "th_TH": "ต่อบริบทการสนทนา", + "es_ES": "Por contexto de conversación", + "ru_RU": "По контексту разговора" + } + }, + { + "name": "{query_id}", + "label": { + "en_US": "Per message (isolated)", + "zh_Hans": "每条消息(完全隔离)", + "zh_Hant": "每條訊息(完全隔離)", + "ja_JP": "メッセージごと(隔離)", + "vi_VN": "Mỗi tin nhắn (cách ly)", + "th_TH": "ต่อข้อความ (แยกส่วน)", + "es_ES": "Por mensaje (aislado)", + "ru_RU": "По сообщению (изолированно)" + } + } + ] + }, + { + "name": "advanced-settings", + "label": { + "en_US": "Advanced Settings", + "zh_Hans": "高级设置" + }, + "description": { + "en_US": "Show tuning controls for retrieval, tools, timeouts, and context management.", + "zh_Hans": "显示检索、工具、超时和上下文管理的调优选项。" + }, + "type": "boolean", + "required": false, + "default": false + }, + { + "name": "date-grounding", + "label": { + "en_US": "Current Date Grounding", + "zh_Hans": "当前日期锚定" + }, + "description": { + "en_US": "Add the current UTC date and a reminder to verify time-sensitive facts with available search tools.", + "zh_Hans": "注入当前 UTC 日期,并提醒使用可用搜索工具核实有时效性的信息。" + }, + "type": "boolean", + "required": false, + "default": true, + "show_if": { + "field": "advanced-settings", + "operator": "eq", + "value": true + } + }, + { + "name": "timeout", + "label": { + "en_US": "Timeout", + "zh_Hans": "执行超时" + }, + "type": "integer", + "required": false, + "default": 300, + "show_if": { + "field": "advanced-settings", + "operator": "eq", + "value": true + } + }, + { + "name": "remove-think", + "label": { + "en_US": "Remove Thinking Output", + "zh_Hans": "移除思考内容" + }, + "type": "boolean", + "required": false, + "default": false, + "show_if": { + "field": "advanced-settings", + "operator": "eq", + "value": true + } + }, + { + "name": "retrieval-top-k", + "label": { + "en_US": "Retrieval Top K", + "zh_Hans": "知识库检索数量" + }, + "type": "integer", + "required": false, + "default": 5, + "show_if": { + "field": "advanced-settings", + "operator": "eq", + "value": true + } + }, + { + "name": "rerank-model", + "label": { + "en_US": "Rerank Model", + "zh_Hans": "重排序模型" + }, + "type": "rerank-model-selector", + "required": false, + "default": "", + "show_if": { + "field": "advanced-settings", + "operator": "eq", + "value": true + } + }, + { + "name": "rerank-top-k", + "label": { + "en_US": "Rerank Top K", + "zh_Hans": "重排序保留数量" + }, + "type": "integer", + "required": false, + "default": 5, + "show_if": { + "field": "advanced-settings", + "operator": "eq", + "value": true + } + }, + { + "name": "max-tool-iterations", + "label": { + "en_US": "Max Tool Iterations", + "zh_Hans": "最大工具调用轮数" + }, + "type": "integer", + "required": false, + "default": 100, + "show_if": { + "field": "advanced-settings", + "operator": "eq", + "value": true + } + }, + { + "name": "tool-execution-mode", + "description": { + "en_US": "Parallel is faster for independent tools. Use serial for dependent or side-effecting actions; result order does not guarantee execution order.", + "zh_Hans": "并行适合独立工具;有依赖或副作用的操作请选择串行。结果排列不保证执行顺序。" + }, + "label": { + "en_US": "Tool Execution Mode", + "zh_Hans": "工具执行模式" + }, + "type": "select", + "required": false, + "default": "parallel", + "options": [ + { + "name": "parallel", + "label": { + "en_US": "Parallel", + "zh_Hans": "并行" + } + }, + { + "name": "serial", + "label": { + "en_US": "Serial", + "zh_Hans": "串行" + } + } + ], + "show_if": { + "field": "advanced-settings", + "operator": "eq", + "value": true + } + }, + { + "name": "max-tool-result-chars", + "label": { + "en_US": "Max Tool Result Characters", + "zh_Hans": "最大工具结果字符数" + }, + "type": "integer", + "required": false, + "default": 20000, + "show_if": { + "field": "advanced-settings", + "operator": "eq", + "value": true + } + }, + { + "name": "context-history-fetch-limit", + "label": { + "en_US": "History Fetch Limit", + "zh_Hans": "历史消息拉取数量" + }, + "type": "integer", + "required": false, + "default": 50, + "show_if": { + "field": "advanced-settings", + "operator": "eq", + "value": true + } + }, + { + "name": "context-window-tokens", + "label": { + "en_US": "Context Window Tokens", + "zh_Hans": "上下文窗口 Token 数" + }, + "type": "integer", + "required": false, + "default": 200000, + "show_if": { + "field": "advanced-settings", + "operator": "eq", + "value": true + } + }, + { + "name": "context-reserve-tokens", + "label": { + "en_US": "Reserved Output Tokens", + "zh_Hans": "输出保留 Token 数" + }, + "type": "integer", + "required": false, + "default": 16384, + "show_if": { + "field": "advanced-settings", + "operator": "eq", + "value": true + } + }, + { + "name": "context-keep-recent-tokens", + "label": { + "en_US": "Recent Context Tokens", + "zh_Hans": "最近上下文 Token 数" + }, + "type": "integer", + "required": false, + "default": 20000, + "show_if": { + "field": "advanced-settings", + "operator": "eq", + "value": true + } + }, + { + "name": "context-summary-tokens", + "label": { + "en_US": "Summary Tokens", + "zh_Hans": "摘要 Token 数" + }, + "type": "integer", + "required": false, + "default": 8000, + "show_if": { + "field": "advanced-settings", + "operator": "eq", + "value": true + } + } + ], + "capabilities": { + "streaming": true, + "tool_calling": true, + "knowledge_retrieval": true, + "multimodal_input": true, + "skill_authoring": true, + "interrupt": true, + "steering": true + }, + "permissions": { + "models": [ + "count_tokens", + "invoke", + "stream", + "rerank" + ], + "tools": [ + "detail", + "call" + ], + "knowledge_bases": [ + "list", + "retrieve" + ], + "history": [ + "page" + ] + } + }, + "execution": { + "python": { + "path": "default.py", + "attr": "DefaultRunner" + } + } + }, + "package_sha256": "52780785960722cbc0f2a9335c48a852cf631a32e4d04ce1e1ff601af7c0b599", + "schema_sha256": "cdb336b8655b53dec482cfe9e191d0ed1d409929423584e5848255aabe499338" + }, + "dify-agent": { + "manifest": { + "apiVersion": "v1", + "kind": "Plugin", + "metadata": { + "author": "langbot-team", + "name": "DifyAgent", + "version": "0.1.7", + "repository": "https://github.com/langbot-app/langbot-plugins/tree/main/Runner/dify-agent", + "label": { + "en_US": "Dify Agent", + "zh_Hans": "Dify Agent", + "zh_Hant": "Dify Agent", + "ja_JP": "Dify Agent", + "th_TH": "Dify Agent", + "vi_VN": "Dify Agent", + "es_ES": "Agente Dify", + "ru_RU": "Агент Dify" + }, + "description": { + "en_US": "Run a Dify application as a LangBot Runner.", + "zh_Hans": "将 Dify 应用作为 LangBot 运行器运行。", + "zh_Hant": "將 Dify 應用程式作為 LangBot 運行器執行。", + "ja_JP": "Dify アプリを LangBot Runner として実行します。", + "th_TH": "เรียกใช้แอป Dify เป็น LangBot Runner", + "vi_VN": "Chạy ứng dụng Dify dưới dạng LangBot Runner.", + "es_ES": "Ejecuta una aplicación de Dify como LangBot Runner.", + "ru_RU": "Запускает приложение Dify как LangBot Runner." + }, + "icon": "assets/icon.svg" + }, + "spec": { + "config": [], + "components": { + "Runner": { + "fromDirs": [ + { + "path": "components/runner/" + } + ] + } + }, + "pages": [] + }, + "execution": { + "python": { + "path": "main.py", + "attr": "DifyAgentPlugin" + } + } + }, + "component": { + "apiVersion": "langbot/v1", + "kind": "Runner", + "metadata": { + "name": "default", + "label": { + "en_US": "Dify", + "zh_Hans": "Dify" + }, + "description": { + "en_US": "Run a Dify application as a LangBot Runner.", + "zh_Hans": "将 Dify 应用作为 LangBot 运行器运行。" + } + }, + "spec": { + "usages": [ + "agent" + ], + "config": [ + { + "name": "user-id-source", + "label": { + "en_US": "Provider User Identity", + "zh_Hans": "供应商用户身份" + }, + "description": { + "en_US": "Sender is the default. Legacy mode requires trusted Host identity; missing identity fails closed. Does not import old sessions.", + "zh_Hans": "默认使用发送者身份。兼容模式需要可信 Host 身份,缺失时拒绝请求;不会导入旧会话。" + }, + "type": "select", + "required": false, + "default": "sender", + "options": [ + { + "name": "sender", + "label": { + "en_US": "Sender", + "zh_Hans": "发送者" + } + }, + { + "name": "legacy-session", + "label": { + "en_US": "Native identity compatibility", + "zh_Hans": "原生身份兼容" + } + } + ] + }, + { + "name": "base-url", + "label": { + "en_US": "Base URL", + "zh_Hans": "基础 URL" + }, + "type": "string", + "description": { + "en_US": "Dify Service API base URL. Keep the /v1 suffix for Dify Cloud and standard self-hosted deployments.", + "zh_Hans": "Dify Service API 基础地址。Dify Cloud 和标准自托管部署请保留 /v1 后缀。" + }, + "required": true, + "default": "https://api.dify.ai/v1" + }, + { + "name": "advanced-settings", + "label": { + "en_US": "Advanced Settings", + "zh_Hans": "高级设置" + }, + "description": { + "en_US": "Show prompt and timeout tuning controls.", + "zh_Hans": "显示提示词和超时调优选项。" + }, + "type": "boolean", + "required": false, + "default": false + }, + { + "name": "base-prompt", + "label": { + "en_US": "Base PROMPT", + "zh_Hans": "基础提示词" + }, + "type": "text", + "description": { + "en_US": "Fallback instruction when LangBot input is empty, for example when only a file is sent.", + "zh_Hans": "LangBot 文本输入为空时使用的回退指令,例如仅发送文件时。" + }, + "required": true, + "default": "When the file content is readable, please read the content of this file. When the file is an image, describe the content of this image.", + "show_if": { + "field": "advanced-settings", + "operator": "eq", + "value": true + } + }, + { + "name": "app-type", + "label": { + "en_US": "App Type", + "zh_Hans": "应用类型" + }, + "type": "select", + "description": { + "en_US": "Must match the Dify application type that issued the Service API key.", + "zh_Hans": "必须与生成 Service API 密钥的 Dify 应用类型一致。" + }, + "required": true, + "default": "chat", + "options": [ + { + "name": "chat", + "label": { + "en_US": "Chat", + "zh_Hans": "聊天(包括Chatflow)" + } + }, + { + "name": "chatflow", + "label": { + "en_US": "Chatflow", + "zh_Hans": "对话流" + } + }, + { + "name": "agent", + "label": { + "en_US": "Agent", + "zh_Hans": "Agent" + } + }, + { + "name": "workflow", + "label": { + "en_US": "Workflow", + "zh_Hans": "工作流" + } + } + ] + }, + { + "name": "api-key", + "label": { + "en_US": "API Key", + "zh_Hans": "API 密钥" + }, + "type": "secret", + "description": { + "en_US": "Service API key from the selected Dify application, not a console login token.", + "zh_Hans": "所选 Dify 应用的 Service API 密钥,不是控制台登录令牌。" + }, + "required": true, + "default": "" + }, + { + "name": "timeout", + "label": { + "en_US": "Timeout", + "zh_Hans": "超时时间" + }, + "type": "integer", + "description": { + "en_US": "Maximum time in seconds for a Dify request. Use at least 60 seconds when a proxy or long workflow is involved.", + "zh_Hans": "Dify 请求的最长等待秒数。使用代理或长工作流时建议至少设置为 60 秒。" + }, + "required": false, + "default": 30, + "show_if": { + "field": "advanced-settings", + "operator": "eq", + "value": true + } + }, + { + "name": "remove-think", + "label": { + "en_US": "Hide Thinking Content", + "zh_Hans": "隐藏思考内容" + }, + "description": { + "en_US": "Remove think-tagged reasoning from responses, including unfinished streamed reasoning.", + "zh_Hans": "从回复中移除 think 标签内的推理内容,包括尚未结束的流式思考。" + }, + "type": "boolean", + "required": false, + "default": false, + "show_if": { + "field": "advanced-settings", + "operator": "eq", + "value": true + } + }, + { + "name": "langbot-assets-enabled", + "label": { + "en_US": "Enable LangBot Assets", + "zh_Hans": "启用 LangBot 资产" + }, + "type": "boolean", + "description": { + "en_US": "Advanced. Expose run-scoped LangBot assets to Dify through a temporary token gateway.", + "zh_Hans": "高级选项。通过临时令牌网关向 Dify 暴露本次运行范围内的 LangBot 资产。" + }, + "required": false, + "default": false + }, + { + "name": "langbot-assets-gateway-host", + "label": { + "en_US": "LangBot Asset Gateway Host", + "zh_Hans": "LangBot 资产网关监听地址" + }, + "type": "string", + "required": false, + "default": "0.0.0.0", + "show_if": { + "field": "langbot-assets-enabled", + "operator": "eq", + "value": true + } + }, + { + "name": "langbot-assets-gateway-port", + "label": { + "en_US": "LangBot Asset Gateway Port", + "zh_Hans": "LangBot 资产网关端口" + }, + "type": "integer", + "required": false, + "default": 8765, + "show_if": { + "field": "langbot-assets-enabled", + "operator": "eq", + "value": true + } + }, + { + "name": "langbot-assets-gateway-request-timeout", + "label": { + "en_US": "LangBot Asset Gateway Request Timeout", + "zh_Hans": "LangBot 资产网关请求超时" + }, + "type": "integer", + "required": false, + "default": 60, + "show_if": { + "field": "langbot-assets-enabled", + "operator": "eq", + "value": true + } + }, + { + "name": "langbot-assets-token-ttl", + "label": { + "en_US": "LangBot Asset Token TTL", + "zh_Hans": "LangBot 资产令牌有效期" + }, + "type": "integer", + "required": false, + "default": 3600, + "show_if": { + "field": "langbot-assets-enabled", + "operator": "eq", + "value": true + } + }, + { + "name": "langbot-assets-input-name", + "label": { + "en_US": "LangBot Asset Token Input Name", + "zh_Hans": "LangBot 资产令牌输入变量名" + }, + "type": "string", + "required": false, + "default": "langbot_asset_run_token", + "show_if": { + "field": "langbot-assets-enabled", + "operator": "eq", + "value": true + } + } + ], + "capabilities": { + "streaming": true, + "tool_calling": true, + "knowledge_retrieval": true, + "multimodal_input": true, + "interrupt": false, + "interactions": true + }, + "permissions": { + "tools": [ + "detail", + "call" + ], + "knowledge_bases": [ + "retrieve" + ], + "history": [ + "page" + ], + "storage": [ + "plugin" + ], + "interactions": [ + "request" + ] + } + }, + "execution": { + "python": { + "path": "default.py", + "attr": "DefaultRunner" + } + } + }, + "package_sha256": "6fc89a8d127b338b6f8d2deca78001b7dbaccfe9862c88c0d2ce95bd1df1ebb2", + "schema_sha256": "dccc4b7df7730c4e5e2cfc2ca2e67db5ea9195ad9720a79a329bc11fdcaeb840" + }, + "coze-agent": { + "manifest": { + "apiVersion": "v1", + "kind": "Plugin", + "metadata": { + "author": "langbot-team", + "name": "CozeAgent", + "version": "0.1.7", + "repository": "https://github.com/langbot-app/langbot-plugins/tree/main/Runner/coze-agent", + "label": { + "en_US": "Coze Agent", + "zh_Hans": "扣子 Agent", + "zh_Hant": "Coze Agent", + "ja_JP": "Coze Agent", + "th_TH": "Coze Agent", + "vi_VN": "Coze Agent", + "es_ES": "Agente Coze", + "ru_RU": "Агент Coze" + }, + "description": { + "en_US": "Run a Coze bot as a LangBot Runner.", + "zh_Hans": "将扣子机器人作为 LangBot 运行器运行。", + "zh_Hant": "將 Coze 機器人作為 LangBot 運行器執行。", + "ja_JP": "Coze ボットを LangBot Runner として実行します。", + "th_TH": "เรียกใช้บอต Coze เป็น LangBot Runner", + "vi_VN": "Chạy bot Coze dưới dạng LangBot Runner.", + "es_ES": "Ejecuta un bot de Coze como LangBot Runner.", + "ru_RU": "Запускает бота Coze как LangBot Runner." + }, + "icon": "assets/icon.svg" + }, + "spec": { + "config": [], + "components": { + "Runner": { + "fromDirs": [ + { + "path": "components/runner/" + } + ] + } + }, + "pages": [] + }, + "execution": { + "python": { + "path": "main.py", + "attr": "CozeAgentPlugin" + } + } + }, + "component": { + "apiVersion": "langbot/v1", + "kind": "Runner", + "metadata": { + "name": "default", + "label": { + "en_US": "Coze", + "zh_Hans": "扣子" + }, + "description": { + "en_US": "Run a Coze bot as a LangBot Runner.", + "zh_Hans": "将扣子机器人作为 LangBot 运行器运行。" + } + }, + "spec": { + "usages": [ + "agent" + ], + "config": [ + { + "name": "user-id-source", + "label": { + "en_US": "Provider User Identity", + "zh_Hans": "供应商用户身份" + }, + "description": { + "en_US": "Sender is the default. Legacy mode requires trusted Host identity; missing identity fails closed. Does not import old sessions.", + "zh_Hans": "默认使用发送者身份。兼容模式需要可信 Host 身份,缺失时拒绝请求;不会导入旧会话。" + }, + "type": "select", + "required": false, + "default": "sender", + "options": [ + { + "name": "sender", + "label": { + "en_US": "Sender", + "zh_Hans": "发送者" + } + }, + { + "name": "legacy-session", + "label": { + "en_US": "Native identity compatibility", + "zh_Hans": "原生身份兼容" + } + } + ] + }, + { + "name": "remove-think", + "label": { + "en_US": "Hide reasoning", + "zh_Hans": "隐藏思考过程" + }, + "description": { + "en_US": "Hide reasoning fields and think blocks without removing answer or tool content.", + "zh_Hans": "隐藏推理字段和思考块,保留正文和工具内容。" + }, + "type": "boolean", + "required": false, + "default": false + }, + { + "name": "api-key", + "label": { + "en_US": "API Key", + "zh_Hans": "API 密钥" + }, + "type": "secret", + "required": true, + "default": "" + }, + { + "name": "bot-id", + "label": { + "en_US": "Bot ID", + "zh_Hans": "机器人 ID" + }, + "type": "string", + "required": true, + "default": "" + }, + { + "name": "api-base", + "label": { + "en_US": "API Base URL", + "zh_Hans": "API 基础 URL" + }, + "type": "string", + "required": true, + "default": "https://api.coze.cn", + "description": { + "en_US": "Coze China, Global, or a trusted custom HTTP(S) endpoint (including a path prefix).", + "zh_Hans": "扣子中国版、全球版或可信的自定义 HTTP(S) 地址(支持路径前缀)。" + } + }, + { + "name": "advanced-settings", + "label": { + "en_US": "Advanced Settings", + "zh_Hans": "高级设置" + }, + "description": { + "en_US": "Show history and timeout tuning controls.", + "zh_Hans": "显示历史和超时调优选项。" + }, + "type": "boolean", + "required": false, + "default": false + }, + { + "name": "auto-save-history", + "label": { + "en_US": "Auto Save History", + "zh_Hans": "自动保存历史" + }, + "type": "boolean", + "required": false, + "default": true, + "show_if": { + "field": "advanced-settings", + "operator": "eq", + "value": true + } + }, + { + "name": "timeout", + "label": { + "en_US": "Request Timeout", + "zh_Hans": "请求超时" + }, + "type": "number", + "required": false, + "default": 120, + "show_if": { + "field": "advanced-settings", + "operator": "eq", + "value": true + } + }, + { + "name": "langbot-assets-enabled", + "label": { + "en_US": "Enable LangBot Assets", + "zh_Hans": "启用 LangBot 资产" + }, + "type": "boolean", + "required": false, + "default": false + }, + { + "name": "langbot-assets-gateway-host", + "label": { + "en_US": "LangBot Asset Gateway Host", + "zh_Hans": "LangBot 资产网关监听地址" + }, + "type": "string", + "required": false, + "default": "0.0.0.0", + "show_if": { + "field": "langbot-assets-enabled", + "operator": "eq", + "value": true + } + }, + { + "name": "langbot-assets-gateway-port", + "label": { + "en_US": "LangBot Asset Gateway Port", + "zh_Hans": "LangBot 资产网关端口" + }, + "type": "integer", + "required": false, + "default": 8765, + "show_if": { + "field": "langbot-assets-enabled", + "operator": "eq", + "value": true + } + }, + { + "name": "langbot-assets-gateway-request-timeout", + "label": { + "en_US": "LangBot Asset Gateway Request Timeout", + "zh_Hans": "LangBot 资产网关请求超时" + }, + "type": "integer", + "required": false, + "default": 60, + "show_if": { + "field": "langbot-assets-enabled", + "operator": "eq", + "value": true + } + }, + { + "name": "langbot-assets-token-ttl", + "label": { + "en_US": "LangBot Asset Token TTL", + "zh_Hans": "LangBot 资产令牌有效期" + }, + "type": "integer", + "required": false, + "default": 3600, + "show_if": { + "field": "langbot-assets-enabled", + "operator": "eq", + "value": true + } + }, + { + "name": "langbot-assets-input-name", + "label": { + "en_US": "LangBot Asset Token Input Name", + "zh_Hans": "LangBot 资产令牌输入变量名" + }, + "type": "string", + "required": false, + "default": "langbot_asset_run_token", + "show_if": { + "field": "langbot-assets-enabled", + "operator": "eq", + "value": true + } + } + ], + "capabilities": { + "streaming": true, + "tool_calling": true, + "knowledge_retrieval": true, + "multimodal_input": true, + "interrupt": false + }, + "permissions": { + "tools": [ + "detail", + "call" + ], + "knowledge_bases": [ + "retrieve" + ], + "history": [ + "page" + ], + "storage": [ + "plugin" + ] + } + }, + "execution": { + "python": { + "path": "default.py", + "attr": "DefaultRunner" + } + } + }, + "package_sha256": "c8e48659a0358bd8291137379f6159e61f2b1dbe72766772d81c9af49f7a8317", + "schema_sha256": "3f7c4d4e5c8a7ace3e804f0ca02739c48439abcc8f360bfc17394b9cf6660674" + }, + "dashscope-agent": { + "manifest": { + "apiVersion": "v1", + "kind": "Plugin", + "metadata": { + "author": "langbot-team", + "name": "DashScopeAgent", + "version": "0.1.7", + "repository": "https://github.com/langbot-app/langbot-plugins/tree/main/Runner/dashscope-agent", + "label": { + "en_US": "Dashscope Agent", + "zh_Hans": "百炼 Agent", + "zh_Hant": "DashScope Agent", + "ja_JP": "DashScope Agent", + "th_TH": "DashScope Agent", + "vi_VN": "DashScope Agent", + "es_ES": "Agente DashScope", + "ru_RU": "Агент DashScope" + }, + "description": { + "en_US": "Run an Aliyun DashScope application as a LangBot Runner.", + "zh_Hans": "将阿里云百炼应用作为 LangBot 运行器运行。", + "zh_Hant": "將阿里雲 DashScope 應用程式作為 LangBot 運行器執行。", + "ja_JP": "Alibaba Cloud DashScope アプリを LangBot Runner として実行します。", + "th_TH": "เรียกใช้แอป Aliyun DashScope เป็น LangBot Runner", + "vi_VN": "Chạy ứng dụng Aliyun DashScope dưới dạng LangBot Runner.", + "es_ES": "Ejecuta una aplicación de Aliyun DashScope como LangBot Runner.", + "ru_RU": "Запускает приложение Aliyun DashScope как LangBot Runner." + }, + "icon": "assets/icon.png" + }, + "spec": { + "config": [], + "components": { + "Runner": { + "fromDirs": [ + { + "path": "components/runner/" + } + ] + } + }, + "pages": [] + }, + "execution": { + "python": { + "path": "main.py", + "attr": "DashScopeAgentPlugin" + } + } + }, + "component": { + "apiVersion": "langbot/v1", + "kind": "Runner", + "metadata": { + "name": "default", + "label": { + "en_US": "DashScope", + "zh_Hans": "百炼" + }, + "description": { + "en_US": "Run an Aliyun DashScope application as a LangBot Runner.", + "zh_Hans": "将阿里云百炼应用作为 LangBot 运行器运行。" + } + }, + "spec": { + "usages": [ + "agent" + ], + "config": [ + { + "name": "remove-think", + "label": { + "en_US": "Hide reasoning", + "zh_Hans": "隐藏思考过程" + }, + "description": { + "en_US": "Hide reasoning fields and think blocks without removing answer or tool content.", + "zh_Hans": "隐藏推理字段和思考块,保留正文和工具内容。" + }, + "type": "boolean", + "required": false, + "default": false + }, + { + "name": "app-type", + "label": { + "en_US": "App Type", + "zh_Hans": "应用类型" + }, + "type": "select", + "required": true, + "default": "agent", + "options": [ + { + "name": "agent", + "label": { + "en_US": "Agent", + "zh_Hans": "Agent" + } + }, + { + "name": "workflow", + "label": { + "en_US": "Workflow", + "zh_Hans": "工作流" + } + } + ] + }, + { + "name": "api-key", + "label": { + "en_US": "API Key", + "zh_Hans": "API 密钥" + }, + "type": "secret", + "required": true, + "default": "" + }, + { + "name": "app-id", + "label": { + "en_US": "App ID", + "zh_Hans": "应用 ID" + }, + "type": "string", + "required": true, + "default": "" + }, + { + "name": "advanced-settings", + "label": { + "en_US": "Advanced Settings", + "zh_Hans": "高级设置" + }, + "description": { + "en_US": "Show reference formatting and timeout tuning controls.", + "zh_Hans": "显示引用格式和超时调优选项。" + }, + "type": "boolean", + "required": false, + "default": false + }, + { + "name": "references_quote", + "label": { + "en_US": "References Quote", + "zh_Hans": "引用文本" + }, + "type": "string", + "required": false, + "default": "参考资料来自:", + "show_if": { + "field": "advanced-settings", + "operator": "eq", + "value": true + } + }, + { + "name": "timeout", + "label": { + "en_US": "Timeout", + "zh_Hans": "超时时间" + }, + "type": "number", + "required": false, + "default": 120, + "show_if": { + "field": "advanced-settings", + "operator": "eq", + "value": true + } + }, + { + "name": "langbot-assets-enabled", + "label": { + "en_US": "Enable LangBot Assets", + "zh_Hans": "启用 LangBot 资产" + }, + "type": "boolean", + "required": false, + "default": false + }, + { + "name": "langbot-assets-gateway-host", + "label": { + "en_US": "LangBot Asset Gateway Host", + "zh_Hans": "LangBot 资产网关监听地址" + }, + "type": "string", + "required": false, + "default": "0.0.0.0", + "show_if": { + "field": "langbot-assets-enabled", + "operator": "eq", + "value": true + } + }, + { + "name": "langbot-assets-gateway-port", + "label": { + "en_US": "LangBot Asset Gateway Port", + "zh_Hans": "LangBot 资产网关端口" + }, + "type": "integer", + "required": false, + "default": 8765, + "show_if": { + "field": "langbot-assets-enabled", + "operator": "eq", + "value": true + } + }, + { + "name": "langbot-assets-gateway-request-timeout", + "label": { + "en_US": "LangBot Asset Gateway Request Timeout", + "zh_Hans": "LangBot 资产网关请求超时" + }, + "type": "integer", + "required": false, + "default": 60, + "show_if": { + "field": "langbot-assets-enabled", + "operator": "eq", + "value": true + } + }, + { + "name": "langbot-assets-token-ttl", + "label": { + "en_US": "LangBot Asset Token TTL", + "zh_Hans": "LangBot 资产令牌有效期" + }, + "type": "integer", + "required": false, + "default": 3600, + "show_if": { + "field": "langbot-assets-enabled", + "operator": "eq", + "value": true + } + }, + { + "name": "langbot-assets-input-name", + "label": { + "en_US": "LangBot Asset Token Input Name", + "zh_Hans": "LangBot 资产令牌输入变量名" + }, + "type": "string", + "required": false, + "default": "langbot_asset_run_token", + "show_if": { + "field": "langbot-assets-enabled", + "operator": "eq", + "value": true + } + } + ], + "capabilities": { + "streaming": true, + "tool_calling": true, + "knowledge_retrieval": true, + "multimodal_input": false, + "interrupt": false + }, + "permissions": { + "tools": [ + "detail", + "call" + ], + "knowledge_bases": [ + "retrieve" + ], + "history": [ + "page" + ], + "storage": [ + "plugin" + ] + } + }, + "execution": { + "python": { + "path": "default.py", + "attr": "DefaultRunner" + } + } + }, + "package_sha256": "cd4e28e53eb986b906d6aa92d26b49d8ad4a9514fcc764f359bbdb84ad1a3f00", + "schema_sha256": "aadfce3fe0157b124235437f251c24fbfea0e498ece4be8f9de6028b97573937" + }, + "n8n-agent": { + "manifest": { + "apiVersion": "v1", + "kind": "Plugin", + "metadata": { + "author": "langbot-team", + "name": "N8nAgent", + "version": "0.1.7", + "repository": "https://github.com/langbot-app/langbot-plugins/tree/main/Runner/n8n-agent", + "label": { + "en_US": "N8n Agent", + "zh_Hans": "n8n 工作流 Agent", + "zh_Hant": "n8n 工作流程 Agent", + "ja_JP": "n8n ワークフローエージェント", + "th_TH": "เอเจนต์เวิร์กโฟลว์ n8n", + "vi_VN": "Tác nhân quy trình n8n", + "es_ES": "Agente de flujo n8n", + "ru_RU": "Агент рабочих процессов n8n" + }, + "description": { + "en_US": "Run an n8n workflow webhook as a LangBot Runner.", + "zh_Hans": "将 n8n 工作流 webhook 作为 LangBot 运行器运行。", + "zh_Hant": "將 n8n 工作流程 Webhook 作為 LangBot 運行器執行。", + "ja_JP": "n8n ワークフローの Webhook を LangBot Runner として実行します。", + "th_TH": "เรียกใช้เว็บฮุกเวิร์กโฟลว์ n8n เป็น LangBot Runner", + "vi_VN": "Chạy webhook quy trình n8n dưới dạng LangBot Runner.", + "es_ES": "Ejecuta un webhook de flujo de n8n como LangBot Runner.", + "ru_RU": "Запускает webhook рабочего процесса n8n как LangBot Runner." + }, + "icon": "assets/icon.svg" + }, + "spec": { + "config": [], + "components": { + "Runner": { + "fromDirs": [ + { + "path": "components/runner/" + } + ] + } + }, + "pages": [] + }, + "execution": { + "python": { + "path": "main.py", + "attr": "N8nAgentPlugin" + } + } + }, + "component": { + "apiVersion": "langbot/v1", + "kind": "Runner", + "metadata": { + "name": "default", + "label": { + "en_US": "n8n", + "zh_Hans": "n8n" + }, + "description": { + "en_US": "Run an n8n workflow webhook as a LangBot Runner.", + "zh_Hans": "将 n8n 工作流 webhook 作为 LangBot 运行器运行。" + } + }, + "spec": { + "usages": [ + "agent" + ], + "config": [ + { + "name": "user-id-source", + "label": { + "en_US": "Provider User Identity", + "zh_Hans": "供应商用户身份" + }, + "description": { + "en_US": "Sender is the default. Legacy mode requires trusted Host identity; missing identity fails closed. Does not import old sessions.", + "zh_Hans": "默认使用发送者身份。兼容模式需要可信 Host 身份,缺失时拒绝请求;不会导入旧会话。" + }, + "type": "select", + "required": false, + "default": "sender", + "options": [ + { + "name": "sender", + "label": { + "en_US": "Sender", + "zh_Hans": "发送者" + } + }, + { + "name": "legacy-session", + "label": { + "en_US": "Native identity compatibility", + "zh_Hans": "原生身份兼容" + } + } + ] + }, + { + "name": "webhook-url", + "label": { + "en_US": "Webhook URL", + "zh_Hans": "Webhook URL" + }, + "type": "string", + "required": true, + "default": "" + }, + { + "name": "auth-type", + "label": { + "en_US": "Authentication Type", + "zh_Hans": "认证类型" + }, + "type": "select", + "required": true, + "default": "none", + "options": [ + { + "name": "none", + "label": { + "en_US": "None", + "zh_Hans": "无认证" + } + }, + { + "name": "basic", + "label": { + "en_US": "Basic Auth", + "zh_Hans": "基本认证" + } + }, + { + "name": "jwt", + "label": { + "en_US": "JWT", + "zh_Hans": "JWT认证" + } + }, + { + "name": "header", + "label": { + "en_US": "Header Auth", + "zh_Hans": "请求头认证" + } + } + ] + }, + { + "name": "basic-username", + "label": { + "en_US": "Username", + "zh_Hans": "用户名" + }, + "type": "string", + "required": false, + "default": "", + "show_if": { + "field": "auth-type", + "operator": "eq", + "value": "basic" + } + }, + { + "name": "basic-password", + "label": { + "en_US": "Password", + "zh_Hans": "密码" + }, + "type": "secret", + "required": false, + "default": "", + "show_if": { + "field": "auth-type", + "operator": "eq", + "value": "basic" + } + }, + { + "name": "basic-encoding", + "label": { + "en_US": "Basic Auth Encoding", + "zh_Hans": "Basic 凭据编码", + "ja_JP": "Basic 認証の文字コード" + }, + "description": { + "en_US": "UTF-8 preserves this plugin's default. Choose Latin-1 to preserve the native runner's non-ASCII Basic credentials.", + "zh_Hans": "UTF-8 保持本插件的默认行为。迁移原生运行器的非 ASCII Basic 凭据时,请选择 Latin-1。", + "ja_JP": "UTF-8 は本プラグインの既定動作を維持します。旧ネイティブ Runner の非 ASCII Basic 資格情報を引き継ぐ場合は Latin-1 を選択してください。" + }, + "type": "select", + "required": false, + "default": "utf-8", + "options": [ + { + "name": "utf-8", + "label": { + "en_US": "UTF-8", + "zh_Hans": "UTF-8", + "ja_JP": "UTF-8" + } + }, + { + "name": "latin1", + "label": { + "en_US": "Latin-1", + "zh_Hans": "Latin-1", + "ja_JP": "Latin-1" + } + } + ], + "show_if": { + "field": "auth-type", + "operator": "eq", + "value": "basic" + } + }, + { + "name": "jwt-secret", + "label": { + "en_US": "Secret", + "zh_Hans": "密钥" + }, + "type": "secret", + "required": false, + "default": "", + "show_if": { + "field": "auth-type", + "operator": "eq", + "value": "jwt" + } + }, + { + "name": "jwt-algorithm", + "label": { + "en_US": "Algorithm", + "zh_Hans": "算法" + }, + "type": "string", + "required": false, + "default": "HS256", + "show_if": { + "field": "auth-type", + "operator": "eq", + "value": "jwt" + } + }, + { + "name": "header-name", + "label": { + "en_US": "Header Name", + "zh_Hans": "请求头名称" + }, + "type": "string", + "required": false, + "default": "", + "show_if": { + "field": "auth-type", + "operator": "eq", + "value": "header" + } + }, + { + "name": "header-value", + "label": { + "en_US": "Header Value", + "zh_Hans": "请求头值" + }, + "type": "secret", + "required": false, + "default": "", + "show_if": { + "field": "auth-type", + "operator": "eq", + "value": "header" + } + }, + { + "name": "response-handling", + "label": { + "en_US": "Response Handling", + "zh_Hans": "响应处理", + "ja_JP": "レスポンスの処理" + }, + "description": { + "en_US": "Reply forwards HTTP 200 output. Ignore accepts any HTTP 2xx without reading or forwarding the body; configure the webhook to Respond Immediately. Run-scoped asset access ends as soon as the request returns.", + "zh_Hans": "回复模式转发 HTTP 200 的输出。忽略模式接受任意 HTTP 2xx,不读取或转发正文;请将 Webhook 配置为立即响应。请求返回后,本次运行的资产访问权限即失效。", + "ja_JP": "返信モードは HTTP 200 の出力を転送します。無視モードは HTTP 2xx を受け入れ、本文を読み取り・転送しません。Webhook を即時応答に設定してください。リクエストが戻ると実行単位のアセットアクセスは終了します。" + }, + "type": "select", + "required": false, + "default": "reply", + "options": [ + { + "name": "reply", + "label": { + "en_US": "Forward as chat reply", + "zh_Hans": "转发为聊天回复", + "ja_JP": "チャットの返信として転送" + } + }, + { + "name": "ignore", + "label": { + "en_US": "Ignore response body", + "zh_Hans": "忽略响应正文", + "ja_JP": "レスポンス本文を無視" + } + } + ] + }, + { + "name": "advanced-settings", + "label": { + "en_US": "Advanced Settings", + "zh_Hans": "高级设置" + }, + "description": { + "en_US": "Show timeout and response mapping controls.", + "zh_Hans": "显示超时和响应映射调优选项。" + }, + "type": "boolean", + "required": false, + "default": false + }, + { + "name": "timeout", + "label": { + "en_US": "Timeout", + "zh_Hans": "超时时间" + }, + "type": "integer", + "required": false, + "default": 120, + "show_if": { + "field": "advanced-settings", + "operator": "eq", + "value": true + } + }, + { + "name": "output-key", + "label": { + "en_US": "Output Key", + "zh_Hans": "输出键名" + }, + "type": "string", + "required": false, + "default": "response", + "show_if": { + "field": "advanced-settings", + "operator": "eq", + "value": true + } + }, + { + "name": "langbot-assets-enabled", + "label": { + "en_US": "Enable LangBot Assets", + "zh_Hans": "启用 LangBot 资产" + }, + "type": "boolean", + "required": false, + "default": false + }, + { + "name": "langbot-assets-gateway-host", + "label": { + "en_US": "LangBot Asset Gateway Host", + "zh_Hans": "LangBot 资产网关监听地址" + }, + "type": "string", + "required": false, + "default": "0.0.0.0", + "show_if": { + "field": "langbot-assets-enabled", + "operator": "eq", + "value": true + } + }, + { + "name": "langbot-assets-gateway-port", + "label": { + "en_US": "LangBot Asset Gateway Port", + "zh_Hans": "LangBot 资产网关端口" + }, + "type": "integer", + "required": false, + "default": 8765, + "show_if": { + "field": "langbot-assets-enabled", + "operator": "eq", + "value": true + } + }, + { + "name": "langbot-assets-gateway-request-timeout", + "label": { + "en_US": "LangBot Asset Gateway Request Timeout", + "zh_Hans": "LangBot 资产网关请求超时" + }, + "type": "integer", + "required": false, + "default": 60, + "show_if": { + "field": "langbot-assets-enabled", + "operator": "eq", + "value": true + } + }, + { + "name": "langbot-assets-token-ttl", + "label": { + "en_US": "LangBot Asset Token TTL", + "zh_Hans": "LangBot 资产令牌有效期" + }, + "type": "integer", + "required": false, + "default": 3600, + "show_if": { + "field": "langbot-assets-enabled", + "operator": "eq", + "value": true + } + }, + { + "name": "langbot-assets-input-name", + "label": { + "en_US": "LangBot Asset Token Input Name", + "zh_Hans": "LangBot 资产令牌输入变量名" + }, + "type": "string", + "required": false, + "default": "langbot_asset_run_token", + "show_if": { + "field": "langbot-assets-enabled", + "operator": "eq", + "value": true + } + } + ], + "capabilities": { + "streaming": true, + "tool_calling": true, + "knowledge_retrieval": true, + "multimodal_input": false, + "interrupt": false + }, + "permissions": { + "tools": [ + "detail", + "call" + ], + "knowledge_bases": [ + "retrieve" + ], + "history": [ + "page" + ], + "storage": [ + "plugin" + ] + } + }, + "execution": { + "python": { + "path": "default.py", + "attr": "DefaultRunner" + } + } + }, + "package_sha256": "507af9773690ff90c8ce2cdf8c3660ca01050e195eeb954cb631ea0116fe680c", + "schema_sha256": "9e492577b0e8d197fd792cd1456771e5ff1731ebf897a128c70f4850b4dfece0" + }, + "langflow-agent": { + "manifest": { + "apiVersion": "v1", + "kind": "Plugin", + "metadata": { + "author": "langbot-team", + "name": "LangflowAgent", + "version": "0.1.7", + "repository": "https://github.com/langbot-app/langbot-plugins/tree/main/Runner/langflow-agent", + "label": { + "en_US": "Langflow Agent", + "zh_Hans": "Langflow Agent", + "zh_Hant": "Langflow Agent", + "ja_JP": "Langflow Agent", + "th_TH": "Langflow Agent", + "vi_VN": "Langflow Agent", + "es_ES": "Agente Langflow", + "ru_RU": "Агент Langflow" + }, + "description": { + "en_US": "Run a Langflow flow as a LangBot Runner.", + "zh_Hans": "将 Langflow 流程作为 LangBot 运行器运行。", + "zh_Hant": "將 Langflow 流程作為 LangBot 運行器執行。", + "ja_JP": "Langflow フローを LangBot Runner として実行します。", + "th_TH": "เรียกใช้โฟลว์ Langflow เป็น LangBot Runner", + "vi_VN": "Chạy luồng Langflow dưới dạng LangBot Runner.", + "es_ES": "Ejecuta un flujo de Langflow como LangBot Runner.", + "ru_RU": "Запускает поток Langflow как LangBot Runner." + }, + "icon": "assets/icon.svg" + }, + "spec": { + "config": [], + "components": { + "Runner": { + "fromDirs": [ + { + "path": "components/runner/" + } + ] + } + }, + "pages": [] + }, + "execution": { + "python": { + "path": "main.py", + "attr": "LangflowAgentPlugin" + } + } + }, + "component": { + "apiVersion": "langbot/v1", + "kind": "Runner", + "metadata": { + "name": "default", + "label": { + "en_US": "Langflow", + "zh_Hans": "Langflow" + }, + "description": { + "en_US": "Run a Langflow flow as a LangBot Runner.", + "zh_Hans": "将 Langflow 流程作为 LangBot 运行器运行。" + } + }, + "spec": { + "usages": [ + "agent" + ], + "config": [ + { + "name": "base-url", + "label": { + "en_US": "Base URL", + "zh_Hans": "基础 URL" + }, + "type": "string", + "required": true, + "default": "http://localhost:7860" + }, + { + "name": "api-key", + "label": { + "en_US": "API Key", + "zh_Hans": "API 密钥" + }, + "type": "secret", + "required": true, + "default": "" + }, + { + "name": "flow-id", + "label": { + "en_US": "Flow ID", + "zh_Hans": "流程 ID" + }, + "type": "string", + "required": true, + "default": "" + }, + { + "name": "advanced-settings", + "label": { + "en_US": "Advanced Settings", + "zh_Hans": "高级设置" + }, + "description": { + "en_US": "Show input, output, and flow tweak controls.", + "zh_Hans": "显示输入、输出和流程 tweak 调优选项。" + }, + "type": "boolean", + "required": false, + "default": false + }, + { + "name": "input-type", + "label": { + "en_US": "Input Type", + "zh_Hans": "输入类型" + }, + "type": "string", + "required": false, + "default": "chat", + "show_if": { + "field": "advanced-settings", + "operator": "eq", + "value": true + } + }, + { + "name": "output-type", + "label": { + "en_US": "Output Type", + "zh_Hans": "输出类型" + }, + "type": "string", + "required": false, + "default": "chat", + "show_if": { + "field": "advanced-settings", + "operator": "eq", + "value": true + } + }, + { + "name": "tweaks", + "label": { + "en_US": "Tweaks", + "zh_Hans": "调整参数" + }, + "type": "json", + "required": false, + "default": "{}", + "show_if": { + "field": "advanced-settings", + "operator": "eq", + "value": true + } + }, + { + "name": "langbot-assets-enabled", + "label": { + "en_US": "Enable LangBot Assets", + "zh_Hans": "启用 LangBot 资产" + }, + "type": "boolean", + "required": false, + "default": false + }, + { + "name": "langbot-assets-gateway-host", + "label": { + "en_US": "LangBot Asset Gateway Host", + "zh_Hans": "LangBot 资产网关监听地址" + }, + "type": "string", + "required": false, + "default": "0.0.0.0", + "show_if": { + "field": "langbot-assets-enabled", + "operator": "eq", + "value": true + } + }, + { + "name": "langbot-assets-gateway-port", + "label": { + "en_US": "LangBot Asset Gateway Port", + "zh_Hans": "LangBot 资产网关端口" + }, + "type": "integer", + "required": false, + "default": 8765, + "show_if": { + "field": "langbot-assets-enabled", + "operator": "eq", + "value": true + } + }, + { + "name": "langbot-assets-gateway-request-timeout", + "label": { + "en_US": "LangBot Asset Gateway Request Timeout", + "zh_Hans": "LangBot 资产网关请求超时" + }, + "type": "integer", + "required": false, + "default": 60, + "show_if": { + "field": "langbot-assets-enabled", + "operator": "eq", + "value": true + } + }, + { + "name": "langbot-assets-token-ttl", + "label": { + "en_US": "LangBot Asset Token TTL", + "zh_Hans": "LangBot 资产令牌有效期" + }, + "type": "integer", + "required": false, + "default": 3600, + "show_if": { + "field": "langbot-assets-enabled", + "operator": "eq", + "value": true + } + }, + { + "name": "langbot-assets-input-name", + "label": { + "en_US": "LangBot Asset Token Input Name", + "zh_Hans": "LangBot 资产令牌输入组件名" + }, + "type": "string", + "required": false, + "default": "langbot_asset_run_token", + "show_if": { + "field": "langbot-assets-enabled", + "operator": "eq", + "value": true + } + } + ], + "capabilities": { + "streaming": true, + "tool_calling": true, + "knowledge_retrieval": true, + "multimodal_input": false, + "interrupt": false + }, + "permissions": { + "tools": [ + "detail", + "call" + ], + "knowledge_bases": [ + "retrieve" + ], + "history": [ + "page" + ], + "storage": [ + "plugin" + ] + } + }, + "execution": { + "python": { + "path": "default.py", + "attr": "DefaultRunner" + } + } + }, + "package_sha256": "93fee3064505d0a69d8b16d8aceb4a171ffdffed63a36278eef635267681f5a1", + "schema_sha256": "c7d940674bae9acab993801c6957da778bbdb3fa8734bfad9cda96dbb57f5fee" + }, + "deerflow-agent": { + "manifest": { + "apiVersion": "v1", + "kind": "Plugin", + "metadata": { + "author": "langbot-team", + "name": "DeerFlowAgent", + "version": "0.1.7", + "repository": "https://github.com/langbot-app/langbot-plugins/tree/main/Runner/deerflow-agent", + "label": { + "en_US": "DeerFlow Agent", + "zh_Hans": "DeerFlow Agent", + "zh_Hant": "DeerFlow Agent", + "ja_JP": "DeerFlow Agent", + "th_TH": "DeerFlow Agent", + "vi_VN": "DeerFlow Agent", + "es_ES": "Agente DeerFlow", + "ru_RU": "Агент DeerFlow" + }, + "description": { + "en_US": "Run a DeerFlow LangGraph agent as a LangBot Runner.", + "zh_Hans": "将 DeerFlow LangGraph 智能体作为 LangBot 运行器运行。", + "zh_Hant": "將 DeerFlow LangGraph 代理作為 LangBot 運行器執行。", + "ja_JP": "DeerFlow LangGraph エージェントを LangBot Runner として実行します。", + "th_TH": "เรียกใช้เอเจนต์ DeerFlow LangGraph เป็น LangBot Runner", + "vi_VN": "Chạy tác nhân DeerFlow LangGraph dưới dạng LangBot Runner.", + "es_ES": "Ejecuta un agente DeerFlow LangGraph como LangBot Runner.", + "ru_RU": "Запускает агент DeerFlow LangGraph как LangBot Runner." + }, + "icon": "assets/icon.svg" + }, + "spec": { + "config": [], + "components": { + "Runner": { + "fromDirs": [ + { + "path": "components/runner/" + } + ] + } + }, + "pages": [] + }, + "execution": { + "python": { + "path": "main.py", + "attr": "DeerFlowAgentPlugin" + } + } + }, + "component": { + "apiVersion": "langbot/v1", + "kind": "Runner", + "metadata": { + "name": "default", + "label": { + "en_US": "DeerFlow", + "zh_Hans": "DeerFlow" + }, + "description": { + "en_US": "Run a DeerFlow LangGraph agent as a LangBot Runner.", + "zh_Hans": "将 DeerFlow LangGraph 智能体作为 LangBot 运行器运行。" + } + }, + "spec": { + "usages": [ + "agent" + ], + "config": [ + { + "name": "api-base", + "label": { + "en_US": "API Base URL", + "zh_Hans": "API 基础 URL" + }, + "description": { + "en_US": "The base URL of the DeerFlow server, such as http://127.0.0.1:2026.", + "zh_Hans": "DeerFlow 服务器的基础 URL,例如 http://127.0.0.1:2026。" + }, + "type": "string", + "required": true, + "default": "http://127.0.0.1:2026" + }, + { + "name": "api-key", + "label": { + "en_US": "API Key", + "zh_Hans": "API 密钥" + }, + "description": { + "en_US": "Optional API key for DeerFlow. Leave empty if the server does not require authentication.", + "zh_Hans": "DeerFlow API 密钥。如果服务端不需要鉴权可留空。" + }, + "type": "secret", + "required": false, + "default": "" + }, + { + "name": "auth-header", + "label": { + "en_US": "Authorization Header Value", + "zh_Hans": "Authorization 请求头值" + }, + "description": { + "en_US": "Optional complete Authorization header value. When set, it takes precedence over api-key.", + "zh_Hans": "可选的完整 Authorization 请求头值。设置后优先于 api-key。" + }, + "type": "secret", + "required": false, + "default": "" + }, + { + "name": "assistant-id", + "label": { + "en_US": "Assistant ID", + "zh_Hans": "助手 ID" + }, + "description": { + "en_US": "The DeerFlow assistant or graph id.", + "zh_Hans": "DeerFlow 助手或图 ID。" + }, + "type": "string", + "required": true, + "default": "lead_agent" + }, + { + "name": "advanced-settings", + "label": { + "en_US": "Advanced Settings", + "zh_Hans": "高级设置" + }, + "description": { + "en_US": "Show model, timeout, and recursion tuning controls.", + "zh_Hans": "显示模型、超时和递归调优选项。" + }, + "type": "boolean", + "required": false, + "default": false + }, + { + "name": "model-name", + "label": { + "en_US": "Model Name", + "zh_Hans": "模型名称" + }, + "description": { + "en_US": "Optional model override forwarded to DeerFlow configurable.", + "zh_Hans": "可选的模型名称覆盖,会作为 configurable 转发给 DeerFlow。" + }, + "type": "string", + "required": false, + "default": "", + "show_if": { + "field": "advanced-settings", + "operator": "eq", + "value": true + } + }, + { + "name": "thinking-enabled", + "label": { + "en_US": "Enable Thinking", + "zh_Hans": "启用思考" + }, + "description": { + "en_US": "Whether to enable DeerFlow thinking mode.", + "zh_Hans": "是否启用 DeerFlow 思考模式。" + }, + "type": "boolean", + "required": false, + "default": false + }, + { + "name": "plan-mode", + "label": { + "en_US": "Plan Mode", + "zh_Hans": "规划模式" + }, + "description": { + "en_US": "Whether to enable DeerFlow plan mode.", + "zh_Hans": "是否启用 DeerFlow 规划模式。" + }, + "type": "boolean", + "required": false, + "default": false + }, + { + "name": "subagent-enabled", + "label": { + "en_US": "Enable Subagents", + "zh_Hans": "启用子代理" + }, + "description": { + "en_US": "Whether to enable parallel subagent execution.", + "zh_Hans": "是否启用并行子代理执行。" + }, + "type": "boolean", + "required": false, + "default": false + }, + { + "name": "max-concurrent-subagents", + "label": { + "en_US": "Max Concurrent Subagents", + "zh_Hans": "最大并发子代理数" + }, + "description": { + "en_US": "Maximum number of concurrent subagents. Only effective when subagents are enabled.", + "zh_Hans": "最大并发子代理数。仅在启用子代理时生效。" + }, + "type": "integer", + "required": false, + "default": 3, + "show_if": { + "field": "subagent-enabled", + "operator": "eq", + "value": true + } + }, + { + "name": "timeout", + "label": { + "en_US": "Timeout", + "zh_Hans": "超时时间" + }, + "description": { + "en_US": "Request timeout in seconds. DeerFlow runs may take a long time.", + "zh_Hans": "请求超时时间(秒)。DeerFlow 运行可能耗时较长。" + }, + "type": "integer", + "required": false, + "default": 300, + "show_if": { + "field": "advanced-settings", + "operator": "eq", + "value": true + } + }, + { + "name": "recursion-limit", + "label": { + "en_US": "Recursion Limit", + "zh_Hans": "递归上限" + }, + "description": { + "en_US": "LangGraph recursion limit for a single run.", + "zh_Hans": "单次运行的 LangGraph 递归上限。" + }, + "type": "integer", + "required": false, + "default": 1000, + "show_if": { + "field": "advanced-settings", + "operator": "eq", + "value": true + } + } + ], + "capabilities": { + "streaming": true, + "tool_calling": false, + "knowledge_retrieval": false, + "multimodal_input": true, + "interrupt": false + }, + "permissions": { + "storage": [ + "plugin" + ] + } + }, + "execution": { + "python": { + "path": "default.py", + "attr": "DefaultRunner" + } + } + }, + "package_sha256": "10c93fec92e2d34c791baa541b71debf060e80aaa5fb64c386dc25fbc6b510a5", + "schema_sha256": "8a865320f37e367095a1f332bcc490cad8c0af273bc82baf768015242457c8f6" + }, + "tbox-agent": { + "manifest": { + "apiVersion": "v1", + "kind": "Plugin", + "metadata": { + "author": "langbot-team", + "name": "TboxAgent", + "version": "0.1.5", + "repository": "https://github.com/langbot-app/langbot-plugins/tree/main/Runner/tbox-agent", + "label": { + "en_US": "Tbox Agent", + "zh_Hans": "百宝箱 Agent", + "zh_Hant": "Tbox Agent", + "ja_JP": "Tbox Agent", + "th_TH": "Tbox Agent", + "vi_VN": "Tbox Agent", + "es_ES": "Agente Tbox", + "ru_RU": "Агент Tbox" + }, + "description": { + "en_US": "Run an Ant Tbox application as a LangBot Runner.", + "zh_Hans": "将蚂蚁百宝箱应用作为 LangBot 运行器运行。", + "zh_Hant": "將螞蟻 Tbox 應用程式作為 LangBot 運行器執行。", + "ja_JP": "Ant Tbox アプリを LangBot Runner として実行します。", + "th_TH": "เรียกใช้แอป Ant Tbox เป็น LangBot Runner", + "vi_VN": "Chạy ứng dụng Ant Tbox dưới dạng LangBot Runner.", + "es_ES": "Ejecuta una aplicación Ant Tbox como LangBot Runner.", + "ru_RU": "Запускает приложение Ant Tbox как LangBot Runner." + }, + "icon": "assets/icon.png" + }, + "spec": { + "config": [], + "components": { + "Runner": { + "fromDirs": [ + { + "path": "components/runner/" + } + ] + } + }, + "pages": [] + }, + "execution": { + "python": { + "path": "main.py", + "attr": "TboxAgentPlugin" + } + } + }, + "component": { + "apiVersion": "langbot/v1", + "kind": "Runner", + "metadata": { + "name": "default", + "label": { + "en_US": "Tbox", + "zh_Hans": "百宝箱" + }, + "description": { + "en_US": "Run an Ant Tbox application as a LangBot Runner.", + "zh_Hans": "将蚂蚁百宝箱应用作为 LangBot 运行器运行。" + } + }, + "spec": { + "usages": [ + "agent" + ], + "config": [ + { + "name": "user-id-source", + "label": { + "en_US": "Provider User Identity", + "zh_Hans": "供应商用户身份" + }, + "description": { + "en_US": "Sender is the default. Legacy mode requires trusted Host identity; missing identity fails closed. Does not import old sessions.", + "zh_Hans": "默认使用发送者身份。兼容模式需要可信 Host 身份,缺失时拒绝请求;不会导入旧会话。" + }, + "type": "select", + "required": false, + "default": "sender", + "options": [ + { + "name": "sender", + "label": { + "en_US": "Sender", + "zh_Hans": "发送者" + } + }, + { + "name": "legacy-bot", + "label": { + "en_US": "Native identity compatibility", + "zh_Hans": "原生身份兼容" + } + } + ] + }, + { + "name": "remove-think", + "label": { + "en_US": "Hide reasoning", + "zh_Hans": "隐藏思考过程" + }, + "description": { + "en_US": "Hide reasoning fields and think blocks without removing answer or tool content.", + "zh_Hans": "隐藏推理字段和思考块,保留正文和工具内容。" + }, + "type": "boolean", + "required": false, + "default": false + }, + { + "name": "api-key", + "label": { + "en_US": "API Key", + "zh_Hans": "API 密钥" + }, + "type": "secret", + "required": true, + "default": "" + }, + { + "name": "app-id", + "label": { + "en_US": "App ID", + "zh_Hans": "应用 ID" + }, + "type": "string", + "required": true, + "default": "" + }, + { + "name": "timeout", + "label": { + "en_US": "Timeout", + "zh_Hans": "超时时间" + }, + "type": "number", + "required": false, + "default": 120 + } + ], + "capabilities": { + "streaming": true, + "tool_calling": false, + "knowledge_retrieval": false, + "multimodal_input": true, + "interrupt": false + }, + "permissions": { + "storage": [ + "plugin" + ] + } + }, + "execution": { + "python": { + "path": "default.py", + "attr": "DefaultRunner" + } + } + }, + "package_sha256": "ed9c7a3f7c904a1fc6a95e8e7de0253f85282bf159250e355480fdcc91b4f629", + "schema_sha256": "ac34c6a84859728328aa8d554aadbd1bef9b92c35ce4524812a541bac58c5088" + }, + "weknora-agent": { + "manifest": { + "apiVersion": "v1", + "kind": "Plugin", + "metadata": { + "author": "langbot-team", + "name": "WeKnoraAgent", + "version": "0.1.7", + "repository": "https://github.com/langbot-app/langbot-plugins/tree/main/Runner/weknora-agent", + "label": { + "en_US": "WeKnora Agent", + "zh_Hans": "WeKnora Agent", + "zh_Hant": "WeKnora Agent", + "ja_JP": "WeKnora Agent", + "th_TH": "WeKnora Agent", + "vi_VN": "WeKnora Agent", + "es_ES": "Agente WeKnora", + "ru_RU": "Агент WeKnora" + }, + "description": { + "en_US": "Run a WeKnora agent or knowledge-base chat app as a LangBot Runner.", + "zh_Hans": "将 WeKnora 智能体或知识库问答应用作为 LangBot 运行器运行。", + "zh_Hant": "將 WeKnora 代理或知識庫聊天應用程式作為 LangBot 運行器執行。", + "ja_JP": "WeKnora エージェントまたはナレッジベースチャットを LangBot Runner として実行します。", + "th_TH": "เรียกใช้เอเจนต์ WeKnora หรือแอปแชตฐานความรู้เป็น LangBot Runner", + "vi_VN": "Chạy tác nhân WeKnora hoặc ứng dụng trò chuyện kho tri thức dưới dạng LangBot Runner.", + "es_ES": "Ejecuta un agente WeKnora o una aplicación de chat con base de conocimiento como LangBot Runner.", + "ru_RU": "Запускает агент WeKnora или чат с базой знаний как LangBot Runner." + }, + "icon": "assets/icon.svg" + }, + "spec": { + "config": [], + "components": { + "Runner": { + "fromDirs": [ + { + "path": "components/runner/" + } + ] + } + }, + "pages": [] + }, + "execution": { + "python": { + "path": "main.py", + "attr": "WeKnoraAgentPlugin" + } + } + }, + "component": { + "apiVersion": "langbot/v1", + "kind": "Runner", + "metadata": { + "name": "default", + "label": { + "en_US": "WeKnora", + "zh_Hans": "WeKnora" + }, + "description": { + "en_US": "Run a WeKnora agent or knowledge-base chat app as a LangBot Runner.", + "zh_Hans": "将 WeKnora 智能体或知识库问答应用作为 LangBot 运行器运行。" + } + }, + "spec": { + "usages": [ + "agent" + ], + "config": [ + { + "name": "base-url", + "label": { + "en_US": "Base URL", + "zh_Hans": "基础 URL" + }, + "description": { + "en_US": "The WeKnora API base URL, including /api/v1.", + "zh_Hans": "WeKnora API 基础 URL,包含 /api/v1。" + }, + "type": "string", + "required": true, + "default": "http://localhost:8080/api/v1" + }, + { + "name": "api-key", + "label": { + "en_US": "API Key", + "zh_Hans": "API 密钥" + }, + "description": { + "en_US": "API key generated from WeKnora Settings -> API Keys.", + "zh_Hans": "从 WeKnora 设置 -> API Keys 生成的 API 密钥。" + }, + "type": "secret", + "required": true, + "default": "" + }, + { + "name": "app-type", + "label": { + "en_US": "App Type", + "zh_Hans": "应用类型" + }, + "type": "select", + "required": true, + "default": "agent", + "options": [ + { + "name": "agent", + "label": { + "en_US": "Agent (Smart Reasoning)", + "zh_Hans": "Agent(智能推理)" + } + }, + { + "name": "chat", + "label": { + "en_US": "Chat (Knowledge Base RAG)", + "zh_Hans": "聊天(知识库 RAG)" + } + } + ] + }, + { + "name": "agent-id", + "label": { + "en_US": "Agent ID", + "zh_Hans": "智能体 ID" + }, + "description": { + "en_US": "When omitted, chat uses builtin-quick-answer and agent uses builtin-smart-reasoning. An explicit empty value lets the server choose. Saved IDs apply in both modes.", + "zh_Hans": "未设置时,聊天使用 builtin-quick-answer,智能体使用 builtin-smart-reasoning。显式留空则由服务端选择。已保存的 ID 在两种模式下均生效。" + }, + "type": "string", + "required": false + }, + { + "name": "knowledge-base-ids", + "label": { + "en_US": "Knowledge Base IDs", + "zh_Hans": "知识库 ID 列表" + }, + "description": { + "en_US": "Remote WeKnora knowledge base IDs used in both modes, not LangBot knowledge base UUIDs. Values and order are preserved.", + "zh_Hans": "两种模式均可使用的远端 WeKnora 知识库 ID,不是 LangBot 知识库 UUID。保留原值和顺序。" + }, + "type": "array[string]", + "required": false, + "default": [] + }, + { + "name": "web-search-enabled", + "label": { + "en_US": "Enable Web Search", + "zh_Hans": "启用网络搜索" + }, + "description": { + "en_US": "Whether to enable web search in agent mode.", + "zh_Hans": "是否在 Agent 模式下启用网络搜索。" + }, + "type": "boolean", + "required": false, + "default": false, + "show_if": { + "field": "app-type", + "operator": "eq", + "value": "agent" + } + }, + { + "name": "advanced-settings", + "label": { + "en_US": "Advanced Settings", + "zh_Hans": "高级设置" + }, + "description": { + "en_US": "Show timeout and fallback prompt controls.", + "zh_Hans": "显示超时和回退提示词调优选项。" + }, + "type": "boolean", + "required": false, + "default": false + }, + { + "name": "timeout", + "label": { + "en_US": "Timeout", + "zh_Hans": "超时时间" + }, + "description": { + "en_US": "Request timeout in seconds.", + "zh_Hans": "请求超时时间(秒)。" + }, + "type": "integer", + "required": false, + "default": 120, + "show_if": { + "field": "advanced-settings", + "operator": "eq", + "value": true + } + }, + { + "name": "base-prompt", + "label": { + "en_US": "Base Prompt", + "zh_Hans": "基础提示词" + }, + "description": { + "en_US": "Default prompt when the user message is empty.", + "zh_Hans": "用户消息为空时使用的默认提示词。" + }, + "type": "string", + "required": false, + "default": "请回答用户的问题。", + "show_if": { + "field": "advanced-settings", + "operator": "eq", + "value": true + } + } + ], + "capabilities": { + "streaming": true, + "tool_calling": false, + "knowledge_retrieval": false, + "multimodal_input": false, + "interrupt": false + }, + "permissions": { + "storage": [ + "plugin" + ] + } + }, + "execution": { + "python": { + "path": "default.py", + "attr": "DefaultRunner" + } + } + }, + "package_sha256": "63841513ad9a0272f1e9863ab70a280989651a26db421b3f0df634972978135b", + "schema_sha256": "78d6d36d38f3991cc1a0bfc057d09a18a43c55e6af45b6ed61908d66bf475aa2" + } + }, + "cases": [ + { + "legacy_runner": "local-agent", + "source": { + "trigger": { + "group-respond-rules": { + "at": true, + "prefix": [ + "ai" + ], + "regexp": [], + "random": 0.0 + }, + "access-control": { + "mode": "blacklist", + "blacklist": [], + "whitelist": [] + }, + "ignore-rules": { + "prefix": [], + "regexp": [] + }, + "message-aggregation": { + "enabled": false, + "delay": 1.5 + }, + "misc": { + "combine-quote-message": true + } + }, + "safety": { + "content-filter": { + "scope": "all", + "check-sensitive-words": true + }, + "rate-limit": { + "window-length": 60, + "limitation": 60, + "strategy": "drop" + } + }, + "ai": { + "runner": { + "runner": "local-agent", + "expire-time": 0 + }, + "local-agent": { + "model": { + "primary": "synthetic-model", + "fallbacks": [] + }, + "max-round": 10, + "prompt": [ + { + "role": "system", + "content": "Synthetic prompt" + } + ], + "enable-all-tools": false, + "tools": [] + }, + "dify-service-api": { + "base-url": "https://dify.invalid/v1", + "api-key": "synthetic-key", + "app-type": "chat", + "base-prompt": "Synthetic prompt" + }, + "coze-api": { + "api-key": "synthetic-key", + "bot-id": "synthetic-bot", + "api-base": "https://api.coze.cn", + "auto_save_history": false, + "timeout": 120 + }, + "dashscope-app-api": { + "api-key": "synthetic-key", + "app-id": "synthetic-app", + "app-type": "agent", + "references_quote": "Synthetic references" + }, + "n8n-service-api": { + "webhook-url": "https://n8n.invalid/hook?token=synthetic", + "auth-type": "none", + "timeout": 120, + "output-key": "response", + "response-handling": "reply" + }, + "langflow-api": { + "base-url": "https://langflow.invalid", + "api-key": "synthetic-key", + "flow-id": "synthetic-flow", + "input_type": "chat", + "output_type": "chat", + "tweaks": "{}" + }, + "deerflow-api": { + "api-base": "https://deerflow.invalid", + "api-key": "synthetic-key", + "auth-header": "", + "assistant-id": "lead_agent", + "model-name": "synthetic-model", + "thinking-enabled": false, + "plan-mode": false, + "subagent-enabled": false, + "max-concurrent-subagents": 3, + "timeout": 300, + "recursion-limit": 1000 + }, + "tbox-app-api": { + "api-key": "synthetic-key", + "app-id": "synthetic-app" + }, + "weknora-api": { + "base-url": "https://weknora.invalid/api/v1", + "api-key": "synthetic-key", + "app-type": "agent", + "knowledge-base-ids": [ + "remote-kb" + ], + "web-search-enabled": false + } + }, + "output": { + "long-text-processing": { + "threshold": 1000, + "strategy": "none", + "font-path": "" + }, + "force-delay": { + "min": 0, + "max": 0 + }, + "misc": { + "exception-handling": "show-hint", + "failure-hint": "Request failed.", + "at-sender": true, + "quote-origin": true, + "track-function-calls": false, + "remove-think": false + } + } + }, + "plan": { + "state": "ready", + "legacy_runner": "local-agent", + "target_runner_id": "plugin:langbot-team/LocalAgent/default", + "target_plugin": { + "author": "langbot-team", + "name": "LocalAgent", + "version": "0.1.7" + }, + "config": { + "trigger": { + "group-respond-rules": { + "at": true, + "prefix": [ + "ai" + ], + "regexp": [], + "random": 0.0 + }, + "access-control": { + "mode": "blacklist", + "blacklist": [], + "whitelist": [] + }, + "ignore-rules": { + "prefix": [], + "regexp": [] + }, + "message-aggregation": { + "enabled": false, + "delay": 1.5 + }, + "misc": { + "combine-quote-message": true + } + }, + "safety": { + "content-filter": { + "scope": "all", + "check-sensitive-words": true + }, + "rate-limit": { + "window-length": 60, + "limitation": 60, + "strategy": "drop" + } + }, + "ai": { + "runner": { + "expire-time": 0, + "id": "plugin:langbot-team/LocalAgent/default" + }, + "runner_config": { + "plugin:langbot-team/LocalAgent/default": { + "advanced-settings": false, + "date-grounding": true, + "timeout": 300, + "retrieval-top-k": 5, + "rerank-model": "", + "rerank-top-k": 5, + "max-tool-iterations": 100, + "tool-execution-mode": "serial", + "max-tool-result-chars": 20000, + "context-history-fetch-limit": 50, + "context-window-tokens": 200000, + "context-reserve-tokens": 16384, + "context-keep-recent-tokens": 20000, + "context-summary-tokens": 8000, + "enable-all-tools": false, + "tools": [], + "knowledge-bases": [], + "model": { + "fallbacks": [], + "reasoning": {}, + "primary": "synthetic-model" + }, + "prompt": [ + { + "role": "system", + "content": "Synthetic prompt" + } + ], + "mcp-resources": [], + "mcp-resource-agent-read-enabled": true, + "remove-think": false + } + } + }, + "output": { + "long-text-processing": { + "threshold": 1000, + "strategy": "none", + "font-path": "" + }, + "force-delay": { + "min": 0, + "max": 0 + }, + "misc": { + "exception-handling": "show-hint", + "failure-hint": "Request failed.", + "at-sender": true, + "quote-origin": true, + "track-function-calls": false, + "remove-think": false + } + } + }, + "changed_paths": [ + "ai.runner.runner", + "ai.runner.id", + "ai.runner_config", + "ai.local-agent", + "ai.dify-service-api", + "ai.coze-api", + "ai.dashscope-app-api", + "ai.n8n-service-api", + "ai.langflow-api", + "ai.deerflow-api", + "ai.tbox-app-api", + "ai.weknora-api" + ], + "blockers": [], + "warnings": [ + { + "code": "local.context_defaults", + "field": "ai.local-agent.max-round" + }, + { + "code": "local.serial_tools_preserved", + "field": "ai.local-agent.tools" + }, + { + "code": "local.retrieval_defaults", + "field": "ai.local-agent.knowledge-bases" + }, + { + "code": "local.box_state_reset", + "field": "ai.local-agent.box-session-id-template" + }, + { + "code": "external.state_validation_required", + "field": "ai.runner" + }, + { + "code": "migration.history_reset", + "field": "ai.runner" + }, + { + "code": "migration.new_defaults", + "field": "ai.local-agent" + }, + { + "code": "migration.legacy_sections_archived", + "field": "ai" + }, + { + "code": "migration.output_policy_copied", + "field": "output.misc.remove-think" + } + ] + } + }, + { + "legacy_runner": "dify-service-api", + "source": { + "trigger": { + "group-respond-rules": { + "at": true, + "prefix": [ + "ai" + ], + "regexp": [], + "random": 0.0 + }, + "access-control": { + "mode": "blacklist", + "blacklist": [], + "whitelist": [] + }, + "ignore-rules": { + "prefix": [], + "regexp": [] + }, + "message-aggregation": { + "enabled": false, + "delay": 1.5 + }, + "misc": { + "combine-quote-message": true + } + }, + "safety": { + "content-filter": { + "scope": "all", + "check-sensitive-words": true + }, + "rate-limit": { + "window-length": 60, + "limitation": 60, + "strategy": "drop" + } + }, + "ai": { + "runner": { + "runner": "dify-service-api", + "expire-time": 0 + }, + "local-agent": { + "model": { + "primary": "synthetic-model", + "fallbacks": [] + }, + "max-round": 10, + "prompt": [ + { + "role": "system", + "content": "Synthetic prompt" + } + ], + "enable-all-tools": false, + "tools": [] + }, + "dify-service-api": { + "base-url": "https://dify.invalid/v1", + "api-key": "synthetic-key", + "app-type": "chat", + "base-prompt": "Synthetic prompt" + }, + "coze-api": { + "api-key": "synthetic-key", + "bot-id": "synthetic-bot", + "api-base": "https://api.coze.cn", + "auto_save_history": false, + "timeout": 120 + }, + "dashscope-app-api": { + "api-key": "synthetic-key", + "app-id": "synthetic-app", + "app-type": "agent", + "references_quote": "Synthetic references" + }, + "n8n-service-api": { + "webhook-url": "https://n8n.invalid/hook?token=synthetic", + "auth-type": "none", + "timeout": 120, + "output-key": "response", + "response-handling": "reply" + }, + "langflow-api": { + "base-url": "https://langflow.invalid", + "api-key": "synthetic-key", + "flow-id": "synthetic-flow", + "input_type": "chat", + "output_type": "chat", + "tweaks": "{}" + }, + "deerflow-api": { + "api-base": "https://deerflow.invalid", + "api-key": "synthetic-key", + "auth-header": "", + "assistant-id": "lead_agent", + "model-name": "synthetic-model", + "thinking-enabled": false, + "plan-mode": false, + "subagent-enabled": false, + "max-concurrent-subagents": 3, + "timeout": 300, + "recursion-limit": 1000 + }, + "tbox-app-api": { + "api-key": "synthetic-key", + "app-id": "synthetic-app" + }, + "weknora-api": { + "base-url": "https://weknora.invalid/api/v1", + "api-key": "synthetic-key", + "app-type": "agent", + "knowledge-base-ids": [ + "remote-kb" + ], + "web-search-enabled": false + } + }, + "output": { + "long-text-processing": { + "threshold": 1000, + "strategy": "none", + "font-path": "" + }, + "force-delay": { + "min": 0, + "max": 0 + }, + "misc": { + "exception-handling": "show-hint", + "failure-hint": "Request failed.", + "at-sender": true, + "quote-origin": true, + "track-function-calls": false, + "remove-think": false + } + } + }, + "plan": { + "state": "ready", + "legacy_runner": "dify-service-api", + "target_runner_id": "plugin:langbot-team/DifyAgent/default", + "target_plugin": { + "author": "langbot-team", + "name": "DifyAgent", + "version": "0.1.7" + }, + "config": { + "trigger": { + "group-respond-rules": { + "at": true, + "prefix": [ + "ai" + ], + "regexp": [], + "random": 0.0 + }, + "access-control": { + "mode": "blacklist", + "blacklist": [], + "whitelist": [] + }, + "ignore-rules": { + "prefix": [], + "regexp": [] + }, + "message-aggregation": { + "enabled": false, + "delay": 1.5 + }, + "misc": { + "combine-quote-message": true + } + }, + "safety": { + "content-filter": { + "scope": "all", + "check-sensitive-words": true + }, + "rate-limit": { + "window-length": 60, + "limitation": 60, + "strategy": "drop" + } + }, + "ai": { + "runner": { + "expire-time": 0, + "id": "plugin:langbot-team/DifyAgent/default" + }, + "runner_config": { + "plugin:langbot-team/DifyAgent/default": { + "timeout": 30, + "advanced-settings": false, + "base-url": "https://dify.invalid/v1", + "api-key": "synthetic-key", + "app-type": "chat", + "base-prompt": "Synthetic prompt", + "user-id-source": "legacy-session", + "enable-all-tools": false, + "tools": [], + "knowledge-bases": [], + "mcp-resources": [], + "mcp-resource-agent-read-enabled": false, + "langbot-assets-enabled": false, + "remove-think": false + } + } + }, + "output": { + "long-text-processing": { + "threshold": 1000, + "strategy": "none", + "font-path": "" + }, + "force-delay": { + "min": 0, + "max": 0 + }, + "misc": { + "exception-handling": "show-hint", + "failure-hint": "Request failed.", + "at-sender": true, + "quote-origin": true, + "track-function-calls": false, + "remove-think": false + } + } + }, + "changed_paths": [ + "ai.runner.runner", + "ai.runner.id", + "ai.runner_config", + "ai.local-agent", + "ai.dify-service-api", + "ai.coze-api", + "ai.dashscope-app-api", + "ai.n8n-service-api", + "ai.langflow-api", + "ai.deerflow-api", + "ai.tbox-app-api", + "ai.weknora-api" + ], + "blockers": [], + "warnings": [ + { + "code": "dify.timeout_default", + "field": "ai.dify-service-api.timeout" + }, + { + "code": "external.state_validation_required", + "field": "ai.runner" + }, + { + "code": "migration.history_reset", + "field": "ai.runner" + }, + { + "code": "migration.new_defaults", + "field": "ai.dify-service-api" + }, + { + "code": "migration.legacy_sections_archived", + "field": "ai" + }, + { + "code": "migration.filtered_variables", + "field": "ai.dify-service-api" + }, + { + "code": "migration.identity_preserved", + "field": "ai.dify-service-api" + }, + { + "code": "migration.output_policy_copied", + "field": "output.misc.remove-think" + } + ] + } + }, + { + "legacy_runner": "coze-api", + "source": { + "trigger": { + "group-respond-rules": { + "at": true, + "prefix": [ + "ai" + ], + "regexp": [], + "random": 0.0 + }, + "access-control": { + "mode": "blacklist", + "blacklist": [], + "whitelist": [] + }, + "ignore-rules": { + "prefix": [], + "regexp": [] + }, + "message-aggregation": { + "enabled": false, + "delay": 1.5 + }, + "misc": { + "combine-quote-message": true + } + }, + "safety": { + "content-filter": { + "scope": "all", + "check-sensitive-words": true + }, + "rate-limit": { + "window-length": 60, + "limitation": 60, + "strategy": "drop" + } + }, + "ai": { + "runner": { + "runner": "coze-api", + "expire-time": 0 + }, + "local-agent": { + "model": { + "primary": "synthetic-model", + "fallbacks": [] + }, + "max-round": 10, + "prompt": [ + { + "role": "system", + "content": "Synthetic prompt" + } + ], + "enable-all-tools": false, + "tools": [] + }, + "dify-service-api": { + "base-url": "https://dify.invalid/v1", + "api-key": "synthetic-key", + "app-type": "chat", + "base-prompt": "Synthetic prompt" + }, + "coze-api": { + "api-key": "synthetic-key", + "bot-id": "synthetic-bot", + "api-base": "https://api.coze.cn", + "auto_save_history": false, + "timeout": 120 + }, + "dashscope-app-api": { + "api-key": "synthetic-key", + "app-id": "synthetic-app", + "app-type": "agent", + "references_quote": "Synthetic references" + }, + "n8n-service-api": { + "webhook-url": "https://n8n.invalid/hook?token=synthetic", + "auth-type": "none", + "timeout": 120, + "output-key": "response", + "response-handling": "reply" + }, + "langflow-api": { + "base-url": "https://langflow.invalid", + "api-key": "synthetic-key", + "flow-id": "synthetic-flow", + "input_type": "chat", + "output_type": "chat", + "tweaks": "{}" + }, + "deerflow-api": { + "api-base": "https://deerflow.invalid", + "api-key": "synthetic-key", + "auth-header": "", + "assistant-id": "lead_agent", + "model-name": "synthetic-model", + "thinking-enabled": false, + "plan-mode": false, + "subagent-enabled": false, + "max-concurrent-subagents": 3, + "timeout": 300, + "recursion-limit": 1000 + }, + "tbox-app-api": { + "api-key": "synthetic-key", + "app-id": "synthetic-app" + }, + "weknora-api": { + "base-url": "https://weknora.invalid/api/v1", + "api-key": "synthetic-key", + "app-type": "agent", + "knowledge-base-ids": [ + "remote-kb" + ], + "web-search-enabled": false + } + }, + "output": { + "long-text-processing": { + "threshold": 1000, + "strategy": "none", + "font-path": "" + }, + "force-delay": { + "min": 0, + "max": 0 + }, + "misc": { + "exception-handling": "show-hint", + "failure-hint": "Request failed.", + "at-sender": true, + "quote-origin": true, + "track-function-calls": false, + "remove-think": false + } + } + }, + "plan": { + "state": "ready", + "legacy_runner": "coze-api", + "target_runner_id": "plugin:langbot-team/CozeAgent/default", + "target_plugin": { + "author": "langbot-team", + "name": "CozeAgent", + "version": "0.1.7" + }, + "config": { + "trigger": { + "group-respond-rules": { + "at": true, + "prefix": [ + "ai" + ], + "regexp": [], + "random": 0.0 + }, + "access-control": { + "mode": "blacklist", + "blacklist": [], + "whitelist": [] + }, + "ignore-rules": { + "prefix": [], + "regexp": [] + }, + "message-aggregation": { + "enabled": false, + "delay": 1.5 + }, + "misc": { + "combine-quote-message": true + } + }, + "safety": { + "content-filter": { + "scope": "all", + "check-sensitive-words": true + }, + "rate-limit": { + "window-length": 60, + "limitation": 60, + "strategy": "drop" + } + }, + "ai": { + "runner": { + "expire-time": 0, + "id": "plugin:langbot-team/CozeAgent/default" + }, + "runner_config": { + "plugin:langbot-team/CozeAgent/default": { + "auto-save-history": false, + "advanced-settings": false, + "api-key": "synthetic-key", + "bot-id": "synthetic-bot", + "api-base": "https://api.coze.cn", + "timeout": 120, + "user-id-source": "legacy-session", + "enable-all-tools": false, + "tools": [], + "knowledge-bases": [], + "mcp-resources": [], + "mcp-resource-agent-read-enabled": false, + "langbot-assets-enabled": false, + "remove-think": false + } + } + }, + "output": { + "long-text-processing": { + "threshold": 1000, + "strategy": "none", + "font-path": "" + }, + "force-delay": { + "min": 0, + "max": 0 + }, + "misc": { + "exception-handling": "show-hint", + "failure-hint": "Request failed.", + "at-sender": true, + "quote-origin": true, + "track-function-calls": false, + "remove-think": false + } + } + }, + "changed_paths": [ + "ai.runner.runner", + "ai.runner.id", + "ai.runner_config", + "ai.local-agent", + "ai.dify-service-api", + "ai.coze-api", + "ai.dashscope-app-api", + "ai.n8n-service-api", + "ai.langflow-api", + "ai.deerflow-api", + "ai.tbox-app-api", + "ai.weknora-api" + ], + "blockers": [], + "warnings": [ + { + "code": "migration.alias_repaired", + "field": "ai.coze-api.auto_save_history" + }, + { + "code": "coze.persistent_history", + "field": "ai.coze-api" + }, + { + "code": "external.state_validation_required", + "field": "ai.runner" + }, + { + "code": "migration.history_reset", + "field": "ai.runner" + }, + { + "code": "migration.new_defaults", + "field": "ai.coze-api" + }, + { + "code": "migration.legacy_sections_archived", + "field": "ai" + }, + { + "code": "migration.identity_preserved", + "field": "ai.coze-api" + }, + { + "code": "migration.output_policy_copied", + "field": "output.misc.remove-think" + } + ] + } + }, + { + "legacy_runner": "dashscope-app-api", + "source": { + "trigger": { + "group-respond-rules": { + "at": true, + "prefix": [ + "ai" + ], + "regexp": [], + "random": 0.0 + }, + "access-control": { + "mode": "blacklist", + "blacklist": [], + "whitelist": [] + }, + "ignore-rules": { + "prefix": [], + "regexp": [] + }, + "message-aggregation": { + "enabled": false, + "delay": 1.5 + }, + "misc": { + "combine-quote-message": true + } + }, + "safety": { + "content-filter": { + "scope": "all", + "check-sensitive-words": true + }, + "rate-limit": { + "window-length": 60, + "limitation": 60, + "strategy": "drop" + } + }, + "ai": { + "runner": { + "runner": "dashscope-app-api", + "expire-time": 0 + }, + "local-agent": { + "model": { + "primary": "synthetic-model", + "fallbacks": [] + }, + "max-round": 10, + "prompt": [ + { + "role": "system", + "content": "Synthetic prompt" + } + ], + "enable-all-tools": false, + "tools": [] + }, + "dify-service-api": { + "base-url": "https://dify.invalid/v1", + "api-key": "synthetic-key", + "app-type": "chat", + "base-prompt": "Synthetic prompt" + }, + "coze-api": { + "api-key": "synthetic-key", + "bot-id": "synthetic-bot", + "api-base": "https://api.coze.cn", + "auto_save_history": false, + "timeout": 120 + }, + "dashscope-app-api": { + "api-key": "synthetic-key", + "app-id": "synthetic-app", + "app-type": "agent", + "references_quote": "Synthetic references" + }, + "n8n-service-api": { + "webhook-url": "https://n8n.invalid/hook?token=synthetic", + "auth-type": "none", + "timeout": 120, + "output-key": "response", + "response-handling": "reply" + }, + "langflow-api": { + "base-url": "https://langflow.invalid", + "api-key": "synthetic-key", + "flow-id": "synthetic-flow", + "input_type": "chat", + "output_type": "chat", + "tweaks": "{}" + }, + "deerflow-api": { + "api-base": "https://deerflow.invalid", + "api-key": "synthetic-key", + "auth-header": "", + "assistant-id": "lead_agent", + "model-name": "synthetic-model", + "thinking-enabled": false, + "plan-mode": false, + "subagent-enabled": false, + "max-concurrent-subagents": 3, + "timeout": 300, + "recursion-limit": 1000 + }, + "tbox-app-api": { + "api-key": "synthetic-key", + "app-id": "synthetic-app" + }, + "weknora-api": { + "base-url": "https://weknora.invalid/api/v1", + "api-key": "synthetic-key", + "app-type": "agent", + "knowledge-base-ids": [ + "remote-kb" + ], + "web-search-enabled": false + } + }, + "output": { + "long-text-processing": { + "threshold": 1000, + "strategy": "none", + "font-path": "" + }, + "force-delay": { + "min": 0, + "max": 0 + }, + "misc": { + "exception-handling": "show-hint", + "failure-hint": "Request failed.", + "at-sender": true, + "quote-origin": true, + "track-function-calls": false, + "remove-think": false + } + } + }, + "plan": { + "state": "ready", + "legacy_runner": "dashscope-app-api", + "target_runner_id": "plugin:langbot-team/DashScopeAgent/default", + "target_plugin": { + "author": "langbot-team", + "name": "DashScopeAgent", + "version": "0.1.7" + }, + "config": { + "trigger": { + "group-respond-rules": { + "at": true, + "prefix": [ + "ai" + ], + "regexp": [], + "random": 0.0 + }, + "access-control": { + "mode": "blacklist", + "blacklist": [], + "whitelist": [] + }, + "ignore-rules": { + "prefix": [], + "regexp": [] + }, + "message-aggregation": { + "enabled": false, + "delay": 1.5 + }, + "misc": { + "combine-quote-message": true + } + }, + "safety": { + "content-filter": { + "scope": "all", + "check-sensitive-words": true + }, + "rate-limit": { + "window-length": 60, + "limitation": 60, + "strategy": "drop" + } + }, + "ai": { + "runner": { + "expire-time": 0, + "id": "plugin:langbot-team/DashScopeAgent/default" + }, + "runner_config": { + "plugin:langbot-team/DashScopeAgent/default": { + "references_quote": "Synthetic references", + "timeout": 120, + "advanced-settings": false, + "api-key": "synthetic-key", + "app-id": "synthetic-app", + "app-type": "agent", + "enable-all-tools": false, + "tools": [], + "knowledge-bases": [], + "mcp-resources": [], + "mcp-resource-agent-read-enabled": false, + "langbot-assets-enabled": false, + "remove-think": false + } + } + }, + "output": { + "long-text-processing": { + "threshold": 1000, + "strategy": "none", + "font-path": "" + }, + "force-delay": { + "min": 0, + "max": 0 + }, + "misc": { + "exception-handling": "show-hint", + "failure-hint": "Request failed.", + "at-sender": true, + "quote-origin": true, + "track-function-calls": false, + "remove-think": false + } + } + }, + "changed_paths": [ + "ai.runner.runner", + "ai.runner.id", + "ai.runner_config", + "ai.local-agent", + "ai.dify-service-api", + "ai.coze-api", + "ai.dashscope-app-api", + "ai.n8n-service-api", + "ai.langflow-api", + "ai.deerflow-api", + "ai.tbox-app-api", + "ai.weknora-api" + ], + "blockers": [], + "warnings": [ + { + "code": "external.state_validation_required", + "field": "ai.runner" + }, + { + "code": "migration.history_reset", + "field": "ai.runner" + }, + { + "code": "migration.new_defaults", + "field": "ai.dashscope-app-api" + }, + { + "code": "migration.legacy_sections_archived", + "field": "ai" + }, + { + "code": "migration.filtered_variables", + "field": "ai.dashscope-app-api" + }, + { + "code": "migration.output_policy_copied", + "field": "output.misc.remove-think" + } + ] + } + }, + { + "legacy_runner": "n8n-service-api", + "source": { + "trigger": { + "group-respond-rules": { + "at": true, + "prefix": [ + "ai" + ], + "regexp": [], + "random": 0.0 + }, + "access-control": { + "mode": "blacklist", + "blacklist": [], + "whitelist": [] + }, + "ignore-rules": { + "prefix": [], + "regexp": [] + }, + "message-aggregation": { + "enabled": false, + "delay": 1.5 + }, + "misc": { + "combine-quote-message": true + } + }, + "safety": { + "content-filter": { + "scope": "all", + "check-sensitive-words": true + }, + "rate-limit": { + "window-length": 60, + "limitation": 60, + "strategy": "drop" + } + }, + "ai": { + "runner": { + "runner": "n8n-service-api", + "expire-time": 0 + }, + "local-agent": { + "model": { + "primary": "synthetic-model", + "fallbacks": [] + }, + "max-round": 10, + "prompt": [ + { + "role": "system", + "content": "Synthetic prompt" + } + ], + "enable-all-tools": false, + "tools": [] + }, + "dify-service-api": { + "base-url": "https://dify.invalid/v1", + "api-key": "synthetic-key", + "app-type": "chat", + "base-prompt": "Synthetic prompt" + }, + "coze-api": { + "api-key": "synthetic-key", + "bot-id": "synthetic-bot", + "api-base": "https://api.coze.cn", + "auto_save_history": false, + "timeout": 120 + }, + "dashscope-app-api": { + "api-key": "synthetic-key", + "app-id": "synthetic-app", + "app-type": "agent", + "references_quote": "Synthetic references" + }, + "n8n-service-api": { + "webhook-url": "https://n8n.invalid/hook?token=synthetic", + "auth-type": "none", + "timeout": 120, + "output-key": "response", + "response-handling": "reply" + }, + "langflow-api": { + "base-url": "https://langflow.invalid", + "api-key": "synthetic-key", + "flow-id": "synthetic-flow", + "input_type": "chat", + "output_type": "chat", + "tweaks": "{}" + }, + "deerflow-api": { + "api-base": "https://deerflow.invalid", + "api-key": "synthetic-key", + "auth-header": "", + "assistant-id": "lead_agent", + "model-name": "synthetic-model", + "thinking-enabled": false, + "plan-mode": false, + "subagent-enabled": false, + "max-concurrent-subagents": 3, + "timeout": 300, + "recursion-limit": 1000 + }, + "tbox-app-api": { + "api-key": "synthetic-key", + "app-id": "synthetic-app" + }, + "weknora-api": { + "base-url": "https://weknora.invalid/api/v1", + "api-key": "synthetic-key", + "app-type": "agent", + "knowledge-base-ids": [ + "remote-kb" + ], + "web-search-enabled": false + } + }, + "output": { + "long-text-processing": { + "threshold": 1000, + "strategy": "none", + "font-path": "" + }, + "force-delay": { + "min": 0, + "max": 0 + }, + "misc": { + "exception-handling": "show-hint", + "failure-hint": "Request failed.", + "at-sender": true, + "quote-origin": true, + "track-function-calls": false, + "remove-think": false + } + } + }, + "plan": { + "state": "ready", + "legacy_runner": "n8n-service-api", + "target_runner_id": "plugin:langbot-team/N8nAgent/default", + "target_plugin": { + "author": "langbot-team", + "name": "N8nAgent", + "version": "0.1.7" + }, + "config": { + "trigger": { + "group-respond-rules": { + "at": true, + "prefix": [ + "ai" + ], + "regexp": [], + "random": 0.0 + }, + "access-control": { + "mode": "blacklist", + "blacklist": [], + "whitelist": [] + }, + "ignore-rules": { + "prefix": [], + "regexp": [] + }, + "message-aggregation": { + "enabled": false, + "delay": 1.5 + }, + "misc": { + "combine-quote-message": true + } + }, + "safety": { + "content-filter": { + "scope": "all", + "check-sensitive-words": true + }, + "rate-limit": { + "window-length": 60, + "limitation": 60, + "strategy": "drop" + } + }, + "ai": { + "runner": { + "expire-time": 0, + "id": "plugin:langbot-team/N8nAgent/default" + }, + "runner_config": { + "plugin:langbot-team/N8nAgent/default": { + "auth-type": "none", + "basic-username": "", + "basic-password": "", + "jwt-secret": "", + "jwt-algorithm": "HS256", + "header-name": "", + "header-value": "", + "timeout": 120, + "output-key": "response", + "response-handling": "reply", + "basic-encoding": "latin1", + "advanced-settings": false, + "webhook-url": "https://n8n.invalid/hook?token=synthetic", + "user-id-source": "legacy-session", + "enable-all-tools": false, + "tools": [], + "knowledge-bases": [], + "mcp-resources": [], + "mcp-resource-agent-read-enabled": false, + "langbot-assets-enabled": false + } + } + }, + "output": { + "long-text-processing": { + "threshold": 1000, + "strategy": "none", + "font-path": "" + }, + "force-delay": { + "min": 0, + "max": 0 + }, + "misc": { + "exception-handling": "show-hint", + "failure-hint": "Request failed.", + "at-sender": true, + "quote-origin": true, + "track-function-calls": false, + "remove-think": false + } + } + }, + "changed_paths": [ + "ai.runner.runner", + "ai.runner.id", + "ai.runner_config", + "ai.local-agent", + "ai.dify-service-api", + "ai.coze-api", + "ai.dashscope-app-api", + "ai.n8n-service-api", + "ai.langflow-api", + "ai.deerflow-api", + "ai.tbox-app-api", + "ai.weknora-api" + ], + "blockers": [], + "warnings": [ + { + "code": "n8n.session_ids_reset", + "field": "ai.n8n-service-api" + }, + { + "code": "external.state_validation_required", + "field": "ai.runner" + }, + { + "code": "migration.history_reset", + "field": "ai.runner" + }, + { + "code": "migration.new_defaults", + "field": "ai.n8n-service-api" + }, + { + "code": "migration.legacy_sections_archived", + "field": "ai" + }, + { + "code": "migration.filtered_variables", + "field": "ai.n8n-service-api" + }, + { + "code": "migration.identity_preserved", + "field": "ai.n8n-service-api" + } + ] + } + }, + { + "legacy_runner": "langflow-api", + "source": { + "trigger": { + "group-respond-rules": { + "at": true, + "prefix": [ + "ai" + ], + "regexp": [], + "random": 0.0 + }, + "access-control": { + "mode": "blacklist", + "blacklist": [], + "whitelist": [] + }, + "ignore-rules": { + "prefix": [], + "regexp": [] + }, + "message-aggregation": { + "enabled": false, + "delay": 1.5 + }, + "misc": { + "combine-quote-message": true + } + }, + "safety": { + "content-filter": { + "scope": "all", + "check-sensitive-words": true + }, + "rate-limit": { + "window-length": 60, + "limitation": 60, + "strategy": "drop" + } + }, + "ai": { + "runner": { + "runner": "langflow-api", + "expire-time": 0 + }, + "local-agent": { + "model": { + "primary": "synthetic-model", + "fallbacks": [] + }, + "max-round": 10, + "prompt": [ + { + "role": "system", + "content": "Synthetic prompt" + } + ], + "enable-all-tools": false, + "tools": [] + }, + "dify-service-api": { + "base-url": "https://dify.invalid/v1", + "api-key": "synthetic-key", + "app-type": "chat", + "base-prompt": "Synthetic prompt" + }, + "coze-api": { + "api-key": "synthetic-key", + "bot-id": "synthetic-bot", + "api-base": "https://api.coze.cn", + "auto_save_history": false, + "timeout": 120 + }, + "dashscope-app-api": { + "api-key": "synthetic-key", + "app-id": "synthetic-app", + "app-type": "agent", + "references_quote": "Synthetic references" + }, + "n8n-service-api": { + "webhook-url": "https://n8n.invalid/hook?token=synthetic", + "auth-type": "none", + "timeout": 120, + "output-key": "response", + "response-handling": "reply" + }, + "langflow-api": { + "base-url": "https://langflow.invalid", + "api-key": "synthetic-key", + "flow-id": "synthetic-flow", + "input_type": "chat", + "output_type": "chat", + "tweaks": "{}" + }, + "deerflow-api": { + "api-base": "https://deerflow.invalid", + "api-key": "synthetic-key", + "auth-header": "", + "assistant-id": "lead_agent", + "model-name": "synthetic-model", + "thinking-enabled": false, + "plan-mode": false, + "subagent-enabled": false, + "max-concurrent-subagents": 3, + "timeout": 300, + "recursion-limit": 1000 + }, + "tbox-app-api": { + "api-key": "synthetic-key", + "app-id": "synthetic-app" + }, + "weknora-api": { + "base-url": "https://weknora.invalid/api/v1", + "api-key": "synthetic-key", + "app-type": "agent", + "knowledge-base-ids": [ + "remote-kb" + ], + "web-search-enabled": false + } + }, + "output": { + "long-text-processing": { + "threshold": 1000, + "strategy": "none", + "font-path": "" + }, + "force-delay": { + "min": 0, + "max": 0 + }, + "misc": { + "exception-handling": "show-hint", + "failure-hint": "Request failed.", + "at-sender": true, + "quote-origin": true, + "track-function-calls": false, + "remove-think": false + } + } + }, + "plan": { + "state": "ready", + "legacy_runner": "langflow-api", + "target_runner_id": "plugin:langbot-team/LangflowAgent/default", + "target_plugin": { + "author": "langbot-team", + "name": "LangflowAgent", + "version": "0.1.7" + }, + "config": { + "trigger": { + "group-respond-rules": { + "at": true, + "prefix": [ + "ai" + ], + "regexp": [], + "random": 0.0 + }, + "access-control": { + "mode": "blacklist", + "blacklist": [], + "whitelist": [] + }, + "ignore-rules": { + "prefix": [], + "regexp": [] + }, + "message-aggregation": { + "enabled": false, + "delay": 1.5 + }, + "misc": { + "combine-quote-message": true + } + }, + "safety": { + "content-filter": { + "scope": "all", + "check-sensitive-words": true + }, + "rate-limit": { + "window-length": 60, + "limitation": 60, + "strategy": "drop" + } + }, + "ai": { + "runner": { + "expire-time": 0, + "id": "plugin:langbot-team/LangflowAgent/default" + }, + "runner_config": { + "plugin:langbot-team/LangflowAgent/default": { + "input-type": "chat", + "output-type": "chat", + "tweaks": {}, + "advanced-settings": false, + "base-url": "https://langflow.invalid", + "api-key": "synthetic-key", + "flow-id": "synthetic-flow", + "enable-all-tools": false, + "tools": [], + "knowledge-bases": [], + "mcp-resources": [], + "mcp-resource-agent-read-enabled": false, + "langbot-assets-enabled": false + } + } + }, + "output": { + "long-text-processing": { + "threshold": 1000, + "strategy": "none", + "font-path": "" + }, + "force-delay": { + "min": 0, + "max": 0 + }, + "misc": { + "exception-handling": "show-hint", + "failure-hint": "Request failed.", + "at-sender": true, + "quote-origin": true, + "track-function-calls": false, + "remove-think": false + } + } + }, + "changed_paths": [ + "ai.runner.runner", + "ai.runner.id", + "ai.runner_config", + "ai.local-agent", + "ai.dify-service-api", + "ai.coze-api", + "ai.dashscope-app-api", + "ai.n8n-service-api", + "ai.langflow-api", + "ai.deerflow-api", + "ai.tbox-app-api", + "ai.weknora-api" + ], + "blockers": [], + "warnings": [ + { + "code": "migration.alias_repaired", + "field": "ai.langflow-api.input_type" + }, + { + "code": "migration.alias_repaired", + "field": "ai.langflow-api.output_type" + }, + { + "code": "langflow.persistent_history", + "field": "ai.langflow-api" + }, + { + "code": "external.state_validation_required", + "field": "ai.runner" + }, + { + "code": "migration.history_reset", + "field": "ai.runner" + }, + { + "code": "migration.new_defaults", + "field": "ai.langflow-api" + }, + { + "code": "migration.legacy_sections_archived", + "field": "ai" + } + ] + } + }, + { + "legacy_runner": "deerflow-api", + "source": { + "trigger": { + "group-respond-rules": { + "at": true, + "prefix": [ + "ai" + ], + "regexp": [], + "random": 0.0 + }, + "access-control": { + "mode": "blacklist", + "blacklist": [], + "whitelist": [] + }, + "ignore-rules": { + "prefix": [], + "regexp": [] + }, + "message-aggregation": { + "enabled": false, + "delay": 1.5 + }, + "misc": { + "combine-quote-message": true + } + }, + "safety": { + "content-filter": { + "scope": "all", + "check-sensitive-words": true + }, + "rate-limit": { + "window-length": 60, + "limitation": 60, + "strategy": "drop" + } + }, + "ai": { + "runner": { + "runner": "deerflow-api", + "expire-time": 0 + }, + "local-agent": { + "model": { + "primary": "synthetic-model", + "fallbacks": [] + }, + "max-round": 10, + "prompt": [ + { + "role": "system", + "content": "Synthetic prompt" + } + ], + "enable-all-tools": false, + "tools": [] + }, + "dify-service-api": { + "base-url": "https://dify.invalid/v1", + "api-key": "synthetic-key", + "app-type": "chat", + "base-prompt": "Synthetic prompt" + }, + "coze-api": { + "api-key": "synthetic-key", + "bot-id": "synthetic-bot", + "api-base": "https://api.coze.cn", + "auto_save_history": false, + "timeout": 120 + }, + "dashscope-app-api": { + "api-key": "synthetic-key", + "app-id": "synthetic-app", + "app-type": "agent", + "references_quote": "Synthetic references" + }, + "n8n-service-api": { + "webhook-url": "https://n8n.invalid/hook?token=synthetic", + "auth-type": "none", + "timeout": 120, + "output-key": "response", + "response-handling": "reply" + }, + "langflow-api": { + "base-url": "https://langflow.invalid", + "api-key": "synthetic-key", + "flow-id": "synthetic-flow", + "input_type": "chat", + "output_type": "chat", + "tweaks": "{}" + }, + "deerflow-api": { + "api-base": "https://deerflow.invalid", + "api-key": "synthetic-key", + "auth-header": "", + "assistant-id": "lead_agent", + "model-name": "synthetic-model", + "thinking-enabled": false, + "plan-mode": false, + "subagent-enabled": false, + "max-concurrent-subagents": 3, + "timeout": 300, + "recursion-limit": 1000 + }, + "tbox-app-api": { + "api-key": "synthetic-key", + "app-id": "synthetic-app" + }, + "weknora-api": { + "base-url": "https://weknora.invalid/api/v1", + "api-key": "synthetic-key", + "app-type": "agent", + "knowledge-base-ids": [ + "remote-kb" + ], + "web-search-enabled": false + } + }, + "output": { + "long-text-processing": { + "threshold": 1000, + "strategy": "none", + "font-path": "" + }, + "force-delay": { + "min": 0, + "max": 0 + }, + "misc": { + "exception-handling": "show-hint", + "failure-hint": "Request failed.", + "at-sender": true, + "quote-origin": true, + "track-function-calls": false, + "remove-think": false + } + } + }, + "plan": { + "state": "ready", + "legacy_runner": "deerflow-api", + "target_runner_id": "plugin:langbot-team/DeerFlowAgent/default", + "target_plugin": { + "author": "langbot-team", + "name": "DeerFlowAgent", + "version": "0.1.7" + }, + "config": { + "trigger": { + "group-respond-rules": { + "at": true, + "prefix": [ + "ai" + ], + "regexp": [], + "random": 0.0 + }, + "access-control": { + "mode": "blacklist", + "blacklist": [], + "whitelist": [] + }, + "ignore-rules": { + "prefix": [], + "regexp": [] + }, + "message-aggregation": { + "enabled": false, + "delay": 1.5 + }, + "misc": { + "combine-quote-message": true + } + }, + "safety": { + "content-filter": { + "scope": "all", + "check-sensitive-words": true + }, + "rate-limit": { + "window-length": 60, + "limitation": 60, + "strategy": "drop" + } + }, + "ai": { + "runner": { + "expire-time": 0, + "id": "plugin:langbot-team/DeerFlowAgent/default" + }, + "runner_config": { + "plugin:langbot-team/DeerFlowAgent/default": { + "api-key": "synthetic-key", + "auth-header": "", + "assistant-id": "lead_agent", + "model-name": "synthetic-model", + "thinking-enabled": false, + "plan-mode": false, + "subagent-enabled": false, + "max-concurrent-subagents": 3, + "timeout": 300, + "recursion-limit": 1000, + "api-base": "https://deerflow.invalid", + "enable-all-tools": false, + "tools": [], + "knowledge-bases": [], + "mcp-resources": [], + "mcp-resource-agent-read-enabled": false + } + } + }, + "output": { + "long-text-processing": { + "threshold": 1000, + "strategy": "none", + "font-path": "" + }, + "force-delay": { + "min": 0, + "max": 0 + }, + "misc": { + "exception-handling": "show-hint", + "failure-hint": "Request failed.", + "at-sender": true, + "quote-origin": true, + "track-function-calls": false, + "remove-think": false + } + } + }, + "changed_paths": [ + "ai.runner.runner", + "ai.runner.id", + "ai.runner_config", + "ai.local-agent", + "ai.dify-service-api", + "ai.coze-api", + "ai.dashscope-app-api", + "ai.n8n-service-api", + "ai.langflow-api", + "ai.deerflow-api", + "ai.tbox-app-api", + "ai.weknora-api" + ], + "blockers": [], + "warnings": [ + { + "code": "external.state_validation_required", + "field": "ai.runner" + }, + { + "code": "migration.history_reset", + "field": "ai.runner" + }, + { + "code": "migration.new_defaults", + "field": "ai.deerflow-api" + }, + { + "code": "migration.legacy_sections_archived", + "field": "ai" + } + ] + } + }, + { + "legacy_runner": "tbox-app-api", + "source": { + "trigger": { + "group-respond-rules": { + "at": true, + "prefix": [ + "ai" + ], + "regexp": [], + "random": 0.0 + }, + "access-control": { + "mode": "blacklist", + "blacklist": [], + "whitelist": [] + }, + "ignore-rules": { + "prefix": [], + "regexp": [] + }, + "message-aggregation": { + "enabled": false, + "delay": 1.5 + }, + "misc": { + "combine-quote-message": true + } + }, + "safety": { + "content-filter": { + "scope": "all", + "check-sensitive-words": true + }, + "rate-limit": { + "window-length": 60, + "limitation": 60, + "strategy": "drop" + } + }, + "ai": { + "runner": { + "runner": "tbox-app-api", + "expire-time": 0 + }, + "local-agent": { + "model": { + "primary": "synthetic-model", + "fallbacks": [] + }, + "max-round": 10, + "prompt": [ + { + "role": "system", + "content": "Synthetic prompt" + } + ], + "enable-all-tools": false, + "tools": [] + }, + "dify-service-api": { + "base-url": "https://dify.invalid/v1", + "api-key": "synthetic-key", + "app-type": "chat", + "base-prompt": "Synthetic prompt" + }, + "coze-api": { + "api-key": "synthetic-key", + "bot-id": "synthetic-bot", + "api-base": "https://api.coze.cn", + "auto_save_history": false, + "timeout": 120 + }, + "dashscope-app-api": { + "api-key": "synthetic-key", + "app-id": "synthetic-app", + "app-type": "agent", + "references_quote": "Synthetic references" + }, + "n8n-service-api": { + "webhook-url": "https://n8n.invalid/hook?token=synthetic", + "auth-type": "none", + "timeout": 120, + "output-key": "response", + "response-handling": "reply" + }, + "langflow-api": { + "base-url": "https://langflow.invalid", + "api-key": "synthetic-key", + "flow-id": "synthetic-flow", + "input_type": "chat", + "output_type": "chat", + "tweaks": "{}" + }, + "deerflow-api": { + "api-base": "https://deerflow.invalid", + "api-key": "synthetic-key", + "auth-header": "", + "assistant-id": "lead_agent", + "model-name": "synthetic-model", + "thinking-enabled": false, + "plan-mode": false, + "subagent-enabled": false, + "max-concurrent-subagents": 3, + "timeout": 300, + "recursion-limit": 1000 + }, + "tbox-app-api": { + "api-key": "synthetic-key", + "app-id": "synthetic-app" + }, + "weknora-api": { + "base-url": "https://weknora.invalid/api/v1", + "api-key": "synthetic-key", + "app-type": "agent", + "knowledge-base-ids": [ + "remote-kb" + ], + "web-search-enabled": false + } + }, + "output": { + "long-text-processing": { + "threshold": 1000, + "strategy": "none", + "font-path": "" + }, + "force-delay": { + "min": 0, + "max": 0 + }, + "misc": { + "exception-handling": "show-hint", + "failure-hint": "Request failed.", + "at-sender": true, + "quote-origin": true, + "track-function-calls": false, + "remove-think": false + } + } + }, + "plan": { + "state": "ready", + "legacy_runner": "tbox-app-api", + "target_runner_id": "plugin:langbot-team/TboxAgent/default", + "target_plugin": { + "author": "langbot-team", + "name": "TboxAgent", + "version": "0.1.5" + }, + "config": { + "trigger": { + "group-respond-rules": { + "at": true, + "prefix": [ + "ai" + ], + "regexp": [], + "random": 0.0 + }, + "access-control": { + "mode": "blacklist", + "blacklist": [], + "whitelist": [] + }, + "ignore-rules": { + "prefix": [], + "regexp": [] + }, + "message-aggregation": { + "enabled": false, + "delay": 1.5 + }, + "misc": { + "combine-quote-message": true + } + }, + "safety": { + "content-filter": { + "scope": "all", + "check-sensitive-words": true + }, + "rate-limit": { + "window-length": 60, + "limitation": 60, + "strategy": "drop" + } + }, + "ai": { + "runner": { + "expire-time": 0, + "id": "plugin:langbot-team/TboxAgent/default" + }, + "runner_config": { + "plugin:langbot-team/TboxAgent/default": { + "timeout": 120, + "api-key": "synthetic-key", + "app-id": "synthetic-app", + "user-id-source": "legacy-bot", + "enable-all-tools": false, + "tools": [], + "knowledge-bases": [], + "mcp-resources": [], + "mcp-resource-agent-read-enabled": false, + "remove-think": false + } + } + }, + "output": { + "long-text-processing": { + "threshold": 1000, + "strategy": "none", + "font-path": "" + }, + "force-delay": { + "min": 0, + "max": 0 + }, + "misc": { + "exception-handling": "show-hint", + "failure-hint": "Request failed.", + "at-sender": true, + "quote-origin": true, + "track-function-calls": false, + "remove-think": false + } + } + }, + "changed_paths": [ + "ai.runner.runner", + "ai.runner.id", + "ai.runner_config", + "ai.local-agent", + "ai.dify-service-api", + "ai.coze-api", + "ai.dashscope-app-api", + "ai.n8n-service-api", + "ai.langflow-api", + "ai.deerflow-api", + "ai.tbox-app-api", + "ai.weknora-api" + ], + "blockers": [], + "warnings": [ + { + "code": "external.state_validation_required", + "field": "ai.runner" + }, + { + "code": "migration.history_reset", + "field": "ai.runner" + }, + { + "code": "migration.new_defaults", + "field": "ai.tbox-app-api" + }, + { + "code": "migration.legacy_sections_archived", + "field": "ai" + }, + { + "code": "migration.identity_preserved", + "field": "ai.tbox-app-api" + }, + { + "code": "migration.output_policy_copied", + "field": "output.misc.remove-think" + } + ] + } + }, + { + "legacy_runner": "weknora-api", + "source": { + "trigger": { + "group-respond-rules": { + "at": true, + "prefix": [ + "ai" + ], + "regexp": [], + "random": 0.0 + }, + "access-control": { + "mode": "blacklist", + "blacklist": [], + "whitelist": [] + }, + "ignore-rules": { + "prefix": [], + "regexp": [] + }, + "message-aggregation": { + "enabled": false, + "delay": 1.5 + }, + "misc": { + "combine-quote-message": true + } + }, + "safety": { + "content-filter": { + "scope": "all", + "check-sensitive-words": true + }, + "rate-limit": { + "window-length": 60, + "limitation": 60, + "strategy": "drop" + } + }, + "ai": { + "runner": { + "runner": "weknora-api", + "expire-time": 0 + }, + "local-agent": { + "model": { + "primary": "synthetic-model", + "fallbacks": [] + }, + "max-round": 10, + "prompt": [ + { + "role": "system", + "content": "Synthetic prompt" + } + ], + "enable-all-tools": false, + "tools": [] + }, + "dify-service-api": { + "base-url": "https://dify.invalid/v1", + "api-key": "synthetic-key", + "app-type": "chat", + "base-prompt": "Synthetic prompt" + }, + "coze-api": { + "api-key": "synthetic-key", + "bot-id": "synthetic-bot", + "api-base": "https://api.coze.cn", + "auto_save_history": false, + "timeout": 120 + }, + "dashscope-app-api": { + "api-key": "synthetic-key", + "app-id": "synthetic-app", + "app-type": "agent", + "references_quote": "Synthetic references" + }, + "n8n-service-api": { + "webhook-url": "https://n8n.invalid/hook?token=synthetic", + "auth-type": "none", + "timeout": 120, + "output-key": "response", + "response-handling": "reply" + }, + "langflow-api": { + "base-url": "https://langflow.invalid", + "api-key": "synthetic-key", + "flow-id": "synthetic-flow", + "input_type": "chat", + "output_type": "chat", + "tweaks": "{}" + }, + "deerflow-api": { + "api-base": "https://deerflow.invalid", + "api-key": "synthetic-key", + "auth-header": "", + "assistant-id": "lead_agent", + "model-name": "synthetic-model", + "thinking-enabled": false, + "plan-mode": false, + "subagent-enabled": false, + "max-concurrent-subagents": 3, + "timeout": 300, + "recursion-limit": 1000 + }, + "tbox-app-api": { + "api-key": "synthetic-key", + "app-id": "synthetic-app" + }, + "weknora-api": { + "base-url": "https://weknora.invalid/api/v1", + "api-key": "synthetic-key", + "app-type": "agent", + "knowledge-base-ids": [ + "remote-kb" + ], + "web-search-enabled": false + } + }, + "output": { + "long-text-processing": { + "threshold": 1000, + "strategy": "none", + "font-path": "" + }, + "force-delay": { + "min": 0, + "max": 0 + }, + "misc": { + "exception-handling": "show-hint", + "failure-hint": "Request failed.", + "at-sender": true, + "quote-origin": true, + "track-function-calls": false, + "remove-think": false + } + } + }, + "plan": { + "state": "ready", + "legacy_runner": "weknora-api", + "target_runner_id": "plugin:langbot-team/WeKnoraAgent/default", + "target_plugin": { + "author": "langbot-team", + "name": "WeKnoraAgent", + "version": "0.1.7" + }, + "config": { + "trigger": { + "group-respond-rules": { + "at": true, + "prefix": [ + "ai" + ], + "regexp": [], + "random": 0.0 + }, + "access-control": { + "mode": "blacklist", + "blacklist": [], + "whitelist": [] + }, + "ignore-rules": { + "prefix": [], + "regexp": [] + }, + "message-aggregation": { + "enabled": false, + "delay": 1.5 + }, + "misc": { + "combine-quote-message": true + } + }, + "safety": { + "content-filter": { + "scope": "all", + "check-sensitive-words": true + }, + "rate-limit": { + "window-length": 60, + "limitation": 60, + "strategy": "drop" + } + }, + "ai": { + "runner": { + "expire-time": 0, + "id": "plugin:langbot-team/WeKnoraAgent/default" + }, + "runner_config": { + "plugin:langbot-team/WeKnoraAgent/default": { + "knowledge-base-ids": [ + "remote-kb" + ], + "web-search-enabled": false, + "timeout": 120, + "base-prompt": "请回答用户的问题。", + "advanced-settings": false, + "base-url": "https://weknora.invalid/api/v1", + "api-key": "synthetic-key", + "app-type": "agent", + "enable-all-tools": false, + "tools": [], + "knowledge-bases": [], + "mcp-resources": [], + "mcp-resource-agent-read-enabled": false, + "agent-id": "builtin-smart-reasoning" + } + } + }, + "output": { + "long-text-processing": { + "threshold": 1000, + "strategy": "none", + "font-path": "" + }, + "force-delay": { + "min": 0, + "max": 0 + }, + "misc": { + "exception-handling": "show-hint", + "failure-hint": "Request failed.", + "at-sender": true, + "quote-origin": true, + "track-function-calls": false, + "remove-think": false + } + } + }, + "changed_paths": [ + "ai.runner.runner", + "ai.runner.id", + "ai.runner_config", + "ai.local-agent", + "ai.dify-service-api", + "ai.coze-api", + "ai.dashscope-app-api", + "ai.n8n-service-api", + "ai.langflow-api", + "ai.deerflow-api", + "ai.tbox-app-api", + "ai.weknora-api" + ], + "blockers": [], + "warnings": [ + { + "code": "external.state_validation_required", + "field": "ai.runner" + }, + { + "code": "migration.history_reset", + "field": "ai.runner" + }, + { + "code": "migration.new_defaults", + "field": "ai.weknora-api" + }, + { + "code": "migration.legacy_sections_archived", + "field": "ai" + }, + { + "code": "weknora.session_title_changed", + "field": "ai.weknora-api" + } + ] + } + } + ], + "ai_schema": { + "name": "ai", + "label": { + "en_US": "AI Feature", + "zh_Hans": "AI 能力" + }, + "stages": [ + { + "name": "runner", + "label": { + "en_US": "Runner", + "zh_Hans": "运行方式" + }, + "description": { + "en_US": "Strategy to call AI to process messages", + "zh_Hans": "调用 AI 处理消息的方式" + }, + "config": [ + { + "name": "id", + "label": { + "en_US": "Runner", + "zh_Hans": "运行器" + }, + "type": "select", + "required": true + }, + { + "name": "expire-time", + "label": { + "en_US": "Conversation expire time (seconds)", + "zh_Hans": "单个对话过期时间(秒)" + }, + "description": { + "en_US": "Maximum idle time of a single conversation, measured from the last message/preprocess update time. When a new message arrives and the current conversation has been idle for longer than this value, the existing external conversation id is discarded and a new one is started automatically — the user does not need to trigger a reset manually. Set to 0 to disable expiry (conversations live until the user or another mechanism resets them).", + "zh_Hans": "单个对话的最长空闲时间,从最后一条消息/preprocess 更新时间开始计算。 当新消息到达且当前对话空闲时间已超过该值时,旧的外部对话 ID 会被自动丢弃并开启新对话, 用户无需手动重置。设置为 0 表示不限制过期时间(对话会一直保留,直到用户或其他机制主动重置)。" + }, + "type": "integer", + "required": true, + "default": 0 + } + ] + } + ] + }, + "output_schema": { + "name": "output", + "label": { + "en_US": "Output Processing", + "zh_Hans": "输出处理" + }, + "stages": [ + { + "name": "long-text-processing", + "label": { + "en_US": "Long Text Processing", + "zh_Hans": "长文本处理" + }, + "config": [ + { + "name": "threshold", + "label": { + "en_US": "Threshold", + "zh_Hans": "阈值" + }, + "description": { + "en_US": "The threshold of the long text", + "zh_Hans": "超过此长度的文本将被处理" + }, + "type": "integer", + "required": true, + "default": 1000 + }, + { + "name": "strategy", + "label": { + "en_US": "Strategy", + "zh_Hans": "策略" + }, + "description": { + "en_US": "The strategy of the long text", + "zh_Hans": "长文本的处理策略" + }, + "type": "select", + "required": true, + "default": "none", + "options": [ + { + "name": "forward", + "label": { + "en_US": "Forward Message Component", + "zh_Hans": "转换为转发消息组件(部分平台不支持)" + } + }, + { + "name": "image", + "label": { + "en_US": "Convert to Image", + "zh_Hans": "转换为图片" + } + }, + { + "name": "none", + "label": { + "en_US": "None", + "zh_Hans": "不处理" + } + } + ] + }, + { + "name": "font-path", + "label": { + "en_US": "Font Path", + "zh_Hans": "字体路径" + }, + "description": { + "en_US": "The path of the font to be used when converting to image", + "zh_Hans": "选用转换为图片时,所使用的字体路径" + }, + "type": "string", + "required": false, + "default": "" + } + ] + }, + { + "name": "force-delay", + "label": { + "en_US": "Force Delay", + "zh_Hans": "强制延迟" + }, + "description": { + "en_US": "Force the output to be delayed for a while", + "zh_Hans": "强制延迟一段时间后再回复给用户" + }, + "config": [ + { + "name": "min", + "label": { + "en_US": "Min Seconds", + "zh_Hans": "最小秒数" + }, + "type": "integer", + "required": true, + "default": 0 + }, + { + "name": "max", + "label": { + "en_US": "Max Seconds", + "zh_Hans": "最大秒数" + }, + "type": "integer", + "required": true, + "default": 0 + } + ] + }, + { + "name": "misc", + "label": { + "en_US": "Misc", + "zh_Hans": "杂项" + }, + "config": [ + { + "name": "exception-handling", + "label": { + "en_US": "Exception Handling Strategy", + "zh_Hans": "异常处理策略" + }, + "description": { + "en_US": "Controls how error messages are displayed to the user when an AI request fails", + "zh_Hans": "控制 AI 请求失败时向用户展示错误信息的方式" + }, + "type": "select", + "required": true, + "default": "show-hint", + "options": [ + { + "name": "show-error", + "label": { + "en_US": "Show Full Error", + "zh_Hans": "显示完整报错信息" + } + }, + { + "name": "show-hint", + "label": { + "en_US": "Show Failure Hint", + "zh_Hans": "仅文字提示" + } + }, + { + "name": "hide", + "label": { + "en_US": "Hide All", + "zh_Hans": "不显示任何异常信息" + } + } + ] + }, + { + "name": "failure-hint", + "label": { + "en_US": "Failure Hint Text", + "zh_Hans": "失败提示文本" + }, + "description": { + "en_US": "The text to display when a request fails. Only effective when Exception Handling Strategy is set to \"Show Failure Hint\"", + "zh_Hans": "请求失败时显示的提示文本,仅在异常处理策略设置为\"仅文字提示\"时生效" + }, + "type": "string", + "required": false, + "default": "Request failed." + }, + { + "name": "at-sender", + "label": { + "en_US": "At Sender", + "zh_Hans": "在群聊回复中@发送者" + }, + "type": "boolean", + "required": true, + "default": true + }, + { + "name": "quote-origin", + "label": { + "en_US": "Quote Origin Message", + "zh_Hans": "引用原消息" + }, + "type": "boolean", + "required": true, + "default": false + }, + { + "name": "track-function-calls", + "label": { + "en_US": "Track Function Calls", + "zh_Hans": "跟踪函数调用" + }, + "description": { + "en_US": "If enabled, the function calls will be tracked and output to the user", + "zh_Hans": "启用后,Agent 每次调用工具时都会输出一个提示给用户" + }, + "type": "boolean", + "required": true, + "default": false + }, + { + "name": "remove-think", + "label": { + "en_US": "Remove CoT", + "zh_Hans": "删除思维链" + }, + "description": { + "en_US": "If enabled, LangBot will remove the LLM thought content in response. Note: When using streaming response, removing CoT may cause the first token to wait for a long time.", + "zh_Hans": "如果启用,将自动删除大模型回复中的模型思考内容。注意:当您使用流式响应时,删除思维链可能会导致首个 Token 的等待时间过长" + }, + "type": "boolean", + "required": true, + "default": false + } + ] + } + ] + } +} diff --git a/web/tests/e2e/mcp-oauth-required.spec.ts b/web/tests/e2e/mcp-oauth-required.spec.ts new file mode 100644 index 000000000..61dc5fa5c --- /dev/null +++ b/web/tests/e2e/mcp-oauth-required.spec.ts @@ -0,0 +1,74 @@ +import { expect, test } from '@playwright/test'; + +import { installLangBotApiMocks } from './fixtures/langbot-api'; + +function ok(data: unknown) { + return { + code: 0, + message: 'ok', + data, + timestamp: Date.now(), + }; +} + +test('shows an actionable OAuth-required state after a transient MCP test', async ({ + page, +}, testInfo) => { + await installLangBotApiMocks(page, { authenticated: true }); + + await page.route('**/api/v1/mcp/servers/_/test', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(ok({ task_id: 2363 })), + }); + }); + await page.route('**/api/v1/system/tasks/2363', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify( + ok({ + runtime: { + done: true, + exception: 'Connection failed', + state: 'error', + }, + task_context: { + current_action: 'Testing MCP server', + log: '', + metadata: { + runtime_info: { + status: 'error', + error_phase: 'oauth_required', + retry_count: 1, + tool_count: 0, + tools: [], + resource_count: 0, + resources: [], + }, + }, + }, + }), + ), + }); + }); + + await page.goto('/home/mcp?id=new'); + await page.locator('input[name="name"]').fill('oauth-protected-mcp'); + await page + .locator('input[name="url"]') + .fill('https://mcp.example.test/protected'); + await page.getByRole('button', { name: /^Test$/ }).click(); + + await expect(page.getByText('OAuth authorization required')).toBeVisible(); + await expect( + page.getByText( + 'This MCP server requires OAuth sign-in. OAuth sign-in is not available yet; add an Authorization header manually if the server supports it.', + ), + ).toBeVisible(); + await page.screenshot({ + path: testInfo.outputPath('oauth-required.png'), + fullPage: true, + }); +}); diff --git a/web/tests/e2e/monitoring-turns.spec.ts b/web/tests/e2e/monitoring-turns.spec.ts index 89c98554f..2abc76eb6 100644 --- a/web/tests/e2e/monitoring-turns.spec.ts +++ b/web/tests/e2e/monitoring-turns.spec.ts @@ -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((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', diff --git a/web/tests/e2e/pipeline-migration.spec.ts b/web/tests/e2e/pipeline-migration.spec.ts new file mode 100644 index 000000000..d7b88e022 --- /dev/null +++ b/web/tests/e2e/pipeline-migration.spec.ts @@ -0,0 +1,698 @@ +import { expect, test, type Page, type Route } from '@playwright/test'; +import { readFileSync } from 'node:fs'; + +const canonicalConfig = JSON.parse( + readFileSync('../src/langbot/templates/default-pipeline-config.json', 'utf8'), +); +import type { + MigrationInstallation, + PipelineMigrationItem, + PipelineMigrationResult, + PipelineMigrationState, +} from '../../src/app/infra/entities/api/pipeline-migration'; +import { + installLangBotApiMocks, + makeWorkspaceEntry, +} from './fixtures/langbot-api'; + +const root = '/api/v1/pipelines/_/migration'; +const runner = 'plugin:langbot-team/DifyAgent/default'; +const row = ( + pipeline_uuid: string, + state: PipelineMigrationState = 'ready', +): PipelineMigrationItem => ({ + pipeline_uuid, + name: `Legacy ${pipeline_uuid}`, + state, + legacy_runner: 'dify-service-api', + target_runner_id: runner, + target_plugin: { + author: 'langbot-team', + name: 'DifyAgent', + version: '1.0.0', + }, + changed_paths: ['ai.runner.id'], + blockers: [], + warnings: [], + preview_token: state === 'ready' ? `token-${pipeline_uuid}` : null, +}); +const reply = (route: Route, data: unknown) => + route.fulfill({ json: { code: 0, data } }); + +async function setup( + page: Page, + options: { viewer?: boolean; current?: boolean; cloud?: boolean } = {}, +) { + const workspace = makeWorkspaceEntry( + 'workspace-playwright', + 'Migration workspace', + options.cloud ? 'cloud_projection' : 'local', + ); + if (options.viewer) + workspace.permissions = ['resource.view', 'workspace.view']; + await installLangBotApiMocks(page, { + authenticated: true, + workspaces: [workspace], + }); + await page.routeWebSocket('**/api/v1/pipelines/**/ws/connect**', (ws) => { + ws.onMessage((raw) => { + if (JSON.parse(String(raw)).type === 'authenticate') + ws.send( + JSON.stringify({ + type: 'connected', + connection_id: 'migration-test', + session_type: 'person', + }), + ); + }); + }); + const state = { + items: [row('one'), row('two')], + posts: [] as unknown[], + writes: [] as unknown[], + previews: 0, + polls: 0, + installations: [] as MigrationInstallation[], + metadataReads: 0, + pipelineReads: 0, + executeStatus: 200, + pollLost: false, + done: true, + taskException: false, + results: [ + { pipeline_uuid: 'one', state: 'migrated', code: null }, + { pipeline_uuid: 'two', state: 'migrated', code: null }, + ] as PipelineMigrationResult[], + holdExecute: null as Promise | null, + holdPreview: null as Promise | null, + holdPoll: null as Promise | null, + }; + let config = options.current + ? { + ai: { + runner: { id: 'plugin:langbot-team/LocalAgent/default' }, + runner_config: {}, + }, + } + : { + ai: { + runner: { runner: 'dify-service-api' }, + 'dify-service-api': { 'api-key': 'fixture-not-a-secret' }, + }, + }; + await page.route(`**${root}/preview`, async (route) => { + state.previews++; + const items = structuredClone(state.items); + const scope = + route.request().headers()['x-workspace-id'] || 'workspace-playwright'; + if (state.holdPreview) await state.holdPreview; + await reply(route, { + planner_version: '1', + workspace_uuid: scope, + items, + total: items.length, + }); + }); + await page.route(`**${root}/execute`, async (route) => { + state.posts.push(route.request().postDataJSON()); + if (state.holdExecute) await state.holdExecute; + if (state.executeStatus !== 200) + return route.fulfill({ + status: state.executeStatus, + json: { code: state.executeStatus, msg: 'unsafe-upstream-secret' }, + }); + await reply(route, { + task_id: 411, + pipeline_uuids: state.items + .filter((r) => !['already_current', 'not_legacy'].includes(r.state)) + .map((r) => r.pipeline_uuid), + }); + }); + await page.route('**/api/v1/system/tasks/411', async (route) => { + state.polls++; + if (state.holdPoll) await state.holdPoll; + if (state.pollLost) + return route.fulfill({ status: 404, json: { code: 404 } }); + if ( + state.done && + state.results.some( + (item) => item.pipeline_uuid === 'one' && item.state === 'migrated', + ) + ) { + config = { + ai: { + runner: { id: 'plugin:langbot-team/LocalAgent/default' }, + runner_config: {}, + }, + }; + } + await reply(route, { + id: 411, + runtime: { + done: state.done, + exception: state.taskException ? 'unsafe-upstream-secret' : null, + }, + task_context: { + metadata: { + kind: 'pipeline_migration', + results: state.results, + installations: state.installations, + }, + }, + }); + }); + await page.route('**/api/v1/pipelines/one', async (route) => { + if (route.request().method() !== 'GET') + state.writes.push(route.request().postDataJSON()); + state.pipelineReads++; + await reply(route, { + pipeline: { + uuid: 'one', + name: 'Legacy one', + description: 'Preserved metadata', + emoji: '⚙️', + config, + is_default: false, + }, + }); + }); + await page.route('**/api/v1/pipelines/_/metadata', async (route) => { + state.metadataReads++; + await route.fallback(); + }); + return state; +} + +async function open(page: Page, detail = false) { + await page.goto(`/home/pipelines${detail ? '?id=one' : ''}`); + await page + .getByRole('button', { name: 'Review migration', exact: true }) + .click(); + return page.getByRole('dialog', { name: 'Pipeline migration' }); +} +test('legacy detail never mounts editable runner defaults or debug autosave, metadata stays available', async ({ + page, +}) => { + const state = await setup(page); + await page.goto('/home/pipelines?id=one'); + await expect( + page.getByText( + 'Legacy configuration is read-only until migration. Save and debug are unavailable to prevent implicit conversion.', + ), + ).toBeVisible(); + await expect(page.getByText('Preserved metadata')).toBeVisible(); + await expect(page.getByRole('tab', { name: 'AI', exact: true })).toHaveCount( + 0, + ); + await expect( + page.getByRole('button', { name: 'Save', exact: true }), + ).toHaveCount(0); + expect(state.writes).toEqual([]); + expect(state.posts).toEqual([]); +}); + +const installButton = (page: Page) => + page.getByRole('button', { + name: 'Install plugins and migrate', + exact: true, + }); +const dataButton = (page: Page) => + page.getByRole('button', { name: 'Migrate data only', exact: true }); + +test('compact assistant hides details, has no checkboxes and does not execute on open or close', async ({ + page, +}) => { + const state = await setup(page); + const dialog = await open(page); + await expect(dialog.getByRole('checkbox')).toHaveCount(0); + await expect(dialog.getByText('Legacy one', { exact: true })).toHaveCount(0); + await expect(installButton(page)).toBeEnabled(); + await expect(dataButton(page)).toBeEnabled(); + await dialog.getByRole('button', { name: 'View pipelines' }).click(); + await expect(dialog.getByText('Legacy one', { exact: true })).toBeVisible(); + await expect(dialog).not.toContainText('ai.runner.id'); + await dialog + .getByRole('button', { name: 'Close', exact: true }) + .first() + .click(); + expect(state.posts).toEqual([]); +}); + +for (const install of [true, false]) { + test(`one click migrates all pipelines, install_plugins=${install}`, async ({ + page, + }) => { + const state = await setup(page); + state.items.push(row('missing', 'needs_plugin')); + state.results.push({ + pipeline_uuid: 'missing', + state: 'migrated', + code: install ? null : 'data_only', + }); + const dialog = await open(page); + await (install ? installButton(page) : dataButton(page)).click(); + await expect(dialog).toContainText('3 migrated; 0 need attention.'); + expect(state.posts).toEqual([ + { confirmed: true, all: true, install_plugins: install }, + ]); + expect(state.writes).toEqual([]); + if (!install) + await expect(dialog).toContainText( + 'Install the corresponding runner plugins yourself', + ); + await expect(installButton(page)).toHaveCount(0); + await expect(dataButton(page)).toHaveCount(0); + await Promise.all([ + page.waitForEvent('load'), + dialog.getByRole('button', { name: 'Done', exact: true }).click(), + ]); + await expect(dialog).toHaveCount(0); + expect(state.posts).toHaveLength(1); + }); +} + +test('read-only users can inspect but cannot migrate', async ({ page }) => { + const state = await setup(page, { viewer: true }); + await open(page); + await expect(installButton(page)).toBeDisabled(); + await expect(dataButton(page)).toBeDisabled(); + expect(state.posts).toEqual([]); +}); + +test('double clicks do not submit duplicate tasks; close does not cancel running task', async ({ + page, +}) => { + const state = await setup(page); + state.done = false; + const dialog = await open(page); + await installButton(page).dblclick(); + await expect(dialog.getByRole('status')).toContainText( + 'Installing required plugins', + ); + await expect(installButton(page)).toHaveCount(0); + await dialog + .getByRole('button', { name: 'Close', exact: true }) + .first() + .click(); + await page + .getByRole('button', { name: 'Review migration', exact: true }) + .click(); + await expect(dialog.getByRole('status')).toContainText( + 'Installing required plugins', + ); + expect(state.posts).toHaveLength(1); + state.done = true; + await expect(dialog).toContainText('2 migrated; 0 need attention.'); +}); + +test('partial failures are reported without exposing upstream errors, and reopening allows retry', async ({ + page, +}) => { + const state = await setup(page); + state.results = [ + { pipeline_uuid: 'one', state: 'migrated', code: null }, + { pipeline_uuid: 'two', state: 'blocked', code: 'plugin_install_failed' }, + ]; + const dialog = await open(page); + await installButton(page).click(); + await expect(dialog).toContainText('1 migrated; 1 need attention.'); + await dialog.getByRole('button', { name: 'View pipelines' }).click(); + await expect(dialog.getByTestId('migration-result-two')).toContainText( + 'Plugin installation failed', + ); + await expect(dialog).not.toContainText('unsafe-upstream-secret'); + await expect(installButton(page)).toBeEnabled(); + await expect( + dialog.getByRole('button', { name: 'Refresh preview' }), + ).toHaveCount(0); + await dialog + .getByRole('button', { name: 'Close', exact: true }) + .first() + .click(); + await page + .getByRole('button', { name: 'Review migration', exact: true }) + .click(); + await expect(installButton(page)).toBeEnabled(); + expect(state.posts).toHaveLength(1); +}); + +test('all pipelines including more than fifty can be migrated, expanded list scrolls without moving actions', async ({ + page, +}) => { + await page.setViewportSize({ width: 1024, height: 650 }); + const state = await setup(page); + state.items = Array.from({ length: 65 }, (_, i) => row(`many-${i}`)); + state.results = state.items.map((r) => ({ + pipeline_uuid: r.pipeline_uuid, + state: 'migrated', + code: null, + })); + const dialog = await open(page); + await dialog.getByRole('button', { name: 'View pipelines' }).click(); + const viewport = dialog + .getByTestId('migration-scroll-area') + .locator('[data-slot="scroll-area-viewport"]'); + const before = await installButton(page).boundingBox(); + const bounds = await viewport.boundingBox(); + await page.mouse.move( + bounds!.x + bounds!.width / 2, + bounds!.y + bounds!.height / 2, + ); + await page.mouse.wheel(0, 20000); + await expect + .poll(() => + viewport.evaluate( + (el) => el.scrollTop + el.clientHeight >= el.scrollHeight - 2, + ), + ) + .toBe(true); + await expect( + dialog.getByText('Legacy many-64', { exact: true }), + ).toBeInViewport(); + expect((await installButton(page).boundingBox())!.y).toBeCloseTo( + before!.y, + 0, + ); + await expect(installButton(page)).toBeInViewport(); + await dataButton(page).click(); + await expect(dialog).toContainText('65 migrated; 0 need attention.'); + expect(state.posts).toEqual([ + { confirmed: true, all: true, install_plugins: false }, + ]); +}); + +for (const failure of [ + 'missing', + 'extra', + 'duplicate', + 'invalid-state', + 'pending', + 'poll-lost', + 'task-error', +]) { + test(`task result boundary: ${failure}`, async ({ page }) => { + const state = await setup(page); + if (failure === 'missing') state.results.pop(); + if (failure === 'extra') + state.results.push({ + pipeline_uuid: 'foreign', + state: 'migrated', + code: null, + }); + if (failure === 'duplicate') state.results.push(state.results[0]); + if (failure === 'invalid-state') + state.results[0].state = 'bogus' as PipelineMigrationResult['state']; + if (failure === 'pending') state.results[0].state = 'pending'; + if (failure === 'poll-lost') state.pollLost = true; + if (failure === 'task-error') state.taskException = true; + const dialog = await open(page); + await installButton(page).click(); + await expect(dialog).toContainText( + failure === 'task-error' ? 'Task failed' : 'Task observation lost', + ); + await expect(installButton(page)).toBeDisabled(); + await expect(dialog).not.toContainText('unsafe-upstream-secret'); + expect(state.posts).toHaveLength(1); + }); +} + +test('workspace changes discard previous task results and stop polling', async ({ + page, +}) => { + const state = await setup(page); + let release!: () => void; + state.holdPoll = new Promise((resolve) => { + release = resolve; + }); + await open(page); + await installButton(page).click(); + await expect.poll(() => state.polls).toBe(1); + await page.evaluate(async () => { + const path = '/src/app/infra/http/currentWorkspaceStore.ts'; + const store = await import(path); + const current = store.getCurrentWorkspaceSnapshot(); + store.setCurrentWorkspaceSnapshot({ ...current, placement_generation: 2 }); + }); + release(); + await expect( + page.getByRole('dialog', { name: 'Pipeline migration' }), + ).toHaveCount(0); + expect(state.posts).toHaveLength(1); +}); + +for (const [language, title, review, action] of [ + ['zh-Hans', '流水线迁移', '检查迁移', '自动安装插件并迁移'], + [ + 'ja-JP', + 'パイプライン移行', + '移行を確認', + 'プラグインを自動インストールして移行', + ], +]) { + test(`localized automatic assistant (${language})`, async ({ page }) => { + await setup(page); + await page.addInitScript( + (locale) => localStorage.setItem('langbot_language', locale), + language, + ); + await page.goto('/home/pipelines'); + await page.getByRole('button', { name: review, exact: true }).click(); + const dialog = page.getByRole('dialog', { name: title }); + await expect( + dialog.getByRole('button', { name: action, exact: true }), + ).toBeEnabled(); + await expect(dialog).not.toContainText('pipelineMigration.'); + }); +} + +async function actualAgentRoute(page: Page) { + const state = await setup(page); + const agent = { + uuid: 'one', + name: 'Legacy one', + description: 'Preserved metadata', + emoji: '⚙️', + kind: 'pipeline', + config: canonicalConfig, + }; + let listReads = 0; + await page.route('**/api/v1/agents', async (route) => { + listReads++; + await reply(route, { agents: [agent] }); + }); + await page.route('**/api/v1/agents/one', (route) => reply(route, { agent })); + return { state, listReads: () => listReads }; +} + +async function clickSidebarPipeline(page: Page) { + const sidebar = page.locator('[data-slot="sidebar"]'); + const entry = sidebar.getByText('Legacy one', { exact: true }); + if (!(await entry.isVisible())) { + await sidebar.getByText('Processors', { exact: true }).click(); + } + await entry.click(); + await expect(page).toHaveURL(/\/home\/agents\?id=one$/); +} + +test('SPEC canonical empty current detail allows selecting and saving a runner', async ({ + page, +}) => { + const { state } = await actualAgentRoute(page); + await page.route('**/api/v1/pipelines/one', async (route) => { + if (route.request().method() !== 'GET') + state.writes.push(route.request().postDataJSON()); + await reply(route, { + pipeline: { + uuid: 'one', + name: 'Legacy one', + description: 'Preserved metadata', + emoji: '⚙️', + config: canonicalConfig, + is_default: false, + }, + }); + }); + await page.goto('/home/agents?id=one'); + await expect( + page.getByRole('tab', { name: 'AI', exact: true }), + ).toBeVisible(); + await page.getByRole('tab', { name: 'AI', exact: true }).click(); + const selector = page.getByRole('combobox', { name: 'Runner', exact: true }); + await selector.click(); + await page.getByRole('option', { name: /Local Agent/ }).click(); + const save = page.getByRole('button', { name: 'Save', exact: true }); + await expect(save).toBeEnabled(); + await save.click(); + await expect + .poll(() => state.writes) + .toMatchObject([ + { + config: { + ai: { + runner: { id: 'plugin:langbot-team/LocalAgent/default' }, + }, + }, + }, + ]); + expect(state.posts).toEqual([]); +}); + +test('SPEC actual legacy on second read remains guarded on the sidebar route', async ({ + page, +}) => { + const { state } = await actualAgentRoute(page); + let reads = 0; + await page.route('**/api/v1/pipelines/one', async (route) => { + if (route.request().method() !== 'GET') + state.writes.push(route.request().postDataJSON()); + reads++; + await reply(route, { + pipeline: { + uuid: 'one', + name: 'Legacy one', + description: 'Preserved metadata', + emoji: '⚙️', + config: + reads === 1 + ? canonicalConfig + : { ai: { runner: { runner: 'local-agent' } } }, + }, + }); + }); + await page.goto('/home'); + await clickSidebarPipeline(page); + await expect.poll(() => reads).toBeGreaterThan(1); + await expect( + page.getByText( + 'Legacy configuration is read-only until migration. Save and debug are unavailable to prevent implicit conversion.', + ), + ).toBeVisible(); + await expect(page.getByRole('tab', { name: 'AI', exact: true })).toHaveCount( + 0, + ); + await expect( + page.getByRole('button', { name: 'Save', exact: true }), + ).toHaveCount(0); + expect(state.writes).toEqual([]); +}); + +test('Box reuse notice stays concise and template validation has an actionable message', async ({ + page, +}) => { + const state = await setup(page); + state.items[0].legacy_runner = 'local-agent'; + state.items[0].warnings = [ + { + code: 'local.box_state_reset', + field: 'ai.local-agent.box-session-id-template', + }, + ]; + state.items[1].state = 'blocked'; + state.items[1].blockers = [ + { + code: 'local.box_template_invalid', + field: 'ai.local-agent.box-session-id-template', + }, + ]; + const dialog = await open(page); + await expect( + dialog.getByText('Sandbox reuse settings are preserved.', { exact: false }), + ).not.toBeVisible(); + await dialog.getByRole('button', { name: 'View pipelines' }).click(); + await expect( + dialog.getByText('Sandbox reuse settings are preserved.', { exact: false }), + ).toBeVisible(); + await expect( + dialog.getByText('Invalid sandbox reuse template.', { exact: false }), + ).toBeVisible(); + await expect(dialog).not.toContainText('ai.local-agent'); + await expect(dialog.getByRole('checkbox')).toHaveCount(0); +}); + +test('installation progress remains inspectable during work and after a missing release failure', async ({ + page, +}) => { + const state = await setup(page); + await page.route( + '**/api/v1/marketplace/plugins/langbot-team/DifyAgent', + (route) => + reply(route, { + plugin: { + author: 'langbot-team', + name: 'DifyAgent', + label: { en_US: 'Dify Agent' }, + description: { en_US: 'Connect your Dify workflows.' }, + icon: 'https://market.test/dify.svg', + }, + }), + ); + await page.route('https://market.test/dify.svg', (route) => + route.fulfill({ + contentType: 'image/svg+xml', + body: '', + }), + ); + state.done = false; + state.results = state.items.map((item) => ({ + pipeline_uuid: item.pipeline_uuid, + state: 'pending', + code: null, + })); + state.installations = [ + { + author: 'langbot-team', + name: 'DifyAgent', + version: '1.0.0', + status: 'installing', + stage: 'downloading', + code: null, + progress_percent: 15, + download_current: 1024, + download_total: 2048, + started_at: 100, + updated_at: 103, + finished_at: null, + steps: [ + { stage: 'checking', started_at: 100, finished_at: 101 }, + { stage: 'downloading', started_at: 101, finished_at: null }, + ], + }, + ]; + const dialog = await open(page); + await installButton(page).click(); + await expect(dialog.getByRole('progressbar')).toHaveAttribute( + 'aria-valuenow', + '15', + ); + await expect(dialog.getByText('Dify Agent', { exact: true })).toBeVisible(); + await expect( + dialog.getByText('Connect your Dify workflows.', { exact: true }), + ).toBeVisible(); + await expect(dialog.getByRole('img', { name: 'Dify Agent' })).toBeVisible(); + await dialog.getByRole('button', { name: 'Installation details' }).click(); + await expect(dialog.getByText('Downloaded 1.0 KB / 2.0 KB')).toBeVisible(); + await dialog.getByRole('button', { name: 'View pipelines' }).click(); + await expect(dialog.getByText('Legacy one', { exact: true })).toBeVisible(); + state.installations[0] = { + ...state.installations[0], + status: 'failed', + code: 'plugin_version_unavailable', + finished_at: 104, + updated_at: 104, + }; + state.results = state.items.map((item) => ({ + pipeline_uuid: item.pipeline_uuid, + state: 'blocked', + code: 'plugin_version_unavailable', + })); + state.done = true; + await expect( + dialog + .getByTestId('migration-installations') + .getByText( + 'The required plugin version is not available in the marketplace. Retry after it is published, or migrate data only.', + ), + ).toBeVisible(); + await expect(dialog.getByRole('progressbar')).toHaveCount(0); + await expect(dialog.getByText('Downloaded 1.0 KB / 2.0 KB')).toBeVisible(); +}); diff --git a/web/tests/e2e/provider-delete-footer.spec.ts b/web/tests/e2e/provider-delete-footer.spec.ts new file mode 100644 index 000000000..49d696a9e --- /dev/null +++ b/web/tests/e2e/provider-delete-footer.spec.ts @@ -0,0 +1,340 @@ +import { expect, test, type Page, type Route } from '@playwright/test'; +import { installLangBotApiMocks } from './fixtures/langbot-api'; + +// UI fixtures only: no real provider/model deletion or subscription authentication. +async function fixture(page: Page, requester = 'openai', empty = false) { + await installLangBotApiMocks(page, { authenticated: true }); + const provider = { + uuid: 'provider-delete-fixture', + name: 'Delete fixture provider', + requester, + base_url: 'https://example.test/v1', + api_keys: [], + llm_count: empty ? 0 : 1, + embedding_count: empty ? 0 : 1, + rerank_count: empty ? 0 : 1, + }; + const state = { + deleted: false, + fail: false, + deletes: [] as string[], + reads: [] as string[], + release: undefined as (() => void) | undefined, + hold: false, + }; + const ok = (route: Route, data: unknown) => + route.fulfill({ json: { code: 0, data } }); + await page.route('**/api/v1/provider/**', async (route) => { + const url = new URL(route.request().url()); + const path = url.pathname; + const method = route.request().method(); + if (method === 'DELETE') { + state.deletes.push(path + url.search); + if (state.hold) + await new Promise((resolve) => { + state.release = resolve; + }); + if (state.fail) + return route.fulfill({ + status: 409, + json: { code: 409, msg: 'Fixture deletion blocked; try again.' }, + }); + state.deleted = true; + return ok(route, {}); + } + if (path.endsWith('/icon')) + return route.fulfill({ + contentType: 'image/svg+xml', + body: '', + }); + if (path.endsWith('/requesters')) + return ok(route, { + requesters: ['openai', 'openai-codex'].map((name) => ({ + name, + label: { en_US: name }, + description: { en_US: '' }, + spec: { + provider_category: 'manufacturer', + support_type: ['llm', 'embedding', 'rerank'], + config: [], + }, + })), + }); + if (method === 'GET') state.reads.push(path + url.search); + if (path.endsWith('/providers')) + return ok(route, { providers: state.deleted ? [] : [provider] }); + if (path.endsWith('/codex/status')) + return ok(route, { + status: 'connected', + connected: true, + expires_at: null, + }); + if (path.includes('/models/')) { + const type = path.split('/').pop(); + return ok(route, { + models: state.deleted + ? [] + : [ + { + uuid: `fixture-${type}`, + name: `Fixture ${type} model`, + provider_uuid: provider.uuid, + provider, + abilities: [], + extra_args: {}, + }, + ], + }); + } + if (path.endsWith(provider.uuid)) return ok(route, { provider }); + return ok(route, {}); + }); + await page.goto('/home/bots'); + await page.getByRole('button', { name: 'Models', exact: true }).click(); + return state; +} +const editDialog = (page: Page) => + page.locator('[role="dialog"]').filter({ + has: page.locator('[data-slot="dialog-title"]', { + hasText: /^Edit Provider$/, + }), + }); +async function edit(page: Page) { + const card = page + .locator('[data-slot="card"]') + .filter({ hasText: 'Delete fixture provider' }); + await card.getByRole('button', { name: 'Expand', exact: true }).click(); + await expect( + card.getByText('Fixture llm model', { exact: true }), + ).toBeVisible(); + await card + .locator('button') + .filter({ has: page.locator('svg.lucide-settings') }) + .click(); + await expect(editDialog(page).locator('input[name="name"]')).toHaveValue( + 'Delete fixture provider', + ); +} + +for (const width of [1280, 320]) { + test(`confirmation stays centered throughout entry (${width}px)`, async ({ + page, + }) => { + const state = await fixture(page); + await edit(page); + await page.setViewportSize({ width, height: 900 }); + // Trigger without Playwright's post-click wait so the browser animation is + // still live. Sample its actual keyframes, not only the final screenshot. + await editDialog(page) + .getByRole('button', { name: 'Delete', exact: true }) + .evaluate((el) => (el as HTMLButtonElement).click()); + const confirmation = page.getByRole('alertdialog'); + for (const phase of ['entry']) { + const samples = await confirmation.evaluate(async (el) => { + const animations = el.getAnimations(); + if (!animations.length) + throw new Error('Expected the real dialog animation'); + await Promise.all(animations.map((a) => a.ready)); + animations.forEach((a) => a.pause()); + const samples = [0, 0.25, 0.5, 0.75, 0.99].map((fraction) => { + animations.forEach((a) => { + a.currentTime = Number(a.effect!.getTiming().duration) * fraction; + }); + const r = el.getBoundingClientRect(); + return { + x: r.x + r.width / 2, + y: r.y + r.height / 2, + left: r.left, + right: r.right, + }; + }); + animations.forEach((a) => a.finish()); + return samples; + }); + for (const sample of samples) { + expect( + Math.abs(sample.x - width / 2), + `${phase} horizontal center`, + ).toBeLessThan(1); + expect( + Math.abs(sample.y - 450), + `${phase} vertical center`, + ).toBeLessThan(1); + expect(sample.left).toBeGreaterThanOrEqual(0); + expect(sample.right).toBeLessThanOrEqual(width); + } + } + await confirmation + .getByRole('button', { name: 'Cancel', exact: true }) + .click(); + await expect(confirmation).toHaveCount(0); + expect(state.deletes).toEqual([]); + }); +} + +for (const requester of ['openai', 'openai-codex']) { + for (const width of [1280, 320]) { + test(`footer deletion confirmation cancellation and geometry (${requester}, ${width}px)`, async ({ + page, + }) => { + const state = await fixture(page, requester); + await edit(page); + await page.setViewportSize({ width, height: 900 }); + const dialog = editDialog(page); + const footer = dialog.locator('[data-slot="dialog-footer"]'); + const remove = footer.getByRole('button', { + name: 'Delete', + exact: true, + }); + await expect(remove).toBeVisible(); + for (const button of await footer.getByRole('button').all()) { + await expect(button).toBeInViewport({ ratio: 1 }); + const box = await button.boundingBox(); + expect(box!.x).toBeGreaterThanOrEqual(0); + expect(box!.x + box!.width).toBeLessThanOrEqual(width); + } + const left = await remove.boundingBox(); + const cancel = await footer + .getByRole('button', { name: 'Cancel', exact: true }) + .boundingBox(); + expect(left!.x + left!.width).toBeLessThan(cancel!.x); + await remove.click(); + const confirmation = page.getByRole('alertdialog'); + await expect(confirmation).toContainText('this provider and ALL models'); + await expect(confirmation).toContainText('cannot be undone'); + await expect(confirmation).toBeInViewport({ ratio: 1 }); + await confirmation.evaluate(async (element) => { + await Promise.all( + element.getAnimations().map((animation) => animation.finished), + ); + }); + const box = await confirmation.boundingBox(); + expect(box!.x).toBeGreaterThanOrEqual(0); + expect(box!.x + box!.width).toBeLessThanOrEqual(width); + await confirmation + .getByRole('button', { name: 'Cancel', exact: true }) + .click(); + await expect(confirmation).toHaveCount(0); + await expect(dialog).toBeVisible(); + expect(state.deletes).toEqual([]); + }); + } + test(`one awaited cascade request refreshes providers and clears models (${requester})`, async ({ + page, + }) => { + const state = await fixture(page, requester); + await edit(page); + state.hold = true; + await editDialog(page) + .getByRole('button', { name: 'Delete', exact: true }) + .click(); + const confirmation = page.getByRole('alertdialog'); + await confirmation + .getByRole('button', { name: 'Delete', exact: true }) + .click(); + await expect.poll(() => state.deletes.length).toBe(1); + await expect( + confirmation.getByRole('button', { name: 'Delete', exact: true }), + ).toBeDisabled(); + await expect( + confirmation.getByRole('button', { name: 'Cancel', exact: true }), + ).toBeDisabled(); + await expect( + editDialog(page).getByRole('button', { + name: requester === 'openai' ? 'Save' : 'Done', + exact: true, + includeHidden: true, + }), + ).toBeDisabled(); + await page.keyboard.press('Escape'); + await expect(confirmation).toBeVisible(); + state.reads = []; + state.release!(); + await expect(editDialog(page)).toHaveCount(0); + await expect( + page.getByText('Delete fixture provider', { exact: true }), + ).toHaveCount(0); + await expect( + page.getByText('Fixture llm model', { exact: true }), + ).toHaveCount(0); + expect(state.deletes).toEqual([ + '/api/v1/provider/providers/provider-delete-fixture?cascade=true', + ]); + expect(state.reads).toContain('/api/v1/provider/providers'); + }); +} + +test('failed cascade retains readable error and can retry', async ({ + page, +}) => { + const state = await fixture(page); + await edit(page); + state.fail = true; + await editDialog(page) + .getByRole('button', { name: 'Delete', exact: true }) + .click(); + const confirmation = page.getByRole('alertdialog'); + await confirmation + .getByRole('button', { name: 'Delete', exact: true }) + .click(); + await expect(confirmation.getByRole('alert')).toContainText( + 'Fixture deletion blocked; try again.', + ); + await expect( + confirmation.getByRole('button', { name: 'Delete', exact: true }), + ).toBeEnabled(); + await expect(editDialog(page)).toBeVisible(); + state.fail = false; + await confirmation + .getByRole('button', { name: 'Delete', exact: true }) + .click(); + await expect(editDialog(page)).toHaveCount(0); + expect(state.deletes).toHaveLength(2); +}); + +test('new providers do not expose footer deletion', async ({ page }) => { + const state = await fixture(page); + await page.getByRole('button', { name: 'Add Provider', exact: true }).click(); + await expect( + page + .getByRole('dialog', { name: 'Add Provider', exact: true }) + .getByRole('button', { name: 'Delete', exact: true }), + ).toHaveCount(0); + expect(state.deletes).toEqual([]); +}); + +test('system-managed provider has no edit or delete entry', async ({ + page, +}) => { + const state = await fixture(page, 'space-chat-completions'); + const card = page + .locator('[data-slot="card"]') + .filter({ hasText: 'Delete fixture provider' }); + await expect(card).toBeVisible(); + await expect(card.locator('svg.lucide-settings')).toHaveCount(0); + await expect(card.locator('svg.lucide-trash-2')).toHaveCount(0); + expect(state.deletes).toEqual([]); +}); + +test('existing empty-provider card delete keeps its non-cascade request', async ({ + page, +}) => { + const state = await fixture(page, 'openai', true); + const card = page + .locator('[data-slot="card"]') + .filter({ hasText: 'Delete fixture provider' }); + await card + .locator('button') + .filter({ has: page.locator('svg.lucide-trash-2') }) + .click(); + await expect( + page.getByText('Are you sure you want to delete this provider?', { + exact: true, + }), + ).toBeVisible(); + await page.getByRole('button', { name: 'Delete', exact: true }).click(); + await expect(card).toHaveCount(0); + expect(state.deletes).toEqual([ + '/api/v1/provider/providers/provider-delete-fixture', + ]); +}); diff --git a/web/tests/e2e/provider-dropdown.spec.ts b/web/tests/e2e/provider-dropdown.spec.ts new file mode 100644 index 000000000..9424cc713 --- /dev/null +++ b/web/tests/e2e/provider-dropdown.spec.ts @@ -0,0 +1,177 @@ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { expect, test } from '@playwright/test'; +import { installLangBotApiMocks } from './fixtures/langbot-api'; + +// UI fixtures only: never authenticate or write a real provider. +test.use({ hasTouch: true }); +for (const width of [1280, 390, 320]) { + test(`provider dropdown bounded without dialog growth (${width}px)`, async ({ + page, + }, testInfo) => { + await installLangBotApiMocks(page, { authenticated: true }); + await page.route('**/api/v1/provider/**', async (route) => { + const path = new URL(route.request().url()).pathname; + if (path.endsWith('/icon')) + return route.fulfill({ + contentType: 'image/svg+xml', + body: '', + }); + const data = path.endsWith('/requesters') + ? { + requesters: Array.from({ length: 30 }, (_, i) => ({ + name: i === 0 ? 'openai-codex' : `provider-${i}`, + label: { en_US: i === 0 ? 'OpenAI Codex' : `Provider ${i}` }, + description: { en_US: '' }, + spec: { + provider_category: 'manufacturer', + config: [], + support_type: ['llm'], + }, + })), + } + : { providers: [], models: [] }; + await route.fulfill({ json: { code: 0, data } }); + }); + await page.setViewportSize({ width: 1280, height: 720 }); + await page.goto('/home/bots'); + await page.getByRole('button', { name: 'Models', exact: true }).click(); + await page + .getByRole('button', { name: 'Add Provider', exact: true }) + .click(); + await page.setViewportSize({ width, height: 720 }); + const trigger = page.getByRole('button', { + name: 'Select Provider Type', + exact: true, + }); + const dialog = page + .locator('[role="dialog"]') + .filter({ has: page.locator('input[name="name"]') }); + await trigger.scrollIntoViewIfNeeded(); + const before = await dialog.evaluate((el) => ({ + height: el.clientHeight, + scroll: el.scrollHeight, + })); + await trigger.click(); + const search = page.getByPlaceholder('Search providers...'); + await expect(search).toBeFocused(); + const menu = search.locator('../..'); + await expect( + page.getByRole('button', { name: 'Provider 29', exact: false }), + ).toBeAttached(); + await menu.evaluate(async (el) => { + await Promise.all(el.getAnimations().map((a) => a.finished)); + }); + const options = menu.locator(':scope > div').last(); + await options.hover(); + await page.mouse.wheel(0, 1200); + await expect + .poll(() => options.evaluate((el) => el.scrollTop)) + .toBeGreaterThan(0); + if (width < 1280) { + await page.mouse.wheel(0, -1200); + await expect.poll(() => options.evaluate((el) => el.scrollTop)).toBe(0); + const box = (await options.boundingBox())!; + const session = await page.context().newCDPSession(page); + const x = box.x + box.width / 2; + const y = box.y + box.height - 30; + await session.send('Input.dispatchTouchEvent', { + type: 'touchStart', + touchPoints: [{ x, y }], + }); + for (let step = 1; step <= 10; step++) { + await session.send('Input.dispatchTouchEvent', { + type: 'touchMove', + touchPoints: [{ x, y: y - step * 18 }], + }); + } + await session.send('Input.dispatchTouchEvent', { + type: 'touchEnd', + touchPoints: [], + }); + await session.detach(); + await expect + .poll(() => options.evaluate((el) => el.scrollTop)) + .toBeGreaterThan(0); + } + const geometry = await menu.evaluate((el) => { + const rect = el.getBoundingClientRect(); + const list = el.lastElementChild as HTMLElement; + const clipped: string[] = []; + for ( + let parent = el.parentElement; + parent; + parent = parent.parentElement + ) { + const bounds = parent.getBoundingClientRect(); + if ( + /(auto|scroll|hidden|clip)/.test( + getComputedStyle(parent).overflowY, + ) && + (rect.bottom > bounds.bottom + 1 || rect.top < bounds.top - 1) + ) + clipped.push(parent.tagName); + } + return { + left: rect.left, + right: rect.right, + top: rect.top, + bottom: rect.bottom, + clipped, + listHeight: list.clientHeight, + listScroll: list.scrollHeight, + scrollTop: list.scrollTop, + documentWidth: document.documentElement.scrollWidth, + }; + }); + const after = await dialog.evaluate((el) => ({ + height: el.clientHeight, + scroll: el.scrollHeight, + })); + const dir = process.env.DROPDOWN_EVIDENCE_DIR || testInfo.outputDir; + mkdirSync(dir, { recursive: true }); + await page.screenshot({ + path: `${dir}/dropdown-${width}.png`, + fullPage: true, + }); + writeFileSync( + `${dir}/dropdown-${width}.json`, + JSON.stringify( + { evidence: 'UI fixture only', width, before, after, geometry }, + null, + 2, + ), + ); + expect.soft(after).toEqual(before); + expect.soft(geometry.clipped).toEqual([]); + expect.soft(geometry.left).toBeGreaterThanOrEqual(0); + expect.soft(geometry.right).toBeLessThanOrEqual(width); + expect.soft(geometry.top).toBeGreaterThanOrEqual(0); + expect.soft(geometry.bottom).toBeLessThanOrEqual(720); + expect.soft(geometry.documentWidth).toBeLessThanOrEqual(width); + expect(geometry.listScroll).toBeGreaterThan(geometry.listHeight); + expect(geometry.scrollTop).toBeGreaterThan(0); + await page.keyboard.press('Escape'); + await expect(search).toBeHidden(); + await expect(dialog).toBeVisible(); + await expect(trigger).toBeFocused(); + await trigger.click(); + await search.fill('Provider 29'); + await page.locator('input[name="name"]').click(); + await expect(search).toBeHidden(); + await expect(page.locator('input[name="name"]')).toBeFocused(); + await trigger.click(); + await expect(search).toHaveValue(''); + await search.fill('Codex'); + await page + .getByRole('button', { name: 'OpenAI Codex', exact: false }) + .click(); + await expect(search).toBeHidden(); + await expect(page.locator('input[name="api_key"]')).toHaveCount(0); + await expect( + page.getByRole('button', { name: 'Save and sign in', exact: true }), + ).toBeVisible(); + await expect( + page.getByRole('button', { name: 'OpenAI Codex', exact: false }), + ).toBeFocused(); + }); +} diff --git a/web/tests/e2e/provider-edit-loading.spec.ts b/web/tests/e2e/provider-edit-loading.spec.ts new file mode 100644 index 000000000..e73a59196 --- /dev/null +++ b/web/tests/e2e/provider-edit-loading.spec.ts @@ -0,0 +1,262 @@ +import { expect, test, type Page, type Route } from '@playwright/test'; +import { installLangBotApiMocks } from './fixtures/langbot-api'; + +// All API traffic is intercepted; no real provider secrets or mutations. +async function fixture(page: Page, requester = 'openai') { + await installLangBotApiMocks(page, { authenticated: true }); + const providers = ['alpha', 'beta'].map((id) => ({ + uuid: `loading-${id}`, + name: `Loading fixture ${id}`, + requester, + base_url: `https://${id}.example.test/v1`, + api_keys: [`fixture-key-${id}`], + llm_count: 0, + embedding_count: 0, + rerank_count: 0, + })); + const state = { + hold: '' as '' | 'detail' | 'requesters', + fail: '' as '' | 'detail' | 'requesters', + held: [] as { release: () => void; finished: Promise }[], + reads: [] as string[], + mutations: [] as string[], + errors: [] as string[], + }; + page.on('pageerror', (error) => state.errors.push(error.message)); + const ok = (route: Route, data: unknown) => + route.fulfill({ json: { code: 0, data } }); + await page.route('**/api/v1/provider/**', async (route) => { + const path = new URL(route.request().url()).pathname; + if (route.request().method() !== 'GET') { + state.mutations.push(route.request().method() + ' ' + path); + return ok(route, {}); + } + if (path.endsWith('/icon')) + return route.fulfill({ + contentType: 'image/svg+xml', + body: '', + }); + state.reads.push(path); + const provider = providers.find((p) => path.endsWith('/' + p.uuid)); + const dependency = path.endsWith('/requesters') + ? 'requesters' + : provider + ? 'detail' + : ''; + const fail = dependency && state.fail === dependency; + let finish: (() => void) | undefined; + if (dependency && state.hold === dependency) { + const finished = new Promise((resolve) => { + finish = resolve; + }); + await new Promise((release) => + state.held.push({ release, finished }), + ); + } + try { + if (fail) + return await route.fulfill({ + status: 503, + json: { code: 503, msg: `Fixture ${dependency} unavailable` }, + }); + if (dependency === 'requesters') + return await ok(route, { + requesters: [ + { + name: requester, + label: { + en_US: + requester === 'openai' ? 'OpenAI fixture' : 'Codex fixture', + }, + description: { en_US: '' }, + spec: { + provider_category: 'manufacturer', + support_type: ['llm'], + config: [], + }, + }, + ], + }); + if (provider) return await ok(route, { provider }); + if (path.endsWith('/providers')) return await ok(route, { providers }); + if (path.endsWith('/codex/status')) + return await ok(route, { + status: 'connected', + connected: true, + expires_at: null, + }); + return await ok(route, { models: [] }); + } finally { + finish?.(); + } + }); + await page.goto('/home/bots'); + await page.getByRole('button', { name: 'Models', exact: true }).click(); + await expect( + page.getByText(providers[0].name, { exact: true }), + ).toBeVisible(); + // Let the panel's independent requester-support read finish before gating the form. + await expect + .poll(() => state.reads.filter((p) => p.endsWith('/requesters')).length) + .toBeGreaterThanOrEqual(1); + return state; +} + +const dialog = (page: Page) => + page.getByRole('dialog', { name: 'Edit Provider', exact: true }); +const editButton = (page: Page, id = 'alpha') => + page + .locator('[data-slot="card"]') + .filter({ hasText: `Loading fixture ${id}` }) + .locator('button') + .filter({ has: page.locator('svg.lucide-settings') }); + +async function expectLoading(page: Page) { + const form = dialog(page); + await expect(form.getByRole('status')).toContainText('Loading...'); + await expect( + form.getByRole('status').locator('svg.animate-spin'), + ).toBeVisible(); + await expect(form.locator('input')).toHaveCount(0); + await expect( + form.getByRole('button', { name: /^(Save|Done|Delete)$/ }), + ).toHaveCount(0); + await expect( + form.getByRole('button', { name: 'Cancel', exact: true }), + ).toBeEnabled(); +} + +async function expectReady(page: Page, id = 'alpha', requester = 'openai') { + const form = dialog(page); + await expect(form.locator('input[name="name"]')).toHaveValue( + `Loading fixture ${id}`, + ); + await expect( + form.getByRole('status', { name: 'Loading...', exact: true }), + ).toHaveCount(0); + await expect( + form.getByRole('button', { name: 'Delete', exact: true }), + ).toBeEnabled(); + await expect( + form.getByRole('button', { + name: requester === 'openai' ? 'Save' : 'Done', + exact: true, + }), + ).toBeEnabled(); + if (requester === 'openai') { + await expect(form.locator('input[name="base_url"]')).toHaveValue( + `https://${id}.example.test/v1`, + ); + await expect(form.locator('input[name="api_key"]')).toHaveValue( + `fixture-key-${id}`, + ); + await expect( + form.getByRole('button', { name: /OpenAI fixture/ }), + ).toBeVisible(); + } else { + await expect(form.locator('input[name="api_key"]')).toHaveCount(0); + await expect( + form.getByRole('button', { name: /Codex fixture/ }), + ).toBeVisible(); + } +} + +for (const requester of ['openai', 'openai-codex']) { + for (const dependency of ['detail', 'requesters'] as const) { + test(`edit waits for ${dependency} before showing populated ${requester} form`, async ({ + page, + }) => { + const state = await fixture(page, requester); + state.hold = dependency; + await editButton(page).click(); + await expect.poll(() => state.held.length).toBeGreaterThanOrEqual(1); + await expectLoading(page); + // Remain gated for the whole delay, not just the first render. + await page.waitForTimeout(250); + await expectLoading(page); + state.hold = ''; + state.held.forEach((request) => request.release()); + await expectReady(page, 'alpha', requester); + expect(state.mutations).toEqual([]); + expect(state.errors).toEqual([]); + }); + } +} + +for (const dependency of ['detail', 'requesters'] as const) { + test(`${dependency} load failure is recoverable with Retry or Cancel`, async ({ + page, + }) => { + const state = await fixture(page); + state.fail = dependency; + await editButton(page).click(); + const form = dialog(page); + await expect(form.getByRole('alert')).toContainText('Failed to load data'); + await expect(form.locator('input')).toHaveCount(0); + await expect( + form.getByRole('button', { name: /^(Save|Done|Delete)$/ }), + ).toHaveCount(0); + await expect( + form.getByRole('button', { name: 'Retry', exact: true }), + ).toBeEnabled(); + await expect( + form.getByRole('button', { name: 'Cancel', exact: true }), + ).toBeEnabled(); + state.fail = ''; + state.hold = dependency; + await form.getByRole('button', { name: 'Retry', exact: true }).click(); + await expect.poll(() => state.held.length).toBeGreaterThanOrEqual(1); + await expectLoading(page); + state.hold = ''; + state.held.forEach((request) => request.release()); + await expectReady(page); + await form.getByRole('button', { name: 'Cancel', exact: true }).click(); + await expect(form).toHaveCount(0); + state.fail = dependency; + await editButton(page).click(); + await expect(form.getByRole('alert')).toBeVisible(); + await form.getByRole('button', { name: 'Cancel', exact: true }).click(); + await expect(form).toHaveCount(0); + expect(state.mutations).toEqual([]); + expect(state.errors).toEqual([]); + }); +} + +for (const next of ['alpha', 'beta']) { + for (const staleFailure of [false, true]) { + test(`closed request ${staleFailure ? 'failure' : 'success'} cannot affect reopened ${next}`, async ({ + page, + }) => { + const state = await fixture(page); + state.hold = 'detail'; + state.fail = staleFailure ? 'detail' : ''; + await editButton(page).click(); + await expect.poll(() => state.held.length).toBeGreaterThanOrEqual(1); + await expectLoading(page); + const staleRequests = state.held.splice(0); + await dialog(page) + .getByRole('button', { name: 'Cancel', exact: true }) + .click(); + state.fail = ''; + // Reopen during the closing animation, before Radix's retained content unmounts. + await editButton(page, next).dispatchEvent('click'); + await expect.poll(() => state.held.length).toBeGreaterThanOrEqual(1); + await expectLoading(page); + state.hold = ''; + state.held.forEach((request) => request.release()); + await expectReady(page, next); + await dialog(page) + .locator('input[name="name"]') + .fill('Unsaved fixture edit'); + staleRequests.forEach((request) => request.release()); + await Promise.all(staleRequests.map((request) => request.finished)); + await page.waitForTimeout(250); + await expect(dialog(page).locator('input[name="name"]')).toHaveValue( + 'Unsaved fixture edit', + ); + await expect(dialog(page).getByRole('alert')).toHaveCount(0); + expect(state.mutations).toEqual([]); + expect(state.errors).toEqual([]); + }); + } +} diff --git a/web/tests/e2e/reasoning-edit-semantics.spec.ts b/web/tests/e2e/reasoning-edit-semantics.spec.ts new file mode 100644 index 000000000..738137291 --- /dev/null +++ b/web/tests/e2e/reasoning-edit-semantics.spec.ts @@ -0,0 +1,332 @@ +import { test, expect, type Page } from '@playwright/test'; +import { readFileSync } from 'node:fs'; +const contract = JSON.parse( + readFileSync('tests/e2e/fixtures/runner-migration-contract.json', 'utf8'), +); +import { installLangBotApiMocks } from './fixtures/langbot-api'; +const canonical = JSON.parse( + readFileSync('../src/langbot/templates/default-pipeline-config.json', 'utf8'), +); +const outputSchema = contract.output_schema; +async function setup( + page: Page, + plugin = 'weknora-agent', + name = 'WeKnoraAgent', + agentId: null | 'absent' = null, + converted?: typeof canonical, + reasoningEnabled = true, +) { + const schema = contract.plugins[plugin].component; + const id = `plugin:langbot-team/${name}/default`; + const raw: Record = { + 'api-key': ' fixture credential ', + 'base-url': ' http://localhost:8080/api/v1 ', + 'app-type': 'agent', + 'enable-all-tools': false, + tools: ['tool-allowed'], + 'knowledge-bases': ['kb-allowed'], + 'mcp-resources': [{ server: 'private', uri: 'secret' }], + 'mcp-resource-agent-read-enabled': false, + }; + if (agentId === null) raw['agent-id'] = null; + if (plugin === 'LocalAgent') { + raw['advanced-settings'] = true; + raw.model = { + primary: 'llm-valid', + fallbacks: ['llm-fallback'], + reasoning: { 'llm-valid': 'high', 'llm-fallback': 'low' }, + }; + raw['remove-think'] = true; + } + let config = { + ...structuredClone(canonical), + ai: { runner: { id }, runner_config: { [id]: raw } }, + }; + if (converted) config = structuredClone(converted); + else config.output.misc['remove-think'] = false; // Deliberate disagreement must not synchronize on mount. + const writes: any[] = []; + await installLangBotApiMocks(page, { authenticated: true }); + await page.routeWebSocket('**/api/v1/pipelines/**/ws/connect**', (ws) => { + ws.onMessage((raw) => { + if (JSON.parse(String(raw)).type === 'authenticate') + ws.send( + JSON.stringify({ + type: 'connected', + connection_id: 'fixture', + session_type: 'person', + }), + ); + }); + }); + await page.route('**/api/v1/pipelines/preserve', async (route) => { + if (route.request().method() !== 'GET') { + const body = route.request().postDataJSON(); + writes.push(body); + if (body.config) config = body.config; + } + await route.fulfill({ + json: { + code: 0, + data: { + pipeline: { + uuid: 'preserve', + name: 'Preservation', + description: '', + emoji: '⚙️', + config, + }, + }, + }, + }); + }); + await page.route('**/api/v1/pipelines/_/metadata', (route) => + route.fulfill({ + json: { + code: 0, + data: { + configs: [ + { + name: 'ai', + label: { en_US: 'AI' }, + stages: [ + { + name: 'runner', + label: { en_US: 'Runner' }, + config: contract.ai_schema.stages[0].config.map( + (field: Record) => + field.name === 'id' + ? { + ...field, + default: id, + options: [ + { name: id, label: schema.metadata.label }, + ], + } + : field, + ), + }, + { + name: id, + label: schema.metadata.label, + config: schema.spec.config, + }, + ], + }, + outputSchema, + ], + }, + }, + }), + ); + await page.route('**/api/v1/provider/models/llm', (route) => + route.fulfill({ + json: { + code: 0, + data: { + models: ['llm-valid', 'llm-fallback'].map((uuid) => ({ + uuid, + name: uuid, + provider_uuid: 'provider-valid', + provider: { uuid: 'provider-valid', name: 'Mock Provider' }, + abilities: reasoningEnabled ? ['reasoning'] : [], + reasoning: { level: 'high' }, + reasoning_capabilities: { + supported: true, + levels: ['provider_default', 'low', 'high'], + }, + })), + }, + }, + }), + ); + await page.goto('/home/pipelines?id=preserve'); + await page.getByRole('tab', { name: 'AI', exact: true }).click(); + return { writes, raw, id }; +} + +test('explicit reasoning choices persist independently for primary and fallback models', async ({ + page, +}) => { + const { writes, raw, id } = await setup(page, 'LocalAgent', 'LocalAgent'); + await page + .getByRole('button', { name: 'Reasoning level: High', exact: true }) + .click(); + await expect( + page.getByRole('button', { name: 'Use model setting', exact: true }), + ).toHaveCount(0); + await page + .getByRole('button', { name: 'Use provider default', exact: true }) + .click(); + await page.keyboard.press('Escape'); + await page.getByRole('button', { name: 'Save', exact: true }).click(); + await expect.poll(() => writes.length).toBe(1); + expect(writes[0].config.ai.runner_config[id]).toEqual({ + ...raw, + model: { + ...(raw.model as object), + reasoning: { 'llm-valid': 'provider_default', 'llm-fallback': 'low' }, + }, + }); + await page.reload(); + await page.getByRole('tab', { name: 'AI', exact: true }).click(); + await expect( + page.getByRole('button', { + name: 'Reasoning level: Use provider default', + exact: true, + }), + ).toBeVisible(); + await expect( + page.getByRole('button', { name: 'Save', exact: true }), + ).toBeDisabled(); + await page + .getByRole('button', { name: 'Reasoning level: Low', exact: true }) + .click(); + await page.getByRole('slider').press('End'); + await page.keyboard.press('Escape'); + await page.getByRole('button', { name: 'Save', exact: true }).click(); + await expect.poll(() => writes.length).toBe(2); + expect(writes[1].config.ai.runner_config[id].model.reasoning).toEqual({ + 'llm-valid': 'provider_default', + 'llm-fallback': 'high', + }); + expect(writes[1].config.output).toEqual(writes[0].config.output); +}); + +test('custom models without reasoning enabled hide budget controls despite inferred support', async ({ + page, +}) => { + await setup(page, 'LocalAgent', 'LocalAgent', null, undefined, false); + await expect(page.getByRole('combobox').first()).toBeVisible(); + await expect( + page.getByRole('button', { name: /^Reasoning level:/ }), + ).toHaveCount(0); +}); + +test('standalone picker keeps its original levels and provider default API', async ({ + page, +}) => { + await installLangBotApiMocks(page, { authenticated: true }); + await page.goto('/home/pipelines'); + await page.evaluate(async () => { + // Vite serves the actual component, not a test replacement. + const resources = performance + .getEntriesByType('resource') + .map((entry) => entry.name); + const reactPath = resources.find((url) => + new URL(url).pathname.endsWith('/react.js'), + )!; + const domPath = resources.find((url) => + new URL(url).pathname.endsWith('/react-dom_client.js'), + )!; + const pickerPath = + '/src/app/home/components/reasoning/ReasoningLevelPicker.tsx'; + const reactModule = await import(reactPath); + const React = reactModule.default || reactModule; + const domModule = await import(domPath); + const { createRoot } = domModule.default || domModule; + const { default: Picker } = await import(pickerPath); + const host = document.createElement('div'); + const app = document.getElementById('root'); + if (app) app.style.display = 'none'; + document.body.appendChild(host); + function Harness() { + const [value, setValue] = React.useState('provider_default'); + return React.createElement(Picker, { + value, + levels: ['provider_default', 'high'], + onChange: (next: string) => { + host.dataset.value = next; + setValue(next); + }, + }); + } + host.id = 'standalone-reasoning'; + createRoot(host).render(React.createElement(Harness)); + }); + await page + .getByRole('button', { + name: 'Reasoning level: Use provider default', + exact: true, + }) + .click(); + await expect( + page.getByRole('button', { name: 'Use model setting', exact: true }), + ).toHaveCount(0); + await page.getByRole('slider').press('End'); + await expect(page.locator('#standalone-reasoning')).toHaveAttribute( + 'data-value', + 'high', + ); + await page.getByRole('slider').press('Home'); + await expect(page.locator('#standalone-reasoning')).toHaveAttribute( + 'data-value', + 'provider_default', + ); +}); + +test('LangBot Models reasoning checkbox matches the capability icon', async ({ + page, +}) => { + await installLangBotApiMocks(page, { authenticated: true }); + await page.route('**/api/v1/user/info', (route) => + route.fulfill({ + json: { + code: 0, + data: { + account_uuid: 'account-playwright', + user: 'admin@example.com', + account_type: 'space', + has_password: true, + }, + }, + }), + ); + const provider = { + uuid: 'space-provider', + name: 'LangBot Models', + requester: 'space-chat-completions', + base_url: '', + api_keys: [], + llm_count: 1, + embedding_count: 0, + rerank_count: 0, + }; + await page.route('**/api/v1/provider/**', (route) => { + const path = new URL(route.request().url()).pathname; + const ok = (data: unknown) => route.fulfill({ json: { code: 0, data } }); + if (path.endsWith('/providers')) return ok({ providers: [provider] }); + if (path.endsWith('/requesters')) return ok({ requesters: [] }); + if (path.includes('/models/')) + return ok({ + models: path.endsWith('/llm') + ? [ + { + uuid: 'space-reasoning', + name: 'Space reasoning model', + provider_uuid: provider.uuid, + provider, + abilities: ['vision', 'func_call'], + extra_args: {}, + reasoning_capabilities: { + supported: true, + levels: ['provider_default', 'low', 'high'], + }, + }, + ] + : [], + }); + return ok({ provider }); + }); + await page.goto('/home/bots'); + await page.getByRole('button', { name: 'Models', exact: true }).click(); + const card = page + .locator('[data-slot="card"]') + .filter({ hasText: 'LangBot Models' }); + await card.getByText('Space reasoning model', { exact: true }).click(); + const checkbox = page.getByRole('checkbox', { + name: 'Reasoning', + exact: true, + }); + await expect(checkbox).toBeChecked(); + await expect(checkbox).toBeDisabled(); +}); diff --git a/web/tests/e2e/reset-password.spec.ts b/web/tests/e2e/reset-password.spec.ts new file mode 100644 index 000000000..96170f386 --- /dev/null +++ b/web/tests/e2e/reset-password.spec.ts @@ -0,0 +1,122 @@ +import { expect, test, type Page } from '@playwright/test'; + +import { installLangBotApiMocks } from './fixtures/langbot-api'; + +const resetEndpoint = '**/api/v1/user/reset-password'; +const email = 'reset-password@example.com'; +const newPassword = 'Regression-password-2026!'; +const successMessage = 'Password reset successfully, please login'; +const failureMessage = + 'Password reset failed, please check your email and recovery key'; + +async function fillResetForm(page: Page, recoveryKey: string) { + await page.goto('/reset-password'); + await page.getByPlaceholder('Enter email address').fill(email); + const recoveryInput = page.getByPlaceholder('Enter recovery key'); + await recoveryInput.fill(recoveryKey); + await expect(recoveryInput).toHaveValue(recoveryKey); + await page.getByPlaceholder('Enter new password').fill(newPassword); +} + +test.beforeEach(async ({ page }) => { + await installLangBotApiMocks(page, { authenticated: false }); +}); + +const recoveryKeys = [ + { name: 'eight-character recovery code', value: '2A3B4C5D' }, + { name: 'six-character legacy recovery key', value: 'ABC123' }, + { + name: '43-character mixed-case base64url recovery key', + value: 'aB-_'.repeat(10) + 'xYz', + }, +]; + +for (const { name, value } of recoveryKeys) { + test(`submits the ${name} verbatim and returns to login`, async ({ + page, + }) => { + const requests: { method: string; body: unknown }[] = []; + await page.route(resetEndpoint, async (route) => { + requests.push({ + method: route.request().method(), + body: route.request().postDataJSON(), + }); + await route.fulfill({ + status: 200, + json: { code: 0, msg: 'ok', data: { user: email } }, + }); + }); + + await fillResetForm(page, value); + await page + .getByRole('button', { name: 'Reset Password', exact: true }) + .click(); + + await expect(page).toHaveURL(/\/login$/); + await expect(page.getByText(successMessage, { exact: true })).toBeVisible(); + await expect( + page.getByRole('button', { name: 'Login with password', exact: true }), + ).toBeVisible(); + expect(requests).toEqual([ + { + method: 'POST', + body: { user: email, recovery_key: value, new_password: newPassword }, + }, + ]); + await expect(page.getByText(failureMessage, { exact: true })).toHaveCount( + 0, + ); + }); +} + +test('HTTP 429 shows failure, stays on reset-password, and reenables submission', async ({ + page, +}) => { + const recoveryKey = '2A3B4C5D'; + const requests: { method: string; body: unknown }[] = []; + let releaseResponse!: () => void; + const responseGate = new Promise((resolve) => { + releaseResponse = resolve; + }); + await page.route(resetEndpoint, async (route) => { + requests.push({ + method: route.request().method(), + body: route.request().postDataJSON(), + }); + await responseGate; + await route.fulfill({ + status: 429, + json: { code: -1, msg: 'Too many attempts, try again later' }, + }); + }); + + await fillResetForm(page, recoveryKey); + const submit = page.locator('button[type="submit"]'); + await submit.click(); + try { + await expect.poll(() => requests.length).toBe(1); + await expect(submit).toBeDisabled(); + await expect(submit).toHaveText('Resetting...'); + } finally { + releaseResponse(); + } + + await expect(page.getByText(failureMessage, { exact: true })).toBeVisible(); + await expect(submit).toBeEnabled(); + await expect(submit).toHaveText('Reset Password'); + await expect(page).toHaveURL(/\/reset-password$/); + await expect(page.getByText(successMessage, { exact: true })).toHaveCount(0); + await expect(page.getByPlaceholder('Enter recovery key')).toHaveValue( + recoveryKey, + ); + expect(requests).toEqual([ + { + method: 'POST', + body: { + user: email, + recovery_key: recoveryKey, + new_password: newPassword, + }, + }, + ]); +}); diff --git a/web/tests/e2e/runner-config-preservation.spec.ts b/web/tests/e2e/runner-config-preservation.spec.ts new file mode 100644 index 000000000..ae9455cde --- /dev/null +++ b/web/tests/e2e/runner-config-preservation.spec.ts @@ -0,0 +1,212 @@ +import { test, expect, type Page } from '@playwright/test'; +import { readFileSync } from 'node:fs'; +const contract = JSON.parse( + readFileSync('tests/e2e/fixtures/runner-migration-contract.json', 'utf8'), +); +import { installLangBotApiMocks } from './fixtures/langbot-api'; +const canonical = JSON.parse( + readFileSync('../src/langbot/templates/default-pipeline-config.json', 'utf8'), +); +const outputSchema = contract.output_schema; +const switchField = (page: Page, label: string) => + page + .locator('label') + .filter({ hasText: label }) + .locator('../..') + .getByRole('switch'); +async function setup( + page: Page, + plugin = 'weknora-agent', + name = 'WeKnoraAgent', + agentId: null | 'absent' = null, + converted?: typeof canonical, +) { + const schema = contract.plugins[plugin].component; + const id = `plugin:langbot-team/${name}/default`; + const raw: Record = { + 'api-key': ' fixture credential ', + 'base-url': ' http://localhost:8080/api/v1 ', + 'app-type': 'agent', + 'enable-all-tools': false, + tools: ['tool-allowed'], + 'knowledge-bases': ['kb-allowed'], + 'mcp-resources': [{ server: 'private', uri: 'secret' }], + 'mcp-resource-agent-read-enabled': false, + }; + if (agentId === null) raw['agent-id'] = null; + if (plugin === 'LocalAgent') { + raw['advanced-settings'] = true; + raw.model = { + primary: 'llm-valid', + fallbacks: [], + reasoning: { 'llm-valid': 'provider_default' }, + }; + raw['remove-think'] = true; + } + let config = { + ...structuredClone(canonical), + ai: { runner: { id }, runner_config: { [id]: raw } }, + }; + if (converted) config = structuredClone(converted); + else config.output.misc['remove-think'] = false; // Deliberate disagreement must not synchronize on mount. + const writes: any[] = []; + await installLangBotApiMocks(page, { authenticated: true }); + await page.routeWebSocket('**/api/v1/pipelines/**/ws/connect**', (ws) => { + ws.onMessage((raw) => { + if (JSON.parse(String(raw)).type === 'authenticate') + ws.send( + JSON.stringify({ + type: 'connected', + connection_id: 'fixture', + session_type: 'person', + }), + ); + }); + }); + await page.route('**/api/v1/pipelines/preserve', async (route) => { + if (route.request().method() !== 'GET') { + const body = route.request().postDataJSON(); + writes.push(body); + if (body.config) config = body.config; + } + await route.fulfill({ + json: { + code: 0, + data: { + pipeline: { + uuid: 'preserve', + name: 'Preservation', + description: '', + emoji: '⚙️', + config, + }, + }, + }, + }); + }); + await page.route('**/api/v1/pipelines/_/metadata', (route) => + route.fulfill({ + json: { + code: 0, + data: { + configs: [ + { + name: 'ai', + label: { en_US: 'AI' }, + stages: [ + { + name: 'runner', + label: { en_US: 'Runner' }, + config: contract.ai_schema.stages[0].config.map( + (field: Record) => + field.name === 'id' + ? { + ...field, + default: id, + options: [ + { name: id, label: schema.metadata.label }, + ], + } + : field, + ), + }, + { + name: id, + label: schema.metadata.label, + config: schema.spec.config, + }, + ], + }, + outputSchema, + ], + }, + }, + }), + ); + await page.goto('/home/pipelines?id=preserve'); + await page.getByRole('tab', { name: 'AI', exact: true }).click(); + return { writes, raw, id }; +} +for (const candidate of contract.cases) { + test(`actual converter output stays editable and unchanged for ${candidate.legacy_runner}`, async ({ + page, + }) => { + const name = candidate.plan.target_plugin.name; + const folder = Object.keys(contract.plugins).find( + (key) => contract.plugins[key].manifest.metadata.name === name, + )!; + const { writes } = await setup( + page, + folder, + name, + null, + candidate.plan.config, + ); + await expect( + page.getByRole('button', { name: 'Save', exact: true }), + ).toBeDisabled(); + expect(writes).toHaveLength(0); + await page.getByRole('tab', { name: 'Output', exact: true }).click(); + await switchField(page, 'Quote Origin Message').click(); + await page.getByRole('button', { name: 'Save', exact: true }).click(); + await expect.poll(() => writes.length).toBe(1); + expect(writes[0].config.ai).toEqual(candidate.plan.config.ai); + expect(writes[0].config.output.misc['quote-origin']).toBe( + !candidate.plan.config.output.misc['quote-origin'], + ); + }); +} + +for (const agentId of [null, 'absent'] as const) { + test(`mounted real WeKnora schema preserves ${agentId} agent ID and hidden policy`, async ({ + page, + }) => { + const { writes, raw, id } = await setup( + page, + 'weknora-agent', + 'WeKnoraAgent', + agentId, + ); + await expect(page.getByText('Agent ID', { exact: true })).toBeVisible(); + expect(writes).toEqual([]); + await expect( + page.getByRole('button', { name: 'Save', exact: true }), + ).toBeDisabled(); + await switchField(page, 'Enable Web Search').click(); + await page.getByRole('button', { name: 'Save', exact: true }).click(); + await expect.poll(() => writes.length).toBe(1); + expect(writes[0].config.ai.runner_config[id]).toEqual({ + ...raw, + 'web-search-enabled': true, + }); + await page.reload(); + await page.getByRole('tab', { name: 'AI', exact: true }).click(); + await expect(switchField(page, 'Enable Web Search')).toBeChecked(); + await expect( + page.getByRole('button', { name: 'Save', exact: true }), + ).toBeDisabled(); + expect(writes).toHaveLength(1); + }); +} +test('remove-think is synchronized only after intentional output or runner toggles', async ({ + page, +}) => { + const { writes, id } = await setup(page, 'LocalAgent', 'LocalAgent'); + const runnerThink = switchField(page, 'Remove'); + await expect(runnerThink).toBeChecked(); + expect(writes).toEqual([]); + await page.getByRole('tab', { name: 'Output', exact: true }).click(); + const outputThink = switchField(page, 'Remove CoT'); + await expect(outputThink).not.toBeChecked(); + await outputThink.click(); + await page.getByRole('button', { name: 'Save', exact: true }).click(); + await expect.poll(() => writes.length).toBe(1); + expect(writes[0].config.output.misc['remove-think']).toBe(true); + expect(writes[0].config.ai.runner_config[id]['remove-think']).toBe(true); + await page.getByRole('tab', { name: 'AI', exact: true }).click(); + await runnerThink.click(); + await page.getByRole('button', { name: 'Save', exact: true }).click(); + await expect.poll(() => writes.length).toBe(2); + expect(writes[1].config.output.misc['remove-think']).toBe(false); + expect(writes[1].config.ai.runner_config[id]['remove-think']).toBe(false); +}); diff --git a/web/tests/e2e/sandbox-scope-tooltip.spec.ts b/web/tests/e2e/sandbox-scope-tooltip.spec.ts new file mode 100644 index 000000000..6c7405a22 --- /dev/null +++ b/web/tests/e2e/sandbox-scope-tooltip.spec.ts @@ -0,0 +1,268 @@ +import { expect, test, type Page } from '@playwright/test'; +import { installLangBotApiMocks } from './fixtures/langbot-api'; +import { getBoxScopeContext } from '../../src/app/home/pipelines/components/pipeline-form/BoxScopeContext'; + +const unavailableHint = '沙箱未启用,请启用 Box 并确认连接正常后再修改作用域。'; +const forcedHint = '已强制使用全局沙箱,无法修改作用域。'; +const customHint = '已强制使用固定沙箱作用域,无法修改作用域。'; + +// Exercise the real generic renderer, not a retired local-agent page. Host +// execution scope is not a plugin configuration field; see fixtures/README.md. +async function openConditionForm(page: Page, available: boolean, forced = '') { + await installLangBotApiMocks(page, { + authenticated: true, + storage: { langbot_language: 'zh-Hans' }, + }); + const params = new URLSearchParams({ available: String(available), forced }); + await page.goto(`/tests/e2e/fixtures/dynamic-form.html?${params}`); + const scope = page + .locator('[data-slot="form-item"]') + .filter({ has: page.getByText('沙箱作用域', { exact: true }) }) + .getByRole('combobox'); + await expect(scope).toBeVisible(); + return scope; +} + +async function updateContext(page: Page, context: Record) { + await page.evaluate((detail) => { + window.dispatchEvent(new CustomEvent('test-form-context', { detail })); + }, context); +} + +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) { + for (const hint of [unavailableHint, forcedHint, customHint]) { + await expect(page.getByRole('button', { name: hint })).toHaveCount(0); + } + await expect(page.getByRole('tooltip')).toHaveCount(0); +} + +test.describe('sandbox condition renderer compatibility (UI fixture only)', () => { + for (const scenario of [ + { name: 'Box disabled', forced: '' }, + { name: 'Box disconnected', forced: '' }, + { + name: 'unavailable Box takes precedence over forced global', + forced: '{global}', + }, + { + name: 'unavailable Box takes precedence over forced fixed', + forced: '{pipeline_id}', + }, + ]) { + test(scenario.name, async ({ page }) => { + const scope = await openConditionForm(page, false, 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} ', '{pipeline_id}']) { + test(`available context explains the restriction without rewriting config (${JSON.stringify(forced)})`, async ({ + page, + }) => { + const scope = await openConditionForm(page, true, forced); + await expect(scope).toHaveCSS('pointer-events', 'none'); + // The generic renderer must not coerce values based on a Host policy. + await expect(scope).toHaveText('每个会话(推荐)'); + await expectWarning( + page, + forced.trim() === '{global}' ? forcedHint : customHint, + ); + await expect( + page.getByRole('button', { name: unavailableHint }), + ).toHaveCount(0); + await expect(page.getByTestId('saved-values')).toContainText( + '"box-session-id-template":"{launcher_type}_{launcher_id}"', + ); + }); + } + + for (const forced of ['', ' ']) { + test(`available unforced context is editable (${JSON.stringify(forced)})`, async ({ + page, + }) => { + const scope = await openConditionForm(page, 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(`caller context updates the warning without remounting (${forced || 'unforced'})`, async ({ + page, + }) => { + const scope = await openConditionForm(page, false, forced); + const original = await scope.elementHandle(); + await expect(scope).toHaveCSS('pointer-events', 'none'); + await expectWarning(page, unavailableHint); + await page.mouse.move(0, 0); + await updateContext(page, getBoxScopeContext(true, forced)); + 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); + await updateContext(page, getBoxScopeContext(false, forced)); + await expect(scope).toHaveCSS('pointer-events', 'none'); + await expectWarning(page, unavailableHint); + await expect(page.getByRole('tooltip')).not.toContainText('强制'); + expect(await original!.evaluate((element) => element.isConnected)).toBe( + true, + ); + }); + } + + test('manifest secret and number aliases retain live show/disable conditions', async ({ + page, + }) => { + await openConditionForm(page, true); + const secret = page + .locator('[data-slot="form-item"]') + .filter({ has: page.getByText('Plugin secret', { exact: true }) }) + .locator('input'); + await expect(secret).toHaveAttribute('type', 'password'); + await expect(secret).toHaveCSS('pointer-events', 'auto'); + await expect(page.getByRole('spinbutton')).toHaveValue('3'); + await secret.fill('fixture-only-not-a-credential'); + await updateContext(page, { locked: true }); + await expect(secret).toHaveCSS('pointer-events', 'none'); + await expectWarning(page, 'Live reason wins'); + await page.mouse.move(0, 0); + await page + .locator('[data-slot="form-item"]') + .filter({ has: page.getByText('Mode', { exact: true }) }) + .getByRole('combobox') + .click(); + await page.getByRole('option', { name: 'Hidden', exact: true }).click(); + await expect(secret).toHaveCount(0); + await expect(page.getByRole('spinbutton')).toHaveCount(0); + await expect( + page.getByRole('button', { name: 'Live reason wins' }), + ).toHaveCount(0); + }); +}); + +test('pipeline plugin schema round-trips without resurrecting Core sandbox ownership', async ({ + page, +}) => { + await installLangBotApiMocks(page, { + authenticated: true, + storage: { langbot_language: 'en-US' }, + }); + const runnerId = 'plugin:qa/conditions/default'; + await page.route('**/api/v1/pipelines/_/metadata', (route) => + route.fulfill({ + json: { + code: 0, + data: { + configs: [ + { + name: 'ai', + label: { en_US: 'AI Feature' }, + stages: [ + { + name: 'runner', + label: { en_US: 'Runner' }, + config: [ + { + name: 'id', + type: 'select', + label: { en_US: 'Runner' }, + default: runnerId, + options: [ + { name: runnerId, label: { en_US: 'Fixture runner' } }, + ], + }, + ], + }, + { + name: runnerId, + label: { en_US: 'Plugin settings' }, + config: [ + { + name: 'note', + type: 'string', + label: { en_US: 'Plugin note' }, + default: '', + show_if: { + field: '__system.pipeline_id', + operator: 'eq', + value: 'pipeline-scope-fixture', + }, + }, + ], + }, + ], + }, + ], + }, + }, + }), + ); + const pipeline = { + uuid: 'pipeline-scope-fixture', + name: 'Plugin scope fixture', + description: '', + emoji: '⚙️', + is_default: false, + config: { + ai: { + runner: { id: runnerId }, + runner_config: { [runnerId]: { note: 'original' } }, + }, + trigger: {}, + safety: {}, + output: {}, + }, + }; + let saved: typeof pipeline | undefined; + await page.route( + '**/api/v1/pipelines/pipeline-scope-fixture', + async (route) => { + if (route.request().method() === 'PUT') { + saved = route.request().postDataJSON(); + await route.fulfill({ json: { code: 0, data: {} } }); + } else { + await route.fulfill({ json: { code: 0, data: { pipeline } } }); + } + }, + ); + await page.goto('/home/pipelines?id=pipeline-scope-fixture'); + await page.getByRole('tab', { name: 'AI', exact: true }).click(); + await expect( + page.getByRole('region', { name: 'Configuration' }).getByRole('textbox'), + ).toHaveValue('original'); + await expect(page.getByText('Sandbox Scope', { exact: true })).toHaveCount(0); + await page + .getByRole('region', { name: 'Configuration' }) + .getByRole('textbox') + .fill('round-trip'); + await page.getByRole('button', { name: 'Save', exact: true }).click(); + await expect + .poll(() => saved?.config.ai) + .toEqual({ + runner: { id: runnerId }, + runner_config: { [runnerId]: { note: 'round-trip' } }, + }); +}); diff --git a/web/tests/e2e/structured-runner-editor.spec.ts b/web/tests/e2e/structured-runner-editor.spec.ts new file mode 100644 index 000000000..88db05226 --- /dev/null +++ b/web/tests/e2e/structured-runner-editor.spec.ts @@ -0,0 +1,233 @@ +import { test, expect, type Page } from '@playwright/test'; +import { readFileSync } from 'node:fs'; +const contract = JSON.parse( + readFileSync('tests/e2e/fixtures/runner-migration-contract.json', 'utf8'), +); +import { installLangBotApiMocks } from './fixtures/langbot-api'; +const canonical = contract.cases[0].plan.config; +const outputSchema = contract.output_schema; +const switchField = (page: Page, label: string) => + page + .locator('label') + .filter({ hasText: label }) + .locator('../..') + .getByRole('switch'); +async function setup( + page: Page, + plugin = 'weknora-agent', + name = 'WeKnoraAgent', + agentId: null | 'absent' = null, + converted?: typeof canonical, +) { + const schema = contract.plugins[plugin].component; + const id = `plugin:langbot-team/${name}/default`; + const raw: Record = { + 'api-key': ' fixture credential ', + 'base-url': ' http://localhost:8080/api/v1 ', + 'app-type': 'agent', + 'enable-all-tools': false, + tools: ['tool-allowed'], + 'knowledge-bases': ['kb-allowed'], + 'mcp-resources': [{ server: 'private', uri: 'secret' }], + 'mcp-resource-agent-read-enabled': false, + }; + if (agentId === null) raw['agent-id'] = null; + if (plugin === 'LocalAgent') { + raw['advanced-settings'] = true; + raw.model = { + primary: 'llm-valid', + fallbacks: [], + reasoning: { 'llm-valid': 'provider_default' }, + }; + raw['remove-think'] = true; + } + let config = { + ...structuredClone(canonical), + ai: { runner: { id }, runner_config: { [id]: raw } }, + }; + if (converted) config = structuredClone(converted); + else config.output.misc['remove-think'] = false; // Deliberate disagreement must not synchronize on mount. + const writes: any[] = []; + await installLangBotApiMocks(page, { authenticated: true }); + await page.routeWebSocket('**/api/v1/pipelines/**/ws/connect**', (ws) => { + ws.onMessage((raw) => { + if (JSON.parse(String(raw)).type === 'authenticate') + ws.send( + JSON.stringify({ + type: 'connected', + connection_id: 'fixture', + session_type: 'person', + }), + ); + }); + }); + await page.route('**/api/v1/pipelines/preserve', async (route) => { + if (route.request().method() !== 'GET') { + const body = route.request().postDataJSON(); + writes.push(body); + if (body.config) config = body.config; + } + await route.fulfill({ + json: { + code: 0, + data: { + pipeline: { + uuid: 'preserve', + name: 'Preservation', + description: '', + emoji: '⚙️', + config, + }, + }, + }, + }); + }); + await page.route('**/api/v1/pipelines/_/metadata', (route) => + route.fulfill({ + json: { + code: 0, + data: { + configs: [ + { + name: 'ai', + label: { en_US: 'AI' }, + stages: [ + { + name: 'runner', + label: { en_US: 'Runner' }, + config: contract.ai_schema.stages[0].config.map( + (field: Record) => + field.name === 'id' + ? { + ...field, + default: id, + options: [ + { name: id, label: schema.metadata.label }, + ], + } + : field, + ), + }, + { + name: id, + label: schema.metadata.label, + config: schema.spec.config, + }, + ], + }, + outputSchema, + ], + }, + }, + }), + ); + await page.goto('/home/pipelines?id=preserve'); + await page.getByRole('tab', { name: 'AI', exact: true }).click(); + return { writes, raw, id }; +} + +for (const kind of ['json', 'prompt', 'prompt-omitted'] as const) { + test(`canonical ${kind} edits preserve types and metadata, reject invalid drafts`, async ({ + page, + }) => { + const candidate = contract.cases.find( + (c: any) => + c.legacy_runner === (kind === 'json' ? 'langflow-api' : 'local-agent'), + ); + const config = structuredClone(candidate.plan.config); + const id = candidate.plan.target_runner_id; + const field = kind === 'json' ? 'tweaks' : 'prompt'; + const initial = + kind === 'json' + ? { + node: { + enabled: false, + count: 0, + missing: null, + list: [false, 0, null], + }, + } + : kind === 'prompt-omitted' + ? [ + { + role: 'assistant', + tool_calls: [], + name: 'original', + provider_specific_fields: { cache: false, count: 0 }, + }, + ] + : [ + { + role: 'user', + content: [{ type: 'text', text: 'original' }], + name: 'alice', + metadata: { flag: false, count: 0 }, + }, + { + role: 'assistant', + content: null, + tool_calls: [ + { + id: 'call1', + type: 'function', + function: { name: 'foo', arguments: '{}' }, + }, + ], + }, + { role: 'tool', content: [], tool_call_id: 'call1' }, + ]; + config.ai.runner_config[id][field] = initial; + config.ai.runner_config[id]['advanced-settings'] = true; + const { writes } = await setup( + page, + kind === 'json' ? 'langflow-agent' : 'LocalAgent', + candidate.plan.target_plugin.name, + null, + config, + ); + const editor = page.getByTestId(`structured-editor-${field}`); + await expect(editor).toBeVisible(); + expect(JSON.parse(await editor.inputValue())).toEqual(initial); + expect(writes).toHaveLength(0); + await expect( + page.getByRole('button', { name: 'Save', exact: true }), + ).toBeDisabled(); + const edited = structuredClone(initial) as any; + if (kind === 'json') edited.node.count = 7; + else if (kind === 'prompt-omitted') edited[0].name = 'edited'; + else edited[0].content[0].text = 'edited'; + await editor.fill(JSON.stringify(edited, null, 2)); + await page.getByRole('button', { name: 'Save', exact: true }).click(); + await expect.poll(() => writes.length).toBe(1); + expect(writes[0].config.ai.runner_config[id][field]).toEqual(edited); + if (kind === 'prompt-omitted') + expect( + Object.hasOwn( + writes[0].config.ai.runner_config[id][field][0], + 'content', + ), + ).toBe(false); + await page.reload(); + await page.getByRole('tab', { name: 'AI', exact: true }).click(); + expect(JSON.parse(await editor.inputValue())).toEqual(edited); + await editor.fill('{ invalid'); + await expect( + page.getByRole('alert').filter({ hasText: `${field} (JSON)` }), + ).toBeVisible(); + if (kind === 'json') await switchField(page, 'Advanced Settings').click(); + // Make another valid edit so Save remains available: invalid JSON must + // block the entire save, not silently persist the previous value. + await page.getByRole('tab', { name: 'Output', exact: true }).click(); + await switchField(page, 'Quote Origin Message').click(); + await page.getByRole('button', { name: 'Save', exact: true }).click(); + await page.waitForTimeout(400); + expect(writes).toHaveLength(1); + await page.getByRole('tab', { name: 'AI', exact: true }).click(); + if (kind === 'json') await switchField(page, 'Advanced Settings').click(); + await expect(editor).toHaveValue('{ invalid'); + await editor.fill(JSON.stringify(edited)); + await page.getByRole('button', { name: 'Save', exact: true }).click(); + await expect.poll(() => writes.length).toBe(2); + expect(writes[1].config.ai.runner_config[id][field]).toEqual(edited); + }); +} diff --git a/web/tests/e2e/wizard-default-agent.spec.ts b/web/tests/e2e/wizard-default-agent.spec.ts new file mode 100644 index 000000000..4a524261c --- /dev/null +++ b/web/tests/e2e/wizard-default-agent.spec.ts @@ -0,0 +1,560 @@ +import { expect, test } from '@playwright/test'; +import { + installLangBotApiMocks, + pipelineMetadata, +} from './fixtures/langbot-api'; + +const runnerId = 'plugin:langbot-team/LocalAgent/default'; +const ok = (data: unknown) => JSON.stringify({ code: 0, message: 'ok', data }); + +for (const failsFirst of [false, true]) { + test(`prepares Local Agent before enabling the bot${failsFirst ? ' after an installation retry' : ''}`, async ({ + page, + }) => { + await installLangBotApiMocks(page, { + authenticated: true, + withAdapterEvents: true, + }); + let installed = false; + let completeInstall = false; + let installCount = 0; + let modelCount = 0; + let messageReceived = false; + const order: string[] = []; + const configs: Record[] = []; + await page.route('**/api/v1/pipelines/_/metadata', async (route) => { + const metadata = pipelineMetadata(); + if (!installed) metadata.configs[0].stages = []; + else { + const model = metadata.configs[0].stages.find( + (stage) => stage.name === runnerId, + )!.config[0]; + model.default = { primary: '', fallbacks: [] } as never; + } + await route.fulfill({ + contentType: 'application/json', + body: ok(metadata), + }); + }); + await page.route( + '**/api/v1/marketplace/plugins/langbot-team/LocalAgent', + async (route) => { + await route.fulfill({ + contentType: 'application/json', + body: ok({ + plugin: { + author: 'langbot-team', + name: 'LocalAgent', + label: { en_US: 'Local Agent' }, + latest_version: '0.1.7', + }, + }), + }); + }, + ); + await page.route('**/api/v1/plugins/install/marketplace', async (route) => { + installCount += 1; + expect(route.request().postDataJSON()).toEqual({ + plugin_author: 'langbot-team', + plugin_name: 'LocalAgent', + plugin_version: '0.1.7', + }); + order.push('install'); + await route.fulfill({ + contentType: 'application/json', + body: ok({ task_id: installCount }), + }); + }); + await page.route('**/api/v1/system/tasks/*', async (route) => { + const failed = failsFirst && installCount === 1; + installed = !failed && completeInstall; + await route.fulfill({ + contentType: 'application/json', + body: ok({ + id: installCount, + name: 'plugin-install-marketplace', + label: 'LocalAgent', + runtime: { + done: failed || completeInstall, + exception: failed ? 'Download failed: connection reset' : null, + }, + task_context: { + current_action: 'Downloading package', + metadata: { progress_percent: 25 }, + }, + }), + }); + }); + await page.route( + '**/api/v1/system/wizard/recommended-model', + async (route) => { + modelCount += 1; + order.push('model'); + await route.fulfill({ + contentType: 'application/json', + body: ok({ uuid: 'recommended-model', name: 'Recommended' }), + }); + }, + ); + await page.route('**/api/v1/platform/bots/*/logs', async (route) => { + await route.fulfill({ + contentType: 'application/json', + body: ok({ + logs: messageReceived + ? [ + { + seq_id: 1, + timestamp: Date.now() / 1000, + level: 'info', + text: 'Received message', + images: [], + message_session_id: 'person_123', + }, + ] + : [], + total_count: messageReceived ? 1 : 0, + }), + }); + }); + page.on('request', (request) => { + const path = new URL(request.url()).pathname; + if ( + request.method() === 'PUT' && + path === '/api/v1/pipelines/pipeline-1' + ) { + configs.push(request.postDataJSON().config); + order.push('configure'); + } + if ( + request.method() === 'PUT' && + path === '/api/v1/platform/bots/bot-1' && + request.postDataJSON().enable + ) + order.push('enable'); + }); + await page.goto('/wizard'); + await page.getByText('Playwright Adapter', { exact: true }).click(); + await page.getByRole('button', { name: 'Confirm, Create Bot' }).click(); + await page.getByRole('button', { name: 'Save & Enable Bot' }).click(); + if (failsFirst) { + await expect( + page.getByText(/Download failed: connection reset/), + ).toBeVisible(); + expect(order).toEqual(['install']); + await expect(page.getByRole('button', { name: 'Next' })).toBeDisabled(); + await page.getByRole('button', { name: 'Save & Enable Bot' }).click(); + } + const preparing = page.getByRole('button', { + name: /Preparing default AI.*23%/, + }); + await expect(preparing).toBeDisabled(); + await expect(preparing.locator('span[aria-hidden="true"]')).toHaveAttribute( + 'style', + 'width: 23%;', + ); + await expect(page.getByRole('progressbar')).toHaveCount(0); + completeInstall = true; + await expect( + page.getByRole('button', { name: 'Re-save Configuration' }), + ).toBeVisible(); + expect(order.slice(-4)).toEqual([ + 'install', + 'model', + 'configure', + 'enable', + ]); + expect(configs[0].ai.runner.id).toBe(runnerId); + expect(configs[0].ai.runner_config[runnerId].model.primary).toBe( + 'recommended-model', + ); + await page.getByRole('button', { name: 'Re-save Configuration' }).click(); + await expect.poll(() => configs.length).toBe(2); + expect(installCount).toBe(failsFirst ? 2 : 1); + expect(modelCount).toBe(1); + messageReceived = true; + await page.getByRole('button', { name: 'Next' }).click(); + await expect( + page.getByText('Use the default setup', { exact: true }), + ).toBeVisible(); + await expect(page.getByText('Local Agent', { exact: true })).toHaveCount(0); + await page.getByRole('button', { name: 'Done', exact: true }).click(); + await expect( + page.getByRole('button', { name: 'Back to Workbench' }), + ).toBeVisible(); + expect(configs.length).toBe(2); + }); +} + +test('uses a custom model in the existing Local Agent pipeline', async ({ + page, +}) => { + await installLangBotApiMocks(page, { + authenticated: true, + withAdapterEvents: true, + }); + await page.route('**/api/v1/platform/bots/*/logs', async (route) => { + await route.fulfill({ + json: { + code: 0, + data: { + logs: [ + { + seq_id: 1, + timestamp: Date.now() / 1000, + level: 'info', + text: 'Received message', + images: [], + message_session_id: 'person_123', + }, + ], + total_count: 1, + }, + }, + }); + }); + await page.route('**/api/v1/provider/**', async (route) => { + const path = new URL(route.request().url()).pathname; + if (path.endsWith('/requesters')) + return route.fulfill({ + json: { + code: 0, + data: { + requesters: [ + { + name: 'openai', + label: { en_US: 'OpenAI API' }, + description: { en_US: '' }, + spec: { + provider_category: 'manufacturer', + support_type: ['llm'], + config: [ + { name: 'base_url', default: 'https://api.openai.com/v1' }, + ], + }, + }, + ], + }, + }, + }); + if (path.endsWith('/providers') && route.request().method() === 'POST') + return route.fulfill({ + json: { code: 0, data: { uuid: 'own-provider' } }, + }); + if (path.endsWith('/scan-models')) + return route.fulfill({ json: { code: 0, data: { models: [] } } }); + if (path.endsWith('/models/llm') && route.request().method() === 'POST') + return route.fulfill({ json: { code: 0, data: { uuid: 'own-model' } } }); + await route.fallback(); + }); + await page.goto('/wizard'); + await page.getByText('Playwright Adapter', { exact: true }).click(); + await page.getByRole('button', { name: 'Confirm, Create Bot' }).click(); + await page.getByRole('button', { name: 'Save & Enable Bot' }).click(); + await page.getByRole('button', { name: 'Next' }).click(); + await page.getByText('Use My Own Model', { exact: true }).click(); + await expect( + page.getByRole('button', { name: 'Use Selected Model & Finish' }), + ).toBeDisabled(); + await page.locator('input[name="name"]').fill('Wizard custom provider'); + await page + .getByRole('button', { name: 'Select Provider Type', exact: true }) + .click(); + await page.getByRole('button', { name: /OpenAI API/ }).click(); + await page.locator('input[name="api_key"]').fill('fixture-key'); + await page.getByRole('button', { name: 'Save', exact: true }).click(); + await page.getByLabel('Model ID', { exact: false }).fill('test-model'); + const update = page.waitForRequest( + (request) => + request.method() === 'PUT' && + new URL(request.url()).pathname === '/api/v1/pipelines/pipeline-1', + ); + await page + .getByRole('button', { name: 'Use Selected Model & Finish' }) + .click(); + const config = (await update).postDataJSON().config; + expect(config.ai.runner.id).toBe(runnerId); + expect(config.ai.runner_config[runnerId].model.primary).toBe('own-model'); + await expect( + page.getByRole('button', { name: 'Back to Workbench' }), + ).toBeVisible(); +}); + +test('only labels debugging Runners even when the marketplace is offline', async ({ + page, +}) => { + await installLangBotApiMocks(page, { authenticated: true }); + await page.route('**/api/v1/system/info', async (route) => { + await route.fulfill({ + json: { + code: 0, + data: { + version: 'fixture', + edition: 'community', + cloud_service_url: 'https://space.langbot.app', + enable_marketplace: true, + wizard_status: 'none', + wizard_progress: { + step: 2, + created_bot_uuid: 'bot-1', + created_pipeline_uuid: 'pipeline-1', + selected_adapter: 'test', + bot_saved: true, + message_received: true, + }, + }, + }, + }); + }); + const plugins = [ + { name: 'local', label: 'Local Runner', debug: false }, + { name: 'debug', label: 'Debug Runner', debug: true }, + ]; + await page.route('**/api/v1/plugins', async (route) => { + await route.fulfill({ + json: { + code: 0, + data: { + plugins: plugins.map((plugin) => ({ + manifest: { + manifest: { + metadata: { + author: 'qa', + name: plugin.name, + description: { en_US: `${plugin.label} description` }, + }, + }, + }, + debug: plugin.debug, + })), + }, + }, + }); + }); + await page.route('**/api/v1/pipelines/_/metadata', async (route) => { + const metadata = pipelineMetadata(); + const selector = metadata.configs[0].stages[0].config[0]; + if (!('options' in selector)) + throw new Error('Missing Runner options in fixture'); + const options = selector.options; + for (const plugin of plugins) { + options.push({ + name: `plugin:qa/${plugin.name}/default`, + label: { en_US: plugin.label, zh_Hans: plugin.label }, + }); + } + await route.fulfill({ json: { code: 0, data: metadata } }); + }); + await page.route('**/api/v1/marketplace/**', async (route) => { + await route.fulfill({ + status: 503, + json: { code: 503, msg: 'Marketplace offline' }, + }); + }); + await page.goto('/wizard'); + await page + .getByRole('radio', { name: 'Connect an External Agent', exact: true }) + .click(); + const local = page + .locator('[data-slot="card"]') + .filter({ hasText: 'Local Runner' }); + const debug = page + .locator('[data-slot="card"]') + .filter({ hasText: 'Debug Runner' }); + await expect(local.getByText('Installed', { exact: true })).toHaveCount(0); + await expect(debug.getByText('Debugging', { exact: true })).toBeVisible(); + await expect(debug.getByText('Installed', { exact: true })).toHaveCount(0); + await debug.getByRole('button', { name: 'Use This Runner' }).click(); + await expect( + page + .locator('[data-slot="card"]') + .filter({ hasText: 'Debug Runner' }) + .getByText('Debugging', { exact: true }), + ).toBeVisible(); +}); + +test('shows installation progress inside the matching card button and recovers after failure', async ({ + page, +}) => { + await page.emulateMedia({ reducedMotion: 'reduce' }); + await installLangBotApiMocks(page, { authenticated: true }); + await page.route('**/api/v1/system/info', async (route) => { + await route.fulfill({ + json: { + code: 0, + data: { + version: 'fixture', + edition: 'community', + cloud_service_url: 'https://space.langbot.app', + enable_marketplace: true, + wizard_status: 'none', + wizard_progress: { + step: 2, + created_bot_uuid: 'bot-1', + created_pipeline_uuid: 'pipeline-1', + selected_adapter: 'test', + bot_saved: true, + message_received: true, + }, + }, + }, + }); + }); + const plugin = (name: string) => ({ + name, + author: 'qa', + label: { en_US: name }, + description: { en_US: 'Test Runner' }, + components: { Runner: 1 }, + runner_usages: ['agent'], + latest_version: '1.0.0', + install_count: 0, + type: 'plugin', + }); + await page.route( + '**/api/v1/marketplace/extensions/search**', + async (route) => { + await route.fulfill({ + json: { + code: 0, + data: { + extensions: [plugin('ProgressRunner'), plugin('OtherRunner')], + total: 2, + }, + }, + }); + }, + ); + let attempt = 0; + let phase: 'download' | 'deps' | 'failed' | 'done' = 'download'; + let registered = false; + await page.route('**/api/v1/plugins/install/marketplace', async (route) => { + attempt += 1; + phase = 'download'; + await route.fulfill({ json: { code: 0, data: { task_id: attempt } } }); + }); + await page.route('**/api/v1/system/tasks/*', async (route) => { + await route.fulfill({ + json: { + code: 0, + data: { + id: attempt, + name: 'plugin-install-marketplace', + label: 'ProgressRunner', + runtime: { + done: phase === 'done' || phase === 'failed', + exception: + phase === 'failed' ? 'Dependency installation failed' : null, + }, + task_context: { + current_action: + phase === 'download' + ? 'Downloading package' + : 'Installing dependencies', + metadata: { progress_percent: phase === 'download' ? 24 : 64 }, + }, + }, + }, + }); + }); + await page.route('**/api/v1/pipelines/_/metadata', async (route) => { + const metadata = pipelineMetadata(); + const selector = metadata.configs[0].stages[0].config[0]; + if ('options' in selector) + selector.options.push({ + name: 'plugin:qa/ExistingRunner/default', + label: { en_US: 'ExistingRunner', zh_Hans: 'ExistingRunner' }, + }); + metadata.configs[0].stages.push({ + name: 'plugin:qa/ExistingRunner/default', + label: { en_US: 'ExistingRunner', zh_Hans: 'ExistingRunner' }, + config: [ + { + name: 'api-key', + label: { en_US: 'API Key', zh_Hans: 'API Key' }, + type: 'string', + required: true, + default: '', + }, + ], + } as (typeof metadata.configs)[0]['stages'][number]); + if (registered && 'options' in selector) + selector.options.push({ + name: 'plugin:qa/ProgressRunner/default', + label: { en_US: 'ProgressRunner', zh_Hans: 'ProgressRunner' }, + }); + await route.fulfill({ json: { code: 0, data: metadata } }); + }); + await page.goto('/wizard'); + await page + .getByRole('radio', { name: 'Connect an External Agent', exact: true }) + .click(); + const card = page + .locator('[data-slot="card"]') + .filter({ has: page.getByText('ProgressRunner', { exact: true }) }); + const other = page + .locator('[data-slot="card"]') + .filter({ has: page.getByText('OtherRunner', { exact: true }) }); + const back = page.getByRole('button', { + name: /Back to (options|list)/, + exact: true, + }); + await expect(back).toBeVisible(); + const pickerBounds = await back.boundingBox(); + await page + .locator('[data-slot="card"]') + .filter({ has: page.getByText('ExistingRunner', { exact: true }) }) + .getByRole('button', { name: 'Use This Runner' }) + .click(); + await expect(page.getByText('ExistingRunner Configuration')).toBeVisible(); + await expect + .poll(async () => (await back.boundingBox())?.x) + .toBeCloseTo(pickerBounds!.x, 0); + await expect + .poll(async () => (await back.boundingBox())?.y) + .toBeCloseTo(pickerBounds!.y, 0); + await page.getByRole('textbox').fill('unsaved-test-key'); + await page + .locator('[data-slot="card"]') + .filter({ has: page.getByText('ExistingRunner', { exact: true }) }) + .getByRole('button', { name: 'Use This Runner' }) + .click(); + await expect(page.getByRole('textbox')).toHaveValue('unsaved-test-key'); + await card + .getByRole('button', { name: 'Install & Continue', exact: true }) + .click(); + await expect( + card.getByRole('button', { name: /Downloading.*24%/ }), + ).toBeDisabled(); + await expect(page.getByRole('progressbar')).toHaveCount(0); + await expect( + other.getByRole('button', { name: 'Install & Continue', exact: true }), + ).toBeDisabled(); + phase = 'deps'; + await expect( + card.getByRole('button', { name: /Installing Dependencies.*64%/ }), + ).toBeDisabled(); + phase = 'failed'; + await card + .getByRole('button', { name: 'Failed · Retry', exact: true }) + .click(); + await expect( + card.getByRole('button', { name: /Downloading.*24%/ }), + ).toBeDisabled(); + phase = 'done'; + await expect( + card.getByRole('button', { + name: /Starting and refreshing components.*95%/, + }), + ).toBeDisabled(); + registered = true; + await expect( + card.getByRole('button', { name: 'Use This Runner' }), + ).toBeEnabled(); + await expect( + card.getByRole('button', { name: 'Use This Runner' }), + ).toHaveAttribute('aria-pressed', 'true'); + await expect( + other.getByRole('button', { name: 'Install & Continue' }), + ).toBeEnabled(); + expect(attempt).toBe(2); +}); diff --git a/web/tests/e2e/wizard-platform-regressions.spec.ts b/web/tests/e2e/wizard-platform-regressions.spec.ts index 280dcb3ce..2c1cca56f 100644 --- a/web/tests/e2e/wizard-platform-regressions.spec.ts +++ b/web/tests/e2e/wizard-platform-regressions.spec.ts @@ -91,15 +91,123 @@ async function createDraftBot(page: Page, adapterLabel: string) { } test.describe('wizard and QR platform regressions', () => { - test('opens the Page Bot test panel after the first and every later save', async ({ + test('starts with message platforms directly and keeps legacy adapters available', async ({ + page, + }) => { + await installLangBotApiMocks(page, { authenticated: true }); + const adapter = adapterWithQrLogin( + 'message-adapter', + 'Message Adapter', + 'feishu', + ); + await page.route('**/api/v1/platform/adapters', async (route) => { + await route.fulfill({ + contentType: 'application/json', + body: ok({ + adapters: [ + adapter, + adapter, + { + ...adapter, + name: 'events-only', + label: { en_US: 'Events Only' }, + spec: { + ...adapter.spec, + supported_events: ['group.member_joined'], + }, + }, + { + ...adapter, + name: 'legacy', + label: { en_US: 'Legacy Message Adapter' }, + spec: { + ...adapter.spec, + legacy: true, + supported_events: undefined, + }, + }, + ], + }), + }); + }); + await page.goto('/wizard'); + await expect( + page.getByRole('heading', { name: 'Select a Platform' }), + ).toBeVisible(); + await expect( + page.getByRole('button', { + name: /Reply to messages|Welcome new members/, + }), + ).toHaveCount(0); + await expect( + page.getByText('Message Adapter', { exact: true }), + ).toHaveCount(1); + await expect(page.getByText('Events Only', { exact: true })).toHaveCount(0); + await page.getByRole('button', { name: /Legacy Adapters/i }).click(); + await expect( + page.getByText('Legacy Message Adapter', { exact: true }), + ).toBeVisible(); + await page.getByText('Message Adapter', { exact: true }).click(); + await expect( + page.getByRole('button', { name: 'Confirm, Create Bot' }), + ).toBeEnabled(); + }); + + test('old non-message drafts start fresh without modifying their Agent or bot', async ({ + page, + }) => { + await installLangBotApiMocks(page, { authenticated: true }); + await page.route('**/api/v1/system/info', async (route) => { + await route.fulfill({ + contentType: 'application/json', + body: ok({ + version: 'frontend-smoke', + edition: 'community', + wizard_status: 'none', + cloud_service_url: 'https://space.langbot.app', + enable_marketplace: true, + wizard_progress: { + step: 2, + selected_scenario: 'welcome_members', + created_bot_uuid: 'old-bot', + selected_adapter: 'test', + }, + }), + }); + }); + const resourceRequests: string[] = []; + page.on('request', (request) => { + if ( + /\/api\/v1\/(agents|pipelines|platform\/bots)(\/[^_]|$)/.test( + new URL(request.url()).pathname, + ) && + request.method() !== 'GET' + ) { + resourceRequests.push(request.url()); + } + }); + await page.goto('/wizard'); + await expect( + page.getByRole('heading', { name: 'Select a Platform' }), + ).toBeVisible(); + await expect( + page.getByRole('button', { name: 'Confirm, Create Bot' }), + ).toBeDisabled(); + expect(resourceRequests).toEqual([]); + }); + + test('loads the Page Bot from the API origin, retries failures, and reopens after every save', async ({ page, }) => { await installLangBotApiMocks(page, { authenticated: true }); let pipelineCreateCount = 0; let boundPipelineUuid: string | null = null; + let backendOrigin = ''; page.on('request', (request) => { const url = new URL(request.url()); + if (url.pathname === '/api/v1/platform/adapters') + backendOrigin = url.origin; if (request.method() === 'POST' && url.pathname === '/api/v1/pipelines') { pipelineCreateCount += 1; } @@ -160,8 +268,10 @@ test.describe('wizard and QR platform regressions', () => { path.resolve(process.cwd(), '../src/langbot/templates/embed/widget.js'), 'utf8', ); + let widgetAvailable = false; await page.route('**/api/v1/embed/*/widget.js?*', async (route) => { - if (!boundPipelineUuid || pipelineCreateCount === 0) { + expect(new URL(route.request().url()).origin).toBe(backendOrigin); + if (!widgetAvailable || !boundPipelineUuid || pipelineCreateCount === 0) { await route.fulfill({ status: 404, contentType: 'application/javascript', @@ -184,7 +294,6 @@ test.describe('wizard and QR platform regressions', () => { }); await page.goto('/wizard'); - await page.getByRole('button', { name: /Reply to messages/ }).click(); await page.getByText('Page Bot', { exact: true }).click(); await page.getByRole('button', { name: 'Confirm, Create Bot' }).click(); @@ -196,6 +305,15 @@ test.describe('wizard and QR platform regressions', () => { await expect.poll(() => pipelineCreateCount).toBe(1); await expect.poll(() => boundPipelineUuid).toBe('pipeline-1'); + await expect( + page.getByText( + 'Failed to load the test chat. Please save the configuration again to retry.', + { exact: true }, + ), + ).toBeVisible(); + widgetAvailable = true; + await saveButton.click(); + const widgetRoot = page.locator('#langbot-widget-root'); await expect(widgetRoot).toBeAttached(); await expect @@ -337,7 +455,6 @@ test.describe('wizard and QR platform regressions', () => { ); await page.goto('/wizard'); - await page.getByRole('button', { name: /Reply to messages/ }).click(); await page.getByText('HTTP Bot', { exact: true }).click(); await page.getByRole('button', { name: 'Confirm, Create Bot' }).click(); await page @@ -354,13 +471,44 @@ test.describe('wizard and QR platform regressions', () => { await expect.poll(() => inboundTestCount).toBe(1); }); - test('blocks deployment until required Runner configuration is real', async ({ + test('creates only a message pipeline after message verification and valid Runner configuration', async ({ page, }) => { await installLangBotApiMocks(page, { authenticated: true, withAdapterEvents: true, }); + const processorRequests: string[] = []; + page.on('request', (request) => { + const path = new URL(request.url()).pathname; + if ( + request.method() === 'POST' && + ['/api/v1/pipelines', '/api/v1/agents'].includes(path) + ) { + processorRequests.push(path); + } + }); + let messageReceived = false; + await page.route('**/api/v1/platform/bots/*/logs', async (route) => { + await route.fulfill({ + contentType: 'application/json', + body: ok({ + logs: messageReceived + ? [ + { + seq_id: 1, + timestamp: Date.now() / 1000, + level: 'info', + text: 'Received message', + images: [], + message_session_id: 'person_123', + }, + ] + : [], + total_count: messageReceived ? 1 : 0, + }), + }); + }); await page.route('**/api/v1/pipelines/_/metadata', async (route) => { await route.fulfill({ status: 200, @@ -371,6 +519,17 @@ test.describe('wizard and QR platform regressions', () => { name: 'ai', label: { en_US: 'AI Feature', zh_Hans: 'AI 能力' }, stages: [ + { + name: 'plugin:langbot-team/LocalAgent/default', + config: [ + { + name: 'model', + type: 'model-fallback-selector', + required: true, + default: { primary: 'llm-valid', fallbacks: [] }, + }, + ], + }, { name: 'runner', label: { en_US: 'Runtime', zh_Hans: '运行方式' }, @@ -418,17 +577,41 @@ test.describe('wizard and QR platform regressions', () => { }); await page.goto('/wizard'); - await page.getByRole('button', { name: /Welcome new members/ }).click(); await page.getByText('Playwright Adapter', { exact: true }).click(); await page.getByRole('button', { name: 'Confirm, Create Bot' }).click(); await page.getByRole('button', { name: 'Save & Enable Bot' }).click(); + await expect(page.getByRole('button', { name: 'Next' })).toBeDisabled(); + messageReceived = true; await page.getByRole('button', { name: 'Next' }).click(); - await page.getByText('External Runner', { exact: true }).click(); + await expect( + page.getByText('Use the default setup', { exact: true }), + ).toBeVisible(); + await page.getByText('Connect an External Agent', { exact: true }).click(); + await page + .locator('[data-slot="card"]') + .filter({ has: page.getByText('External Runner', { exact: true }) }) + .getByRole('button', { name: 'Use This Runner' }) + .click(); const deployButton = page.getByRole('button', { name: 'Create & Deploy' }); await expect(deployButton).toBeDisabled(); await page.getByRole('textbox').fill('app-real-api-key'); await expect(deployButton).toBeEnabled(); + const bindingRequest = page.waitForRequest( + (request) => + request.method() === 'PUT' && + new URL(request.url()).pathname === '/api/v1/pipelines/pipeline-1', + ); + await deployButton.click(); + const body = (await bindingRequest).postDataJSON(); + expect(body.config.ai.runner.id).toBe('external-runner'); + expect(body.config.ai.runner_config['external-runner']['api-key']).toBe( + 'app-real-api-key', + ); + await expect( + page.getByRole('button', { name: 'Back to Workbench' }), + ).toBeVisible(); + expect(processorRequests).toEqual(['/api/v1/pipelines']); }); for (const qrPlatform of qrPlatforms) { diff --git a/web/tests/fixtures/README.md b/web/tests/fixtures/README.md new file mode 100644 index 000000000..d458f14eb --- /dev/null +++ b/web/tests/fixtures/README.md @@ -0,0 +1,17 @@ +# Sandbox condition compatibility fixture + +`sandbox-scope-schema.json` preserves the sandbox field from master +`9b7ba0d64708496ace30a82866f6dbc185f089dc` (`templates/metadata/pipeline/ai.yaml`) +solely as regression input for the generic dynamic-form condition renderer. +It is **not** shipped pipeline metadata, an installed plugin manifest, or an +assertion that 4.11 plugins may select the Host's execution sandbox scope. + +The old local-agent page is retired. Its unit matrix (availability precedence, +trimmed forced scope, ordered overrides, locale parity and live/external/system +resolution) remains covered. Browser coverage now mounts the real +`DynamicFormComponent` and updates caller context without remounting, rather +than expecting a removed PipelineForm Box poller or scope-value coercion. +A separate real-pipeline browser regression verifies plugin `runner_config` +round-tripping and absence of an injected Core sandbox selector. + +The fixture uses no real Box, provider credentials, or production resources. diff --git a/web/tests/fixtures/sandbox-scope-schema.json b/web/tests/fixtures/sandbox-scope-schema.json new file mode 100644 index 000000000..e282d2a81 --- /dev/null +++ b/web/tests/fixtures/sandbox-scope-schema.json @@ -0,0 +1,149 @@ +{ + "name": "box-session-id-template", + "label": { + "en_US": "Sandbox Scope", + "zh_Hans": "沙箱作用域", + "zh_Hant": "沙箱作用域", + "ja_JP": "サンドボックススコープ", + "vi_VN": "Phạm vi Sandbox", + "th_TH": "ขอบเขต Sandbox", + "es_ES": "Alcance del Sandbox", + "ru_RU": "Область песочницы" + }, + "description": { + "en_US": "Determines how sandbox environments are shared across messages.", + "zh_Hans": "决定沙箱环境在不同消息间的共享方式。", + "zh_Hant": "決定沙箱環境在不同訊息間的共享方式。", + "ja_JP": "メッセージ間でサンドボックス環境を共有する方法を決定します。", + "vi_VN": "Xác định cách chia sẻ môi trường sandbox giữa các tin nhắn.", + "th_TH": "กำหนดวิธีแชร์สภาพแวดล้อม Sandbox ระหว่างข้อความ", + "es_ES": "Determina cómo se comparten los entornos sandbox entre mensajes.", + "ru_RU": "Определяет, как песочницы используются совместно между сообщениями." + }, + "disable_if": { + "field": "__system.box_scope_editable", + "operator": "eq", + "value": false + }, + "disabled_tooltip": { + "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}", + "options": [ + { + "name": "{global}", + "label": { + "en_US": "Global (shared by all)", + "zh_Hans": "全局(所有人共享)", + "zh_Hant": "全域(所有人共用)", + "ja_JP": "グローバル(全員共有)", + "vi_VN": "Toàn cục (chia sẻ cho tất cả)", + "th_TH": "ทั่วไป (แชร์ทั้งหมด)", + "es_ES": "Global (compartido por todos)", + "ru_RU": "Глобальный (общий для всех)" + } + }, + { + "name": "{launcher_type}_{launcher_id}", + "label": { + "en_US": "Per chat (Recommended)", + "zh_Hans": "每个会话(推荐)", + "zh_Hant": "每個會話(推薦)", + "ja_JP": "チャットごと(推奨)", + "vi_VN": "Mỗi cuộc trò chuyện (Khuyến nghị)", + "th_TH": "ต่อแชท (แนะนำ)", + "es_ES": "Por chat (Recomendado)", + "ru_RU": "По чату (Рекомендуется)" + } + }, + { + "name": "{launcher_type}_{launcher_id}_{sender_id}", + "label": { + "en_US": "Per user in chat", + "zh_Hans": "会话中每个用户", + "zh_Hant": "會話中每個用戶", + "ja_JP": "チャット内のユーザーごと", + "vi_VN": "Mỗi người dùng trong cuộc trò chuyện", + "th_TH": "ต่อผู้ใช้ในแชท", + "es_ES": "Por usuario en chat", + "ru_RU": "По пользователю в чате" + } + }, + { + "name": "{launcher_type}_{launcher_id}_{conversation_id}", + "label": { + "en_US": "Per conversation context", + "zh_Hans": "每个对话上下文", + "zh_Hant": "每個對話上下文", + "ja_JP": "会話コンテキストごと", + "vi_VN": "Mỗi ngữ cảnh hội thoại", + "th_TH": "ต่อบริบทการสนทนา", + "es_ES": "Por contexto de conversación", + "ru_RU": "По контексту разговора" + } + }, + { + "name": "{query_id}", + "label": { + "en_US": "Per message (isolated)", + "zh_Hans": "每条消息(完全隔离)", + "zh_Hant": "每條訊息(完全隔離)", + "ja_JP": "メッセージごと(隔離)", + "vi_VN": "Mỗi tin nhắn (cách ly)", + "th_TH": "ต่อข้อความ (แยกส่วน)", + "es_ES": "Por mensaje (aislado)", + "ru_RU": "По сообщению (изолированно)" + } + } + ], + "show_if": { + "field": "__system.is_wizard", + "operator": "neq", + "value": true + } +} diff --git a/web/tests/unit/cloud-new-account-entry.test.mjs b/web/tests/unit/cloud-new-account-entry.test.mjs new file mode 100644 index 000000000..8a096be33 --- /dev/null +++ b/web/tests/unit/cloud-new-account-entry.test.mjs @@ -0,0 +1,19 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import test from 'node:test'; + +const source = fs.readFileSync( + new URL('../../src/app/login/page.tsx', import.meta.url), + 'utf8', +); + +test('normal Cloud login uses the standard Space OAuth callback path', () => { + assert.doesNotMatch(source, /cloudEntry/); + assert.match(source, /getSpaceAuthorizeUrl\(redirectUri\)/); +}); + +test('invitation login uses the same OAuth callback before accepting the invitation', () => { + assert.doesNotMatch(source, /cloudEntry/); + assert.match(source, /const invitationToken = getPendingInvitationToken\(\)/); + assert.match(source, /acceptWorkspaceInvitation\(invitationToken\)/); +}); diff --git a/web/tests/unit/codex-subscription.test.mjs b/web/tests/unit/codex-subscription.test.mjs new file mode 100644 index 000000000..dfafce695 --- /dev/null +++ b/web/tests/unit/codex-subscription.test.mjs @@ -0,0 +1,115 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import test from 'node:test'; +import ts from 'typescript'; + +function loadCatalog(url) { + const compiled = ts.transpileModule(fs.readFileSync(url, 'utf8'), { + compilerOptions: { module: ts.ModuleKind.CommonJS }, + }).outputText; + const module = { exports: {} }; + const require = (specifier) => { + assert.ok(specifier.startsWith('./'), 'Locale imports must be relative'); + return loadCatalog(new URL(`${specifier}.ts`, url)); + }; + new Function('module', 'exports', 'require', compiled)( + module, + module.exports, + require, + ); + return module.exports; +} + +test('all locale catalogs cover Codex states and preserve the expiry placeholder', () => { + const directory = new URL('../../src/i18n/locales/', import.meta.url); + const files = fs + .readdirSync(directory) + .filter((file) => file.endsWith('.ts')); + assert.equal(files.length, 8); + let expected; + for (const file of files) { + const exports = loadCatalog(new URL(file, directory)); + const catalog = (exports.default || Object.values(exports)[0]).models.codex; + const keys = Object.keys(catalog).sort(); + expected ??= keys; + assert.deepEqual(keys, expected, file); + assert.equal(keys.length, 26, file); + assert.ok(catalog.expiresAt.includes('{{time}}'), file); + } +}); + +function policy() { + const source = fs.readFileSync( + new URL( + '../../src/app/home/components/models-dialog/component/provider-form/codexPolicy.ts', + import.meta.url, + ), + 'utf8', + ); + const compiled = ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.CommonJS }, + }).outputText; + const module = { exports: {} }; + new Function('module', 'exports', compiled)(module, module.exports); + return module.exports; +} + +test('Codex payload discards previously entered API credentials and URL', () => { + const { providerPayload } = policy(); + assert.deepEqual( + providerPayload({ + name: 'Subscription', + requester: 'openai-codex', + base_url: 'https://proxy.invalid', + api_key: 'fixture-only', + }), + { + name: 'Subscription', + requester: 'openai-codex', + base_url: 'https://chatgpt.com/backend-api/codex', + api_keys: [], + }, + ); +}); + +test('ordinary providers preserve API key and base URL behavior', () => { + assert.deepEqual( + policy().providerPayload({ + name: 'API', + requester: 'openai', + base_url: 'https://api.example.test/v1', + api_key: 'fixture-only', + }), + { + name: 'API', + requester: 'openai', + base_url: 'https://api.example.test/v1', + api_keys: ['fixture-only'], + }, + ); +}); + +test('poll delay honors upstream minimum and transient backoff', () => { + const { pollDelay } = policy(); + assert.equal(pollDelay(5, 0), 5000); + assert.equal(pollDelay(10, 2), 40000); + assert.equal(pollDelay(120, 3), 120000); + assert.equal(pollDelay(NaN, 0), 5000); + assert.equal(pollDelay(-1, 0), 5000); +}); + +test('only the contracted OpenAI device authorization URL can be opened', () => { + const { isCodexVerificationUri } = policy(); + assert.equal( + isCodexVerificationUri('https://auth.openai.com/codex/device'), + true, + ); + for (const url of [ + 'javascript:alert(1)', + 'https://auth.openai.com.evil.test/codex/device', + 'https://evil.test', + 'https://user@auth.openai.com/codex/device', + ]) { + assert.equal(isCodexVerificationUri(url), false); + } +}); diff --git a/web/tests/unit/dynamic-form-save-values.test.mjs b/web/tests/unit/dynamic-form-save-values.test.mjs index f5626726b..1845716ae 100644 --- a/web/tests/unit/dynamic-form-save-values.test.mjs +++ b/web/tests/unit/dynamic-form-save-values.test.mjs @@ -66,6 +66,7 @@ test('normalizes only single-line text fields in a dynamic form save snapshot', fallbacks: ['fallback-model'], reasoning: { 'primary-model': 'high', + 'fallback-model': 'provider_default', }, }, }); diff --git a/web/tests/unit/embed-widget-assistant-dedupe.test.mjs b/web/tests/unit/embed-widget-assistant-dedupe.test.mjs new file mode 100644 index 000000000..42f172d3d --- /dev/null +++ b/web/tests/unit/embed-widget-assistant-dedupe.test.mjs @@ -0,0 +1,201 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import vm from 'node:vm'; +import { fileURLToPath } from 'node:url'; + +const testDirectory = path.dirname(fileURLToPath(import.meta.url)); +const widgetPath = path.resolve( + testDirectory, + '../../../src/langbot/templates/embed/widget.js', +); +const widgetSource = fs.readFileSync(widgetPath, 'utf8'); + +class FakeElement { + constructor(tagName) { + this.tagName = tagName; + this.children = []; + this.className = ''; + this.dataset = {}; + this.style = {}; + this.listeners = {}; + this._innerHTML = ''; + } + + appendChild(child) { + this.children.push(child); + return child; + } + + setAttribute(name, value) { + this[name] = String(value); + } + + addEventListener(event, listener) { + this.listeners[event] = listener; + } + + click() { + this.listeners.click?.({}); + } + + attachShadow() { + this.shadowRoot = new FakeElement('shadow-root'); + return this.shadowRoot; + } + + get classList() { + return { + add: (...names) => { + const classes = `${this.className} ${names.join(' ')}` + .trim() + .split(/\s+/); + this.className = [...new Set(classes)].join(' '); + }, + }; + } + + set textContent(value) { + this._innerHTML = String(value ?? ''); + } + + set innerHTML(value) { + this._innerHTML = String(value ?? ''); + } + + get innerHTML() { + return this._innerHTML; + } + + querySelectorAll(selector) { + const matches = []; + for (const child of this.children) { + if ( + selector.startsWith('.') && + child.className.split(/\s+/).includes(selector.slice(1)) + ) { + matches.push(child); + } + matches.push(...child.querySelectorAll(selector)); + } + return matches; + } + + querySelector(selector) { + return this.querySelectorAll(selector)[0] ?? null; + } +} + +class FakeDocument { + constructor() { + this.body = new FakeElement('body'); + this.head = new FakeElement('head'); + this.readyState = 'complete'; + this.currentScript = { getAttribute: () => null }; + } + + createElement(tagName) { + return new FakeElement(tagName); + } + + getElementById(id) { + const find = (element) => { + if (element.id === id) return element; + for (const child of element.children) { + const match = find(child); + if (match) return match; + } + return null; + }; + return find(this.body) ?? find(this.head); + } +} + +class FakeWebSocket { + static OPEN = 1; + static instances = []; + + constructor(url) { + this.url = url; + this.readyState = FakeWebSocket.OPEN; + FakeWebSocket.instances.push(this); + } + + send() {} +} + +function launchWidget() { + FakeWebSocket.instances = []; + const document = new FakeDocument(); + const window = { + crypto: { randomUUID: () => '00000000-0000-4000-8000-000000000000' }, + sessionStorage: { getItem: () => null, setItem: () => {} }, + }; + const context = vm.createContext({ + document, + window, + navigator: { clipboard: { writeText: () => Promise.resolve() } }, + WebSocket: FakeWebSocket, + fetch: () => new Promise(() => {}), + requestAnimationFrame: () => 0, + setTimeout: () => 0, + clearTimeout: () => {}, + setInterval: () => 0, + clearInterval: () => {}, + }); + + vm.runInContext(widgetSource, context, { filename: widgetPath }); + const root = document.getElementById('langbot-widget-root'); + assert.ok(root, 'widget should initialize'); + root.shadowRoot.querySelector('.lb-bubble').click(); + const socket = FakeWebSocket.instances.at(-1); + assert.ok(socket, 'opening widget should connect its WebSocket'); + + return { + receive(message) { + socket.onmessage({ + data: JSON.stringify({ type: 'response', data: message }), + }); + }, + assistantMessages() { + return root.shadowRoot.querySelectorAll('.lb-msg-assistant'); + }, + }; +} + +function assistant(id, content) { + return { id, role: 'assistant', content, is_final: true }; +} + +test('renders a non-empty reply after an empty assistant frame', () => { + const widget = launchWidget(); + + widget.receive(assistant('thought', '')); + widget.receive(assistant('answer', 'visible answer')); + + const messages = widget.assistantMessages(); + assert.equal(messages.length, 2); + assert.equal( + messages[1].querySelector('.lb-msg-bubble').innerHTML, + 'visible answer', + ); +}); + +test('still drops a duplicate non-empty assistant frame', () => { + const widget = launchWidget(); + + widget.receive(assistant('answer-1', 'same answer')); + widget.receive(assistant('answer-2', 'same answer')); + + assert.equal(widget.assistantMessages().length, 1); +}); + +test('still keeps two distinct non-empty assistant frames', () => { + const widget = launchWidget(); + + widget.receive(assistant('answer-1', 'first answer')); + widget.receive(assistant('answer-2', 'second answer')); + + assert.equal(widget.assistantMessages().length, 2); +}); diff --git a/web/tests/unit/n8n-auth-field-visibility.test.mjs b/web/tests/unit/n8n-auth-field-visibility.test.mjs new file mode 100644 index 000000000..7a022d774 --- /dev/null +++ b/web/tests/unit/n8n-auth-field-visibility.test.mjs @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import ts from 'typescript'; +import { fileURLToPath } from 'node:url'; + +const currentDirectory = path.dirname(fileURLToPath(import.meta.url)); +const sourcePath = path.resolve( + currentDirectory, + '../../src/app/home/components/dynamic-form/N8nAuthFieldVisibility.ts', +); + +function loadVisibilityPolicy() { + const source = fs.readFileSync(sourcePath, 'utf8'); + const compiled = ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.CommonJS }, + }).outputText; + const loadedModule = { exports: {} }; + new Function('require', 'module', 'exports', compiled)( + () => { + throw new Error('N8nAuthFieldVisibility must not have runtime imports'); + }, + loadedModule, + loadedModule.exports, + ); + return loadedModule.exports; +} + +test('shows response handling with the other common n8n fields', () => { + const { shouldShowN8nConfigField } = loadVisibilityPolicy(); + + for (const field of [ + 'webhook-url', + 'auth-type', + 'timeout', + 'output-key', + 'response-handling', + ]) { + assert.equal(shouldShowN8nConfigField(field, 'none'), true, field); + } +}); + +test('shows only fields for the selected n8n authentication method', () => { + const { shouldShowN8nConfigField } = loadVisibilityPolicy(); + + assert.equal(shouldShowN8nConfigField('basic-username', 'basic'), true); + assert.equal(shouldShowN8nConfigField('basic-password', 'jwt'), false); + assert.equal(shouldShowN8nConfigField('jwt-secret', 'jwt'), true); + assert.equal(shouldShowN8nConfigField('header-name', 'header'), true); + assert.equal(shouldShowN8nConfigField('unrelated-field', 'none'), false); +}); diff --git a/web/tests/unit/pipeline-config-safety.test.mjs b/web/tests/unit/pipeline-config-safety.test.mjs new file mode 100644 index 000000000..c236a48eb --- /dev/null +++ b/web/tests/unit/pipeline-config-safety.test.mjs @@ -0,0 +1,69 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import vm from 'node:vm'; +import test from 'node:test'; +import ts from 'typescript'; + +const source = readFileSync( + 'src/app/home/pipelines/pipeline-config-safety.ts', + 'utf8', +); +const context = { exports: {} }; +vm.runInNewContext( + ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.CommonJS }, + }).outputText, + context, +); +const { isCurrentPipelineConfig } = context.exports; +const plugin = 'plugin:langbot-team/LocalAgent/default'; + +test('canonical current empty runner remains editable', () => { + const config = JSON.parse( + readFileSync( + '../src/langbot/templates/default-pipeline-config.json', + 'utf8', + ), + ); + assert.equal(isCurrentPipelineConfig(config), true); +}); + +test('configured current binding remains editable', () => { + assert.equal( + isCurrentPipelineConfig({ + ai: { runner: { id: plugin }, runner_config: {} }, + }), + true, + ); +}); + +for (const [name, ai] of Object.entries({ + legacy: { runner: { runner: 'local-agent' } }, + mixedSelector: { runner: { id: plugin, runner: 'local-agent' } }, + mixedLegacySection: { + runner: { id: plugin }, + runner_config: {}, + 'local-agent': {}, + }, + emptyMixedLegacySection: { + runner: { id: '' }, + runner_config: {}, + 'dify-service-api': {}, + }, + emptyMissingContainer: { runner: { id: '' } }, + emptyMalformedContainer: { runner: { id: '' }, runner_config: [] }, + emptyAmbiguousContainer: { + runner: { id: '' }, + runner_config: { orphan: {} }, + }, + emptyAmbiguousSelector: { + runner: { id: '', legacy: 'local-agent' }, + runner_config: {}, + }, + missingId: { runner: {}, runner_config: {} }, + nonPlugin: { runner: { id: 'local-agent' }, runner_config: {} }, +})) { + test(`${name} does not hydrate current editor defaults`, () => { + assert.equal(isCurrentPipelineConfig({ ai }), false); + }); +} diff --git a/web/tests/unit/pipeline-migration-notices.test.mjs b/web/tests/unit/pipeline-migration-notices.test.mjs new file mode 100644 index 000000000..54a15595f --- /dev/null +++ b/web/tests/unit/pipeline-migration-notices.test.mjs @@ -0,0 +1,71 @@ +import assert from 'node:assert/strict'; +import { readFileSync, readdirSync } from 'node:fs'; +import test from 'node:test'; +import vm from 'node:vm'; +import ts from 'typescript'; + +function load(path) { + const context = { exports: {} }; + vm.runInNewContext( + ts.transpileModule(readFileSync(path, 'utf8'), { + compilerOptions: { module: ts.ModuleKind.CommonJS }, + }).outputText, + context, + ); + return context.exports; +} + +const warnings = [ + ...new Set( + [ + ...readFileSync( + '../src/langbot/pkg/pipeline/legacy_config_migration.py', + 'utf8', + ).matchAll(/_warn\(result,\s*'([^']+)'/g), + ].map((match) => match[1]), + ), +]; + +test('every converter warning has a specific explanation in all eight locales', () => { + const { migrationIssueKey } = load( + 'src/app/home/pipelines/pipeline-migration-issues.ts', + ); + const base = 'src/i18n/locales/pipeline-migration'; + const locales = readdirSync(base).filter((name) => name.endsWith('.ts')); + assert.equal(locales.length, 8); + assert.ok( + warnings.length > 10, + 'warning discovery must not silently go empty', + ); + for (const file of locales) { + const catalog = load(`${base}/${file}`).default; + for (const code of warnings) { + const key = migrationIssueKey(code); + assert.ok(key, `${file}: missing mapping for ${code}`); + assert.equal(typeof catalog.notices?.[key], 'string', `${file}: ${code}`); + assert.ok(catalog.notices[key].length > 10); + } + assert.ok(catalog.notices.pluginVersion); + assert.ok(catalog.notices.pendingInteraction); + for (const code of ['plugin_runtime_unavailable', 'migration_failed']) { + const key = migrationIssueKey(code); + assert.ok(key); + assert.equal(typeof catalog.notices[key], 'string'); + assert.notEqual(catalog.notices[key], catalog.blockerFallback); + } + } +}); + +test('unknown backend issue codes never become translation keys or displayed messages', () => { + const { migrationIssueKey } = load( + 'src/app/home/pipelines/pipeline-migration-issues.ts', + ); + for (const code of [ + 'unknown', + '__proto__', + 'constructor', + 'secret-token.example', + ]) { + assert.equal(migrationIssueKey(code), undefined); + } +}); diff --git a/web/tests/unit/reasoning-edit-semantics.test.mjs b/web/tests/unit/reasoning-edit-semantics.test.mjs new file mode 100644 index 000000000..c44e93e22 --- /dev/null +++ b/web/tests/unit/reasoning-edit-semantics.test.mjs @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import test from 'node:test'; +import ts from 'typescript'; +const source = fs.readFileSync( + 'src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx', + 'utf8', +); +// Exercise the actual field callback without mocking React's form lifecycle. +const callback = source.slice( + source.indexOf('const updateModelReasoning ='), + source.indexOf('const replaceModel ='), +); +function edit(reasoning, uuid, level) { + let result; + const code = ts.transpileModule( + callback + '\nupdateModelReasoning(uuid, level);', + { compilerOptions: { target: ts.ScriptTarget.ES2020 } }, + ).outputText; + new Function('modelValue', 'updateValue', 'uuid', 'level', code)( + { reasoning }, + (value) => { + result = value.reasoning; + }, + uuid, + level, + ); + return result; +} +test('provider default is an explicit override, not inheritance', () => { + assert.deepEqual(edit({ a: 'high', b: 'low' }, 'a', 'provider_default'), { + a: 'provider_default', + b: 'low', + }); +}); +test('changing one model preserves other overrides without mutating input', () => { + const input = Object.freeze({ a: 'provider_default', b: 'high' }); + const output = edit(input, 'a', 'low'); + assert.deepEqual(output, { a: 'low', b: 'high' }); + assert.deepEqual(input, { a: 'provider_default', b: 'high' }); +}); diff --git a/web/tests/unit/runner-config-preservation.test.mjs b/web/tests/unit/runner-config-preservation.test.mjs new file mode 100644 index 000000000..c7e41e936 --- /dev/null +++ b/web/tests/unit/runner-config-preservation.test.mjs @@ -0,0 +1,103 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import test from 'node:test'; +import ts from 'typescript'; +function load(relative) { + const source = fs.readFileSync(new URL(relative, import.meta.url), 'utf8'); + const compiled = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + }, + }).outputText; + const module = { exports: {} }; + new Function('module', 'exports', compiled)(module, module.exports); + return module.exports; +} +const helper = + '../../src/app/home/pipelines/components/pipeline-form/RunnerConfigPreservation.ts'; +test('mount emission is not a persisted edit', () => { + const { preserveRunnerConfig } = load(helper); + const raw = { + 'api-key': ' secret ', + 'agent-id': null, + 'enable-all-tools': false, + }; + assert.deepEqual( + preserveRunnerConfig(raw, undefined, { + 'api-key': 'secret', + 'agent-id': '', + mode: 'chat', + }), + raw, + ); +}); +test('unrelated edits preserve hidden Host policy, absent/null and credentials', () => { + const { preserveRunnerConfig } = load(helper); + const raw = { + 'api-key': ' secret ', + 'agent-id': null, + 'enable-all-tools': false, + tools: ['one'], + 'knowledge-bases': ['kb'], + 'mcp-resources': [{ uri: 'private' }], + 'mcp-resource-agent-read-enabled': false, + }; + const first = { + 'api-key': 'secret', + 'agent-id': '', + mode: 'chat', + absent: 'default', + }; + assert.deepEqual( + preserveRunnerConfig(raw, first, { ...first, mode: 'agent' }), + { ...raw, mode: 'agent' }, + ); +}); +test('intentional edits, removals and nested edits win without normalizing untouched siblings', () => { + const { preserveRunnerConfig } = load(helper); + const raw = { + 'api-key': ' secret ', + model: { + primary: 'one', + fallbacks: ['two'], + reasoning: { one: 'provider_default', two: 'high' }, + }, + }; + const first = { 'api-key': 'secret', model: raw.model }; + assert.deepEqual( + preserveRunnerConfig(raw, first, { + 'api-key': 'new', + model: { + primary: 'one', + fallbacks: [], + reasoning: { one: 'provider_default' }, + }, + }), + { + 'api-key': 'new', + model: { + primary: 'one', + fallbacks: [], + reasoning: { one: 'provider_default' }, + }, + }, + ); +}); +test('save normalization preserves explicit selected per-model provider_default', () => { + const { normalizeDynamicFormValuesForSave } = load( + '../../src/app/home/components/dynamic-form/DynamicFormSaveValues.ts', + ); + const value = { + primary: 'one', + fallbacks: ['two'], + reasoning: { one: 'provider_default', two: 'high', removed: 'high' }, + }; + assert.deepEqual( + normalizeDynamicFormValuesForSave( + [{ name: 'model', type: 'model-fallback-selector' }], + { model: value }, + ).model.reasoning, + { one: 'provider_default', two: 'high' }, + ); +}); diff --git a/web/tests/unit/runner-merge-contract.test.mjs b/web/tests/unit/runner-merge-contract.test.mjs new file mode 100644 index 000000000..6432bccb4 --- /dev/null +++ b/web/tests/unit/runner-merge-contract.test.mjs @@ -0,0 +1,112 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import test from 'node:test'; +import ts from 'typescript'; + +const read = (path) => fs.readFileSync(new URL(path, import.meta.url), 'utf8'); + +test('dynamic form combines manifest normalization and shared condition helpers', () => { + const source = read( + '../../src/app/home/components/dynamic-form/DynamicFormComponent.tsx', + ); + const parsed = ts.createSourceFile( + 'DynamicFormComponent.tsx', + source, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX, + ); + assert.equal( + parsed.parseDiagnostics.length, + 0, + 'Dynamic form must parse after the merge', + ); + const imports = parsed.statements.filter(ts.isImportDeclaration); + const importedNames = (module) => + imports + .filter((item) => item.moduleSpecifier.text === module) + .flatMap( + (item) => + item.importClause?.namedBindings?.elements?.map( + (entry) => entry.name.text, + ) ?? [], + ); + assert.ok( + importedNames('./DynamicFormItemConfig').includes( + 'parseDynamicFormItemType', + ), + ); + assert.deepEqual(importedNames('./DynamicFormConditions').sort(), [ + 'resolveDisabledState', + 'resolveShowIfValue', + ]); + assert.equal( + parsed.statements.filter( + (item) => + ts.isFunctionDeclaration(item) && + item.name?.text === 'resolveShowIfValue', + ).length, + 0, + ); +}); + +test('pipeline defaults retain plugin runner containers, not retired Core runners', () => { + const config = JSON.parse( + read('../../../src/langbot/templates/default-pipeline-config.json'), + ); + assert.deepEqual(config.ai, { + runner: { id: '', 'expire-time': 0 }, + runner_config: {}, + }); + const metadata = read( + '../../../src/langbot/templates/metadata/pipeline/ai.yaml', + ); + assert.doesNotMatch( + metadata, + /^\s+- name: (?:local-agent|n8n-service-api|langflow-api)$/m, + ); + assert.match(metadata, /RunnerRegistry/); +}); + +test('pipeline UI does not restore host-owned Box scope as local-agent config', () => { + const source = read( + '../../src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx', + ); + assert.doesNotMatch( + source, + /getBoxScopeContext|force_box_session_id_template|N8nAuthFormComponent/, + ); + assert.match(source, /ai\.runner_config/); + assert.match(source, /systemContext=\{dynamicFormSystemContext\}/); +}); + +test('backend client preserves complementary Codex and runner debug API imports', () => { + const source = read('../../src/app/infra/http/BackendClient.ts'); + const parsed = ts.createSourceFile( + 'BackendClient.ts', + source, + ts.ScriptTarget.Latest, + true, + ); + assert.equal( + parsed.parseDiagnostics.length, + 0, + 'API client must parse after the merge', + ); + const imports = parsed.statements + .filter(ts.isImportDeclaration) + .flatMap( + (item) => + item.importClause?.namedBindings?.elements?.map( + (entry) => entry.name.text, + ) ?? [], + ); + for (const name of [ + 'CodexAuthStatus', + 'CodexDeviceAuthorization', + 'CodexDevicePoll', + 'DebugExecutionEvent', + ]) { + assert.ok(imports.includes(name), name); + } +}); diff --git a/web/tests/unit/sandbox-scope-tooltip.test.mjs b/web/tests/unit/sandbox-scope-tooltip.test.mjs new file mode 100644 index 000000000..a5cf624aa --- /dev/null +++ b/web/tests/unit/sandbox-scope-tooltip.test.mjs @@ -0,0 +1,246 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import test from 'node:test'; +import ts from 'typescript'; + +// Compatibility fixture, not Core metadata: 4.11 owns sandbox scope in the +// Runner Host. Keep the original condition/locale matrix without reintroducing +// a local-agent configuration stage or claiming plugins control Host scope. +const scope = JSON.parse( + fs.readFileSync( + new URL('../fixtures/sandbox-scope-schema.json', import.meta.url), + 'utf8', + ), +); +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', + ); +}); diff --git a/web/tests/unit/session-monitor-pagination.test.mjs b/web/tests/unit/session-monitor-pagination.test.mjs new file mode 100644 index 000000000..38b09f306 --- /dev/null +++ b/web/tests/unit/session-monitor-pagination.test.mjs @@ -0,0 +1,155 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../..', +); +const repoRoot = path.resolve(root, '..'); +const read = (file) => fs.readFileSync(path.join(root, file), 'utf8'); +const readRepo = (file) => fs.readFileSync(path.join(repoRoot, file), 'utf8'); +const includes = (source, token, message) => + assert.ok(source.includes(token), message); + +test('session list request supports a server-side page and operator filters', () => { + const client = read('src/app/infra/http/BackendClient.ts'); + includes(client, 'startTime?: string', 'client accepts a start date'); + includes(client, 'endTime?: string', 'client accepts an end date'); + includes(client, 'userQuery?: string', 'client accepts a user query'); + includes( + client, + "queryParams.append('offset', options.offset.toString())", + 'client sends the requested session offset', + ); + includes( + client, + "queryParams.append('userQuery', options.userQuery)", + 'client sends the user query', + ); + + const monitor = read( + 'src/app/home/bots/components/bot-session/BotSessionMonitor.tsx', + ); + for (const token of [ + 'SESSION_PAGE_SIZE', + 'sessionTotal', + 'sessionPage', + 'startDate', + 'endDate', + 'userQuery', + ]) { + includes(monitor, token, `monitor includes ${token}`); + } +}); + +test('session detail requests and renders a bounded message page', () => { + const monitor = read( + 'src/app/home/bots/components/bot-session/BotSessionMonitor.tsx', + ); + for (const token of [ + 'MESSAGE_PAGE_SIZE', + 'messageTotal', + 'messagePage', + 'page * MESSAGE_PAGE_SIZE', + ]) { + includes(monitor, token, `message pagination includes ${token}`); + } +}); + +test('backend filters sessions by user id or user name in the existing endpoint', () => { + const controller = readRepo( + 'src/langbot/pkg/api/http/controller/groups/monitoring.py', + ); + const service = readRepo('src/langbot/pkg/api/http/service/monitoring.py'); + includes( + controller, + "quart.request.args.get('userQuery')", + 'route accepts userQuery', + ); + includes(controller, 'user_query=user_query', 'route forwards userQuery'); + includes( + service, + 'user_query: str | None = None', + 'service accepts userQuery', + ); + includes( + service, + 'MonitoringSession.user_id.ilike', + 'service searches user ids', + ); + includes( + service, + 'MonitoringSession.user_name.ilike', + 'service searches user names', + ); +}); + +test('stale session and message page responses cannot overwrite the latest page', () => { + const monitor = read( + 'src/app/home/bots/components/bot-session/BotSessionMonitor.tsx', + ); + for (const token of [ + 'sessionRequestIdRef', + 'messageRequestIdRef', + 'requestId !== sessionRequestIdRef.current', + 'requestId !== messageRequestIdRef.current', + 'messageRequestIdRef.current += 1', + ]) { + includes(monitor, token, `stale response guard includes ${token}`); + } +}); + +test('changing the session page or filters clears the selected detail', () => { + const monitor = read( + 'src/app/home/bots/components/bot-session/BotSessionMonitor.tsx', + ); + includes(monitor, 'setSelectedSessionId(null)', 'selection is cleared'); + includes( + monitor, + '[appliedUserQuery, botId, endDate, sessionPage, startDate]', + 'page and filters invalidate the selected session', + ); +}); + +test('date filters use the operator local calendar day', () => { + const monitor = read( + 'src/app/home/bots/components/bot-session/BotSessionMonitor.tsx', + ); + includes( + monitor, + 'localDateBoundaryToISOString(startDate, false)', + 'local start-of-day conversion', + ); + includes( + monitor, + 'localDateBoundaryToISOString(endDate, true)', + 'local end-of-day conversion', + ); +}); + +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, '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', + ); +}); diff --git a/web/tests/unit/structured-field-value.test.mjs b/web/tests/unit/structured-field-value.test.mjs new file mode 100644 index 000000000..d3d908d6a --- /dev/null +++ b/web/tests/unit/structured-field-value.test.mjs @@ -0,0 +1,74 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import test from 'node:test'; +import ts from 'typescript'; +const file = 'src/app/home/components/dynamic-form/StructuredFieldValue.ts'; +function load() { + const compiled = ts.transpileModule(fs.readFileSync(file, 'utf8'), { + compilerOptions: { module: ts.ModuleKind.CommonJS }, + }).outputText; + const m = { exports: {} }; + new Function('module', 'exports', compiled)(m, m.exports); + return m.exports; +} +test('JSON parsing preserves nested false, zero, null and arrays as typed values', () => { + const { parseStructuredDraft } = load(); + const value = { n: { a: false, b: 0, c: null, d: [false, 0, null] } }; + assert.deepEqual(parseStructuredDraft(JSON.stringify(value), false), value); +}); +test('prompt parsing preserves structured content, null, empty arrays, metadata and first role', () => { + const { parseStructuredDraft } = load(); + const value = [ + { role: 'user', content: [{ type: 'text', text: 'x' }], name: 'alice' }, + { role: 'assistant', content: null, tool_calls: [] }, + { role: 'tool', content: [], tool_call_id: 'x' }, + ]; + assert.deepEqual(parseStructuredDraft(JSON.stringify(value), true), value); +}); +test('prompt metadata edits preserve omitted content without inserting null', () => { + const { parseStructuredDraft, isPromptValue } = load(); + const value = [ + { + role: 'assistant', + tool_calls: [], + name: 'edited', + provider_specific_fields: { cache: false }, + }, + ]; + assert.equal(isPromptValue(value), true); + const result = parseStructuredDraft(JSON.stringify(value), true); + assert.deepEqual(result, value); + assert.equal(Object.hasOwn(result[0], 'content'), false); + for (const content of [false, 0, {}, undefined]) + assert.equal(isPromptValue([{ ...value[0], content }]), false); +}); +test('invalid JSON and non-message prompt shapes reject rather than silently retain old values', () => { + const { parseStructuredDraft } = load(); + for (const raw of ['{broken', '', 'undefined']) + assert.throws(() => parseStructuredDraft(raw, false)); + for (const raw of [ + '{}', + 'null', + '[{"role":0,"content":"x"}]', + '[{"role":"user","content":false}]', + ]) + assert.throws(() => parseStructuredDraft(raw, true)); +}); +test('friendly prompt editor is restricted to losslessly represented roles and string content', () => { + const { isSimplePrompt } = load(); + assert.equal( + isSimplePrompt([ + { role: 'system', content: '' }, + { role: 'user', content: 'x' }, + ]), + true, + ); + for (const value of [ + [{ role: 'user', content: 'x' }], + [{ role: 'system', content: null }], + [{ role: 'system', content: [] }], + [{ role: 'system', content: 'x', name: 'a' }], + [], + ]) + assert.equal(isSimplePrompt(value), false); +}); diff --git a/web/tests/unit/wizard-http-bot.test.mjs b/web/tests/unit/wizard-http-bot.test.mjs index 656e2f8a8..43dfb1ce2 100644 --- a/web/tests/unit/wizard-http-bot.test.mjs +++ b/web/tests/unit/wizard-http-bot.test.mjs @@ -28,6 +28,7 @@ function loadWizardUtils() { } const { + LOCAL_AGENT_RUNNER_ID, configureLocalAgentPrimaryModel, ensureHttpBotSigningSecret, findDefaultPipeline, @@ -81,23 +82,31 @@ test('configures the selected model as the Local Agent primary model', () => { const config = { trigger: { prefix: '!' }, ai: { - runner: { runner: 'plugin:external', timeout: 30 }, - 'local-agent': { - model: { primary: 'old-model', fallbacks: ['fallback-model'] }, - tools: { enabled: true }, + runner: { id: 'plugin:external', timeout: 30 }, + runner_config: { + [LOCAL_AGENT_RUNNER_ID]: { + model: { primary: 'old-model', fallbacks: ['fallback-model'] }, + tools: { enabled: true }, + }, }, }, }; const updated = configureLocalAgentPrimaryModel(config, 'selected-model'); - assert.equal(updated.ai.runner.runner, 'local-agent'); + assert.equal(updated.ai.runner.id, LOCAL_AGENT_RUNNER_ID); assert.equal(updated.ai.runner.timeout, 30); - assert.equal(updated.ai['local-agent'].model.primary, 'selected-model'); - assert.deepEqual(updated.ai['local-agent'].model.fallbacks, [ - 'fallback-model', - ]); - assert.deepEqual(updated.ai['local-agent'].tools, { enabled: true }); + assert.equal( + updated.ai.runner_config[LOCAL_AGENT_RUNNER_ID].model.primary, + 'selected-model', + ); + assert.deepEqual( + updated.ai.runner_config[LOCAL_AGENT_RUNNER_ID].model.fallbacks, + ['fallback-model'], + ); + assert.deepEqual(updated.ai.runner_config[LOCAL_AGENT_RUNNER_ID].tools, { + enabled: true, + }); assert.deepEqual(updated.trigger, { prefix: '!' }); }); diff --git a/web/tests/unit/wizard-page-bot.test.mjs b/web/tests/unit/wizard-page-bot.test.mjs index 16090f870..aea775a7f 100644 --- a/web/tests/unit/wizard-page-bot.test.mjs +++ b/web/tests/unit/wizard-page-bot.test.mjs @@ -45,20 +45,20 @@ test('opens the Page Bot preview after every successful wizard save', () => { wizardSource, /setPageBotPreviewRequest\(\(request\) => request \+ 1\)/, ); - assert.match(wizardSource, /root\?\.langbotOpen\?\.\(\)/); + assert.match(widgetSource, /getAttribute\("data-auto-open"\) === "true"/); assert.match(widgetSource, /root\.langbotOpen = function \(\)/); assert.match(widgetSource, /if \(scriptAutoOpen\) root\.langbotOpen\(\)/); }); -test('binds every message-reply bot to its provisional pipeline before verification', () => { +test('binds every wizard bot to its provisional pipeline before verification', () => { assert.match( wizardSource, - /selectedScenarioDefinition\?\.processorKind === 'pipeline'[\s\S]*?httpClient\.createPipeline\(/, + /if \(!previewPipelineUuid\) \{[\s\S]*?httpClient\.createPipeline\(/, ); assert.match( wizardSource, - /event_pattern: selectedScenarioDefinition\.eventType,[\s\S]*?target_type: 'pipeline',[\s\S]*?target_uuid: previewPipelineUuid/, + /event_pattern: 'message\.received',[\s\S]*?target_type: 'pipeline',[\s\S]*?target_uuid: previewPipelineUuid/, ); assert.doesNotMatch( wizardSource, @@ -89,25 +89,19 @@ test('requires the selected Runner mandatory configuration before finishing', () wizardSource, /isRequiredRunnerConfigComplete\(selectedRunnerConfigItems, runnerConfig\)/, ); + assert.match(wizardSource, /aiChoice === 'external'/); assert.match( wizardSource, - /return selectedRunner !== null && isRunnerConfigComplete/, - ); - assert.match( - wizardSource, - /!selectedRunner \|\|[\s\S]*?!isRunnerConfigComplete \|\|[\s\S]*?!createdBotUuid/, + /!createdBotUuid \|\| !createdPipelineUuid \|\| !canProceed\(\)/, ); }); -test('requires an observed message only for the message-reply scenario', () => { +test('requires an observed message before configuring the runner', () => { assert.match( wizardSource, - /selectedScenario !== 'message_reply' \|\| messageReceived/, - ); - assert.match( - wizardSource, - /requiresMessageVerification=\{selectedScenario === 'message_reply'\}/, + /createdBotUuid !== null && botSaved && messageReceived/, ); + assert.match(wizardSource, /requiresMessageVerification/); assert.match(wizardSource, /onMessageReceived=\{handleMessageReceived\}/); }); diff --git a/web/vite.reasoning.config.ts b/web/vite.reasoning.config.ts new file mode 100644 index 000000000..6e2b7f492 --- /dev/null +++ b/web/vite.reasoning.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import path from 'node:path'; +export default defineConfig({ + plugins: [react()], + cacheDir: '/tmp/411-reasoning-vite-cache', + resolve: { alias: { '@': path.resolve(__dirname, 'src') } }, +}); diff --git a/web/vite.structured.config.ts b/web/vite.structured.config.ts new file mode 100644 index 000000000..43fb560da --- /dev/null +++ b/web/vite.structured.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import path from 'node:path'; +export default defineConfig({ + plugins: [react()], + cacheDir: '/tmp/411-structured-vite-cache', + resolve: { alias: { '@': path.resolve(__dirname, 'src') } }, +});