Compare commits

..

6 Commits

Author SHA1 Message Date
RockChinQ
2b6dcfe9c7 feat(survey): add bot_response_success_100 milestone trigger event
Counts successful non-WebSocket bot responses (persisted in the metadata
table as survey_bot_response_count, survives restarts) and fires the
bot_response_success_100 survey event once the instance reaches 100
responses. Counting stops after the milestone has been triggered.

Existing first_bot_response_success behavior unchanged. 6 new unit tests.
2026-06-12 09:40:07 -04:00
RockChinQ
dd96da895c feat(telemetry): payload v2 with feature usage counters and instance heartbeat
Per-query events now carry event_type='query' and a features JSON object:
- tool_calls by source (native/plugin/mcp/skill) via ToolManager
- tool_call_rounds, kb usage (count/engine plugins/retrieved entries) via local-agent
- sandbox execs/errors via BoxService
- activated_skills and bound mcp_servers snapshots

New instance_heartbeat event (startup + daily) reports anonymous instance
profile: deploy platform, database/vdb kind, box backend/availability,
adapter type names, and resource counts. Respects space.disable_telemetry.

All collection helpers are defensive and never break the pipeline.
Verified: ruff, 37 telemetry unit tests (13 new), 504 box/provider/pipeline tests.
2026-06-12 08:11:43 -04:00
Junyan Qin
bca710dbd4 feat(platform): show deployment outbound IPs on adapter config forms
Cloud/NAT deployments couldn't complete WeCom-family / Official Account /
QQ Official setup because the trusted-IP (IP whitelist) value — the
server's egress IPs — was nowhere visible in LangBot.

- config.yaml: new system.outbound_ips list (env: SYSTEM__OUTBOUND_IPS,
  comma-separated), exposed via GET /api/v1/system/info
- dynamic form: generic __system.*-named display-only fields resolved
  from systemContext (same namespace as show_if), one read-only row per
  value with a copy button, excluded from form state and emitted values;
  hidden entirely when the deployment provides no IPs
- manifests: trusted-IP display field for wecom, wecomcs, wecombot,
  officialaccount, qqofficial

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 19:41:17 +08:00
RockChinQ
47ade18596 fix(log): roll daily log file at midnight for long-running processes
The log filename was computed once at init_logging() startup and the
RotatingFileHandler only rotated by size, so a process running across
midnight kept appending every subsequent day's logs to the start-day
file (langbot-<start date>.log). No file ever appeared for the current
day until the process was restarted, confusing users into thinking
logging had stopped.

Replace RotatingFileHandler with DailyGroupedRotatingFileHandler, which
switches to langbot-<current date>.log when the local date changes while
still doing size-based numbered rotation within a day. On-disk naming
stays compatible with the maintenance log-retention cleanup
(LOG_FILE_PATTERN). Adds regression tests.
2026-06-10 04:58:11 -04:00
Junyan Qin
733c9cdf16 fix(ci): trigger CLA check on PR reopen
Allows attaching the required CLA status to pull requests opened
before the workflow existed, by closing and reopening them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 12:10:52 +08:00
Junyan Qin
bbc508d42f feat: add Contributor License Agreement (CLA) and signing workflow
Introduce an individual CLA (license-grant style, based on Apache ICLA
v2.2) with English as the authoritative text and a Chinese reference
translation. Contributors sign by replying to a bot comment on their
first PR; signatures are recorded in the langbot-app/cla repository
and cover all repositories in the organization.

- CLA.md: agreement text (grantee: Beijing Langbo Intelligent
  Technology Co., Ltd.)
- .github/workflows/cla.yml: contributor-assistant action pinned to
  v2.6.1, signatures stored remotely in langbot-app/cla
- CONTRIBUTING.md / PR template: bilingual CLA notice

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 11:49:30 +08:00
32 changed files with 1215 additions and 29 deletions

View File

@@ -21,6 +21,7 @@
*请在方括号间写`x`以打勾 / Please tick the box with `x`* *请在方括号间写`x`以打勾 / Please tick the box with `x`*
- [ ] 阅读仓库[贡献指引](https://github.com/langbot-app/LangBot/blob/master/CONTRIBUTING.md)了吗? / Have you read the [contribution guide](https://github.com/langbot-app/LangBot/blob/master/CONTRIBUTING.md)? - [ ] 阅读仓库[贡献指引](https://github.com/langbot-app/LangBot/blob/master/CONTRIBUTING.md)了吗? / Have you read the [contribution guide](https://github.com/langbot-app/LangBot/blob/master/CONTRIBUTING.md)?
- [ ] 我已签署或将在机器人提示后签署 [CLA](https://github.com/langbot-app/LangBot/blob/master/CLA.md)。 / I have signed, or will sign when prompted by the bot, the [CLA](https://github.com/langbot-app/LangBot/blob/master/CLA.md).
- [ ] 与项目所有者沟通过了吗? / Have you communicated with the project maintainer? - [ ] 与项目所有者沟通过了吗? / Have you communicated with the project maintainer?
- [ ] 我确定已自行测试所作的更改,确保功能符合预期。 / I have tested the changes and ensured they work as expected. - [ ] 我确定已自行测试所作的更改,确保功能符合预期。 / I have tested the changes and ensured they work as expected.

41
.github/workflows/cla.yml vendored Normal file
View File

@@ -0,0 +1,41 @@
name: "CLA Assistant"
on:
issue_comment:
types: [created]
pull_request_target:
types: [opened, closed, synchronize, reopened]
permissions:
actions: write # re-run the failed CLA check after signing
contents: read # signatures are stored in the remote langbot-app/cla repo
pull-requests: write # post guidance comments, lock PR after merge
statuses: write # set the commit status
jobs:
CLAAssistant:
runs-on: ubuntu-latest
steps:
- name: "CLA Assistant"
if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target'
# Upstream repo was archived in 2026-03; pin to the v2.6.1 commit SHA.
uses: contributor-assistant/github-action@ca4a40a7d1004f18d9960b404b97e5f30a505a08 # v2.6.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# repo-scope PAT with write access to langbot-app/cla
PERSONAL_ACCESS_TOKEN: ${{ secrets.CLA_PAT }}
with:
path-to-document: 'https://github.com/langbot-app/LangBot/blob/master/CLA.md'
remote-organization-name: 'langbot-app'
remote-repository-name: 'cla'
path-to-signatures: 'signatures/version1/cla.json'
branch: 'main'
allowlist: 'dependabot[bot],github-actions[bot],devin-ai-integration[bot],Copilot,renovate[bot],bot*'
custom-notsigned-prcomment: |
Thank you for your contribution! :heart: Before we can merge this pull request, we need you to sign the [LangBot Contributor License Agreement (CLA)](https://github.com/langbot-app/LangBot/blob/master/CLA.md). You keep full copyright of your code — the CLA grants us a license to use and distribute your contribution. Signing takes 10 seconds and covers all repositories in this organization, permanently.
感谢您的贡献!合并前请阅读并签署[贡献者许可协议CLA](https://github.com/langbot-app/LangBot/blob/master/CLA.md)。您保留代码的全部版权,签署仅需回复下方指定内容,一次签署对本组织全部仓库永久有效。
custom-allsigned-prcomment: 'All contributors have signed the CLA. :white_check_mark: 所有贡献者均已签署 CLA。'
lock-pullrequest-aftermerge: true
# SECURITY: this workflow runs on pull_request_target (it holds secrets and has
# write access to the base repository). NEVER add an actions/checkout step that
# checks out the PR's code here.

107
CLA.md Normal file
View File

@@ -0,0 +1,107 @@
# LangBot Individual Contributor License Agreement (v1.0)
Thank you for your interest in contributing to LangBot (the "Project"), stewarded by Beijing Langbo Intelligent Technology Co., Ltd. (北京浪波智能科技有限公司) ("We" or "Us").
This Individual Contributor License Agreement ("Agreement") documents the rights granted by contributors to Us. By signing this Agreement (see Section 9), You accept and agree to the following terms and conditions for Your present and future Contributions submitted to the Project. Except for the licenses granted herein to Us and recipients of software distributed by Us, You reserve all right, title, and interest in and to Your Contributions.
## 1. Definitions
"You" (or "Your") shall mean the copyright owner or legal entity authorized by the copyright owner that is making this Agreement with Us.
"Contribution" shall mean any original work of authorship, including any modifications or additions to an existing work, that is intentionally submitted by You to Us for inclusion in, or documentation of, any of the products or repositories owned or managed by Us (the "Work"). For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to Us or our representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, Us for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by You as "Not a Contribution."
## 2. Grant of Copyright License
Subject to the terms and conditions of this Agreement, You hereby grant to Us and to recipients of software distributed by Us a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare derivative works of, publicly display, publicly perform, sublicense, and distribute Your Contributions and such derivative works. For clarity, this includes the right for Us to distribute Your Contributions, alone or as part of the Work, under the terms of any license, including without limitation open source licenses and commercial or proprietary licenses.
## 3. Grant of Patent License
Subject to the terms and conditions of this Agreement, You hereby grant to Us and to recipients of software distributed by Us a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by You that are necessarily infringed by Your Contribution(s) alone or by combination of Your Contribution(s) with the Work to which such Contribution(s) was submitted. If any entity institutes patent litigation against You or any other entity (including a cross-claim or counterclaim in a lawsuit) alleging that Your Contribution, or the Work to which You have contributed, constitutes direct or contributory patent infringement, then any patent licenses granted to that entity under this Agreement for that Contribution or Work shall terminate as of the date such litigation is filed.
## 4. Authority; Employer
You represent that You are legally entitled to grant the above licenses. If Your employer(s) has rights to intellectual property that You create that includes Your Contributions, You represent that You have received permission to make Contributions on behalf of that employer, that Your employer has waived such rights for Your Contributions to Us, or that Your employer has executed a separate Corporate Contributor License Agreement with Us.
## 5. Original Creation; Disclosure
You represent that each of Your Contributions is Your original creation (see Section 7 for submissions on behalf of others). You represent that Your Contribution submissions include complete details of any third-party license or other restriction (including, but not limited to, related patents and trademarks) of which You are personally aware and which are associated with any part of Your Contributions.
## 6. No Obligation of Support; Disclaimer
You are not expected to provide support for Your Contributions, except to the extent You desire to provide support. You may provide support for free, for a fee, or not at all. Unless required by applicable law or agreed to in writing, You provide Your Contributions on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE.
## 7. Third-Party Works
Should You wish to submit work that is not Your original creation, You may submit it to Us separately from any Contribution, identifying the complete details of its source and of any license or other restriction (including, but not limited to, related patents, trademarks, and license agreements) of which You are personally aware, and conspicuously marking the work as "Submitted on behalf of a third-party: [named here]".
## 8. Notification
You agree to notify Us of any facts or circumstances of which You become aware that would make these representations inaccurate in any respect.
## 9. Electronic Signature
This Agreement is accepted and signed electronically: posting a comment containing the exact phrase designated by Us (currently "I have read the CLA Document and I hereby sign the CLA") from Your GitHub account on a pull request in the Project's repositories constitutes Your binding electronic signature to this Agreement. You represent that the GitHub account used to sign belongs to You and that You are of legal age to form a binding contract. Your signature covers Your present and future Contributions to all repositories owned or managed by Us, until and unless You notify Us in writing that You withdraw from this Agreement for future Contributions (licenses already granted are irrevocable).
## 10. Our Commitment
We commit that the Project's main repository will continue to make an open source version of the Work publicly available.
## 11. Miscellaneous
This Agreement is the entire agreement between You and Us regarding Your Contributions and supersedes any prior agreements on this subject. If any provision is held unenforceable, the remaining provisions remain in effect. This Agreement is executed in English; the Chinese translation below is provided for reference only, and the English version shall prevail in case of any discrepancy.
---
# LangBot 个人贡献者许可协议v1.0)中文参考译文
> 本译文仅供参考,如与英文版有任何歧义,以英文版为准。
感谢您有意为 LangBot下称"本项目")作出贡献。本项目由北京浪波智能科技有限公司(下称"我方")运营管理。
本《个人贡献者许可协议》(下称"本协议")旨在记录贡献者授予我方的各项权利。您一经签署本协议(见第 9 条),即接受并同意以下条款与条件,适用于您向本项目提交的现在及未来的全部贡献。除本协议授予我方及我方分发软件之接收者的许可外,您保留对您的贡献的全部权利、所有权和利益。
## 1. 定义
"您"指与我方订立本协议的版权所有人,或经版权所有人授权的法律实体。
"贡献"指您有意提交给我方、用于纳入我方拥有或管理的任何产品或代码仓库(下称"作品")或其文档的任何原创作品,包括对既有作品的修改或增补。就本定义而言,"提交"指以任何电子、口头或书面形式向我方或我方代表发送的通信,包括但不限于在由我方或代表我方管理的电子邮件列表、源代码管理系统和问题跟踪系统中,为讨论和改进作品而进行的通信;但您以显著方式标注或以书面形式声明为"非贡献"Not a Contribution的通信除外。
## 2. 版权许可的授予
在遵守本协议条款与条件的前提下,您特此授予我方及我方分发软件之接收者一项永久的、全球范围的、非独占的、免费的、免版税的、不可撤销的版权许可,以复制您的贡献、基于其创作衍生作品、公开展示、公开表演、再许可以及分发您的贡献及上述衍生作品。为明确起见,上述许可包括我方有权以任何许可条款(包括但不限于开源许可证以及商业或专有许可证)单独或作为作品的一部分分发您的贡献。
## 3. 专利许可的授予
在遵守本协议条款与条件的前提下,您特此授予我方及我方分发软件之接收者一项永久的、全球范围的、非独占的、免费的、免版税的、不可撤销的(本条所述情形除外)专利许可,以制造、委托制造、使用、许诺销售、销售、进口及以其他方式转让作品;该许可仅适用于您可许可的、且因您的贡献本身或您的贡献与其所提交之作品的结合而必然受到侵犯的专利权利要求。如任何实体对您或任何其他实体提起专利诉讼(包括诉讼中的交叉请求或反诉),主张您的贡献或您所贡献的作品构成直接或帮助性专利侵权,则依据本协议就该贡献或作品授予该实体的任何专利许可,自该诉讼提起之日起终止。
## 4. 权利能力与雇主
您声明您在法律上有权授予上述许可。如您的雇主对您创作的、包含您的贡献在内的知识产权享有权利,您声明:您已获得该雇主代表其作出贡献的许可,或该雇主已就您向我方的贡献放弃上述权利,或该雇主已与我方另行签署《企业贡献者许可协议》。
## 5. 原创性声明与披露义务
您声明您的每项贡献均为您的原创作品(代表第三方提交的情形见第 7 条)。您声明您提交的贡献中已完整披露您本人知悉的、与您的贡献任何部分相关的任何第三方许可或其他限制(包括但不限于相关专利和商标)的全部细节。
## 6. 无支持义务;免责声明
您无义务为您的贡献提供支持,除非您自愿提供。您可以免费提供支持、收费提供支持或不提供支持。除非适用法律要求或另有书面约定,您的贡献按"现状"AS IS提供不附带任何明示或默示的保证或条件包括但不限于关于权属、不侵权、适销性或特定用途适用性的任何保证或条件。
## 7. 第三方作品
如您希望提交非您原创的作品,您可以将其与任何贡献分开单独提交给我方,并完整说明其来源以及您本人知悉的任何许可或其他限制(包括但不限于相关专利、商标和许可协议)的全部细节,同时以显著方式将该作品标注为"代表第三方提交:[此处注明第三方名称]"。
## 8. 通知义务
如您知悉任何事实或情况将导致上述声明在任何方面不准确,您同意通知我方。
## 9. 电子签署
本协议以电子方式接受并签署:您通过您的 GitHub 账号在本项目代码仓库的拉取请求pull request中发表包含我方指定语句现为 "I have read the CLA Document and I hereby sign the CLA")的评论,即构成您对本协议具有约束力的电子签名。您声明用于签署的 GitHub 账号归您本人所有,且您已达到订立有约束力合同的法定年龄。您的签署覆盖您对我方拥有或管理的全部代码仓库的现在及未来的贡献,直至您以书面形式通知我方就未来贡献退出本协议为止(已授予的许可不可撤销)。
## 10. 我方承诺
我方承诺本项目主仓库将持续公开提供作品的开源版本。
## 11. 其他
本协议构成您与我方之间就您的贡献达成的完整协议,并取代双方先前就此主题达成的任何协议。如本协议任何条款被认定为不可执行,其余条款仍然有效。本协议以英文签署,中文译文仅供参考,如有歧义以英文版为准。

View File

@@ -14,6 +14,12 @@
- 在 PR 和 Commit Message 中请使用全英文 - 在 PR 和 Commit Message 中请使用全英文
- 对于中文用户issue 中可以使用中文 - 对于中文用户issue 中可以使用中文
### 贡献者许可协议CLA
为了保护项目和每一位贡献者,我们要求所有代码贡献者签署[贡献者许可协议CLA](./CLA.md)。这是 Apache、Google、Grafana 等主流开源项目的标准做法:您保留自己代码的全部版权,仅授予项目使用、分发您贡献的许可。
签署只需 10 秒:首次提交 PR 时,机器人会自动评论提示,按提示回复一句话即完成签署,此后对本组织所有仓库永久有效。历史贡献不受影响。
<hr/> <hr/>
## Guidelines ## Guidelines
@@ -29,3 +35,9 @@
- Use English in PRs and Commit Messages - Use English in PRs and Commit Messages
- For English users, you can use English in issues - For English users, you can use English in issues
### Contributor License Agreement (CLA)
To protect the project and every contributor, we require all code contributors to sign our [Contributor License Agreement](./CLA.md). This is standard practice in major open source projects such as Apache, Google, and Grafana: you keep full copyright of your code — the CLA only grants us a license to use and distribute your contribution.
Signing takes 10 seconds: when you open your first PR, a bot will guide you to reply with a single comment. One signature covers all repositories in this organization, permanently. Past contributions are not affected.

View File

@@ -31,6 +31,18 @@ class SystemRouterGroup(group.RouterGroup):
except Exception: except Exception:
pass pass
# ``system.outbound_ips`` may be a comma-separated string instead of
# a list when injected via the SYSTEM__OUTBOUND_IPS env var into a
# pre-existing data/config.yaml that lacks the key (env overrides
# only coerce to list when the key already holds one).
outbound_ips = self.ap.instance_config.data.get('system', {}).get('outbound_ips', [])
if isinstance(outbound_ips, str):
outbound_ips = [ip.strip() for ip in outbound_ips.split(',') if ip.strip()]
elif isinstance(outbound_ips, list):
outbound_ips = [str(ip).strip() for ip in outbound_ips if str(ip).strip()]
else:
outbound_ips = []
return self.success( return self.success(
data={ data={
'version': constants.semantic_version, 'version': constants.semantic_version,
@@ -49,6 +61,7 @@ class SystemRouterGroup(group.RouterGroup):
'disable_models_service', False 'disable_models_service', False
), ),
'limitation': self.ap.instance_config.data.get('system', {}).get('limitation', {}), 'limitation': self.ap.instance_config.data.get('system', {}).get('limitation', {}),
'outbound_ips': outbound_ips,
'wizard_status': wizard_status, 'wizard_status': wizard_status,
'wizard_progress': wizard_progress, 'wizard_progress': wizard_progress,
} }

View File

@@ -12,6 +12,7 @@ import pydantic
from langbot_plugin.box.client import BoxRuntimeClient from langbot_plugin.box.client import BoxRuntimeClient
from .connector import BoxRuntimeConnector, _get_box_config from .connector import BoxRuntimeConnector, _get_box_config
from ..telemetry import features as telemetry_features
from langbot_plugin.box.errors import BoxError, BoxValidationError from langbot_plugin.box.errors import BoxError, BoxValidationError
from langbot_plugin.box.models import ( from langbot_plugin.box.models import (
BUILTIN_PROFILES, BUILTIN_PROFILES,
@@ -218,6 +219,7 @@ class BoxService:
f'query_id={query.query_id} ' f'query_id={query.query_id} '
f'summary={json.dumps(self._summarize_result(result), ensure_ascii=False)}' f'summary={json.dumps(self._summarize_result(result), ensure_ascii=False)}'
) )
telemetry_features.increment(query, 'sandbox', 'execs')
return self._serialize_result(result) return self._serialize_result(result)
def resolve_box_session_id(self, query: pipeline_query.Query) -> str: def resolve_box_session_id(self, query: pipeline_query.Query) -> str:
@@ -785,6 +787,7 @@ class BoxService:
# ── Observability ───────────────────────────────────────────────── # ── Observability ─────────────────────────────────────────────────
def _record_error(self, exc: Exception, query: pipeline_query.Query): def _record_error(self, exc: Exception, query: pipeline_query.Query):
telemetry_features.increment(query, 'sandbox', 'errors')
self._recent_errors.append( self._recent_errors.append(
{ {
'timestamp': _dt.datetime.now(_UTC).isoformat(), 'timestamp': _dt.datetime.now(_UTC).isoformat(),

View File

@@ -200,6 +200,17 @@ class Application:
scopes=[core_entities.LifecycleControlScope.APPLICATION], scopes=[core_entities.LifecycleControlScope.APPLICATION],
) )
# Telemetry instance heartbeat (startup + daily); respects
# space.disable_telemetry via TelemetryManager.send().
if self.telemetry is not None:
from ..telemetry import heartbeat as telemetry_heartbeat
self.task_mgr.create_task(
telemetry_heartbeat.heartbeat_loop(self),
name='telemetry-heartbeat',
scopes=[core_entities.LifecycleControlScope.APPLICATION],
)
# Start monitoring data cleanup task if enabled # Start monitoring data cleanup task if enabled
monitoring_cfg = self.instance_config.data.get('monitoring', {}) monitoring_cfg = self.instance_config.data.get('monitoring', {})
auto_cleanup_cfg = monitoring_cfg.get('auto_cleanup', {}) auto_cleanup_cfg = monitoring_cfg.get('auto_cleanup', {})

View File

@@ -1,5 +1,6 @@
import logging import logging
import logging.handlers import logging.handlers
import os
import sys import sys
import time import time
@@ -20,6 +21,66 @@ log_colors_config = {
LOG_FILE_MAX_BYTES = 10 * 1024 * 1024 # 10MB per file LOG_FILE_MAX_BYTES = 10 * 1024 * 1024 # 10MB per file
LOG_FILE_BACKUP_COUNT = 5 # Keep 5 backup files (total ~50MB max) LOG_FILE_BACKUP_COUNT = 5 # Keep 5 backup files (total ~50MB max)
LOG_DIR = 'data/logs'
class DailyGroupedRotatingFileHandler(logging.handlers.RotatingFileHandler):
"""File handler that writes to ``data/logs/langbot-YYYY-MM-DD.log``.
It combines two rotation triggers:
* **Size** — within a single day the file is rotated once it exceeds
``maxBytes``, producing numbered backups (``langbot-DATE.log.1`` etc.),
exactly like :class:`~logging.handlers.RotatingFileHandler`.
* **Date** — when the local date changes, logging switches to a fresh
``langbot-<new date>.log`` file. This happens even within a single
long-running process, so a bot started on day N keeps writing to that
day's file and rolls over to day N+1's file at midnight, instead of
appending every subsequent day's logs to the start-day file.
The on-disk naming stays compatible with the log-retention cleanup in
``api/http/service/maintenance.py`` (``LOG_FILE_PATTERN``).
"""
def __init__(self, log_dir: str, max_bytes: int, backup_count: int, encoding: str = 'utf-8'):
self.log_dir = log_dir
self._current_date = self._today()
super().__init__(
self._build_path(self._current_date),
maxBytes=max_bytes,
backupCount=backup_count,
encoding=encoding,
)
@staticmethod
def _today() -> str:
return time.strftime('%Y-%m-%d', time.localtime())
def _build_path(self, date_str: str) -> str:
return os.path.join(self.log_dir, 'langbot-%s.log' % date_str)
def shouldRollover(self, record):
# Roll over when the day changes, regardless of file size.
if self._today() != self._current_date:
return True
return super().shouldRollover(record)
def doRollover(self):
today = self._today()
if today != self._current_date:
# Date changed: point the handler at the new day's file.
# This is a date switch, not a size-based numbered rotation.
if self.stream:
self.stream.close()
self.stream = None
self._current_date = today
self.baseFilename = os.path.abspath(self._build_path(today))
if not self.delay:
self.stream = self._open()
else:
# Same day, file exceeded maxBytes: numbered rotation.
super().doRollover()
async def init_logging(extra_handlers: list[logging.Handler] = None) -> logging.Logger: async def init_logging(extra_handlers: list[logging.Handler] = None) -> logging.Logger:
# Remove all existing loggers # Remove all existing loggers
@@ -31,8 +92,6 @@ async def init_logging(extra_handlers: list[logging.Handler] = None) -> logging.
if constants.debug_mode: if constants.debug_mode:
level = logging.DEBUG level = logging.DEBUG
log_file_name = 'data/logs/langbot-%s.log' % time.strftime('%Y-%m-%d', time.localtime())
qcg_logger = logging.getLogger('langbot') qcg_logger = logging.getLogger('langbot')
qcg_logger.setLevel(level) qcg_logger.setLevel(level)
@@ -48,12 +107,13 @@ async def init_logging(extra_handlers: list[logging.Handler] = None) -> logging.
# stream_handler.setFormatter(color_formatter) # stream_handler.setFormatter(color_formatter)
stream_handler.stream = open(sys.stdout.fileno(), mode='w', encoding='utf-8', buffering=1) stream_handler.stream = open(sys.stdout.fileno(), mode='w', encoding='utf-8', buffering=1)
# Use RotatingFileHandler to prevent unbounded log file growth # Rotate by size within a day and switch files when the date changes,
rotating_file_handler = logging.handlers.RotatingFileHandler( # so long-running processes still produce a log file for the current day.
log_file_name, rotating_file_handler = DailyGroupedRotatingFileHandler(
LOG_DIR,
max_bytes=LOG_FILE_MAX_BYTES,
backup_count=LOG_FILE_BACKUP_COUNT,
encoding='utf-8', encoding='utf-8',
maxBytes=LOG_FILE_MAX_BYTES,
backupCount=LOG_FILE_BACKUP_COUNT,
) )
log_handlers: list[logging.Handler] = [ log_handlers: list[logging.Handler] = [

View File

@@ -13,6 +13,7 @@ from ....provider import runner as runner_module
import langbot_plugin.api.entities.events as events import langbot_plugin.api.entities.events as events
from ....utils import importutil, constants, runner as runner_utils from ....utils import importutil, constants, runner as runner_utils
from ....telemetry import features as telemetry_features
from ....provider import runners from ....provider import runners
import langbot_plugin.api.entities.builtin.provider.session as provider_session import langbot_plugin.api.entities.builtin.provider.session as provider_session
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
@@ -201,7 +202,12 @@ class ChatMessageHandler(handler.MessageHandler):
runner_name, runner, query.pipeline_config runner_name, runner, query.pipeline_config
) )
# Feature usage collected during query processing (tool calls,
# knowledge base usage, sandbox executions, activated skills, ...)
features = telemetry_features.collect_features(query)
payload = { payload = {
'event_type': 'query',
'query_id': query.query_id, 'query_id': query.query_id,
'adapter': adapter_name, 'adapter': adapter_name,
'runner': runner_name, 'runner': runner_name,
@@ -212,6 +218,7 @@ class ChatMessageHandler(handler.MessageHandler):
'instance_id': constants.instance_id, 'instance_id': constants.instance_id,
'edition': constants.edition, 'edition': constants.edition,
'pipeline_plugins': pipeline_plugins, 'pipeline_plugins': pipeline_plugins,
'features': features,
'error': locals().get('error_info', None), 'error': locals().get('error_info', None),
'timestamp': datetime.utcnow().isoformat(), 'timestamp': datetime.utcnow().isoformat(),
} }
@@ -219,10 +226,12 @@ class ChatMessageHandler(handler.MessageHandler):
# Send telemetry asynchronously and do not block pipeline via app's telemetry manager # Send telemetry asynchronously and do not block pipeline via app's telemetry manager
await self.ap.telemetry.start_send_task(payload) await self.ap.telemetry.start_send_task(payload)
# Trigger survey event on first successful non-WebSocket response # Trigger survey events on successful non-WebSocket responses
if not locals().get('error_info') and adapter_name and 'WebSocket' not in adapter_name: if not locals().get('error_info') and adapter_name and 'WebSocket' not in adapter_name:
if self.ap.survey: if self.ap.survey:
await self.ap.survey.trigger_event('first_bot_response_success') await self.ap.survey.trigger_event('first_bot_response_success')
# Counts toward the bot_response_success_100 milestone event
await self.ap.survey.record_bot_response_success()
except Exception as ex: except Exception as ex:
# Ensure telemetry issues do not affect normal flow # Ensure telemetry issues do not affect normal flow
self.ap.logger.warning(f'Failed to send telemetry: {ex}') self.ap.logger.warning(f'Failed to send telemetry: {ex}')

View File

@@ -31,6 +31,18 @@ spec:
type: webhook-url type: webhook-url
required: false required: false
default: "" default: ""
- name: __system.outbound_ips
label:
en_US: IP Whitelist
zh_Hans: IP 白名单
zh_Hant: IP 白名單
description:
en_US: Add these outbound IPs of the LangBot server to the IP whitelist in the "Basic Configuration" of the WeChat Official Account platform
zh_Hans: 请将这些 LangBot 服务器的出网 IP 添加到微信公众平台「基本配置」中的 IP 白名单
zh_Hant: 請將這些 LangBot 伺服器的出網 IP 加入微信公眾平台「基本配置」中的 IP 白名單
type: array[string]
required: false
default: []
- name: token - name: token
label: label:
en_US: Token en_US: Token

View File

@@ -19,6 +19,18 @@ spec:
en: https://link.langbot.app/en/platforms/qqofficial en: https://link.langbot.app/en/platforms/qqofficial
ja: https://link.langbot.app/ja/platforms/qqofficial ja: https://link.langbot.app/ja/platforms/qqofficial
config: config:
- name: __system.outbound_ips
label:
en_US: IP Whitelist
zh_Hans: IP 白名单
zh_Hant: IP 白名單
description:
en_US: Add these outbound IPs of the LangBot server to the IP whitelist in the development settings of the QQ Open Platform
zh_Hans: 请将这些 LangBot 服务器的出网 IP 添加到 QQ 开放平台开发设置中的 IP 白名单
zh_Hant: 請將這些 LangBot 伺服器的出網 IP 加入 QQ 開放平台開發設定中的 IP 白名單
type: array[string]
required: false
default: []
- name: appid - name: appid
label: label:
en_US: App ID en_US: App ID

View File

@@ -32,6 +32,18 @@ spec:
type: webhook-url type: webhook-url
required: false required: false
default: "" default: ""
- name: __system.outbound_ips
label:
en_US: Trusted IPs
zh_Hans: 企业可信 IP
zh_Hant: 企業可信 IP
description:
en_US: Add these outbound IPs of the LangBot server to the "Trusted Enterprise IPs" of your app in the WeCom admin console
zh_Hans: 请将这些 LangBot 服务器的出网 IP 添加到企业微信管理后台应用详情页的「企业可信 IP」中
zh_Hant: 請將這些 LangBot 伺服器的出網 IP 加入企業微信管理後台應用詳情頁的「企業可信 IP」中
type: array[string]
required: false
default: []
- name: corpid - name: corpid
label: label:
en_US: Corpid en_US: Corpid

View File

@@ -75,6 +75,18 @@ spec:
field: enable-webhook field: enable-webhook
operator: eq operator: eq
value: true value: true
- name: __system.outbound_ips
label:
en_US: Trusted IPs
zh_Hans: 企业可信 IP
zh_Hant: 企業可信 IP
description:
en_US: Add these outbound IPs of the LangBot server to the "Trusted Enterprise IPs" of the bot configuration in the WeCom admin console
zh_Hans: 请将这些 LangBot 服务器的出网 IP 添加到企业微信管理后台智能机器人配置的「企业可信 IP」中
zh_Hant: 請將這些 LangBot 伺服器的出網 IP 加入企業微信管理後台智慧機器人設定的「企業可信 IP」中
type: array[string]
required: false
default: []
- name: Secret - name: Secret
label: label:
en_US: Secret en_US: Secret

View File

@@ -31,6 +31,18 @@ spec:
type: webhook-url type: webhook-url
required: false required: false
default: "" default: ""
- name: __system.outbound_ips
label:
en_US: Trusted IPs
zh_Hans: 企业可信 IP
zh_Hant: 企業可信 IP
description:
en_US: Add these outbound IPs of the LangBot server to the "Trusted Enterprise IPs" of WeChat Customer Service in the WeCom admin console
zh_Hans: 请将这些 LangBot 服务器的出网 IP 添加到企业微信管理后台微信客服的「企业可信 IP」中
zh_Hant: 請將這些 LangBot 伺服器的出網 IP 加入企業微信管理後台微信客服的「企業可信 IP」中
type: array[string]
required: false
default: []
- name: corpid - name: corpid
label: label:
en_US: Corpid en_US: Corpid

View File

@@ -4,6 +4,7 @@ import json
import copy import copy
import typing import typing
from .. import runner from .. import runner
from ...telemetry import features as telemetry_features
from ..modelmgr import requester as modelmgr_requester from ..modelmgr import requester as modelmgr_requester
from ..tools.loaders.native import EXEC_TOOL_NAME from ..tools.loaders.native import EXEC_TOOL_NAME
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
@@ -187,6 +188,8 @@ class LocalAgentRunner(runner.RequestRunner):
# only support text for now # only support text for now
all_results: list[rag_context.RetrievalResultEntry] = [] all_results: list[rag_context.RetrievalResultEntry] = []
kb_engine_plugins: set[str] = set()
# Retrieve from each knowledge base # Retrieve from each knowledge base
for kb_uuid in kb_uuids: for kb_uuid in kb_uuids:
kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(kb_uuid) kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(kb_uuid)
@@ -195,6 +198,12 @@ class LocalAgentRunner(runner.RequestRunner):
self.ap.logger.warning(f'Knowledge base {kb_uuid} not found, skipping') self.ap.logger.warning(f'Knowledge base {kb_uuid} not found, skipping')
continue continue
try:
engine_plugin_id = kb.get_knowledge_engine_plugin_id() or 'builtin'
except Exception:
engine_plugin_id = 'builtin'
kb_engine_plugins.add(engine_plugin_id)
result = await kb.retrieve( result = await kb.retrieve(
user_message_text, user_message_text,
settings={ settings={
@@ -207,6 +216,17 @@ class LocalAgentRunner(runner.RequestRunner):
if result: if result:
all_results.extend(result) all_results.extend(result)
# Telemetry: knowledge base usage (counts and engine categories only)
telemetry_features.set_value(
query,
'kb',
{
'kb_count': len(kb_uuids),
'engine_plugins': sorted(kb_engine_plugins),
'retrieved_entries': len(all_results),
},
)
# Rerank step: re-score results using a rerank model if configured # Rerank step: re-score results using a rerank model if configured
local_agent_config = query.pipeline_config.get('ai', {}).get('local-agent', {}) local_agent_config = query.pipeline_config.get('ai', {}).get('local-agent', {})
rerank_model_uuid = local_agent_config.get('rerank-model', '') rerank_model_uuid = local_agent_config.get('rerank-model', '')
@@ -373,6 +393,7 @@ class LocalAgentRunner(runner.RequestRunner):
tool_call_round = 0 tool_call_round = 0
while pending_tool_calls: while pending_tool_calls:
tool_call_round += 1 tool_call_round += 1
telemetry_features.set_value(query, 'tool_call_rounds', tool_call_round)
if tool_call_round > MAX_TOOL_CALL_ROUNDS: if tool_call_round > MAX_TOOL_CALL_ROUNDS:
self.ap.logger.warning( self.ap.logger.warning(
f'Tool-call loop reached the {MAX_TOOL_CALL_ROUNDS}-round cap ' f'Tool-call loop reached the {MAX_TOOL_CALL_ROUNDS}-round cap '

View File

@@ -97,13 +97,19 @@ class ToolManager:
return tools return tools
async def execute_func_call(self, name: str, parameters: dict, query: pipeline_query.Query) -> typing.Any: async def execute_func_call(self, name: str, parameters: dict, query: pipeline_query.Query) -> typing.Any:
from langbot.pkg.telemetry import features as telemetry_features
if await self.native_tool_loader.has_tool(name): if await self.native_tool_loader.has_tool(name):
telemetry_features.increment(query, 'tool_calls', 'native')
return await self.native_tool_loader.invoke_tool(name, parameters, query) return await self.native_tool_loader.invoke_tool(name, parameters, query)
if await self.plugin_tool_loader.has_tool(name): if await self.plugin_tool_loader.has_tool(name):
telemetry_features.increment(query, 'tool_calls', 'plugin')
return await self.plugin_tool_loader.invoke_tool(name, parameters, query) return await self.plugin_tool_loader.invoke_tool(name, parameters, query)
if await self.mcp_tool_loader.has_tool(name): if await self.mcp_tool_loader.has_tool(name):
telemetry_features.increment(query, 'tool_calls', 'mcp')
return await self.mcp_tool_loader.invoke_tool(name, parameters, query) return await self.mcp_tool_loader.invoke_tool(name, parameters, query)
if await self.skill_tool_loader.has_tool(name): if await self.skill_tool_loader.has_tool(name):
telemetry_features.increment(query, 'tool_calls', 'skill')
return await self.skill_tool_loader.invoke_tool(name, parameters, query) return await self.skill_tool_loader.invoke_tool(name, parameters, query)
raise ValueError(f'未找到工具: {name}') raise ValueError(f'未找到工具: {name}')

View File

@@ -13,6 +13,11 @@ from ..entity.persistence.metadata import Metadata
from ..utils import constants from ..utils import constants
SURVEY_TRIGGERED_KEY = 'survey_triggered_events' SURVEY_TRIGGERED_KEY = 'survey_triggered_events'
BOT_RESPONSE_COUNT_KEY = 'survey_bot_response_count'
# Milestone event fired when an instance accumulates this many successful bot responses
BOT_RESPONSE_MILESTONE = 100
BOT_RESPONSE_MILESTONE_EVENT = f'bot_response_success_{BOT_RESPONSE_MILESTONE}'
class SurveyManager: class SurveyManager:
@@ -23,11 +28,13 @@ class SurveyManager:
self._triggered_events: set[str] = set() self._triggered_events: set[str] = set()
self._pending_survey: typing.Optional[dict] = None self._pending_survey: typing.Optional[dict] = None
self._space_url: str = '' self._space_url: str = ''
self._bot_response_count: int = 0
async def initialize(self): async def initialize(self):
space_config = self.ap.instance_config.data.get('space', {}) space_config = self.ap.instance_config.data.get('space', {})
self._space_url = space_config.get('url', '').rstrip('/') self._space_url = space_config.get('url', '').rstrip('/')
await self._load_triggered_events() await self._load_triggered_events()
await self._load_bot_response_count()
async def _load_triggered_events(self): async def _load_triggered_events(self):
"""Load previously triggered events from metadata table.""" """Load previously triggered events from metadata table."""
@@ -65,6 +72,54 @@ class SurveyManager:
return False return False
return bool(self._space_url) return bool(self._space_url)
async def _load_bot_response_count(self):
"""Load the persisted successful bot response count from metadata table."""
try:
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(Metadata).where(Metadata.key == BOT_RESPONSE_COUNT_KEY)
)
row = result.first()
if row:
self._bot_response_count = int(row[0].value)
except Exception:
self._bot_response_count = 0
async def _save_bot_response_count(self):
"""Persist the successful bot response count to metadata table."""
try:
value = str(self._bot_response_count)
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(Metadata).where(Metadata.key == BOT_RESPONSE_COUNT_KEY)
)
if result.first():
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(Metadata).where(Metadata.key == BOT_RESPONSE_COUNT_KEY).values(value=value)
)
else:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(Metadata).values(key=BOT_RESPONSE_COUNT_KEY, value=value)
)
except Exception as e:
self.ap.logger.debug(f'Failed to save survey bot response count: {e}')
async def record_bot_response_success(self):
"""Count a successful bot response; fires the milestone event at the threshold.
Called by the chat handler after each successful (non-WebSocket) response.
The count is persisted so it survives restarts. Once the milestone event
has been triggered, counting stops (no further writes needed).
"""
if BOT_RESPONSE_MILESTONE_EVENT in self._triggered_events:
return
if not self._is_space_configured():
return
self._bot_response_count += 1
await self._save_bot_response_count()
if self._bot_response_count >= BOT_RESPONSE_MILESTONE:
await self.trigger_event(BOT_RESPONSE_MILESTONE_EVENT)
async def trigger_event(self, event: str): async def trigger_event(self, event: str):
"""Called when an event occurs. Checks Space for a pending survey.""" """Called when an event occurs. Checks Space for a pending survey."""
if event in self._triggered_events: if event in self._triggered_events:

View File

@@ -0,0 +1,102 @@
"""Per-query telemetry feature counters.
Collects anonymous, content-free usage signals (tool call counts, knowledge
base usage, sandbox executions, ...) into ``query.variables`` during query
processing. The chat handler reads the accumulated dict when building the
telemetry payload and ships it as the ``features`` JSON object.
Every helper here is defensive: telemetry must NEVER break the pipeline, so
all mutations are wrapped and failures are silently ignored.
"""
from __future__ import annotations
import typing
if typing.TYPE_CHECKING:
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
FEATURES_KEY = '_telemetry_features'
def get_features(query: pipeline_query.Query) -> dict:
"""Return the mutable features dict for this query, creating it if needed."""
try:
return query.variables.setdefault(FEATURES_KEY, {})
except Exception:
return {}
def increment(query: pipeline_query.Query, group: str, key: str | None = None, amount: int = 1) -> None:
"""Increment a counter.
``increment(q, 'sandbox', 'execs')`` -> features['sandbox']['execs'] += 1
``increment(q, 'tool_call_rounds')`` -> features['tool_call_rounds'] += 1
"""
try:
features = get_features(query)
if key is None:
features[group] = int(features.get(group, 0)) + amount
else:
nested = features.setdefault(group, {})
if isinstance(nested, dict):
nested[key] = int(nested.get(key, 0)) + amount
except Exception:
pass
def set_value(query: pipeline_query.Query, group: str, value: typing.Any) -> None:
"""Set a feature value (overwrites)."""
try:
get_features(query)[group] = value
except Exception:
pass
def collect_features(query: pipeline_query.Query) -> dict:
"""Build the final ``features`` object for the telemetry payload.
Combines the counters accumulated during processing with end-of-query
snapshots (activated skills, bound MCP servers). Returns a plain dict
that must be JSON-serializable; non-serializable values are dropped.
"""
features: dict = {}
try:
accumulated = query.variables.get(FEATURES_KEY)
if isinstance(accumulated, dict):
features.update(accumulated)
except Exception:
pass
# Activated skills (names only, registered by the activate tool)
try:
activated = query.variables.get('_activated_skills', {})
if isinstance(activated, dict) and activated:
features['activated_skills'] = sorted(activated.keys())
except Exception:
pass
# MCP servers bound to the pipeline (names only; None means "all enabled")
try:
bound_mcp = query.variables.get('_pipeline_bound_mcp_servers', None)
if bound_mcp is not None:
features['mcp_servers'] = list(bound_mcp)
except Exception:
pass
# Drop anything that is not JSON-serializable
import json
try:
json.dumps(features)
return features
except Exception:
safe: dict = {}
for k, v in features.items():
try:
json.dumps({k: v})
safe[k] = v
except Exception:
continue
return safe

View File

@@ -0,0 +1,131 @@
"""Instance heartbeat telemetry.
Sends a periodic (startup + daily) anonymous snapshot of the instance's
configuration profile so feature *adoption* can be measured separately from
feature *usage* (which is covered by per-query telemetry).
The snapshot contains only configuration categories and object counts —
never names of user resources (except adapter type names, which are LangBot
adapter identifiers, not account info), never message content, never
credentials.
"""
from __future__ import annotations
import asyncio
import typing
from datetime import datetime, timezone
import sqlalchemy
from ..utils import constants, platform as platform_utils
if typing.TYPE_CHECKING:
from ..core import app as core_app
HEARTBEAT_INTERVAL_SECONDS = 24 * 3600
async def _count(ap: core_app.Application, table) -> int:
"""Count rows in a persistence table; -1 when unavailable."""
try:
result = await ap.persistence_mgr.execute_async(sqlalchemy.select(sqlalchemy.func.count()).select_from(table))
return int(result.scalar() or 0)
except Exception:
return -1
async def build_heartbeat_payload(ap: core_app.Application) -> dict:
"""Collect the anonymous instance profile snapshot."""
from ..entity.persistence import bot as persistence_bot
from ..entity.persistence import mcp as persistence_mcp
from ..entity.persistence import pipeline as persistence_pipeline
from ..entity.persistence import rag as persistence_rag
config = ap.instance_config.data if ap.instance_config else {}
features: dict = {
'deploy_platform': platform_utils.get_platform(),
'database': config.get('database', {}).get('use', 'sqlite'),
'vdb': config.get('vdb', {}).get('use', 'chroma'),
}
# Box / sandbox profile
try:
box_service = getattr(ap, 'box_service', None)
if box_service is not None:
box_info: dict = {
'enabled': bool(box_service.enabled),
'available': bool(box_service.available),
}
box_cfg = config.get('box', {})
box_info['backend'] = box_cfg.get('backend', 'local')
try:
box_info['shares_fs'] = bool(box_service.shares_filesystem_with_box)
except Exception:
pass
features['box'] = box_info
except Exception:
pass
# Bots / adapters (adapter type names only)
try:
platform_mgr = getattr(ap, 'platform_mgr', None)
if platform_mgr is not None and getattr(platform_mgr, 'bots', None) is not None:
enabled_bots = [bot for bot in platform_mgr.bots if getattr(bot, 'enable', False)]
features['bot_count'] = len(platform_mgr.bots)
adapters = sorted({bot.adapter.__class__.__name__ for bot in enabled_bots if getattr(bot, 'adapter', None)})
features['adapters'] = adapters
except Exception:
pass
# Resource counts
features['pipeline_count'] = await _count(ap, persistence_pipeline.LegacyPipeline)
features['mcp_server_count'] = await _count(ap, persistence_mcp.MCPServer)
features['knowledge_base_count'] = await _count(ap, persistence_rag.KnowledgeBase)
if 'bot_count' not in features:
features['bot_count'] = await _count(ap, persistence_bot.Bot)
# Plugin count (from plugin runtime)
try:
plugin_connector = getattr(ap, 'plugin_connector', None)
if plugin_connector is not None:
plugins = await plugin_connector.list_plugins()
features['plugin_count'] = len(plugins)
except Exception:
features['plugin_count'] = -1
# Skill count (from Box runtime via skill manager)
try:
skill_mgr = getattr(ap, 'skill_mgr', None)
if skill_mgr is not None and getattr(skill_mgr, 'skills', None) is not None:
features['skill_count'] = len(skill_mgr.skills)
except Exception:
pass
return {
'event_type': 'instance_heartbeat',
'query_id': '',
'version': constants.semantic_version,
'instance_id': constants.instance_id,
'edition': constants.edition,
'features': features,
'timestamp': datetime.now(timezone.utc).isoformat(),
}
async def heartbeat_loop(ap: core_app.Application) -> None:
"""Send one heartbeat shortly after startup, then daily."""
# Small delay so managers (platform, skills, plugins) finish loading first
await asyncio.sleep(30)
while True:
try:
payload = await build_heartbeat_payload(ap)
await ap.telemetry.start_send_task(payload)
except Exception as e:
try:
ap.logger.debug(f'Telemetry heartbeat failed: {e}')
except Exception:
pass
await asyncio.sleep(HEARTBEAT_INTERVAL_SECONDS)

View File

@@ -68,10 +68,21 @@ class TelemetryManager:
'edition', 'edition',
'error', 'error',
'timestamp', 'timestamp',
'event_type',
): ):
if sfield not in sanitized:
continue
v = sanitized.get(sfield) v = sanitized.get(sfield)
sanitized[sfield] = '' if v is None else str(v) sanitized[sfield] = '' if v is None else str(v)
# event_type defaults to 'query' for backward compatibility
if not sanitized.get('event_type'):
sanitized['event_type'] = 'query'
# features must be a JSON object
if 'features' in sanitized and not isinstance(sanitized['features'], dict):
sanitized['features'] = {}
if 'duration_ms' in sanitized: if 'duration_ms' in sanitized:
try: try:
sanitized['duration_ms'] = ( sanitized['duration_ms'] = (

View File

@@ -21,6 +21,13 @@ system:
recovery_key: '' recovery_key: ''
allow_modify_login_info: true allow_modify_login_info: true
disabled_adapters: [] disabled_adapters: []
# Public outbound IP addresses of this LangBot deployment. Some platforms
# (e.g. WeCom, WeChat Official Account, QQ Official API) require the
# caller's IPs to be added to their trusted-IP / IP-whitelist settings.
# When set, the web UI shows these IPs on the bot config form of such
# adapters. Also settable via the SYSTEM__OUTBOUND_IPS env var
# (comma-separated). Empty list = hidden in the web UI.
outbound_ips: []
limitation: limitation:
max_bots: -1 max_bots: -1
max_pipelines: -1 max_pipelines: -1

View File

@@ -0,0 +1,120 @@
"""Tests for the daily-grouped rotating log file handler.
Regression coverage for the bug where a long-running process names its log
file after the *start* day and keeps appending to it across midnight, so no
file ever appears for the current day. See
``langbot.pkg.core.bootutils.log.DailyGroupedRotatingFileHandler``.
"""
from __future__ import annotations
import logging
import os
import re
import langbot.pkg.core.bootutils.log as logmod
from langbot.pkg.core.bootutils.log import DailyGroupedRotatingFileHandler
# Mirror of the cleanup pattern in api/http/service/maintenance.py.
MAINTENANCE_LOG_FILE_PATTERN = re.compile(r'^langbot-(\d{4}-\d{2}-\d{2})\.log(?:\.\d+)?$')
def _listing(directory):
return sorted(os.listdir(directory))
def _make_logger(handler, name):
logger = logging.getLogger(name)
logger.setLevel(logging.INFO)
logger.handlers.clear()
logger.addHandler(handler)
logger.propagate = False
return logger
class TestDailyGroupedRotatingFileHandler:
def _patch_date(self, monkeypatch, box):
"""Make the handler read its current date from ``box['date']``."""
def fake_strftime(fmt, t=None):
if fmt == '%Y-%m-%d':
return box['date']
return '00:00:00'
monkeypatch.setattr(logmod.time, 'strftime', fake_strftime)
def test_initial_file_named_for_current_day(self, tmp_path, monkeypatch):
box = {'date': '2026-06-08'}
self._patch_date(monkeypatch, box)
handler = DailyGroupedRotatingFileHandler(str(tmp_path), max_bytes=10_000, backup_count=3)
logger = _make_logger(handler, 'lb_logtest_initial')
logger.info('hello')
handler.close()
assert _listing(tmp_path) == ['langbot-2026-06-08.log']
def test_same_day_size_rotation_creates_numbered_backups(self, tmp_path, monkeypatch):
box = {'date': '2026-06-08'}
self._patch_date(monkeypatch, box)
handler = DailyGroupedRotatingFileHandler(str(tmp_path), max_bytes=200, backup_count=3)
logger = _make_logger(handler, 'lb_logtest_size')
for i in range(40):
logger.info('padding line to exceed maxBytes %d', i)
handler.close()
files = _listing(tmp_path)
assert 'langbot-2026-06-08.log' in files
assert any(f.startswith('langbot-2026-06-08.log.') for f in files)
def test_rolls_to_new_file_when_day_changes(self, tmp_path, monkeypatch):
box = {'date': '2026-06-08'}
self._patch_date(monkeypatch, box)
handler = DailyGroupedRotatingFileHandler(str(tmp_path), max_bytes=10_000, backup_count=3)
logger = _make_logger(handler, 'lb_logtest_midnight')
logger.info('day1 line')
# Simulate crossing midnight within the same running process.
box['date'] = '2026-06-09'
logger.info('day2 line after midnight')
handler.close()
files = _listing(tmp_path)
assert 'langbot-2026-06-08.log' in files
assert 'langbot-2026-06-09.log' in files
day2 = (tmp_path / 'langbot-2026-06-09.log').read_text(encoding='utf-8')
assert 'day2 line after midnight' in day2
assert 'day1 line' not in day2
def test_rollover_repeats_across_multiple_days(self, tmp_path, monkeypatch):
box = {'date': '2026-06-08'}
self._patch_date(monkeypatch, box)
handler = DailyGroupedRotatingFileHandler(str(tmp_path), max_bytes=10_000, backup_count=3)
logger = _make_logger(handler, 'lb_logtest_multiday')
for day in ('2026-06-08', '2026-06-09', '2026-06-10'):
box['date'] = day
logger.info('line for %s', day)
handler.close()
files = _listing(tmp_path)
for day in ('2026-06-08', '2026-06-09', '2026-06-10'):
assert f'langbot-{day}.log' in files
def test_all_filenames_match_maintenance_cleanup_pattern(self, tmp_path, monkeypatch):
box = {'date': '2026-06-08'}
self._patch_date(monkeypatch, box)
handler = DailyGroupedRotatingFileHandler(str(tmp_path), max_bytes=200, backup_count=3)
logger = _make_logger(handler, 'lb_logtest_pattern')
for i in range(40):
logger.info('padding line %d', i)
box['date'] = '2026-06-09'
logger.info('next day line')
handler.close()
for name in _listing(tmp_path):
assert MAINTENANCE_LOG_FILE_PATTERN.match(name), name

View File

@@ -7,6 +7,7 @@ Tests cover:
- Survey response submission - Survey response submission
- Survey dismissal - Survey dismissal
""" """
from __future__ import annotations from __future__ import annotations
import pytest import pytest
@@ -127,9 +128,7 @@ class TestLoadTriggeredEvents:
"""Test that empty set is used when no events stored.""" """Test that empty set is used when no events stored."""
survey_module = get_survey_module() survey_module = get_survey_module()
mock_app = create_mock_app() mock_app = create_mock_app()
mock_app.persistence_mgr.execute_async = AsyncMock( mock_app.persistence_mgr.execute_async = AsyncMock(return_value=Mock(first=Mock(return_value=None)))
return_value=Mock(first=Mock(return_value=None))
)
manager = survey_module.SurveyManager(mock_app) manager = survey_module.SurveyManager(mock_app)
await manager._load_triggered_events() await manager._load_triggered_events()
@@ -219,9 +218,7 @@ class TestTriggerEvent:
"""Test that new event is added and saved.""" """Test that new event is added and saved."""
survey_module = get_survey_module() survey_module = get_survey_module()
mock_app = create_mock_app() mock_app = create_mock_app()
mock_app.persistence_mgr.execute_async = AsyncMock( mock_app.persistence_mgr.execute_async = AsyncMock(return_value=Mock(first=Mock(return_value=None)))
return_value=Mock(first=Mock(return_value=None))
)
manager = survey_module.SurveyManager(mock_app) manager = survey_module.SurveyManager(mock_app)
manager._space_url = 'https://space.example.com' manager._space_url = 'https://space.example.com'
@@ -231,6 +228,104 @@ class TestTriggerEvent:
assert 'new_event' in manager._triggered_events assert 'new_event' in manager._triggered_events
class TestRecordBotResponseSuccess:
"""Tests for the bot_response_success_100 milestone counter."""
def _make_manager(self, survey_module, mock_app):
manager = survey_module.SurveyManager(mock_app)
manager._space_url = 'https://space.example.com'
# No existing metadata rows: select returns no row
mock_app.persistence_mgr.execute_async = AsyncMock(return_value=Mock(first=Mock(return_value=None)))
return manager
@pytest.mark.asyncio
async def test_increments_and_persists_count(self):
survey_module = get_survey_module()
mock_app = create_mock_app()
manager = self._make_manager(survey_module, mock_app)
await manager.record_bot_response_success()
assert manager._bot_response_count == 1
# select + insert for the count key
assert mock_app.persistence_mgr.execute_async.call_count >= 2
@pytest.mark.asyncio
async def test_fires_milestone_event_at_threshold(self):
survey_module = get_survey_module()
mock_app = create_mock_app()
manager = self._make_manager(survey_module, mock_app)
manager._bot_response_count = survey_module.BOT_RESPONSE_MILESTONE - 1
await manager.record_bot_response_success()
assert manager._bot_response_count == survey_module.BOT_RESPONSE_MILESTONE
assert survey_module.BOT_RESPONSE_MILESTONE_EVENT in manager._triggered_events
@pytest.mark.asyncio
async def test_does_not_fire_below_threshold(self):
survey_module = get_survey_module()
mock_app = create_mock_app()
manager = self._make_manager(survey_module, mock_app)
manager._bot_response_count = 5
await manager.record_bot_response_success()
assert survey_module.BOT_RESPONSE_MILESTONE_EVENT not in manager._triggered_events
@pytest.mark.asyncio
async def test_stops_counting_after_milestone_triggered(self):
survey_module = get_survey_module()
mock_app = create_mock_app()
manager = self._make_manager(survey_module, mock_app)
manager._triggered_events.add(survey_module.BOT_RESPONSE_MILESTONE_EVENT)
manager._bot_response_count = survey_module.BOT_RESPONSE_MILESTONE
await manager.record_bot_response_success()
# No persistence write, count unchanged
mock_app.persistence_mgr.execute_async.assert_not_called()
assert manager._bot_response_count == survey_module.BOT_RESPONSE_MILESTONE
@pytest.mark.asyncio
async def test_skips_when_space_not_configured(self):
survey_module = get_survey_module()
mock_app = create_mock_app()
manager = self._make_manager(survey_module, mock_app)
manager._space_url = ''
await manager.record_bot_response_success()
assert manager._bot_response_count == 0
mock_app.persistence_mgr.execute_async.assert_not_called()
@pytest.mark.asyncio
async def test_count_loaded_on_initialize(self):
survey_module = get_survey_module()
mock_app = create_mock_app()
count_row = Mock()
count_row.value = '42'
def execute_side_effect(stmt):
result = Mock()
# Both _load_triggered_events and _load_bot_response_count select
# from Metadata; return the count row only for the count key.
stmt_str = str(stmt.compile(compile_kwargs={'literal_binds': True}))
if survey_module.BOT_RESPONSE_COUNT_KEY in stmt_str:
result.first.return_value = (count_row,)
else:
result.first.return_value = None
return result
mock_app.persistence_mgr.execute_async = AsyncMock(side_effect=execute_side_effect)
manager = survey_module.SurveyManager(mock_app)
await manager.initialize()
assert manager._bot_response_count == 42
class TestPendingSurvey: class TestPendingSurvey:
"""Tests for get_pending_survey and clear_pending_survey.""" """Tests for get_pending_survey and clear_pending_survey."""
@@ -296,14 +391,19 @@ class TestSubmitResponse:
# Mock successful HTTP response # Mock successful HTTP response
import httpx import httpx
mock_response = Mock() mock_response = Mock()
mock_response.status_code = 200 mock_response.status_code = 200
with pytest.MonkeyPatch().context() as m: with pytest.MonkeyPatch().context() as m:
m.setattr(httpx, 'AsyncClient', lambda **kwargs: MagicMock( m.setattr(
__aenter__=AsyncMock(return_value=Mock(post=AsyncMock(return_value=mock_response))), httpx,
__aexit__=AsyncMock(return_value=None) 'AsyncClient',
)) lambda **kwargs: MagicMock(
__aenter__=AsyncMock(return_value=Mock(post=AsyncMock(return_value=mock_response))),
__aexit__=AsyncMock(return_value=None),
),
)
result = await manager.submit_response('survey123', {'q1': 'answer1'}) result = await manager.submit_response('survey123', {'q1': 'answer1'})
assert result is True assert result is True
@@ -338,14 +438,19 @@ class TestDismissSurvey:
# Mock successful HTTP response # Mock successful HTTP response
import httpx import httpx
mock_response = Mock() mock_response = Mock()
mock_response.status_code = 200 mock_response.status_code = 200
with pytest.MonkeyPatch().context() as m: with pytest.MonkeyPatch().context() as m:
m.setattr(httpx, 'AsyncClient', lambda **kwargs: MagicMock( m.setattr(
__aenter__=AsyncMock(return_value=Mock(post=AsyncMock(return_value=mock_response))), httpx,
__aexit__=AsyncMock(return_value=None) 'AsyncClient',
)) lambda **kwargs: MagicMock(
__aenter__=AsyncMock(return_value=Mock(post=AsyncMock(return_value=mock_response))),
__aexit__=AsyncMock(return_value=None),
),
)
result = await manager.dismiss_survey('survey123') result = await manager.dismiss_survey('survey123')
assert result is True assert result is True

View File

@@ -0,0 +1,92 @@
"""Unit tests for telemetry feature counters (pkg/telemetry/features.py)."""
from __future__ import annotations
from importlib import import_module
def get_features_module():
return import_module('langbot.pkg.telemetry.features')
class FakeQuery:
def __init__(self):
self.variables = {}
class TestIncrement:
def test_increment_nested_counter(self):
features = get_features_module()
q = FakeQuery()
features.increment(q, 'tool_calls', 'native')
features.increment(q, 'tool_calls', 'native')
features.increment(q, 'tool_calls', 'mcp')
assert q.variables[features.FEATURES_KEY]['tool_calls'] == {'native': 2, 'mcp': 1}
def test_increment_flat_counter(self):
features = get_features_module()
q = FakeQuery()
features.increment(q, 'something')
features.increment(q, 'something', amount=2)
assert q.variables[features.FEATURES_KEY]['something'] == 3
def test_increment_never_raises_on_broken_query(self):
features = get_features_module()
class Broken:
@property
def variables(self):
raise RuntimeError('boom')
# Must not raise
features.increment(Broken(), 'tool_calls', 'native')
def test_set_value(self):
features = get_features_module()
q = FakeQuery()
features.set_value(q, 'tool_call_rounds', 5)
assert q.variables[features.FEATURES_KEY]['tool_call_rounds'] == 5
class TestCollectFeatures:
def test_collect_empty(self):
features = get_features_module()
q = FakeQuery()
assert features.collect_features(q) == {}
def test_collect_combines_counters_and_snapshots(self):
features = get_features_module()
q = FakeQuery()
features.increment(q, 'sandbox', 'execs')
features.set_value(q, 'kb', {'kb_count': 2, 'engine_plugins': ['builtin'], 'retrieved_entries': 7})
q.variables['_activated_skills'] = {'pdf-tools': {}, 'a-skill': {}}
q.variables['_pipeline_bound_mcp_servers'] = ['srv1', 'srv2']
result = features.collect_features(q)
assert result['sandbox'] == {'execs': 1}
assert result['kb']['kb_count'] == 2
assert result['activated_skills'] == ['a-skill', 'pdf-tools'] # sorted
assert result['mcp_servers'] == ['srv1', 'srv2']
def test_collect_omits_mcp_when_all_enabled(self):
"""None means 'all enabled' and is not reported."""
features = get_features_module()
q = FakeQuery()
q.variables['_pipeline_bound_mcp_servers'] = None
assert 'mcp_servers' not in features.collect_features(q)
def test_collect_drops_non_json_serializable(self):
features = get_features_module()
q = FakeQuery()
features.set_value(q, 'good', 1)
features.set_value(q, 'bad', object())
result = features.collect_features(q)
assert result == {'good': 1}
def test_collect_is_json_serializable(self):
import json
features = get_features_module()
q = FakeQuery()
features.increment(q, 'tool_calls', 'skill')
json.dumps(features.collect_features(q))

View File

@@ -0,0 +1,104 @@
"""Unit tests for telemetry heartbeat payload (pkg/telemetry/heartbeat.py)."""
from __future__ import annotations
import json
import pytest
from unittest.mock import AsyncMock, Mock
from importlib import import_module
def get_heartbeat_module():
return import_module('langbot.pkg.telemetry.heartbeat')
def make_app():
ap = Mock()
ap.instance_config = Mock()
ap.instance_config.data = {
'database': {'use': 'postgresql'},
'vdb': {'use': 'chroma'},
'box': {'enabled': True, 'backend': 'nsjail'},
}
# persistence counts
result = Mock()
result.scalar.return_value = 3
ap.persistence_mgr = Mock()
ap.persistence_mgr.execute_async = AsyncMock(return_value=result)
# box service
ap.box_service = Mock()
ap.box_service.enabled = True
ap.box_service.available = False
ap.box_service.shares_filesystem_with_box = False
# platform manager with one enabled bot
bot = Mock()
bot.enable = True
bot.adapter = Mock()
bot.adapter.__class__.__name__ = 'TelegramAdapter'
ap.platform_mgr = Mock()
ap.platform_mgr.bots = [bot]
# plugin connector
ap.plugin_connector = Mock()
ap.plugin_connector.list_plugins = AsyncMock(return_value=[{}, {}])
# skills
ap.skill_mgr = Mock()
ap.skill_mgr.skills = {'a': {}, 'b': {}, 'c': {}}
return ap
class TestBuildHeartbeatPayload:
@pytest.mark.asyncio
async def test_payload_shape(self):
heartbeat = get_heartbeat_module()
ap = make_app()
payload = await heartbeat.build_heartbeat_payload(ap)
assert payload['event_type'] == 'instance_heartbeat'
assert payload['query_id'] == ''
assert 'timestamp' in payload
f = payload['features']
assert f['database'] == 'postgresql'
assert f['vdb'] == 'chroma'
assert f['box'] == {
'enabled': True,
'available': False,
'backend': 'nsjail',
'shares_fs': False,
}
assert f['adapters'] == ['TelegramAdapter']
assert f['bot_count'] == 1
assert f['plugin_count'] == 2
assert f['skill_count'] == 3
assert f['pipeline_count'] == 3
assert f['mcp_server_count'] == 3
assert f['knowledge_base_count'] == 3
@pytest.mark.asyncio
async def test_payload_is_json_serializable(self):
heartbeat = get_heartbeat_module()
payload = await heartbeat.build_heartbeat_payload(make_app())
json.dumps(payload)
@pytest.mark.asyncio
async def test_count_failure_yields_minus_one(self):
heartbeat = get_heartbeat_module()
ap = make_app()
ap.persistence_mgr.execute_async = AsyncMock(side_effect=RuntimeError('db down'))
payload = await heartbeat.build_heartbeat_payload(ap)
assert payload['features']['pipeline_count'] == -1
@pytest.mark.asyncio
async def test_no_user_content_fields(self):
"""The heartbeat must never carry message content / credentials keys."""
heartbeat = get_heartbeat_module()
payload = await heartbeat.build_heartbeat_payload(make_app())
flat = json.dumps(payload).lower()
for forbidden in ('api_key', 'password', 'token', 'message_content'):
assert forbidden not in flat

View File

@@ -13,6 +13,7 @@ import { IDynamicFormItemSchema } from '@/app/infra/entities/form/dynamic';
import { UUID } from 'uuidjs'; import { UUID } from 'uuidjs';
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent'; import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
import { httpClient } from '@/app/infra/http/HttpClient'; import { httpClient } from '@/app/infra/http/HttpClient';
import { systemInfo } from '@/app/infra/http';
import { Bot } from '@/app/infra/entities/api'; import { Bot } from '@/app/infra/entities/api';
import { getAdapterDocUrl } from '@/app/infra/entities/adapter-docs'; import { getAdapterDocUrl } from '@/app/infra/entities/adapter-docs';
import { ExternalLink } from 'lucide-react'; import { ExternalLink } from 'lucide-react';
@@ -621,6 +622,7 @@ export default function BotForm({
extra_webhook_url: extraWebhookUrl, extra_webhook_url: extraWebhookUrl,
bot_uuid: initBotId || '', bot_uuid: initBotId || '',
adapter_config: form.getValues('adapter_config') || {}, adapter_config: form.getValues('adapter_config') || {},
outbound_ips: systemInfo.outbound_ips,
}} }}
/> />
)} )}

View File

@@ -1,4 +1,7 @@
import { IDynamicFormItemSchema } from '@/app/infra/entities/form/dynamic'; import {
IDynamicFormItemSchema,
SYSTEM_FIELD_PREFIX,
} from '@/app/infra/entities/form/dynamic';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod'; import { z } from 'zod';
@@ -44,8 +47,8 @@ function resolveShowIfValue(
externalDependentValues?: Record<string, unknown>, externalDependentValues?: Record<string, unknown>,
systemContext?: Record<string, unknown>, systemContext?: Record<string, unknown>,
): unknown { ): unknown {
if (field.startsWith('__system.')) { if (field.startsWith(SYSTEM_FIELD_PREFIX)) {
const key = field.slice('__system.'.length); const key = field.slice(SYSTEM_FIELD_PREFIX.length);
return systemContext?.[key]; return systemContext?.[key];
} }
if (watchedValues[field] !== undefined) { if (watchedValues[field] !== undefined) {
@@ -198,6 +201,66 @@ function WebhookUrlField({
); );
} }
/**
* Display-only component for `__system.*` fields (e.g. the deployment's
* outbound IPs that the operator must add to a platform's trusted-IP list).
* Renders one read-only row per value, each with a copy button. Rendered
* outside of react-hook-form binding since the values come from
* systemContext, not user input.
*/
function SystemInfoField({
label,
description,
values,
}: {
label: string;
description?: string;
values: string[];
}) {
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
const handleCopy = (text: string, index: number) => {
copyToClipboard(text).catch(() => {});
setCopiedIndex(index);
setTimeout(() => setCopiedIndex(null), 2000);
};
return (
<FormItem className="min-w-0">
<FormLabel className="break-words">{label}</FormLabel>
<div className="space-y-2">
{values.map((value, index) => (
<div key={index} className="flex min-w-0 items-center gap-2">
<Input
value={value}
readOnly
className="min-w-0 flex-1 bg-muted"
onClick={(e) => (e.target as HTMLInputElement).select()}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => handleCopy(value, index)}
>
{copiedIndex === index ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
))}
</div>
{description && (
<p className="text-sm break-words text-muted-foreground">
{description}
</p>
)}
</FormItem>
);
}
// Hover-only Radix tooltips never open on touch devices (no pointer hover), // Hover-only Radix tooltips never open on touch devices (no pointer hover),
// so the ``disabled_tooltip`` explaining why a field is locked was invisible on // so the ``disabled_tooltip`` explaining why a field is locked was invisible on
// mobile. This wrapper makes the info icon also toggle the tooltip on tap while // mobile. This wrapper makes the info icon also toggle the tooltip on tap while
@@ -290,15 +353,17 @@ export default function DynamicFormComponent({
return value; return value;
}; };
// Filter out display-only field types (e.g. webhook-url, embed-code) that should not // Filter out display-only fields (webhook-url/embed-code/qr-code-login types
// participate in form state, validation, or value emission. // and `__system.*`-named fields) that should not participate in form state,
// validation, or value emission.
const editableItems = useMemo( const editableItems = useMemo(
() => () =>
itemConfigList.filter( itemConfigList.filter(
(item) => (item) =>
item.type !== 'webhook-url' && item.type !== 'webhook-url' &&
item.type !== 'embed-code' && item.type !== 'embed-code' &&
item.type !== 'qr-code-login', item.type !== 'qr-code-login' &&
!item.name.startsWith(SYSTEM_FIELD_PREFIX),
), ),
[itemConfigList], [itemConfigList],
); );
@@ -583,6 +648,31 @@ export default function DynamicFormComponent({
<DisabledTooltipIcon text={disabledTooltip} /> <DisabledTooltipIcon text={disabledTooltip} />
) : null; ) : null;
// `__system.*` fields are display-only; their value is resolved
// from systemContext (same namespace as show_if), not user input.
// Hidden entirely when the deployment doesn't provide the value.
if (config.name.startsWith(SYSTEM_FIELD_PREFIX)) {
const rawValue =
systemContext?.[config.name.slice(SYSTEM_FIELD_PREFIX.length)];
const values = (Array.isArray(rawValue) ? rawValue : [rawValue])
.filter((v) => v !== undefined && v !== null && v !== '')
.map(String);
if (values.length === 0) return null;
return (
<SystemInfoField
key={config.id}
label={extractI18nObject(config.label)}
description={
config.description
? extractI18nObject(config.description)
: undefined
}
values={values}
/>
);
}
// Webhook URL fields are display-only; render outside of form binding // Webhook URL fields are display-only; render outside of form binding
if (config.type === 'webhook-url') { if (config.type === 'webhook-url') {
const webhookUrl = (systemContext?.webhook_url as string) || ''; const webhookUrl = (systemContext?.webhook_url as string) || '';

View File

@@ -3,6 +3,7 @@ import {
DynamicFormItemType, DynamicFormItemType,
IDynamicFormItemOption, IDynamicFormItemOption,
IShowIfCondition, IShowIfCondition,
SYSTEM_FIELD_PREFIX,
} from '@/app/infra/entities/form/dynamic'; } from '@/app/infra/entities/form/dynamic';
import { I18nObject } from '@/app/infra/entities/common'; import { I18nObject } from '@/app/infra/entities/common';
@@ -50,6 +51,11 @@ export function getDefaultValues(
): Record<string, any> { ): Record<string, any> {
return itemConfigList.reduce( return itemConfigList.reduce(
(acc, item) => { (acc, item) => {
// `__system.*` fields are display-only (resolved from systemContext);
// their placeholder defaults must not leak into the config values.
if (item.name.startsWith(SYSTEM_FIELD_PREFIX)) {
return acc;
}
acc[item.name] = item.default; acc[item.name] = item.default;
return acc; return acc;
}, },

View File

@@ -348,6 +348,10 @@ export interface ApiRespSystemInfo {
allow_modify_login_info: boolean; allow_modify_login_info: boolean;
disable_models_service: boolean; disable_models_service: boolean;
limitation: SystemLimitation; limitation: SystemLimitation;
/** Public outbound IPs of the deployment (``system.outbound_ips`` in
* config.yaml). Shown on adapter config forms whose platform requires
* trusted-IP / IP-whitelist settings. Empty = not configured. */
outbound_ips: string[];
wizard_status: string; // 'none' | 'skipped' | 'completed' wizard_status: string; // 'none' | 'skipped' | 'completed'
wizard_progress: WizardProgress | null; wizard_progress: WizardProgress | null;
} }

View File

@@ -1,5 +1,10 @@
import { I18nObject } from '@/app/infra/entities/common'; import { I18nObject } from '@/app/infra/entities/common';
/** Namespace prefix shared by ``show_if.field`` references and display-only
* form item names whose value is resolved from the caller-supplied
* ``DynamicFormComponent.systemContext``. */
export const SYSTEM_FIELD_PREFIX = '__system.';
export interface IShowIfCondition { export interface IShowIfCondition {
field: string; field: string;
operator: 'eq' | 'neq' | 'in'; operator: 'eq' | 'neq' | 'in';
@@ -11,6 +16,12 @@ export interface IDynamicFormItemSchema {
id: string; id: string;
default: string | number | boolean | Array<unknown>; default: string | number | boolean | Array<unknown>;
label: I18nObject; label: I18nObject;
/** Form value key. Names prefixed with ``__system.`` denote display-only
* fields whose value is resolved from
* ``DynamicFormComponent.systemContext`` (e.g. ``__system.outbound_ips``
* → ``systemContext.outbound_ips``) — same namespace as ``show_if``.
* Such fields are rendered read-only with copy buttons, excluded from
* form state/validation/emission, and hidden when the value is empty. */
name: string; name: string;
required: boolean; required: boolean;
type: DynamicFormItemType; type: DynamicFormItemType;

View File

@@ -16,6 +16,7 @@ export const systemInfo: ApiRespSystemInfo = {
max_pipelines: -1, max_pipelines: -1,
max_extensions: -1, max_extensions: -1,
}, },
outbound_ips: [],
wizard_status: 'none', wizard_status: 'none',
wizard_progress: null, wizard_progress: null,
}; };

View File

@@ -939,6 +939,7 @@ function StepBotConfig({
is_wizard: true, is_wizard: true,
webhook_url: webhookUrl, webhook_url: webhookUrl,
extra_webhook_url: extraWebhookUrl, extra_webhook_url: extraWebhookUrl,
outbound_ips: systemInfo.outbound_ips,
}} }}
/> />
</CardContent> </CardContent>