mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-22 18:27:12 +00:00
Compare commits
66 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 59538fd5cd | |||
| 235b33c335 | |||
| c588f9dfc1 | |||
| d770de7f7f | |||
| cc7a13158e | |||
| 7de7c7f714 | |||
| 85923e3d7b | |||
| c7c6c5dc51 | |||
| b056646696 | |||
| 096ec1a8ce | |||
| 2618e06492 | |||
| 5c8c0eb17b | |||
| ccc51522cf | |||
| 6a99a83f2d | |||
| ebab5343cf | |||
| b97cc800d3 | |||
| 48905ea080 | |||
| ddb77fc43c | |||
| 5b2826fa49 | |||
| 20636ac432 | |||
| af42602547 | |||
| 53b20e2b13 | |||
| 1242dc2d21 | |||
| 04628d93cb | |||
| 9c22a1521c | |||
| c8d5039580 | |||
| 85d8d9304e | |||
| 76471af179 | |||
| 59b2a7cd51 | |||
| a43978ff24 | |||
| e3417dd20b | |||
| 2982e7c553 | |||
| e1e14e9269 | |||
| 1c128a1524 | |||
| 8ad1203fd5 | |||
| 144bec371c | |||
| 417cca016c | |||
| cb0cc4d06a | |||
| cb0bb44db8 | |||
| 6609bebeec | |||
| 192b69b0fb | |||
| 543fbd8ca0 | |||
| 804448b6cd | |||
| 5d4e40459f | |||
| b5c43cc113 | |||
| 8ebfcd963a | |||
| 127198675e | |||
| 44fb188994 | |||
| 265385a563 | |||
| 253cc6cbea | |||
| f99d3022e8 | |||
| 5c5614667a | |||
| 313d553271 | |||
| bb7db53447 | |||
| 27c0d344bf | |||
| c088dc114f | |||
| 37c74b0622 | |||
| c9f7911efe | |||
| 75fdfe6806 | |||
| eb9f38b102 | |||
| fc40d3c949 | |||
| d176a448e0 | |||
| ada4c30f85 | |||
| 32c9eaff45 | |||
| 9706ee2d53 | |||
| e7c9bc69d3 |
@@ -1,160 +1,105 @@
|
|||||||
# AGENTS.md
|
# AGENTS.md
|
||||||
|
|
||||||
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.
|
This file guides code agents working in the LangBot main repository. `CLAUDE.md` is a symlink to this file.
|
||||||
|
|
||||||
## Project Overview
|
Read `ARCHITECTURE.md` before non-trivial backend, frontend, runtime, plugin, Box, MCP, persistence, or cross-repo SDK changes. This file is the working checklist; `ARCHITECTURE.md` is the system map.
|
||||||
|
|
||||||
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.
|
## Quick Facts
|
||||||
|
|
||||||
LangBot has a comprehensive web frontend — almost every operation can be performed through it.
|
- Python backend: `>=3.11,<4.0`, dependencies managed by `uv`.
|
||||||
|
- Frontend: `web/` is Vite + React Router 7 + shadcn/ui + Tailwind, managed by `pnpm`.
|
||||||
|
- Backend framework: Quart served by Hypercorn on `api.port`, default `5300`.
|
||||||
|
- Frontend dev server: `web/` on `3000`, with `VITE_API_BASE_URL` pointing at the backend.
|
||||||
|
- Plugin/Box/runtime contracts live in sibling repo `langbot-plugin-sdk`, pinned as `langbot-plugin` in `pyproject.toml`.
|
||||||
|
|
||||||
- **Python**: `>=3.11,<4.0`, dependencies managed by `uv`. Package version is in `pyproject.toml`.
|
## Essential Commands
|
||||||
- **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`.
|
|
||||||
|
|
||||||
## Repository Layout
|
|
||||||
|
|
||||||
```
|
|
||||||
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 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
|
|
||||||
```
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
### Frontend
|
|
||||||
|
|
||||||
Requires Node.js + [pnpm](https://pnpm.io/installation).
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd web
|
|
||||||
cp .env.example .env # Windows: copy .env.example .env
|
|
||||||
pnpm install
|
|
||||||
pnpm dev # http://127.0.0.1:3000 (npm install / npm run dev also work)
|
|
||||||
```
|
|
||||||
|
|
||||||
`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.
|
|
||||||
|
|
||||||
### Code formatting
|
|
||||||
|
|
||||||
The repo runs lint + format checks in CI. Install the pre-commit hooks so the same checks run locally before each commit:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
uv sync --dev
|
||||||
|
uv run main.py
|
||||||
uv run pre-commit install
|
uv run pre-commit install
|
||||||
|
|
||||||
|
cd web
|
||||||
|
pnpm install
|
||||||
|
pnpm dev
|
||||||
|
pnpm build
|
||||||
```
|
```
|
||||||
|
|
||||||
## Plugin System
|
Useful focused tests:
|
||||||
|
|
||||||
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`.
|
|
||||||
|
|
||||||
### Architecture (what to know inside this repo)
|
|
||||||
|
|
||||||
- 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
|
```bash
|
||||||
# Run from the project root (requires data/config.yaml to exist)
|
uv run pytest tests/unit_tests -q
|
||||||
uv run python -m langbot.pkg.persistence.alembic_runner autogenerate "description of your change"
|
uv run pytest tests/integration -q
|
||||||
|
uv run pytest tests/integration/persistence -q
|
||||||
|
uv run pytest tests/manual/mcp_smoke.py
|
||||||
|
|
||||||
|
cd web
|
||||||
|
pnpm lint
|
||||||
|
pnpm test:e2e
|
||||||
```
|
```
|
||||||
|
|
||||||
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#数据库迁移).
|
Run the narrowest useful test first, then broader checks when confidence is needed.
|
||||||
|
|
||||||
When writing a migration, follow these rules:
|
## Where to Look
|
||||||
|
|
||||||
- **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`.
|
- Architecture map: `ARCHITECTURE.md`.
|
||||||
- **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.
|
- Dev environment guide: https://docs.langbot.app/zh/develop/dev-config.
|
||||||
- **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`).
|
- Plugin runtime / CLI / SDK debugging: https://docs.langbot.app/zh/develop/plugin-runtime.
|
||||||
|
- API-key auth: `docs/API_KEY_AUTH.md`.
|
||||||
|
- Box deep-dive notes: `docs/review/box-architecture.md` and related files.
|
||||||
|
- In-repo skills: `skills/` is the single source of truth for LangBot agent skills.
|
||||||
|
- SDK repo: `../langbot-plugin-sdk/` when changing shared entities, plugin APIs, action protocol, `lbp rt`, or `lbp box`.
|
||||||
|
|
||||||
> **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.
|
## Cross-Repo SDK Work
|
||||||
|
|
||||||
## Agent-Facing Surfaces (MCP + Skills)
|
When changing SDK contracts used by LangBot:
|
||||||
|
|
||||||
LangBot is built to be **agent-friendly**. Three surfaces let AI agents work
|
```bash
|
||||||
with LangBot, and they MUST be kept in lockstep with the HTTP API:
|
# from langbot-plugin-sdk, with LangBot's .venv active
|
||||||
|
uv pip install .
|
||||||
|
|
||||||
1. **MCP server** — `src/langbot/pkg/api/mcp/` exposes a curated subset of the
|
# from LangBot, preserve the locally installed SDK
|
||||||
API as MCP tools at `/mcp` (API-key authenticated, including the
|
uv run --no-sync main.py
|
||||||
`api.global_api_key` from config.yaml). `server.py` defines the tools (they
|
```
|
||||||
call the service layer directly); `mount.py` is the ASGI dispatcher.
|
|
||||||
2. **In-repo skills** — `skills/` is the **single source of truth** for agent
|
|
||||||
skills (plugin/core/deploy/e2e/MCP-ops). Docs and the landing page link here
|
|
||||||
rather than embedding their own copies.
|
|
||||||
3. **API-key auth** — `api.global_api_key` (config.yaml) authenticates the API
|
|
||||||
and MCP without a login session; see `docs/API_KEY_AUTH.md`.
|
|
||||||
|
|
||||||
> **Maintenance rule (important).** When you add, remove, or change an HTTP API
|
For standalone runtime debugging:
|
||||||
> endpoint that should be agent-accessible, you MUST update **both** the matching
|
|
||||||
> MCP tool in `src/langbot/pkg/api/mcp/server.py` **and** the relevant skill under
|
|
||||||
> `skills/` (especially `skills/skills/langbot-mcp-ops`). The API, the MCP tool
|
|
||||||
> surface, and the skills are one system — drift between them is a bug.
|
|
||||||
|
|
||||||
## Some Principles
|
```bash
|
||||||
|
# in langbot-plugin-sdk
|
||||||
|
uv run --no-sync lbp rt
|
||||||
|
uv run --no-sync lbp box
|
||||||
|
|
||||||
|
# in LangBot
|
||||||
|
uv run --no-sync main.py --standalone-runtime
|
||||||
|
uv run --no-sync main.py --standalone-box
|
||||||
|
```
|
||||||
|
|
||||||
|
Config keys to verify in `data/config.yaml` / `src/langbot/templates/config.yaml`:
|
||||||
|
|
||||||
|
- Plugin runtime: `plugin.runtime_ws_url`, default Docker host `langbot_plugin_runtime:5400/control/ws`.
|
||||||
|
- Box runtime: `box.enabled`, `box.backend`, `box.runtime.endpoint`, Docker host `langbot_box:5410`.
|
||||||
|
- API/MCP auth: `api.global_api_key`.
|
||||||
|
|
||||||
|
## Change Rules
|
||||||
|
|
||||||
|
- HTTP API changes that should be agent-accessible must update the matching MCP tool in `src/langbot/pkg/api/mcp/server.py` and the relevant skill under `skills/` in the same pass.
|
||||||
|
- New schema changes use Alembic under `src/langbot/pkg/persistence/alembic/versions/`; do not add legacy `dbmXXX` migrations.
|
||||||
|
- New platform behavior belongs in platform adapters only for platform translation; pipeline/business logic belongs in `pkg/pipeline/` or services.
|
||||||
|
- User-facing strings must support i18n (`en_US`, `zh_Hans`; include `ja_JP` where the repo already does).
|
||||||
|
- Code comments and docstrings must be English.
|
||||||
|
- Keep compatibility and security in mind; LangBot is used in both self-hosted/community and toB deployments.
|
||||||
|
- Commit message format: `<type>(<scope>): <subject>`.
|
||||||
|
|
||||||
|
## Runtime Pitfalls
|
||||||
|
|
||||||
|
- Local stdio Plugin Runtime disconnects do not auto-reconnect; restart LangBot if that path breaks.
|
||||||
|
- Orphan runtime processes on `5400`/`5401` commonly break plugin debugging.
|
||||||
|
- Use `uv run --no-sync` after locally installing the SDK, or `uv` may restore the pinned package.
|
||||||
|
- A false Box “no backend” often means Docker is running but the current user lacks Docker socket permission.
|
||||||
|
- Do not confuse external MCP servers LangBot connects to (`pkg/provider/tools/loaders/mcp.py`) with LangBot's own `/mcp` server (`pkg/api/mcp/`).
|
||||||
|
- `CLAUDE.md` is a symlink to this file; edit `AGENTS.md`, not the symlink.
|
||||||
|
|
||||||
|
## Principles
|
||||||
|
|
||||||
- Keep it simple, stupid.
|
- Keep it simple, stupid.
|
||||||
- Entities should not be multiplied unnecessarily.
|
- Entities should not be multiplied unnecessarily.
|
||||||
|
|||||||
+250
@@ -0,0 +1,250 @@
|
|||||||
|
# Architecture
|
||||||
|
|
||||||
|
This document is a map of LangBot's moving parts. It is intentionally more stable than a feature guide and more concrete than the README: when you need to change behavior, start here, then follow the file references into the code.
|
||||||
|
|
||||||
|
For agent-specific working rules, see `AGENTS.md`. For plugin-runtime and Box-runtime implementation details, also read the sibling SDK repo: [`langbot-plugin-sdk`](https://github.com/langbot-app/langbot-plugin-sdk).
|
||||||
|
|
||||||
|
## What LangBot Is
|
||||||
|
|
||||||
|
LangBot is an open-source platform for building production IM bots backed by LLMs, agents, RAG, plugins, MCP tools, and a web management panel.
|
||||||
|
|
||||||
|
At runtime, one LangBot process owns:
|
||||||
|
|
||||||
|
- a Quart/Hypercorn HTTP service and the built web UI on `:5300`;
|
||||||
|
- messaging-platform adapters such as Discord, Telegram, Slack, WeChat, QQ, WeCom, Lark, DingTalk, KOOK, LINE, Satori, Matrix, and HTTP/WebSocket bots;
|
||||||
|
- a pipeline engine that turns inbound platform messages into LLM/tool/plugin work and replies;
|
||||||
|
- persistence, storage, vector database, telemetry, monitoring, and configuration managers;
|
||||||
|
- bridges to the Plugin Runtime and Box Runtime provided by `langbot-plugin-sdk`;
|
||||||
|
- an MCP server at `/mcp` exposing a curated agent-facing subset of the service layer.
|
||||||
|
|
||||||
|
## Repository Boundary
|
||||||
|
|
||||||
|
LangBot is not a single-repo system.
|
||||||
|
|
||||||
|
- `LangBot/` is the main product: backend, web UI, platform adapters, pipeline engine, HTTP API, MCP server, RAG, persistence, skills integration, and the bridge code that talks to runtimes.
|
||||||
|
- `langbot-plugin-sdk/` is published as `langbot-plugin` and pinned in `LangBot/pyproject.toml`. It contains plugin developer APIs, shared entities, `lbp`, the Plugin Runtime (`lbp rt`), and the Box Runtime (`lbp box`).
|
||||||
|
- Plugins import SDK APIs from `langbot_plugin.*`; the LangBot main process imports the same package for shared entities and runtime protocols.
|
||||||
|
|
||||||
|
This split matters. If a change modifies SDK entities, component APIs, action protocols, `lbp rt`, or `lbp box`, verify the sibling SDK repo and install the local SDK into LangBot's virtualenv when testing cross-repo behavior.
|
||||||
|
|
||||||
|
## Startup Path
|
||||||
|
|
||||||
|
The process entrypoint is small and layered:
|
||||||
|
|
||||||
|
1. `main.py` delegates to `langbot.__main__.main()`.
|
||||||
|
2. `src/langbot/__main__.py` parses `--standalone-runtime`, `--standalone-box`, and `--debug`, checks dependencies, generates missing config/data files, and calls `pkg.core.boot.main()`.
|
||||||
|
3. `pkg/core/boot.py` executes startup stages in order: `LoadConfigStage`, `GenKeysStage`, `SetupLoggerStage`, `BuildAppStage`, `ShowNotesStage`.
|
||||||
|
4. `BuildAppStage` constructs the `Application` object by wiring managers, services, runtime connectors, and controllers.
|
||||||
|
5. `Application.run()` starts the platform manager, query controller, HTTP controller, telemetry/cleanup loops, and plugin initialization.
|
||||||
|
|
||||||
|
The central runtime object is `pkg/core/app.py::Application`. It is a service locator for long-lived managers. That is not elegant, but it is the current architectural center; most subsystems receive `ap: Application` and collaborate through it.
|
||||||
|
|
||||||
|
## Top-Level Layout
|
||||||
|
|
||||||
|
```text
|
||||||
|
LangBot/
|
||||||
|
├── main.py # Entrypoint shim
|
||||||
|
├── pyproject.toml # Python package, deps, pinned langbot-plugin
|
||||||
|
├── src/langbot/
|
||||||
|
│ ├── __main__.py # CLI entrypoint and boot handoff
|
||||||
|
│ ├── pkg/
|
||||||
|
│ │ ├── core/ # Application, boot stages, task manager
|
||||||
|
│ │ ├── api/ # HTTP API + MCP server mount
|
||||||
|
│ │ ├── platform/ # IM adapters and runtime bot manager
|
||||||
|
│ │ ├── pipeline/ # Message routing and pipeline stages
|
||||||
|
│ │ ├── provider/ # LLM runners, model manager, tools
|
||||||
|
│ │ ├── plugin/ # LangBot-side Plugin Runtime connector/handler
|
||||||
|
│ │ ├── box/ # LangBot-side Box service/connector
|
||||||
|
│ │ ├── skill/ # Skill metadata/activation integration
|
||||||
|
│ │ ├── rag/ , vector/ # Knowledge-base and vector DB integration
|
||||||
|
│ │ ├── persistence/ # SQLAlchemy/SQLModel, Alembic, legacy migrations
|
||||||
|
│ │ ├── storage/ # Local/S3 file storage abstraction
|
||||||
|
│ │ └── config/, entity/, utils/, telemetry/, survey/
|
||||||
|
│ ├── libs/ # Vendored third-party platform SDKs
|
||||||
|
│ └── templates/ # Default config and component metadata
|
||||||
|
├── web/ # Vite + React Router + shadcn/ui + Tailwind SPA
|
||||||
|
├── docker/ # Deployment manifests
|
||||||
|
├── skills/ # In-repo agent skills, single source of truth
|
||||||
|
└── tests/ # Unit/integration/e2e/manual tests
|
||||||
|
```
|
||||||
|
|
||||||
|
## The Runtime Graph
|
||||||
|
|
||||||
|
The most useful mental model is this graph:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Platform adapter
|
||||||
|
→ RuntimeBot
|
||||||
|
→ MessageAggregator
|
||||||
|
→ QueryPool
|
||||||
|
→ Controller
|
||||||
|
→ RuntimePipeline
|
||||||
|
→ PipelineStage chain
|
||||||
|
→ RequestRunner / ToolManager / PluginRuntimeConnector / BoxService
|
||||||
|
→ response via adapter
|
||||||
|
```
|
||||||
|
|
||||||
|
The HTTP and MCP surfaces are parallel entrypoints into the same service layer:
|
||||||
|
|
||||||
|
```text
|
||||||
|
HTTP client / Web UI
|
||||||
|
→ Quart route group
|
||||||
|
→ api/http/service/*
|
||||||
|
→ Application managers / persistence / runtime connectors
|
||||||
|
|
||||||
|
MCP client
|
||||||
|
→ /mcp mount
|
||||||
|
→ api/mcp/server.py tools
|
||||||
|
→ the same service layer directly
|
||||||
|
```
|
||||||
|
|
||||||
|
## Message Flow
|
||||||
|
|
||||||
|
Inbound platform messages enter through adapter-specific SDK callbacks. The common path is:
|
||||||
|
|
||||||
|
1. A platform adapter under `pkg/platform/sources/` converts platform-specific events into SDK message/event entities.
|
||||||
|
2. `RuntimeBot` in `pkg/platform/botmgr.py` applies pipeline routing rules and either discards the message, pushes it to webhooks, or sends it to the message aggregator.
|
||||||
|
3. `MessageAggregator` batches/normalizes messages before adding a `Query` to `QueryPool`.
|
||||||
|
4. `Controller` in `pkg/pipeline/controller.py` selects queries subject to global pipeline concurrency and per-session concurrency.
|
||||||
|
5. `RuntimePipeline` in `pkg/pipeline/pipelinemgr.py` runs configured pipeline stages using a responsibility-chain style executor that supports generator stages.
|
||||||
|
6. The chat stage emits plugin events, calls a configured `RequestRunner`, handles streaming/non-streaming responses, records telemetry, and appends conversation history.
|
||||||
|
7. Output stages send text, cards, chunks, files, or error notices back through the original platform adapter.
|
||||||
|
|
||||||
|
Pipeline components are registered by decorators and package import side effects. When adding a new stage, loader, runner, or adapter, check the corresponding preregistration mechanism instead of inventing a second registry.
|
||||||
|
|
||||||
|
## Platform Layer
|
||||||
|
|
||||||
|
Platform code lives under `pkg/platform/`.
|
||||||
|
|
||||||
|
- `botmgr.py` owns runtime bots, routing rules, event logging, webhook pushing, and adapter lifecycle.
|
||||||
|
- `sources/` contains adapter implementations. Each adapter subclasses `langbot_plugin.api.definition.abstract.platform.adapter.AbstractMessagePlatformAdapter` from the SDK.
|
||||||
|
- Platform entities such as `MessageChain`, `Image`, `At`, `Voice`, and events come from `langbot-plugin-sdk`, not from this repo.
|
||||||
|
|
||||||
|
The platform layer should translate between external platform APIs and LangBot's shared message/event model. It should not contain LLM-provider logic or pipeline business logic.
|
||||||
|
|
||||||
|
## Pipeline Layer
|
||||||
|
|
||||||
|
Pipeline code lives under `pkg/pipeline/`.
|
||||||
|
|
||||||
|
Important pieces:
|
||||||
|
|
||||||
|
- `pool.py::QueryPool` stores pending queries and cached in-flight queries for plugin backward-compatible calls.
|
||||||
|
- `controller.py::Controller` schedules query processing and enforces concurrency.
|
||||||
|
- `pipelinemgr.py::RuntimePipeline` materializes database pipeline config into a runtime stage chain.
|
||||||
|
- `process/handlers/chat.py::ChatMessageHandler` is the main LLM conversation handler.
|
||||||
|
- Stage families include response rules, banned sessions, content filters, preprocessors, rate limits, message truncation, long text handling, response-back, command handling, and wrappers.
|
||||||
|
|
||||||
|
Pipelines are configuration-driven. Prefer adding a stage or extending an existing stage family over hard-coding behavior in platform adapters.
|
||||||
|
|
||||||
|
## Provider, RAG, and Tools
|
||||||
|
|
||||||
|
Provider code lives under `pkg/provider/`.
|
||||||
|
|
||||||
|
- `modelmgr/` manages configured model providers and requesters.
|
||||||
|
- `runners/` implements request runners such as the local agent runner and external workflow integrations.
|
||||||
|
- `tools/toolmgr.py` aggregates tools from native tools, plugin tools, external MCP servers, and skill-authoring tools.
|
||||||
|
- `tools/loaders/mcp.py` is the MCP client side: external MCP servers that LangBot connects to for agent tools.
|
||||||
|
- RAG lives across `pkg/rag/`, `pkg/vector/`, model services, and plugin KnowledgeEngine actions.
|
||||||
|
|
||||||
|
Do not confuse LangBot's MCP client side with LangBot's own MCP server at `/mcp`; they are different surfaces.
|
||||||
|
|
||||||
|
## Plugin System
|
||||||
|
|
||||||
|
The plugin system crosses the repo boundary.
|
||||||
|
|
||||||
|
In this repo:
|
||||||
|
|
||||||
|
- `pkg/plugin/connector.py` connects LangBot to the Plugin Runtime over stdio or WebSocket.
|
||||||
|
- `pkg/plugin/handler.py` exposes LangBot actions to the runtime and calls runtime actions for plugin operations.
|
||||||
|
- `pkg/provider/tools/loaders/plugin.py` exposes plugin Tool components to LLM runners.
|
||||||
|
- Pipeline handlers emit SDK events such as normal-message events and prompt-processing events.
|
||||||
|
|
||||||
|
In `langbot-plugin-sdk`:
|
||||||
|
|
||||||
|
- `src/langbot_plugin/api/` defines `BasePlugin`, component base classes, message/event entities, contexts, proxies, and manifests.
|
||||||
|
- `src/langbot_plugin/runtime/` implements `lbp rt`, plugin discovery, dependency installation, process launching, and control/debug connections.
|
||||||
|
- `src/langbot_plugin/entities/io/` defines the action protocol shared by LangBot, runtime, and plugin processes.
|
||||||
|
|
||||||
|
The Plugin Runtime supports stdio and WebSocket control transports. Direct local LangBot runs usually spawn the runtime over stdio. Containerized/standalone deployments connect over WebSocket using `plugin.runtime_ws_url` and `--standalone-runtime`.
|
||||||
|
|
||||||
|
## Box Runtime and Skills
|
||||||
|
|
||||||
|
Box is the sandbox subsystem used by native agent tools, stdio MCP servers, skill authoring, and managed processes.
|
||||||
|
|
||||||
|
In this repo:
|
||||||
|
|
||||||
|
- `pkg/box/service.py` is the application-facing facade for exec, sessions, managed processes, skill CRUD, status, reconnects, quotas, mounts, and sandbox profiles.
|
||||||
|
- `pkg/box/connector.py` connects to the Box Runtime over stdio, Windows subprocess+WebSocket, or remote WebSocket.
|
||||||
|
- `pkg/provider/tools/loaders/native.py`, `mcp_stdio.py`, and skill loaders depend on Box availability.
|
||||||
|
- `pkg/skill/manager.py` loads skills from the Box runtime, falling back to local `data/skills` when needed.
|
||||||
|
|
||||||
|
In `langbot-plugin-sdk`:
|
||||||
|
|
||||||
|
- `src/langbot_plugin/box/server.py` implements `lbp box` and the WebSocket endpoints on `:5410`.
|
||||||
|
- `src/langbot_plugin/box/runtime.py` owns sandbox sessions and managed processes.
|
||||||
|
- `backend.py`, `nsjail_backend.py`, and `e2b_backend.py` implement sandbox backends.
|
||||||
|
- `skill_store.py` manages skill packages from the Box side.
|
||||||
|
|
||||||
|
Important config keys live under `box:` in `src/langbot/templates/config.yaml`: `box.enabled`, `box.backend`, `box.runtime.endpoint`, and `box.local.*`. Start LangBot with `--standalone-box` when connecting to an externally launched Box runtime.
|
||||||
|
|
||||||
|
## HTTP API, Web UI, and MCP Server
|
||||||
|
|
||||||
|
`pkg/api/http/controller/main.py` builds a Quart app, registers route groups, serves the built SPA, and wraps the ASGI app with the MCP dispatcher.
|
||||||
|
|
||||||
|
- HTTP route groups live under `pkg/api/http/controller/groups/`.
|
||||||
|
- Service-layer logic lives under `pkg/api/http/service/`.
|
||||||
|
- The built web UI is served from the frontend build path with SPA fallback.
|
||||||
|
- The MCP server lives under `pkg/api/mcp/` and is mounted at `/mcp`.
|
||||||
|
|
||||||
|
The MCP server intentionally exposes a curated subset of the API. Tools call service classes directly rather than making HTTP requests back into LangBot.
|
||||||
|
|
||||||
|
Maintenance rule: when adding, removing, or changing an HTTP endpoint that should be agent-accessible, update the matching MCP tool and the relevant in-repo skill under `skills/` in the same pass.
|
||||||
|
|
||||||
|
## Persistence and Configuration
|
||||||
|
|
||||||
|
Persistence is centered on `pkg/persistence/mgr.py`.
|
||||||
|
|
||||||
|
- SQLite is the default database; PostgreSQL is supported.
|
||||||
|
- Models live under `pkg/entity/persistence/`.
|
||||||
|
- Fresh schemas are created from metadata, then legacy migrations run up to the frozen 3.x baseline, then Alembic migrations run to head.
|
||||||
|
- New schema changes should use Alembic under `pkg/persistence/alembic/versions/`; do not extend the frozen legacy migration chain.
|
||||||
|
|
||||||
|
Configuration starts from `src/langbot/templates/config.yaml` and is generated into `data/config.yaml` on first run. Most long-lived managers read from `ap.instance_config.data`.
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
|
||||||
|
The frontend lives in `web/` and is a Vite SPA using React Router 7, shadcn/ui, Tailwind CSS, and pnpm. It is not Next.js, despite some historical filenames.
|
||||||
|
|
||||||
|
In development, `pnpm dev` serves the UI on `:3000` and reads `VITE_API_BASE_URL` to call the backend on `:5300`. In production, the built frontend is packaged into the Python distribution and served by the backend.
|
||||||
|
|
||||||
|
Keep frontend API behavior aligned with `pkg/api/http/service/` and route groups. User-facing strings must go through the existing i18n setup.
|
||||||
|
|
||||||
|
## Agent-Facing Surfaces
|
||||||
|
|
||||||
|
LangBot is deliberately agent-friendly. The agent-facing surfaces are part of the architecture, not extra docs.
|
||||||
|
|
||||||
|
- `skills/` is the single source of truth for in-repo skills.
|
||||||
|
- `pkg/api/mcp/server.py` exposes the LangBot MCP server at `/mcp`.
|
||||||
|
- `api.global_api_key` authenticates API/MCP access without a browser login.
|
||||||
|
- `AGENTS.md` and `ARCHITECTURE.md` tell coding agents how the repo works.
|
||||||
|
|
||||||
|
When one of these changes, update the others if the behavior or contract changed. API, MCP tools, and skills are one system; drift is a bug.
|
||||||
|
|
||||||
|
## Where to Change Things
|
||||||
|
|
||||||
|
- New HTTP API: add/adjust a service in `pkg/api/http/service/`, a route group in `pkg/api/http/controller/groups/`, tests, and MCP/skills if agent-accessible.
|
||||||
|
- New platform adapter: add a `pkg/platform/sources/*` adapter, component metadata/templates as needed, i18n, docs, and tests/smoke coverage.
|
||||||
|
- New pipeline behavior: add or extend a pipeline stage family under `pkg/pipeline/`; avoid putting pipeline rules in adapters.
|
||||||
|
- New LLM provider/requester: work under `pkg/provider/modelmgr/` and related service/UI surfaces.
|
||||||
|
- New LLM tool source: extend `pkg/provider/tools/loaders/` and `ToolManager` intentionally.
|
||||||
|
- New plugin component/API/protocol: change `langbot-plugin-sdk` first or in lockstep, then update LangBot bridge code.
|
||||||
|
- New Box capability: change both `pkg/box/` and `langbot-plugin-sdk/src/langbot_plugin/box/`, plus config and tests.
|
||||||
|
- New database schema: add an Alembic migration, not a legacy `dbmXXX` migration.
|
||||||
|
|
||||||
|
## Design Biases
|
||||||
|
|
||||||
|
- Keep platform translation, pipeline orchestration, provider execution, and runtime protocols separate.
|
||||||
|
- Reuse existing registries and service layers instead of adding parallel paths.
|
||||||
|
- Prefer small, explicit agent surfaces over exposing every internal API.
|
||||||
|
- Treat cross-repo contracts with the SDK as public interfaces.
|
||||||
|
- Test behavior at the narrowest useful layer first, then add integration/e2e coverage for runtime or platform changes.
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
<a href="https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light" alt="LangBot - Production-grade IM bot made easy. | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
<a href="https://www.producthunt.com/products/langbot/launches/langbot?embed=true&utm_source=badge-featured&utm_medium=badge&utm_campaign=badge-langbot" target="_blank" rel="noopener noreferrer"><img alt="LangBot - Easy-to-use global IM bot platform designed for the LLM era | Product Hunt" width="250" height="54" src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=979554&theme=light&t=1782822143403"></a>
|
||||||
|
|
||||||
<h3>Production-grade platform for building agentic IM bots.</h3>
|
<h3>Production-grade platform for building agentic IM bots.</h3>
|
||||||
<h4>Quickly build, debug, and ship AI bots to Slack, Discord, Telegram, WeChat, and more.</h4>
|
<h4>Quickly build, debug, and ship AI bots to Slack, Discord, Telegram, WeChat, and more.</h4>
|
||||||
@@ -136,7 +136,7 @@ docker compose --profile all up -d
|
|||||||
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | GPU Platform | ✅ |
|
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | GPU Platform | ✅ |
|
||||||
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPU Platform | ✅ |
|
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPU Platform | ✅ |
|
||||||
| [接口 AI](https://jiekou.ai/) | Gateway | ✅ |
|
| [接口 AI](https://jiekou.ai/) | Gateway | ✅ |
|
||||||
| [302.AI](https://share.302.ai/SuTG99) | Gateway | ✅ |
|
| [302.AI](https://share.302ai.cn/SuTG99) | Gateway | ✅ |
|
||||||
| [Qiniu](https://www.qiniu.com/ai/agent) | Gateway | ✅ |
|
| [Qiniu](https://www.qiniu.com/ai/agent) | Gateway | ✅ |
|
||||||
|
|
||||||
[→ View all integrations](https://link.langbot.app/en/docs/features)
|
[→ View all integrations](https://link.langbot.app/en/docs/features)
|
||||||
|
|||||||
+1
-1
@@ -136,7 +136,7 @@ docker compose --profile all up -d
|
|||||||
| [优云智算](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | GPU 平台 | ✅ |
|
| [优云智算](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | GPU 平台 | ✅ |
|
||||||
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | GPU 平台 | ✅ |
|
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | GPU 平台 | ✅ |
|
||||||
| [接口 AI](https://jiekou.ai/) | 聚合平台 | ✅ |
|
| [接口 AI](https://jiekou.ai/) | 聚合平台 | ✅ |
|
||||||
| [302.AI](https://share.302.ai/SuTG99) | 聚合平台 | ✅ |
|
| [302.AI](https://share.302ai.cn/SuTG99) | 聚合平台 | ✅ |
|
||||||
| [小马算力](https://www.tokenpony.cn/453z1) | 聚合平台 | ✅ |
|
| [小马算力](https://www.tokenpony.cn/453z1) | 聚合平台 | ✅ |
|
||||||
| [百宝箱Tbox](https://www.tbox.cn/open) | 智能体平台 | ✅ |
|
| [百宝箱Tbox](https://www.tbox.cn/open) | 智能体平台 | ✅ |
|
||||||
| [七牛云Qiniu](https://www.qiniu.com/ai/agent) | 聚合平台 | ✅ |
|
| [七牛云Qiniu](https://www.qiniu.com/ai/agent) | 聚合平台 | ✅ |
|
||||||
|
|||||||
+2
-2
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
<a href="https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light" alt="LangBot - Production-grade IM bot made easy. | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
<a href="https://www.producthunt.com/products/langbot/launches/langbot?embed=true&utm_source=badge-featured&utm_medium=badge&utm_campaign=badge-langbot" target="_blank" rel="noopener noreferrer"><img alt="LangBot - Easy-to-use global IM bot platform designed for the LLM era | Product Hunt" width="250" height="54" src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=979554&theme=light&t=1782822143403"></a>
|
||||||
|
|
||||||
<h3>Plataforma de grado de producción para construir bots de mensajería instantánea con agentes de IA.</h3>
|
<h3>Plataforma de grado de producción para construir bots de mensajería instantánea con agentes de IA.</h3>
|
||||||
<h4>Construya, depure y despliegue bots de IA rápidamente en Slack, Discord, Telegram, WeChat y más.</h4>
|
<h4>Construya, depure y despliegue bots de IA rápidamente en Slack, Discord, Telegram, WeChat y más.</h4>
|
||||||
@@ -135,7 +135,7 @@ docker compose --profile all up -d
|
|||||||
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | Plataforma GPU | ✅ |
|
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | Plataforma GPU | ✅ |
|
||||||
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Plataforma GPU | ✅ |
|
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Plataforma GPU | ✅ |
|
||||||
| [接口 AI](https://jiekou.ai/) | Pasarela | ✅ |
|
| [接口 AI](https://jiekou.ai/) | Pasarela | ✅ |
|
||||||
| [302.AI](https://share.302.ai/SuTG99) | Pasarela | ✅ |
|
| [302.AI](https://share.302ai.cn/SuTG99) | Pasarela | ✅ |
|
||||||
| [Qiniu](https://www.qiniu.com/ai/agent) | Pasarela | ✅ |
|
| [Qiniu](https://www.qiniu.com/ai/agent) | Pasarela | ✅ |
|
||||||
|
|
||||||
[→ Ver todas las integraciones](https://link.langbot.app/en/docs/features)
|
[→ Ver todas las integraciones](https://link.langbot.app/en/docs/features)
|
||||||
|
|||||||
+2
-2
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
<a href="https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light" alt="LangBot - Production-grade IM bot made easy. | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
<a href="https://www.producthunt.com/products/langbot/launches/langbot?embed=true&utm_source=badge-featured&utm_medium=badge&utm_campaign=badge-langbot" target="_blank" rel="noopener noreferrer"><img alt="LangBot - Easy-to-use global IM bot platform designed for the LLM era | Product Hunt" width="250" height="54" src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=979554&theme=light&t=1782822143403"></a>
|
||||||
|
|
||||||
<h3>Plateforme de niveau production pour construire des bots de messagerie instantanée avec agents IA.</h3>
|
<h3>Plateforme de niveau production pour construire des bots de messagerie instantanée avec agents IA.</h3>
|
||||||
<h4>Créez, déboguez et déployez rapidement des bots IA sur Slack, Discord, Telegram, WeChat et plus.</h4>
|
<h4>Créez, déboguez et déployez rapidement des bots IA sur Slack, Discord, Telegram, WeChat et plus.</h4>
|
||||||
@@ -132,7 +132,7 @@ docker compose --profile all up -d
|
|||||||
| [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | Passerelle | ✅ |
|
| [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | Passerelle | ✅ |
|
||||||
| [GiteeAI](https://ai.gitee.com/) | Passerelle | ✅ |
|
| [GiteeAI](https://ai.gitee.com/) | Passerelle | ✅ |
|
||||||
| [接口 AI](https://jiekou.ai/) | Passerelle | ✅ |
|
| [接口 AI](https://jiekou.ai/) | Passerelle | ✅ |
|
||||||
| [302.AI](https://share.302.ai/SuTG99) | Passerelle | ✅ |
|
| [302.AI](https://share.302ai.cn/SuTG99) | Passerelle | ✅ |
|
||||||
| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | Plateforme GPU | ✅ |
|
| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | Plateforme GPU | ✅ |
|
||||||
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | Plateforme GPU | ✅ |
|
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | Plateforme GPU | ✅ |
|
||||||
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Plateforme GPU | ✅ |
|
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Plateforme GPU | ✅ |
|
||||||
|
|||||||
+2
-2
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
<a href="https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light" alt="LangBot - Production-grade IM bot made easy. | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
<a href="https://www.producthunt.com/products/langbot/launches/langbot?embed=true&utm_source=badge-featured&utm_medium=badge&utm_campaign=badge-langbot" target="_blank" rel="noopener noreferrer"><img alt="LangBot - Easy-to-use global IM bot platform designed for the LLM era | Product Hunt" width="250" height="54" src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=979554&theme=light&t=1782822143403"></a>
|
||||||
|
|
||||||
<h3>AIエージェント搭載IMボットを構築するための本番グレードプラットフォーム。</h3>
|
<h3>AIエージェント搭載IMボットを構築するための本番グレードプラットフォーム。</h3>
|
||||||
<h4>Slack、Discord、Telegram、WeChat などに AI ボットを素早く構築、デバッグ、デプロイ。</h4>
|
<h4>Slack、Discord、Telegram、WeChat などに AI ボットを素早く構築、デバッグ、デプロイ。</h4>
|
||||||
@@ -135,7 +135,7 @@ docker compose --profile all up -d
|
|||||||
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | GPUプラットフォーム | ✅ |
|
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | GPUプラットフォーム | ✅ |
|
||||||
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPUプラットフォーム | ✅ |
|
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPUプラットフォーム | ✅ |
|
||||||
| [接口 AI](https://jiekou.ai/) | ゲートウェイ | ✅ |
|
| [接口 AI](https://jiekou.ai/) | ゲートウェイ | ✅ |
|
||||||
| [302.AI](https://share.302.ai/SuTG99) | ゲートウェイ | ✅ |
|
| [302.AI](https://share.302ai.cn/SuTG99) | ゲートウェイ | ✅ |
|
||||||
| [Qiniu](https://www.qiniu.com/ai/agent) | ゲートウェイ | ✅ |
|
| [Qiniu](https://www.qiniu.com/ai/agent) | ゲートウェイ | ✅ |
|
||||||
|
|
||||||
[→ すべての統合を表示](https://link.langbot.app/en/docs/features)
|
[→ すべての統合を表示](https://link.langbot.app/en/docs/features)
|
||||||
|
|||||||
+2
-2
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
<a href="https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light" alt="LangBot - Production-grade IM bot made easy. | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
<a href="https://www.producthunt.com/products/langbot/launches/langbot?embed=true&utm_source=badge-featured&utm_medium=badge&utm_campaign=badge-langbot" target="_blank" rel="noopener noreferrer"><img alt="LangBot - Easy-to-use global IM bot platform designed for the LLM era | Product Hunt" width="250" height="54" src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=979554&theme=light&t=1782822143403"></a>
|
||||||
|
|
||||||
<h3>AI 에이전트 IM 봇 구축을 위한 프로덕션 등급 플랫폼.</h3>
|
<h3>AI 에이전트 IM 봇 구축을 위한 프로덕션 등급 플랫폼.</h3>
|
||||||
<h4>Slack, Discord, Telegram, WeChat 등에 AI 봇을 빠르게 구축, 디버그 및 배포.</h4>
|
<h4>Slack, Discord, Telegram, WeChat 등에 AI 봇을 빠르게 구축, 디버그 및 배포.</h4>
|
||||||
@@ -135,7 +135,7 @@ docker compose --profile all up -d
|
|||||||
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | GPU 플랫폼 | ✅ |
|
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | GPU 플랫폼 | ✅ |
|
||||||
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPU 플랫폼 | ✅ |
|
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPU 플랫폼 | ✅ |
|
||||||
| [接口 AI](https://jiekou.ai/) | 게이트웨이 | ✅ |
|
| [接口 AI](https://jiekou.ai/) | 게이트웨이 | ✅ |
|
||||||
| [302.AI](https://share.302.ai/SuTG99) | 게이트웨이 | ✅ |
|
| [302.AI](https://share.302ai.cn/SuTG99) | 게이트웨이 | ✅ |
|
||||||
| [Qiniu](https://www.qiniu.com/ai/agent) | 게이트웨이 | ✅ |
|
| [Qiniu](https://www.qiniu.com/ai/agent) | 게이트웨이 | ✅ |
|
||||||
|
|
||||||
[→ 모든 통합 보기](https://link.langbot.app/en/docs/features)
|
[→ 모든 통합 보기](https://link.langbot.app/en/docs/features)
|
||||||
|
|||||||
+2
-2
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
<a href="https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light" alt="LangBot - Production-grade IM bot made easy. | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
<a href="https://www.producthunt.com/products/langbot/launches/langbot?embed=true&utm_source=badge-featured&utm_medium=badge&utm_campaign=badge-langbot" target="_blank" rel="noopener noreferrer"><img alt="LangBot - Easy-to-use global IM bot platform designed for the LLM era | Product Hunt" width="250" height="54" src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=979554&theme=light&t=1782822143403"></a>
|
||||||
|
|
||||||
<h3>Платформа производственного уровня для создания агентных IM-ботов.</h3>
|
<h3>Платформа производственного уровня для создания агентных IM-ботов.</h3>
|
||||||
<h4>Быстро создавайте, отлаживайте и развертывайте ИИ-ботов в Slack, Discord, Telegram, WeChat и других платформах.</h4>
|
<h4>Быстро создавайте, отлаживайте и развертывайте ИИ-ботов в Slack, Discord, Telegram, WeChat и других платформах.</h4>
|
||||||
@@ -131,7 +131,7 @@ docker compose --profile all up -d
|
|||||||
| [Volc Engine Ark](https://console.volcengine.com/ark/region:ark+cn-beijing/model?vendor=Bytedance&view=LIST_VIEW) | Шлюз | ✅ |
|
| [Volc Engine Ark](https://console.volcengine.com/ark/region:ark+cn-beijing/model?vendor=Bytedance&view=LIST_VIEW) | Шлюз | ✅ |
|
||||||
| [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | Шлюз | ✅ |
|
| [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | Шлюз | ✅ |
|
||||||
| [GiteeAI](https://ai.gitee.com/) | Шлюз | ✅ |
|
| [GiteeAI](https://ai.gitee.com/) | Шлюз | ✅ |
|
||||||
| [302.AI](https://share.302.ai/SuTG99) | Шлюз | ✅ |
|
| [302.AI](https://share.302ai.cn/SuTG99) | Шлюз | ✅ |
|
||||||
| [接口 AI](https://jiekou.ai/) | Шлюз | ✅ |
|
| [接口 AI](https://jiekou.ai/) | Шлюз | ✅ |
|
||||||
| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | Платформа GPU | ✅ |
|
| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | Платформа GPU | ✅ |
|
||||||
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | Платформа GPU | ✅ |
|
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | Платформа GPU | ✅ |
|
||||||
|
|||||||
+1
-1
@@ -137,7 +137,7 @@ docker compose --profile all up -d
|
|||||||
| [優雲智算](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | GPU 平台 | ✅ |
|
| [優雲智算](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | GPU 平台 | ✅ |
|
||||||
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | GPU 平台 | ✅ |
|
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | GPU 平台 | ✅ |
|
||||||
| [接口 AI](https://jiekou.ai/) | 聚合平台 | ✅ |
|
| [接口 AI](https://jiekou.ai/) | 聚合平台 | ✅ |
|
||||||
| [302.AI](https://share.302.ai/SuTG99) | 聚合平台 | ✅ |
|
| [302.AI](https://share.302ai.cn/SuTG99) | 聚合平台 | ✅ |
|
||||||
| [Qiniu](https://www.qiniu.com/ai/agent) | 聚合平台 | ✅ |
|
| [Qiniu](https://www.qiniu.com/ai/agent) | 聚合平台 | ✅ |
|
||||||
|
|
||||||
### TTS(語音合成)
|
### TTS(語音合成)
|
||||||
|
|||||||
+2
-2
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
<a href="https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light" alt="LangBot - Production-grade IM bot made easy. | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
<a href="https://www.producthunt.com/products/langbot/launches/langbot?embed=true&utm_source=badge-featured&utm_medium=badge&utm_campaign=badge-langbot" target="_blank" rel="noopener noreferrer"><img alt="LangBot - Easy-to-use global IM bot platform designed for the LLM era | Product Hunt" width="250" height="54" src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=979554&theme=light&t=1782822143403"></a>
|
||||||
|
|
||||||
<h3>Nền tảng cấp sản xuất để xây dựng bot IM với AI agent.</h3>
|
<h3>Nền tảng cấp sản xuất để xây dựng bot IM với AI agent.</h3>
|
||||||
<h4>Xây dựng, gỡ lỗi và triển khai bot AI nhanh chóng trên Slack, Discord, Telegram, WeChat và nhiều nền tảng khác.</h4>
|
<h4>Xây dựng, gỡ lỗi và triển khai bot AI nhanh chóng trên Slack, Discord, Telegram, WeChat và nhiều nền tảng khác.</h4>
|
||||||
@@ -135,7 +135,7 @@ docker compose --profile all up -d
|
|||||||
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | Nền tảng GPU | ✅ |
|
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | Nền tảng GPU | ✅ |
|
||||||
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Nền tảng GPU | ✅ |
|
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Nền tảng GPU | ✅ |
|
||||||
| [接口 AI](https://jiekou.ai/) | Cổng | ✅ |
|
| [接口 AI](https://jiekou.ai/) | Cổng | ✅ |
|
||||||
| [302.AI](https://share.302.ai/SuTG99) | Cổng | ✅ |
|
| [302.AI](https://share.302ai.cn/SuTG99) | Cổng | ✅ |
|
||||||
| [Qiniu](https://www.qiniu.com/ai/agent) | Cổng | ✅ |
|
| [Qiniu](https://www.qiniu.com/ai/agent) | Cổng | ✅ |
|
||||||
|
|
||||||
[→ Xem tất cả tích hợp](https://link.langbot.app/en/docs/features)
|
[→ Xem tất cả tích hợp](https://link.langbot.app/en/docs/features)
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Compare YAML node definitions with frontend node-configs."""
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import json
|
||||||
|
|
||||||
|
# 1. Parse YAML files
|
||||||
|
yaml_dir = 'src/langbot/templates/metadata/nodes'
|
||||||
|
yaml_nodes = {}
|
||||||
|
|
||||||
|
for filename in sorted(os.listdir(yaml_dir)):
|
||||||
|
if filename.endswith('.yaml'):
|
||||||
|
filepath = os.path.join(yaml_dir, filename)
|
||||||
|
with open(filepath, 'r') as f:
|
||||||
|
data = yaml.safe_load(f)
|
||||||
|
node_name = data.get('name', filename.replace('.yaml', ''))
|
||||||
|
yaml_nodes[node_name] = {
|
||||||
|
'category': data.get('category', ''),
|
||||||
|
'inputs': [i['name'] for i in data.get('inputs', [])],
|
||||||
|
'outputs': [o['name'] for o in data.get('outputs', [])],
|
||||||
|
'config': [c['name'] for c in data.get('config', [])]
|
||||||
|
}
|
||||||
|
|
||||||
|
# 2. Parse frontend node-configs TypeScript files
|
||||||
|
node_configs_dir = 'web/src/app/home/workflows/components/workflow-editor/node-configs'
|
||||||
|
|
||||||
|
frontend_nodes = {}
|
||||||
|
|
||||||
|
def parse_ts_file(filepath):
|
||||||
|
"""Parse a TypeScript file to extract node configurations."""
|
||||||
|
with open(filepath, 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Find all node type definitions
|
||||||
|
# Pattern: nodeType: 'xxx'
|
||||||
|
node_type_pattern = r"nodeType:\s*'([^']+)'"
|
||||||
|
node_types = re.findall(node_type_pattern, content)
|
||||||
|
|
||||||
|
# For each node type, extract inputs, outputs, and config
|
||||||
|
for node_type in node_types:
|
||||||
|
# Find the config object for this node type
|
||||||
|
# Look for the section between this nodeType and the next one or end of object
|
||||||
|
pattern = rf"nodeType:\s*'({re.escape(node_type)})'.*?(?=nodeType:|export\s+(const|function)|$)"
|
||||||
|
match = re.search(pattern, content, re.DOTALL)
|
||||||
|
|
||||||
|
if match:
|
||||||
|
section = match.group(0)
|
||||||
|
|
||||||
|
# Extract inputs
|
||||||
|
inputs = re.findall(r"createInput\('([^']+)'", section)
|
||||||
|
|
||||||
|
# Extract outputs
|
||||||
|
outputs = re.findall(r"createOutput\('([^']+)'", section)
|
||||||
|
|
||||||
|
# Extract config names
|
||||||
|
config_names = re.findall(r"name:\s*'([^']+)'", section)
|
||||||
|
# Remove duplicates while preserving order
|
||||||
|
seen = set()
|
||||||
|
unique_config = []
|
||||||
|
for c in config_names:
|
||||||
|
if c not in seen:
|
||||||
|
seen.add(c)
|
||||||
|
unique_config.append(c)
|
||||||
|
|
||||||
|
frontend_nodes[node_type] = {
|
||||||
|
'inputs': inputs,
|
||||||
|
'outputs': outputs,
|
||||||
|
'config': unique_config
|
||||||
|
}
|
||||||
|
|
||||||
|
# Parse all config files
|
||||||
|
for filename in os.listdir(node_configs_dir):
|
||||||
|
if filename.endswith('.ts') and filename != 'types.ts' and filename != 'index.ts':
|
||||||
|
filepath = os.path.join(node_configs_dir, filename)
|
||||||
|
parse_ts_file(filepath)
|
||||||
|
|
||||||
|
# 3. Compare and report differences
|
||||||
|
print("=" * 80)
|
||||||
|
print("WORKFLOW NODE COMPARISON REPORT: YAML vs Frontend")
|
||||||
|
print("=" * 80)
|
||||||
|
|
||||||
|
all_node_types = sorted(set(list(yaml_nodes.keys()) + list(frontend_nodes.keys())))
|
||||||
|
|
||||||
|
discrepancies = []
|
||||||
|
|
||||||
|
for node_type in all_node_types:
|
||||||
|
yaml_def = yaml_nodes.get(node_type)
|
||||||
|
frontend_def = frontend_nodes.get(node_type)
|
||||||
|
|
||||||
|
node_discrepancies = []
|
||||||
|
|
||||||
|
if not yaml_def:
|
||||||
|
print(f"\n⚠️ {node_type}: ONLY in frontend (not in YAML)")
|
||||||
|
continue
|
||||||
|
if not frontend_def:
|
||||||
|
print(f"\n⚠️ {node_type}: ONLY in YAML (not in frontend)")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Compare inputs
|
||||||
|
yaml_inputs = set(yaml_def['inputs'])
|
||||||
|
frontend_inputs = set(frontend_def['inputs'])
|
||||||
|
if yaml_inputs != frontend_inputs:
|
||||||
|
only_yaml = yaml_inputs - frontend_inputs
|
||||||
|
only_frontend = frontend_inputs - yaml_inputs
|
||||||
|
node_discrepancies.append({
|
||||||
|
'type': 'inputs',
|
||||||
|
'only_yaml': list(only_yaml),
|
||||||
|
'only_frontend': list(only_frontend)
|
||||||
|
})
|
||||||
|
|
||||||
|
# Compare outputs
|
||||||
|
yaml_outputs = set(yaml_def['outputs'])
|
||||||
|
frontend_outputs = set(frontend_def['outputs'])
|
||||||
|
if yaml_outputs != frontend_outputs:
|
||||||
|
only_yaml = yaml_outputs - frontend_outputs
|
||||||
|
only_frontend = frontend_outputs - yaml_outputs
|
||||||
|
node_discrepancies.append({
|
||||||
|
'type': 'outputs',
|
||||||
|
'only_yaml': list(only_yaml),
|
||||||
|
'only_frontend': list(only_frontend)
|
||||||
|
})
|
||||||
|
|
||||||
|
# Compare config
|
||||||
|
yaml_config = set(yaml_def['config'])
|
||||||
|
frontend_config = set(frontend_def['config'])
|
||||||
|
if yaml_config != frontend_config:
|
||||||
|
only_yaml = yaml_config - frontend_config
|
||||||
|
only_frontend = frontend_config - yaml_config
|
||||||
|
node_discrepancies.append({
|
||||||
|
'type': 'config',
|
||||||
|
'only_yaml': list(only_yaml),
|
||||||
|
'only_frontend': list(only_frontend)
|
||||||
|
})
|
||||||
|
|
||||||
|
if node_discrepancies:
|
||||||
|
print(f"\n❌ {node_type} ({yaml_def['category']}): HAS DISCREPANCIES")
|
||||||
|
for d in node_discrepancies:
|
||||||
|
print(f" {d['type']}:")
|
||||||
|
if d['only_yaml']:
|
||||||
|
print(f" Only in YAML: {d['only_yaml']}")
|
||||||
|
if d['only_frontend']:
|
||||||
|
print(f" Only in Frontend: {d['only_frontend']}")
|
||||||
|
discrepancies.append((node_type, node_discrepancies))
|
||||||
|
else:
|
||||||
|
print(f"\n✅ {node_type} ({yaml_def['category']}): OK")
|
||||||
|
|
||||||
|
print(f"\n{'=' * 80}")
|
||||||
|
print(f"SUMMARY: {len(discrepancies)} nodes with discrepancies out of {len(all_node_types)} total")
|
||||||
|
print(f"{'=' * 80}")
|
||||||
|
|
||||||
|
# Output as JSON for further processing
|
||||||
|
output = {
|
||||||
|
'yaml_nodes': {k: v for k, v in yaml_nodes.items()},
|
||||||
|
'frontend_nodes': {k: v for k, v in frontend_nodes.items()},
|
||||||
|
'discrepancies': {k: v for k, v in discrepancies}
|
||||||
|
}
|
||||||
|
|
||||||
|
with open('node_comparison.json', 'w') as f:
|
||||||
|
json.dump(output, f, indent=2)
|
||||||
|
|
||||||
|
print(f"\nDetailed comparison saved to node_comparison.json")
|
||||||
@@ -62,11 +62,12 @@ services:
|
|||||||
- TZ=Asia/Shanghai
|
- TZ=Asia/Shanghai
|
||||||
# Unified env-override convention: SECTION__SUBSECTION__KEY overrides the
|
# Unified env-override convention: SECTION__SUBSECTION__KEY overrides the
|
||||||
# matching config.yaml field (see LoadConfigStage). These map onto
|
# matching config.yaml field (see LoadConfigStage). These map onto
|
||||||
# box.local.* and are forwarded to the Box runtime via INIT RPC.
|
# box.* and are forwarded to the Box runtime via INIT RPC.
|
||||||
- BOX__LOCAL__HOST_ROOT=${LANGBOT_BOX_ROOT:-${PWD}/data/box}
|
- BOX__LOCAL__HOST_ROOT=${LANGBOT_BOX_ROOT:-${PWD}/data/box}
|
||||||
- BOX__LOCAL__DEFAULT_WORKSPACE=default
|
- BOX__LOCAL__DEFAULT_WORKSPACE=default
|
||||||
- BOX__LOCAL__SKILLS_ROOT=skills
|
- BOX__LOCAL__SKILLS_ROOT=skills
|
||||||
- BOX__LOCAL__ALLOWED_MOUNT_ROOTS=${LANGBOT_BOX_ROOT:-${PWD}/data/box}
|
- BOX__LOCAL__ALLOWED_MOUNT_ROOTS=${LANGBOT_BOX_ROOT:-${PWD}/data/box}
|
||||||
|
- BOX__DOCKER__CPU_LIMIT_ENABLED=${LANGBOT_BOX_DOCKER_CPU_LIMIT_ENABLED:-true}
|
||||||
ports:
|
ports:
|
||||||
- 5300:5300 # For web ui and webhook callback
|
- 5300:5300 # For web ui and webhook callback
|
||||||
- 2280-2285:2280-2285 # For platform reverse connection
|
- 2280-2285:2280-2285 # For platform reverse connection
|
||||||
|
|||||||
@@ -0,0 +1,575 @@
|
|||||||
|
# HTTP Bot Adapter — Design Document
|
||||||
|
|
||||||
|
> Status: **Implemented** · Branch: `feat/http-bot-adapter` · Author: LangBot core
|
||||||
|
>
|
||||||
|
> A first-class, **standalone** message-platform adapter (`http_bot`) that lets
|
||||||
|
> any external system (e.g. LangBot Space ticketing, an internal back-office, a
|
||||||
|
> CRM, a custom web app) talk to a LangBot pipeline over plain HTTP — **inbound**
|
||||||
|
> by POSTing messages in, **outbound** by receiving replies on a callback URL —
|
||||||
|
> with full support for the pipeline's native N→1 aggregation and 1→M
|
||||||
|
> multi-reply semantics, and **without** holding a long-lived WebSocket
|
||||||
|
> connection.
|
||||||
|
>
|
||||||
|
> **Shipped in this branch:**
|
||||||
|
> - `src/langbot/pkg/platform/sources/http_bot.yaml` — adapter manifest (auto-discovered)
|
||||||
|
> - `src/langbot/pkg/platform/sources/http_bot.py` — `HttpBotAdapter`
|
||||||
|
> - `src/langbot/pkg/platform/sources/http_bot_signing.py` — HMAC helpers
|
||||||
|
> - `src/langbot/pkg/platform/sources/http_bot.svg` — icon
|
||||||
|
> - `docs/platforms/http-bot.md` — integration guide
|
||||||
|
> - `docs/http-bot-openapi.json` — machine-readable contract
|
||||||
|
> - `examples/http-bot/` — Python + TypeScript reference clients
|
||||||
|
>
|
||||||
|
> **Final decisions (resolving the original open questions):**
|
||||||
|
> 1. Callback URL is **config-only** — never accepted per-message (SSRF closed).
|
||||||
|
> 2. **Session reset is provided** — `POST /bots/<uuid>/reset` keyed by `session_id`.
|
||||||
|
> 3. Reference **clients are provided** — `examples/http-bot/client.py` + `client.ts`.
|
||||||
|
> 4. **Sync convenience mode is included** — `POST /bots/<uuid>/sync` (opt-in, lossy).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Background & Motivation
|
||||||
|
|
||||||
|
### 1.1 The concrete need
|
||||||
|
|
||||||
|
LangBot Space wants to use a LangBot pipeline as the brain for **ticket
|
||||||
|
handling**. The integration is **server-to-server**: Space's backend pushes a
|
||||||
|
user's ticket messages into LangBot and renders LangBot's replies back into the
|
||||||
|
ticket thread.
|
||||||
|
|
||||||
|
This interaction is **not** request/response shaped:
|
||||||
|
|
||||||
|
- **N → 1**: a user may fire several messages in a row ("the app crashed" …
|
||||||
|
"when I click export" … "here's a screenshot"). The pipeline's
|
||||||
|
**message aggregation** feature should debounce and merge these into one turn.
|
||||||
|
- **1 → N**: a single turn may yield **multiple** outbound messages — a tool/
|
||||||
|
function call narrating progress, a plugin emitting several cards, a streamed
|
||||||
|
answer split into chunks.
|
||||||
|
|
||||||
|
### 1.2 Why the existing options don't fit
|
||||||
|
|
||||||
|
LangBot today exposes exactly one externally-reachable way to drive a pipeline
|
||||||
|
that is **not** tied to a specific IM vendor: the **WebSocket** path
|
||||||
|
(`/api/v1/pipelines/<uuid>/ws/connect` for dashboard debug, and
|
||||||
|
`/api/v1/embed/<bot_uuid>/ws/connect` for the embeddable web widget).
|
||||||
|
|
||||||
|
For a server-to-server integration the WebSocket path has real friction:
|
||||||
|
|
||||||
|
| Problem | Detail |
|
||||||
|
|---|---|
|
||||||
|
| Long-lived connection | Caller must maintain a socket, heartbeats, and reconnect logic for what is fundamentally a fire-and-collect workload. |
|
||||||
|
| Session identity | Inbound messages are keyed by the transient `connection_id` (`websocket_{connection_id}`); the caller **cannot supply a stable, business-meaningful session id** (e.g. a ticket number). Multi-ticket isolation is not expressible. |
|
||||||
|
| Auth mismatch | The debug socket is gated by the **dashboard JWT** (must not be handed to an external service); the embed socket is gated by **Cloudflare Turnstile** (a *browser* human-check that a backend cannot satisfy). Neither is a server-to-server credential. |
|
||||||
|
| In-memory, single-process state | Session history lives in process memory and is lost on restart. |
|
||||||
|
|
||||||
|
> **Key realisation.** The N→1 / 1→M behaviour the caller wants is **not**
|
||||||
|
> provided by WebSocket — it is provided by the **pipeline** (aggregation +
|
||||||
|
> the adapter being free to call `reply_message` any number of times). It is
|
||||||
|
> therefore **transport-independent**. We can deliver the exact same semantics
|
||||||
|
> over a far lighter HTTP transport.
|
||||||
|
|
||||||
|
### 1.3 Why a *new, standalone* adapter (not a refactor of an existing one)
|
||||||
|
|
||||||
|
The brief is explicit: **do not reuse / fork an existing vendor adapter.** The
|
||||||
|
vendor adapters (`lark`, `wecom`, `qqofficial`, `slack`, …) carry vendor-specific
|
||||||
|
signature schemes, payload shapes, and message-segment mappings. Bending one of
|
||||||
|
them into a "generic" mode would couple a public integration surface to one
|
||||||
|
vendor's quirks and make the developer experience worse for everyone.
|
||||||
|
|
||||||
|
Instead we ship `http_bot` as a clean, independent adapter whose **entire
|
||||||
|
contract is LangBot's own** — documented, versioned, and designed front-to-back
|
||||||
|
around *integrator* developer experience.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Goals & Non-Goals
|
||||||
|
|
||||||
|
### Goals
|
||||||
|
|
||||||
|
- **G1** A standalone `http_bot` adapter, selectable like any other platform
|
||||||
|
adapter in the dashboard, with its own config schema and docs.
|
||||||
|
- **G2** **Inbound**: external systems POST messages to a stable LangBot URL,
|
||||||
|
carrying a **caller-defined `session_id`** that maps 1:1 to a LangBot session.
|
||||||
|
- **G3** **Outbound**: LangBot delivers each reply by POSTing to a
|
||||||
|
caller-configured **callback URL**; one turn may produce **many** callbacks.
|
||||||
|
- **G4** Preserve pipeline-native **N→1 aggregation** and **1→M multi-reply**.
|
||||||
|
- **G5** Server-to-server **auth**: shared-secret HMAC request signing both
|
||||||
|
directions (no JWT, no Turnstile, no long-lived socket).
|
||||||
|
- **G6** **Great DX**: copy-pasteable curl, a tiny reference client, an OpenAPI
|
||||||
|
fragment, idempotency, clear error envelope, and a local echo-server recipe.
|
||||||
|
|
||||||
|
### Non-Goals
|
||||||
|
|
||||||
|
- Not replacing or deprecating the WebSocket / embed widget path (that remains
|
||||||
|
the right tool for *browser*, real-time, streaming chat UIs).
|
||||||
|
- Not a synchronous "one request → one response" RPC (explicitly rejected: it
|
||||||
|
cannot express 1→M; see §9 for the optional sync convenience mode).
|
||||||
|
- No built-in message **persistence/replay** in v1 (callbacks are at-least-once
|
||||||
|
best-effort; durability is the caller's responsibility — see §8).
|
||||||
|
- No multi-tenant API-key management UI in v1 (one secret per bot; see §11).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. How LangBot routes a message (the parts we plug into)
|
||||||
|
|
||||||
|
Understanding the existing flow is what makes this adapter cheap. A message
|
||||||
|
flows through these stages (verified against current `master`):
|
||||||
|
|
||||||
|
```
|
||||||
|
INBOUND OUTBOUND
|
||||||
|
external POST ─┐ ┌─ reply_message()
|
||||||
|
▼ │ reply_message_chunk()
|
||||||
|
POST /bots/<bot_uuid> (unified webhook router, AuthType.NONE)
|
||||||
|
│ webhooks.py → adapter.handle_unified_webhook(bot_uuid, path, request)
|
||||||
|
▼ │
|
||||||
|
HttpBotAdapter.handle_unified_webhook │ (called 0..N times
|
||||||
|
• verify HMAC signature │ per turn by the
|
||||||
|
• parse {session_id, message[]} │ pipeline / plugins)
|
||||||
|
• build FriendMessage / GroupMessage │
|
||||||
|
• fire registered listener ───────────────┐ │
|
||||||
|
│ │ │
|
||||||
|
▼ ▼ │
|
||||||
|
botmgr.on_friend_message / on_group_message │
|
||||||
|
• (optional) webhook_pusher fan-out │
|
||||||
|
• msg_aggregator.add_message(...) ── N→1 debounce ──►│
|
||||||
|
│ │
|
||||||
|
▼ │
|
||||||
|
query_pool → pipeline.run() ─── invokes adapter ─────┘
|
||||||
|
reply methods 1..M times
|
||||||
|
```
|
||||||
|
|
||||||
|
Two framework facts we rely on:
|
||||||
|
|
||||||
|
1. **N→1 aggregation is free.** `botmgr` hands every inbound event to
|
||||||
|
`self.ap.msg_aggregator.add_message(...)`, which debounces per
|
||||||
|
`session_id` and merges consecutive messages into one pipeline turn
|
||||||
|
(`pkg/pipeline/aggregator.py`). The adapter does nothing special.
|
||||||
|
|
||||||
|
2. **1→M is free.** The pipeline (and any plugin in the chain) calls
|
||||||
|
`adapter.reply_message()` / `reply_message_chunk()` **as many times as it
|
||||||
|
wants** per turn. The adapter's only job is to deliver each call outward.
|
||||||
|
For `http_bot` that means: **one outbound callback POST per call.**
|
||||||
|
|
||||||
|
3. **A unified inbound route already exists.** `WebhookRouterGroup`
|
||||||
|
(`pkg/api/http/controller/groups/webhooks.py`) maps
|
||||||
|
`POST /bots/<bot_uuid>[/<path>]` (auth `NONE`) to
|
||||||
|
`adapter.handle_unified_webhook(bot_uuid, path, request)`. `http_bot`
|
||||||
|
implements that method and is reachable **without registering any new
|
||||||
|
route** — it does its own signature verification, exactly like the vendor
|
||||||
|
webhook adapters do.
|
||||||
|
|
||||||
|
> Net new code is essentially: one `http_bot.py` adapter, one `http_bot.yaml`
|
||||||
|
> schema, signing helpers, and docs. No router, aggregator, or pipeline changes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Architecture Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────┐ (1) inbound: POST signed message
|
||||||
|
│ External system │ ──────────────────────────────────────────────► ┌──────────────────────┐
|
||||||
|
│ (LangBot Space, │ POST /bots/<bot_uuid> │ LangBot │
|
||||||
|
│ CRM, web app …) │ X-LB-Signature, X-LB-Timestamp │ │
|
||||||
|
│ │ { session_id, message:[...] } │ HttpBotAdapter │
|
||||||
|
│ - callback server │ ◄────────────────────────────────────────────── │ (platform/sources) │
|
||||||
|
│ (receives │ (4) outbound: POST signed reply(s) │ │
|
||||||
|
│ replies) │ POST <callback_url> │ pipeline + aggregator│
|
||||||
|
└────────────────────┘ X-LB-Signature, X-LB-Timestamp └──────────────────────┘
|
||||||
|
{ session_id, sequence, is_final,
|
||||||
|
message:[...] } (sent 1..M times)
|
||||||
|
```
|
||||||
|
|
||||||
|
- The adapter is **stateless across requests** at the HTTP layer; session
|
||||||
|
continuity is carried by `session_id` and resolved by LangBot's normal
|
||||||
|
session manager.
|
||||||
|
- **Inbound** and **outbound** are **independent HTTP exchanges**. LangBot does
|
||||||
|
not answer the inbound POST with the pipeline result; it `202 Accepts` it and
|
||||||
|
later POSTs the reply(s) to the callback URL. This is what makes 1→M natural.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Configuration Schema (`http_bot.yaml`)
|
||||||
|
|
||||||
|
Follows the existing `MessagePlatformAdapter` manifest convention (cf.
|
||||||
|
`slack.yaml`). Fields:
|
||||||
|
|
||||||
|
| field | type | required | purpose |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `inbound_secret` | string (secret) | yes | HMAC key the **caller** uses to sign inbound POSTs; LangBot verifies. |
|
||||||
|
| `callback_url` | string (url) | no* | Where LangBot POSTs replies. *Optional if the caller supplies `callback_url` per-message (see §6.1); a static default lives here. |
|
||||||
|
| `outbound_secret` | string (secret) | no | HMAC key LangBot uses to sign outbound callbacks; caller verifies. Defaults to `inbound_secret` if empty. |
|
||||||
|
| `default_session_type` | enum `person`/`group` | no | Default when a message omits `session_type`. Default `person`. |
|
||||||
|
| `signature_required` | bool | no | If `false`, skip inbound signature check (dev only; logs a warning). Default `true`. |
|
||||||
|
| `callback_timeout` | int (seconds) | no | Per-callback HTTP timeout. Default `15`. |
|
||||||
|
| `callback_max_retries` | int | no | Retries on 5xx/timeout with backoff. Default `3`. |
|
||||||
|
| `webhook_url` | webhook-url (display) | — | Read-only field rendering the inbound URL `…/bots/<bot_uuid>` for copy-paste, like other webhook adapters. |
|
||||||
|
|
||||||
|
Manifest sketch (i18n labels elided for brevity):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
apiVersion: v1
|
||||||
|
kind: MessagePlatformAdapter
|
||||||
|
metadata:
|
||||||
|
name: http_bot
|
||||||
|
label: { en_US: "HTTP Bot", zh_Hans: "HTTP 通用接入" }
|
||||||
|
description:
|
||||||
|
en_US: "Integrate any backend over plain HTTP. Push messages in, receive replies on a callback URL. Server-to-server, no long-lived connection."
|
||||||
|
zh_Hans: "通过 HTTP 接入任意后端系统。推入消息、在回调地址接收回复。面向服务间集成,无需长连接。"
|
||||||
|
icon: http_bot.svg
|
||||||
|
spec:
|
||||||
|
categories: [popular, global]
|
||||||
|
help_links:
|
||||||
|
zh: https://docs.langbot.app/zh/platforms/http-bot
|
||||||
|
en: https://docs.langbot.app/en/platforms/http-bot
|
||||||
|
config:
|
||||||
|
- { name: inbound_secret, type: string, required: true, default: "" }
|
||||||
|
- { name: callback_url, type: string, required: false, default: "" }
|
||||||
|
- { name: outbound_secret, type: string, required: false, default: "" }
|
||||||
|
- { name: default_session_type, type: select, required: false, default: "person",
|
||||||
|
options: [person, group] }
|
||||||
|
- { name: signature_required, type: boolean, required: false, default: true }
|
||||||
|
- { name: callback_timeout, type: integer, required: false, default: 15 }
|
||||||
|
- { name: callback_max_retries, type: integer, required: false, default: 3 }
|
||||||
|
- { name: webhook_url, type: webhook-url, required: false, default: "" }
|
||||||
|
execution:
|
||||||
|
python:
|
||||||
|
path: ./http_bot.py
|
||||||
|
attr: HttpBotAdapter
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. The HTTP Contract (this is the DX surface)
|
||||||
|
|
||||||
|
### 6.1 Inbound — push a message into LangBot
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /bots/{bot_uuid}
|
||||||
|
Content-Type: application/json
|
||||||
|
X-LB-Timestamp: 1718000000
|
||||||
|
X-LB-Signature: sha256=<hex hmac>
|
||||||
|
X-LB-Idempotency-Key: <uuid> # optional, dedup window
|
||||||
|
```
|
||||||
|
|
||||||
|
Body:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"session_id": "ticket-10293", // REQUIRED. Caller-defined. Maps 1:1 to a LangBot session.
|
||||||
|
"session_type": "person", // optional, "person" | "group"; default from config
|
||||||
|
"sender": { // optional metadata, surfaced to pipeline/plugins
|
||||||
|
"id": "user-5567",
|
||||||
|
"name": "Alice"
|
||||||
|
},
|
||||||
|
"message": [ // REQUIRED. A LangBot MessageChain (list of segments).
|
||||||
|
{ "type": "Plain", "text": "Export keeps failing on the dashboard." },
|
||||||
|
{ "type": "Image", "url": "https://.../screenshot.png" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response (LangBot does **not** block on the pipeline):
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
// 202 Accepted
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"msg": "accepted",
|
||||||
|
"data": {
|
||||||
|
"session_id": "ticket-10293",
|
||||||
|
"accepted_message_id": "in_01H....", // server-assigned id for this inbound message
|
||||||
|
"aggregating": true // true if buffered by the aggregator
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**N→1 in practice.** Fire three POSTs with the same `session_id` inside the
|
||||||
|
aggregation window → the pipeline runs **once** with the three messages merged.
|
||||||
|
No special flag needed; this is the aggregator's default behaviour when enabled
|
||||||
|
on the pipeline.
|
||||||
|
|
||||||
|
### 6.2 Outbound — LangBot delivers replies to your callback
|
||||||
|
|
||||||
|
For each `reply_message` / `reply_message_chunk` the pipeline emits, LangBot
|
||||||
|
POSTs to `callback_url`:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST {callback_url}
|
||||||
|
Content-Type: application/json
|
||||||
|
X-LB-Timestamp: 1718000001
|
||||||
|
X-LB-Signature: sha256=<hex hmac over body>
|
||||||
|
```
|
||||||
|
|
||||||
|
Body:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"session_id": "ticket-10293", // echoes the inbound session
|
||||||
|
"reply_to": "in_01H....", // the inbound message id this answers
|
||||||
|
"sequence": 1, // 1-based ordinal within this turn (for 1→M ordering)
|
||||||
|
"is_final": false, // false for intermediate/streamed parts
|
||||||
|
"stream": false, // true when this is a streamed chunk
|
||||||
|
"message": [
|
||||||
|
{ "type": "Plain", "text": "Looking into it — checking your export logs…" }
|
||||||
|
],
|
||||||
|
"timestamp": "2026-06-22T09:00:01Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**1→M in practice.** A turn that fires a function call then a final answer
|
||||||
|
produces e.g.:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST callback → { sequence: 1, is_final: false, message: ["Checking logs…"] }
|
||||||
|
POST callback → { sequence: 2, is_final: false, message: ["Found 2 failed exports."] }
|
||||||
|
POST callback → { sequence: 3, is_final: true, message: ["Fixed. Try again now."] }
|
||||||
|
```
|
||||||
|
|
||||||
|
The caller stitches by `session_id` + `sequence`, and knows the turn is complete
|
||||||
|
when `is_final: true` arrives.
|
||||||
|
|
||||||
|
Your callback endpoint should return `200` quickly. A non-2xx triggers retry
|
||||||
|
with backoff (`callback_max_retries`).
|
||||||
|
|
||||||
|
### 6.3 Error envelope (inbound)
|
||||||
|
|
||||||
|
Consistent, machine-readable; never leak internals:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{ "code": 40101, "msg": "invalid signature", "data": null }
|
||||||
|
```
|
||||||
|
|
||||||
|
| HTTP | code | meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| 202 | 0 | accepted |
|
||||||
|
| 400 | 40001 | malformed body / missing `session_id` or `message` |
|
||||||
|
| 401 | 40101 | bad/expired signature |
|
||||||
|
| 403 | 40301 | bot disabled |
|
||||||
|
| 404 | 40401 | bot_uuid not found / not an `http_bot` adapter |
|
||||||
|
| 409 | 40901 | duplicate idempotency key (already accepted) |
|
||||||
|
| 413 | 41301 | message too large |
|
||||||
|
| 500 | 50001 | internal error |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Signing scheme (both directions)
|
||||||
|
|
||||||
|
Symmetric, dependency-free HMAC-SHA256 — trivial to implement in any language.
|
||||||
|
|
||||||
|
```
|
||||||
|
signing_string = "{timestamp}.{raw_request_body}"
|
||||||
|
signature = "sha256=" + hex(HMAC_SHA256(secret, signing_string))
|
||||||
|
```
|
||||||
|
|
||||||
|
Verification rules:
|
||||||
|
|
||||||
|
- Reject if `|now - timestamp| > 300s` (replay window).
|
||||||
|
- Constant-time compare (`hmac.compare_digest`).
|
||||||
|
- Inbound verified with `inbound_secret`; outbound signed with
|
||||||
|
`outbound_secret` (falls back to `inbound_secret`).
|
||||||
|
- `signature_required: false` bypasses verification **and logs a warning** —
|
||||||
|
intended only for local development behind a trusted network.
|
||||||
|
|
||||||
|
Reference (Python, ~6 lines):
|
||||||
|
|
||||||
|
```python
|
||||||
|
import hmac, hashlib, time
|
||||||
|
|
||||||
|
def sign(secret: str, body: bytes, ts: int | None = None) -> tuple[str, str]:
|
||||||
|
ts = ts or int(time.time())
|
||||||
|
mac = hmac.new(secret.encode(), f"{ts}.".encode() + body, hashlib.sha256)
|
||||||
|
return str(ts), "sha256=" + mac.hexdigest()
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Delivery semantics & reliability
|
||||||
|
|
||||||
|
- **Inbound**: `202 Accepted` means *queued*, not *processed*. Use
|
||||||
|
`X-LB-Idempotency-Key` to make client retries safe (dedup window, e.g. 10 min).
|
||||||
|
- **Outbound**: **at-least-once**, best-effort. Retries on timeout/5xx with
|
||||||
|
exponential backoff up to `callback_max_retries`. Callbacks for one
|
||||||
|
`session_id` are delivered **in `sequence` order** (serialised per session);
|
||||||
|
across sessions they may interleave.
|
||||||
|
- **No persistence in v1**: if LangBot restarts mid-turn, in-flight callbacks
|
||||||
|
may be lost. Durable replay is deferred (see §13). Callers needing exactly-once
|
||||||
|
should dedup on `(session_id, reply_to, sequence)`.
|
||||||
|
- **Backpressure**: the adapter must not block the pipeline on slow callbacks —
|
||||||
|
outbound POSTs run on a per-session ordered queue with the configured timeout.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Optional: synchronous convenience mode (v1.1, behind a flag)
|
||||||
|
|
||||||
|
Some simple callers genuinely want "POST a message, get the reply in the HTTP
|
||||||
|
response" and don't care about streaming/multi-part. We can offer an **opt-in**
|
||||||
|
sync endpoint that internally waits for `is_final` and **collapses** all 1→M
|
||||||
|
parts into one array:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /bots/{bot_uuid}/sync → 200 { session_id, message: [ ...all parts concatenated... ] }
|
||||||
|
```
|
||||||
|
|
||||||
|
Implemented by attaching a per-request future that resolves on the final reply,
|
||||||
|
with a hard timeout. This is a **convenience wrapper** over the same machinery,
|
||||||
|
explicitly documented as lossy for streaming/ordering. Not in v1 core.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Adapter implementation sketch (`platform/sources/http_bot.py`)
|
||||||
|
|
||||||
|
Implements `AbstractMessagePlatformAdapter`. Key methods:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class HttpBotAdapter(AbstractMessagePlatformAdapter):
|
||||||
|
listeners: dict = pydantic.Field(default_factory=dict, exclude=True)
|
||||||
|
|
||||||
|
# --- inbound -------------------------------------------------------
|
||||||
|
async def handle_unified_webhook(self, bot_uuid, path, request):
|
||||||
|
body = await request.get_body()
|
||||||
|
if self.config.get("signature_required", True):
|
||||||
|
if not self._verify(request, body):
|
||||||
|
return jsonify({"code": 40101, "msg": "invalid signature"}), 401
|
||||||
|
data = json.loads(body)
|
||||||
|
session_id = data["session_id"] # caller-defined identity
|
||||||
|
session_type = data.get("session_type", self.config.get("default_session_type", "person"))
|
||||||
|
chain = MessageChain.model_validate(data["message"])
|
||||||
|
event = self._build_event(session_type, session_id, data.get("sender"), chain)
|
||||||
|
# remember where to send replies for this session
|
||||||
|
self._callback_for[session_id] = data.get("callback_url") or self.config.get("callback_url")
|
||||||
|
# fire the registered listener → botmgr → msg_aggregator (N→1) → pipeline
|
||||||
|
if type(event) in self.listeners:
|
||||||
|
asyncio.create_task(self.listeners[type(event)](event, self))
|
||||||
|
return jsonify({"code": 0, "msg": "accepted",
|
||||||
|
"data": {"session_id": session_id, "accepted_message_id": event.message_id}}), 202
|
||||||
|
|
||||||
|
# --- outbound (called 1..M times per turn by the pipeline) ---------
|
||||||
|
async def reply_message(self, message_source, message, quote_origin=False):
|
||||||
|
return await self._post_callback(message_source, message, is_final=True, stream=False)
|
||||||
|
|
||||||
|
async def reply_message_chunk(self, message_source, bot_message, message,
|
||||||
|
quote_origin=False, is_final=False):
|
||||||
|
return await self._post_callback(message_source, message, is_final=is_final, stream=True)
|
||||||
|
|
||||||
|
async def is_stream_output_supported(self) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
def register_listener(self, event_type, func): self.listeners[event_type] = func
|
||||||
|
def unregister_listener(self, event_type, func): self.listeners.pop(event_type, None)
|
||||||
|
async def run_async(self): pass # nothing to poll; purely webhook-driven
|
||||||
|
async def kill(self): pass
|
||||||
|
```
|
||||||
|
|
||||||
|
`_post_callback` resolves the session's callback URL, assigns the next
|
||||||
|
`sequence`, signs the body, and enqueues an ordered, retrying POST.
|
||||||
|
|
||||||
|
Session→callback mapping is kept in a small in-memory dict keyed by
|
||||||
|
`session_id` (acceptable for v1; a turn's callback URL is captured at inbound
|
||||||
|
time so replies always have a destination even if config later changes).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Security considerations
|
||||||
|
|
||||||
|
- **Inbound route is `AuthType.NONE`** at the framework level (same as all
|
||||||
|
webhook adapters) — the adapter **must** enforce HMAC itself. Default
|
||||||
|
`signature_required: true`.
|
||||||
|
- **Timestamp window** (±300s) + idempotency key blunt replay.
|
||||||
|
- **SSRF on callback_url**: validate scheme (`https` in prod), and consider an
|
||||||
|
allow-list / block of private CIDRs since LangBot initiates the POST. Document
|
||||||
|
this; enforce in code where feasible.
|
||||||
|
- **Secret storage**: secrets live in the bot's `adapter_config` like every
|
||||||
|
other adapter credential; surfaced as `type: string`/secret in the dashboard.
|
||||||
|
- **One secret per bot** in v1. Per-caller key rotation / multiple keys is a
|
||||||
|
future enhancement (§13).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Developer Experience (explicit deliverables)
|
||||||
|
|
||||||
|
The whole point of a standalone adapter is that **integrating is pleasant**. v1
|
||||||
|
ships:
|
||||||
|
|
||||||
|
1. **`docs/platforms/http-bot.md`** — task-oriented integration guide:
|
||||||
|
create the bot → copy inbound URL → set secret → stand up a callback
|
||||||
|
endpoint → send first message → handle 1→M.
|
||||||
|
2. **Copy-paste curl** for the first message (with a working signing one-liner).
|
||||||
|
3. **Reference clients** (≤50 LOC each) in `examples/http-bot/`:
|
||||||
|
`client.py` (push + a Flask/Quart callback receiver) and `client.ts`.
|
||||||
|
4. **OpenAPI fragment** `docs/http-bot-openapi.json` describing inbound +
|
||||||
|
callback shapes, so integrators can codegen.
|
||||||
|
5. **Local echo recipe**: a one-command callback server that prints every
|
||||||
|
reply, so a developer sees N→1 and 1→M working in under five minutes.
|
||||||
|
6. **Postman/Hoppscotch collection** (nice-to-have).
|
||||||
|
|
||||||
|
DX acceptance check: *a developer who has never seen LangBot can, from the docs
|
||||||
|
alone, push a message and observe a multi-part reply on their callback within
|
||||||
|
10 minutes.*
|
||||||
|
|
||||||
|
### Quickstart (curl)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
BOT=https://your-langbot/bots/2f1c....
|
||||||
|
SECRET=supersecret
|
||||||
|
BODY='{"session_id":"ticket-10293","message":[{"type":"Plain","text":"hello"}]}'
|
||||||
|
TS=$(date +%s)
|
||||||
|
SIG="sha256=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -r | cut -d' ' -f1)"
|
||||||
|
curl -sS -X POST "$BOT" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "X-LB-Timestamp: $TS" \
|
||||||
|
-H "X-LB-Signature: $SIG" \
|
||||||
|
-d "$BODY"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Future work
|
||||||
|
|
||||||
|
- **Durable outbound queue** (persist + replay across restarts; exactly-once).
|
||||||
|
- **Per-caller API keys** with rotation and scopes (multi-tenant Space usage).
|
||||||
|
- **Sync convenience endpoint** (§9) once core is stable.
|
||||||
|
- **Server-Sent Events outbound option** for callers that *do* want a stream but
|
||||||
|
not a full duplex socket — single GET, server pushes chunks.
|
||||||
|
- **Dashboard "test console"** for `http_bot` (send a message, watch callbacks)
|
||||||
|
mirroring the existing WebSocket debug panel.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Rollout / task breakdown
|
||||||
|
|
||||||
|
| # | Task | Touches |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | `http_bot.yaml` manifest + icon | `platform/sources/` |
|
||||||
|
| 2 | `HttpBotAdapter` (inbound verify, event build, outbound queue) | `platform/sources/http_bot.py` |
|
||||||
|
| 3 | Signing helper module (shared) | `platform/sources/` or `utils/` |
|
||||||
|
| 4 | i18n strings (en/zh/ja) | adapter yaml + web locale |
|
||||||
|
| 5 | Integration docs `docs/platforms/http-bot.md` | `docs/` |
|
||||||
|
| 6 | OpenAPI fragment + reference clients | `docs/`, `examples/http-bot/` |
|
||||||
|
| 7 | Tests: signature verify, N→1 aggregation, 1→M ordering, retry | `tests/` |
|
||||||
|
| 8 | (opt) SSRF guard for callback_url | adapter |
|
||||||
|
|
||||||
|
No changes required to: the unified webhook router, the aggregator, the query
|
||||||
|
pool, or the pipeline. That is the design's main payoff.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Resolved decisions
|
||||||
|
|
||||||
|
1. **Callback URL trust** — **config-only.** The inbound message may not carry a
|
||||||
|
`callback_url`; replies always go to the bot-config URL. Closes the SSRF
|
||||||
|
vector where a leaked inbound secret could redirect replies.
|
||||||
|
2. **Session lifecycle** — **`POST /bots/<uuid>/reset`** (body `{session_id,
|
||||||
|
session_type?}`) drops the matching session from the session manager; the
|
||||||
|
next message starts a fresh conversation. Implemented via sub-path routing in
|
||||||
|
`handle_unified_webhook`.
|
||||||
|
3. **Group semantics** — for `session_type: group`, `session_id` is the group/
|
||||||
|
launcher id; `sender.id` (and optional `sender.group_name`) identify the
|
||||||
|
member. A Space ticket maps to one `session_id`.
|
||||||
|
4. **Backpressure** — bounded per-session outbound queue (maxlen 1000); on
|
||||||
|
overflow the oldest reply is dropped and a warning logged, so a persistently
|
||||||
|
down callback can never exhaust memory.
|
||||||
|
|
||||||
|
### Still open / deferred (see §13)
|
||||||
|
|
||||||
|
- Durable outbound queue (persist + replay across restarts).
|
||||||
|
- Per-caller API keys with rotation/scopes for multi-tenant Space usage.
|
||||||
|
- SSE outbound option and a dashboard test console.
|
||||||
@@ -0,0 +1,713 @@
|
|||||||
|
# Workflow 系统开发者文档
|
||||||
|
|
||||||
|
本文档面向 LangBot 开发者,详细介绍 Workflow 系统的技术架构、核心组件和扩展方法。
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
|
||||||
|
- [系统架构概述](#系统架构概述)
|
||||||
|
- [目录结构](#目录结构)
|
||||||
|
- [核心组件](#核心组件)
|
||||||
|
- [后端模块](#后端模块)
|
||||||
|
- [前端组件](#前端组件)
|
||||||
|
- [数据库表结构](#数据库表结构)
|
||||||
|
- [API 接口文档](#api-接口文档)
|
||||||
|
- [如何添加新节点类型](#如何添加新节点类型)
|
||||||
|
- [调试功能实现](#调试功能实现)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 系统架构概述
|
||||||
|
|
||||||
|
Workflow 系统采用前后端分离架构,主要包含以下层次:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ 前端层 (React) │
|
||||||
|
│ ┌─────────────┬──────────────┬──────────────┬───────────┐ │
|
||||||
|
│ │ 可视化编辑器 │ 节点面板 │ 属性面板 │ 调试器 │ │
|
||||||
|
│ │ ReactFlow │ NodePalette │ PropertyPanel│ Debugger │ │
|
||||||
|
│ └─────────────┴──────────────┴──────────────┴───────────┘ │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ API 层 (Quart) │
|
||||||
|
│ ┌─────────────┬──────────────┬──────────────────────────┐ │
|
||||||
|
│ │ Workflow API│ Debug API │ Node Types API │ │
|
||||||
|
│ └─────────────┴──────────────┴──────────────────────────┘ │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ 核心引擎层 (Python) │
|
||||||
|
│ ┌─────────────┬──────────────┬──────────────┬───────────┐ │
|
||||||
|
│ │ Executor │ Registry │ Node │ Entities │ │
|
||||||
|
│ │ 执行引擎 │ 节点注册表 │ 节点基类 │ 数据结构 │ │
|
||||||
|
│ └─────────────┴──────────────┴──────────────┴───────────┘ │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ 存储层 (SQLAlchemy) │
|
||||||
|
│ ┌─────────────┬──────────────┬──────────────────────────┐ │
|
||||||
|
│ │ Workflow │ Executions │ Triggers │ │
|
||||||
|
│ └─────────────┴──────────────┴──────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
### 后端代码结构
|
||||||
|
|
||||||
|
```
|
||||||
|
LangBot/src/langbot/pkg/
|
||||||
|
├── workflow/ # Workflow 核心模块
|
||||||
|
│ ├── __init__.py # 模块初始化,导出公共接口
|
||||||
|
│ ├── entities.py # 数据实体定义
|
||||||
|
│ ├── executor.py # 执行引擎
|
||||||
|
│ ├── node.py # 节点基类和装饰器
|
||||||
|
│ ├── registry.py # 节点类型注册表
|
||||||
|
│ └── nodes/ # 内置节点实现
|
||||||
|
│ ├── __init__.py # 注册所有内置节点
|
||||||
|
│ ├── trigger.py # 触发节点
|
||||||
|
│ ├── process.py # 处理节点
|
||||||
|
│ ├── control.py # 控制节点
|
||||||
|
│ └── action.py # 动作节点
|
||||||
|
├── entity/persistence/
|
||||||
|
│ └── workflow.py # 数据库模型
|
||||||
|
├── api/http/
|
||||||
|
│ ├── controller/groups/workflows/
|
||||||
|
│ │ └── workflows.py # API 路由控制器
|
||||||
|
│ └── service/
|
||||||
|
│ └── workflow.py # 业务逻辑服务
|
||||||
|
└── persistence/migrations/
|
||||||
|
└── dbm026_workflow_tables.py # 数据库迁移
|
||||||
|
```
|
||||||
|
|
||||||
|
### 前端代码结构
|
||||||
|
|
||||||
|
```
|
||||||
|
LangBot/web/src/app/home/workflows/
|
||||||
|
├── page.tsx # Workflow 列表页
|
||||||
|
├── WorkflowDetailContent.tsx # 详情页内容
|
||||||
|
├── store/
|
||||||
|
│ └── useWorkflowStore.ts # Zustand 状态管理
|
||||||
|
└── components/
|
||||||
|
├── workflow-editor/ # 可视化编辑器
|
||||||
|
│ ├── index.ts # 导出
|
||||||
|
│ ├── WorkflowEditorComponent.tsx # 主编辑器组件
|
||||||
|
│ ├── WorkflowNodeComponent.tsx # 自定义节点组件
|
||||||
|
│ ├── NodePalette.tsx # 节点面板
|
||||||
|
│ ├── PropertyPanel.tsx # 属性面板
|
||||||
|
│ └── node-configs/ # 节点配置元数据
|
||||||
|
│ ├── types.ts # 配置类型定义
|
||||||
|
│ ├── trigger-configs.ts
|
||||||
|
│ ├── ai-configs.ts
|
||||||
|
│ ├── process-configs.ts
|
||||||
|
│ ├── control-configs.ts
|
||||||
|
│ ├── action-configs.ts
|
||||||
|
│ ├── integration-configs.ts
|
||||||
|
│ └── index.ts # 配置汇总
|
||||||
|
├── workflow-debugger/ # 调试器组件
|
||||||
|
│ ├── index.ts
|
||||||
|
│ └── WorkflowDebugger.tsx
|
||||||
|
├── workflow-form/ # 表单组件
|
||||||
|
│ └── WorkflowFormComponent.tsx
|
||||||
|
└── workflow-executions/ # 执行历史组件
|
||||||
|
└── WorkflowExecutionsTab.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 核心组件
|
||||||
|
|
||||||
|
### 后端模块
|
||||||
|
|
||||||
|
#### 1. 执行引擎 (WorkflowExecutor)
|
||||||
|
|
||||||
|
位置:[`executor.py`](../../src/langbot/pkg/workflow/executor.py)
|
||||||
|
|
||||||
|
执行引擎负责工作流的实际执行,包括:
|
||||||
|
|
||||||
|
- **拓扑排序**:确定节点执行顺序
|
||||||
|
- **节点执行**:调用各节点的 execute 方法
|
||||||
|
- **控制流处理**:处理条件分支、循环、并行执行
|
||||||
|
- **错误处理**:支持重试机制
|
||||||
|
|
||||||
|
```python
|
||||||
|
class WorkflowExecutor:
|
||||||
|
async def execute(
|
||||||
|
self,
|
||||||
|
workflow: WorkflowDefinition,
|
||||||
|
context: ExecutionContext,
|
||||||
|
start_node_id: Optional[str] = None
|
||||||
|
) -> ExecutionContext:
|
||||||
|
"""执行工作流"""
|
||||||
|
# 1. 构建执行图
|
||||||
|
# 2. 初始化节点状态
|
||||||
|
# 3. 找到起始节点
|
||||||
|
# 4. 按拓扑顺序执行
|
||||||
|
```
|
||||||
|
|
||||||
|
**调试执行器 (DebugWorkflowExecutor)**
|
||||||
|
|
||||||
|
继承自 WorkflowExecutor,增加了调试支持:
|
||||||
|
|
||||||
|
- 断点支持
|
||||||
|
- 单步执行
|
||||||
|
- 暂停/继续
|
||||||
|
- 实时日志
|
||||||
|
|
||||||
|
```python
|
||||||
|
class DebugWorkflowExecutor(WorkflowExecutor):
|
||||||
|
async def execute_debug(
|
||||||
|
self,
|
||||||
|
workflow: WorkflowDefinition,
|
||||||
|
context: ExecutionContext,
|
||||||
|
debug_state: DebugExecutionState,
|
||||||
|
) -> ExecutionContext:
|
||||||
|
"""调试模式执行"""
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. 节点注册表 (NodeTypeRegistry)
|
||||||
|
|
||||||
|
位置:[`registry.py`](../../src/langbot/pkg/workflow/registry.py)
|
||||||
|
|
||||||
|
单例模式管理所有节点类型:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class NodeTypeRegistry:
|
||||||
|
_instance: Optional['NodeTypeRegistry'] = None
|
||||||
|
|
||||||
|
def register(self, node_type: str, node_class: type[WorkflowNode]):
|
||||||
|
"""注册节点类型"""
|
||||||
|
|
||||||
|
def create_instance(self, node_type: str, node_id: str, config: dict) -> WorkflowNode:
|
||||||
|
"""创建节点实例"""
|
||||||
|
|
||||||
|
def list_all(self) -> list[dict]:
|
||||||
|
"""获取所有节点类型的 Schema"""
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. 节点基类 (WorkflowNode)
|
||||||
|
|
||||||
|
位置:[`node.py`](../../src/langbot/pkg/workflow/node.py)
|
||||||
|
|
||||||
|
所有节点必须继承此基类:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class WorkflowNode(abc.ABC):
|
||||||
|
# 节点元数据
|
||||||
|
type_name: str = ""
|
||||||
|
name: str = ""
|
||||||
|
description: str = ""
|
||||||
|
category: str = "misc"
|
||||||
|
icon: str = ""
|
||||||
|
|
||||||
|
# 端口定义
|
||||||
|
inputs: list[NodePort] = []
|
||||||
|
outputs: list[NodePort] = []
|
||||||
|
|
||||||
|
# 配置 Schema
|
||||||
|
config_schema: list[NodeConfig] = []
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
async def execute(
|
||||||
|
self,
|
||||||
|
inputs: dict[str, Any],
|
||||||
|
context: ExecutionContext
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""执行节点逻辑"""
|
||||||
|
pass
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. 数据实体 (entities.py)
|
||||||
|
|
||||||
|
主要数据结构:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class WorkflowDefinition:
|
||||||
|
"""工作流定义"""
|
||||||
|
uuid: str
|
||||||
|
name: str
|
||||||
|
nodes: list[NodeDefinition]
|
||||||
|
edges: list[EdgeDefinition]
|
||||||
|
settings: WorkflowSettings
|
||||||
|
|
||||||
|
class ExecutionContext:
|
||||||
|
"""执行上下文"""
|
||||||
|
execution_id: str
|
||||||
|
workflow_id: str
|
||||||
|
status: ExecutionStatus
|
||||||
|
variables: dict
|
||||||
|
node_states: dict[str, NodeState]
|
||||||
|
history: list[ExecutionStep]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 前端组件
|
||||||
|
|
||||||
|
#### 1. WorkflowEditorComponent
|
||||||
|
|
||||||
|
主编辑器组件,基于 React Flow 实现:
|
||||||
|
|
||||||
|
- **画布交互**:拖拽、缩放、平移
|
||||||
|
- **节点连接**:自动验证端口类型
|
||||||
|
- **撤销/重做**:基于历史记录栈
|
||||||
|
- **复制/粘贴**:支持多选复制
|
||||||
|
|
||||||
|
关键功能:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function WorkflowEditorInner() {
|
||||||
|
const { nodes, edges, onNodesChange, onEdgesChange, onConnect } = useWorkflowStore();
|
||||||
|
|
||||||
|
// 拖放添加节点
|
||||||
|
const onDrop = useCallback((event: React.DragEvent) => {
|
||||||
|
const type = event.dataTransfer.getData('application/reactflow');
|
||||||
|
const position = screenToFlowPosition({ x: event.clientX, y: event.clientY });
|
||||||
|
addNode(type, position);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 复制粘贴
|
||||||
|
const handleCopy = useCallback(() => { ... }, []);
|
||||||
|
const handlePaste = useCallback(() => { ... }, []);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. NodePalette
|
||||||
|
|
||||||
|
节点面板组件,展示可用节点类型:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function NodePalette() {
|
||||||
|
// 按类别组织节点
|
||||||
|
const categories = [
|
||||||
|
{ id: 'trigger', name: '触发节点', icon: Zap },
|
||||||
|
{ id: 'ai', name: 'AI 节点', icon: Brain },
|
||||||
|
{ id: 'process', name: '处理节点', icon: Cpu },
|
||||||
|
{ id: 'control', name: '控制节点', icon: GitBranch },
|
||||||
|
{ id: 'action', name: '动作节点', icon: Send },
|
||||||
|
{ id: 'integration', name: '集成节点', icon: Plug },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 拖拽开始
|
||||||
|
const onDragStart = (event: React.DragEvent, nodeType: string) => {
|
||||||
|
event.dataTransfer.setData('application/reactflow', nodeType);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. PropertyPanel
|
||||||
|
|
||||||
|
属性面板组件,动态渲染节点配置表单:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function PropertyPanel() {
|
||||||
|
const { selectedNodeId, nodes, updateNodeData } = useWorkflowStore();
|
||||||
|
|
||||||
|
// 根据节点类型获取配置元数据
|
||||||
|
const selectedNode = nodes.find(n => n.id === selectedNodeId);
|
||||||
|
const nodeConfig = getNodeConfig(selectedNode?.data?.nodeType);
|
||||||
|
|
||||||
|
// 动态渲染配置字段
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{nodeConfig?.fields.map(field => (
|
||||||
|
<ConfigField key={field.name} field={field} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. WorkflowDebugger
|
||||||
|
|
||||||
|
调试器组件,支持实时调试:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function WorkflowDebugger({ workflowUuid, workflow }) {
|
||||||
|
const [debugState, setDebugState] = useState<DebugState>('idle');
|
||||||
|
const [executionId, setExecutionId] = useState<string>('');
|
||||||
|
const [logs, setLogs] = useState<ExecutionLog[]>([]);
|
||||||
|
|
||||||
|
// 启动调试
|
||||||
|
const startDebug = async () => {
|
||||||
|
const result = await backendClient.post(
|
||||||
|
`/api/v1/workflows/${workflowUuid}/debug/start`,
|
||||||
|
{ context, variables, breakpoints }
|
||||||
|
);
|
||||||
|
setExecutionId(result.execution_id);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 轮询状态
|
||||||
|
useEffect(() => {
|
||||||
|
if (debugState === 'running') {
|
||||||
|
const interval = setInterval(fetchState, 500);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}
|
||||||
|
}, [debugState]);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5. useWorkflowStore
|
||||||
|
|
||||||
|
Zustand 状态管理:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface WorkflowState {
|
||||||
|
nodes: WorkflowNode[];
|
||||||
|
edges: WorkflowEdge[];
|
||||||
|
selectedNodeId: string | null;
|
||||||
|
history: HistoryEntry[];
|
||||||
|
historyIndex: number;
|
||||||
|
isDirty: boolean;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
addNode: (type: string, position: XYPosition) => void;
|
||||||
|
updateNodeData: (nodeId: string, data: Partial<NodeData>) => void;
|
||||||
|
deleteNode: (nodeId: string) => void;
|
||||||
|
undo: () => void;
|
||||||
|
redo: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useWorkflowStore = create<WorkflowState>((set, get) => ({
|
||||||
|
// ... state and actions
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 数据库表结构
|
||||||
|
|
||||||
|
### workflows 表
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE workflows (
|
||||||
|
uuid VARCHAR(255) PRIMARY KEY,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
emoji VARCHAR(10) DEFAULT '🔄',
|
||||||
|
version INTEGER DEFAULT 1,
|
||||||
|
is_enabled BOOLEAN DEFAULT TRUE,
|
||||||
|
definition JSON NOT NULL, -- 节点和边定义
|
||||||
|
global_config JSON DEFAULT '{}', -- 全局配置
|
||||||
|
extensions_preferences JSON, -- 插件和 MCP 配置
|
||||||
|
created_at TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### workflow_versions 表
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE workflow_versions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
workflow_uuid VARCHAR(255) NOT NULL,
|
||||||
|
version INTEGER NOT NULL,
|
||||||
|
definition JSON NOT NULL,
|
||||||
|
global_config JSON DEFAULT '{}',
|
||||||
|
created_at TIMESTAMP,
|
||||||
|
created_by VARCHAR(255),
|
||||||
|
UNIQUE(workflow_uuid, version)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### workflow_executions 表
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE workflow_executions (
|
||||||
|
uuid VARCHAR(255) PRIMARY KEY,
|
||||||
|
workflow_uuid VARCHAR(255) NOT NULL,
|
||||||
|
workflow_version INTEGER NOT NULL,
|
||||||
|
status VARCHAR(20) NOT NULL, -- pending/running/completed/failed/cancelled
|
||||||
|
trigger_type VARCHAR(50),
|
||||||
|
trigger_data JSON,
|
||||||
|
variables JSON,
|
||||||
|
start_time TIMESTAMP,
|
||||||
|
end_time TIMESTAMP,
|
||||||
|
error TEXT,
|
||||||
|
created_at TIMESTAMP
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### workflow_node_executions 表
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE workflow_node_executions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
execution_uuid VARCHAR(255) NOT NULL,
|
||||||
|
node_id VARCHAR(100) NOT NULL,
|
||||||
|
node_type VARCHAR(50) NOT NULL,
|
||||||
|
status VARCHAR(20) NOT NULL,
|
||||||
|
inputs JSON,
|
||||||
|
outputs JSON,
|
||||||
|
start_time TIMESTAMP,
|
||||||
|
end_time TIMESTAMP,
|
||||||
|
error TEXT,
|
||||||
|
retry_count INTEGER DEFAULT 0
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### workflow_triggers 表
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE workflow_triggers (
|
||||||
|
uuid VARCHAR(255) PRIMARY KEY,
|
||||||
|
workflow_uuid VARCHAR(255) NOT NULL,
|
||||||
|
type VARCHAR(50) NOT NULL, -- message/cron/event/webhook
|
||||||
|
config JSON NOT NULL,
|
||||||
|
is_enabled BOOLEAN DEFAULT TRUE,
|
||||||
|
priority INTEGER DEFAULT 0,
|
||||||
|
created_at TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API 接口文档
|
||||||
|
|
||||||
|
### Workflow CRUD
|
||||||
|
|
||||||
|
| 方法 | 路径 | 描述 |
|
||||||
|
|-----|------|------|
|
||||||
|
| GET | `/api/v1/workflows` | 获取工作流列表 |
|
||||||
|
| POST | `/api/v1/workflows` | 创建工作流 |
|
||||||
|
| GET | `/api/v1/workflows/:uuid` | 获取单个工作流 |
|
||||||
|
| PUT | `/api/v1/workflows/:uuid` | 更新工作流 |
|
||||||
|
| DELETE | `/api/v1/workflows/:uuid` | 删除工作流 |
|
||||||
|
| POST | `/api/v1/workflows/:uuid/copy` | 复制工作流 |
|
||||||
|
|
||||||
|
### 执行相关
|
||||||
|
|
||||||
|
| 方法 | 路径 | 描述 |
|
||||||
|
|-----|------|------|
|
||||||
|
| POST | `/api/v1/workflows/:uuid/execute` | 手动执行工作流 |
|
||||||
|
| GET | `/api/v1/workflows/:uuid/executions` | 获取执行记录 |
|
||||||
|
|
||||||
|
### 版本管理
|
||||||
|
|
||||||
|
| 方法 | 路径 | 描述 |
|
||||||
|
|-----|------|------|
|
||||||
|
| GET | `/api/v1/workflows/:uuid/versions` | 获取版本列表 |
|
||||||
|
| POST | `/api/v1/workflows/:uuid/rollback/:version` | 回滚到指定版本 |
|
||||||
|
|
||||||
|
### 调试 API
|
||||||
|
|
||||||
|
| 方法 | 路径 | 描述 |
|
||||||
|
|-----|------|------|
|
||||||
|
| POST | `/api/v1/workflows/:uuid/debug/start` | 启动调试 |
|
||||||
|
| POST | `/api/v1/workflows/:uuid/debug/:exec_id/pause` | 暂停执行 |
|
||||||
|
| POST | `/api/v1/workflows/:uuid/debug/:exec_id/resume` | 继续执行 |
|
||||||
|
| POST | `/api/v1/workflows/:uuid/debug/:exec_id/stop` | 停止执行 |
|
||||||
|
| POST | `/api/v1/workflows/:uuid/debug/:exec_id/step` | 单步执行 |
|
||||||
|
| GET | `/api/v1/workflows/:uuid/debug/:exec_id/state` | 获取调试状态 |
|
||||||
|
|
||||||
|
### 节点类型
|
||||||
|
|
||||||
|
| 方法 | 路径 | 描述 |
|
||||||
|
|-----|------|------|
|
||||||
|
| GET | `/api/v1/workflows/_/node-types` | 获取所有节点类型 |
|
||||||
|
| GET | `/api/v1/workflows/_/node-types/categories` | 按类别获取节点类型 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 如何添加新节点类型
|
||||||
|
|
||||||
|
### 步骤 1:创建节点类
|
||||||
|
|
||||||
|
在 `LangBot/src/langbot/pkg/workflow/nodes/` 下创建或修改文件:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from ..node import WorkflowNode, NodePort, NodeConfig, workflow_node
|
||||||
|
from ..entities import ExecutionContext
|
||||||
|
|
||||||
|
@workflow_node('my_custom_node')
|
||||||
|
class MyCustomNode(WorkflowNode):
|
||||||
|
"""自定义节点"""
|
||||||
|
|
||||||
|
# 元数据
|
||||||
|
type_name = 'my_custom_node'
|
||||||
|
name = '我的自定义节点'
|
||||||
|
description = '这是一个自定义节点'
|
||||||
|
category = 'process' # trigger/process/control/action/integration
|
||||||
|
icon = '🔧'
|
||||||
|
|
||||||
|
# 输入端口
|
||||||
|
inputs = [
|
||||||
|
NodePort(name='input', type='string', description='输入数据', required=True),
|
||||||
|
]
|
||||||
|
|
||||||
|
# 输出端口
|
||||||
|
outputs = [
|
||||||
|
NodePort(name='output', type='string', description='输出数据'),
|
||||||
|
]
|
||||||
|
|
||||||
|
# 配置字段
|
||||||
|
config_schema = [
|
||||||
|
NodeConfig(
|
||||||
|
name='option',
|
||||||
|
type='select',
|
||||||
|
required=True,
|
||||||
|
options=['选项A', '选项B'],
|
||||||
|
description='选择一个选项'
|
||||||
|
),
|
||||||
|
NodeConfig(
|
||||||
|
name='value',
|
||||||
|
type='string',
|
||||||
|
required=False,
|
||||||
|
default='默认值',
|
||||||
|
description='配置值'
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
async def execute(
|
||||||
|
self,
|
||||||
|
inputs: dict[str, Any],
|
||||||
|
context: ExecutionContext
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""执行节点逻辑"""
|
||||||
|
input_data = inputs.get('input', '')
|
||||||
|
option = self.get_config('option')
|
||||||
|
value = self.get_config('value', '')
|
||||||
|
|
||||||
|
# 处理逻辑
|
||||||
|
result = f"处理: {input_data} with {option} and {value}"
|
||||||
|
|
||||||
|
return {'output': result}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 步骤 2:注册节点
|
||||||
|
|
||||||
|
在 `LangBot/src/langbot/pkg/workflow/nodes/__init__.py` 中导入:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from .process import (
|
||||||
|
CodeExecutorNode,
|
||||||
|
HttpRequestNode,
|
||||||
|
DataTransformNode,
|
||||||
|
MyCustomNode, # 添加新节点
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 步骤 3:添加前端配置
|
||||||
|
|
||||||
|
在 `LangBot/web/src/app/home/workflows/components/workflow-editor/node-configs/` 目录下添加配置:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// process-configs.ts
|
||||||
|
export const processNodeConfigs: NodeConfigMap = {
|
||||||
|
// ... 其他配置
|
||||||
|
|
||||||
|
my_custom_node: {
|
||||||
|
type: 'my_custom_node',
|
||||||
|
label: 'workflows.nodes.myCustomNode',
|
||||||
|
description: 'workflows.nodes.myCustomNodeDesc',
|
||||||
|
icon: 'Wrench',
|
||||||
|
category: 'process',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'option',
|
||||||
|
type: 'select',
|
||||||
|
label: 'workflows.fields.option',
|
||||||
|
required: true,
|
||||||
|
options: [
|
||||||
|
{ value: '选项A', label: '选项 A' },
|
||||||
|
{ value: '选项B', label: '选项 B' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'value',
|
||||||
|
type: 'string',
|
||||||
|
label: 'workflows.fields.value',
|
||||||
|
required: false,
|
||||||
|
defaultValue: '默认值',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 步骤 4:添加国际化
|
||||||
|
|
||||||
|
在 `LangBot/web/src/i18n/locales/` 中添加翻译:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// zh-Hans.ts
|
||||||
|
workflows: {
|
||||||
|
nodes: {
|
||||||
|
myCustomNode: '我的自定义节点',
|
||||||
|
myCustomNodeDesc: '这是一个自定义节点',
|
||||||
|
},
|
||||||
|
fields: {
|
||||||
|
option: '选项',
|
||||||
|
value: '值',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 调试功能实现
|
||||||
|
|
||||||
|
### 后端调试状态管理
|
||||||
|
|
||||||
|
```python
|
||||||
|
class DebugExecutionState:
|
||||||
|
"""调试执行状态"""
|
||||||
|
|
||||||
|
def __init__(self, execution_id: str, breakpoints: list[str] = None):
|
||||||
|
self.execution_id = execution_id
|
||||||
|
self.status: str = 'running'
|
||||||
|
self.is_paused: bool = False
|
||||||
|
self.is_stopped: bool = False
|
||||||
|
self.breakpoints: set[str] = set(breakpoints or [])
|
||||||
|
self.logs: list[ExecutionLog] = []
|
||||||
|
self._pause_event = asyncio.Event()
|
||||||
|
|
||||||
|
def pause(self):
|
||||||
|
"""暂停执行"""
|
||||||
|
self.is_paused = True
|
||||||
|
self._pause_event.clear()
|
||||||
|
|
||||||
|
def resume(self):
|
||||||
|
"""继续执行"""
|
||||||
|
self.is_paused = False
|
||||||
|
self._pause_event.set()
|
||||||
|
|
||||||
|
async def wait_if_paused(self):
|
||||||
|
"""如果暂停则等待"""
|
||||||
|
if self.is_paused:
|
||||||
|
await self._pause_event.wait()
|
||||||
|
```
|
||||||
|
|
||||||
|
### 前端调试流程
|
||||||
|
|
||||||
|
1. **设置断点**:点击节点设置断点
|
||||||
|
2. **启动调试**:调用 `/debug/start` 启动调试执行
|
||||||
|
3. **轮询状态**:定期调用 `/debug/:id/state` 获取状态
|
||||||
|
4. **控制执行**:调用 pause/resume/step/stop 控制执行
|
||||||
|
5. **查看日志**:实时显示执行日志和节点状态
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 调试状态轮询
|
||||||
|
const fetchDebugState = async () => {
|
||||||
|
const state = await backendClient.get(
|
||||||
|
`/api/v1/workflows/${workflowUuid}/debug/${executionId}/state`
|
||||||
|
);
|
||||||
|
|
||||||
|
// 更新节点状态
|
||||||
|
setNodeStates(state.node_states);
|
||||||
|
|
||||||
|
// 追加新日志
|
||||||
|
if (state.new_logs.length > 0) {
|
||||||
|
setLogs(prev => [...prev, ...state.new_logs]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查完成状态
|
||||||
|
if (state.status === 'completed' || state.status === 'error') {
|
||||||
|
setDebugState('idle');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 扩展阅读
|
||||||
|
|
||||||
|
- [Workflow 功能设计文档](../../../plans/langbot-workflow-design.md)
|
||||||
|
- [用户使用指南](../user-guide/workflow-guide.md)
|
||||||
|
- [API 认证文档](../API_KEY_AUTH.md)
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
{
|
||||||
|
"openapi": "3.0.3",
|
||||||
|
"info": {
|
||||||
|
"title": "LangBot HTTP Bot Adapter",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Server-to-server HTTP integration for a LangBot pipeline. Inbound messages are POSTed to the unified webhook route; replies are delivered to a configured callback URL (one POST per reply part). All requests are HMAC-SHA256 signed. See docs/platforms/http-bot.md."
|
||||||
|
},
|
||||||
|
"paths": {
|
||||||
|
"/bots/{bot_uuid}": {
|
||||||
|
"post": {
|
||||||
|
"summary": "Push a message into the pipeline (fire-and-collect)",
|
||||||
|
"description": "Returns 202 immediately. Replies arrive asynchronously on the configured callback URL. Reuse the same session_id within the aggregation window to merge multiple messages into one turn (N->1).",
|
||||||
|
"parameters": [
|
||||||
|
{ "$ref": "#/components/parameters/BotUuid" },
|
||||||
|
{ "$ref": "#/components/parameters/Timestamp" },
|
||||||
|
{ "$ref": "#/components/parameters/Signature" },
|
||||||
|
{ "$ref": "#/components/parameters/Idempotency" }
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/InboundMessage" } } }
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"202": {
|
||||||
|
"description": "Accepted (queued for the pipeline)",
|
||||||
|
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/AcceptedResponse" } } }
|
||||||
|
},
|
||||||
|
"400": { "$ref": "#/components/responses/Error" },
|
||||||
|
"401": { "$ref": "#/components/responses/Error" },
|
||||||
|
"409": { "$ref": "#/components/responses/Error" },
|
||||||
|
"413": { "$ref": "#/components/responses/Error" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/bots/{bot_uuid}/sync": {
|
||||||
|
"post": {
|
||||||
|
"summary": "Push a message and wait for the collapsed reply",
|
||||||
|
"description": "Blocking convenience mode. Waits for is_final and returns all reply parts collapsed into one array. Lossy (no sequence/streaming). One in-flight sync per session_id.",
|
||||||
|
"parameters": [
|
||||||
|
{ "$ref": "#/components/parameters/BotUuid" },
|
||||||
|
{ "$ref": "#/components/parameters/Timestamp" },
|
||||||
|
{ "$ref": "#/components/parameters/Signature" }
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/InboundMessage" } } }
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "The collapsed reply",
|
||||||
|
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/SyncResponse" } } }
|
||||||
|
},
|
||||||
|
"400": { "$ref": "#/components/responses/Error" },
|
||||||
|
"401": { "$ref": "#/components/responses/Error" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/bots/{bot_uuid}/reset": {
|
||||||
|
"post": {
|
||||||
|
"summary": "Reset a session's conversation",
|
||||||
|
"parameters": [
|
||||||
|
{ "$ref": "#/components/parameters/BotUuid" },
|
||||||
|
{ "$ref": "#/components/parameters/Timestamp" },
|
||||||
|
{ "$ref": "#/components/parameters/Signature" }
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["session_id"],
|
||||||
|
"properties": {
|
||||||
|
"session_id": { "type": "string" },
|
||||||
|
"session_type": { "type": "string", "enum": ["person", "group"] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": { "description": "Reset done" },
|
||||||
|
"400": { "$ref": "#/components/responses/Error" },
|
||||||
|
"401": { "$ref": "#/components/responses/Error" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"components": {
|
||||||
|
"parameters": {
|
||||||
|
"BotUuid": {
|
||||||
|
"name": "bot_uuid", "in": "path", "required": true,
|
||||||
|
"schema": { "type": "string", "format": "uuid" }
|
||||||
|
},
|
||||||
|
"Timestamp": {
|
||||||
|
"name": "X-LB-Timestamp", "in": "header", "required": true,
|
||||||
|
"description": "Unix seconds; rejected if more than +/-300s from server time.",
|
||||||
|
"schema": { "type": "string" }
|
||||||
|
},
|
||||||
|
"Signature": {
|
||||||
|
"name": "X-LB-Signature", "in": "header", "required": true,
|
||||||
|
"description": "sha256=<hex> of HMAC-SHA256(secret, \"{timestamp}.\" + raw_body).",
|
||||||
|
"schema": { "type": "string" }
|
||||||
|
},
|
||||||
|
"Idempotency": {
|
||||||
|
"name": "X-LB-Idempotency-Key", "in": "header", "required": false,
|
||||||
|
"description": "Dedup key; a repeat within the dedup window returns 409.",
|
||||||
|
"schema": { "type": "string" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"schemas": {
|
||||||
|
"Segment": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["type"],
|
||||||
|
"properties": {
|
||||||
|
"type": { "type": "string", "enum": ["Plain", "Image", "Voice", "File", "At", "Quote"] },
|
||||||
|
"text": { "type": "string", "description": "For type=Plain." },
|
||||||
|
"url": { "type": "string", "description": "For media types." },
|
||||||
|
"base64": { "type": "string", "description": "For media types (data URI or raw base64)." }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"InboundMessage": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["session_id", "message"],
|
||||||
|
"properties": {
|
||||||
|
"session_id": { "type": "string", "description": "Caller-defined; maps 1:1 to a LangBot session." },
|
||||||
|
"session_type": { "type": "string", "enum": ["person", "group"], "default": "person" },
|
||||||
|
"sender": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": { "type": "string" },
|
||||||
|
"name": { "type": "string" },
|
||||||
|
"group_name": { "type": "string", "description": "For session_type=group." }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"message": { "type": "array", "items": { "$ref": "#/components/schemas/Segment" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AcceptedResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"code": { "type": "integer", "example": 0 },
|
||||||
|
"msg": { "type": "string", "example": "accepted" },
|
||||||
|
"data": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"session_id": { "type": "string" },
|
||||||
|
"accepted_message_id": { "type": "string", "example": "in_01H..." },
|
||||||
|
"aggregating": { "type": "boolean" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"SyncResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"code": { "type": "integer", "example": 0 },
|
||||||
|
"msg": { "type": "string", "example": "ok" },
|
||||||
|
"data": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"session_id": { "type": "string" },
|
||||||
|
"reply_to": { "type": "string" },
|
||||||
|
"message": { "type": "array", "items": { "$ref": "#/components/schemas/Segment" } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Callback": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Delivered by LangBot to your callback_url, one POST per reply part. Signed with the outbound secret.",
|
||||||
|
"properties": {
|
||||||
|
"session_id": { "type": "string" },
|
||||||
|
"reply_to": { "type": "string", "description": "The accepted_message_id this answers." },
|
||||||
|
"sequence": { "type": "integer", "description": "1-based ordinal within the turn." },
|
||||||
|
"is_final": { "type": "boolean", "description": "True on the last part of the turn." },
|
||||||
|
"stream": { "type": "boolean" },
|
||||||
|
"message": { "type": "array", "items": { "$ref": "#/components/schemas/Segment" } },
|
||||||
|
"timestamp": { "type": "string", "format": "date-time" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ErrorEnvelope": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"code": { "type": "integer", "example": 40101 },
|
||||||
|
"msg": { "type": "string", "example": "invalid signature: signature_mismatch" },
|
||||||
|
"data": { "nullable": true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"Error": {
|
||||||
|
"description": "Error envelope",
|
||||||
|
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
# HTTP Bot Adapter — Integration Guide
|
||||||
|
|
||||||
|
Integrate **any backend system** with a LangBot pipeline over plain HTTP. Push
|
||||||
|
messages in via a signed webhook; receive replies on a callback URL. No
|
||||||
|
long-lived connection, full support for message **aggregation** (many inbound
|
||||||
|
messages merged into one turn) and **multi-part replies** (one turn → many
|
||||||
|
outbound messages).
|
||||||
|
|
||||||
|
This is the right adapter for **server-to-server** integrations — ticketing
|
||||||
|
systems, CRMs, internal tools, custom web backends. (For an in-browser,
|
||||||
|
real-time chat widget, use the embeddable Web Page Bot instead.)
|
||||||
|
|
||||||
|
> **5-minute goal:** stand up a callback receiver, send a message, and watch a
|
||||||
|
> multi-part reply arrive — using the reference client in
|
||||||
|
> [`examples/http-bot/`](../../examples/http-bot/).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Mental model
|
||||||
|
|
||||||
|
```
|
||||||
|
Your backend ──(1) POST signed message──► LangBot /bots/<bot_uuid>
|
||||||
|
(pipeline runs: aggregate → think → reply)
|
||||||
|
Your callback ◄─(2) POST signed reply(s)── LangBot one POST per reply part
|
||||||
|
```
|
||||||
|
|
||||||
|
- **(1) Inbound** is *fire-and-collect*: LangBot answers `202 Accepted`
|
||||||
|
immediately and does **not** return the pipeline result on that response.
|
||||||
|
- **(2) Outbound** replies arrive later as separate signed POSTs to your
|
||||||
|
`callback_url`. A single turn may produce **several** callbacks (e.g. a tool
|
||||||
|
call narration followed by the final answer).
|
||||||
|
- Everything is keyed by a **`session_id` you choose** (e.g. a ticket number).
|
||||||
|
Each `session_id` maps to one isolated LangBot conversation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Create the bot
|
||||||
|
|
||||||
|
1. In the LangBot dashboard, add a bot and choose the **HTTP Bot** platform.
|
||||||
|
2. Fill in the config:
|
||||||
|
|
||||||
|
| Field | Required | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| **Inbound Signing Secret** | yes | Your backend signs inbound requests with this. |
|
||||||
|
| **Outbound Callback URL** | yes | Where LangBot POSTs replies. **Config-only** — cannot be overridden per message (SSRF protection). |
|
||||||
|
| **Outbound Signing Secret** | no | LangBot signs callbacks with this; defaults to the inbound secret. |
|
||||||
|
| **Default Session Type** | no | `person` (default) or `group`. |
|
||||||
|
| **Require Inbound Signature** | no | Keep `true` in production. |
|
||||||
|
| **Callback Timeout / Max Retries** | no | Defaults: 15s, 3 retries. |
|
||||||
|
|
||||||
|
3. Bind the bot to a **pipeline** and **enable** it.
|
||||||
|
4. Copy the **Inbound Webhook URL** shown in the config — it looks like
|
||||||
|
`https://your-langbot/bots/<bot_uuid>`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. The signature scheme
|
||||||
|
|
||||||
|
Both directions use the same dependency-free HMAC-SHA256 scheme:
|
||||||
|
|
||||||
|
```
|
||||||
|
signing_string = "{timestamp}." + raw_body_bytes
|
||||||
|
signature = "sha256=" + hex(HMAC_SHA256(secret, signing_string))
|
||||||
|
```
|
||||||
|
|
||||||
|
Sent as headers:
|
||||||
|
|
||||||
|
| Header | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `X-LB-Timestamp` | Unix seconds. Rejected if more than **±300s** from server time. |
|
||||||
|
| `X-LB-Signature` | `sha256=<hex>` over `"{timestamp}." + body`. |
|
||||||
|
| `X-LB-Idempotency-Key` | *(optional, inbound)* dedup key; retries with the same key return `409`. |
|
||||||
|
|
||||||
|
Verify outbound callbacks the same way, using the **outbound** secret (or the
|
||||||
|
inbound secret if you left it blank).
|
||||||
|
|
||||||
|
A six-line reference implementation is in `examples/http-bot/client.py`
|
||||||
|
(`sign()` / `verify()`); a Node/TS version is in `client.ts`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Send your first message (curl)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
BOT="https://your-langbot/bots/<bot_uuid>"
|
||||||
|
SECRET="your-inbound-secret"
|
||||||
|
BODY='{"session_id":"ticket-10293","message":[{"type":"Plain","text":"Export keeps failing on the dashboard."}]}'
|
||||||
|
TS=$(date +%s)
|
||||||
|
SIG="sha256=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -r | cut -d' ' -f1)"
|
||||||
|
|
||||||
|
curl -sS -X POST "$BOT" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "X-LB-Timestamp: $TS" \
|
||||||
|
-H "X-LB-Signature: $SIG" \
|
||||||
|
-d "$BODY"
|
||||||
|
# -> 202 {"code":0,"msg":"accepted","data":{"session_id":"ticket-10293","accepted_message_id":"in_...","aggregating":true}}
|
||||||
|
```
|
||||||
|
|
||||||
|
The reply(s) will be POSTed to your configured callback URL shortly after.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Inbound request format
|
||||||
|
|
||||||
|
`POST /bots/{bot_uuid}`
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"session_id": "ticket-10293", // REQUIRED. Your stable id. Maps 1:1 to a LangBot session.
|
||||||
|
"session_type": "person", // optional: "person" | "group"; default from config
|
||||||
|
"sender": { // optional metadata, surfaced to the pipeline/plugins
|
||||||
|
"id": "user-5567",
|
||||||
|
"name": "Alice"
|
||||||
|
},
|
||||||
|
"message": [ // REQUIRED. A LangBot MessageChain (array of segments).
|
||||||
|
{ "type": "Plain", "text": "Export keeps failing on the dashboard." },
|
||||||
|
{ "type": "Image", "url": "https://example.com/screenshot.png" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Message segments.** Text uses `{"type":"Plain","text":"..."}`. Images use
|
||||||
|
`{"type":"Image","url":"..."}` (or `base64`). Other supported types: `Voice`,
|
||||||
|
`File`, `At`, `Quote`.
|
||||||
|
|
||||||
|
> Note: the callback URL is **not** accepted in the body — it is taken only from
|
||||||
|
> bot config. This is deliberate (prevents an attacker who obtains the inbound
|
||||||
|
> secret from redirecting replies to an arbitrary host).
|
||||||
|
|
||||||
|
### Aggregation (N → 1)
|
||||||
|
|
||||||
|
If your pipeline has **message aggregation** enabled, send several messages with
|
||||||
|
the **same `session_id`** within the aggregation window and they are merged into
|
||||||
|
**one** pipeline turn. No special flag — just reuse the `session_id`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Outbound callback format
|
||||||
|
|
||||||
|
LangBot POSTs each reply part to your `callback_url`:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"session_id": "ticket-10293", // echoes the inbound session
|
||||||
|
"reply_to": "in_01H...", // the accepted_message_id this answers
|
||||||
|
"sequence": 1, // 1-based ordinal within this turn
|
||||||
|
"is_final": false, // true on the last part of the turn
|
||||||
|
"stream": false, // true for streamed chunks
|
||||||
|
"message": [ { "type": "Plain", "text": "Looking into it…" } ],
|
||||||
|
"timestamp": "2026-06-22T09:00:01Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Your endpoint should return `2xx` quickly. Non-2xx / timeout → LangBot retries
|
||||||
|
with exponential backoff (up to `callback_max_retries`).
|
||||||
|
|
||||||
|
### Multi-part replies (1 → M)
|
||||||
|
|
||||||
|
One turn may emit multiple callbacks, delivered **in `sequence` order** for a
|
||||||
|
given session:
|
||||||
|
|
||||||
|
```
|
||||||
|
seq=1 is_final=false "Checking your export logs…"
|
||||||
|
seq=2 is_final=false "Found 2 failed exports."
|
||||||
|
seq=3 is_final=true "Fixed — please try again."
|
||||||
|
```
|
||||||
|
|
||||||
|
Stitch by `session_id` + `sequence`; the turn is complete when
|
||||||
|
`is_final: true` arrives.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Reset a session
|
||||||
|
|
||||||
|
Start a fresh conversation for a `session_id` (drops history):
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /bots/{bot_uuid}/reset
|
||||||
|
{ "session_id": "ticket-10293", "session_type": "person" }
|
||||||
|
→ 200 { "code":0, "msg":"reset", "data": { "session_id":"ticket-10293", "removed": true } }
|
||||||
|
```
|
||||||
|
|
||||||
|
Signed exactly like an inbound message.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Synchronous convenience mode
|
||||||
|
|
||||||
|
If you don't need streaming/multi-part and just want one reply back on the same
|
||||||
|
HTTP call, POST to `/sync`. LangBot waits for the turn to finish and returns all
|
||||||
|
parts **collapsed** into one array:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /bots/{bot_uuid}/sync
|
||||||
|
{ "session_id": "ticket-10293", "message": [ { "type":"Plain", "text":"hi" } ] }
|
||||||
|
→ 200 { "code":0, "msg":"ok",
|
||||||
|
"data": { "session_id":"ticket-10293", "reply_to":"in_...",
|
||||||
|
"message": [ {"type":"Plain","text":"..."}, ... ] } }
|
||||||
|
```
|
||||||
|
|
||||||
|
This is **lossy** (you lose `sequence` / streaming boundaries) and blocks up to
|
||||||
|
`callback_timeout × 4` seconds. Prefer the callback model for anything
|
||||||
|
real-time or multi-part. Only one in-flight `/sync` per `session_id`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Error envelope
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{ "code": 40101, "msg": "invalid signature: signature_mismatch", "data": null }
|
||||||
|
```
|
||||||
|
|
||||||
|
| HTTP | code | meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| 202 | 0 | accepted |
|
||||||
|
| 400 | 40001 | malformed body / missing `session_id` or `message` |
|
||||||
|
| 401 | 40101 | bad/expired signature |
|
||||||
|
| 409 | 40901 | duplicate idempotency key |
|
||||||
|
| 413 | 41301 | message too large (>1 MiB) |
|
||||||
|
| 500 | 50001 | internal error |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Try it end-to-end in 5 minutes
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd examples/http-bot
|
||||||
|
pip install flask requests
|
||||||
|
|
||||||
|
# Terminal 1 — your callback receiver (point the bot's callback_url here, e.g. via a tunnel):
|
||||||
|
python client.py serve --port 8900 --secret SHARED_SECRET
|
||||||
|
|
||||||
|
# Terminal 2 — push a message:
|
||||||
|
python client.py push \
|
||||||
|
--url https://your-langbot/bots/<bot_uuid> \
|
||||||
|
--secret SHARED_SECRET \
|
||||||
|
--session ticket-1 \
|
||||||
|
--text "hello"
|
||||||
|
```
|
||||||
|
|
||||||
|
Watch Terminal 1 print each reply part (`[part ]` / `[FINAL]`) with its
|
||||||
|
sequence number — that's 1→M working, signatures verified.
|
||||||
|
|
||||||
|
A machine-readable contract is in
|
||||||
|
[`docs/http-bot-openapi.json`](../http-bot-openapi.json).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Security checklist
|
||||||
|
|
||||||
|
- Keep **Require Inbound Signature** on in production.
|
||||||
|
- Use **HTTPS** callback URLs; the URL is config-only (no per-message override).
|
||||||
|
- Treat the secrets like passwords; rotate via the dashboard.
|
||||||
|
- The inbound route is unauthenticated at the framework level **by design** —
|
||||||
|
security comes entirely from the HMAC signature, so never disable it on a
|
||||||
|
public deployment.
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
# MCP Resources PR #2215 Review
|
||||||
|
|
||||||
|
> 更新日期: 2026-06-29
|
||||||
|
> 分支: `mcp_resources`
|
||||||
|
> PR: langbot-app/LangBot#2215
|
||||||
|
> 主题: MCP Resources 在 LangBot 中的产品价值、AgentRunner 集成方式与后续架构方向
|
||||||
|
|
||||||
|
## 结论
|
||||||
|
|
||||||
|
PR #2215 对 LangBot 有明确价值:它补齐了 MCP 协议中 Resources 这一重要能力,让 MCP server 不再只暴露 tools,也可以暴露文档、代码片段、配置、日志、图片等上下文资源。管理端可以发现和预览资源,Agent 也可以通过当前实现按需列出和读取资源。
|
||||||
|
|
||||||
|
但当前 AgentRunner 层的接入方式更接近一个可用的第一阶段方案,而不是最终架构。现在 MCP Resources 被包装成两个 synthetic tools:
|
||||||
|
|
||||||
|
- `langbot_mcp_list_resources`
|
||||||
|
- `langbot_mcp_read_resource`
|
||||||
|
|
||||||
|
这让模型可以通过 function calling 主动探索资源,落地成本低,也复用了已有 `ToolManager` / `LocalAgentRunner` 的工具调用链路。不过从 MCP 规范和主流实现来看,Resources 更适合作为一种一等上下文来源,而不是长期隐藏在工具列表里。
|
||||||
|
|
||||||
|
建议保留当前 synthetic tools 作为探索能力,同时把后续主线设计调整为:MCP Resources 是 pipeline / conversation / message 级别可选择、可固定、可审计的上下文输入。
|
||||||
|
|
||||||
|
## 当前实现判断
|
||||||
|
|
||||||
|
当前 AgentRunner 集成路径如下:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Pipeline 绑定 MCP server
|
||||||
|
-> query.variables['_pipeline_bound_mcp_servers']
|
||||||
|
-> Preproc 为 local-agent 加载工具
|
||||||
|
-> ToolManager.get_all_tools()
|
||||||
|
-> MCPLoader 注入 synthetic resource tools
|
||||||
|
-> LocalAgentRunner 将工具 schema 传给模型
|
||||||
|
-> 模型发起 list/read tool call
|
||||||
|
-> ToolManager.execute_func_call()
|
||||||
|
-> MCPLoader 调 MCP session.list_resources/read_resource
|
||||||
|
-> tool result 回灌给模型
|
||||||
|
```
|
||||||
|
|
||||||
|
这个路径的优点是:
|
||||||
|
|
||||||
|
- 复用现有工具调用机制,改动范围小。
|
||||||
|
- Agent 可以按需探索资源,不需要每轮预先读取所有资源。
|
||||||
|
- 可以沿用 pipeline 绑定的 MCP server 范围,避免越权读取未绑定 server。
|
||||||
|
- 对已有 MCP tools 行为影响较小。
|
||||||
|
|
||||||
|
主要问题是:
|
||||||
|
|
||||||
|
- Resources 在语义上被降级成 tools,和 MCP 规范里的 resource primitive 不完全一致。
|
||||||
|
- 模型必须先理解并主动调用 `list/read`,资源不会自然成为上下文。
|
||||||
|
- pipeline 不能配置“默认携带某些资源”或“本轮附加某些资源”。
|
||||||
|
- UI 资源 tab 目前是管理端预览能力,和 Agent 上下文选择没有打通。
|
||||||
|
- 对 blob、图片、大文件、结构化资源的处理还比较粗糙。
|
||||||
|
- 缺少 resource templates、订阅更新、缓存、chunk、token budget、trace 与审计策略。
|
||||||
|
|
||||||
|
## 主流项目做法
|
||||||
|
|
||||||
|
### MCP 官方规范
|
||||||
|
|
||||||
|
MCP Resources 是 server 暴露上下文数据的协议能力。规范没有要求 resources 必须以 tool call 形式给模型使用,而是把如何选择、过滤、读取和纳入上下文交给 Host application。
|
||||||
|
|
||||||
|
这意味着比较正统的集成方式是:LangBot 作为 Host,在 pipeline、会话或消息层决定哪些 resources 进入模型上下文。
|
||||||
|
|
||||||
|
参考: https://modelcontextprotocol.io/specification/2025-06-18/server/resources
|
||||||
|
|
||||||
|
### VS Code Copilot
|
||||||
|
|
||||||
|
VS Code 把 MCP Resources 做成 chat context 的一部分。用户可以通过 `Add Context > MCP Resources` 或命令浏览 MCP resources,并把选中的资源附加到一次 chat request。
|
||||||
|
|
||||||
|
这是目前最值得 LangBot 参考的产品形态:资源不是模型工具,而是用户和 Host 可控的上下文附件。
|
||||||
|
|
||||||
|
参考: https://code.visualstudio.com/docs/agent-customization/mcp-servers
|
||||||
|
|
||||||
|
### Anthropic SDK
|
||||||
|
|
||||||
|
Anthropic 的 client-side MCP helpers 提供资源读取和转换能力,例如把 MCP resource 转为 Claude message content 或 file。也就是说,应用先读取 resource,再显式放进模型消息。
|
||||||
|
|
||||||
|
这同样是 application-owned context injection,而不是把 resource 伪装成模型工具。
|
||||||
|
|
||||||
|
参考: https://platform.claude.com/docs/en/agents-and-tools/mcp-connector
|
||||||
|
|
||||||
|
### LangChain MCP Adapters
|
||||||
|
|
||||||
|
LangChain 把 MCP Resources 更像 data loader / document input 来处理,可以把资源加载成 `Blob`,再进入 LangChain 的文档、检索或上下文处理链路。
|
||||||
|
|
||||||
|
这说明 Resources 很适合作为知识源、文档源或上下文源,而不只是即时工具调用。
|
||||||
|
|
||||||
|
参考: https://docs.langchain.com/oss/python/langchain/mcp
|
||||||
|
|
||||||
|
### OpenAI Agents SDK
|
||||||
|
|
||||||
|
OpenAI Agents SDK 主路径仍偏向 MCP tools,但底层 MCP server API 已经有 `list_resources`、`list_resource_templates`、`read_resource` 等能力。当前形态说明 resources 是 client 能力,但并未默认变成 agent-visible tools。
|
||||||
|
|
||||||
|
参考: https://openai.github.io/openai-agents-python/mcp/
|
||||||
|
|
||||||
|
### Cline
|
||||||
|
|
||||||
|
Cline 会拉取 MCP tools、resources、resourceTemplates、prompts,并通过类似 `access_mcp_resource` 的内置访问方式让模型读取资源。这个方向和 LangBot 当前 synthetic tools 比较接近。
|
||||||
|
|
||||||
|
这种模式适合让 Agent 自主探索,但更像 Host 自定义的模型访问协议,不应成为唯一集成路径。
|
||||||
|
|
||||||
|
参考: https://github.com/cline/cline/blob/main/src/services/mcp/McpHub.ts
|
||||||
|
|
||||||
|
## 建议架构方向
|
||||||
|
|
||||||
|
### 1. 保留探索型工具
|
||||||
|
|
||||||
|
保留当前两个 synthetic tools:
|
||||||
|
|
||||||
|
- `langbot_mcp_list_resources`
|
||||||
|
- `langbot_mcp_read_resource`
|
||||||
|
|
||||||
|
它们适合处理“用户没有显式选择资源,但 Agent 判断需要探索 MCP server 上下文”的场景。后续可以优化工具描述、返回格式、资源大小限制和错误信息。
|
||||||
|
|
||||||
|
### 2. 增加一等 Resource Context
|
||||||
|
|
||||||
|
新增一个 Host 层资源上下文概念,例如:
|
||||||
|
|
||||||
|
```text
|
||||||
|
PipelineResourceBinding
|
||||||
|
ConversationResourceAttachment
|
||||||
|
MessageResourceAttachment
|
||||||
|
```
|
||||||
|
|
||||||
|
Preproc 或独立的 `ResourceContextProvider` 在模型调用前读取这些资源,按 MIME 类型、大小、token budget 转为模型可消费的上下文。
|
||||||
|
|
||||||
|
### 3. 打通 UI 与 Agent 上下文
|
||||||
|
|
||||||
|
当前 MCP 详情页的 Resources tab 可以继续作为资源发现和预览入口。建议增加操作:
|
||||||
|
|
||||||
|
- 添加到本轮上下文
|
||||||
|
- 固定到当前 pipeline
|
||||||
|
- 固定到当前 bot / conversation
|
||||||
|
- 查看资源读取历史和错误
|
||||||
|
|
||||||
|
这样 UI 资源管理能力才能真正影响 Agent 行为。
|
||||||
|
|
||||||
|
### 4. 支持 resource templates
|
||||||
|
|
||||||
|
MCP resource templates 允许 server 暴露参数化资源,例如:
|
||||||
|
|
||||||
|
```text
|
||||||
|
repo://{owner}/{repo}/file/{path}
|
||||||
|
log://{service}/{date}
|
||||||
|
```
|
||||||
|
|
||||||
|
LangBot 后续应支持模板发现、参数填写、实例化和绑定。否则只能使用静态 resources,覆盖面会受限。
|
||||||
|
|
||||||
|
### 5. 增加资源处理策略
|
||||||
|
|
||||||
|
建议补齐:
|
||||||
|
|
||||||
|
- 文本资源 token budget 与截断策略。
|
||||||
|
- 大文件 chunk 与摘要策略。
|
||||||
|
- 图片/blob 的模型能力判断与 fallback。
|
||||||
|
- MIME 类型白名单与安全限制。
|
||||||
|
- 缓存与过期策略。
|
||||||
|
- `resources/listChanged` 或订阅更新。
|
||||||
|
- resource read trace,便于审计 Agent 读取了什么上下文。
|
||||||
|
|
||||||
|
## 推荐落地顺序
|
||||||
|
|
||||||
|
### Phase 1: 完成当前 PR 可用性
|
||||||
|
|
||||||
|
- 保留 synthetic tools。
|
||||||
|
- 明确文档说明当前 Agent 集成是 tool-mediated。
|
||||||
|
- 完善资源工具描述,降低模型误用概率。
|
||||||
|
- 给 read/list 增加大小限制和更清晰的 MIME 处理。
|
||||||
|
- 前端 Resources tab 与 Tools tab 分离,保持管理端清晰。
|
||||||
|
|
||||||
|
### Phase 2: 做 Host-owned context attachments
|
||||||
|
|
||||||
|
- 在 pipeline 或 conversation 层新增 resource attachment 配置。
|
||||||
|
- Preproc 读取已绑定 resources,注入模型上下文。
|
||||||
|
- UI 支持“添加到上下文 / 固定到 pipeline”。
|
||||||
|
- 记录每轮实际注入的 resource URI 和 token 消耗。
|
||||||
|
|
||||||
|
### Phase 3: 做完整 MCP Resources 能力
|
||||||
|
|
||||||
|
- 支持 resource templates。
|
||||||
|
- 支持资源订阅更新。
|
||||||
|
- 支持 chunk、summary、RAG 化接入。
|
||||||
|
- 为 DifyAgentRunner、LocalAgentRunner 等不同 runner 定义统一资源上下文接口。
|
||||||
|
|
||||||
|
## 最终建议
|
||||||
|
|
||||||
|
PR #2215 可以作为 MCP Resources 的第一阶段实现继续推进。它让 LangBot 快速拥有“资源发现、预览、按需读取”的闭环,也给 Agent 探索资源提供了可运行路径。
|
||||||
|
|
||||||
|
但在正式设计上,不建议把 “Resources == Tools” 固化为长期抽象。LangBot 更应该把 MCP Resources 定位为上下文来源,与 tools、prompts、knowledge base 并列:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Tools -> Agent 可以执行的动作
|
||||||
|
Resources -> Host/用户/Agent 可以选择的上下文数据
|
||||||
|
Prompts -> 可复用的任务模板
|
||||||
|
Knowledge -> 可检索、可索引的长期知识
|
||||||
|
```
|
||||||
|
|
||||||
|
这样既尊重 MCP 协议语义,也能让 LangBot 在 Agent 工作流、企业知识接入和多 MCP server 管理上走得更稳。
|
||||||
@@ -0,0 +1,425 @@
|
|||||||
|
# Workflow 用户指南
|
||||||
|
|
||||||
|
本文档帮助您了解和使用 LangBot 的 Workflow(工作流)功能,通过可视化方式构建自动化的对话处理流程。
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
|
||||||
|
- [功能介绍](#功能介绍)
|
||||||
|
- [快速入门](#快速入门)
|
||||||
|
- [节点类型说明](#节点类型说明)
|
||||||
|
- [编辑器使用指南](#编辑器使用指南)
|
||||||
|
- [调试功能](#调试功能)
|
||||||
|
- [常见问题解答](#常见问题解答)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 功能介绍
|
||||||
|
|
||||||
|
### 什么是 Workflow?
|
||||||
|
|
||||||
|
Workflow(工作流)是 LangBot 提供的可视化自动化编排系统。通过拖拽节点、连接边的方式,您可以:
|
||||||
|
|
||||||
|
- 📝 **构建复杂的对话流程**:使用条件分支、循环等控制节点
|
||||||
|
- 🤖 **调用 AI 能力**:集成 LLM、知识库检索、参数提取
|
||||||
|
- 🔗 **连接外部服务**:集成 Dify、n8n、Coze 等平台
|
||||||
|
- ⚡ **自动化任务执行**:消息触发、定时触发、Webhook 触发
|
||||||
|
|
||||||
|
### Workflow vs Pipeline
|
||||||
|
|
||||||
|
| 对比项 | Pipeline | Workflow |
|
||||||
|
|-------|----------|----------|
|
||||||
|
| 配置方式 | 表单配置 | 可视化拖拽 |
|
||||||
|
| 流程控制 | 线性执行 | 支持分支、循环、并行 |
|
||||||
|
| 适用场景 | 简单对话 | 复杂流程 |
|
||||||
|
| 学习曲线 | 低 | 中等 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 快速入门
|
||||||
|
|
||||||
|
### 第一步:创建 Workflow
|
||||||
|
|
||||||
|
1. 在侧边栏点击 **Workflow** 进入工作流列表
|
||||||
|
2. 点击右上角 **创建工作流** 按钮
|
||||||
|
3. 填写基本信息:
|
||||||
|
- **名称**:给工作流起一个描述性的名字
|
||||||
|
- **描述**:可选,说明工作流的用途
|
||||||
|
- **图标**:选择一个 emoji 作为标识
|
||||||
|
|
||||||
|
### 第二步:添加节点
|
||||||
|
|
||||||
|
进入编辑器后,左侧是节点面板,中间是画布区域,右侧是属性面板。
|
||||||
|
|
||||||
|
1. **添加触发节点**:从左侧面板拖拽一个"消息触发"节点到画布
|
||||||
|
2. **添加 AI 节点**:拖拽一个"LLM 调用"节点
|
||||||
|
3. **添加回复节点**:拖拽一个"回复消息"节点
|
||||||
|
|
||||||
|
### 第三步:连接节点
|
||||||
|
|
||||||
|
1. 将鼠标悬停在触发节点的输出端口(右侧小圆点)
|
||||||
|
2. 按住鼠标拖拽到 LLM 节点的输入端口(左侧小圆点)
|
||||||
|
3. 同样方式连接 LLM 节点和回复节点
|
||||||
|
|
||||||
|
```
|
||||||
|
[消息触发] ──▶ [LLM 调用] ──▶ [回复消息]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 第四步:配置节点
|
||||||
|
|
||||||
|
点击 LLM 调用节点,在右侧属性面板配置:
|
||||||
|
|
||||||
|
- **运行方式**:选择"本地 Agent"
|
||||||
|
- **系统提示词**:描述 AI 的角色和行为
|
||||||
|
- **模型**:选择要使用的 LLM 模型
|
||||||
|
|
||||||
|
点击回复消息节点配置:
|
||||||
|
|
||||||
|
- **消息内容**:设置为 `{{nodes.llm_call.outputs.response}}`(引用 LLM 输出)
|
||||||
|
|
||||||
|
### 第五步:保存并绑定
|
||||||
|
|
||||||
|
1. 点击工具栏的 **保存** 按钮
|
||||||
|
2. 返回 Bot 配置页面
|
||||||
|
3. 在 Bot 的绑定设置中选择 **Workflow**,然后选择刚创建的工作流
|
||||||
|
|
||||||
|
恭喜!您已经创建了第一个 Workflow。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 节点类型说明
|
||||||
|
|
||||||
|
### 触发节点 (Trigger)
|
||||||
|
|
||||||
|
触发节点是工作流的入口,定义何时启动执行。
|
||||||
|
|
||||||
|
| 节点 | 说明 | 输出 |
|
||||||
|
|-----|------|------|
|
||||||
|
| 消息触发 | 收到消息时触发 | message, sender_id, platform |
|
||||||
|
| 定时触发 | 按 Cron 表达式定时触发 | timestamp |
|
||||||
|
| Webhook 触发 | 收到 HTTP 请求时触发 | request_body, headers |
|
||||||
|
| 事件触发 | 系统事件触发 | event_type, event_data |
|
||||||
|
|
||||||
|
**消息触发配置示例**:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
触发条件:
|
||||||
|
- 关键词匹配: ["帮助", "help"]
|
||||||
|
- 平台: ["wechat", "qq"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### AI 节点
|
||||||
|
|
||||||
|
AI 节点用于调用各种 AI 能力。
|
||||||
|
|
||||||
|
| 节点 | 说明 | 典型用途 |
|
||||||
|
|-----|------|---------|
|
||||||
|
| LLM 调用 | 调用大语言模型 | 生成回复、理解意图 |
|
||||||
|
| 问题分类器 | 对用户问题分类 | 路由到不同处理分支 |
|
||||||
|
| 参数提取器 | 从文本提取结构化数据 | 提取订单号、日期等 |
|
||||||
|
| 知识库检索 | 查询知识库 | RAG 增强回复 |
|
||||||
|
|
||||||
|
**LLM 调用配置示例**:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
运行方式: 本地 Agent
|
||||||
|
模型: gpt-4
|
||||||
|
系统提示词: |
|
||||||
|
你是一个友好的客服助手。
|
||||||
|
请根据用户的问题提供帮助。
|
||||||
|
温度: 0.7
|
||||||
|
最大 Token 数: 2000
|
||||||
|
```
|
||||||
|
|
||||||
|
### 处理节点 (Process)
|
||||||
|
|
||||||
|
处理节点用于数据处理和外部调用。
|
||||||
|
|
||||||
|
| 节点 | 说明 | 典型用途 |
|
||||||
|
|-----|------|---------|
|
||||||
|
| 代码执行 | 执行 Python/JavaScript 代码 | 数据处理、格式转换 |
|
||||||
|
| HTTP 请求 | 发送 HTTP 请求 | 调用外部 API |
|
||||||
|
| 数据转换 | JSON/模板转换 | 数据格式化 |
|
||||||
|
|
||||||
|
**HTTP 请求配置示例**:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
URL: https://api.example.com/data
|
||||||
|
方法: POST
|
||||||
|
请求头:
|
||||||
|
Content-Type: application/json
|
||||||
|
Authorization: Bearer {{variables.api_key}}
|
||||||
|
请求体: |
|
||||||
|
{"query": "{{message.content}}"}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 控制节点 (Control)
|
||||||
|
|
||||||
|
控制节点用于流程控制。
|
||||||
|
|
||||||
|
| 节点 | 说明 | 用途 |
|
||||||
|
|-----|------|------|
|
||||||
|
| 条件分支 | 二选一分支 | if-else 逻辑 |
|
||||||
|
| 多路分支 | 多选一分支 | switch-case 逻辑 |
|
||||||
|
| 循环 | 遍历数组 | 批量处理 |
|
||||||
|
| 并行 | 同时执行多分支 | 并发处理 |
|
||||||
|
| 等待 | 暂停执行 | 延时处理 |
|
||||||
|
| 合并 | 合并多个分支 | 汇总结果 |
|
||||||
|
|
||||||
|
**条件分支配置示例**:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
条件表达式: "{{nodes.classifier.outputs.category}}" == "complaint"
|
||||||
|
真分支: 投诉处理
|
||||||
|
假分支: 普通咨询
|
||||||
|
```
|
||||||
|
|
||||||
|
### 动作节点 (Action)
|
||||||
|
|
||||||
|
动作节点执行具体操作。
|
||||||
|
|
||||||
|
| 节点 | 说明 | 用途 |
|
||||||
|
|-----|------|------|
|
||||||
|
| 发送消息 | 主动发送消息 | 通知、推送 |
|
||||||
|
| 回复消息 | 回复当前消息 | 对话回复 |
|
||||||
|
| 存储数据 | 保存数据到存储 | 持久化 |
|
||||||
|
| 调用 Pipeline | 调用现有 Pipeline | 复用现有流程 |
|
||||||
|
|
||||||
|
**回复消息配置示例**:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
消息内容: |
|
||||||
|
感谢您的咨询!
|
||||||
|
|
||||||
|
{{nodes.llm_call.outputs.response}}
|
||||||
|
|
||||||
|
如有其他问题,随时联系我。
|
||||||
|
```
|
||||||
|
|
||||||
|
### 集成节点 (Integration)
|
||||||
|
|
||||||
|
集成节点连接外部平台。
|
||||||
|
|
||||||
|
| 节点 | 说明 | 平台 |
|
||||||
|
|-----|------|------|
|
||||||
|
| Dify 工作流 | 调用 Dify 应用 | Dify |
|
||||||
|
| Dify 知识库 | 查询 Dify 知识库 | Dify |
|
||||||
|
| n8n 工作流 | 调用 n8n 流程 | n8n |
|
||||||
|
| Langflow | 调用 Langflow 流程 | Langflow |
|
||||||
|
| Coze Bot | 调用扣子 Bot | Coze |
|
||||||
|
|
||||||
|
**Dify 工作流配置示例**:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
API 地址: https://api.dify.ai/v1
|
||||||
|
API Key: sk-xxxxx
|
||||||
|
应用类型: workflow
|
||||||
|
同步对话历史: true
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 编辑器使用指南
|
||||||
|
|
||||||
|
### 画布操作
|
||||||
|
|
||||||
|
| 操作 | 方式 |
|
||||||
|
|-----|------|
|
||||||
|
| 平移画布 | 按住鼠标中键/空格+左键 拖拽 |
|
||||||
|
| 缩放画布 | 鼠标滚轮 / 工具栏按钮 |
|
||||||
|
| 框选多个节点 | 按住 Shift + 拖拽框选 |
|
||||||
|
| 适应视图 | 点击工具栏"适应"按钮 |
|
||||||
|
|
||||||
|
### 节点操作
|
||||||
|
|
||||||
|
| 操作 | 方式 |
|
||||||
|
|-----|------|
|
||||||
|
| 添加节点 | 从左侧面板拖拽到画布 |
|
||||||
|
| 移动节点 | 点击节点拖拽 |
|
||||||
|
| 删除节点 | 选中后按 Delete / 点击工具栏删除 |
|
||||||
|
| 复制节点 | 选中后 Ctrl+C / 工具栏复制 |
|
||||||
|
| 粘贴节点 | Ctrl+V / 工具栏粘贴 |
|
||||||
|
|
||||||
|
### 连接操作
|
||||||
|
|
||||||
|
| 操作 | 方式 |
|
||||||
|
|-----|------|
|
||||||
|
| 创建连接 | 从输出端口拖拽到输入端口 |
|
||||||
|
| 删除连接 | 点击连接线后按 Delete |
|
||||||
|
| 选中连接 | 点击连接线 |
|
||||||
|
|
||||||
|
### 快捷键
|
||||||
|
|
||||||
|
| 快捷键 | 功能 |
|
||||||
|
|-------|------|
|
||||||
|
| Ctrl + Z | 撤销 |
|
||||||
|
| Ctrl + Shift + Z | 重做 |
|
||||||
|
| Ctrl + C | 复制 |
|
||||||
|
| Ctrl + V | 粘贴 |
|
||||||
|
| Delete | 删除选中 |
|
||||||
|
| Ctrl + S | 保存 |
|
||||||
|
|
||||||
|
### 工具栏功能
|
||||||
|
|
||||||
|
```
|
||||||
|
[撤销] [重做] | [放大] [缩小] [适应] | [复制] [粘贴] [删除] | [保存] [调试]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 调试功能
|
||||||
|
|
||||||
|
### 启动调试
|
||||||
|
|
||||||
|
1. 点击工具栏的 **调试** 按钮
|
||||||
|
2. 在调试面板中配置初始数据:
|
||||||
|
- **输入消息**:模拟用户发送的消息
|
||||||
|
- **会话 ID**:可选,用于测试会话变量
|
||||||
|
- **变量**:设置初始变量值
|
||||||
|
|
||||||
|
3. 点击 **开始调试** 按钮
|
||||||
|
|
||||||
|
### 调试控制
|
||||||
|
|
||||||
|
| 按钮 | 功能 |
|
||||||
|
|-----|------|
|
||||||
|
| ▶️ 开始/继续 | 开始或继续执行 |
|
||||||
|
| ⏸️ 暂停 | 暂停执行 |
|
||||||
|
| ⏹️ 停止 | 停止执行 |
|
||||||
|
| ⏭️ 单步 | 执行下一个节点 |
|
||||||
|
|
||||||
|
### 断点
|
||||||
|
|
||||||
|
- **设置断点**:点击节点上的断点图标
|
||||||
|
- **断点触发**:执行到断点时自动暂停
|
||||||
|
- **查看状态**:在暂停时查看节点的输入输出
|
||||||
|
|
||||||
|
### 执行日志
|
||||||
|
|
||||||
|
调试面板下方显示实时日志:
|
||||||
|
|
||||||
|
```
|
||||||
|
[INFO] 2024-01-15 10:30:00 - Starting debug execution
|
||||||
|
[INFO] 2024-01-15 10:30:00 - Executing node: message_trigger
|
||||||
|
[DEBUG] 2024-01-15 10:30:00 - Node inputs: {"message": "你好"}
|
||||||
|
[INFO] 2024-01-15 10:30:01 - Node completed in 50ms
|
||||||
|
[INFO] 2024-01-15 10:30:01 - Executing node: llm_call
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 节点状态颜色
|
||||||
|
|
||||||
|
| 颜色 | 状态 |
|
||||||
|
|-----|------|
|
||||||
|
| 灰色 | 待执行 |
|
||||||
|
| 蓝色 | 执行中 |
|
||||||
|
| 绿色 | 已完成 |
|
||||||
|
| 红色 | 失败 |
|
||||||
|
| 黄色 | 已跳过 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 常见问题解答
|
||||||
|
|
||||||
|
### Q1:如何在节点间传递数据?
|
||||||
|
|
||||||
|
使用表达式语法引用其他节点的输出:
|
||||||
|
|
||||||
|
```
|
||||||
|
{{nodes.节点ID.outputs.输出名称}}
|
||||||
|
```
|
||||||
|
|
||||||
|
例如:
|
||||||
|
- `{{nodes.llm_call.outputs.response}}` - 引用 LLM 节点的响应
|
||||||
|
- `{{nodes.http_request.outputs.body}}` - 引用 HTTP 请求的响应体
|
||||||
|
|
||||||
|
### Q2:如何使用变量?
|
||||||
|
|
||||||
|
Workflow 支持三种变量类型:
|
||||||
|
|
||||||
|
1. **工作流变量**:`{{variables.变量名}}`
|
||||||
|
2. **会话变量**:`{{conversation_variables.变量名}}`
|
||||||
|
3. **消息上下文**:`{{message.content}}`、`{{message.sender_id}}`
|
||||||
|
|
||||||
|
### Q3:条件分支如何写条件表达式?
|
||||||
|
|
||||||
|
支持以下运算符:
|
||||||
|
|
||||||
|
- 比较:`==`, `!=`, `>`, `<`, `>=`, `<=`
|
||||||
|
- 逻辑:`and`, `or`, `not`
|
||||||
|
- 包含:`in`
|
||||||
|
|
||||||
|
示例:
|
||||||
|
```python
|
||||||
|
# 字符串比较
|
||||||
|
"{{nodes.classifier.outputs.intent}}" == "purchase"
|
||||||
|
|
||||||
|
# 数值比较
|
||||||
|
{{nodes.extractor.outputs.amount}} > 1000
|
||||||
|
|
||||||
|
# 包含检查
|
||||||
|
"退款" in "{{message.content}}"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Q4:如何处理错误?
|
||||||
|
|
||||||
|
1. **节点级重试**:在节点配置中设置重试次数
|
||||||
|
2. **全局错误处理**:在 Workflow 设置中配置错误处理策略
|
||||||
|
3. **条件分支**:使用条件节点检查上一节点的状态
|
||||||
|
|
||||||
|
### Q5:如何查看执行历史?
|
||||||
|
|
||||||
|
1. 进入 Workflow 详情页
|
||||||
|
2. 点击 **执行历史** 标签
|
||||||
|
3. 查看每次执行的状态、耗时、输入输出
|
||||||
|
|
||||||
|
### Q6:Workflow 可以被多个 Bot 使用吗?
|
||||||
|
|
||||||
|
是的。一个 Workflow 可以被多个 Bot 绑定使用,但每个 Bot 只能绑定一个处理单元(Pipeline 或 Workflow)。
|
||||||
|
|
||||||
|
### Q7:如何复制现有的 Workflow?
|
||||||
|
|
||||||
|
在 Workflow 列表页,点击工作流卡片右上角的菜单,选择"复制"即可创建副本。
|
||||||
|
|
||||||
|
### Q8:支持版本回滚吗?
|
||||||
|
|
||||||
|
支持。每次保存都会创建新版本。在 Workflow 详情页可以查看版本历史并回滚到指定版本。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 最佳实践
|
||||||
|
|
||||||
|
### 1. 合理命名
|
||||||
|
|
||||||
|
- 为节点和 Workflow 使用描述性名称
|
||||||
|
- 使用统一的命名规范
|
||||||
|
|
||||||
|
### 2. 模块化设计
|
||||||
|
|
||||||
|
- 将复杂流程拆分为多个小 Workflow
|
||||||
|
- 使用"调用 Pipeline"节点复用现有流程
|
||||||
|
|
||||||
|
### 3. 错误处理
|
||||||
|
|
||||||
|
- 为关键节点设置重试机制
|
||||||
|
- 使用条件分支处理异常情况
|
||||||
|
- 添加日志记录便于排查问题
|
||||||
|
|
||||||
|
### 4. 测试先行
|
||||||
|
|
||||||
|
- 使用调试功能充分测试
|
||||||
|
- 准备多种测试场景
|
||||||
|
- 检查边界情况
|
||||||
|
|
||||||
|
### 5. 性能优化
|
||||||
|
|
||||||
|
- 避免不必要的节点
|
||||||
|
- 使用并行节点提高效率
|
||||||
|
- 合理设置超时时间
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 更多资源
|
||||||
|
|
||||||
|
- [开发者文档](../development/workflow-system.md)
|
||||||
|
- [设计文档](../../../plans/langbot-workflow-design.md)
|
||||||
|
- [API 文档](../service-api-openapi.json)
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# HTTP Bot Adapter — Reference Clients
|
||||||
|
|
||||||
|
> English | [中文](./README.zh.md)
|
||||||
|
|
||||||
|
Minimal, dependency-light clients for the LangBot **HTTP Bot** platform adapter.
|
||||||
|
They show the whole loop: signing a request, pushing a message, and receiving
|
||||||
|
multi-part replies on a callback endpoint.
|
||||||
|
|
||||||
|
Full guide: [docs.langbot.app — HTTP Bot](https://docs.langbot.app/en/usage/platforms/http-bot).
|
||||||
|
Machine-readable contract: [`docs/http-bot-openapi.json`](../../docs/http-bot-openapi.json).
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| File | What it is |
|
||||||
|
|---|---|
|
||||||
|
| `playground.py` | **Interactive browser debug console** — a single-file web app you open in a browser to chat with a running `http_bot` bot and watch signing / 202 / callbacks live. Zero extra deps. |
|
||||||
|
| `client.py` | Python client + Flask callback receiver (`pip install flask requests`). |
|
||||||
|
| `client.ts` | TypeScript/Node 18+ client + callback receiver, **zero deps** (`npx tsx client.ts`). |
|
||||||
|
|
||||||
|
All three implement the identical HMAC-SHA256 scheme
|
||||||
|
(`sha256=hex(HMAC(secret, "{timestamp}." + body))`) — verified byte-for-byte
|
||||||
|
against the adapter.
|
||||||
|
|
||||||
|
## Interactive playground (recommended first run)
|
||||||
|
|
||||||
|
A self-contained web console: type a message in your browser, it is signed and
|
||||||
|
POSTed to a **running** `http_bot` bot, and the bot's replies stream back into
|
||||||
|
the page — with a debug panel showing the signature, the `202` ack, and each
|
||||||
|
callback's `sequence` / signature-verification.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From the LangBot repo root, with the backend already running:
|
||||||
|
PUBLIC_IP=<your-host-ip> ./.venv/bin/python examples/http-bot/playground.py
|
||||||
|
# then open http://<your-host-ip>:8920/
|
||||||
|
```
|
||||||
|
|
||||||
|
On startup it reads the LangBot API key + the `http_bot` bot from
|
||||||
|
`data/langbot.db`, and configures that bot (inbound/outbound secret +
|
||||||
|
`callback_url`) to point back at itself via the LangBot API — the bot reloads
|
||||||
|
live, no restart needed. Requirements: an enabled `http_bot` bot bound to a
|
||||||
|
working pipeline, and port `8920` reachable from your browser.
|
||||||
|
|
||||||
|
Env knobs: `PUBLIC_IP` (default `127.0.0.1`), `PLAYGROUND_PORT` (default `8920`).
|
||||||
|
|
||||||
|
## Headless clients
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Python — Terminal 1: callback receiver (your callback_url target)
|
||||||
|
python client.py serve --port 8900 --secret SHARED_SECRET
|
||||||
|
|
||||||
|
# Python — Terminal 2: push a message
|
||||||
|
python client.py push --url https://your-langbot/bots/<BOT_UUID> \
|
||||||
|
--secret SHARED_SECRET --session ticket-1 --text "hello"
|
||||||
|
|
||||||
|
# blocking sync mode
|
||||||
|
python client.py sync --url https://your-langbot/bots/<BOT_UUID> \
|
||||||
|
--secret SHARED_SECRET --session ticket-1 --text "hello"
|
||||||
|
|
||||||
|
# reset a session
|
||||||
|
python client.py reset --url https://your-langbot/bots/<BOT_UUID> \
|
||||||
|
--secret SHARED_SECRET --session ticket-1
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# TypeScript (Node 18+)
|
||||||
|
npx tsx client.ts serve 8900 SHARED_SECRET
|
||||||
|
npx tsx client.ts push https://your-langbot/bots/<BOT_UUID> SHARED_SECRET ticket-1 "hello"
|
||||||
|
```
|
||||||
|
|
||||||
|
When the bot replies, the receiver prints each part with its `sequence` and an
|
||||||
|
`[FINAL]` marker on the last one — that's the 1→M multi-reply model in action.
|
||||||
|
|
||||||
|
> The bot's `callback_url` must be reachable from LangBot. For local testing,
|
||||||
|
> expose your receiver with a tunnel (cloudflared / ngrok) and set that URL in
|
||||||
|
> the bot config.
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# HTTP Bot 适配器 —— 参考客户端
|
||||||
|
|
||||||
|
> [English](./README.md) | 中文
|
||||||
|
|
||||||
|
面向 LangBot **HTTP Bot** 平台适配器的极简、低依赖客户端示例。
|
||||||
|
它们完整展示了整条链路:对请求签名、推送一条消息、在回调端点接收
|
||||||
|
1→M 的多段回复。
|
||||||
|
|
||||||
|
完整指南:[docs.langbot.app —— HTTP Bot](https://docs.langbot.app/zh/usage/platforms/http-bot)。
|
||||||
|
机器可读的接口契约:[`docs/http-bot-openapi.json`](../../docs/http-bot-openapi.json)。
|
||||||
|
|
||||||
|
## 文件清单
|
||||||
|
|
||||||
|
| 文件 | 是什么 |
|
||||||
|
|---|---|
|
||||||
|
| `playground.py` | **浏览器交互式调试台** —— 单文件 Web 应用,在浏览器里和一个运行中的 `http_bot` bot 对话,实时观察签名 / 202 / 回调。零额外依赖。 |
|
||||||
|
| `client.py` | Python 客户端 + Flask 回调接收端(`pip install flask requests`)。 |
|
||||||
|
| `client.ts` | TypeScript/Node 18+ 客户端 + 回调接收端,**零依赖**(`npx tsx client.ts`)。 |
|
||||||
|
|
||||||
|
三者实现完全一致的 HMAC-SHA256 签名方案
|
||||||
|
(`sha256=hex(HMAC(secret, "{timestamp}." + body))`)—— 已与适配器逐字节比对验证。
|
||||||
|
|
||||||
|
## 交互式 playground(推荐先跑这个)
|
||||||
|
|
||||||
|
一个自包含的 Web 控制台:在浏览器里输入消息,它会被签名并 POST 给一个
|
||||||
|
**运行中**的 `http_bot` bot,bot 的回复会流式回到页面上 —— 调试面板会显示
|
||||||
|
签名、`202` 确认,以及每条回调的 `sequence` / 签名验证结果。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 在 LangBot 仓库根目录、后端已启动的前提下:
|
||||||
|
PUBLIC_IP=<你的主机IP> ./.venv/bin/python examples/http-bot/playground.py
|
||||||
|
# 然后打开 http://<你的主机IP>:8920/
|
||||||
|
```
|
||||||
|
|
||||||
|
启动时它会从 `data/langbot.db` 读取 LangBot API key 和 `http_bot` bot,
|
||||||
|
并通过 LangBot API 把该 bot 配好(入站/出站密钥 + `callback_url`)指回自己 ——
|
||||||
|
bot 会热加载,无需重启。前提:有一个已启用、绑定了可用 pipeline 的
|
||||||
|
`http_bot` bot,且端口 `8920` 能从你的浏览器访问到。
|
||||||
|
|
||||||
|
可调环境变量:`PUBLIC_IP`(默认 `127.0.0.1`)、`PLAYGROUND_PORT`(默认 `8920`)。
|
||||||
|
|
||||||
|
## 无头客户端
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Python —— 终端 1:回调接收端(你的 callback_url 指向它)
|
||||||
|
python client.py serve --port 8900 --secret SHARED_SECRET
|
||||||
|
|
||||||
|
# Python —— 终端 2:推送一条消息
|
||||||
|
python client.py push --url https://your-langbot/bots/<BOT_UUID> \
|
||||||
|
--secret SHARED_SECRET --session ticket-1 --text "hello"
|
||||||
|
|
||||||
|
# 阻塞式同步模式
|
||||||
|
python client.py sync --url https://your-langbot/bots/<BOT_UUID> \
|
||||||
|
--secret SHARED_SECRET --session ticket-1 --text "hello"
|
||||||
|
|
||||||
|
# 重置一个会话
|
||||||
|
python client.py reset --url https://your-langbot/bots/<BOT_UUID> \
|
||||||
|
--secret SHARED_SECRET --session ticket-1
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# TypeScript(Node 18+)
|
||||||
|
npx tsx client.ts serve 8900 SHARED_SECRET
|
||||||
|
npx tsx client.ts push https://your-langbot/bots/<BOT_UUID> SHARED_SECRET ticket-1 "hello"
|
||||||
|
```
|
||||||
|
|
||||||
|
当 bot 回复时,接收端会逐条打印,带上各自的 `sequence`,并在最后一条标记
|
||||||
|
`[FINAL]` —— 这就是 1→M 多段回复模型的实际效果。
|
||||||
|
|
||||||
|
> bot 的 `callback_url` 必须能从 LangBot 访问到。本地测试时,可用隧道
|
||||||
|
> (cloudflared / ngrok)把你的接收端暴露出去,并把那个 URL 填进 bot 配置。
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""LangBot HTTP Bot adapter — reference client (Python).
|
||||||
|
|
||||||
|
Two things in one file:
|
||||||
|
|
||||||
|
1. ``push()`` / ``push_sync()`` — send a message into a LangBot ``http_bot`` bot.
|
||||||
|
2. A tiny Flask callback receiver that verifies signatures and prints replies,
|
||||||
|
so you can watch N->1 aggregation and 1->M multi-reply working live.
|
||||||
|
|
||||||
|
Usage
|
||||||
|
-----
|
||||||
|
pip install flask requests
|
||||||
|
|
||||||
|
# Terminal 1 — start the callback receiver (this is your callback_url):
|
||||||
|
python client.py serve --port 8900 --secret SHARED_SECRET
|
||||||
|
|
||||||
|
# Terminal 2 — push a message (async; reply lands on the receiver):
|
||||||
|
python client.py push \
|
||||||
|
--url https://your-langbot/bots/<BOT_UUID> \
|
||||||
|
--secret SHARED_SECRET \
|
||||||
|
--session ticket-10293 \
|
||||||
|
--text "Export keeps failing on the dashboard."
|
||||||
|
|
||||||
|
# Or push and block for the collapsed reply (sync convenience mode):
|
||||||
|
python client.py sync --url https://your-langbot/bots/<BOT_UUID> \
|
||||||
|
--secret SHARED_SECRET --session ticket-10293 --text "hi"
|
||||||
|
|
||||||
|
The signing scheme is HMAC-SHA256 over ``"{timestamp}." + raw_body``; see
|
||||||
|
``sign()`` below — it is intentionally tiny and easy to port.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
HEADER_TIMESTAMP = 'X-LB-Timestamp'
|
||||||
|
HEADER_SIGNATURE = 'X-LB-Signature'
|
||||||
|
HEADER_IDEMPOTENCY = 'X-LB-Idempotency-Key'
|
||||||
|
REPLAY_WINDOW = 300
|
||||||
|
|
||||||
|
|
||||||
|
def sign(secret: str, body: bytes, timestamp: int | None = None) -> tuple[str, str]:
|
||||||
|
"""Return (timestamp, signature) for *body*."""
|
||||||
|
ts = str(timestamp if timestamp is not None else int(time.time()))
|
||||||
|
mac = hmac.new(secret.encode(), f'{ts}.'.encode() + body, hashlib.sha256)
|
||||||
|
return ts, 'sha256=' + mac.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def verify(secret: str, body: bytes, timestamp: str | None, signature: str | None) -> bool:
|
||||||
|
"""Verify an inbound signature (used by the callback receiver)."""
|
||||||
|
if not timestamp or not signature:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
if abs(int(time.time()) - int(float(timestamp))) > REPLAY_WINDOW:
|
||||||
|
return False
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
_, expected = sign(secret, body, int(float(timestamp)))
|
||||||
|
return hmac.compare_digest(expected, signature)
|
||||||
|
|
||||||
|
|
||||||
|
def _post(url: str, secret: str, payload: dict, idempotency: bool = True):
|
||||||
|
import requests
|
||||||
|
|
||||||
|
body = json.dumps(payload, ensure_ascii=False).encode()
|
||||||
|
ts, sig = sign(secret, body)
|
||||||
|
headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
HEADER_TIMESTAMP: ts,
|
||||||
|
HEADER_SIGNATURE: sig,
|
||||||
|
}
|
||||||
|
if idempotency:
|
||||||
|
headers[HEADER_IDEMPOTENCY] = uuid.uuid4().hex
|
||||||
|
resp = requests.post(url, data=body, headers=headers, timeout=30)
|
||||||
|
print(f'-> {resp.status_code} {resp.text}')
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
def push(url: str, secret: str, session: str, text: str, session_type: str = 'person'):
|
||||||
|
"""Fire-and-collect: returns 202 immediately; reply arrives on your callback."""
|
||||||
|
payload = {
|
||||||
|
'session_id': session,
|
||||||
|
'session_type': session_type,
|
||||||
|
'message': [{'type': 'Plain', 'text': text}],
|
||||||
|
}
|
||||||
|
return _post(url.rstrip('/'), secret, payload)
|
||||||
|
|
||||||
|
|
||||||
|
def push_sync(url: str, secret: str, session: str, text: str, session_type: str = 'person'):
|
||||||
|
"""Blocking convenience: POST to /sync and get the collapsed reply back."""
|
||||||
|
payload = {
|
||||||
|
'session_id': session,
|
||||||
|
'session_type': session_type,
|
||||||
|
'message': [{'type': 'Plain', 'text': text}],
|
||||||
|
}
|
||||||
|
resp = _post(url.rstrip('/') + '/sync', secret, payload, idempotency=False)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
def reset(url: str, secret: str, session: str, session_type: str = 'person'):
|
||||||
|
"""Reset a session's conversation (next message starts fresh)."""
|
||||||
|
payload = {'session_id': session, 'session_type': session_type}
|
||||||
|
return _post(url.rstrip('/') + '/reset', secret, payload, idempotency=False)
|
||||||
|
|
||||||
|
|
||||||
|
def serve(port: int, secret: str):
|
||||||
|
"""Run a callback receiver that verifies signatures and prints replies."""
|
||||||
|
from flask import Flask, request
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
@app.route('/', methods=['POST'])
|
||||||
|
def recv():
|
||||||
|
raw = request.get_data()
|
||||||
|
ok = verify(secret, raw, request.headers.get(HEADER_TIMESTAMP), request.headers.get(HEADER_SIGNATURE))
|
||||||
|
if not ok:
|
||||||
|
print('!! signature verification FAILED — rejecting')
|
||||||
|
return {'error': 'bad signature'}, 401
|
||||||
|
data = json.loads(raw)
|
||||||
|
text_parts = [c.get('text', '') for c in data.get('message', []) if c.get('type') == 'Plain']
|
||||||
|
marker = 'FINAL' if data.get('is_final') else 'part '
|
||||||
|
print(
|
||||||
|
f'[{marker}] session={data["session_id"]} seq={data["sequence"]} '
|
||||||
|
f'reply_to={data.get("reply_to")}: {" ".join(text_parts)}'
|
||||||
|
)
|
||||||
|
return {'ok': True}
|
||||||
|
|
||||||
|
print(f'callback receiver listening on http://0.0.0.0:{port}/ (Ctrl-C to stop)')
|
||||||
|
app.run(host='0.0.0.0', port=port)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None):
|
||||||
|
p = argparse.ArgumentParser(description='LangBot HTTP Bot reference client')
|
||||||
|
sub = p.add_subparsers(dest='cmd', required=True)
|
||||||
|
|
||||||
|
sp = sub.add_parser('serve', help='run the callback receiver')
|
||||||
|
sp.add_argument('--port', type=int, default=8900)
|
||||||
|
sp.add_argument('--secret', required=True)
|
||||||
|
|
||||||
|
for name in ('push', 'sync', 'reset'):
|
||||||
|
c = sub.add_parser(name)
|
||||||
|
c.add_argument('--url', required=True, help='https://host/bots/<BOT_UUID>')
|
||||||
|
c.add_argument('--secret', required=True)
|
||||||
|
c.add_argument('--session', required=True)
|
||||||
|
c.add_argument('--session-type', default='person', choices=['person', 'group'])
|
||||||
|
if name != 'reset':
|
||||||
|
c.add_argument('--text', required=True)
|
||||||
|
|
||||||
|
args = p.parse_args(argv)
|
||||||
|
if args.cmd == 'serve':
|
||||||
|
serve(args.port, args.secret)
|
||||||
|
elif args.cmd == 'push':
|
||||||
|
push(args.url, args.secret, args.session, args.text, args.session_type)
|
||||||
|
elif args.cmd == 'sync':
|
||||||
|
push_sync(args.url, args.secret, args.session, args.text, args.session_type)
|
||||||
|
elif args.cmd == 'reset':
|
||||||
|
reset(args.url, args.secret, args.session, args.session_type)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
/**
|
||||||
|
* LangBot HTTP Bot adapter — reference client (TypeScript / Node 18+).
|
||||||
|
*
|
||||||
|
* Zero runtime dependencies (uses global `fetch`, `crypto`, and `http`).
|
||||||
|
*
|
||||||
|
* - `push()` : fire-and-collect; reply lands on your callback URL.
|
||||||
|
* - `pushSync()` : POST /sync and await the collapsed reply.
|
||||||
|
* - `reset()` : reset a session's conversation.
|
||||||
|
* - `startReceiver()` : a callback server that verifies signatures and logs
|
||||||
|
* replies, so you can watch N->1 and 1->M live.
|
||||||
|
*
|
||||||
|
* Run the demos:
|
||||||
|
* npx tsx client.ts serve 8900 SHARED_SECRET
|
||||||
|
* npx tsx client.ts push https://host/bots/<UUID> SHARED_SECRET ticket-1 "hello"
|
||||||
|
* npx tsx client.ts sync https://host/bots/<UUID> SHARED_SECRET ticket-1 "hello"
|
||||||
|
* npx tsx client.ts reset https://host/bots/<UUID> SHARED_SECRET ticket-1
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
|
||||||
|
import { createServer } from 'node:http';
|
||||||
|
|
||||||
|
const HEADER_TIMESTAMP = 'X-LB-Timestamp';
|
||||||
|
const HEADER_SIGNATURE = 'X-LB-Signature';
|
||||||
|
const HEADER_IDEMPOTENCY = 'X-LB-Idempotency-Key';
|
||||||
|
const REPLAY_WINDOW = 300;
|
||||||
|
|
||||||
|
/** Compute the `sha256=<hex>` signature over `"{ts}." + body`. */
|
||||||
|
export function sign(secret: string, body: Buffer | string, timestamp?: number): [string, string] {
|
||||||
|
const ts = String(timestamp ?? Math.floor(Date.now() / 1000));
|
||||||
|
const buf = typeof body === 'string' ? Buffer.from(body) : body;
|
||||||
|
const mac = createHmac('sha256', secret).update(Buffer.concat([Buffer.from(`${ts}.`), buf])).digest('hex');
|
||||||
|
return [ts, `sha256=${mac}`];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verify an inbound signature (used by the callback receiver). */
|
||||||
|
export function verify(secret: string, body: Buffer, timestamp?: string, signature?: string): boolean {
|
||||||
|
if (!timestamp || !signature) return false;
|
||||||
|
if (Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)) > REPLAY_WINDOW) return false;
|
||||||
|
const [, expected] = sign(secret, body, Number(timestamp));
|
||||||
|
const a = Buffer.from(expected);
|
||||||
|
const b = Buffer.from(signature);
|
||||||
|
return a.length === b.length && timingSafeEqual(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Segment { type: string; text?: string; url?: string; [k: string]: unknown }
|
||||||
|
|
||||||
|
async function post(url: string, secret: string, payload: object, idempotency = true) {
|
||||||
|
const body = Buffer.from(JSON.stringify(payload));
|
||||||
|
const [ts, sig] = sign(secret, body);
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
[HEADER_TIMESTAMP]: ts,
|
||||||
|
[HEADER_SIGNATURE]: sig,
|
||||||
|
};
|
||||||
|
if (idempotency) headers[HEADER_IDEMPOTENCY] = randomUUID();
|
||||||
|
const resp = await fetch(url, { method: 'POST', headers, body });
|
||||||
|
const text = await resp.text();
|
||||||
|
console.log(`-> ${resp.status} ${text}`);
|
||||||
|
return { status: resp.status, text };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fire-and-collect: 202 now, reply later on your callback URL. */
|
||||||
|
export function push(url: string, secret: string, session: string, text: string, sessionType = 'person') {
|
||||||
|
return post(url.replace(/\/$/, ''), secret, {
|
||||||
|
session_id: session,
|
||||||
|
session_type: sessionType,
|
||||||
|
message: [{ type: 'Plain', text }] as Segment[],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Blocking convenience: POST /sync, get the collapsed reply. */
|
||||||
|
export function pushSync(url: string, secret: string, session: string, text: string, sessionType = 'person') {
|
||||||
|
return post(`${url.replace(/\/$/, '')}/sync`, secret, {
|
||||||
|
session_id: session,
|
||||||
|
session_type: sessionType,
|
||||||
|
message: [{ type: 'Plain', text }] as Segment[],
|
||||||
|
}, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reset a session's conversation. */
|
||||||
|
export function reset(url: string, secret: string, session: string, sessionType = 'person') {
|
||||||
|
return post(`${url.replace(/\/$/, '')}/reset`, secret, { session_id: session, session_type: sessionType }, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run a callback receiver that verifies signatures and prints replies. */
|
||||||
|
export function startReceiver(port: number, secret: string) {
|
||||||
|
const server = createServer((req, res) => {
|
||||||
|
if (req.method !== 'POST') { res.writeHead(405).end(); return; }
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
req.on('data', (c) => chunks.push(c));
|
||||||
|
req.on('end', () => {
|
||||||
|
const raw = Buffer.concat(chunks);
|
||||||
|
const ok = verify(secret, raw, req.headers[HEADER_TIMESTAMP.toLowerCase()] as string,
|
||||||
|
req.headers[HEADER_SIGNATURE.toLowerCase()] as string);
|
||||||
|
if (!ok) {
|
||||||
|
console.log('!! signature verification FAILED — rejecting');
|
||||||
|
res.writeHead(401, { 'Content-Type': 'application/json' }).end(JSON.stringify({ error: 'bad signature' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = JSON.parse(raw.toString());
|
||||||
|
const parts = (data.message as Segment[]).filter((c) => c.type === 'Plain').map((c) => c.text).join(' ');
|
||||||
|
const marker = data.is_final ? 'FINAL' : 'part ';
|
||||||
|
console.log(`[${marker}] session=${data.session_id} seq=${data.sequence} reply_to=${data.reply_to}: ${parts}`);
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ ok: true }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
server.listen(port, () => console.log(`callback receiver listening on http://0.0.0.0:${port}/ (Ctrl-C to stop)`));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CLI ---
|
||||||
|
const [cmd, ...rest] = process.argv.slice(2);
|
||||||
|
if (cmd === 'serve') {
|
||||||
|
startReceiver(Number(rest[0] ?? 8900), rest[1] ?? 'SHARED_SECRET');
|
||||||
|
} else if (cmd === 'push') {
|
||||||
|
push(rest[0], rest[1], rest[2], rest[3]);
|
||||||
|
} else if (cmd === 'sync') {
|
||||||
|
pushSync(rest[0], rest[1], rest[2], rest[3]);
|
||||||
|
} else if (cmd === 'reset') {
|
||||||
|
reset(rest[0], rest[1], rest[2]);
|
||||||
|
} else if (cmd) {
|
||||||
|
console.error(`unknown command: ${cmd}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""LangBot HTTP Bot — interactive playground (public, browser-based).
|
||||||
|
|
||||||
|
This is a REAL end-to-end demo against the RUNNING LangBot instance on this
|
||||||
|
host. It is NOT a mock and NOT an in-process import: every message you type in
|
||||||
|
the browser is signed and POSTed to the live `http_bot` bot at
|
||||||
|
http://127.0.0.1:5300/bots/<uuid>, and the bot's replies come back to this
|
||||||
|
server's /callback endpoint over real HTTP, then stream to your browser via SSE.
|
||||||
|
|
||||||
|
What it does on startup:
|
||||||
|
1. Reads the LangBot API key + the http_bot bot from data/langbot.db.
|
||||||
|
2. Configures the bot via the LangBot API (PUT /api/v1/platform/bots/<uuid>):
|
||||||
|
sets inbound_secret + outbound_secret + callback_url to point back here.
|
||||||
|
(LangBot reloads the bot live — no server restart needed.)
|
||||||
|
3. Serves a chat page on 0.0.0.0:<PORT> so you can open it from the internet.
|
||||||
|
|
||||||
|
Run: ./.venv/bin/python examples/http-bot/playground.py
|
||||||
|
Then open: http://<this-host-public-ip>:<PORT>/
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
|
||||||
|
REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
|
||||||
|
sys.path.insert(0, os.path.join(REPO, 'src'))
|
||||||
|
|
||||||
|
from aiohttp import web # noqa: E402
|
||||||
|
import aiohttp # noqa: E402
|
||||||
|
|
||||||
|
from langbot.pkg.platform.sources import http_bot_signing as sg # noqa: E402
|
||||||
|
|
||||||
|
# ---- config -----------------------------------------------------------------
|
||||||
|
LANGBOT_BASE = 'http://127.0.0.1:5300'
|
||||||
|
DB_PATH = os.path.join(REPO, 'data', 'langbot.db')
|
||||||
|
PUBLIC_IP = os.environ.get('PUBLIC_IP', '127.0.0.1')
|
||||||
|
PORT = int(os.environ.get('PLAYGROUND_PORT', '8920'))
|
||||||
|
SECRET = 'playground-shared-secret'
|
||||||
|
|
||||||
|
# SSE subscribers: list of asyncio.Queue
|
||||||
|
subscribers: list[asyncio.Queue] = []
|
||||||
|
|
||||||
|
|
||||||
|
def db_lookup() -> tuple[str, str]:
|
||||||
|
"""Return (api_key, http_bot_uuid) from the LangBot DB."""
|
||||||
|
db = sqlite3.connect(DB_PATH)
|
||||||
|
db.row_factory = sqlite3.Row
|
||||||
|
api_key = db.execute('SELECT key FROM api_keys LIMIT 1').fetchone()['key']
|
||||||
|
bot = db.execute("SELECT uuid FROM bots WHERE adapter='http_bot' LIMIT 1").fetchone()
|
||||||
|
if not bot:
|
||||||
|
raise SystemExit('No http_bot bot found. Create one in the WebUI first.')
|
||||||
|
return api_key, bot['uuid']
|
||||||
|
|
||||||
|
|
||||||
|
async def configure_bot(api_key: str, bot_uuid: str, callback_url: str):
|
||||||
|
"""Point the live bot at this playground via the LangBot API.
|
||||||
|
|
||||||
|
update_bot() runs a raw SQL UPDATE with whatever keys we send, so we send a
|
||||||
|
MINIMAL payload: only adapter_config (built from scratch, not read back —
|
||||||
|
the GET masks secrets). LangBot reloads + reruns the bot live.
|
||||||
|
"""
|
||||||
|
cfg = {
|
||||||
|
'inbound_secret': SECRET,
|
||||||
|
'outbound_secret': SECRET,
|
||||||
|
'callback_url': callback_url,
|
||||||
|
'signature_required': True,
|
||||||
|
'default_session_type': 'person',
|
||||||
|
'callback_timeout': 15,
|
||||||
|
'callback_max_retries': 3,
|
||||||
|
}
|
||||||
|
async with aiohttp.ClientSession() as s:
|
||||||
|
async with s.put(
|
||||||
|
f'{LANGBOT_BASE}/api/v1/platform/bots/{bot_uuid}',
|
||||||
|
headers={'Authorization': f'Bearer {api_key}', 'Content-Type': 'application/json'},
|
||||||
|
json={'adapter_config': cfg},
|
||||||
|
) as r:
|
||||||
|
txt = await r.text()
|
||||||
|
print(f'[configure] PUT adapter_config -> {r.status} {txt[:200]}')
|
||||||
|
return r.status < 400
|
||||||
|
|
||||||
|
|
||||||
|
async def broadcast(event: dict):
|
||||||
|
for q in list(subscribers):
|
||||||
|
try:
|
||||||
|
q.put_nowait(event)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ---- HTTP handlers ----------------------------------------------------------
|
||||||
|
async def index(request: web.Request):
|
||||||
|
return web.Response(text=PAGE, content_type='text/html')
|
||||||
|
|
||||||
|
|
||||||
|
async def send(request: web.Request):
|
||||||
|
"""Browser -> here -> signed POST -> live LangBot bot."""
|
||||||
|
body_in = await request.json()
|
||||||
|
session_id = body_in.get('session_id') or 'playground-1'
|
||||||
|
text = body_in.get('text', '')
|
||||||
|
bot_uuid = request.app['bot_uuid']
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
'session_id': session_id,
|
||||||
|
'sender': {'id': 'browser-user', 'name': 'You'},
|
||||||
|
'message': [{'type': 'Plain', 'text': text}],
|
||||||
|
}
|
||||||
|
raw = json.dumps(payload, ensure_ascii=False).encode()
|
||||||
|
ts, sig = sg.sign(SECRET, raw)
|
||||||
|
url = f'{LANGBOT_BASE}/bots/{bot_uuid}'
|
||||||
|
|
||||||
|
# echo what we send to the browser timeline
|
||||||
|
await broadcast(
|
||||||
|
{'dir': 'out', 'kind': 'request', 'session_id': session_id, 'text': text, 'url': url, 'sig': sig[:24] + '…'}
|
||||||
|
)
|
||||||
|
|
||||||
|
async with aiohttp.ClientSession() as s:
|
||||||
|
async with s.post(
|
||||||
|
url,
|
||||||
|
data=raw,
|
||||||
|
headers={
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
sg.HEADER_TIMESTAMP: ts,
|
||||||
|
sg.HEADER_SIGNATURE: sig,
|
||||||
|
},
|
||||||
|
) as r:
|
||||||
|
status = r.status
|
||||||
|
try:
|
||||||
|
jr = await r.json()
|
||||||
|
except Exception:
|
||||||
|
jr = {'raw': await r.text()}
|
||||||
|
await broadcast({'dir': 'in', 'kind': 'ack', 'status': status, 'data': jr})
|
||||||
|
return web.json_response({'status': status, 'data': jr})
|
||||||
|
|
||||||
|
|
||||||
|
async def callback(request: web.Request):
|
||||||
|
"""Live LangBot bot -> here. Verify signature, stream to browser."""
|
||||||
|
raw = await request.read()
|
||||||
|
ok, why = sg.verify(SECRET, raw, request.headers.get(sg.HEADER_TIMESTAMP), request.headers.get(sg.HEADER_SIGNATURE))
|
||||||
|
data = json.loads(raw)
|
||||||
|
text = ' '.join(c.get('text', '') for c in data.get('message', []) if c.get('type') == 'Plain')
|
||||||
|
await broadcast(
|
||||||
|
{
|
||||||
|
'dir': 'in',
|
||||||
|
'kind': 'reply',
|
||||||
|
'session_id': data.get('session_id'),
|
||||||
|
'sequence': data.get('sequence'),
|
||||||
|
'is_final': data.get('is_final'),
|
||||||
|
'sig_ok': ok,
|
||||||
|
'sig_why': why,
|
||||||
|
'text': text,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return web.json_response({'ok': True})
|
||||||
|
|
||||||
|
|
||||||
|
async def events(request: web.Request):
|
||||||
|
"""SSE stream to the browser."""
|
||||||
|
resp = web.StreamResponse(
|
||||||
|
headers={
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
'Connection': 'keep-alive',
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
await resp.prepare(request)
|
||||||
|
q: asyncio.Queue = asyncio.Queue()
|
||||||
|
subscribers.append(q)
|
||||||
|
try:
|
||||||
|
await resp.write(b': connected\n\n')
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
ev = await asyncio.wait_for(q.get(), timeout=15)
|
||||||
|
await resp.write(f'data: {json.dumps(ev, ensure_ascii=False)}\n\n'.encode())
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
await resp.write(b': ping\n\n')
|
||||||
|
except (asyncio.CancelledError, ConnectionResetError):
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
if q in subscribers:
|
||||||
|
subscribers.remove(q)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
PAGE = r"""<!doctype html>
|
||||||
|
<html lang="zh"><head><meta charset="utf-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1"/>
|
||||||
|
<title>LangBot HTTP Bot · 调试台</title>
|
||||||
|
<style>
|
||||||
|
:root{
|
||||||
|
--bg:#f7f8fa; --panel:#ffffff; --line:#e8eaed; --ink:#1f2329; --mut:#8a909a;
|
||||||
|
--brand:#2563eb; --brand-soft:#eef3ff; --ok:#16a34a; --bad:#dc2626; --code:#f3f4f6;
|
||||||
|
}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
html,body{height:100%}
|
||||||
|
body{margin:0;background:var(--bg);color:var(--ink);
|
||||||
|
font:14px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"PingFang SC","Microsoft YaHei",sans-serif}
|
||||||
|
.top{height:52px;background:var(--panel);border-bottom:1px solid var(--line);
|
||||||
|
display:flex;align-items:center;gap:10px;padding:0 18px}
|
||||||
|
.logo{width:26px;height:26px;border-radius:7px;background:var(--brand);display:grid;place-items:center;color:#fff;font-weight:700;font-size:14px}
|
||||||
|
.top b{font-size:15px} .top .ver{font-size:12px;color:var(--mut)}
|
||||||
|
.dot{width:8px;height:8px;border-radius:50%;background:#cbd2dc;display:inline-block;margin-right:5px;vertical-align:middle}
|
||||||
|
.dot.on{background:var(--ok)} .dot.off{background:var(--bad)}
|
||||||
|
.conn{margin-left:auto;font-size:12px;color:var(--mut)}
|
||||||
|
.wrap{max-width:1080px;margin:0 auto;padding:18px;display:grid;grid-template-columns:1fr 360px;gap:16px}
|
||||||
|
@media(max-width:880px){.wrap{grid-template-columns:1fr}}
|
||||||
|
.card{background:var(--panel);border:1px solid var(--line);border-radius:12px;display:flex;flex-direction:column;min-height:0}
|
||||||
|
.card h3{margin:0;padding:12px 16px;font-size:13px;font-weight:600;color:#4b5563;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:8px}
|
||||||
|
.chat{height:62vh}
|
||||||
|
.msgs{flex:1;overflow:auto;padding:16px;display:flex;flex-direction:column;gap:12px}
|
||||||
|
.row{display:flex;flex-direction:column;gap:4px;max-width:82%}
|
||||||
|
.row.me{align-self:flex-end;align-items:flex-end}
|
||||||
|
.row.bot{align-self:flex-start}
|
||||||
|
.bub{padding:9px 13px;border-radius:12px;white-space:pre-wrap;word-break:break-word}
|
||||||
|
.me .bub{background:var(--brand);color:#fff;border-bottom-right-radius:3px}
|
||||||
|
.bot .bub{background:#f1f3f6;color:var(--ink);border-bottom-left-radius:3px}
|
||||||
|
.meta{font-size:11px;color:var(--mut)}
|
||||||
|
.meta .ok{color:var(--ok)} .meta .bad{color:var(--bad)}
|
||||||
|
.sys{align-self:center;font-size:12px;color:var(--mut);background:#f1f3f6;border-radius:8px;padding:4px 12px}
|
||||||
|
.bar{display:flex;gap:8px;padding:12px;border-top:1px solid var(--line)}
|
||||||
|
.bar input{flex:1;border:1px solid var(--line);border-radius:9px;padding:10px 12px;font-size:14px;outline:none}
|
||||||
|
.bar input:focus{border-color:var(--brand);box-shadow:0 0 0 3px var(--brand-soft)}
|
||||||
|
.bar button{background:var(--brand);color:#fff;border:0;border-radius:9px;padding:0 18px;font-size:14px;font-weight:500;cursor:pointer}
|
||||||
|
.bar button:disabled{opacity:.5;cursor:default}
|
||||||
|
.side{height:62vh}
|
||||||
|
.kv{padding:12px 16px;border-bottom:1px solid var(--line);font-size:12px}
|
||||||
|
.kv .k{color:var(--mut)} .kv .v{color:var(--ink);word-break:break-all}
|
||||||
|
.kv code{background:var(--code);border-radius:5px;padding:1px 5px;font-size:11px}
|
||||||
|
.sessrow{display:flex;align-items:center;gap:8px;padding:10px 16px;border-bottom:1px solid var(--line);font-size:12px}
|
||||||
|
.sessrow input{flex:1;border:1px solid var(--line);border-radius:7px;padding:5px 8px;font-size:12px}
|
||||||
|
.sessrow button{border:1px solid var(--line);background:#fff;border-radius:7px;padding:5px 9px;font-size:12px;cursor:pointer;color:#4b5563}
|
||||||
|
.trace{flex:1;overflow:auto;padding:10px 12px;font:11px/1.55 ui-monospace,SFMono-Regular,Menlo,monospace}
|
||||||
|
.ev{padding:6px 8px;border-radius:7px;margin-bottom:6px;border:1px solid var(--line)}
|
||||||
|
.ev .t{font-weight:600;font-size:10px;letter-spacing:.3px;text-transform:uppercase}
|
||||||
|
.ev.out{background:#f5f8ff;border-color:#dbe6ff}.ev.out .t{color:var(--brand)}
|
||||||
|
.ev.ack{background:#f4f6f8}.ev.ack .t{color:#6b7280}
|
||||||
|
.ev.reply{background:#f1faf3;border-color:#cdeed6}.ev.reply .t{color:var(--ok)}
|
||||||
|
.ev pre{margin:3px 0 0;white-space:pre-wrap;word-break:break-all;color:#374151}
|
||||||
|
</style></head>
|
||||||
|
<body>
|
||||||
|
<div class="top">
|
||||||
|
<div class="logo">L</div>
|
||||||
|
<b>HTTP Bot 调试台</b><span class="ver">examples/http-bot</span>
|
||||||
|
<span class="conn"><span class="dot off" id="cdot"></span><span id="conn">连接中…</span></span>
|
||||||
|
</div>
|
||||||
|
<div class="wrap">
|
||||||
|
<!-- chat -->
|
||||||
|
<div class="card chat">
|
||||||
|
<h3>对话 · 真实发往运行中的 http_bot</h3>
|
||||||
|
<div class="msgs" id="msgs"></div>
|
||||||
|
<div class="bar">
|
||||||
|
<input id="msg" placeholder="输入消息,回车发送…" autofocus/>
|
||||||
|
<button id="send">发送</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- debug -->
|
||||||
|
<div class="card side">
|
||||||
|
<h3>调试信息</h3>
|
||||||
|
<div class="kv"><span class="k">入站地址</span><br><span class="v"><code id="endpoint">/bots/<uuid></code></span></div>
|
||||||
|
<div class="kv"><span class="k">签名</span> <span class="v">HMAC-SHA256 · <code>X-LB-Signature</code></span></div>
|
||||||
|
<div class="sessrow">
|
||||||
|
<span class="k">会话</span>
|
||||||
|
<input id="sid" value="playground-1"/>
|
||||||
|
<button id="reset">新会话</button>
|
||||||
|
</div>
|
||||||
|
<div class="trace" id="trace"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
const $=s=>document.querySelector(s);
|
||||||
|
const msgs=$('#msgs'),trace=$('#trace'),inp=$('#msg'),btn=$('#send'),
|
||||||
|
conn=$('#conn'),cdot=$('#cdot'),sidIn=$('#sid');
|
||||||
|
function el(c){const d=document.createElement('div');d.className=c;return d}
|
||||||
|
function atBottom(n){n.scrollTop=n.scrollHeight}
|
||||||
|
function bubble(side,text,metaHtml){
|
||||||
|
const r=el('row '+side),b=el('bub');b.textContent=text;r.appendChild(b);
|
||||||
|
if(metaHtml){const m=el('meta');m.innerHTML=metaHtml;r.appendChild(m)}
|
||||||
|
msgs.appendChild(r);atBottom(msgs)}
|
||||||
|
function sys(t){const d=el('sys');d.textContent=t;msgs.appendChild(d);atBottom(msgs)}
|
||||||
|
function logEv(kind,title,obj){
|
||||||
|
const e=el('ev '+kind),t=el('t');t.textContent=title;e.appendChild(t);
|
||||||
|
if(obj!==undefined){const p=document.createElement('pre');
|
||||||
|
p.textContent=typeof obj==='string'?obj:JSON.stringify(obj,null,2);e.appendChild(p)}
|
||||||
|
trace.appendChild(e);atBottom(trace)}
|
||||||
|
|
||||||
|
const es=new EventSource('/events');
|
||||||
|
es.onopen=()=>{conn.textContent='SSE 已连接';cdot.className='dot on'};
|
||||||
|
es.onerror=()=>{conn.textContent='SSE 断开,重连…';cdot.className='dot off'};
|
||||||
|
es.onmessage=e=>{const ev=JSON.parse(e.data);
|
||||||
|
if(ev.kind==='request'){
|
||||||
|
if(ev.endpoint)$('#endpoint').textContent=ev.url||ev.endpoint;
|
||||||
|
logEv('out','出站 · 已签名 POST',{url:ev.url,session_id:ev.session_id,'X-LB-Signature':ev.sig});
|
||||||
|
}else if(ev.kind==='ack'){
|
||||||
|
const id=ev.data&&ev.data.data&&ev.data.data.accepted_message_id;
|
||||||
|
sys(`LangBot 已接收 · HTTP ${ev.status}`);
|
||||||
|
logEv('ack','入站确认 202',{status:ev.status,accepted_message_id:id||'-'});
|
||||||
|
}else if(ev.kind==='reply'){
|
||||||
|
const sig=ev.sig_ok?'<span class=ok>验签通过</span>':'<span class=bad>验签失败</span>';
|
||||||
|
bubble('bot',ev.text,`seq=${ev.sequence} · ${ev.is_final?'<b>FINAL</b>':'中间段'} · ${sig}`);
|
||||||
|
logEv('reply',`回调 · seq ${ev.sequence}${ev.is_final?' · FINAL':''}`,
|
||||||
|
{session_id:ev.session_id,sequence:ev.sequence,is_final:ev.is_final,sig_ok:ev.sig_ok,text:ev.text});
|
||||||
|
}};
|
||||||
|
|
||||||
|
async function send(){
|
||||||
|
const t=inp.value.trim();if(!t)return;inp.value='';btn.disabled=true;
|
||||||
|
bubble('me',t,'已签名 → POST /bots/<uuid>');
|
||||||
|
try{await fetch('/send',{method:'POST',headers:{'Content-Type':'application/json'},
|
||||||
|
body:JSON.stringify({session_id:sidIn.value.trim()||'playground-1',text:t})});}
|
||||||
|
catch(e){sys('发送失败:'+e)}
|
||||||
|
btn.disabled=false;inp.focus();}
|
||||||
|
btn.onclick=send;inp.addEventListener('keydown',e=>{if(e.key==='Enter')send()});
|
||||||
|
$('#reset').onclick=()=>{sidIn.value='playground-'+Math.random().toString(36).slice(2,7);
|
||||||
|
sys('已切换到新会话 '+sidIn.value);};
|
||||||
|
sys('调试台就绪 · 每条消息都会真实发往运行中的 http_bot,右侧可观察签名 / 202 / 回调全过程。');
|
||||||
|
</script>
|
||||||
|
</body></html>"""
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
api_key, bot_uuid = db_lookup()
|
||||||
|
callback_url = f'http://{PUBLIC_IP}:{PORT}/callback'
|
||||||
|
print(f'[init] http_bot uuid = {bot_uuid}')
|
||||||
|
print(f'[init] callback_url = {callback_url}')
|
||||||
|
ok = await configure_bot(api_key, bot_uuid, callback_url)
|
||||||
|
if not ok:
|
||||||
|
print('[warn] bot config update failed; check the API key / payload shape')
|
||||||
|
|
||||||
|
app = web.Application()
|
||||||
|
app['bot_uuid'] = bot_uuid
|
||||||
|
app.router.add_get('/', index)
|
||||||
|
app.router.add_post('/send', send)
|
||||||
|
app.router.add_post('/callback', callback)
|
||||||
|
app.router.add_get('/events', events)
|
||||||
|
|
||||||
|
runner = web.AppRunner(app)
|
||||||
|
await runner.setup()
|
||||||
|
site = web.TCPSite(runner, '0.0.0.0', PORT)
|
||||||
|
await site.start()
|
||||||
|
print(f'\n ▶ 打开: http://{PUBLIC_IP}:{PORT}/\n')
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(3600)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# Page Bot Adapter — Embed Demo
|
||||||
|
|
||||||
|
> English | [中文](./README.zh.md)
|
||||||
|
|
||||||
|
A single self-contained HTML page that demos the LangBot **Page Bot**
|
||||||
|
(`web_page_bot`) embeddable chat widget — the one you drop onto any website with
|
||||||
|
a single `<script>` tag.
|
||||||
|
|
||||||
|
Full guide: [docs.langbot.app — Page Bot](https://docs.langbot.app/en/usage/platforms/webpage).
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| File | What it is |
|
||||||
|
|---|---|
|
||||||
|
| `index.html` | **Browser demo** — open it, point it at a running LangBot instance + a Page Bot you created, and it loads the live embed widget so you can chat with the bot exactly as a site visitor would. Zero deps, no build step. |
|
||||||
|
|
||||||
|
## How to use
|
||||||
|
|
||||||
|
1. In the LangBot WebUI, create a bot with the **Page Bot** (`页面机器人`)
|
||||||
|
adapter and bind it to a working pipeline. Copy its **bot UUID** from the
|
||||||
|
generated embed code.
|
||||||
|
2. Open `index.html` in a browser. Any of these work:
|
||||||
|
- double-click the file, or
|
||||||
|
- serve the folder: `python3 -m http.server 8930` then open
|
||||||
|
`http://localhost:8930/examples/web-page-bot/`.
|
||||||
|
3. Fill in:
|
||||||
|
- **LangBot base URL** — where your instance is reachable from the browser
|
||||||
|
(e.g. `http://localhost:5300`, or your public address).
|
||||||
|
- **Page Bot UUID** — from step 1.
|
||||||
|
- **Widget title** — optional, sets the `data-title` attribute.
|
||||||
|
4. Click **Load widget**. A floating chat bubble appears in the bottom-right
|
||||||
|
corner — click it and chat.
|
||||||
|
|
||||||
|
The page also renders the exact `<script>` snippet you'd paste into your own
|
||||||
|
site (before `</body>`), and updates it live as you edit the fields.
|
||||||
|
|
||||||
|
## What it demonstrates
|
||||||
|
|
||||||
|
- The embed contract: `<script data-title="…" src="<base>/api/v1/embed/<uuid>/widget.js"></script>`.
|
||||||
|
- `widget.js` is served by LangBot pre-configured for that bot UUID — title,
|
||||||
|
bubble icon, language and optional Cloudflare Turnstile protection all come
|
||||||
|
from the bot's config, no page changes needed.
|
||||||
|
- Messages travel over a WebSocket to the bot's bound pipeline; replies stream
|
||||||
|
back into the bubble.
|
||||||
|
|
||||||
|
> The widget loads `widget.js` from your LangBot instance, so the **base URL
|
||||||
|
> must be reachable from the browser** you open this page in. If LangBot runs on
|
||||||
|
> a server, use its public address instead of `localhost`.
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# 页面机器人适配器 —— 嵌入演示
|
||||||
|
|
||||||
|
> [English](./README.md) | 中文
|
||||||
|
|
||||||
|
一个自包含的单文件 HTML 页面,用于演示 LangBot **页面机器人**
|
||||||
|
(`web_page_bot`) 的可嵌入聊天组件 —— 也就是你用一行 `<script>` 标签就能放到任意
|
||||||
|
网站上的那个组件。
|
||||||
|
|
||||||
|
完整指南:[docs.langbot.app —— 页面机器人](https://docs.langbot.app/zh/usage/platforms/webpage)。
|
||||||
|
|
||||||
|
## 文件清单
|
||||||
|
|
||||||
|
| 文件 | 是什么 |
|
||||||
|
|---|---|
|
||||||
|
| `index.html` | **浏览器演示页** —— 打开它,填上一个运行中的 LangBot 实例地址 + 你创建的页面机器人,它就会加载真实的嵌入组件,让你像网站访客一样和机器人对话。零依赖,无需构建。 |
|
||||||
|
|
||||||
|
## 使用方法
|
||||||
|
|
||||||
|
1. 在 LangBot WebUI 中,用 **页面机器人**(`web_page_bot`)适配器创建一个机器人,
|
||||||
|
并绑定一个可用的流水线。从生成的嵌入代码里复制它的 **机器人 UUID**。
|
||||||
|
2. 在浏览器中打开 `index.html`,以下任一方式皆可:
|
||||||
|
- 直接双击该文件;或
|
||||||
|
- 起一个静态服务:`python3 -m http.server 8930`,然后打开
|
||||||
|
`http://localhost:8930/examples/web-page-bot/`。
|
||||||
|
3. 填写:
|
||||||
|
- **LangBot base URL** —— 你的实例在该浏览器中可访问的地址
|
||||||
|
(例如 `http://localhost:5300`,或你的公网地址)。
|
||||||
|
- **页面机器人 UUID** —— 第 1 步里复制的。
|
||||||
|
- **组件标题** —— 可选,对应 `data-title` 属性。
|
||||||
|
4. 点击 **Load widget**。页面右下角会出现一个浮动聊天气泡 —— 点开即可对话。
|
||||||
|
|
||||||
|
页面还会实时渲染出你需要粘贴到自己网站(放在 `</body>` 前)的那段 `<script>`
|
||||||
|
代码,并随着你编辑输入框同步更新。
|
||||||
|
|
||||||
|
## 它演示了什么
|
||||||
|
|
||||||
|
- 嵌入契约:`<script data-title="…" src="<base>/api/v1/embed/<uuid>/widget.js"></script>`。
|
||||||
|
- `widget.js` 由 LangBot 针对该机器人 UUID 预配置后下发 —— 标题、气泡图标、语言
|
||||||
|
以及可选的 Cloudflare Turnstile 防护,全部来自机器人配置,无需改动页面。
|
||||||
|
- 消息通过 WebSocket 发往机器人绑定的流水线,回复流式回到气泡中。
|
||||||
|
|
||||||
|
> 组件会从你的 LangBot 实例加载 `widget.js`,因此 **base URL 必须能从你打开本页
|
||||||
|
> 的浏览器访问到**。如果 LangBot 部署在服务器上,请用它的公网地址而非
|
||||||
|
> `localhost`。
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>LangBot Page Bot · Embed Demo</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #f7f8fa; --panel: #ffffff; --line: #e8eaed; --ink: #1f2329;
|
||||||
|
--mut: #8a909a; --brand: #2563eb; --brand-soft: #eef3ff;
|
||||||
|
--ok: #16a34a; --bad: #dc2626; --code: #f3f4f6;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { height: 100%; }
|
||||||
|
body {
|
||||||
|
margin: 0; background: var(--bg); color: var(--ink);
|
||||||
|
font: 14px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||||
|
"PingFang SC", "Microsoft YaHei", sans-serif;
|
||||||
|
}
|
||||||
|
.top {
|
||||||
|
height: 52px; background: var(--panel); border-bottom: 1px solid var(--line);
|
||||||
|
display: flex; align-items: center; gap: 10px; padding: 0 18px;
|
||||||
|
}
|
||||||
|
.logo {
|
||||||
|
width: 26px; height: 26px; border-radius: 7px; background: var(--brand);
|
||||||
|
display: grid; place-items: center; color: #fff; font-weight: 700; font-size: 14px;
|
||||||
|
}
|
||||||
|
.top b { font-size: 15px; }
|
||||||
|
.top .ver { font-size: 12px; color: var(--mut); }
|
||||||
|
.wrap { max-width: 760px; margin: 0 auto; padding: 28px 18px 80px; }
|
||||||
|
.hero h1 { margin: 8px 0 6px; font-size: 22px; }
|
||||||
|
.hero p { margin: 0 0 4px; color: var(--mut); }
|
||||||
|
.card {
|
||||||
|
background: var(--panel); border: 1px solid var(--line); border-radius: 12px;
|
||||||
|
padding: 20px; margin-top: 20px;
|
||||||
|
}
|
||||||
|
.card h3 {
|
||||||
|
margin: 0 0 14px; font-size: 14px; font-weight: 600; color: #4b5563;
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
}
|
||||||
|
.card h3 .num {
|
||||||
|
width: 20px; height: 20px; border-radius: 50%; background: var(--brand-soft);
|
||||||
|
color: var(--brand); display: grid; place-items: center; font-size: 12px; font-weight: 700;
|
||||||
|
}
|
||||||
|
.field { margin-bottom: 14px; }
|
||||||
|
.field:last-child { margin-bottom: 0; }
|
||||||
|
.field label { display: block; font-size: 12px; color: var(--mut); margin-bottom: 5px; }
|
||||||
|
.field input {
|
||||||
|
width: 100%; border: 1px solid var(--line); border-radius: 9px;
|
||||||
|
padding: 10px 12px; font-size: 14px; outline: none; font-family: inherit;
|
||||||
|
}
|
||||||
|
.field input:focus { border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-soft); }
|
||||||
|
.hint { font-size: 12px; color: var(--mut); margin-top: 5px; }
|
||||||
|
.hint code { background: var(--code); border-radius: 5px; padding: 1px 5px; font-size: 11px; }
|
||||||
|
.actions { display: flex; gap: 10px; margin-top: 18px; align-items: center; }
|
||||||
|
button {
|
||||||
|
border: 0; border-radius: 9px; padding: 10px 18px; font-size: 14px;
|
||||||
|
font-weight: 500; cursor: pointer; font-family: inherit;
|
||||||
|
}
|
||||||
|
.btn-primary { background: var(--brand); color: #fff; }
|
||||||
|
.btn-primary:disabled { opacity: .5; cursor: default; }
|
||||||
|
.btn-ghost { background: #fff; border: 1px solid var(--line); color: #4b5563; }
|
||||||
|
.status { font-size: 13px; color: var(--mut); }
|
||||||
|
.status .ok { color: var(--ok); }
|
||||||
|
.status .bad { color: var(--bad); }
|
||||||
|
pre {
|
||||||
|
background: #0f172a; color: #e2e8f0; border-radius: 10px; padding: 14px 16px;
|
||||||
|
overflow: auto; font: 12px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.snippet-row { position: relative; }
|
||||||
|
.snippet-row .copy {
|
||||||
|
position: absolute; top: 10px; right: 10px; background: rgba(255,255,255,.12);
|
||||||
|
color: #fff; border: 0; border-radius: 7px; padding: 5px 10px; font-size: 12px; cursor: pointer;
|
||||||
|
}
|
||||||
|
ul.steps { margin: 0; padding-left: 18px; color: #4b5563; }
|
||||||
|
ul.steps li { margin-bottom: 6px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="top">
|
||||||
|
<div class="logo">L</div>
|
||||||
|
<b>Page Bot · Embed Demo</b>
|
||||||
|
<span class="ver">examples/web-page-bot</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="hero">
|
||||||
|
<h1>Try the LangBot Page Bot widget</h1>
|
||||||
|
<p>Point this page at a running LangBot instance and a <strong>Page Bot</strong> you created,</p>
|
||||||
|
<p>then load the live embed widget below to chat with it — exactly as your site visitors would.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3><span class="num">1</span> Connect your Page Bot</h3>
|
||||||
|
<div class="field">
|
||||||
|
<label for="base">LangBot base URL</label>
|
||||||
|
<input id="base" placeholder="http://localhost:5300" value="http://localhost:5300" />
|
||||||
|
<div class="hint">The address where your LangBot instance is reachable from this browser. No trailing slash.</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="uuid">Page Bot UUID</label>
|
||||||
|
<input id="uuid" placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" />
|
||||||
|
<div class="hint">Create a bot with the <code>Page Bot</code> adapter in the WebUI, then copy its UUID from the embed code.</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="title">Widget title (optional)</label>
|
||||||
|
<input id="title" placeholder="LangBot" value="LangBot" />
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button id="load" class="btn-primary">Load widget</button>
|
||||||
|
<button id="unload" class="btn-ghost">Remove widget</button>
|
||||||
|
<span class="status" id="status">Not loaded.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3><span class="num">2</span> The embed snippet</h3>
|
||||||
|
<p style="margin:0 0 12px;color:var(--mut)">This is exactly what you paste into your own site (before <code></body></code>). It updates as you edit the fields above.</p>
|
||||||
|
<div class="snippet-row">
|
||||||
|
<button class="copy" id="copy">Copy</button>
|
||||||
|
<pre id="snippet"><script data-title="LangBot" src="http://localhost:5300/api/v1/embed/<bot-uuid>/widget.js"></script></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3><span class="num">3</span> How it works</h3>
|
||||||
|
<ul class="steps">
|
||||||
|
<li>The <code><script></code> tag pulls <code>widget.js</code> from your LangBot instance, pre-configured for that bot UUID.</li>
|
||||||
|
<li>A floating chat bubble appears in the bottom-right corner of the page.</li>
|
||||||
|
<li>Messages travel over a WebSocket to the bot's bound pipeline; replies stream back into the bubble.</li>
|
||||||
|
<li>Title, bubble icon, language and optional Cloudflare Turnstile protection are all set in the bot's config — no page changes needed.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
var $ = function (s) { return document.querySelector(s); };
|
||||||
|
var baseEl = $("#base"), uuidEl = $("#uuid"), titleEl = $("#title"),
|
||||||
|
statusEl = $("#status"), snippetEl = $("#snippet");
|
||||||
|
var WIDGET_ID = "langbot-embed-demo-script";
|
||||||
|
|
||||||
|
function clean(v) { return (v || "").trim().replace(/\/+$/, ""); }
|
||||||
|
|
||||||
|
function buildSrc() {
|
||||||
|
var base = clean(baseEl.value) || "http://localhost:5300";
|
||||||
|
var uuid = uuidEl.value.trim() || "<bot-uuid>";
|
||||||
|
return base + "/api/v1/embed/" + uuid + "/widget.js";
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshSnippet() {
|
||||||
|
var title = titleEl.value.trim() || "LangBot";
|
||||||
|
var src = buildSrc();
|
||||||
|
snippetEl.textContent =
|
||||||
|
'<script data-title="' + title + '" src="' + src + '"><\/script>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(html) { statusEl.innerHTML = html; }
|
||||||
|
|
||||||
|
function removeWidget() {
|
||||||
|
var old = document.getElementById(WIDGET_ID);
|
||||||
|
if (old) old.remove();
|
||||||
|
// The widget injects its own DOM (bubble + panel). Clear the common containers it creates.
|
||||||
|
document.querySelectorAll('[id^="langbot-"]').forEach(function (n) {
|
||||||
|
if (n.id !== WIDGET_ID) n.remove();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadWidget() {
|
||||||
|
var uuid = uuidEl.value.trim();
|
||||||
|
var uuidRe = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i;
|
||||||
|
if (!uuidRe.test(uuid)) {
|
||||||
|
setStatus('<span class="bad">Enter a valid bot UUID first.</span>');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
removeWidget();
|
||||||
|
var s = document.createElement("script");
|
||||||
|
s.id = WIDGET_ID;
|
||||||
|
s.setAttribute("data-title", titleEl.value.trim() || "LangBot");
|
||||||
|
s.src = buildSrc();
|
||||||
|
s.onload = function () {
|
||||||
|
setStatus('<span class="ok">Widget loaded — look bottom-right.</span>');
|
||||||
|
};
|
||||||
|
s.onerror = function () {
|
||||||
|
setStatus('<span class="bad">Failed to load widget.js — check the base URL and that the bot is enabled.</span>');
|
||||||
|
};
|
||||||
|
document.body.appendChild(s);
|
||||||
|
setStatus("Loading…");
|
||||||
|
}
|
||||||
|
|
||||||
|
$("#load").onclick = loadWidget;
|
||||||
|
$("#unload").onclick = function () {
|
||||||
|
removeWidget();
|
||||||
|
setStatus("Widget removed.");
|
||||||
|
};
|
||||||
|
$("#copy").onclick = function () {
|
||||||
|
navigator.clipboard.writeText(snippetEl.textContent).then(function () {
|
||||||
|
var b = $("#copy"); b.textContent = "Copied"; setTimeout(function () { b.textContent = "Copy"; }, 1200);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
[baseEl, uuidEl, titleEl].forEach(function (el) { el.addEventListener("input", refreshSnippet); });
|
||||||
|
refreshSnippet();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "langbot"
|
name = "langbot"
|
||||||
version = "4.10.2"
|
version = "4.10.4"
|
||||||
description = "Production-grade platform for building agentic IM bots"
|
description = "Production-grade platform for building agentic IM bots"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license-files = ["LICENSE"]
|
license-files = ["LICENSE"]
|
||||||
@@ -70,7 +70,7 @@ dependencies = [
|
|||||||
"chromadb>=1.0.0,<2.0.0",
|
"chromadb>=1.0.0,<2.0.0",
|
||||||
"qdrant-client (>=1.15.1,<2.0.0)",
|
"qdrant-client (>=1.15.1,<2.0.0)",
|
||||||
"pyseekdb==1.1.0.post3",
|
"pyseekdb==1.1.0.post3",
|
||||||
"langbot-plugin==0.4.5",
|
"langbot-plugin @ file:///home/qinjunyan/code/projects/langbot/langbot-plugin-sdk",
|
||||||
"asyncpg>=0.30.0",
|
"asyncpg>=0.30.0",
|
||||||
"line-bot-sdk>=3.19.0",
|
"line-bot-sdk>=3.19.0",
|
||||||
"matrix-nio>=0.25.2",
|
"matrix-nio>=0.25.2",
|
||||||
|
|||||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
@@ -42,6 +42,38 @@ MyPlugin/
|
|||||||
|
|
||||||
Each component has a `.yaml` (metadata) and `.py` (implementation).
|
Each component has a `.yaml` (metadata) and `.py` (implementation).
|
||||||
|
|
||||||
|
## README & i18n convention (enforced on the marketplace)
|
||||||
|
|
||||||
|
A plugin published to LangBot Space serves a localized README on its detail page.
|
||||||
|
The resolver (`langbot-space` `PluginService.GetPluginREADME`) works like this:
|
||||||
|
|
||||||
|
- **Root `README.md` MUST be in English.** It is the default and the fallback —
|
||||||
|
when no per-language README matches the viewer's locale, the page serves the
|
||||||
|
root `README.md`. A non-English root README makes the English/default view show
|
||||||
|
the wrong language.
|
||||||
|
- **All other languages live under `readme/README_{lang}.md`** — e.g.
|
||||||
|
`readme/README_zh_Hans.md`, `readme/README_ja_JP.md`. The 8 supported locales:
|
||||||
|
`en_US, zh_Hans, zh_Hant, ja_JP, th_TH, vi_VN, es_ES, ru_RU`.
|
||||||
|
- `manifest.yaml` `metadata.label` / `metadata.description` should carry the same
|
||||||
|
8-locale i18n set (`repository` must be a real, alive URL).
|
||||||
|
|
||||||
|
```
|
||||||
|
MyPlugin/
|
||||||
|
├── manifest.yaml
|
||||||
|
├── README.md # English (default + fallback) — REQUIRED, must be English
|
||||||
|
└── readme/
|
||||||
|
├── README_zh_Hans.md
|
||||||
|
├── README_zh_Hant.md
|
||||||
|
├── README_ja_JP.md
|
||||||
|
├── README_th_TH.md
|
||||||
|
├── README_vi_VN.md
|
||||||
|
├── README_es_ES.md
|
||||||
|
└── README_ru_RU.md
|
||||||
|
```
|
||||||
|
|
||||||
|
`manifest.yaml` (incl. `repository`) is the source of truth — the marketplace
|
||||||
|
syncs from it, so edit the package and re-publish rather than patching live data.
|
||||||
|
|
||||||
## Critical SDK Pitfalls
|
## Critical SDK Pitfalls
|
||||||
|
|
||||||
### 1. MessageChain is a RootModel — iterate directly
|
### 1. MessageChain is a RootModel — iterate directly
|
||||||
|
|||||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
@@ -1,3 +1,5 @@
|
|||||||
"""LangBot - Production-grade platform for building agentic IM bots"""
|
"""LangBot - Production-grade platform for building agentic IM bots"""
|
||||||
|
|
||||||
__version__ = '4.10.2'
|
from importlib.metadata import version
|
||||||
|
|
||||||
|
__version__ = version('langbot')
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from langbot.pkg.utils import constants
|
||||||
|
|
||||||
from .. import group
|
from .. import group
|
||||||
|
from .box_visibility import should_hide_box_runtime_status
|
||||||
|
|
||||||
|
|
||||||
@group.group_class('box', '/api/v1/box')
|
@group.group_class('box', '/api/v1/box')
|
||||||
@@ -9,6 +12,7 @@ class BoxRouterGroup(group.RouterGroup):
|
|||||||
@self.route('/status', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
@self.route('/status', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
async def _() -> str:
|
async def _() -> str:
|
||||||
status = await self.ap.box_service.get_status()
|
status = await self.ap.box_service.get_status()
|
||||||
|
status['hidden'] = should_hide_box_runtime_status(constants.edition, status.get('enabled'))
|
||||||
return self.success(data=status)
|
return self.success(data=status)
|
||||||
|
|
||||||
@self.route('/sessions', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
@self.route('/sessions', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
def should_hide_box_runtime_status(edition: str, box_enabled: bool | None) -> bool:
|
||||||
|
return edition == 'cloud' and box_enabled is False
|
||||||
@@ -62,16 +62,24 @@ class EmbedRouterGroup(group.RouterGroup):
|
|||||||
"""Resolve *bot_uuid* to ``(runtime_bot, pipeline_uuid)``.
|
"""Resolve *bot_uuid* to ``(runtime_bot, pipeline_uuid)``.
|
||||||
|
|
||||||
Returns ``(None, None)`` when the bot does not exist, is not a
|
Returns ``(None, None)`` when the bot does not exist, is not a
|
||||||
``web_page_bot``, is disabled, or has no pipeline bound.
|
``web_page_bot``, is disabled, or has no pipeline/workflow bound.
|
||||||
"""
|
"""
|
||||||
for bot in self.ap.platform_mgr.bots:
|
for bot in self.ap.platform_mgr.bots:
|
||||||
if (
|
if (
|
||||||
bot.bot_entity.uuid == bot_uuid
|
bot.bot_entity.uuid == bot_uuid
|
||||||
and bot.bot_entity.adapter == 'web_page_bot'
|
and bot.bot_entity.adapter == 'web_page_bot'
|
||||||
and bot.bot_entity.enable
|
and bot.bot_entity.enable
|
||||||
and bot.bot_entity.use_pipeline_uuid
|
|
||||||
):
|
):
|
||||||
return bot, bot.bot_entity.use_pipeline_uuid
|
# Check for workflow binding first
|
||||||
|
binding_type = getattr(bot.bot_entity, 'binding_type', 'pipeline') or 'pipeline'
|
||||||
|
binding_uuid = getattr(bot.bot_entity, 'binding_uuid', None)
|
||||||
|
|
||||||
|
if binding_type == 'workflow' and binding_uuid:
|
||||||
|
# For workflow binding, return workflow UUID
|
||||||
|
return bot, binding_uuid
|
||||||
|
elif bot.bot_entity.use_pipeline_uuid:
|
||||||
|
# For pipeline binding, return pipeline UUID
|
||||||
|
return bot, bot.bot_entity.use_pipeline_uuid
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
def _get_bot_config(self, bot_uuid: str) -> dict:
|
def _get_bot_config(self, bot_uuid: str) -> dict:
|
||||||
|
|||||||
@@ -86,6 +86,10 @@ class PipelinesRouterGroup(group.RouterGroup):
|
|||||||
'available_plugins': plugins,
|
'available_plugins': plugins,
|
||||||
'bound_mcp_servers': extensions_prefs.get('mcp_servers', []),
|
'bound_mcp_servers': extensions_prefs.get('mcp_servers', []),
|
||||||
'available_mcp_servers': mcp_servers,
|
'available_mcp_servers': mcp_servers,
|
||||||
|
'bound_mcp_resources': extensions_prefs.get('mcp_resources', []),
|
||||||
|
'mcp_resource_agent_read_enabled': extensions_prefs.get(
|
||||||
|
'mcp_resource_agent_read_enabled', True
|
||||||
|
),
|
||||||
'bound_skills': extensions_prefs.get('skills', []),
|
'bound_skills': extensions_prefs.get('skills', []),
|
||||||
'available_skills': available_skills,
|
'available_skills': available_skills,
|
||||||
}
|
}
|
||||||
@@ -99,6 +103,8 @@ class PipelinesRouterGroup(group.RouterGroup):
|
|||||||
bound_plugins = json_data.get('bound_plugins', [])
|
bound_plugins = json_data.get('bound_plugins', [])
|
||||||
bound_mcp_servers = json_data.get('bound_mcp_servers', [])
|
bound_mcp_servers = json_data.get('bound_mcp_servers', [])
|
||||||
bound_skills = json_data.get('bound_skills', [])
|
bound_skills = json_data.get('bound_skills', [])
|
||||||
|
bound_mcp_resources = json_data.get('bound_mcp_resources')
|
||||||
|
mcp_resource_agent_read_enabled = json_data.get('mcp_resource_agent_read_enabled')
|
||||||
|
|
||||||
await self.ap.pipeline_service.update_pipeline_extensions(
|
await self.ap.pipeline_service.update_pipeline_extensions(
|
||||||
pipeline_uuid,
|
pipeline_uuid,
|
||||||
@@ -108,6 +114,8 @@ class PipelinesRouterGroup(group.RouterGroup):
|
|||||||
enable_all_mcp_servers,
|
enable_all_mcp_servers,
|
||||||
bound_skills=bound_skills,
|
bound_skills=bound_skills,
|
||||||
enable_all_skills=enable_all_skills,
|
enable_all_skills=enable_all_skills,
|
||||||
|
bound_mcp_resources=bound_mcp_resources,
|
||||||
|
mcp_resource_agent_read_enabled=mcp_resource_agent_read_enabled,
|
||||||
)
|
)
|
||||||
|
|
||||||
return self.success()
|
return self.success()
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ class BotsRouterGroup(group.RouterGroup):
|
|||||||
@self.route('/<bot_uuid>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
@self.route('/<bot_uuid>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||||
async def _(bot_uuid: str) -> str:
|
async def _(bot_uuid: str) -> str:
|
||||||
if quart.request.method == 'GET':
|
if quart.request.method == 'GET':
|
||||||
# 返回运行时信息,包括webhook地址等
|
|
||||||
bot = await self.ap.bot_service.get_runtime_bot_info(bot_uuid)
|
bot = await self.ap.bot_service.get_runtime_bot_info(bot_uuid)
|
||||||
if bot is None:
|
if bot is None:
|
||||||
return self.http_status(404, -1, 'bot not found')
|
return self.http_status(404, -1, 'bot not found')
|
||||||
@@ -37,30 +36,21 @@ class BotsRouterGroup(group.RouterGroup):
|
|||||||
from_index = json_data.get('from_index', -1)
|
from_index = json_data.get('from_index', -1)
|
||||||
max_count = json_data.get('max_count', 10)
|
max_count = json_data.get('max_count', 10)
|
||||||
logs, total_count = await self.ap.bot_service.list_event_logs(bot_uuid, from_index, max_count)
|
logs, total_count = await self.ap.bot_service.list_event_logs(bot_uuid, from_index, max_count)
|
||||||
return self.success(
|
return self.success(data={'logs': logs, 'total_count': total_count})
|
||||||
data={
|
|
||||||
'logs': logs,
|
|
||||||
'total_count': total_count,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
@self.route('/<bot_uuid>/send_message', methods=['POST'], auth_type=group.AuthType.API_KEY)
|
@self.route('/<bot_uuid>/send_message', methods=['POST'], auth_type=group.AuthType.API_KEY)
|
||||||
async def _(bot_uuid: str) -> str:
|
async def _(bot_uuid: str) -> str:
|
||||||
"""Send message to a specific target via bot"""
|
|
||||||
json_data = await quart.request.json
|
json_data = await quart.request.json
|
||||||
target_type = json_data.get('target_type')
|
target_type = json_data.get('target_type')
|
||||||
target_id = json_data.get('target_id')
|
target_id = json_data.get('target_id')
|
||||||
message_chain_data = json_data.get('message_chain')
|
message_chain_data = json_data.get('message_chain')
|
||||||
|
|
||||||
# Validate required fields
|
|
||||||
if not target_type:
|
if not target_type:
|
||||||
return self.http_status(400, -1, 'target_type is required')
|
return self.http_status(400, -1, 'target_type is required')
|
||||||
if not target_id:
|
if not target_id:
|
||||||
return self.http_status(400, -1, 'target_id is required')
|
return self.http_status(400, -1, 'target_id is required')
|
||||||
if not message_chain_data:
|
if not message_chain_data:
|
||||||
return self.http_status(400, -1, 'message_chain is required')
|
return self.http_status(400, -1, 'message_chain is required')
|
||||||
|
|
||||||
# Validate target_type
|
|
||||||
if target_type not in ['person', 'group']:
|
if target_type not in ['person', 'group']:
|
||||||
return self.http_status(400, -1, 'target_type must be either "person" or "group"')
|
return self.http_status(400, -1, 'target_type must be either "person" or "group"')
|
||||||
|
|
||||||
@@ -72,3 +62,29 @@ class BotsRouterGroup(group.RouterGroup):
|
|||||||
|
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
return self.http_status(500, -1, f'Failed to send message: {str(e)}')
|
return self.http_status(500, -1, f'Failed to send message: {str(e)}')
|
||||||
|
|
||||||
|
# ============ Bot Admins ============
|
||||||
|
|
||||||
|
@self.route('/<bot_uuid>/admins', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||||
|
async def _(bot_uuid: str) -> str:
|
||||||
|
if quart.request.method == 'GET':
|
||||||
|
admins = await self.ap.bot_service.get_bot_admins(bot_uuid)
|
||||||
|
return self.success(data={'admins': admins})
|
||||||
|
elif quart.request.method == 'POST':
|
||||||
|
json_data = await quart.request.json
|
||||||
|
launcher_type = json_data.get('launcher_type', '').strip()
|
||||||
|
launcher_id = str(json_data.get('launcher_id', '')).strip()
|
||||||
|
if not launcher_type or not launcher_id:
|
||||||
|
return self.http_status(400, -1, 'launcher_type and launcher_id are required')
|
||||||
|
try:
|
||||||
|
admin_id = await self.ap.bot_service.add_bot_admin(bot_uuid, launcher_type, launcher_id)
|
||||||
|
return self.success(data={'id': admin_id})
|
||||||
|
except Exception as e:
|
||||||
|
return self.http_status(409, -1, str(e))
|
||||||
|
|
||||||
|
@self.route(
|
||||||
|
'/<bot_uuid>/admins/<int:admin_id>', methods=['DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY
|
||||||
|
)
|
||||||
|
async def _(bot_uuid: str, admin_id: int) -> str:
|
||||||
|
await self.ap.bot_service.delete_bot_admin(bot_uuid, admin_id)
|
||||||
|
return self.success()
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import quart
|
import quart
|
||||||
import traceback
|
import traceback
|
||||||
|
from urllib.parse import unquote
|
||||||
|
|
||||||
|
|
||||||
from ... import group
|
from ... import group
|
||||||
@@ -66,3 +67,50 @@ class MCPRouterGroup(group.RouterGroup):
|
|||||||
server_data = await quart.request.json
|
server_data = await quart.request.json
|
||||||
task_id = await self.ap.mcp_service.test_mcp_server(server_name=server_name, server_data=server_data)
|
task_id = await self.ap.mcp_service.test_mcp_server(server_name=server_name, server_data=server_data)
|
||||||
return self.success(data={'task_id': task_id})
|
return self.success(data={'task_id': task_id})
|
||||||
|
|
||||||
|
@self.route('/servers/<server_name>/resources', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
|
async def _(server_name: str) -> str:
|
||||||
|
"""Get resources from an MCP server"""
|
||||||
|
server_name = unquote(server_name)
|
||||||
|
try:
|
||||||
|
resources = await self.ap.mcp_service.get_mcp_server_resources(server_name)
|
||||||
|
templates = await self.ap.mcp_service.get_mcp_server_resource_templates(server_name)
|
||||||
|
runtime_info = await self.ap.mcp_service.get_runtime_info(server_name)
|
||||||
|
return self.success(
|
||||||
|
data={
|
||||||
|
'resources': resources,
|
||||||
|
'resource_templates': templates,
|
||||||
|
'resource_capabilities': (runtime_info or {}).get('resource_capabilities', {}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return self.http_status(500, -1, f'Failed to get resources: {str(e)}')
|
||||||
|
|
||||||
|
@self.route('/servers/<server_name>/resource-templates', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
|
async def _(server_name: str) -> str:
|
||||||
|
"""Get resource templates from an MCP server"""
|
||||||
|
server_name = unquote(server_name)
|
||||||
|
try:
|
||||||
|
templates = await self.ap.mcp_service.get_mcp_server_resource_templates(server_name)
|
||||||
|
return self.success(data={'resource_templates': templates})
|
||||||
|
except Exception as e:
|
||||||
|
return self.http_status(500, -1, f'Failed to get resource templates: {str(e)}')
|
||||||
|
|
||||||
|
@self.route('/servers/<server_name>/resources/read', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
|
async def _(server_name: str) -> str:
|
||||||
|
"""Read a resource from an MCP server"""
|
||||||
|
server_name = unquote(server_name)
|
||||||
|
data = await quart.request.json
|
||||||
|
uri = data.get('uri')
|
||||||
|
if not uri:
|
||||||
|
return self.http_status(400, -1, 'URI is required')
|
||||||
|
try:
|
||||||
|
envelope = await self.ap.mcp_service.read_mcp_server_resource_envelope(
|
||||||
|
server_name,
|
||||||
|
uri,
|
||||||
|
max_bytes=data.get('max_bytes'),
|
||||||
|
include_blob=bool(data.get('include_blob', False)),
|
||||||
|
)
|
||||||
|
return self.success(data=envelope)
|
||||||
|
except Exception as e:
|
||||||
|
return self.http_status(500, -1, f'Failed to read resource: {str(e)}')
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import quart
|
||||||
|
|
||||||
from ... import group
|
from ... import group
|
||||||
|
|
||||||
|
|
||||||
@@ -9,25 +11,41 @@ class ToolsRouterGroup(group.RouterGroup):
|
|||||||
@self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
@self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
async def _() -> str:
|
async def _() -> str:
|
||||||
"""获取所有可用工具列表"""
|
"""获取所有可用工具列表"""
|
||||||
tools = await self.ap.tool_mgr.get_all_tools()
|
pipeline_uuid = quart.request.args.get('pipeline_uuid') or quart.request.args.get('pipeline_id')
|
||||||
|
bound_plugins: list[str] | None = None
|
||||||
|
bound_mcp_servers: list[str] | None = None
|
||||||
|
|
||||||
tool_list = []
|
if pipeline_uuid:
|
||||||
for tool in tools:
|
pipeline = await self.ap.pipeline_service.get_pipeline(pipeline_uuid)
|
||||||
tool_list.append(
|
if pipeline is None:
|
||||||
{
|
return self.http_status(404, -1, 'pipeline not found')
|
||||||
'name': tool.name,
|
|
||||||
'description': tool.description,
|
|
||||||
'human_desc': tool.human_desc,
|
|
||||||
'parameters': tool.parameters,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return self.success(data={'tools': tool_list})
|
extensions_prefs = pipeline.get('extensions_preferences', {}) or {}
|
||||||
|
if not extensions_prefs.get('enable_all_plugins', True):
|
||||||
|
bound_plugins = [
|
||||||
|
f'{plugin.get("author", "")}/{plugin.get("name", "")}'
|
||||||
|
for plugin in extensions_prefs.get('plugins', [])
|
||||||
|
if isinstance(plugin, dict) and plugin.get('name')
|
||||||
|
]
|
||||||
|
if not extensions_prefs.get('enable_all_mcp_servers', True):
|
||||||
|
bound_mcp_servers = [
|
||||||
|
server for server in (extensions_prefs.get('mcp_servers', []) or []) if isinstance(server, str)
|
||||||
|
]
|
||||||
|
|
||||||
|
return self.success(
|
||||||
|
data={
|
||||||
|
'tools': await self.ap.tool_mgr.get_tool_catalog(
|
||||||
|
bound_plugins,
|
||||||
|
bound_mcp_servers,
|
||||||
|
include_skill_authoring=True,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
@self.route('/<tool_name>', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
@self.route('/<tool_name>', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
async def _(tool_name: str) -> str:
|
async def _(tool_name: str) -> str:
|
||||||
"""获取特定工具详情"""
|
"""获取特定工具详情"""
|
||||||
tools = await self.ap.tool_mgr.get_all_tools()
|
tools = await self.ap.tool_mgr.get_all_tools(include_skill_authoring=True)
|
||||||
|
|
||||||
for tool in tools:
|
for tool in tools:
|
||||||
if tool.name == tool_name:
|
if tool.name == tool_name:
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import base64
|
||||||
|
|
||||||
import quart
|
import quart
|
||||||
|
|
||||||
from .. import group
|
from .. import group
|
||||||
@@ -30,6 +32,50 @@ class SurveyRouterGroup(group.RouterGroup):
|
|||||||
return self.fail(2, 'Failed to submit response')
|
return self.fail(2, 'Failed to submit response')
|
||||||
return self.fail(3, 'Survey not available')
|
return self.fail(3, 'Survey not available')
|
||||||
|
|
||||||
|
@self.route('/feedback', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
|
async def _feedback(user_email: str) -> str:
|
||||||
|
"""Submit on-demand user feedback from the sidebar."""
|
||||||
|
json_data = await quart.request.get_json(silent=True) or {}
|
||||||
|
content = str(json_data.get('content', '')).strip()
|
||||||
|
attachments = json_data.get('attachments', [])
|
||||||
|
|
||||||
|
if not content:
|
||||||
|
return self.fail(1, 'content required')
|
||||||
|
if len(content) > 5000:
|
||||||
|
return self.fail(2, 'content too long')
|
||||||
|
if not isinstance(attachments, list):
|
||||||
|
return self.fail(3, 'attachments must be an array')
|
||||||
|
if len(attachments) > 3:
|
||||||
|
return self.fail(4, 'too many attachments')
|
||||||
|
|
||||||
|
normalized_attachments = []
|
||||||
|
for item in attachments:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
data_url = str(item.get('data_url', ''))
|
||||||
|
mime_type = str(item.get('mime_type', ''))[:128]
|
||||||
|
name = str(item.get('name', ''))[:255]
|
||||||
|
if not data_url.startswith('data:image/'):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
payload = data_url.split(',', 1)[1]
|
||||||
|
if len(base64.b64decode(payload, validate=True)) > 1024 * 1024:
|
||||||
|
return self.fail(5, 'attachment too large')
|
||||||
|
except Exception:
|
||||||
|
return self.fail(5, 'attachment too large')
|
||||||
|
normalized_attachments.append({'name': name, 'mime_type': mime_type, 'data_url': data_url})
|
||||||
|
|
||||||
|
if self.ap.survey:
|
||||||
|
ok = await self.ap.survey.submit_feedback(
|
||||||
|
content=content,
|
||||||
|
attachments=normalized_attachments,
|
||||||
|
user_email=user_email,
|
||||||
|
)
|
||||||
|
if ok:
|
||||||
|
return self.success()
|
||||||
|
return self.fail(6, 'Failed to submit feedback')
|
||||||
|
return self.fail(7, 'Survey not available')
|
||||||
|
|
||||||
@self.route('/dismiss', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
@self.route('/dismiss', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
async def _dismiss() -> str:
|
async def _dismiss() -> str:
|
||||||
"""Dismiss survey."""
|
"""Dismiss survey."""
|
||||||
|
|||||||
@@ -195,6 +195,13 @@ class UserRouterGroup(group.RouterGroup):
|
|||||||
@self.route('/set-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
@self.route('/set-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
async def _(user_email: str) -> str:
|
async def _(user_email: str) -> str:
|
||||||
"""Set password for Space account (first time) or change password"""
|
"""Set password for Space account (first time) or change password"""
|
||||||
|
# Check if modifying login info is allowed
|
||||||
|
allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get(
|
||||||
|
'allow_modify_login_info', True
|
||||||
|
)
|
||||||
|
if not allow_modify_login_info:
|
||||||
|
return self.http_status(403, -1, 'Modifying login info is disabled')
|
||||||
|
|
||||||
json_data = await quart.request.json
|
json_data = await quart.request.json
|
||||||
new_password = json_data.get('new_password')
|
new_password = json_data.get('new_password')
|
||||||
current_password = json_data.get('current_password')
|
current_password = json_data.get('current_password')
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Workflow router group
|
||||||
|
from .workflows import WorkflowsRouterGroup, ExecutionsRouterGroup
|
||||||
|
from .websocket_chat import WorkflowWebSocketChatRouterGroup
|
||||||
|
|
||||||
|
__all__ = ['WorkflowsRouterGroup', 'ExecutionsRouterGroup', 'WorkflowWebSocketChatRouterGroup']
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
"""Workflow WebSocket聊天路由 - 支持工作流调试的双向实时通信"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import datetime
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import quart
|
||||||
|
|
||||||
|
from ... import group
|
||||||
|
from ......platform.sources.websocket_manager import ws_connection_manager
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@group.group_class('workflow_websocket_chat', '/api/v1/workflows/<workflow_uuid>/ws')
|
||||||
|
class WorkflowWebSocketChatRouterGroup(group.RouterGroup):
|
||||||
|
async def initialize(self) -> None:
|
||||||
|
@self.quart_app.websocket(self.path + '/connect')
|
||||||
|
async def workflow_websocket_connect(workflow_uuid: str):
|
||||||
|
"""
|
||||||
|
建立工作流WebSocket连接
|
||||||
|
|
||||||
|
URL参数:
|
||||||
|
- workflow_uuid: 工作流UUID
|
||||||
|
- session_type: 会话类型 (person/group)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
session_type = quart.websocket.args.get('session_type', 'person')
|
||||||
|
logger.info(
|
||||||
|
'Workflow WebSocket connect request received',
|
||||||
|
extra={
|
||||||
|
'workflow_uuid': workflow_uuid,
|
||||||
|
'session_type': session_type,
|
||||||
|
'path': quart.websocket.path,
|
||||||
|
'query_string': quart.websocket.query_string.decode('utf-8', errors='ignore'),
|
||||||
|
'remote_addr': getattr(quart.websocket, 'remote_addr', None),
|
||||||
|
'user_agent': quart.websocket.headers.get('User-Agent', ''),
|
||||||
|
'host': quart.websocket.headers.get('Host', ''),
|
||||||
|
'origin': quart.websocket.headers.get('Origin', ''),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if session_type not in ['person', 'group']:
|
||||||
|
await quart.websocket.send(
|
||||||
|
json.dumps({'type': 'error', 'message': 'session_type must be person or group'})
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter
|
||||||
|
|
||||||
|
if not websocket_adapter:
|
||||||
|
logger.warning(
|
||||||
|
'Workflow WebSocket adapter missing',
|
||||||
|
extra={
|
||||||
|
'workflow_uuid': workflow_uuid,
|
||||||
|
'session_type': session_type,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'WebSocket adapter not found'}))
|
||||||
|
return
|
||||||
|
|
||||||
|
connection = await ws_connection_manager.add_connection(
|
||||||
|
websocket=quart.websocket._get_current_object(),
|
||||||
|
pipeline_uuid=workflow_uuid,
|
||||||
|
session_type=session_type,
|
||||||
|
metadata={'user_agent': quart.websocket.headers.get('User-Agent', ''), 'is_workflow': True},
|
||||||
|
)
|
||||||
|
|
||||||
|
await quart.websocket.send(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
'type': 'connected',
|
||||||
|
'connection_id': connection.connection_id,
|
||||||
|
'workflow_uuid': workflow_uuid,
|
||||||
|
'session_type': session_type,
|
||||||
|
'timestamp': connection.created_at.isoformat(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f'Workflow WebSocket connection established: {connection.connection_id} '
|
||||||
|
f'(workflow={workflow_uuid}, session_type={session_type})'
|
||||||
|
)
|
||||||
|
|
||||||
|
receive_task = asyncio.create_task(self._handle_receive(connection, websocket_adapter))
|
||||||
|
send_task = asyncio.create_task(self._handle_send(connection))
|
||||||
|
|
||||||
|
try:
|
||||||
|
await asyncio.gather(receive_task, send_task)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'Workflow WebSocket task execution error: {e}')
|
||||||
|
finally:
|
||||||
|
await ws_connection_manager.remove_connection(connection.connection_id)
|
||||||
|
logger.debug(f'Workflow WebSocket connection cleaned: {connection.connection_id}')
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
'Workflow WebSocket connection error',
|
||||||
|
exc_info=True,
|
||||||
|
extra={
|
||||||
|
'workflow_uuid': workflow_uuid,
|
||||||
|
'session_type': quart.websocket.args.get('session_type', 'person'),
|
||||||
|
'path': quart.websocket.path,
|
||||||
|
'query_string': quart.websocket.query_string.decode('utf-8', errors='ignore'),
|
||||||
|
'remote_addr': getattr(quart.websocket, 'remote_addr', None),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await quart.websocket.send(json.dumps({'type': 'error', 'message': str(e)}))
|
||||||
|
except Exception as send_error:
|
||||||
|
logger.debug(
|
||||||
|
'Failed to send error message to workflow websocket client',
|
||||||
|
exc_info=True,
|
||||||
|
extra={
|
||||||
|
'workflow_uuid': workflow_uuid,
|
||||||
|
'send_error': str(send_error),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@self.route('/messages/<session_type>', methods=['GET'])
|
||||||
|
async def get_messages(workflow_uuid: str, session_type: str) -> str:
|
||||||
|
"""获取工作流消息历史"""
|
||||||
|
try:
|
||||||
|
if session_type not in ['person', 'group']:
|
||||||
|
return self.http_status(400, -1, 'session_type must be person or group')
|
||||||
|
|
||||||
|
websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter
|
||||||
|
|
||||||
|
if not websocket_adapter:
|
||||||
|
return self.http_status(404, -1, 'WebSocket adapter not found')
|
||||||
|
|
||||||
|
messages = websocket_adapter.get_websocket_messages(workflow_uuid, session_type)
|
||||||
|
|
||||||
|
return self.success(data={'messages': messages})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return self.http_status(500, -1, f'Internal server error: {str(e)}')
|
||||||
|
|
||||||
|
@self.route('/reset/<session_type>', methods=['POST'])
|
||||||
|
async def reset_session(workflow_uuid: str, session_type: str) -> str:
|
||||||
|
"""重置工作流会话"""
|
||||||
|
try:
|
||||||
|
if session_type not in ['person', 'group']:
|
||||||
|
return self.http_status(400, -1, 'session_type must be person or group')
|
||||||
|
|
||||||
|
websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter
|
||||||
|
|
||||||
|
if not websocket_adapter:
|
||||||
|
return self.http_status(404, -1, 'WebSocket adapter not found')
|
||||||
|
|
||||||
|
websocket_adapter.reset_session(workflow_uuid, session_type)
|
||||||
|
|
||||||
|
return self.success(data={'message': 'Session reset successfully'})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return self.http_status(500, -1, f'Internal server error: {str(e)}')
|
||||||
|
|
||||||
|
@self.route('/connections', methods=['GET'])
|
||||||
|
async def get_connections(workflow_uuid: str) -> str:
|
||||||
|
"""获取当前工作流连接统计"""
|
||||||
|
try:
|
||||||
|
stats = ws_connection_manager.get_stats()
|
||||||
|
connections = await ws_connection_manager.get_connections_by_pipeline(workflow_uuid)
|
||||||
|
|
||||||
|
return self.success(
|
||||||
|
data={
|
||||||
|
'stats': stats,
|
||||||
|
'connections': [
|
||||||
|
{
|
||||||
|
'connection_id': conn.connection_id,
|
||||||
|
'session_type': conn.session_type,
|
||||||
|
'created_at': conn.created_at.isoformat(),
|
||||||
|
'last_active': conn.last_active.isoformat(),
|
||||||
|
'is_active': conn.is_active,
|
||||||
|
}
|
||||||
|
for conn in connections
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return self.http_status(500, -1, f'Internal server error: {str(e)}')
|
||||||
|
|
||||||
|
@self.route('/broadcast', methods=['POST'])
|
||||||
|
async def broadcast_message(workflow_uuid: str) -> str:
|
||||||
|
"""向所有工作流连接广播消息"""
|
||||||
|
try:
|
||||||
|
data = await quart.request.get_json()
|
||||||
|
message = data.get('message')
|
||||||
|
|
||||||
|
if not message:
|
||||||
|
return self.http_status(400, -1, 'message is required')
|
||||||
|
|
||||||
|
broadcast_data = {
|
||||||
|
'type': 'broadcast',
|
||||||
|
'message': message,
|
||||||
|
'timestamp': datetime.datetime.now().isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
await ws_connection_manager.broadcast_to_pipeline(workflow_uuid, broadcast_data)
|
||||||
|
|
||||||
|
return self.success(data={'message': 'Broadcast sent successfully'})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return self.http_status(500, -1, f'Internal server error: {str(e)}')
|
||||||
|
|
||||||
|
async def _handle_receive(self, connection, websocket_adapter):
|
||||||
|
"""处理接收消息的任务"""
|
||||||
|
try:
|
||||||
|
while connection.is_active:
|
||||||
|
message = await quart.websocket.receive()
|
||||||
|
|
||||||
|
await ws_connection_manager.update_activity(connection.connection_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(message)
|
||||||
|
message_type = data.get('type', 'message')
|
||||||
|
|
||||||
|
if message_type == 'ping':
|
||||||
|
await connection.send_queue.put(
|
||||||
|
{'type': 'pong', 'timestamp': datetime.datetime.now().isoformat()}
|
||||||
|
)
|
||||||
|
|
||||||
|
elif message_type == 'message':
|
||||||
|
logger.debug(f'收到工作流消息: {data} from {connection.connection_id}')
|
||||||
|
await websocket_adapter.handle_websocket_message(connection, data)
|
||||||
|
|
||||||
|
elif message_type == 'disconnect':
|
||||||
|
logger.debug(f'Client disconnected: {connection.connection_id}')
|
||||||
|
break
|
||||||
|
|
||||||
|
else:
|
||||||
|
logger.warning(f'Unknown message type: {message_type}')
|
||||||
|
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
logger.error(f'Invalid JSON message: {message}')
|
||||||
|
await connection.send_queue.put({'type': 'error', 'message': 'Invalid JSON format'})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'Receive message error: {e}', exc_info=True)
|
||||||
|
finally:
|
||||||
|
connection.is_active = False
|
||||||
|
|
||||||
|
async def _handle_send(self, connection):
|
||||||
|
"""处理发送消息的任务"""
|
||||||
|
try:
|
||||||
|
while connection.is_active:
|
||||||
|
try:
|
||||||
|
message = await asyncio.wait_for(connection.send_queue.get(), timeout=1.0)
|
||||||
|
await quart.websocket.send(json.dumps(message))
|
||||||
|
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'Send message error: {e}', exc_info=True)
|
||||||
|
finally:
|
||||||
|
connection.is_active = False
|
||||||
@@ -0,0 +1,484 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import quart
|
||||||
|
|
||||||
|
from ... import group
|
||||||
|
from ....service.workflow import WorkflowExecutionFailedError
|
||||||
|
|
||||||
|
|
||||||
|
@group.group_class('workflows', '/api/v1/workflows')
|
||||||
|
class WorkflowsRouterGroup(group.RouterGroup):
|
||||||
|
"""Workflow API router group"""
|
||||||
|
|
||||||
|
async def initialize(self) -> None:
|
||||||
|
# Workflow CRUD
|
||||||
|
@self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||||
|
async def _() -> str:
|
||||||
|
if quart.request.method == 'GET':
|
||||||
|
sort_by = quart.request.args.get('sort_by', 'created_at')
|
||||||
|
sort_order = quart.request.args.get('sort_order', 'DESC')
|
||||||
|
enabled_only = quart.request.args.get('enabled_only', 'false').lower() == 'true'
|
||||||
|
return self.success(
|
||||||
|
data={'workflows': await self.ap.workflow_service.get_workflows(sort_by, sort_order, enabled_only)}
|
||||||
|
)
|
||||||
|
elif quart.request.method == 'POST':
|
||||||
|
json_data = await quart.request.json
|
||||||
|
workflow_uuid = await self.ap.workflow_service.create_workflow(json_data)
|
||||||
|
return self.success(data={'uuid': workflow_uuid})
|
||||||
|
|
||||||
|
# Get node types (available nodes for the editor)
|
||||||
|
@self.route('/_/node-types', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||||
|
async def _() -> str:
|
||||||
|
return self.success(
|
||||||
|
data={
|
||||||
|
'node_types': await self.ap.workflow_service.get_node_types(),
|
||||||
|
'categories': await self.ap.workflow_service.get_node_types_by_category_meta(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get node types by category
|
||||||
|
@self.route('/_/node-types/categories', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||||
|
async def _() -> str:
|
||||||
|
return self.success(data={'categories': await self.ap.workflow_service.get_node_types_by_category()})
|
||||||
|
|
||||||
|
# Single workflow operations
|
||||||
|
@self.route(
|
||||||
|
'/<workflow_uuid>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY
|
||||||
|
)
|
||||||
|
async def _(workflow_uuid: str) -> str:
|
||||||
|
if quart.request.method == 'GET':
|
||||||
|
workflow = await self.ap.workflow_service.get_workflow(workflow_uuid)
|
||||||
|
if workflow is None:
|
||||||
|
return self.http_status(404, -1, 'workflow not found')
|
||||||
|
return self.success(data={'workflow': workflow})
|
||||||
|
elif quart.request.method == 'PUT':
|
||||||
|
json_data = await quart.request.json
|
||||||
|
try:
|
||||||
|
await self.ap.workflow_service.update_workflow(workflow_uuid, json_data)
|
||||||
|
return self.success()
|
||||||
|
except ValueError as e:
|
||||||
|
return self.http_status(404, -1, str(e))
|
||||||
|
elif quart.request.method == 'DELETE':
|
||||||
|
await self.ap.workflow_service.delete_workflow(workflow_uuid)
|
||||||
|
return self.success()
|
||||||
|
return self.http_status(405, -1, 'method not allowed')
|
||||||
|
|
||||||
|
# Publish workflow (enable)
|
||||||
|
@self.route('/<workflow_uuid>/publish', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||||
|
async def _(workflow_uuid: str) -> str:
|
||||||
|
try:
|
||||||
|
await self.ap.workflow_service.publish_workflow(workflow_uuid)
|
||||||
|
return self.success()
|
||||||
|
except ValueError as e:
|
||||||
|
return self.http_status(404, -1, str(e))
|
||||||
|
|
||||||
|
# Unpublish workflow (disable)
|
||||||
|
@self.route('/<workflow_uuid>/unpublish', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||||
|
async def _(workflow_uuid: str) -> str:
|
||||||
|
try:
|
||||||
|
await self.ap.workflow_service.unpublish_workflow(workflow_uuid)
|
||||||
|
return self.success()
|
||||||
|
except ValueError as e:
|
||||||
|
return self.http_status(404, -1, str(e))
|
||||||
|
|
||||||
|
# Copy workflow
|
||||||
|
@self.route('/<workflow_uuid>/copy', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||||
|
async def _(workflow_uuid: str) -> str:
|
||||||
|
try:
|
||||||
|
new_uuid = await self.ap.workflow_service.copy_workflow(workflow_uuid)
|
||||||
|
return self.success(data={'uuid': new_uuid})
|
||||||
|
except ValueError as e:
|
||||||
|
return self.http_status(404, -1, str(e))
|
||||||
|
|
||||||
|
# Execute workflow manually
|
||||||
|
@self.route('/<workflow_uuid>/execute', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||||
|
async def _(workflow_uuid: str) -> str:
|
||||||
|
json_data = await quart.request.json or {}
|
||||||
|
trigger_data = json_data.get('trigger_data', {})
|
||||||
|
session_id = json_data.get('session_id')
|
||||||
|
user_id = json_data.get('user_id')
|
||||||
|
bot_id = json_data.get('bot_id')
|
||||||
|
|
||||||
|
try:
|
||||||
|
execution_id = await self.ap.workflow_service.execute_workflow(
|
||||||
|
workflow_uuid,
|
||||||
|
trigger_type='manual',
|
||||||
|
trigger_data=trigger_data,
|
||||||
|
session_id=session_id,
|
||||||
|
user_id=user_id,
|
||||||
|
bot_id=bot_id,
|
||||||
|
)
|
||||||
|
return self.success(data={'execution_id': execution_id})
|
||||||
|
except ValueError as e:
|
||||||
|
return self.http_status(404, -1, str(e))
|
||||||
|
except WorkflowExecutionFailedError as e:
|
||||||
|
return self.http_status(500, -1, e.message)
|
||||||
|
|
||||||
|
# Get workflow executions
|
||||||
|
@self.route('/<workflow_uuid>/executions', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||||
|
async def _(workflow_uuid: str) -> str:
|
||||||
|
limit = int(quart.request.args.get('limit', 50))
|
||||||
|
offset = int(quart.request.args.get('offset', 0))
|
||||||
|
executions = await self.ap.workflow_service.get_executions(
|
||||||
|
workflow_uuid=workflow_uuid, limit=limit, offset=offset
|
||||||
|
)
|
||||||
|
return self.success(data=executions)
|
||||||
|
|
||||||
|
@self.route(
|
||||||
|
'/<workflow_uuid>/executions/<execution_uuid>',
|
||||||
|
methods=['GET'],
|
||||||
|
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||||
|
)
|
||||||
|
async def _(workflow_uuid: str, execution_uuid: str) -> str:
|
||||||
|
execution = await self.ap.workflow_service.get_execution(execution_uuid)
|
||||||
|
if execution is None:
|
||||||
|
return self.http_status(404, -1, 'execution not found')
|
||||||
|
if execution.get('workflow_uuid') != workflow_uuid:
|
||||||
|
return self.http_status(404, -1, 'execution not found in workflow')
|
||||||
|
return self.success(data={'execution': execution})
|
||||||
|
|
||||||
|
# Get workflow versions
|
||||||
|
@self.route('/<workflow_uuid>/versions', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||||
|
async def _(workflow_uuid: str) -> str:
|
||||||
|
versions = await self.ap.workflow_service.get_versions(workflow_uuid)
|
||||||
|
return self.success(data={'versions': versions})
|
||||||
|
|
||||||
|
# Rollback to a specific version
|
||||||
|
@self.route(
|
||||||
|
'/<workflow_uuid>/rollback/<int:version>', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY
|
||||||
|
)
|
||||||
|
async def _(workflow_uuid: str, version: int) -> str:
|
||||||
|
try:
|
||||||
|
await self.ap.workflow_service.rollback_to_version(workflow_uuid, version)
|
||||||
|
return self.success()
|
||||||
|
except ValueError as e:
|
||||||
|
return self.http_status(404, -1, str(e))
|
||||||
|
|
||||||
|
# Workflow extensions (plugins and MCP servers)
|
||||||
|
@self.route(
|
||||||
|
'/<workflow_uuid>/extensions', methods=['GET', 'PUT'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY
|
||||||
|
)
|
||||||
|
async def _(workflow_uuid: str) -> str:
|
||||||
|
if quart.request.method == 'GET':
|
||||||
|
workflow = await self.ap.workflow_service.get_workflow(workflow_uuid)
|
||||||
|
if workflow is None:
|
||||||
|
return self.http_status(404, -1, 'workflow not found')
|
||||||
|
|
||||||
|
# Get available plugins and MCP servers
|
||||||
|
pipeline_component_kinds = ['Command', 'EventListener', 'Tool']
|
||||||
|
plugins = await self.ap.plugin_connector.list_plugins(component_kinds=pipeline_component_kinds)
|
||||||
|
mcp_servers = await self.ap.mcp_service.get_mcp_servers(contain_runtime_info=True)
|
||||||
|
|
||||||
|
extensions_prefs = workflow.get('extensions_preferences', {})
|
||||||
|
return self.success(
|
||||||
|
data={
|
||||||
|
'enable_all_plugins': extensions_prefs.get('enable_all_plugins', True),
|
||||||
|
'enable_all_mcp_servers': extensions_prefs.get('enable_all_mcp_servers', True),
|
||||||
|
'bound_plugins': extensions_prefs.get('plugins', []),
|
||||||
|
'available_plugins': plugins,
|
||||||
|
'bound_mcp_servers': extensions_prefs.get('mcp_servers', []),
|
||||||
|
'available_mcp_servers': mcp_servers,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
elif quart.request.method == 'PUT':
|
||||||
|
json_data = await quart.request.json
|
||||||
|
enable_all_plugins = json_data.get('enable_all_plugins', True)
|
||||||
|
enable_all_mcp_servers = json_data.get('enable_all_mcp_servers', True)
|
||||||
|
bound_plugins = json_data.get('bound_plugins', [])
|
||||||
|
bound_mcp_servers = json_data.get('bound_mcp_servers', [])
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self.ap.workflow_service.update_workflow_extensions(
|
||||||
|
workflow_uuid, bound_plugins, bound_mcp_servers, enable_all_plugins, enable_all_mcp_servers
|
||||||
|
)
|
||||||
|
return self.success()
|
||||||
|
except ValueError as e:
|
||||||
|
return self.http_status(404, -1, str(e))
|
||||||
|
return self.http_status(405, -1, 'method not allowed')
|
||||||
|
|
||||||
|
# Debug API - Start debug execution
|
||||||
|
@self.route('/<workflow_uuid>/debug/start', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||||
|
async def _(workflow_uuid: str) -> str:
|
||||||
|
json_data = await quart.request.json or {}
|
||||||
|
context = json_data.get('context', {})
|
||||||
|
variables = json_data.get('variables', {})
|
||||||
|
breakpoints = json_data.get('breakpoints', [])
|
||||||
|
|
||||||
|
try:
|
||||||
|
execution_id = await self.ap.workflow_service.start_debug_execution(
|
||||||
|
workflow_uuid, context=context, variables=variables, breakpoints=breakpoints
|
||||||
|
)
|
||||||
|
return self.success(data={'execution_id': execution_id})
|
||||||
|
except ValueError as e:
|
||||||
|
return self.http_status(404, -1, str(e))
|
||||||
|
|
||||||
|
# Debug API - Pause execution
|
||||||
|
@self.route(
|
||||||
|
'/<workflow_uuid>/debug/<execution_uuid>/pause',
|
||||||
|
methods=['POST'],
|
||||||
|
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||||
|
)
|
||||||
|
async def _(workflow_uuid: str, execution_uuid: str) -> str:
|
||||||
|
try:
|
||||||
|
await self.ap.workflow_service.pause_debug_execution(workflow_uuid, execution_uuid)
|
||||||
|
return self.success()
|
||||||
|
except ValueError as e:
|
||||||
|
return self.http_status(404, -1, str(e))
|
||||||
|
|
||||||
|
# Debug API - Resume execution
|
||||||
|
@self.route(
|
||||||
|
'/<workflow_uuid>/debug/<execution_uuid>/resume',
|
||||||
|
methods=['POST'],
|
||||||
|
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||||
|
)
|
||||||
|
async def _(workflow_uuid: str, execution_uuid: str) -> str:
|
||||||
|
try:
|
||||||
|
await self.ap.workflow_service.resume_debug_execution(workflow_uuid, execution_uuid)
|
||||||
|
return self.success()
|
||||||
|
except ValueError as e:
|
||||||
|
return self.http_status(404, -1, str(e))
|
||||||
|
|
||||||
|
# Debug API - Step execution
|
||||||
|
@self.route(
|
||||||
|
'/<workflow_uuid>/debug/<execution_uuid>/step',
|
||||||
|
methods=['POST'],
|
||||||
|
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||||
|
)
|
||||||
|
async def _(workflow_uuid: str, execution_uuid: str) -> str:
|
||||||
|
try:
|
||||||
|
result = await self.ap.workflow_service.step_debug_execution(workflow_uuid, execution_uuid)
|
||||||
|
return self.success(data=result)
|
||||||
|
except ValueError as e:
|
||||||
|
return self.http_status(404, -1, str(e))
|
||||||
|
|
||||||
|
# Debug API - Stop execution
|
||||||
|
@self.route(
|
||||||
|
'/<workflow_uuid>/debug/<execution_uuid>/stop',
|
||||||
|
methods=['POST'],
|
||||||
|
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||||
|
)
|
||||||
|
async def _(workflow_uuid: str, execution_uuid: str) -> str:
|
||||||
|
try:
|
||||||
|
await self.ap.workflow_service.stop_debug_execution(workflow_uuid, execution_uuid)
|
||||||
|
return self.success()
|
||||||
|
except ValueError as e:
|
||||||
|
return self.http_status(404, -1, str(e))
|
||||||
|
|
||||||
|
# Debug API - Get debug state
|
||||||
|
@self.route(
|
||||||
|
'/<workflow_uuid>/debug/<execution_uuid>/state',
|
||||||
|
methods=['GET'],
|
||||||
|
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||||
|
)
|
||||||
|
async def _(workflow_uuid: str, execution_uuid: str) -> str:
|
||||||
|
try:
|
||||||
|
state = await self.ap.workflow_service.get_debug_state(workflow_uuid, execution_uuid)
|
||||||
|
return self.success(data=state)
|
||||||
|
except ValueError as e:
|
||||||
|
return self.http_status(404, -1, str(e))
|
||||||
|
|
||||||
|
# Get execution logs
|
||||||
|
@self.route(
|
||||||
|
'/<workflow_uuid>/executions/<execution_uuid>/logs',
|
||||||
|
methods=['GET'],
|
||||||
|
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||||
|
)
|
||||||
|
async def _(workflow_uuid: str, execution_uuid: str) -> str:
|
||||||
|
limit = int(quart.request.args.get('limit', 100))
|
||||||
|
offset = int(quart.request.args.get('offset', 0))
|
||||||
|
try:
|
||||||
|
result = await self.ap.workflow_service.get_execution_logs(workflow_uuid, execution_uuid, limit, offset)
|
||||||
|
return self.success(data=result)
|
||||||
|
except ValueError as e:
|
||||||
|
return self.http_status(404, -1, str(e))
|
||||||
|
|
||||||
|
# Rerun execution
|
||||||
|
@self.route(
|
||||||
|
'/<workflow_uuid>/executions/<execution_uuid>/rerun',
|
||||||
|
methods=['POST'],
|
||||||
|
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||||
|
)
|
||||||
|
async def _(workflow_uuid: str, execution_uuid: str) -> str:
|
||||||
|
try:
|
||||||
|
new_execution_id = await self.ap.workflow_service.rerun_execution(workflow_uuid, execution_uuid)
|
||||||
|
return self.success(data={'execution_uuid': new_execution_id})
|
||||||
|
except ValueError as e:
|
||||||
|
return self.http_status(404, -1, str(e))
|
||||||
|
|
||||||
|
# Get workflow statistics
|
||||||
|
@self.route('/<workflow_uuid>/stats', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||||
|
async def _(workflow_uuid: str) -> str:
|
||||||
|
try:
|
||||||
|
stats = await self.ap.workflow_service.get_workflow_stats(workflow_uuid)
|
||||||
|
return self.success(data=stats)
|
||||||
|
except ValueError as e:
|
||||||
|
return self.http_status(404, -1, str(e))
|
||||||
|
|
||||||
|
# LLM Node Performance Test Endpoint
|
||||||
|
# Tests each step of LLM node execution with detailed timing
|
||||||
|
@self.route('/_/test/llm-node', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||||
|
async def _() -> str:
|
||||||
|
"""Test LLM node performance with detailed step-by-step timing.
|
||||||
|
|
||||||
|
Request body:
|
||||||
|
{
|
||||||
|
"model_uuid": "uuid-of-model",
|
||||||
|
"system_prompt": "optional system prompt",
|
||||||
|
"user_prompt": "test message",
|
||||||
|
"temperature": 0.7,
|
||||||
|
"max_tokens": 100
|
||||||
|
}
|
||||||
|
|
||||||
|
Response includes timing for each step:
|
||||||
|
- model_fetch: Time to get model from model_mgr
|
||||||
|
- prompt_build: Time to build messages
|
||||||
|
- llm_call: Time for actual LLM invocation
|
||||||
|
- total: Total time
|
||||||
|
- usage: Token usage information
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
|
||||||
|
json_data = await quart.request.json
|
||||||
|
if not json_data:
|
||||||
|
return self.http_status(400, -1, 'Request body is required')
|
||||||
|
|
||||||
|
model_uuid = json_data.get('model_uuid', '')
|
||||||
|
if not model_uuid:
|
||||||
|
return self.http_status(400, -1, 'model_uuid is required')
|
||||||
|
|
||||||
|
user_prompt = json_data.get('user_prompt', 'test')
|
||||||
|
system_prompt = json_data.get('system_prompt', '')
|
||||||
|
temperature = json_data.get('temperature')
|
||||||
|
max_tokens = json_data.get('max_tokens', 0)
|
||||||
|
|
||||||
|
timings = {}
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
# Step 1: Model fetch
|
||||||
|
t_start = time.perf_counter()
|
||||||
|
try:
|
||||||
|
runtime_model = await self.ap.model_mgr.get_model_by_uuid(model_uuid)
|
||||||
|
timings['model_fetch_ms'] = round((time.perf_counter() - t_start) * 1000, 2)
|
||||||
|
timings['model_found'] = True
|
||||||
|
timings['model_name'] = runtime_model.model_entity.name if runtime_model else None
|
||||||
|
except Exception as e:
|
||||||
|
timings['model_fetch_ms'] = round((time.perf_counter() - t_start) * 1000, 2)
|
||||||
|
timings['model_found'] = False
|
||||||
|
errors.append(f'Model fetch failed: {str(e)}')
|
||||||
|
return self.http_status(400, -1, {
|
||||||
|
'error': errors[0],
|
||||||
|
'timings': timings,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Step 2: Build messages
|
||||||
|
t_start = time.perf_counter()
|
||||||
|
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||||
|
messages = []
|
||||||
|
if system_prompt:
|
||||||
|
messages.append(provider_message.Message(role='system', content=system_prompt))
|
||||||
|
messages.append(provider_message.Message(role='user', content=user_prompt))
|
||||||
|
timings['prompt_build_ms'] = round((time.perf_counter() - t_start) * 1000, 2)
|
||||||
|
|
||||||
|
# Step 3: Build extra args
|
||||||
|
extra_args = {}
|
||||||
|
if temperature is not None:
|
||||||
|
extra_args['temperature'] = float(temperature)
|
||||||
|
if max_tokens and int(max_tokens) > 0:
|
||||||
|
extra_args['max_tokens'] = int(max_tokens)
|
||||||
|
|
||||||
|
# Step 4: LLM call
|
||||||
|
t_start = time.perf_counter()
|
||||||
|
try:
|
||||||
|
result_message = await runtime_model.provider.invoke_llm(
|
||||||
|
query=None,
|
||||||
|
model=runtime_model,
|
||||||
|
messages=messages,
|
||||||
|
funcs=None,
|
||||||
|
extra_args=extra_args,
|
||||||
|
)
|
||||||
|
timings['llm_call_ms'] = round((time.perf_counter() - t_start) * 1000, 2)
|
||||||
|
timings['llm_call_success'] = True
|
||||||
|
|
||||||
|
# Extract response text
|
||||||
|
response_text = ''
|
||||||
|
if isinstance(result_message.content, str):
|
||||||
|
response_text = result_message.content
|
||||||
|
elif isinstance(result_message.content, list):
|
||||||
|
for elem in result_message.content:
|
||||||
|
if hasattr(elem, 'text') and elem.text:
|
||||||
|
response_text += elem.text
|
||||||
|
elif isinstance(elem, str):
|
||||||
|
response_text += elem
|
||||||
|
|
||||||
|
timings['response_length'] = len(response_text)
|
||||||
|
timings['response_preview'] = response_text[:200]
|
||||||
|
|
||||||
|
# Extract usage
|
||||||
|
usage = {'prompt_tokens': 0, 'completion_tokens': 0, 'total_tokens': 0}
|
||||||
|
if hasattr(result_message, 'usage') and result_message.usage:
|
||||||
|
u = result_message.usage
|
||||||
|
usage = {
|
||||||
|
'prompt_tokens': getattr(u, 'prompt_tokens', 0) or 0,
|
||||||
|
'completion_tokens': getattr(u, 'completion_tokens', 0) or 0,
|
||||||
|
'total_tokens': getattr(u, 'total_tokens', 0) or 0,
|
||||||
|
}
|
||||||
|
timings['usage'] = usage
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
timings['llm_call_ms'] = round((time.perf_counter() - t_start) * 1000, 2)
|
||||||
|
timings['llm_call_success'] = False
|
||||||
|
errors.append(f'LLM call failed: {str(e)}')
|
||||||
|
|
||||||
|
# Calculate total
|
||||||
|
timings['total_ms'] = round(sum([
|
||||||
|
timings.get('model_fetch_ms', 0),
|
||||||
|
timings.get('prompt_build_ms', 0),
|
||||||
|
timings.get('llm_call_ms', 0),
|
||||||
|
]), 2)
|
||||||
|
|
||||||
|
# Add breakdown percentage
|
||||||
|
if timings['total_ms'] > 0:
|
||||||
|
timings['breakdown'] = {
|
||||||
|
'model_fetch_pct': round(timings.get('model_fetch_ms', 0) / timings['total_ms'] * 100, 1),
|
||||||
|
'prompt_build_pct': round(timings.get('prompt_build_ms', 0) / timings['total_ms'] * 100, 1),
|
||||||
|
'llm_call_pct': round(timings.get('llm_call_ms', 0) / timings['total_ms'] * 100, 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
timings['errors'] = errors
|
||||||
|
|
||||||
|
return self.success(data={'test_result': timings})
|
||||||
|
|
||||||
|
|
||||||
|
@group.group_class('executions', '/api/v1/executions')
|
||||||
|
class ExecutionsRouterGroup(group.RouterGroup):
|
||||||
|
"""Workflow execution API router group"""
|
||||||
|
|
||||||
|
async def initialize(self) -> None:
|
||||||
|
# Get all executions (across all workflows)
|
||||||
|
@self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||||
|
async def _() -> str:
|
||||||
|
limit = int(quart.request.args.get('limit', 50))
|
||||||
|
offset = int(quart.request.args.get('offset', 0))
|
||||||
|
status = quart.request.args.get('status')
|
||||||
|
executions = await self.ap.workflow_service.get_executions(limit=limit, offset=offset, status=status)
|
||||||
|
return self.success(data=executions)
|
||||||
|
|
||||||
|
# Get single execution
|
||||||
|
@self.route('/<execution_uuid>', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||||
|
async def _(execution_uuid: str) -> str:
|
||||||
|
execution = await self.ap.workflow_service.get_execution(execution_uuid)
|
||||||
|
if execution is None:
|
||||||
|
return self.http_status(404, -1, 'execution not found')
|
||||||
|
return self.success(data={'execution': execution})
|
||||||
|
|
||||||
|
# Cancel execution
|
||||||
|
@self.route('/<execution_uuid>/cancel', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||||
|
async def _(execution_uuid: str) -> str:
|
||||||
|
try:
|
||||||
|
await self.ap.workflow_service.cancel_execution(execution_uuid)
|
||||||
|
return self.success()
|
||||||
|
except ValueError as e:
|
||||||
|
return self.http_status(404, -1, str(e))
|
||||||
|
except RuntimeError as e:
|
||||||
|
return self.http_status(400, -1, str(e))
|
||||||
@@ -17,6 +17,7 @@ from .groups import platform as groups_platform
|
|||||||
from .groups import pipelines as groups_pipelines
|
from .groups import pipelines as groups_pipelines
|
||||||
from .groups import knowledge as groups_knowledge
|
from .groups import knowledge as groups_knowledge
|
||||||
from .groups import resources as groups_resources
|
from .groups import resources as groups_resources
|
||||||
|
from .groups import workflows as groups_workflows
|
||||||
from ...mcp.mount import MCPMount
|
from ...mcp.mount import MCPMount
|
||||||
|
|
||||||
importutil.import_modules_in_pkg(groups)
|
importutil.import_modules_in_pkg(groups)
|
||||||
@@ -25,6 +26,7 @@ importutil.import_modules_in_pkg(groups_platform)
|
|||||||
importutil.import_modules_in_pkg(groups_pipelines)
|
importutil.import_modules_in_pkg(groups_pipelines)
|
||||||
importutil.import_modules_in_pkg(groups_knowledge)
|
importutil.import_modules_in_pkg(groups_knowledge)
|
||||||
importutil.import_modules_in_pkg(groups_resources)
|
importutil.import_modules_in_pkg(groups_resources)
|
||||||
|
importutil.import_modules_in_pkg(groups_workflows)
|
||||||
|
|
||||||
|
|
||||||
class HTTPController:
|
class HTTPController:
|
||||||
|
|||||||
@@ -99,16 +99,23 @@ class BotService:
|
|||||||
# TODO: 检查配置信息格式
|
# TODO: 检查配置信息格式
|
||||||
bot_data['uuid'] = str(uuid.uuid4())
|
bot_data['uuid'] = str(uuid.uuid4())
|
||||||
|
|
||||||
# bind the most recently updated pipeline if any exist
|
# Set default binding_type if not provided
|
||||||
|
if 'binding_type' not in bot_data:
|
||||||
|
bot_data['binding_type'] = 'pipeline'
|
||||||
|
|
||||||
|
# checkout the default pipeline (for backward compatibility)
|
||||||
result = await self.ap.persistence_mgr.execute_async(
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
sqlalchemy.select(persistence_pipeline.LegacyPipeline)
|
sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
|
||||||
.order_by(persistence_pipeline.LegacyPipeline.updated_at.desc())
|
persistence_pipeline.LegacyPipeline.is_default == True
|
||||||
.limit(1)
|
)
|
||||||
)
|
)
|
||||||
pipeline = result.first()
|
pipeline = result.first()
|
||||||
if pipeline is not None:
|
if pipeline is not None:
|
||||||
bot_data['use_pipeline_uuid'] = pipeline.uuid
|
bot_data['use_pipeline_uuid'] = pipeline.uuid
|
||||||
bot_data['use_pipeline_name'] = pipeline.name
|
bot_data['use_pipeline_name'] = pipeline.name
|
||||||
|
# Also set binding_uuid for new unified binding model
|
||||||
|
if 'binding_uuid' not in bot_data:
|
||||||
|
bot_data['binding_uuid'] = pipeline.uuid
|
||||||
|
|
||||||
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_bot.Bot).values(bot_data))
|
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_bot.Bot).values(bot_data))
|
||||||
|
|
||||||
@@ -120,26 +127,45 @@ class BotService:
|
|||||||
|
|
||||||
async def update_bot(self, bot_uuid: str, bot_data: dict) -> None:
|
async def update_bot(self, bot_uuid: str, bot_data: dict) -> None:
|
||||||
"""Update bot"""
|
"""Update bot"""
|
||||||
update_data = bot_data.copy()
|
if 'uuid' in bot_data:
|
||||||
|
del bot_data['uuid']
|
||||||
|
|
||||||
if 'uuid' in update_data:
|
# Handle binding_type and binding_uuid for the new unified binding model
|
||||||
del update_data['uuid']
|
# If binding_type is explicitly set to 'workflow', skip pipeline validation
|
||||||
|
binding_type = bot_data.get('binding_type')
|
||||||
|
|
||||||
# set use_pipeline_name
|
# set use_pipeline_name (for backward compatibility with 'pipeline' binding_type)
|
||||||
if 'use_pipeline_uuid' in update_data:
|
# Only validate pipeline when binding_type is 'pipeline' or not set (default to pipeline)
|
||||||
|
if 'use_pipeline_uuid' in bot_data and binding_type != 'workflow':
|
||||||
result = await self.ap.persistence_mgr.execute_async(
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
|
sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
|
||||||
persistence_pipeline.LegacyPipeline.uuid == update_data['use_pipeline_uuid']
|
persistence_pipeline.LegacyPipeline.uuid == bot_data['use_pipeline_uuid']
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
pipeline = result.first()
|
pipeline = result.first()
|
||||||
if pipeline is not None:
|
if pipeline is not None:
|
||||||
update_data['use_pipeline_name'] = pipeline.name
|
bot_data['use_pipeline_name'] = pipeline.name
|
||||||
|
# Also sync to binding_uuid if binding_type is 'pipeline' or not set
|
||||||
|
if binding_type is None or binding_type == 'pipeline':
|
||||||
|
bot_data['binding_uuid'] = bot_data['use_pipeline_uuid']
|
||||||
|
bot_data['binding_type'] = 'pipeline'
|
||||||
else:
|
else:
|
||||||
raise Exception('Pipeline not found')
|
# Only raise error if binding_type is explicitly 'pipeline' or not set
|
||||||
|
if binding_type is None or binding_type == 'pipeline':
|
||||||
|
raise Exception('Pipeline not found')
|
||||||
|
# If binding_type is 'workflow', just clear the use_pipeline_uuid
|
||||||
|
bot_data['use_pipeline_uuid'] = None
|
||||||
|
bot_data['use_pipeline_name'] = None
|
||||||
|
|
||||||
|
# If binding_uuid is set directly (for workflow), clear pipeline fields
|
||||||
|
if 'binding_uuid' in bot_data and binding_type == 'workflow':
|
||||||
|
# For workflow binding, clear pipeline-related fields to avoid confusion
|
||||||
|
bot_data['binding_type'] = 'workflow'
|
||||||
|
bot_data['use_pipeline_uuid'] = None
|
||||||
|
bot_data['use_pipeline_name'] = None
|
||||||
|
|
||||||
await self.ap.persistence_mgr.execute_async(
|
await self.ap.persistence_mgr.execute_async(
|
||||||
sqlalchemy.update(persistence_bot.Bot).values(update_data).where(persistence_bot.Bot.uuid == bot_uuid)
|
sqlalchemy.update(persistence_bot.Bot).values(bot_data).where(persistence_bot.Bot.uuid == bot_uuid)
|
||||||
)
|
)
|
||||||
await self.ap.platform_mgr.remove_bot(bot_uuid)
|
await self.ap.platform_mgr.remove_bot(bot_uuid)
|
||||||
|
|
||||||
@@ -199,3 +225,35 @@ class BotService:
|
|||||||
|
|
||||||
# Send message via adapter
|
# Send message via adapter
|
||||||
await runtime_bot.adapter.send_message(target_type, str(target_id), message_chain)
|
await runtime_bot.adapter.send_message(target_type, str(target_id), message_chain)
|
||||||
|
|
||||||
|
# ============ Bot Admins ============
|
||||||
|
|
||||||
|
async def get_bot_admins(self, bot_uuid: str) -> list[dict]:
|
||||||
|
from ....entity.persistence import bot as persistence_bot
|
||||||
|
|
||||||
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.select(persistence_bot.BotAdmin).where(persistence_bot.BotAdmin.bot_uuid == bot_uuid)
|
||||||
|
)
|
||||||
|
return [{'id': r.id, 'launcher_type': r.launcher_type, 'launcher_id': r.launcher_id} for r in result.all()]
|
||||||
|
|
||||||
|
async def add_bot_admin(self, bot_uuid: str, launcher_type: str, launcher_id: str) -> int:
|
||||||
|
from ....entity.persistence import bot as persistence_bot
|
||||||
|
|
||||||
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.insert(persistence_bot.BotAdmin).values(
|
||||||
|
bot_uuid=bot_uuid,
|
||||||
|
launcher_type=launcher_type,
|
||||||
|
launcher_id=launcher_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result.inserted_primary_key[0]
|
||||||
|
|
||||||
|
async def delete_bot_admin(self, bot_uuid: str, admin_id: int) -> None:
|
||||||
|
from ....entity.persistence import bot as persistence_bot
|
||||||
|
|
||||||
|
await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.delete(persistence_bot.BotAdmin).where(
|
||||||
|
persistence_bot.BotAdmin.bot_uuid == bot_uuid,
|
||||||
|
persistence_bot.BotAdmin.id == admin_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|||||||
@@ -136,6 +136,32 @@ class MCPService:
|
|||||||
if server_name in self.ap.tool_mgr.mcp_tool_loader.sessions:
|
if server_name in self.ap.tool_mgr.mcp_tool_loader.sessions:
|
||||||
await self.ap.tool_mgr.mcp_tool_loader.remove_mcp_server(server_name)
|
await self.ap.tool_mgr.mcp_tool_loader.remove_mcp_server(server_name)
|
||||||
|
|
||||||
|
async def get_mcp_server_resources(self, server_name: str) -> list[dict]:
|
||||||
|
"""Get resources from a specific MCP server."""
|
||||||
|
return await self.ap.tool_mgr.mcp_tool_loader.get_resources(server_name)
|
||||||
|
|
||||||
|
async def get_mcp_server_resource_templates(self, server_name: str) -> list[dict]:
|
||||||
|
"""Get resource templates from a specific MCP server."""
|
||||||
|
return await self.ap.tool_mgr.mcp_tool_loader.get_resource_templates(server_name)
|
||||||
|
|
||||||
|
async def read_mcp_server_resource_envelope(
|
||||||
|
self,
|
||||||
|
server_name: str,
|
||||||
|
uri: str,
|
||||||
|
*,
|
||||||
|
max_bytes: int | None = None,
|
||||||
|
include_blob: bool = False,
|
||||||
|
) -> dict:
|
||||||
|
"""Read a resource from a specific MCP server with metadata."""
|
||||||
|
kwargs = {'include_blob': include_blob, 'source': 'ui_preview'}
|
||||||
|
if max_bytes is not None:
|
||||||
|
kwargs['max_bytes'] = max_bytes
|
||||||
|
return await self.ap.tool_mgr.mcp_tool_loader.read_resource_envelope(server_name, uri, **kwargs)
|
||||||
|
|
||||||
|
async def read_mcp_server_resource(self, server_name: str, uri: str) -> list[dict]:
|
||||||
|
"""Read a resource from a specific MCP server."""
|
||||||
|
return await self.ap.tool_mgr.mcp_tool_loader.read_resource(server_name, uri)
|
||||||
|
|
||||||
async def test_mcp_server(self, server_name: str, server_data: dict) -> int:
|
async def test_mcp_server(self, server_name: str, server_data: dict) -> int:
|
||||||
"""测试 MCP 服务器连接并返回任务 ID"""
|
"""测试 MCP 服务器连接并返回任务 ID"""
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,20 @@ class PipelineService:
|
|||||||
|
|
||||||
return self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline)
|
return self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline)
|
||||||
|
|
||||||
|
async def get_pipeline_by_name(self, pipeline_name: str) -> dict | None:
|
||||||
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
|
||||||
|
persistence_pipeline.LegacyPipeline.name == pipeline_name
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
pipeline = result.first()
|
||||||
|
|
||||||
|
if pipeline is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline)
|
||||||
|
|
||||||
async def create_pipeline(self, pipeline_data: dict, default: bool = False) -> str:
|
async def create_pipeline(self, pipeline_data: dict, default: bool = False) -> str:
|
||||||
from ....utils import paths as path_utils
|
from ....utils import paths as path_utils
|
||||||
|
|
||||||
@@ -100,6 +114,8 @@ class PipelineService:
|
|||||||
'enable_all_mcp_servers': True,
|
'enable_all_mcp_servers': True,
|
||||||
'plugins': [],
|
'plugins': [],
|
||||||
'mcp_servers': [],
|
'mcp_servers': [],
|
||||||
|
'mcp_resources': [],
|
||||||
|
'mcp_resource_agent_read_enabled': True,
|
||||||
}
|
}
|
||||||
|
|
||||||
await self.ap.persistence_mgr.execute_async(
|
await self.ap.persistence_mgr.execute_async(
|
||||||
@@ -193,6 +209,8 @@ class PipelineService:
|
|||||||
'enable_all_mcp_servers': True,
|
'enable_all_mcp_servers': True,
|
||||||
'plugins': [],
|
'plugins': [],
|
||||||
'mcp_servers': [],
|
'mcp_servers': [],
|
||||||
|
'mcp_resources': [],
|
||||||
|
'mcp_resource_agent_read_enabled': True,
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
@@ -217,6 +235,8 @@ class PipelineService:
|
|||||||
enable_all_mcp_servers: bool = True,
|
enable_all_mcp_servers: bool = True,
|
||||||
bound_skills: list[str] = None,
|
bound_skills: list[str] = None,
|
||||||
enable_all_skills: bool = True,
|
enable_all_skills: bool = True,
|
||||||
|
bound_mcp_resources: list[dict] = None,
|
||||||
|
mcp_resource_agent_read_enabled: bool | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Update the bound plugins and MCP servers for a pipeline"""
|
"""Update the bound plugins and MCP servers for a pipeline"""
|
||||||
# Get current pipeline
|
# Get current pipeline
|
||||||
@@ -236,10 +256,14 @@ class PipelineService:
|
|||||||
extensions_preferences['enable_all_mcp_servers'] = enable_all_mcp_servers
|
extensions_preferences['enable_all_mcp_servers'] = enable_all_mcp_servers
|
||||||
extensions_preferences['enable_all_skills'] = enable_all_skills
|
extensions_preferences['enable_all_skills'] = enable_all_skills
|
||||||
extensions_preferences['plugins'] = bound_plugins
|
extensions_preferences['plugins'] = bound_plugins
|
||||||
|
if mcp_resource_agent_read_enabled is not None:
|
||||||
|
extensions_preferences['mcp_resource_agent_read_enabled'] = mcp_resource_agent_read_enabled
|
||||||
if bound_mcp_servers is not None:
|
if bound_mcp_servers is not None:
|
||||||
extensions_preferences['mcp_servers'] = bound_mcp_servers
|
extensions_preferences['mcp_servers'] = bound_mcp_servers
|
||||||
if bound_skills is not None:
|
if bound_skills is not None:
|
||||||
extensions_preferences['skills'] = bound_skills
|
extensions_preferences['skills'] = bound_skills
|
||||||
|
if bound_mcp_resources is not None:
|
||||||
|
extensions_preferences['mcp_resources'] = bound_mcp_resources
|
||||||
|
|
||||||
await self.ap.persistence_mgr.execute_async(
|
await self.ap.persistence_mgr.execute_async(
|
||||||
sqlalchemy.update(persistence_pipeline.LegacyPipeline)
|
sqlalchemy.update(persistence_pipeline.LegacyPipeline)
|
||||||
|
|||||||
@@ -20,6 +20,15 @@ class UserService:
|
|||||||
def __init__(self, ap: app.Application) -> None:
|
def __init__(self, ap: app.Application) -> None:
|
||||||
self.ap = ap
|
self.ap = ap
|
||||||
self._create_user_lock = asyncio.Lock()
|
self._create_user_lock = asyncio.Lock()
|
||||||
|
self._password_hash_lock = asyncio.Semaphore(1)
|
||||||
|
|
||||||
|
async def _hash_password(self, password: str) -> str:
|
||||||
|
async with self._password_hash_lock:
|
||||||
|
return await asyncio.to_thread(argon2.PasswordHasher().hash, password)
|
||||||
|
|
||||||
|
async def _verify_password(self, hashed_password: str, password: str) -> None:
|
||||||
|
async with self._password_hash_lock:
|
||||||
|
await asyncio.to_thread(argon2.PasswordHasher().verify, hashed_password, password)
|
||||||
|
|
||||||
async def is_initialized(self) -> bool:
|
async def is_initialized(self) -> bool:
|
||||||
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(user.User).limit(1))
|
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(user.User).limit(1))
|
||||||
@@ -28,9 +37,7 @@ class UserService:
|
|||||||
return result_list is not None and len(result_list) > 0
|
return result_list is not None and len(result_list) > 0
|
||||||
|
|
||||||
async def create_user(self, user_email: str, password: str) -> None:
|
async def create_user(self, user_email: str, password: str) -> None:
|
||||||
ph = argon2.PasswordHasher()
|
hashed_password = await self._hash_password(password)
|
||||||
|
|
||||||
hashed_password = ph.hash(password)
|
|
||||||
|
|
||||||
await self.ap.persistence_mgr.execute_async(
|
await self.ap.persistence_mgr.execute_async(
|
||||||
sqlalchemy.insert(user.User).values(user=user_email, password=hashed_password, account_type='local')
|
sqlalchemy.insert(user.User).values(user=user_email, password=hashed_password, account_type='local')
|
||||||
@@ -69,9 +76,7 @@ class UserService:
|
|||||||
if not user_obj.password:
|
if not user_obj.password:
|
||||||
raise ValueError('请使用 Space 账户登录')
|
raise ValueError('请使用 Space 账户登录')
|
||||||
|
|
||||||
ph = argon2.PasswordHasher()
|
await self._verify_password(user_obj.password, password)
|
||||||
|
|
||||||
ph.verify(user_obj.password, password)
|
|
||||||
|
|
||||||
return await self.generate_jwt_token(user_email)
|
return await self.generate_jwt_token(user_email)
|
||||||
|
|
||||||
@@ -93,17 +98,13 @@ class UserService:
|
|||||||
return jwt.decode(token, jwt_secret, algorithms=['HS256'])['user']
|
return jwt.decode(token, jwt_secret, algorithms=['HS256'])['user']
|
||||||
|
|
||||||
async def reset_password(self, user_email: str, new_password: str) -> None:
|
async def reset_password(self, user_email: str, new_password: str) -> None:
|
||||||
ph = argon2.PasswordHasher()
|
hashed_password = await self._hash_password(new_password)
|
||||||
|
|
||||||
hashed_password = ph.hash(new_password)
|
|
||||||
|
|
||||||
await self.ap.persistence_mgr.execute_async(
|
await self.ap.persistence_mgr.execute_async(
|
||||||
sqlalchemy.update(user.User).where(user.User.user == user_email).values(password=hashed_password)
|
sqlalchemy.update(user.User).where(user.User.user == user_email).values(password=hashed_password)
|
||||||
)
|
)
|
||||||
|
|
||||||
async def change_password(self, user_email: str, current_password: str, new_password: str) -> None:
|
async def change_password(self, user_email: str, current_password: str, new_password: str) -> None:
|
||||||
ph = argon2.PasswordHasher()
|
|
||||||
|
|
||||||
user_obj = await self.get_user_by_email(user_email)
|
user_obj = await self.get_user_by_email(user_email)
|
||||||
if user_obj is None:
|
if user_obj is None:
|
||||||
raise ValueError('User not found')
|
raise ValueError('User not found')
|
||||||
@@ -111,9 +112,9 @@ class UserService:
|
|||||||
if not user_obj.password:
|
if not user_obj.password:
|
||||||
raise ValueError('No local password set, please set a password first')
|
raise ValueError('No local password set, please set a password first')
|
||||||
|
|
||||||
ph.verify(user_obj.password, current_password)
|
await self._verify_password(user_obj.password, current_password)
|
||||||
|
|
||||||
hashed_password = ph.hash(new_password)
|
hashed_password = await self._hash_password(new_password)
|
||||||
|
|
||||||
await self.ap.persistence_mgr.execute_async(
|
await self.ap.persistence_mgr.execute_async(
|
||||||
sqlalchemy.update(user.User).where(user.User.user == user_email).values(password=hashed_password)
|
sqlalchemy.update(user.User).where(user.User.user == user_email).values(password=hashed_password)
|
||||||
@@ -232,7 +233,6 @@ class UserService:
|
|||||||
|
|
||||||
async def set_password(self, user_email: str, new_password: str, current_password: str | None = None) -> None:
|
async def set_password(self, user_email: str, new_password: str, current_password: str | None = None) -> None:
|
||||||
"""Set or change password for a user"""
|
"""Set or change password for a user"""
|
||||||
ph = argon2.PasswordHasher()
|
|
||||||
user_obj = await self.get_user_by_email(user_email)
|
user_obj = await self.get_user_by_email(user_email)
|
||||||
|
|
||||||
if user_obj is None:
|
if user_obj is None:
|
||||||
@@ -243,9 +243,9 @@ class UserService:
|
|||||||
if has_password:
|
if has_password:
|
||||||
if not current_password:
|
if not current_password:
|
||||||
raise ValueError('Current password is required')
|
raise ValueError('Current password is required')
|
||||||
ph.verify(user_obj.password, current_password)
|
await self._verify_password(user_obj.password, current_password)
|
||||||
|
|
||||||
hashed_password = ph.hash(new_password)
|
hashed_password = await self._hash_password(new_password)
|
||||||
await self.ap.persistence_mgr.execute_async(
|
await self.ap.persistence_mgr.execute_async(
|
||||||
sqlalchemy.update(user.User).where(user.User.user == user_email).values(password=hashed_password)
|
sqlalchemy.update(user.User).where(user.User.user == user_email).values(password=hashed_password)
|
||||||
)
|
)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -82,7 +82,6 @@ class BoxService:
|
|||||||
return self._enabled
|
return self._enabled
|
||||||
|
|
||||||
async def initialize(self):
|
async def initialize(self):
|
||||||
self._ensure_default_workspace()
|
|
||||||
if not self._enabled:
|
if not self._enabled:
|
||||||
# Disabled by config: do NOT connect to a remote runtime, do NOT
|
# Disabled by config: do NOT connect to a remote runtime, do NOT
|
||||||
# fork a stdio subprocess. Every consumer of box_service should
|
# fork a stdio subprocess. Every consumer of box_service should
|
||||||
@@ -99,6 +98,7 @@ class BoxService:
|
|||||||
await self._runtime_connector.initialize()
|
await self._runtime_connector.initialize()
|
||||||
else:
|
else:
|
||||||
await self.client.initialize()
|
await self.client.initialize()
|
||||||
|
self._ensure_default_workspace()
|
||||||
self._available = True
|
self._available = True
|
||||||
self._connector_error = ''
|
self._connector_error = ''
|
||||||
self.ap.logger.info(
|
self.ap.logger.info(
|
||||||
@@ -1152,6 +1152,9 @@ class BoxService:
|
|||||||
if self.default_workspace is None:
|
if self.default_workspace is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if not self.shares_filesystem_with_box:
|
||||||
|
return
|
||||||
|
|
||||||
if os.path.isdir(self.default_workspace):
|
if os.path.isdir(self.default_workspace):
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -1176,7 +1179,7 @@ class BoxService:
|
|||||||
return
|
return
|
||||||
|
|
||||||
host_path = os.path.realpath(spec.host_path)
|
host_path = os.path.realpath(spec.host_path)
|
||||||
if not os.path.isdir(host_path):
|
if self.shares_filesystem_with_box and not os.path.isdir(host_path):
|
||||||
raise BoxValidationError('host_path must point to an existing directory on the host')
|
raise BoxValidationError('host_path must point to an existing directory on the host')
|
||||||
|
|
||||||
if not self.allowed_mount_roots:
|
if not self.allowed_mount_roots:
|
||||||
|
|||||||
@@ -84,7 +84,17 @@ class CommandManager:
|
|||||||
|
|
||||||
privilege = 1
|
privilege = 1
|
||||||
|
|
||||||
if f'{query.launcher_type.value}_{query.launcher_id}' in self.ap.instance_config.data['admins']:
|
import sqlalchemy as _sa
|
||||||
|
from ..entity.persistence.bot import BotAdmin as _BotAdmin
|
||||||
|
|
||||||
|
_admins = await self.ap.persistence_mgr.execute_async(
|
||||||
|
_sa.select(_BotAdmin).where(
|
||||||
|
_BotAdmin.bot_uuid == (query.bot_uuid or ''),
|
||||||
|
_BotAdmin.launcher_type == query.launcher_type.value,
|
||||||
|
_BotAdmin.launcher_id == str(query.launcher_id),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if _admins.first() is not None:
|
||||||
privilege = 2
|
privilege = 2
|
||||||
|
|
||||||
ctx = command_context.ExecuteContext(
|
ctx = command_context.ExecuteContext(
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ from ..api.http.service import mcp as mcp_service
|
|||||||
from ..api.http.service import apikey as apikey_service
|
from ..api.http.service import apikey as apikey_service
|
||||||
from ..api.http.service import webhook as webhook_service
|
from ..api.http.service import webhook as webhook_service
|
||||||
from ..api.http.service import monitoring as monitoring_service
|
from ..api.http.service import monitoring as monitoring_service
|
||||||
|
from ..api.http.service import workflow as workflow_service
|
||||||
from ..api.http.service import skill as skill_service
|
from ..api.http.service import skill as skill_service
|
||||||
from ..api.http.service import maintenance as maintenance_service
|
from ..api.http.service import maintenance as maintenance_service
|
||||||
from ..discover import engine as discover_engine
|
from ..discover import engine as discover_engine
|
||||||
@@ -153,6 +154,8 @@ class Application:
|
|||||||
|
|
||||||
webhook_service: webhook_service.WebhookService = None
|
webhook_service: webhook_service.WebhookService = None
|
||||||
|
|
||||||
|
workflow_service: workflow_service.WorkflowService = None
|
||||||
|
|
||||||
telemetry: telemetry_module.TelemetryManager = None
|
telemetry: telemetry_module.TelemetryManager = None
|
||||||
|
|
||||||
survey: survey_module.SurveyManager = None
|
survey: survey_module.SurveyManager = None
|
||||||
@@ -255,6 +258,22 @@ class Application:
|
|||||||
scopes=[core_entities.LifecycleControlScope.APPLICATION],
|
scopes=[core_entities.LifecycleControlScope.APPLICATION],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def workflow_execution_cleanup_loop():
|
||||||
|
check_interval_seconds = 60
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
cancelled = await self.workflow_service.cleanup_stale_executions()
|
||||||
|
if cancelled > 0:
|
||||||
|
self.logger.info(f'Workflow execution auto-cleanup: cancelled {cancelled} stale executions')
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.warning(f'Workflow execution auto-cleanup error: {e}')
|
||||||
|
await asyncio.sleep(check_interval_seconds)
|
||||||
|
|
||||||
|
self.task_mgr.create_task(
|
||||||
|
workflow_execution_cleanup_loop(),
|
||||||
|
name='workflow-execution-cleanup',
|
||||||
|
scopes=[core_entities.LifecycleControlScope.APPLICATION],
|
||||||
|
)
|
||||||
# Start storage/log maintenance task if enabled
|
# Start storage/log maintenance task if enabled
|
||||||
storage_cleanup_cfg = self.instance_config.data.get('storage', {}).get('cleanup', {})
|
storage_cleanup_cfg = self.instance_config.data.get('storage', {}).get('cleanup', {})
|
||||||
if storage_cleanup_cfg.get('enabled', True) and self.maintenance_service is not None:
|
if storage_cleanup_cfg.get('enabled', True) and self.maintenance_service is not None:
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ from ...api.http.service import mcp as mcp_service
|
|||||||
from ...api.http.service import apikey as apikey_service
|
from ...api.http.service import apikey as apikey_service
|
||||||
from ...api.http.service import webhook as webhook_service
|
from ...api.http.service import webhook as webhook_service
|
||||||
from ...api.http.service import monitoring as monitoring_service
|
from ...api.http.service import monitoring as monitoring_service
|
||||||
|
from ...api.http.service import workflow as workflow_service
|
||||||
from ...api.http.service import skill as skill_service
|
from ...api.http.service import skill as skill_service
|
||||||
from ...skill import manager as skill_mgr
|
from ...skill import manager as skill_mgr
|
||||||
from ...api.http.service import maintenance as maintenance_service
|
from ...api.http.service import maintenance as maintenance_service
|
||||||
@@ -89,6 +90,9 @@ class BuildAppStage(stage.BootingStage):
|
|||||||
webhook_service_inst = webhook_service.WebhookService(ap)
|
webhook_service_inst = webhook_service.WebhookService(ap)
|
||||||
ap.webhook_service = webhook_service_inst
|
ap.webhook_service = webhook_service_inst
|
||||||
|
|
||||||
|
workflow_service_inst = workflow_service.WorkflowService(ap)
|
||||||
|
ap.workflow_service = workflow_service_inst
|
||||||
|
|
||||||
skill_service_inst = skill_service.SkillService(ap)
|
skill_service_inst = skill_service.SkillService(ap)
|
||||||
ap.skill_service = skill_service_inst
|
ap.skill_service = skill_service_inst
|
||||||
|
|
||||||
|
|||||||
@@ -231,3 +231,34 @@ class LoadConfigStage(stage.BootingStage):
|
|||||||
ap.pipeline_config_meta_safety = await load_resource_yaml_template_data('metadata/pipeline/safety.yaml')
|
ap.pipeline_config_meta_safety = await load_resource_yaml_template_data('metadata/pipeline/safety.yaml')
|
||||||
ap.pipeline_config_meta_ai = await load_resource_yaml_template_data('metadata/pipeline/ai.yaml')
|
ap.pipeline_config_meta_ai = await load_resource_yaml_template_data('metadata/pipeline/ai.yaml')
|
||||||
ap.pipeline_config_meta_output = await load_resource_yaml_template_data('metadata/pipeline/output.yaml')
|
ap.pipeline_config_meta_output = await load_resource_yaml_template_data('metadata/pipeline/output.yaml')
|
||||||
|
|
||||||
|
# Load workflow node metadata from YAML files. YAML is the source of
|
||||||
|
# truth for workflow editor metadata; Python classes provide execution
|
||||||
|
# logic and are bound through the registry.
|
||||||
|
from langbot.pkg.workflow.metadata import NodeMetadataLoader
|
||||||
|
from langbot.pkg.workflow.registry import NodeTypeRegistry
|
||||||
|
|
||||||
|
workflow_metadata_loader = NodeMetadataLoader()
|
||||||
|
workflow_node_count = await workflow_metadata_loader.load_core_metadata()
|
||||||
|
ap.workflow_node_configs = workflow_metadata_loader.get_all_metadata()
|
||||||
|
ap.workflow_node_metadata_loader = workflow_metadata_loader
|
||||||
|
|
||||||
|
workflow_registry = NodeTypeRegistry.instance()
|
||||||
|
for node_config in ap.workflow_node_configs.values():
|
||||||
|
workflow_registry.register_metadata(node_config, source=node_config.get('_source', 'core'))
|
||||||
|
|
||||||
|
# Auto-discover and register workflow nodes using discovery engine
|
||||||
|
if hasattr(ap, 'discover') and ap.discover is not None:
|
||||||
|
workflow_registry.discover_nodes(ap.discover)
|
||||||
|
|
||||||
|
workflow_load_errors = workflow_metadata_loader.get_load_errors()
|
||||||
|
if workflow_load_errors:
|
||||||
|
print(f'Workflow node metadata load errors: {len(workflow_load_errors)}')
|
||||||
|
for error in workflow_load_errors:
|
||||||
|
print(f" - {error.get('file')}: {error.get('error')}")
|
||||||
|
|
||||||
|
print(
|
||||||
|
f'Loaded {workflow_node_count} workflow node metadata files; '
|
||||||
|
f'registered {workflow_registry.metadata_count()} metadata definitions, '
|
||||||
|
f'{workflow_registry.count()} node types'
|
||||||
|
)
|
||||||
|
|||||||
@@ -304,3 +304,65 @@ class ComponentDiscoveryEngine:
|
|||||||
if component.kind == kind:
|
if component.kind == kind:
|
||||||
result.append(component)
|
result.append(component)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
def discover_workflow_nodes(self, nodes_dir: str) -> typing.List[typing.Type]:
|
||||||
|
"""Discover workflow node classes from a directory of Python modules.
|
||||||
|
|
||||||
|
Scans all .py files in the given directory, imports them, and collects
|
||||||
|
classes that are subclasses of WorkflowNode.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
nodes_dir: Directory path like 'pkg/workflow/nodes/'
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of WorkflowNode subclasses found
|
||||||
|
"""
|
||||||
|
from langbot.pkg.workflow.node import WorkflowNode
|
||||||
|
|
||||||
|
node_classes: typing.List[typing.Type[WorkflowNode]] = []
|
||||||
|
|
||||||
|
# Normalize path
|
||||||
|
if nodes_dir.endswith('/'):
|
||||||
|
nodes_dir = nodes_dir[:-1]
|
||||||
|
|
||||||
|
# Import the nodes package to trigger all module imports
|
||||||
|
module_path = nodes_dir.replace('/', '.').replace('\\', '.')
|
||||||
|
package_path = module_path
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Import the package __init__ to trigger submodule imports
|
||||||
|
importlib.import_module(f'langbot.{package_path}')
|
||||||
|
except ImportError:
|
||||||
|
self.ap.logger.warning(f'Failed to import workflow nodes package: langbot.{package_path}')
|
||||||
|
|
||||||
|
# Since workflow/__init__.py is empty, explicitly import all .py files in the nodes directory
|
||||||
|
import os
|
||||||
|
# engine.py is in langbot/pkg/discover/, nodes are in langbot/pkg/workflow/nodes/
|
||||||
|
nodes_abs_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'workflow', 'nodes'))
|
||||||
|
if os.path.isdir(nodes_abs_path):
|
||||||
|
for filename in os.listdir(nodes_abs_path):
|
||||||
|
if filename.endswith('.py') and not filename.startswith('_'):
|
||||||
|
module_name = filename[:-3]
|
||||||
|
try:
|
||||||
|
importlib.import_module(f'langbot.{package_path}.{module_name}')
|
||||||
|
except ImportError as e:
|
||||||
|
self.ap.logger.warning(f'Failed to import workflow node module: {module_name}: {e}')
|
||||||
|
|
||||||
|
# Now collect all WorkflowNode subclasses from sys.modules
|
||||||
|
import sys
|
||||||
|
prefix = f'langbot.{package_path}.'
|
||||||
|
for mod_name, mod in sys.modules.items():
|
||||||
|
if mod_name.startswith(prefix) and mod is not None:
|
||||||
|
for attr_name in dir(mod):
|
||||||
|
attr = getattr(mod, attr_name)
|
||||||
|
if (
|
||||||
|
isinstance(attr, type)
|
||||||
|
and issubclass(attr, WorkflowNode)
|
||||||
|
and attr is not WorkflowNode
|
||||||
|
and hasattr(attr, 'type_name')
|
||||||
|
and attr.type_name
|
||||||
|
):
|
||||||
|
if attr not in node_classes:
|
||||||
|
node_classes.append(attr)
|
||||||
|
|
||||||
|
return node_classes
|
||||||
|
|||||||
@@ -3,6 +3,20 @@ import sqlalchemy
|
|||||||
from .base import Base
|
from .base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class BotAdmin(Base):
|
||||||
|
"""Bot admin — a launcher that has admin privilege for a specific bot's commands"""
|
||||||
|
|
||||||
|
__tablename__ = 'bot_admins'
|
||||||
|
|
||||||
|
id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True)
|
||||||
|
bot_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||||
|
launcher_type = sqlalchemy.Column(sqlalchemy.String(64), nullable=False)
|
||||||
|
launcher_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||||
|
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
|
||||||
|
|
||||||
|
__table_args__ = (sqlalchemy.UniqueConstraint('bot_uuid', 'launcher_type', 'launcher_id', name='uq_bot_admin'),)
|
||||||
|
|
||||||
|
|
||||||
class Bot(Base):
|
class Bot(Base):
|
||||||
"""Bot"""
|
"""Bot"""
|
||||||
|
|
||||||
@@ -17,6 +31,13 @@ class Bot(Base):
|
|||||||
use_pipeline_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
use_pipeline_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||||
use_pipeline_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
use_pipeline_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||||
pipeline_routing_rules = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, server_default='[]')
|
pipeline_routing_rules = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, server_default='[]')
|
||||||
|
|
||||||
|
# New unified binding fields
|
||||||
|
# binding_type: 'pipeline' or 'workflow'
|
||||||
|
binding_type = sqlalchemy.Column(sqlalchemy.String(32), nullable=False, server_default='pipeline')
|
||||||
|
# binding_uuid: UUID of the bound Pipeline or Workflow
|
||||||
|
binding_uuid = sqlalchemy.Column(sqlalchemy.String(64), nullable=True)
|
||||||
|
|
||||||
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
|
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
|
||||||
updated_at = sqlalchemy.Column(
|
updated_at = sqlalchemy.Column(
|
||||||
sqlalchemy.DateTime,
|
sqlalchemy.DateTime,
|
||||||
|
|||||||
@@ -26,7 +26,14 @@ class LegacyPipeline(Base):
|
|||||||
extensions_preferences = sqlalchemy.Column(
|
extensions_preferences = sqlalchemy.Column(
|
||||||
sqlalchemy.JSON,
|
sqlalchemy.JSON,
|
||||||
nullable=False,
|
nullable=False,
|
||||||
default={'enable_all_plugins': True, 'enable_all_mcp_servers': True, 'plugins': [], 'mcp_servers': []},
|
default={
|
||||||
|
'enable_all_plugins': True,
|
||||||
|
'enable_all_mcp_servers': True,
|
||||||
|
'plugins': [],
|
||||||
|
'mcp_servers': [],
|
||||||
|
'mcp_resources': [],
|
||||||
|
'mcp_resource_agent_read_enabled': True,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"""Workflow persistence entities"""
|
||||||
|
|
||||||
|
import sqlalchemy
|
||||||
|
|
||||||
|
from .base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Workflow(Base):
|
||||||
|
"""Workflow definition"""
|
||||||
|
|
||||||
|
__tablename__ = 'workflows'
|
||||||
|
|
||||||
|
uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
|
||||||
|
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||||
|
description = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
|
||||||
|
emoji = sqlalchemy.Column(sqlalchemy.String(10), nullable=True, default='🔄')
|
||||||
|
version = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, default=1)
|
||||||
|
is_enabled = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=True)
|
||||||
|
|
||||||
|
# Workflow definition stored as JSON
|
||||||
|
# Contains: nodes, edges, variables, settings
|
||||||
|
definition = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})
|
||||||
|
|
||||||
|
# Global config (inherited from Pipeline capabilities)
|
||||||
|
# Contains: safety, output configs
|
||||||
|
global_config = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})
|
||||||
|
|
||||||
|
# Extensions preferences (same as Pipeline)
|
||||||
|
extensions_preferences = sqlalchemy.Column(
|
||||||
|
sqlalchemy.JSON,
|
||||||
|
nullable=False,
|
||||||
|
default={'enable_all_plugins': True, 'enable_all_mcp_servers': True, 'plugins': [], 'mcp_servers': []},
|
||||||
|
)
|
||||||
|
|
||||||
|
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
|
||||||
|
updated_at = sqlalchemy.Column(
|
||||||
|
sqlalchemy.DateTime,
|
||||||
|
nullable=False,
|
||||||
|
server_default=sqlalchemy.func.now(),
|
||||||
|
onupdate=sqlalchemy.func.now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowVersion(Base):
|
||||||
|
"""Workflow version history"""
|
||||||
|
|
||||||
|
__tablename__ = 'workflow_versions'
|
||||||
|
|
||||||
|
id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True)
|
||||||
|
workflow_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
|
||||||
|
version = sqlalchemy.Column(sqlalchemy.Integer, nullable=False)
|
||||||
|
definition = sqlalchemy.Column(sqlalchemy.JSON, nullable=False)
|
||||||
|
global_config = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})
|
||||||
|
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
|
||||||
|
created_by = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (sqlalchemy.UniqueConstraint('workflow_uuid', 'version', name='uq_workflow_version'),)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowTrigger(Base):
|
||||||
|
"""Workflow trigger configuration"""
|
||||||
|
|
||||||
|
__tablename__ = 'workflow_triggers'
|
||||||
|
|
||||||
|
uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
|
||||||
|
workflow_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
|
||||||
|
type = sqlalchemy.Column(sqlalchemy.String(50), nullable=False) # message, cron, event, webhook
|
||||||
|
config = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})
|
||||||
|
is_enabled = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=True)
|
||||||
|
priority = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, default=0)
|
||||||
|
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
|
||||||
|
updated_at = sqlalchemy.Column(
|
||||||
|
sqlalchemy.DateTime,
|
||||||
|
nullable=False,
|
||||||
|
server_default=sqlalchemy.func.now(),
|
||||||
|
onupdate=sqlalchemy.func.now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowExecution(Base):
|
||||||
|
"""Workflow execution record"""
|
||||||
|
|
||||||
|
__tablename__ = 'workflow_executions'
|
||||||
|
|
||||||
|
uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
|
||||||
|
workflow_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
|
||||||
|
workflow_version = sqlalchemy.Column(sqlalchemy.Integer, nullable=False)
|
||||||
|
status = sqlalchemy.Column(sqlalchemy.String(20), nullable=False) # pending, running, completed, failed, cancelled
|
||||||
|
trigger_type = sqlalchemy.Column(sqlalchemy.String(50), nullable=True)
|
||||||
|
trigger_data = sqlalchemy.Column(sqlalchemy.JSON, nullable=True)
|
||||||
|
variables = sqlalchemy.Column(sqlalchemy.JSON, nullable=True)
|
||||||
|
start_time = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
|
||||||
|
end_time = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
|
||||||
|
error = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
|
||||||
|
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowNodeExecution(Base):
|
||||||
|
"""Workflow node execution record"""
|
||||||
|
|
||||||
|
__tablename__ = 'workflow_node_executions'
|
||||||
|
|
||||||
|
id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True)
|
||||||
|
execution_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
|
||||||
|
node_id = sqlalchemy.Column(sqlalchemy.String(100), nullable=False)
|
||||||
|
node_type = sqlalchemy.Column(sqlalchemy.String(50), nullable=False)
|
||||||
|
status = sqlalchemy.Column(sqlalchemy.String(20), nullable=False) # pending, running, completed, failed, skipped
|
||||||
|
inputs = sqlalchemy.Column(sqlalchemy.JSON, nullable=True)
|
||||||
|
outputs = sqlalchemy.Column(sqlalchemy.JSON, nullable=True)
|
||||||
|
start_time = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
|
||||||
|
end_time = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
|
||||||
|
error = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
|
||||||
|
retry_count = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, default=0)
|
||||||
|
|
||||||
|
|
||||||
|
class ScheduledJob(Base):
|
||||||
|
"""Scheduled job for cron triggers"""
|
||||||
|
|
||||||
|
__tablename__ = 'workflow_scheduled_jobs'
|
||||||
|
|
||||||
|
uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
|
||||||
|
trigger_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
|
||||||
|
cron_expression = sqlalchemy.Column(sqlalchemy.String(100), nullable=True)
|
||||||
|
next_run_time = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
|
||||||
|
last_run_time = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
|
||||||
|
is_enabled = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=True)
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"""add bot_admins table and migrate config admins
|
||||||
|
|
||||||
|
Revision ID: 0007_add_bot_admins
|
||||||
|
Revises: 0006_normalize_mcp_remote_mode
|
||||||
|
Create Date: 2026-06-26
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = '0007_add_bot_admins'
|
||||||
|
down_revision = '0006_normalize_mcp_remote_mode'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
if 'bot_admins' in sa.inspect(conn).get_table_names():
|
||||||
|
return
|
||||||
|
op.create_table(
|
||||||
|
'bot_admins',
|
||||||
|
sa.Column('id', sa.Integer, primary_key=True, autoincrement=True),
|
||||||
|
sa.Column('bot_uuid', sa.String(255), nullable=False),
|
||||||
|
sa.Column('launcher_type', sa.String(64), nullable=False),
|
||||||
|
sa.Column('launcher_id', sa.String(255), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime, nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.UniqueConstraint('bot_uuid', 'launcher_type', 'launcher_id', name='uq_bot_admin'),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Migrate old config-based admins into the first bot (best-effort)
|
||||||
|
inspector = sa.inspect(conn)
|
||||||
|
tables = inspector.get_table_names()
|
||||||
|
|
||||||
|
if 'bots' not in tables:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Read the first bot uuid
|
||||||
|
row = conn.execute(sa.text('SELECT uuid FROM bots ORDER BY created_at LIMIT 1')).first()
|
||||||
|
if row is None:
|
||||||
|
return
|
||||||
|
first_bot_uuid = row[0]
|
||||||
|
|
||||||
|
# Read instance_config metadata key that holds the admins list
|
||||||
|
if 'metadata' not in tables:
|
||||||
|
return
|
||||||
|
meta_row = conn.execute(sa.text("SELECT value FROM metadata WHERE key = 'instance_config'")).first()
|
||||||
|
if meta_row is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
try:
|
||||||
|
cfg = json.loads(meta_row[0])
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
admins = cfg.get('admins', [])
|
||||||
|
for entry in admins:
|
||||||
|
parts = entry.split('_', 1)
|
||||||
|
if len(parts) != 2:
|
||||||
|
continue
|
||||||
|
launcher_type, launcher_id = parts
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
sa.text(
|
||||||
|
'INSERT OR IGNORE INTO bot_admins (bot_uuid, launcher_type, launcher_id) VALUES (:bu, :lt, :li)'
|
||||||
|
),
|
||||||
|
{'bu': first_bot_uuid, 'lt': launcher_type, 'li': launcher_id},
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Remove admins key from stored config
|
||||||
|
if 'admins' in cfg:
|
||||||
|
del cfg['admins']
|
||||||
|
conn.execute(
|
||||||
|
sa.text("UPDATE metadata SET value = :v WHERE key = 'instance_config'"),
|
||||||
|
{'v': json.dumps(cfg)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table('bot_admins')
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"""add mcp resource preferences to pipelines
|
||||||
|
|
||||||
|
Revision ID: 0008_mcp_resource_prefs
|
||||||
|
Revises: 0007_add_bot_admins
|
||||||
|
Create Date: 2026-06-30
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = '0008_mcp_resource_prefs'
|
||||||
|
down_revision = '0007_add_bot_admins'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
_PIPELINE_TABLE = sa.table(
|
||||||
|
'legacy_pipelines',
|
||||||
|
sa.column('uuid', sa.String(255)),
|
||||||
|
sa.column('extensions_preferences', sa.JSON()),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _has_extensions_preferences_table(conn: sa.Connection) -> bool:
|
||||||
|
inspector = sa.inspect(conn)
|
||||||
|
if 'legacy_pipelines' not in inspector.get_table_names():
|
||||||
|
return False
|
||||||
|
columns = {column['name'] for column in inspector.get_columns('legacy_pipelines')}
|
||||||
|
return 'extensions_preferences' in columns
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_preferences(value: Any) -> dict[str, Any]:
|
||||||
|
if value is None:
|
||||||
|
return {}
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return dict(value)
|
||||||
|
if isinstance(value, str):
|
||||||
|
try:
|
||||||
|
decoded = json.loads(value)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return {}
|
||||||
|
if isinstance(decoded, dict):
|
||||||
|
return decoded
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _update_preferences(conn: sa.Connection, uuid: str, preferences: dict[str, Any]) -> None:
|
||||||
|
conn.execute(
|
||||||
|
_PIPELINE_TABLE.update().where(_PIPELINE_TABLE.c.uuid == uuid).values(extensions_preferences=preferences)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
if not _has_extensions_preferences_table(conn):
|
||||||
|
return
|
||||||
|
|
||||||
|
rows = conn.execute(sa.select(_PIPELINE_TABLE.c.uuid, _PIPELINE_TABLE.c.extensions_preferences)).all()
|
||||||
|
for uuid, raw_preferences in rows:
|
||||||
|
preferences = _decode_preferences(raw_preferences)
|
||||||
|
changed = False
|
||||||
|
|
||||||
|
if 'mcp_resources' not in preferences:
|
||||||
|
preferences['mcp_resources'] = []
|
||||||
|
changed = True
|
||||||
|
if 'mcp_resource_agent_read_enabled' not in preferences:
|
||||||
|
preferences['mcp_resource_agent_read_enabled'] = True
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
if changed:
|
||||||
|
_update_preferences(conn, uuid, preferences)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
if not _has_extensions_preferences_table(conn):
|
||||||
|
return
|
||||||
|
|
||||||
|
rows = conn.execute(sa.select(_PIPELINE_TABLE.c.uuid, _PIPELINE_TABLE.c.extensions_preferences)).all()
|
||||||
|
for uuid, raw_preferences in rows:
|
||||||
|
preferences = _decode_preferences(raw_preferences)
|
||||||
|
changed = False
|
||||||
|
|
||||||
|
for key in ('mcp_resources', 'mcp_resource_agent_read_enabled'):
|
||||||
|
if key in preferences:
|
||||||
|
preferences.pop(key)
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
if changed:
|
||||||
|
_update_preferences(conn, uuid, preferences)
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
"""add workflow tables and bot binding fields
|
||||||
|
|
||||||
|
Revision ID: 0009_add_workflow_tables
|
||||||
|
Revises: 0008_mcp_resource_prefs
|
||||||
|
Create Date: 2026-07-01
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = '0009_add_workflow_tables'
|
||||||
|
down_revision = '0008_mcp_resource_prefs'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _table_exists(conn: sa.Connection, table_name: str) -> bool:
|
||||||
|
return table_name in sa.inspect(conn).get_table_names()
|
||||||
|
|
||||||
|
|
||||||
|
def _has_column(conn: sa.Connection, table_name: str, column_name: str) -> bool:
|
||||||
|
if not _table_exists(conn, table_name):
|
||||||
|
return False
|
||||||
|
return column_name in {column['name'] for column in sa.inspect(conn).get_columns(table_name)}
|
||||||
|
|
||||||
|
|
||||||
|
def _has_index_for_columns(conn: sa.Connection, table_name: str, columns: tuple[str, ...]) -> bool:
|
||||||
|
if not _table_exists(conn, table_name):
|
||||||
|
return False
|
||||||
|
for index in sa.inspect(conn).get_indexes(table_name):
|
||||||
|
if tuple(index.get('column_names') or ()) == columns:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_index(conn: sa.Connection, table_name: str, index_name: str, columns: list[str]) -> None:
|
||||||
|
if _has_index_for_columns(conn, table_name, tuple(columns)):
|
||||||
|
return
|
||||||
|
op.create_index(index_name, table_name, columns)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_workflow_tables(conn: sa.Connection) -> None:
|
||||||
|
if not _table_exists(conn, 'workflows'):
|
||||||
|
op.create_table(
|
||||||
|
'workflows',
|
||||||
|
sa.Column('uuid', sa.String(255), primary_key=True),
|
||||||
|
sa.Column('name', sa.String(255), nullable=False),
|
||||||
|
sa.Column('description', sa.Text(), nullable=True),
|
||||||
|
sa.Column('emoji', sa.String(10), nullable=True),
|
||||||
|
sa.Column('version', sa.Integer(), nullable=False, server_default='1'),
|
||||||
|
sa.Column('is_enabled', sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||||
|
sa.Column('definition', sa.JSON(), nullable=False, server_default=sa.text("'{}'")),
|
||||||
|
sa.Column('global_config', sa.JSON(), nullable=False, server_default=sa.text("'{}'")),
|
||||||
|
sa.Column(
|
||||||
|
'extensions_preferences',
|
||||||
|
sa.JSON(),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text(
|
||||||
|
'\'{"enable_all_plugins": true, "enable_all_mcp_servers": true, "plugins": [], "mcp_servers": []}\''
|
||||||
|
),
|
||||||
|
),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not _table_exists(conn, 'workflow_versions'):
|
||||||
|
op.create_table(
|
||||||
|
'workflow_versions',
|
||||||
|
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column('workflow_uuid', sa.String(255), nullable=False),
|
||||||
|
sa.Column('version', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('definition', sa.JSON(), nullable=False),
|
||||||
|
sa.Column('global_config', sa.JSON(), nullable=False, server_default=sa.text("'{}'")),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column('created_by', sa.String(255), nullable=True),
|
||||||
|
sa.UniqueConstraint('workflow_uuid', 'version', name='uq_workflow_version'),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not _table_exists(conn, 'workflow_triggers'):
|
||||||
|
op.create_table(
|
||||||
|
'workflow_triggers',
|
||||||
|
sa.Column('uuid', sa.String(255), primary_key=True),
|
||||||
|
sa.Column('workflow_uuid', sa.String(255), nullable=False),
|
||||||
|
sa.Column('type', sa.String(50), nullable=False),
|
||||||
|
sa.Column('config', sa.JSON(), nullable=False, server_default=sa.text("'{}'")),
|
||||||
|
sa.Column('is_enabled', sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||||
|
sa.Column('priority', sa.Integer(), nullable=False, server_default='0'),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not _table_exists(conn, 'workflow_executions'):
|
||||||
|
op.create_table(
|
||||||
|
'workflow_executions',
|
||||||
|
sa.Column('uuid', sa.String(255), primary_key=True),
|
||||||
|
sa.Column('workflow_uuid', sa.String(255), nullable=False),
|
||||||
|
sa.Column('workflow_version', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('status', sa.String(20), nullable=False),
|
||||||
|
sa.Column('trigger_type', sa.String(50), nullable=True),
|
||||||
|
sa.Column('trigger_data', sa.JSON(), nullable=True),
|
||||||
|
sa.Column('variables', sa.JSON(), nullable=True),
|
||||||
|
sa.Column('start_time', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('end_time', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('error', sa.Text(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not _table_exists(conn, 'workflow_node_executions'):
|
||||||
|
op.create_table(
|
||||||
|
'workflow_node_executions',
|
||||||
|
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column('execution_uuid', sa.String(255), nullable=False),
|
||||||
|
sa.Column('node_id', sa.String(100), nullable=False),
|
||||||
|
sa.Column('node_type', sa.String(50), nullable=False),
|
||||||
|
sa.Column('status', sa.String(20), nullable=False),
|
||||||
|
sa.Column('inputs', sa.JSON(), nullable=True),
|
||||||
|
sa.Column('outputs', sa.JSON(), nullable=True),
|
||||||
|
sa.Column('start_time', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('end_time', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('error', sa.Text(), nullable=True),
|
||||||
|
sa.Column('retry_count', sa.Integer(), nullable=False, server_default='0'),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not _table_exists(conn, 'workflow_scheduled_jobs'):
|
||||||
|
op.create_table(
|
||||||
|
'workflow_scheduled_jobs',
|
||||||
|
sa.Column('uuid', sa.String(255), primary_key=True),
|
||||||
|
sa.Column('trigger_uuid', sa.String(255), nullable=False),
|
||||||
|
sa.Column('cron_expression', sa.String(100), nullable=True),
|
||||||
|
sa.Column('next_run_time', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('last_run_time', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('is_enabled', sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||||
|
)
|
||||||
|
|
||||||
|
_ensure_index(conn, 'workflow_versions', 'ix_workflow_versions_workflow_uuid', ['workflow_uuid'])
|
||||||
|
_ensure_index(conn, 'workflow_triggers', 'ix_workflow_triggers_workflow_uuid', ['workflow_uuid'])
|
||||||
|
_ensure_index(conn, 'workflow_executions', 'ix_workflow_executions_workflow_uuid', ['workflow_uuid'])
|
||||||
|
_ensure_index(
|
||||||
|
conn,
|
||||||
|
'workflow_node_executions',
|
||||||
|
'ix_workflow_node_executions_execution_uuid',
|
||||||
|
['execution_uuid'],
|
||||||
|
)
|
||||||
|
_ensure_index(conn, 'workflow_scheduled_jobs', 'ix_workflow_scheduled_jobs_trigger_uuid', ['trigger_uuid'])
|
||||||
|
|
||||||
|
|
||||||
|
def _add_bot_binding_fields(conn: sa.Connection) -> None:
|
||||||
|
if not _table_exists(conn, 'bots'):
|
||||||
|
return
|
||||||
|
|
||||||
|
if not _has_column(conn, 'bots', 'binding_type'):
|
||||||
|
op.add_column(
|
||||||
|
'bots',
|
||||||
|
sa.Column('binding_type', sa.String(32), nullable=False, server_default='pipeline'),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not _has_column(conn, 'bots', 'binding_uuid'):
|
||||||
|
op.add_column('bots', sa.Column('binding_uuid', sa.String(64), nullable=True))
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
UPDATE bots
|
||||||
|
SET binding_uuid = use_pipeline_uuid
|
||||||
|
WHERE use_pipeline_uuid IS NOT NULL
|
||||||
|
AND use_pipeline_uuid != ''
|
||||||
|
AND (binding_uuid IS NULL OR binding_uuid = '')
|
||||||
|
""")
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
UPDATE bots
|
||||||
|
SET binding_type = 'pipeline'
|
||||||
|
WHERE binding_uuid IS NOT NULL
|
||||||
|
AND binding_uuid != ''
|
||||||
|
AND (binding_type IS NULL OR binding_type = '')
|
||||||
|
""")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
_create_workflow_tables(conn)
|
||||||
|
_add_bot_binding_fields(conn)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
|
||||||
|
if _has_column(conn, 'bots', 'binding_uuid'):
|
||||||
|
with op.batch_alter_table('bots') as batch_op:
|
||||||
|
batch_op.drop_column('binding_uuid')
|
||||||
|
if _has_column(conn, 'bots', 'binding_type'):
|
||||||
|
with op.batch_alter_table('bots') as batch_op:
|
||||||
|
batch_op.drop_column('binding_type')
|
||||||
|
|
||||||
|
for table_name in (
|
||||||
|
'workflow_scheduled_jobs',
|
||||||
|
'workflow_node_executions',
|
||||||
|
'workflow_executions',
|
||||||
|
'workflow_triggers',
|
||||||
|
'workflow_versions',
|
||||||
|
'workflows',
|
||||||
|
):
|
||||||
|
if _table_exists(conn, table_name):
|
||||||
|
op.drop_table(table_name)
|
||||||
@@ -32,7 +32,7 @@ class MonitoringHelper:
|
|||||||
"""Record the start of query processing, returns message_id"""
|
"""Record the start of query processing, returns message_id"""
|
||||||
try:
|
try:
|
||||||
# Check if session exists, if not, record session start
|
# Check if session exists, if not, record session start
|
||||||
session_id = f'{query.launcher_type}_{query.launcher_id}'
|
session_id = f'{query.launcher_type.value if hasattr(query.launcher_type, "value") else query.launcher_type}_{query.launcher_id}'
|
||||||
|
|
||||||
# Get sender name from message event
|
# Get sender name from message event
|
||||||
sender_name = None
|
sender_name = None
|
||||||
@@ -137,7 +137,7 @@ class MonitoringHelper:
|
|||||||
):
|
):
|
||||||
"""Record bot response message to monitoring"""
|
"""Record bot response message to monitoring"""
|
||||||
try:
|
try:
|
||||||
session_id = f'{query.launcher_type}_{query.launcher_id}'
|
session_id = f'{query.launcher_type.value if hasattr(query.launcher_type, "value") else query.launcher_type}_{query.launcher_id}'
|
||||||
|
|
||||||
# Get sender name from message event
|
# Get sender name from message event
|
||||||
sender_name = None
|
sender_name = None
|
||||||
@@ -202,7 +202,7 @@ class MonitoringHelper:
|
|||||||
) -> str:
|
) -> str:
|
||||||
"""Record query processing error, returns message_id"""
|
"""Record query processing error, returns message_id"""
|
||||||
try:
|
try:
|
||||||
session_id = f'{query.launcher_type}_{query.launcher_id}'
|
session_id = f'{query.launcher_type.value if hasattr(query.launcher_type, "value") else query.launcher_type}_{query.launcher_id}'
|
||||||
|
|
||||||
# Get sender name from message event
|
# Get sender name from message event
|
||||||
sender_name = None
|
sender_name = None
|
||||||
@@ -268,7 +268,7 @@ class MonitoringHelper:
|
|||||||
):
|
):
|
||||||
"""Record LLM call"""
|
"""Record LLM call"""
|
||||||
try:
|
try:
|
||||||
session_id = f'{query.launcher_type}_{query.launcher_id}'
|
session_id = f'{query.launcher_type.value if hasattr(query.launcher_type, "value") else query.launcher_type}_{query.launcher_id}'
|
||||||
|
|
||||||
await ap.monitoring_service.record_llm_call(
|
await ap.monitoring_service.record_llm_call(
|
||||||
bot_id=bot_id,
|
bot_id=bot_id,
|
||||||
@@ -13,7 +13,7 @@ import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
|||||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||||
import langbot_plugin.api.entities.events as events
|
import langbot_plugin.api.entities.events as events
|
||||||
from ..utils import importutil
|
from ..utils import importutil
|
||||||
from .config_coercion import coerce_pipeline_config
|
from .config import coerce_pipeline_config
|
||||||
|
|
||||||
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
||||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||||
@@ -96,6 +96,15 @@ class RuntimePipeline:
|
|||||||
extensions_prefs = pipeline_entity.extensions_preferences or {}
|
extensions_prefs = pipeline_entity.extensions_preferences or {}
|
||||||
self.enable_all_plugins = extensions_prefs.get('enable_all_plugins', True)
|
self.enable_all_plugins = extensions_prefs.get('enable_all_plugins', True)
|
||||||
self.enable_all_mcp_servers = extensions_prefs.get('enable_all_mcp_servers', True)
|
self.enable_all_mcp_servers = extensions_prefs.get('enable_all_mcp_servers', True)
|
||||||
|
local_agent_config = (pipeline_entity.config or {}).get('ai', {}).get('local-agent', {})
|
||||||
|
self.mcp_resource_attachments = local_agent_config.get(
|
||||||
|
'mcp-resources',
|
||||||
|
extensions_prefs.get('mcp_resources', []),
|
||||||
|
)
|
||||||
|
self.mcp_resource_agent_read_enabled = local_agent_config.get(
|
||||||
|
'mcp-resource-agent-read-enabled',
|
||||||
|
extensions_prefs.get('mcp_resource_agent_read_enabled', True),
|
||||||
|
)
|
||||||
|
|
||||||
if self.enable_all_plugins:
|
if self.enable_all_plugins:
|
||||||
# None indicates to use all available plugins
|
# None indicates to use all available plugins
|
||||||
@@ -116,6 +125,8 @@ class RuntimePipeline:
|
|||||||
# Store bound plugins and MCP servers in query for filtering
|
# Store bound plugins and MCP servers in query for filtering
|
||||||
query.variables['_pipeline_bound_plugins'] = self.bound_plugins
|
query.variables['_pipeline_bound_plugins'] = self.bound_plugins
|
||||||
query.variables['_pipeline_bound_mcp_servers'] = self.bound_mcp_servers
|
query.variables['_pipeline_bound_mcp_servers'] = self.bound_mcp_servers
|
||||||
|
query.variables['_pipeline_mcp_resource_attachments'] = self.mcp_resource_attachments
|
||||||
|
query.variables['_pipeline_mcp_resource_agent_read_enabled'] = self.mcp_resource_agent_read_enabled
|
||||||
|
|
||||||
# Record query start for monitoring
|
# Record query start for monitoring
|
||||||
try:
|
try:
|
||||||
@@ -178,7 +189,7 @@ class RuntimePipeline:
|
|||||||
bot_name = query.variables.get('_monitoring_bot_name', 'Unknown')
|
bot_name = query.variables.get('_monitoring_bot_name', 'Unknown')
|
||||||
pipeline_name = query.variables.get('_monitoring_pipeline_name', 'Unknown')
|
pipeline_name = query.variables.get('_monitoring_pipeline_name', 'Unknown')
|
||||||
message_id = query.variables.get('_monitoring_message_id', '')
|
message_id = query.variables.get('_monitoring_message_id', '')
|
||||||
session_id = f'{query.launcher_type}_{query.launcher_id}'
|
session_id = f'{query.launcher_type.value if hasattr(query.launcher_type, "value") else query.launcher_type}_{query.launcher_id}'
|
||||||
|
|
||||||
# Update message status to error
|
# Update message status to error
|
||||||
if message_id:
|
if message_id:
|
||||||
@@ -284,9 +295,9 @@ class RuntimePipeline:
|
|||||||
# Record query start and store message_id
|
# Record query start and store message_id
|
||||||
message_id = ''
|
message_id = ''
|
||||||
try:
|
try:
|
||||||
from . import monitoring_helper
|
from . import monitor
|
||||||
|
|
||||||
message_id = await monitoring_helper.MonitoringHelper.record_query_start(
|
message_id = await monitor.MonitoringHelper.record_query_start(
|
||||||
ap=self.ap,
|
ap=self.ap,
|
||||||
query=query,
|
query=query,
|
||||||
bot_id=query.bot_uuid or 'unknown',
|
bot_id=query.bot_uuid or 'unknown',
|
||||||
@@ -338,7 +349,7 @@ class RuntimePipeline:
|
|||||||
# Record query success only if no error occurred during processing
|
# Record query success only if no error occurred during processing
|
||||||
if not query.variables.get('_monitoring_has_error', False):
|
if not query.variables.get('_monitoring_has_error', False):
|
||||||
try:
|
try:
|
||||||
await monitoring_helper.MonitoringHelper.record_query_success(
|
await monitor.MonitoringHelper.record_query_success(
|
||||||
ap=self.ap,
|
ap=self.ap,
|
||||||
message_id=message_id,
|
message_id=message_id,
|
||||||
query=query,
|
query=query,
|
||||||
@@ -348,7 +359,7 @@ class RuntimePipeline:
|
|||||||
|
|
||||||
# Record bot response message
|
# Record bot response message
|
||||||
try:
|
try:
|
||||||
await monitoring_helper.MonitoringHelper.record_query_response(
|
await monitor.MonitoringHelper.record_query_response(
|
||||||
ap=self.ap,
|
ap=self.ap,
|
||||||
query=query,
|
query=query,
|
||||||
bot_id=query.bot_uuid or 'unknown',
|
bot_id=query.bot_uuid or 'unknown',
|
||||||
@@ -367,9 +378,9 @@ class RuntimePipeline:
|
|||||||
|
|
||||||
# Record query error
|
# Record query error
|
||||||
try:
|
try:
|
||||||
from . import monitoring_helper
|
from . import monitor
|
||||||
|
|
||||||
await monitoring_helper.MonitoringHelper.record_query_error(
|
await monitor.MonitoringHelper.record_query_error(
|
||||||
ap=self.ap,
|
ap=self.ap,
|
||||||
query=query,
|
query=query,
|
||||||
bot_id=query.bot_uuid or 'unknown',
|
bot_id=query.bot_uuid or 'unknown',
|
||||||
@@ -384,7 +395,8 @@ class RuntimePipeline:
|
|||||||
|
|
||||||
finally:
|
finally:
|
||||||
self.ap.logger.debug(f'Query {query.query_id} processed')
|
self.ap.logger.debug(f'Query {query.query_id} processed')
|
||||||
del self.ap.query_pool.cached_queries[query.query_id]
|
# Use pop with default to avoid KeyError if query was never cached
|
||||||
|
self.ap.query_pool.cached_queries.pop(query.query_id, None)
|
||||||
|
|
||||||
|
|
||||||
class PipelineManager:
|
class PipelineManager:
|
||||||
|
|||||||
@@ -0,0 +1,274 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import traceback
|
||||||
|
import weakref
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||||
|
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PluginResponseSource:
|
||||||
|
plugin: dict[str, str]
|
||||||
|
event_name: str | None = None
|
||||||
|
is_approximate: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class QueryDiagnosticState:
|
||||||
|
pending_by_chain_id: dict[int, list[PluginResponseSource]] = field(default_factory=dict)
|
||||||
|
by_response_index: dict[int, list[PluginResponseSource]] = field(default_factory=dict)
|
||||||
|
finalizer: weakref.finalize | None = None
|
||||||
|
|
||||||
|
|
||||||
|
_QUERY_STATES: dict[int, QueryDiagnosticState] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def record_plugin_response_source(
|
||||||
|
query: pipeline_query.Query,
|
||||||
|
response_index: int,
|
||||||
|
response_sources: list[dict[str, Any]] | None,
|
||||||
|
emitted_plugins: list[dict[str, Any]] | None = None,
|
||||||
|
event_name: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
plugin_sources = _build_plugin_sources(response_sources, emitted_plugins, event_name)
|
||||||
|
if not plugin_sources:
|
||||||
|
return
|
||||||
|
state = _get_or_create_query_state(query)
|
||||||
|
state.by_response_index[response_index] = plugin_sources
|
||||||
|
|
||||||
|
|
||||||
|
def record_last_plugin_response_source(
|
||||||
|
query: pipeline_query.Query,
|
||||||
|
response_sources: list[dict[str, Any]] | None,
|
||||||
|
emitted_plugins: list[dict[str, Any]] | None = None,
|
||||||
|
event_name: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
record_plugin_response_source(
|
||||||
|
query,
|
||||||
|
len(query.resp_message_chain) - 1,
|
||||||
|
response_sources,
|
||||||
|
emitted_plugins,
|
||||||
|
event_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def record_pending_plugin_response_source(
|
||||||
|
query: pipeline_query.Query,
|
||||||
|
message_chain: platform_message.MessageChain,
|
||||||
|
response_sources: list[dict[str, Any]] | None,
|
||||||
|
emitted_plugins: list[dict[str, Any]] | None = None,
|
||||||
|
event_name: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
plugin_sources = _build_plugin_sources(response_sources, emitted_plugins, event_name)
|
||||||
|
if not plugin_sources:
|
||||||
|
return
|
||||||
|
state = _get_or_create_query_state(query)
|
||||||
|
state.pending_by_chain_id[id(message_chain)] = plugin_sources
|
||||||
|
|
||||||
|
|
||||||
|
def consume_pending_plugin_response_source(
|
||||||
|
query: pipeline_query.Query,
|
||||||
|
message_chain: platform_message.MessageChain,
|
||||||
|
response_index: int,
|
||||||
|
) -> None:
|
||||||
|
state = _get_query_state(query)
|
||||||
|
if state is None:
|
||||||
|
return
|
||||||
|
source = state.pending_by_chain_id.pop(id(message_chain), None)
|
||||||
|
if source is None:
|
||||||
|
return
|
||||||
|
state.by_response_index[response_index] = source
|
||||||
|
|
||||||
|
|
||||||
|
def clear_response_source(query: pipeline_query.Query, response_index: int) -> None:
|
||||||
|
state = _get_query_state(query)
|
||||||
|
if state is None:
|
||||||
|
return
|
||||||
|
state.by_response_index.pop(response_index, None)
|
||||||
|
_discard_query_state_if_empty(query)
|
||||||
|
|
||||||
|
|
||||||
|
async def notify_response_delivery_failure(
|
||||||
|
ap: Any,
|
||||||
|
query: pipeline_query.Query,
|
||||||
|
response_index: int,
|
||||||
|
message_chain: platform_message.MessageChain,
|
||||||
|
error: Exception,
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
plugin_refs = _get_response_sources(query, response_index)
|
||||||
|
if not plugin_refs:
|
||||||
|
return
|
||||||
|
connector = getattr(ap, 'plugin_connector', None)
|
||||||
|
if connector is None or not hasattr(connector, 'notify_plugin_diagnostic'):
|
||||||
|
return
|
||||||
|
for source in plugin_refs:
|
||||||
|
payload = _build_delivery_failure_payload(
|
||||||
|
plugin_ref=source.plugin,
|
||||||
|
event_name=source.event_name,
|
||||||
|
is_approximate=source.is_approximate,
|
||||||
|
query=query,
|
||||||
|
response_index=response_index,
|
||||||
|
message_chain=message_chain,
|
||||||
|
error=error,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await connector.notify_plugin_diagnostic(payload)
|
||||||
|
except Exception as diag_error:
|
||||||
|
_debug(ap, f'Plugin diagnostic forwarding failed: {diag_error}')
|
||||||
|
except Exception as diag_error:
|
||||||
|
_debug(ap, f'Plugin diagnostic forwarding skipped: {diag_error}')
|
||||||
|
|
||||||
|
|
||||||
|
def get_emitted_plugins(event_ctx: Any) -> list[dict[str, Any]]:
|
||||||
|
emitted_plugins = getattr(event_ctx, '_emitted_plugins', [])
|
||||||
|
return emitted_plugins if isinstance(emitted_plugins, list) else []
|
||||||
|
|
||||||
|
|
||||||
|
def get_response_sources(event_ctx: Any) -> list[dict[str, Any]] | None:
|
||||||
|
event_attrs = vars(event_ctx)
|
||||||
|
if '_response_sources' not in event_attrs:
|
||||||
|
return None
|
||||||
|
response_sources = event_attrs['_response_sources']
|
||||||
|
return response_sources if isinstance(response_sources, list) else []
|
||||||
|
|
||||||
|
|
||||||
|
def _get_or_create_query_state(query: pipeline_query.Query) -> QueryDiagnosticState:
|
||||||
|
query_key = id(query)
|
||||||
|
state = _QUERY_STATES.get(query_key)
|
||||||
|
if state is not None:
|
||||||
|
return state
|
||||||
|
|
||||||
|
state = QueryDiagnosticState()
|
||||||
|
try:
|
||||||
|
state.finalizer = weakref.finalize(query, _discard_query_state, query_key)
|
||||||
|
except TypeError:
|
||||||
|
state.finalizer = None
|
||||||
|
_QUERY_STATES[query_key] = state
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
def _get_query_state(query: pipeline_query.Query) -> QueryDiagnosticState | None:
|
||||||
|
return _QUERY_STATES.get(id(query))
|
||||||
|
|
||||||
|
|
||||||
|
def _discard_query_state(query_key: int) -> None:
|
||||||
|
_QUERY_STATES.pop(query_key, None)
|
||||||
|
|
||||||
|
|
||||||
|
def _discard_query_state_if_empty(query: pipeline_query.Query) -> None:
|
||||||
|
query_key = id(query)
|
||||||
|
state = _QUERY_STATES.get(query_key)
|
||||||
|
if state is None:
|
||||||
|
return
|
||||||
|
if state.pending_by_chain_id or state.by_response_index:
|
||||||
|
return
|
||||||
|
if state.finalizer is not None:
|
||||||
|
state.finalizer.detach()
|
||||||
|
_discard_query_state(query_key)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_response_sources(
|
||||||
|
query: pipeline_query.Query,
|
||||||
|
response_index: int,
|
||||||
|
) -> list[PluginResponseSource]:
|
||||||
|
state = _get_query_state(query)
|
||||||
|
if state is None:
|
||||||
|
return []
|
||||||
|
return state.by_response_index.get(response_index, [])
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_plugin_ref(plugin: Any) -> dict[str, str] | None:
|
||||||
|
manifest = plugin.get('manifest') if isinstance(plugin, dict) else None
|
||||||
|
metadata = manifest.get('metadata') if isinstance(manifest, dict) else None
|
||||||
|
if not isinstance(metadata, dict):
|
||||||
|
return None
|
||||||
|
author = metadata.get('author')
|
||||||
|
name = metadata.get('name')
|
||||||
|
if not author or not name:
|
||||||
|
return None
|
||||||
|
return {'author': str(author), 'name': str(name)}
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_response_source_plugin_ref(source: Any) -> dict[str, str] | None:
|
||||||
|
if not isinstance(source, dict):
|
||||||
|
return None
|
||||||
|
if source.get('kind') != 'reply_message_chain':
|
||||||
|
return None
|
||||||
|
plugin_ref = source.get('plugin')
|
||||||
|
if not isinstance(plugin_ref, dict):
|
||||||
|
return None
|
||||||
|
author = plugin_ref.get('author')
|
||||||
|
name = plugin_ref.get('name')
|
||||||
|
if not author or not name:
|
||||||
|
return None
|
||||||
|
return {'author': str(author), 'name': str(name)}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_plugin_sources(
|
||||||
|
response_sources: list[dict[str, Any]] | None,
|
||||||
|
emitted_plugins: list[dict[str, Any]] | None,
|
||||||
|
event_name: str | None,
|
||||||
|
) -> list[PluginResponseSource]:
|
||||||
|
if response_sources is not None:
|
||||||
|
plugin_refs = [_extract_response_source_plugin_ref(source) for source in response_sources]
|
||||||
|
return [
|
||||||
|
PluginResponseSource(plugin=plugin, event_name=event_name) for plugin in plugin_refs if plugin is not None
|
||||||
|
]
|
||||||
|
|
||||||
|
if emitted_plugins:
|
||||||
|
plugin_refs = [_extract_plugin_ref(plugin) for plugin in emitted_plugins]
|
||||||
|
return [
|
||||||
|
PluginResponseSource(plugin=plugin, event_name=event_name, is_approximate=True)
|
||||||
|
for plugin in plugin_refs
|
||||||
|
if plugin is not None
|
||||||
|
]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _debug(ap: Any, message: str) -> None:
|
||||||
|
logger = getattr(ap, 'logger', None)
|
||||||
|
if logger is not None:
|
||||||
|
logger.debug(message)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_delivery_failure_payload(
|
||||||
|
plugin_ref: dict[str, str],
|
||||||
|
event_name: str | None,
|
||||||
|
is_approximate: bool,
|
||||||
|
query: pipeline_query.Query,
|
||||||
|
response_index: int,
|
||||||
|
message_chain: platform_message.MessageChain,
|
||||||
|
error: Exception,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
details: dict[str, Any] = {
|
||||||
|
'message_component_types': [component.__class__.__name__ for component in message_chain],
|
||||||
|
'message_preview': str(message_chain)[:200],
|
||||||
|
}
|
||||||
|
if is_approximate:
|
||||||
|
details['attribution_warning'] = (
|
||||||
|
'This diagnostic was delivered to all plugins that handled the event because the '
|
||||||
|
'plugin runtime did not report the exact reply_message_chain source.'
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'level': 'ERROR',
|
||||||
|
'code': 'response_delivery_failed',
|
||||||
|
'message': 'Failed to deliver a plugin-provided response message.',
|
||||||
|
'plugin': plugin_ref,
|
||||||
|
'query': {
|
||||||
|
'query_id': query.query_id,
|
||||||
|
'event_name': event_name or query.message_event.__class__.__name__,
|
||||||
|
'stage': query.current_stage_name or 'SendResponseBackStage',
|
||||||
|
'response_index': response_index,
|
||||||
|
},
|
||||||
|
'details': details,
|
||||||
|
'delivery': {
|
||||||
|
'error_type': error.__class__.__name__,
|
||||||
|
'error_message': str(error),
|
||||||
|
'traceback': traceback.format_exception_only(type(error), error)[-1].strip(),
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -25,6 +25,21 @@ class PreProcessor(stage.PipelineStage):
|
|||||||
- use_funcs
|
- use_funcs
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _filter_selected_tools(
|
||||||
|
tools: list,
|
||||||
|
local_agent_config: dict,
|
||||||
|
) -> list:
|
||||||
|
if local_agent_config.get('enable-all-tools', True) is not False:
|
||||||
|
return tools
|
||||||
|
|
||||||
|
selected_tools = local_agent_config.get('tools', [])
|
||||||
|
if not isinstance(selected_tools, list):
|
||||||
|
return []
|
||||||
|
|
||||||
|
selected_tool_names = {tool for tool in selected_tools if isinstance(tool, str)}
|
||||||
|
return [tool for tool in tools if tool.name in selected_tool_names]
|
||||||
|
|
||||||
async def process(
|
async def process(
|
||||||
self,
|
self,
|
||||||
query: pipeline_query.Query,
|
query: pipeline_query.Query,
|
||||||
@@ -32,6 +47,7 @@ class PreProcessor(stage.PipelineStage):
|
|||||||
) -> entities.StageProcessResult:
|
) -> entities.StageProcessResult:
|
||||||
"""Process"""
|
"""Process"""
|
||||||
selected_runner = query.pipeline_config['ai']['runner']['runner']
|
selected_runner = query.pipeline_config['ai']['runner']['runner']
|
||||||
|
local_agent_config = query.pipeline_config.get('ai', {}).get('local-agent', {})
|
||||||
include_skill_authoring = (
|
include_skill_authoring = (
|
||||||
selected_runner == 'local-agent' and getattr(self.ap, 'skill_service', None) is not None
|
selected_runner == 'local-agent' and getattr(self.ap, 'skill_service', None) is not None
|
||||||
)
|
)
|
||||||
@@ -43,7 +59,7 @@ class PreProcessor(stage.PipelineStage):
|
|||||||
if selected_runner == 'local-agent':
|
if selected_runner == 'local-agent':
|
||||||
# Read model config — new format is { primary: str, fallbacks: [str] },
|
# Read model config — new format is { primary: str, fallbacks: [str] },
|
||||||
# but handle legacy plain string for backward compatibility
|
# but handle legacy plain string for backward compatibility
|
||||||
model_config = query.pipeline_config['ai']['local-agent'].get('model', {})
|
model_config = local_agent_config.get('model', {})
|
||||||
if isinstance(model_config, str):
|
if isinstance(model_config, str):
|
||||||
# Legacy format: plain UUID string
|
# Legacy format: plain UUID string
|
||||||
primary_uuid = model_config
|
primary_uuid = model_config
|
||||||
@@ -113,11 +129,14 @@ class PreProcessor(stage.PipelineStage):
|
|||||||
# Get bound plugins and MCP servers for filtering tools
|
# Get bound plugins and MCP servers for filtering tools
|
||||||
bound_plugins = query.variables.get('_pipeline_bound_plugins', None)
|
bound_plugins = query.variables.get('_pipeline_bound_plugins', None)
|
||||||
bound_mcp_servers = query.variables.get('_pipeline_bound_mcp_servers', None)
|
bound_mcp_servers = query.variables.get('_pipeline_bound_mcp_servers', None)
|
||||||
query.use_funcs = await self.ap.tool_mgr.get_all_tools(
|
include_mcp_resource_tools = query.variables.get('_pipeline_mcp_resource_agent_read_enabled', True)
|
||||||
|
all_tools = await self.ap.tool_mgr.get_all_tools(
|
||||||
bound_plugins,
|
bound_plugins,
|
||||||
bound_mcp_servers,
|
bound_mcp_servers,
|
||||||
include_skill_authoring=include_skill_authoring,
|
include_skill_authoring=include_skill_authoring,
|
||||||
|
include_mcp_resource_tools=include_mcp_resource_tools,
|
||||||
)
|
)
|
||||||
|
query.use_funcs = self._filter_selected_tools(all_tools, local_agent_config)
|
||||||
|
|
||||||
self.ap.logger.debug(f'Bound plugins: {bound_plugins}')
|
self.ap.logger.debug(f'Bound plugins: {bound_plugins}')
|
||||||
self.ap.logger.debug(f'Bound MCP servers: {bound_mcp_servers}')
|
self.ap.logger.debug(f'Bound MCP servers: {bound_mcp_servers}')
|
||||||
@@ -128,11 +147,14 @@ class PreProcessor(stage.PipelineStage):
|
|||||||
if not query.use_funcs and query.variables.get('_fallback_model_uuids'):
|
if not query.use_funcs and query.variables.get('_fallback_model_uuids'):
|
||||||
bound_plugins = query.variables.get('_pipeline_bound_plugins', None)
|
bound_plugins = query.variables.get('_pipeline_bound_plugins', None)
|
||||||
bound_mcp_servers = query.variables.get('_pipeline_bound_mcp_servers', None)
|
bound_mcp_servers = query.variables.get('_pipeline_bound_mcp_servers', None)
|
||||||
query.use_funcs = await self.ap.tool_mgr.get_all_tools(
|
include_mcp_resource_tools = query.variables.get('_pipeline_mcp_resource_agent_read_enabled', True)
|
||||||
|
all_tools = await self.ap.tool_mgr.get_all_tools(
|
||||||
bound_plugins,
|
bound_plugins,
|
||||||
bound_mcp_servers,
|
bound_mcp_servers,
|
||||||
include_skill_authoring=include_skill_authoring,
|
include_skill_authoring=include_skill_authoring,
|
||||||
|
include_mcp_resource_tools=include_mcp_resource_tools,
|
||||||
)
|
)
|
||||||
|
query.use_funcs = self._filter_selected_tools(all_tools, local_agent_config)
|
||||||
|
|
||||||
sender_name = ''
|
sender_name = ''
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user