Compare commits

..

18 Commits

Author SHA1 Message Date
Hyu ec63978ecf docs: replace legacy documentation shortlinks (#2510)
* docs: replace legacy documentation shortlinks

* style: format updated documentation URLs

---------

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-09-05 01:19:28 +08:00
Hyu 1cfe87186c docs: update published documentation links (#2509)
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-09-05 00:55:18 +08:00
leonoxo 9794df0933 feat(n8n-runner): support async response handling (#2487)
* feat(n8n-runner): support async response handling

* fix(n8n-runner): expose response handling in form

* fix(n8n-runner): preserve async response semantics

---------

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-09-04 22:36:40 +08:00
Hyu a63808caa6 chore(release): prepare LangBot 4.10.10 (#2507)
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-09-04 21:47:31 +08:00
mintya de3c0b00ad fix(bot): roll back inserted bot row when adapter fails to load (#2497)
create_bot inserts the Bot row first and only then instantiates the
adapter via platform_mgr.load_bot. When the adapter constructor raises
(e.g. KeyError on a missing credential key), the insert is already
committed and nothing removes the row: the HTTP layer returns 500 but
a permanently disabled orphan bot stays in the DB. Callers never
receive the bot uuid, so they cannot compensate by deleting it, and
load_bots_from_db skips enable=False bots, so the orphan is never
loaded or surfaced anywhere.

Wrap load_bot in try/except and delete the inserted row before
re-raising. Add a regression test asserting the DELETE is issued when
the adapter constructor fails.
2026-09-04 13:06:12 +08:00
mintya cb45807b12 fix(qqofficial): tolerate missing optional token in adapter config (#2496)
Since b55f073e the token field is optional in qqofficial.yaml ("the
current adapter implementation does not use it either, so it can be
safely left blank"), but the adapter constructor still reads it with
config['token']. Creating a bot via QR binding (which only returns
appid/secret) or with the token field left blank raises KeyError and
the API returns 500.

Read it with config.get('token', '') instead. The value is never used
by QQOfficialClient beyond being stored, so an empty string default is
safe.
2026-09-04 13:05:36 +08:00
Amir Fathi d942bfe19a fix(wecom): read media_id instead of media in send_message() (#2447)
WecomMessageConverter.yiri2target() always emits {'media_id': ...} for
image/voice/file parts (never 'media'), matching the correct usage
already in reply_message(). send_message(), the entry point plugins
use via PluginToRuntimeAction.SEND_MESSAGE, instead read
content['media'], which is never set, so any image/voice/file part
raises KeyError and aborts the send.

Refs #1687

Signed-off-by: Amir Fathi <amirfathi.me@gmail.com>
2026-09-04 13:02:40 +08:00
Hyu b44b8f474d fix(cloud): provision login workspace just in time (#2505)
* fix(cloud): provision login workspace just in time

* fix(oauth): send callback URI during code exchange

* fix(oauth): preserve callback URI through browser exchange

* fix(oauth): negotiate redirect-bound codes

---------

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-09-03 23:14:44 +08:00
Hyu ab52684a01 Revert "fix(cloud): request automatic Space launch (#2501)" (#2503)
This reverts commit d50957fc4f.

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-09-03 12:16:21 +08:00
Hyu d50957fc4f fix(cloud): request automatic Space launch (#2501)
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-09-03 11:44:32 +08:00
Hyu c8d8b1aac4 fix(cloud): serialize directory catch-up (#2500)
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-09-02 21:57:53 +08:00
Hyu 018dd7a363 fix(cloud): launch newly registered accounts through Space (#2499)
* fix(cloud): launch new accounts through Space

* style: format Cloud entry URL

* fix(cloud): wait for launch workspace projection

---------

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-09-02 21:41:35 +08:00
huanghuoguoguo 7b7d3f04e8 feat(box): support explicit host backend (#2498) 2026-09-02 21:22:14 +08:00
CWT 601c6975ea fix(ollama): use litellm's ollama_chat provider for native tool-calling (#2494)
The Ollama requester declared litellm_provider: ollama, which routes
every request through litellm's legacy /api/generate-based
OllamaConfig. That config's get_supported_openai_params() does not
include "tools"/"tool_choice" at all, so an Ollama-hosted model in a
local-agent pipeline could never receive a structured tool definition
or return a structured tool_calls response - it could only try to
express a tool call as free text (typically inside its own <think>
reasoning), which LangBot then has no way to execute.

litellm's "ollama_chat" provider targets Ollama's modern /api/chat
endpoint instead, which correctly forwards tools/tool_choice and
correctly surfaces the model's native message.tool_calls field.
Verified against a real local Ollama 0.33.2 instance with the exact
system prompt, RAG-augmented user message, and tool set a live
pipeline sends.

Two follow-on fixes needed because the Ollama requester definition is
shared by LLM and text-embedding models:

- get_reasoning_capabilities: match family in ('ollama', 'ollama_chat')
  so the reasoning-level UI still works for this provider.
- scan_models: retry {base_url}/v1/models on a 404 from {base_url}/models,
  since Ollama's base_url is a bare host (must not include /v1 - that
  would break OllamaChatConfig.get_complete_url, which appends /api/chat
  to it directly), unlike most other OpenAI-compatible providers whose
  base_url already ends in /v1.
- invoke_embedding: litellm's embedding routing has no "ollama_chat"
  case, only "ollama". Build the embedding model name with an explicit
  custom_llm_provider="ollama" override when the requester is configured
  for ollama_chat, so embedding models (e.g. bge-m3) keep working.

Co-authored-by: zx90316 <zx90316@users.noreply.github.com>
2026-09-01 22:36:35 +08:00
mintya 5ca30133a3 fix(lark): stop duplicating final reply text in streaming card (#2490)
* fix(lark): stop duplicating final reply text in streaming card

* fix(lark): stop duplicating final reply text in streaming card
2026-09-01 21:24:54 +08:00
Hyu 5c49cb60e3 fix(embed): preserve replies after empty assistant frames (#2492)
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-09-01 20:11:21 +08:00
Hyu 8cf0015502 fix(dingtalk): restore card auto layout (#2491)
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-09-01 17:56:02 +08:00
Hyu bf8d418ad4 feat(monitoring): paginate sessions and messages (#2489)
* feat(monitoring): paginate sessions and messages

* fix(monitoring): align detail pages with local dates

---------

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-09-01 15:14:29 +08:00
101 changed files with 1937 additions and 272 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
name: 漏洞反馈
description: 【供中文用户】报错或漏洞请使用这个模板创建,不使用此模板创建的异常、漏洞相关issue将被直接关闭。由于自己操作不当/不甚了解所用技术栈引起的网络连接问题恕无法解决,请勿提 issue。容器间网络连接问题,参考文档 https://link.langbot.app/zh/docs/network
description: 【供中文用户】报错或漏洞请使用这个模板创建,不使用此模板创建的异常、漏洞相关issue将被直接关闭。由于自己操作不当/不甚了解所用技术栈引起的网络连接问题恕无法解决,请勿提 issue。容器间网络连接问题,参考文档 https://langbot.app/docs/zh/workshop/network-details
title: "[Bug]: "
labels: ["bug?"]
body:
+1 -1
View File
@@ -1,5 +1,5 @@
name: Bug report
description: Report bugs or vulnerabilities using this template. For container network connection issues, refer to the documentation https://link.langbot.app/en/docs/network
description: Report bugs or vulnerabilities using this template. For container network connection issues, refer to the documentation https://langbot.app/docs/en/workshop/network-details
title: "[Bug]: "
labels: ["bug?"]
body:
+2 -2
View File
@@ -43,8 +43,8 @@ Run the narrowest useful test first, then broader checks when confidence is need
## Where to Look
- Architecture map: `ARCHITECTURE.md`.
- Dev environment guide: https://docs.langbot.app/zh/develop/dev-config.
- Plugin runtime / CLI / SDK debugging: https://docs.langbot.app/zh/develop/plugin-runtime.
- Dev environment guide: https://langbot.app/docs/zh/develop/dev-config.
- Plugin runtime / CLI / SDK debugging: https://langbot.app/docs/zh/develop/plugin-runtime.
- API-key auth: `docs/API_KEY_AUTH.md`.
- Box deep-dive notes: `docs/review/box-architecture.md` and related files.
- In-repo skills: `skills/` is the single source of truth for LangBot agent skills.
+6 -6
View File
@@ -19,9 +19,9 @@ English / [简体中文](README_CN.md) / [繁體中文](README_TW.md) / [日本
[![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers)
<a href="https://langbot.app">Website</a>
<a href="https://link.langbot.app/en/docs/features">Features</a>
<a href="https://link.langbot.app/en/docs/guide">Docs</a>
<a href="https://link.langbot.app/en/docs/api">API</a>
<a href="https://langbot.app/docs/en/insight/features">Features</a>
<a href="https://langbot.app/docs/en/insight/guide">Docs</a>
<a href="https://langbot.app/docs/en/tags/readme">API</a>
<a href="https://space.langbot.app/cloud">Cloud</a>
<a href="https://space.langbot.app">Plugin Market</a>
<a href="https://langbot.featurebase.app/roadmap">Roadmap</a>
@@ -49,7 +49,7 @@ LangBot is an **open-source, production-grade platform** for building AI-powered
- **Web Management Panel** — Configure, manage, and monitor your bots through an intuitive browser interface. No YAML editing required.
- **Multi-Pipeline Architecture** — Different bots for different scenarios, with comprehensive monitoring and exception handling.
[→ Learn more about all features](https://link.langbot.app/en/docs/features)
[→ Learn more about all features](https://langbot.app/docs/en/insight/features)
📍 Practical guides: [deploy a multi-platform AI bot in 5 minutes](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [connect DeepSeek to WeChat, Discord, and Telegram](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [run a Dify Agent in Discord, Telegram, and Slack](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/), and [build an n8n-powered chatbot](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
@@ -89,7 +89,7 @@ docker compose --profile all up -d
[![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)
**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)
**More options:** [Docker](https://langbot.app/docs/en/deploy/langbot/docker) · [Manual](https://langbot.app/docs/en/deploy/langbot/manual) · [BTPanel](https://langbot.app/docs/en/deploy/langbot/one-click/bt) · [Kubernetes](https://langbot.app/docs/en/deploy/langbot/kubernetes)
---
@@ -151,7 +151,7 @@ _Note: Public demo environment. Do not enter sensitive information._
| [302.AI](https://share.302ai.cn/SuTG99) | Gateway | ✅ |
| [Qiniu](https://www.qiniu.com/ai/agent) | Gateway | ✅ |
[→ View all integrations](https://link.langbot.app/en/docs/features)
[→ View all integrations](https://langbot.app/docs/en/insight/features)
---
+6 -6
View File
@@ -21,9 +21,9 @@
[![star](https://gitcode.com/RockChinQ/LangBot/star/badge.svg)](https://gitcode.com/RockChinQ/LangBot)
<a href="https://langbot.app">官网</a>
<a href="https://link.langbot.app/zh/docs/features">特性</a>
<a href="https://link.langbot.app/zh/docs/guide">文档</a>
<a href="https://link.langbot.app/zh/docs/api">API</a>
<a href="https://langbot.app/docs/zh/insight/features">特性</a>
<a href="https://langbot.app/docs/zh/insight/guide">文档</a>
<a href="https://langbot.app/docs/zh/tags/readme">API</a>
<a href="https://space.langbot.app/cloud">Cloud</a>
<a href="https://space.langbot.app">扩展市场</a>
<a href="https://langbot.featurebase.app/roadmap">路线图</a>
@@ -49,7 +49,7 @@ LangBot 是一个**开源的生产级平台**,用于构建 AI 驱动的即时
- **Web 管理面板** — 通过浏览器直观地配置、管理和监控机器人,无需手动编辑配置文件。
- **多流水线架构** — 不同机器人用于不同场景,具备全面的监控和异常处理能力。
[→ 了解更多功能特性](https://link.langbot.app/zh/docs/features)
[→ 了解更多功能特性](https://langbot.app/docs/zh/insight/features)
📍 实践指南:[5 分钟部署多平台 AI 机器人](https://langbot.app/zh/blog/deploy-ai-bot-in-5-minutes/)、[将 DeepSeek 接入微信、企业微信与 Discord](https://langbot.app/zh/blog/connect-deepseek-to-wechat/)、[让 Dify Agent 跑在 Discord、Telegram 和 Slack 上](https://langbot.app/zh/blog/dify-agent-discord-telegram-slack/),以及[用 n8n 构建多平台 AI 聊天机器人](https://langbot.app/zh/blog/n8n-multi-platform-ai-chatbot/)。
@@ -89,7 +89,7 @@ docker compose --profile all up -d
[![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)
**更多方式:** [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)
**更多方式:** [Docker](https://langbot.app/docs/zh/deploy/langbot/docker) · [手动部署](https://langbot.app/docs/zh/deploy/langbot/manual) · [宝塔面板](https://langbot.app/docs/zh/deploy/langbot/one-click/bt) · [Kubernetes](https://langbot.app/docs/zh/deploy/langbot/kubernetes)
---
@@ -152,7 +152,7 @@ docker compose --profile all up -d
| [百宝箱Tbox](https://www.tbox.cn/open) | 智能体平台 | ✅ |
| [七牛云Qiniu](https://www.qiniu.com/ai/agent) | 聚合平台 | ✅ |
[→ 查看完整集成列表](https://link.langbot.app/zh/docs/features)
[→ 查看完整集成列表](https://langbot.app/docs/zh/insight/features)
### TTS(语音合成)
+6 -6
View File
@@ -19,9 +19,9 @@
[![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers)
<a href="https://langbot.app">Inicio</a>
<a href="https://link.langbot.app/en/docs/features">Características</a>
<a href="https://link.langbot.app/en/docs/guide">Documentación</a>
<a href="https://link.langbot.app/en/docs/api">API</a>
<a href="https://langbot.app/docs/en/insight/features">Características</a>
<a href="https://langbot.app/docs/en/insight/guide">Documentación</a>
<a href="https://langbot.app/docs/en/tags/readme">API</a>
<a href="https://space.langbot.app">Mercado de Plugins</a>
<a href="https://langbot.featurebase.app/roadmap">Hoja de Ruta</a>
@@ -48,7 +48,7 @@ LangBot es una **plataforma de código abierto y grado de producción** para con
- **Panel de Gestión Web** — Configure, gestione y monitoree sus bots a través de una interfaz de navegador intuitiva. Sin necesidad de editar YAML.
- **Arquitectura Multi-Pipeline** — Diferentes bots para diferentes escenarios, con monitoreo completo y manejo de excepciones.
[→ Conocer más sobre todas las funcionalidades](https://link.langbot.app/en/docs/features)
[→ Conocer más sobre todas las funcionalidades](https://langbot.app/docs/en/insight/features)
📍 Guías prácticas: [desplegar un bot de IA multiplataforma en 5 minutos](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [conectar DeepSeek a WeChat, Discord y Telegram](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [ejecutar un Dify Agent en Discord, Telegram y Slack](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/) y [crear un chatbot con n8n](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
@@ -88,7 +88,7 @@ docker compose --profile all up -d
[![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)
**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)
**Más opciones:** [Docker](https://langbot.app/docs/en/deploy/langbot/docker) · [Manual](https://langbot.app/docs/en/deploy/langbot/manual) · [BTPanel](https://langbot.app/docs/en/deploy/langbot/one-click/bt) · [Kubernetes](https://langbot.app/docs/en/deploy/langbot/kubernetes)
---
@@ -149,7 +149,7 @@ docker compose --profile all up -d
| [302.AI](https://share.302ai.cn/SuTG99) | Pasarela | ✅ |
| [Qiniu](https://www.qiniu.com/ai/agent) | Pasarela | ✅ |
[→ Ver todas las integraciones](https://link.langbot.app/en/docs/features)
[→ Ver todas las integraciones](https://langbot.app/docs/en/insight/features)
---
+6 -6
View File
@@ -19,9 +19,9 @@
[![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers)
<a href="https://langbot.app">Accueil</a>
<a href="https://link.langbot.app/en/docs/features">Fonctionnalités</a>
<a href="https://link.langbot.app/en/docs/guide">Documentation</a>
<a href="https://link.langbot.app/en/docs/api">API</a>
<a href="https://langbot.app/docs/en/insight/features">Fonctionnalités</a>
<a href="https://langbot.app/docs/en/insight/guide">Documentation</a>
<a href="https://langbot.app/docs/en/tags/readme">API</a>
<a href="https://space.langbot.app">Marché des Plugins</a>
<a href="https://langbot.featurebase.app/roadmap">Feuille de Route</a>
@@ -48,7 +48,7 @@ LangBot est une **plateforme open-source de niveau production** pour créer des
- **Panneau de Gestion Web** — Configurez, gérez et surveillez vos bots via une interface navigateur intuitive. Aucune édition de YAML requise.
- **Architecture Multi-Pipeline** — Différents bots pour différents scénarios, avec surveillance complète et gestion des exceptions.
[→ En savoir plus sur toutes les fonctionnalités](https://link.langbot.app/en/docs/features)
[→ En savoir plus sur toutes les fonctionnalités](https://langbot.app/docs/en/insight/features)
📍 Guides pratiques : [déployer un bot IA multiplateforme en 5 minutes](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [connecter DeepSeek à WeChat, Discord et Telegram](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [exécuter un Dify Agent dans Discord, Telegram et Slack](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/) et [créer un chatbot avec n8n](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
@@ -88,7 +88,7 @@ docker compose --profile all up -d
[![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)
**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)
**Plus d'options :** [Docker](https://langbot.app/docs/en/deploy/langbot/docker) · [Manuel](https://langbot.app/docs/en/deploy/langbot/manual) · [BTPanel](https://langbot.app/docs/en/deploy/langbot/one-click/bt) · [Kubernetes](https://langbot.app/docs/en/deploy/langbot/kubernetes)
---
@@ -149,7 +149,7 @@ docker compose --profile all up -d
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Plateforme GPU | ✅ |
| [Qiniu](https://www.qiniu.com/ai/agent) | Passerelle | ✅ |
[→ Voir toutes les intégrations](https://link.langbot.app/en/docs/features)
[→ Voir toutes les intégrations](https://langbot.app/docs/en/insight/features)
---
+6 -6
View File
@@ -19,9 +19,9 @@
[![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers)
<a href="https://langbot.app">ホーム</a>
<a href="https://link.langbot.app/ja/docs/features">機能</a>
<a href="https://link.langbot.app/ja/docs/guide">ドキュメント</a>
<a href="https://link.langbot.app/ja/docs/api">API</a>
<a href="https://langbot.app/docs/ja/insight/features">機能</a>
<a href="https://langbot.app/docs/ja/insight/guide">ドキュメント</a>
<a href="https://langbot.app/docs/ja/tags/readme">API</a>
<a href="https://space.langbot.app">プラグインマーケット</a>
<a href="https://langbot.featurebase.app/roadmap">ロードマップ</a>
@@ -48,7 +48,7 @@ LangBot は、AI搭載のインスタントメッセージングボットを構
- **Web管理パネル** — 直感的なブラウザインターフェースからボットの設定、管理、監視が可能。YAML編集は不要。
- **マルチパイプラインアーキテクチャ** — 異なるシナリオに異なるボットを配置し、包括的な監視と例外処理を実現。
[→ すべての機能について詳しく見る](https://link.langbot.app/ja/docs/features)
[→ すべての機能について詳しく見る](https://langbot.app/docs/ja/insight/features)
📍 実践ガイド: [5分でマルチプラットフォームAIボットをデプロイ](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/)、[DeepSeekをWeChat・Discord・Telegramに接続](https://langbot.app/en/blog/connect-deepseek-to-wechat/)、[Dify AgentをDiscord・Telegram・Slackで動かす](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/)、[n8n連携チャットボットを構築](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/)。
@@ -88,7 +88,7 @@ docker compose --profile all up -d
[![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)
**その他:** [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)
**その他:** [Docker](https://langbot.app/docs/en/deploy/langbot/docker) · [手動デプロイ](https://langbot.app/docs/en/deploy/langbot/manual) · [BTPanel](https://langbot.app/docs/en/deploy/langbot/one-click/bt) · [Kubernetes](https://langbot.app/docs/en/deploy/langbot/kubernetes)
---
@@ -149,7 +149,7 @@ docker compose --profile all up -d
| [302.AI](https://share.302ai.cn/SuTG99) | ゲートウェイ | ✅ |
| [Qiniu](https://www.qiniu.com/ai/agent) | ゲートウェイ | ✅ |
[→ すべての統合を表示](https://link.langbot.app/en/docs/features)
[→ すべての統合を表示](https://langbot.app/docs/en/insight/features)
---
+6 -6
View File
@@ -19,9 +19,9 @@
[![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers)
<a href="https://langbot.app">홈</a>
<a href="https://link.langbot.app/en/docs/features">기능</a>
<a href="https://link.langbot.app/en/docs/guide">문서</a>
<a href="https://link.langbot.app/en/docs/api">API</a>
<a href="https://langbot.app/docs/en/insight/features">기능</a>
<a href="https://langbot.app/docs/en/insight/guide">문서</a>
<a href="https://langbot.app/docs/en/tags/readme">API</a>
<a href="https://space.langbot.app">플러그인 마켓</a>
<a href="https://langbot.featurebase.app/roadmap">로드맵</a>
@@ -48,7 +48,7 @@ LangBot은 AI 기반 인스턴트 메시징 봇을 구축하기 위한 **오픈
- **웹 관리 패널** — 직관적인 브라우저 인터페이스로 봇을 구성, 관리 및 모니터링. YAML 편집 불필요.
- **멀티 파이프라인 아키텍처** — 다양한 시나리오에 맞는 다양한 봇 구성, 종합 모니터링 및 예외 처리.
[→ 모든 기능 자세히 보기](https://link.langbot.app/en/docs/features)
[→ 모든 기능 자세히 보기](https://langbot.app/docs/en/insight/features)
📍 실전 가이드: [5분 만에 멀티 플랫폼 AI 봇 배포하기](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [DeepSeek를 WeChat, Discord, Telegram에 연결하기](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [Dify Agent를 Discord, Telegram, Slack에서 실행하기](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/), [n8n 기반 챗봇 만들기](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
@@ -88,7 +88,7 @@ docker compose --profile all up -d
[![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)
**더 많은 옵션:** [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)
**더 많은 옵션:** [Docker](https://langbot.app/docs/en/deploy/langbot/docker) · [수동 배포](https://langbot.app/docs/en/deploy/langbot/manual) · [BTPanel](https://langbot.app/docs/en/deploy/langbot/one-click/bt) · [Kubernetes](https://langbot.app/docs/en/deploy/langbot/kubernetes)
---
@@ -149,7 +149,7 @@ docker compose --profile all up -d
| [302.AI](https://share.302ai.cn/SuTG99) | 게이트웨이 | ✅ |
| [Qiniu](https://www.qiniu.com/ai/agent) | 게이트웨이 | ✅ |
[→ 모든 통합 보기](https://link.langbot.app/en/docs/features)
[→ 모든 통합 보기](https://langbot.app/docs/en/insight/features)
---
+6 -6
View File
@@ -19,9 +19,9 @@
[![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers)
<a href="https://langbot.app">Главная</a>
<a href="https://link.langbot.app/en/docs/features">Возможности</a> |
<a href="https://link.langbot.app/en/docs/guide">Документация</a> |
<a href="https://link.langbot.app/en/docs/api">API</a>
<a href="https://langbot.app/docs/en/insight/features">Возможности</a> |
<a href="https://langbot.app/docs/en/insight/guide">Документация</a> |
<a href="https://langbot.app/docs/en/tags/readme">API</a>
<a href="https://space.langbot.app">Магазин плагинов</a>
<a href="https://langbot.featurebase.app/roadmap">Дорожная карта</a>
@@ -48,7 +48,7 @@ LangBot — это **платформа с открытым исходным к
- **Веб-панель управления** — Настраивайте, управляйте и мониторьте ваших ботов через интуитивный браузерный интерфейс. Ручное редактирование YAML не требуется.
- **Мультиконвейерная архитектура** — Разные боты для разных сценариев с комплексным мониторингом и обработкой исключений.
[→ Подробнее обо всех возможностях](https://link.langbot.app/en/docs/features)
[→ Подробнее обо всех возможностях](https://langbot.app/docs/en/insight/features)
📍 Практические руководства: [развернуть мультиплатформенного ИИ-бота за 5 минут](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [подключить DeepSeek к WeChat, Discord и Telegram](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [запустить Dify Agent в Discord, Telegram и Slack](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/) и [создать чат-бота на n8n](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
@@ -88,7 +88,7 @@ docker compose --profile all up -d
[![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)
**Другие варианты:** [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)
**Другие варианты:** [Docker](https://langbot.app/docs/en/deploy/langbot/docker) · [Ручная установка](https://langbot.app/docs/en/deploy/langbot/manual) · [BTPanel](https://langbot.app/docs/en/deploy/langbot/one-click/bt) · [Kubernetes](https://langbot.app/docs/en/deploy/langbot/kubernetes)
---
@@ -149,7 +149,7 @@ docker compose --profile all up -d
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Платформа GPU | ✅ |
| [Qiniu](https://www.qiniu.com/ai/agent) | Шлюз | ✅ |
[→ Смотреть все интеграции](https://link.langbot.app/en/docs/features)
[→ Смотреть все интеграции](https://langbot.app/docs/en/insight/features)
---
+6 -6
View File
@@ -21,9 +21,9 @@
[![star](https://gitcode.com/RockChinQ/LangBot/star/badge.svg)](https://gitcode.com/RockChinQ/LangBot)
<a href="https://langbot.app">官網</a>
<a href="https://link.langbot.app/zh/docs/features">特性</a>
<a href="https://link.langbot.app/zh/docs/guide">文件</a>
<a href="https://link.langbot.app/zh/docs/api">API</a>
<a href="https://langbot.app/docs/zh/insight/features">特性</a>
<a href="https://langbot.app/docs/zh/insight/guide">文件</a>
<a href="https://langbot.app/docs/zh/tags/readme">API</a>
<a href="https://space.langbot.app">外掛市場</a>
<a href="https://langbot.featurebase.app/roadmap">路線圖</a>
@@ -50,7 +50,7 @@ LangBot 是一個**開源的生產級平台**,用於建構 AI 驅動的即時
- **Web 管理面板** — 透過瀏覽器直觀地配置、管理和監控機器人,無需手動編輯設定檔。
- **多流水線架構** — 不同機器人用於不同場景,具備全面的監控和異常處理能力。
[→ 了解更多功能特性](https://link.langbot.app/zh/docs/features)
[→ 了解更多功能特性](https://langbot.app/docs/zh/insight/features)
📍 實踐指南:[5 分鐘部署多平台 AI 機器人](https://langbot.app/zh/blog/deploy-ai-bot-in-5-minutes/)、[將 DeepSeek 接入微信、企業微信與 Discord](https://langbot.app/zh/blog/connect-deepseek-to-wechat/)、[讓 Dify Agent 跑在 Discord、Telegram 和 Slack 上](https://langbot.app/zh/blog/dify-agent-discord-telegram-slack/),以及[用 n8n 建構多平台 AI 聊天機器人](https://langbot.app/zh/blog/n8n-multi-platform-ai-chatbot/)。
@@ -90,7 +90,7 @@ docker compose --profile all up -d
[![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)
**更多方式:** [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)
**更多方式:** [Docker](https://langbot.app/docs/zh/deploy/langbot/docker) · [手動部署](https://langbot.app/docs/zh/deploy/langbot/manual) · [寶塔面板](https://langbot.app/docs/zh/deploy/langbot/one-click/bt) · [Kubernetes](https://langbot.app/docs/zh/deploy/langbot/kubernetes)
---
@@ -165,7 +165,7 @@ docker compose --profile all up -d
|-----------|------|
| 阿里雲百煉 | [外掛](https://github.com/Thetail001/LangBot_BailianTextToImagePlugin) |
[→ 查看完整整合列表](https://link.langbot.app/zh/docs/features)
[→ 查看完整整合列表](https://langbot.app/docs/zh/insight/features)
---
+6 -6
View File
@@ -19,9 +19,9 @@
[![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers)
<a href="https://langbot.app">Trang chủ</a>
<a href="https://link.langbot.app/en/docs/features">Tính năng</a>
<a href="https://link.langbot.app/en/docs/guide">Tài liệu</a>
<a href="https://link.langbot.app/en/docs/api">API</a>
<a href="https://langbot.app/docs/en/insight/features">Tính năng</a>
<a href="https://langbot.app/docs/en/insight/guide">Tài liệu</a>
<a href="https://langbot.app/docs/en/tags/readme">API</a>
<a href="https://space.langbot.app">Chợ Plugin</a>
<a href="https://langbot.featurebase.app/roadmap">Lộ trình</a>
@@ -48,7 +48,7 @@ LangBot là một **nền tảng mã nguồn mở, cấp sản xuất** để x
- **Bảng quản lý Web** — Cấu hình, quản lý và giám sát bot thông qua giao diện trình duyệt trực quan. Không cần chỉnh sửa YAML.
- **Kiến trúc đa Pipeline** — Các bot khác nhau cho các kịch bản khác nhau, với giám sát toàn diện và xử lý ngoại lệ.
[→ Tìm hiểu thêm về tất cả tính năng](https://link.langbot.app/en/docs/features)
[→ Tìm hiểu thêm về tất cả tính năng](https://langbot.app/docs/en/insight/features)
📍 Hướng dẫn thực hành: [triển khai bot AI đa nền tảng trong 5 phút](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [kết nối DeepSeek với WeChat, Discord và Telegram](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [chạy Dify Agent trên Discord, Telegram và Slack](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/) và [xây dựng chatbot với n8n](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
@@ -88,7 +88,7 @@ docker compose --profile all up -d
[![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)
**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)
**Thêm tùy chọn:** [Docker](https://langbot.app/docs/en/deploy/langbot/docker) · [Thủ công](https://langbot.app/docs/en/deploy/langbot/manual) · [BTPanel](https://langbot.app/docs/en/deploy/langbot/one-click/bt) · [Kubernetes](https://langbot.app/docs/en/deploy/langbot/kubernetes)
---
@@ -149,7 +149,7 @@ docker compose --profile all up -d
| [302.AI](https://share.302ai.cn/SuTG99) | Cổng | ✅ |
| [Qiniu](https://www.qiniu.com/ai/agent) | Cổng | ✅ |
[→ Xem tất cả tích hợp](https://link.langbot.app/en/docs/features)
[→ Xem tất cả tích hợp](https://langbot.app/docs/en/insight/features)
---
+1 -1
View File
@@ -1,5 +1,5 @@
# Docker Compose configuration for LangBot
# For Kubernetes deployment, see kubernetes.yaml and the deployment guide at https://docs.langbot.app
# For Kubernetes deployment, see kubernetes.yaml and the deployment guide at https://langbot.app/docs
version: "3"
services:
+1 -1
View File
@@ -1,7 +1,7 @@
# Kubernetes Deployment for LangBot
# This file provides Kubernetes deployment manifests for LangBot based on docker-compose.yaml
#
# Full deployment guide (zh/en/ja): https://docs.langbot.app -> Installation -> Kubernetes
# Full deployment guide (zh/en/ja): https://langbot.app/docs -> Installation -> Kubernetes
#
# Usage:
# kubectl -n langbot create secret generic langbot-plugin-runtime-control \
+2 -2
View File
@@ -218,8 +218,8 @@ metadata:
spec:
categories: [popular, global]
help_links:
zh: https://docs.langbot.app/zh/platforms/http-bot
en: https://docs.langbot.app/en/platforms/http-bot
zh: https://langbot.app/docs/zh/platforms/http-bot
en: https://langbot.app/docs/en/platforms/http-bot
config:
- { name: inbound_secret, type: string, required: true, default: "" }
- { name: callback_url, type: string, required: false, default: "" }
+1 -1
View File
@@ -243,7 +243,7 @@ For large datasets:
- SeekDB GitHub: https://github.com/oceanbase/seekdb
- pyseekdb SDK: https://github.com/oceanbase/pyseekdb
- OceanBase Documentation: https://oceanbase.ai
- LangBot Documentation: https://docs.langbot.app
- LangBot Documentation: https://langbot.app/docs
## License
+1 -1
View File
@@ -6,7 +6,7 @@ Minimal, dependency-light clients for the LangBot **HTTP Bot** platform adapter.
They show the whole loop: signing a request, pushing a message, and receiving
multi-part replies on a callback endpoint.
Full guide: [docs.langbot.app — HTTP Bot](https://docs.langbot.app/en/usage/platforms/http-bot).
Full guide: [docs.langbot.app — HTTP Bot](https://langbot.app/docs/en/usage/platforms/http-bot).
Machine-readable contract: [`docs/http-bot-openapi.json`](../../docs/http-bot-openapi.json).
## Files
+1 -1
View File
@@ -6,7 +6,7 @@
它们完整展示了整条链路:对请求签名、推送一条消息、在回调端点接收
1→M 的多段回复。
完整指南:[docs.langbot.app —— HTTP Bot](https://docs.langbot.app/zh/usage/platforms/http-bot)。
完整指南:[docs.langbot.app —— HTTP Bot](https://langbot.app/docs/zh/usage/platforms/http-bot)。
机器可读的接口契约:[`docs/http-bot-openapi.json`](../../docs/http-bot-openapi.json)。
## 文件清单
+1 -1
View File
@@ -6,7 +6,7 @@ A single self-contained HTML page that demos the LangBot **Page Bot**
(`web_page_bot`) embeddable chat widget — the one you drop onto any website with
a single `<script>` tag.
Full guide: [docs.langbot.app — Page Bot](https://docs.langbot.app/en/usage/platforms/webpage).
Full guide: [docs.langbot.app — Page Bot](https://langbot.app/docs/en/usage/platforms/webpage).
## Files
+1 -1
View File
@@ -6,7 +6,7 @@
(`web_page_bot`) 的可嵌入聊天组件 —— 也就是你用一行 `<script>` 标签就能放到任意
网站上的那个组件。
完整指南:[docs.langbot.app —— 页面机器人](https://docs.langbot.app/zh/usage/platforms/webpage)。
完整指南:[docs.langbot.app —— 页面机器人](https://langbot.app/docs/zh/usage/platforms/webpage)。
## 文件清单
+3 -3
View File
@@ -1,6 +1,6 @@
[project]
name = "langbot"
version = "4.10.9"
version = "4.10.10"
description = "Production-grade platform for building agentic IM bots"
readme = "README.md"
license-files = ["LICENSE"]
@@ -70,7 +70,7 @@ dependencies = [
"langchain-text-splitters>=1.1.2",
"chromadb>=1.0.0,<2.0.0",
"qdrant-client (>=1.15.1,<2.0.0)",
"langbot-plugin==0.5.6",
"langbot-plugin==0.5.7",
"asyncpg>=0.30.0",
"line-bot-sdk>=3.19.0",
"matrix-nio>=0.25.2",
@@ -114,7 +114,7 @@ seekdb = [
[project.urls]
Homepage = "https://langbot.app"
Documentation = "https://docs.langbot.app"
Documentation = "https://langbot.app/docs"
Repository = "https://github.com/langbot-app/LangBot"
[project.scripts]
+2 -1
View File
@@ -1349,7 +1349,8 @@
"local-agent",
"tools",
"e2b",
"nsjail"
"nsjail",
"host"
],
"automation": "",
"setup_automation": [],
+8 -2
View File
@@ -48,7 +48,7 @@ tools, skill add/edit, and stdio MCP are disabled. Set `box.enabled: false`
## Kubernetes
See `docker/kubernetes.yaml` and the deployment guide at
https://docs.langbot.app. `docker/deploy-k8s-test.sh` is a test helper.
https://langbot.app/docs. `docker/deploy-k8s-test.sh` is a test helper.
## config.yaml (generated at `data/config.yaml` on first run)
@@ -63,7 +63,7 @@ Key settings:
| `api.global_api_key` | **Global API key** for the HTTP API + MCP server. Non-empty = accepted with no login/DB record; no `lbk_` prefix required. Empty = disabled. Plaintext — trusted/internal only, serve over HTTPS. |
| `plugin.runtime_ws_url` | Standalone plugin runtime WS URL (e.g. `ws://langbot_plugin_runtime:5400/control/ws`) |
| `box.enabled` | Master switch for the Box sandbox runtime |
| `box.backend` | `local` (Docker/nsjail autopick) / `docker` / `nsjail` / `e2b`; env override `BOX__BACKEND` |
| `box.backend` | `local` (Docker/nsjail autopick) / `docker` / `nsjail` / `e2b` / explicit unsafe `host`; env override `BOX__BACKEND` |
| `box.runtime.endpoint` | External Box runtime URL (e.g. `ws://127.0.0.1:5410`); empty = local auto-managed |
Many keys have `ENV__SUBKEY` overrides (e.g. `BOX__BACKEND`, `BOX__ENABLED`).
@@ -75,6 +75,10 @@ Many keys have `ENV__SUBKEY` overrides (e.g. `BOX__BACKEND`, `BOX__ENABLED`).
with `--standalone-runtime`.
- Box has a parallel `--standalone-box` flag; the Docker box host is
`langbot_box:5410`.
- `box.backend: host` runs commands directly as the Box Runtime system user.
It is never auto-selected, provides no sandbox isolation, and is only for
trusted local development. A WebSocket-controlled host backend requires
`LANGBOT_BOX_CONTROL_TOKEN`; local stdio control is allowed.
## Global API key — enabling for agents/automation
@@ -93,5 +97,7 @@ login session. See `langbot-mcp-ops` for using it, and `docs/API_KEY_AUTH.md`.
- "No supported sandbox backend (Docker / nsjail / E2B)" with Docker running
usually means the user isn't in the `docker` group →
`sudo usermod -aG docker <user>` and restart in a new shell.
- Do not use `box.backend: host` as a production fallback. It cannot enforce
image, filesystem, network, PID, CPU, memory, or storage isolation.
- Box root host/container path mismatch breaks sandbox container creation.
- Don't commit a non-empty `api.global_api_key` to version control.
@@ -13,6 +13,7 @@ tags:
- tools
- e2b
- nsjail
- host
skills:
- langbot-env-setup
- langbot-testing
@@ -23,7 +24,7 @@ env:
- LANGBOT_LOCAL_AGENT_PIPELINE_NAME
preconditions:
- "LANGBOT_LOCAL_AGENT_PIPELINE_URL or LANGBOT_LOCAL_AGENT_PIPELINE_NAME points to the local-agent pipeline under test."
- "LangBot is started with the sandbox backend intended for this run, such as e2b or nsjail."
- "LangBot is started with the Box backend intended for this run, such as e2b, nsjail, or explicit host development mode."
- "The selected model route supports tool/function calling strongly enough to invoke sandbox tools."
steps:
- "Start LangBot with the target sandbox backend and confirm the Box status UI or LANGBOT_BACKEND_URL /api/v1/box/status reports the expected backend."
@@ -33,7 +34,7 @@ steps:
checks:
- "UI: Debug Chat final assistant response contains E2E_OK:<skill-name>."
- "Logs: The model called exec, register_skill, activate, then exec again from the activated skill path."
- "Logs: The selected backend name is the expected one, such as e2b or nsjail."
- "Logs: The selected backend name is the expected one, such as e2b, nsjail, or host."
- "Skill store: The registered package and activated writeback match references/sandbox-skill-authoring.md."
- "Box status: recent_error_count is 0 after the run."
evidence_required:
@@ -4,7 +4,7 @@
Verify that Local Agent can use sandbox tools to create, register, activate, and use a LangBot skill package through the same path a user would exercise in Debug Chat.
This flow applies to Docker, nsjail, and E2B backends. API calls are useful diagnostics, but the primary pass/fail signal is the model-driven Debug Chat tool sequence.
This flow applies to Docker, nsjail, E2B, and the explicit host development backend. Host runs commands directly as the Box Runtime user and must never be treated as sandbox-isolation coverage. API calls are useful diagnostics, but the primary pass/fail signal is the model-driven Debug Chat tool sequence.
## Preconditions
@@ -13,6 +13,7 @@ This flow applies to Docker, nsjail, and E2B backends. API calls are useful diag
- `BOX_BACKEND=e2b` when validating E2B.
- `BOX_BACKEND=nsjail` when validating nsjail.
- `BOX_BACKEND=local` or `docker` when validating local container fallback.
- `BOX_BACKEND=host` only when validating explicit, trusted local direct execution.
3. Confirm `/api/v1/box/status` reports `available: true` and the expected backend name.
4. Confirm Debug Chat uses a model with function-calling ability.
5. Confirm backend logs say native sandbox tools are available.
@@ -71,7 +72,7 @@ Backend logs should show:
- `register_skill`
- `activate`
- a second `exec` whose workdir is `/workspace/.skills/<skill-name>`
- `backend=e2b`, `backend=nsjail`, or the expected local backend
- `backend=e2b`, `backend=nsjail`, `backend=host`, or the expected local backend
After the run, verify the skill store through the UI or API:
@@ -125,6 +126,8 @@ For E2B raw HTTP diagnostics, include a valid template id such as `base`; a miss
- Session metadata should keep LangBot logical paths such as `/workspace`; storing provider-internal paths can make later requests look incompatible.
- nsjail versions differ. Some expose only `--disable_clone_new*` flags and use `--bindmount` instead of `--rw_bind`.
- On WSL, cgroup v2 may exist but not be writable. The backend should warn and fall back to rlimits rather than fail the sandbox.
- The host backend does not honor sandbox image, network, rootfs, process, or
resource isolation. Use a disposable workspace and low-privilege account.
- If `ALL_PROXY` uses a SOCKS URL and `socksio` is not installed, some Python HTTP clients can fail during startup. Prefer consistent HTTP proxy variables unless SOCKS support is installed.
## Related Troubleshooting
@@ -3,7 +3,7 @@ title: "Native sandbox tools are unavailable even though a backend is configured
date: 2026-05-18
symptoms:
- "Backend logs show Native sandbox tools (exec/read/write/edit/glob/grep) are NOT available."
- "The Box runtime later reports that E2B, nsjail, or Docker is configured."
- "The Box runtime later reports that E2B, nsjail, Docker, or explicit host mode is configured."
- "Debug Chat does not expose exec, register_skill, or activate as usable tools."
patterns:
- "Native sandbox tools ... are NOT available"
@@ -19,6 +19,7 @@ fix_steps:
- "Ensure the Box runtime reselects a backend when get_backend_info is called and the cached backend is empty."
- "For E2B, verify the key without printing it and confirm any required template setting."
- "For nsjail, run nsjail --help and confirm the binary is on PATH for the LangBot process."
- "For trusted local development only, explicitly set box.backend=host; never use host as a production sandbox fallback."
verification: "Run sandbox-skill-authoring-e2e. Logs should show Native sandbox tools are available and /api/v1/box/status should report available=true with the expected backend."
related_cases:
- sandbox-skill-authoring-e2e
+1 -1
View File
@@ -16,7 +16,7 @@ asciiart = r"""
|___/
Open Source 开源地址: https://github.com/langbot-app/LangBot
📖 Documentation 文档地址: https://docs.langbot.app
📖 Documentation 文档地址: https://langbot.app/docs
"""
+3 -2
View File
@@ -697,9 +697,10 @@ class DingTalkClient:
if not await self.check_access_token():
await self.get_access_token()
cardData: dict = {'cardParamMap': _stringify_card_param_map(card_param_map)}
template_params = dict(card_param_map or {})
if card_data_config is not None:
cardData['config'] = json.dumps(card_data_config)
template_params['config'] = card_data_config
cardData: dict = {'cardParamMap': _stringify_card_param_map(template_params)}
body: dict = {
'cardTemplateId': card_template_id,
@@ -218,6 +218,7 @@ class MonitoringRouterGroup(group.RouterGroup):
pipeline_ids = quart.request.args.getlist('pipelineId')
start_time_str = quart.request.args.get('startTime')
end_time_str = quart.request.args.get('endTime')
user_query = quart.request.args.get('userQuery')
is_active_str = quart.request.args.get('isActive')
limit = int(quart.request.args.get('limit', 100))
offset = int(quart.request.args.get('offset', 0))
@@ -237,6 +238,7 @@ class MonitoringRouterGroup(group.RouterGroup):
pipeline_ids=pipeline_ids if pipeline_ids else None,
start_time=start_time,
end_time=end_time,
user_query=user_query,
is_active=is_active,
limit=limit,
offset=offset,
@@ -396,7 +398,14 @@ class MonitoringRouterGroup(group.RouterGroup):
@self.route('/sessions/<session_id>/analysis', methods=['GET'], permission=Permission.RESOURCE_VIEW)
async def get_session_analysis(session_id: str, request_context: RequestContext) -> str:
"""Get detailed analysis for a specific session"""
analysis = await self.ap.monitoring_service.get_session_analysis(request_context, session_id)
start_time = parse_iso_datetime(quart.request.args.get('startTime'))
end_time = parse_iso_datetime(quart.request.args.get('endTime'))
analysis = await self.ap.monitoring_service.get_session_analysis(
request_context,
session_id,
start_time=start_time,
end_time=end_time,
)
# Always return success with the analysis data
# The frontend will handle the 'found: false' case
@@ -186,6 +186,9 @@ class UserRouterGroup(group.RouterGroup):
json_data = await quart.request.json
code = json_data.get('code')
state = json_data.get('state')
redirect_uri = json_data.get('redirect_uri') or (
quart.request.url_root.rstrip('/') + '/auth/space/callback'
)
launch_assertion = json_data.get('launch_assertion')
workspace_uuid = json_data.get('workspace_uuid')
@@ -199,8 +202,11 @@ class UserRouterGroup(group.RouterGroup):
return self.fail(1, 'Missing authorization code')
if not state:
return self.fail(1, 'Missing state parameter')
if not str(code).startswith('v4_'):
return self.fail(1, 'Unsupported Space OAuth code contract')
try:
redirect_uri = self._validate_space_redirect_uri(str(redirect_uri), bind=False)
consumed_state = await self.ap.user_service.consume_space_oauth_state_details(state, 'login')
# Exchange code for tokens
launch_workspace_uuid = consumed_state.launch_workspace_uuid
@@ -218,24 +224,36 @@ class UserRouterGroup(group.RouterGroup):
code,
workspace_uuids,
workspace_created_ats,
redirect_uri=redirect_uri,
)
access_token = token_data.get('access_token')
refresh_token = token_data.get('refresh_token')
expires_in = token_data.get('expires_in', 0)
cloud_workspace_uuid = token_data.get('cloud_workspace_uuid')
if not access_token:
return self.fail(1, 'Failed to get access token from Space')
# Authenticate and create/update local user
cloud_mode = getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud'
if cloud_mode and launch_workspace_uuid and launch_workspace_uuid != cloud_workspace_uuid:
return self.fail(1, 'Space OAuth Workspace binding mismatch')
target_workspace_uuid = launch_workspace_uuid or cloud_workspace_uuid
if cloud_mode:
if not target_workspace_uuid:
return self.fail(1, 'Space OAuth response is missing the Cloud Workspace binding')
await self.ap.directory_projection_service.reconcile_workspaces((target_workspace_uuid,))
# Authenticate only after the signed, exact Workspace delta has
# established the Account and membership runtime shadow rows.
jwt_token, user_obj = await self.ap.user_service.authenticate_space_user(
access_token, refresh_token, expires_in
)
if launch_workspace_uuid:
if target_workspace_uuid:
try:
access = await self.ap.workspace_collaboration_service.resolve_account_workspace(
user_obj.uuid,
launch_workspace_uuid,
target_workspace_uuid,
)
except Exception:
self.ap.logger.warning('Rejected Space OAuth launch for unauthorized Workspace')
@@ -367,12 +385,17 @@ class UserRouterGroup(group.RouterGroup):
json_data = await quart.request.json
code = json_data.get('code')
state = json_data.get('state')
redirect_uri = json_data.get('redirect_uri') or (
quart.request.url_root.rstrip('/') + '/auth/space/callback?mode=bind'
)
if not code:
return self.http_status(400, -1, 'Missing authorization code')
if not state:
return self.http_status(400, -1, 'Missing state parameter')
if not str(code).startswith('v4_'):
return self.http_status(400, -1, 'Unsupported Space OAuth code contract')
try:
user_obj = await self.ap.user_service.consume_space_oauth_state(state, 'bind')
@@ -385,7 +408,10 @@ class UserRouterGroup(group.RouterGroup):
return self.http_status(400, -1, 'Only local accounts can bind to Space')
try:
updated_user = await self.ap.user_service.bind_space_account(user_obj.user, code)
redirect_uri = self._validate_space_redirect_uri(str(redirect_uri), bind=True)
updated_user = await self.ap.user_service.bind_space_account(
user_obj.user, code, redirect_uri=redirect_uri
)
jwt_token = await self.ap.user_service.generate_jwt_token(updated_user)
return self.success(
data={
@@ -428,6 +454,10 @@ class UserRouterGroup(group.RouterGroup):
}
)
projection_service = self.ap.directory_projection_service
if projection_service is None:
raise SpaceLaunchError('Cloud directory projection is unavailable')
await projection_service.reconcile_workspaces((launch['workspace_uuid'],))
account = await self.ap.user_service.get_user_by_uuid(launch['account_uuid'])
if account is None:
raise SpaceLaunchError('Launch Account is not projected into Core')
+10 -1
View File
@@ -137,7 +137,16 @@ class BotService:
bot = await self.get_bot(context, bot_data['uuid'], include_secret=True)
await self.ap.platform_mgr.load_bot(context, bot)
try:
await self.ap.platform_mgr.load_bot(context, bot)
except Exception:
# The bot row was already inserted above; without this rollback a
# failing adapter constructor (e.g. a missing optional credential
# key) would leave a permanently disabled orphan bot in the DB.
await self.ap.persistence_mgr.execute_async(
sqlalchemy.delete(persistence_bot.Bot).where(persistence_bot.Bot.uuid == bot_data['uuid'])
)
raise
return bot_data['uuid']
+20 -4
View File
@@ -1257,6 +1257,7 @@ class MonitoringService:
pipeline_ids: list[str] | None = None,
start_time: datetime.datetime | None = None,
end_time: datetime.datetime | None = None,
user_query: str | None = None,
is_active: bool | None = None,
limit: int = 100,
offset: int = 0,
@@ -1274,6 +1275,14 @@ class MonitoringService:
conditions.append(persistence_monitoring.MonitoringSession.start_time >= start_time)
if end_time:
conditions.append(persistence_monitoring.MonitoringSession.start_time <= end_time)
if user_query and user_query.strip():
user_pattern = f'%{user_query.strip()}%'
conditions.append(
sqlalchemy.or_(
persistence_monitoring.MonitoringSession.user_id.ilike(user_pattern),
persistence_monitoring.MonitoringSession.user_name.ilike(user_pattern),
)
)
if is_active is not None:
conditions.append(persistence_monitoring.MonitoringSession.is_active == is_active)
@@ -1365,6 +1374,8 @@ class MonitoringService:
self,
context: TenantContext,
session_id: str,
start_time: datetime.datetime | None = None,
end_time: datetime.datetime | None = None,
) -> dict:
"""Get bounded session details with full statistics computed in SQL."""
workspace_uuid = require_workspace_uuid(context)
@@ -1478,12 +1489,17 @@ class MonitoringService:
)
)
tool_stats = tool_stats_result.one()
tool_conditions = [
persistence_monitoring.MonitoringToolCall.workspace_uuid == workspace_uuid,
persistence_monitoring.MonitoringToolCall.session_id == session_id,
]
if start_time is not None:
tool_conditions.append(persistence_monitoring.MonitoringToolCall.timestamp >= start_time)
if end_time is not None:
tool_conditions.append(persistence_monitoring.MonitoringToolCall.timestamp <= end_time)
tool_query = (
sqlalchemy.select(persistence_monitoring.MonitoringToolCall)
.where(
persistence_monitoring.MonitoringToolCall.workspace_uuid == workspace_uuid,
persistence_monitoring.MonitoringToolCall.session_id == session_id,
)
.where(*tool_conditions)
.order_by(persistence_monitoring.MonitoringToolCall.timestamp.asc())
.limit(detail_limit + 1)
)
+4 -1
View File
@@ -119,7 +119,7 @@ class SpaceService:
space_config = self._get_space_config()
authorize_url = space_config['oauth_authorize_url']
params = {'redirect_uri': redirect_uri}
params = {'redirect_uri': redirect_uri, 'code_contract': 'redirect-v1'}
if state:
params['state'] = state
return f'{authorize_url}?{urlencode(params)}'
@@ -129,6 +129,8 @@ class SpaceService:
code: str,
workspace_uuids: list[str] | None = None,
workspace_created_ats: dict[str, int] | None = None,
*,
redirect_uri: str = '',
) -> typing.Dict:
"""Exchange OAuth authorization code for tokens"""
from langbot.pkg.utils import constants
@@ -141,6 +143,7 @@ class SpaceService:
f'{space_url}/api/v1/accounts/oauth/token',
json={
'code': code,
'redirect_uri': redirect_uri,
'instance_id': constants.instance_id,
# Sending an explicit empty list tells new Space servers not to
# synthesize a legacy instance-derived Workspace binding.
+3 -2
View File
@@ -774,7 +774,7 @@ class UserService:
f'email:{normalized_email}',
)
async def bind_space_account(self, user_email: str, code: str) -> user.User:
async def bind_space_account(self, user_email: str, code: str, *, redirect_uri: str = '') -> user.User:
"""Bind Space account to existing local account"""
local_account = await self.get_user_by_email(user_email)
if local_account is None:
@@ -794,12 +794,13 @@ class UserService:
code,
[binding.workspace_uuid],
{binding.workspace_uuid: created_ts},
redirect_uri=redirect_uri,
)
else:
# Compatibility for early/bootstrap call sites that have not wired
# WorkspaceService yet; old Space servers still derive the legacy
# Workspace identity from instance_id when the field is omitted.
token_data = await self.ap.space_service.exchange_oauth_code(code)
token_data = await self.ap.space_service.exchange_oauth_code(code, redirect_uri=redirect_uri)
access_token = token_data.get('access_token')
refresh_token = token_data.get('refresh_token')
expires_in = token_data.get('expires_in', 0)
+10 -3
View File
@@ -455,7 +455,9 @@ class BoxService:
async def _require_validated_workspace_sandbox(self, execution_context: ExecutionContext) -> None:
if not self._available:
raise BoxError('Box runtime is not available. Install and start Docker to use sandbox features.')
raise BoxError(
'Box runtime is not available. Configure an available Box backend before using Box features.'
)
if self._cloud_managed:
if self._admission is None:
raise BoxAdmissionError('Cloud Box sandbox admission is unavailable')
@@ -565,7 +567,9 @@ class BoxService:
skip_host_mount_validation: bool = False,
) -> dict:
if not self._available:
raise BoxError('Box runtime is not available. Install and start Docker to use sandbox features.')
raise BoxError(
'Box runtime is not available. Configure an available Box backend before using Box features.'
)
execution_context = await self._validated_execution_context(self._query_execution_context(query))
spec_payload = self._managed_policy_payload(execution_context, spec_payload)
await self._require_validated_workspace_sandbox(execution_context)
@@ -2142,5 +2146,8 @@ class BoxService:
if backend_name:
payload['connector_error'] = f'Configured sandbox backend "{backend_name}" is unavailable'
else:
payload['connector_error'] = 'No supported sandbox backend (Docker / nsjail / E2B) is available'
payload['connector_error'] = (
'No supported sandbox backend (Docker / nsjail / E2B) is available. '
'Trusted local development may explicitly select the unsafe host backend.'
)
return payload
+100 -2
View File
@@ -125,10 +125,21 @@ class DirectoryProjectionService:
# The database cursor remains the shared projection high-water mark,
# while this cursor tracks what this process has actually observed.
self._consumer_cursor: int | None = None
self._sync_lock = asyncio.Lock()
async def initialize(self) -> None:
"""Block Cloud startup until one full signed snapshot is committed."""
async with self._sync_lock:
await self._refresh_snapshot()
async def refresh_snapshot(self) -> None:
"""Refresh from one full signed snapshot within the sync single-flight."""
async with self._sync_lock:
await self._refresh_snapshot()
async def _refresh_snapshot(self) -> None:
last_superseded: _DirectorySnapshotSuperseded | None = None
for _attempt in range(5):
snapshot = await self.provider.fetch_snapshot(self.instance_uuid)
@@ -159,9 +170,84 @@ class DirectoryProjectionService:
delay = min(max(delay * 2, self.sync_interval_seconds), self.max_staleness_seconds / 2)
async def sync_once(self) -> None:
async with self._sync_lock:
await self._sync_once()
async def reconcile_workspaces(self, workspace_uuids: Iterable[str]) -> None:
"""Synchronously project an exact Workspace set without moving the event cursor."""
requested = tuple(sorted({str(value).strip() for value in workspace_uuids if str(value).strip()}))
if not requested:
raise DirectoryProjectionUnavailableError('Targeted directory reconciliation requires a Workspace')
if len(requested) > self.event_limit:
raise DirectoryProjectionUnavailableError('Targeted directory reconciliation exceeds the batch limit')
async with self._sync_lock:
delta = await self.provider.fetch_workspaces(self.instance_uuid, requested)
await self._apply_targeted_delta(delta, requested)
async def _apply_targeted_delta(
self,
delta: DirectoryDelta,
requested_workspace_uuids: tuple[str, ...],
) -> None:
if not isinstance(delta, DirectoryDelta):
raise DirectoryProjectionUnavailableError('Directory provider returned an invalid delta')
workspace_count, membership_count = self._validate_batch_capacity(
delta.workspaces,
full_snapshot=False,
)
delta = DirectoryDelta.model_validate(delta.model_dump())
if delta.instance_uuid != self.instance_uuid:
raise DirectoryProjectionUnavailableError('Directory delta targets another LangBot instance')
requested = set(requested_workspace_uuids)
if set(delta.requested_workspace_uuids) != requested:
raise DirectoryProjectionUnavailableError('Directory delta does not match the requested Workspaces')
if {workspace.uuid for workspace in delta.workspaces} != requested:
raise DirectoryProjectionUnavailableError('Directory delta omitted a requested Workspace')
directory_uow = getattr(self.ap.persistence_mgr, 'directory_projection_uow', None)
if not callable(directory_uow):
raise DirectoryProjectionUnavailableError('Directory projection persistence scope is unavailable')
async with directory_uow(self.instance_uuid) as uow:
session = uow.session
state = await session.scalar(
sqlalchemy.select(DirectoryProjectionState)
.where(DirectoryProjectionState.instance_uuid == self.instance_uuid)
.with_for_update()
)
if state is None:
raise DirectoryProjectionUnavailableError('Directory projection is not initialized')
snapshot = DirectorySnapshot(
instance_uuid=self.instance_uuid,
cursor=state.cursor,
generated_at=delta.generated_at,
workspaces=delta.workspaces,
)
accounts_by_uuid = await self._apply_accounts(session, snapshot, preserve_existing=True)
await self._apply_workspaces(session, snapshot, accounts_by_uuid=accounts_by_uuid)
active_workspace_count = await self._enforce_active_workspace_capacity(session)
await session.flush()
await self._update_entitlement_workspace_activity(
snapshot.workspaces,
requested_workspace_uuids=requested,
)
self._publish_runtime_execution_projection(
snapshot.workspaces,
affected_workspace_uuids=requested,
)
self._request_model_catalog_sync()
self._record_batch_cardinality(
active_workspaces=active_workspace_count,
workspaces=workspace_count,
memberships=membership_count,
)
async def _sync_once(self) -> None:
cursor = self._consumer_cursor
if cursor is None:
await self.initialize()
await self._refresh_snapshot()
return
batch = await self.provider.fetch_events(
self.instance_uuid,
@@ -708,7 +794,13 @@ class DirectoryProjectionService:
for row in inbox_rows:
row.applied_at = now
async def _apply_accounts(self, session: Any, snapshot: DirectorySnapshot) -> dict[str, User]:
async def _apply_accounts(
self,
session: Any,
snapshot: DirectorySnapshot,
*,
preserve_existing: bool = False,
) -> dict[str, User]:
selected: dict[str, DirectoryMember] = {}
emails: dict[str, str] = {}
for workspace in snapshot.workspaces:
@@ -773,6 +865,12 @@ class DirectoryProjectionService:
continue
if account.source != AccountSource.CLOUD_PROJECTION.value:
raise DirectoryProjectionUnavailableError('Directory account UUID collides with a local Core account')
if preserve_existing:
# A targeted Workspace fetch has no independently monotonic
# Account revision. It may create a missing runtime shadow, but
# ordered event/snapshot projection remains the only updater of
# existing Account identity and status fields.
continue
if account.projection_revision > snapshot.cursor:
raise DirectoryProjectionUnavailableError('Directory account revision rolled back')
projected_account = self._account_projection(member)
+2 -2
View File
@@ -635,9 +635,9 @@ class Application:
frontend_path = paths.get_frontend_path()
if not os.path.exists(frontend_path):
self.logger.warning('WebUI 文件缺失,请根据文档部署:https://docs.langbot.app/zh')
self.logger.warning('WebUI 文件缺失,请根据文档部署:https://langbot.app/docs/zh')
self.logger.warning(
'WebUI files are missing, please deploy according to the documentation: https://docs.langbot.app/en'
'WebUI files are missing, please deploy according to the documentation: https://langbot.app/docs/en'
)
return
@@ -15,9 +15,9 @@ spec:
categories:
- protocol
help_links:
zh: https://link.langbot.app/zh/platforms/aiocqhttp
en: https://link.langbot.app/en/platforms/aiocqhttp
ja: https://link.langbot.app/ja/platforms/aiocqhttp
zh: https://langbot.app/docs/zh/usage/platforms/qq/aiocqhttp/napcat
en: https://langbot.app/docs/en/usage/platforms/qq/aiocqhttp/napcat
ja: https://langbot.app/docs/ja/usage/platforms/qq/aiocqhttp/napcat
config:
- name: host
label:
@@ -15,9 +15,9 @@ spec:
categories:
- china
help_links:
zh: https://link.langbot.app/zh/platforms/dingtalk
en: https://link.langbot.app/en/platforms/dingtalk
ja: https://link.langbot.app/ja/platforms/dingtalk
zh: https://langbot.app/docs/zh/usage/platforms/dingtalk
en: https://langbot.app/docs/en/usage/platforms/dingtalk
ja: https://langbot.app/docs/ja/usage/platforms/dingtalk
config:
- name: one-click-create
label:
@@ -24,9 +24,9 @@ spec:
- popular
- global
help_links:
zh: https://link.langbot.app/zh/platforms/discord
en: https://link.langbot.app/en/platforms/discord
ja: https://link.langbot.app/ja/platforms/discord
zh: https://langbot.app/docs/zh/usage/platforms/discord
en: https://langbot.app/docs/en/usage/platforms/discord
ja: https://langbot.app/docs/ja/usage/platforms/discord
config:
- name: client_id
label:
@@ -18,9 +18,9 @@ spec:
- popular
- global
help_links:
zh: https://docs.langbot.app/zh/platforms/http-bot
en: https://docs.langbot.app/en/platforms/http-bot
ja: https://docs.langbot.app/ja/platforms/http-bot
zh: https://langbot.app/docs/zh/platforms/http-bot
en: https://langbot.app/docs/en/platforms/http-bot
ja: https://langbot.app/docs/ja/platforms/http-bot
config:
- name: webhook_url
label:
+3 -3
View File
@@ -15,9 +15,9 @@ spec:
categories:
- china
help_links:
zh: https://link.langbot.app/zh/platforms/kook
en: https://link.langbot.app/en/platforms/kook
ja: https://link.langbot.app/ja/platforms/kook
zh: https://langbot.app/docs/zh/usage/platforms/kook
en: https://langbot.app/docs/en/usage/platforms/kook
ja: https://langbot.app/docs/ja/usage/platforms/kook
config:
- name: token
label:
+32 -4
View File
@@ -160,6 +160,29 @@ def _lark_should_update_stream_element(
return not resume_from and not form_data and (msg_seq % 8 == 0 or is_final)
def _lark_final_layout_texts(
*,
resume_from: bool,
text_message: str,
pre_pause_cached: str | None,
resume_cached: str,
) -> tuple[str, str]:
"""Return (main_text, resume_placeholder_text) for the final card update.
Non-resume round: the full reply belongs in the main streaming element
only also rendering the resume placeholder duplicates the reply, since
both hold the same accumulated text. Resume round (Dify HITL): keep the
pre-pause text in the main element and the resumed text in the
placeholder, as they are distinct segments.
"""
if resume_from:
# An empty pre-pause cache is valid (Dify paused before emitting any
# text); only a missing entry (None) falls back to the full text.
main_text = text_message if pre_pause_cached is None else pre_pause_cached
return main_text, resume_cached
return text_message, ''
def _lark_display_input_value(field: dict, value: typing.Any) -> str:
field_type = _dify_field_type(field)
if field_type == 'file':
@@ -2358,16 +2381,21 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
self.card_form_input_defs[card_id] = _lark_form_input_defs(form_data)
self.card_form_inputs[card_id] = dict(form_data.get('inputs') or {})
else:
# Normal finish: keep pre-pause + resume content visible,
# remove buttons/notice, drop the resume placeholder.
# Normal finish: remove buttons/notice and finalize the card.
main_text, resume_text = _lark_final_layout_texts(
resume_from=resume_from,
text_message=text_message,
pre_pause_cached=self.card_pre_pause_text.get(card_id),
resume_cached=resume_cached,
)
await self._update_card_layout(
card_id=card_id,
message_source=message_source,
text_message=pre_pause,
text_message=main_text,
sequence=final_seq,
form_data=None,
notice_text=selected_notice if resume_from else '',
resume_placeholder_text=resume_cached,
resume_placeholder_text=resume_text,
)
self._drop_card_state(card_id)
self.card_id_dict.pop(message_id, None)
+3 -3
View File
@@ -19,9 +19,9 @@ spec:
- china
- global
help_links:
zh: https://link.langbot.app/zh/platforms/lark
en: https://link.langbot.app/en/platforms/lark
ja: https://link.langbot.app/ja/platforms/lark
zh: https://langbot.app/docs/zh/usage/platforms/lark
en: https://langbot.app/docs/en/usage/platforms/lark
ja: https://langbot.app/docs/ja/usage/platforms/lark
config:
- name: domain
label:
+3 -3
View File
@@ -22,9 +22,9 @@ spec:
categories:
- global
help_links:
zh: https://link.langbot.app/zh/platforms/line
en: https://link.langbot.app/en/platforms/line
ja: https://link.langbot.app/ja/platforms/line
zh: https://langbot.app/docs/zh/usage/platforms/line
en: https://langbot.app/docs/en/usage/platforms/line
ja: https://langbot.app/docs/ja/usage/platforms/line
config:
- name: webhook_url
label:
@@ -15,9 +15,9 @@ spec:
categories:
- china
help_links:
zh: https://link.langbot.app/zh/platforms/officialaccount
en: https://link.langbot.app/en/platforms/officialaccount
ja: https://link.langbot.app/ja/platforms/officialaccount
zh: https://langbot.app/docs/zh/usage/platforms/wxoa
en: https://langbot.app/docs/en/usage/platforms/wxoa
ja: https://langbot.app/docs/ja/usage/platforms/wxoa
config:
- name: webhook_url
label:
@@ -16,9 +16,9 @@ spec:
- popular
- china
help_links:
zh: https://link.langbot.app/zh/platforms/openclaw_weixin
en: https://link.langbot.app/en/platforms/openclaw_weixin
ja: https://link.langbot.app/ja/platforms/openclaw_weixin
zh: https://langbot.app/docs/zh/usage/platforms/wechat/weixin
en: https://langbot.app/docs/en/usage/platforms/readme
ja: https://langbot.app/docs/ja/usage/platforms/readme
config:
- name: base_url
label:
@@ -205,7 +205,7 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
bot = QQOfficialClient(
app_id=config['appid'],
secret=config['secret'],
token=config['token'],
token=config.get('token', ''),
logger=logger,
unified_mode=enable_webhook,
)
@@ -15,9 +15,9 @@ spec:
categories:
- china
help_links:
zh: https://link.langbot.app/zh/platforms/qqofficial
en: https://link.langbot.app/en/platforms/qqofficial
ja: https://link.langbot.app/ja/platforms/qqofficial
zh: https://langbot.app/docs/zh/usage/platforms/qq/official_webhook
en: https://langbot.app/docs/en/usage/platforms/qq/official_webhook
ja: https://langbot.app/docs/ja/usage/platforms/qq/official_webhook
config:
- name: __system.outbound_ips
label:
+3 -3
View File
@@ -21,9 +21,9 @@ spec:
categories:
- protocol
help_links:
zh: https://link.langbot.app/zh/platforms/satori
en: https://link.langbot.app/en/platforms/satori
ja: https://link.langbot.app/ja/platforms/satori
zh: https://langbot.app/docs/zh/usage/platforms/readme
en: https://langbot.app/docs/en/usage/platforms/readme
ja: https://langbot.app/docs/ja/usage/platforms/readme
config:
- name: platform
label:
+3 -3
View File
@@ -24,9 +24,9 @@ spec:
- popular
- global
help_links:
zh: https://link.langbot.app/zh/platforms/slack
en: https://link.langbot.app/en/platforms/slack
ja: https://link.langbot.app/ja/platforms/slack
zh: https://langbot.app/docs/zh/usage/platforms/slack
en: https://langbot.app/docs/en/usage/platforms/slack
ja: https://langbot.app/docs/ja/usage/platforms/slack
config:
- name: webhook_url
label:
@@ -24,9 +24,9 @@ spec:
- popular
- global
help_links:
zh: https://link.langbot.app/zh/platforms/telegram
en: https://link.langbot.app/en/platforms/telegram
ja: https://link.langbot.app/ja/platforms/telegram
zh: https://langbot.app/docs/zh/usage/platforms/telegram
en: https://langbot.app/docs/en/usage/platforms/telegram
ja: https://langbot.app/docs/ja/usage/platforms/telegram
config:
- name: token
label:
@@ -15,9 +15,9 @@ spec:
categories:
- china
help_links:
zh: https://link.langbot.app/zh/platforms/wechatpad
en: https://link.langbot.app/en/platforms/wechatpad
ja: https://link.langbot.app/ja/platforms/wechatpad
zh: https://langbot.app/docs/zh/usage/platforms/wechat/wechatpad
en: https://langbot.app/docs/en/usage/platforms/readme
ja: https://langbot.app/docs/ja/usage/platforms/readme
config:
- name: wechatpad_url
label:
+3 -3
View File
@@ -274,11 +274,11 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
if content['type'] == 'text':
await self.bot.send_private_msg(user_id, agent_id, content['content'])
if content['type'] == 'image':
await self.bot.send_image(user_id, agent_id, content['media'])
await self.bot.send_image(user_id, agent_id, content['media_id'])
if content['type'] == 'voice':
await self.bot.send_voice(user_id, agent_id, content['media'])
await self.bot.send_voice(user_id, agent_id, content['media_id'])
if content['type'] == 'file':
await self.bot.send_file(user_id, agent_id, content['media'])
await self.bot.send_file(user_id, agent_id, content['media_id'])
def register_listener(
self,
+3 -3
View File
@@ -16,9 +16,9 @@ spec:
- popular
- china
help_links:
zh: https://link.langbot.app/zh/platforms/wecom
en: https://link.langbot.app/en/platforms/wecom
ja: https://link.langbot.app/ja/platforms/wecom
zh: https://langbot.app/docs/zh/usage/platforms/wecom/wecom
en: https://langbot.app/docs/en/usage/platforms/wecom/wecom
ja: https://langbot.app/docs/ja/usage/platforms/wecom/wecom
config:
- name: webhook_url
label:
@@ -15,9 +15,9 @@ spec:
categories:
- china
help_links:
zh: https://link.langbot.app/zh/platforms/wecombot
en: https://link.langbot.app/en/platforms/wecombot
ja: https://link.langbot.app/ja/platforms/wecombot
zh: https://langbot.app/docs/zh/usage/platforms/wecom/wecombot
en: https://langbot.app/docs/en/usage/platforms/wecom/wecombot
ja: https://langbot.app/docs/ja/usage/platforms/wecom/wecombot
config:
- name: one-click-create
label:
@@ -15,9 +15,9 @@ spec:
categories:
- china
help_links:
zh: https://link.langbot.app/zh/platforms/wecomcs
en: https://link.langbot.app/en/platforms/wecomcs
ja: https://link.langbot.app/ja/platforms/wecomcs
zh: https://langbot.app/docs/zh/usage/platforms/wecom/wecomcs
en: https://langbot.app/docs/en/usage/platforms/wecom/wecomcs
ja: https://langbot.app/docs/ja/usage/platforms/wecom/wecomcs
config:
- name: webhook_url
label:
@@ -573,7 +573,7 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
levels = ['provider_default', 'disabled', 'enabled']
elif family == 'doubao':
levels = ['provider_default', 'disabled', 'low', 'medium', 'high']
elif family == 'ollama':
elif family in ('ollama', 'ollama_chat'):
levels = ['provider_default']
levels.append('disabled')
if normalized_name.startswith('gpt-oss') or '/gpt-oss' in normalized_name:
@@ -1345,7 +1345,14 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
extra_args: dict[str, typing.Any] = {},
) -> tuple[list[list[float]], dict]:
"""Invoke embedding and return vectors with usage info."""
model_name = self._build_litellm_model_name(model.model_entity.name)
# litellm's embedding routing has no "ollama_chat" branch (that provider
# exists only for /api/chat completions) — embeddings still go through
# the plain "ollama" provider. Requesters configured for ollama_chat
# (to get native tool-calling on the chat path) must fall back to
# "ollama" here specifically, or embedding calls raise "Unmapped LLM
# provider for this endpoint".
embedding_provider = 'ollama' if self._get_custom_llm_provider() == 'ollama_chat' else None
model_name = self._build_litellm_model_name(model.model_entity.name, embedding_provider)
api_key = model.provider.token_mgr.get_token()
args = {
@@ -1541,6 +1548,12 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
event_hooks=httpclient.httpx_response_limit_hooks(),
) as client:
response = await client.get(models_url, headers=headers)
if response.status_code == 404 and not base_url.rstrip('/').endswith('/v1'):
# Some OpenAI-compatible servers (notably a bare Ollama host,
# e.g. http://host:11434) expose the model list under /v1/models
# rather than /models. Providers whose configured base_url
# already ends in /v1 keep their original (working) URL.
response = await client.get(f'{base_url}/v1/models', headers=headers)
response.raise_for_status()
payload = await httpclient.parse_json_response(response)
@@ -7,7 +7,7 @@ metadata:
zh_Hans: Ollama
icon: ollama.svg
spec:
litellm_provider: ollama
litellm_provider: ollama_chat
config:
- name: base_url
label:
+13 -1
View File
@@ -39,6 +39,9 @@ class N8nServiceAPIRunner(runner.RequestRunner):
# 获取输出键名,默认为response
self.output_key = self.pipeline_config['ai']['n8n-service-api'].get('output-key', 'response')
self.response_handling = self.pipeline_config['ai']['n8n-service-api'].get('response-handling', 'reply')
if self.response_handling not in {'reply', 'ignore'}:
raise ValueError(f'Invalid n8n response-handling: {self.response_handling}')
# 获取认证类型,默认为none
self.auth_type = self.pipeline_config['ai']['n8n-service-api'].get('auth-type', 'none')
@@ -262,7 +265,11 @@ class N8nServiceAPIRunner(runner.RequestRunner):
async with session.post(
self.webhook_url, json=payload, headers=headers, auth=auth, timeout=self.timeout
) as response:
if response.status != 200:
if self.response_handling == 'ignore':
status_ok = 200 <= response.status < 300
else:
status_ok = response.status == 200
if not status_ok:
error_text = (
await httpclient.read_limited(
response,
@@ -272,6 +279,11 @@ class N8nServiceAPIRunner(runner.RequestRunner):
self.ap.logger.error(f'n8n webhook call failed: {response.status}, {error_text}')
raise Exception(f'n8n webhook call failed: {response.status}, {error_text}')
if self.response_handling == 'ignore':
response.release()
self.ap.logger.debug('n8n async webhook accepted; response body ignored')
return
async for chunk in self._process_response(response):
if is_stream:
yield chunk
@@ -222,6 +222,7 @@ class NativeToolLoader(loader.ToolLoader):
self.ap.logger.warning(
'Native sandbox tools (exec/read/write/edit/glob/grep) are NOT available. '
'No sandbox backend (Docker/nsjail/E2B) is ready. '
'Trusted local development may explicitly select box.backend=host. '
'The LLM will not have access to code execution or file operation tools.'
)
@@ -42,7 +42,8 @@ class SkillToolLoader(loader.ToolLoader):
else:
self.ap.logger.info(
'Skill tools (activate/register_skill) are NOT available. '
'No sandbox backend (Docker/nsjail/E2B) is ready.'
'No sandbox backend (Docker/nsjail/E2B) is ready. '
'Trusted local development may explicitly select box.backend=host.'
)
async def _check_sandbox_available(self) -> bool:
+1 -1
View File
@@ -83,7 +83,7 @@ class VersionManager:
try:
if await self.is_new_version_available():
return (
'New version available. Update guide: https://link.langbot.app/en/docs/update',
'New version available. Update guide: https://langbot.app/docs/en/deploy/update',
logging.INFO,
)
except Exception as e:
@@ -269,7 +269,7 @@ class InvitationDeliveryService:
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="width:100%;max-width:600px;">
<tr>
<td style="padding:0 4px 20px;">
<img src="https://docs.langbot.app/langbot-logo.png" alt="LangBot" width="34" height="34" style="display:inline-block;width:34px;height:34px;border:0;vertical-align:middle;">
<img src="https://langbot.app/docs/langbot-logo.png" alt="LangBot" width="34" height="34" style="display:inline-block;width:34px;height:34px;border:0;vertical-align:middle;">
<span style="display:inline-block;margin-left:10px;vertical-align:middle;font-size:18px;font-weight:700;letter-spacing:-.01em;">LangBot</span>
</td>
</tr>
+4 -1
View File
@@ -331,7 +331,10 @@ box:
# skill tool, skill add/edit, and stdio-mode MCP servers. Skills can still
# be listed read-only and http/sse MCP servers continue to work.
enabled: true
backend: 'local' # 'local' (Docker/nsjail), 'docker', 'nsjail', or 'e2b'. Can be written via BOX__BACKEND.
# 'host' runs commands directly as the Box Runtime user without sandbox
# isolation. It is never auto-selected and is only for trusted local
# development. Can be written via BOX__BACKEND.
backend: 'local' # 'local' (Docker/nsjail), 'docker', 'nsjail', 'e2b', or explicit unsafe 'host'.
runtime:
# LANGBOT_BOX_CONTROL_TOKEN is optional for OSS external WebSocket
# runtimes. To protect an exposed endpoint, set the same strong secret
@@ -80,7 +80,8 @@
"header-name": "",
"header-value": "",
"timeout": 120,
"output-key": "response"
"output-key": "response",
"response-handling": "reply"
},
"langflow-api": {
"base-url": "http://localhost:7860",
+4 -3
View File
@@ -642,9 +642,10 @@
.replace(/\s+/g, " ")
.trim();
if (
prevContent === content ||
prevContent.indexOf(content) >= 0 ||
content.indexOf(prevContent) >= 0
prevContent &&
(prevContent === content ||
prevContent.indexOf(content) >= 0 ||
content.indexOf(prevContent) >= 0)
)
return;
}
@@ -475,6 +475,25 @@ stages:
type: string
required: false
default: 'response'
- name: response-handling
label:
en_US: Webhook Response Handling
zh_Hans: Webhook 响应处理方式
description:
en_US: Choose whether LangBot forwards the n8n webhook response to the chat user. Ignore mode requires the n8n Webhook node to use Respond Immediately.
zh_Hans: 选择是否将 n8n Webhook 响应转发给聊天用户。忽略模式要求 n8n Webhook 节点使用“立即响应”。
type: select
required: false
default: 'reply'
options:
- name: reply
label:
en_US: Forward as chat reply
zh_Hans: 转发为聊天回复
- name: ignore
label:
en_US: Ignore response body (asynchronous workflow)
zh_Hans: 忽略响应正文(异步工作流)
- name: coze-api
label:
en_US: coze API
+24 -2
View File
@@ -242,6 +242,22 @@ class TestMonitoringSessionsEndpoint:
assert response.status_code == 200
@pytest.mark.asyncio
async def test_get_sessions_forwards_user_search_and_page_window(self, quart_test_client, fake_monitoring_app):
fake_monitoring_app.monitoring_service.get_sessions.reset_mock()
response = await quart_test_client.get(
'/api/v1/monitoring/sessions?botId=bot-1&userQuery=alice&limit=20&offset=40',
headers={'Authorization': 'Bearer test_token'},
)
assert response.status_code == 200
kwargs = fake_monitoring_app.monitoring_service.get_sessions.await_args.kwargs
assert kwargs['bot_ids'] == ['bot-1']
assert kwargs['user_query'] == 'alice'
assert kwargs['limit'] == 20
assert kwargs['offset'] == 40
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestMonitoringErrorsEndpoint:
@@ -278,13 +294,19 @@ class TestMonitoringDetailsEndpoints:
"""Tests for detail endpoints."""
@pytest.mark.asyncio
async def test_get_session_analysis(self, quart_test_client):
async def test_get_session_analysis(self, quart_test_client, fake_monitoring_app):
"""GET /api/v1/monitoring/sessions/{id}/analysis."""
response = await quart_test_client.get(
'/api/v1/monitoring/sessions/sess-1/analysis', headers={'Authorization': 'Bearer test_token'}
'/api/v1/monitoring/sessions/sess-1/analysis'
'?startTime=2026-08-31T16%3A00%3A00.000Z'
'&endTime=2026-09-01T15%3A59%3A59.999Z',
headers={'Authorization': 'Bearer test_token'},
)
assert response.status_code == 200
kwargs = fake_monitoring_app.monitoring_service.get_session_analysis.await_args.kwargs
assert kwargs['start_time'].isoformat() == '2026-08-31T16:00:00'
assert kwargs['end_time'].isoformat() == '2026-09-01T15:59:59.999000'
@pytest.mark.asyncio
async def test_get_message_details(self, quart_test_client):
+198 -8
View File
@@ -27,7 +27,8 @@ async def space_oauth_api():
execution=SimpleNamespace(instance_uuid='instance-a', placement_generation=1),
)
application = Mock()
application.deployment = SimpleNamespace(multi_workspace_enabled=False)
application.deployment = SimpleNamespace(multi_workspace_enabled=False, mode='oss')
application.directory_projection_service = None
application.persistence_mgr = None
application.user_service.get_authenticated_account = AsyncMock(return_value=account)
application.user_service.issue_space_oauth_state = AsyncMock(
@@ -125,6 +126,26 @@ async def test_cloud_launch_state_is_server_issued_and_workspace_bound(space_oau
)
@pytest.mark.asyncio
async def test_cloud_login_entry_uses_normal_stateful_oauth(space_oauth_api):
application, client = space_oauth_api
application.deployment.mode = 'cloud'
response = await client.get(
'/api/v1/user/space/authorize-url',
query_string={
'redirect_uri': 'http://localhost/auth/space/callback',
'cloud_entry': '1',
},
headers={'Origin': 'http://localhost'},
)
assert response.status_code == 200
authorize_url = (await response.get_json())['data']['authorize_url']
assert authorize_url.startswith('https://space.example/authorize?state=')
application.user_service.issue_space_oauth_state.assert_awaited_once_with('login')
@pytest.mark.asyncio
async def test_public_login_rejects_caller_supplied_state(space_oauth_api):
application, client = space_oauth_api
@@ -249,10 +270,14 @@ async def test_server_side_webhook_origin_supports_bundled_ui(space_oauth_api):
async def test_login_callback_requires_and_consumes_server_state(space_oauth_api):
application, client = space_oauth_api
missing = await client.post('/api/v1/user/space/callback', json={'code': 'oauth-code'})
missing = await client.post('/api/v1/user/space/callback', json={'code': 'v4_oauth-code'})
response = await client.post(
'/api/v1/user/space/callback',
json={'code': 'oauth-code', 'state': 'opaque-login-state'},
json={
'code': 'v4_oauth-code',
'state': 'opaque-login-state',
'redirect_uri': 'https://oss.example/auth/space/callback',
},
)
assert (await missing.get_json())['code'] == 1
@@ -260,12 +285,146 @@ async def test_login_callback_requires_and_consumes_server_state(space_oauth_api
assert (await response.get_json())['data']['token'] == 'space-login-token'
application.user_service.consume_space_oauth_state_details.assert_awaited_once_with('opaque-login-state', 'login')
application.space_service.exchange_oauth_code.assert_awaited_once_with(
'oauth-code',
'v4_oauth-code',
[WORKSPACE_UUID],
{WORKSPACE_UUID: int(WORKSPACE_CREATED_AT.timestamp())},
redirect_uri='https://oss.example/auth/space/callback',
)
@pytest.mark.asyncio
async def test_login_callback_rejects_downgraded_legacy_code(space_oauth_api):
application, client = space_oauth_api
response = await client.post(
'/api/v1/user/space/callback',
json={'code': 'v2_legacy-code', 'state': 'opaque-login-state'},
)
payload = await response.get_json()
assert response.status_code == 200
assert payload['code'] == 1
assert 'code contract' in payload['msg']
application.space_service.exchange_oauth_code.assert_not_awaited()
@pytest.mark.asyncio
async def test_cloud_login_callback_reconciles_authorized_workspace_before_local_authentication(space_oauth_api):
application, client = space_oauth_api
application.deployment.mode = 'cloud'
calls: list[str] = []
application.directory_projection_service = SimpleNamespace(
reconcile_workspaces=AsyncMock(side_effect=lambda _workspace_uuids: calls.append('reconcile'))
)
application.space_service.exchange_oauth_code.return_value = {
'access_token': 'space-access-token',
'refresh_token': 'space-refresh-token',
'expires_in': 3600,
'cloud_workspace_uuid': WORKSPACE_UUID,
}
authenticated_account = application.user_service.authenticate_space_user.return_value[1]
async def authenticate(*_args):
calls.append('authenticate')
return 'space-login-token', authenticated_account
application.user_service.authenticate_space_user.side_effect = authenticate
response = await client.post(
'/api/v1/user/space/callback',
json={'code': 'v4_oauth-code', 'state': 'opaque-login-state'},
)
assert response.status_code == 200
assert (await response.get_json())['data']['workspace_uuid'] == WORKSPACE_UUID
assert calls == ['reconcile', 'authenticate']
application.directory_projection_service.reconcile_workspaces.assert_awaited_once_with((WORKSPACE_UUID,))
@pytest.mark.asyncio
async def test_cloud_login_callback_fails_closed_without_workspace_binding(space_oauth_api):
application, client = space_oauth_api
application.deployment.mode = 'cloud'
application.directory_projection_service = SimpleNamespace(reconcile_workspaces=AsyncMock())
response = await client.post(
'/api/v1/user/space/callback',
json={'code': 'v4_oauth-code', 'state': 'opaque-login-state'},
)
payload = await response.get_json()
assert response.status_code == 200
assert payload['code'] == 1
assert 'Cloud Workspace binding' in payload['msg']
application.directory_projection_service.reconcile_workspaces.assert_not_awaited()
application.user_service.authenticate_space_user.assert_not_awaited()
@pytest.mark.asyncio
async def test_cloud_login_callback_requires_code_binding_for_launch_state(space_oauth_api):
application, client = space_oauth_api
application.deployment.mode = 'cloud'
application.directory_projection_service = SimpleNamespace(reconcile_workspaces=AsyncMock())
application.user_service.consume_space_oauth_state_details.return_value = SimpleNamespace(
launch_workspace_uuid=WORKSPACE_UUID
)
response = await client.post(
'/api/v1/user/space/callback',
json={'code': 'v4_oauth-code', 'state': 'opaque-login-state'},
)
payload = await response.get_json()
assert response.status_code == 200
assert payload['code'] == 1
assert 'Workspace binding' in payload['msg']
application.directory_projection_service.reconcile_workspaces.assert_not_awaited()
application.user_service.authenticate_space_user.assert_not_awaited()
@pytest.mark.asyncio
async def test_cloud_login_callback_rejects_conflicting_state_and_code_workspace_bindings(space_oauth_api):
application, client = space_oauth_api
application.deployment.mode = 'cloud'
application.directory_projection_service = SimpleNamespace(reconcile_workspaces=AsyncMock())
application.user_service.consume_space_oauth_state_details.return_value = SimpleNamespace(
launch_workspace_uuid=WORKSPACE_UUID
)
application.space_service.exchange_oauth_code.return_value = {
'access_token': 'space-access-token',
'refresh_token': 'space-refresh-token',
'expires_in': 3600,
'cloud_workspace_uuid': 'workspace-from-another-flow',
}
response = await client.post(
'/api/v1/user/space/callback',
json={'code': 'v4_oauth-code', 'state': 'opaque-login-state'},
)
payload = await response.get_json()
assert response.status_code == 200
assert payload['code'] == 1
assert 'Workspace binding' in payload['msg']
application.directory_projection_service.reconcile_workspaces.assert_not_awaited()
application.user_service.authenticate_space_user.assert_not_awaited()
@pytest.mark.asyncio
async def test_oss_login_callback_does_not_request_cloud_reconciliation(space_oauth_api):
application, client = space_oauth_api
application.directory_projection_service = SimpleNamespace(reconcile_workspaces=AsyncMock())
response = await client.post(
'/api/v1/user/space/callback',
json={'code': 'v4_oauth-code', 'state': 'opaque-login-state'},
)
assert response.status_code == 200
application.directory_projection_service.reconcile_workspaces.assert_not_awaited()
@pytest.mark.asyncio
async def test_login_callback_launch_state_selects_asserted_workspace(space_oauth_api):
application, client = space_oauth_api
@@ -276,7 +435,7 @@ async def test_login_callback_launch_state_selects_asserted_workspace(space_oaut
response = await client.post(
'/api/v1/user/space/callback',
json={'code': 'oauth-code', 'state': 'opaque-login-state'},
json={'code': 'v4_oauth-code', 'state': 'opaque-login-state'},
)
assert response.status_code == 200
@@ -375,18 +534,22 @@ async def test_bind_callback_uses_opaque_state_and_never_treats_it_as_jwt(space_
rejected = await client.post(
'/api/v1/user/bind-space',
json={'code': 'attacker-code', 'state': 'jwt.must-not-be-used'},
json={'code': 'v4_attacker-code', 'state': 'jwt.must-not-be-used'},
)
response = await client.post(
'/api/v1/user/bind-space',
json={'code': 'oauth-code', 'state': 'opaque-bind-state'},
json={'code': 'v4_oauth-code', 'state': 'opaque-bind-state'},
)
assert rejected.status_code == 401
assert response.status_code == 200
assert (await response.get_json())['data']['token'] == 'rotated-account-token'
application.user_service.verify_jwt_token.assert_not_awaited()
application.user_service.bind_space_account.assert_awaited_once_with('owner@example.com', 'oauth-code')
application.user_service.bind_space_account.assert_awaited_once_with(
'owner@example.com',
'v4_oauth-code',
redirect_uri='http://localhost/auth/space/callback?mode=bind',
)
@pytest.mark.asyncio
@@ -394,6 +557,7 @@ async def test_direct_launch_assertion_does_not_consume_normal_oauth_state(space
application, client = space_oauth_api
application.user_service.consume_space_oauth_state.reset_mock()
application.space_service.exchange_oauth_code.reset_mock()
application.directory_projection_service = SimpleNamespace(reconcile_workspaces=AsyncMock())
response = await client.post(
'/api/v1/user/space/callback',
@@ -414,3 +578,29 @@ async def test_direct_launch_assertion_does_not_consume_normal_oauth_state(space
)
application.user_service.consume_space_oauth_state.assert_not_awaited()
application.space_service.exchange_oauth_code.assert_not_awaited()
@pytest.mark.asyncio
async def test_direct_launch_reconciles_exact_workspace_before_resolving_access(space_oauth_api):
application, client = space_oauth_api
projected_account = SimpleNamespace(
uuid='account-a',
user='owner@example.com',
account_type='space',
status='active',
)
application.user_service.get_user_by_uuid = AsyncMock(return_value=projected_account)
application.directory_projection_service = SimpleNamespace(reconcile_workspaces=AsyncMock())
response = await client.post(
'/api/v1/user/space/callback',
json={
'workspace_uuid': WORKSPACE_UUID,
'launch_assertion': 'signed-launch-token',
},
)
assert response.status_code == 200
assert (await response.get_json())['data']['workspace_uuid'] == WORKSPACE_UUID
application.directory_projection_service.reconcile_workspaces.assert_awaited_once_with((WORKSPACE_UUID,))
application.user_service.get_user_by_uuid.assert_awaited_once_with('account-a')
@@ -12,6 +12,7 @@ import pytest
from unittest.mock import AsyncMock, MagicMock, Mock, patch
from types import SimpleNamespace
import json
import sqlalchemy
import uuid
from langbot.pkg.api.http.service.bot import BotService
@@ -449,10 +450,58 @@ class TestBotServiceCreateBot:
insert_statement = ap.persistence_mgr.execute_async.await_args_list[1].args[0]
insert_values = insert_statement.compile().params
assert insert_values['workspace_uuid'] == WORKSPACE_UUID
assert insert_values['use_pipeline_uuid'] == 'default-pipeline-uuid'
assert insert_values['use_pipeline_name'] == 'Default Pipeline'
assert bot_uuid is not None # Verify UUID was returned
async def test_create_bot_rolls_back_insert_when_load_bot_fails(self):
"""Deletes the inserted row when the adapter fails to load.
Regression: a failing adapter constructor (e.g. KeyError on a missing
optional credential key) used to leave a permanently disabled orphan
bot in the DB the insert was already committed and the HTTP layer
surfaced a 500 without any cleanup.
"""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.instance_config = SimpleNamespace()
ap.instance_config.data = {'system': {'limitation': {'max_bots': -1}}}
ap.platform_mgr = SimpleNamespace()
ap.platform_mgr.load_bot = AsyncMock(side_effect=KeyError('token'))
pipeline_result = Mock()
pipeline_result.first = Mock(return_value=None)
bot_result = Mock()
bot_result.first = Mock(return_value=_create_mock_bot())
executed_statements = []
async def mock_execute(query):
executed_statements.append(query)
if len(executed_statements) <= 2:
return pipeline_result # 1: limitation bots query, 2: pipeline query
if len(executed_statements) == 3:
return Mock() # insert
return bot_result # get_bot after insert
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
ap.persistence_mgr.serialize_model = Mock(return_value={'uuid': 'new-uuid', 'name': 'New Bot'})
service = BotService(ap)
# Execute & Verify: the adapter error propagates
with pytest.raises(KeyError, match='token'):
await service.create_bot(
WORKSPACE_UUID, {'name': 'New Bot', 'adapter': 'telegram', 'adapter_config': {}}
)
# And the inserted row is rolled back via a DELETE on the new uuid
# (no limitation query runs because max_bots=-1)
assert len(executed_statements) == 4 # pipeline select, insert, bot select, delete
delete_statement = executed_statements[-1]
assert isinstance(delete_statement, sqlalchemy.sql.dml.Delete)
compiled = delete_statement.compile()
assert compiled.params['uuid_1'] is not None
class TestBotServiceUpdateBot:
"""Tests for update_bot method."""
@@ -138,6 +138,39 @@ async def test_same_session_and_resource_ids_do_not_collide(service):
assert (await service.get_message_details(context_a, message_b))['found'] is False
async def test_session_search_matches_user_id_or_name_within_workspace(service):
context_a = _context(WORKSPACE_A)
context_b = _context(WORKSPACE_B)
fixtures = [
(context_a, 'session-id-match', 'customer-42', 'Alice'),
(context_a, 'session-name-match', 'customer-99', 'Bob Alice Cooper'),
(context_a, 'session-no-match', 'customer-7', 'Bob'),
(context_b, 'session-other-workspace', 'customer-42', 'Alice'),
]
for context, session_id, user_id, user_name in fixtures:
await service.record_session_start(
context,
session_id=session_id,
bot_id='same-bot',
bot_name='Same Bot',
pipeline_id='same-pipeline',
pipeline_name='Same Pipeline',
user_id=user_id,
user_name=user_name,
)
by_id, id_total = await service.get_sessions(context_a, user_query='customer-42')
by_name, name_total = await service.get_sessions(context_a, user_query='alice')
assert id_total == 1
assert [session['session_id'] for session in by_id] == ['session-id-match']
assert name_total == 2
assert {session['session_id'] for session in by_name} == {
'session-id-match',
'session-name-match',
}
async def test_tool_call_inherits_context_from_connection_message_row(service):
context = _context(WORKSPACE_A)
message_id = await _record_message(service, context, 'tool context')
@@ -95,7 +95,9 @@ class TestSpaceServiceGetOAuthAuthorizeUrl:
result = service.get_oauth_authorize_url('http://localhost/callback')
# Verify
assert parse_qs(urlsplit(result).query)['redirect_uri'] == ['http://localhost/callback']
query = parse_qs(urlsplit(result).query)
assert query['redirect_uri'] == ['http://localhost/callback']
assert query['code_contract'] == ['redirect-v1']
assert 'https://space.langbot.app/auth/authorize' in result
def test_get_oauth_authorize_url_with_state(self):
@@ -578,12 +580,14 @@ class TestSpaceServiceExchangeOAuthCode:
'auth_code',
['workspace-1'],
{'workspace-1': 1_700_000_000},
redirect_uri='https://oss.example/auth/space/callback',
)
# Verify
assert result['access_token'] == 'new_access_token'
assert mock_session_obj.post.call_args.kwargs['json'] == {
'code': 'auth_code',
'redirect_uri': 'https://oss.example/auth/space/callback',
'instance_id': constants.instance_id,
'workspace_uuids': ['workspace-1'],
'workspace_created_ats': {'workspace-1': 1_700_000_000},
@@ -846,10 +850,7 @@ class TestSpaceServiceGetModelSelection:
if response_shape == 'models-envelope':
data = {'models': models}
elif response_shape == 'availability-wrapper':
data = [
{'model': model, 'latency_ms': index + 10, 'http_code': 200}
for index, model in enumerate(models)
]
data = [{'model': model, 'latency_ms': index + 10, 'http_code': 200} for index, model in enumerate(models)]
else:
data = models
payload = {'code': 0, 'data': data}
@@ -1,9 +1,10 @@
from __future__ import annotations
import asyncio
import datetime
import logging
from types import SimpleNamespace
from unittest.mock import Mock
from unittest.mock import AsyncMock, Mock
import pytest
import sqlalchemy
@@ -214,6 +215,88 @@ async def test_directory_delta_requests_model_catalog_sync_after_commit(projecti
request_sync.assert_called_once_with()
async def test_targeted_reconciliation_projects_new_workspace_without_advancing_event_cursor(projection_context):
application, session_factory = projection_context
provider = _Provider(
[_snapshot(7, workspaces=[])],
deltas=[_delta(workspaces=[_workspace(revision=8, name='JIT Workspace')])],
)
service = DirectoryProjectionService(application, provider, INSTANCE_UUID)
await service.initialize()
await service.reconcile_workspaces((WORKSPACE_UUID,))
async with session_factory() as session:
account = await session.scalar(sqlalchemy.select(User).where(User.uuid == ACCOUNT_UUID))
workspace = await session.get(Workspace, WORKSPACE_UUID)
membership = await session.scalar(
sqlalchemy.select(WorkspaceMembership).where(
WorkspaceMembership.workspace_uuid == WORKSPACE_UUID,
WorkspaceMembership.account_uuid == ACCOUNT_UUID,
)
)
state = await session.get(DirectoryProjectionState, INSTANCE_UUID)
assert account is not None
assert workspace is not None and workspace.name == 'JIT Workspace'
assert membership is not None and membership.status == 'active'
assert state is not None and state.cursor == 7
assert provider.delta_calls == 1
assert provider.after_cursors == []
async def test_targeted_reconciliation_preserves_existing_account_until_ordered_event_projection(projection_context):
application, session_factory = projection_context
targeted_workspace = _workspace(revision=8, name='Renamed Workspace').model_copy(
update={
'members': [
_member(revision=8).model_copy(update={'display_name': 'Changed Account Name'})
]
}
)
provider = _Provider(
[_snapshot(7)],
deltas=[_delta(workspaces=[targeted_workspace])],
)
service = DirectoryProjectionService(application, provider, INSTANCE_UUID)
await service.initialize()
await service.reconcile_workspaces((WORKSPACE_UUID,))
async with session_factory() as session:
account = await session.scalar(sqlalchemy.select(User).where(User.uuid == ACCOUNT_UUID))
workspace = await session.get(Workspace, WORKSPACE_UUID)
state = await session.get(DirectoryProjectionState, INSTANCE_UUID)
assert account is not None and account.user == 'Workspace Owner'
assert account.projection_revision == 7
assert workspace is not None and workspace.name == 'Renamed Workspace'
assert state is not None and state.cursor == 7
async def test_targeted_reconciliation_only_updates_requested_workspace_side_effects(projection_context):
application, _session_factory = projection_context
provider = _Provider(
[_snapshot(7, workspaces=[])],
deltas=[_delta(workspaces=[_workspace(revision=8, name='JIT Workspace')])],
)
service = DirectoryProjectionService(application, provider, INSTANCE_UUID)
await service.initialize()
service._reconcile_entitlement_snapshot_set = AsyncMock()
service._update_entitlement_workspace_activity = AsyncMock()
service._publish_runtime_execution_projection = Mock()
await service.reconcile_workspaces((WORKSPACE_UUID,))
service._reconcile_entitlement_snapshot_set.assert_not_awaited()
service._update_entitlement_workspace_activity.assert_awaited_once()
assert service._update_entitlement_workspace_activity.await_args.kwargs == {
'requested_workspace_uuids': {WORKSPACE_UUID},
}
service._publish_runtime_execution_projection.assert_called_once()
assert service._publish_runtime_execution_projection.call_args.kwargs == {
'affected_workspace_uuids': {WORKSPACE_UUID},
}
async def test_initial_snapshot_projects_core_owned_rows(projection_context):
application, session_factory = projection_context
reconcile_execution_projection = Mock()
@@ -735,6 +818,72 @@ async def test_each_replica_consumes_events_with_its_own_cursor(projection_conte
assert second_provider.after_cursors == [1, 2]
async def test_concurrent_sync_once_calls_are_serialized_per_service(projection_context):
application, _session_factory = projection_context
class _ConcurrentProvider(_Provider):
def __init__(self) -> None:
super().__init__([_snapshot(1)])
self.first_fetch_started = asyncio.Event()
self.release_first_fetch = asyncio.Event()
self.active_fetches = 0
self.max_active_fetches = 0
async def fetch_events(
self,
instance_uuid: str,
after_cursor: int,
limit: int,
) -> DirectoryEventBatch:
assert instance_uuid == INSTANCE_UUID
assert limit == 100
self.after_cursors.append(after_cursor)
self.active_fetches += 1
self.max_active_fetches = max(self.max_active_fetches, self.active_fetches)
try:
if len(self.after_cursors) == 1:
self.first_fetch_started.set()
await self.release_first_fetch.wait()
cursor = after_cursor + 1
return DirectoryEventBatch(
instance_uuid=instance_uuid,
after_cursor=after_cursor,
cursor=cursor,
high_water_cursor=cursor,
events=(
DirectoryEvent(
cursor=cursor,
uuid=f'40000000-0000-4000-8000-{cursor:012d}',
aggregate_uuid=WORKSPACE_UUID,
event_type='entitlement.changed',
revision=cursor,
payload={
'workspace_uuid': WORKSPACE_UUID,
'entitlement_revision': cursor,
},
created_at=datetime.datetime(2026, 7, 24, 12, cursor, tzinfo=datetime.UTC),
),
),
)
finally:
self.active_fetches -= 1
provider = _ConcurrentProvider()
service = DirectoryProjectionService(application, provider, INSTANCE_UUID)
await service.initialize()
first = asyncio.create_task(service.sync_once())
await provider.first_fetch_started.wait()
second = asyncio.create_task(service.sync_once())
await asyncio.sleep(0)
provider.release_first_fetch.set()
await asyncio.gather(first, second)
assert provider.max_active_fetches == 1
assert provider.after_cursors == [1, 2]
assert service._consumer_cursor == 3
async def test_snapshot_coverage_allows_lagging_replica_to_replay_receipts(projection_context):
application, session_factory = projection_context
event_two = DirectoryEvent(
+88 -1
View File
@@ -55,7 +55,7 @@ finally:
# ---------------------------------------------------------------------------
def make_runner(output_key: str = 'response') -> N8nServiceAPIRunner:
def make_runner(output_key: str = 'response', response_handling: str = 'reply') -> N8nServiceAPIRunner:
ap = Mock()
ap.logger = Mock()
pipeline_config = {
@@ -63,6 +63,7 @@ def make_runner(output_key: str = 'response') -> N8nServiceAPIRunner:
'n8n-service-api': {
'webhook-url': 'http://test-n8n/webhook',
'output-key': output_key,
'response-handling': response_handling,
'auth-type': 'none',
}
}
@@ -287,6 +288,7 @@ def make_http_session_mock(response_bytes: bytes, status: int = 200):
"""Mock httpclient.get_session() returning a session whose post() yields response_bytes."""
mock_response = make_mock_response([response_bytes], status=status)
mock_response.status = status
mock_response.headers = {}
mock_cm = AsyncMock()
mock_cm.__aenter__ = AsyncMock(return_value=mock_response)
@@ -314,6 +316,91 @@ async def test_call_webhook_nonstream_adapter_plain_json():
assert results[0].content == 'result text'
@pytest.mark.asyncio
@pytest.mark.parametrize('status', [200, 201, 202, 204])
@pytest.mark.parametrize(
'response_body',
[
b'{"message":"Workflow was started"}',
b'{"response":"must not be forwarded"}',
b'plain acknowledgement',
],
)
async def test_call_webhook_ignore_response_body(response_body: bytes, status: int):
"""Ignore mode accepts any HTTP 2xx response without emitting chat output."""
runner = make_runner(response_handling='ignore')
query = make_query(is_stream=False)
http_session = make_http_session_mock(response_body, status=status)
with patch('langbot.pkg.provider.runners.n8nsvapi.httpclient.get_session', return_value=http_session):
results = []
async for message in runner._call_webhook(query):
results.append(message)
assert results == []
@pytest.mark.asyncio
async def test_call_webhook_ignore_releases_without_reading_response_body():
"""Ignore mode returns after the success status without waiting for the body."""
runner = make_runner(response_handling='ignore')
query = make_query(is_stream=False)
mock_response = make_mock_response([], status=202)
mock_response.headers = {}
mock_response.release = Mock()
async def fail_if_read(_size):
raise AssertionError('ignore mode must not read the response body')
yield b''
mock_response.content.iter_chunked = fail_if_read
mock_cm = AsyncMock()
mock_cm.__aenter__ = AsyncMock(return_value=mock_response)
mock_cm.__aexit__ = AsyncMock(return_value=False)
mock_session = Mock()
mock_session.post = Mock(return_value=mock_cm)
with patch('langbot.pkg.provider.runners.n8nsvapi.httpclient.get_session', return_value=mock_session):
results = [message async for message in runner._call_webhook(query)]
assert results == []
mock_response.release.assert_called_once_with()
@pytest.mark.asyncio
@pytest.mark.parametrize('status', [201, 202, 204])
async def test_call_webhook_reply_mode_preserves_http_200_contract(status: int):
"""Reply mode remains backward compatible and rejects non-200 statuses."""
runner = make_runner(response_handling='reply')
query = make_query(is_stream=False)
http_session = make_http_session_mock(b'', status=status)
with patch('langbot.pkg.provider.runners.n8nsvapi.httpclient.get_session', return_value=http_session):
with pytest.raises(N8nAPIError, match=f'n8n webhook call failed: {status}'):
async for _ in runner._call_webhook(query):
pass
@pytest.mark.asyncio
async def test_call_webhook_ignore_mode_preserves_http_error():
"""Ignore mode must not swallow a failed n8n webhook response."""
runner = make_runner(response_handling='ignore')
query = make_query(is_stream=False)
http_session = make_http_session_mock(b'{"error":"unavailable"}', status=500)
with patch('langbot.pkg.provider.runners.n8nsvapi.httpclient.get_session', return_value=http_session):
with pytest.raises(N8nAPIError, match='n8n webhook call exception'):
async for _ in runner._call_webhook(query):
pass
@pytest.mark.asyncio
async def test_invalid_response_handling_is_rejected():
"""Configuration errors should fail fast instead of silently changing reply behavior."""
with pytest.raises(ValueError, match='Invalid n8n response-handling'):
make_runner(response_handling='unexpected')
@pytest.mark.asyncio
async def test_call_webhook_stream_adapter_stream_format():
"""Stream adapter + stream format → MessageChunks, last is_final."""
+42 -1
View File
@@ -1,8 +1,11 @@
"""Tests for DingTalk API payload helpers."""
import json
from contextlib import asynccontextmanager
from unittest.mock import AsyncMock
from langbot.libs.dingtalk_api.api import _stringify_card_param_map
from langbot.libs.dingtalk_api.api import DingTalkClient, _stringify_card_param_map
from langbot.pkg.utils import httpclient
def test_dingtalk_card_param_map_stringifies_select_component_arrays():
@@ -40,3 +43,41 @@ def test_dingtalk_card_param_map_stringifies_unregistered_structures():
assert params['other'] == '["A"]'
assert params['empty'] == ''
async def test_create_card_embeds_layout_config_as_template_parameter(monkeypatch):
response = type('Response', (), {'status_code': 200})()
post = AsyncMock(return_value=response)
@asynccontextmanager
async def client_context():
yield type('HttpClient', (), {'post': post})()
client = object.__new__(DingTalkClient)
client.access_token = 'access-token'
client.robot_code = 'robot-code'
client.key = 'client-id'
client.logger = None
client.check_access_token = AsyncMock(return_value=True)
client._http_client_context = client_context
monkeypatch.setattr(httpclient, 'response_text', AsyncMock(return_value='{}'))
original_params = {'content': 'hello'}
delivered = await client.create_and_deliver_card(
card_template_id='template-id',
out_track_id='track-id',
open_space_id='dtv1.card//IM_ROBOT.user-id',
is_group=False,
card_param_map=original_params,
card_data_config={'autoLayout': True},
)
request_body = post.await_args.kwargs['json']
assert delivered is True
assert request_body['cardData'] == {
'cardParamMap': {
'content': 'hello',
'config': '{"autoLayout": true}',
}
}
assert original_params == {'content': 'hello'}
+120 -1
View File
@@ -1,7 +1,7 @@
"""Tests for Lark adapter helper behavior."""
import threading
from unittest.mock import MagicMock
from unittest.mock import AsyncMock, MagicMock
import pytest
@@ -12,6 +12,7 @@ from langbot.pkg.platform.sources.lark import (
_lark_completed_input_lines,
_lark_current_input_defs,
_lark_extract_action_form_inputs,
_lark_final_layout_texts,
_lark_should_update_stream_element,
_lark_visible_form_content,
)
@@ -221,3 +222,121 @@ def test_lark_completed_input_lines_display_select_value_from_object():
)
assert lines == ['✅ xialaB']
def test_lark_final_layout_texts_normal_round_drops_resume_placeholder():
"""Non-resume final chunk: the reply must land in the main element only.
Regression: rendering the resume placeholder too duplicated the reply,
because the accumulated streaming text equals the final text on a normal
round (e.g. 'It is Sep 1, 2026.\nIt is Sep 1, 2026.' in the card).
"""
main_text, resume_text = _lark_final_layout_texts(
resume_from=False,
text_message='It is Sep 1, 2026, 15:09:15.',
pre_pause_cached=None,
resume_cached='It is Sep 1, 2026, 15:09:15.',
)
assert main_text == 'It is Sep 1, 2026, 15:09:15.'
assert resume_text == ''
def test_lark_final_layout_texts_resume_round_keeps_both_segments():
"""Dify HITL resume final chunk: pre-pause text and resumed text differ,
both segments stay visible."""
main_text, resume_text = _lark_final_layout_texts(
resume_from=True,
text_message='resumed answer',
pre_pause_cached='partial answer before pause',
resume_cached='resumed answer',
)
assert main_text == 'partial answer before pause'
assert resume_text == 'resumed answer'
def test_lark_final_layout_texts_resume_round_without_pre_pause_falls_back():
main_text, resume_text = _lark_final_layout_texts(
resume_from=True,
text_message='answer',
pre_pause_cached=None,
resume_cached='answer',
)
assert main_text == 'answer'
assert resume_text == 'answer'
def test_lark_final_layout_texts_resume_round_empty_pre_pause_kept_empty():
"""Dify paused before emitting any text: the pre-pause cache is a valid
empty string and must NOT be treated as a cache miss.
Regression: `pre_pause_cached or text_message` fell back to the full
text, so the final card rendered ('resumed answer', 'resumed answer')
and duplicated the reply.
"""
main_text, resume_text = _lark_final_layout_texts(
resume_from=True,
text_message='resumed answer',
pre_pause_cached='',
resume_cached='resumed answer',
)
assert main_text == ''
assert resume_text == 'resumed answer'
def _build_resume_final_chunk_adapter(message_text: str):
"""Build a LarkAdapter whose card state mimics a Dify HITL round that
paused before emitting any text, then resumed and completed."""
adapter = LarkAdapter.model_construct(
api_client=MagicMock(),
message_converter=MagicMock(yiri2target=AsyncMock(return_value=([[{'tag': 'text', 'text': message_text}]], []))),
)
adapter.config = {'app_type': 'self'}
LarkAdapter.get_app_access_token = lambda self: None
LarkAdapter.get_tenant_access_token = lambda self, tenant_key: None
adapter.card_id_dict = {'msg-1': 'card-1'}
adapter.card_streaming_text = {'card-1': message_text}
adapter.card_pre_pause_text = {'card-1': ''}
adapter.card_resume_transitioned = {'card-1'}
adapter.card_sequence_dict = {}
adapter.card_last_accessed = {}
adapter.card_cleanup_at = 0.0
adapter.card_id_to_source_ids = {}
adapter.reply_message_card_ids = {}
adapter.card_form_content = {}
adapter.card_form_input_defs = {}
adapter.card_form_inputs = {}
adapter._update_card_layout = AsyncMock()
return adapter
@pytest.mark.asyncio
async def test_reply_message_chunk_resume_final_with_empty_pre_pause_keeps_main_empty():
"""End-to-end regression via reply_message_chunk: Dify paused before any
text, so the pre-pause cache is ''. The final card update must render the
resumed answer only once (empty main text + resume placeholder), not
twice as ('resumed answer', 'resumed answer')."""
adapter = _build_resume_final_chunk_adapter('resumed answer')
bot_message = MagicMock(
resp_message_id='msg-1',
msg_sequence=1,
spec=['resp_message_id', 'msg_sequence', '_resume_from_form'],
)
bot_message._resume_from_form = True
message_source = MagicMock(source_platform_object=None)
await adapter.reply_message_chunk(
message_source,
bot_message,
MagicMock(),
is_final=True,
)
adapter._update_card_layout.assert_awaited_once()
layout_kwargs = adapter._update_card_layout.await_args.kwargs
assert layout_kwargs['text_message'] == ''
assert layout_kwargs['resume_placeholder_text'] == 'resumed answer'
@@ -0,0 +1,59 @@
"""Tests for WecomAdapter.send_message content-key handling."""
import pytest
import langbot_plugin.api.entities.builtin.platform.message as platform_message
from langbot.pkg.platform.sources.wecom import WecomAdapter
class StubWecomClient:
def __init__(self):
self.calls = []
async def get_media_id(self, msg):
return 'MEDIA_ID_123'
async def send_private_msg(self, user_id, agent_id, text):
self.calls.append(('text', user_id, agent_id, text))
async def send_image(self, user_id, agent_id, media_id):
self.calls.append(('image', user_id, agent_id, media_id))
async def send_voice(self, user_id, agent_id, media_id):
self.calls.append(('voice', user_id, agent_id, media_id))
async def send_file(self, user_id, agent_id, media_id):
self.calls.append(('file', user_id, agent_id, media_id))
def _make_adapter():
adapter = WecomAdapter.model_construct(bot=StubWecomClient())
return adapter
@pytest.mark.asyncio
@pytest.mark.parametrize(
('part', 'expected_type'),
[
(platform_message.Image(url='https://example.com/x.jpg'), 'image'),
(platform_message.Voice(url='https://example.com/x.amr'), 'voice'),
(platform_message.File(url='https://example.com/x.pdf', name='x.pdf'), 'file'),
],
)
async def test_send_message_dispatches_media_by_id(part, expected_type):
adapter = _make_adapter()
chain = platform_message.MessageChain([part])
await adapter.send_message('person', 'USER1|1000001', chain)
assert adapter.bot.calls == [(expected_type, 'USER1', 1000001, 'MEDIA_ID_123')]
@pytest.mark.asyncio
async def test_send_message_text_still_works():
adapter = _make_adapter()
chain = platform_message.MessageChain([platform_message.Plain(text='hello')])
await adapter.send_message('person', 'USER1|1000001', chain)
assert adapter.bot.calls == [('text', 'USER1', 1000001, 'hello')]
@@ -113,7 +113,7 @@ async def test_invitation_email_uses_quiet_brand_lockup_and_compact_fallback_lin
html = service._html("RockChinQ's Workspace", link)
assert 'https://docs.langbot.app/langbot-logo.png' in html
assert 'https://langbot.app/docs/langbot-logo.png' in html
assert '>LangBot<' in html
assert 'Workspace invitation' in html
assert 'Open invitation link' in html
Generated
+5 -5
View File
@@ -2008,7 +2008,7 @@ wheels = [
[[package]]
name = "langbot"
version = "4.10.9"
version = "4.10.10"
source = { editable = "." }
dependencies = [
{ name = "aiocqhttp" },
@@ -2129,7 +2129,7 @@ requires-dist = [
{ name = "ebooklib", specifier = ">=0.18" },
{ name = "gewechat-client", specifier = ">=0.1.5" },
{ name = "html2text", specifier = ">=2024.2.26" },
{ name = "langbot-plugin", specifier = "==0.5.6" },
{ name = "langbot-plugin", specifier = "==0.5.7" },
{ name = "langchain", specifier = ">=1.3.9" },
{ name = "langchain-core", specifier = ">=1.3.3" },
{ name = "langchain-text-splitters", specifier = ">=1.1.2" },
@@ -2196,7 +2196,7 @@ dev = [
[[package]]
name = "langbot-plugin"
version = "0.5.6"
version = "0.5.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiofiles" },
@@ -2217,9 +2217,9 @@ dependencies = [
{ name = "watchdog" },
{ name = "websockets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0b/1b/0c2e1f457abedf7ce052f47ad193937322b5f25f4e09e35d92bb5bd0346f/langbot_plugin-0.5.6.tar.gz", hash = "sha256:b7d6bb170ceffead6929e8d95ac388dd9a90a6d971ec4fcdaf7f7b46e894fa9e", size = 475814, upload-time = "2026-08-31T16:04:51.604Z" }
sdist = { url = "https://files.pythonhosted.org/packages/d2/7d/b024770f1f52c9dc71ddcab79fc07dfb6147ce8e645f0fed170d758e49cb/langbot_plugin-0.5.7.tar.gz", hash = "sha256:faecd566b7ff57dc5f3a5b1be01e2165d25924031c0a65a829c83b51c65255ee", size = 480635, upload-time = "2026-09-04T13:39:22.505Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ad/40/1bb5d3562f66c88ac45b3b5b6ee77e9f8a6943599aea95731ea4a4e8b005/langbot_plugin-0.5.6-py3-none-any.whl", hash = "sha256:8f35a07be667abeb84147c4299d7afcc394125c73455fc74d9fcc887eae3a7d4", size = 306108, upload-time = "2026-08-31T16:04:50.427Z" },
{ url = "https://files.pythonhosted.org/packages/cd/25/416745039cacace6a0ca3f719a2eff41dc74cdb30ef7ffaec1de0142bd2e/langbot_plugin-0.5.7-py3-none-any.whl", hash = "sha256:b1a20bcb6a2d482019eafbfe0ac628c106b8e915c7afe89df057b4d8e2015f05", size = 310463, upload-time = "2026-09-04T13:39:21.18Z" },
]
[[package]]
+1 -1
View File
@@ -1,6 +1,6 @@
# Debug LangBot Frontend
Please refer to the [Development Guide](https://link.langbot.app/en/docs/dev-config) for more information.
Please refer to the [Development Guide](https://langbot.app/docs/en/develop/dev-config) for more information.
## Tests
+15 -3
View File
@@ -43,17 +43,24 @@ const pendingSpaceOAuthLogins = new Map<
function getOrCreateSpaceOAuthLoginPromise(
authCode: string,
state: string,
redirectUri: string,
workspaceUuid?: string,
launchAssertion?: string,
): Promise<SpaceOAuthLoginResult> {
const requestKey = `${authCode}:${state}:${workspaceUuid ?? ''}:${launchAssertion ?? ''}`;
const requestKey = `${authCode}:${state}:${redirectUri}:${workspaceUuid ?? ''}:${launchAssertion ?? ''}`;
const pendingRequest = pendingSpaceOAuthLogins.get(requestKey);
if (pendingRequest) {
return pendingRequest;
}
const requestPromise = httpClient
.exchangeSpaceOAuthCode(authCode, state, workspaceUuid, launchAssertion)
.exchangeSpaceOAuthCode(
authCode,
state,
redirectUri,
workspaceUuid,
launchAssertion,
)
.finally(() => {
pendingSpaceOAuthLogins.delete(requestKey);
});
@@ -95,6 +102,7 @@ function SpaceOAuthCallbackContent() {
const response = await getOrCreateSpaceOAuthLoginPromise(
authCode,
state,
`${window.location.origin}/auth/space/callback`,
workspaceUuid,
launchAssertion,
);
@@ -195,7 +203,11 @@ function SpaceOAuthCallbackContent() {
async (authCode: string, state: string) => {
setIsProcessing(true);
try {
const response = await httpClient.bindSpaceAccount(authCode, state);
const response = await httpClient.bindSpaceAccount(
authCode,
state,
`${window.location.origin}/auth/space/callback?mode=bind`,
);
if (!isMountedRef.current) {
return;
}
@@ -17,6 +17,7 @@ import {
Copy,
Check,
ChevronDown,
ChevronLeft,
ChevronRight,
Workflow,
ThumbsUp,
@@ -117,16 +118,43 @@ interface BotSessionMonitorProps {
botId: string;
}
const SESSION_PAGE_SIZE = 20;
const MESSAGE_PAGE_SIZE = 50;
const localDateBoundaryToISOString = (
dateValue: string,
endOfDay: boolean,
): string => {
const [year, month, day] = dateValue.split('-').map(Number);
return new Date(
year,
month - 1,
day,
endOfDay ? 23 : 0,
endOfDay ? 59 : 0,
endOfDay ? 59 : 0,
endOfDay ? 999 : 0,
).toISOString();
};
const BotSessionMonitor = forwardRef<
BotSessionMonitorHandle,
BotSessionMonitorProps
>(function BotSessionMonitor({ botId }, ref) {
const { t } = useTranslation();
const [sessions, setSessions] = useState<SessionInfo[]>([]);
const [sessionTotal, setSessionTotal] = useState(0);
const [sessionPage, setSessionPage] = useState(0);
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [userQuery, setUserQuery] = useState('');
const [appliedUserQuery, setAppliedUserQuery] = useState('');
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(
null,
);
const [messages, setMessages] = useState<SessionMessage[]>([]);
const [messageTotal, setMessageTotal] = useState(0);
const [messagePage, setMessagePage] = useState(0);
const [loadingSessions, setLoadingSessions] = useState(false);
const [loadingMessages, setLoadingMessages] = useState(false);
const [copiedUserId, setCopiedUserId] = useState(false);
@@ -138,6 +166,8 @@ const BotSessionMonitor = forwardRef<
Record<string, boolean>
>({});
const messagesContainerRef = useRef<HTMLDivElement>(null);
const sessionRequestIdRef = useRef(0);
const messageRequestIdRef = useRef(0);
const { admins, reload: reloadAdmins } = useBotAdmins(botId);
const [adminsDialogOpen, setAdminsDialogOpen] = useState(false);
const [togglingAdmin, setTogglingAdmin] = useState<string | null>(null);
@@ -204,16 +234,33 @@ const BotSessionMonitor = forwardRef<
};
const loadSessions = useCallback(async () => {
const requestId = ++sessionRequestIdRef.current;
setLoadingSessions(true);
try {
const response = await httpClient.getBotSessions(botId);
const response = await httpClient.getBotSessions(botId, {
limit: SESSION_PAGE_SIZE,
offset: sessionPage * SESSION_PAGE_SIZE,
startTime: startDate
? localDateBoundaryToISOString(startDate, false)
: undefined,
endTime: endDate
? localDateBoundaryToISOString(endDate, true)
: undefined,
userQuery: appliedUserQuery || undefined,
});
if (requestId !== sessionRequestIdRef.current) return;
setSessions(response.sessions ?? []);
setSessionTotal(response.total ?? 0);
} catch (error) {
console.error('Failed to load sessions:', error);
if (requestId === sessionRequestIdRef.current) {
console.error('Failed to load sessions:', error);
}
} finally {
setLoadingSessions(false);
if (requestId === sessionRequestIdRef.current) {
setLoadingSessions(false);
}
}
}, [botId]);
}, [appliedUserQuery, botId, endDate, sessionPage, startDate]);
useImperativeHandle(
ref,
@@ -224,25 +271,39 @@ const BotSessionMonitor = forwardRef<
);
const loadMessages = useCallback(
async (sessionId: string) => {
async (sessionId: string, page: number) => {
const requestId = ++messageRequestIdRef.current;
setLoadingMessages(true);
setExpandedToolCallIds({});
try {
const messagesRes = await httpClient.getSessionMessages(sessionId);
const messagesRes = await httpClient.getSessionMessages(
sessionId,
MESSAGE_PAGE_SIZE,
page * MESSAGE_PAGE_SIZE,
);
if (requestId !== messageRequestIdRef.current) return;
const sorted = (messagesRes.messages ?? []).sort(
(a, b) =>
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
);
setMessages(sorted);
setMessageTotal(messagesRes.total ?? 0);
try {
const analysisParams = new URLSearchParams();
if (sorted.length > 0) {
analysisParams.set('startTime', sorted[0].timestamp);
analysisParams.set('endTime', sorted[sorted.length - 1].timestamp);
}
const analysisRes = await httpClient.get<{
tool_calls?: SessionToolCall[];
}>(
`/api/v1/monitoring/sessions/${encodeURIComponent(sessionId)}/analysis`,
`/api/v1/monitoring/sessions/${encodeURIComponent(sessionId)}/analysis?${analysisParams.toString()}`,
);
if (requestId !== messageRequestIdRef.current) return;
setToolCalls(analysisRes?.tool_calls ?? []);
} catch (analysisError) {
if (requestId !== messageRequestIdRef.current) return;
console.error('Failed to load session tool calls:', analysisError);
setToolCalls([]);
}
@@ -259,6 +320,7 @@ const BotSessionMonitor = forwardRef<
}>(
`/api/v1/monitoring/feedback?botId=${encodeURIComponent(botId)}&limit=200`,
);
if (requestId !== messageRequestIdRef.current) return;
const map: Record<string, SessionFeedback> = {};
if (feedbackRes?.feedback) {
@@ -273,9 +335,13 @@ const BotSessionMonitor = forwardRef<
setFeedbackMap({});
}
} catch (error) {
console.error('Failed to load session messages:', error);
if (requestId === messageRequestIdRef.current) {
console.error('Failed to load session messages:', error);
}
} finally {
setLoadingMessages(false);
if (requestId === messageRequestIdRef.current) {
setLoadingMessages(false);
}
}
},
[botId],
@@ -285,16 +351,24 @@ const BotSessionMonitor = forwardRef<
loadSessions();
}, [loadSessions]);
useEffect(() => {
setSelectedSessionId(null);
setMessagePage(0);
}, [appliedUserQuery, botId, endDate, sessionPage, startDate]);
useEffect(() => {
if (selectedSessionId) {
loadMessages(selectedSessionId);
loadMessages(selectedSessionId, messagePage);
} else {
messageRequestIdRef.current += 1;
setLoadingMessages(false);
setMessages([]);
setMessageTotal(0);
setToolCalls([]);
setExpandedToolCallIds({});
setFeedbackMap({});
}
}, [selectedSessionId, loadMessages]);
}, [selectedSessionId, messagePage, loadMessages]);
useEffect(() => {
if (messages.length === 0 && toolCalls.length === 0) return;
@@ -552,6 +626,19 @@ const BotSessionMonitor = forwardRef<
const selectedSession = sessions.find(
(s) => s.session_id === selectedSessionId,
);
const sessionPageCount = Math.max(
1,
Math.ceil(sessionTotal / SESSION_PAGE_SIZE),
);
const messagePageCount = Math.max(
1,
Math.ceil(messageTotal / MESSAGE_PAGE_SIZE),
);
const applyUserSearch = () => {
setSessionPage(0);
setAppliedUserQuery(userQuery.trim());
};
return (
<>
@@ -575,6 +662,65 @@ const BotSessionMonitor = forwardRef<
)}
</span>
</button>
<span className="text-[11px] text-muted-foreground tabular-nums">
{t('bots.sessionMonitor.totalSessions', {
defaultValue: '{{count}} sessions',
count: sessionTotal,
})}
</span>
</div>
<div className="p-1.5 border-b shrink-0 space-y-1.5">
<div className="flex gap-1">
<input
value={userQuery}
onChange={(event) => setUserQuery(event.target.value)}
onKeyDown={(event) =>
event.key === 'Enter' && applyUserSearch()
}
aria-label={t('bots.sessionMonitor.userSearch', {
defaultValue: 'User ID or name',
})}
placeholder={t('bots.sessionMonitor.userSearch', {
defaultValue: 'User ID or name',
})}
className="h-7 min-w-0 flex-1 rounded border bg-background px-2 text-xs"
/>
<button
type="button"
onClick={applyUserSearch}
className="h-7 rounded border px-2 text-[11px] hover:bg-accent"
>
{t('common.search', { defaultValue: 'Search' })}
</button>
</div>
<div className="grid grid-cols-2 gap-1">
<input
type="date"
value={startDate}
max={endDate || undefined}
onChange={(event) => {
setSessionPage(0);
setStartDate(event.target.value);
}}
aria-label={t('bots.sessionMonitor.startDate', {
defaultValue: 'Start date',
})}
className="h-7 min-w-0 rounded border bg-background px-1 text-[10px]"
/>
<input
type="date"
value={endDate}
min={startDate || undefined}
onChange={(event) => {
setSessionPage(0);
setEndDate(event.target.value);
}}
aria-label={t('bots.sessionMonitor.endDate', {
defaultValue: 'End date',
})}
className="h-7 min-w-0 rounded border bg-background px-1 text-[10px]"
/>
</div>
</div>
{/* Session List */}
<ScrollArea className="flex-1 min-h-0">
@@ -601,7 +747,10 @@ const BotSessionMonitor = forwardRef<
'w-full text-left px-2.5 py-2 rounded-md transition-colors cursor-pointer',
isSelected ? 'bg-accent' : 'hover:bg-accent/50',
)}
onClick={() => setSelectedSessionId(session.session_id)}
onClick={() => {
setSelectedSessionId(session.session_id);
setMessagePage(0);
}}
>
<div className="flex items-center justify-between mb-0.5">
<span className="text-sm font-medium truncate mr-2">
@@ -637,6 +786,29 @@ const BotSessionMonitor = forwardRef<
</div>
)}
</ScrollArea>
<div className="h-8 border-t px-1.5 flex items-center justify-between shrink-0 text-[11px]">
<button
type="button"
aria-label={t('common.previous', { defaultValue: 'Previous' })}
disabled={sessionPage === 0 || loadingSessions}
onClick={() => setSessionPage((page) => Math.max(0, page - 1))}
className="p-1 rounded hover:bg-accent disabled:opacity-40"
>
<ChevronLeft className="size-3.5" />
</button>
<span className="tabular-nums text-muted-foreground">
{sessionPage + 1} / {sessionPageCount}
</span>
<button
type="button"
aria-label={t('common.next', { defaultValue: 'Next' })}
disabled={sessionPage + 1 >= sessionPageCount || loadingSessions}
onClick={() => setSessionPage((page) => page + 1)}
className="p-1 rounded hover:bg-accent disabled:opacity-40"
>
<ChevronRight className="size-3.5" />
</button>
</div>
</div>
{/* Right Panel: Messages */}
@@ -975,6 +1147,33 @@ const BotSessionMonitor = forwardRef<
)}
</div>
</ScrollArea>
<div className="h-9 border-t px-3 flex items-center justify-center gap-3 shrink-0 text-xs">
<button
type="button"
disabled={messagePage === 0 || loadingMessages}
onClick={() =>
setMessagePage((page) => Math.max(0, page - 1))
}
className="inline-flex items-center gap-1 rounded px-2 py-1 hover:bg-accent disabled:opacity-40"
>
<ChevronLeft className="size-3.5" />
{t('common.previous', { defaultValue: 'Previous' })}
</button>
<span className="tabular-nums text-muted-foreground">
{messagePage + 1} / {messagePageCount} · {messageTotal}
</span>
<button
type="button"
disabled={
messagePage + 1 >= messagePageCount || loadingMessages
}
onClick={() => setMessagePage((page) => page + 1)}
className="inline-flex items-center gap-1 rounded px-2 py-1 hover:bg-accent disabled:opacity-40"
>
{t('common.next', { defaultValue: 'Next' })}
<ChevronRight className="size-3.5" />
</button>
</div>
</>
)}
</div>
@@ -0,0 +1,22 @@
const COMMON_N8N_CONFIG_FIELDS = new Set([
'webhook-url',
'auth-type',
'timeout',
'output-key',
'response-handling',
]);
export function shouldShowN8nConfigField(
fieldName: string,
authType: string,
): boolean {
if (COMMON_N8N_CONFIG_FIELDS.has(fieldName)) {
return true;
}
return (
(authType === 'basic' && fieldName.startsWith('basic-')) ||
(authType === 'jwt' && fieldName.startsWith('jwt-')) ||
(authType === 'header' && fieldName.startsWith('header-'))
);
}
@@ -13,6 +13,7 @@ import {
import { IDynamicFormItemSchema } from '@/app/infra/entities/form/dynamic';
import DynamicFormItemComponent from '@/app/home/components/dynamic-form/DynamicFormItemComponent';
import { normalizeDynamicFormValuesForSave } from '@/app/home/components/dynamic-form/DynamicFormSaveValues';
import { shouldShowN8nConfigField } from '@/app/home/components/dynamic-form/N8nAuthFieldVisibility';
import { extractI18nObject } from '@/i18n/I18nProvider';
/**
@@ -181,29 +182,9 @@ export default function N8nAuthFormComponent({
}, [form, itemConfigList]);
// 根据认证类型过滤表单项
const filteredConfigList = itemConfigList.filter((config) => {
// 始终显示webhook-url、auth-type、timeout和output-key
if (
['webhook-url', 'auth-type', 'timeout', 'output-key'].includes(
config.name,
)
) {
return true;
}
// 根据认证类型显示相应的表单项
if (authType === 'basic' && config.name.startsWith('basic-')) {
return true;
}
if (authType === 'jwt' && config.name.startsWith('jwt-')) {
return true;
}
if (authType === 'header' && config.name.startsWith('header-')) {
return true;
}
return false;
});
const filteredConfigList = itemConfigList.filter((config) =>
shouldShowN8nConfigField(config.name, authType),
);
return (
<Form {...form}>
@@ -2195,12 +2195,12 @@ export default function HomeSidebar({
localStorage.getItem('langbot_language');
if (language === 'zh-Hans' || language === 'zh-Hant') {
window.open(
'https://link.langbot.app/zh/docs/guide',
'https://langbot.app/docs/zh/insight/guide',
'_blank',
);
} else {
window.open(
'https://link.langbot.app/en/docs/guide',
'https://langbot.app/docs/en/insight/guide',
'_blank',
);
}
@@ -49,9 +49,9 @@ export const sidebarConfigList = [
route: '/home/bots',
description: t('bots.description'),
helpLink: {
en_US: 'https://link.langbot.app/en/docs/platforms',
zh_Hans: 'https://link.langbot.app/zh/docs/platforms',
ja_JP: 'https://link.langbot.app/ja/docs/platforms',
en_US: 'https://langbot.app/docs/en/usage/platforms/readme',
zh_Hans: 'https://langbot.app/docs/zh/usage/platforms/readme',
ja_JP: 'https://langbot.app/docs/ja/usage/platforms/readme',
},
section: 'home',
}),
@@ -62,9 +62,9 @@ export const sidebarConfigList = [
route: '/home/pipelines',
description: t('pipelines.description'),
helpLink: {
en_US: 'https://link.langbot.app/en/docs/pipelines',
zh_Hans: 'https://link.langbot.app/zh/docs/pipelines',
ja_JP: 'https://link.langbot.app/ja/docs/pipelines',
en_US: 'https://langbot.app/docs/en/usage/pipelines/readme',
zh_Hans: 'https://langbot.app/docs/zh/usage/pipelines/readme',
ja_JP: 'https://langbot.app/docs/ja/usage/pipelines/readme',
},
section: 'home',
}),
@@ -75,9 +75,9 @@ export const sidebarConfigList = [
route: '/home/knowledge',
description: t('knowledge.description'),
helpLink: {
en_US: 'https://link.langbot.app/en/docs/knowledge',
zh_Hans: 'https://link.langbot.app/zh/docs/knowledge',
ja_JP: 'https://link.langbot.app/ja/docs/knowledge',
en_US: 'https://langbot.app/docs/en/usage/knowledge/readme',
zh_Hans: 'https://langbot.app/docs/zh/usage/knowledge/readme',
ja_JP: 'https://langbot.app/docs/ja/usage/knowledge/readme',
},
section: 'home',
}),
@@ -89,9 +89,9 @@ export const sidebarConfigList = [
route: '/home/extensions',
description: t('plugins.description'),
helpLink: {
en_US: 'https://docs.langbot.app/en/plugin/plugin-intro',
zh_Hans: 'https://docs.langbot.app/zh/plugin/plugin-intro',
ja_JP: 'https://docs.langbot.app/ja/plugin/plugin-intro',
en_US: 'https://langbot.app/docs/en/plugin/plugin-intro',
zh_Hans: 'https://langbot.app/docs/zh/plugin/plugin-intro',
ja_JP: 'https://langbot.app/docs/ja/plugin/plugin-intro',
},
section: 'extensions',
}),
@@ -102,9 +102,9 @@ export const sidebarConfigList = [
route: '/home/add-extension',
description: t('plugins.description'),
helpLink: {
en_US: 'https://docs.langbot.app/en/plugin/plugin-intro',
zh_Hans: 'https://docs.langbot.app/zh/plugin/plugin-intro',
ja_JP: 'https://docs.langbot.app/ja/plugin/plugin-intro',
en_US: 'https://langbot.app/docs/en/plugin/plugin-intro',
zh_Hans: 'https://langbot.app/docs/zh/plugin/plugin-intro',
ja_JP: 'https://langbot.app/docs/ja/plugin/plugin-intro',
},
section: 'extensions',
}),
@@ -34,11 +34,11 @@ export default function NewVersionDialog({
const getUpdateDocsUrl = () => {
const language = i18n.language;
if (language === 'zh-Hans' || language === 'zh-Hant') {
return 'https://link.langbot.app/zh/docs/update';
return 'https://langbot.app/docs/zh/deploy/update';
} else if (language === 'ja-JP') {
return 'https://link.langbot.app/ja/docs/update';
return 'https://langbot.app/docs/ja/deploy/update';
} else {
return 'https://link.langbot.app/en/docs/update';
return 'https://langbot.app/docs/en/deploy/update';
}
};
+22 -5
View File
@@ -474,8 +474,13 @@ export class BackendClient extends BaseHttpClient {
public getBotSessions(
botId: string,
limit: number = 100,
offset: number = 0,
options: {
limit: number;
offset: number;
startTime?: string;
endTime?: string;
userQuery?: string;
},
): Promise<{
sessions: Array<{
session_id: string;
@@ -495,8 +500,17 @@ export class BackendClient extends BaseHttpClient {
}> {
const queryParams = new URLSearchParams();
queryParams.append('botId', botId);
queryParams.append('limit', limit.toString());
queryParams.append('offset', offset.toString());
queryParams.append('limit', options.limit.toString());
queryParams.append('offset', options.offset.toString());
if (options.startTime) {
queryParams.append('startTime', options.startTime);
}
if (options.endTime) {
queryParams.append('endTime', options.endTime);
}
if (options.userQuery) {
queryParams.append('userQuery', options.userQuery);
}
return this.get(`/api/v1/monitoring/sessions?${queryParams.toString()}`);
}
@@ -1351,6 +1365,7 @@ export class BackendClient extends BaseHttpClient {
public async bindSpaceAccount(
code: string,
state: string,
redirectUri: string,
): Promise<{
token: string;
user: string;
@@ -1358,7 +1373,7 @@ export class BackendClient extends BaseHttpClient {
}> {
const response = await this.instance.post(
'/api/v1/user/bind-space',
{ code, state },
{ code, state, redirect_uri: redirectUri },
{ skipWorkspace: true } as RequestConfig,
);
if (response.data.code !== 0) {
@@ -1394,6 +1409,7 @@ export class BackendClient extends BaseHttpClient {
public async exchangeSpaceOAuthCode(
code: string,
state: string,
redirectUri: string,
workspaceUuid?: string,
launchAssertion?: string,
): Promise<{
@@ -1408,6 +1424,7 @@ export class BackendClient extends BaseHttpClient {
{
code,
state,
redirect_uri: redirectUri,
workspace_uuid: workspaceUuid,
launch_assertion: launchAssertion,
},
+2 -1
View File
@@ -76,7 +76,8 @@ const enUS = {
privacyPolicy: 'Privacy Policy',
and: 'and',
dataCollectionPolicy: 'Data Collection Policy',
dataCollectionPolicyUrl: 'https://link.langbot.app/en/docs/data-policy',
dataCollectionPolicyUrl:
'https://langbot.app/docs/en/insight/data-collection-policy',
loading: 'Loading...',
fieldRequired: 'This field is required',
or: 'or',
+2 -1
View File
@@ -79,7 +79,8 @@ const esES = {
privacyPolicy: 'Política de privacidad',
and: 'y',
dataCollectionPolicy: 'Política de recopilación de datos',
dataCollectionPolicyUrl: 'https://link.langbot.app/en/docs/data-policy',
dataCollectionPolicyUrl:
'https://langbot.app/docs/en/insight/data-collection-policy',
loading: 'Cargando...',
fieldRequired: 'Este campo es obligatorio',
or: 'o',
+2 -1
View File
@@ -77,7 +77,8 @@ const jaJP = {
privacyPolicy: 'プライバシーポリシー',
and: 'および',
dataCollectionPolicy: 'データ収集ポリシー',
dataCollectionPolicyUrl: 'https://link.langbot.app/ja/docs/data-policy',
dataCollectionPolicyUrl:
'https://langbot.app/docs/ja/insight/data-collection-policy',
loading: '読み込み中...',
fieldRequired: 'この項目は必須です',
or: 'または',
+2 -1
View File
@@ -76,7 +76,8 @@ const ruRU = {
privacyPolicy: 'Политикой конфиденциальности',
and: 'и',
dataCollectionPolicy: 'Политикой сбора данных',
dataCollectionPolicyUrl: 'https://link.langbot.app/en/docs/data-policy',
dataCollectionPolicyUrl:
'https://langbot.app/docs/en/insight/data-collection-policy',
loading: 'Загрузка...',
fieldRequired: 'Это поле обязательно для заполнения',
or: 'или',
+2 -1
View File
@@ -76,7 +76,8 @@ const thTH = {
privacyPolicy: 'นโยบายความเป็นส่วนตัว',
and: 'และ',
dataCollectionPolicy: 'นโยบายการเก็บรวบรวมข้อมูล',
dataCollectionPolicyUrl: 'https://link.langbot.app/en/docs/data-policy',
dataCollectionPolicyUrl:
'https://langbot.app/docs/en/insight/data-collection-policy',
loading: 'กำลังโหลด...',
fieldRequired: 'ช่องนี้จำเป็นต้องกรอก',
or: 'หรือ',
+2 -1
View File
@@ -77,7 +77,8 @@ const viVN = {
privacyPolicy: 'Chính sách bảo mật',
and: 'và',
dataCollectionPolicy: 'Chính sách thu thập dữ liệu',
dataCollectionPolicyUrl: 'https://link.langbot.app/en/docs/data-policy',
dataCollectionPolicyUrl:
'https://langbot.app/docs/en/insight/data-collection-policy',
loading: 'Đang tải...',
fieldRequired: 'Trường này là bắt buộc',
or: 'hoặc',
+2 -1
View File
@@ -75,7 +75,8 @@ const zhHans = {
privacyPolicy: '隐私政策',
and: '和',
dataCollectionPolicy: '数据收集政策',
dataCollectionPolicyUrl: 'https://link.langbot.app/zh/docs/data-policy',
dataCollectionPolicyUrl:
'https://langbot.app/docs/zh/insight/data-collection-policy',
loading: '加载中...',
fieldRequired: '此字段为必填项',
or: '或',
+2 -1
View File
@@ -75,7 +75,8 @@ const zhHant = {
privacyPolicy: '隱私政策',
and: '和',
dataCollectionPolicy: '數據收集政策',
dataCollectionPolicyUrl: 'https://link.langbot.app/zh/docs/data-policy',
dataCollectionPolicyUrl:
'https://langbot.app/docs/zh/insight/data-collection-policy',
loading: '載入中...',
fieldRequired: '此欄位為必填',
or: '或',
@@ -0,0 +1,19 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import test from 'node:test';
const source = fs.readFileSync(
new URL('../../src/app/login/page.tsx', import.meta.url),
'utf8',
);
test('normal Cloud login uses the standard Space OAuth callback path', () => {
assert.doesNotMatch(source, /cloudEntry/);
assert.match(source, /getSpaceAuthorizeUrl\(redirectUri\)/);
});
test('invitation login uses the same OAuth callback before accepting the invitation', () => {
assert.doesNotMatch(source, /cloudEntry/);
assert.match(source, /const invitationToken = getPendingInvitationToken\(\)/);
assert.match(source, /acceptWorkspaceInvitation\(invitationToken\)/);
});
@@ -0,0 +1,201 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import vm from 'node:vm';
import { fileURLToPath } from 'node:url';
const testDirectory = path.dirname(fileURLToPath(import.meta.url));
const widgetPath = path.resolve(
testDirectory,
'../../../src/langbot/templates/embed/widget.js',
);
const widgetSource = fs.readFileSync(widgetPath, 'utf8');
class FakeElement {
constructor(tagName) {
this.tagName = tagName;
this.children = [];
this.className = '';
this.dataset = {};
this.style = {};
this.listeners = {};
this._innerHTML = '';
}
appendChild(child) {
this.children.push(child);
return child;
}
setAttribute(name, value) {
this[name] = String(value);
}
addEventListener(event, listener) {
this.listeners[event] = listener;
}
click() {
this.listeners.click?.({});
}
attachShadow() {
this.shadowRoot = new FakeElement('shadow-root');
return this.shadowRoot;
}
get classList() {
return {
add: (...names) => {
const classes = `${this.className} ${names.join(' ')}`
.trim()
.split(/\s+/);
this.className = [...new Set(classes)].join(' ');
},
};
}
set textContent(value) {
this._innerHTML = String(value ?? '');
}
set innerHTML(value) {
this._innerHTML = String(value ?? '');
}
get innerHTML() {
return this._innerHTML;
}
querySelectorAll(selector) {
const matches = [];
for (const child of this.children) {
if (
selector.startsWith('.') &&
child.className.split(/\s+/).includes(selector.slice(1))
) {
matches.push(child);
}
matches.push(...child.querySelectorAll(selector));
}
return matches;
}
querySelector(selector) {
return this.querySelectorAll(selector)[0] ?? null;
}
}
class FakeDocument {
constructor() {
this.body = new FakeElement('body');
this.head = new FakeElement('head');
this.readyState = 'complete';
this.currentScript = { getAttribute: () => null };
}
createElement(tagName) {
return new FakeElement(tagName);
}
getElementById(id) {
const find = (element) => {
if (element.id === id) return element;
for (const child of element.children) {
const match = find(child);
if (match) return match;
}
return null;
};
return find(this.body) ?? find(this.head);
}
}
class FakeWebSocket {
static OPEN = 1;
static instances = [];
constructor(url) {
this.url = url;
this.readyState = FakeWebSocket.OPEN;
FakeWebSocket.instances.push(this);
}
send() {}
}
function launchWidget() {
FakeWebSocket.instances = [];
const document = new FakeDocument();
const window = {
crypto: { randomUUID: () => '00000000-0000-4000-8000-000000000000' },
sessionStorage: { getItem: () => null, setItem: () => {} },
};
const context = vm.createContext({
document,
window,
navigator: { clipboard: { writeText: () => Promise.resolve() } },
WebSocket: FakeWebSocket,
fetch: () => new Promise(() => {}),
requestAnimationFrame: () => 0,
setTimeout: () => 0,
clearTimeout: () => {},
setInterval: () => 0,
clearInterval: () => {},
});
vm.runInContext(widgetSource, context, { filename: widgetPath });
const root = document.getElementById('langbot-widget-root');
assert.ok(root, 'widget should initialize');
root.shadowRoot.querySelector('.lb-bubble').click();
const socket = FakeWebSocket.instances.at(-1);
assert.ok(socket, 'opening widget should connect its WebSocket');
return {
receive(message) {
socket.onmessage({
data: JSON.stringify({ type: 'response', data: message }),
});
},
assistantMessages() {
return root.shadowRoot.querySelectorAll('.lb-msg-assistant');
},
};
}
function assistant(id, content) {
return { id, role: 'assistant', content, is_final: true };
}
test('renders a non-empty reply after an empty assistant frame', () => {
const widget = launchWidget();
widget.receive(assistant('thought', ''));
widget.receive(assistant('answer', 'visible answer'));
const messages = widget.assistantMessages();
assert.equal(messages.length, 2);
assert.equal(
messages[1].querySelector('.lb-msg-bubble').innerHTML,
'visible answer',
);
});
test('still drops a duplicate non-empty assistant frame', () => {
const widget = launchWidget();
widget.receive(assistant('answer-1', 'same answer'));
widget.receive(assistant('answer-2', 'same answer'));
assert.equal(widget.assistantMessages().length, 1);
});
test('still keeps two distinct non-empty assistant frames', () => {
const widget = launchWidget();
widget.receive(assistant('answer-1', 'first answer'));
widget.receive(assistant('answer-2', 'second answer'));
assert.equal(widget.assistantMessages().length, 2);
});
@@ -0,0 +1,52 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import ts from 'typescript';
import { fileURLToPath } from 'node:url';
const currentDirectory = path.dirname(fileURLToPath(import.meta.url));
const sourcePath = path.resolve(
currentDirectory,
'../../src/app/home/components/dynamic-form/N8nAuthFieldVisibility.ts',
);
function loadVisibilityPolicy() {
const source = fs.readFileSync(sourcePath, 'utf8');
const compiled = ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.CommonJS },
}).outputText;
const loadedModule = { exports: {} };
new Function('require', 'module', 'exports', compiled)(
() => {
throw new Error('N8nAuthFieldVisibility must not have runtime imports');
},
loadedModule,
loadedModule.exports,
);
return loadedModule.exports;
}
test('shows response handling with the other common n8n fields', () => {
const { shouldShowN8nConfigField } = loadVisibilityPolicy();
for (const field of [
'webhook-url',
'auth-type',
'timeout',
'output-key',
'response-handling',
]) {
assert.equal(shouldShowN8nConfigField(field, 'none'), true, field);
}
});
test('shows only fields for the selected n8n authentication method', () => {
const { shouldShowN8nConfigField } = loadVisibilityPolicy();
assert.equal(shouldShowN8nConfigField('basic-username', 'basic'), true);
assert.equal(shouldShowN8nConfigField('basic-password', 'jwt'), false);
assert.equal(shouldShowN8nConfigField('jwt-secret', 'jwt'), true);
assert.equal(shouldShowN8nConfigField('header-name', 'header'), true);
assert.equal(shouldShowN8nConfigField('unrelated-field', 'none'), false);
});

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