Compare commits

..

73 Commits

Author SHA1 Message Date
huanghuoguoguo
9fa3251f3d fix(tools): decouple runtime from agent runner 2026-06-14 21:15:21 +08:00
huanghuoguoguo
64b7e9c509 fix(tools): clear stale Python workspace env locks 2026-06-14 11:32:10 +08:00
huanghuoguoguo
7b67dcc302 fix(tools): bootstrap Python workspaces with available interpreter 2026-06-14 11:32:10 +08:00
huanghuoguoguo
a60827f221 fix(tools): harden agent runner tool runtimes 2026-06-14 11:32:10 +08:00
huanghuoguoguo
e9fe2f2d43 feat(agent-runner): support host tool lookup (#2244) 2026-06-14 11:29:57 +08:00
huanghuoguoguo
27be09ab15 fix(provider): preserve litellm usage details (#2246) 2026-06-14 11:12:29 +08:00
huanghuoguoguo
1ef4507d9a [codex] Delegate web page bot stream helpers (#2245)
* fix(platform): delegate web page bot stream helpers

* style(platform): format web page bot adapter
2026-06-14 10:57:53 +08:00
RockChinQ
2e7978317c chore(release): bump version to 4.10.2 2026-06-13 11:21:44 -04:00
RockChinQ
b7d8332cb0 feat(telemetry): include instance_create_ts in heartbeat payload
Load the instance creation timestamp from data/labels/instance_id.json
(backfilling+persisting it for instances created before the field existed),
expose it as constants.instance_create_ts, and include it in the heartbeat
payload so Space can anchor Time-To-Value / onboarding analytics on real
install time rather than first-heartbeat.

Verified: py_compile, ruff, pytest tests/unit_tests/telemetry/ (37 passed).
2026-06-13 11:13:18 -04:00
huanghuoguoguo
7fe3eedeea fix(provider): use LiteLLM input window for context length (#2243) 2026-06-13 21:27:47 +08:00
RockChinQ
b6fde30aa7 style(plugins): ruff format logs route 2026-06-13 08:03:29 -04:00
RockChinQ
5bfa38cbf2 feat(plugins): show plugin logs on detail page via Docs/Logs tablist
Add a Logs tab beside Documentation on the plugin detail page, showing
the output a plugin prints through the standard Python logger (per the
wiki style guide). Logs are captured from the plugin's stderr by the
plugin runtime and fetched on demand.

- Bump langbot-plugin pin to 0.4.4 (adds GET_PLUGIN_LOGS action)
- plugin_connector/handler: get_plugin_logs RPC client
- HTTP route GET /api/v1/plugins/<author>/<name>/logs (limit + level)
- Frontend: wrap detail right panel in Docs/Logs Tabs; PluginLogs
  component with level filter, manual + 3s auto refresh, bottom-follow
- i18n: 7 new keys across all 8 locales
2026-06-13 08:01:18 -04:00
RockChinQ
a97d2040bb fix(i18n,api): backfill missing token-monitoring keys and fix JWT expiry tz
- i18n: add models.searchProviders, monitoring.tabs.tokens and the
  monitoring.tokens.* block (incl. bucket.hour/day) to es-ES, ja-JP,
  ru-RU, th-TH, vi-VN and zh-Hant, which were missing them and failed
  the Check i18n Keys CI.
- api: generate_jwt_token built 'exp' from a naive datetime.now(), which
  PyJWT validates against UTC — in any timezone ahead of UTC the token
  was already expired at issue time. Use datetime.now(timezone.utc).
2026-06-13 05:26:18 -04:00
RockChinQ
a2c6c8201b refactor(persistence): freeze legacy DB migration chain, drop dbm026
The legacy pkg/persistence/migrations (DBMigration / dbmXXX) system now
coexists with Alembic but accepts no new migrations — all new schema
changes go through Alembic.

- remove dbm026_llm_model_context_length (superseded by Alembic
  0005_add_llm_context_length, which makes the identical change)
- cap required_database_version at 25 (legacy chain dbm001-025 kept
  read-only to upgrade pre-existing 3.x DBs to the Alembic baseline)
- add migrations/README.md documenting the freeze
- document the Alembic-only policy and revision-id/idempotency rules in
  AGENTS.md
2026-06-13 05:26:08 -04:00
RockChinQ
672abfe95d refactor(core): remove pre-3.x legacy config migration system
The pkg/core/migrations system (m001-m043 DBMigration-style config
migrations, MigrationStage, and the core.migration base class) only ever
ran when upgrading from LangBot 3.x. The last 3.x release is over a year
old and is no longer supported, so this dead code is removed entirely:

- delete pkg/core/migrations/ (43 mXXX_*.py + __init__)
- delete pkg/core/migration.py (base class + registry)
- delete pkg/core/stages/migrate.py (MigrationStage)
- drop 'MigrationStage' from boot.py stage_order
- delete tests/unit_tests/core/test_migration.py (tested the removed base class)
2026-06-13 05:26:01 -04:00
huanghuoguoguo
9ecb587ac0 refactor(provider): use LiteLLM as unified LLM requester backend (#2150)
* refactor(provider): use LiteLLM as unified LLM requester backend

  - Replace 23+ individual requester implementations with unified litellmchat.py
  - Add litellm_provider field to 27 YAML manifests for provider routing
  - Delete redundant requester subclasses
  - Add unit tests for LiteLLMRequester (29 tests)
  - Fix num_retries parameter name (was max_retries)
  - Fix exception handling order for subclass exceptions

  LiteLLM provides unified API for 100+ providers, eliminating need for
  provider-specific requesters.

* fix: ruff format provider.py

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(provider): simplify LiteLLM requester usage handling

  - Remove unused Anthropic-specific tool schema generation
  - Share completion argument construction between normal and streaming calls
  - Use LiteLLM/OpenAI native usage fields for monitoring
  - Collect stream token usage from LiteLLM stream_options
  - Update LiteLLM requester tests for unified usage fields

* restore: restore deleted provider requester files

Restore individual provider requester implementations that were
removed in de61b5d3. These files coexist with the unified
litellmchat.py backend.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat: update requesters and improve provider selection UI

- Added `litellm_provider` field to various requesters' YAML configurations.
- Removed obsolete Python requester files for OpenRouter, PPIO, QHAIGC, ShengSuanYun, SiliconFlow, Space, TokenPony, VolcArk, and Xai.
- Introduced new requesters for Tencent and Together AI with corresponding YAML configurations and SVG icons.
- Enhanced the ProviderForm component to include a searchable dropdown for selecting providers, improving user experience.
- Updated localization files to include search provider text for both English and Chinese.

* fix(provider): align litellm rebase with master

* fix(provider): capture streaming token usage; add token observability

The LiteLLM streaming requester only captured usage when a chunk had an
empty `choices` list. Many OpenAI-compatible gateways (e.g. new-api) and
providers send the final usage payload in a chunk that still carries an
empty-delta choice, so streamed calls always recorded 0 tokens in the
monitoring logs/dashboard (non-streaming worked).

- Capture stream usage whenever a chunk carries it, regardless of choices
- Add robust _normalize_usage (dict/obj shapes, derive missing total_tokens)
- Register litellm in bootutils/deps.py (was in pyproject only)
- Add MonitoringService.get_token_statistics + /monitoring/token-statistics
  endpoint: summary, per-model breakdown, token timeseries, and a
  zero-token-success data-quality signal
- Add TokenMonitoring dashboard tab (summary tiles, stacked token chart,
  per-model table) + i18n (en/zh)
- Regression tests for stream usage capture and usage normalization

Verified end-to-end against a real OpenAI-compatible endpoint with
gpt-5.5 and claude-opus-4-8: tokens now recorded non-zero for both
streaming and non-streaming paths.

* refactor(provider): simplify litellm capabilities

* style: simplify wrapped expressions

* feat(models): persist context metadata

* fix(provider): handle dict embeddings and openai-compatible rerank in LiteLLMRequester

- invoke_embedding: support both object- and dict-shaped response.data
  entries (OpenAI-compatible gateways like new-api return dicts)
- invoke_rerank: litellm.arerank rejects the 'openai' provider, so for
  openai-compatible (or unspecified) providers call the standard
  Jina/Cohere-style POST /v1/rerank endpoint directly over HTTP
- accept both 'relevance_score' and 'score' fields in rerank results
- add unit tests for the openai-compatible HTTP rerank path

* feat(provider): enforce requester support_type when adding models

- frontend: AddModelPopover only shows model-type tabs (llm/embedding/
  rerank) that the provider's requester declares in its manifest
  support_type; ModelsDialog fetches requester manifests and maps
  requester -> support_type, passed down through ProviderCard
- backend: add _validate_provider_supports guard in create_llm_model /
  create_embedding_model / create_rerank_model so a model cannot be
  attached to a provider whose requester does not support that type,
  even if the frontend restriction is bypassed (manifests without
  support_type are allowed for backward compatibility)
- manifests: correct support_type for providers that do not offer all
  three model types:
  - llm only: anthropic, deepseek, groq, moonshot, openrouter, xai
  - llm + text-embedding: openai, gemini, mistral
  - add rerank to new-api (verified working via /v1/rerank)
  - set llm + text-embedding + rerank for aggregator/unknown gateways

* feat(provider): add searchable alias to requester manifests

- add a free-text 'alias' field to every requester manifest spec,
  containing the vendor's English/Chinese names, pinyin, common
  nicknames and flagship model-series names (e.g. moonshot -> kimi,
  月之暗面; zhipu -> glm, 智谱清言)
- frontend: ProviderForm requester search now also matches against
  alias (substring/contains), so searching 'kimi' surfaces Moonshot,
  '硅基' surfaces SiliconFlow, etc.
- also fix support_type: openrouter (relay) supports embedding+rerank;
  LangBot Space gains rerank (coming soon)

* fix(provider): make support_type guard defensive against incomplete model_mgr

- _validate_provider_supports now uses getattr to gracefully skip when
  model_mgr / provider_dict / manifest lookup is unavailable, instead of
  raising AttributeError (fixes unit tests that mock ap.model_mgr as a
  bare SimpleNamespace)
- add TestValidateProviderSupports covering: allow supported type,
  reject unsupported type, allow when support_type missing, allow when
  provider unknown, degrade safely when model_mgr is incomplete

* fix(persistence): guard 0004 migration against missing llm_models table

The 0004_add_llm_model_context_length migration called
inspector.get_columns('llm_models') unconditionally, raising
NoSuchTableError when the table does not exist (e.g. migrating a
fresh/empty DB, as exercised by the integration tests where
create_all() registers no tables because the ORM models are not
imported). Every other migration guards with a table-existence check
first; add the same guard here for both upgrade and downgrade.

Also restore the test head assertion to 0004 (it had been lowered to
0003 to mask this failure).

* Merge branch 'master' into feat/litellm

Resolve conflicts:
- uv.lock: regenerated via 'uv lock' to reconcile litellm/fastuuid
  (ours) with openai bump (master).
- Alembic migrations: master added 0004_add_mcp_readme while this
  branch added 0004_add_llm_model_context_length, both as children of
  0003 (would create multiple heads). Re-chain the litellm migration as
  0005_add_llm_model_context_length with down_revision=0004_add_mcp_readme
  for a single linear head. Update test head assertion accordingly.

* fix(persistence): shorten migration revision id to fit varchar(32)

PostgreSQL stores alembic_version.version_num as varchar(32).
'0005_add_llm_model_context_length' (33 chars) overflowed it, raising
StringDataRightTruncationError in the PG migration tests. Rename the
revision (and file) to '0005_add_llm_context_length' (27 chars) and
update the head assertions in both SQLite and PostgreSQL migration
tests.

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: fdc310 <2213070223@qq.com>
Co-authored-by: RockChinQ <rockchinq@gmail.com>
2026-06-13 16:59:48 +08:00
RockChinQ
7965d333ac fix(mcp): read stdio args from form state in testMcp to avoid stale closure
The MCP detail page invokes testMcp() through an imperative handle
(formRef.current.testMcp()). The handle closure is only refreshed when
[mcpTesting] changes, so testMcp read a stale snapshot of the stdioArgs/
extraArgs React state — on the detail page that snapshot is the empty
initial [], so stdio 'args' were dropped entirely. The sandbox then
launched 'uvx' with no package, which exits 2 and surfaces only an opaque
'Connection closed' with no detail.

Read command/args/env via form.getValues() (kept in sync on every edit and
on load) instead of the captured state, matching how 'command' was already
read. Fixes stdio MCP test failing with empty args on the detail page.
2026-06-13 01:56:03 -04:00
RockChinQ
f7300f1473 chore(deps): bump langbot-plugin 0.4.2 -> 0.4.3
Picks up the nsjail Box backend fix: correct cgroup v2 detection (probe
cgroup.subtree_control instead of mkdir, fixing the private-cgroupns EBUSY
false-positive) and removal of the RLIMIT_AS memory cap that instantly
killed uv/node-based stdio MCP servers (exit 255). Containerized nsjail
deployments now require the host cgroup namespace (--cgroupns=host).
2026-06-13 01:00:00 -04:00
RockChinQ
2b6dcfe9c7 feat(survey): add bot_response_success_100 milestone trigger event
Counts successful non-WebSocket bot responses (persisted in the metadata
table as survey_bot_response_count, survives restarts) and fires the
bot_response_success_100 survey event once the instance reaches 100
responses. Counting stops after the milestone has been triggered.

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 11:49:30 +08:00
RockChinQ
0551d22689 chore(release): bump version to 4.10.1 2026-06-09 13:32:58 -04:00
RockChinQ
53d4edb609 fix(dify): send 'user' as plain form field in file upload
The multipart tuple form (None, user) is httpx 'files=' syntax for a part
with no filename; placed under 'data=' it expanded into a stray user=None
field, so Dify associated the uploaded file with the wrong user and the
workflow never received the image. Send 'user' as a plain string.
2026-06-09 10:43:55 -04:00
RockChinQ
f897987ac1 chore(deps): bump langbot-plugin to 0.4.2 (stable) 2026-06-09 09:52:07 -04:00
Junyan Chin
8e558ad3a1 Feat/saas sandbox adaptation (#2234)
* fix(box): trust Box-reported skill paths when filesystem is not shared

In separated deployments (Docker Compose, k8s sidecar, --standalone-box,
remote runtime.endpoint) the Box runtime owns its own filesystem, so the
skill package_root it reports via list_skills is not resolvable on the
LangBot side. LangBot's reload_skills and build_skill_extra_mounts
validated those paths with os.path.isdir() against its own filesystem,
which silently dropped every skill in such deployments — breaking the
sandbox skill feature for the nsjail/SaaS backend.

Add BoxService.shares_filesystem_with_box, derived from the connector
transport (stdio = shared, WebSocket = separated), with an explicit
override seam for tests/embedders. Gate both isdir() guards on it: keep
local validation in shared-fs stdio mode, trust Box-reported paths
otherwise. The Box runtime only reports skills found on its own
filesystem, so those paths are valid there by construction.

Adds topology-derivation tests (real connector, no mocks) and
skill-retention tests for both shared and separated filesystems.

* build(docker): ship a self-contained nsjail sandbox backend in the image

Compile nsjail 3.6 from source in a dedicated multi-stage build and carry
only the binary plus its runtime libs (libprotobuf32, libnl-route-3-200)
into the final image. This lets the Box runtime isolate sandboxed code via
nsjail user/mount/pid/net namespaces without a host Docker socket — the
prerequisite for running Box on LangBot Cloud (k8s), where mounting
docker.sock would grant node root and is not acceptable for multi-tenant.

The build toolchain (build-essential/bison/flex/protobuf-dev/libnl-dev)
stays in the nsjail-build stage and is not present in the shipped image.

Verified: image builds (583MB), nsjail --help exits 0, libraries resolve,
and the real NsjailBackend executes an isolated command end-to-end on a
v6.1/cgroup2 host matching LangBot Cloud prod (rlimit fallback path, since
container /sys/fs/cgroup is read-only; PID-namespace isolation confirmed).

* feat(box): SaaS guard to force a single global sandbox scope

Add system.limitation.force_box_session_id_template: when non-empty it
overrides every pipeline's box-session-id-template at resolve time, pinning
all queries to one shared sandbox (e.g. {global}). This is the authoritative,
unbypassable guard — it runs on every exec call, so editing the pipeline
config via API cannot escape it. The web UI locks the Sandbox Scope selector
via a combined box_scope_editable flag (box available AND not forced).

* build(deps): pin langbot-plugin==0.4.2b1 (nsjail cgroup container-safety beta)

* fix(web): show forced sandbox scope + make disabled tooltip tap-friendly

When a SaaS deployment pins every pipeline to a fixed sandbox scope via
system.limitation.force_box_session_id_template, the Sandbox Scope selector was
correctly locked but still displayed the pipeline's stored value (e.g. the
per-chat default), misrepresenting the scope that the runtime actually enforces
on every exec. Coerce the displayed/saved value to the forced template so the
locked selector truthfully shows the active scope (e.g. Global).

Also fix the disabled_tooltip being invisible on touch devices: hover-only Radix
tooltips never open without a pointer, so the explanation of why the field is
locked could not be read on mobile. Wrap the info icon so a tap toggles the
tooltip while desktop hover still works.

* feat(web): hide sidebar new-version prompt for edition=cloud

Cloud instances are upgraded centrally by the operator, so surfacing a GitHub
'new version available' badge to tenants is misleading and actionable only by
the operator. Skip the release check entirely when edition=cloud.

* style(web): prettier formatting for DisabledTooltipIcon ternary

* chore(deps): bump langbot-plugin to 0.4.2b2

Picks up the SDK fix that creates a read-write host_path before the
nsjail bind-mount, fixing the SaaS MCP shared-workspace sandbox failure
(exec exit 255 with empty output when host_path didn't exist).

* chore(deps): bump langbot-plugin to 0.4.2b3

Picks up the nsjail /dev-node fix so stdio MCP servers (uvx-launched) can
start under force_global_sandbox instead of failing with 'Connection closed
/ please check URL'.

* fix(web): show real MCP runtime status on installed extensions list

The installed-extensions list badge keyed solely off the enable flag, so a
server that was still CONNECTING (or in ERROR) was shown as 'Connected'.
Reflect the actual runtime_info.status (connecting/connected/error/disabled)
with matching colors, and poll quietly every 3s while any MCP server is
connecting so the badge transitions without a manual refresh.

* chore(deps): bump langbot-plugin to 0.4.2b4

Picks up the 30s start_managed_process timeout so cold uvx MCP bootstraps
don't get torn down mid-install.

* style(web): satisfy prettier — parenthesize nullish-coalescing in ternary

* fix(mcp): isolate transient test sessions from the shared Box session

A config-page 'test' (server_name='_', no persisted UUID) ran in the same
shared 'mcp-shared' Box session as live MCP servers. A failing test (e.g.
empty args) churned that shared session and tore down healthy, already-
connected servers — leaving them stuck after exhausting their retries.

Mark UUID-less sessions as transient, give them their own isolated Box
session ('mcp-test-<uuid>'), and fully delete that session on cleanup so
tests can never disturb live servers and don't leak sessions.

* fix(mcp): tear down transient test session after test completes

A successful config-page test left its isolated 'mcp-test-<uuid>' Box
session running (the lifecycle task blocks until shutdown). Wrap the
transient test coroutine so it always shuts the session down afterward,
preventing isolated test sessions from leaking.
2026-06-09 19:30:17 +08:00
RockChinQ
47fe9bde03 docs(docker): move k8s deployment docs to wiki, drop README_K8S.md
The Kubernetes deployment guide now lives only in the wiki
(docs.langbot.app -> Installation -> Kubernetes). Remove the in-repo
docker/README_K8S.md, repoint the README language variants and the
docker-compose / kubernetes.yaml header comments to the wiki, and keep
kubernetes.yaml self-describing via inline comments.
2026-06-07 11:36:39 -04:00
RockChinQ
5c3a619e2d docs(docker): add Box sandbox runtime to k8s manifest and deploy guide
The k8s manifest was missing the Box runtime that backs the sandbox
tools, the activate skill tool, skill add/edit and stdio MCP. Add a
langbot-box Deployment/Service (port 5410), wire langbot to it via
BOX__RUNTIME__ENDPOINT (explicit Service name since the in-container
default langbot_box uses an underscore, invalid for k8s DNS), and share
the Box workspace root as a node hostPath pinned via podAffinity so the
node Docker daemon resolves bind-mount paths consistently. Document the
component, the shared-FS constraint, security implications and readiness
checks in README_K8S.md (zh + en).
2026-06-07 11:18:27 -04:00
RockChinQ
e223edeb45 docs(agents): add --standalone-box flag and box config keys 2026-06-07 08:57:43 -04:00
RockChinQ
d2c3146334 docs(agents): refresh AGENTS.md for current architecture and runtime/box debugging 2026-06-07 08:43:30 -04:00
Haoxuan Xing
7d9c8e3065 Merge pull request #2231 from langbot-app/TyperBody-patch-1
Update key capabilities in README.md
2026-06-07 13:08:19 +08:00
Haoxuan Xing
f12ed81e1e Update key capabilities in README.md
Added links to Deerflow and Weknora in the capabilities section.
2026-06-07 13:05:46 +08:00
Haoxuan Xing
6d4d19b6d7 Merge pull request #2230 from langbot-app/feat/addweknoradeerflow
Add DeerFlow LangGraph API as a Provider Runner
2026-06-07 12:22:55 +08:00
Typer_Body
07b90f12a2 ruff3 2026-06-07 02:38:05 +08:00
Typer_Body
fd896c6974 ruff2 2026-06-07 02:35:10 +08:00
Typer_Body
1fbfa868fb ruff 2026-06-07 02:31:42 +08:00
Typer_Body
ad05819c2e readme 2026-06-07 02:26:25 +08:00
Typer_Body
0c6f71738c deerflow 2026-06-07 02:17:40 +08:00
Typer_Body
af451e7006 weknora2 2026-06-07 01:14:02 +08:00
Typer_Body
59f20bcc73 weknora 2026-06-07 01:08:39 +08:00
RockChinQ
7eca3cdfca feat(web): show sub-entity name in document title on detail pages
Detail pages (plugin / MCP / pipeline / knowledge base / skill) only showed
the type in the tab title. Drive the /home document title from HomeLayout,
which has the selected entity name via context: '<entity> · <type> · LangBot'
when a sub-entity is open, '<type> · LangBot' otherwise. The top-level hook
now skips /home and only handles login/register/reset-password/wizard.
Type label falls back to a route-derived i18n key on direct page loads.
2026-06-06 12:12:08 -04:00
RockChinQ
c40354f838 feat(web): dynamic document title per route
The browser tab title was hard-coded to 'LangBot' in index.html and never
changed. Add a useDocumentTitle hook that maps the active route to an
existing i18n key and sets document.title to '<page> · LangBot', driven by
a new top-level RootLayout route element. Re-runs on navigation and on
language change so the title stays localized. Falls back to the bare app
name for unmapped routes.
2026-06-06 12:07:41 -04:00
RockChinQ
21a5b4658a fix(plugin-market): keep fixed card width regardless of result count
The result grid used auto-fit tracks, so a single search result stretched
to fill the whole row. Switch to fixed responsive column counts (1/2/3/4
across breakpoints), matching langbot-space, so cards keep a consistent
max width no matter how many results are shown.
2026-06-06 11:40:02 -04:00
RockChinQ
073acaa053 feat(plugin-market): move extension count into search box placeholder
Mirror the langbot-space marketplace change: drop the '共 xxx 个扩展'
stats line below the tag filter, surface the count in the search
placeholder ('搜索 xxx 个扩展、能力或场景...') when no query is active,
and show the total at the bottom via allLoadedCount when searching.
Adds searchPlaceholderCount + allLoadedCount to all 8 locales.
2026-06-06 11:33:46 -04:00
RockChinQ
38759b229d feat(plugin-market): show per-format extension counts in type filter
Mirror the LangBot Space marketplace: the advanced-filter type options
(plugin / MCP / skill) now display their live extension count, e.g.
"插件 (74)". Counts are fetched on mount via three lightweight
searchMarketplaceExtensions calls (page_size=1) reading total per type.
The all-formats option intentionally shows no count.
2026-06-06 08:11:59 -04:00
RockChinQ
efe32e34ae fix(deps): patch Dependabot vulnerability alerts (Python + web)
Python (pyproject.toml + uv.lock):
- aiohttp 3.13.5->3.14.0, langchain-core 1.3.2->1.4.1, langsmith 0.7.36->0.8.9,
  lxml 6.0.2->6.1.1, Mako 1.3.11->1.3.12, PyJWT 2.11.0->2.13.0,
  python-multipart 0.0.26->0.0.32, urllib3 2.6.3->2.7.0, Pygments 2.19.2->2.20.0,
  idna 3.11->3.18, pip 26.0->26.1.2, python-dotenv 1.2.1->1.2.2,
  requests 2.32.5->2.34.2, starlette 0.52.1->1.2.1, uv 0.11.7->0.11.19

web (package.json + both lockfiles):
- axios ->1.17.0, postcss ->8.5.15, react-router(-dom) ->7.17.0 (direct)
- overrides for transitive: flatted >=3.4.2, follow-redirects >=1.16.0,
  minimatch (3.1.3 / 9.0.7), picomatch (2.3.2 / 4.0.4)
- regenerated both package-lock.json and pnpm-lock.yaml in sync

Verified: uv sync + core imports OK; pnpm --frozen-lockfile + tsc + vite build pass.

Not fixable (no upstream patch yet, tracked separately):
- chromadb (critical, <=1.5.9 is latest) — awaiting upstream release
- PyPDF2 (medium, deprecated; needs migration to pypdf, code change)
2026-06-06 06:06:59 -04:00
Junyan Chin
46db4de11a Update QQ Group link in README_CN.md 2026-06-06 17:20:19 +08:00
RockChinQ
170a6756f4 fix(add-extension): load real icon in install confirm dialog from URL params
When the install confirm dialog is opened via URL query params (e.g. from a
marketplace deep link), installInfo carried no icon, so the icon fell back to
the /resources/icon endpoint which 404s for extensions whose icon is an
external URL (simpleicons / iconify), showing a Package placeholder.

Fetch the icon from the marketplace detail API (mcp/skill/plugin) after opening
the dialog and inject it into installInfo, and reset the icon-failed state when
the resolved URL changes so the <img> retries instead of sticking on the
placeholder.
2026-06-06 04:45:46 -04:00
RockChinQ
7330732f62 fix(ci): bump migration head assertion to 0004, apply prettier
- Update test_migrations / test_migrations_postgres head assertion from
  0003 to 0004 after adding the mcp readme migration.
- Reformat MCPForm.tsx / MCPReadme.tsx to satisfy prettier/prettier.
2026-06-06 03:56:14 -04:00
RockChinQ
b08e5ca09a feat(mcp): add Docs/Tools tablist on detail page, tidy sidebar label
Wrap the MCP detail right panel in a compact left-aligned Docs/Tools
tablist (Docs first). Move the tool count into the Tools tab label and
drop the redundant panel title/subtitle; connecting/failed states still
render the status component. Shorten the sidebar 'Installed Extensions'
entry to 'Installed' across all 8 locales, and add tabTools/tabDocs/
noReadme strings.
2026-06-06 03:52:17 -04:00
RockChinQ
dff80a0c0a fix(marketplace): use external icon URL when icon field is absolute
Many MCP / skill records store their icon as an absolute external URL
(simpleicons.org / iconify.design) rather than an uploaded file, so the
/resources/icon endpoint 404s and the card icon breaks. Add
resolveMarketplaceIconURL() which prefers an absolute http(s) icon field
and otherwise falls back to the resources endpoint.
2026-06-06 03:52:09 -04:00
RockChinQ
f54ae4b91c feat(mcp): persist and display marketplace README
Capture the README markdown from LangBot Space when installing an MCP
server and store it on the mcp_servers record (new readme column +
alembic migration 0004). The detail page can then render docs offline,
independent of the server's runtime/connection state.
2026-06-06 03:52:00 -04:00
RockChinQ
e5b3cced1f feat(market): show 24 plugins per page 2026-06-05 11:33:02 -04:00
Junyan Qin
101e04db6d feat(web): add Discord link to sidebar account menu
Add a "Join our Discord" entry to the account dropdown's external-links
group, opening https://discord.gg/wdNEHETs87 in a new tab. lucide-react
has no Discord brand glyph, so include a small inline Discord SVG icon
(brand color). Add the joinDiscord label to all 8 locales.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 22:26:55 +08:00
Junyan Qin
b79edda3a7 style(web): give extension cards a subtle border
The softened shadow alone left cards with no visible edge against the
page background. Add `border border-border` so each card has a clear,
restrained boundary while keeping the gentle shadow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 21:49:55 +08:00
Junyan Qin
a20d3d11e5 style(web): soften extension card shadow and hover effect
Reduce the marketplace card box-shadow (4px/0.2 -> 2px/0.06) and the
hover shadow (8px/0.15 -> 5px/0.08, dark proportional) for a more
restrained, understated look.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 21:45:35 +08:00
Junyan Qin
3b4c455813 fix(web): distinct extension-format icons (plugin/mcp/skill)
The format filter used Wrench/AudioWaveform/Book for plugin/mcp/skill,
which collided with the plugin-component icons (Tool/EventListener/
KnowledgeEngine) shown right below. Switch formats to Puzzle/Server/
Sparkles — matching the canonical getTypeIcon used by the detail badges
— across the market filter, installed filter, install-queue map and
install-progress dialog.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 21:34:23 +08:00
Junyan Qin
c967a2aa82 i18n(market): say "extensions" not "plugins" in the marketplace count
The marketplace now lists plugins, MCPs and skills, so the item count
("Total N plugins") read wrong. Update market.totalPlugins and
market.searchResults to "extensions" across all 8 locales.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 21:24:10 +08:00
Junyan Qin
79cc6da96f fix(mcp): surface real cause from TaskGroup ExceptionGroups
MCP connection failures were reported as "unhandled errors in a
TaskGroup (1 sub-exception)" because anyio/the MCP client wrap the real
error in an ExceptionGroup and we interpolated its str() directly. Add
_describe_exception() to recurse into ExceptionGroups and surface the
leaf cause (e.g. "httpx.HTTPStatusError: Client error '410 Gone'") in
both the retry warning and the final error_message.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 21:19:18 +08:00
Junyan Qin
fee7d48dc3 refactor(web): drop redundant Manual/Scan tabs in model add popover
The model add/scan popover nested a second Manual/Scan tab row inside
the Chat/Embedding/Rerank type tabs. But ProviderCard already opens the
popover from two distinct entry points (Add -> manual, Scan -> scan via
initialMode), so the inner tabs were redundant. Render the manual form
or scan UI directly off `mode` and remove the inner Tabs/TabsList,
leaving a single clean tab row.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 18:36:59 +08:00
Junyan Qin
8811fb647f fix(plugin): call _inspect_plugin_package in marketplace install path
Marketplace plugin install referenced self._extract_deps_metadata,
which no longer exists (renamed to _inspect_plugin_package), raising
AttributeError and failing every plugin install from Space. Use the
current method name; it extracts identity + dependency metadata as
the local-install path already does.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 18:17:01 +08:00
Junyan Qin
37b017459d fix(modelmgr): upsert Space-managed models instead of insert-only
sync_new_models_from_space() skipped any model whose uuid already
existed. LangBot Space reuses a model's uuid across renames/re-specs
(e.g. the uuid that was claude-opus-4-6 later becomes claude-opus-4-7),
so renamed models never propagated locally — the stale local name was
also sent to the models gateway, causing model_not_found at inference.

Now upsert: create new uuids, and for existing models owned by the
Space provider, update name/abilities/ranking to track Space (models
from other providers are left untouched). Logs added/updated counts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 18:11:26 +08:00
Junyan Qin
4889a3881b chore(release): bump version to 4.10.0
Version-only bump from 4.10.0-beta.3. No release/tag/publish.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 17:26:03 +08:00
Junyan Qin
fe4f95b9a3 fix(docker): install docker CLI for box backend; bump to 4.10.0-beta.3
The langbot_box service drives sandbox containers through the docker CLI
(CLISandboxBackend shells out to `docker run`/`docker exec`), but the
image shipped without a docker client, so DockerBackend.is_available()
was always false and the Box sandbox backend was unavailable in Docker
deployments — disabling native tools, skill execution and stdio MCP.
Install docker-ce-cli (client only) in the image, arch-aware so
multi-arch builds work.

Also bump langbot-plugin pin to 0.4.1, which disables proxy
auto-detection on internal control-plane WebSocket connections (the
langbot<->plugin_runtime / langbot<->box handshakes were failing on
hosts that inject a proxy into containers).

Bumps version to 4.10.0-beta.3.
2026-06-04 13:20:36 +08:00
Junyan Qin
a2817f6524 chore(release): bump version to 4.10.0-beta.2
The 4.10.0-beta.2 release built and tried to publish 4.10.0b1 (the version was
never bumped), which PyPI rejected as a duplicate. Bump pyproject.toml and
__init__.py to 4.10.0-beta.2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 23:24:37 +08:00
Junyan Qin
b9560b26ff Revert "chore(tooling): wire CodeGraph MCP server + agent guidance"
This reverts commit 1ad7071aa0.
2026-06-03 23:17:34 +08:00
Junyan Qin
1ad7071aa0 chore(tooling): wire CodeGraph MCP server + agent guidance
Add the codegraph stdio MCP server to .mcp.json and the CodeGraph usage
guidance block to AGENTS.md, so coding agents working in this repo can use the
codegraph_* structural-search tools.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 23:14:21 +08:00
huanghuoguoguo
96b041846d Feat/sandbox (#2072)
* feat: add mcp and skills

* feat: add filter

* feat: modify frontend

* feat(box): add sandbox_exec tool loop for local-agent calculations

* feat(box): add host workspace mounting and sandbox_exec guidance

* feat(box): add BoxProfile with resource limits and improved output truncation

  - Implement head+tail output truncation (60/40 split) so LLM sees both
    beginning and final results; add streaming byte-limited reads in backend
    to prevent unbounded memory usage (_MAX_RAW_OUTPUT_BYTES = 1MB)
  - Define BoxProfile model with locked fields and max_timeout_sec clamping
  - Add four built-in profiles: default, offline_readonly, network_basic,
    network_extended with differentiated resource and security constraints
  - Add resource limit fields to BoxSpec (cpus, memory_mb, pids_limit,
    read_only_rootfs) and pass corresponding container CLI flags
    (--cpus, --memory, --pids-limit, --read-only, --tmpfs)
  - Profile loaded from config (box.profile), applied in service layer
    before BoxSpec validation; locked fields cannot be overridden by
    tool-call parameters

* feat(box): add obs

* refactor(box): unify box service lifecycle and local runtime
  management

* refactor(box): remove legacy in-process runtime code and clean up smells

After the architecture settled on always using an independent Box Runtime
service, several pieces of compatibility code and design shortcuts were
left behind. This commit cleans them up:

- Remove `LocalBoxRuntimeClient` and `create_box_runtime_client` from
  production code (moved to test-only helper).
- Remove unused `_clip_bytes` method from backend.
- Remove `__langbot_session_placeholder__` hack by making `BoxSpec.cmd`
  default to empty and validating non-empty only in `runtime.execute()`.
- Extract `get_box_config()` helper to eliminate 5× duplicated config
  access boilerplate.
- Remove `session_id`/`host_path`/`host_path_mode` from the LLM-facing
  tool schema to enforce request-scoped session isolation.
- Fix dual shutdown path: `NativeToolLoader.shutdown()` no longer calls
  `box_service.shutdown()` (handled by `Application.dispose()`).
- Simplify `_assert_session_compatible` with a loop.
- Inline client creation in `BoxRuntimeConnector`.
- Remove redundant `BOX__RUNTIME_URL` env var from docker-compose
  (auto-detected by code).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add test

* fix: fix box intergration test

* feat(box/mcp): integrate MCP stdio with Box sandbox — auto-isolation, dep install, security

  ## Summary

  When Podman/Docker is available, all stdio-mode MCP servers now automatically
  run inside Box containers with dependency installation, path rewriting, and
  lifecycle management. When no container runtime exists, LangBot starts normally
  and stdio MCP falls back to host-direct execution.

  ## What changed

  ### MCP stdio → Box integration (mcp.py)
  - Add `MCPServerBoxConfig` pydantic model for structured box configuration
    with validation and defaults (network, host_path_mode, timeouts, resources)
  - Auto-infer `host_path` from command/args with venv detection: recognizes
    `.venv/bin/python` patterns and walks up to the project root
  - Rewrite host paths to container `/workspace` paths transparently
  - Replace venv python commands with container-native `python`
  - Auto-detect `pyproject.toml`/`setup.py`/`requirements.txt` and run
    `pip install` inside the container before starting the MCP server
  - Copy project to `/tmp` before install to handle read-only mounts
  - Add retry with exponential backoff (3 retries, 2s/4s/8s delays)
  - Add Box managed process health monitoring (poll every 5s)
  - Fix session leak: `_cleanup_box_stdio_session()` now runs in `finally`
    block of `_lifecycle_loop`, covering all exit paths
  - Fix retry logic: `_ready_event` is only set after all retries exhaust
    or on success, not on first failure
  - Enhance `get_runtime_info_dict()` with `box_session_id` and `box_enabled`

  ### Box security (security.py — new)
  - `validate_sandbox_security()` blocks dangerous host paths:
    `/etc`, `/proc`, `/sys`, `/dev`, `/root`, `/boot`, `/run`,
    docker.sock, podman socket
  - Called at the start of `CLISandboxBackend.start_session()`

  ### Box models (models.py)
  - Add `BoxHostMountMode.NONE` — skips volume mount entirely
  - Adjust `validate_host_mount_consistency` to allow arbitrary workdir
    when `host_path_mode=NONE`

  ### Box backend (backend.py)
  - Add `validate_sandbox_security()` call in `start_session()`
  - Add `langbot.box.config_hash` label on containers for drift detection
  - Handle `BoxHostMountMode.NONE` — skip `-v` mount arg
  - Add `cleanup_orphaned_containers()` to base class (no-op default) and
    CLI implementation (single batched `rm -f` command)

  ### Box runtime (runtime.py)
  - Call `cleanup_orphaned_containers()` during `initialize()` to remove
    lingering containers from previous runs

  ### Box service (service.py)
  - Graceful degradation: `initialize()` catches runtime errors and sets
    `available=False` instead of crashing LangBot startup
  - Add `available` property and guard on `execute_sandbox_tool()`
  - Add `skip_host_mount_validation` parameter to `build_spec()` and
    `create_session()` — MCP paths are admin-configured and trusted,
    bypassing `allowed_host_mount_roots` restrictions meant for
    LLM-generated sandbox_exec commands

  ### Default behavior
  - stdio MCP servers automatically use Box when `box_service.available`
    is True (Podman/Docker detected); no explicit `box` config needed
  - When no container runtime exists, falls back to host-direct stdio
  - MCP Box defaults: `network=on` (for pip install), `read_only_rootfs=false`
    (for site-packages), `host_path_mode=ro`, `startup_timeout=120s`

  ### Tests
  - `test_box_security.py`: blocked paths, safe paths, subpath rejection
  - `test_mcp_box_integration.py`: config model, path rewriting, venv
    unwrap, host_path inference, payload building, runtime info, box
    availability check
  - `test_box_service.py`: `BoxHostMountMode.NONE` validation tests

* feat(box/mcp): instance-based orphan cleanup, error classification, session API, and integration tests

  ## Changes

  ### Precise orphan container cleanup
  - Runtime generates a unique instance_id on startup
  - Every container gets a `langbot.box.instance_id` label
  - `cleanup_orphaned_containers()` only removes containers from
    previous instances, preserving containers owned by the current one
  - Containers from older versions (no label) are also cleaned up
  - `cleanup_orphaned_containers` added to `BaseSandboxBackend` as
    a no-op default method, removing hasattr duck-typing

  ### Fine-grained MCP error classification
  - New `MCPSessionErrorPhase` enum with 7 phases: session_create,
    dep_install, process_start, relay_connect, mcp_init, runtime,
    tool_call
  - Each phase in `_init_box_stdio_server()` sets the error phase
    before re-raising, enabling precise failure diagnosis
  - `retry_count` tracked across retry attempts
  - `get_runtime_info_dict()` exposes `error_phase` and `retry_count`

  ### GET /v1/sessions/{id} API
  - `BoxRuntime.get_session()` returns session details including
    managed process info when present
  - `handle_get_session` HTTP handler + route in server.py
  - `BoxRuntimeClient.get_session()` abstract method + remote impl

  ### stdio defaults to Box when runtime is available
  - `_uses_box_stdio()` checks `box_service.available` instead of
    requiring explicit `box` key in server_config
  - `BoxService.initialize()` catches runtime errors gracefully,
    sets `available=False` instead of crashing LangBot startup
  - When no container runtime exists, stdio MCP falls back to
    host-direct execution

  ### Code quality (from /simplify review)
  - Extracted `_VENV_DIRS` / `_VENV_BIN_DIRS` module-level constants
  - Removed dead `_box_network_mode()` method and unused `bc` variable
  - Fixed broken import `from ....box.models` → `from ...box.models`
  - Cached `_resolve_host_path()` result — computed once, passed through
  - Config hash now includes `host_path` field
  - Batched orphan cleanup into single `rm -f` command

  ### Session leak fix
  - `_cleanup_box_stdio_session()` now runs in `_lifecycle_loop`'s
    finally block, covering all exit paths (normal shutdown, error,
    retry, final failure)

  ### Integration tests
  - 6 end-to-end tests covering managed process lifecycle, WebSocket
    stdio bidirectional IO, session cleanup verification, single
    session query, process exit detection, and orphan cleanup safety

* refactor: use rpc

* fix: import

* refactor(box): clean up sandbox subsystem code quality and efficiency

  - Fix O(n²) stderr trimming in runtime.py with running length tracker
  - Remove dead code: RESERVED_CONTAINER_PATHS, _subprocess_wait_task,
    unused config_hash computation, unused imports
  - Deduplicate connection callback in BoxRuntimeConnector, parse URL once
  - Use enum comparison instead of stringly-typed spec.network.value check
  - Replace manual _result_to_dict/_session_to_dict with model_dump()
  - Cache NativeToolLoader tool definition and sandbox system guidance
  - Extract _is_path_under() helper to eliminate duplicated path checks
  - Import SANDBOX_EXEC_TOOL_NAME from native.py instead of redefining
  - Add JSON startswith guard in logging_utils to skip futile json.loads
  - Fix ruff lint errors (F401 unused imports, F841 unused variables)

* fix: ruff

* refactor(sandbox): keep box logic out of pipeline and localagent

  - Move sandbox system-prompt guidance from LocalAgentRunner into
    BoxService.get_system_guidance() so all box domain knowledge stays
    in the box module.
  - Remove standalone logging_utils.py; merge format_result_log() into
    MessageHandler base class alongside cut_str().
  - Strip sandbox-specific JSON parsing from log formatting; tool
    results now use generic truncation.
  - Revert TYPE_CHECKING changes in stage.py and runner.py that were
    unrelated to this feature.
  - Skip two test files affected by a pre-existing circular import
    (runner ↔ app) until the import cycle is resolved in a separate PR.

* fix: ruff

* refactor(box): move box runtime to langbot-plugin-sdk

  Extract self-contained box runtime modules (actions, backend, client,
  errors, models, runtime, security, server) to langbot-plugin-sdk and
  update all imports to use `langbot_plugin.box.*`. Keep only service
  and
  connector in LangBot core as they depend on the Application context.

  - Update docker-compose to use `langbot_plugin.box.server` entry
  point
  - Update pyproject.toml to use local SDK via `tool.uv.sources`
  - Remove migrated source files and their unit/integration tests
  - Update remaining test imports to match new module paths

* fix: ruff

* feat: enhance sandbox api

* refactor(box): derive paths from shared host root

* fix(box): tighten sandbox exposure and restore box integration coverage

* refactor(types): remove quoted annotations under postponed evaluation

* feat(box): unify native agent tools around exec/read/write/edit

* chore(sandbox): move MCP loader changes to follow-up branch

* feat(box): add session workspace quota enforcement and SDK quota metadata

* feat(skills): add Agent Skills management system (#1917)

* feat(skills): add Agent Skills management system

Implement comprehensive skills management feature inspired by agentskills spec:

Backend:
- Add Skill and SkillPipelineBinding database entities
- Add database migration (dbm018) for skills tables
- Implement SkillManager for skill loading, matching, and resolution
- Implement SkillService for CRUD operations
- Add skills API endpoints for skill and pipeline binding management
- Integrate skill index injection into pipeline preprocessor
- Add skill activation detection in LocalAgentRunner

Frontend:
- Add Skills page with listing, search, and type filter
- Add SkillDetailDialog for create/edit with preview
- Add SkillCard and SkillForm components
- Add skills API methods to BackendClient
- Add skills entry to sidebar navigation
- Add i18n translations (en-US, zh-Hans)

Features:
- Support skill and workflow types
- Sub-skill composition via {{INVOKE_SKILL: name}} syntax
- Progressive disclosure (index in prompt, full instructions on activation)
- Pipeline-specific skill bindings with priority

* fix: resolve cherry-pick conflicts for agentskills onto sandbox

- Remove non-existent external_kb service import
- Add skill_mgr mock to localagent sandbox_exec tests
- Keep database version at 24 (sandbox branch's latest)

* feat(skills): upgrade to package-backed skills with sandbox execution

  Evolve the skills system from pure prompt-based to package-backed with
  sandbox tool execution support:

  - Add source_type/package_root/entry_file/skill_tools fields to Skill entity
  - SkillManager loads SKILL.md from local package directories
  - SkillToolLoader as 4th dispatch layer in ToolManager (query-scoped)
  - LocalAgent injects skill tools into use_funcs on skill activation
  - BoxService.execute_skill_tool() runs scripts in sandbox (ro mount, env params)
  - Skill tool names auto-namespaced as skill__{skill}__{tool}
  - API validation for package_root allowlist and entry path traversal
  - Frontend source_type toggle, package_root input, skill_tools editor
  - Migration renumbered to 025 with ALTER TABLE fallback for existing DBs
  - Fix unclosed limitation section in i18n files
  - Fix skills API methods misplaced outside BackendClient class

* fix: test info

* feat(skills): switch skills to package-backed storage and add import tooling
  - skills 从 inline/package 双轨收敛成 package-first
  - instructions 改为写入并读取 SKILL.md
  - 新增本地目录扫描和 GitHub 安装 skill
  - 前端把 skills 整合进 plugins 页,新增 SkillsComponent 和 GitHub 导入弹窗
  - skill form 去掉 source_type / type 筛选,改成目录扫描驱动
  - Box skill tool 挂载模式从 ro 改成 rw
  - 测试和中英文文案同步更新

* feat: simplify langbot skill create and import

* refactor(skills): clean up legacy skill API and harden activation flow

* refactor(skills): remove skill dependency expansion and add skill_get

* fix: lint

* fix: delete

* fix(skills): align tool manager loader initialization

* refactor: remove sandbox execute skill

* fix(skills): hide activation markers and isolate skill activation flow

* refactor(skills): switch skill model to filesystem-backed packages

* refactor(skills): switch skill model to filesystem-backed packages

* refactor(skills): unify runtime skill access around filesystem paths

* refactor(skills): unify runtime skill access around filesystem paths

* feat(skills): align rw package design and fix skill activation, visibility, and lint issues

* refactor(skills): replace rich authoring API with import/reload flow and update
  Box design doc

* feat(box): add sandbox_exec tool loop for local-agent calculations

* feat(box): add host workspace mounting and sandbox_exec guidance

* feat(box): add BoxProfile with resource limits and improved output truncation

  - Implement head+tail output truncation (60/40 split) so LLM sees both
    beginning and final results; add streaming byte-limited reads in backend
    to prevent unbounded memory usage (_MAX_RAW_OUTPUT_BYTES = 1MB)
  - Define BoxProfile model with locked fields and max_timeout_sec clamping
  - Add four built-in profiles: default, offline_readonly, network_basic,
    network_extended with differentiated resource and security constraints
  - Add resource limit fields to BoxSpec (cpus, memory_mb, pids_limit,
    read_only_rootfs) and pass corresponding container CLI flags
    (--cpus, --memory, --pids-limit, --read-only, --tmpfs)
  - Profile loaded from config (box.profile), applied in service layer
    before BoxSpec validation; locked fields cannot be overridden by
    tool-call parameters

* feat(box): add obs

* refactor(box): unify box service lifecycle and local runtime
  management

* refactor(box): remove legacy in-process runtime code and clean up smells

After the architecture settled on always using an independent Box Runtime
service, several pieces of compatibility code and design shortcuts were
left behind. This commit cleans them up:

- Remove `LocalBoxRuntimeClient` and `create_box_runtime_client` from
  production code (moved to test-only helper).
- Remove unused `_clip_bytes` method from backend.
- Remove `__langbot_session_placeholder__` hack by making `BoxSpec.cmd`
  default to empty and validating non-empty only in `runtime.execute()`.
- Extract `get_box_config()` helper to eliminate 5× duplicated config
  access boilerplate.
- Remove `session_id`/`host_path`/`host_path_mode` from the LLM-facing
  tool schema to enforce request-scoped session isolation.
- Fix dual shutdown path: `NativeToolLoader.shutdown()` no longer calls
  `box_service.shutdown()` (handled by `Application.dispose()`).
- Simplify `_assert_session_compatible` with a loop.
- Inline client creation in `BoxRuntimeConnector`.
- Remove redundant `BOX__RUNTIME_URL` env var from docker-compose
  (auto-detected by code).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(box/mcp): integrate MCP stdio with Box sandbox — auto-isolation, dep install, security

  ## Summary

  When Podman/Docker is available, all stdio-mode MCP servers now automatically
  run inside Box containers with dependency installation, path rewriting, and
  lifecycle management. When no container runtime exists, LangBot starts normally
  and stdio MCP falls back to host-direct execution.

  ## What changed

  ### MCP stdio → Box integration (mcp.py)
  - Add `MCPServerBoxConfig` pydantic model for structured box configuration
    with validation and defaults (network, host_path_mode, timeouts, resources)
  - Auto-infer `host_path` from command/args with venv detection: recognizes
    `.venv/bin/python` patterns and walks up to the project root
  - Rewrite host paths to container `/workspace` paths transparently
  - Replace venv python commands with container-native `python`
  - Auto-detect `pyproject.toml`/`setup.py`/`requirements.txt` and run
    `pip install` inside the container before starting the MCP server
  - Copy project to `/tmp` before install to handle read-only mounts
  - Add retry with exponential backoff (3 retries, 2s/4s/8s delays)
  - Add Box managed process health monitoring (poll every 5s)
  - Fix session leak: `_cleanup_box_stdio_session()` now runs in `finally`
    block of `_lifecycle_loop`, covering all exit paths
  - Fix retry logic: `_ready_event` is only set after all retries exhaust
    or on success, not on first failure
  - Enhance `get_runtime_info_dict()` with `box_session_id` and `box_enabled`

  ### Box security (security.py — new)
  - `validate_sandbox_security()` blocks dangerous host paths:
    `/etc`, `/proc`, `/sys`, `/dev`, `/root`, `/boot`, `/run`,
    docker.sock, podman socket
  - Called at the start of `CLISandboxBackend.start_session()`

  ### Box models (models.py)
  - Add `BoxHostMountMode.NONE` — skips volume mount entirely
  - Adjust `validate_host_mount_consistency` to allow arbitrary workdir
    when `host_path_mode=NONE`

  ### Box backend (backend.py)
  - Add `validate_sandbox_security()` call in `start_session()`
  - Add `langbot.box.config_hash` label on containers for drift detection
  - Handle `BoxHostMountMode.NONE` — skip `-v` mount arg
  - Add `cleanup_orphaned_containers()` to base class (no-op default) and
    CLI implementation (single batched `rm -f` command)

  ### Box runtime (runtime.py)
  - Call `cleanup_orphaned_containers()` during `initialize()` to remove
    lingering containers from previous runs

  ### Box service (service.py)
  - Graceful degradation: `initialize()` catches runtime errors and sets
    `available=False` instead of crashing LangBot startup
  - Add `available` property and guard on `execute_sandbox_tool()`
  - Add `skip_host_mount_validation` parameter to `build_spec()` and
    `create_session()` — MCP paths are admin-configured and trusted,
    bypassing `allowed_host_mount_roots` restrictions meant for
    LLM-generated sandbox_exec commands

  ### Default behavior
  - stdio MCP servers automatically use Box when `box_service.available`
    is True (Podman/Docker detected); no explicit `box` config needed
  - When no container runtime exists, falls back to host-direct stdio
  - MCP Box defaults: `network=on` (for pip install), `read_only_rootfs=false`
    (for site-packages), `host_path_mode=ro`, `startup_timeout=120s`

  ### Tests
  - `test_box_security.py`: blocked paths, safe paths, subpath rejection
  - `test_mcp_box_integration.py`: config model, path rewriting, venv
    unwrap, host_path inference, payload building, runtime info, box
    availability check
  - `test_box_service.py`: `BoxHostMountMode.NONE` validation tests

* feat(box/mcp): instance-based orphan cleanup, error classification, session API, and integration tests

  ## Changes

  ### Precise orphan container cleanup
  - Runtime generates a unique instance_id on startup
  - Every container gets a `langbot.box.instance_id` label
  - `cleanup_orphaned_containers()` only removes containers from
    previous instances, preserving containers owned by the current one
  - Containers from older versions (no label) are also cleaned up
  - `cleanup_orphaned_containers` added to `BaseSandboxBackend` as
    a no-op default method, removing hasattr duck-typing

  ### Fine-grained MCP error classification
  - New `MCPSessionErrorPhase` enum with 7 phases: session_create,
    dep_install, process_start, relay_connect, mcp_init, runtime,
    tool_call
  - Each phase in `_init_box_stdio_server()` sets the error phase
    before re-raising, enabling precise failure diagnosis
  - `retry_count` tracked across retry attempts
  - `get_runtime_info_dict()` exposes `error_phase` and `retry_count`

  ### GET /v1/sessions/{id} API
  - `BoxRuntime.get_session()` returns session details including
    managed process info when present
  - `handle_get_session` HTTP handler + route in server.py
  - `BoxRuntimeClient.get_session()` abstract method + remote impl

  ### stdio defaults to Box when runtime is available
  - `_uses_box_stdio()` checks `box_service.available` instead of
    requiring explicit `box` key in server_config
  - `BoxService.initialize()` catches runtime errors gracefully,
    sets `available=False` instead of crashing LangBot startup
  - When no container runtime exists, stdio MCP falls back to
    host-direct execution

  ### Code quality (from /simplify review)
  - Extracted `_VENV_DIRS` / `_VENV_BIN_DIRS` module-level constants
  - Removed dead `_box_network_mode()` method and unused `bc` variable
  - Fixed broken import `from ....box.models` → `from ...box.models`
  - Cached `_resolve_host_path()` result — computed once, passed through
  - Config hash now includes `host_path` field
  - Batched orphan cleanup into single `rm -f` command

  ### Session leak fix
  - `_cleanup_box_stdio_session()` now runs in `_lifecycle_loop`'s
    finally block, covering all exit paths (normal shutdown, error,
    retry, final failure)

  ### Integration tests
  - 6 end-to-end tests covering managed process lifecycle, WebSocket
    stdio bidirectional IO, session cleanup verification, single
    session query, process exit detection, and orphan cleanup safety

* refactor: use rpc

* fix: import

* refactor(box): clean up sandbox subsystem code quality and efficiency

  - Fix O(n²) stderr trimming in runtime.py with running length tracker
  - Remove dead code: RESERVED_CONTAINER_PATHS, _subprocess_wait_task,
    unused config_hash computation, unused imports
  - Deduplicate connection callback in BoxRuntimeConnector, parse URL once
  - Use enum comparison instead of stringly-typed spec.network.value check
  - Replace manual _result_to_dict/_session_to_dict with model_dump()
  - Cache NativeToolLoader tool definition and sandbox system guidance
  - Extract _is_path_under() helper to eliminate duplicated path checks
  - Import SANDBOX_EXEC_TOOL_NAME from native.py instead of redefining
  - Add JSON startswith guard in logging_utils to skip futile json.loads
  - Fix ruff lint errors (F401 unused imports, F841 unused variables)

* fix: ruff

* refactor(sandbox): keep box logic out of pipeline and localagent

  - Move sandbox system-prompt guidance from LocalAgentRunner into
    BoxService.get_system_guidance() so all box domain knowledge stays
    in the box module.
  - Remove standalone logging_utils.py; merge format_result_log() into
    MessageHandler base class alongside cut_str().
  - Strip sandbox-specific JSON parsing from log formatting; tool
    results now use generic truncation.
  - Revert TYPE_CHECKING changes in stage.py and runner.py that were
    unrelated to this feature.
  - Skip two test files affected by a pre-existing circular import
    (runner ↔ app) until the import cycle is resolved in a separate PR.

* refactor(box): move box runtime to langbot-plugin-sdk

  Extract self-contained box runtime modules (actions, backend, client,
  errors, models, runtime, security, server) to langbot-plugin-sdk and
  update all imports to use `langbot_plugin.box.*`. Keep only service
  and
  connector in LangBot core as they depend on the Application context.

  - Update docker-compose to use `langbot_plugin.box.server` entry
  point
  - Update pyproject.toml to use local SDK via `tool.uv.sources`
  - Remove migrated source files and their unit/integration tests
  - Update remaining test imports to match new module paths

* fix: ruff

* fix(box): tighten sandbox exposure and restore box integration coverage

* refactor(types): remove quoted annotations under postponed evaluation

* chore(sandbox): move MCP loader changes to follow-up branch

* refactor(plugins): simplify GitHub install flow to default master archive

* revert(api): restore plugin GitHub import flow in plugins controller

* Improve data-root handling and skill install previews

* Add managed skill authoring tools for local agents

* Refactor the skills UI around sidebar detail pages

* Document why managed skill authoring tools bypass box

* fix: lint

* feat(web): refactor plugin/skill install flows and fix skills page

- Fix sidebar skill icon
- Add skills route and error page component
- Refactor plugin GitHub install from dialog modal to inline card
- Add skill install dropdown menu (create/upload/github) in sidebar
- Wire sidebar → skills page communication via pendingSkillInstallAction context
- Add i18n keys for error page and skill install actions

* fix(web): persist sidebar collapsible section open state on navigation

Sections opened via sub-item navigation now retain their expanded state
when the user switches to a different section, instead of collapsing
because the isActive fallback becomes false.

---------

Co-authored-by: youhuanghe <1051233107@qq.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Junyan Qin <rockchinq@gmail.com>

* feat(sandbox): add MCP box integration on top of sandbox base (#2083)

* refactor(mcp): extract box stdio runtime helper

* refactor(box): introduce reusable workspace session helper

* refactor(box): run Box Runtime as subprocess inside LangBot container

  Remove the separate langbot_box_runtime Docker service. Box Runtime
  now always launches as a local stdio subprocess, regardless of whether
  LangBot runs in Docker or not. The WebSocket transport path is kept
  only for explicit runtime_url configuration (remote deployment).

  This simplifies deployment by eliminating cross-container path mapping
  and network hops. Box Runtime is a pure scheduling process (talks to
  Docker socket / nsjail), it does not execute user code or touch the
  filesystem, so container isolation is unnecessary — unlike Plugin
  Runtime.

* fix(web): prevent first-emission snapshot from swallowing unsaved changes in pipeline editor

When switching runner (e.g. local-agent → n8n), the newly mounted stage's
first emit would re-capture the saved snapshot, erasing the dirty state
caused by the runner change. The save button would incorrectly go dim.

- Skip snapshot re-capture in handleDynamicFormEmit when form is already dirty
- Add mount-time emit to N8nAuthFormComponent (matching DynamicFormComponent)
- Use stable onSubmitRef to prevent useEffect subscription churn
- Add previousInitialValues guard to prevent initialValues echo loops

* style(web): align plugin list header button heights

* docs(review): update Box architecture review documents

Replace old review docs with 5 focused documents:
- box-architecture.md: deep architecture analysis (LangBot + SDK)
- box-issues.md: 22 issues rated P0/P1/P2
- box-test-coverage.md: test coverage analysis
- box-tob-analysis.md: toB commercialization analysis
- box-vs-plugin-runtime.md: Box vs Plugin runtime comparison

* feat(web): improve login error layout and add Terms of Service link

- Improve backend connection error display with bordered container,
  inline icon, and better visual hierarchy
- Extract actual error message from axios response object
- Add Terms of Service link (https://langbot.app/terms) to login footer
- Add termsOfService i18n key for all 7 locales

* refactor(web): replace all hardcoded SVG icons with lucide-react

Unify icon usage across the entire frontend by replacing 67 hardcoded
SVG icons with lucide-react components across ~25 files. This improves
consistency, maintainability, and reduces bundle duplication.

Key replacements:
- Sidebar nav: Zap, LayoutDashboard, Bot, Workflow, BookMarked, etc.
- MCP forms: Loader2, XCircle, Trash2
- Monitoring: Sparkles, MessageSquare, CheckCircle2, RefreshCw, etc.
- Cards: Clock, Star, Workflow, Hexagon, Puzzle, Github, etc.
- Misc: Paperclip, AudioLines, CloudUpload, Layers, Heart, Smile

Zero hardcoded <svg> tags remain in .tsx files.

* fix(web): stop polling plugin tasks when no active installs

The PluginInstallTaskProvider was unconditionally polling
getAsyncTasks every 3s on all /home/* routes. Now it only
syncs once on mount and starts periodic polling only when
there are active (non-terminal) install tasks.

* fix(deps): update langbot-plugin version and add new dependencies

* refactor: use Space API for release checks and stop idle polling

- version.py: switch release list API from GitHub to space.langbot.app,
  remove unused in-place update logic (update_all, compare_version_str),
  translate all comments/logs to English
- PluginInstallTaskContext: only poll when active install tasks exist

* feat(box): add --standalone-box flag and 3-way transport decision for Box runtime

Align Box runtime connection logic with Plugin runtime's pattern:
- Docker: WebSocket to langbot_box container (ws://langbot_box:5411)
- --standalone-box: WebSocket to external Box process (ws://localhost:5411)
- Windows: subprocess + WebSocket (workaround for async stdio limitation)
- Unix/macOS: subprocess + stdio pipe (unchanged)

BoxRuntimeConnector now inherits ManagedRuntimeConnector for subprocess
lifecycle reuse. Add langbot_box service to docker-compose.yaml.

* refactor(box): use single port with path-based routing for Box WS

Update connector to use ws://host:5410/rpc/ws instead of ws://host:5411.
Update review docs to reflect the single-port architecture.

* feat(web): show Box runtime status in plugin debug info popover

Add Box status section to the debug info popover on the plugin list page,
displaying connection status, backend info, profile, active sessions,
and recent error count. Fetched from GET /api/v1/box/status in parallel
with plugin debug info. Includes i18n for all 8 supported languages.

* fix(web): remove ephemeral sandbox count from Box status display

The active_sessions count reflects transient sandbox containers that
expire after 5 minutes of inactivity, making it misleading in the UI.
Keep only connection status, backend, profile, and error count.

* feat(box): configurable sandbox scope and unified skill containers

Replace the per-message session_id with a template-based system
configurable per pipeline via 'Sandbox Scope' in the local-agent panel.
Default scope is per-chat ({launcher_type}_{launcher_id}).

Unify skill exec into the same container as default exec — skills are
mounted at /workspace/.skills/{name}/ via extra_mounts instead of
getting separate containers. All pipeline-bound skills are injected
at container creation time.

- Add box-session-id-template to pipeline metadata (select, 4 options, 8 languages)
- Add resolve_box_session_id() and build_skill_extra_mounts() to BoxService
- Rewrite native.py skill exec path to use execute_tool with shared session
- Update tests for new session_id format
- Add design doc: docs/review/box-session-scope.md

* feat(web): show active sandbox details in Box status popover

Display sandbox count and a detailed list of active sessions including
session ID, image, backend, resources (CPU/memory), network mode, and
last used time. Fetched from GET /api/v1/box/sessions in parallel.
Includes i18n for all 8 supported languages.

* feat(box): add startup and availability logging for sandbox tools

Log Box runtime initialization result (success with profile info, or
failure warning). Log native tool availability status at ToolManager
startup so it's immediately clear whether exec/read/write/edit tools
are registered for the LLM.

* feat(box): support custom sandbox container image via config.yaml

Add 'image' field to box config section. When set, it overrides the
profile default image (python:3.11-slim) for all sandbox containers.
Priority: caller-specified > config.yaml image > profile default.

* feat(box): add heartbeat and reconnection for Box runtime connector

Add 20-second heartbeat ping loop to detect silent Box runtime
disconnections. On disconnect, set available=false and attempt
reconnection after 3 seconds via the disconnect callback chain.

- BoxRuntimeConnector: heartbeat loop, disconnect callback parameter,
  disconnect detection in connection callback and WS failure handler
- BoxService: wire disconnect callback to toggle available state and
  re-initialize the connector on reconnection

* feat(web): move runtime status to dashboard, clean up plugin debug popover

Add SystemStatusCards component to the monitoring dashboard showing
Plugin Runtime and Box Runtime connection status with details (backend,
profile, sandbox count). Remove all Box/session status from the plugin
page debug popover — it now only shows debug URL and key.

Includes i18n for all 8 supported languages.

* refactor(web): compact system status into a single card alongside metrics

Replace the separate two-card row with a single compact 'System Status'
card placed as the 5th column in the metrics grid. Shows green/red dots
for Plugin Runtime and Box Runtime. Click to expand a popover with
connection details (backend, profile, sandbox count).

* feat: show connector error details for Plugin and Box runtime status

Record Box connector error in BoxService and expose it as
'connector_error' in GET /api/v1/box/status when unavailable.
Display error messages in the dashboard System Status popover
for both Plugin Runtime (plugin_connector_error) and Box Runtime
(connector_error) when they are disconnected.

* fix(web): auto-refresh system status and show disconnect errors in real time

Poll Plugin Runtime and Box Runtime status every 30 seconds so the
dashboard reflects disconnections without a manual page refresh.
Also re-fetch when the popover is opened for immediate feedback.

* fix(box): handle RPC failure in get_status/get_sessions gracefully

When the Box runtime disconnects, there is a race between the heartbeat
flipping _available=false and the frontend polling get_status(). If the
poll arrives first, client.get_status() throws a ConnectionClosedError
which propagated as a 500, causing the frontend to show a grey dot
(null status) instead of a red dot with error details.

Now get_status() catches RPC errors and returns available=false with
the exception message as connector_error. get_sessions() returns an
empty list when unavailable or on RPC failure.

* fix(box): add persistent reconnection loop with exponential backoff

The previous disconnect handler only retried once and then gave up.
Now spawns a background task that retries with exponential backoff
(3s, 6s, 12s, ... up to 60s) until the Box runtime is reachable again.
Uses a _reconnecting guard to prevent duplicate loops. Calls
connector.dispose() before each retry to clean up stale tasks.

* fix(box): detect disconnect when handler.run() returns normally

The generic Handler.run() catches ConnectionClosedError and breaks out
of its loop (normal return) instead of raising, because it has no
disconnect_callback. The old code only triggered reconnection in the
except branch, so a clean WebSocket close was never detected.

Now treat handler.run() returning normally (after successful handshake)
as a disconnect event, triggering the reconnection callback.

* fix(web): refresh system status card when clicking Refresh Data button

Pass a refreshKey prop through OverviewCards to SystemStatusCard that
increments on each Refresh Data click, triggering a re-fetch of Plugin
and Box runtime status alongside the monitoring data refresh.

* fix(web): fix system status card stuck in loading state

fetchStatus(showLoading=false) never called setLoading(false), so the
initial loading=true was never cleared. Simplify to always setLoading
in the finally block — the spinner only shows on the very first load
since subsequent fetches complete near-instantly.

* feat(web): show active sandbox details in dashboard Box status popover

Fetch box sessions alongside status and display each active sandbox
in the popover with session ID, image, resources (CPU/memory), and
last used time.

* feat(box): add global sandbox scope option

Add a 'Global (shared by all)' option to the sandbox scope selector.
Uses a constant '{global}' template variable that always resolves to
'global', so all users and chats share one sandbox container.

* refactor(web): replace popover with dialog for system status details

Replace the dropdown popover with a proper Dialog for runtime status
details. Add a small info button on the System Status card that opens
the dialog. Session details now show in a spacious 2-column grid layout
with full image name, backend, CPU/memory, network, mount path, and
created/last-used timestamps.

* fix(web): widen system status dialog and fix scroll border issue

Use max-w-2xl (matching other dialogs) instead of max-w-lg. Move
overflow-y-auto to an inner container with overflow-hidden on
DialogContent to prevent padding bleed at scroll edges.

* feat(web): add tooltips for truncated fields in system status dialog

Wrap session_id, image, and mount path fields with Tooltip components
so hovering over truncated text shows the full value.

* feat: add download button

* feat: successfully install

* feat: delete old filter

* feat: youhua frontend

* fix: align box runtime launch args

* feat: translate

* feat: refactor market

* feat: youhua qianduan

* chore: rename extension zh translation

* feat(extensions): unify extensions endpoint and refresh extensions page UX

- Rename /home/plugins route to /home/extensions and update all sidebar links.
- Add unified GET /api/v1/extensions returning plugins, MCP servers and skills,
  sorted by name; replace the three separate frontend fetches with this single call.
- Migrate the extensions page to shadcn primitives (Tabs/Card/Alert/Badge/Skeleton/
  Switch/Label) and clean up hardcoded color tokens on the extension card.
- Add a localStorage-persisted "Group by type" switch that, when enabled in the
  All Types tab, renders extensions grouped by type with a compact section header.
- Show a spinner while loading and rename the empty-state copy from
  "No plugins installed" to "No extensions installed".
- Rename the "格式 / Formats" filter label to "类型 / Types" across all 8 locales.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(extensions): fallback lucide icon when extension icon is missing

Render a tinted lucide icon (Puzzle / Server / Sparkles) on the extension
card when the icon URL is empty or the image fails to load. Picked icons
distinct from EventListener (AudioWaveform) and KnowledgeEngine (Book) to
avoid visual collision with plugin component badges.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(sidebar): unify installed-extensions list with plugins, MCP and skills

- Render plugins, MCP servers and skills together under the "Installed
  Extensions" sidebar entry, alphabetically sorted to match the list page.
- Resolve per-item routes by extension type (plugin -> /home/extensions,
  mcp -> /home/mcp, skill -> /home/skills) and gate the plugin-only hover
  context menu on extensionType === 'plugin'.
- Lift the "group by type" toggle into SidebarDataContext (still persisted
  in localStorage) so the sidebar groups items with section headers
  whenever the list page has the toggle enabled.
- Show lucide fallback icons (Server / Sparkles / Puzzle) tinted in the
  LangBot blue for MCP, skill, and missing-icon plugin items, overriding
  the SidebarMenuSubButton svg color rule.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(extensions): mobile-friendly layout for extensions and add-extension pages

- Stack the extensions page header vertically on small screens, let the
  filter Tabs scroll horizontally if they overflow, hide the debug
  button label below sm and let the install/debug controls wrap.
- Constrain the debug popover and its inputs to the viewport width so
  they no longer overflow on phone-sized screens.
- Drop the card grid from a fixed 30rem column to a min(100%, 22rem)
  column at base / 28rem at sm, and reduce the gap, so cards render
  cleanly at 360px+ widths in both flat and grouped views.
- Make the add-extension header actions wrap on lg- viewports and the
  install dialog responsive instead of a hard 500px box.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: change ui

* feat: delete version for mcp and skills

* fix: constrain home page content width

* fix: preserve monitoring card borders under sticky filters

* fix(box): restore sandbox config and shared mcp runtime

* fix(box): harden sandbox session isolation

* fix(skill): remove auto activation setting

* feat(skill): align skill system with Claude Code's Tool Call design

- Replace text marker activation with `activate` tool (Tool Call mechanism)
- Replace 7 authoring tools with 2: `activate` + `register_skill`
- Add builtin skills loading from templates/skills/
- Add create-skill as first builtin skill
- Remove SKILL_ACTIVATION_MARKER and text detection methods
- Tool Result returns SKILL.md content (protects KV Cache)

This aligns with Claude Code's progressive disclosure pattern:
- Metadata (name+description) always visible in tool description
- SKILL.md body loaded on activate via Tool Call
- Bundled resources accessible through virtual path mapping

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(tools): add glob and grep native sandbox tools

Add file discovery and content search capabilities to the sandbox:
- glob: Find files by pattern (supports ** recursive matching)
- grep: Search file contents with regex patterns

Both tools respect skill package paths and include safety limits
(max 100 files for glob, max 200 matches for grep).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* feat(skill): add skill file browsing capability

- Add API endpoints for listing/reading/writing skill files
- Add FileTree component in SkillForm for directory browsing
- Users can now view scripts/, references/, assets/ directories
- Files can be selected and edited in the instructions textarea
- Add translations for new file browsing features

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(skill): copy builtin skills to data/skills on startup

- Builtin skills (templates/skills/) are now copied to data/skills/
- Users can view and manage builtin skills in the UI
- Rename SkillAuthoringToolLoader to SkillToolLoader

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(skill): improve file browsing and fix path handling

- Fix nested directory display in skill file tree (preserve root entries)
- Fix file content display when clicking files in skill browser
- Add skill manager and tool manager as proper package modules
- Separate fileContent state to allow editing non-SKILL.md files

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(toolmgr): correct skill_tool_loader attribute name

Rename skill_authoring_tool_loader to skill_tool_loader in execute_func_call
and shutdown methods to match the attribute defined in initialize().

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(native): update tool descriptions to use register_skill

Replace references to removed import_skill_from_directory with
register_skill in exec/write/edit tool descriptions.

* feat(toolmgr): enhance tool initialization with backend availability checks

* refactor: remove unused imports and clean up code in various files

* feat: polish extension detail pages

* feat: persist sidebar list expansion

* fix: refine extension ui and backend errors

* fix: align add extension marketplace ui

* feat: manage skills through box runtime

* feat: support github skill installation

* fix: import github skill directories

* feat: install market extensions from card click

* feat(web): improve skill import flow

* feat: polish extension import flow

* fix(mcp): stabilize shared box managed processes

* fix(web): improve backend retry and sidebar scrolling

* docs(review): refresh box architecture review for feat/sandbox

Sync the docs/review/ suite to the current state of the feat/sandbox branch
(both LangBot and langbot-plugin-sdk), ~30 commits ahead of the prior review.

- box-architecture.md: rewrite for the new box.{backend,runtime,local,e2b}
  config schema, add E2B backend, 6 native tools (incl. glob/grep), Skill
  Tool Call activation, shared multi-process MCP container, SkillManager,
  BoxSkillStore (SDK), 25 actions, 9 error types, heartbeat/reconnect
- box-issues.md: move resolved items (reconnect, heartbeat, Windows, nsjail
  image conflict, frontend monitoring card) into a Resolved section; add
  new P0 (INIT/backend ordering), P1 (extra_mounts immutability after
  container creation), P2 (skill_store test gap, integration tests not in CI)
- box-session-scope.md: add §0 Implementation Status — Phase 1 shipped,
  MCP unification landed earlier than originally scoped
- box-test-coverage.md: realign file inventory (4,400 -> 6,500 LOC),
  add 7 new test files including SDK backend_selection/e2b/skill_store
- box-tob-analysis.md: connection recovery now满足基本要求; add E2B and
  backend self-heal to capabilities; tick off Phase 1 reconnect/heartbeat
- box-vs-plugin-runtime.md: heartbeat/reconnect/Windows support now aligned
  with Plugin Runtime; revise remaining gaps (WS auth, shared base class)

* refactor(box): use unified env-override mechanism for box.local config

The box module hand-rolled its own LANGBOT_BOX_LOCAL_* env parsing in two
places (connector._get_box_config and service._local_config), duplicating
logic that LoadConfigStage._apply_env_overrides_to_config already provides
generically via the SECTION__SUBSECTION__KEY convention.

- Drop the bespoke LANGBOT_BOX_LOCAL_* parsing; read box.local straight
  from instance_config (the unified BOX__LOCAL__* overrides are already
  applied before BoxService initializes)
- Harden _load_allowed_mount_roots to accept a comma-separated string,
  since the generic mechanism stores a freshly-created key as a raw
  string when config.yaml has no box.local.allowed_mount_roots entry
- docker-compose: rename the langbot container env vars to
  BOX__LOCAL__* (the canonical convention); remove them entirely from
  the langbot_box container — the Box runtime never reads box.local from
  env/config.yaml, it is configured via the INIT RPC action

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: repair stale skill/sandbox tests for feat/sandbox

The skill subsystem moved to Tool-Call activation and a Box-managed
skill store; several tests still asserted removed APIs and a sys.modules
stub leaked across the suite. Full unit suite now green (was 23 failing).

- test_skill_tools: drop TestSkillManagerActivation (text-marker API
  removed); rewrite TestSkillActivationHelper around the current
  skill.activation.register_activated_skill; replace the CRUD
  TestSkillAuthoringToolLoader with TestSkillToolLoader covering the
  current activate/register_skill tools and sandbox-availability gating
- test_tool_manager_native: ToolManager attr is skill_tool_loader (not
  skill_authoring_tool_loader); native loader now exposes 6 tools
  (exec/read/write/edit/glob/grep) and requires initialize() with a
  backend-available get_status()
- test_localagent_sandbox_exec: remove obsolete activation-marker
  leakage tests and their helper providers
- test_model_service / pipeline conftest: give the mocks skill_mgr=None
  so PreProcessor's local-agent skill-binding guard short-circuits
- test_n8nsvapi: stop permanently overwriting sys.modules
  ('langbot.pkg.provider.runner' etc.); save and restore around the
  import so other modules get the real LocalAgentRunner base class

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ci(tests): run unit tests on every push to feat/** branches

- Add feat/** to push branches so long-lived feature branches are
  tested on every push (they accumulate large changes before a PR)
- Drop the push path filter entirely: every push to master/develop/
  feat/** now runs the full unit suite (the old 'pkg/**' filter never
  matched the real source path 'src/langbot/pkg/**', so backend-only
  pushes silently skipped tests)
- Fix the same broken path glob on the pull_request trigger
  ('pkg/**' -> 'src/langbot/pkg/**')

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(skill): harden mount/reload paths and HTTP errors against stale skill cache

The Box backends behave inconsistently when extra_mounts reference a
missing host directory (nsjail aborts the entire sandbox start, Docker
silently creates a root-owned empty dir on the host, E2B silently skips
the upload). The cache in skill_mgr.skills is only refreshed on
in-process mutations, so out-of-band changes — container rebuilds,
manual rm in the box volume, anything the LangBot API didn't drive —
leave a stale skill that later produces one of those bad mount paths.

- box/service.py: build_skill_extra_mounts now filters skills whose
  package_root is not isdir on the LangBot-visible filesystem and logs
  a warning, instead of passing the bad mount through to the backend
- skill/manager.py: reload_skills (Box path) drops skills whose
  package_root is missing on the LangBot-side filesystem before they
  reach the in-memory cache, with a summary warning
- api/http/controller/groups/skills.py: file/CRUD handlers now also
  catch BoxError (RuntimeError subclass, previously slipping past
  ``except ValueError`` and surfacing as 500); list/get handlers gain
  a try/except so a transient Box RPC failure becomes a clean 400
  instead of a stack trace

Tests added for build_skill_extra_mounts (skip missing, skip empty,
no skill manager) and SkillManager.reload_skills (drop missing on Box
path). Full unit suite: 279 passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(box): add box.enabled toggle and gate consumers on availability

Make the Box sandbox runtime optional. When ``box.enabled`` is false in
config (or when an enabled Box fails to connect), every dependent feature
degrades to the same disabled-state UX rather than crashing or silently
falling back to less safe code paths.

Backend:

- config.yaml: new top-level ``box.enabled: true`` flag (default true)
- BoxService:
  - Read box.enabled on construction
  - initialize() short-circuits when disabled — no remote WS connect, no
    stdio subprocess fork
  - _on_runtime_disconnect is a no-op when disabled (no reconnect loop
    on a deliberately-off service)
  - get_status() now exposes ``enabled`` so the frontend can tell
    "disabled in config" from "configured but failed"
- MCP stdio loader (mcp_stdio.uses_box_stdio): requires box_service to
  be available, not just installed
- MCP _init_stdio_python_server: when ap.box_service exists but is
  unavailable, refuse the stdio server with an actionable error instead
  of silently falling through to host-stdio (which bypasses the sandbox
  the operator asked for). Setups without ap.box_service installed at
  all keep the legacy host-stdio fallback for pre-Box dev mode
- SkillService._require_box_for_write: refuses create/update/install/
  write_skill_file when ap.box_service is installed but unavailable.
  Distinguishes disabled vs failed in the error message so the UI can
  surface the right hint. Legacy setups (no ap.box_service) keep the
  local fallback path — that distinction is what keeps the existing
  local-skills tests valid

Tests:
- Box disabled-state behavior (4 cases)
- Skill write refusal in disabled & failed states (7 cases)
- MCP stdio runtime info policy updated to match new refuse-when-down
  behavior

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(web): surface Box disabled/unavailable state across consumers

When Box is disabled in config (``box.enabled = false``) or fails to
connect, every dependent UI surface now degrades visibly:

- ``useBoxStatus`` hook: shared, polled 30s, exposes ``available``,
  ``disabled`` (config-off) and a single ``hint`` key so callers don't
  have to re-derive the three states
- ``BoxUnavailableNotice`` reusable Alert banner driven by that hint
- Dashboard SystemStatusCards: three-state dot + label
  (connected / disabled-gray / disconnected-red); disabled state shows
  the ``boxDisabled`` hint, failed state continues to show the connector
  error. Plugin block kept untouched
- Skills page (create view) and SkillDetailContent (edit view):
  Save button disabled and banner inserted above the form when Box is
  unavailable — matches the backend gate added in the previous commit
- PipelineExtension skill section: ``enable_all_skills`` switch, Add
  Skill button and Remove buttons all gate on Box availability;
  banner inline under the section header
- PipelineFormComponent: banner above the ``local-agent`` stage card
  when Box is unavailable, since that stage carries the sandbox-bound
  ``box-session-id-template`` field
- Box status payload type (``ApiRespBoxStatus.enabled``) and 8 locale
  files updated with ``boxDisabled`` / ``boxUnavailable`` /
  ``boxRequiredHint`` strings

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(box): document the box.enabled toggle and gate behavior matrix

- docker-compose: move ``langbot_box`` under compose profiles
  (``box`` and ``all``) so ``docker compose up`` no longer requires
  the sandbox container. Inline comment explains how to pair the
  profile choice with ``box.enabled`` so the langbot service does not
  thrash trying to reach a runtime that was never started
- docs/review/box-architecture.md:
  - Annotate ``box.enabled`` in the config.yaml example, listing the
    exact side effects (no remote/stdio connect; tools/skills/MCP
    stdio off; reads still work)
  - Replace the bare compose snippet with the actual profile-driven
    invocation and the BOX__ENABLED pairing
  - New "关闭/连接失败时的行为矩阵" section: a single table mapping
    every consumer (native tools, activate/register_skill, stdio MCP,
    skill list/CRUD, pipeline AI config, extensions page, dashboard)
    to its disabled-state behavior, plus the legacy ``ap.box_service``
    distinguisher note

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(pipeline-form): swap Box banner for field-level disable_if + tooltip

The previous commit hard-coded a BoxUnavailableNotice banner above the
``local-agent`` stage card. That works, but it shouts at the user about
every field in that stage when in reality only one field —
``box-session-id-template`` — depends on the sandbox.

Use the dynamic-form schema's existing variable-injection mechanism
(``__system.*`` references via ``systemContext``) and add a sibling to
``show_if``: ``disable_if`` + ``disabled_tooltip``. The field stays
visible, becomes inert, and an info icon next to its label exposes the
reason on hover. The rest of the AI tab is left untouched.

- entities/form/dynamic.ts: extend IDynamicFormItemSchema with
  ``disable_if: IShowIfCondition`` and ``disabled_tooltip: I18nObject``
- DynamicFormComponent: evaluate disable_if with the same resolver as
  show_if; OR the result into isFieldDisabled; render an Info tooltip
  trigger next to the label when the condition matches
- ai.yaml metadata: attach disable_if (__system.box_available eq false)
  and a localized disabled_tooltip to box-session-id-template
- PipelineFormComponent: drop the BoxUnavailableNotice import and the
  per-stage banner; pass ``systemContext={ box_available: boxAvailable }``
  only for the local-agent stage so other stages aren't paying the
  re-render cost

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(mcp): friendly UI message when stdio MCP refused by Box state

Previously the MCP detail dialog dumped the raw RuntimeError text from
``_init_stdio_python_server`` — English-only, prefixed with "Failed
after 4 attempts", and exposing internal config names. The retry
wrapper also kept retrying a refusal that is deterministically going
to fail again, polluting logs.

Replace the raw text with a structured signal:

- New ``MCPSessionErrorPhase.BOX_UNAVAILABLE`` enum value. The stdio
  refusal path sets it before raising and uses a short opaque
  discriminator (``box_disabled_in_config`` / ``box_unavailable``) as
  the message body — never user-facing
- ``_lifecycle_loop_with_retry`` short-circuits on
  ``BOX_UNAVAILABLE``: surfaces the error immediately, no retries,
  no "Failed after N attempts" prefix. Silences the warning storm
  seen during smoke-testing
- ``MCPServerRuntimeInfo`` (TS type) now declares ``error_phase``,
  ``retry_count``, ``box_session_id``, ``box_enabled`` to match what
  the backend already returns in get_runtime_info_dict()
- Both MCP detail forms (``mcp/components/mcp-form/MCPForm.tsx`` and
  ``plugins/mcp-server/mcp-form/MCPFormDialog.tsx``) detect
  ``error_phase === 'box_unavailable'`` and render a two-line
  localized notice: state line ("Box disabled / unreachable") plus
  remediation line ("enable Box or switch to http/sse")
- 8 locale files (en/zh-Hans/zh-Hant/ja/ru/vi/th/es) get
  ``mcp.boxDisabledStdioRefused``, ``mcp.boxUnavailableStdioRefused``,
  ``mcp.boxStdioRefusedSuggestion``

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(mcp-web): block stdio MCP creation at the form when Box is unavailable

When Box is disabled in config (``box.enabled = false``) or unreachable,
saving a new MCP server in stdio mode produced one that could never
start — the user would only learn that from the runtime error on the
detail page. Stop the user before they save instead.

Both MCP forms (the page-level ``MCPForm.tsx`` and the older dialog
``MCPFormDialog.tsx``) now:

- Disable the ``stdio`` option in the mode select when Box is
  unavailable, with a small "(requires Box)" suffix so the reason is
  obvious. Existing stdio configs still display their current value
- Show ``BoxUnavailableNotice`` inline under the mode select when the
  currently-selected mode is stdio and Box is unavailable, so editing
  a stale stdio config makes the cause visible
- Disable the Save / Submit button while stdio is selected under that
  condition. ``MCPForm`` exposes a new ``onSaveBlockedChange`` prop
  so the parent ``MCPDetailContent`` can disable both its Submit and
  Save buttons. ``MCPFormDialog`` disables its Save button locally
- Refuse the submit handler too (Enter-key path) with a toast carrying
  the same i18n message

i18n: ``mcp.boxRequired`` (short tag in the disabled option) and
``mcp.stdioBlockedByBoxToast`` added to all 8 locales.

Backend runtime gate (``_init_stdio_python_server`` refusal +
``BOX_UNAVAILABLE`` error_phase + retry short-circuit) stays in place
as the last line of defence for API bypass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(web): prevent plugin config form overflow

* refactor(skill): remove all local-filesystem fallbacks; Box is the sole source

Skills now flow exclusively through the Box runtime. Every read and write
method funnels through ``_box_service()``; when Box is unavailable
(disabled in config, connection failed, or simply not installed) the
operation either returns an empty surface (``list_skills`` → []) or
raises with a clear ``Box runtime ... not initialised / disabled /
unavailable: ...`` message via the new ``_require_box(action)`` helper.

Why: the legacy local-fallback path scanned ``data/skills/``, but Box
manages its own ``box.local.skills_root`` (default ``data/box/skills/``).
The two diverging directories caused stale / phantom skill lists when
Box flapped, and the local-fallback writes silently bypassed all the
sandboxing the operator had configured.

SkillService (``api/http/service/skill.py``):
- New ``_require_box(action)`` returns the box service or raises a
  structured ValueError. ``_require_box_for_write`` kept as alias
- ``list_skills`` → returns [] when Box is down so the UI can render
  the disabled banner cleanly
- ``get_skill`` / ``get_skill_by_name`` → return None
- All read-file / write-file / scan-dir / create / update / delete /
  install / preview methods → ``_require_box`` then box delegate.
  Local fallback bodies (shutil.copytree, tempfile.mkdtemp, preview
  pipelines) removed entirely

SkillManager (``pkg/skill/manager.py``):
- ``reload_skills`` returns early with empty cache when Box is down.
  data/skills/ discovery loop removed
- ``refresh_skill_from_disk`` now just reports cache presence; the
  on-disk re-parse is gone since Box is the only writer

Tests:
- Drop 11 obsolete test_skill_service.py tests that exercised the
  removed local-fallback paths (create/install/file/delete/update)
- Add list-empty + read-refused tests; flip the legacy-allow test to
  legacy-refuses-too
- Rewrite refresh_skill_from_disk test to match the new behaviour

Several helper methods (_managed_skill_path, _resolve_skill_path,
_preview_skill_candidates, _install_preview_candidates, etc.) are now
unreachable; a follow-up commit will prune them so this diff stays
reviewable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(skill): prune dead local-filesystem helpers left over from Box migration

Follow-up to the Box-only refactor. The previous commit removed the
local-fallback BRANCHES from every public method; this one removes the
HELPERS those branches called, which are now unreachable.

SkillService (service/skill.py): 787 → 449 lines
  Removed: scan_directory (sync), _read_skill_package, _write_skill_md,
  _resolve_create_field, _managed_skill_path,
  _managed_install_root_for_package, _normalize_package_root,
  _resolve_skill_path, _find_skill_entry, _discover_skill_directories,
  _safe_extract_zip, _extract_uploaded_skill_to_temp,
  _download_github_skill_to_temp, _resolve_github_source_root,
  _build_preview_target_dir, _preview_skill_candidates,
  _select_preview_candidates, _install_preview_candidates,
  _preview_source_root, _resolve_installed_skills, plus the
  module-level _FRONTMATTER_FIELDS and _build_skill_md.
  Kept (still needed by the surviving GitHub-import path):
  _download_github_asset, _download_github_skill_directory_as_zip,
  _find_github_skill_archive_entry, _copy_github_skill_directory_to_zip,
  _is_github_skill_md_url, _parse_github_skill_md_url,
  _resolve_github_skill_md_package_name, _validate_github_asset_url,
  _uploaded_skill_target_stem, _validate_skill_name.
  Imports dropped: shutil, tempfile, yaml, ....utils.paths.

SkillManager (skill/manager.py): 187 → 88 lines
  Removed: get_managed_skills_root, _discover_skill_directories,
  _find_skill_entry, _load_skill_file, _normalize_package_root.
  Imports dropped: datetime, parse_frontmatter, paths.

Tests:
  - test_skill_service.py: drop the 3 sync scan_directory tests +
    skill_service fixture + _create_skill_file helper
  - test_skill_tools.py: drop test_load_skill_file_success; rename
    TestSkillManagerPackageLoading → TestSkillManagerCache

Full unit suite: 277 passed, 1 skipped. ``ruff check`` clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(skill): re-inject skill index into local-agent system prompt

The contributor's original PR (#1917) appended an ``Available Skills``
index to the system prompt before the LLM saw the user message, so the
LLM could decide whether to activate a skill. ``7145447b`` removed the
text-marker activation flow and, together with it, the entire system
prompt injection — but the Tool Call replacement only put the available
skills inside the ``activate`` tool's description. In practice the LLM
ignores tool descriptions for selection and goes straight to native
tools, so user-visible skill activation silently broke.

Restore the injection, adapted for the Tool Call era:

- SkillManager regains ``get_skill_index(bound_skills)`` and
  ``build_skill_aware_prompt_addition(bound_skills)``. The addendum
  carries only ``name (display_name): description`` for each
  pipeline-visible skill plus one instruction line pointing at the
  ``activate`` tool. No SKILL.md contents — KV cache stays clean
- PreProcessor appends the addendum to the first system message (or
  inserts a new one) of ``query.prompt.messages`` for the local-agent
  runner. Handles plain-string and ContentElement[] bodies. Skips
  cleanly when no skills are visible
- 3 new test_preproc cases: injection happens, bound-skills subset
  honoured, empty addendum touches nothing. 280 passed

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(box): downgrade get_status.available when backend probed unavailable

Until now ``BoxService.get_status`` returned ``available: true`` whenever
the runtime connector was healthy, even if the runtime itself reported
``backend: { available: false }`` (operator selected nsjail without the
binary, Docker daemon crashed mid-session, E2B credentials wrong, ...).
The dashboard / ``useBoxStatus`` hook / skill_service gate consumed the
top-level flag and showed "connected" while every actual call to native
exec or skill management would fail.

The native-tool loader already polled ``status.backend.available``
independently and hid its tools correctly, but every other consumer
(dashboard banner, the disabled-state hint, the LLM-facing message)
disagreed with it.

Combine the two in the payload: ``available = self._available AND
status.backend.available``. When ``backend.available`` is false we now
also surface a ``connector_error`` that names the backend
("Configured sandbox backend \"nsjail\" is unavailable") so the dialog
shows the actionable reason instead of an empty error pane. The
detailed ``backend`` object is preserved unchanged for the dialog.

Internal ``box_service.available`` (used by ``skill_service`` writes,
``mcp_stdio.uses_box_stdio``, the reconnect callback) is intentionally
NOT changed — it still tracks connector health only, so a backend blip
does not trigger spurious reconnect loops.

Tests:
- ``test_get_status_downgrades_available_when_backend_dead`` — exercise
  the new branch (connector OK, backend.available=false → top-level
  available=false, connector_error mentions the backend name)
- ``test_get_status_keeps_available_true_when_backend_ok`` — guard
  against regressing the happy path

Live-verified with ``box.backend: nsjail`` on macOS (no nsjail binary):
``GET /api/v1/box/status`` now returns ``available: false`` with the
named connector_error, instead of the previous misleading
``available: true``.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(web): surface the specific Box failure reason in unavailable banner

When Box is configured but the runtime reports its backend is dead
(e.g. ``box.backend = nsjail`` but the binary is missing, or Docker
daemon crashed), the backend now returns a structured
``connector_error`` like ``Configured sandbox backend "nsjail" is
unavailable``. The previous notice only said "Box sandbox is
unavailable" + a generic "enable Box" hint, hiding the actionable
detail.

- ``useBoxStatus``: derive ``reason`` from ``status.connector_error``.
  Only exposed for the failed-state (``hint === 'boxUnavailable'``),
  since the disabled-by-config message already carries its reason
- ``BoxUnavailableNotice``: insert the reason as a small monospaced
  line between the state message and the action hint. The disabled
  variant is unchanged (operator chose the state)
- Wire ``reason`` through every existing call site (Skills page +
  detail, PipelineExtension, both MCP forms). Old unused ``context``
  prop dropped

Net layout (3 lines, still compact):

  ⚠ Box sandbox is unavailable — sandbox tools, skill add/edit, ...
    Configured sandbox backend "nsjail" is unavailable
    This feature requires the Box runtime. Enable it in config ...

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: reconcile master's unit tests with feat/sandbox refactors

The merge from master brought in new unit tests that target pre-refactor
APIs on feat/sandbox. Reconcile each:

- factories/app.py: FakeApp now exposes a Mock skill_mgr (with empty .skills
  dict + inert prompt-addition builder) and a Mock pipeline_service so the
  PreProcessor skill-index injection branch can run end-to-end in tests.

- pipeline/conftest.py: eagerly import langbot.pkg.pipeline.pipelinemgr so
  pipeline.stage is fully initialised before any individual stage test
  (preproc, longtext, ...) tries to lazy-load it. Without this preload,
  running test_preproc.py in isolation hit a circular-import error via the
  stage -> app -> pipelinemgr -> stage chain.

- provider/test_tool_manager.py: ToolManager now probes four loaders
  (native -> plugin -> mcp -> skill). Inject inert native + skill mocks in
  the execute_func_call fixture and assert all four shutdowns fire.

- utils/test_paths.py: drop the three cwd-dependent _check_if_source_install
  cases. The refactor walks Path(__file__).resolve().parents looking for
  pyproject.toml + main.py, so cwd no longer factors in and there's no
  file read to mock-fail. The positive case and caching test still apply.

- utils/test_version.py: delete entirely. is_newer and compare_version_str
  were removed when VersionManager was refactored to use the Space API for
  release checks (1b4107a9); the tests targeted a surface that no longer
  exists.

* refactor(box): launch box runtime via the lbp CLI subcommand

Mirror the plugin runtime: box is now started through the same CLI entry
point (langbot_plugin.cli) instead of the box module directly.

- docker-compose.yaml: langbot_box command runs `langbot_plugin.cli ... box`
  (WebSocket is the default transport, no flag needed — matches `rt`).
- box/connector.py: both subprocess launch sites (_start_local_stdio and
  the Windows _start_subprocess_then_ws path) invoke
  `langbot_plugin.cli.__init__ box`, using `-s` for the stdio transport.
- docs/review: update stale `-m langbot_plugin.box[.server]` references.

Pairs with the SDK change that removes box's direct-launch entry points
(python -m langbot_plugin.box / .box.server) and the legacy --mode flag.

* chore: bump langbot-plugin beta 1

* fix(ci): resolve langbot-plugin from PyPI and clear lint failures

CI on feat/sandbox failed across Unit Tests, Lint and Build Dev Image.
Root causes and fixes:

- pyproject.toml had a [tool.uv.sources] editable override pinning
  langbot-plugin to ../langbot-plugin-sdk. That path only exists in a
  paired local checkout, so `uv sync` failed on every CI runner
  ("Distribution not found"). Remove the override and regenerate uv.lock
  so langbot-plugin==0.4.0b1 resolves from PyPI, matching master.

- tests/integration/api/test_pipelines.py: the pipeline extensions
  endpoint now calls ap.skill_service.list_skills(); add the missing
  skill_service mock to the fake_pipeline_app fixture (the test came
  from master, the endpoint change from feat/sandbox).

- Apply ruff format to three src files and prettier to three web files
  that had committed formatting drift, failing `ruff format --check`
  and `pnpm lint`.

* chore: bump beta version

* docs: remove BOX_BACKEND override reference

* fix(pipelines): stop attributing dashboard debug WS to bound web_page_bot

The dashboard pipeline-debug WebSocket
(/api/v1/pipelines/<uuid>/ws/connect) and the embed widget WebSocket
(/api/v1/embed/<bot_uuid>/ws/connect) already live on separate paths,
but the debug handler ran `_find_owner_bot(pipeline_uuid)` and, when
the same pipeline happened to be bound to a web_page_bot, passed that
bot as `owner_bot` into `handle_websocket_message`. The adapter then
used the page bot's listeners + adapter for the request, so debug
sessions were logged as "page bot" activity in the dashboard.

Debug sessions must always run under the built-in websocket_proxy_bot.
Remove `_find_owner_bot`, drop the `owner_bot` parameter from the
debug-path `_handle_receive`, and call `handle_websocket_message`
without it so the adapter takes its default proxy-bot branch. The
embed handler still resolves and passes its `runtime_bot` for the
page-bot path, so attribution there is unchanged.

* fix(plugin): install marketplace MCP from canonical mode + extra_args

_install_mcp_from_marketplace read the dropped `mcp_data.config` field
and reconstructed mode/extra_args by guessing from the URL — which lost
stdio's command/args/env/box entirely, so stdio MCP installs from the
marketplace always failed.

Use the Space record's canonical `mode` and `extra_args` directly (the
same shape stored in mcp_servers), and gate the install on `mode`
instead of the removed `config`. After a successful install, best-effort
POST to the marketplace install endpoint to bump install_count.

* feat(web): show recommendation lists in plugin market; mixed-type icons

The marketplace recommendation lists (curated rows from Space) were never
mounted in the plugin market page. Wire them in:
- fetch recommendation lists on mount and render them above the extension
  grid, only when no search/filter is active.

Recommendation lists now mix plugins, MCPs and skills, so resolve each
card's icon by type (plugin / mcp / skill marketplace icon URL) instead of
always using the plugin icon endpoint.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): auto-open install dialog from one-click deep link

Accept a deep link from LangBot Space's one-click install:
/home/add-extension?install=1&extension_type=<plugin|mcp|skill>&author=&name=&version=
On mount, populate the install info, open the confirm dialog directly, and
strip the params from the URL. Reuses the existing marketplace install flow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat: push marketplace URL to runtime; fix market client base race

- On connecting to the plugin runtime, push the configured space.url via the
  new SET_RUNTIME_CONFIG action so the runtime downloads plugins from the same
  Space, instead of relying on its own CLOUD_SERVICE_URL env/default. Wrapped
  in try/except so an older SDK without the action degrades gracefully.
- web: the plugin market fetched recommendation lists (and listings) via the
  sync cloud client before its baseURL was resolved from system info, so it
  hit the default space.langbot.app. Await getCloudServiceClient() before the
  initial fetches and for the recommendation list.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(web): don't show MCP "connection failed" while still connecting

The MCP status UI rendered "连接失败" for any non-connected state, so during a
normal connection attempt the subtitle showed "连接失败" while the status pill
below it showed "连接中..." — contradictory.

Only treat an explicit ERROR (or box-unavailable) status as failed; a
CONNECTING or initial/unresolved status now shows "连接中". Applied to the MCP
detail form (subtitle + StatusDisplay) and the MCP server card.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): type-aware install dialog + refresh sidebar after install

The marketplace install confirm dialog was hardcoded to "安装插件 / 确定要安装
插件 X 吗" for every type. Make it type-aware (plugin / MCP / skill) and show
more info: type chip, author/name id, and version when present.

Also refresh all sidebar extension lists (plugins, MCP servers, skills) when
an install task completes, so the newly-installed extension appears
immediately regardless of type (previously only refreshPlugins ran).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): richer install dialog (icon + name + description), drop redundant type row

The install dialog already states the type in its title, so the "类型" row was
redundant. Replace the info box with the extension's icon (avatar), display
name, author/name id + version, and description — built from the PluginV4 for
in-app installs and from the icon endpoint by type for the one-click deep link.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(web): TDZ crash in add-extension (installIconURL before installInfo)

installIconURL was computed above the useState declaration of installInfo,
causing "Cannot access 'installInfo' before initialization" (500) on the
add-extension page. Move the computation below the state declarations.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): redesign install-progress dialog for MCP/skill

The progress dialog showed plugin-only stages (download + dependency install)
for every type. MCP/skill have no such steps, so show a single
"installing → done/failed" row for them (MCP: adding & connecting the server;
skill: installing the package) while keeping the detailed download/deps
stages for plugins.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(web): add missing market.componentName i18n keys

The marketplace component filter (and component badges) used
market.componentName.{Tool,Command,EventListener,KnowledgeEngine,Parser,Page}
but those keys only existed under plugins.componentName, so the market UI
showed raw keys. Add a componentName block to the market namespace (zh-Hans +
en-US; other locales fall back to zh-Hans).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): sidebar extensions refresh button + full-name tooltip

- Add a refresh button to the installed-extensions category header in the
  sidebar; it re-fetches plugins + MCP servers + skills and spins while
  loading.
- The sidebar item tooltip now shows the extension's full name (with the
  description below when present), so truncated MCP/extension names are
  readable on hover.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(plugin-market): rename component filter to "插件组件" with hint tooltip + persist filters

- Rename the in-app plugin market component filter label to "插件组件" /
  "Plugin Component"
- Add an Info icon tooltip explaining what plugin components are (Tool /
  Command / EventListener, etc.)
- Persist filter selections (type / component / tags / sort) in localStorage
  so they survive reloads; restored on mount (URL type param still wins)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(plugin-market): restore missing "页面"(Page) component filter option

The market component-filter list on this branch was a diverged rewrite that
dropped the Page component kind master had added. The i18n key
(market.componentName.Page) already existed; re-add the Page entry to the
componentOptions list so plugins providing Page components can be filtered.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(i18n): reword plugin component filter hint

Drop the redundant "插件组件是" lead-in and mention that components extend
LangBot's capabilities; mirror the wording in en-US.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(i18n): backfill missing market/addExtension keys in 6 locales

check-i18n surfaced that market.componentName.*, market.filterByComponentHint
and the addExtension.install* keys existed only in en-US/zh-Hans. Backfill
them for es-ES, ja-JP, ru-RU, th-TH, vi-VN and zh-Hant (reusing each locale's
existing component-name translations) and align the filterByComponent label
with the new "Plugin Component" wording. check-i18n now passes for all locales.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* i18n(plugins): relabel "group by type" as "group by format"

The installed-extensions grouping is by extension format (plugin / MCP / skill),
so rename the toggle label accordingly across all 8 locales (key unchanged).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(plugin-market): cursor-pointer on tag filter trigger

The TagsFilter Select trigger used the default cursor; add cursor-pointer so the
tag filter is clearly clickable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sidebar): show edition badge (Community / Cloud) in logo area

Add a small badge next to the LangBot name in the sidebar header that reflects
systemInfo.edition: a neutral "Community" badge for the community edition and a
blue "Cloud" badge for the cloud edition. Adds sidebar.editionCommunity /
sidebar.editionCloud across all 8 locales.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* i18n(sidebar): unify zh-Hans cloud edition label to 云端版

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(sidebar): edition badge - drop hover, use "Cloud" in all locales

The edition badge is not interactive, so remove the hover background on the
cloud badge. Also use the literal "Cloud" label uniformly across all locales
instead of localized variants (云端版/クラウド版/...).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(box): cap tool-call loop and run workspace-quota walk off the event loop

Two robustness fixes that bite under normal sandbox usage (not just attack),
hardening the self-hosted community edition before release:

- localagent: cap the tool-call loop at MAX_TOOL_CALL_ROUNDS (128). A looping
  or adversarial model could otherwise emit tool calls indefinitely (each
  potentially a sandbox exec), producing a non-terminating request and runaway
  cost. The cap is generous enough not to interrupt legitimate multi-step
  agentic workflows.
- box.service: make _enforce_workspace_quota async and run the recursive
  workspace scan via asyncio.to_thread. It ran on every quota-enforced exec and
  a large workspace would block the whole asyncio runtime (all bots/pipelines).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(review): refresh box docs; trim issue list to SaaS blockers only

Community self-hosted edition is release-ready, so the box review docs are
updated to current state (date 2026-06-02 + status note) and box-issues.md is
rewritten to keep only the SaaS / multi-tenant / network-exposed release
blockers (S1-S8): unauthenticated control plane, no per-pipeline exec
authorization, unbounded sessions + no reaper, no kernel-level quota, mount
validation gaps (/ + extra_mounts), missing container hardening, lock-around-
cold-start, and the lower-severity follow-ups. Resolved items (tool-call loop
cap, async quota scan, host_path mount allowlist, _is_path_under dedup) moved to
a short "resolved before community release" record; community-only and
pure-cleanup items dropped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(deps): pin langbot-plugin to 0.4.0

Track the stable SDK release (0.4.0b1 -> 0.4.0); regenerate uv.lock.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: WangCham <651122857@qq.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: fdc310 <82008029+fdc310@users.noreply.github.com>
Co-authored-by: Junyan Qin <rockchinq@gmail.com>
2026-06-03 11:12:39 +08:00
RockChinQ
4054ba2a76 docs(issue-template): add deployment version selector 2026-06-01 23:31:29 -04:00
Dongchuan Fu
c7cb42bd79 feat(lark): add domain configuration options for Lark adapter (#2220) 2026-05-27 15:34:35 +08:00
Dongchuan Fu
894709d577 feat(qrcode-login): enhance WeChat login flow with expiration handlin… (#2212)
* feat(qrcode-login): enhance WeChat login flow with expiration handling and improved session management

* feat(qrcode-login): replace RefreshCw icon with RotateCw for loading state

* feat(qrcode-login): adjust session expiration handling and improve error status management
2026-05-21 14:28:02 +08:00
287 changed files with 13276 additions and 7899 deletions

View File

@@ -10,6 +10,15 @@ body:
placeholder: 例如v3.3.0、CentOS x64 Python 3.10.3、Docker placeholder: 例如v3.3.0、CentOS x64 Python 3.10.3、Docker
validations: validations:
required: true required: true
- type: dropdown
attributes:
label: 部署版本
description: 请选择您使用的 LangBot 部署版本。
options:
- 社区版
- 云服务
validations:
required: true
- type: textarea - type: textarea
attributes: attributes:
label: 异常情况 label: 异常情况

View File

@@ -10,6 +10,15 @@ body:
placeholder: "For example: v3.3.0, CentOS x64 Python 3.10.3, Docker" placeholder: "For example: v3.3.0, CentOS x64 Python 3.10.3, Docker"
validations: validations:
required: true required: true
- type: dropdown
attributes:
label: Deployment version
description: Please select the LangBot deployment version you are using.
options:
- Community Edition
- Cloud Service
validations:
required: true
- type: textarea - type: textarea
attributes: attributes:
label: Exception label: Exception

View File

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

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

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

151
AGENTS.md
View File

@@ -1,81 +1,142 @@
# AGENTS.md # AGENTS.md
This file is for guiding code agents (like Claude Code, GitHub Copilot, OpenAI Codex, etc.) to work in LangBot project. This file guides code agents (Claude Code, GitHub Copilot, OpenAI Codex, etc.) working in the LangBot project. `CLAUDE.md` is a symlink to this file.
## Project Overview ## Project Overview
LangBot is a open-source LLM native instant messaging bot development platform, aiming to provide an out-of-the-box IM robot development experience, with Agent, RAG, MCP and other LLM application functions, supporting global instant messaging platforms, and providing rich API interfaces, supporting custom development. LangBot is an open-source, LLM-native instant-messaging bot development platform. It aims to provide an out-of-the-box IM bot development experience with Agent, RAG, MCP and other LLM application capabilities, supporting mainstream global IM platforms and exposing rich APIs for custom development.
LangBot has a comprehensive frontend, all operations can be performed through the frontend. The project splited into these major parts: LangBot has a comprehensive web frontend almost every operation can be performed through it.
- `./src/langbot`: The main python package of the project, below are the main modules in this package: - **Python**: `>=3.11,<4.0`, dependencies managed by `uv`. Package version is in `pyproject.toml`.
- `./pkg`: The core python package of the project backend. - **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`.)
- `./pkg/platform`: The platform module of the project, containing the logic of message platform adapters, bot managers, message session managers, etc. - **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`.
- `./pkg/provider`: The provider module of the project, containing the logic of LLM providers, tool providers, etc.
- `./pkg/pipeline`: The pipeline module of the project, containing the logic of pipelines, stages, query pool, etc.
- `./pkg/api`: The api module of the project, containing the http api controllers and services.
- `./pkg/plugin`: LangBot bridge for connecting with plugin system.
- `./libs`: Some SDKs we previously developed for the project, such as `qq_official_api`, `wecom_api`, etc.
- `./templates`: Templates of config files, components, etc.
- `./web`: Frontend codebase, built with Next.js + **shadcn** + **Tailwind CSS**.
- `./docker`: docker-compose deployment files.
## Backend Development ## Repository Layout
We use `uv` to manage dependencies. ```
LangBot/
├── main.py # Entrypoint shim -> langbot.__main__.main()
├── pyproject.toml # Python project + deps (uv), pins langbot-plugin==<x.y.z>
├── src/langbot/
│ ├── __main__.py # Real entrypoint, CLI args (--standalone-runtime, --standalone-box, --debug)
│ ├── pkg/ # Core backend package
│ │ ├── api/ # HTTP API controllers + services (Quart)
│ │ ├── core/ # App bootstrap, stages, task manager
│ │ ├── platform/ # IM platform adapters, bot managers, session managers
│ │ ├── provider/ # LLM providers, requesters, tool providers
│ │ ├── pipeline/ # Pipelines, stages, query pool
│ │ ├── plugin/ # Bridge connecting LangBot to the plugin runtime (see below)
│ │ ├── box/ # Code-sandbox subsystem (Docker / nsjail / E2B backends)
│ │ ├── skill/ # Skill subsystem
│ │ ├── rag/ , vector/ # RAG + vector store
│ │ ├── command/ # Built-in commands
│ │ ├── persistence/ # ORM models + Alembic migrations (SQLite & PostgreSQL)
│ │ ├── storage/ # Object/file storage abstractions
│ │ ├── config/, entity/, discover/, utils/, telemetry/, survey/
│ ├── libs/ # Vendored SDKs (qq_official_api, wecom_api, etc.)
│ └── templates/ # Config/component templates (e.g. templates/config.yaml)
├── web/ # Frontend SPA (Vite + React Router 7 + shadcn + Tailwind)
└── docker/ # docker-compose deployment files
```
## Development Environment Setup
Full guide lives in the wiki: **["开发配置" / Dev Config](https://docs.langbot.app/zh/develop/dev-config)**. Summary:
### Backend
```bash ```bash
pip install uv pip install uv
uv sync --dev uv sync --dev # uv creates a .venv/ for you; point your editor's interpreter at it
uv run main.py # serves API + web UI on http://127.0.0.1:5300
``` ```
Start the backend and run the project in development mode. On first run the config file is generated at `data/config.yaml`. DB is SQLite by default (zero setup); PostgreSQL is supported. Migrations run automatically on startup.
```bash ### Frontend
uv run main.py
```
Then you can access the project at `http://127.0.0.1:5300`. Requires Node.js + [pnpm](https://pnpm.io/installation).
## Frontend Development
We use `pnpm` to manage dependencies.
```bash ```bash
cd web cd web
cp .env.example .env cp .env.example .env # Windows: copy .env.example .env
pnpm install pnpm install
pnpm dev pnpm dev # http://127.0.0.1:3000 (npm install / npm run dev also work)
``` ```
Then you can access the project at `http://127.0.0.1:3000`. `pnpm dev` reads `VITE_API_BASE_URL` from `web/.env` so the dev frontend can reach the backend on port `5300`. In production the frontend is pre-built into static files served by the backend on the same origin.
## Plugin System Architecture ### Code formatting
LangBot is composed of various internal components such as Large Language Model tools, commands, messaging platform adapters, LLM requesters, and more. To meet extensibility and flexibility requirements, we have implemented a production-grade plugin system. The repo runs lint + format checks in CI. Install the pre-commit hooks so the same checks run locally before each commit:
Each plugin runs in an independent process, managed uniformly by the Plugin Runtime. It has two operating modes: `stdio` and `websocket`. When LangBot is started directly by users (not running in a container), it uses `stdio` mode, which is common for personal users or lightweight environments. When LangBot runs in a container, it uses `websocket` mode, designed specifically for production environments. ```bash
uv run pre-commit install
```
Plugin Runtime automatically starts each installed plugin and interacts through stdio. In plugin development scenarios, developers can use the lbp command-line tool to start plugins and connect to the running Runtime via WebSocket for debugging. ## Plugin System
> Plugin SDK, CLI, Runtime, and entities definitions shared between LangBot and plugins are contained in the [`langbot-plugin-sdk`](https://github.com/langbot-app/langbot-plugin-sdk) repository. LangBot's plugin system (Plugin SDK, CLI `lbp`, Plugin Runtime, and the shared entity/API definitions) lives in a **separate repository**: [`langbot-plugin-sdk`](https://github.com/langbot-app/langbot-plugin-sdk). LangBot depends on it via the pinned `langbot-plugin` package in `pyproject.toml`.
## Some Development Tips and Standards ### Architecture (what to know inside this repo)
- LangBot is a global project, any comments in code should be in English, and user experience should be considered in all aspects. - Plugins run as independent processes managed by the **Plugin Runtime**. The Runtime supports two control transports: `stdio` and `websocket`.
- Thus you should consider the i18n support in all aspects. - When LangBot is started directly by a user (not in a container), it spawns and connects to the Runtime over **stdio** (lightweight/personal use).
- LangBot is widely adopted in both toC and toB scenarios, so you should consider the compatibility and security in all aspects. - When LangBot runs in a container, it connects to a standalone Runtime over **WebSocket** (production).
- If you were asked to make a commit, please follow the commit message format: - The bridge code lives in `src/langbot/pkg/plugin/` (`connector.py`, `handler.py`).
- format: <type>(<scope>): <subject> - 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.
- type: must be a specific type, such as feat (new feature), fix (bug fix), docs (documentation), style (code style), refactor (refactoring), perf (performance optimization), etc.
- scope: the scope of the commit, such as the package name, the file name, the function name, the class name, the module name, etc. ### Debugging the Plugin Runtime / CLI / SDK
- subject: the subject of the commit, such as the description of the commit, the reason for the commit, the impact of the commit, etc.
- LangBot uses [Alembic](https://alembic.sqlalchemy.org/) to manage database migrations, supporting both SQLite and PostgreSQL. Migration files are located in `src/langbot/pkg/persistence/alembic/versions/`. If you changed the definition of database entities (ORM models), generate a new migration script by running `uv run python -m langbot.pkg.persistence.alembic_runner autogenerate "description of your change"` in the project root (requires `data/config.yaml` to exist). Review and edit the generated script before committing. Migrations are executed automatically on LangBot startup. For data migrations (e.g. modifying JSON field content), you need to manually add the migration code in the generated script. This is documented in detail in the **SDK repo's `AGENTS.md`** and in the wiki page **["调试插件运行时、CLI、SDK" / Plugin Runtime](https://docs.langbot.app/zh/develop/plugin-runtime)**. The short version:
- Clone `LangBot` and `langbot-plugin-sdk` as siblings under one parent dir so the editor resolves shared entities.
- Start a standalone Runtime from the SDK repo: `uv run --no-sync lbp rt` (control port `5400`, debug port `5401`).
- To make LangBot use a locally-modified SDK: from the SDK dir, with LangBot's `.venv` active, run `uv pip install .`, then launch LangBot with `uv run --no-sync main.py --standalone-runtime` (keep `--no-sync` so your local SDK isn't overwritten).
### Debugging the Box (sandbox) runtime
The Box subsystem (`src/langbot/pkg/box/`) is the code sandbox. It picks the first available backend among **Docker / nsjail / E2B**. The standalone Box runtime is launched via the SDK CLI: `lbp box`. Backend selection details, the `lbp box` flags, and the SDK-side architecture are documented in the SDK repo's `AGENTS.md`.
Relevant config (`data/config.yaml`, `box:` section): `box.enabled` (master switch — disabling it also disables the native sandbox tools, skill add/edit, and stdio-mode MCP servers), `box.backend` (`'local'` = Docker/nsjail auto-pick, or `'docker'` / `'nsjail'` / `'e2b'`; also settable via `BOX__BACKEND`), and `box.runtime.endpoint` (external Box runtime base URL, e.g. `ws://127.0.0.1:5410`; empty = local auto-managed runtime). Like the plugin runtime, LangBot can connect to an externally-launched Box runtime by setting that endpoint and starting with `--standalone-box`.
> A common false "No supported sandbox backend (Docker / nsjail / E2B) is available" comes from Docker being installed and running but the current user not being in the `docker` group → `docker info` gets `permission denied` on the socket. Fix: `sudo usermod -aG docker <user>` and restart the backend in a shell that has the new group.
## Development Standards
- LangBot is a global project: **all code comments and docstrings must be in English**, and every user-facing string must support **i18n** (`en_US` + `zh_Hans` at minimum, plus `ja_JP` where the repo already has it).
- LangBot is adopted in both toC and toB scenarios — always consider compatibility and security.
- **Commit message format**: `<type>(<scope>): <subject>`
- `type`: one of `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `chore`, etc.
- `scope`: the affected package/module/file/class.
- `subject`: concise description of the change.
### Database migrations (Alembic)
LangBot uses [Alembic](https://alembic.sqlalchemy.org/) for migrations, supporting both SQLite and PostgreSQL from a single set of scripts. Migration files live in `src/langbot/pkg/persistence/alembic/versions/`.
If you change ORM model definitions, generate a migration:
```bash
# Run from the project root (requires data/config.yaml to exist)
uv run python -m langbot.pkg.persistence.alembic_runner autogenerate "description of your change"
```
Review and edit the generated script before committing. Migrations execute automatically on startup. `autogenerate` detects schema changes (add/drop columns, tables, type changes) but **data migrations** (e.g. mutating JSON field contents) must be hand-written into the generated script. `env.py` sets `render_as_batch=True`, so SQLite's ALTER TABLE limits are handled automatically — no need to branch per database. More in the wiki ["开发配置"](https://docs.langbot.app/zh/develop/dev-config#数据库迁移).
When writing a migration, follow these rules:
- **Revision id ≤ 32 characters.** PostgreSQL stores `alembic_version.version_num` as `varchar(32)`; a longer id raises `StringDataRightTruncationError` at runtime. Prefer short, descriptive ids like `0005_add_llm_context_length`.
- **Guard every operation against missing tables/columns.** Fresh installs build the schema via `create_all()` and then stamp the Alembic baseline, so a migration may run against a table that already has the change — or, in tests, against an empty database. Check `inspector.get_table_names()` / `inspector.get_columns(...)` before `add_column` / `drop_column`, mirroring the existing migrations.
- **Keep a single linear head.** Chain `down_revision` to the current head; do not create branches. Run the migration tests after adding one: `uv run pytest tests/integration/persistence/ -q` (the PostgreSQL test needs a running PG via `TEST_POSTGRES_URL`).
> **Legacy migration system (deprecated — do not extend).** The old 3.x migration system under `src/langbot/pkg/persistence/migrations/` (`DBMigration` subclasses in `dbmXXX_*.py`, run from `pkg/persistence/mgr.py`) is **frozen**. Do **not** add new `dbmXXX_*.py` files. The chain is capped at `required_database_version = 25` (`pkg/utils/constants.py`); those files only exist to upgrade pre-existing 3.x databases up to the Alembic baseline and are kept read-only. All new schema changes go through Alembic.
## Some Principles ## Some Principles
- Keep it simple, stupid. - Keep it simple, stupid.
- Entities should not be multiplied unnecessarily - Entities should not be multiplied unnecessarily.
- 八荣八耻 - 八荣八耻
以瞎猜接口为耻,以认真查询为荣。 以瞎猜接口为耻,以认真查询为荣。

107
CLA.md Normal file
View File

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

View File

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

View File

@@ -6,6 +6,25 @@ COPY web ./web
RUN cd web && npm install && npx vite build RUN cd web && npm install && npx vite build
# Build nsjail from source so the image ships a self-contained sandbox backend
# that needs no host Docker socket. Pinned to a release tag for reproducibility.
# Multi-stage keeps the compile toolchain (bison/flex/protobuf-dev/libnl-dev)
# out of the final image; only the nsjail binary and its small runtime libs
# (libprotobuf, libnl-route-3) are carried over.
FROM python:3.12.7-slim AS nsjail-build
ARG NSJAIL_VERSION=3.6
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates git build-essential \
autoconf bison flex libtool pkg-config \
protobuf-compiler libprotobuf-dev libnl-route-3-dev \
&& git clone --depth 1 --branch "${NSJAIL_VERSION}" https://github.com/google/nsjail.git /nsjail \
&& make -C /nsjail \
&& install -m 0755 /nsjail/nsjail /usr/local/bin/nsjail \
&& rm -rf /var/lib/apt/lists/*
FROM python:3.12.7-slim FROM python:3.12.7-slim
WORKDIR /app WORKDIR /app
@@ -14,10 +33,29 @@ COPY . .
COPY --from=node /app/web/dist ./web/dist COPY --from=node /app/web/dist ./web/dist
RUN apt update \ # nsjail binary built in the dedicated stage above. Self-contained sandbox
&& apt install gcc -y \ # backend; lets the Box runtime isolate code without a host Docker socket.
COPY --from=nsjail-build /usr/local/bin/nsjail /usr/local/bin/nsjail
RUN apt-get update \
&& apt-get install -y --no-install-recommends gcc ca-certificates curl gnupg \
# nsjail runtime libraries (the build toolchain stays in the nsjail-build
# stage; only these shared libs are needed to execute the binary).
&& apt-get install -y --no-install-recommends libprotobuf32 libnl-route-3-200 \
# Install the Docker CLI (client only) so the optional langbot_box
# service can drive the mounted host Docker socket and create sandbox
# containers. The same image powers langbot / plugin_runtime / box; only
# box uses the client. Arch-aware via dpkg so multi-arch builds work.
&& install -m 0755 -d /etc/apt/keyrings \
&& curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc \
&& chmod a+r /etc/apt/keyrings/docker.asc \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian $(. /etc/os-release && echo \"$VERSION_CODENAME\") stable" > /etc/apt/sources.list.d/docker.list \
&& apt-get update \
&& apt-get install -y --no-install-recommends docker-ce-cli \
&& python -m pip install --no-cache-dir uv \ && python -m pip install --no-cache-dir uv \
&& uv sync \ && uv sync \
&& apt-get purge -y --auto-remove curl gnupg \
&& rm -rf /var/lib/apt/lists/* \
&& touch /.dockerenv && touch /.dockerenv
CMD [ "uv", "run", "--no-sync", "main.py" ] CMD [ "uv", "run", "--no-sync", "main.py" ]

View File

@@ -38,7 +38,7 @@ LangBot is an **open-source, production-grade platform** for building AI-powered
### Key Capabilities ### Key Capabilities
- **AI Conversations & Agents** — Multi-turn dialogues, tool calling, multi-modal support, streaming output. Built-in RAG (knowledge base) with deep integration to [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org). - **AI Conversations & Agents** — Multi-turn dialogues, tool calling, multi-modal support, streaming output. Built-in RAG (knowledge base) with deep integration to [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org), [Deerflow](https://deerflow.tech), [Weknora](https://weknora.weixin.qq.com).
- **Universal IM Platform Support** — One codebase for Discord, Telegram, Slack, LINE, QQ, WeChat, WeCom, Lark, DingTalk, KOOK. - **Universal IM Platform Support** — One codebase for Discord, Telegram, Slack, LINE, QQ, WeChat, WeCom, Lark, DingTalk, KOOK.
- **Production-Ready** — Access control, rate limiting, sensitive word filtering, comprehensive monitoring, and exception handling. Trusted by enterprises. - **Production-Ready** — Access control, rate limiting, sensitive word filtering, comprehensive monitoring, and exception handling. Trusted by enterprises.
- **Plugin Ecosystem** — Hundreds of plugins, event-driven architecture, component extensions, and [MCP protocol](https://modelcontextprotocol.io/) support. - **Plugin Ecosystem** — Hundreds of plugins, event-driven architecture, component extensions, and [MCP protocol](https://modelcontextprotocol.io/) support.
@@ -78,7 +78,7 @@ docker compose up -d
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH) [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH)
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) [![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF)
**More options:** [Docker](https://link.langbot.app/en/docs/docker) · [Manual](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](./docker/README_K8S.md) **More options:** [Docker](https://link.langbot.app/en/docs/docker) · [Manual](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes)
--- ---

View File

@@ -13,7 +13,7 @@
[English](README.md) / 简体中文 / [繁體中文](README_TW.md) / [日本語](README_JP.md) / [Español](README_ES.md) / [Français](README_FR.md) / [한국어](README_KO.md) / [Русский](README_RU.md) / [Tiếng Việt](README_VI.md) [English](README.md) / 简体中文 / [繁體中文](README_TW.md) / [日本語](README_JP.md) / [Español](README_ES.md) / [Français](README_FR.md) / [한국어](README_KO.md) / [Русский](README_RU.md) / [Tiếng Việt](README_VI.md)
[![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb)](https://discord.gg/wdNEHETs87) [![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb)](https://discord.gg/wdNEHETs87)
[![QQ Group](https://img.shields.io/badge/%E7%A4%BE%E5%8C%BAQQ%E7%BE%A4-1030838208-blue)](https://qm.qq.com/q/DxZZcNxM1W) [![QQ Group](https://img.shields.io/badge/%E7%A4%BE%E5%8C%BAQQ%E7%BE%A4-1030838208-blue)](https://qm.qq.com/q/IrlV8QFacU)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/langbot-app/LangBot) [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/langbot-app/LangBot)
[![GitHub release (latest by date)](https://img.shields.io/github/v/release/langbot-app/LangBot)](https://github.com/langbot-app/LangBot/releases/latest) [![GitHub release (latest by date)](https://img.shields.io/github/v/release/langbot-app/LangBot)](https://github.com/langbot-app/LangBot/releases/latest)
<img src="https://img.shields.io/badge/python-3.10 ~ 3.13 -blue.svg" alt="python"> <img src="https://img.shields.io/badge/python-3.10 ~ 3.13 -blue.svg" alt="python">
@@ -38,7 +38,7 @@ LangBot 是一个**开源的生产级平台**,用于构建 AI 驱动的即时
### 核心能力 ### 核心能力
- **AI 对话与 Agent** — 多轮对话、工具调用、多模态、流式输出。自带 RAG知识库深度集成 [Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io)、[Langflow](https://langflow.org) 等 LLMOps 平台。 - **AI 对话与 Agent** — 多轮对话、工具调用、多模态、流式输出。自带 RAG知识库深度集成 [Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io)、[Langflow](https://langflow.org)、[Deerflow](https://deerflow.tech)、[Weknora](https://weknora.weixin.qq.com)等 LLMOps 平台。
- **全平台支持** — 一套代码,覆盖 QQ、微信、企业微信、飞书、钉钉、Discord、Telegram、Slack、LINE、KOOK 等平台。 - **全平台支持** — 一套代码,覆盖 QQ、微信、企业微信、飞书、钉钉、Discord、Telegram、Slack、LINE、KOOK 等平台。
- **生产就绪** — 访问控制、限速、敏感词过滤、全面监控与异常处理,已被多家企业采用。 - **生产就绪** — 访问控制、限速、敏感词过滤、全面监控与异常处理,已被多家企业采用。
- **插件生态** — 数百个插件,跨进程的事件驱动架构,组件扩展,适配 [MCP 协议](https://modelcontextprotocol.io/)。 - **插件生态** — 数百个插件,跨进程的事件驱动架构,组件扩展,适配 [MCP 协议](https://modelcontextprotocol.io/)。
@@ -78,7 +78,7 @@ docker compose up -d
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/zh-CN/templates/ZKTBDH) [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/zh-CN/templates/ZKTBDH)
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) [![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF)
**更多方式:** [Docker](https://link.langbot.app/zh/docs/docker) · [手动部署](https://link.langbot.app/zh/docs/manual-deploy) · [宝塔面板](https://link.langbot.app/zh/docs/bt-panel) · [Kubernetes](./docker/README_K8S.md) **更多方式:** [Docker](https://link.langbot.app/zh/docs/docker) · [手动部署](https://link.langbot.app/zh/docs/manual-deploy) · [宝塔面板](https://link.langbot.app/zh/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/zh/deploy/langbot/kubernetes)
--- ---

View File

@@ -37,7 +37,7 @@ LangBot es una **plataforma de código abierto y grado de producción** para con
### Capacidades Clave ### Capacidades Clave
- **Conversaciones e Agentes IA** — Diálogos de múltiples turnos, llamadas a herramientas, soporte multimodal, salida en streaming. RAG (base de conocimientos) incorporado con integración profunda con [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org). - **Conversaciones e Agentes IA** — Diálogos de múltiples turnos, llamadas a herramientas, soporte multimodal, salida en streaming. RAG (base de conocimientos) incorporado con integración profunda con [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org), [Deerflow](https://deerflow.tech)、[Weknora](https://weknora.weixin.qq.com).
- **Soporte Universal de Plataformas de MI** — Un solo código base para Discord, Telegram, Slack, LINE, QQ, WeChat, WeCom, Lark, DingTalk, KOOK. - **Soporte Universal de Plataformas de MI** — Un solo código base para Discord, Telegram, Slack, LINE, QQ, WeChat, WeCom, Lark, DingTalk, KOOK.
- **Listo para Producción** — Control de acceso, limitación de velocidad, filtrado de palabras sensibles, monitoreo completo y manejo de excepciones. De confianza para empresas. - **Listo para Producción** — Control de acceso, limitación de velocidad, filtrado de palabras sensibles, monitoreo completo y manejo de excepciones. De confianza para empresas.
- **Ecosistema de Plugins** — Cientos de plugins, arquitectura basada en eventos, extensiones de componentes y soporte del [protocolo MCP](https://modelcontextprotocol.io/). - **Ecosistema de Plugins** — Cientos de plugins, arquitectura basada en eventos, extensiones de componentes y soporte del [protocolo MCP](https://modelcontextprotocol.io/).
@@ -77,7 +77,7 @@ docker compose up -d
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH) [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH)
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) [![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF)
**Más opciones:** [Docker](https://link.langbot.app/en/docs/docker) · [Manual](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](./docker/README_K8S.md) **Más opciones:** [Docker](https://link.langbot.app/en/docs/docker) · [Manual](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes)
--- ---

View File

@@ -37,7 +37,7 @@ LangBot est une **plateforme open-source de niveau production** pour créer des
### Capacités Clés ### Capacités Clés
- **Conversations IA & Agents** — Dialogues multi-tours, appels d'outils, support multimodal, sortie en streaming. RAG (base de connaissances) intégré avec intégration profonde de [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org). - **Conversations IA & Agents** — Dialogues multi-tours, appels d'outils, support multimodal, sortie en streaming. RAG (base de connaissances) intégré avec intégration profonde de [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org), [Deerflow](https://deerflow.tech), [Weknora](https://weknora.weixin.qq.com).
- **Support Universel des Plateformes de MI** — Un seul code pour Discord, Telegram, Slack, LINE, QQ, WeChat, WeCom, Lark, DingTalk, KOOK. - **Support Universel des Plateformes de MI** — Un seul code pour Discord, Telegram, Slack, LINE, QQ, WeChat, WeCom, Lark, DingTalk, KOOK.
- **Prêt pour la Production** — Contrôle d'accès, limitation de débit, filtrage de mots sensibles, surveillance complète et gestion des exceptions. Approuvé par les entreprises. - **Prêt pour la Production** — Contrôle d'accès, limitation de débit, filtrage de mots sensibles, surveillance complète et gestion des exceptions. Approuvé par les entreprises.
- **Écosystème de Plugins** — Des centaines de plugins, architecture événementielle, extensions de composants, et support du [protocole MCP](https://modelcontextprotocol.io/). - **Écosystème de Plugins** — Des centaines de plugins, architecture événementielle, extensions de composants, et support du [protocole MCP](https://modelcontextprotocol.io/).
@@ -77,7 +77,7 @@ docker compose up -d
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH) [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH)
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) [![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF)
**Plus d'options :** [Docker](https://link.langbot.app/en/docs/docker) · [Manuel](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](./docker/README_K8S.md) **Plus d'options :** [Docker](https://link.langbot.app/en/docs/docker) · [Manuel](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes)
--- ---

View File

@@ -37,7 +37,7 @@ LangBot は、AI搭載のインスタントメッセージングボットを構
### 主な機能 ### 主な機能
- **AI対話とエージェント** — マルチターン対話、ツール呼び出し、マルチモーダル対応、ストリーミング出力。RAGナレッジベースを内蔵し、[Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io)、[Langflow](https://langflow.org) と深く統合。 - **AI対話とエージェント** — マルチターン対話、ツール呼び出し、マルチモーダル対応、ストリーミング出力。RAGナレッジベースを内蔵し、[Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io)、[Langflow](https://langflow.org)、[Deerflow](https://deerflow.tech)、[Weknora](https://weknora.weixin.qq.com) と深く統合。
- **ユニバーサルIMプラットフォーム対応** — 単一のコードベースで Discord、Telegram、Slack、LINE、QQ、WeChat、WeCom、Lark、DingTalk、KOOK に対応。 - **ユニバーサルIMプラットフォーム対応** — 単一のコードベースで Discord、Telegram、Slack、LINE、QQ、WeChat、WeCom、Lark、DingTalk、KOOK に対応。
- **本番環境対応** — アクセス制御、レート制限、センシティブワードフィルタリング、包括的な監視、例外処理を搭載。エンタープライズの信頼に応える品質。 - **本番環境対応** — アクセス制御、レート制限、センシティブワードフィルタリング、包括的な監視、例外処理を搭載。エンタープライズの信頼に応える品質。
- **プラグインエコシステム** — 数百のプラグイン、イベント駆動アーキテクチャ、コンポーネント拡張、[MCPプロトコル](https://modelcontextprotocol.io/)対応。 - **プラグインエコシステム** — 数百のプラグイン、イベント駆動アーキテクチャ、コンポーネント拡張、[MCPプロトコル](https://modelcontextprotocol.io/)対応。
@@ -77,7 +77,7 @@ docker compose up -d
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH) [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH)
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) [![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF)
**その他:** [Docker](https://link.langbot.app/en/docs/docker) · [手動デプロイ](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](./docker/README_K8S.md) **その他:** [Docker](https://link.langbot.app/en/docs/docker) · [手動デプロイ](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes)
--- ---

View File

@@ -37,7 +37,7 @@ LangBot은 AI 기반 인스턴트 메시징 봇을 구축하기 위한 **오픈
### 핵심 기능 ### 핵심 기능
- **AI 대화 및 에이전트** — 멀티턴 대화, 도구 호출, 멀티모달 지원, 스트리밍 출력. 내장 RAG(지식 베이스)와 [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org) 심층 통합. - **AI 대화 및 에이전트** — 멀티턴 대화, 도구 호출, 멀티모달 지원, 스트리밍 출력. 내장 RAG(지식 베이스)와 [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org), [Deerflow](https://deerflow.tech), [Weknora](https://weknora.weixin.qq.com) 심층 통합.
- **유니버설 IM 플랫폼 지원** — 단일 코드베이스로 Discord, Telegram, Slack, LINE, QQ, WeChat, WeCom, Lark, DingTalk, KOOK 지원. - **유니버설 IM 플랫폼 지원** — 단일 코드베이스로 Discord, Telegram, Slack, LINE, QQ, WeChat, WeCom, Lark, DingTalk, KOOK 지원.
- **프로덕션 레디** — 접근 제어, 속도 제한, 민감어 필터링, 종합 모니터링 및 예외 처리. 기업 환경에서 검증됨. - **프로덕션 레디** — 접근 제어, 속도 제한, 민감어 필터링, 종합 모니터링 및 예외 처리. 기업 환경에서 검증됨.
- **플러그인 생태계** — 수백 개의 플러그인, 이벤트 기반 아키텍처, 컴포넌트 확장, [MCP 프로토콜](https://modelcontextprotocol.io/) 지원. - **플러그인 생태계** — 수백 개의 플러그인, 이벤트 기반 아키텍처, 컴포넌트 확장, [MCP 프로토콜](https://modelcontextprotocol.io/) 지원.
@@ -77,7 +77,7 @@ docker compose up -d
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH) [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH)
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) [![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF)
**더 많은 옵션:** [Docker](https://link.langbot.app/en/docs/docker) · [수동 배포](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](./docker/README_K8S.md) **더 많은 옵션:** [Docker](https://link.langbot.app/en/docs/docker) · [수동 배포](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes)
--- ---

View File

@@ -37,7 +37,7 @@ LangBot — это **платформа с открытым исходным к
### Ключевые возможности ### Ключевые возможности
- **ИИ-диалоги и агенты** — Многораундовые диалоги, вызов инструментов, мультимодальная поддержка, потоковый вывод. Встроенная реализация RAG (база знаний) с глубокой интеграцией в [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org). - **ИИ-диалоги и агенты** — Многораундовые диалоги, вызов инструментов, мультимодальная поддержка, потоковый вывод. Встроенная реализация RAG (база знаний) с глубокой интеграцией в [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org), [Deerflow](https://deerflow.tech), [Weknora](https://weknora.weixin.qq.com).
- **Универсальная поддержка IM-платформ** — Единая кодовая база для Discord, Telegram, Slack, LINE, QQ, WeChat, WeCom, Lark, DingTalk, KOOK. - **Универсальная поддержка IM-платформ** — Единая кодовая база для Discord, Telegram, Slack, LINE, QQ, WeChat, WeCom, Lark, DingTalk, KOOK.
- **Готовность к продакшену** — Контроль доступа, ограничение скорости, фильтрация чувствительных слов, комплексный мониторинг и обработка исключений. Проверено в корпоративной среде. - **Готовность к продакшену** — Контроль доступа, ограничение скорости, фильтрация чувствительных слов, комплексный мониторинг и обработка исключений. Проверено в корпоративной среде.
- **Экосистема плагинов** — Сотни плагинов, событийно-ориентированная архитектура, расширения компонентов и поддержка [протокола MCP](https://modelcontextprotocol.io/). - **Экосистема плагинов** — Сотни плагинов, событийно-ориентированная архитектура, расширения компонентов и поддержка [протокола MCP](https://modelcontextprotocol.io/).
@@ -77,7 +77,7 @@ docker compose up -d
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH) [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH)
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) [![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF)
**Другие варианты:** [Docker](https://link.langbot.app/en/docs/docker) · [Ручная установка](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](./docker/README_K8S.md) **Другие варианты:** [Docker](https://link.langbot.app/en/docs/docker) · [Ручная установка](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes)
--- ---

View File

@@ -39,7 +39,7 @@ LangBot 是一個**開源的生產級平台**,用於建構 AI 驅動的即時
### 核心能力 ### 核心能力
- **AI 對話與 Agent** — 多輪對話、工具調用、多模態、流式輸出。自帶 RAG知識庫深度整合 [Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io)、[Langflow](https://langflow.org) 等 LLMOps 平台。 - **AI 對話與 Agent** — 多輪對話、工具調用、多模態、流式輸出。自帶 RAG知識庫深度整合 [Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io)、[Langflow](https://langflow.org)、 [Deerflow](https://deerflow.tech)、[Weknora](https://weknora.weixin.qq.com)等 LLMOps 平台。
- **全平台支援** — 一套程式碼,覆蓋 QQ、微信、企業微信、飛書、釘釘、Discord、Telegram、Slack、LINE、KOOK 等平台。 - **全平台支援** — 一套程式碼,覆蓋 QQ、微信、企業微信、飛書、釘釘、Discord、Telegram、Slack、LINE、KOOK 等平台。
- **生產就緒** — 存取控制、限速、敏感詞過濾、全面監控與異常處理,已被多家企業採用。 - **生產就緒** — 存取控制、限速、敏感詞過濾、全面監控與異常處理,已被多家企業採用。
- **外掛生態** — 數百個外掛,事件驅動架構,組件擴展,適配 [MCP 協議](https://modelcontextprotocol.io/)。 - **外掛生態** — 數百個外掛,事件驅動架構,組件擴展,適配 [MCP 協議](https://modelcontextprotocol.io/)。
@@ -79,7 +79,7 @@ docker compose up -d
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/zh-CN/templates/ZKTBDH) [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/zh-CN/templates/ZKTBDH)
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) [![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF)
**更多方式:** [Docker](https://link.langbot.app/zh/docs/docker) · [手動部署](https://link.langbot.app/zh/docs/manual-deploy) · [寶塔面板](https://link.langbot.app/zh/docs/bt-panel) · [Kubernetes](./docker/README_K8S.md) **更多方式:** [Docker](https://link.langbot.app/zh/docs/docker) · [手動部署](https://link.langbot.app/zh/docs/manual-deploy) · [寶塔面板](https://link.langbot.app/zh/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/zh/deploy/langbot/kubernetes)
--- ---

View File

@@ -37,7 +37,7 @@ LangBot là một **nền tảng mã nguồn mở, cấp sản xuất** để x
### Khả năng chính ### Khả năng chính
- **Hội thoại AI & Agent** — Đối thoại nhiều lượt, gọi công cụ, hỗ trợ đa phương thức, đầu ra streaming. RAG (cơ sở kiến thức) tích hợp sẵn với tích hợp sâu vào [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org). - **Hội thoại AI & Agent** — Đối thoại nhiều lượt, gọi công cụ, hỗ trợ đa phương thức, đầu ra streaming. RAG (cơ sở kiến thức) tích hợp sẵn với tích hợp sâu vào [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org), [Deerflow](https://deerflow.tech), [Weknora](https://weknora.weixin.qq.com).
- **Hỗ trợ đa nền tảng IM** — Một mã nguồn cho Discord, Telegram, Slack, LINE, QQ, WeChat, WeCom, Lark, DingTalk, KOOK. - **Hỗ trợ đa nền tảng IM** — Một mã nguồn cho Discord, Telegram, Slack, LINE, QQ, WeChat, WeCom, Lark, DingTalk, KOOK.
- **Sẵn sàng cho sản xuất** — Kiểm soát truy cập, giới hạn tốc độ, lọc từ nhạy cảm, giám sát toàn diện và xử lý ngoại lệ. Được doanh nghiệp tin dùng. - **Sẵn sàng cho sản xuất** — Kiểm soát truy cập, giới hạn tốc độ, lọc từ nhạy cảm, giám sát toàn diện và xử lý ngoại lệ. Được doanh nghiệp tin dùng.
- **Hệ sinh thái Plugin** — Hàng trăm plugin, kiến trúc hướng sự kiện, mở rộng thành phần, và hỗ trợ [giao thức MCP](https://modelcontextprotocol.io/). - **Hệ sinh thái Plugin** — Hàng trăm plugin, kiến trúc hướng sự kiện, mở rộng thành phần, và hỗ trợ [giao thức MCP](https://modelcontextprotocol.io/).
@@ -77,7 +77,7 @@ docker compose up -d
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH) [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH)
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) [![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF)
**Thêm tùy chọn:** [Docker](https://link.langbot.app/en/docs/docker) · [Thủ công](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](./docker/README_K8S.md) **Thêm tùy chọn:** [Docker](https://link.langbot.app/en/docs/docker) · [Thủ công](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes)
--- ---

View File

@@ -1,629 +0,0 @@
# LangBot Kubernetes 部署指南 / Kubernetes Deployment Guide
[简体中文](#简体中文) | [English](#english)
---
## 简体中文
### 概述
本指南提供了在 Kubernetes 集群中部署 LangBot 的完整步骤。Kubernetes 部署配置基于 `docker-compose.yaml`,适用于生产环境的容器化部署。
### 前置要求
- Kubernetes 集群(版本 1.19+
- `kubectl` 命令行工具已配置并可访问集群
- 集群中有可用的存储类StorageClass用于持久化存储可选但推荐
- 至少 2 vCPU 和 4GB RAM 的可用资源
### 架构说明
Kubernetes 部署包含以下组件:
1. **langbot**: 主应用服务
- 提供 Web UI端口 5300
- 处理平台 webhook端口 2280-2290
- 数据持久化卷
2. **langbot-plugin-runtime**: 插件运行时服务
- WebSocket 通信(端口 5400
- 插件数据持久化卷
3. **持久化存储**:
- `langbot-data`: LangBot 主数据
- `langbot-plugins`: 插件文件
- `langbot-plugin-runtime-data`: 插件运行时数据
### 快速开始
#### 1. 下载部署文件
```bash
# 克隆仓库
git clone https://github.com/langbot-app/LangBot
cd LangBot/docker
# 或直接下载 kubernetes.yaml
wget https://raw.githubusercontent.com/langbot-app/LangBot/main/docker/kubernetes.yaml
```
#### 2. 部署到 Kubernetes
```bash
# 应用所有配置
kubectl apply -f kubernetes.yaml
# 检查部署状态
kubectl get all -n langbot
# 查看 Pod 日志
kubectl logs -n langbot -l app=langbot -f
```
#### 3. 访问 LangBot
默认情况下LangBot 服务使用 ClusterIP 类型,只能在集群内部访问。您可以选择以下方式之一来访问:
**选项 A: 端口转发(推荐用于测试)**
```bash
kubectl port-forward -n langbot svc/langbot 5300:5300
```
然后访问 http://localhost:5300
**选项 B: NodePort适用于开发环境**
编辑 `kubernetes.yaml`,取消注释 NodePort Service 部分,然后:
```bash
kubectl apply -f kubernetes.yaml
# 获取节点 IP
kubectl get nodes -o wide
# 访问 http://<NODE_IP>:30300
```
**选项 C: LoadBalancer适用于云环境**
编辑 `kubernetes.yaml`,取消注释 LoadBalancer Service 部分,然后:
```bash
kubectl apply -f kubernetes.yaml
# 获取外部 IP
kubectl get svc -n langbot langbot-loadbalancer
# 访问 http://<EXTERNAL_IP>
```
**选项 D: Ingress推荐用于生产环境**
确保集群中已安装 Ingress Controller如 nginx-ingress然后
1. 编辑 `kubernetes.yaml` 中的 Ingress 配置
2. 修改域名为您的实际域名
3. 应用配置:
```bash
kubectl apply -f kubernetes.yaml
# 访问 http://langbot.yourdomain.com
```
### 配置说明
#### 环境变量
`ConfigMap` 中配置环境变量:
```yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: langbot-config
namespace: langbot
data:
TZ: "Asia/Shanghai" # 修改为您的时区
```
#### 存储配置
默认使用动态存储分配。如果您有特定的 StorageClass请在 PVC 中指定:
```yaml
spec:
storageClassName: your-storage-class-name
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 10Gi
```
#### 资源限制
根据您的需求调整资源限制:
```yaml
resources:
requests:
memory: "1Gi"
cpu: "500m"
limits:
memory: "4Gi"
cpu: "2000m"
```
### 常用操作
#### 查看日志
```bash
# 查看 LangBot 主服务日志
kubectl logs -n langbot -l app=langbot -f
# 查看插件运行时日志
kubectl logs -n langbot -l app=langbot-plugin-runtime -f
```
#### 重启服务
```bash
# 重启 LangBot
kubectl rollout restart deployment/langbot -n langbot
# 重启插件运行时
kubectl rollout restart deployment/langbot-plugin-runtime -n langbot
```
#### 更新镜像
```bash
# 更新到最新版本
kubectl set image deployment/langbot -n langbot langbot=rockchin/langbot:latest
kubectl set image deployment/langbot-plugin-runtime -n langbot langbot-plugin-runtime=rockchin/langbot:latest
# 检查更新状态
kubectl rollout status deployment/langbot -n langbot
```
#### 扩容(不推荐)
注意:由于 LangBot 使用 ReadWriteOnce 的持久化存储,不支持多副本扩容。如需高可用,请考虑使用 ReadWriteMany 存储或其他架构方案。
#### 备份数据
```bash
# 备份 PVC 数据
kubectl exec -n langbot -it <langbot-pod-name> -- tar czf /tmp/backup.tar.gz /app/data
kubectl cp langbot/<langbot-pod-name>:/tmp/backup.tar.gz ./backup.tar.gz
```
### 卸载
```bash
# 删除所有资源(保留 PVC
kubectl delete deployment,service,configmap -n langbot --all
# 删除 PVC会删除数据
kubectl delete pvc -n langbot --all
# 删除命名空间
kubectl delete namespace langbot
```
### 故障排查
#### Pod 无法启动
```bash
# 查看 Pod 状态
kubectl get pods -n langbot
# 查看详细信息
kubectl describe pod -n langbot <pod-name>
# 查看事件
kubectl get events -n langbot --sort-by='.lastTimestamp'
```
#### 存储问题
```bash
# 检查 PVC 状态
kubectl get pvc -n langbot
# 检查 PV
kubectl get pv
```
#### 网络访问问题
```bash
# 检查 Service
kubectl get svc -n langbot
# 检查端口转发
kubectl port-forward -n langbot svc/langbot 5300:5300
```
### 生产环境建议
1. **使用特定版本标签**:避免使用 `latest` 标签,使用具体版本号如 `rockchin/langbot:v1.0.0`
2. **配置资源限制**:根据实际负载调整 CPU 和内存限制
3. **使用 Ingress + TLS**:配置 HTTPS 访问和证书管理
4. **配置监控和告警**:集成 Prometheus、Grafana 等监控工具
5. **定期备份**:配置自动备份策略保护数据
6. **使用专用 StorageClass**:为生产环境配置高性能存储
7. **配置亲和性规则**:确保 Pod 调度到合适的节点
### 高级配置
#### 使用 Secrets 管理敏感信息
如果需要配置 API 密钥等敏感信息:
```yaml
apiVersion: v1
kind: Secret
metadata:
name: langbot-secrets
namespace: langbot
type: Opaque
data:
api_key: <base64-encoded-value>
```
然后在 Deployment 中引用:
```yaml
env:
- name: API_KEY
valueFrom:
secretKeyRef:
name: langbot-secrets
key: api_key
```
#### 配置水平自动扩缩容HPA
注意:需要确保使用 ReadWriteMany 存储类型
```yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: langbot-hpa
namespace: langbot
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: langbot
minReplicas: 1
maxReplicas: 3
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
```
### 参考资源
- [LangBot 官方文档](https://docs.langbot.app)
- [Docker 部署文档](https://link.langbot.app/zh/docs/docker)
- [Kubernetes 官方文档](https://kubernetes.io/docs/)
---
## English
### Overview
This guide provides complete steps for deploying LangBot in a Kubernetes cluster. The Kubernetes deployment configuration is based on `docker-compose.yaml` and is suitable for production containerized deployments.
### Prerequisites
- Kubernetes cluster (version 1.19+)
- `kubectl` command-line tool configured with cluster access
- Available StorageClass in the cluster for persistent storage (optional but recommended)
- At least 2 vCPU and 4GB RAM of available resources
### Architecture
The Kubernetes deployment includes the following components:
1. **langbot**: Main application service
- Provides Web UI (port 5300)
- Handles platform webhooks (ports 2280-2290)
- Data persistence volume
2. **langbot-plugin-runtime**: Plugin runtime service
- WebSocket communication (port 5400)
- Plugin data persistence volume
3. **Persistent Storage**:
- `langbot-data`: LangBot main data
- `langbot-plugins`: Plugin files
- `langbot-plugin-runtime-data`: Plugin runtime data
### Quick Start
#### 1. Download Deployment Files
```bash
# Clone repository
git clone https://github.com/langbot-app/LangBot
cd LangBot/docker
# Or download kubernetes.yaml directly
wget https://raw.githubusercontent.com/langbot-app/LangBot/main/docker/kubernetes.yaml
```
#### 2. Deploy to Kubernetes
```bash
# Apply all configurations
kubectl apply -f kubernetes.yaml
# Check deployment status
kubectl get all -n langbot
# View Pod logs
kubectl logs -n langbot -l app=langbot -f
```
#### 3. Access LangBot
By default, LangBot service uses ClusterIP type, accessible only within the cluster. Choose one of the following methods to access:
**Option A: Port Forwarding (Recommended for testing)**
```bash
kubectl port-forward -n langbot svc/langbot 5300:5300
```
Then visit http://localhost:5300
**Option B: NodePort (Suitable for development)**
Edit `kubernetes.yaml`, uncomment the NodePort Service section, then:
```bash
kubectl apply -f kubernetes.yaml
# Get node IP
kubectl get nodes -o wide
# Visit http://<NODE_IP>:30300
```
**Option C: LoadBalancer (Suitable for cloud environments)**
Edit `kubernetes.yaml`, uncomment the LoadBalancer Service section, then:
```bash
kubectl apply -f kubernetes.yaml
# Get external IP
kubectl get svc -n langbot langbot-loadbalancer
# Visit http://<EXTERNAL_IP>
```
**Option D: Ingress (Recommended for production)**
Ensure an Ingress Controller (e.g., nginx-ingress) is installed in the cluster, then:
1. Edit the Ingress configuration in `kubernetes.yaml`
2. Change the domain to your actual domain
3. Apply configuration:
```bash
kubectl apply -f kubernetes.yaml
# Visit http://langbot.yourdomain.com
```
### Configuration
#### Environment Variables
Configure environment variables in ConfigMap:
```yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: langbot-config
namespace: langbot
data:
TZ: "Asia/Shanghai" # Change to your timezone
```
#### Storage Configuration
Uses dynamic storage provisioning by default. If you have a specific StorageClass, specify it in PVC:
```yaml
spec:
storageClassName: your-storage-class-name
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 10Gi
```
#### Resource Limits
Adjust resource limits based on your needs:
```yaml
resources:
requests:
memory: "1Gi"
cpu: "500m"
limits:
memory: "4Gi"
cpu: "2000m"
```
### Common Operations
#### View Logs
```bash
# View LangBot main service logs
kubectl logs -n langbot -l app=langbot -f
# View plugin runtime logs
kubectl logs -n langbot -l app=langbot-plugin-runtime -f
```
#### Restart Services
```bash
# Restart LangBot
kubectl rollout restart deployment/langbot -n langbot
# Restart plugin runtime
kubectl rollout restart deployment/langbot-plugin-runtime -n langbot
```
#### Update Images
```bash
# Update to latest version
kubectl set image deployment/langbot -n langbot langbot=rockchin/langbot:latest
kubectl set image deployment/langbot-plugin-runtime -n langbot langbot-plugin-runtime=rockchin/langbot:latest
# Check update status
kubectl rollout status deployment/langbot -n langbot
```
#### Scaling (Not Recommended)
Note: Due to LangBot using ReadWriteOnce persistent storage, multi-replica scaling is not supported. For high availability, consider using ReadWriteMany storage or alternative architectures.
#### Backup Data
```bash
# Backup PVC data
kubectl exec -n langbot -it <langbot-pod-name> -- tar czf /tmp/backup.tar.gz /app/data
kubectl cp langbot/<langbot-pod-name>:/tmp/backup.tar.gz ./backup.tar.gz
```
### Uninstall
```bash
# Delete all resources (keep PVCs)
kubectl delete deployment,service,configmap -n langbot --all
# Delete PVCs (will delete data)
kubectl delete pvc -n langbot --all
# Delete namespace
kubectl delete namespace langbot
```
### Troubleshooting
#### Pods Not Starting
```bash
# Check Pod status
kubectl get pods -n langbot
# View detailed information
kubectl describe pod -n langbot <pod-name>
# View events
kubectl get events -n langbot --sort-by='.lastTimestamp'
```
#### Storage Issues
```bash
# Check PVC status
kubectl get pvc -n langbot
# Check PV
kubectl get pv
```
#### Network Access Issues
```bash
# Check Service
kubectl get svc -n langbot
# Test port forwarding
kubectl port-forward -n langbot svc/langbot 5300:5300
```
### Production Recommendations
1. **Use specific version tags**: Avoid using `latest` tag, use specific version like `rockchin/langbot:v1.0.0`
2. **Configure resource limits**: Adjust CPU and memory limits based on actual load
3. **Use Ingress + TLS**: Configure HTTPS access and certificate management
4. **Configure monitoring and alerts**: Integrate monitoring tools like Prometheus, Grafana
5. **Regular backups**: Configure automated backup strategy to protect data
6. **Use dedicated StorageClass**: Configure high-performance storage for production
7. **Configure affinity rules**: Ensure Pods are scheduled to appropriate nodes
### Advanced Configuration
#### Using Secrets for Sensitive Information
If you need to configure sensitive information like API keys:
```yaml
apiVersion: v1
kind: Secret
metadata:
name: langbot-secrets
namespace: langbot
type: Opaque
data:
api_key: <base64-encoded-value>
```
Then reference in Deployment:
```yaml
env:
- name: API_KEY
valueFrom:
secretKeyRef:
name: langbot-secrets
key: api_key
```
#### Configure Horizontal Pod Autoscaling (HPA)
Note: Requires ReadWriteMany storage type
```yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: langbot-hpa
namespace: langbot
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: langbot
minReplicas: 1
maxReplicas: 3
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
```
### References
- [LangBot Official Documentation](https://docs.langbot.app)
- [Docker Deployment Guide](https://link.langbot.app/zh/docs/docker)
- [Kubernetes Official Documentation](https://kubernetes.io/docs/)

View File

@@ -1,5 +1,5 @@
# Docker Compose configuration for LangBot # Docker Compose configuration for LangBot
# For Kubernetes deployment, see kubernetes.yaml and README_K8S.md # For Kubernetes deployment, see kubernetes.yaml and the deployment guide at https://docs.langbot.app
version: "3" version: "3"
services: services:

View File

@@ -1,6 +1,8 @@
# Kubernetes Deployment for LangBot # Kubernetes Deployment for LangBot
# This file provides Kubernetes deployment manifests for LangBot based on docker-compose.yaml # This file provides Kubernetes deployment manifests for LangBot based on docker-compose.yaml
# #
# Full deployment guide (zh/en/ja): https://docs.langbot.app -> Installation -> Kubernetes
#
# Usage: # Usage:
# kubectl apply -f kubernetes.yaml # kubectl apply -f kubernetes.yaml
# #
@@ -8,13 +10,15 @@
# - A Kubernetes cluster (1.19+) # - A Kubernetes cluster (1.19+)
# - kubectl configured to communicate with your cluster # - kubectl configured to communicate with your cluster
# - (Optional) A StorageClass for dynamic volume provisioning # - (Optional) A StorageClass for dynamic volume provisioning
# - For the Box sandbox runtime: a node with a reachable Docker daemon
# (the box mounts the node's /var/run/docker.sock). See the deployment guide.
# #
# Components: # Components:
# - Namespace: langbot # - Namespace: langbot
# - PersistentVolumeClaims for data persistence # - PersistentVolumeClaims for data persistence
# - Deployments for langbot and langbot_plugin_runtime # - Deployments for langbot, langbot-plugin-runtime, and langbot-box (sandbox)
# - Services for network access # - Services for network access
# - ConfigMap for timezone configuration # - ConfigMap for timezone + runtime endpoints
--- ---
# Namespace # Namespace
@@ -83,6 +87,11 @@ metadata:
data: data:
TZ: "Asia/Shanghai" TZ: "Asia/Shanghai"
PLUGIN__RUNTIME_WS_URL: "ws://langbot-plugin-runtime:5400/control/ws" PLUGIN__RUNTIME_WS_URL: "ws://langbot-plugin-runtime:5400/control/ws"
# Box sandbox runtime endpoint. LangBot connects to the Box runtime over
# WebSocket. The hostname MUST match the langbot-box Service name. Note the
# in-container default ("langbot_box") uses an underscore, which is an
# invalid Kubernetes DNS name — so the endpoint is always set explicitly here.
BOX__RUNTIME__ENDPOINT: "ws://langbot-box:5410"
--- ---
# Deployment for LangBot Plugin Runtime # Deployment for LangBot Plugin Runtime
@@ -169,6 +178,136 @@ spec:
protocol: TCP protocol: TCP
name: runtime name: runtime
---
# Deployment for LangBot Box (sandbox) runtime
#
# The Box runtime backs LangBot's sandbox tools (exec / read / write / edit /
# glob / grep), the `activate` skill tool, skill add/edit, and stdio-mode MCP
# servers. It is OPTIONAL: if you do not deploy it, set `BOX__ENABLED=false` on
# the langbot Deployment (or `box.enabled: false` in config.yaml) so the
# dashboard renders cleanly with sandbox features disabled.
#
# IMPORTANT — how the sandbox actually runs:
# The bundled image ships only the Docker CLI (no dockerd, no nsjail). The Box
# runtime therefore creates sandbox containers by talking to a Docker daemon
# over the mounted socket (`/var/run/docker.sock`). Because that daemon
# resolves bind-mount paths on the NODE filesystem, the Box workspace root
# must be the SAME absolute path inside the box container, inside every
# sandbox container it spawns, AND on the node. That is why this manifest uses
# a hostPath at a fixed absolute path (/app/data/box) and pins langbot + box
# to the same node via podAffinity. A normal PVC will NOT work for the box
# workspace, because the node's dockerd cannot see paths that exist only
# inside the pod's mount namespace.
#
# Security note: mounting the host Docker socket grants the Box runtime (and any
# code executed in the sandbox) effective root on the node. Only deploy Box on
# nodes you trust for this workload, ideally a dedicated node pool. For a
# stronger isolation boundary, switch box.backend to 'e2b' (set E2B_API_KEY) and
# drop the docker.sock mount + hostPath entirely.
apiVersion: apps/v1
kind: Deployment
metadata:
name: langbot-box
namespace: langbot
labels:
app: langbot-box
spec:
replicas: 1
selector:
matchLabels:
app: langbot-box
template:
metadata:
labels:
app: langbot-box
spec:
# Pin to the same node as langbot so they share the hostPath box root.
affinity:
podAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchLabels:
app: langbot
topologyKey: kubernetes.io/hostname
containers:
- name: langbot-box
image: rockchin/langbot:latest
imagePullPolicy: Always
# Launched through the same CLI entry point as the plugin runtime.
# No flag => WebSocket control transport (default), listening on 5410.
command: ["uv", "run", "--no-sync", "-m", "langbot_plugin.cli.__init__", "box"]
ports:
- containerPort: 5410
name: box-rpc
protocol: TCP
env:
- name: TZ
valueFrom:
configMapKeyRef:
name: langbot-config
key: TZ
# The Box runtime does NOT read box.local.* / BOX__* from its own env;
# it receives its configuration from LangBot via the INIT RPC action.
# Do not add BOX__* here — they would be silently ignored.
volumeMounts:
# Box workspace root — identical path on node, box, and sandbox
# containers (see the IMPORTANT note above).
- name: box-root
mountPath: /app/data/box
# Host Docker socket — the sandbox backend uses it to create containers.
- name: docker-sock
mountPath: /var/run/docker.sock
resources:
requests:
memory: "256Mi"
cpu: "100m"
limits:
memory: "1Gi"
cpu: "1000m"
livenessProbe:
tcpSocket:
port: 5410
initialDelaySeconds: 20
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
tcpSocket:
port: 5410
initialDelaySeconds: 10
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3
volumes:
- name: box-root
hostPath:
path: /app/data/box
type: DirectoryOrCreate
- name: docker-sock
hostPath:
path: /var/run/docker.sock
type: Socket
restartPolicy: Always
---
# Service for LangBot Box runtime
apiVersion: v1
kind: Service
metadata:
name: langbot-box
namespace: langbot
labels:
app: langbot-box
spec:
type: ClusterIP
selector:
app: langbot-box
ports:
- port: 5410
targetPort: 5410
protocol: TCP
name: box-rpc
--- ---
# Deployment for LangBot # Deployment for LangBot
apiVersion: apps/v1 apiVersion: apps/v1
@@ -213,11 +352,36 @@ spec:
configMapKeyRef: configMapKeyRef:
name: langbot-config name: langbot-config
key: PLUGIN__RUNTIME_WS_URL key: PLUGIN__RUNTIME_WS_URL
# Box (sandbox) runtime endpoint. Connects LangBot to the langbot-box
# Service over WebSocket. Remove this (and the langbot-box Deployment)
# and set BOX__ENABLED=false if you do not want the sandbox.
- name: BOX__RUNTIME__ENDPOINT
valueFrom:
configMapKeyRef:
name: langbot-config
key: BOX__RUNTIME__ENDPOINT
# box.local.* config — forwarded to the Box runtime via INIT RPC. The
# host_root MUST match the box-root hostPath mountPath below AND the box
# Deployment's box-root mountPath, so that skill package paths resolve
# identically on both sides and on the node's Docker daemon.
- name: BOX__LOCAL__HOST_ROOT
value: "/app/data/box"
- name: BOX__LOCAL__DEFAULT_WORKSPACE
value: "default"
- name: BOX__LOCAL__SKILLS_ROOT
value: "skills"
- name: BOX__LOCAL__ALLOWED_MOUNT_ROOTS
value: "/app/data/box"
volumeMounts: volumeMounts:
- name: data - name: data
mountPath: /app/data mountPath: /app/data
- name: plugins - name: plugins
mountPath: /app/plugins mountPath: /app/plugins
# Same node-level box root as the langbot-box Deployment. Mounted over
# the data PVC's /app/data/box subpath so both LangBot and the Box
# runtime (and the node's dockerd) agree on one absolute path.
- name: box-root
mountPath: /app/data/box
resources: resources:
requests: requests:
memory: "1Gi" memory: "1Gi"
@@ -250,6 +414,13 @@ spec:
- name: plugins - name: plugins
persistentVolumeClaim: persistentVolumeClaim:
claimName: langbot-plugins claimName: langbot-plugins
# Node-level box workspace root, shared with the langbot-box Deployment.
# hostPath (not PVC) because the node's Docker daemon must see the same
# absolute path when bind-mounting workspaces into sandbox containers.
- name: box-root
hostPath:
path: /app/data/box
type: DirectoryOrCreate
restartPolicy: Always restartPolicy: Always
--- ---

View File

@@ -1,8 +1,9 @@
# Box 系统架构深度分析 # Box 系统架构深度分析
> 更新日期: 2026-05-19 > 更新日期: 2026-06-02
> 状态更新: 自部署社区版已具备发布条件box 可选、降级完善、无迁移欠债);工具调用循环上限、配额遍历异步化、`host_path` 挂载白名单等已落地。剩余多租户 / 安全硬化项见 [SaaS 阻塞项清单](./box-issues.md)。
> 分支: `feat/sandbox` (LangBot + langbot-plugin-sdk) > 分支: `feat/sandbox` (LangBot + langbot-plugin-sdk)
> 相关文档: [问题清单](./box-issues.md) | [Session 作用域](./box-session-scope.md) | [Runtime 对比](./box-vs-plugin-runtime.md) | [测试覆盖](./box-test-coverage.md) | [toB 分析](./box-tob-analysis.md) > 相关文档: [SaaS 阻塞项](./box-issues.md) | [Session 作用域](./box-session-scope.md) | [Runtime 对比](./box-vs-plugin-runtime.md) | [测试覆盖](./box-test-coverage.md) | [toB 分析](./box-tob-analysis.md)
--- ---
@@ -163,7 +164,7 @@ BoxService
### 2.4 policy.py (`pkg/box/policy.py`, 98 行) — 仍是死代码 ### 2.4 policy.py (`pkg/box/policy.py`, 98 行) — 仍是死代码
三层安全策略设计(`SandboxPolicy` / `ToolPolicy` / `ElevatedPolicy`),全项目无任何导入或调用。详见 [问题清单 #1](./box-issues.md)。 三层安全策略设计(`SandboxPolicy` / `ToolPolicy` / `ElevatedPolicy`),全项目无任何导入或调用。详见 [SaaS 阻塞项 S2](./box-issues.md)。
### 2.5 SkillManager (`pkg/skill/manager.py`, 186 行) ### 2.5 SkillManager (`pkg/skill/manager.py`, 186 行)
@@ -364,7 +365,7 @@ GitHub 安装路径HTTP 层(`api/http/service/skill.py`)先 `git clone`
`validate_sandbox_security()`: 黑名单校验 host_path阻止挂载 `/etc`/`/proc`/`/sys`/`/dev`/`/root`/`/boot` 及 Docker/Podman socket。 `validate_sandbox_security()`: 黑名单校验 host_path阻止挂载 `/etc`/`/proc`/`/sys`/`/dev`/`/root`/`/boot` 及 Docker/Podman socket。
**已知缺陷**: 根路径 `/` 未拦截,用户 home 目录未拦截,是 denylist 而非 allowlist 策略。详见 [问题清单 #5](./box-issues.md)。 **已知缺陷**: 根路径 `/` 未拦截,用户 home 目录未拦截,是 denylist 而非 allowlist 策略。详见 [SaaS 阻塞项 S5](./box-issues.md)。
### 3.9 Errors (`box/errors.py`, 33 行) ### 3.9 Errors (`box/errors.py`, 33 行)
@@ -512,7 +513,7 @@ box:
# - skill 列表/读取保持只读可用 # - skill 列表/读取保持只读可用
# BOX__ENABLED 环境变量可覆盖(统一约定) # BOX__ENABLED 环境变量可覆盖(统一约定)
backend: 'local' # 'local' (探测) / 'docker' / 'nsjail' / 'e2b' backend: 'local' # 'local' (探测) / 'docker' / 'nsjail' / 'e2b'
# BOX_BACKEND 环境变量优先级更高 # 由 box.backend / BOX__BACKEND 选择后端
runtime: runtime:
endpoint: '' # 外部 Runtime 的 WS 基地址 'ws://host:5410' endpoint: '' # 外部 Runtime 的 WS 基地址 'ws://host:5410'
# 留空 = 本地自管 Runtime # 留空 = 本地自管 Runtime

View File

@@ -1,157 +1,76 @@
# Box 系统架构问题清单 # Box 系统 — SaaS 发布前阻塞项
> 更新日期: 2026-05-19 > 更新日期: 2026-06-02
> 分支: `feat/sandbox` (LangBot + langbot-plugin-sdk) > 分支: `feat/sandbox` (LangBot + langbot-plugin-sdk)
> 相关文档: [架构分析](./box-architecture.md) | [Session 作用域](./box-session-scope.md) | [Runtime 对比](./box-vs-plugin-runtime.md) | [测试覆盖](./box-test-coverage.md) | [toB 分析](./box-tob-analysis.md)
## 范围说明
**自部署社区版已具备发布条件**:默认 stdio 模式、box 为可选项box 关闭 / 不可用时后端、前端、工具、skill、stdio-MCP 均能干净降级(清晰报错、不崩溃);配置向后兼容(旧 `data/config.yaml` 可直接启动);无新增 ORM 模型、无迁移欠债市场安装失败不会破坏实例。CI 全绿。
本清单**只保留发布 SaaS / 多租户 / 公网暴露前必须处理的阻塞项**。社区版(可信、单运营者、内网)不受这些项阻塞——它们的风险面在"不可信调用方能直接触达 Box 控制面"或"多租户共享资源"的场景才成立。
## 已解决(社区版发布前)
| 项 | 处理 |
|----|------|
| 工具调用循环无上限 (原 #13) | `localagent.py` 增加 `MAX_TOOL_CALL_ROUNDS=128`,超限优雅终止(`cafef1a3` |
| 配额校验同步遍历阻塞事件循环 (原 #10) | `_enforce_workspace_quota` 改 async工作区遍历走 `asyncio.to_thread``cafef1a3` |
| `host_path` 挂载白名单 (原 #3 的 LangBot 侧) | `pkg/box/service.py` `allowed_mount_roots` 白名单,空列表时拒绝一切宿主挂载 |
| 重复的 `_is_path_under` (原 #12) | 已去重,仅保留一处定义 |
| 重连 / 心跳 / Windows 兼容 / nsjail image 字段 / 前端 Box 状态接入 | 见上一轮 review 记录,均已合入 |
--- ---
## 已解决(自上一轮 review ## SaaS 阻塞项
下列原 P0/P1 项在最新分支已被修复,仅作记录: ### S1. Box 控制面无认证 — Critical
| 原编号 | 问题 | 处理 commit / 说明 | - **位置**: SDK `box/server.py` — Action RPC WS (`/rpc/ws`) 与 managed-process relay (`/v1/sessions/{id}/managed-process/{pid}/ws`)
|--------|------|---------------------| - **现状**: 两个 WS handler 在 `ws.prepare` 后直接服务,无任何 token / 鉴权box 默认绑定 `0.0.0.0:5410`。任何能触达该端口者可发起 `EXEC`、创建 session、attach 任意 session 的 managed-process stdin/stdout、甚至 `SHUTDOWN`。LangBot→box 的 INIT 也未下发任何凭证。
| #3 | Box 无重连机制 | `_make_connection_callback` 已接入 `runtime_disconnect_callback``BoxService._reconnect_loop()` 实现指数退避重连 (`2dfd9d5d``c6882cf`) | - **缓解现状**: 默认 `docker-compose.yaml``langbot_box` 未把 5410 发布到宿主(爆炸半径限于内网 bridge但 box 挂载了 `/var/run/docker.sock`,同网络的任意服务(含被攻破的插件)→ 宿主 root。若运营者把 5410 发布到宿主或独立以 `0.0.0.0` 起 box则完全裸奔。
| #4 | Box 无心跳 | `BoxRuntimeConnector._heartbeat_loop()`,间隔 20s沿用 Plugin 模式) | - **要求**: INIT 时下发 token两个 WS 路由按连接校验query/header。这是 SaaS 的**头号**阻塞项。
| #10 | Windows 兼容 | connector 增加 Windows 分支 (subprocess + WS)backend 适配 Windows Docker (`120817a``fafb7a4`) |
| #12 | nsjail image 字段冲突 | `_assert_session_compatible()` 在不支持自定义镜像的 backend 跳过 image 字段 |
| #22 | 前端无 Box UI | 监控页 `SystemStatusCards.tsx` 已接入 `/api/v1/box/status`Skill 管理页接入了全部 skill APIsessions/errors API 仍未接入) |
--- ### S2. 无 exec 授权模型policy.py 死代码) — High
## P0 — 合并前建议修复 - **位置**: LangBot `pkg/box/policy.py``SandboxPolicy` / `ToolPolicy` / `ElevatedPolicy` 全项目无引用);`pkg/provider/tools/loaders/native.py``pkg/provider/tools/toolmgr.py`
- **现状**: 原生工具(`exec/read/write/edit/glob/grep`)按"box 是否可用"全有或全无地暴露,**无 per-pipeline 的 exec 网关 / 工具白名单 / 沙箱模式 / 权限提升控制**。只要 box 可用,任何使用 local-agent + 函数调用模型的 pipeline 都能跑任意 shell。
- **要求**: 接入 policy.py或等价机制按 pipeline 控制是否暴露 `exec`、可用工具白名单、沙箱网络/只读模式。
### 1. policy.py 是死代码 ### S3. 会话资源无界DoS — High
- **位置**: `pkg/box/policy.py` (98 行) - **#5 session 数量无上限**: SDK `box/runtime.py` `_get_or_create_session``_sessions` dict 无容量限制——可变 `session_id` 的恶意调用可无限创建容器,耗尽宿主 CPU/内存/PID/磁盘。
- **现状**: `SandboxPolicy``ToolPolicy``ElevatedPolicy` 三个类已定义,但全项目无任何导入或调用 - **#8 无定时回收**: 过期 session 仅在 `_get_or_create_session` 时机会性清理,无独立周期任务;一波创建后转静默会永久泄漏容器。
- **影响**: 三层安全策略(沙箱模式 / 工具白名单 / 权限提升)完全未生效。当前实际策略仍是"Box 可用就暴露全部 6 个 native tool不可用就全部隐藏" - **要求**: `max_sessions` 上限(拒绝或 LRU加独立周期 reaper如 60s
- **建议**: 要么删除死代码,要么接入 NativeToolLoader 的工具暴露 / exec 调用链。如果短期不会接入,至少在 `pkg/box/__init__.py` 显式标注其状态
### 2. WebSocket relay 无认证 ### S4. 工作区配额无内核级限制TOCTOU — Med-High
- **位置**: SDK `box/server.py` — Action RPC 路径 `/rpc/ws` 与 managed-process relay `/v1/sessions/{id}/managed-process/{pid}/ws` - **位置**: LangBot `pkg/box/service.py` `_enforce_workspace_quota`(应用层 read-then-checkSDK 侧 `workspace_quota_mb` 仅记录/透传,无 `--storage-opt size=` 等内核/FS 限额
- **现状**: 任何能访问 5410 端口的客户端都可以连接attach 任意 session 的 managed process stdin/stdout或直接发起 EXEC - **现状**: 执行前后两次检查之间存在竞态窗口;单条命令(`dd`/`fallocate`)可在检查间隙撑爆磁盘,事后检查只能补救。
- **影响**: 容器化 / Docker compose 部署中,若 Box runtime 端口外暴露,网络内的攻击者可直接控制沙箱 - **要求**: Docker `--storage-opt size=` 做内核级限制,或 Redis 原子计数预留式配额。
- **建议**: 至少加 token 认证INIT 时下发WS 连接 query string 或 header 校验);多 process 后 attach 面更大,更不能裸奔
### 3. security.py 根路径未拦截 ### S5. 挂载校验缺口 — Med-High
- **位置**: SDK `box/security.py` `BLOCKED_HOST_PATHS_POSIX` - **位置**: SDK `box/security.py` `_BLOCKED_HOST_PATHS_POSIX``box/backend.py``extra_mounts` 处理
- **现状**: 黑名单中没有 `/``host_path="/"` 可通过校验并挂载整个主机文件系统;用户 home 目录、`/var` 等也未拦截 - **现状**: ① SDK 黑名单仍不含 `/`(前缀匹配`host_path="/"` 可通过挂载整个宿主 fs;用户 home`/usr``/opt``/tmp` 也未拦截。② `validate_sandbox_security` 只校验 `spec.host_path`**从不遍历 `spec.extra_mounts`**——LangBot 侧 `allowed_mount_roots` 也只校验 `host_path`。当前 `extra_mounts` 仅由 `build_skill_extra_mounts` 内部填充agent 不可达),但缺乏纵深防御:一旦 S1 的无认证 RPC 被触达extra_mounts 可挂任意宿主路径,两层都不拦。
- **建议**: `/` 加入黑名单,或改白名单策略与 LangBot 侧 `allowed_mount_roots` 二次拦截 - **要求**: SDK 黑名单加入 `/`或改白名单`extra_mounts` 在 SDK 与 LangBot 两侧都纳入挂载校验。
### 4. INIT 与 backend 初始化的竞态 ### S6. 容器加固缺失 — Med
- **位置**: SDK `box/runtime.py` `init()` 在握手后才下发实际配置;`backend` 在 INIT 之前可能已经按默认值实例化 - **位置**: SDK `box/backend.py``docker run` 组装
- **现状**: commit `5029d9c` 修复了 "init config before backend reuse" 的部分场景,但 backend 重新实例化时若有正在执行的 session可能命中旧 backend - **现状**: 未设置 `--cap-drop=ALL``--security-opt=no-new-privileges`、非 root `--user`;叠加挂载 docker.sock逃逸面偏大。
- **建议**: 整理 init/handshake 顺序——要么 INIT 完成前不接受任何业务 action要么允许 backend 配置变更时显式清理现有 session - **要求**: 默认加上上述加固 flag需回归常用 skill 不被破坏)。
--- ### S7. 全局锁内执行慢操作(扩展性) — Med
## P1 — 合并后优先跟进 - **位置**: SDK `box/runtime.py` `_get_or_create_session``self._lock` 持有期间调用 `backend.start_session()``docker run` / nsjail 启动 / E2B `Sandbox.create`
- **影响**: 冷启动镜像拉取数秒、E2B >1s期间串行阻塞所有并发请求——多租户负载下整个 Box runtime 停顿。降级表现是延迟而非失败。
- **要求**: 锁内只做状态检查与注册,容器创建移到锁外。
### 5. Session 数量无上限 ### S8. 其他硬化 / 跟进 — Low
- **位置**: SDK `box/runtime.py` `_get_or_create_session()` - **#9** SDK `box/server.py` 直接读 `runtime._sessions` 私有字段、绕过锁,并发下可能读到不一致状态——应加公共访问方法。
- **现状**: `_sessions` dict 无容量限制,恶意或异常调用可创建无限 session - **#16** `pkg/provider/tools/toolmgr.py` `execute_func_call` 按优先级分发plugin/MCP 若有同名 `exec/read/write/...` 工具会被静默遮蔽——应加命名空间或冲突告警。
- **建议**: 加 `max_sessions` 配置项,达到上限时拒绝新建或按 LRU 清理 - **#4** SDK `box/runtime.py` INIT/handshake 与 backend 实例化的残留竞态(仅"纯远程 WS box 先启动、LangBot 后连"场景成立stdio/compose 路径下 config 经 env 在 spawn 时已就位,无竞态)——应在 INIT 完成前拒绝业务 action。
- **#11** `extra_mounts` 在容器创建时固定SDK `runtime.py` 兼容性检查不含 extra_mounts长生命周期共享 session 后续新激活的 skill 不会挂上(当前缓解:创建时挂上 pipeline 绑定的全部 skill——动态绑定场景需销毁重建或文档说明。
### 6. Quota 检查存在 TOCTOU - **#21** 集成测试未进 CI容器实际执行、E2B 真机、managed-process WS attach 仅本地可跑。安全关键路径缺自动化覆盖——SaaS 前建议加 Docker-in-Docker CI stage 或合并前手动 checklist。
- **位置**: `pkg/box/service.py` `_enforce_workspace_quota()`
- **现状**: 应用层先读磁盘大小再执行命令,两步之间有竞态窗口
- **建议**: 短期用 Docker `--storage-opt size=` 做内核级限制;长期用 Redis 原子计数器做预留式配额
### 7. 全局锁持有期间执行慢操作
- **位置**: SDK `box/runtime.py` `_get_or_create_session()``self._lock` 下调用 `backend.start_session()` (即 `docker run` / `nsjail` 进程启动 / E2B `Sandbox.create`)
- **影响**: `docker run` 可能耗时数秒含镜像拉取、E2B 冷启动通常 > 1s期间阻塞所有并发请求
- **建议**: 在 `_lock` 下仅做状态检查和 session 注册,容器创建在锁外执行
### 8. Session 清理是机会性的
- **位置**: SDK `box/runtime.py` `_reap_expired_sessions_locked()` — 仅在 `_get_or_create_session()` 时调用
- **影响**: 如果长时间无新 session 请求,过期 session含容器不会被清理
- **建议**: 加一个独立的 `asyncio.create_task` 定时清理(如每 60s 一次)
### 9. server.py 直接访问 runtime 私有字段
- **位置**: SDK `box/server.py` — managed-process WS handler 直接读 `runtime._sessions`
- **影响**: 绕过锁和封装,在并发场景下可能读到不一致状态
- **建议**: 在 BoxRuntime 上增加公共方法(如 `get_session_managed_process(session_id, process_id)`
### 10. workspace quota 检查阻塞事件循环
- **位置**: `pkg/box/service.py` `_get_workspace_size_bytes()` — 使用同步 `os.scandir` 递归遍历
- **影响**: 大工作区可能阻塞 asyncio event loop
- **建议**: 用 `asyncio.to_thread()` 包装,或用 `aiofiles` 异步扫描
### 11. extra_mounts 一旦容器创建即固定
- **位置**: SDK `box/runtime.py` 的兼容性检查;`pkg/box/service.py:build_skill_extra_mounts()`
- **现状**: Skill 挂载在容器创建时一次性写入;同一 session 后续 pipeline 切换 skill 列表时,新挂载不会生效(除非销毁重建)
- **影响**: 用户长时间共享 session 的场景下,新激活的 skill 可能挂不上
- **建议**: 要么在创建时把 pipeline 绑定的所有 skill 都挂上(实际现状)+ 写入文档;要么变更挂载时强制销毁 session 重建(已被 commit `5029d9c` 部分覆盖,需校验)
---
## P2 — 后续迭代
### 12. 重复的 `_is_path_under` 函数
- **位置**: `pkg/box/service.py` 行 30 附近 — 同名函数定义两次
- **建议**: 删除重复定义
### 13. localagent.py 工具循环无迭代上限
- **位置**: `pkg/provider/runners/localagent.py` `while pending_tool_calls` 循环
- **影响**: 恶意或混乱的 LLM 可无限产生 tool call消耗资源
- **建议**: 加 `max_tool_iterations` 配置项(如默认 50 次)
### 14. localagent.py 中的死代码
- **位置**: `pkg/provider/runners/localagent.py:29-35` 附近 — 旧命名 `SANDBOX_EXEC_TOOL_NAME``SANDBOX_EXEC_SYSTEM_GUIDANCE`
- **现状**: 旧命名方案的遗留常量,从未被引用(实际使用 `EXEC_TOOL_NAME` from native.py
- **建议**: 删除
### 15. @loader_class 装饰器未使用
- **位置**: `pkg/provider/tools/loader.py``preregistered_loaders` 列表和 `@loader_class` 装饰器
- **现状**: 各 loader 的 `@loader_class` 多数被注释掉ToolManager 手动实例化所有 loader
- **建议**: 要么启用装饰器自动注册,要么删除未用的机制
### 16. 工具名冲突风险
- **位置**: `pkg/provider/tools/toolmgr.py` `execute_func_call()` — 按优先级 native → plugin → mcp → skill → skill_authoring 分发
- **影响**: 如果 plugin 或 MCP 有名为 `exec`/`read`/`write`/`edit`/`glob`/`grep`/`activate` 的工具,会被前序 loader 静默遮蔽
- **建议**: 加命名空间前缀或冲突检测告警
### 17. client.py 反序列化不一致
- **位置**: SDK `box/client.py``execute()` 与其他方法对返回值的反序列化方式不统一(部分手动构造 model部分用 `model_validate`
- **建议**: 统一使用 `model_validate`
### 18. 错误类型还原基于字符串前缀匹配
- **位置**: SDK `box/client.py` `_translate_action_error()`
- **影响**: 如果 server 端错误消息格式变化client 会回退到通用 `BoxError`,丢失类型信息
- **建议**: 在 ActionResponse 中增加结构化的错误类型字段(如 `error_code` 枚举)
### 19. 前端只用到了 status
- **位置**: `web/src/app/home/monitoring/...` 已接入 `/api/v1/box/status`
- **现状**: `/api/v1/box/sessions``/api/v1/box/errors` 后端可用、前端未消费
- **建议**: 在监控页或独立 Box 详情页展示活跃 session 列表与最近错误,提升运维体感
### 20. skill_store 测试覆盖偏薄
- **位置**: SDK `tests/box/test_skill_store.py` 仅 88 行
- **现状**: 相对 `skill_store.py` 的 647 行实现单测覆盖度不够GitHub 安装路径、`source_subdir` / `target_suffix` 组合、损坏 zip 的错误处理等场景未覆盖
- **建议**: 至少补到核心 path 覆盖preview/install/list/file CRUD 各 2~3 个 case
### 21. 集成测试未进 CI
- **位置**: LangBot `tests/integration_tests/box/test_box_integration.py``test_box_mcp_integration.py`SDK 端的 E2B 真机测试
- **现状**: 容器实际执行、E2B 真实 sandbox、Managed process WS attach 均仅本地能跑
- **建议**: 加一个可选的 Docker-in-Docker CI stage或在合并前手动跑 checklist

View File

@@ -1,6 +1,7 @@
# Box Session Scope Design # Box Session Scope Design
> Date: 2026-04-18 (last reviewed 2026-05-19) > Date: 2026-04-18 (last reviewed 2026-06-02)
> Status (2026-06-02): the self-hosted community edition is release-ready (box optional, clean degradation, no migration debt). Tool-call loop cap, async quota scan, and the host_path mount allowlist have landed. Remaining multi-tenant / security hardening is tracked in [box-issues.md](./box-issues.md).
> Branch: `feat/sandbox` (LangBot + langbot-plugin-sdk) > Branch: `feat/sandbox` (LangBot + langbot-plugin-sdk)
> Related: [Box Architecture](./box-architecture.md) | [Box vs Plugin Runtime](./box-vs-plugin-runtime.md) > Related: [Box Architecture](./box-architecture.md) | [Box vs Plugin Runtime](./box-vs-plugin-runtime.md)

View File

@@ -1,6 +1,7 @@
# Box 系统测试覆盖分析 # Box 系统测试覆盖分析
> 更新日期: 2026-05-19 > 更新日期: 2026-06-02
> 状态更新: 自部署社区版已具备发布条件box 可选、降级完善、无迁移欠债);工具调用循环上限、配额遍历异步化、`host_path` 挂载白名单等已落地。剩余多租户 / 安全硬化项见 [SaaS 阻塞项清单](./box-issues.md)。
> 分支: `feat/sandbox` (LangBot + langbot-plugin-sdk) > 分支: `feat/sandbox` (LangBot + langbot-plugin-sdk)
--- ---

View File

@@ -1,6 +1,7 @@
# Box 系统 toB 商业化分析 # Box 系统 toB 商业化分析
> 更新日期: 2026-05-19 > 更新日期: 2026-06-02
> 状态更新: 自部署社区版已具备发布条件box 可选、降级完善、无迁移欠债);工具调用循环上限、配额遍历异步化、`host_path` 挂载白名单等已落地。剩余多租户 / 安全硬化项见 [SaaS 阻塞项清单](./box-issues.md)。
> 分支: `feat/sandbox` (LangBot + langbot-plugin-sdk) > 分支: `feat/sandbox` (LangBot + langbot-plugin-sdk)
--- ---

View File

@@ -1,6 +1,7 @@
# Box Runtime vs Plugin Runtime: 连接架构对比 # Box Runtime vs Plugin Runtime: 连接架构对比
> 更新日期: 2026-05-19 > 更新日期: 2026-06-02
> 状态更新: 自部署社区版已具备发布条件box 可选、降级完善、无迁移欠债);工具调用循环上限、配额遍历异步化、`host_path` 挂载白名单等已落地。剩余多租户 / 安全硬化项见 [SaaS 阻塞项清单](./box-issues.md)。
> 分支: `feat/sandbox` (LangBot + langbot-plugin-sdk) > 分支: `feat/sandbox` (LangBot + langbot-plugin-sdk)
--- ---

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "langbot" name = "langbot"
version = "4.10.0-beta.1" version = "4.10.2"
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"]
@@ -8,7 +8,7 @@ requires-python = ">=3.11,<4.0"
dependencies = [ dependencies = [
"aiocqhttp>=1.4.4", "aiocqhttp>=1.4.4",
"aiofiles>=24.1.0", "aiofiles>=24.1.0",
"aiohttp>=3.13.4", "aiohttp>=3.14.0",
"aioshutil>=1.5", "aioshutil>=1.5",
"aiosqlite>=0.21.0", "aiosqlite>=0.21.0",
"anthropic>=0.51.0", "anthropic>=0.51.0",
@@ -31,27 +31,27 @@ dependencies = [
"psutil>=7.0.0", "psutil>=7.0.0",
"pycryptodome>=3.22.0", "pycryptodome>=3.22.0",
"pydantic>2.0", "pydantic>2.0",
"pyjwt>=2.10.1", "pyjwt>=2.12.0",
"python-telegram-bot>=22.0", "python-telegram-bot>=22.0",
"pyyaml>=6.0.2", "pyyaml>=6.0.2",
"qq-botpy-rc>=1.2.1.6", "qq-botpy-rc>=1.2.1.6",
"qrcode>=7.4", "qrcode>=7.4",
"quart>=0.20.0", "quart>=0.20.0",
"quart-cors>=0.8.0", "quart-cors>=0.8.0",
"requests>=2.32.3", "requests>=2.33.0",
"slack-sdk>=3.35.0", "slack-sdk>=3.35.0",
"alembic>=1.15.0", "alembic>=1.15.0",
"sqlalchemy[asyncio]>=2.0.40", "sqlalchemy[asyncio]>=2.0.40",
"sqlmodel>=0.0.24", "sqlmodel>=0.0.24",
"telegramify-markdown>=0.5.1", "telegramify-markdown>=0.5.1",
"tiktoken>=0.9.0", "tiktoken>=0.9.0",
"urllib3>=2.4.0", "urllib3>=2.7.0",
"websockets>=15.0.1", "websockets>=15.0.1",
"python-socks>=2.7.1", # dingtalk missing dependency "python-socks>=2.7.1", # dingtalk missing dependency
"pip>=25.1.1", "pip>=26.1",
"ruff>=0.11.9", "ruff>=0.11.9",
"pre-commit>=4.2.0", "pre-commit>=4.2.0",
"uv>=0.11.6", "uv>=0.11.15",
"mypy>=1.16.0", "mypy>=1.16.0",
"PyPDF2>=3.0.1", "PyPDF2>=3.0.1",
"python-docx>=1.1.0", "python-docx>=1.1.0",
@@ -62,15 +62,15 @@ dependencies = [
"ebooklib>=0.18", "ebooklib>=0.18",
"html2text>=2024.2.26", "html2text>=2024.2.26",
"langchain>=0.2.0", "langchain>=0.2.0",
"langchain-core>=1.2.28", "langchain-core>=1.3.3",
"langsmith>=0.7.31", "langsmith>=0.8.0",
"python-multipart>=0.0.26", "python-multipart>=0.0.27",
"Mako>=1.3.11", "Mako>=1.3.12",
"langchain-text-splitters>=1.1.2", "langchain-text-splitters>=1.1.2",
"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.0b1", "langbot-plugin==0.4.4",
"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",
@@ -79,6 +79,7 @@ dependencies = [
"pymilvus>=2.6.4", "pymilvus>=2.6.4",
"pgvector>=0.4.1", "pgvector>=0.4.1",
"botocore>=1.42.39", "botocore>=1.42.39",
"litellm>=1.0.0",
] ]
keywords = [ keywords = [
"bot", "bot",

View File

@@ -1,3 +1,3 @@
"""LangBot - Production-grade platform for building agentic IM bots""" """LangBot - Production-grade platform for building agentic IM bots"""
__version__ = '4.10.0-beta.1' __version__ = '4.10.2'

View File

@@ -0,0 +1,5 @@
from .client import AsyncDeerFlowClient
from .errors import DeerFlowAPIError
from . import stream_utils
__all__ = ['AsyncDeerFlowClient', 'DeerFlowAPIError', 'stream_utils']

View File

@@ -0,0 +1,204 @@
"""DeerFlow LangGraph HTTP API 客户端
参考 astrbot 的 deerflow_api_client 实现,使用 httpx 适配 LangBot 风格。
"""
from __future__ import annotations
import codecs
import json
import typing
from collections.abc import AsyncGenerator
import httpx
from .errors import DeerFlowAPIError
SSE_MAX_BUFFER_CHARS = 1_048_576
def _normalize_sse_newlines(text: str) -> str:
"""规范化 CRLF/CR 为 LF确保 SSE 块分割稳定"""
return text.replace('\r\n', '\n').replace('\r', '\n')
def _parse_sse_data_lines(data_lines: list[str]) -> typing.Any:
raw_data = '\n'.join(data_lines)
try:
return json.loads(raw_data)
except json.JSONDecodeError:
# 某些 LangGraph 兼容服务端会在单个 SSE 事件中用多个 data 行
# 发送多段 JSON 片段(例如 tuple payload
parsed_lines: list[typing.Any] = []
can_parse_all = True
for line in data_lines:
line = line.strip()
if not line:
continue
try:
parsed_lines.append(json.loads(line))
except json.JSONDecodeError:
can_parse_all = False
break
if can_parse_all and parsed_lines:
return parsed_lines[0] if len(parsed_lines) == 1 else parsed_lines
return raw_data
def _parse_sse_block(block: str) -> dict[str, typing.Any] | None:
if not block.strip():
return None
event_name = 'message'
data_lines: list[str] = []
for line in block.splitlines():
if line.startswith('event:'):
event_name = line[6:].strip()
elif line.startswith('data:'):
data_lines.append(line[5:].lstrip())
if not data_lines:
return None
return {'event': event_name, 'data': _parse_sse_data_lines(data_lines)}
class AsyncDeerFlowClient:
"""DeerFlow LangGraph HTTP API 客户端"""
api_base: str
headers: dict[str, str]
def __init__(
self,
api_base: str = 'http://127.0.0.1:2026',
api_key: str = '',
auth_header: str = '',
) -> None:
self.api_base = api_base.rstrip('/')
self.headers: dict[str, str] = {}
if auth_header:
self.headers['Authorization'] = auth_header
elif api_key:
self.headers['Authorization'] = f'Bearer {api_key}'
async def create_thread(self, timeout: float = 20) -> dict[str, typing.Any]:
"""创建一个新的 LangGraph thread
Returns:
包含 thread_id 等信息的字典
"""
url = f'{self.api_base}/api/langgraph/threads'
payload = {'metadata': {}}
async with httpx.AsyncClient(
trust_env=True,
timeout=timeout,
) as client:
response = await client.post(
url,
headers=self.headers,
json=payload,
)
if response.status_code not in (200, 201):
raise DeerFlowAPIError(
operation='create thread',
status=response.status_code,
body=response.text,
url=url,
)
return response.json()
async def delete_thread(self, thread_id: str, timeout: float = 20) -> None:
"""删除指定 thread"""
url = f'{self.api_base}/api/threads/{thread_id}'
async with httpx.AsyncClient(
trust_env=True,
timeout=timeout,
) as client:
response = await client.delete(url, headers=self.headers)
if response.status_code not in (200, 202, 204, 404):
raise DeerFlowAPIError(
operation='delete thread',
status=response.status_code,
body=response.text,
url=url,
thread_id=thread_id,
)
async def stream_run(
self,
thread_id: str,
payload: dict[str, typing.Any],
timeout: float = 120,
) -> AsyncGenerator[dict[str, typing.Any], None]:
"""运行一次 LangGraph stream 请求,逐事件 yield
Yields:
事件字典 {'event': event_name, 'data': parsed_data}
"""
url = f'{self.api_base}/api/langgraph/threads/{thread_id}/runs/stream'
# 流式请求使用单独的 read timeout 控制
stream_timeout = httpx.Timeout(
connect=min(timeout, 30),
read=timeout,
write=timeout,
pool=timeout,
)
async with httpx.AsyncClient(
trust_env=True,
timeout=stream_timeout,
) as client:
async with client.stream(
'POST',
url,
headers={
**self.headers,
'Accept': 'text/event-stream',
'Content-Type': 'application/json',
},
json=payload,
) as resp:
if resp.status_code != 200:
body = await resp.aread()
raise DeerFlowAPIError(
operation='runs/stream request',
status=resp.status_code,
body=body.decode('utf-8', errors='replace'),
url=url,
thread_id=thread_id,
)
decoder = codecs.getincrementaldecoder('utf-8')('replace')
buffer = ''
async for chunk in resp.aiter_bytes(8192):
buffer += _normalize_sse_newlines(decoder.decode(chunk))
while '\n\n' in buffer:
block, buffer = buffer.split('\n\n', 1)
parsed = _parse_sse_block(block)
if parsed is not None:
yield parsed
if len(buffer) > SSE_MAX_BUFFER_CHARS:
# 缓冲区过大,强制 flush
parsed = _parse_sse_block(buffer)
if parsed is not None:
yield parsed
buffer = ''
# flush 剩余内容
buffer += _normalize_sse_newlines(decoder.decode(b'', final=True))
while '\n\n' in buffer:
block, buffer = buffer.split('\n\n', 1)
parsed = _parse_sse_block(block)
if parsed is not None:
yield parsed
if buffer.strip():
parsed = _parse_sse_block(buffer)
if parsed is not None:
yield parsed

View File

@@ -0,0 +1,30 @@
from __future__ import annotations
class DeerFlowAPIError(Exception):
"""DeerFlow API 请求失败"""
def __init__(
self,
*,
operation: str = '',
status: int = 0,
body: str = '',
url: str = '',
thread_id: str | None = None,
message: str = '',
) -> None:
self.operation = operation
self.status = status
self.body = body
self.url = url
self.thread_id = thread_id
if message:
super().__init__(message)
return
msg = f'DeerFlow {operation} failed: status={status}, url={url}, body={body}'
if thread_id is not None:
msg = f'DeerFlow {operation} failed: thread_id={thread_id}, status={status}, url={url}, body={body}'
super().__init__(msg)

View File

@@ -0,0 +1,212 @@
"""DeerFlow LangGraph 流式响应解析工具
参考 astrbot 实现的 deerflow_stream_utils。
"""
from __future__ import annotations
import typing
from collections.abc import Iterable
def extract_text(content: typing.Any) -> str:
"""从消息 content 中提取纯文本"""
if isinstance(content, str):
return content
if isinstance(content, dict):
if isinstance(content.get('text'), str):
return content['text']
if 'content' in content:
return extract_text(content.get('content'))
if 'kwargs' in content and isinstance(content['kwargs'], dict):
return extract_text(content['kwargs'].get('content'))
if isinstance(content, list):
parts: list[str] = []
for item in content:
if isinstance(item, str):
parts.append(item)
elif isinstance(item, dict):
item_type = item.get('type')
if item_type == 'text' and isinstance(item.get('text'), str):
parts.append(item['text'])
elif 'content' in item:
parts.append(extract_text(item['content']))
return '\n'.join([p for p in parts if p]).strip()
return str(content) if content is not None else ''
def extract_messages_from_values_data(data: typing.Any) -> list[typing.Any]:
"""从 values 事件中提取 messages 列表"""
candidates: list[typing.Any] = []
if isinstance(data, dict):
candidates.append(data)
if isinstance(data.get('values'), dict):
candidates.append(data['values'])
elif isinstance(data, list):
candidates.extend([x for x in data if isinstance(x, dict)])
for item in candidates:
messages = item.get('messages')
if isinstance(messages, list):
return messages
return []
def is_ai_message(message: dict[str, typing.Any]) -> bool:
"""判断是否为 AI/assistant 消息"""
role = str(message.get('role', '')).lower()
if role in {'assistant', 'ai'}:
return True
msg_type = str(message.get('type', '')).lower()
if msg_type in {'ai', 'assistant', 'aimessage', 'aimessagechunk'}:
return True
if 'ai' in msg_type and all(token not in msg_type for token in ('human', 'tool', 'system')):
return True
return False
def extract_latest_ai_text(messages: Iterable[typing.Any]) -> str:
"""获取最近一条 AI 消息的文本内容"""
if isinstance(messages, (list, tuple)):
iterable = reversed(messages)
else:
iterable = reversed(list(messages))
for msg in iterable:
if not isinstance(msg, dict):
continue
if is_ai_message(msg):
text = extract_text(msg.get('content'))
if text:
return text
return ''
def extract_latest_ai_message(messages: Iterable[typing.Any]) -> dict[str, typing.Any] | None:
"""获取最近一条 AI 消息对象"""
if isinstance(messages, (list, tuple)):
iterable = reversed(messages)
else:
iterable = reversed(list(messages))
for msg in iterable:
if not isinstance(msg, dict):
continue
if is_ai_message(msg):
return msg
return None
def is_clarification_tool_message(message: dict[str, typing.Any]) -> bool:
"""判断是否为澄清问题工具消息"""
msg_type = str(message.get('type', '')).lower()
tool_name = str(message.get('name', '')).lower()
return msg_type == 'tool' and tool_name == 'ask_clarification'
def extract_latest_clarification_text(messages: Iterable[typing.Any]) -> str:
"""提取最近的澄清问题文本"""
if isinstance(messages, (list, tuple)):
iterable = reversed(messages)
else:
iterable = reversed(list(messages))
for msg in iterable:
if not isinstance(msg, dict):
continue
if is_clarification_tool_message(msg):
text = extract_text(msg.get('content'))
if text:
return text
return ''
def get_message_id(message: typing.Any) -> str:
"""提取消息 ID"""
if not isinstance(message, dict):
return ''
msg_id = message.get('id')
return msg_id if isinstance(msg_id, str) else ''
def extract_event_message_obj(data: typing.Any) -> dict[str, typing.Any] | None:
"""从事件 data 中提取消息对象"""
msg_obj = data
if isinstance(data, (list, tuple)) and data:
msg_obj = data[0]
if isinstance(msg_obj, dict) and isinstance(msg_obj.get('data'), dict):
msg_obj = msg_obj['data']
return msg_obj if isinstance(msg_obj, dict) else None
def extract_ai_delta_from_event_data(data: typing.Any) -> str:
"""从 messages-tuple 事件中提取 AI delta 文本"""
msg_obj = extract_event_message_obj(data)
if not msg_obj:
return ''
if is_ai_message(msg_obj):
return extract_text(msg_obj.get('content'))
return ''
def extract_clarification_from_event_data(data: typing.Any) -> str:
"""从事件中提取澄清问题"""
msg_obj = extract_event_message_obj(data)
if not msg_obj:
return ''
if is_clarification_tool_message(msg_obj):
return extract_text(msg_obj.get('content'))
return ''
def _iter_custom_event_items(data: typing.Any) -> list[dict[str, typing.Any]]:
items: list[dict[str, typing.Any]] = []
if isinstance(data, dict):
return [data]
if isinstance(data, list):
for item in data:
if isinstance(item, dict):
items.append(item)
elif isinstance(item, (list, tuple)):
for nested in item:
if isinstance(nested, dict):
items.append(nested)
return items
def extract_task_failures_from_custom_event(data: typing.Any) -> list[str]:
"""从 custom 事件中提取子任务失败信息"""
failures: list[str] = []
for item in _iter_custom_event_items(data):
event_type = str(item.get('type', '')).lower()
if event_type not in {'task_failed', 'task_timed_out'}:
continue
task_id = str(item.get('task_id', '')).strip()
error_text = extract_text(item.get('error')).strip()
if task_id and error_text:
failures.append(f'{task_id}: {error_text}')
elif error_text:
failures.append(error_text)
elif task_id:
failures.append(f'{task_id}: unknown error')
else:
failures.append('unknown task failure')
return failures
def build_task_failure_summary(failures: list[str]) -> str:
"""构建任务失败摘要"""
if not failures:
return ''
deduped: list[str] = []
seen: set[str] = set()
for failure in failures:
if failure not in seen:
seen.add(failure)
deduped.append(failure)
if len(deduped) == 1:
return f'DeerFlow subtask failed: {deduped[0]}'
joined = '\n'.join([f'- {item}' for item in deduped[:5]])
return f'DeerFlow subtasks failed:\n{joined}'

View File

@@ -145,7 +145,7 @@ class AsyncDifyServiceClient:
'file': file, 'file': file,
}, },
data={ data={
'user': (None, user), 'user': user,
}, },
) )

View File

@@ -0,0 +1,4 @@
from .client import AsyncWeKnoraClient
from .errors import WeKnoraAPIError
__all__ = ['AsyncWeKnoraClient', 'WeKnoraAPIError']

View File

@@ -0,0 +1,180 @@
from __future__ import annotations
import httpx
import typing
import json
from .errors import WeKnoraAPIError
class AsyncWeKnoraClient:
"""WeKnora API 客户端"""
api_key: str
base_url: str
def __init__(
self,
api_key: str,
base_url: str = 'http://localhost:80/api/v1',
) -> None:
self.api_key = api_key
self.base_url = base_url
async def create_session(
self,
title: str = '',
description: str = '',
timeout: float = 30.0,
) -> str:
"""创建会话,返回 session_id"""
async with httpx.AsyncClient(
base_url=self.base_url,
trust_env=True,
timeout=timeout,
) as client:
payload: dict[str, typing.Any] = {}
if title:
payload['title'] = title
if description:
payload['description'] = description
response = await client.post(
'/sessions',
headers={
'X-API-Key': self.api_key,
'Content-Type': 'application/json',
},
json=payload,
)
if response.status_code not in (200, 201):
raise WeKnoraAPIError(f'{response.status_code} {response.text}')
data = response.json()
return data['data']['id']
async def agent_chat(
self,
session_id: str,
query: str,
user: str,
agent_id: str = '',
knowledge_base_ids: list[str] | None = None,
web_search_enabled: bool = False,
timeout: float = 120.0,
) -> typing.AsyncGenerator[dict[str, typing.Any], None]:
"""
Agent 智能对话SSE 流式)
响应事件类型:
- agent_query: Agent 开始处理
- thinking: 思考过程
- tool_call: 工具调用
- tool_result: 工具结果
- references: 知识库引用
- answer: 回答内容
- reflection: 反思
- session_title: 会话标题
- error: 错误
"""
if knowledge_base_ids is None:
knowledge_base_ids = []
async with httpx.AsyncClient(
base_url=self.base_url,
trust_env=True,
timeout=timeout,
) as client:
payload: dict[str, typing.Any] = {
'query': query,
'agent_enabled': True,
'channel': 'im',
}
if agent_id:
payload['agent_id'] = agent_id
if knowledge_base_ids:
payload['knowledge_base_ids'] = knowledge_base_ids
if web_search_enabled:
payload['web_search_enabled'] = True
async with client.stream(
'POST',
f'/agent-chat/{session_id}',
headers={
'X-API-Key': self.api_key,
'Content-Type': 'application/json',
},
json=payload,
) as r:
async for chunk in r.aiter_lines():
if r.status_code != 200:
raise WeKnoraAPIError(f'{r.status_code} {chunk}')
if chunk.strip() == '':
continue
if chunk.startswith('data:'):
try:
data = json.loads(chunk[5:].strip())
except json.JSONDecodeError:
continue
yield data
# 收到 error 事件后主动结束流,避免上层未 raise 时持续等待
if data.get('response_type') == 'error':
return
async def knowledge_chat(
self,
session_id: str,
query: str,
user: str,
agent_id: str = 'builtin-quick-answer',
knowledge_base_ids: list[str] | None = None,
timeout: float = 120.0,
) -> typing.AsyncGenerator[dict[str, typing.Any], None]:
"""
知识库 RAG 问答SSE 流式)
响应事件类型:
- references: 知识库引用
- answer: 回答内容
"""
if knowledge_base_ids is None:
knowledge_base_ids = []
async with httpx.AsyncClient(
base_url=self.base_url,
trust_env=True,
timeout=timeout,
) as client:
payload: dict[str, typing.Any] = {
'query': query,
'channel': 'im',
}
if agent_id:
payload['agent_id'] = agent_id
if knowledge_base_ids:
payload['knowledge_base_ids'] = knowledge_base_ids
async with client.stream(
'POST',
f'/knowledge-chat/{session_id}',
headers={
'X-API-Key': self.api_key,
'Content-Type': 'application/json',
},
json=payload,
) as r:
async for chunk in r.aiter_lines():
if r.status_code != 200:
raise WeKnoraAPIError(f'{r.status_code} {chunk}')
if chunk.strip() == '':
continue
if chunk.startswith('data:'):
try:
data = json.loads(chunk[5:].strip())
except json.JSONDecodeError:
continue
yield data
# 收到 error 事件后主动结束流,避免上层未 raise 时持续等待
if data.get('response_type') == 'error':
return

View File

@@ -0,0 +1,6 @@
class WeKnoraAPIError(Exception):
"""WeKnora API 请求失败"""
def __init__(self, message: str = ''):
self.message = message
super().__init__(self.message)

View File

@@ -46,6 +46,30 @@ class MonitoringRouterGroup(group.RouterGroup):
return self.success(data=metrics) return self.success(data=metrics)
@self.route('/token-statistics', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def get_token_statistics() -> str:
"""Get detailed token usage statistics (summary, per-model, timeseries)."""
bot_ids = quart.request.args.getlist('botId')
pipeline_ids = quart.request.args.getlist('pipelineId')
start_time_str = quart.request.args.get('startTime')
end_time_str = quart.request.args.get('endTime')
bucket = quart.request.args.get('bucket', 'hour')
if bucket not in ('hour', 'day'):
bucket = 'hour'
start_time = parse_iso_datetime(start_time_str)
end_time = parse_iso_datetime(end_time_str)
stats = await self.ap.monitoring_service.get_token_statistics(
bot_ids=bot_ids if bot_ids else None,
pipeline_ids=pipeline_ids if pipeline_ids else None,
start_time=start_time,
end_time=end_time,
bucket=bucket,
)
return self.success(data=stats)
@self.route('/messages', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) @self.route('/messages', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def get_messages() -> str: async def get_messages() -> str:
"""Get message logs""" """Get message logs"""

View File

@@ -43,8 +43,12 @@ class WebSocketChatRouterGroup(group.RouterGroup):
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'WebSocket adapter not found'})) await quart.websocket.send(json.dumps({'type': 'error', 'message': 'WebSocket adapter not found'}))
return return
# Find the owning bot for this pipeline (e.g. a web_page_bot) # Dashboard pipeline-debug sessions must always run under the
owner_bot = self._find_owner_bot(pipeline_uuid) # built-in websocket_proxy_bot identity. We deliberately do NOT
# resolve a web_page_bot owner here — even if one is bound to
# the same pipeline, debug requests must not be attributed to
# it. The embed widget path (`/api/v1/embed/<bot>/ws/connect`)
# is the one that carries the page-bot identity.
# 注册连接 # 注册连接
connection = await ws_connection_manager.add_connection( connection = await ws_connection_manager.add_connection(
@@ -73,7 +77,7 @@ class WebSocketChatRouterGroup(group.RouterGroup):
) )
# 创建接收和发送任务 # 创建接收和发送任务
receive_task = asyncio.create_task(self._handle_receive(connection, websocket_adapter, owner_bot)) receive_task = asyncio.create_task(self._handle_receive(connection, websocket_adapter))
send_task = asyncio.create_task(self._handle_send(connection)) send_task = asyncio.create_task(self._handle_send(connection))
# 等待任务完成 # 等待任务完成
@@ -181,14 +185,7 @@ class WebSocketChatRouterGroup(group.RouterGroup):
except Exception as e: except Exception as e:
return self.http_status(500, -1, f'Internal server error: {str(e)}') return self.http_status(500, -1, f'Internal server error: {str(e)}')
def _find_owner_bot(self, pipeline_uuid: str): async def _handle_receive(self, connection, websocket_adapter):
"""Find a user-created bot (e.g. web_page_bot) that owns this pipeline."""
for bot in self.ap.platform_mgr.bots:
if bot.bot_entity.adapter == 'web_page_bot' and bot.bot_entity.use_pipeline_uuid == pipeline_uuid:
return bot
return None
async def _handle_receive(self, connection, websocket_adapter, owner_bot=None):
"""处理接收消息的任务""" """处理接收消息的任务"""
try: try:
while connection.is_active: while connection.is_active:
@@ -213,7 +210,10 @@ class WebSocketChatRouterGroup(group.RouterGroup):
logger.debug(f'收到消息: {data} from {connection.connection_id}') logger.debug(f'收到消息: {data} from {connection.connection_id}')
# 处理消息不等待响应响应会通过broadcast异步发送 # 处理消息不等待响应响应会通过broadcast异步发送
await websocket_adapter.handle_websocket_message(connection, data, owner_bot=owner_bot) # owner_bot is intentionally NOT passed: the dashboard
# debug WebSocket must always run under the proxy bot,
# never under a coincidentally-bound web_page_bot.
await websocket_adapter.handle_websocket_message(connection, data)
elif message_type == 'disconnect': elif message_type == 'disconnect':
# 客户端主动断开 # 客户端主动断开

View File

@@ -179,8 +179,6 @@ class AdaptersRouterGroup(group.RouterGroup):
"""Start WeChat QR code login. Returns session_id + QR code data URL.""" """Start WeChat QR code login. Returns session_id + QR code data URL."""
import uuid import uuid
import time import time
import io
import base64
from langbot.libs.openclaw_weixin_api.client import OpenClawWeixinClient, DEFAULT_BASE_URL from langbot.libs.openclaw_weixin_api.client import OpenClawWeixinClient, DEFAULT_BASE_URL
@@ -208,60 +206,32 @@ class AdaptersRouterGroup(group.RouterGroup):
async def run_login(): async def run_login():
try: try:
import qrcode as qr_lib
for _attempt in range(3): def on_qrcode(qr_data_url: str, _qr_url: str):
qr_resp = await client.fetch_qrcode() def _update():
if not qr_resp.qrcode or not qr_resp.qrcode_img_content: session['qr_data_url'] = qr_data_url
raise Exception('Failed to get QR code from server') session['expire_at'] = time.time() + 180
# Generate QR code image locally
qr = qr_lib.QRCode(error_correction=qr_lib.constants.ERROR_CORRECT_L)
qr.add_data(qr_resp.qrcode_img_content)
qr.make(fit=True)
img = qr.make_image(fill_color='black', back_color='white')
buf = io.BytesIO()
img.save(buf, format='PNG')
b64 = base64.b64encode(buf.getvalue()).decode('utf-8')
data_url = f'data:image/png;base64,{b64}'
def _update_qr():
session['qr_data_url'] = data_url
session['expire_at'] = time.time() + 480 # 8 minutes
session['status'] = 'waiting' session['status'] = 'waiting'
loop.call_soon_threadsafe(_update_qr) loop.call_soon_threadsafe(_update)
# Poll for scan status result = await client.login(
deadline = loop.time() + 180 max_retries=1,
while loop.time() < deadline: poll_timeout_ms=180_000,
try: on_qrcode=on_qrcode,
status_resp = await client.poll_qrcode_status(qr_resp.qrcode) )
except Exception:
await asyncio.sleep(2)
continue
if status_resp.status == 'confirmed' and status_resp.bot_token:
session['status'] = 'success' session['status'] = 'success'
session['token'] = status_resp.bot_token session['token'] = result.token
session['base_url'] = status_resp.baseurl or client.base_url session['base_url'] = result.base_url
session['account_id'] = status_resp.ilink_bot_id or '' session['account_id'] = result.account_id
return
if status_resp.status == 'expired':
break # retry with new QR code
await asyncio.sleep(1)
else:
pass # timeout, retry
# All retries exhausted
session['status'] = 'error'
session['error'] = 'QR code login failed: max retries exceeded'
except Exception as e: except Exception as e:
error_message = str(e)
if 'expired' in error_message.lower() or 'max retries exceeded' in error_message.lower():
session['status'] = 'expired'
session['error'] = 'QR code expired'
else:
session['status'] = 'error' session['status'] = 'error'
session['error'] = str(e) session['error'] = error_message
finally: finally:
await client.close() await client.close()
@@ -295,7 +265,11 @@ class AdaptersRouterGroup(group.RouterGroup):
if not session: if not session:
return self.http_status(404, -1, 'Session not found') return self.http_status(404, -1, 'Session not found')
data = {'status': session['status']} data = {
'status': session['status'],
'qr_data_url': session['qr_data_url'],
'expire_at': session['expire_at'],
}
if session['status'] == 'success': if session['status'] == 'success':
data['token'] = session['token'] data['token'] = session['token']
@@ -305,6 +279,9 @@ class AdaptersRouterGroup(group.RouterGroup):
elif session['status'] == 'error': elif session['status'] == 'error':
data['error'] = session['error'] data['error'] = session['error']
_weixin_login_sessions.pop(session_id, None) _weixin_login_sessions.pop(session_id, None)
elif session['status'] == 'expired':
data['error'] = session['error']
_weixin_login_sessions.pop(session_id, None)
return self.success(data=data) return self.success(data=data)

View File

@@ -271,6 +271,20 @@ class PluginsRouterGroup(group.RouterGroup):
readme = await self.ap.plugin_connector.get_plugin_readme(author, plugin_name, language=language) readme = await self.ap.plugin_connector.get_plugin_readme(author, plugin_name, language=language)
return self.success(data={'readme': readme}) return self.success(data={'readme': readme})
@self.route(
'/<author>/<plugin_name>/logs',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
)
async def _(author: str, plugin_name: str) -> quart.Response:
try:
limit = int(quart.request.args.get('limit', 200))
except (TypeError, ValueError):
limit = 200
level = quart.request.args.get('level') or None
logs = await self.ap.plugin_connector.get_plugin_logs(author, plugin_name, limit=limit, level=level)
return self.success(data={'logs': logs})
@self.route( @self.route(
'/<author>/<plugin_name>/icon', '/<author>/<plugin_name>/icon',
methods=['GET'], methods=['GET'],

View File

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

View File

@@ -152,7 +152,24 @@ class MCPService:
coroutine = runtime_mcp_session.refresh() coroutine = runtime_mcp_session.refresh()
else: else:
runtime_mcp_session = await self.ap.tool_mgr.mcp_tool_loader.load_mcp_server(server_config=server_data) runtime_mcp_session = await self.ap.tool_mgr.mcp_tool_loader.load_mcp_server(server_config=server_data)
coroutine = runtime_mcp_session.start()
# A transient test owns an isolated Box session. Always tear it down
# after the test completes (success or failure) so it does not leak.
test_session = runtime_mcp_session
async def _run_and_cleanup() -> None:
try:
await test_session.start()
finally:
try:
await test_session.shutdown()
except Exception as exc:
self.ap.logger.warning(
f'Failed to tear down transient MCP test session '
f'{test_session.server_name}: {type(exc).__name__}: {exc}'
)
coroutine = _run_and_cleanup()
ctx = taskmgr.TaskContext.new() ctx = taskmgr.TaskContext.new()
wrapper = self.ap.task_mgr.create_user_task( wrapper = self.ap.task_mgr.create_user_task(

View File

@@ -34,6 +34,46 @@ def _runtime_model_data(model_uuid: str, model_data: dict) -> dict:
return {**model_data, 'uuid': model_uuid} return {**model_data, 'uuid': model_uuid}
async def _validate_provider_supports(ap: app.Application, provider_uuid: str, model_type: str) -> None:
"""Validate that the provider's requester declares support for ``model_type``.
``model_type`` is one of the manifest ``support_type`` values:
'llm', 'text-embedding', 'rerank'. Raises ValueError when the requester
manifest does not list the requested type. This is a server-side guard so
a model cannot be attached to a provider that does not support it, even if
the frontend tab restriction is bypassed.
"""
model_mgr = getattr(ap, 'model_mgr', None)
if model_mgr is None:
return
provider_dict = getattr(model_mgr, 'provider_dict', None)
if not provider_dict:
return
runtime_provider = provider_dict.get(provider_uuid)
if runtime_provider is None:
return
requester_name = getattr(getattr(runtime_provider, 'provider_entity', None), 'requester', None)
if not requester_name:
return
get_manifest = getattr(model_mgr, 'get_available_requester_manifest_by_name', None)
if not callable(get_manifest):
return
manifest = get_manifest(requester_name)
if manifest is None:
return
spec = getattr(manifest, 'spec', None) or {}
support_type = spec.get('support_type') if isinstance(spec, dict) else None
# When a manifest omits support_type, do not block (backward compatible).
if not support_type:
return
if model_type not in support_type:
raise ValueError(f'Provider requester "{requester_name}" does not support {model_type} models')
class LLMModelsService: class LLMModelsService:
ap: app.Application ap: app.Application
@@ -96,6 +136,8 @@ class LLMModelsService:
) )
model_data['provider_uuid'] = provider_uuid model_data['provider_uuid'] = provider_uuid
await _validate_provider_supports(self.ap, model_data['provider_uuid'], 'llm')
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_model.LLMModel).values(**model_data)) await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_model.LLMModel).values(**model_data))
runtime_provider = self.ap.model_mgr.provider_dict.get(model_data['provider_uuid']) runtime_provider = self.ap.model_mgr.provider_dict.get(model_data['provider_uuid'])
@@ -274,6 +316,8 @@ class EmbeddingModelsService:
) )
model_data['provider_uuid'] = provider_uuid model_data['provider_uuid'] = provider_uuid
await _validate_provider_supports(self.ap, model_data['provider_uuid'], 'text-embedding')
await self.ap.persistence_mgr.execute_async( await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(persistence_model.EmbeddingModel).values(**model_data) sqlalchemy.insert(persistence_model.EmbeddingModel).values(**model_data)
) )
@@ -434,6 +478,8 @@ class RerankModelsService:
) )
model_data['provider_uuid'] = provider_uuid model_data['provider_uuid'] = provider_uuid
await _validate_provider_supports(self.ap, model_data['provider_uuid'], 'rerank')
await self.ap.persistence_mgr.execute_async( await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(persistence_model.RerankModel).values(**model_data) sqlalchemy.insert(persistence_model.RerankModel).values(**model_data)
) )

View File

@@ -472,6 +472,179 @@ class MonitoringService:
'active_sessions': active_sessions, 'active_sessions': active_sessions,
} }
async def get_token_statistics(
self,
bot_ids: list[str] | None = None,
pipeline_ids: list[str] | None = None,
start_time: datetime.datetime | None = None,
end_time: datetime.datetime | None = None,
bucket: str = 'hour',
) -> dict:
"""Get detailed token usage statistics for production observability.
Returns:
- summary: aggregate token counters and call/latency stats over the window
- by_model: per-model token + call breakdown (sorted by total tokens desc)
- timeseries: token usage bucketed by `bucket` ('hour' or 'day')
Only successful LLM calls are counted toward token totals; error calls are
reported separately so a spike in failures is visible without polluting
token accounting.
"""
LLMCall = persistence_monitoring.MonitoringLLMCall
conditions = []
if bot_ids:
conditions.append(LLMCall.bot_id.in_(bot_ids))
if pipeline_ids:
conditions.append(LLMCall.pipeline_id.in_(pipeline_ids))
if start_time:
conditions.append(LLMCall.timestamp >= start_time)
if end_time:
conditions.append(LLMCall.timestamp <= end_time)
def _apply(query):
if conditions:
query = query.where(sqlalchemy.and_(*conditions))
return query
# ---- Summary aggregates ----
summary_query = _apply(
sqlalchemy.select(
sqlalchemy.func.count(LLMCall.id),
sqlalchemy.func.coalesce(sqlalchemy.func.sum(LLMCall.input_tokens), 0),
sqlalchemy.func.coalesce(sqlalchemy.func.sum(LLMCall.output_tokens), 0),
sqlalchemy.func.coalesce(sqlalchemy.func.sum(LLMCall.total_tokens), 0),
sqlalchemy.func.coalesce(sqlalchemy.func.sum(LLMCall.duration), 0),
sqlalchemy.func.coalesce(sqlalchemy.func.sum(LLMCall.cost), 0.0),
sqlalchemy.func.sum(sqlalchemy.case((LLMCall.status == 'success', 1), else_=0)),
sqlalchemy.func.sum(sqlalchemy.case((LLMCall.status == 'error', 1), else_=0)),
# Count of successful calls that nonetheless recorded zero tokens —
# a data-quality signal that usage reporting may be broken upstream.
sqlalchemy.func.sum(
sqlalchemy.case(
(sqlalchemy.and_(LLMCall.status == 'success', LLMCall.total_tokens == 0), 1),
else_=0,
)
),
)
)
summary_result = await self.ap.persistence_mgr.execute_async(summary_query)
row = summary_result.first()
(
total_calls,
total_input_tokens,
total_output_tokens,
total_tokens,
total_duration,
total_cost,
success_calls,
error_calls,
zero_token_success_calls,
) = row if row else (0, 0, 0, 0, 0, 0.0, 0, 0, 0)
total_calls = total_calls or 0
success_calls = success_calls or 0
error_calls = error_calls or 0
zero_token_success_calls = zero_token_success_calls or 0
summary = {
'total_calls': total_calls,
'success_calls': success_calls,
'error_calls': error_calls,
'total_input_tokens': int(total_input_tokens or 0),
'total_output_tokens': int(total_output_tokens or 0),
'total_tokens': int(total_tokens or 0),
'total_cost': round(float(total_cost or 0.0), 6),
'avg_tokens_per_call': int((total_tokens or 0) / total_calls) if total_calls > 0 else 0,
'avg_duration_ms': int((total_duration or 0) / total_calls) if total_calls > 0 else 0,
'avg_tokens_per_second': round((total_output_tokens or 0) / (total_duration / 1000), 2)
if total_duration and total_duration > 0
else 0,
'zero_token_success_calls': zero_token_success_calls,
}
# ---- Per-model breakdown ----
by_model_query = _apply(
sqlalchemy.select(
LLMCall.model_name,
sqlalchemy.func.count(LLMCall.id),
sqlalchemy.func.coalesce(sqlalchemy.func.sum(LLMCall.input_tokens), 0),
sqlalchemy.func.coalesce(sqlalchemy.func.sum(LLMCall.output_tokens), 0),
sqlalchemy.func.coalesce(sqlalchemy.func.sum(LLMCall.total_tokens), 0),
sqlalchemy.func.coalesce(sqlalchemy.func.sum(LLMCall.duration), 0),
sqlalchemy.func.coalesce(sqlalchemy.func.sum(LLMCall.cost), 0.0),
sqlalchemy.func.sum(sqlalchemy.case((LLMCall.status == 'error', 1), else_=0)),
).group_by(LLMCall.model_name)
)
by_model_result = await self.ap.persistence_mgr.execute_async(by_model_query)
by_model = []
for mrow in by_model_result.all():
(
model_name,
m_calls,
m_in,
m_out,
m_total,
m_duration,
m_cost,
m_errors,
) = mrow
m_calls = m_calls or 0
by_model.append(
{
'model_name': model_name,
'calls': m_calls,
'error_calls': m_errors or 0,
'input_tokens': int(m_in or 0),
'output_tokens': int(m_out or 0),
'total_tokens': int(m_total or 0),
'cost': round(float(m_cost or 0.0), 6),
'avg_tokens_per_call': int((m_total or 0) / m_calls) if m_calls > 0 else 0,
'avg_duration_ms': int((m_duration or 0) / m_calls) if m_calls > 0 else 0,
}
)
by_model.sort(key=lambda x: x['total_tokens'], reverse=True)
# ---- Time-bucketed series ----
# Use a DB-agnostic bucketing approach: fetch (timestamp, tokens) rows and
# aggregate in Python. The window is bounded by the time filter, so this is
# cheap for typical dashboard ranges (hours/days).
series_query = _apply(
sqlalchemy.select(
LLMCall.timestamp,
LLMCall.input_tokens,
LLMCall.output_tokens,
LLMCall.total_tokens,
).order_by(LLMCall.timestamp.asc())
)
series_result = await self.ap.persistence_mgr.execute_async(series_query)
bucket_fmt = '%Y-%m-%d %H:00' if bucket == 'hour' else '%Y-%m-%d'
buckets: dict[str, dict] = {}
for srow in series_result.all():
ts, s_in, s_out, s_total = srow
if ts is None:
continue
key = ts.strftime(bucket_fmt)
b = buckets.setdefault(
key,
{'bucket': key, 'input_tokens': 0, 'output_tokens': 0, 'total_tokens': 0, 'calls': 0},
)
b['input_tokens'] += int(s_in or 0)
b['output_tokens'] += int(s_out or 0)
b['total_tokens'] += int(s_total or 0)
b['calls'] += 1
timeseries = [buckets[k] for k in sorted(buckets.keys())]
return {
'summary': summary,
'by_model': by_model,
'timeseries': timeseries,
'bucket': bucket,
}
async def get_messages( async def get_messages(
self, self,
bot_ids: list[str] | None = None, bot_ids: list[str] | None = None,

View File

@@ -82,7 +82,7 @@ class UserService:
payload = { payload = {
'user': user_email, 'user': user_email,
'iss': 'LangBot-' + constants.edition, 'iss': 'LangBot-' + constants.edition,
'exp': datetime.datetime.now() + datetime.timedelta(seconds=jwt_expire), 'exp': datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(seconds=jwt_expire),
} }
return jwt.encode(payload, jwt_secret, algorithm='HS256') return jwt.encode(payload, jwt_secret, algorithm='HS256')

View File

@@ -120,13 +120,19 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
self._relay_port = parsed.port or _DEFAULT_PORT self._relay_port = parsed.port or _DEFAULT_PORT
self._filtered_box_config = _filter_config_for_runtime(_get_box_config(ap)) self._filtered_box_config = _filter_config_for_runtime(_get_box_config(ap))
def _uses_websocket(self) -> bool: def uses_websocket(self) -> bool:
"""Whether the connector should use WebSocket to reach the Box runtime. """Whether the connector should use WebSocket to reach the Box runtime.
True when: True when:
- Running inside Docker (Box runtime is a separate container) - Running inside Docker (Box runtime is a separate container)
- The ``--standalone-box`` CLI flag was passed - The ``--standalone-box`` CLI flag was passed
- An explicit ``runtime.endpoint`` was configured - An explicit ``runtime.endpoint`` was configured
When this is True the Box runtime lives in a separate process with its
own filesystem view (container, pod sidecar, or remote host), so paths
it reports (e.g. skill ``package_root``) are NOT resolvable on the
LangBot side. When False, Box runs as a stdio child process that shares
LangBot's filesystem.
""" """
return bool( return bool(
self.configured_runtime_endpoint self.configured_runtime_endpoint
@@ -134,6 +140,10 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
or platform.use_websocket_to_connect_box_runtime() or platform.use_websocket_to_connect_box_runtime()
) )
# Backwards-compatible private alias.
def _uses_websocket(self) -> bool:
return self.uses_websocket()
async def initialize(self) -> None: async def initialize(self) -> None:
if self._uses_websocket(): if self._uses_websocket():
if platform.get_platform() == 'win32' and not self.configured_runtime_endpoint: if platform.get_platform() == 'win32' and not self.configured_runtime_endpoint:

View File

@@ -12,6 +12,7 @@ import pydantic
from langbot_plugin.box.client import BoxRuntimeClient from langbot_plugin.box.client import BoxRuntimeClient
from .connector import BoxRuntimeConnector, _get_box_config from .connector import BoxRuntimeConnector, _get_box_config
from ..telemetry import features as telemetry_features
from langbot_plugin.box.errors import BoxError, BoxValidationError from langbot_plugin.box.errors import BoxError, BoxValidationError
from langbot_plugin.box.models import ( from langbot_plugin.box.models import (
BUILTIN_PROFILES, BUILTIN_PROFILES,
@@ -67,6 +68,10 @@ class BoxService:
self._available = False self._available = False
self._connector_error: str = '' self._connector_error: str = ''
self._reconnecting = False self._reconnecting = False
# Optional explicit override for shares_filesystem_with_box. None means
# "derive from the connector transport". Set by tests / embedders that
# know the real LangBot<->Box filesystem topology.
self._shares_filesystem_with_box_override: bool | None = None
@property @property
def enabled(self) -> bool: def enabled(self) -> bool:
@@ -148,6 +153,32 @@ class BoxService:
def available(self) -> bool: def available(self) -> bool:
return self._available return self._available
@property
def shares_filesystem_with_box(self) -> bool:
"""Whether LangBot and the Box runtime share a filesystem view.
This is True only when Box runs as a local stdio child process of
LangBot (same container/host). In that case paths the Box runtime
reports — notably skill ``package_root`` — resolve identically on the
LangBot side, so LangBot may validate them against its own filesystem.
It is False for every separated deployment (Docker Compose, k8s
sidecar, ``--standalone-box``, or an explicit ``runtime.endpoint``),
where the Box runtime owns its own filesystem and LangBot must trust
the paths it reports rather than checking them locally.
When Box is wired up with an injected client (tests, custom embeds)
there is no connector to introspect; we conservatively report False so
LangBot never wrongly drops Box-reported skills. An explicit override
can be set via ``_shares_filesystem_with_box`` (used by tests and any
embedder that knows the real topology).
"""
if self._shares_filesystem_with_box_override is not None:
return self._shares_filesystem_with_box_override
if self._runtime_connector is None:
return False
return not self._runtime_connector.uses_websocket()
async def execute_spec_payload( async def execute_spec_payload(
self, self,
spec_payload: dict, spec_payload: dict,
@@ -168,7 +199,7 @@ class BoxService:
f'spec={json.dumps(self._summarize_spec(spec), ensure_ascii=False)}' f'spec={json.dumps(self._summarize_spec(spec), ensure_ascii=False)}'
) )
try: try:
self._enforce_workspace_quota(spec, phase='before execution') await self._enforce_workspace_quota(spec, phase='before execution')
except BoxError as exc: except BoxError as exc:
self._record_error(exc, query) self._record_error(exc, query)
raise raise
@@ -178,7 +209,7 @@ class BoxService:
self._record_error(exc, query) self._record_error(exc, query)
raise raise
try: try:
self._enforce_workspace_quota(spec, phase='after execution') await self._enforce_workspace_quota(spec, phase='after execution')
except BoxError as exc: except BoxError as exc:
await self._cleanup_exceeded_session(spec) await self._cleanup_exceeded_session(spec)
self._record_error(exc, query) self._record_error(exc, query)
@@ -188,10 +219,23 @@ class BoxService:
f'query_id={query.query_id} ' f'query_id={query.query_id} '
f'summary={json.dumps(self._summarize_result(result), ensure_ascii=False)}' f'summary={json.dumps(self._summarize_result(result), ensure_ascii=False)}'
) )
telemetry_features.increment(query, 'sandbox', 'execs')
return self._serialize_result(result) return self._serialize_result(result)
def resolve_box_session_id(self, query: pipeline_query.Query) -> str: def resolve_box_session_id(self, query: pipeline_query.Query) -> str:
"""Resolve the Box session_id from the pipeline's template and query variables.""" """Resolve the Box session_id from the pipeline's template and query variables.
When ``system.limitation.force_box_session_id_template`` is set to a
non-empty value, that template overrides whatever the pipeline
configured. This is the authoritative SaaS guard: it runs on every
``exec`` call, so a tenant cannot escape a single shared sandbox even
by editing the pipeline config directly through the API (which only
gates the web UI).
"""
forced_template = self._forced_box_session_id_template()
if forced_template:
template = forced_template
else:
template = ( template = (
(query.pipeline_config or {}) (query.pipeline_config or {})
.get('ai', {}) .get('ai', {})
@@ -220,14 +264,24 @@ class BoxService:
all skill packages mounted, regardless of which skill is currently all skill packages mounted, regardless of which skill is currently
activated. activated.
Skills whose ``package_root`` is missing or no longer a directory on Path validation is filesystem-topology dependent. When LangBot and the
the LangBot-visible filesystem are skipped with a warning instead of Box runtime share a filesystem (local stdio mode), a skill whose
being passed through to the backend. Without this guard the three ``package_root`` is missing or no longer a directory is skipped with a
backends behave inconsistently on a stale mount: nsjail refuses to warning instead of being passed through to the backend. Without that
start the sandbox (failing every exec in the session), Docker guard the three backends behave inconsistently on a stale mount: nsjail
silently auto-creates a root-owned empty directory on the host, and refuses to start the sandbox (failing every exec in the session),
E2B silently skips the upload — none of which surfaces an Docker silently auto-creates a root-owned empty directory on the host,
actionable error to the agent or operator. and E2B silently skips the upload — none of which surfaces an
actionable error.
When Box runs as a separate process (Docker Compose, k8s sidecar,
``--standalone-box``, or a remote ``runtime.endpoint``), the
``package_root`` reported by ``list_skills`` is the Box runtime's own
filesystem path and is NOT resolvable on the LangBot side. Validating
it locally would wrongly drop every skill, so LangBot trusts the path
and lets the Box runtime resolve it. The Box runtime only ever reports
skills it discovered on its own filesystem, so the path is valid there
by construction.
""" """
skill_mgr = getattr(self.ap, 'skill_mgr', None) skill_mgr = getattr(self.ap, 'skill_mgr', None)
if skill_mgr is None: if skill_mgr is None:
@@ -235,13 +289,15 @@ class BoxService:
from ..provider.tools.loaders import skill as skill_loader from ..provider.tools.loaders import skill as skill_loader
validate_locally = self.shares_filesystem_with_box
visible_skills = skill_loader.get_visible_skills(self.ap, query) visible_skills = skill_loader.get_visible_skills(self.ap, query)
mounts: list[dict] = [] mounts: list[dict] = []
for skill_name, skill_data in visible_skills.items(): for skill_name, skill_data in visible_skills.items():
package_root = str(skill_data.get('package_root', '') or '').strip() package_root = str(skill_data.get('package_root', '') or '').strip()
if not package_root: if not package_root:
continue continue
if not os.path.isdir(package_root): if validate_locally and not os.path.isdir(package_root):
self.ap.logger.warning( self.ap.logger.warning(
f'Skill "{skill_name}" package_root missing on filesystem ' f'Skill "{skill_name}" package_root missing on filesystem '
f'({package_root}); skipping mount to prevent sandbox failures. ' f'({package_root}); skipping mount to prevent sandbox failures. '
@@ -564,6 +620,20 @@ class BoxService:
raw = str(self._local_config().get('image', '') or '').strip() raw = str(self._local_config().get('image', '') or '').strip()
return raw or None return raw or None
def _forced_box_session_id_template(self) -> str:
"""Return the SaaS-forced sandbox-scope template, or '' when unset.
Read from ``system.limitation.force_box_session_id_template``. A
non-empty value pins every pipeline to a single sandbox scope
(e.g. ``'{global}'``) and cannot be overridden per-pipeline.
"""
limitation = (
(self.ap.instance_config.data or {}).get('system', {}).get('limitation', {})
if getattr(self.ap, 'instance_config', None) is not None
else {}
)
return str(limitation.get('force_box_session_id_template', '') or '').strip()
def _load_workspace_quota_mb(self) -> int | None: def _load_workspace_quota_mb(self) -> int | None:
raw_value = self._local_config().get('workspace_quota_mb') raw_value = self._local_config().get('workspace_quota_mb')
if raw_value in (None, ''): if raw_value in (None, ''):
@@ -683,7 +753,7 @@ class BoxService:
_walk(root) _walk(root)
return total return total
def _enforce_workspace_quota(self, spec: BoxSpec, *, phase: str) -> None: async def _enforce_workspace_quota(self, spec: BoxSpec, *, phase: str) -> None:
if spec.host_path is None or spec.workspace_quota_mb <= 0: if spec.host_path is None or spec.workspace_quota_mb <= 0:
return return
@@ -691,7 +761,10 @@ class BoxService:
if not os.path.isdir(host_path): if not os.path.isdir(host_path):
return return
used_bytes = self._get_workspace_size_bytes(host_path) # Walk the workspace off the event loop — this runs on every
# quota-enforced exec, and a large tree would otherwise block the whole
# asyncio runtime (all bots/pipelines) for the duration of the scan.
used_bytes = await asyncio.to_thread(self._get_workspace_size_bytes, host_path)
limit_bytes = spec.workspace_quota_mb * _MIB limit_bytes = spec.workspace_quota_mb * _MIB
if used_bytes <= limit_bytes: if used_bytes <= limit_bytes:
return return
@@ -714,6 +787,7 @@ class BoxService:
# ── Observability ───────────────────────────────────────────────── # ── Observability ─────────────────────────────────────────────────
def _record_error(self, exc: Exception, query: pipeline_query.Query): def _record_error(self, exc: Exception, query: pipeline_query.Query):
telemetry_features.increment(query, 'sandbox', 'errors')
self._recent_errors.append( self._recent_errors.append(
{ {
'timestamp': _dt.datetime.now(_UTC).isoformat(), 'timestamp': _dt.datetime.now(_UTC).isoformat(),

View File

@@ -146,13 +146,19 @@ def wrap_python_command_with_env(command: str, *, mount_path: str = '/workspace'
_LB_PIP_CACHE_DIR="{mount_path}/.cache/pip" _LB_PIP_CACHE_DIR="{mount_path}/.cache/pip"
mkdir -p "$_LB_META_DIR" "$_LB_TMP_DIR" "$_LB_PIP_CACHE_DIR" mkdir -p "$_LB_META_DIR" "$_LB_TMP_DIR" "$_LB_PIP_CACHE_DIR"
_LB_SYSTEM_PYTHON="$(command -v python3 || command -v python || true)"
if [ -z "$_LB_SYSTEM_PYTHON" ]; then
echo "python3 or python is required to prepare the workspace Python environment" >&2
exit 127
fi
export TMPDIR="$_LB_TMP_DIR" export TMPDIR="$_LB_TMP_DIR"
export TEMP="$_LB_TMP_DIR" export TEMP="$_LB_TMP_DIR"
export TMP="$_LB_TMP_DIR" export TMP="$_LB_TMP_DIR"
export PIP_CACHE_DIR="$_LB_PIP_CACHE_DIR" export PIP_CACHE_DIR="$_LB_PIP_CACHE_DIR"
_lb_python_meta() {{ _lb_python_meta() {{
python - <<'PY' "$_LB_SYSTEM_PYTHON" - <<'PY'
import hashlib import hashlib
import json import json
import os import os
@@ -201,15 +207,26 @@ def wrap_python_command_with_env(command: str, *, mount_path: str = '/workspace'
_LB_LOCK_WAIT=0 _LB_LOCK_WAIT=0
while ! mkdir "$_LB_LOCK_DIR" 2>/dev/null; do while ! mkdir "$_LB_LOCK_DIR" 2>/dev/null; do
if [ "$_LB_LOCK_WAIT" -ge 120 ]; then if [ "$_LB_LOCK_WAIT" -ge 120 ]; then
_LB_LOCK_OWNER="$(cat "$_LB_LOCK_DIR/pid" 2>/dev/null || true)"
if [ -n "$_LB_LOCK_OWNER" ] && kill -0 "$_LB_LOCK_OWNER" 2>/dev/null; then
echo "Timed out waiting for active Python environment lock: $_LB_LOCK_DIR" >&2
exit 1
fi
echo "Timed out waiting for Python environment lock, clearing stale lock: $_LB_LOCK_DIR" >&2
rm -rf "$_LB_LOCK_DIR" 2>/dev/null || true
if mkdir "$_LB_LOCK_DIR" 2>/dev/null; then
break
fi
echo "Timed out waiting for Python environment lock: $_LB_LOCK_DIR" >&2 echo "Timed out waiting for Python environment lock: $_LB_LOCK_DIR" >&2
exit 1 exit 1
fi fi
sleep 1 sleep 1
_LB_LOCK_WAIT=$((_LB_LOCK_WAIT + 1)) _LB_LOCK_WAIT=$((_LB_LOCK_WAIT + 1))
done done
printf '%s\\n' "$$" > "$_LB_LOCK_DIR/pid" 2>/dev/null || true
_lb_cleanup_lock() {{ _lb_cleanup_lock() {{
rmdir "$_LB_LOCK_DIR" >/dev/null 2>&1 || true rm -rf "$_LB_LOCK_DIR" >/dev/null 2>&1 || true
}} }}
trap _lb_cleanup_lock EXIT INT TERM trap _lb_cleanup_lock EXIT INT TERM
@@ -225,7 +242,7 @@ def wrap_python_command_with_env(command: str, *, mount_path: str = '/workspace'
if [ "$_LB_NEEDS_BOOTSTRAP" -eq 1 ]; then if [ "$_LB_NEEDS_BOOTSTRAP" -eq 1 ]; then
rm -rf "$_LB_VENV_DIR" rm -rf "$_LB_VENV_DIR"
python -m venv "$_LB_VENV_DIR" "$_LB_SYSTEM_PYTHON" -m venv "$_LB_VENV_DIR"
. "$_LB_VENV_DIR/bin/activate" . "$_LB_VENV_DIR/bin/activate"
python -m pip install --upgrade pip setuptools wheel python -m pip install --upgrade pip setuptools wheel
if [ -f "{mount_path}/requirements.txt" ]; then if [ -f "{mount_path}/requirements.txt" ]; then

View File

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

View File

@@ -16,7 +16,6 @@ importutil.import_modules_in_pkg(stages)
stage_order = [ stage_order = [
'LoadConfigStage', 'LoadConfigStage',
'MigrationStage',
'GenKeysStage', 'GenKeysStage',
'SetupLoggerStage', 'SetupLoggerStage',
'BuildAppStage', 'BuildAppStage',

View File

@@ -42,6 +42,7 @@ required_deps = {
'telegramify_markdown': 'telegramify-markdown', 'telegramify_markdown': 'telegramify-markdown',
'slack_sdk': 'slack_sdk', 'slack_sdk': 'slack_sdk',
'asyncpg': 'asyncpg', 'asyncpg': 'asyncpg',
'litellm': 'litellm',
} }

View File

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

View File

@@ -1,45 +0,0 @@
from __future__ import annotations
import abc
import typing
from . import app
preregistered_migrations: list[typing.Type[Migration]] = []
"""Currently not supported for extension"""
def migration_class(name: str, number: int):
"""Register a migration"""
def decorator(cls: typing.Type[Migration]) -> typing.Type[Migration]:
cls.name = name
cls.number = number
preregistered_migrations.append(cls)
return cls
return decorator
class Migration(abc.ABC):
"""A version migration"""
name: str
number: int
ap: app.Application
def __init__(self, ap: app.Application):
self.ap = ap
@abc.abstractmethod
async def need_migrate(self) -> bool:
"""Determine if the current environment needs to run this migration"""
pass
@abc.abstractmethod
async def run(self):
"""Run migration"""
pass

View File

@@ -1,24 +0,0 @@
from __future__ import annotations
import os
from .. import migration
@migration.migration_class('sensitive-word-migration', 1)
class SensitiveWordMigration(migration.Migration):
"""敏感词迁移"""
async def need_migrate(self) -> bool:
"""判断当前环境是否需要运行此迁移"""
return os.path.exists('data/config/sensitive-words.json') and not os.path.exists(
'data/metadata/sensitive-words.json'
)
async def run(self):
"""执行迁移"""
# 移动文件
os.rename('data/config/sensitive-words.json', 'data/metadata/sensitive-words.json')
# 重新加载配置
await self.ap.sensitive_meta.load_config()

View File

@@ -1,44 +0,0 @@
from __future__ import annotations
from .. import migration
@migration.migration_class('openai-config-migration', 2)
class OpenAIConfigMigration(migration.Migration):
"""OpenAI配置迁移"""
async def need_migrate(self) -> bool:
"""判断当前环境是否需要运行此迁移"""
return 'openai-config' in self.ap.provider_cfg.data
async def run(self):
"""执行迁移"""
old_openai_config = self.ap.provider_cfg.data['openai-config'].copy()
if 'keys' not in self.ap.provider_cfg.data:
self.ap.provider_cfg.data['keys'] = {}
if 'openai' not in self.ap.provider_cfg.data['keys']:
self.ap.provider_cfg.data['keys']['openai'] = []
self.ap.provider_cfg.data['keys']['openai'] = old_openai_config['api-keys']
self.ap.provider_cfg.data['model'] = old_openai_config['chat-completions-params']['model']
del old_openai_config['chat-completions-params']['model']
if 'requester' not in self.ap.provider_cfg.data:
self.ap.provider_cfg.data['requester'] = {}
if 'openai-chat-completions' not in self.ap.provider_cfg.data['requester']:
self.ap.provider_cfg.data['requester']['openai-chat-completions'] = {}
self.ap.provider_cfg.data['requester']['openai-chat-completions'] = {
'base-url': old_openai_config['base_url'],
'args': old_openai_config['chat-completions-params'],
'timeout': old_openai_config['request-timeout'],
}
del self.ap.provider_cfg.data['openai-config']
await self.ap.provider_cfg.dump_config()

View File

@@ -1,29 +0,0 @@
from __future__ import annotations
from .. import migration
@migration.migration_class('anthropic-requester-config-completion', 3)
class AnthropicRequesterConfigCompletionMigration(migration.Migration):
"""OpenAI配置迁移"""
async def need_migrate(self) -> bool:
"""判断当前环境是否需要运行此迁移"""
return (
'anthropic-messages' not in self.ap.provider_cfg.data['requester']
or 'anthropic' not in self.ap.provider_cfg.data['keys']
)
async def run(self):
"""执行迁移"""
if 'anthropic-messages' not in self.ap.provider_cfg.data['requester']:
self.ap.provider_cfg.data['requester']['anthropic-messages'] = {
'base-url': 'https://api.anthropic.com',
'args': {'max_tokens': 1024},
'timeout': 120,
}
if 'anthropic' not in self.ap.provider_cfg.data['keys']:
self.ap.provider_cfg.data['keys']['anthropic'] = []
await self.ap.provider_cfg.dump_config()

View File

@@ -1,29 +0,0 @@
from __future__ import annotations
from .. import migration
@migration.migration_class('moonshot-config-completion', 4)
class MoonshotConfigCompletionMigration(migration.Migration):
"""OpenAI配置迁移"""
async def need_migrate(self) -> bool:
"""判断当前环境是否需要运行此迁移"""
return (
'moonshot-chat-completions' not in self.ap.provider_cfg.data['requester']
or 'moonshot' not in self.ap.provider_cfg.data['keys']
)
async def run(self):
"""执行迁移"""
if 'moonshot-chat-completions' not in self.ap.provider_cfg.data['requester']:
self.ap.provider_cfg.data['requester']['moonshot-chat-completions'] = {
'base-url': 'https://api.moonshot.cn/v1',
'args': {},
'timeout': 120,
}
if 'moonshot' not in self.ap.provider_cfg.data['keys']:
self.ap.provider_cfg.data['keys']['moonshot'] = []
await self.ap.provider_cfg.dump_config()

View File

@@ -1,29 +0,0 @@
from __future__ import annotations
from .. import migration
@migration.migration_class('deepseek-config-completion', 5)
class DeepseekConfigCompletionMigration(migration.Migration):
"""OpenAI配置迁移"""
async def need_migrate(self) -> bool:
"""判断当前环境是否需要运行此迁移"""
return (
'deepseek-chat-completions' not in self.ap.provider_cfg.data['requester']
or 'deepseek' not in self.ap.provider_cfg.data['keys']
)
async def run(self):
"""执行迁移"""
if 'deepseek-chat-completions' not in self.ap.provider_cfg.data['requester']:
self.ap.provider_cfg.data['requester']['deepseek-chat-completions'] = {
'base-url': 'https://api.deepseek.com',
'args': {},
'timeout': 120,
}
if 'deepseek' not in self.ap.provider_cfg.data['keys']:
self.ap.provider_cfg.data['keys']['deepseek'] = []
await self.ap.provider_cfg.dump_config()

View File

@@ -1,19 +0,0 @@
from __future__ import annotations
from .. import migration
@migration.migration_class('vision-config', 6)
class VisionConfigMigration(migration.Migration):
"""迁移"""
async def need_migrate(self) -> bool:
"""判断当前环境是否需要运行此迁移"""
return 'enable-vision' not in self.ap.provider_cfg.data
async def run(self):
"""执行迁移"""
if 'enable-vision' not in self.ap.provider_cfg.data:
self.ap.provider_cfg.data['enable-vision'] = False
await self.ap.provider_cfg.dump_config()

View File

@@ -1,20 +0,0 @@
from __future__ import annotations
from .. import migration
@migration.migration_class('qcg-center-url-config', 7)
class QCGCenterURLConfigMigration(migration.Migration):
"""迁移"""
async def need_migrate(self) -> bool:
"""判断当前环境是否需要运行此迁移"""
return 'qcg-center-url' not in self.ap.system_cfg.data
async def run(self):
"""执行迁移"""
if 'qcg-center-url' not in self.ap.system_cfg.data:
self.ap.system_cfg.data['qcg-center-url'] = 'https://api.qchatgpt.rockchin.top/api/v2'
await self.ap.system_cfg.dump_config()

View File

@@ -1,25 +0,0 @@
from __future__ import annotations
from .. import migration
@migration.migration_class('ad-fixwin-cfg-migration', 8)
class AdFixwinConfigMigration(migration.Migration):
"""迁移"""
async def need_migrate(self) -> bool:
"""判断当前环境是否需要运行此迁移"""
return isinstance(self.ap.pipeline_cfg.data['rate-limit']['fixwin']['default'], int)
async def run(self):
"""执行迁移"""
for session_name in self.ap.pipeline_cfg.data['rate-limit']['fixwin']:
temp_dict = {
'window-size': 60,
'limit': self.ap.pipeline_cfg.data['rate-limit']['fixwin'][session_name],
}
self.ap.pipeline_cfg.data['rate-limit']['fixwin'][session_name] = temp_dict
await self.ap.pipeline_cfg.dump_config()

View File

@@ -1,22 +0,0 @@
from __future__ import annotations
from .. import migration
@migration.migration_class('msg-truncator-cfg-migration', 9)
class MsgTruncatorConfigMigration(migration.Migration):
"""迁移"""
async def need_migrate(self) -> bool:
"""判断当前环境是否需要运行此迁移"""
return 'msg-truncate' not in self.ap.pipeline_cfg.data
async def run(self):
"""执行迁移"""
self.ap.pipeline_cfg.data['msg-truncate'] = {
'method': 'round',
'round': {'max-round': 10},
}
await self.ap.pipeline_cfg.dump_config()

View File

@@ -1,23 +0,0 @@
from __future__ import annotations
from .. import migration
@migration.migration_class('ollama-requester-config', 10)
class MsgTruncatorConfigMigration(migration.Migration):
"""迁移"""
async def need_migrate(self) -> bool:
"""判断当前环境是否需要运行此迁移"""
return 'ollama-chat' not in self.ap.provider_cfg.data['requester']
async def run(self):
"""执行迁移"""
self.ap.provider_cfg.data['requester']['ollama-chat'] = {
'base-url': 'http://127.0.0.1:11434',
'args': {},
'timeout': 600,
}
await self.ap.provider_cfg.dump_config()

View File

@@ -1,19 +0,0 @@
from __future__ import annotations
from .. import migration
@migration.migration_class('command-prefix-config', 11)
class CommandPrefixConfigMigration(migration.Migration):
"""迁移"""
async def need_migrate(self) -> bool:
"""判断当前环境是否需要运行此迁移"""
return 'command-prefix' not in self.ap.command_cfg.data
async def run(self):
"""执行迁移"""
self.ap.command_cfg.data['command-prefix'] = ['!', '']
await self.ap.command_cfg.dump_config()

View File

@@ -1,19 +0,0 @@
from __future__ import annotations
from .. import migration
@migration.migration_class('runner-config', 12)
class RunnerConfigMigration(migration.Migration):
"""迁移"""
async def need_migrate(self) -> bool:
"""判断当前环境是否需要运行此迁移"""
return 'runner' not in self.ap.provider_cfg.data
async def run(self):
"""执行迁移"""
self.ap.provider_cfg.data['runner'] = 'local-agent'
await self.ap.provider_cfg.dump_config()

View File

@@ -1,29 +0,0 @@
from __future__ import annotations
from .. import migration
@migration.migration_class('http-api-config', 13)
class HttpApiConfigMigration(migration.Migration):
"""迁移"""
async def need_migrate(self) -> bool:
"""判断当前环境是否需要运行此迁移"""
return 'http-api' not in self.ap.system_cfg.data or 'persistence' not in self.ap.system_cfg.data
async def run(self):
"""执行迁移"""
self.ap.system_cfg.data['http-api'] = {
'enable': True,
'host': '0.0.0.0',
'port': 5300,
'jwt-expire': 604800,
}
self.ap.system_cfg.data['persistence'] = {
'sqlite': {'path': 'data/persistence.db'},
'use': 'sqlite',
}
await self.ap.system_cfg.dump_config()

View File

@@ -1,22 +0,0 @@
from __future__ import annotations
from .. import migration
@migration.migration_class('force-delay-config', 14)
class ForceDelayConfigMigration(migration.Migration):
"""迁移"""
async def need_migrate(self) -> bool:
"""判断当前环境是否需要运行此迁移"""
return isinstance(self.ap.platform_cfg.data['force-delay'], list)
async def run(self):
"""执行迁移"""
self.ap.platform_cfg.data['force-delay'] = {
'min': self.ap.platform_cfg.data['force-delay'][0],
'max': self.ap.platform_cfg.data['force-delay'][1],
}
await self.ap.platform_cfg.dump_config()

View File

@@ -1,27 +0,0 @@
from __future__ import annotations
from .. import migration
@migration.migration_class('gitee-ai-config', 15)
class GiteeAIConfigMigration(migration.Migration):
"""迁移"""
async def need_migrate(self) -> bool:
"""判断当前环境是否需要运行此迁移"""
return (
'gitee-ai-chat-completions' not in self.ap.provider_cfg.data['requester']
or 'gitee-ai' not in self.ap.provider_cfg.data['keys']
)
async def run(self):
"""执行迁移"""
self.ap.provider_cfg.data['requester']['gitee-ai-chat-completions'] = {
'base-url': 'https://ai.gitee.com/v1',
'args': {},
'timeout': 120,
}
self.ap.provider_cfg.data['keys']['gitee-ai'] = ['XXXXX']
await self.ap.provider_cfg.dump_config()

View File

@@ -1,23 +0,0 @@
from __future__ import annotations
from .. import migration
@migration.migration_class('dify-service-api-config', 16)
class DifyServiceAPICfgMigration(migration.Migration):
"""迁移"""
async def need_migrate(self) -> bool:
"""判断当前环境是否需要运行此迁移"""
return 'dify-service-api' not in self.ap.provider_cfg.data
async def run(self):
"""执行迁移"""
self.ap.provider_cfg.data['dify-service-api'] = {
'base-url': 'https://api.dify.ai/v1',
'app-type': 'chat',
'chat': {'api-key': 'app-1234567890'},
'workflow': {'api-key': 'app-1234567890', 'output-key': 'summary'},
}
await self.ap.provider_cfg.dump_config()

View File

@@ -1,27 +0,0 @@
from __future__ import annotations
from .. import migration
@migration.migration_class('dify-api-timeout-params', 17)
class DifyAPITimeoutParamsMigration(migration.Migration):
"""迁移"""
async def need_migrate(self) -> bool:
"""判断当前环境是否需要运行此迁移"""
return (
'timeout' not in self.ap.provider_cfg.data['dify-service-api']['chat']
or 'timeout' not in self.ap.provider_cfg.data['dify-service-api']['workflow']
or 'agent' not in self.ap.provider_cfg.data['dify-service-api']
)
async def run(self):
"""执行迁移"""
self.ap.provider_cfg.data['dify-service-api']['chat']['timeout'] = 120
self.ap.provider_cfg.data['dify-service-api']['workflow']['timeout'] = 120
self.ap.provider_cfg.data['dify-service-api']['agent'] = {
'api-key': 'app-1234567890',
'timeout': 120,
}
await self.ap.provider_cfg.dump_config()

View File

@@ -1,23 +0,0 @@
from __future__ import annotations
from .. import migration
@migration.migration_class('xai-config', 18)
class XaiConfigMigration(migration.Migration):
"""迁移"""
async def need_migrate(self) -> bool:
"""判断当前环境是否需要运行此迁移"""
return 'xai-chat-completions' not in self.ap.provider_cfg.data['requester']
async def run(self):
"""执行迁移"""
self.ap.provider_cfg.data['requester']['xai-chat-completions'] = {
'base-url': 'https://api.x.ai/v1',
'args': {},
'timeout': 120,
}
self.ap.provider_cfg.data['keys']['xai'] = ['xai-1234567890']
await self.ap.provider_cfg.dump_config()

View File

@@ -1,23 +0,0 @@
from __future__ import annotations
from .. import migration
@migration.migration_class('zhipuai-config', 19)
class ZhipuaiConfigMigration(migration.Migration):
"""迁移"""
async def need_migrate(self) -> bool:
"""判断当前环境是否需要运行此迁移"""
return 'zhipuai-chat-completions' not in self.ap.provider_cfg.data['requester']
async def run(self):
"""执行迁移"""
self.ap.provider_cfg.data['requester']['zhipuai-chat-completions'] = {
'base-url': 'https://open.bigmodel.cn/api/paas/v4',
'args': {},
'timeout': 120,
}
self.ap.provider_cfg.data['keys']['zhipuai'] = ['xxxxxxx']
await self.ap.provider_cfg.dump_config()

View File

@@ -1,36 +0,0 @@
from __future__ import annotations
from .. import migration
@migration.migration_class('wecom-config', 20)
class WecomConfigMigration(migration.Migration):
"""迁移"""
async def need_migrate(self) -> bool:
"""判断当前环境是否需要运行此迁移"""
# for adapter in self.ap.platform_cfg.data['platform-adapters']:
# if adapter['adapter'] == 'wecom':
# return False
# return True
return False
async def run(self):
"""执行迁移"""
self.ap.platform_cfg.data['platform-adapters'].append(
{
'adapter': 'wecom',
'enable': False,
'host': '0.0.0.0',
'port': 2290,
'corpid': '',
'secret': '',
'token': '',
'EncodingAESKey': '',
'contacts_secret': '',
}
)
await self.ap.platform_cfg.dump_config()

View File

@@ -1,35 +0,0 @@
from __future__ import annotations
from .. import migration
@migration.migration_class('lark-config', 21)
class LarkConfigMigration(migration.Migration):
"""迁移"""
async def need_migrate(self) -> bool:
"""判断当前环境是否需要运行此迁移"""
# for adapter in self.ap.platform_cfg.data['platform-adapters']:
# if adapter['adapter'] == 'lark':
# return False
# return True
return False
async def run(self):
"""执行迁移"""
self.ap.platform_cfg.data['platform-adapters'].append(
{
'adapter': 'lark',
'enable': False,
'app_id': 'cli_abcdefgh',
'app_secret': 'XXXXXXXXXX',
'bot_name': 'LangBot',
'enable-webhook': False,
'port': 2285,
'encrypt-key': 'xxxxxxxxx',
}
)
await self.ap.platform_cfg.dump_config()

View File

@@ -1,23 +0,0 @@
from __future__ import annotations
from .. import migration
@migration.migration_class('lmstudio-config', 22)
class LmStudioConfigMigration(migration.Migration):
"""迁移"""
async def need_migrate(self) -> bool:
"""判断当前环境是否需要运行此迁移"""
return 'lmstudio-chat-completions' not in self.ap.provider_cfg.data['requester']
async def run(self):
"""执行迁移"""
self.ap.provider_cfg.data['requester']['lmstudio-chat-completions'] = {
'base-url': 'http://127.0.0.1:1234/v1',
'args': {},
'timeout': 120,
}
await self.ap.provider_cfg.dump_config()

View File

@@ -1,25 +0,0 @@
from __future__ import annotations
from .. import migration
@migration.migration_class('siliconflow-config', 23)
class SiliconFlowConfigMigration(migration.Migration):
"""迁移"""
async def need_migrate(self) -> bool:
"""判断当前环境是否需要运行此迁移"""
return 'siliconflow-chat-completions' not in self.ap.provider_cfg.data['requester']
async def run(self):
"""执行迁移"""
self.ap.provider_cfg.data['keys']['siliconflow'] = ['xxxxxxx']
self.ap.provider_cfg.data['requester']['siliconflow-chat-completions'] = {
'base-url': 'https://api.siliconflow.cn/v1',
'args': {},
'timeout': 120,
}
await self.ap.provider_cfg.dump_config()

View File

@@ -1,31 +0,0 @@
from __future__ import annotations
from .. import migration
@migration.migration_class('discord-config', 24)
class DiscordConfigMigration(migration.Migration):
"""迁移"""
async def need_migrate(self) -> bool:
"""判断当前环境是否需要运行此迁移"""
# for adapter in self.ap.platform_cfg.data['platform-adapters']:
# if adapter['adapter'] == 'discord':
# return False
# return True
return False
async def run(self):
"""执行迁移"""
self.ap.platform_cfg.data['platform-adapters'].append(
{
'adapter': 'discord',
'enable': False,
'client_id': '1234567890',
'token': 'XXXXXXXXXX',
}
)
await self.ap.platform_cfg.dump_config()

View File

@@ -1,35 +0,0 @@
from __future__ import annotations
from .. import migration
@migration.migration_class('gewechat-config', 25)
class GewechatConfigMigration(migration.Migration):
"""迁移"""
async def need_migrate(self) -> bool:
"""判断当前环境是否需要运行此迁移"""
# for adapter in self.ap.platform_cfg.data['platform-adapters']:
# if adapter['adapter'] == 'gewechat':
# return False
# return True
return False
async def run(self):
"""执行迁移"""
self.ap.platform_cfg.data['platform-adapters'].append(
{
'adapter': 'gewechat',
'enable': False,
'gewechat_url': 'http://your-gewechat-server:2531',
'gewechat_file_url': 'http://your-gewechat-server:2532',
'port': 2286,
'callback_url': 'http://your-callback-url:2286/gewechat/callback',
'app_id': '',
'token': '',
}
)
await self.ap.platform_cfg.dump_config()

View File

@@ -1,33 +0,0 @@
from __future__ import annotations
from .. import migration
@migration.migration_class('qqofficial-config', 26)
class QQOfficialConfigMigration(migration.Migration):
"""迁移"""
async def need_migrate(self) -> bool:
"""判断当前环境是否需要运行此迁移"""
# for adapter in self.ap.platform_cfg.data['platform-adapters']:
# if adapter['adapter'] == 'qqofficial':
# return False
# return True
return False
async def run(self):
"""执行迁移"""
self.ap.platform_cfg.data['platform-adapters'].append(
{
'adapter': 'qqofficial',
'enable': False,
'appid': '',
'secret': '',
'port': 2284,
'token': '',
}
)
await self.ap.platform_cfg.dump_config()

View File

@@ -1,35 +0,0 @@
from __future__ import annotations
from .. import migration
@migration.migration_class('wx-official-account-config', 27)
class WXOfficialAccountConfigMigration(migration.Migration):
"""迁移"""
async def need_migrate(self) -> bool:
"""判断当前环境是否需要运行此迁移"""
# for adapter in self.ap.platform_cfg.data['platform-adapters']:
# if adapter['adapter'] == 'officialaccount':
# return False
# return True
return False
async def run(self):
"""执行迁移"""
self.ap.platform_cfg.data['platform-adapters'].append(
{
'adapter': 'officialaccount',
'enable': False,
'token': '',
'EncodingAESKey': '',
'AppID': '',
'AppSecret': '',
'host': '0.0.0.0',
'port': 2287,
}
)
await self.ap.platform_cfg.dump_config()

View File

@@ -1,25 +0,0 @@
from __future__ import annotations
from .. import migration
@migration.migration_class('bailian-requester-config', 28)
class BailianRequesterConfigMigration(migration.Migration):
"""迁移"""
async def need_migrate(self) -> bool:
"""判断当前环境是否需要运行此迁移"""
return 'bailian-chat-completions' not in self.ap.provider_cfg.data['requester']
async def run(self):
"""执行迁移"""
self.ap.provider_cfg.data['keys']['bailian'] = ['sk-xxxxxxx']
self.ap.provider_cfg.data['requester']['bailian-chat-completions'] = {
'base-url': 'https://dashscope.aliyuncs.com/compatible-mode/v1',
'args': {},
'timeout': 120,
}
await self.ap.provider_cfg.dump_config()

View File

@@ -1,27 +0,0 @@
from __future__ import annotations
from .. import migration
@migration.migration_class('dashscope-app-api-config', 29)
class DashscopeAppAPICfgMigration(migration.Migration):
"""迁移"""
async def need_migrate(self) -> bool:
"""判断当前环境是否需要运行此迁移"""
return 'dashscope-app-api' not in self.ap.provider_cfg.data
async def run(self):
"""执行迁移"""
self.ap.provider_cfg.data['dashscope-app-api'] = {
'app-type': 'agent',
'api-key': 'sk-1234567890',
'agent': {'app-id': 'Your_app_id', 'references_quote': '参考资料来自:'},
'workflow': {
'app-id': 'Your_app_id',
'references_quote': '参考资料来自:',
'biz_params': {'city': '北京', 'date': '2023-08-10'},
},
}
await self.ap.provider_cfg.dump_config()

View File

@@ -1,31 +0,0 @@
from __future__ import annotations
from .. import migration
@migration.migration_class('lark-config-cmpl', 30)
class LarkConfigCmplMigration(migration.Migration):
"""迁移"""
async def need_migrate(self) -> bool:
"""判断当前环境是否需要运行此迁移"""
for adapter in self.ap.platform_cfg.data['platform-adapters']:
if adapter['adapter'] == 'lark':
if 'enable-webhook' not in adapter:
return True
return False
async def run(self):
"""执行迁移"""
for adapter in self.ap.platform_cfg.data['platform-adapters']:
if adapter['adapter'] == 'lark':
if 'enable-webhook' not in adapter:
adapter['enable-webhook'] = False
if 'port' not in adapter:
adapter['port'] = 2285
if 'encrypt-key' not in adapter:
adapter['encrypt-key'] = 'xxxxxxxxx'
await self.ap.platform_cfg.dump_config()

View File

@@ -1,33 +0,0 @@
from __future__ import annotations
from .. import migration
@migration.migration_class('dingtalk-config', 31)
class DingTalkConfigMigration(migration.Migration):
"""迁移"""
async def need_migrate(self) -> bool:
"""判断当前环境是否需要运行此迁移"""
# for adapter in self.ap.platform_cfg.data['platform-adapters']:
# if adapter['adapter'] == 'dingtalk':
# return False
# return True
return False
async def run(self):
"""执行迁移"""
self.ap.platform_cfg.data['platform-adapters'].append(
{
'adapter': 'dingtalk',
'enable': False,
'client_id': '',
'client_secret': '',
'robot_code': '',
'robot_name': '',
}
)
await self.ap.platform_cfg.dump_config()

View File

@@ -1,25 +0,0 @@
from __future__ import annotations
from .. import migration
@migration.migration_class('volcark-requester-config', 32)
class VolcArkRequesterConfigMigration(migration.Migration):
"""迁移"""
async def need_migrate(self) -> bool:
"""判断当前环境是否需要运行此迁移"""
return 'volcark-chat-completions' not in self.ap.provider_cfg.data['requester']
async def run(self):
"""执行迁移"""
self.ap.provider_cfg.data['keys']['volcark'] = ['xxxxxxxx']
self.ap.provider_cfg.data['requester']['volcark-chat-completions'] = {
'base-url': 'https://ark.cn-beijing.volces.com/api/v3',
'args': {},
'timeout': 120,
}
await self.ap.provider_cfg.dump_config()

View File

@@ -1,24 +0,0 @@
from __future__ import annotations
from .. import migration
@migration.migration_class('dify-thinking-config', 33)
class DifyThinkingConfigMigration(migration.Migration):
"""迁移"""
async def need_migrate(self) -> bool:
"""判断当前环境是否需要运行此迁移"""
if 'options' not in self.ap.provider_cfg.data['dify-service-api']:
return True
if 'convert-thinking-tips' not in self.ap.provider_cfg.data['dify-service-api']['options']:
return True
return False
async def run(self):
"""执行迁移"""
self.ap.provider_cfg.data['dify-service-api']['options'] = {'convert-thinking-tips': 'plain'}
await self.ap.provider_cfg.dump_config()

View File

@@ -1,29 +0,0 @@
from __future__ import annotations
from urllib.parse import urlparse
from .. import migration
@migration.migration_class('gewechat-file-url-config', 34)
class GewechatFileUrlConfigMigration(migration.Migration):
"""迁移"""
async def need_migrate(self) -> bool:
"""判断当前环境是否需要运行此迁移"""
for adapter in self.ap.platform_cfg.data['platform-adapters']:
if adapter['adapter'] == 'gewechat':
if 'gewechat_file_url' not in adapter:
return True
return False
async def run(self):
"""执行迁移"""
for adapter in self.ap.platform_cfg.data['platform-adapters']:
if adapter['adapter'] == 'gewechat':
if 'gewechat_file_url' not in adapter:
parsed_url = urlparse(adapter['gewechat_url'])
adapter['gewechat_file_url'] = f'{parsed_url.scheme}://{parsed_url.hostname}:2532'
await self.ap.platform_cfg.dump_config()

View File

@@ -1,26 +0,0 @@
from __future__ import annotations
from .. import migration
@migration.migration_class('wxoa-mode', 35)
class WxoaModeMigration(migration.Migration):
"""迁移"""
async def need_migrate(self) -> bool:
"""判断当前环境是否需要运行此迁移"""
for adapter in self.ap.platform_cfg.data['platform-adapters']:
if adapter['adapter'] == 'officialaccount':
if 'Mode' not in adapter:
return True
return False
async def run(self):
"""执行迁移"""
for adapter in self.ap.platform_cfg.data['platform-adapters']:
if adapter['adapter'] == 'officialaccount':
if 'Mode' not in adapter:
adapter['Mode'] = 'drop'
await self.ap.platform_cfg.dump_config()

View File

@@ -1,26 +0,0 @@
from __future__ import annotations
from .. import migration
@migration.migration_class('wxoa-loading-message', 36)
class WxoaLoadingMessageMigration(migration.Migration):
"""迁移"""
async def need_migrate(self) -> bool:
"""判断当前环境是否需要运行此迁移"""
for adapter in self.ap.platform_cfg.data['platform-adapters']:
if adapter['adapter'] == 'officialaccount':
if 'LoadingMessage' not in adapter:
return True
return False
async def run(self):
"""执行迁移"""
for adapter in self.ap.platform_cfg.data['platform-adapters']:
if adapter['adapter'] == 'officialaccount':
if 'LoadingMessage' not in adapter:
adapter['LoadingMessage'] = 'AI正在思考中请发送任意内容获取回复。'
await self.ap.platform_cfg.dump_config()

View File

@@ -1,18 +0,0 @@
from __future__ import annotations
from .. import migration
@migration.migration_class('mcp-config', 37)
class MCPConfigMigration(migration.Migration):
"""迁移"""
async def need_migrate(self) -> bool:
"""判断当前环境是否需要运行此迁移"""
return 'mcp' not in self.ap.provider_cfg.data
async def run(self):
"""执行迁移"""
self.ap.provider_cfg.data['mcp'] = {'servers': []}
await self.ap.provider_cfg.dump_config()

View File

@@ -1,25 +0,0 @@
from __future__ import annotations
from .. import migration
@migration.migration_class('tg-dingtalk-markdown', 38)
class TgDingtalkMarkdownMigration(migration.Migration):
"""迁移"""
async def need_migrate(self) -> bool:
"""判断当前环境是否需要运行此迁移"""
for adapter in self.ap.platform_cfg.data['platform-adapters']:
if adapter['adapter'] in ['dingtalk', 'telegram']:
if 'markdown_card' not in adapter:
return True
return False
async def run(self):
"""执行迁移"""
for adapter in self.ap.platform_cfg.data['platform-adapters']:
if adapter['adapter'] in ['dingtalk', 'telegram']:
if 'markdown_card' not in adapter:
adapter['markdown_card'] = False
await self.ap.platform_cfg.dump_config()

View File

@@ -1,29 +0,0 @@
from __future__ import annotations
from .. import migration
@migration.migration_class('modelscope-config-completion', 39)
class ModelScopeConfigCompletionMigration(migration.Migration):
"""ModelScope配置迁移"""
async def need_migrate(self) -> bool:
"""判断当前环境是否需要运行此迁移"""
return (
'modelscope-chat-completions' not in self.ap.provider_cfg.data['requester']
or 'modelscope' not in self.ap.provider_cfg.data['keys']
)
async def run(self):
"""执行迁移"""
if 'modelscope-chat-completions' not in self.ap.provider_cfg.data['requester']:
self.ap.provider_cfg.data['requester']['modelscope-chat-completions'] = {
'base-url': 'https://api-inference.modelscope.cn/v1',
'args': {},
'timeout': 120,
}
if 'modelscope' not in self.ap.provider_cfg.data['keys']:
self.ap.provider_cfg.data['keys']['modelscope'] = []
await self.ap.provider_cfg.dump_config()

View File

@@ -1,29 +0,0 @@
from __future__ import annotations
from .. import migration
@migration.migration_class('ppio-config', 40)
class PPIOConfigMigration(migration.Migration):
"""PPIO配置迁移"""
async def need_migrate(self) -> bool:
"""判断当前环境是否需要运行此迁移"""
return (
'ppio-chat-completions' not in self.ap.provider_cfg.data['requester']
or 'ppio' not in self.ap.provider_cfg.data['keys']
)
async def run(self):
"""执行迁移"""
if 'ppio-chat-completions' not in self.ap.provider_cfg.data['requester']:
self.ap.provider_cfg.data['requester']['ppio-chat-completions'] = {
'base-url': 'https://api.ppinfra.com/v3/openai',
'args': {},
'timeout': 120,
}
if 'ppio' not in self.ap.provider_cfg.data['keys']:
self.ap.provider_cfg.data['keys']['ppio'] = []
await self.ap.provider_cfg.dump_config()

View File

@@ -1,17 +0,0 @@
from __future__ import annotations
from .. import migration
@migration.migration_class('dingtalk_card_auto_layout', 41)
class DingTalkCardAutoLayoutMigration(migration.Migration):
"""迁移"""
async def need_migrate(self) -> bool:
"""判断当前环境是否需要运行此迁移"""
return True
async def run(self):
"""执行迁移"""
self.ap.platform_cfg.data['platform-adapters']['app']['dingtalk']['card_auto_layout'] = False
await self.ap.platform_cfg.dump_config()

View File

@@ -202,6 +202,16 @@ class LoadConfigStage(stage.BootingStage):
constants.instance_id = new_id constants.instance_id = new_id
constants.edition = ap.instance_config.data.get('system', {}).get('edition', 'community') constants.edition = ap.instance_config.data.get('system', {}).get('edition', 'community')
# Instance creation timestamp: sourced from data/labels/instance_id.json.
# Instances created before this field existed (or supplied via
# system.instance_id) won't have it, so backfill with the current time
# and persist it via the dump below — from then on it stays stable.
instance_create_ts = ap.instance_id.data.get('instance_create_ts', 0)
if not isinstance(instance_create_ts, int) or instance_create_ts <= 0:
instance_create_ts = int(time.time())
ap.instance_id.data['instance_create_ts'] = instance_create_ts
constants.instance_create_ts = instance_create_ts
print(f'LangBot instance id: {constants.instance_id}') print(f'LangBot instance id: {constants.instance_id}')
print(f'LangBot edition: {constants.edition}') print(f'LangBot edition: {constants.edition}')

View File

@@ -1,43 +0,0 @@
from __future__ import annotations
from .. import stage, app
from .. import migration
from ...utils import importutil
from .. import migrations
importutil.import_modules_in_pkg(migrations)
@stage.stage_class('MigrationStage')
class MigrationStage(stage.BootingStage):
"""Migration stage
These migrations are legacy, only performed in version 3.x
"""
async def run(self, ap: app.Application):
"""Run migration"""
if any(
[
ap.command_cfg is None,
ap.pipeline_cfg is None,
ap.platform_cfg is None,
ap.provider_cfg is None,
ap.system_cfg is None,
]
): # only run migration when version is 3.x
return
migrations = migration.preregistered_migrations
# Sort by migration number
migrations.sort(key=lambda x: x.number)
for migration_cls in migrations:
migration_instance = migration_cls(ap)
if await migration_instance.need_migrate():
await migration_instance.run()
print(f'Migration {migration_instance.name} executed')

View File

@@ -11,6 +11,10 @@ class MCPServer(Base):
enable = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=False) enable = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=False)
mode = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) # stdio, sse, http mode = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) # stdio, sse, http
extra_args = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={}) extra_args = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})
# Markdown documentation captured from LangBot Space at install time so the
# detail page can show docs even when the server is offline / has no tools.
# Empty string for manually-created servers that have no marketplace README.
readme = sqlalchemy.Column(sqlalchemy.Text, nullable=False, server_default='', default='')
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now()) 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,

View File

@@ -31,6 +31,7 @@ class LLMModel(Base):
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
provider_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) provider_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
abilities = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default=[]) abilities = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default=[])
context_length = sqlalchemy.Column(sqlalchemy.Integer, nullable=True)
extra_args = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={}) extra_args = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})
prefered_ranking = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, default=0) prefered_ranking = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, default=0)
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())

View File

@@ -0,0 +1,34 @@
"""add readme column to mcp_servers
Revision ID: 0004_add_mcp_readme
Revises: 0003_add_rerank_models
Create Date: 2026-06-06
"""
import sqlalchemy as sa
from alembic import op
revision = '0004_add_mcp_readme'
down_revision = '0003_add_rerank_models'
branch_labels = None
depends_on = None
def upgrade() -> None:
# Add ``readme`` to mcp_servers if the table exists and the column is missing
# (the table may have been created by create_all() with the column already
# present on fresh installs, so guard against duplicate-add).
conn = op.get_bind()
inspector = sa.inspect(conn)
if 'mcp_servers' not in inspector.get_table_names():
return
columns = {col['name'] for col in inspector.get_columns('mcp_servers')}
if 'readme' not in columns:
op.add_column(
'mcp_servers',
sa.Column('readme', sa.Text(), nullable=False, server_default=''),
)
def downgrade() -> None:
op.drop_column('mcp_servers', 'readme')

Some files were not shown because too many files have changed in this diff Show More