mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-12 16:56:02 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b6dcfe9c7 | ||
|
|
dd96da895c | ||
|
|
bca710dbd4 | ||
|
|
47ade18596 | ||
|
|
733c9cdf16 | ||
|
|
bbc508d42f |
1
.github/pull_request_template.md
vendored
1
.github/pull_request_template.md
vendored
@@ -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
41
.github/workflows/cla.yml
vendored
Normal 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
107
CLA.md
Normal 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. 其他
|
||||||
|
|
||||||
|
本协议构成您与我方之间就您的贡献达成的完整协议,并取代双方先前就此主题达成的任何协议。如本协议任何条款被认定为不可执行,其余条款仍然有效。本协议以英文签署,中文译文仅供参考,如有歧义以英文版为准。
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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', {})
|
||||||
|
|||||||
@@ -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] = [
|
||||||
|
|||||||
@@ -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}')
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 '
|
||||||
|
|||||||
@@ -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}')
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
102
src/langbot/pkg/telemetry/features.py
Normal file
102
src/langbot/pkg/telemetry/features.py
Normal 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
|
||||||
131
src/langbot/pkg/telemetry/heartbeat.py
Normal file
131
src/langbot/pkg/telemetry/heartbeat.py
Normal 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)
|
||||||
@@ -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'] = (
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
120
tests/unit_tests/core/test_bootutils_log.py
Normal file
120
tests/unit_tests/core/test_bootutils_log.py
Normal 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
|
||||||
@@ -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(
|
||||||
|
httpx,
|
||||||
|
'AsyncClient',
|
||||||
|
lambda **kwargs: MagicMock(
|
||||||
__aenter__=AsyncMock(return_value=Mock(post=AsyncMock(return_value=mock_response))),
|
__aenter__=AsyncMock(return_value=Mock(post=AsyncMock(return_value=mock_response))),
|
||||||
__aexit__=AsyncMock(return_value=None)
|
__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(
|
||||||
|
httpx,
|
||||||
|
'AsyncClient',
|
||||||
|
lambda **kwargs: MagicMock(
|
||||||
__aenter__=AsyncMock(return_value=Mock(post=AsyncMock(return_value=mock_response))),
|
__aenter__=AsyncMock(return_value=Mock(post=AsyncMock(return_value=mock_response))),
|
||||||
__aexit__=AsyncMock(return_value=None)
|
__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
|
||||||
|
|||||||
92
tests/unit_tests/telemetry/test_features.py
Normal file
92
tests/unit_tests/telemetry/test_features.py
Normal 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))
|
||||||
104
tests/unit_tests/telemetry/test_heartbeat.py
Normal file
104
tests/unit_tests/telemetry/test_heartbeat.py
Normal 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
|
||||||
@@ -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,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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) || '';
|
||||||
|
|||||||
@@ -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;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user