mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-13 17:26:04 +00:00
Compare commits
52 Commits
v4.10.0
...
fix/litell
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81ac771fb5 | ||
|
|
b6fde30aa7 | ||
|
|
5bfa38cbf2 | ||
|
|
a97d2040bb | ||
|
|
a2c6c8201b | ||
|
|
672abfe95d | ||
|
|
9ecb587ac0 | ||
|
|
7965d333ac | ||
|
|
f7300f1473 | ||
|
|
2b6dcfe9c7 | ||
|
|
dd96da895c | ||
|
|
bca710dbd4 | ||
|
|
47ade18596 | ||
|
|
733c9cdf16 | ||
|
|
bbc508d42f | ||
|
|
0551d22689 | ||
|
|
53d4edb609 | ||
|
|
f897987ac1 | ||
|
|
8e558ad3a1 | ||
|
|
47fe9bde03 | ||
|
|
5c3a619e2d | ||
|
|
e223edeb45 | ||
|
|
d2c3146334 | ||
|
|
7d9c8e3065 | ||
|
|
f12ed81e1e | ||
|
|
6d4d19b6d7 | ||
|
|
07b90f12a2 | ||
|
|
fd896c6974 | ||
|
|
1fbfa868fb | ||
|
|
ad05819c2e | ||
|
|
0c6f71738c | ||
|
|
af451e7006 | ||
|
|
59f20bcc73 | ||
|
|
7eca3cdfca | ||
|
|
c40354f838 | ||
|
|
21a5b4658a | ||
|
|
073acaa053 | ||
|
|
38759b229d | ||
|
|
efe32e34ae | ||
|
|
46db4de11a | ||
|
|
170a6756f4 | ||
|
|
7330732f62 | ||
|
|
b08e5ca09a | ||
|
|
dff80a0c0a | ||
|
|
f54ae4b91c | ||
|
|
e5b3cced1f | ||
|
|
101e04db6d | ||
|
|
b79edda3a7 | ||
|
|
a20d3d11e5 | ||
|
|
3b4c455813 | ||
|
|
c967a2aa82 | ||
|
|
79cc6da96f |
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`*
|
||||
|
||||
- [ ] 阅读仓库[贡献指引](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?
|
||||
- [ ] 我确定已自行测试所作的更改,确保功能符合预期。 / 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.
|
||||
153
AGENTS.md
153
AGENTS.md
@@ -1,81 +1,142 @@
|
||||
# AGENTS.md
|
||||
|
||||
This file is for guiding code agents (like Claude Code, GitHub Copilot, OpenAI Codex, etc.) to work in LangBot project.
|
||||
This file guides code agents (Claude Code, GitHub Copilot, OpenAI Codex, etc.) working in the LangBot project. `CLAUDE.md` is a symlink to this file.
|
||||
|
||||
## Project Overview
|
||||
|
||||
LangBot is a open-source LLM native instant messaging bot development platform, aiming to provide an out-of-the-box IM robot development experience, with Agent, RAG, MCP and other LLM application functions, supporting global instant messaging platforms, and providing rich API interfaces, supporting custom development.
|
||||
LangBot is an open-source, LLM-native instant-messaging bot development platform. It aims to provide an out-of-the-box IM bot development experience with Agent, RAG, MCP and other LLM application capabilities, supporting mainstream global IM platforms and exposing rich APIs for custom development.
|
||||
|
||||
LangBot has a comprehensive frontend, all operations can be performed through the frontend. The project splited into these major parts:
|
||||
LangBot has a comprehensive web frontend — almost every operation can be performed through it.
|
||||
|
||||
- `./src/langbot`: The main python package of the project, below are the main modules in this package:
|
||||
- `./pkg`: The core python package of the project backend.
|
||||
- `./pkg/platform`: The platform module of the project, containing the logic of message platform adapters, bot managers, message session managers, etc.
|
||||
- `./pkg/provider`: The provider module of the project, containing the logic of LLM providers, tool providers, etc.
|
||||
- `./pkg/pipeline`: The pipeline module of the project, containing the logic of pipelines, stages, query pool, etc.
|
||||
- `./pkg/api`: The api module of the project, containing the http api controllers and services.
|
||||
- `./pkg/plugin`: LangBot bridge for connecting with plugin system.
|
||||
- `./libs`: Some SDKs we previously developed for the project, such as `qq_official_api`, `wecom_api`, etc.
|
||||
- `./templates`: Templates of config files, components, etc.
|
||||
- `./web`: Frontend codebase, built with Next.js + **shadcn** + **Tailwind CSS**.
|
||||
- `./docker`: docker-compose deployment files.
|
||||
- **Python**: `>=3.11,<4.0`, dependencies managed by `uv`. Package version is in `pyproject.toml`.
|
||||
- **Frontend**: `web/` is a **Vite + React Router 7 + shadcn/ui + Tailwind CSS** SPA, managed by `pnpm`. (Note: this is NOT Next.js — the `dev` script is `vite`.)
|
||||
- **Backend framework**: Quart (the async flavour of Flask). The HTTP API and the pre-built web UI are both served by the backend on `http://127.0.0.1:5300`.
|
||||
|
||||
## Backend Development
|
||||
## Repository Layout
|
||||
|
||||
We use `uv` to manage dependencies.
|
||||
```
|
||||
LangBot/
|
||||
├── main.py # Entrypoint shim -> langbot.__main__.main()
|
||||
├── pyproject.toml # Python project + deps (uv), pins langbot-plugin==<x.y.z>
|
||||
├── src/langbot/
|
||||
│ ├── __main__.py # Real entrypoint, CLI args (--standalone-runtime, --standalone-box, --debug)
|
||||
│ ├── pkg/ # Core backend package
|
||||
│ │ ├── api/ # HTTP API controllers + services (Quart)
|
||||
│ │ ├── core/ # App bootstrap, stages, task manager
|
||||
│ │ ├── platform/ # IM platform adapters, bot managers, session managers
|
||||
│ │ ├── provider/ # LLM providers, requesters, tool providers
|
||||
│ │ ├── pipeline/ # Pipelines, stages, query pool
|
||||
│ │ ├── plugin/ # Bridge connecting LangBot to the plugin runtime (see below)
|
||||
│ │ ├── box/ # Code-sandbox subsystem (Docker / nsjail / E2B backends)
|
||||
│ │ ├── skill/ # Skill subsystem
|
||||
│ │ ├── rag/ , vector/ # RAG + vector store
|
||||
│ │ ├── command/ # Built-in commands
|
||||
│ │ ├── persistence/ # ORM models + Alembic migrations (SQLite & PostgreSQL)
|
||||
│ │ ├── storage/ # Object/file storage abstractions
|
||||
│ │ ├── config/, entity/, discover/, utils/, telemetry/, survey/
|
||||
│ ├── libs/ # Vendored SDKs (qq_official_api, wecom_api, etc.)
|
||||
│ └── templates/ # Config/component templates (e.g. templates/config.yaml)
|
||||
├── web/ # Frontend SPA (Vite + React Router 7 + shadcn + Tailwind)
|
||||
└── docker/ # docker-compose deployment files
|
||||
```
|
||||
|
||||
## Development Environment Setup
|
||||
|
||||
Full guide lives in the wiki: **["开发配置" / Dev Config](https://docs.langbot.app/zh/develop/dev-config)**. Summary:
|
||||
|
||||
### Backend
|
||||
|
||||
```bash
|
||||
pip install uv
|
||||
uv sync --dev
|
||||
uv sync --dev # uv creates a .venv/ for you; point your editor's interpreter at it
|
||||
uv run main.py # serves API + web UI on http://127.0.0.1:5300
|
||||
```
|
||||
|
||||
Start the backend and run the project in development mode.
|
||||
On first run the config file is generated at `data/config.yaml`. DB is SQLite by default (zero setup); PostgreSQL is supported. Migrations run automatically on startup.
|
||||
|
||||
```bash
|
||||
uv run main.py
|
||||
```
|
||||
### Frontend
|
||||
|
||||
Then you can access the project at `http://127.0.0.1:5300`.
|
||||
|
||||
## Frontend Development
|
||||
|
||||
We use `pnpm` to manage dependencies.
|
||||
Requires Node.js + [pnpm](https://pnpm.io/installation).
|
||||
|
||||
```bash
|
||||
cd web
|
||||
cp .env.example .env
|
||||
cp .env.example .env # Windows: copy .env.example .env
|
||||
pnpm install
|
||||
pnpm dev
|
||||
pnpm dev # http://127.0.0.1:3000 (npm install / npm run dev also work)
|
||||
```
|
||||
|
||||
Then you can access the project at `http://127.0.0.1:3000`.
|
||||
`pnpm dev` reads `VITE_API_BASE_URL` from `web/.env` so the dev frontend can reach the backend on port `5300`. In production the frontend is pre-built into static files served by the backend on the same origin.
|
||||
|
||||
## Plugin System Architecture
|
||||
### Code formatting
|
||||
|
||||
LangBot is composed of various internal components such as Large Language Model tools, commands, messaging platform adapters, LLM requesters, and more. To meet extensibility and flexibility requirements, we have implemented a production-grade plugin system.
|
||||
The repo runs lint + format checks in CI. Install the pre-commit hooks so the same checks run locally before each commit:
|
||||
|
||||
Each plugin runs in an independent process, managed uniformly by the Plugin Runtime. It has two operating modes: `stdio` and `websocket`. When LangBot is started directly by users (not running in a container), it uses `stdio` mode, which is common for personal users or lightweight environments. When LangBot runs in a container, it uses `websocket` mode, designed specifically for production environments.
|
||||
```bash
|
||||
uv run pre-commit install
|
||||
```
|
||||
|
||||
Plugin Runtime automatically starts each installed plugin and interacts through stdio. In plugin development scenarios, developers can use the lbp command-line tool to start plugins and connect to the running Runtime via WebSocket for debugging.
|
||||
## Plugin System
|
||||
|
||||
> Plugin SDK, CLI, Runtime, and entities definitions shared between LangBot and plugins are contained in the [`langbot-plugin-sdk`](https://github.com/langbot-app/langbot-plugin-sdk) repository.
|
||||
LangBot's plugin system (Plugin SDK, CLI `lbp`, Plugin Runtime, and the shared entity/API definitions) lives in a **separate repository**: [`langbot-plugin-sdk`](https://github.com/langbot-app/langbot-plugin-sdk). LangBot depends on it via the pinned `langbot-plugin` package in `pyproject.toml`.
|
||||
|
||||
## Some Development Tips and Standards
|
||||
### Architecture (what to know inside this repo)
|
||||
|
||||
- LangBot is a global project, any comments in code should be in English, and user experience should be considered in all aspects.
|
||||
- Thus you should consider the i18n support in all aspects.
|
||||
- LangBot is widely adopted in both toC and toB scenarios, so you should consider the compatibility and security in all aspects.
|
||||
- If you were asked to make a commit, please follow the commit message format:
|
||||
- format: <type>(<scope>): <subject>
|
||||
- type: must be a specific type, such as feat (new feature), fix (bug fix), docs (documentation), style (code style), refactor (refactoring), perf (performance optimization), etc.
|
||||
- scope: the scope of the commit, such as the package name, the file name, the function name, the class name, the module name, etc.
|
||||
- subject: the subject of the commit, such as the description of the commit, the reason for the commit, the impact of the commit, etc.
|
||||
- LangBot uses [Alembic](https://alembic.sqlalchemy.org/) to manage database migrations, supporting both SQLite and PostgreSQL. Migration files are located in `src/langbot/pkg/persistence/alembic/versions/`. If you changed the definition of database entities (ORM models), generate a new migration script by running `uv run python -m langbot.pkg.persistence.alembic_runner autogenerate "description of your change"` in the project root (requires `data/config.yaml` to exist). Review and edit the generated script before committing. Migrations are executed automatically on LangBot startup. For data migrations (e.g. modifying JSON field content), you need to manually add the migration code in the generated script.
|
||||
- Plugins run as independent processes managed by the **Plugin Runtime**. The Runtime supports two control transports: `stdio` and `websocket`.
|
||||
- When LangBot is started directly by a user (not in a container), it spawns and connects to the Runtime over **stdio** (lightweight/personal use).
|
||||
- When LangBot runs in a container, it connects to a standalone Runtime over **WebSocket** (production).
|
||||
- The bridge code lives in `src/langbot/pkg/plugin/` (`connector.py`, `handler.py`).
|
||||
- Relevant config (`data/config.yaml`): `plugin.runtime_ws_url` (e.g. `ws://langbot_plugin_runtime:5400/control/ws`). Start LangBot with `--standalone-runtime` to make it connect to an externally-launched Runtime over WebSocket instead of spawning one over stdio.
|
||||
|
||||
### Debugging the Plugin Runtime / CLI / SDK
|
||||
|
||||
This is documented in detail in the **SDK repo's `AGENTS.md`** and in the wiki page **["调试插件运行时、CLI、SDK" / Plugin Runtime](https://docs.langbot.app/zh/develop/plugin-runtime)**. The short version:
|
||||
|
||||
- Clone `LangBot` and `langbot-plugin-sdk` as siblings under one parent dir so the editor resolves shared entities.
|
||||
- Start a standalone Runtime from the SDK repo: `uv run --no-sync lbp rt` (control port `5400`, debug port `5401`).
|
||||
- To make LangBot use a locally-modified SDK: from the SDK dir, with LangBot's `.venv` active, run `uv pip install .`, then launch LangBot with `uv run --no-sync main.py --standalone-runtime` (keep `--no-sync` so your local SDK isn't overwritten).
|
||||
|
||||
### Debugging the Box (sandbox) runtime
|
||||
|
||||
The Box subsystem (`src/langbot/pkg/box/`) is the code sandbox. It picks the first available backend among **Docker / nsjail / E2B**. The standalone Box runtime is launched via the SDK CLI: `lbp box`. Backend selection details, the `lbp box` flags, and the SDK-side architecture are documented in the SDK repo's `AGENTS.md`.
|
||||
|
||||
Relevant config (`data/config.yaml`, `box:` section): `box.enabled` (master switch — disabling it also disables the native sandbox tools, skill add/edit, and stdio-mode MCP servers), `box.backend` (`'local'` = Docker/nsjail auto-pick, or `'docker'` / `'nsjail'` / `'e2b'`; also settable via `BOX__BACKEND`), and `box.runtime.endpoint` (external Box runtime base URL, e.g. `ws://127.0.0.1:5410`; empty = local auto-managed runtime). Like the plugin runtime, LangBot can connect to an externally-launched Box runtime by setting that endpoint and starting with `--standalone-box`.
|
||||
|
||||
> A common false "No supported sandbox backend (Docker / nsjail / E2B) is available" comes from Docker being installed and running but the current user not being in the `docker` group → `docker info` gets `permission denied` on the socket. Fix: `sudo usermod -aG docker <user>` and restart the backend in a shell that has the new group.
|
||||
|
||||
## Development Standards
|
||||
|
||||
- LangBot is a global project: **all code comments and docstrings must be in English**, and every user-facing string must support **i18n** (`en_US` + `zh_Hans` at minimum, plus `ja_JP` where the repo already has it).
|
||||
- LangBot is adopted in both toC and toB scenarios — always consider compatibility and security.
|
||||
- **Commit message format**: `<type>(<scope>): <subject>`
|
||||
- `type`: one of `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `chore`, etc.
|
||||
- `scope`: the affected package/module/file/class.
|
||||
- `subject`: concise description of the change.
|
||||
|
||||
### Database migrations (Alembic)
|
||||
|
||||
LangBot uses [Alembic](https://alembic.sqlalchemy.org/) for migrations, supporting both SQLite and PostgreSQL from a single set of scripts. Migration files live in `src/langbot/pkg/persistence/alembic/versions/`.
|
||||
|
||||
If you change ORM model definitions, generate a migration:
|
||||
|
||||
```bash
|
||||
# Run from the project root (requires data/config.yaml to exist)
|
||||
uv run python -m langbot.pkg.persistence.alembic_runner autogenerate "description of your change"
|
||||
```
|
||||
|
||||
Review and edit the generated script before committing. Migrations execute automatically on startup. `autogenerate` detects schema changes (add/drop columns, tables, type changes) but **data migrations** (e.g. mutating JSON field contents) must be hand-written into the generated script. `env.py` sets `render_as_batch=True`, so SQLite's ALTER TABLE limits are handled automatically — no need to branch per database. More in the wiki ["开发配置"](https://docs.langbot.app/zh/develop/dev-config#数据库迁移).
|
||||
|
||||
When writing a migration, follow these rules:
|
||||
|
||||
- **Revision id ≤ 32 characters.** PostgreSQL stores `alembic_version.version_num` as `varchar(32)`; a longer id raises `StringDataRightTruncationError` at runtime. Prefer short, descriptive ids like `0005_add_llm_context_length`.
|
||||
- **Guard every operation against missing tables/columns.** Fresh installs build the schema via `create_all()` and then stamp the Alembic baseline, so a migration may run against a table that already has the change — or, in tests, against an empty database. Check `inspector.get_table_names()` / `inspector.get_columns(...)` before `add_column` / `drop_column`, mirroring the existing migrations.
|
||||
- **Keep a single linear head.** Chain `down_revision` to the current head; do not create branches. Run the migration tests after adding one: `uv run pytest tests/integration/persistence/ -q` (the PostgreSQL test needs a running PG via `TEST_POSTGRES_URL`).
|
||||
|
||||
> **Legacy migration system (deprecated — do not extend).** The old 3.x migration system under `src/langbot/pkg/persistence/migrations/` (`DBMigration` subclasses in `dbmXXX_*.py`, run from `pkg/persistence/mgr.py`) is **frozen**. Do **not** add new `dbmXXX_*.py` files. The chain is capped at `required_database_version = 25` (`pkg/utils/constants.py`); those files only exist to upgrade pre-existing 3.x databases up to the Alembic baseline and are kept read-only. All new schema changes go through Alembic.
|
||||
|
||||
## Some Principles
|
||||
|
||||
- Keep it simple, stupid.
|
||||
- Entities should not be multiplied unnecessarily
|
||||
- Entities should not be multiplied unnecessarily.
|
||||
- 八荣八耻
|
||||
|
||||
以瞎猜接口为耻,以认真查询为荣。
|
||||
@@ -85,4 +146,4 @@ Plugin Runtime automatically starts each installed plugin and interacts through
|
||||
以跳过验证为耻,以主动测试为荣。
|
||||
以破坏架构为耻,以遵循规范为荣。
|
||||
以假装理解为耻,以诚实无知为荣。
|
||||
以盲目修改为耻,以谨慎重构为荣。
|
||||
以盲目修改为耻,以谨慎重构为荣。
|
||||
|
||||
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 中请使用全英文
|
||||
- 对于中文用户,issue 中可以使用中文
|
||||
|
||||
### 贡献者许可协议(CLA)
|
||||
|
||||
为了保护项目和每一位贡献者,我们要求所有代码贡献者签署[贡献者许可协议(CLA)](./CLA.md)。这是 Apache、Google、Grafana 等主流开源项目的标准做法:您保留自己代码的全部版权,仅授予项目使用、分发您贡献的许可。
|
||||
|
||||
签署只需 10 秒:首次提交 PR 时,机器人会自动评论提示,按提示回复一句话即完成签署,此后对本组织所有仓库永久有效。历史贡献不受影响。
|
||||
|
||||
<hr/>
|
||||
|
||||
## Guidelines
|
||||
@@ -29,3 +35,9 @@
|
||||
|
||||
- Use English in PRs and Commit Messages
|
||||
- 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.
|
||||
|
||||
26
Dockerfile
26
Dockerfile
@@ -6,6 +6,25 @@ COPY web ./web
|
||||
|
||||
RUN cd web && npm install && npx vite build
|
||||
|
||||
# Build nsjail from source so the image ships a self-contained sandbox backend
|
||||
# that needs no host Docker socket. Pinned to a release tag for reproducibility.
|
||||
# Multi-stage keeps the compile toolchain (bison/flex/protobuf-dev/libnl-dev)
|
||||
# out of the final image; only the nsjail binary and its small runtime libs
|
||||
# (libprotobuf, libnl-route-3) are carried over.
|
||||
FROM python:3.12.7-slim AS nsjail-build
|
||||
|
||||
ARG NSJAIL_VERSION=3.6
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
ca-certificates git build-essential \
|
||||
autoconf bison flex libtool pkg-config \
|
||||
protobuf-compiler libprotobuf-dev libnl-route-3-dev \
|
||||
&& git clone --depth 1 --branch "${NSJAIL_VERSION}" https://github.com/google/nsjail.git /nsjail \
|
||||
&& make -C /nsjail \
|
||||
&& install -m 0755 /nsjail/nsjail /usr/local/bin/nsjail \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
FROM python:3.12.7-slim
|
||||
|
||||
WORKDIR /app
|
||||
@@ -14,8 +33,15 @@ COPY . .
|
||||
|
||||
COPY --from=node /app/web/dist ./web/dist
|
||||
|
||||
# nsjail binary built in the dedicated stage above. Self-contained sandbox
|
||||
# backend; lets the Box runtime isolate code without a host Docker socket.
|
||||
COPY --from=nsjail-build /usr/local/bin/nsjail /usr/local/bin/nsjail
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends gcc ca-certificates curl gnupg \
|
||||
# nsjail runtime libraries (the build toolchain stays in the nsjail-build
|
||||
# stage; only these shared libs are needed to execute the binary).
|
||||
&& apt-get install -y --no-install-recommends libprotobuf32 libnl-route-3-200 \
|
||||
# Install the Docker CLI (client only) so the optional langbot_box
|
||||
# service can drive the mounted host Docker socket and create sandbox
|
||||
# containers. The same image powers langbot / plugin_runtime / box; only
|
||||
|
||||
@@ -38,7 +38,7 @@ LangBot is an **open-source, production-grade platform** for building AI-powered
|
||||
|
||||
### Key Capabilities
|
||||
|
||||
- **AI Conversations & Agents** — Multi-turn dialogues, tool calling, multi-modal support, streaming output. Built-in RAG (knowledge base) with deep integration to [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org).
|
||||
- **AI Conversations & Agents** — Multi-turn dialogues, tool calling, multi-modal support, streaming output. Built-in RAG (knowledge base) with deep integration to [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org), [Deerflow](https://deerflow.tech), [Weknora](https://weknora.weixin.qq.com).
|
||||
- **Universal IM Platform Support** — One codebase for Discord, Telegram, Slack, LINE, QQ, WeChat, WeCom, Lark, DingTalk, KOOK.
|
||||
- **Production-Ready** — Access control, rate limiting, sensitive word filtering, comprehensive monitoring, and exception handling. Trusted by enterprises.
|
||||
- **Plugin Ecosystem** — Hundreds of plugins, event-driven architecture, component extensions, and [MCP protocol](https://modelcontextprotocol.io/) support.
|
||||
@@ -78,7 +78,7 @@ docker compose up -d
|
||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||
|
||||
**More options:** [Docker](https://link.langbot.app/en/docs/docker) · [Manual](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](./docker/README_K8S.md)
|
||||
**More options:** [Docker](https://link.langbot.app/en/docs/docker) · [Manual](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
[English](README.md) / 简体中文 / [繁體中文](README_TW.md) / [日本語](README_JP.md) / [Español](README_ES.md) / [Français](README_FR.md) / [한국어](README_KO.md) / [Русский](README_RU.md) / [Tiếng Việt](README_VI.md)
|
||||
|
||||
[](https://discord.gg/wdNEHETs87)
|
||||
[](https://qm.qq.com/q/DxZZcNxM1W)
|
||||
[](https://qm.qq.com/q/IrlV8QFacU)
|
||||
[](https://deepwiki.com/langbot-app/LangBot)
|
||||
[](https://github.com/langbot-app/LangBot/releases/latest)
|
||||
<img src="https://img.shields.io/badge/python-3.10 ~ 3.13 -blue.svg" alt="python">
|
||||
@@ -38,7 +38,7 @@ LangBot 是一个**开源的生产级平台**,用于构建 AI 驱动的即时
|
||||
|
||||
### 核心能力
|
||||
|
||||
- **AI 对话与 Agent** — 多轮对话、工具调用、多模态、流式输出。自带 RAG(知识库),深度集成 [Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io)、[Langflow](https://langflow.org) 等 LLMOps 平台。
|
||||
- **AI 对话与 Agent** — 多轮对话、工具调用、多模态、流式输出。自带 RAG(知识库),深度集成 [Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io)、[Langflow](https://langflow.org)、[Deerflow](https://deerflow.tech)、[Weknora](https://weknora.weixin.qq.com)等 LLMOps 平台。
|
||||
- **全平台支持** — 一套代码,覆盖 QQ、微信、企业微信、飞书、钉钉、Discord、Telegram、Slack、LINE、KOOK 等平台。
|
||||
- **生产就绪** — 访问控制、限速、敏感词过滤、全面监控与异常处理,已被多家企业采用。
|
||||
- **插件生态** — 数百个插件,跨进程的事件驱动架构,组件扩展,适配 [MCP 协议](https://modelcontextprotocol.io/)。
|
||||
@@ -78,7 +78,7 @@ docker compose up -d
|
||||
[](https://zeabur.com/zh-CN/templates/ZKTBDH)
|
||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||
|
||||
**更多方式:** [Docker](https://link.langbot.app/zh/docs/docker) · [手动部署](https://link.langbot.app/zh/docs/manual-deploy) · [宝塔面板](https://link.langbot.app/zh/docs/bt-panel) · [Kubernetes](./docker/README_K8S.md)
|
||||
**更多方式:** [Docker](https://link.langbot.app/zh/docs/docker) · [手动部署](https://link.langbot.app/zh/docs/manual-deploy) · [宝塔面板](https://link.langbot.app/zh/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/zh/deploy/langbot/kubernetes)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ LangBot es una **plataforma de código abierto y grado de producción** para con
|
||||
|
||||
### Capacidades Clave
|
||||
|
||||
- **Conversaciones e Agentes IA** — Diálogos de múltiples turnos, llamadas a herramientas, soporte multimodal, salida en streaming. RAG (base de conocimientos) incorporado con integración profunda con [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org).
|
||||
- **Conversaciones e Agentes IA** — Diálogos de múltiples turnos, llamadas a herramientas, soporte multimodal, salida en streaming. RAG (base de conocimientos) incorporado con integración profunda con [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org), [Deerflow](https://deerflow.tech)、[Weknora](https://weknora.weixin.qq.com).
|
||||
- **Soporte Universal de Plataformas de MI** — Un solo código base para Discord, Telegram, Slack, LINE, QQ, WeChat, WeCom, Lark, DingTalk, KOOK.
|
||||
- **Listo para Producción** — Control de acceso, limitación de velocidad, filtrado de palabras sensibles, monitoreo completo y manejo de excepciones. De confianza para empresas.
|
||||
- **Ecosistema de Plugins** — Cientos de plugins, arquitectura basada en eventos, extensiones de componentes y soporte del [protocolo MCP](https://modelcontextprotocol.io/).
|
||||
@@ -77,7 +77,7 @@ docker compose up -d
|
||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||
|
||||
**Más opciones:** [Docker](https://link.langbot.app/en/docs/docker) · [Manual](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](./docker/README_K8S.md)
|
||||
**Más opciones:** [Docker](https://link.langbot.app/en/docs/docker) · [Manual](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ LangBot est une **plateforme open-source de niveau production** pour créer des
|
||||
|
||||
### Capacités Clés
|
||||
|
||||
- **Conversations IA & Agents** — Dialogues multi-tours, appels d'outils, support multimodal, sortie en streaming. RAG (base de connaissances) intégré avec intégration profonde de [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org).
|
||||
- **Conversations IA & Agents** — Dialogues multi-tours, appels d'outils, support multimodal, sortie en streaming. RAG (base de connaissances) intégré avec intégration profonde de [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org), [Deerflow](https://deerflow.tech), [Weknora](https://weknora.weixin.qq.com).
|
||||
- **Support Universel des Plateformes de MI** — Un seul code pour Discord, Telegram, Slack, LINE, QQ, WeChat, WeCom, Lark, DingTalk, KOOK.
|
||||
- **Prêt pour la Production** — Contrôle d'accès, limitation de débit, filtrage de mots sensibles, surveillance complète et gestion des exceptions. Approuvé par les entreprises.
|
||||
- **Écosystème de Plugins** — Des centaines de plugins, architecture événementielle, extensions de composants, et support du [protocole MCP](https://modelcontextprotocol.io/).
|
||||
@@ -77,7 +77,7 @@ docker compose up -d
|
||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||
|
||||
**Plus d'options :** [Docker](https://link.langbot.app/en/docs/docker) · [Manuel](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](./docker/README_K8S.md)
|
||||
**Plus d'options :** [Docker](https://link.langbot.app/en/docs/docker) · [Manuel](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ LangBot は、AI搭載のインスタントメッセージングボットを構
|
||||
|
||||
### 主な機能
|
||||
|
||||
- **AI対話とエージェント** — マルチターン対話、ツール呼び出し、マルチモーダル対応、ストリーミング出力。RAG(ナレッジベース)を内蔵し、[Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io)、[Langflow](https://langflow.org) と深く統合。
|
||||
- **AI対話とエージェント** — マルチターン対話、ツール呼び出し、マルチモーダル対応、ストリーミング出力。RAG(ナレッジベース)を内蔵し、[Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io)、[Langflow](https://langflow.org)、[Deerflow](https://deerflow.tech)、[Weknora](https://weknora.weixin.qq.com) と深く統合。
|
||||
- **ユニバーサルIMプラットフォーム対応** — 単一のコードベースで Discord、Telegram、Slack、LINE、QQ、WeChat、WeCom、Lark、DingTalk、KOOK に対応。
|
||||
- **本番環境対応** — アクセス制御、レート制限、センシティブワードフィルタリング、包括的な監視、例外処理を搭載。エンタープライズの信頼に応える品質。
|
||||
- **プラグインエコシステム** — 数百のプラグイン、イベント駆動アーキテクチャ、コンポーネント拡張、[MCPプロトコル](https://modelcontextprotocol.io/)対応。
|
||||
@@ -77,7 +77,7 @@ docker compose up -d
|
||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||
|
||||
**その他:** [Docker](https://link.langbot.app/en/docs/docker) · [手動デプロイ](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](./docker/README_K8S.md)
|
||||
**その他:** [Docker](https://link.langbot.app/en/docs/docker) · [手動デプロイ](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ LangBot은 AI 기반 인스턴트 메시징 봇을 구축하기 위한 **오픈
|
||||
|
||||
### 핵심 기능
|
||||
|
||||
- **AI 대화 및 에이전트** — 멀티턴 대화, 도구 호출, 멀티모달 지원, 스트리밍 출력. 내장 RAG(지식 베이스)와 [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org) 심층 통합.
|
||||
- **AI 대화 및 에이전트** — 멀티턴 대화, 도구 호출, 멀티모달 지원, 스트리밍 출력. 내장 RAG(지식 베이스)와 [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org), [Deerflow](https://deerflow.tech), [Weknora](https://weknora.weixin.qq.com) 심층 통합.
|
||||
- **유니버설 IM 플랫폼 지원** — 단일 코드베이스로 Discord, Telegram, Slack, LINE, QQ, WeChat, WeCom, Lark, DingTalk, KOOK 지원.
|
||||
- **프로덕션 레디** — 접근 제어, 속도 제한, 민감어 필터링, 종합 모니터링 및 예외 처리. 기업 환경에서 검증됨.
|
||||
- **플러그인 생태계** — 수백 개의 플러그인, 이벤트 기반 아키텍처, 컴포넌트 확장, [MCP 프로토콜](https://modelcontextprotocol.io/) 지원.
|
||||
@@ -77,7 +77,7 @@ docker compose up -d
|
||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||
|
||||
**더 많은 옵션:** [Docker](https://link.langbot.app/en/docs/docker) · [수동 배포](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](./docker/README_K8S.md)
|
||||
**더 많은 옵션:** [Docker](https://link.langbot.app/en/docs/docker) · [수동 배포](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ LangBot — это **платформа с открытым исходным к
|
||||
|
||||
### Ключевые возможности
|
||||
|
||||
- **ИИ-диалоги и агенты** — Многораундовые диалоги, вызов инструментов, мультимодальная поддержка, потоковый вывод. Встроенная реализация RAG (база знаний) с глубокой интеграцией в [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org).
|
||||
- **ИИ-диалоги и агенты** — Многораундовые диалоги, вызов инструментов, мультимодальная поддержка, потоковый вывод. Встроенная реализация RAG (база знаний) с глубокой интеграцией в [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org), [Deerflow](https://deerflow.tech), [Weknora](https://weknora.weixin.qq.com).
|
||||
- **Универсальная поддержка IM-платформ** — Единая кодовая база для Discord, Telegram, Slack, LINE, QQ, WeChat, WeCom, Lark, DingTalk, KOOK.
|
||||
- **Готовность к продакшену** — Контроль доступа, ограничение скорости, фильтрация чувствительных слов, комплексный мониторинг и обработка исключений. Проверено в корпоративной среде.
|
||||
- **Экосистема плагинов** — Сотни плагинов, событийно-ориентированная архитектура, расширения компонентов и поддержка [протокола MCP](https://modelcontextprotocol.io/).
|
||||
@@ -77,7 +77,7 @@ docker compose up -d
|
||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||
|
||||
**Другие варианты:** [Docker](https://link.langbot.app/en/docs/docker) · [Ручная установка](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](./docker/README_K8S.md)
|
||||
**Другие варианты:** [Docker](https://link.langbot.app/en/docs/docker) · [Ручная установка](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ LangBot 是一個**開源的生產級平台**,用於建構 AI 驅動的即時
|
||||
|
||||
### 核心能力
|
||||
|
||||
- **AI 對話與 Agent** — 多輪對話、工具調用、多模態、流式輸出。自帶 RAG(知識庫),深度整合 [Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io)、[Langflow](https://langflow.org) 等 LLMOps 平台。
|
||||
- **AI 對話與 Agent** — 多輪對話、工具調用、多模態、流式輸出。自帶 RAG(知識庫),深度整合 [Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io)、[Langflow](https://langflow.org)、 [Deerflow](https://deerflow.tech)、[Weknora](https://weknora.weixin.qq.com)等 LLMOps 平台。
|
||||
- **全平台支援** — 一套程式碼,覆蓋 QQ、微信、企業微信、飛書、釘釘、Discord、Telegram、Slack、LINE、KOOK 等平台。
|
||||
- **生產就緒** — 存取控制、限速、敏感詞過濾、全面監控與異常處理,已被多家企業採用。
|
||||
- **外掛生態** — 數百個外掛,事件驅動架構,組件擴展,適配 [MCP 協議](https://modelcontextprotocol.io/)。
|
||||
@@ -79,7 +79,7 @@ docker compose up -d
|
||||
[](https://zeabur.com/zh-CN/templates/ZKTBDH)
|
||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||
|
||||
**更多方式:** [Docker](https://link.langbot.app/zh/docs/docker) · [手動部署](https://link.langbot.app/zh/docs/manual-deploy) · [寶塔面板](https://link.langbot.app/zh/docs/bt-panel) · [Kubernetes](./docker/README_K8S.md)
|
||||
**更多方式:** [Docker](https://link.langbot.app/zh/docs/docker) · [手動部署](https://link.langbot.app/zh/docs/manual-deploy) · [寶塔面板](https://link.langbot.app/zh/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/zh/deploy/langbot/kubernetes)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ LangBot là một **nền tảng mã nguồn mở, cấp sản xuất** để x
|
||||
|
||||
### Khả năng chính
|
||||
|
||||
- **Hội thoại AI & Agent** — Đối thoại nhiều lượt, gọi công cụ, hỗ trợ đa phương thức, đầu ra streaming. RAG (cơ sở kiến thức) tích hợp sẵn với tích hợp sâu vào [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org).
|
||||
- **Hội thoại AI & Agent** — Đối thoại nhiều lượt, gọi công cụ, hỗ trợ đa phương thức, đầu ra streaming. RAG (cơ sở kiến thức) tích hợp sẵn với tích hợp sâu vào [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org), [Deerflow](https://deerflow.tech), [Weknora](https://weknora.weixin.qq.com).
|
||||
- **Hỗ trợ đa nền tảng IM** — Một mã nguồn cho Discord, Telegram, Slack, LINE, QQ, WeChat, WeCom, Lark, DingTalk, KOOK.
|
||||
- **Sẵn sàng cho sản xuất** — Kiểm soát truy cập, giới hạn tốc độ, lọc từ nhạy cảm, giám sát toàn diện và xử lý ngoại lệ. Được doanh nghiệp tin dùng.
|
||||
- **Hệ sinh thái Plugin** — Hàng trăm plugin, kiến trúc hướng sự kiện, mở rộng thành phần, và hỗ trợ [giao thức MCP](https://modelcontextprotocol.io/).
|
||||
@@ -77,7 +77,7 @@ docker compose up -d
|
||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||
|
||||
**Thêm tùy chọn:** [Docker](https://link.langbot.app/en/docs/docker) · [Thủ công](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](./docker/README_K8S.md)
|
||||
**Thêm tùy chọn:** [Docker](https://link.langbot.app/en/docs/docker) · [Thủ công](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,629 +0,0 @@
|
||||
# LangBot Kubernetes 部署指南 / Kubernetes Deployment Guide
|
||||
|
||||
[简体中文](#简体中文) | [English](#english)
|
||||
|
||||
---
|
||||
|
||||
## 简体中文
|
||||
|
||||
### 概述
|
||||
|
||||
本指南提供了在 Kubernetes 集群中部署 LangBot 的完整步骤。Kubernetes 部署配置基于 `docker-compose.yaml`,适用于生产环境的容器化部署。
|
||||
|
||||
### 前置要求
|
||||
|
||||
- Kubernetes 集群(版本 1.19+)
|
||||
- `kubectl` 命令行工具已配置并可访问集群
|
||||
- 集群中有可用的存储类(StorageClass)用于持久化存储(可选但推荐)
|
||||
- 至少 2 vCPU 和 4GB RAM 的可用资源
|
||||
|
||||
### 架构说明
|
||||
|
||||
Kubernetes 部署包含以下组件:
|
||||
|
||||
1. **langbot**: 主应用服务
|
||||
- 提供 Web UI(端口 5300)
|
||||
- 处理平台 webhook(端口 2280-2290)
|
||||
- 数据持久化卷
|
||||
|
||||
2. **langbot-plugin-runtime**: 插件运行时服务
|
||||
- WebSocket 通信(端口 5400)
|
||||
- 插件数据持久化卷
|
||||
|
||||
3. **持久化存储**:
|
||||
- `langbot-data`: LangBot 主数据
|
||||
- `langbot-plugins`: 插件文件
|
||||
- `langbot-plugin-runtime-data`: 插件运行时数据
|
||||
|
||||
### 快速开始
|
||||
|
||||
#### 1. 下载部署文件
|
||||
|
||||
```bash
|
||||
# 克隆仓库
|
||||
git clone https://github.com/langbot-app/LangBot
|
||||
cd LangBot/docker
|
||||
|
||||
# 或直接下载 kubernetes.yaml
|
||||
wget https://raw.githubusercontent.com/langbot-app/LangBot/main/docker/kubernetes.yaml
|
||||
```
|
||||
|
||||
#### 2. 部署到 Kubernetes
|
||||
|
||||
```bash
|
||||
# 应用所有配置
|
||||
kubectl apply -f kubernetes.yaml
|
||||
|
||||
# 检查部署状态
|
||||
kubectl get all -n langbot
|
||||
|
||||
# 查看 Pod 日志
|
||||
kubectl logs -n langbot -l app=langbot -f
|
||||
```
|
||||
|
||||
#### 3. 访问 LangBot
|
||||
|
||||
默认情况下,LangBot 服务使用 ClusterIP 类型,只能在集群内部访问。您可以选择以下方式之一来访问:
|
||||
|
||||
**选项 A: 端口转发(推荐用于测试)**
|
||||
|
||||
```bash
|
||||
kubectl port-forward -n langbot svc/langbot 5300:5300
|
||||
```
|
||||
|
||||
然后访问 http://localhost:5300
|
||||
|
||||
**选项 B: NodePort(适用于开发环境)**
|
||||
|
||||
编辑 `kubernetes.yaml`,取消注释 NodePort Service 部分,然后:
|
||||
|
||||
```bash
|
||||
kubectl apply -f kubernetes.yaml
|
||||
# 获取节点 IP
|
||||
kubectl get nodes -o wide
|
||||
# 访问 http://<NODE_IP>:30300
|
||||
```
|
||||
|
||||
**选项 C: LoadBalancer(适用于云环境)**
|
||||
|
||||
编辑 `kubernetes.yaml`,取消注释 LoadBalancer Service 部分,然后:
|
||||
|
||||
```bash
|
||||
kubectl apply -f kubernetes.yaml
|
||||
# 获取外部 IP
|
||||
kubectl get svc -n langbot langbot-loadbalancer
|
||||
# 访问 http://<EXTERNAL_IP>
|
||||
```
|
||||
|
||||
**选项 D: Ingress(推荐用于生产环境)**
|
||||
|
||||
确保集群中已安装 Ingress Controller(如 nginx-ingress),然后:
|
||||
|
||||
1. 编辑 `kubernetes.yaml` 中的 Ingress 配置
|
||||
2. 修改域名为您的实际域名
|
||||
3. 应用配置:
|
||||
|
||||
```bash
|
||||
kubectl apply -f kubernetes.yaml
|
||||
# 访问 http://langbot.yourdomain.com
|
||||
```
|
||||
|
||||
### 配置说明
|
||||
|
||||
#### 环境变量
|
||||
|
||||
在 `ConfigMap` 中配置环境变量:
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: langbot-config
|
||||
namespace: langbot
|
||||
data:
|
||||
TZ: "Asia/Shanghai" # 修改为您的时区
|
||||
```
|
||||
|
||||
#### 存储配置
|
||||
|
||||
默认使用动态存储分配。如果您有特定的 StorageClass,请在 PVC 中指定:
|
||||
|
||||
```yaml
|
||||
spec:
|
||||
storageClassName: your-storage-class-name
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 10Gi
|
||||
```
|
||||
|
||||
#### 资源限制
|
||||
|
||||
根据您的需求调整资源限制:
|
||||
|
||||
```yaml
|
||||
resources:
|
||||
requests:
|
||||
memory: "1Gi"
|
||||
cpu: "500m"
|
||||
limits:
|
||||
memory: "4Gi"
|
||||
cpu: "2000m"
|
||||
```
|
||||
|
||||
### 常用操作
|
||||
|
||||
#### 查看日志
|
||||
|
||||
```bash
|
||||
# 查看 LangBot 主服务日志
|
||||
kubectl logs -n langbot -l app=langbot -f
|
||||
|
||||
# 查看插件运行时日志
|
||||
kubectl logs -n langbot -l app=langbot-plugin-runtime -f
|
||||
```
|
||||
|
||||
#### 重启服务
|
||||
|
||||
```bash
|
||||
# 重启 LangBot
|
||||
kubectl rollout restart deployment/langbot -n langbot
|
||||
|
||||
# 重启插件运行时
|
||||
kubectl rollout restart deployment/langbot-plugin-runtime -n langbot
|
||||
```
|
||||
|
||||
#### 更新镜像
|
||||
|
||||
```bash
|
||||
# 更新到最新版本
|
||||
kubectl set image deployment/langbot -n langbot langbot=rockchin/langbot:latest
|
||||
kubectl set image deployment/langbot-plugin-runtime -n langbot langbot-plugin-runtime=rockchin/langbot:latest
|
||||
|
||||
# 检查更新状态
|
||||
kubectl rollout status deployment/langbot -n langbot
|
||||
```
|
||||
|
||||
#### 扩容(不推荐)
|
||||
|
||||
注意:由于 LangBot 使用 ReadWriteOnce 的持久化存储,不支持多副本扩容。如需高可用,请考虑使用 ReadWriteMany 存储或其他架构方案。
|
||||
|
||||
#### 备份数据
|
||||
|
||||
```bash
|
||||
# 备份 PVC 数据
|
||||
kubectl exec -n langbot -it <langbot-pod-name> -- tar czf /tmp/backup.tar.gz /app/data
|
||||
kubectl cp langbot/<langbot-pod-name>:/tmp/backup.tar.gz ./backup.tar.gz
|
||||
```
|
||||
|
||||
### 卸载
|
||||
|
||||
```bash
|
||||
# 删除所有资源(保留 PVC)
|
||||
kubectl delete deployment,service,configmap -n langbot --all
|
||||
|
||||
# 删除 PVC(会删除数据)
|
||||
kubectl delete pvc -n langbot --all
|
||||
|
||||
# 删除命名空间
|
||||
kubectl delete namespace langbot
|
||||
```
|
||||
|
||||
### 故障排查
|
||||
|
||||
#### Pod 无法启动
|
||||
|
||||
```bash
|
||||
# 查看 Pod 状态
|
||||
kubectl get pods -n langbot
|
||||
|
||||
# 查看详细信息
|
||||
kubectl describe pod -n langbot <pod-name>
|
||||
|
||||
# 查看事件
|
||||
kubectl get events -n langbot --sort-by='.lastTimestamp'
|
||||
```
|
||||
|
||||
#### 存储问题
|
||||
|
||||
```bash
|
||||
# 检查 PVC 状态
|
||||
kubectl get pvc -n langbot
|
||||
|
||||
# 检查 PV
|
||||
kubectl get pv
|
||||
```
|
||||
|
||||
#### 网络访问问题
|
||||
|
||||
```bash
|
||||
# 检查 Service
|
||||
kubectl get svc -n langbot
|
||||
|
||||
# 检查端口转发
|
||||
kubectl port-forward -n langbot svc/langbot 5300:5300
|
||||
```
|
||||
|
||||
### 生产环境建议
|
||||
|
||||
1. **使用特定版本标签**:避免使用 `latest` 标签,使用具体版本号如 `rockchin/langbot:v1.0.0`
|
||||
2. **配置资源限制**:根据实际负载调整 CPU 和内存限制
|
||||
3. **使用 Ingress + TLS**:配置 HTTPS 访问和证书管理
|
||||
4. **配置监控和告警**:集成 Prometheus、Grafana 等监控工具
|
||||
5. **定期备份**:配置自动备份策略保护数据
|
||||
6. **使用专用 StorageClass**:为生产环境配置高性能存储
|
||||
7. **配置亲和性规则**:确保 Pod 调度到合适的节点
|
||||
|
||||
### 高级配置
|
||||
|
||||
#### 使用 Secrets 管理敏感信息
|
||||
|
||||
如果需要配置 API 密钥等敏感信息:
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: langbot-secrets
|
||||
namespace: langbot
|
||||
type: Opaque
|
||||
data:
|
||||
api_key: <base64-encoded-value>
|
||||
```
|
||||
|
||||
然后在 Deployment 中引用:
|
||||
|
||||
```yaml
|
||||
env:
|
||||
- name: API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: langbot-secrets
|
||||
key: api_key
|
||||
```
|
||||
|
||||
#### 配置水平自动扩缩容(HPA)
|
||||
|
||||
注意:需要确保使用 ReadWriteMany 存储类型
|
||||
|
||||
```yaml
|
||||
apiVersion: autoscaling/v2
|
||||
kind: HorizontalPodAutoscaler
|
||||
metadata:
|
||||
name: langbot-hpa
|
||||
namespace: langbot
|
||||
spec:
|
||||
scaleTargetRef:
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
name: langbot
|
||||
minReplicas: 1
|
||||
maxReplicas: 3
|
||||
metrics:
|
||||
- type: Resource
|
||||
resource:
|
||||
name: cpu
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: 70
|
||||
```
|
||||
|
||||
### 参考资源
|
||||
|
||||
- [LangBot 官方文档](https://docs.langbot.app)
|
||||
- [Docker 部署文档](https://link.langbot.app/zh/docs/docker)
|
||||
- [Kubernetes 官方文档](https://kubernetes.io/docs/)
|
||||
|
||||
---
|
||||
|
||||
## English
|
||||
|
||||
### Overview
|
||||
|
||||
This guide provides complete steps for deploying LangBot in a Kubernetes cluster. The Kubernetes deployment configuration is based on `docker-compose.yaml` and is suitable for production containerized deployments.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Kubernetes cluster (version 1.19+)
|
||||
- `kubectl` command-line tool configured with cluster access
|
||||
- Available StorageClass in the cluster for persistent storage (optional but recommended)
|
||||
- At least 2 vCPU and 4GB RAM of available resources
|
||||
|
||||
### Architecture
|
||||
|
||||
The Kubernetes deployment includes the following components:
|
||||
|
||||
1. **langbot**: Main application service
|
||||
- Provides Web UI (port 5300)
|
||||
- Handles platform webhooks (ports 2280-2290)
|
||||
- Data persistence volume
|
||||
|
||||
2. **langbot-plugin-runtime**: Plugin runtime service
|
||||
- WebSocket communication (port 5400)
|
||||
- Plugin data persistence volume
|
||||
|
||||
3. **Persistent Storage**:
|
||||
- `langbot-data`: LangBot main data
|
||||
- `langbot-plugins`: Plugin files
|
||||
- `langbot-plugin-runtime-data`: Plugin runtime data
|
||||
|
||||
### Quick Start
|
||||
|
||||
#### 1. Download Deployment Files
|
||||
|
||||
```bash
|
||||
# Clone repository
|
||||
git clone https://github.com/langbot-app/LangBot
|
||||
cd LangBot/docker
|
||||
|
||||
# Or download kubernetes.yaml directly
|
||||
wget https://raw.githubusercontent.com/langbot-app/LangBot/main/docker/kubernetes.yaml
|
||||
```
|
||||
|
||||
#### 2. Deploy to Kubernetes
|
||||
|
||||
```bash
|
||||
# Apply all configurations
|
||||
kubectl apply -f kubernetes.yaml
|
||||
|
||||
# Check deployment status
|
||||
kubectl get all -n langbot
|
||||
|
||||
# View Pod logs
|
||||
kubectl logs -n langbot -l app=langbot -f
|
||||
```
|
||||
|
||||
#### 3. Access LangBot
|
||||
|
||||
By default, LangBot service uses ClusterIP type, accessible only within the cluster. Choose one of the following methods to access:
|
||||
|
||||
**Option A: Port Forwarding (Recommended for testing)**
|
||||
|
||||
```bash
|
||||
kubectl port-forward -n langbot svc/langbot 5300:5300
|
||||
```
|
||||
|
||||
Then visit http://localhost:5300
|
||||
|
||||
**Option B: NodePort (Suitable for development)**
|
||||
|
||||
Edit `kubernetes.yaml`, uncomment the NodePort Service section, then:
|
||||
|
||||
```bash
|
||||
kubectl apply -f kubernetes.yaml
|
||||
# Get node IP
|
||||
kubectl get nodes -o wide
|
||||
# Visit http://<NODE_IP>:30300
|
||||
```
|
||||
|
||||
**Option C: LoadBalancer (Suitable for cloud environments)**
|
||||
|
||||
Edit `kubernetes.yaml`, uncomment the LoadBalancer Service section, then:
|
||||
|
||||
```bash
|
||||
kubectl apply -f kubernetes.yaml
|
||||
# Get external IP
|
||||
kubectl get svc -n langbot langbot-loadbalancer
|
||||
# Visit http://<EXTERNAL_IP>
|
||||
```
|
||||
|
||||
**Option D: Ingress (Recommended for production)**
|
||||
|
||||
Ensure an Ingress Controller (e.g., nginx-ingress) is installed in the cluster, then:
|
||||
|
||||
1. Edit the Ingress configuration in `kubernetes.yaml`
|
||||
2. Change the domain to your actual domain
|
||||
3. Apply configuration:
|
||||
|
||||
```bash
|
||||
kubectl apply -f kubernetes.yaml
|
||||
# Visit http://langbot.yourdomain.com
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
#### Environment Variables
|
||||
|
||||
Configure environment variables in ConfigMap:
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: langbot-config
|
||||
namespace: langbot
|
||||
data:
|
||||
TZ: "Asia/Shanghai" # Change to your timezone
|
||||
```
|
||||
|
||||
#### Storage Configuration
|
||||
|
||||
Uses dynamic storage provisioning by default. If you have a specific StorageClass, specify it in PVC:
|
||||
|
||||
```yaml
|
||||
spec:
|
||||
storageClassName: your-storage-class-name
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 10Gi
|
||||
```
|
||||
|
||||
#### Resource Limits
|
||||
|
||||
Adjust resource limits based on your needs:
|
||||
|
||||
```yaml
|
||||
resources:
|
||||
requests:
|
||||
memory: "1Gi"
|
||||
cpu: "500m"
|
||||
limits:
|
||||
memory: "4Gi"
|
||||
cpu: "2000m"
|
||||
```
|
||||
|
||||
### Common Operations
|
||||
|
||||
#### View Logs
|
||||
|
||||
```bash
|
||||
# View LangBot main service logs
|
||||
kubectl logs -n langbot -l app=langbot -f
|
||||
|
||||
# View plugin runtime logs
|
||||
kubectl logs -n langbot -l app=langbot-plugin-runtime -f
|
||||
```
|
||||
|
||||
#### Restart Services
|
||||
|
||||
```bash
|
||||
# Restart LangBot
|
||||
kubectl rollout restart deployment/langbot -n langbot
|
||||
|
||||
# Restart plugin runtime
|
||||
kubectl rollout restart deployment/langbot-plugin-runtime -n langbot
|
||||
```
|
||||
|
||||
#### Update Images
|
||||
|
||||
```bash
|
||||
# Update to latest version
|
||||
kubectl set image deployment/langbot -n langbot langbot=rockchin/langbot:latest
|
||||
kubectl set image deployment/langbot-plugin-runtime -n langbot langbot-plugin-runtime=rockchin/langbot:latest
|
||||
|
||||
# Check update status
|
||||
kubectl rollout status deployment/langbot -n langbot
|
||||
```
|
||||
|
||||
#### Scaling (Not Recommended)
|
||||
|
||||
Note: Due to LangBot using ReadWriteOnce persistent storage, multi-replica scaling is not supported. For high availability, consider using ReadWriteMany storage or alternative architectures.
|
||||
|
||||
#### Backup Data
|
||||
|
||||
```bash
|
||||
# Backup PVC data
|
||||
kubectl exec -n langbot -it <langbot-pod-name> -- tar czf /tmp/backup.tar.gz /app/data
|
||||
kubectl cp langbot/<langbot-pod-name>:/tmp/backup.tar.gz ./backup.tar.gz
|
||||
```
|
||||
|
||||
### Uninstall
|
||||
|
||||
```bash
|
||||
# Delete all resources (keep PVCs)
|
||||
kubectl delete deployment,service,configmap -n langbot --all
|
||||
|
||||
# Delete PVCs (will delete data)
|
||||
kubectl delete pvc -n langbot --all
|
||||
|
||||
# Delete namespace
|
||||
kubectl delete namespace langbot
|
||||
```
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
#### Pods Not Starting
|
||||
|
||||
```bash
|
||||
# Check Pod status
|
||||
kubectl get pods -n langbot
|
||||
|
||||
# View detailed information
|
||||
kubectl describe pod -n langbot <pod-name>
|
||||
|
||||
# View events
|
||||
kubectl get events -n langbot --sort-by='.lastTimestamp'
|
||||
```
|
||||
|
||||
#### Storage Issues
|
||||
|
||||
```bash
|
||||
# Check PVC status
|
||||
kubectl get pvc -n langbot
|
||||
|
||||
# Check PV
|
||||
kubectl get pv
|
||||
```
|
||||
|
||||
#### Network Access Issues
|
||||
|
||||
```bash
|
||||
# Check Service
|
||||
kubectl get svc -n langbot
|
||||
|
||||
# Test port forwarding
|
||||
kubectl port-forward -n langbot svc/langbot 5300:5300
|
||||
```
|
||||
|
||||
### Production Recommendations
|
||||
|
||||
1. **Use specific version tags**: Avoid using `latest` tag, use specific version like `rockchin/langbot:v1.0.0`
|
||||
2. **Configure resource limits**: Adjust CPU and memory limits based on actual load
|
||||
3. **Use Ingress + TLS**: Configure HTTPS access and certificate management
|
||||
4. **Configure monitoring and alerts**: Integrate monitoring tools like Prometheus, Grafana
|
||||
5. **Regular backups**: Configure automated backup strategy to protect data
|
||||
6. **Use dedicated StorageClass**: Configure high-performance storage for production
|
||||
7. **Configure affinity rules**: Ensure Pods are scheduled to appropriate nodes
|
||||
|
||||
### Advanced Configuration
|
||||
|
||||
#### Using Secrets for Sensitive Information
|
||||
|
||||
If you need to configure sensitive information like API keys:
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: langbot-secrets
|
||||
namespace: langbot
|
||||
type: Opaque
|
||||
data:
|
||||
api_key: <base64-encoded-value>
|
||||
```
|
||||
|
||||
Then reference in Deployment:
|
||||
|
||||
```yaml
|
||||
env:
|
||||
- name: API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: langbot-secrets
|
||||
key: api_key
|
||||
```
|
||||
|
||||
#### Configure Horizontal Pod Autoscaling (HPA)
|
||||
|
||||
Note: Requires ReadWriteMany storage type
|
||||
|
||||
```yaml
|
||||
apiVersion: autoscaling/v2
|
||||
kind: HorizontalPodAutoscaler
|
||||
metadata:
|
||||
name: langbot-hpa
|
||||
namespace: langbot
|
||||
spec:
|
||||
scaleTargetRef:
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
name: langbot
|
||||
minReplicas: 1
|
||||
maxReplicas: 3
|
||||
metrics:
|
||||
- type: Resource
|
||||
resource:
|
||||
name: cpu
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: 70
|
||||
```
|
||||
|
||||
### References
|
||||
|
||||
- [LangBot Official Documentation](https://docs.langbot.app)
|
||||
- [Docker Deployment Guide](https://link.langbot.app/zh/docs/docker)
|
||||
- [Kubernetes Official Documentation](https://kubernetes.io/docs/)
|
||||
@@ -1,5 +1,5 @@
|
||||
# Docker Compose configuration for LangBot
|
||||
# For Kubernetes deployment, see kubernetes.yaml and README_K8S.md
|
||||
# For Kubernetes deployment, see kubernetes.yaml and the deployment guide at https://docs.langbot.app
|
||||
version: "3"
|
||||
|
||||
services:
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# Kubernetes Deployment for LangBot
|
||||
# This file provides Kubernetes deployment manifests for LangBot based on docker-compose.yaml
|
||||
#
|
||||
#
|
||||
# Full deployment guide (zh/en/ja): https://docs.langbot.app -> Installation -> Kubernetes
|
||||
#
|
||||
# Usage:
|
||||
# kubectl apply -f kubernetes.yaml
|
||||
#
|
||||
@@ -8,13 +10,15 @@
|
||||
# - A Kubernetes cluster (1.19+)
|
||||
# - kubectl configured to communicate with your cluster
|
||||
# - (Optional) A StorageClass for dynamic volume provisioning
|
||||
# - For the Box sandbox runtime: a node with a reachable Docker daemon
|
||||
# (the box mounts the node's /var/run/docker.sock). See the deployment guide.
|
||||
#
|
||||
# Components:
|
||||
# - Namespace: langbot
|
||||
# - PersistentVolumeClaims for data persistence
|
||||
# - Deployments for langbot and langbot_plugin_runtime
|
||||
# - Deployments for langbot, langbot-plugin-runtime, and langbot-box (sandbox)
|
||||
# - Services for network access
|
||||
# - ConfigMap for timezone configuration
|
||||
# - ConfigMap for timezone + runtime endpoints
|
||||
|
||||
---
|
||||
# Namespace
|
||||
@@ -83,6 +87,11 @@ metadata:
|
||||
data:
|
||||
TZ: "Asia/Shanghai"
|
||||
PLUGIN__RUNTIME_WS_URL: "ws://langbot-plugin-runtime:5400/control/ws"
|
||||
# Box sandbox runtime endpoint. LangBot connects to the Box runtime over
|
||||
# WebSocket. The hostname MUST match the langbot-box Service name. Note the
|
||||
# in-container default ("langbot_box") uses an underscore, which is an
|
||||
# invalid Kubernetes DNS name — so the endpoint is always set explicitly here.
|
||||
BOX__RUNTIME__ENDPOINT: "ws://langbot-box:5410"
|
||||
|
||||
---
|
||||
# Deployment for LangBot Plugin Runtime
|
||||
@@ -169,6 +178,136 @@ spec:
|
||||
protocol: TCP
|
||||
name: runtime
|
||||
|
||||
---
|
||||
# Deployment for LangBot Box (sandbox) runtime
|
||||
#
|
||||
# The Box runtime backs LangBot's sandbox tools (exec / read / write / edit /
|
||||
# glob / grep), the `activate` skill tool, skill add/edit, and stdio-mode MCP
|
||||
# servers. It is OPTIONAL: if you do not deploy it, set `BOX__ENABLED=false` on
|
||||
# the langbot Deployment (or `box.enabled: false` in config.yaml) so the
|
||||
# dashboard renders cleanly with sandbox features disabled.
|
||||
#
|
||||
# IMPORTANT — how the sandbox actually runs:
|
||||
# The bundled image ships only the Docker CLI (no dockerd, no nsjail). The Box
|
||||
# runtime therefore creates sandbox containers by talking to a Docker daemon
|
||||
# over the mounted socket (`/var/run/docker.sock`). Because that daemon
|
||||
# resolves bind-mount paths on the NODE filesystem, the Box workspace root
|
||||
# must be the SAME absolute path inside the box container, inside every
|
||||
# sandbox container it spawns, AND on the node. That is why this manifest uses
|
||||
# a hostPath at a fixed absolute path (/app/data/box) and pins langbot + box
|
||||
# to the same node via podAffinity. A normal PVC will NOT work for the box
|
||||
# workspace, because the node's dockerd cannot see paths that exist only
|
||||
# inside the pod's mount namespace.
|
||||
#
|
||||
# Security note: mounting the host Docker socket grants the Box runtime (and any
|
||||
# code executed in the sandbox) effective root on the node. Only deploy Box on
|
||||
# nodes you trust for this workload, ideally a dedicated node pool. For a
|
||||
# stronger isolation boundary, switch box.backend to 'e2b' (set E2B_API_KEY) and
|
||||
# drop the docker.sock mount + hostPath entirely.
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: langbot-box
|
||||
namespace: langbot
|
||||
labels:
|
||||
app: langbot-box
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: langbot-box
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: langbot-box
|
||||
spec:
|
||||
# Pin to the same node as langbot so they share the hostPath box root.
|
||||
affinity:
|
||||
podAffinity:
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
- labelSelector:
|
||||
matchLabels:
|
||||
app: langbot
|
||||
topologyKey: kubernetes.io/hostname
|
||||
containers:
|
||||
- name: langbot-box
|
||||
image: rockchin/langbot:latest
|
||||
imagePullPolicy: Always
|
||||
# Launched through the same CLI entry point as the plugin runtime.
|
||||
# No flag => WebSocket control transport (default), listening on 5410.
|
||||
command: ["uv", "run", "--no-sync", "-m", "langbot_plugin.cli.__init__", "box"]
|
||||
ports:
|
||||
- containerPort: 5410
|
||||
name: box-rpc
|
||||
protocol: TCP
|
||||
env:
|
||||
- name: TZ
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: langbot-config
|
||||
key: TZ
|
||||
# The Box runtime does NOT read box.local.* / BOX__* from its own env;
|
||||
# it receives its configuration from LangBot via the INIT RPC action.
|
||||
# Do not add BOX__* here — they would be silently ignored.
|
||||
volumeMounts:
|
||||
# Box workspace root — identical path on node, box, and sandbox
|
||||
# containers (see the IMPORTANT note above).
|
||||
- name: box-root
|
||||
mountPath: /app/data/box
|
||||
# Host Docker socket — the sandbox backend uses it to create containers.
|
||||
- name: docker-sock
|
||||
mountPath: /var/run/docker.sock
|
||||
resources:
|
||||
requests:
|
||||
memory: "256Mi"
|
||||
cpu: "100m"
|
||||
limits:
|
||||
memory: "1Gi"
|
||||
cpu: "1000m"
|
||||
livenessProbe:
|
||||
tcpSocket:
|
||||
port: 5410
|
||||
initialDelaySeconds: 20
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
readinessProbe:
|
||||
tcpSocket:
|
||||
port: 5410
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 3
|
||||
volumes:
|
||||
- name: box-root
|
||||
hostPath:
|
||||
path: /app/data/box
|
||||
type: DirectoryOrCreate
|
||||
- name: docker-sock
|
||||
hostPath:
|
||||
path: /var/run/docker.sock
|
||||
type: Socket
|
||||
restartPolicy: Always
|
||||
|
||||
---
|
||||
# Service for LangBot Box runtime
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: langbot-box
|
||||
namespace: langbot
|
||||
labels:
|
||||
app: langbot-box
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app: langbot-box
|
||||
ports:
|
||||
- port: 5410
|
||||
targetPort: 5410
|
||||
protocol: TCP
|
||||
name: box-rpc
|
||||
|
||||
---
|
||||
# Deployment for LangBot
|
||||
apiVersion: apps/v1
|
||||
@@ -213,11 +352,36 @@ spec:
|
||||
configMapKeyRef:
|
||||
name: langbot-config
|
||||
key: PLUGIN__RUNTIME_WS_URL
|
||||
# Box (sandbox) runtime endpoint. Connects LangBot to the langbot-box
|
||||
# Service over WebSocket. Remove this (and the langbot-box Deployment)
|
||||
# and set BOX__ENABLED=false if you do not want the sandbox.
|
||||
- name: BOX__RUNTIME__ENDPOINT
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: langbot-config
|
||||
key: BOX__RUNTIME__ENDPOINT
|
||||
# box.local.* config — forwarded to the Box runtime via INIT RPC. The
|
||||
# host_root MUST match the box-root hostPath mountPath below AND the box
|
||||
# Deployment's box-root mountPath, so that skill package paths resolve
|
||||
# identically on both sides and on the node's Docker daemon.
|
||||
- name: BOX__LOCAL__HOST_ROOT
|
||||
value: "/app/data/box"
|
||||
- name: BOX__LOCAL__DEFAULT_WORKSPACE
|
||||
value: "default"
|
||||
- name: BOX__LOCAL__SKILLS_ROOT
|
||||
value: "skills"
|
||||
- name: BOX__LOCAL__ALLOWED_MOUNT_ROOTS
|
||||
value: "/app/data/box"
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /app/data
|
||||
- name: plugins
|
||||
mountPath: /app/plugins
|
||||
# Same node-level box root as the langbot-box Deployment. Mounted over
|
||||
# the data PVC's /app/data/box subpath so both LangBot and the Box
|
||||
# runtime (and the node's dockerd) agree on one absolute path.
|
||||
- name: box-root
|
||||
mountPath: /app/data/box
|
||||
resources:
|
||||
requests:
|
||||
memory: "1Gi"
|
||||
@@ -250,6 +414,13 @@ spec:
|
||||
- name: plugins
|
||||
persistentVolumeClaim:
|
||||
claimName: langbot-plugins
|
||||
# Node-level box workspace root, shared with the langbot-box Deployment.
|
||||
# hostPath (not PVC) because the node's Docker daemon must see the same
|
||||
# absolute path when bind-mounting workspaces into sandbox containers.
|
||||
- name: box-root
|
||||
hostPath:
|
||||
path: /app/data/box
|
||||
type: DirectoryOrCreate
|
||||
restartPolicy: Always
|
||||
|
||||
---
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "langbot"
|
||||
version = "4.10.0"
|
||||
version = "4.10.1"
|
||||
description = "Production-grade platform for building agentic IM bots"
|
||||
readme = "README.md"
|
||||
license-files = ["LICENSE"]
|
||||
@@ -8,7 +8,7 @@ requires-python = ">=3.11,<4.0"
|
||||
dependencies = [
|
||||
"aiocqhttp>=1.4.4",
|
||||
"aiofiles>=24.1.0",
|
||||
"aiohttp>=3.13.4",
|
||||
"aiohttp>=3.14.0",
|
||||
"aioshutil>=1.5",
|
||||
"aiosqlite>=0.21.0",
|
||||
"anthropic>=0.51.0",
|
||||
@@ -31,27 +31,27 @@ dependencies = [
|
||||
"psutil>=7.0.0",
|
||||
"pycryptodome>=3.22.0",
|
||||
"pydantic>2.0",
|
||||
"pyjwt>=2.10.1",
|
||||
"pyjwt>=2.12.0",
|
||||
"python-telegram-bot>=22.0",
|
||||
"pyyaml>=6.0.2",
|
||||
"qq-botpy-rc>=1.2.1.6",
|
||||
"qrcode>=7.4",
|
||||
"quart>=0.20.0",
|
||||
"quart-cors>=0.8.0",
|
||||
"requests>=2.32.3",
|
||||
"requests>=2.33.0",
|
||||
"slack-sdk>=3.35.0",
|
||||
"alembic>=1.15.0",
|
||||
"sqlalchemy[asyncio]>=2.0.40",
|
||||
"sqlmodel>=0.0.24",
|
||||
"telegramify-markdown>=0.5.1",
|
||||
"tiktoken>=0.9.0",
|
||||
"urllib3>=2.4.0",
|
||||
"urllib3>=2.7.0",
|
||||
"websockets>=15.0.1",
|
||||
"python-socks>=2.7.1", # dingtalk missing dependency
|
||||
"pip>=25.1.1",
|
||||
"pip>=26.1",
|
||||
"ruff>=0.11.9",
|
||||
"pre-commit>=4.2.0",
|
||||
"uv>=0.11.6",
|
||||
"uv>=0.11.15",
|
||||
"mypy>=1.16.0",
|
||||
"PyPDF2>=3.0.1",
|
||||
"python-docx>=1.1.0",
|
||||
@@ -62,15 +62,15 @@ dependencies = [
|
||||
"ebooklib>=0.18",
|
||||
"html2text>=2024.2.26",
|
||||
"langchain>=0.2.0",
|
||||
"langchain-core>=1.2.28",
|
||||
"langsmith>=0.7.31",
|
||||
"python-multipart>=0.0.26",
|
||||
"Mako>=1.3.11",
|
||||
"langchain-core>=1.3.3",
|
||||
"langsmith>=0.8.0",
|
||||
"python-multipart>=0.0.27",
|
||||
"Mako>=1.3.12",
|
||||
"langchain-text-splitters>=1.1.2",
|
||||
"chromadb>=1.0.0,<2.0.0",
|
||||
"qdrant-client (>=1.15.1,<2.0.0)",
|
||||
"pyseekdb==1.1.0.post3",
|
||||
"langbot-plugin==0.4.1",
|
||||
"langbot-plugin==0.4.4",
|
||||
"asyncpg>=0.30.0",
|
||||
"line-bot-sdk>=3.19.0",
|
||||
"matrix-nio>=0.25.2",
|
||||
@@ -79,6 +79,7 @@ dependencies = [
|
||||
"pymilvus>=2.6.4",
|
||||
"pgvector>=0.4.1",
|
||||
"botocore>=1.42.39",
|
||||
"litellm>=1.0.0",
|
||||
]
|
||||
keywords = [
|
||||
"bot",
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""LangBot - Production-grade platform for building agentic IM bots"""
|
||||
|
||||
__version__ = '4.10.0'
|
||||
__version__ = '4.10.1'
|
||||
|
||||
5
src/langbot/libs/deerflow_api/__init__.py
Normal file
5
src/langbot/libs/deerflow_api/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from .client import AsyncDeerFlowClient
|
||||
from .errors import DeerFlowAPIError
|
||||
from . import stream_utils
|
||||
|
||||
__all__ = ['AsyncDeerFlowClient', 'DeerFlowAPIError', 'stream_utils']
|
||||
204
src/langbot/libs/deerflow_api/client.py
Normal file
204
src/langbot/libs/deerflow_api/client.py
Normal file
@@ -0,0 +1,204 @@
|
||||
"""DeerFlow LangGraph HTTP API 客户端
|
||||
|
||||
参考 astrbot 的 deerflow_api_client 实现,使用 httpx 适配 LangBot 风格。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import codecs
|
||||
import json
|
||||
import typing
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
import httpx
|
||||
|
||||
from .errors import DeerFlowAPIError
|
||||
|
||||
|
||||
SSE_MAX_BUFFER_CHARS = 1_048_576
|
||||
|
||||
|
||||
def _normalize_sse_newlines(text: str) -> str:
|
||||
"""规范化 CRLF/CR 为 LF,确保 SSE 块分割稳定"""
|
||||
return text.replace('\r\n', '\n').replace('\r', '\n')
|
||||
|
||||
|
||||
def _parse_sse_data_lines(data_lines: list[str]) -> typing.Any:
|
||||
raw_data = '\n'.join(data_lines)
|
||||
try:
|
||||
return json.loads(raw_data)
|
||||
except json.JSONDecodeError:
|
||||
# 某些 LangGraph 兼容服务端会在单个 SSE 事件中用多个 data 行
|
||||
# 发送多段 JSON 片段(例如 tuple payload)
|
||||
parsed_lines: list[typing.Any] = []
|
||||
can_parse_all = True
|
||||
for line in data_lines:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
parsed_lines.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
can_parse_all = False
|
||||
break
|
||||
if can_parse_all and parsed_lines:
|
||||
return parsed_lines[0] if len(parsed_lines) == 1 else parsed_lines
|
||||
return raw_data
|
||||
|
||||
|
||||
def _parse_sse_block(block: str) -> dict[str, typing.Any] | None:
|
||||
if not block.strip():
|
||||
return None
|
||||
|
||||
event_name = 'message'
|
||||
data_lines: list[str] = []
|
||||
for line in block.splitlines():
|
||||
if line.startswith('event:'):
|
||||
event_name = line[6:].strip()
|
||||
elif line.startswith('data:'):
|
||||
data_lines.append(line[5:].lstrip())
|
||||
|
||||
if not data_lines:
|
||||
return None
|
||||
return {'event': event_name, 'data': _parse_sse_data_lines(data_lines)}
|
||||
|
||||
|
||||
class AsyncDeerFlowClient:
|
||||
"""DeerFlow LangGraph HTTP API 客户端"""
|
||||
|
||||
api_base: str
|
||||
headers: dict[str, str]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_base: str = 'http://127.0.0.1:2026',
|
||||
api_key: str = '',
|
||||
auth_header: str = '',
|
||||
) -> None:
|
||||
self.api_base = api_base.rstrip('/')
|
||||
self.headers: dict[str, str] = {}
|
||||
if auth_header:
|
||||
self.headers['Authorization'] = auth_header
|
||||
elif api_key:
|
||||
self.headers['Authorization'] = f'Bearer {api_key}'
|
||||
|
||||
async def create_thread(self, timeout: float = 20) -> dict[str, typing.Any]:
|
||||
"""创建一个新的 LangGraph thread
|
||||
|
||||
Returns:
|
||||
包含 thread_id 等信息的字典
|
||||
"""
|
||||
url = f'{self.api_base}/api/langgraph/threads'
|
||||
payload = {'metadata': {}}
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
trust_env=True,
|
||||
timeout=timeout,
|
||||
) as client:
|
||||
response = await client.post(
|
||||
url,
|
||||
headers=self.headers,
|
||||
json=payload,
|
||||
)
|
||||
if response.status_code not in (200, 201):
|
||||
raise DeerFlowAPIError(
|
||||
operation='create thread',
|
||||
status=response.status_code,
|
||||
body=response.text,
|
||||
url=url,
|
||||
)
|
||||
return response.json()
|
||||
|
||||
async def delete_thread(self, thread_id: str, timeout: float = 20) -> None:
|
||||
"""删除指定 thread"""
|
||||
url = f'{self.api_base}/api/threads/{thread_id}'
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
trust_env=True,
|
||||
timeout=timeout,
|
||||
) as client:
|
||||
response = await client.delete(url, headers=self.headers)
|
||||
if response.status_code not in (200, 202, 204, 404):
|
||||
raise DeerFlowAPIError(
|
||||
operation='delete thread',
|
||||
status=response.status_code,
|
||||
body=response.text,
|
||||
url=url,
|
||||
thread_id=thread_id,
|
||||
)
|
||||
|
||||
async def stream_run(
|
||||
self,
|
||||
thread_id: str,
|
||||
payload: dict[str, typing.Any],
|
||||
timeout: float = 120,
|
||||
) -> AsyncGenerator[dict[str, typing.Any], None]:
|
||||
"""运行一次 LangGraph stream 请求,逐事件 yield
|
||||
|
||||
Yields:
|
||||
事件字典 {'event': event_name, 'data': parsed_data}
|
||||
"""
|
||||
url = f'{self.api_base}/api/langgraph/threads/{thread_id}/runs/stream'
|
||||
|
||||
# 流式请求使用单独的 read timeout 控制
|
||||
stream_timeout = httpx.Timeout(
|
||||
connect=min(timeout, 30),
|
||||
read=timeout,
|
||||
write=timeout,
|
||||
pool=timeout,
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
trust_env=True,
|
||||
timeout=stream_timeout,
|
||||
) as client:
|
||||
async with client.stream(
|
||||
'POST',
|
||||
url,
|
||||
headers={
|
||||
**self.headers,
|
||||
'Accept': 'text/event-stream',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
json=payload,
|
||||
) as resp:
|
||||
if resp.status_code != 200:
|
||||
body = await resp.aread()
|
||||
raise DeerFlowAPIError(
|
||||
operation='runs/stream request',
|
||||
status=resp.status_code,
|
||||
body=body.decode('utf-8', errors='replace'),
|
||||
url=url,
|
||||
thread_id=thread_id,
|
||||
)
|
||||
|
||||
decoder = codecs.getincrementaldecoder('utf-8')('replace')
|
||||
buffer = ''
|
||||
|
||||
async for chunk in resp.aiter_bytes(8192):
|
||||
buffer += _normalize_sse_newlines(decoder.decode(chunk))
|
||||
|
||||
while '\n\n' in buffer:
|
||||
block, buffer = buffer.split('\n\n', 1)
|
||||
parsed = _parse_sse_block(block)
|
||||
if parsed is not None:
|
||||
yield parsed
|
||||
|
||||
if len(buffer) > SSE_MAX_BUFFER_CHARS:
|
||||
# 缓冲区过大,强制 flush
|
||||
parsed = _parse_sse_block(buffer)
|
||||
if parsed is not None:
|
||||
yield parsed
|
||||
buffer = ''
|
||||
|
||||
# flush 剩余内容
|
||||
buffer += _normalize_sse_newlines(decoder.decode(b'', final=True))
|
||||
while '\n\n' in buffer:
|
||||
block, buffer = buffer.split('\n\n', 1)
|
||||
parsed = _parse_sse_block(block)
|
||||
if parsed is not None:
|
||||
yield parsed
|
||||
if buffer.strip():
|
||||
parsed = _parse_sse_block(buffer)
|
||||
if parsed is not None:
|
||||
yield parsed
|
||||
30
src/langbot/libs/deerflow_api/errors.py
Normal file
30
src/langbot/libs/deerflow_api/errors.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class DeerFlowAPIError(Exception):
|
||||
"""DeerFlow API 请求失败"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
operation: str = '',
|
||||
status: int = 0,
|
||||
body: str = '',
|
||||
url: str = '',
|
||||
thread_id: str | None = None,
|
||||
message: str = '',
|
||||
) -> None:
|
||||
self.operation = operation
|
||||
self.status = status
|
||||
self.body = body
|
||||
self.url = url
|
||||
self.thread_id = thread_id
|
||||
|
||||
if message:
|
||||
super().__init__(message)
|
||||
return
|
||||
|
||||
msg = f'DeerFlow {operation} failed: status={status}, url={url}, body={body}'
|
||||
if thread_id is not None:
|
||||
msg = f'DeerFlow {operation} failed: thread_id={thread_id}, status={status}, url={url}, body={body}'
|
||||
super().__init__(msg)
|
||||
212
src/langbot/libs/deerflow_api/stream_utils.py
Normal file
212
src/langbot/libs/deerflow_api/stream_utils.py
Normal file
@@ -0,0 +1,212 @@
|
||||
"""DeerFlow LangGraph 流式响应解析工具
|
||||
|
||||
参考 astrbot 实现的 deerflow_stream_utils。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
from collections.abc import Iterable
|
||||
|
||||
|
||||
def extract_text(content: typing.Any) -> str:
|
||||
"""从消息 content 中提取纯文本"""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, dict):
|
||||
if isinstance(content.get('text'), str):
|
||||
return content['text']
|
||||
if 'content' in content:
|
||||
return extract_text(content.get('content'))
|
||||
if 'kwargs' in content and isinstance(content['kwargs'], dict):
|
||||
return extract_text(content['kwargs'].get('content'))
|
||||
if isinstance(content, list):
|
||||
parts: list[str] = []
|
||||
for item in content:
|
||||
if isinstance(item, str):
|
||||
parts.append(item)
|
||||
elif isinstance(item, dict):
|
||||
item_type = item.get('type')
|
||||
if item_type == 'text' and isinstance(item.get('text'), str):
|
||||
parts.append(item['text'])
|
||||
elif 'content' in item:
|
||||
parts.append(extract_text(item['content']))
|
||||
return '\n'.join([p for p in parts if p]).strip()
|
||||
return str(content) if content is not None else ''
|
||||
|
||||
|
||||
def extract_messages_from_values_data(data: typing.Any) -> list[typing.Any]:
|
||||
"""从 values 事件中提取 messages 列表"""
|
||||
candidates: list[typing.Any] = []
|
||||
if isinstance(data, dict):
|
||||
candidates.append(data)
|
||||
if isinstance(data.get('values'), dict):
|
||||
candidates.append(data['values'])
|
||||
elif isinstance(data, list):
|
||||
candidates.extend([x for x in data if isinstance(x, dict)])
|
||||
|
||||
for item in candidates:
|
||||
messages = item.get('messages')
|
||||
if isinstance(messages, list):
|
||||
return messages
|
||||
return []
|
||||
|
||||
|
||||
def is_ai_message(message: dict[str, typing.Any]) -> bool:
|
||||
"""判断是否为 AI/assistant 消息"""
|
||||
role = str(message.get('role', '')).lower()
|
||||
if role in {'assistant', 'ai'}:
|
||||
return True
|
||||
|
||||
msg_type = str(message.get('type', '')).lower()
|
||||
if msg_type in {'ai', 'assistant', 'aimessage', 'aimessagechunk'}:
|
||||
return True
|
||||
if 'ai' in msg_type and all(token not in msg_type for token in ('human', 'tool', 'system')):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def extract_latest_ai_text(messages: Iterable[typing.Any]) -> str:
|
||||
"""获取最近一条 AI 消息的文本内容"""
|
||||
if isinstance(messages, (list, tuple)):
|
||||
iterable = reversed(messages)
|
||||
else:
|
||||
iterable = reversed(list(messages))
|
||||
|
||||
for msg in iterable:
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
if is_ai_message(msg):
|
||||
text = extract_text(msg.get('content'))
|
||||
if text:
|
||||
return text
|
||||
return ''
|
||||
|
||||
|
||||
def extract_latest_ai_message(messages: Iterable[typing.Any]) -> dict[str, typing.Any] | None:
|
||||
"""获取最近一条 AI 消息对象"""
|
||||
if isinstance(messages, (list, tuple)):
|
||||
iterable = reversed(messages)
|
||||
else:
|
||||
iterable = reversed(list(messages))
|
||||
|
||||
for msg in iterable:
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
if is_ai_message(msg):
|
||||
return msg
|
||||
return None
|
||||
|
||||
|
||||
def is_clarification_tool_message(message: dict[str, typing.Any]) -> bool:
|
||||
"""判断是否为澄清问题工具消息"""
|
||||
msg_type = str(message.get('type', '')).lower()
|
||||
tool_name = str(message.get('name', '')).lower()
|
||||
return msg_type == 'tool' and tool_name == 'ask_clarification'
|
||||
|
||||
|
||||
def extract_latest_clarification_text(messages: Iterable[typing.Any]) -> str:
|
||||
"""提取最近的澄清问题文本"""
|
||||
if isinstance(messages, (list, tuple)):
|
||||
iterable = reversed(messages)
|
||||
else:
|
||||
iterable = reversed(list(messages))
|
||||
|
||||
for msg in iterable:
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
if is_clarification_tool_message(msg):
|
||||
text = extract_text(msg.get('content'))
|
||||
if text:
|
||||
return text
|
||||
return ''
|
||||
|
||||
|
||||
def get_message_id(message: typing.Any) -> str:
|
||||
"""提取消息 ID"""
|
||||
if not isinstance(message, dict):
|
||||
return ''
|
||||
msg_id = message.get('id')
|
||||
return msg_id if isinstance(msg_id, str) else ''
|
||||
|
||||
|
||||
def extract_event_message_obj(data: typing.Any) -> dict[str, typing.Any] | None:
|
||||
"""从事件 data 中提取消息对象"""
|
||||
msg_obj = data
|
||||
if isinstance(data, (list, tuple)) and data:
|
||||
msg_obj = data[0]
|
||||
if isinstance(msg_obj, dict) and isinstance(msg_obj.get('data'), dict):
|
||||
msg_obj = msg_obj['data']
|
||||
return msg_obj if isinstance(msg_obj, dict) else None
|
||||
|
||||
|
||||
def extract_ai_delta_from_event_data(data: typing.Any) -> str:
|
||||
"""从 messages-tuple 事件中提取 AI delta 文本"""
|
||||
msg_obj = extract_event_message_obj(data)
|
||||
if not msg_obj:
|
||||
return ''
|
||||
if is_ai_message(msg_obj):
|
||||
return extract_text(msg_obj.get('content'))
|
||||
return ''
|
||||
|
||||
|
||||
def extract_clarification_from_event_data(data: typing.Any) -> str:
|
||||
"""从事件中提取澄清问题"""
|
||||
msg_obj = extract_event_message_obj(data)
|
||||
if not msg_obj:
|
||||
return ''
|
||||
if is_clarification_tool_message(msg_obj):
|
||||
return extract_text(msg_obj.get('content'))
|
||||
return ''
|
||||
|
||||
|
||||
def _iter_custom_event_items(data: typing.Any) -> list[dict[str, typing.Any]]:
|
||||
items: list[dict[str, typing.Any]] = []
|
||||
if isinstance(data, dict):
|
||||
return [data]
|
||||
if isinstance(data, list):
|
||||
for item in data:
|
||||
if isinstance(item, dict):
|
||||
items.append(item)
|
||||
elif isinstance(item, (list, tuple)):
|
||||
for nested in item:
|
||||
if isinstance(nested, dict):
|
||||
items.append(nested)
|
||||
return items
|
||||
|
||||
|
||||
def extract_task_failures_from_custom_event(data: typing.Any) -> list[str]:
|
||||
"""从 custom 事件中提取子任务失败信息"""
|
||||
failures: list[str] = []
|
||||
for item in _iter_custom_event_items(data):
|
||||
event_type = str(item.get('type', '')).lower()
|
||||
if event_type not in {'task_failed', 'task_timed_out'}:
|
||||
continue
|
||||
|
||||
task_id = str(item.get('task_id', '')).strip()
|
||||
error_text = extract_text(item.get('error')).strip()
|
||||
if task_id and error_text:
|
||||
failures.append(f'{task_id}: {error_text}')
|
||||
elif error_text:
|
||||
failures.append(error_text)
|
||||
elif task_id:
|
||||
failures.append(f'{task_id}: unknown error')
|
||||
else:
|
||||
failures.append('unknown task failure')
|
||||
return failures
|
||||
|
||||
|
||||
def build_task_failure_summary(failures: list[str]) -> str:
|
||||
"""构建任务失败摘要"""
|
||||
if not failures:
|
||||
return ''
|
||||
deduped: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for failure in failures:
|
||||
if failure not in seen:
|
||||
seen.add(failure)
|
||||
deduped.append(failure)
|
||||
if len(deduped) == 1:
|
||||
return f'DeerFlow subtask failed: {deduped[0]}'
|
||||
joined = '\n'.join([f'- {item}' for item in deduped[:5]])
|
||||
return f'DeerFlow subtasks failed:\n{joined}'
|
||||
@@ -145,7 +145,7 @@ class AsyncDifyServiceClient:
|
||||
'file': file,
|
||||
},
|
||||
data={
|
||||
'user': (None, user),
|
||||
'user': user,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
4
src/langbot/libs/weknora_api/__init__.py
Normal file
4
src/langbot/libs/weknora_api/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from .client import AsyncWeKnoraClient
|
||||
from .errors import WeKnoraAPIError
|
||||
|
||||
__all__ = ['AsyncWeKnoraClient', 'WeKnoraAPIError']
|
||||
180
src/langbot/libs/weknora_api/client.py
Normal file
180
src/langbot/libs/weknora_api/client.py
Normal file
@@ -0,0 +1,180 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
import typing
|
||||
import json
|
||||
|
||||
from .errors import WeKnoraAPIError
|
||||
|
||||
|
||||
class AsyncWeKnoraClient:
|
||||
"""WeKnora API 客户端"""
|
||||
|
||||
api_key: str
|
||||
base_url: str
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str,
|
||||
base_url: str = 'http://localhost:80/api/v1',
|
||||
) -> None:
|
||||
self.api_key = api_key
|
||||
self.base_url = base_url
|
||||
|
||||
async def create_session(
|
||||
self,
|
||||
title: str = '',
|
||||
description: str = '',
|
||||
timeout: float = 30.0,
|
||||
) -> str:
|
||||
"""创建会话,返回 session_id"""
|
||||
async with httpx.AsyncClient(
|
||||
base_url=self.base_url,
|
||||
trust_env=True,
|
||||
timeout=timeout,
|
||||
) as client:
|
||||
payload: dict[str, typing.Any] = {}
|
||||
if title:
|
||||
payload['title'] = title
|
||||
if description:
|
||||
payload['description'] = description
|
||||
|
||||
response = await client.post(
|
||||
'/sessions',
|
||||
headers={
|
||||
'X-API-Key': self.api_key,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
json=payload,
|
||||
)
|
||||
|
||||
if response.status_code not in (200, 201):
|
||||
raise WeKnoraAPIError(f'{response.status_code} {response.text}')
|
||||
|
||||
data = response.json()
|
||||
return data['data']['id']
|
||||
|
||||
async def agent_chat(
|
||||
self,
|
||||
session_id: str,
|
||||
query: str,
|
||||
user: str,
|
||||
agent_id: str = '',
|
||||
knowledge_base_ids: list[str] | None = None,
|
||||
web_search_enabled: bool = False,
|
||||
timeout: float = 120.0,
|
||||
) -> typing.AsyncGenerator[dict[str, typing.Any], None]:
|
||||
"""
|
||||
Agent 智能对话(SSE 流式)
|
||||
|
||||
响应事件类型:
|
||||
- agent_query: Agent 开始处理
|
||||
- thinking: 思考过程
|
||||
- tool_call: 工具调用
|
||||
- tool_result: 工具结果
|
||||
- references: 知识库引用
|
||||
- answer: 回答内容
|
||||
- reflection: 反思
|
||||
- session_title: 会话标题
|
||||
- error: 错误
|
||||
"""
|
||||
if knowledge_base_ids is None:
|
||||
knowledge_base_ids = []
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
base_url=self.base_url,
|
||||
trust_env=True,
|
||||
timeout=timeout,
|
||||
) as client:
|
||||
payload: dict[str, typing.Any] = {
|
||||
'query': query,
|
||||
'agent_enabled': True,
|
||||
'channel': 'im',
|
||||
}
|
||||
if agent_id:
|
||||
payload['agent_id'] = agent_id
|
||||
if knowledge_base_ids:
|
||||
payload['knowledge_base_ids'] = knowledge_base_ids
|
||||
if web_search_enabled:
|
||||
payload['web_search_enabled'] = True
|
||||
|
||||
async with client.stream(
|
||||
'POST',
|
||||
f'/agent-chat/{session_id}',
|
||||
headers={
|
||||
'X-API-Key': self.api_key,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
json=payload,
|
||||
) as r:
|
||||
async for chunk in r.aiter_lines():
|
||||
if r.status_code != 200:
|
||||
raise WeKnoraAPIError(f'{r.status_code} {chunk}')
|
||||
if chunk.strip() == '':
|
||||
continue
|
||||
if chunk.startswith('data:'):
|
||||
try:
|
||||
data = json.loads(chunk[5:].strip())
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
yield data
|
||||
# 收到 error 事件后主动结束流,避免上层未 raise 时持续等待
|
||||
if data.get('response_type') == 'error':
|
||||
return
|
||||
|
||||
async def knowledge_chat(
|
||||
self,
|
||||
session_id: str,
|
||||
query: str,
|
||||
user: str,
|
||||
agent_id: str = 'builtin-quick-answer',
|
||||
knowledge_base_ids: list[str] | None = None,
|
||||
timeout: float = 120.0,
|
||||
) -> typing.AsyncGenerator[dict[str, typing.Any], None]:
|
||||
"""
|
||||
知识库 RAG 问答(SSE 流式)
|
||||
|
||||
响应事件类型:
|
||||
- references: 知识库引用
|
||||
- answer: 回答内容
|
||||
"""
|
||||
if knowledge_base_ids is None:
|
||||
knowledge_base_ids = []
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
base_url=self.base_url,
|
||||
trust_env=True,
|
||||
timeout=timeout,
|
||||
) as client:
|
||||
payload: dict[str, typing.Any] = {
|
||||
'query': query,
|
||||
'channel': 'im',
|
||||
}
|
||||
if agent_id:
|
||||
payload['agent_id'] = agent_id
|
||||
if knowledge_base_ids:
|
||||
payload['knowledge_base_ids'] = knowledge_base_ids
|
||||
|
||||
async with client.stream(
|
||||
'POST',
|
||||
f'/knowledge-chat/{session_id}',
|
||||
headers={
|
||||
'X-API-Key': self.api_key,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
json=payload,
|
||||
) as r:
|
||||
async for chunk in r.aiter_lines():
|
||||
if r.status_code != 200:
|
||||
raise WeKnoraAPIError(f'{r.status_code} {chunk}')
|
||||
if chunk.strip() == '':
|
||||
continue
|
||||
if chunk.startswith('data:'):
|
||||
try:
|
||||
data = json.loads(chunk[5:].strip())
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
yield data
|
||||
# 收到 error 事件后主动结束流,避免上层未 raise 时持续等待
|
||||
if data.get('response_type') == 'error':
|
||||
return
|
||||
6
src/langbot/libs/weknora_api/errors.py
Normal file
6
src/langbot/libs/weknora_api/errors.py
Normal file
@@ -0,0 +1,6 @@
|
||||
class WeKnoraAPIError(Exception):
|
||||
"""WeKnora API 请求失败"""
|
||||
|
||||
def __init__(self, message: str = ''):
|
||||
self.message = message
|
||||
super().__init__(self.message)
|
||||
@@ -46,6 +46,30 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
|
||||
return self.success(data=metrics)
|
||||
|
||||
@self.route('/token-statistics', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def get_token_statistics() -> str:
|
||||
"""Get detailed token usage statistics (summary, per-model, timeseries)."""
|
||||
bot_ids = quart.request.args.getlist('botId')
|
||||
pipeline_ids = quart.request.args.getlist('pipelineId')
|
||||
start_time_str = quart.request.args.get('startTime')
|
||||
end_time_str = quart.request.args.get('endTime')
|
||||
bucket = quart.request.args.get('bucket', 'hour')
|
||||
if bucket not in ('hour', 'day'):
|
||||
bucket = 'hour'
|
||||
|
||||
start_time = parse_iso_datetime(start_time_str)
|
||||
end_time = parse_iso_datetime(end_time_str)
|
||||
|
||||
stats = await self.ap.monitoring_service.get_token_statistics(
|
||||
bot_ids=bot_ids if bot_ids else None,
|
||||
pipeline_ids=pipeline_ids if pipeline_ids else None,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
bucket=bucket,
|
||||
)
|
||||
|
||||
return self.success(data=stats)
|
||||
|
||||
@self.route('/messages', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def get_messages() -> str:
|
||||
"""Get message logs"""
|
||||
|
||||
@@ -271,6 +271,20 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
readme = await self.ap.plugin_connector.get_plugin_readme(author, plugin_name, language=language)
|
||||
return self.success(data={'readme': readme})
|
||||
|
||||
@self.route(
|
||||
'/<author>/<plugin_name>/logs',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
)
|
||||
async def _(author: str, plugin_name: str) -> quart.Response:
|
||||
try:
|
||||
limit = int(quart.request.args.get('limit', 200))
|
||||
except (TypeError, ValueError):
|
||||
limit = 200
|
||||
level = quart.request.args.get('level') or None
|
||||
logs = await self.ap.plugin_connector.get_plugin_logs(author, plugin_name, limit=limit, level=level)
|
||||
return self.success(data={'logs': logs})
|
||||
|
||||
@self.route(
|
||||
'/<author>/<plugin_name>/icon',
|
||||
methods=['GET'],
|
||||
|
||||
@@ -31,6 +31,18 @@ class SystemRouterGroup(group.RouterGroup):
|
||||
except Exception:
|
||||
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(
|
||||
data={
|
||||
'version': constants.semantic_version,
|
||||
@@ -49,6 +61,7 @@ class SystemRouterGroup(group.RouterGroup):
|
||||
'disable_models_service', False
|
||||
),
|
||||
'limitation': self.ap.instance_config.data.get('system', {}).get('limitation', {}),
|
||||
'outbound_ips': outbound_ips,
|
||||
'wizard_status': wizard_status,
|
||||
'wizard_progress': wizard_progress,
|
||||
}
|
||||
|
||||
@@ -152,7 +152,24 @@ class MCPService:
|
||||
coroutine = runtime_mcp_session.refresh()
|
||||
else:
|
||||
runtime_mcp_session = await self.ap.tool_mgr.mcp_tool_loader.load_mcp_server(server_config=server_data)
|
||||
coroutine = runtime_mcp_session.start()
|
||||
|
||||
# A transient test owns an isolated Box session. Always tear it down
|
||||
# after the test completes (success or failure) so it does not leak.
|
||||
test_session = runtime_mcp_session
|
||||
|
||||
async def _run_and_cleanup() -> None:
|
||||
try:
|
||||
await test_session.start()
|
||||
finally:
|
||||
try:
|
||||
await test_session.shutdown()
|
||||
except Exception as exc:
|
||||
self.ap.logger.warning(
|
||||
f'Failed to tear down transient MCP test session '
|
||||
f'{test_session.server_name}: {type(exc).__name__}: {exc}'
|
||||
)
|
||||
|
||||
coroutine = _run_and_cleanup()
|
||||
|
||||
ctx = taskmgr.TaskContext.new()
|
||||
wrapper = self.ap.task_mgr.create_user_task(
|
||||
|
||||
@@ -34,6 +34,46 @@ def _runtime_model_data(model_uuid: str, model_data: dict) -> dict:
|
||||
return {**model_data, 'uuid': model_uuid}
|
||||
|
||||
|
||||
async def _validate_provider_supports(ap: app.Application, provider_uuid: str, model_type: str) -> None:
|
||||
"""Validate that the provider's requester declares support for ``model_type``.
|
||||
|
||||
``model_type`` is one of the manifest ``support_type`` values:
|
||||
'llm', 'text-embedding', 'rerank'. Raises ValueError when the requester
|
||||
manifest does not list the requested type. This is a server-side guard so
|
||||
a model cannot be attached to a provider that does not support it, even if
|
||||
the frontend tab restriction is bypassed.
|
||||
"""
|
||||
model_mgr = getattr(ap, 'model_mgr', None)
|
||||
if model_mgr is None:
|
||||
return
|
||||
|
||||
provider_dict = getattr(model_mgr, 'provider_dict', None)
|
||||
if not provider_dict:
|
||||
return
|
||||
runtime_provider = provider_dict.get(provider_uuid)
|
||||
if runtime_provider is None:
|
||||
return
|
||||
|
||||
requester_name = getattr(getattr(runtime_provider, 'provider_entity', None), 'requester', None)
|
||||
if not requester_name:
|
||||
return
|
||||
|
||||
get_manifest = getattr(model_mgr, 'get_available_requester_manifest_by_name', None)
|
||||
if not callable(get_manifest):
|
||||
return
|
||||
manifest = get_manifest(requester_name)
|
||||
if manifest is None:
|
||||
return
|
||||
|
||||
spec = getattr(manifest, 'spec', None) or {}
|
||||
support_type = spec.get('support_type') if isinstance(spec, dict) else None
|
||||
# When a manifest omits support_type, do not block (backward compatible).
|
||||
if not support_type:
|
||||
return
|
||||
if model_type not in support_type:
|
||||
raise ValueError(f'Provider requester "{requester_name}" does not support {model_type} models')
|
||||
|
||||
|
||||
class LLMModelsService:
|
||||
ap: app.Application
|
||||
|
||||
@@ -96,6 +136,8 @@ class LLMModelsService:
|
||||
)
|
||||
model_data['provider_uuid'] = provider_uuid
|
||||
|
||||
await _validate_provider_supports(self.ap, model_data['provider_uuid'], 'llm')
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_model.LLMModel).values(**model_data))
|
||||
|
||||
runtime_provider = self.ap.model_mgr.provider_dict.get(model_data['provider_uuid'])
|
||||
@@ -274,6 +316,8 @@ class EmbeddingModelsService:
|
||||
)
|
||||
model_data['provider_uuid'] = provider_uuid
|
||||
|
||||
await _validate_provider_supports(self.ap, model_data['provider_uuid'], 'text-embedding')
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.insert(persistence_model.EmbeddingModel).values(**model_data)
|
||||
)
|
||||
@@ -434,6 +478,8 @@ class RerankModelsService:
|
||||
)
|
||||
model_data['provider_uuid'] = provider_uuid
|
||||
|
||||
await _validate_provider_supports(self.ap, model_data['provider_uuid'], 'rerank')
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.insert(persistence_model.RerankModel).values(**model_data)
|
||||
)
|
||||
|
||||
@@ -472,6 +472,179 @@ class MonitoringService:
|
||||
'active_sessions': active_sessions,
|
||||
}
|
||||
|
||||
async def get_token_statistics(
|
||||
self,
|
||||
bot_ids: list[str] | None = None,
|
||||
pipeline_ids: list[str] | None = None,
|
||||
start_time: datetime.datetime | None = None,
|
||||
end_time: datetime.datetime | None = None,
|
||||
bucket: str = 'hour',
|
||||
) -> dict:
|
||||
"""Get detailed token usage statistics for production observability.
|
||||
|
||||
Returns:
|
||||
- summary: aggregate token counters and call/latency stats over the window
|
||||
- by_model: per-model token + call breakdown (sorted by total tokens desc)
|
||||
- timeseries: token usage bucketed by `bucket` ('hour' or 'day')
|
||||
|
||||
Only successful LLM calls are counted toward token totals; error calls are
|
||||
reported separately so a spike in failures is visible without polluting
|
||||
token accounting.
|
||||
"""
|
||||
LLMCall = persistence_monitoring.MonitoringLLMCall
|
||||
|
||||
conditions = []
|
||||
if bot_ids:
|
||||
conditions.append(LLMCall.bot_id.in_(bot_ids))
|
||||
if pipeline_ids:
|
||||
conditions.append(LLMCall.pipeline_id.in_(pipeline_ids))
|
||||
if start_time:
|
||||
conditions.append(LLMCall.timestamp >= start_time)
|
||||
if end_time:
|
||||
conditions.append(LLMCall.timestamp <= end_time)
|
||||
|
||||
def _apply(query):
|
||||
if conditions:
|
||||
query = query.where(sqlalchemy.and_(*conditions))
|
||||
return query
|
||||
|
||||
# ---- Summary aggregates ----
|
||||
summary_query = _apply(
|
||||
sqlalchemy.select(
|
||||
sqlalchemy.func.count(LLMCall.id),
|
||||
sqlalchemy.func.coalesce(sqlalchemy.func.sum(LLMCall.input_tokens), 0),
|
||||
sqlalchemy.func.coalesce(sqlalchemy.func.sum(LLMCall.output_tokens), 0),
|
||||
sqlalchemy.func.coalesce(sqlalchemy.func.sum(LLMCall.total_tokens), 0),
|
||||
sqlalchemy.func.coalesce(sqlalchemy.func.sum(LLMCall.duration), 0),
|
||||
sqlalchemy.func.coalesce(sqlalchemy.func.sum(LLMCall.cost), 0.0),
|
||||
sqlalchemy.func.sum(sqlalchemy.case((LLMCall.status == 'success', 1), else_=0)),
|
||||
sqlalchemy.func.sum(sqlalchemy.case((LLMCall.status == 'error', 1), else_=0)),
|
||||
# Count of successful calls that nonetheless recorded zero tokens —
|
||||
# a data-quality signal that usage reporting may be broken upstream.
|
||||
sqlalchemy.func.sum(
|
||||
sqlalchemy.case(
|
||||
(sqlalchemy.and_(LLMCall.status == 'success', LLMCall.total_tokens == 0), 1),
|
||||
else_=0,
|
||||
)
|
||||
),
|
||||
)
|
||||
)
|
||||
summary_result = await self.ap.persistence_mgr.execute_async(summary_query)
|
||||
row = summary_result.first()
|
||||
(
|
||||
total_calls,
|
||||
total_input_tokens,
|
||||
total_output_tokens,
|
||||
total_tokens,
|
||||
total_duration,
|
||||
total_cost,
|
||||
success_calls,
|
||||
error_calls,
|
||||
zero_token_success_calls,
|
||||
) = row if row else (0, 0, 0, 0, 0, 0.0, 0, 0, 0)
|
||||
|
||||
total_calls = total_calls or 0
|
||||
success_calls = success_calls or 0
|
||||
error_calls = error_calls or 0
|
||||
zero_token_success_calls = zero_token_success_calls or 0
|
||||
|
||||
summary = {
|
||||
'total_calls': total_calls,
|
||||
'success_calls': success_calls,
|
||||
'error_calls': error_calls,
|
||||
'total_input_tokens': int(total_input_tokens or 0),
|
||||
'total_output_tokens': int(total_output_tokens or 0),
|
||||
'total_tokens': int(total_tokens or 0),
|
||||
'total_cost': round(float(total_cost or 0.0), 6),
|
||||
'avg_tokens_per_call': int((total_tokens or 0) / total_calls) if total_calls > 0 else 0,
|
||||
'avg_duration_ms': int((total_duration or 0) / total_calls) if total_calls > 0 else 0,
|
||||
'avg_tokens_per_second': round((total_output_tokens or 0) / (total_duration / 1000), 2)
|
||||
if total_duration and total_duration > 0
|
||||
else 0,
|
||||
'zero_token_success_calls': zero_token_success_calls,
|
||||
}
|
||||
|
||||
# ---- Per-model breakdown ----
|
||||
by_model_query = _apply(
|
||||
sqlalchemy.select(
|
||||
LLMCall.model_name,
|
||||
sqlalchemy.func.count(LLMCall.id),
|
||||
sqlalchemy.func.coalesce(sqlalchemy.func.sum(LLMCall.input_tokens), 0),
|
||||
sqlalchemy.func.coalesce(sqlalchemy.func.sum(LLMCall.output_tokens), 0),
|
||||
sqlalchemy.func.coalesce(sqlalchemy.func.sum(LLMCall.total_tokens), 0),
|
||||
sqlalchemy.func.coalesce(sqlalchemy.func.sum(LLMCall.duration), 0),
|
||||
sqlalchemy.func.coalesce(sqlalchemy.func.sum(LLMCall.cost), 0.0),
|
||||
sqlalchemy.func.sum(sqlalchemy.case((LLMCall.status == 'error', 1), else_=0)),
|
||||
).group_by(LLMCall.model_name)
|
||||
)
|
||||
by_model_result = await self.ap.persistence_mgr.execute_async(by_model_query)
|
||||
by_model = []
|
||||
for mrow in by_model_result.all():
|
||||
(
|
||||
model_name,
|
||||
m_calls,
|
||||
m_in,
|
||||
m_out,
|
||||
m_total,
|
||||
m_duration,
|
||||
m_cost,
|
||||
m_errors,
|
||||
) = mrow
|
||||
m_calls = m_calls or 0
|
||||
by_model.append(
|
||||
{
|
||||
'model_name': model_name,
|
||||
'calls': m_calls,
|
||||
'error_calls': m_errors or 0,
|
||||
'input_tokens': int(m_in or 0),
|
||||
'output_tokens': int(m_out or 0),
|
||||
'total_tokens': int(m_total or 0),
|
||||
'cost': round(float(m_cost or 0.0), 6),
|
||||
'avg_tokens_per_call': int((m_total or 0) / m_calls) if m_calls > 0 else 0,
|
||||
'avg_duration_ms': int((m_duration or 0) / m_calls) if m_calls > 0 else 0,
|
||||
}
|
||||
)
|
||||
by_model.sort(key=lambda x: x['total_tokens'], reverse=True)
|
||||
|
||||
# ---- Time-bucketed series ----
|
||||
# Use a DB-agnostic bucketing approach: fetch (timestamp, tokens) rows and
|
||||
# aggregate in Python. The window is bounded by the time filter, so this is
|
||||
# cheap for typical dashboard ranges (hours/days).
|
||||
series_query = _apply(
|
||||
sqlalchemy.select(
|
||||
LLMCall.timestamp,
|
||||
LLMCall.input_tokens,
|
||||
LLMCall.output_tokens,
|
||||
LLMCall.total_tokens,
|
||||
).order_by(LLMCall.timestamp.asc())
|
||||
)
|
||||
series_result = await self.ap.persistence_mgr.execute_async(series_query)
|
||||
|
||||
bucket_fmt = '%Y-%m-%d %H:00' if bucket == 'hour' else '%Y-%m-%d'
|
||||
buckets: dict[str, dict] = {}
|
||||
for srow in series_result.all():
|
||||
ts, s_in, s_out, s_total = srow
|
||||
if ts is None:
|
||||
continue
|
||||
key = ts.strftime(bucket_fmt)
|
||||
b = buckets.setdefault(
|
||||
key,
|
||||
{'bucket': key, 'input_tokens': 0, 'output_tokens': 0, 'total_tokens': 0, 'calls': 0},
|
||||
)
|
||||
b['input_tokens'] += int(s_in or 0)
|
||||
b['output_tokens'] += int(s_out or 0)
|
||||
b['total_tokens'] += int(s_total or 0)
|
||||
b['calls'] += 1
|
||||
|
||||
timeseries = [buckets[k] for k in sorted(buckets.keys())]
|
||||
|
||||
return {
|
||||
'summary': summary,
|
||||
'by_model': by_model,
|
||||
'timeseries': timeseries,
|
||||
'bucket': bucket,
|
||||
}
|
||||
|
||||
async def get_messages(
|
||||
self,
|
||||
bot_ids: list[str] | None = None,
|
||||
|
||||
@@ -82,7 +82,7 @@ class UserService:
|
||||
payload = {
|
||||
'user': user_email,
|
||||
'iss': 'LangBot-' + constants.edition,
|
||||
'exp': datetime.datetime.now() + datetime.timedelta(seconds=jwt_expire),
|
||||
'exp': datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(seconds=jwt_expire),
|
||||
}
|
||||
|
||||
return jwt.encode(payload, jwt_secret, algorithm='HS256')
|
||||
|
||||
@@ -120,13 +120,19 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
|
||||
self._relay_port = parsed.port or _DEFAULT_PORT
|
||||
self._filtered_box_config = _filter_config_for_runtime(_get_box_config(ap))
|
||||
|
||||
def _uses_websocket(self) -> bool:
|
||||
def uses_websocket(self) -> bool:
|
||||
"""Whether the connector should use WebSocket to reach the Box runtime.
|
||||
|
||||
True when:
|
||||
- Running inside Docker (Box runtime is a separate container)
|
||||
- The ``--standalone-box`` CLI flag was passed
|
||||
- An explicit ``runtime.endpoint`` was configured
|
||||
|
||||
When this is True the Box runtime lives in a separate process with its
|
||||
own filesystem view (container, pod sidecar, or remote host), so paths
|
||||
it reports (e.g. skill ``package_root``) are NOT resolvable on the
|
||||
LangBot side. When False, Box runs as a stdio child process that shares
|
||||
LangBot's filesystem.
|
||||
"""
|
||||
return bool(
|
||||
self.configured_runtime_endpoint
|
||||
@@ -134,6 +140,10 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
|
||||
or platform.use_websocket_to_connect_box_runtime()
|
||||
)
|
||||
|
||||
# Backwards-compatible private alias.
|
||||
def _uses_websocket(self) -> bool:
|
||||
return self.uses_websocket()
|
||||
|
||||
async def initialize(self) -> None:
|
||||
if self._uses_websocket():
|
||||
if platform.get_platform() == 'win32' and not self.configured_runtime_endpoint:
|
||||
|
||||
@@ -12,6 +12,7 @@ import pydantic
|
||||
|
||||
from langbot_plugin.box.client import BoxRuntimeClient
|
||||
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.models import (
|
||||
BUILTIN_PROFILES,
|
||||
@@ -67,6 +68,10 @@ class BoxService:
|
||||
self._available = False
|
||||
self._connector_error: str = ''
|
||||
self._reconnecting = False
|
||||
# Optional explicit override for shares_filesystem_with_box. None means
|
||||
# "derive from the connector transport". Set by tests / embedders that
|
||||
# know the real LangBot<->Box filesystem topology.
|
||||
self._shares_filesystem_with_box_override: bool | None = None
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
@@ -148,6 +153,32 @@ class BoxService:
|
||||
def available(self) -> bool:
|
||||
return self._available
|
||||
|
||||
@property
|
||||
def shares_filesystem_with_box(self) -> bool:
|
||||
"""Whether LangBot and the Box runtime share a filesystem view.
|
||||
|
||||
This is True only when Box runs as a local stdio child process of
|
||||
LangBot (same container/host). In that case paths the Box runtime
|
||||
reports — notably skill ``package_root`` — resolve identically on the
|
||||
LangBot side, so LangBot may validate them against its own filesystem.
|
||||
|
||||
It is False for every separated deployment (Docker Compose, k8s
|
||||
sidecar, ``--standalone-box``, or an explicit ``runtime.endpoint``),
|
||||
where the Box runtime owns its own filesystem and LangBot must trust
|
||||
the paths it reports rather than checking them locally.
|
||||
|
||||
When Box is wired up with an injected client (tests, custom embeds)
|
||||
there is no connector to introspect; we conservatively report False so
|
||||
LangBot never wrongly drops Box-reported skills. An explicit override
|
||||
can be set via ``_shares_filesystem_with_box`` (used by tests and any
|
||||
embedder that knows the real topology).
|
||||
"""
|
||||
if self._shares_filesystem_with_box_override is not None:
|
||||
return self._shares_filesystem_with_box_override
|
||||
if self._runtime_connector is None:
|
||||
return False
|
||||
return not self._runtime_connector.uses_websocket()
|
||||
|
||||
async def execute_spec_payload(
|
||||
self,
|
||||
spec_payload: dict,
|
||||
@@ -188,16 +219,29 @@ class BoxService:
|
||||
f'query_id={query.query_id} '
|
||||
f'summary={json.dumps(self._summarize_result(result), ensure_ascii=False)}'
|
||||
)
|
||||
telemetry_features.increment(query, 'sandbox', 'execs')
|
||||
return self._serialize_result(result)
|
||||
|
||||
def resolve_box_session_id(self, query: pipeline_query.Query) -> str:
|
||||
"""Resolve the Box session_id from the pipeline's template and query variables."""
|
||||
template = (
|
||||
(query.pipeline_config or {})
|
||||
.get('ai', {})
|
||||
.get('local-agent', {})
|
||||
.get('box-session-id-template', '{launcher_type}_{launcher_id}')
|
||||
)
|
||||
"""Resolve the Box session_id from the pipeline's template and query variables.
|
||||
|
||||
When ``system.limitation.force_box_session_id_template`` is set to a
|
||||
non-empty value, that template overrides whatever the pipeline
|
||||
configured. This is the authoritative SaaS guard: it runs on every
|
||||
``exec`` call, so a tenant cannot escape a single shared sandbox even
|
||||
by editing the pipeline config directly through the API (which only
|
||||
gates the web UI).
|
||||
"""
|
||||
forced_template = self._forced_box_session_id_template()
|
||||
if forced_template:
|
||||
template = forced_template
|
||||
else:
|
||||
template = (
|
||||
(query.pipeline_config or {})
|
||||
.get('ai', {})
|
||||
.get('local-agent', {})
|
||||
.get('box-session-id-template', '{launcher_type}_{launcher_id}')
|
||||
)
|
||||
variables = dict(query.variables or {})
|
||||
launcher_type = getattr(query, 'launcher_type', None)
|
||||
if hasattr(launcher_type, 'value'):
|
||||
@@ -220,14 +264,24 @@ class BoxService:
|
||||
all skill packages mounted, regardless of which skill is currently
|
||||
activated.
|
||||
|
||||
Skills whose ``package_root`` is missing or no longer a directory on
|
||||
the LangBot-visible filesystem are skipped with a warning instead of
|
||||
being passed through to the backend. Without this guard the three
|
||||
backends behave inconsistently on a stale mount: nsjail refuses to
|
||||
start the sandbox (failing every exec in the session), Docker
|
||||
silently auto-creates a root-owned empty directory on the host, and
|
||||
E2B silently skips the upload — none of which surfaces an
|
||||
actionable error to the agent or operator.
|
||||
Path validation is filesystem-topology dependent. When LangBot and the
|
||||
Box runtime share a filesystem (local stdio mode), a skill whose
|
||||
``package_root`` is missing or no longer a directory is skipped with a
|
||||
warning instead of being passed through to the backend. Without that
|
||||
guard the three backends behave inconsistently on a stale mount: nsjail
|
||||
refuses to start the sandbox (failing every exec in the session),
|
||||
Docker silently auto-creates a root-owned empty directory on the host,
|
||||
and E2B silently skips the upload — none of which surfaces an
|
||||
actionable error.
|
||||
|
||||
When Box runs as a separate process (Docker Compose, k8s sidecar,
|
||||
``--standalone-box``, or a remote ``runtime.endpoint``), the
|
||||
``package_root`` reported by ``list_skills`` is the Box runtime's own
|
||||
filesystem path and is NOT resolvable on the LangBot side. Validating
|
||||
it locally would wrongly drop every skill, so LangBot trusts the path
|
||||
and lets the Box runtime resolve it. The Box runtime only ever reports
|
||||
skills it discovered on its own filesystem, so the path is valid there
|
||||
by construction.
|
||||
"""
|
||||
skill_mgr = getattr(self.ap, 'skill_mgr', None)
|
||||
if skill_mgr is None:
|
||||
@@ -235,13 +289,15 @@ class BoxService:
|
||||
|
||||
from ..provider.tools.loaders import skill as skill_loader
|
||||
|
||||
validate_locally = self.shares_filesystem_with_box
|
||||
|
||||
visible_skills = skill_loader.get_visible_skills(self.ap, query)
|
||||
mounts: list[dict] = []
|
||||
for skill_name, skill_data in visible_skills.items():
|
||||
package_root = str(skill_data.get('package_root', '') or '').strip()
|
||||
if not package_root:
|
||||
continue
|
||||
if not os.path.isdir(package_root):
|
||||
if validate_locally and not os.path.isdir(package_root):
|
||||
self.ap.logger.warning(
|
||||
f'Skill "{skill_name}" package_root missing on filesystem '
|
||||
f'({package_root}); skipping mount to prevent sandbox failures. '
|
||||
@@ -564,6 +620,20 @@ class BoxService:
|
||||
raw = str(self._local_config().get('image', '') or '').strip()
|
||||
return raw or None
|
||||
|
||||
def _forced_box_session_id_template(self) -> str:
|
||||
"""Return the SaaS-forced sandbox-scope template, or '' when unset.
|
||||
|
||||
Read from ``system.limitation.force_box_session_id_template``. A
|
||||
non-empty value pins every pipeline to a single sandbox scope
|
||||
(e.g. ``'{global}'``) and cannot be overridden per-pipeline.
|
||||
"""
|
||||
limitation = (
|
||||
(self.ap.instance_config.data or {}).get('system', {}).get('limitation', {})
|
||||
if getattr(self.ap, 'instance_config', None) is not None
|
||||
else {}
|
||||
)
|
||||
return str(limitation.get('force_box_session_id_template', '') or '').strip()
|
||||
|
||||
def _load_workspace_quota_mb(self) -> int | None:
|
||||
raw_value = self._local_config().get('workspace_quota_mb')
|
||||
if raw_value in (None, ''):
|
||||
@@ -717,6 +787,7 @@ class BoxService:
|
||||
# ── Observability ─────────────────────────────────────────────────
|
||||
|
||||
def _record_error(self, exc: Exception, query: pipeline_query.Query):
|
||||
telemetry_features.increment(query, 'sandbox', 'errors')
|
||||
self._recent_errors.append(
|
||||
{
|
||||
'timestamp': _dt.datetime.now(_UTC).isoformat(),
|
||||
|
||||
@@ -200,6 +200,17 @@ class 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
|
||||
monitoring_cfg = self.instance_config.data.get('monitoring', {})
|
||||
auto_cleanup_cfg = monitoring_cfg.get('auto_cleanup', {})
|
||||
|
||||
@@ -16,7 +16,6 @@ importutil.import_modules_in_pkg(stages)
|
||||
|
||||
stage_order = [
|
||||
'LoadConfigStage',
|
||||
'MigrationStage',
|
||||
'GenKeysStage',
|
||||
'SetupLoggerStage',
|
||||
'BuildAppStage',
|
||||
|
||||
@@ -42,6 +42,7 @@ required_deps = {
|
||||
'telegramify_markdown': 'telegramify-markdown',
|
||||
'slack_sdk': 'slack_sdk',
|
||||
'asyncpg': 'asyncpg',
|
||||
'litellm': 'litellm',
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
import logging.handlers
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
@@ -20,6 +21,66 @@ log_colors_config = {
|
||||
LOG_FILE_MAX_BYTES = 10 * 1024 * 1024 # 10MB per file
|
||||
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:
|
||||
# Remove all existing loggers
|
||||
@@ -31,8 +92,6 @@ async def init_logging(extra_handlers: list[logging.Handler] = None) -> logging.
|
||||
if constants.debug_mode:
|
||||
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.setLevel(level)
|
||||
@@ -48,12 +107,13 @@ async def init_logging(extra_handlers: list[logging.Handler] = None) -> logging.
|
||||
# stream_handler.setFormatter(color_formatter)
|
||||
stream_handler.stream = open(sys.stdout.fileno(), mode='w', encoding='utf-8', buffering=1)
|
||||
|
||||
# Use RotatingFileHandler to prevent unbounded log file growth
|
||||
rotating_file_handler = logging.handlers.RotatingFileHandler(
|
||||
log_file_name,
|
||||
# Rotate by size within a day and switch files when the date changes,
|
||||
# so long-running processes still produce a log file for the current day.
|
||||
rotating_file_handler = DailyGroupedRotatingFileHandler(
|
||||
LOG_DIR,
|
||||
max_bytes=LOG_FILE_MAX_BYTES,
|
||||
backup_count=LOG_FILE_BACKUP_COUNT,
|
||||
encoding='utf-8',
|
||||
maxBytes=LOG_FILE_MAX_BYTES,
|
||||
backupCount=LOG_FILE_BACKUP_COUNT,
|
||||
)
|
||||
|
||||
log_handlers: list[logging.Handler] = [
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import abc
|
||||
import typing
|
||||
|
||||
from . import app
|
||||
|
||||
|
||||
preregistered_migrations: list[typing.Type[Migration]] = []
|
||||
"""Currently not supported for extension"""
|
||||
|
||||
|
||||
def migration_class(name: str, number: int):
|
||||
"""Register a migration"""
|
||||
|
||||
def decorator(cls: typing.Type[Migration]) -> typing.Type[Migration]:
|
||||
cls.name = name
|
||||
cls.number = number
|
||||
preregistered_migrations.append(cls)
|
||||
return cls
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
class Migration(abc.ABC):
|
||||
"""A version migration"""
|
||||
|
||||
name: str
|
||||
|
||||
number: int
|
||||
|
||||
ap: app.Application
|
||||
|
||||
def __init__(self, ap: app.Application):
|
||||
self.ap = ap
|
||||
|
||||
@abc.abstractmethod
|
||||
async def need_migrate(self) -> bool:
|
||||
"""Determine if the current environment needs to run this migration"""
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
async def run(self):
|
||||
"""Run migration"""
|
||||
pass
|
||||
@@ -1,24 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from .. import migration
|
||||
|
||||
|
||||
@migration.migration_class('sensitive-word-migration', 1)
|
||||
class SensitiveWordMigration(migration.Migration):
|
||||
"""敏感词迁移"""
|
||||
|
||||
async def need_migrate(self) -> bool:
|
||||
"""判断当前环境是否需要运行此迁移"""
|
||||
return os.path.exists('data/config/sensitive-words.json') and not os.path.exists(
|
||||
'data/metadata/sensitive-words.json'
|
||||
)
|
||||
|
||||
async def run(self):
|
||||
"""执行迁移"""
|
||||
# 移动文件
|
||||
os.rename('data/config/sensitive-words.json', 'data/metadata/sensitive-words.json')
|
||||
|
||||
# 重新加载配置
|
||||
await self.ap.sensitive_meta.load_config()
|
||||
@@ -1,44 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .. import migration
|
||||
|
||||
|
||||
@migration.migration_class('openai-config-migration', 2)
|
||||
class OpenAIConfigMigration(migration.Migration):
|
||||
"""OpenAI配置迁移"""
|
||||
|
||||
async def need_migrate(self) -> bool:
|
||||
"""判断当前环境是否需要运行此迁移"""
|
||||
return 'openai-config' in self.ap.provider_cfg.data
|
||||
|
||||
async def run(self):
|
||||
"""执行迁移"""
|
||||
old_openai_config = self.ap.provider_cfg.data['openai-config'].copy()
|
||||
|
||||
if 'keys' not in self.ap.provider_cfg.data:
|
||||
self.ap.provider_cfg.data['keys'] = {}
|
||||
|
||||
if 'openai' not in self.ap.provider_cfg.data['keys']:
|
||||
self.ap.provider_cfg.data['keys']['openai'] = []
|
||||
|
||||
self.ap.provider_cfg.data['keys']['openai'] = old_openai_config['api-keys']
|
||||
|
||||
self.ap.provider_cfg.data['model'] = old_openai_config['chat-completions-params']['model']
|
||||
|
||||
del old_openai_config['chat-completions-params']['model']
|
||||
|
||||
if 'requester' not in self.ap.provider_cfg.data:
|
||||
self.ap.provider_cfg.data['requester'] = {}
|
||||
|
||||
if 'openai-chat-completions' not in self.ap.provider_cfg.data['requester']:
|
||||
self.ap.provider_cfg.data['requester']['openai-chat-completions'] = {}
|
||||
|
||||
self.ap.provider_cfg.data['requester']['openai-chat-completions'] = {
|
||||
'base-url': old_openai_config['base_url'],
|
||||
'args': old_openai_config['chat-completions-params'],
|
||||
'timeout': old_openai_config['request-timeout'],
|
||||
}
|
||||
|
||||
del self.ap.provider_cfg.data['openai-config']
|
||||
|
||||
await self.ap.provider_cfg.dump_config()
|
||||
@@ -1,29 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .. import migration
|
||||
|
||||
|
||||
@migration.migration_class('anthropic-requester-config-completion', 3)
|
||||
class AnthropicRequesterConfigCompletionMigration(migration.Migration):
|
||||
"""OpenAI配置迁移"""
|
||||
|
||||
async def need_migrate(self) -> bool:
|
||||
"""判断当前环境是否需要运行此迁移"""
|
||||
return (
|
||||
'anthropic-messages' not in self.ap.provider_cfg.data['requester']
|
||||
or 'anthropic' not in self.ap.provider_cfg.data['keys']
|
||||
)
|
||||
|
||||
async def run(self):
|
||||
"""执行迁移"""
|
||||
if 'anthropic-messages' not in self.ap.provider_cfg.data['requester']:
|
||||
self.ap.provider_cfg.data['requester']['anthropic-messages'] = {
|
||||
'base-url': 'https://api.anthropic.com',
|
||||
'args': {'max_tokens': 1024},
|
||||
'timeout': 120,
|
||||
}
|
||||
|
||||
if 'anthropic' not in self.ap.provider_cfg.data['keys']:
|
||||
self.ap.provider_cfg.data['keys']['anthropic'] = []
|
||||
|
||||
await self.ap.provider_cfg.dump_config()
|
||||
@@ -1,29 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .. import migration
|
||||
|
||||
|
||||
@migration.migration_class('moonshot-config-completion', 4)
|
||||
class MoonshotConfigCompletionMigration(migration.Migration):
|
||||
"""OpenAI配置迁移"""
|
||||
|
||||
async def need_migrate(self) -> bool:
|
||||
"""判断当前环境是否需要运行此迁移"""
|
||||
return (
|
||||
'moonshot-chat-completions' not in self.ap.provider_cfg.data['requester']
|
||||
or 'moonshot' not in self.ap.provider_cfg.data['keys']
|
||||
)
|
||||
|
||||
async def run(self):
|
||||
"""执行迁移"""
|
||||
if 'moonshot-chat-completions' not in self.ap.provider_cfg.data['requester']:
|
||||
self.ap.provider_cfg.data['requester']['moonshot-chat-completions'] = {
|
||||
'base-url': 'https://api.moonshot.cn/v1',
|
||||
'args': {},
|
||||
'timeout': 120,
|
||||
}
|
||||
|
||||
if 'moonshot' not in self.ap.provider_cfg.data['keys']:
|
||||
self.ap.provider_cfg.data['keys']['moonshot'] = []
|
||||
|
||||
await self.ap.provider_cfg.dump_config()
|
||||
@@ -1,29 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .. import migration
|
||||
|
||||
|
||||
@migration.migration_class('deepseek-config-completion', 5)
|
||||
class DeepseekConfigCompletionMigration(migration.Migration):
|
||||
"""OpenAI配置迁移"""
|
||||
|
||||
async def need_migrate(self) -> bool:
|
||||
"""判断当前环境是否需要运行此迁移"""
|
||||
return (
|
||||
'deepseek-chat-completions' not in self.ap.provider_cfg.data['requester']
|
||||
or 'deepseek' not in self.ap.provider_cfg.data['keys']
|
||||
)
|
||||
|
||||
async def run(self):
|
||||
"""执行迁移"""
|
||||
if 'deepseek-chat-completions' not in self.ap.provider_cfg.data['requester']:
|
||||
self.ap.provider_cfg.data['requester']['deepseek-chat-completions'] = {
|
||||
'base-url': 'https://api.deepseek.com',
|
||||
'args': {},
|
||||
'timeout': 120,
|
||||
}
|
||||
|
||||
if 'deepseek' not in self.ap.provider_cfg.data['keys']:
|
||||
self.ap.provider_cfg.data['keys']['deepseek'] = []
|
||||
|
||||
await self.ap.provider_cfg.dump_config()
|
||||
@@ -1,19 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .. import migration
|
||||
|
||||
|
||||
@migration.migration_class('vision-config', 6)
|
||||
class VisionConfigMigration(migration.Migration):
|
||||
"""迁移"""
|
||||
|
||||
async def need_migrate(self) -> bool:
|
||||
"""判断当前环境是否需要运行此迁移"""
|
||||
return 'enable-vision' not in self.ap.provider_cfg.data
|
||||
|
||||
async def run(self):
|
||||
"""执行迁移"""
|
||||
if 'enable-vision' not in self.ap.provider_cfg.data:
|
||||
self.ap.provider_cfg.data['enable-vision'] = False
|
||||
|
||||
await self.ap.provider_cfg.dump_config()
|
||||
@@ -1,20 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .. import migration
|
||||
|
||||
|
||||
@migration.migration_class('qcg-center-url-config', 7)
|
||||
class QCGCenterURLConfigMigration(migration.Migration):
|
||||
"""迁移"""
|
||||
|
||||
async def need_migrate(self) -> bool:
|
||||
"""判断当前环境是否需要运行此迁移"""
|
||||
return 'qcg-center-url' not in self.ap.system_cfg.data
|
||||
|
||||
async def run(self):
|
||||
"""执行迁移"""
|
||||
|
||||
if 'qcg-center-url' not in self.ap.system_cfg.data:
|
||||
self.ap.system_cfg.data['qcg-center-url'] = 'https://api.qchatgpt.rockchin.top/api/v2'
|
||||
|
||||
await self.ap.system_cfg.dump_config()
|
||||
@@ -1,25 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .. import migration
|
||||
|
||||
|
||||
@migration.migration_class('ad-fixwin-cfg-migration', 8)
|
||||
class AdFixwinConfigMigration(migration.Migration):
|
||||
"""迁移"""
|
||||
|
||||
async def need_migrate(self) -> bool:
|
||||
"""判断当前环境是否需要运行此迁移"""
|
||||
return isinstance(self.ap.pipeline_cfg.data['rate-limit']['fixwin']['default'], int)
|
||||
|
||||
async def run(self):
|
||||
"""执行迁移"""
|
||||
|
||||
for session_name in self.ap.pipeline_cfg.data['rate-limit']['fixwin']:
|
||||
temp_dict = {
|
||||
'window-size': 60,
|
||||
'limit': self.ap.pipeline_cfg.data['rate-limit']['fixwin'][session_name],
|
||||
}
|
||||
|
||||
self.ap.pipeline_cfg.data['rate-limit']['fixwin'][session_name] = temp_dict
|
||||
|
||||
await self.ap.pipeline_cfg.dump_config()
|
||||
@@ -1,22 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .. import migration
|
||||
|
||||
|
||||
@migration.migration_class('msg-truncator-cfg-migration', 9)
|
||||
class MsgTruncatorConfigMigration(migration.Migration):
|
||||
"""迁移"""
|
||||
|
||||
async def need_migrate(self) -> bool:
|
||||
"""判断当前环境是否需要运行此迁移"""
|
||||
return 'msg-truncate' not in self.ap.pipeline_cfg.data
|
||||
|
||||
async def run(self):
|
||||
"""执行迁移"""
|
||||
|
||||
self.ap.pipeline_cfg.data['msg-truncate'] = {
|
||||
'method': 'round',
|
||||
'round': {'max-round': 10},
|
||||
}
|
||||
|
||||
await self.ap.pipeline_cfg.dump_config()
|
||||
@@ -1,23 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .. import migration
|
||||
|
||||
|
||||
@migration.migration_class('ollama-requester-config', 10)
|
||||
class MsgTruncatorConfigMigration(migration.Migration):
|
||||
"""迁移"""
|
||||
|
||||
async def need_migrate(self) -> bool:
|
||||
"""判断当前环境是否需要运行此迁移"""
|
||||
return 'ollama-chat' not in self.ap.provider_cfg.data['requester']
|
||||
|
||||
async def run(self):
|
||||
"""执行迁移"""
|
||||
|
||||
self.ap.provider_cfg.data['requester']['ollama-chat'] = {
|
||||
'base-url': 'http://127.0.0.1:11434',
|
||||
'args': {},
|
||||
'timeout': 600,
|
||||
}
|
||||
|
||||
await self.ap.provider_cfg.dump_config()
|
||||
@@ -1,19 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .. import migration
|
||||
|
||||
|
||||
@migration.migration_class('command-prefix-config', 11)
|
||||
class CommandPrefixConfigMigration(migration.Migration):
|
||||
"""迁移"""
|
||||
|
||||
async def need_migrate(self) -> bool:
|
||||
"""判断当前环境是否需要运行此迁移"""
|
||||
return 'command-prefix' not in self.ap.command_cfg.data
|
||||
|
||||
async def run(self):
|
||||
"""执行迁移"""
|
||||
|
||||
self.ap.command_cfg.data['command-prefix'] = ['!', '!']
|
||||
|
||||
await self.ap.command_cfg.dump_config()
|
||||
@@ -1,19 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .. import migration
|
||||
|
||||
|
||||
@migration.migration_class('runner-config', 12)
|
||||
class RunnerConfigMigration(migration.Migration):
|
||||
"""迁移"""
|
||||
|
||||
async def need_migrate(self) -> bool:
|
||||
"""判断当前环境是否需要运行此迁移"""
|
||||
return 'runner' not in self.ap.provider_cfg.data
|
||||
|
||||
async def run(self):
|
||||
"""执行迁移"""
|
||||
|
||||
self.ap.provider_cfg.data['runner'] = 'local-agent'
|
||||
|
||||
await self.ap.provider_cfg.dump_config()
|
||||
@@ -1,29 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .. import migration
|
||||
|
||||
|
||||
@migration.migration_class('http-api-config', 13)
|
||||
class HttpApiConfigMigration(migration.Migration):
|
||||
"""迁移"""
|
||||
|
||||
async def need_migrate(self) -> bool:
|
||||
"""判断当前环境是否需要运行此迁移"""
|
||||
return 'http-api' not in self.ap.system_cfg.data or 'persistence' not in self.ap.system_cfg.data
|
||||
|
||||
async def run(self):
|
||||
"""执行迁移"""
|
||||
|
||||
self.ap.system_cfg.data['http-api'] = {
|
||||
'enable': True,
|
||||
'host': '0.0.0.0',
|
||||
'port': 5300,
|
||||
'jwt-expire': 604800,
|
||||
}
|
||||
|
||||
self.ap.system_cfg.data['persistence'] = {
|
||||
'sqlite': {'path': 'data/persistence.db'},
|
||||
'use': 'sqlite',
|
||||
}
|
||||
|
||||
await self.ap.system_cfg.dump_config()
|
||||
@@ -1,22 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .. import migration
|
||||
|
||||
|
||||
@migration.migration_class('force-delay-config', 14)
|
||||
class ForceDelayConfigMigration(migration.Migration):
|
||||
"""迁移"""
|
||||
|
||||
async def need_migrate(self) -> bool:
|
||||
"""判断当前环境是否需要运行此迁移"""
|
||||
return isinstance(self.ap.platform_cfg.data['force-delay'], list)
|
||||
|
||||
async def run(self):
|
||||
"""执行迁移"""
|
||||
|
||||
self.ap.platform_cfg.data['force-delay'] = {
|
||||
'min': self.ap.platform_cfg.data['force-delay'][0],
|
||||
'max': self.ap.platform_cfg.data['force-delay'][1],
|
||||
}
|
||||
|
||||
await self.ap.platform_cfg.dump_config()
|
||||
@@ -1,27 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .. import migration
|
||||
|
||||
|
||||
@migration.migration_class('gitee-ai-config', 15)
|
||||
class GiteeAIConfigMigration(migration.Migration):
|
||||
"""迁移"""
|
||||
|
||||
async def need_migrate(self) -> bool:
|
||||
"""判断当前环境是否需要运行此迁移"""
|
||||
return (
|
||||
'gitee-ai-chat-completions' not in self.ap.provider_cfg.data['requester']
|
||||
or 'gitee-ai' not in self.ap.provider_cfg.data['keys']
|
||||
)
|
||||
|
||||
async def run(self):
|
||||
"""执行迁移"""
|
||||
self.ap.provider_cfg.data['requester']['gitee-ai-chat-completions'] = {
|
||||
'base-url': 'https://ai.gitee.com/v1',
|
||||
'args': {},
|
||||
'timeout': 120,
|
||||
}
|
||||
|
||||
self.ap.provider_cfg.data['keys']['gitee-ai'] = ['XXXXX']
|
||||
|
||||
await self.ap.provider_cfg.dump_config()
|
||||
@@ -1,23 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .. import migration
|
||||
|
||||
|
||||
@migration.migration_class('dify-service-api-config', 16)
|
||||
class DifyServiceAPICfgMigration(migration.Migration):
|
||||
"""迁移"""
|
||||
|
||||
async def need_migrate(self) -> bool:
|
||||
"""判断当前环境是否需要运行此迁移"""
|
||||
return 'dify-service-api' not in self.ap.provider_cfg.data
|
||||
|
||||
async def run(self):
|
||||
"""执行迁移"""
|
||||
self.ap.provider_cfg.data['dify-service-api'] = {
|
||||
'base-url': 'https://api.dify.ai/v1',
|
||||
'app-type': 'chat',
|
||||
'chat': {'api-key': 'app-1234567890'},
|
||||
'workflow': {'api-key': 'app-1234567890', 'output-key': 'summary'},
|
||||
}
|
||||
|
||||
await self.ap.provider_cfg.dump_config()
|
||||
@@ -1,27 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .. import migration
|
||||
|
||||
|
||||
@migration.migration_class('dify-api-timeout-params', 17)
|
||||
class DifyAPITimeoutParamsMigration(migration.Migration):
|
||||
"""迁移"""
|
||||
|
||||
async def need_migrate(self) -> bool:
|
||||
"""判断当前环境是否需要运行此迁移"""
|
||||
return (
|
||||
'timeout' not in self.ap.provider_cfg.data['dify-service-api']['chat']
|
||||
or 'timeout' not in self.ap.provider_cfg.data['dify-service-api']['workflow']
|
||||
or 'agent' not in self.ap.provider_cfg.data['dify-service-api']
|
||||
)
|
||||
|
||||
async def run(self):
|
||||
"""执行迁移"""
|
||||
self.ap.provider_cfg.data['dify-service-api']['chat']['timeout'] = 120
|
||||
self.ap.provider_cfg.data['dify-service-api']['workflow']['timeout'] = 120
|
||||
self.ap.provider_cfg.data['dify-service-api']['agent'] = {
|
||||
'api-key': 'app-1234567890',
|
||||
'timeout': 120,
|
||||
}
|
||||
|
||||
await self.ap.provider_cfg.dump_config()
|
||||
@@ -1,23 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .. import migration
|
||||
|
||||
|
||||
@migration.migration_class('xai-config', 18)
|
||||
class XaiConfigMigration(migration.Migration):
|
||||
"""迁移"""
|
||||
|
||||
async def need_migrate(self) -> bool:
|
||||
"""判断当前环境是否需要运行此迁移"""
|
||||
return 'xai-chat-completions' not in self.ap.provider_cfg.data['requester']
|
||||
|
||||
async def run(self):
|
||||
"""执行迁移"""
|
||||
self.ap.provider_cfg.data['requester']['xai-chat-completions'] = {
|
||||
'base-url': 'https://api.x.ai/v1',
|
||||
'args': {},
|
||||
'timeout': 120,
|
||||
}
|
||||
self.ap.provider_cfg.data['keys']['xai'] = ['xai-1234567890']
|
||||
|
||||
await self.ap.provider_cfg.dump_config()
|
||||
@@ -1,23 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .. import migration
|
||||
|
||||
|
||||
@migration.migration_class('zhipuai-config', 19)
|
||||
class ZhipuaiConfigMigration(migration.Migration):
|
||||
"""迁移"""
|
||||
|
||||
async def need_migrate(self) -> bool:
|
||||
"""判断当前环境是否需要运行此迁移"""
|
||||
return 'zhipuai-chat-completions' not in self.ap.provider_cfg.data['requester']
|
||||
|
||||
async def run(self):
|
||||
"""执行迁移"""
|
||||
self.ap.provider_cfg.data['requester']['zhipuai-chat-completions'] = {
|
||||
'base-url': 'https://open.bigmodel.cn/api/paas/v4',
|
||||
'args': {},
|
||||
'timeout': 120,
|
||||
}
|
||||
self.ap.provider_cfg.data['keys']['zhipuai'] = ['xxxxxxx']
|
||||
|
||||
await self.ap.provider_cfg.dump_config()
|
||||
@@ -1,36 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .. import migration
|
||||
|
||||
|
||||
@migration.migration_class('wecom-config', 20)
|
||||
class WecomConfigMigration(migration.Migration):
|
||||
"""迁移"""
|
||||
|
||||
async def need_migrate(self) -> bool:
|
||||
"""判断当前环境是否需要运行此迁移"""
|
||||
|
||||
# for adapter in self.ap.platform_cfg.data['platform-adapters']:
|
||||
# if adapter['adapter'] == 'wecom':
|
||||
# return False
|
||||
|
||||
# return True
|
||||
return False
|
||||
|
||||
async def run(self):
|
||||
"""执行迁移"""
|
||||
self.ap.platform_cfg.data['platform-adapters'].append(
|
||||
{
|
||||
'adapter': 'wecom',
|
||||
'enable': False,
|
||||
'host': '0.0.0.0',
|
||||
'port': 2290,
|
||||
'corpid': '',
|
||||
'secret': '',
|
||||
'token': '',
|
||||
'EncodingAESKey': '',
|
||||
'contacts_secret': '',
|
||||
}
|
||||
)
|
||||
|
||||
await self.ap.platform_cfg.dump_config()
|
||||
@@ -1,35 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .. import migration
|
||||
|
||||
|
||||
@migration.migration_class('lark-config', 21)
|
||||
class LarkConfigMigration(migration.Migration):
|
||||
"""迁移"""
|
||||
|
||||
async def need_migrate(self) -> bool:
|
||||
"""判断当前环境是否需要运行此迁移"""
|
||||
|
||||
# for adapter in self.ap.platform_cfg.data['platform-adapters']:
|
||||
# if adapter['adapter'] == 'lark':
|
||||
# return False
|
||||
|
||||
# return True
|
||||
return False
|
||||
|
||||
async def run(self):
|
||||
"""执行迁移"""
|
||||
self.ap.platform_cfg.data['platform-adapters'].append(
|
||||
{
|
||||
'adapter': 'lark',
|
||||
'enable': False,
|
||||
'app_id': 'cli_abcdefgh',
|
||||
'app_secret': 'XXXXXXXXXX',
|
||||
'bot_name': 'LangBot',
|
||||
'enable-webhook': False,
|
||||
'port': 2285,
|
||||
'encrypt-key': 'xxxxxxxxx',
|
||||
}
|
||||
)
|
||||
|
||||
await self.ap.platform_cfg.dump_config()
|
||||
@@ -1,23 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .. import migration
|
||||
|
||||
|
||||
@migration.migration_class('lmstudio-config', 22)
|
||||
class LmStudioConfigMigration(migration.Migration):
|
||||
"""迁移"""
|
||||
|
||||
async def need_migrate(self) -> bool:
|
||||
"""判断当前环境是否需要运行此迁移"""
|
||||
|
||||
return 'lmstudio-chat-completions' not in self.ap.provider_cfg.data['requester']
|
||||
|
||||
async def run(self):
|
||||
"""执行迁移"""
|
||||
self.ap.provider_cfg.data['requester']['lmstudio-chat-completions'] = {
|
||||
'base-url': 'http://127.0.0.1:1234/v1',
|
||||
'args': {},
|
||||
'timeout': 120,
|
||||
}
|
||||
|
||||
await self.ap.provider_cfg.dump_config()
|
||||
@@ -1,25 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .. import migration
|
||||
|
||||
|
||||
@migration.migration_class('siliconflow-config', 23)
|
||||
class SiliconFlowConfigMigration(migration.Migration):
|
||||
"""迁移"""
|
||||
|
||||
async def need_migrate(self) -> bool:
|
||||
"""判断当前环境是否需要运行此迁移"""
|
||||
|
||||
return 'siliconflow-chat-completions' not in self.ap.provider_cfg.data['requester']
|
||||
|
||||
async def run(self):
|
||||
"""执行迁移"""
|
||||
self.ap.provider_cfg.data['keys']['siliconflow'] = ['xxxxxxx']
|
||||
|
||||
self.ap.provider_cfg.data['requester']['siliconflow-chat-completions'] = {
|
||||
'base-url': 'https://api.siliconflow.cn/v1',
|
||||
'args': {},
|
||||
'timeout': 120,
|
||||
}
|
||||
|
||||
await self.ap.provider_cfg.dump_config()
|
||||
@@ -1,31 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .. import migration
|
||||
|
||||
|
||||
@migration.migration_class('discord-config', 24)
|
||||
class DiscordConfigMigration(migration.Migration):
|
||||
"""迁移"""
|
||||
|
||||
async def need_migrate(self) -> bool:
|
||||
"""判断当前环境是否需要运行此迁移"""
|
||||
|
||||
# for adapter in self.ap.platform_cfg.data['platform-adapters']:
|
||||
# if adapter['adapter'] == 'discord':
|
||||
# return False
|
||||
|
||||
# return True
|
||||
return False
|
||||
|
||||
async def run(self):
|
||||
"""执行迁移"""
|
||||
self.ap.platform_cfg.data['platform-adapters'].append(
|
||||
{
|
||||
'adapter': 'discord',
|
||||
'enable': False,
|
||||
'client_id': '1234567890',
|
||||
'token': 'XXXXXXXXXX',
|
||||
}
|
||||
)
|
||||
|
||||
await self.ap.platform_cfg.dump_config()
|
||||
@@ -1,35 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .. import migration
|
||||
|
||||
|
||||
@migration.migration_class('gewechat-config', 25)
|
||||
class GewechatConfigMigration(migration.Migration):
|
||||
"""迁移"""
|
||||
|
||||
async def need_migrate(self) -> bool:
|
||||
"""判断当前环境是否需要运行此迁移"""
|
||||
|
||||
# for adapter in self.ap.platform_cfg.data['platform-adapters']:
|
||||
# if adapter['adapter'] == 'gewechat':
|
||||
# return False
|
||||
|
||||
# return True
|
||||
return False
|
||||
|
||||
async def run(self):
|
||||
"""执行迁移"""
|
||||
self.ap.platform_cfg.data['platform-adapters'].append(
|
||||
{
|
||||
'adapter': 'gewechat',
|
||||
'enable': False,
|
||||
'gewechat_url': 'http://your-gewechat-server:2531',
|
||||
'gewechat_file_url': 'http://your-gewechat-server:2532',
|
||||
'port': 2286,
|
||||
'callback_url': 'http://your-callback-url:2286/gewechat/callback',
|
||||
'app_id': '',
|
||||
'token': '',
|
||||
}
|
||||
)
|
||||
|
||||
await self.ap.platform_cfg.dump_config()
|
||||
@@ -1,33 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .. import migration
|
||||
|
||||
|
||||
@migration.migration_class('qqofficial-config', 26)
|
||||
class QQOfficialConfigMigration(migration.Migration):
|
||||
"""迁移"""
|
||||
|
||||
async def need_migrate(self) -> bool:
|
||||
"""判断当前环境是否需要运行此迁移"""
|
||||
|
||||
# for adapter in self.ap.platform_cfg.data['platform-adapters']:
|
||||
# if adapter['adapter'] == 'qqofficial':
|
||||
# return False
|
||||
|
||||
# return True
|
||||
return False
|
||||
|
||||
async def run(self):
|
||||
"""执行迁移"""
|
||||
self.ap.platform_cfg.data['platform-adapters'].append(
|
||||
{
|
||||
'adapter': 'qqofficial',
|
||||
'enable': False,
|
||||
'appid': '',
|
||||
'secret': '',
|
||||
'port': 2284,
|
||||
'token': '',
|
||||
}
|
||||
)
|
||||
|
||||
await self.ap.platform_cfg.dump_config()
|
||||
@@ -1,35 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .. import migration
|
||||
|
||||
|
||||
@migration.migration_class('wx-official-account-config', 27)
|
||||
class WXOfficialAccountConfigMigration(migration.Migration):
|
||||
"""迁移"""
|
||||
|
||||
async def need_migrate(self) -> bool:
|
||||
"""判断当前环境是否需要运行此迁移"""
|
||||
|
||||
# for adapter in self.ap.platform_cfg.data['platform-adapters']:
|
||||
# if adapter['adapter'] == 'officialaccount':
|
||||
# return False
|
||||
|
||||
# return True
|
||||
return False
|
||||
|
||||
async def run(self):
|
||||
"""执行迁移"""
|
||||
self.ap.platform_cfg.data['platform-adapters'].append(
|
||||
{
|
||||
'adapter': 'officialaccount',
|
||||
'enable': False,
|
||||
'token': '',
|
||||
'EncodingAESKey': '',
|
||||
'AppID': '',
|
||||
'AppSecret': '',
|
||||
'host': '0.0.0.0',
|
||||
'port': 2287,
|
||||
}
|
||||
)
|
||||
|
||||
await self.ap.platform_cfg.dump_config()
|
||||
@@ -1,25 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .. import migration
|
||||
|
||||
|
||||
@migration.migration_class('bailian-requester-config', 28)
|
||||
class BailianRequesterConfigMigration(migration.Migration):
|
||||
"""迁移"""
|
||||
|
||||
async def need_migrate(self) -> bool:
|
||||
"""判断当前环境是否需要运行此迁移"""
|
||||
|
||||
return 'bailian-chat-completions' not in self.ap.provider_cfg.data['requester']
|
||||
|
||||
async def run(self):
|
||||
"""执行迁移"""
|
||||
self.ap.provider_cfg.data['keys']['bailian'] = ['sk-xxxxxxx']
|
||||
|
||||
self.ap.provider_cfg.data['requester']['bailian-chat-completions'] = {
|
||||
'base-url': 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
||||
'args': {},
|
||||
'timeout': 120,
|
||||
}
|
||||
|
||||
await self.ap.provider_cfg.dump_config()
|
||||
@@ -1,27 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .. import migration
|
||||
|
||||
|
||||
@migration.migration_class('dashscope-app-api-config', 29)
|
||||
class DashscopeAppAPICfgMigration(migration.Migration):
|
||||
"""迁移"""
|
||||
|
||||
async def need_migrate(self) -> bool:
|
||||
"""判断当前环境是否需要运行此迁移"""
|
||||
return 'dashscope-app-api' not in self.ap.provider_cfg.data
|
||||
|
||||
async def run(self):
|
||||
"""执行迁移"""
|
||||
self.ap.provider_cfg.data['dashscope-app-api'] = {
|
||||
'app-type': 'agent',
|
||||
'api-key': 'sk-1234567890',
|
||||
'agent': {'app-id': 'Your_app_id', 'references_quote': '参考资料来自:'},
|
||||
'workflow': {
|
||||
'app-id': 'Your_app_id',
|
||||
'references_quote': '参考资料来自:',
|
||||
'biz_params': {'city': '北京', 'date': '2023-08-10'},
|
||||
},
|
||||
}
|
||||
|
||||
await self.ap.provider_cfg.dump_config()
|
||||
@@ -1,31 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .. import migration
|
||||
|
||||
|
||||
@migration.migration_class('lark-config-cmpl', 30)
|
||||
class LarkConfigCmplMigration(migration.Migration):
|
||||
"""迁移"""
|
||||
|
||||
async def need_migrate(self) -> bool:
|
||||
"""判断当前环境是否需要运行此迁移"""
|
||||
|
||||
for adapter in self.ap.platform_cfg.data['platform-adapters']:
|
||||
if adapter['adapter'] == 'lark':
|
||||
if 'enable-webhook' not in adapter:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
async def run(self):
|
||||
"""执行迁移"""
|
||||
for adapter in self.ap.platform_cfg.data['platform-adapters']:
|
||||
if adapter['adapter'] == 'lark':
|
||||
if 'enable-webhook' not in adapter:
|
||||
adapter['enable-webhook'] = False
|
||||
if 'port' not in adapter:
|
||||
adapter['port'] = 2285
|
||||
if 'encrypt-key' not in adapter:
|
||||
adapter['encrypt-key'] = 'xxxxxxxxx'
|
||||
|
||||
await self.ap.platform_cfg.dump_config()
|
||||
@@ -1,33 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .. import migration
|
||||
|
||||
|
||||
@migration.migration_class('dingtalk-config', 31)
|
||||
class DingTalkConfigMigration(migration.Migration):
|
||||
"""迁移"""
|
||||
|
||||
async def need_migrate(self) -> bool:
|
||||
"""判断当前环境是否需要运行此迁移"""
|
||||
|
||||
# for adapter in self.ap.platform_cfg.data['platform-adapters']:
|
||||
# if adapter['adapter'] == 'dingtalk':
|
||||
# return False
|
||||
|
||||
# return True
|
||||
return False
|
||||
|
||||
async def run(self):
|
||||
"""执行迁移"""
|
||||
self.ap.platform_cfg.data['platform-adapters'].append(
|
||||
{
|
||||
'adapter': 'dingtalk',
|
||||
'enable': False,
|
||||
'client_id': '',
|
||||
'client_secret': '',
|
||||
'robot_code': '',
|
||||
'robot_name': '',
|
||||
}
|
||||
)
|
||||
|
||||
await self.ap.platform_cfg.dump_config()
|
||||
@@ -1,25 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .. import migration
|
||||
|
||||
|
||||
@migration.migration_class('volcark-requester-config', 32)
|
||||
class VolcArkRequesterConfigMigration(migration.Migration):
|
||||
"""迁移"""
|
||||
|
||||
async def need_migrate(self) -> bool:
|
||||
"""判断当前环境是否需要运行此迁移"""
|
||||
|
||||
return 'volcark-chat-completions' not in self.ap.provider_cfg.data['requester']
|
||||
|
||||
async def run(self):
|
||||
"""执行迁移"""
|
||||
self.ap.provider_cfg.data['keys']['volcark'] = ['xxxxxxxx']
|
||||
|
||||
self.ap.provider_cfg.data['requester']['volcark-chat-completions'] = {
|
||||
'base-url': 'https://ark.cn-beijing.volces.com/api/v3',
|
||||
'args': {},
|
||||
'timeout': 120,
|
||||
}
|
||||
|
||||
await self.ap.provider_cfg.dump_config()
|
||||
@@ -1,24 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .. import migration
|
||||
|
||||
|
||||
@migration.migration_class('dify-thinking-config', 33)
|
||||
class DifyThinkingConfigMigration(migration.Migration):
|
||||
"""迁移"""
|
||||
|
||||
async def need_migrate(self) -> bool:
|
||||
"""判断当前环境是否需要运行此迁移"""
|
||||
|
||||
if 'options' not in self.ap.provider_cfg.data['dify-service-api']:
|
||||
return True
|
||||
|
||||
if 'convert-thinking-tips' not in self.ap.provider_cfg.data['dify-service-api']['options']:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
async def run(self):
|
||||
"""执行迁移"""
|
||||
self.ap.provider_cfg.data['dify-service-api']['options'] = {'convert-thinking-tips': 'plain'}
|
||||
await self.ap.provider_cfg.dump_config()
|
||||
@@ -1,29 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from .. import migration
|
||||
|
||||
|
||||
@migration.migration_class('gewechat-file-url-config', 34)
|
||||
class GewechatFileUrlConfigMigration(migration.Migration):
|
||||
"""迁移"""
|
||||
|
||||
async def need_migrate(self) -> bool:
|
||||
"""判断当前环境是否需要运行此迁移"""
|
||||
|
||||
for adapter in self.ap.platform_cfg.data['platform-adapters']:
|
||||
if adapter['adapter'] == 'gewechat':
|
||||
if 'gewechat_file_url' not in adapter:
|
||||
return True
|
||||
return False
|
||||
|
||||
async def run(self):
|
||||
"""执行迁移"""
|
||||
for adapter in self.ap.platform_cfg.data['platform-adapters']:
|
||||
if adapter['adapter'] == 'gewechat':
|
||||
if 'gewechat_file_url' not in adapter:
|
||||
parsed_url = urlparse(adapter['gewechat_url'])
|
||||
adapter['gewechat_file_url'] = f'{parsed_url.scheme}://{parsed_url.hostname}:2532'
|
||||
|
||||
await self.ap.platform_cfg.dump_config()
|
||||
@@ -1,26 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .. import migration
|
||||
|
||||
|
||||
@migration.migration_class('wxoa-mode', 35)
|
||||
class WxoaModeMigration(migration.Migration):
|
||||
"""迁移"""
|
||||
|
||||
async def need_migrate(self) -> bool:
|
||||
"""判断当前环境是否需要运行此迁移"""
|
||||
|
||||
for adapter in self.ap.platform_cfg.data['platform-adapters']:
|
||||
if adapter['adapter'] == 'officialaccount':
|
||||
if 'Mode' not in adapter:
|
||||
return True
|
||||
return False
|
||||
|
||||
async def run(self):
|
||||
"""执行迁移"""
|
||||
for adapter in self.ap.platform_cfg.data['platform-adapters']:
|
||||
if adapter['adapter'] == 'officialaccount':
|
||||
if 'Mode' not in adapter:
|
||||
adapter['Mode'] = 'drop'
|
||||
|
||||
await self.ap.platform_cfg.dump_config()
|
||||
@@ -1,26 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .. import migration
|
||||
|
||||
|
||||
@migration.migration_class('wxoa-loading-message', 36)
|
||||
class WxoaLoadingMessageMigration(migration.Migration):
|
||||
"""迁移"""
|
||||
|
||||
async def need_migrate(self) -> bool:
|
||||
"""判断当前环境是否需要运行此迁移"""
|
||||
|
||||
for adapter in self.ap.platform_cfg.data['platform-adapters']:
|
||||
if adapter['adapter'] == 'officialaccount':
|
||||
if 'LoadingMessage' not in adapter:
|
||||
return True
|
||||
return False
|
||||
|
||||
async def run(self):
|
||||
"""执行迁移"""
|
||||
for adapter in self.ap.platform_cfg.data['platform-adapters']:
|
||||
if adapter['adapter'] == 'officialaccount':
|
||||
if 'LoadingMessage' not in adapter:
|
||||
adapter['LoadingMessage'] = 'AI正在思考中,请发送任意内容获取回复。'
|
||||
|
||||
await self.ap.platform_cfg.dump_config()
|
||||
@@ -1,18 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .. import migration
|
||||
|
||||
|
||||
@migration.migration_class('mcp-config', 37)
|
||||
class MCPConfigMigration(migration.Migration):
|
||||
"""迁移"""
|
||||
|
||||
async def need_migrate(self) -> bool:
|
||||
"""判断当前环境是否需要运行此迁移"""
|
||||
return 'mcp' not in self.ap.provider_cfg.data
|
||||
|
||||
async def run(self):
|
||||
"""执行迁移"""
|
||||
self.ap.provider_cfg.data['mcp'] = {'servers': []}
|
||||
|
||||
await self.ap.provider_cfg.dump_config()
|
||||
@@ -1,25 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .. import migration
|
||||
|
||||
|
||||
@migration.migration_class('tg-dingtalk-markdown', 38)
|
||||
class TgDingtalkMarkdownMigration(migration.Migration):
|
||||
"""迁移"""
|
||||
|
||||
async def need_migrate(self) -> bool:
|
||||
"""判断当前环境是否需要运行此迁移"""
|
||||
|
||||
for adapter in self.ap.platform_cfg.data['platform-adapters']:
|
||||
if adapter['adapter'] in ['dingtalk', 'telegram']:
|
||||
if 'markdown_card' not in adapter:
|
||||
return True
|
||||
return False
|
||||
|
||||
async def run(self):
|
||||
"""执行迁移"""
|
||||
for adapter in self.ap.platform_cfg.data['platform-adapters']:
|
||||
if adapter['adapter'] in ['dingtalk', 'telegram']:
|
||||
if 'markdown_card' not in adapter:
|
||||
adapter['markdown_card'] = False
|
||||
await self.ap.platform_cfg.dump_config()
|
||||
@@ -1,29 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .. import migration
|
||||
|
||||
|
||||
@migration.migration_class('modelscope-config-completion', 39)
|
||||
class ModelScopeConfigCompletionMigration(migration.Migration):
|
||||
"""ModelScope配置迁移"""
|
||||
|
||||
async def need_migrate(self) -> bool:
|
||||
"""判断当前环境是否需要运行此迁移"""
|
||||
return (
|
||||
'modelscope-chat-completions' not in self.ap.provider_cfg.data['requester']
|
||||
or 'modelscope' not in self.ap.provider_cfg.data['keys']
|
||||
)
|
||||
|
||||
async def run(self):
|
||||
"""执行迁移"""
|
||||
if 'modelscope-chat-completions' not in self.ap.provider_cfg.data['requester']:
|
||||
self.ap.provider_cfg.data['requester']['modelscope-chat-completions'] = {
|
||||
'base-url': 'https://api-inference.modelscope.cn/v1',
|
||||
'args': {},
|
||||
'timeout': 120,
|
||||
}
|
||||
|
||||
if 'modelscope' not in self.ap.provider_cfg.data['keys']:
|
||||
self.ap.provider_cfg.data['keys']['modelscope'] = []
|
||||
|
||||
await self.ap.provider_cfg.dump_config()
|
||||
@@ -1,29 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .. import migration
|
||||
|
||||
|
||||
@migration.migration_class('ppio-config', 40)
|
||||
class PPIOConfigMigration(migration.Migration):
|
||||
"""PPIO配置迁移"""
|
||||
|
||||
async def need_migrate(self) -> bool:
|
||||
"""判断当前环境是否需要运行此迁移"""
|
||||
return (
|
||||
'ppio-chat-completions' not in self.ap.provider_cfg.data['requester']
|
||||
or 'ppio' not in self.ap.provider_cfg.data['keys']
|
||||
)
|
||||
|
||||
async def run(self):
|
||||
"""执行迁移"""
|
||||
if 'ppio-chat-completions' not in self.ap.provider_cfg.data['requester']:
|
||||
self.ap.provider_cfg.data['requester']['ppio-chat-completions'] = {
|
||||
'base-url': 'https://api.ppinfra.com/v3/openai',
|
||||
'args': {},
|
||||
'timeout': 120,
|
||||
}
|
||||
|
||||
if 'ppio' not in self.ap.provider_cfg.data['keys']:
|
||||
self.ap.provider_cfg.data['keys']['ppio'] = []
|
||||
|
||||
await self.ap.provider_cfg.dump_config()
|
||||
@@ -1,17 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .. import migration
|
||||
|
||||
|
||||
@migration.migration_class('dingtalk_card_auto_layout', 41)
|
||||
class DingTalkCardAutoLayoutMigration(migration.Migration):
|
||||
"""迁移"""
|
||||
|
||||
async def need_migrate(self) -> bool:
|
||||
"""判断当前环境是否需要运行此迁移"""
|
||||
return True
|
||||
|
||||
async def run(self):
|
||||
"""执行迁移"""
|
||||
self.ap.platform_cfg.data['platform-adapters']['app']['dingtalk']['card_auto_layout'] = False
|
||||
await self.ap.platform_cfg.dump_config()
|
||||
@@ -1,43 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
from .. import stage, app
|
||||
from .. import migration
|
||||
from ...utils import importutil
|
||||
from .. import migrations
|
||||
|
||||
importutil.import_modules_in_pkg(migrations)
|
||||
|
||||
|
||||
@stage.stage_class('MigrationStage')
|
||||
class MigrationStage(stage.BootingStage):
|
||||
"""Migration stage
|
||||
|
||||
These migrations are legacy, only performed in version 3.x
|
||||
"""
|
||||
|
||||
async def run(self, ap: app.Application):
|
||||
"""Run migration"""
|
||||
|
||||
if any(
|
||||
[
|
||||
ap.command_cfg is None,
|
||||
ap.pipeline_cfg is None,
|
||||
ap.platform_cfg is None,
|
||||
ap.provider_cfg is None,
|
||||
ap.system_cfg is None,
|
||||
]
|
||||
): # only run migration when version is 3.x
|
||||
return
|
||||
|
||||
migrations = migration.preregistered_migrations
|
||||
|
||||
# Sort by migration number
|
||||
migrations.sort(key=lambda x: x.number)
|
||||
|
||||
for migration_cls in migrations:
|
||||
migration_instance = migration_cls(ap)
|
||||
|
||||
if await migration_instance.need_migrate():
|
||||
await migration_instance.run()
|
||||
print(f'Migration {migration_instance.name} executed')
|
||||
@@ -11,6 +11,10 @@ class MCPServer(Base):
|
||||
enable = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=False)
|
||||
mode = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) # stdio, sse, http
|
||||
extra_args = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})
|
||||
# Markdown documentation captured from LangBot Space at install time so the
|
||||
# detail page can show docs even when the server is offline / has no tools.
|
||||
# Empty string for manually-created servers that have no marketplace README.
|
||||
readme = sqlalchemy.Column(sqlalchemy.Text, nullable=False, server_default='', default='')
|
||||
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
|
||||
updated_at = sqlalchemy.Column(
|
||||
sqlalchemy.DateTime,
|
||||
|
||||
@@ -31,6 +31,7 @@ class LLMModel(Base):
|
||||
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
provider_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
abilities = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default=[])
|
||||
context_length = sqlalchemy.Column(sqlalchemy.Integer, nullable=True)
|
||||
extra_args = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})
|
||||
prefered_ranking = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, default=0)
|
||||
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"""add readme column to mcp_servers
|
||||
|
||||
Revision ID: 0004_add_mcp_readme
|
||||
Revises: 0003_add_rerank_models
|
||||
Create Date: 2026-06-06
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = '0004_add_mcp_readme'
|
||||
down_revision = '0003_add_rerank_models'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Add ``readme`` to mcp_servers if the table exists and the column is missing
|
||||
# (the table may have been created by create_all() with the column already
|
||||
# present on fresh installs, so guard against duplicate-add).
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
if 'mcp_servers' not in inspector.get_table_names():
|
||||
return
|
||||
columns = {col['name'] for col in inspector.get_columns('mcp_servers')}
|
||||
if 'readme' not in columns:
|
||||
op.add_column(
|
||||
'mcp_servers',
|
||||
sa.Column('readme', sa.Text(), nullable=False, server_default=''),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('mcp_servers', 'readme')
|
||||
@@ -0,0 +1,39 @@
|
||||
"""add llm model context length
|
||||
|
||||
Revision ID: 0005_add_llm_context_length
|
||||
Revises: 0004_add_mcp_readme
|
||||
Create Date: 2026-06-07
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = '0005_add_llm_context_length'
|
||||
down_revision = '0004_add_mcp_readme'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Add ``context_length`` to llm_models if the table exists and the column is
|
||||
# missing. The table may have been created by create_all() with the column
|
||||
# already present on fresh installs, so guard against duplicate-add; it may
|
||||
# also be absent entirely (e.g. migrating a truly empty DB), so guard against
|
||||
# a missing table too.
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
if 'llm_models' not in inspector.get_table_names():
|
||||
return
|
||||
columns = {column['name'] for column in inspector.get_columns('llm_models')}
|
||||
if 'context_length' not in columns:
|
||||
op.add_column('llm_models', sa.Column('context_length', sa.Integer(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
if 'llm_models' not in inspector.get_table_names():
|
||||
return
|
||||
columns = {column['name'] for column in inspector.get_columns('llm_models')}
|
||||
if 'context_length' in columns:
|
||||
op.drop_column('llm_models', 'context_length')
|
||||
36
src/langbot/pkg/persistence/migrations/README.md
Normal file
36
src/langbot/pkg/persistence/migrations/README.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# Legacy migrations (DEPRECATED — do not add new files here)
|
||||
|
||||
This directory holds the **legacy 3.x database migration system**
|
||||
(`DBMigration` subclasses in `dbmXXX_*.py`, registered via
|
||||
`@migration.migration_class(N)` and run from `pkg/persistence/mgr.py`).
|
||||
|
||||
**This system is frozen. Do not add new `dbmXXX_*.py` migrations.**
|
||||
|
||||
The chain is capped at version 25 (`required_database_version = 25` in
|
||||
`pkg/utils/constants.py`). These files exist only to upgrade pre-existing
|
||||
3.x databases up to the Alembic baseline (`0001_baseline`). Removing them
|
||||
would break in-place upgrades from old installations, so they are kept
|
||||
read-only.
|
||||
|
||||
## All new schema changes use Alembic
|
||||
|
||||
Migrations now live in `pkg/persistence/alembic/versions/`. To create one:
|
||||
|
||||
```bash
|
||||
uv run python -m langbot.pkg.persistence.alembic_runner autogenerate "description of your change"
|
||||
```
|
||||
|
||||
(requires `data/config.yaml` to exist). Review and edit the generated
|
||||
script before committing — Alembic migrations run automatically on startup
|
||||
and must be idempotent and guard against missing tables (the test suite
|
||||
runs them against empty databases).
|
||||
|
||||
### Rules for Alembic revision ids
|
||||
|
||||
- Keep the revision id **≤ 32 characters** — PostgreSQL stores
|
||||
`alembic_version.version_num` as `varchar(32)` and will raise
|
||||
`StringDataRightTruncationError` on overflow.
|
||||
- Guard every `op` call against a missing table / missing column
|
||||
(`inspector.get_table_names()` / `inspector.get_columns()`); fresh
|
||||
installs create the schema via `create_all()` and stamp the baseline,
|
||||
so migrations may run against tables that already match or do not exist.
|
||||
@@ -109,7 +109,7 @@ class PreProcessor(stage.PipelineStage):
|
||||
if llm_model:
|
||||
query.use_llm_model_uuid = llm_model.model_entity.uuid
|
||||
|
||||
if llm_model.model_entity.abilities.__contains__('func_call'):
|
||||
if 'func_call' in (llm_model.model_entity.abilities or []):
|
||||
# Get bound plugins and MCP servers for filtering tools
|
||||
bound_plugins = query.variables.get('_pipeline_bound_plugins', None)
|
||||
bound_mcp_servers = query.variables.get('_pipeline_bound_mcp_servers', None)
|
||||
@@ -159,11 +159,7 @@ class PreProcessor(stage.PipelineStage):
|
||||
|
||||
# Check if this model supports vision, if not, remove all images
|
||||
# TODO this checking should be performed in runner, and in this stage, the image should be reserved
|
||||
if (
|
||||
selected_runner == 'local-agent'
|
||||
and llm_model
|
||||
and not llm_model.model_entity.abilities.__contains__('vision')
|
||||
):
|
||||
if selected_runner == 'local-agent' and llm_model and 'vision' not in (llm_model.model_entity.abilities or []):
|
||||
for msg in query.messages:
|
||||
if isinstance(msg.content, list):
|
||||
for me in msg.content:
|
||||
@@ -181,7 +177,7 @@ class PreProcessor(stage.PipelineStage):
|
||||
plain_text += me.text
|
||||
elif isinstance(me, platform_message.Image):
|
||||
if selected_runner != 'local-agent' or (
|
||||
llm_model and llm_model.model_entity.abilities.__contains__('vision')
|
||||
llm_model and 'vision' in (llm_model.model_entity.abilities or [])
|
||||
):
|
||||
if me.base64 is not None:
|
||||
content_list.append(provider_message.ContentElement.from_image_base64(me.base64))
|
||||
@@ -202,7 +198,7 @@ class PreProcessor(stage.PipelineStage):
|
||||
content_list.append(provider_message.ContentElement.from_text(msg.text))
|
||||
elif isinstance(msg, platform_message.Image):
|
||||
if selected_runner != 'local-agent' or (
|
||||
llm_model and llm_model.model_entity.abilities.__contains__('vision')
|
||||
llm_model and 'vision' in (llm_model.model_entity.abilities or [])
|
||||
):
|
||||
if msg.base64 is not None:
|
||||
content_list.append(provider_message.ContentElement.from_image_base64(msg.base64))
|
||||
|
||||
@@ -13,6 +13,7 @@ from ....provider import runner as runner_module
|
||||
|
||||
import langbot_plugin.api.entities.events as events
|
||||
from ....utils import importutil, constants, runner as runner_utils
|
||||
from ....telemetry import features as telemetry_features
|
||||
from ....provider import runners
|
||||
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
||||
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
|
||||
)
|
||||
|
||||
# Feature usage collected during query processing (tool calls,
|
||||
# knowledge base usage, sandbox executions, activated skills, ...)
|
||||
features = telemetry_features.collect_features(query)
|
||||
|
||||
payload = {
|
||||
'event_type': 'query',
|
||||
'query_id': query.query_id,
|
||||
'adapter': adapter_name,
|
||||
'runner': runner_name,
|
||||
@@ -212,6 +218,7 @@ class ChatMessageHandler(handler.MessageHandler):
|
||||
'instance_id': constants.instance_id,
|
||||
'edition': constants.edition,
|
||||
'pipeline_plugins': pipeline_plugins,
|
||||
'features': features,
|
||||
'error': locals().get('error_info', None),
|
||||
'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
|
||||
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 self.ap.survey:
|
||||
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:
|
||||
# Ensure telemetry issues do not affect normal flow
|
||||
self.ap.logger.warning(f'Failed to send telemetry: {ex}')
|
||||
|
||||
@@ -31,6 +31,18 @@ spec:
|
||||
type: webhook-url
|
||||
required: false
|
||||
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
|
||||
label:
|
||||
en_US: Token
|
||||
|
||||
@@ -19,6 +19,18 @@ spec:
|
||||
en: https://link.langbot.app/en/platforms/qqofficial
|
||||
ja: https://link.langbot.app/ja/platforms/qqofficial
|
||||
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
|
||||
label:
|
||||
en_US: App ID
|
||||
|
||||
@@ -32,6 +32,18 @@ spec:
|
||||
type: webhook-url
|
||||
required: false
|
||||
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
|
||||
label:
|
||||
en_US: Corpid
|
||||
|
||||
@@ -75,6 +75,18 @@ spec:
|
||||
field: enable-webhook
|
||||
operator: eq
|
||||
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
|
||||
label:
|
||||
en_US: Secret
|
||||
|
||||
@@ -31,6 +31,18 @@ spec:
|
||||
type: webhook-url
|
||||
required: false
|
||||
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
|
||||
label:
|
||||
en_US: Corpid
|
||||
|
||||
@@ -248,6 +248,9 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
|
||||
mode = mcp_data.get('mode') or 'stdio'
|
||||
extra_args = mcp_data.get('extra_args') or {}
|
||||
# Marketplace records carry the rendered README markdown; persist it so
|
||||
# the detail page Docs tab works offline and without a marketplace round-trip.
|
||||
readme = mcp_data.get('readme') or ''
|
||||
# Use __ instead of / to avoid URL routing issues with slashes
|
||||
name = f'{mcp_data.get("author", "")}__{mcp_data.get("name", "")}'
|
||||
|
||||
@@ -267,6 +270,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
'enable': True,
|
||||
'mode': mode,
|
||||
'extra_args': extra_args,
|
||||
'readme': readme,
|
||||
}
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_mcp.MCPServer).values(server_data))
|
||||
@@ -685,6 +689,16 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
async def get_plugin_readme(self, plugin_author: str, plugin_name: str, language: str = 'en') -> str:
|
||||
return await self.handler.get_plugin_readme(plugin_author, plugin_name, language)
|
||||
|
||||
async def get_plugin_logs(
|
||||
self,
|
||||
plugin_author: str,
|
||||
plugin_name: str,
|
||||
limit: int = 200,
|
||||
level: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
# Not cached: logs are live and change constantly.
|
||||
return await self.handler.get_plugin_logs(plugin_author, plugin_name, limit, level)
|
||||
|
||||
@alru_cache(ttl=5 * 60)
|
||||
async def get_plugin_assets(self, plugin_author: str, plugin_name: str, filepath: str) -> dict[str, Any]:
|
||||
return await self.handler.get_plugin_assets(plugin_author, plugin_name, filepath)
|
||||
|
||||
@@ -953,6 +953,31 @@ class RuntimeConnectionHandler(handler.Handler):
|
||||
|
||||
return readme_bytes.decode('utf-8')
|
||||
|
||||
async def get_plugin_logs(
|
||||
self,
|
||||
plugin_author: str,
|
||||
plugin_name: str,
|
||||
limit: int = 200,
|
||||
level: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Get recent log lines captured from the plugin's stderr."""
|
||||
try:
|
||||
result = await self.call_action(
|
||||
LangBotToRuntimeAction.GET_PLUGIN_LOGS,
|
||||
{
|
||||
'plugin_author': plugin_author,
|
||||
'plugin_name': plugin_name,
|
||||
'limit': limit,
|
||||
'level': level,
|
||||
},
|
||||
timeout=20,
|
||||
)
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
return []
|
||||
|
||||
return result.get('logs', [])
|
||||
|
||||
async def get_plugin_assets(self, plugin_author: str, plugin_name: str, filepath: str) -> dict[str, Any]:
|
||||
"""Get plugin assets"""
|
||||
result = await self.call_action(
|
||||
|
||||
@@ -37,11 +37,41 @@ class ModelManager:
|
||||
self.requester_components = []
|
||||
self.requester_dict = {}
|
||||
|
||||
@staticmethod
|
||||
def _get_litellm_provider_from_manifest(component: engine.Component | None) -> str | None:
|
||||
if component is None:
|
||||
return None
|
||||
|
||||
spec = getattr(component, 'spec', None) or {}
|
||||
litellm_provider = None
|
||||
|
||||
if isinstance(spec, dict):
|
||||
litellm_provider = spec.get('litellm_provider')
|
||||
else:
|
||||
getter = getattr(spec, 'get', None)
|
||||
if callable(getter):
|
||||
try:
|
||||
litellm_provider = getter('litellm_provider')
|
||||
except Exception:
|
||||
litellm_provider = None
|
||||
|
||||
if isinstance(litellm_provider, str) and litellm_provider:
|
||||
return litellm_provider
|
||||
return None
|
||||
|
||||
async def initialize(self):
|
||||
self.requester_components = self.ap.discover.get_components_by_kind('LLMAPIRequester')
|
||||
|
||||
requester_dict: dict[str, type[requester.ProviderAPIRequester]] = {}
|
||||
for component in self.requester_components:
|
||||
# Skip components that use litellm_provider (they will use litellmchat.py instead)
|
||||
litellm_provider = self._get_litellm_provider_from_manifest(component)
|
||||
if litellm_provider:
|
||||
self.ap.logger.debug(
|
||||
f'Skipping Python class loading for {component.metadata.name} '
|
||||
f'(uses litellm_provider={litellm_provider})'
|
||||
)
|
||||
continue
|
||||
requester_dict[component.metadata.name] = component.get_python_component_class()
|
||||
|
||||
self.requester_dict = requester_dict
|
||||
@@ -236,6 +266,7 @@ class ModelManager:
|
||||
name=model_info.get('name', ''),
|
||||
provider_uuid='',
|
||||
abilities=model_info.get('abilities', []),
|
||||
context_length=model_info.get('context_length'),
|
||||
extra_args=model_info.get('extra_args', {}),
|
||||
),
|
||||
provider=runtime_provider,
|
||||
@@ -294,13 +325,37 @@ class ModelManager:
|
||||
else:
|
||||
provider_entity = provider_info
|
||||
|
||||
if provider_entity.requester not in self.requester_dict:
|
||||
raise provider_errors.RequesterNotFoundError(provider_entity.requester)
|
||||
# Get requester manifest to check for litellm_provider
|
||||
requester_manifest = self.get_available_requester_manifest_by_name(provider_entity.requester)
|
||||
litellm_provider = self._get_litellm_provider_from_manifest(requester_manifest)
|
||||
|
||||
# Build config from base_url
|
||||
config = {'base_url': provider_entity.base_url}
|
||||
|
||||
# Check if requester manifest specifies litellm_provider
|
||||
if litellm_provider:
|
||||
from .requesters import litellmchat
|
||||
|
||||
# Use unified LiteLLMRequester with provider prefix
|
||||
# Map litellm_provider (YAML spec) to custom_llm_provider (config)
|
||||
config['custom_llm_provider'] = litellm_provider
|
||||
requester_inst = litellmchat.LiteLLMRequester(
|
||||
ap=self.ap,
|
||||
config=config,
|
||||
)
|
||||
self.ap.logger.debug(
|
||||
f'Using LiteLLMRequester for {provider_entity.requester} '
|
||||
f'with custom_llm_provider={config["custom_llm_provider"]}'
|
||||
)
|
||||
else:
|
||||
# Use original requester class (for backward compatibility)
|
||||
if provider_entity.requester not in self.requester_dict:
|
||||
raise provider_errors.RequesterNotFoundError(provider_entity.requester)
|
||||
requester_inst = self.requester_dict[provider_entity.requester](
|
||||
ap=self.ap,
|
||||
config=config,
|
||||
)
|
||||
|
||||
requester_inst = self.requester_dict[provider_entity.requester](
|
||||
ap=self.ap,
|
||||
config={'base_url': provider_entity.base_url},
|
||||
)
|
||||
await requester_inst.initialize()
|
||||
|
||||
token_mgr = token.TokenManager(name=provider_entity.uuid, tokens=provider_entity.api_keys or [])
|
||||
@@ -406,6 +461,7 @@ class ModelManager:
|
||||
name=model_info.get('name', ''),
|
||||
provider_uuid=model_info.get('provider_uuid', ''),
|
||||
abilities=model_info.get('abilities', []),
|
||||
context_length=model_info.get('context_length'),
|
||||
extra_args=model_info.get('extra_args', {}),
|
||||
)
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user