Compare commits

..

2 Commits

Author SHA1 Message Date
dadachann 0135ae31b8 chore: retrigger repository checks 2026-08-13 15:52:09 +00:00
dadachann c222321f2d fix(auth): allow callbacks from any origin 2026-08-13 15:47:38 +00:00
183 changed files with 915 additions and 7987 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
name: 漏洞反馈 name: 漏洞反馈
description: 【供中文用户】报错或漏洞请使用这个模板创建,不使用此模板创建的异常、漏洞相关issue将被直接关闭。由于自己操作不当/不甚了解所用技术栈引起的网络连接问题恕无法解决,请勿提 issue。容器间网络连接问题,参考文档 https://langbot.app/docs/zh/workshop/network-details description: 【供中文用户】报错或漏洞请使用这个模板创建,不使用此模板创建的异常、漏洞相关issue将被直接关闭。由于自己操作不当/不甚了解所用技术栈引起的网络连接问题恕无法解决,请勿提 issue。容器间网络连接问题,参考文档 https://link.langbot.app/zh/docs/network
title: "[Bug]: " title: "[Bug]: "
labels: ["bug?"] labels: ["bug?"]
body: body:
+1 -1
View File
@@ -1,5 +1,5 @@
name: Bug report name: Bug report
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 description: Report bugs or vulnerabilities using this template. For container network connection issues, refer to the documentation https://link.langbot.app/en/docs/network
title: "[Bug]: " title: "[Bug]: "
labels: ["bug?"] labels: ["bug?"]
body: body:
+13 -32
View File
@@ -7,42 +7,23 @@ on:
jobs: jobs:
build-dev-image: build-dev-image:
runs-on: ubuntu-latest runs-on: ubuntu-latest
# 如果是tag则跳过
if: ${{ !startsWith(github.ref, 'refs/tags/') }} if: ${{ !startsWith(github.ref, 'refs/tags/') }}
permissions:
contents: read
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v2
with: with:
persist-credentials: false persist-credentials: false
- name: Set up Docker Buildx - name: Generate Tag
uses: docker/setup-buildx-action@v3 id: generate_tag
- name: Generate image metadata
id: image
shell: bash
run: | run: |
set -euo pipefail # 获取分支名称,把/替换为-
branch_tag="${GITHUB_REF#refs/heads/}" echo ${{ github.ref }} | sed 's/refs\/heads\///g' | sed 's/\//-/g'
branch_tag="${branch_tag//\//-}" echo ::set-output name=tag::$(echo ${{ github.ref }} | sed 's/refs\/heads\///g' | sed 's/\//-/g')
echo "branch_tag=${branch_tag}" >> "$GITHUB_OUTPUT" - name: Login to Registry
echo "sha_tag=sha-${GITHUB_SHA}" >> "$GITHUB_OUTPUT" run: docker login --username=${{ secrets.DOCKER_USERNAME }} --password ${{ secrets.DOCKER_PASSWORD }}
- name: Build Docker Image
- name: Login to Docker Hub run: |
uses: docker/login-action@v3 docker buildx create --name mybuilder --use
with: docker build -t rockchin/langbot:${{ steps.generate_tag.outputs.tag }} . --push
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push immutable Core image
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
rockchin/langbot:${{ steps.image.outputs.branch_tag }}
rockchin/langbot:${{ steps.image.outputs.sha_tag }}
labels: |
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
+2 -2
View File
@@ -43,8 +43,8 @@ Run the narrowest useful test first, then broader checks when confidence is need
## Where to Look ## Where to Look
- Architecture map: `ARCHITECTURE.md`. - Architecture map: `ARCHITECTURE.md`.
- Dev environment guide: https://langbot.app/docs/zh/develop/dev-config. - Dev environment guide: https://docs.langbot.app/zh/develop/dev-config.
- Plugin runtime / CLI / SDK debugging: https://langbot.app/docs/zh/develop/plugin-runtime. - Plugin runtime / CLI / SDK debugging: https://docs.langbot.app/zh/develop/plugin-runtime.
- API-key auth: `docs/API_KEY_AUTH.md`. - API-key auth: `docs/API_KEY_AUTH.md`.
- Box deep-dive notes: `docs/review/box-architecture.md` and related files. - 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. - In-repo skills: `skills/` is the single source of truth for LangBot agent skills.
+2 -2
View File
@@ -1,4 +1,4 @@
FROM --platform=$BUILDPLATFORM node:22-alpine AS node FROM node:22-alpine AS node
WORKDIR /app WORKDIR /app
@@ -62,7 +62,7 @@ RUN apt-get update \
&& apt-get install -y --no-install-recommends nodejs \ && apt-get install -y --no-install-recommends nodejs \
&& rm -f /tmp/nodesource_setup.sh \ && rm -f /tmp/nodesource_setup.sh \
&& python -m pip install --no-cache-dir uv \ && python -m pip install --no-cache-dir uv \
&& uv sync --extra seekdb \ && uv sync \
&& apt-get purge -y --auto-remove curl git gnupg \ && apt-get purge -y --auto-remove curl git gnupg \
&& rm -rf /var/lib/apt/lists/* \ && rm -rf /var/lib/apt/lists/* \
&& touch /.dockerenv && touch /.dockerenv
+6 -7
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) [![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://langbot.app">Website</a>
<a href="https://langbot.app/docs/en/insight/features">Features</a> <a href="https://link.langbot.app/en/docs/features">Features</a>
<a href="https://langbot.app/docs/en/insight/guide">Docs</a> <a href="https://link.langbot.app/en/docs/guide">Docs</a>
<a href="https://langbot.app/docs/en/tags/readme">API</a> <a href="https://link.langbot.app/en/docs/api">API</a>
<a href="https://space.langbot.app/cloud">Cloud</a> <a href="https://space.langbot.app/cloud">Cloud</a>
<a href="https://space.langbot.app">Plugin Market</a> <a href="https://space.langbot.app">Plugin Market</a>
<a href="https://langbot.featurebase.app/roadmap">Roadmap</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. - **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. - **Multi-Pipeline Architecture** — Different bots for different scenarios, with comprehensive monitoring and exception handling.
[→ Learn more about all features](https://langbot.app/docs/en/insight/features) [→ Learn more about all features](https://link.langbot.app/en/docs/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/). 📍 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/).
@@ -83,13 +83,12 @@ cd LangBot/docker
docker compose --profile all up -d docker compose --profile all up -d
``` ```
### One-Click Cloud Deploy ### One-Click Cloud Deploy
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH) [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH)
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) [![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF)
**More options:** [Docker](https://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) **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)
--- ---
@@ -151,7 +150,7 @@ _Note: Public demo environment. Do not enter sensitive information._
| [302.AI](https://share.302ai.cn/SuTG99) | Gateway | ✅ | | [302.AI](https://share.302ai.cn/SuTG99) | Gateway | ✅ |
| [Qiniu](https://www.qiniu.com/ai/agent) | Gateway | ✅ | | [Qiniu](https://www.qiniu.com/ai/agent) | Gateway | ✅ |
[→ View all integrations](https://langbot.app/docs/en/insight/features) [→ View all integrations](https://link.langbot.app/en/docs/features)
--- ---
+6 -7
View File
@@ -21,9 +21,9 @@
[![star](https://gitcode.com/RockChinQ/LangBot/star/badge.svg)](https://gitcode.com/RockChinQ/LangBot) [![star](https://gitcode.com/RockChinQ/LangBot/star/badge.svg)](https://gitcode.com/RockChinQ/LangBot)
<a href="https://langbot.app">官网</a> <a href="https://langbot.app">官网</a>
<a href="https://langbot.app/docs/zh/insight/features">特性</a> <a href="https://link.langbot.app/zh/docs/features">特性</a>
<a href="https://langbot.app/docs/zh/insight/guide">文档</a> <a href="https://link.langbot.app/zh/docs/guide">文档</a>
<a href="https://langbot.app/docs/zh/tags/readme">API</a> <a href="https://link.langbot.app/zh/docs/api">API</a>
<a href="https://space.langbot.app/cloud">Cloud</a> <a href="https://space.langbot.app/cloud">Cloud</a>
<a href="https://space.langbot.app">扩展市场</a> <a href="https://space.langbot.app">扩展市场</a>
<a href="https://langbot.featurebase.app/roadmap">路线图</a> <a href="https://langbot.featurebase.app/roadmap">路线图</a>
@@ -49,7 +49,7 @@ LangBot 是一个**开源的生产级平台**,用于构建 AI 驱动的即时
- **Web 管理面板** — 通过浏览器直观地配置、管理和监控机器人,无需手动编辑配置文件。 - **Web 管理面板** — 通过浏览器直观地配置、管理和监控机器人,无需手动编辑配置文件。
- **多流水线架构** — 不同机器人用于不同场景,具备全面的监控和异常处理能力。 - **多流水线架构** — 不同机器人用于不同场景,具备全面的监控和异常处理能力。
[→ 了解更多功能特性](https://langbot.app/docs/zh/insight/features) [→ 了解更多功能特性](https://link.langbot.app/zh/docs/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/)。 📍 实践指南:[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/)。
@@ -83,13 +83,12 @@ cd LangBot/docker
docker compose --profile all up -d docker compose --profile all up -d
``` ```
### 一键云部署 ### 一键云部署
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/zh-CN/templates/ZKTBDH) [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/zh-CN/templates/ZKTBDH)
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) [![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF)
**更多方式:** [Docker](https://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) **更多方式:** [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)
--- ---
@@ -152,7 +151,7 @@ docker compose --profile all up -d
| [百宝箱Tbox](https://www.tbox.cn/open) | 智能体平台 | ✅ | | [百宝箱Tbox](https://www.tbox.cn/open) | 智能体平台 | ✅ |
| [七牛云Qiniu](https://www.qiniu.com/ai/agent) | 聚合平台 | ✅ | | [七牛云Qiniu](https://www.qiniu.com/ai/agent) | 聚合平台 | ✅ |
[→ 查看完整集成列表](https://langbot.app/docs/zh/insight/features) [→ 查看完整集成列表](https://link.langbot.app/zh/docs/features)
### TTS(语音合成) ### TTS(语音合成)
+6 -7
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) [![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://langbot.app">Inicio</a>
<a href="https://langbot.app/docs/en/insight/features">Características</a> <a href="https://link.langbot.app/en/docs/features">Características</a>
<a href="https://langbot.app/docs/en/insight/guide">Documentación</a> <a href="https://link.langbot.app/en/docs/guide">Documentación</a>
<a href="https://langbot.app/docs/en/tags/readme">API</a> <a href="https://link.langbot.app/en/docs/api">API</a>
<a href="https://space.langbot.app">Mercado de Plugins</a> <a href="https://space.langbot.app">Mercado de Plugins</a>
<a href="https://langbot.featurebase.app/roadmap">Hoja de Ruta</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. - **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. - **Arquitectura Multi-Pipeline** — Diferentes bots para diferentes escenarios, con monitoreo completo y manejo de excepciones.
[→ Conocer más sobre todas las funcionalidades](https://langbot.app/docs/en/insight/features) [→ Conocer más sobre todas las funcionalidades](https://link.langbot.app/en/docs/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/). 📍 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/).
@@ -82,13 +82,12 @@ cd LangBot/docker
docker compose --profile all up -d docker compose --profile all up -d
``` ```
### Despliegue en la Nube con un Clic ### Despliegue en la Nube con un Clic
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH) [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH)
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) [![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF)
**Más opciones:** [Docker](https://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) **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)
--- ---
@@ -149,7 +148,7 @@ docker compose --profile all up -d
| [302.AI](https://share.302ai.cn/SuTG99) | Pasarela | ✅ | | [302.AI](https://share.302ai.cn/SuTG99) | Pasarela | ✅ |
| [Qiniu](https://www.qiniu.com/ai/agent) | Pasarela | ✅ | | [Qiniu](https://www.qiniu.com/ai/agent) | Pasarela | ✅ |
[→ Ver todas las integraciones](https://langbot.app/docs/en/insight/features) [→ Ver todas las integraciones](https://link.langbot.app/en/docs/features)
--- ---
+6 -7
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) [![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://langbot.app">Accueil</a>
<a href="https://langbot.app/docs/en/insight/features">Fonctionnalités</a> <a href="https://link.langbot.app/en/docs/features">Fonctionnalités</a>
<a href="https://langbot.app/docs/en/insight/guide">Documentation</a> <a href="https://link.langbot.app/en/docs/guide">Documentation</a>
<a href="https://langbot.app/docs/en/tags/readme">API</a> <a href="https://link.langbot.app/en/docs/api">API</a>
<a href="https://space.langbot.app">Marché des Plugins</a> <a href="https://space.langbot.app">Marché des Plugins</a>
<a href="https://langbot.featurebase.app/roadmap">Feuille de Route</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. - **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. - **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://langbot.app/docs/en/insight/features) [→ En savoir plus sur toutes les fonctionnalités](https://link.langbot.app/en/docs/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/). 📍 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/).
@@ -82,13 +82,12 @@ cd LangBot/docker
docker compose --profile all up -d docker compose --profile all up -d
``` ```
### Déploiement Cloud en un Clic ### Déploiement Cloud en un Clic
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH) [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH)
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) [![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF)
**Plus d'options :** [Docker](https://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) **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)
--- ---
@@ -149,7 +148,7 @@ docker compose --profile all up -d
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Plateforme GPU | ✅ | | [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Plateforme GPU | ✅ |
| [Qiniu](https://www.qiniu.com/ai/agent) | Passerelle | ✅ | | [Qiniu](https://www.qiniu.com/ai/agent) | Passerelle | ✅ |
[→ Voir toutes les intégrations](https://langbot.app/docs/en/insight/features) [→ Voir toutes les intégrations](https://link.langbot.app/en/docs/features)
--- ---
+6 -7
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) [![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://langbot.app">ホーム</a>
<a href="https://langbot.app/docs/ja/insight/features">機能</a> <a href="https://link.langbot.app/ja/docs/features">機能</a>
<a href="https://langbot.app/docs/ja/insight/guide">ドキュメント</a> <a href="https://link.langbot.app/ja/docs/guide">ドキュメント</a>
<a href="https://langbot.app/docs/ja/tags/readme">API</a> <a href="https://link.langbot.app/ja/docs/api">API</a>
<a href="https://space.langbot.app">プラグインマーケット</a> <a href="https://space.langbot.app">プラグインマーケット</a>
<a href="https://langbot.featurebase.app/roadmap">ロードマップ</a> <a href="https://langbot.featurebase.app/roadmap">ロードマップ</a>
@@ -48,7 +48,7 @@ LangBot は、AI搭載のインスタントメッセージングボットを構
- **Web管理パネル** — 直感的なブラウザインターフェースからボットの設定、管理、監視が可能。YAML編集は不要。 - **Web管理パネル** — 直感的なブラウザインターフェースからボットの設定、管理、監視が可能。YAML編集は不要。
- **マルチパイプラインアーキテクチャ** — 異なるシナリオに異なるボットを配置し、包括的な監視と例外処理を実現。 - **マルチパイプラインアーキテクチャ** — 異なるシナリオに異なるボットを配置し、包括的な監視と例外処理を実現。
[→ すべての機能について詳しく見る](https://langbot.app/docs/ja/insight/features) [→ すべての機能について詳しく見る](https://link.langbot.app/ja/docs/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/)。 📍 実践ガイド: [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/)。
@@ -82,13 +82,12 @@ cd LangBot/docker
docker compose --profile all up -d docker compose --profile all up -d
``` ```
### ワンクリッククラウドデプロイ ### ワンクリッククラウドデプロイ
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH) [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH)
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) [![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF)
**その他:** [Docker](https://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) **その他:** [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)
--- ---
@@ -149,7 +148,7 @@ docker compose --profile all up -d
| [302.AI](https://share.302ai.cn/SuTG99) | ゲートウェイ | ✅ | | [302.AI](https://share.302ai.cn/SuTG99) | ゲートウェイ | ✅ |
| [Qiniu](https://www.qiniu.com/ai/agent) | ゲートウェイ | ✅ | | [Qiniu](https://www.qiniu.com/ai/agent) | ゲートウェイ | ✅ |
[→ すべての統合を表示](https://langbot.app/docs/en/insight/features) [→ すべての統合を表示](https://link.langbot.app/en/docs/features)
--- ---
+6 -7
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) [![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://langbot.app">홈</a>
<a href="https://langbot.app/docs/en/insight/features">기능</a> <a href="https://link.langbot.app/en/docs/features">기능</a>
<a href="https://langbot.app/docs/en/insight/guide">문서</a> <a href="https://link.langbot.app/en/docs/guide">문서</a>
<a href="https://langbot.app/docs/en/tags/readme">API</a> <a href="https://link.langbot.app/en/docs/api">API</a>
<a href="https://space.langbot.app">플러그인 마켓</a> <a href="https://space.langbot.app">플러그인 마켓</a>
<a href="https://langbot.featurebase.app/roadmap">로드맵</a> <a href="https://langbot.featurebase.app/roadmap">로드맵</a>
@@ -48,7 +48,7 @@ LangBot은 AI 기반 인스턴트 메시징 봇을 구축하기 위한 **오픈
- **웹 관리 패널** — 직관적인 브라우저 인터페이스로 봇을 구성, 관리 및 모니터링. YAML 편집 불필요. - **웹 관리 패널** — 직관적인 브라우저 인터페이스로 봇을 구성, 관리 및 모니터링. YAML 편집 불필요.
- **멀티 파이프라인 아키텍처** — 다양한 시나리오에 맞는 다양한 봇 구성, 종합 모니터링 및 예외 처리. - **멀티 파이프라인 아키텍처** — 다양한 시나리오에 맞는 다양한 봇 구성, 종합 모니터링 및 예외 처리.
[→ 모든 기능 자세히 보기](https://langbot.app/docs/en/insight/features) [→ 모든 기능 자세히 보기](https://link.langbot.app/en/docs/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/). 📍 실전 가이드: [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/).
@@ -82,13 +82,12 @@ cd LangBot/docker
docker compose --profile all up -d docker compose --profile all up -d
``` ```
### 원클릭 클라우드 배포 ### 원클릭 클라우드 배포
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH) [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH)
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) [![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF)
**더 많은 옵션:** [Docker](https://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) **더 많은 옵션:** [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)
--- ---
@@ -149,7 +148,7 @@ docker compose --profile all up -d
| [302.AI](https://share.302ai.cn/SuTG99) | 게이트웨이 | ✅ | | [302.AI](https://share.302ai.cn/SuTG99) | 게이트웨이 | ✅ |
| [Qiniu](https://www.qiniu.com/ai/agent) | 게이트웨이 | ✅ | | [Qiniu](https://www.qiniu.com/ai/agent) | 게이트웨이 | ✅ |
[→ 모든 통합 보기](https://langbot.app/docs/en/insight/features) [→ 모든 통합 보기](https://link.langbot.app/en/docs/features)
--- ---
+6 -7
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) [![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://langbot.app">Главная</a>
<a href="https://langbot.app/docs/en/insight/features">Возможности</a> <a href="https://link.langbot.app/en/docs/features">Возможности</a>
<a href="https://langbot.app/docs/en/insight/guide">Документация</a> <a href="https://link.langbot.app/en/docs/guide">Документация</a>
<a href="https://langbot.app/docs/en/tags/readme">API</a> <a href="https://link.langbot.app/en/docs/api">API</a>
<a href="https://space.langbot.app">Магазин плагинов</a> <a href="https://space.langbot.app">Магазин плагинов</a>
<a href="https://langbot.featurebase.app/roadmap">Дорожная карта</a> <a href="https://langbot.featurebase.app/roadmap">Дорожная карта</a>
@@ -48,7 +48,7 @@ LangBot — это **платформа с открытым исходным к
- **Веб-панель управления** — Настраивайте, управляйте и мониторьте ваших ботов через интуитивный браузерный интерфейс. Ручное редактирование YAML не требуется. - **Веб-панель управления** — Настраивайте, управляйте и мониторьте ваших ботов через интуитивный браузерный интерфейс. Ручное редактирование YAML не требуется.
- **Мультиконвейерная архитектура** — Разные боты для разных сценариев с комплексным мониторингом и обработкой исключений. - **Мультиконвейерная архитектура** — Разные боты для разных сценариев с комплексным мониторингом и обработкой исключений.
[→ Подробнее обо всех возможностях](https://langbot.app/docs/en/insight/features) [→ Подробнее обо всех возможностях](https://link.langbot.app/en/docs/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/). 📍 Практические руководства: [развернуть мультиплатформенного ИИ-бота за 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/).
@@ -82,13 +82,12 @@ cd LangBot/docker
docker compose --profile all up -d docker compose --profile all up -d
``` ```
### Облачное развертывание одним кликом ### Облачное развертывание одним кликом
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH) [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH)
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) [![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF)
**Другие варианты:** [Docker](https://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) **Другие варианты:** [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)
--- ---
@@ -149,7 +148,7 @@ docker compose --profile all up -d
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Платформа GPU | ✅ | | [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Платформа GPU | ✅ |
| [Qiniu](https://www.qiniu.com/ai/agent) | Шлюз | ✅ | | [Qiniu](https://www.qiniu.com/ai/agent) | Шлюз | ✅ |
[→ Смотреть все интеграции](https://langbot.app/docs/en/insight/features) [→ Смотреть все интеграции](https://link.langbot.app/en/docs/features)
--- ---
+6 -7
View File
@@ -21,9 +21,9 @@
[![star](https://gitcode.com/RockChinQ/LangBot/star/badge.svg)](https://gitcode.com/RockChinQ/LangBot) [![star](https://gitcode.com/RockChinQ/LangBot/star/badge.svg)](https://gitcode.com/RockChinQ/LangBot)
<a href="https://langbot.app">官網</a> <a href="https://langbot.app">官網</a>
<a href="https://langbot.app/docs/zh/insight/features">特性</a> <a href="https://link.langbot.app/zh/docs/features">特性</a>
<a href="https://langbot.app/docs/zh/insight/guide">文件</a> <a href="https://link.langbot.app/zh/docs/guide">文件</a>
<a href="https://langbot.app/docs/zh/tags/readme">API</a> <a href="https://link.langbot.app/zh/docs/api">API</a>
<a href="https://space.langbot.app">外掛市場</a> <a href="https://space.langbot.app">外掛市場</a>
<a href="https://langbot.featurebase.app/roadmap">路線圖</a> <a href="https://langbot.featurebase.app/roadmap">路線圖</a>
@@ -50,7 +50,7 @@ LangBot 是一個**開源的生產級平台**,用於建構 AI 驅動的即時
- **Web 管理面板** — 透過瀏覽器直觀地配置、管理和監控機器人,無需手動編輯設定檔。 - **Web 管理面板** — 透過瀏覽器直觀地配置、管理和監控機器人,無需手動編輯設定檔。
- **多流水線架構** — 不同機器人用於不同場景,具備全面的監控和異常處理能力。 - **多流水線架構** — 不同機器人用於不同場景,具備全面的監控和異常處理能力。
[→ 了解更多功能特性](https://langbot.app/docs/zh/insight/features) [→ 了解更多功能特性](https://link.langbot.app/zh/docs/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/)。 📍 實踐指南:[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/)。
@@ -84,13 +84,12 @@ cd LangBot/docker
docker compose --profile all up -d docker compose --profile all up -d
``` ```
### 一鍵雲端部署 ### 一鍵雲端部署
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/zh-CN/templates/ZKTBDH) [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/zh-CN/templates/ZKTBDH)
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) [![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF)
**更多方式:** [Docker](https://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) **更多方式:** [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)
--- ---
@@ -165,7 +164,7 @@ docker compose --profile all up -d
|-----------|------| |-----------|------|
| 阿里雲百煉 | [外掛](https://github.com/Thetail001/LangBot_BailianTextToImagePlugin) | | 阿里雲百煉 | [外掛](https://github.com/Thetail001/LangBot_BailianTextToImagePlugin) |
[→ 查看完整整合列表](https://langbot.app/docs/zh/insight/features) [→ 查看完整整合列表](https://link.langbot.app/zh/docs/features)
--- ---
+6 -7
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) [![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://langbot.app">Trang chủ</a>
<a href="https://langbot.app/docs/en/insight/features">Tính năng</a> <a href="https://link.langbot.app/en/docs/features">Tính năng</a>
<a href="https://langbot.app/docs/en/insight/guide">Tài liệu</a> <a href="https://link.langbot.app/en/docs/guide">Tài liệu</a>
<a href="https://langbot.app/docs/en/tags/readme">API</a> <a href="https://link.langbot.app/en/docs/api">API</a>
<a href="https://space.langbot.app">Chợ Plugin</a> <a href="https://space.langbot.app">Chợ Plugin</a>
<a href="https://langbot.featurebase.app/roadmap">Lộ trình</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. - **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ệ. - **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://langbot.app/docs/en/insight/features) [→ Tìm hiểu thêm về tất cả tính năng](https://link.langbot.app/en/docs/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/). 📍 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/).
@@ -82,13 +82,12 @@ cd LangBot/docker
docker compose --profile all up -d docker compose --profile all up -d
``` ```
### Triển khai đám mây một cú nhấp ### Triển khai đám mây một cú nhấp
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH) [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH)
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) [![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF)
**Thêm tùy chọn:** [Docker](https://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) **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)
--- ---
@@ -149,7 +148,7 @@ docker compose --profile all up -d
| [302.AI](https://share.302ai.cn/SuTG99) | Cổng | ✅ | | [302.AI](https://share.302ai.cn/SuTG99) | Cổng | ✅ |
| [Qiniu](https://www.qiniu.com/ai/agent) | Cổng | ✅ | | [Qiniu](https://www.qiniu.com/ai/agent) | Cổng | ✅ |
[→ Xem tất cả tích hợp](https://langbot.app/docs/en/insight/features) [→ Xem tất cả tích hợp](https://link.langbot.app/en/docs/features)
--- ---
+8 -8
View File
@@ -1,5 +1,5 @@
# Docker Compose configuration for LangBot # Docker Compose configuration for LangBot
# For Kubernetes deployment, see kubernetes.yaml and the deployment guide at https://langbot.app/docs # For Kubernetes deployment, see kubernetes.yaml and the deployment guide at https://docs.langbot.app
version: "3" version: "3"
services: services:
@@ -47,10 +47,11 @@ services:
restart: on-failure restart: on-failure
environment: environment:
- TZ=Asia/Shanghai - TZ=Asia/Shanghai
# Optional shared control-plane secret used to authenticate both the RPC # Shared control-plane secret used to authenticate both the RPC socket
# socket and managed-process relay. Leave unset on both OSS services, or # and managed-process relay. Generate once (for example with
# generate one with ``openssl rand -hex 32`` and set the same value on # ``openssl rand -hex 32``) and export it before enabling this profile.
# both ends. Strongly recommended when the deployment is Internet-accessible. # An empty value is accepted by Compose so Box can remain optional, but
# the Box runtime itself fails closed when the profile is started.
- LANGBOT_BOX_CONTROL_TOKEN=${LANGBOT_BOX_CONTROL_TOKEN:-} - LANGBOT_BOX_CONTROL_TOKEN=${LANGBOT_BOX_CONTROL_TOKEN:-}
# Box has its own process-wide blocking-work budget. # Box has its own process-wide blocking-work budget.
- LANGBOT_BLOCKING_EXECUTOR_MAX_WORKERS=${LANGBOT_BLOCKING_EXECUTOR_MAX_WORKERS:-8} - LANGBOT_BLOCKING_EXECUTOR_MAX_WORKERS=${LANGBOT_BLOCKING_EXECUTOR_MAX_WORKERS:-8}
@@ -78,9 +79,8 @@ services:
- TZ=Asia/Shanghai - TZ=Asia/Shanghai
# Optional. Leave unset on both OSS services, or match plugin Runtime. # Optional. Leave unset on both OSS services, or match plugin Runtime.
- LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN=${LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN:-} - LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN=${LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN:-}
# When set, this must match langbot_box. If both ends leave it unset, # Must match the value supplied to langbot_box. The token is sent only
# OSS permits the connection without token authentication. The token is # in WebSocket handshake headers, never in URLs or action payloads.
# sent only in WebSocket handshake headers, never in URLs or payloads.
- LANGBOT_BOX_CONTROL_TOKEN=${LANGBOT_BOX_CONTROL_TOKEN:-} - LANGBOT_BOX_CONTROL_TOKEN=${LANGBOT_BOX_CONTROL_TOKEN:-}
# Core process-wide blocking-work admission. These are native config # Core process-wide blocking-work admission. These are native config
# overrides and are persisted with the effective data/config.yaml. # overrides and are persisted with the effective data/config.yaml.
+1 -1
View File
@@ -1,7 +1,7 @@
# Kubernetes Deployment for LangBot # Kubernetes Deployment for LangBot
# This file provides Kubernetes deployment manifests for LangBot based on docker-compose.yaml # This file provides Kubernetes deployment manifests for LangBot based on docker-compose.yaml
# #
# Full deployment guide (zh/en/ja): https://langbot.app/docs -> Installation -> Kubernetes # Full deployment guide (zh/en/ja): https://docs.langbot.app -> Installation -> Kubernetes
# #
# Usage: # Usage:
# kubectl -n langbot create secret generic langbot-plugin-runtime-control \ # kubectl -n langbot create secret generic langbot-plugin-runtime-control \
-17
View File
@@ -88,23 +88,6 @@ Each endpoint accepts **either**:
1. **User Token** (via `Authorization: Bearer <user_jwt_token>`) - for web UI and authenticated users 1. **User Token** (via `Authorization: Bearer <user_jwt_token>`) - for web UI and authenticated users
2. **API Key** (via `X-API-Key` or `Authorization: Bearer <api_key>`) - for external services 2. **API Key** (via `X-API-Key` or `Authorization: Bearer <api_key>`) - for external services
### Inspecting API Key Identity
`GET /api/v1/system/context` validates an API key (user JWT not accepted) and returns its bound identity without requiring resource permissions:
```json
{
"code": 0,
"msg": "ok",
"data": {
"instance_uuid": "...",
"workspace_uuid": "...",
"api_key_id": "...",
"permissions": ["..."]
}
}
```
## Example: Model Management ## Example: Model Management
### List All LLM Models ### List All LLM Models
+2 -2
View File
@@ -218,8 +218,8 @@ metadata:
spec: spec:
categories: [popular, global] categories: [popular, global]
help_links: help_links:
zh: https://langbot.app/docs/zh/platforms/http-bot zh: https://docs.langbot.app/zh/platforms/http-bot
en: https://langbot.app/docs/en/platforms/http-bot en: https://docs.langbot.app/en/platforms/http-bot
config: config:
- { name: inbound_secret, type: string, required: true, default: "" } - { name: inbound_secret, type: string, required: true, default: "" }
- { name: callback_url, type: string, required: false, default: "" } - { name: callback_url, type: string, required: false, default: "" }
+1 -18
View File
@@ -10,19 +10,6 @@ uvx langbot
This will automatically download and run the latest version of LangBot. This will automatically download and run the latest version of LangBot.
SeekDB support is optional and is not installed by the command above. If you
want to use the SeekDB vector database or the built-in SeekDB embedding model,
run LangBot with the `seekdb` extra:
```bash
uvx --from 'langbot[seekdb]@latest' langbot
```
The extra includes native dependencies whose supported operating systems may
be narrower than LangBot's. In particular, the current Apple Silicon wheels
require macOS 15 or later. The default Chroma backend does not have this
requirement.
## Install with pip/uv ## Install with pip/uv
You can also install LangBot as a regular Python package: You can also install LangBot as a regular Python package:
@@ -33,10 +20,6 @@ pip install langbot
# Using uv # Using uv
uv pip install langbot uv pip install langbot
# Include optional SeekDB support
pip install 'langbot[seekdb]'
# or: uv pip install 'langbot[seekdb]'
``` ```
Then run it: Then run it:
@@ -118,7 +101,7 @@ uvx langbot
## System Requirements ## System Requirements
- Python 3.11 or higher (lower than Python 4) - Python 3.10.1 or higher
- Operating System: Linux, macOS, or Windows - Operating System: Linux, macOS, or Windows
## Differences from Source Installation ## Differences from Source Installation
+44 -35
View File
@@ -16,20 +16,12 @@ This document describes how to use OceanBase SeekDB as the vector database backe
## Installation ## Installation
SeekDB is an optional LangBot feature. A normal LangBot installation uses SeekDB support is automatically included when you install LangBot. The required dependency `pyseekdb` is listed in `pyproject.toml`.
Chroma by default and does not install `pyseekdb` or its native bindings.
Choose the command that matches how you run LangBot: If you need to install it manually:
```bash ```bash
# PyPI / uvx pip install pyseekdb
uvx --from 'langbot[seekdb]@latest' langbot
# Installed package
pip install 'langbot[seekdb]'
# Source checkout
uv sync --extra seekdb
``` ```
## ⚠️ Platform Compatibility ## ⚠️ Platform Compatibility
@@ -38,36 +30,31 @@ uv sync --extra seekdb
| Platform | Status | Notes | | Platform | Status | Notes |
|----------|--------|-------| |----------|--------|-------|
| Linux x86_64 / ARM64 | ✅ Supported | Full embedded mode support via `pylibseekdb` | | Linux | ✅ Supported | Full embedded mode support via `pylibseekdb` |
| macOS 15+ on Apple Silicon | ✅ Supported | Requires the macOS ARM64 `pylibseekdb` wheel | | macOS | ❌ Not Supported | `pylibseekdb` is Linux-only; use server mode instead |
| macOS 14 or earlier on Apple Silicon | ❌ Not currently supported | The published native wheel requires macOS 15+; follow [oceanbase/seekdb#1324](https://github.com/oceanbase/seekdb/issues/1324) | | Windows | ❌ Not Supported | `pylibseekdb` is Linux-only; use server mode instead |
| macOS on Intel | ❌ Not currently supported | No embedded binding is selected by `pyseekdb` |
| Windows | ❌ Not currently supported | No Windows `pylibseekdb` wheel is published |
**Important**: Embedded mode requires a compatible `pylibseekdb` wheel. Do not **Important**: Embedded mode requires the `pylibseekdb` library, which is only available on Linux. If you're on macOS or Windows, you must use server mode.
force-install or retag a wheel built for a newer macOS release: the bundled
binaries also declare macOS 15 as their minimum deployment target.
### Server Mode (Docker) ### Server Mode (Docker)
| Platform | Status | Notes | | Platform | Status | Notes |
|----------|--------|-------| |----------|--------|-------|
| Linux | ✅ Supported | Full Docker support | | Linux | ✅ Supported | Full Docker support |
| macOS | ✅ Supported by Docker Desktop | The previous slow-disk startup issue was fixed upstream in [oceanbase/seekdb#36](https://github.com/oceanbase/seekdb/issues/36) | | macOS | ⚠️ Known Issue | Docker container initialization failure - [See Issue #36](https://github.com/oceanbase/seekdb/issues/36) |
| Windows | ⚠️ Depends on the container runtime | Use a Linux container and follow the upstream image documentation | | Windows | ⚠️ Untested | Should work but not yet tested |
**macOS Users**: Currently, SeekDB Docker containers have an initialization issue on macOS ([oceanbase/seekdb#36](https://github.com/oceanbase/seekdb/issues/36)). Until this is resolved, we recommend:
- Using ChromaDB or Qdrant as alternatives
- Connecting to a remote SeekDB server on Linux if available
### Server Mode (Remote Connection) ### Server Mode (Remote Connection)
| Platform | Status | Notes | | Platform | Status | Notes |
|----------|--------|-------| |----------|--------|-------|
| Linux | ✅ Supported | Install the `seekdb` extra and connect to the remote server | | All Platforms | ✅ Supported | Connect to SeekDB running on a remote Linux server |
| macOS 15+ on Apple Silicon | ✅ Supported | Install the `seekdb` extra and connect to the remote server |
| macOS 14 or earlier on Apple Silicon | ⚠️ Blocked by upstream packaging | `pyseekdb` currently requires the unavailable native wheel even for server-only use; follow [#1324](https://github.com/oceanbase/seekdb/issues/1324) |
| macOS on Intel / Windows | ✅ Server mode only | Embedded bindings are not available |
Remote server mode does not use embedded storage at runtime. However, whether **Recommendation for macOS/Windows users**: Deploy SeekDB on a Linux server and connect via server mode configuration.
the Python client can be installed still depends on `pyseekdb`'s package
metadata for the current platform.
## Configuration ## Configuration
@@ -183,23 +170,22 @@ Key methods:
### Import Error ### Import Error
If you see: `SeekDB support is not installed` If you see: `ImportError: pyseekdb is not installed`
Solution: Solution:
```bash ```bash
uv sync --extra seekdb pip install pyseekdb
# or: uvx --from 'langbot[seekdb]@latest' langbot
``` ```
### Embedded Mode Is Unavailable on the Current Platform ### Embedded Mode Error on macOS/Windows
**Error**: **Error**:
``` ```
RuntimeError: Embedded Client is not available because pylibseekdb is not available. RuntimeError: Embedded Client is not available because pylibseekdb is not available.
Please install pylibseekdb (Linux only) or use RemoteServerClient (host/port) instead.
``` ```
**Cause**: No compatible `pylibseekdb` wheel is installed for the current OS, **Cause**: `pylibseekdb` is only available on Linux platforms.
CPU architecture, Python version, and macOS deployment target.
**Solution**: Use server mode instead: **Solution**: Use server mode instead:
1. Deploy SeekDB on a Linux server or VM 1. Deploy SeekDB on a Linux server or VM
@@ -222,6 +208,29 @@ vdb:
use: chroma # or qdrant use: chroma # or qdrant
``` ```
### Docker Container Fails on macOS
**Symptoms**:
```bash
docker run -d -p 2881:2881 oceanbase/seekdb:latest
# Container exits immediately with code 30
```
**Error in logs**:
```
[ERROR] Code: Agent.SeekDB.Not.Exists
Message: initialize failed: init agent failed: SeekDB not exists in current directory.
```
**Cause**: This is a known issue with SeekDB Docker containers on macOS. See [oceanbase/seekdb#36](https://github.com/oceanbase/seekdb/issues/36).
**Status**: Under investigation by OceanBase team.
**Workaround Options**:
1. **Use alternatives**: ChromaDB or Qdrant work perfectly on macOS
2. **Remote server**: Deploy SeekDB on a Linux server and connect remotely
3. **Wait for fix**: Monitor the GitHub issue for updates
### Connection Error (Server Mode) ### Connection Error (Server Mode)
If SeekDB server is not reachable, check: If SeekDB server is not reachable, check:
@@ -243,7 +252,7 @@ For large datasets:
- SeekDB GitHub: https://github.com/oceanbase/seekdb - SeekDB GitHub: https://github.com/oceanbase/seekdb
- pyseekdb SDK: https://github.com/oceanbase/pyseekdb - pyseekdb SDK: https://github.com/oceanbase/pyseekdb
- OceanBase Documentation: https://oceanbase.ai - OceanBase Documentation: https://oceanbase.ai
- LangBot Documentation: https://langbot.app/docs - LangBot Documentation: https://docs.langbot.app
## License ## 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 They show the whole loop: signing a request, pushing a message, and receiving
multi-part replies on a callback endpoint. multi-part replies on a callback endpoint.
Full guide: [docs.langbot.app — HTTP Bot](https://langbot.app/docs/en/usage/platforms/http-bot). Full guide: [docs.langbot.app — HTTP Bot](https://docs.langbot.app/en/usage/platforms/http-bot).
Machine-readable contract: [`docs/http-bot-openapi.json`](../../docs/http-bot-openapi.json). Machine-readable contract: [`docs/http-bot-openapi.json`](../../docs/http-bot-openapi.json).
## Files ## Files
+1 -1
View File
@@ -6,7 +6,7 @@
它们完整展示了整条链路:对请求签名、推送一条消息、在回调端点接收 它们完整展示了整条链路:对请求签名、推送一条消息、在回调端点接收
1→M 的多段回复。 1→M 的多段回复。
完整指南:[docs.langbot.app —— HTTP Bot](https://langbot.app/docs/zh/usage/platforms/http-bot)。 完整指南:[docs.langbot.app —— HTTP Bot](https://docs.langbot.app/zh/usage/platforms/http-bot)。
机器可读的接口契约:[`docs/http-bot-openapi.json`](../../docs/http-bot-openapi.json)。 机器可读的接口契约:[`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 (`web_page_bot`) embeddable chat widget — the one you drop onto any website with
a single `<script>` tag. a single `<script>` tag.
Full guide: [docs.langbot.app — Page Bot](https://langbot.app/docs/en/usage/platforms/webpage). Full guide: [docs.langbot.app — Page Bot](https://docs.langbot.app/en/usage/platforms/webpage).
## Files ## Files
+1 -1
View File
@@ -6,7 +6,7 @@
(`web_page_bot`) 的可嵌入聊天组件 —— 也就是你用一行 `<script>` 标签就能放到任意 (`web_page_bot`) 的可嵌入聊天组件 —— 也就是你用一行 `<script>` 标签就能放到任意
网站上的那个组件。 网站上的那个组件。
完整指南:[docs.langbot.app —— 页面机器人](https://langbot.app/docs/zh/usage/platforms/webpage)。 完整指南:[docs.langbot.app —— 页面机器人](https://docs.langbot.app/zh/usage/platforms/webpage)。
## 文件清单 ## 文件清单
+4 -8
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "langbot" name = "langbot"
version = "4.10.10" version = "4.10.7"
description = "Production-grade platform for building agentic IM bots" description = "Production-grade platform for building agentic IM bots"
readme = "README.md" readme = "README.md"
license-files = ["LICENSE"] license-files = ["LICENSE"]
@@ -70,7 +70,8 @@ dependencies = [
"langchain-text-splitters>=1.1.2", "langchain-text-splitters>=1.1.2",
"chromadb>=1.0.0,<2.0.0", "chromadb>=1.0.0,<2.0.0",
"qdrant-client (>=1.15.1,<2.0.0)", "qdrant-client (>=1.15.1,<2.0.0)",
"langbot-plugin==0.5.7", "pyseekdb==1.1.0.post3",
"langbot-plugin @ git+https://github.com/langbot-app/langbot-plugin-sdk.git@555a58e5db3de28e977b08dd4cd116b332848a19",
"asyncpg>=0.30.0", "asyncpg>=0.30.0",
"line-bot-sdk>=3.19.0", "line-bot-sdk>=3.19.0",
"matrix-nio>=0.25.2", "matrix-nio>=0.25.2",
@@ -107,14 +108,9 @@ classifiers = [
"Topic :: Communications :: Chat", "Topic :: Communications :: Chat",
] ]
[project.optional-dependencies]
seekdb = [
"pyseekdb==1.1.0.post3",
]
[project.urls] [project.urls]
Homepage = "https://langbot.app" Homepage = "https://langbot.app"
Documentation = "https://langbot.app/docs" Documentation = "https://docs.langbot.app"
Repository = "https://github.com/langbot-app/LangBot" Repository = "https://github.com/langbot-app/LangBot"
[project.scripts] [project.scripts]
+1 -2
View File
@@ -1349,8 +1349,7 @@
"local-agent", "local-agent",
"tools", "tools",
"e2b", "e2b",
"nsjail", "nsjail"
"host"
], ],
"automation": "", "automation": "",
"setup_automation": [], "setup_automation": [],
+2 -8
View File
@@ -48,7 +48,7 @@ tools, skill add/edit, and stdio MCP are disabled. Set `box.enabled: false`
## Kubernetes ## Kubernetes
See `docker/kubernetes.yaml` and the deployment guide at See `docker/kubernetes.yaml` and the deployment guide at
https://langbot.app/docs. `docker/deploy-k8s-test.sh` is a test helper. https://docs.langbot.app. `docker/deploy-k8s-test.sh` is a test helper.
## config.yaml (generated at `data/config.yaml` on first run) ## 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. | | `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`) | | `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.enabled` | Master switch for the Box sandbox runtime |
| `box.backend` | `local` (Docker/nsjail autopick) / `docker` / `nsjail` / `e2b` / explicit unsafe `host`; env override `BOX__BACKEND` | | `box.backend` | `local` (Docker/nsjail autopick) / `docker` / `nsjail` / `e2b`; env override `BOX__BACKEND` |
| `box.runtime.endpoint` | External Box runtime URL (e.g. `ws://127.0.0.1:5410`); empty = local auto-managed | | `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`). Many keys have `ENV__SUBKEY` overrides (e.g. `BOX__BACKEND`, `BOX__ENABLED`).
@@ -75,10 +75,6 @@ Many keys have `ENV__SUBKEY` overrides (e.g. `BOX__BACKEND`, `BOX__ENABLED`).
with `--standalone-runtime`. with `--standalone-runtime`.
- Box has a parallel `--standalone-box` flag; the Docker box host is - Box has a parallel `--standalone-box` flag; the Docker box host is
`langbot_box:5410`. `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 ## Global API key — enabling for agents/automation
@@ -97,7 +93,5 @@ 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 - "No supported sandbox backend (Docker / nsjail / E2B)" with Docker running
usually means the user isn't in the `docker` group → usually means the user isn't in the `docker` group →
`sudo usermod -aG docker <user>` and restart in a new shell. `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. - Box root host/container path mismatch breaks sandbox container creation.
- Don't commit a non-empty `api.global_api_key` to version control. - Don't commit a non-empty `api.global_api_key` to version control.
-4
View File
@@ -43,8 +43,6 @@ Two kinds of key are accepted:
Invalid, revoked, or expired keys get `401 Unauthorized`. A valid key whose Invalid, revoked, or expired keys get `401 Unauthorized`. A valid key whose
scopes do not authorize a tool gets `403 Forbidden`. scopes do not authorize a tool gets `403 Forbidden`.
To inspect key identity and permissions, call `GET /api/v1/system/context` with the API key.
## Client configuration ## Client configuration
```json ```json
@@ -77,8 +75,6 @@ shape as the corresponding HTTP API request body. Discover resources with the
`list_*` / `get_*` tools before mutating; identifiers are UUIDs. Reads require `list_*` / `get_*` tools before mutating; identifiers are UUIDs. Reads require
`resource.view`; mutations require `resource.manage`. All service calls inherit `resource.view`; mutations require `resource.manage`. All service calls inherit
the immutable Workspace context authenticated at the MCP transport boundary. the immutable Workspace context authenticated at the MCP transport boundary.
Pass `is_default: true` to `create_pipeline` only when the Workspace does not
already have a default pipeline.
## How to use ## How to use
@@ -13,7 +13,6 @@ tags:
- tools - tools
- e2b - e2b
- nsjail - nsjail
- host
skills: skills:
- langbot-env-setup - langbot-env-setup
- langbot-testing - langbot-testing
@@ -24,7 +23,7 @@ env:
- LANGBOT_LOCAL_AGENT_PIPELINE_NAME - LANGBOT_LOCAL_AGENT_PIPELINE_NAME
preconditions: preconditions:
- "LANGBOT_LOCAL_AGENT_PIPELINE_URL or LANGBOT_LOCAL_AGENT_PIPELINE_NAME points to the local-agent pipeline under test." - "LANGBOT_LOCAL_AGENT_PIPELINE_URL or LANGBOT_LOCAL_AGENT_PIPELINE_NAME points to the local-agent pipeline under test."
- "LangBot is started with the Box backend intended for this run, such as e2b, nsjail, or explicit host development mode." - "LangBot is started with the sandbox backend intended for this run, such as e2b or nsjail."
- "The selected model route supports tool/function calling strongly enough to invoke sandbox tools." - "The selected model route supports tool/function calling strongly enough to invoke sandbox tools."
steps: 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." - "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."
@@ -34,7 +33,7 @@ steps:
checks: checks:
- "UI: Debug Chat final assistant response contains E2E_OK:<skill-name>." - "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 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, nsjail, or host." - "Logs: The selected backend name is the expected one, such as e2b or nsjail."
- "Skill store: The registered package and activated writeback match references/sandbox-skill-authoring.md." - "Skill store: The registered package and activated writeback match references/sandbox-skill-authoring.md."
- "Box status: recent_error_count is 0 after the run." - "Box status: recent_error_count is 0 after the run."
evidence_required: 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. 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, 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. 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.
## Preconditions ## Preconditions
@@ -13,7 +13,6 @@ This flow applies to Docker, nsjail, E2B, and the explicit host development back
- `BOX_BACKEND=e2b` when validating E2B. - `BOX_BACKEND=e2b` when validating E2B.
- `BOX_BACKEND=nsjail` when validating nsjail. - `BOX_BACKEND=nsjail` when validating nsjail.
- `BOX_BACKEND=local` or `docker` when validating local container fallback. - `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. 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. 4. Confirm Debug Chat uses a model with function-calling ability.
5. Confirm backend logs say native sandbox tools are available. 5. Confirm backend logs say native sandbox tools are available.
@@ -72,7 +71,7 @@ Backend logs should show:
- `register_skill` - `register_skill`
- `activate` - `activate`
- a second `exec` whose workdir is `/workspace/.skills/<skill-name>` - a second `exec` whose workdir is `/workspace/.skills/<skill-name>`
- `backend=e2b`, `backend=nsjail`, `backend=host`, or the expected local backend - `backend=e2b`, `backend=nsjail`, or the expected local backend
After the run, verify the skill store through the UI or API: After the run, verify the skill store through the UI or API:
@@ -126,8 +125,6 @@ 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. - 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`. - 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. - 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. - 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 ## Related Troubleshooting
@@ -3,7 +3,7 @@ title: "Native sandbox tools are unavailable even though a backend is configured
date: 2026-05-18 date: 2026-05-18
symptoms: symptoms:
- "Backend logs show Native sandbox tools (exec/read/write/edit/glob/grep) are NOT available." - "Backend logs show Native sandbox tools (exec/read/write/edit/glob/grep) are NOT available."
- "The Box runtime later reports that E2B, nsjail, Docker, or explicit host mode is configured." - "The Box runtime later reports that E2B, nsjail, or Docker is configured."
- "Debug Chat does not expose exec, register_skill, or activate as usable tools." - "Debug Chat does not expose exec, register_skill, or activate as usable tools."
patterns: patterns:
- "Native sandbox tools ... are NOT available" - "Native sandbox tools ... are NOT available"
@@ -19,7 +19,6 @@ fix_steps:
- "Ensure the Box runtime reselects a backend when get_backend_info is called and the cached backend is empty." - "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 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 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." 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: related_cases:
- sandbox-skill-authoring-e2e - sandbox-skill-authoring-e2e
+1 -1
View File
@@ -16,7 +16,7 @@ asciiart = r"""
|___/ |___/
Open Source 开源地址: https://github.com/langbot-app/LangBot Open Source 开源地址: https://github.com/langbot-app/LangBot
📖 Documentation 文档地址: https://langbot.app/docs 📖 Documentation 文档地址: https://docs.langbot.app
""" """
+11 -38
View File
@@ -1,14 +1,13 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import json
import os
import typing
from pathlib import Path
import httpx import httpx
import typing
import json
from .errors import DifyAPIError from .errors import DifyAPIError
from pathlib import Path
import os
_MAX_DIFY_RESPONSE_BYTES = 1024 * 1024 _MAX_DIFY_RESPONSE_BYTES = 1024 * 1024
_MAX_DIFY_SSE_LINE_BYTES = 1024 * 1024 _MAX_DIFY_SSE_LINE_BYTES = 1024 * 1024
@@ -16,32 +15,6 @@ _MAX_DIFY_STREAM_BYTES = 16 * 1024 * 1024
_MAX_DIFY_UPLOAD_BYTES = 10 * 1024 * 1024 _MAX_DIFY_UPLOAD_BYTES = 10 * 1024 * 1024
def _decode_sse_data(line: bytes) -> dict[str, typing.Any] | None:
data = line[5:].strip()
if not data or data == b'[DONE]':
return None
try:
payload = json.loads(data.decode('utf-8'))
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
raise DifyAPIError('Dify SSE data line is not valid JSON') from exc
if not isinstance(payload, dict):
raise DifyAPIError('Dify SSE event is not a JSON object')
return payload
def _decode_upload_response(body: bytes) -> dict[str, typing.Any]:
try:
response = json.loads(body)
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
raise DifyAPIError('Dify upload response is not valid JSON') from exc
if not isinstance(response, dict):
raise DifyAPIError('Dify upload response is not a JSON object')
payload = response.get('data', response)
if not isinstance(payload, dict) or not isinstance(payload.get('id'), str) or not payload['id']:
raise DifyAPIError('Dify upload response does not contain a valid file id')
return payload
async def _read_limited_response( async def _read_limited_response(
response: httpx.Response, response: httpx.Response,
*, *,
@@ -83,16 +56,16 @@ async def _iter_sse_json(
line = raw_line.rstrip(b'\r').strip() line = raw_line.rstrip(b'\r').strip()
if not line or not line.startswith(b'data:'): if not line or not line.startswith(b'data:'):
continue continue
payload = _decode_sse_data(line) payload = json.loads(line[5:].decode('utf-8', errors='replace'))
if payload is not None: if isinstance(payload, dict):
yield payload yield payload
if len(buffer) > _MAX_DIFY_SSE_LINE_BYTES: if len(buffer) > _MAX_DIFY_SSE_LINE_BYTES:
raise DifyAPIError('Dify SSE event exceeds the runtime limit') raise DifyAPIError('Dify SSE event exceeds the runtime limit')
line = bytes(buffer).rstrip(b'\r').strip() line = bytes(buffer).rstrip(b'\r').strip()
if line.startswith(b'data:'): if line.startswith(b'data:'):
payload = _decode_sse_data(line) payload = json.loads(line[5:].decode('utf-8', errors='replace'))
if payload is not None: if isinstance(payload, dict):
yield payload yield payload
@@ -269,7 +242,7 @@ class AsyncDifyServiceClient:
file: httpx._types.FileTypes, file: httpx._types.FileTypes,
user: str, user: str,
timeout: float = 30.0, timeout: float = 30.0,
) -> dict[str, typing.Any]: ) -> str:
# 处理 Path 对象 # 处理 Path 对象
if isinstance(file, Path): if isinstance(file, Path):
if not file.exists(): if not file.exists():
@@ -298,6 +271,6 @@ class AsyncDifyServiceClient:
timeout=timeout, timeout=timeout,
) as response: ) as response:
body = await _read_limited_response(response) body = await _read_limited_response(response)
if response.status_code not in (200, 201): if response.status_code != 201:
raise DifyAPIError(f'{response.status_code} {body.decode(errors="replace")}') raise DifyAPIError(f'{response.status_code} {body.decode(errors="replace")}')
return _decode_upload_response(body) return json.loads(body)
+2 -3
View File
@@ -697,10 +697,9 @@ class DingTalkClient:
if not await self.check_access_token(): if not await self.check_access_token():
await self.get_access_token() await self.get_access_token()
template_params = dict(card_param_map or {}) cardData: dict = {'cardParamMap': _stringify_card_param_map(card_param_map)}
if card_data_config is not None: if card_data_config is not None:
template_params['config'] = card_data_config cardData['config'] = json.dumps(card_data_config)
cardData: dict = {'cardParamMap': _stringify_card_param_map(template_params)}
body: dict = { body: dict = {
'cardTemplateId': card_template_id, 'cardTemplateId': card_template_id,
-63
View File
@@ -422,69 +422,6 @@ class QQOfficialClient:
await self.logger.error(f'Failed to send private message: {response_data}') await self.logger.error(f'Failed to send private message: {response_data}')
raise ValueError(response) raise ValueError(response)
async def _send_markdown_msg(
self,
target_type: str,
target_id: str,
content: str,
msg_id: Optional[str] = None,
event_id: Optional[str] = None,
msg_seq: int = 1,
) -> None:
"""Send a Markdown message to a C2C user or QQ group."""
if not await self.check_access_token():
await self.get_access_token()
if target_type == 'c2c':
url = f'{self.base_url}/v2/users/{target_id}/messages'
elif target_type == 'group':
url = f'{self.base_url}/v2/groups/{target_id}/messages'
else:
raise ValueError(f'Unsupported Markdown target type: {target_type}')
data: dict[str, Any] = {
'msg_type': 2,
'markdown': {'content': content},
'msg_seq': msg_seq,
}
if msg_id:
data['msg_id'] = msg_id
if event_id:
data['event_id'] = event_id
async with self._http_client_context() as client:
headers = {
'Authorization': f'QQBot {self.access_token}',
'Content-Type': 'application/json',
}
response = await client.post(url, headers=headers, json=data)
if response.status_code != 200:
response_data = await httpclient.parse_json_response(response)
await self.logger.error(f'Failed to send Markdown message: {response_data}')
raise ValueError(response)
async def send_private_markdown_msg(
self,
user_openid: str,
content: str,
msg_id: Optional[str] = None,
event_id: Optional[str] = None,
msg_seq: int = 1,
) -> None:
"""Send a Markdown C2C message."""
await self._send_markdown_msg('c2c', user_openid, content, msg_id, event_id, msg_seq)
async def send_group_markdown_msg(
self,
group_openid: str,
content: str,
msg_id: Optional[str] = None,
event_id: Optional[str] = None,
msg_seq: int = 1,
) -> None:
"""Send a Markdown QQ group message."""
await self._send_markdown_msg('group', group_openid, content, msg_id, event_id, msg_seq)
async def send_group_text_msg( async def send_group_text_msg(
self, self,
group_openid: str, group_openid: str,
@@ -46,14 +46,6 @@ CMD_RESPOND_MSG = 'aibot_respond_msg'
CMD_RESPOND_WELCOME = 'aibot_respond_welcome_msg' CMD_RESPOND_WELCOME = 'aibot_respond_welcome_msg'
CMD_RESPOND_UPDATE = 'aibot_respond_update_msg' CMD_RESPOND_UPDATE = 'aibot_respond_update_msg'
CMD_SEND_MSG = 'aibot_send_msg' CMD_SEND_MSG = 'aibot_send_msg'
# Media upload protocol (3 steps: init -> chunk * N -> finish). The
# command names below match the WeCom AI Bot long-connection protocol.
CMD_UPLOAD_INIT = 'aibot_upload_media_init'
CMD_UPLOAD_CHUNK = 'aibot_upload_media_chunk'
CMD_UPLOAD_FINISH = 'aibot_upload_media_finish'
# Default upload chunk size: 512 KB before base64 encoding.
_UPLOAD_CHUNK_SIZE = 512 * 1024
_DEDUP_CACHE_MAX = 4096 _DEDUP_CACHE_MAX = 4096
_STREAM_CACHE_MAX = 1024 _STREAM_CACHE_MAX = 1024
@@ -503,145 +495,6 @@ class WecomBotWsClient:
body['chatid'] = chat_id body['chatid'] = chat_id
return await self._send_reply(req_id, body, cmd=CMD_SEND_MSG) return await self._send_reply(req_id, body, cmd=CMD_SEND_MSG)
# ------------------------------------------------------------------
# Media upload (image / voice / file)
# ------------------------------------------------------------------
async def upload_media(
self,
data: bytes,
filename: str = 'attachment',
media_type: str = 'file',
) -> Optional[dict]:
"""Upload *data* to the WeCom AI Bot CDN and return the parsed ACK.
Implements the three-step protocol documented for the WeCom
AI Bot:
1. ``aibot_upload_media_init`` declare media type, file name,
size, MD5 and chunk count; receive ``upload_id``.
2. ``aibot_upload_media_chunk`` send each chunk (base64-encoded
bytes) until done; receive per-chunk ACK.
3. ``aibot_upload_media_finish`` finalize the upload; receive
``media_id``.
Returns a dict with the final ``media_id`` (and the raw
``finish`` ACK) on success, or ``None`` on any failure. The
caller is expected to ignore the result and continue
gracefully the framework will keep working without media
delivery.
"""
import base64 as _b64
import hashlib as _hl
if not data:
return None
file_size = len(data)
file_md5 = _hl.md5(data).hexdigest()
total_chunks = (file_size + _UPLOAD_CHUNK_SIZE - 1) // _UPLOAD_CHUNK_SIZE
if total_chunks == 0:
total_chunks = 1
# Step 1: init.
init_req_id = _generate_req_id(CMD_UPLOAD_INIT)
init_body = {
'type': media_type,
'filename': filename,
'total_size': file_size,
'total_chunks': total_chunks,
'md5': file_md5,
}
init_ack = await self._send_reply(
init_req_id,
init_body,
cmd=CMD_UPLOAD_INIT,
)
if not init_ack or init_ack.get('errcode', 0) != 0:
await self.logger.warning(f'upload_media init failed: ack={init_ack!r}')
return None
upload_id = (
init_ack.get('upload_id')
or init_ack.get('body', {}).get('upload_id')
or init_ack.get('data', {}).get('upload_id')
)
if not upload_id:
await self.logger.warning(f'upload_media init returned no upload_id: ack={init_ack!r}')
return None
# Step 2: chunks.
for index in range(total_chunks):
start = index * _UPLOAD_CHUNK_SIZE
end = min(start + _UPLOAD_CHUNK_SIZE, file_size)
chunk_bytes = data[start:end]
chunk_req_id = _generate_req_id(CMD_UPLOAD_CHUNK)
chunk_body = {
'upload_id': upload_id,
'chunk_index': index,
'base64_data': _b64.b64encode(chunk_bytes).decode('ascii'),
}
chunk_ack = await self._send_reply(
chunk_req_id,
chunk_body,
cmd=CMD_UPLOAD_CHUNK,
)
if not chunk_ack or chunk_ack.get('errcode', 0) != 0:
await self.logger.warning(f'upload_media chunk {index} failed: ack={chunk_ack!r}')
return None
# Step 3: finish.
finish_req_id = _generate_req_id(CMD_UPLOAD_FINISH)
finish_body = {'upload_id': upload_id}
finish_ack = await self._send_reply(
finish_req_id,
finish_body,
cmd=CMD_UPLOAD_FINISH,
)
if not finish_ack or finish_ack.get('errcode', 0) != 0:
await self.logger.warning(f'upload_media finish failed: ack={finish_ack!r}')
return None
media_id = (
finish_ack.get('media_id')
or finish_ack.get('body', {}).get('media_id')
or finish_ack.get('data', {}).get('media_id')
)
if not media_id:
await self.logger.warning(f'upload_media finish returned no media_id: ack={finish_ack!r}')
return None
return {'media_id': media_id, 'ack': finish_ack}
async def _reply_media(
self,
req_id: str,
media_id: str,
kind: str,
) -> Optional[dict]:
"""Send a media reply (image / voice / file) referencing *media_id*.
``kind`` is one of ``'image'``, ``'voice'``, ``'file'``. Uses
the standard ``aibot_respond_msg`` command with a per-kind
body key (matches the convention documented for the WeCom
AI Bot SDK).
"""
if kind not in {'image', 'voice', 'file'}:
await self.logger.warning(f'_reply_media called with unknown kind={kind!r}')
return None
body = {
'msgtype': kind,
kind: {'media_id': media_id},
}
return await self._send_reply(req_id, body, cmd=CMD_RESPOND_MSG)
async def reply_image(self, req_id: str, media_id: str) -> Optional[dict]:
return await self._reply_media(req_id, media_id, 'image')
async def reply_file(self, req_id: str, media_id: str) -> Optional[dict]:
return await self._reply_media(req_id, media_id, 'file')
async def reply_voice(self, req_id: str, media_id: str) -> Optional[dict]:
return await self._reply_media(req_id, media_id, 'voice')
async def push_stream_chunk(self, msg_id: str, content: str, is_final: bool = False) -> bool: async def push_stream_chunk(self, msg_id: str, content: str, is_final: bool = False) -> bool:
"""Push a streaming chunk for a given message ID. """Push a streaming chunk for a given message ID.
@@ -936,13 +789,6 @@ class WecomBotWsClient:
'chat_type': message_data.get('type', 'single'), 'chat_type': message_data.get('type', 'single'),
} }
self._prune_stream_state() self._prune_stream_state()
# Send an initial empty stream frame so the WeCom client
# shows its built-in loading spinner while the pipeline
# processes the message (e.g. RAG retrieval).
try:
await self.reply_stream(req_id, stream_id, '', finish=False)
except Exception:
await self.logger.warning(f'Failed to send initial stream frame: {traceback.format_exc()}')
message_data['stream_id'] = stream_id message_data['stream_id'] = stream_id
message_data['req_id'] = req_id message_data['req_id'] = req_id
@@ -295,34 +295,6 @@ class WecomCSClient:
raise Exception('Failed to send message') raise Exception('Failed to send message')
return data return data
@_bounded_token_retry
async def send_image_msg(self, open_kfid: str, external_userid: str, msgid: str, media_id: str):
if not await self.check_access_token():
self.access_token = await self.get_access_token(self.secret)
url = f'{self.base_url}/kf/send_msg?access_token={self.access_token}'
payload = {
'touser': external_userid,
'open_kfid': open_kfid,
'msgid': msgid,
'msgtype': 'image',
'image': {
'media_id': media_id,
},
}
async with self._http_client_context() as client:
response = await client.post(url, json=payload)
data = await httpclient.parse_json_response(response)
if data['errcode'] == 40014 or data['errcode'] == 42001:
self.access_token = await self.get_access_token(self.secret)
return await self.send_image_msg(open_kfid, external_userid, msgid, media_id)
if data['errcode'] != 0:
await self.logger.error(f'发送图片失败:{data}')
raise Exception('Failed to send image message')
return data
async def handle_callback_request(self): async def handle_callback_request(self):
"""处理回调请求(独立端口模式,使用全局 request)。""" """处理回调请求(独立端口模式,使用全局 request)。"""
return await self._handle_callback_internal(request) return await self._handle_callback_internal(request)
@@ -218,7 +218,6 @@ class MonitoringRouterGroup(group.RouterGroup):
pipeline_ids = quart.request.args.getlist('pipelineId') pipeline_ids = quart.request.args.getlist('pipelineId')
start_time_str = quart.request.args.get('startTime') start_time_str = quart.request.args.get('startTime')
end_time_str = quart.request.args.get('endTime') end_time_str = quart.request.args.get('endTime')
user_query = quart.request.args.get('userQuery')
is_active_str = quart.request.args.get('isActive') is_active_str = quart.request.args.get('isActive')
limit = int(quart.request.args.get('limit', 100)) limit = int(quart.request.args.get('limit', 100))
offset = int(quart.request.args.get('offset', 0)) offset = int(quart.request.args.get('offset', 0))
@@ -238,7 +237,6 @@ class MonitoringRouterGroup(group.RouterGroup):
pipeline_ids=pipeline_ids if pipeline_ids else None, pipeline_ids=pipeline_ids if pipeline_ids else None,
start_time=start_time, start_time=start_time,
end_time=end_time, end_time=end_time,
user_query=user_query,
is_active=is_active, is_active=is_active,
limit=limit, limit=limit,
offset=offset, offset=offset,
@@ -398,14 +396,7 @@ class MonitoringRouterGroup(group.RouterGroup):
@self.route('/sessions/<session_id>/analysis', methods=['GET'], permission=Permission.RESOURCE_VIEW) @self.route('/sessions/<session_id>/analysis', methods=['GET'], permission=Permission.RESOURCE_VIEW)
async def get_session_analysis(session_id: str, request_context: RequestContext) -> str: async def get_session_analysis(session_id: str, request_context: RequestContext) -> str:
"""Get detailed analysis for a specific session""" """Get detailed analysis for a specific session"""
start_time = parse_iso_datetime(quart.request.args.get('startTime')) analysis = await self.ap.monitoring_service.get_session_analysis(request_context, session_id)
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 # Always return success with the analysis data
# The frontend will handle the 'found: false' case # The frontend will handle the 'found: false' case
@@ -39,13 +39,7 @@ class PipelinesRouterGroup(group.RouterGroup):
permission=Permission.RESOURCE_MANAGE, permission=Permission.RESOURCE_MANAGE,
) )
async def _(request_context: RequestContext) -> str: async def _(request_context: RequestContext) -> str:
pipeline_data = await quart.request.json pipeline_uuid = await self.ap.pipeline_service.create_pipeline(request_context, await quart.request.json)
create_as_default = pipeline_data.get('is_default') is True
pipeline_uuid = await self.ap.pipeline_service.create_pipeline(
request_context,
pipeline_data,
default=create_as_default,
)
return self.success(data={'uuid': pipeline_uuid}) return self.success(data={'uuid': pipeline_uuid})
@self.route( @self.route(
@@ -113,24 +113,6 @@ class BotsRouterGroup(group.RouterGroup):
) )
return self.success(data={'sent': True}) return self.success(data={'sent': True})
@self.route(
'/<bot_uuid>/test-inbound',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN,
permission=Permission.RESOURCE_MANAGE,
)
async def _(bot_uuid: str, request_context: RequestContext) -> str:
json_data = await quart.request.get_json(silent=True) or {}
try:
result = await self.ap.bot_service.send_http_bot_test_message(
request_context,
bot_uuid,
str(json_data.get('message') or ''),
)
except ValueError as exc:
return self.http_status(400, -1, str(exc))
return self.success(data=result)
@self.route( @self.route(
'/<bot_uuid>/admins', '/<bot_uuid>/admins',
methods=['GET'], methods=['GET'],
@@ -12,44 +12,9 @@ from .....provider.tools.loaders.mcp_policy import stdio_mcp_enabled
from .....workspace.invitation_delivery import InvitationDeliveryService from .....workspace.invitation_delivery import InvitationDeliveryService
SYSTEM_CAPABILITY_OPERATIONS = (
'bot.list',
'bot.get',
'bot.create',
'bot.update',
'bot.delete',
'pipeline.list',
'pipeline.get',
'pipeline.create',
'pipeline.update',
'pipeline.delete',
'pipeline.copy',
)
@group.group_class('system', '/api/v1/system') @group.group_class('system', '/api/v1/system')
class SystemRouterGroup(group.RouterGroup): class SystemRouterGroup(group.RouterGroup):
async def initialize(self) -> None: async def initialize(self) -> None:
@self.route('/context', methods=['GET'], auth_type=group.AuthType.API_KEY)
async def _(request_context: RequestContext) -> str:
return self.success(
data={
'instance_uuid': request_context.instance_uuid,
'workspace_uuid': request_context.workspace_uuid,
'api_key_id': request_context.principal.api_key_uuid,
'permissions': sorted(request_context.workspace.permissions),
}
)
@self.route('/capabilities', methods=['GET'], auth_type=group.AuthType.API_KEY)
async def _() -> str:
return self.success(
data={
'schema_version': 1,
'operations': {operation: {'supported': True} for operation in SYSTEM_CAPABILITY_OPERATIONS},
}
)
@self.route('/info', methods=['GET'], auth_type=group.AuthType.NONE) @self.route('/info', methods=['GET'], auth_type=group.AuthType.NONE)
async def _() -> str: async def _() -> str:
# Read wizard_status and wizard_progress from metadata table # Read wizard_status and wizard_progress from metadata table
@@ -241,20 +206,6 @@ class SystemRouterGroup(group.RouterGroup):
return self.success(data={}) return self.success(data={})
@self.route(
'/wizard/recommended-model',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN,
permission=Permission.RESOURCE_MANAGE,
)
async def _(request_context: RequestContext) -> str:
"""Resolve Space's best available chat model to this Workspace."""
try:
model = await self.ap.space_service.get_recommended_chat_model(request_context)
except ValueError as exc:
return self.http_status(503, -1, str(exc))
return self.success(data=model)
@self.route( @self.route(
'/tasks', '/tasks',
methods=['GET'], methods=['GET'],
@@ -186,9 +186,6 @@ class UserRouterGroup(group.RouterGroup):
json_data = await quart.request.json json_data = await quart.request.json
code = json_data.get('code') code = json_data.get('code')
state = json_data.get('state') 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') launch_assertion = json_data.get('launch_assertion')
workspace_uuid = json_data.get('workspace_uuid') workspace_uuid = json_data.get('workspace_uuid')
@@ -202,11 +199,8 @@ class UserRouterGroup(group.RouterGroup):
return self.fail(1, 'Missing authorization code') return self.fail(1, 'Missing authorization code')
if not state: if not state:
return self.fail(1, 'Missing state parameter') return self.fail(1, 'Missing state parameter')
if not str(code).startswith('v4_'):
return self.fail(1, 'Unsupported Space OAuth code contract')
try: 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') consumed_state = await self.ap.user_service.consume_space_oauth_state_details(state, 'login')
# Exchange code for tokens # Exchange code for tokens
launch_workspace_uuid = consumed_state.launch_workspace_uuid launch_workspace_uuid = consumed_state.launch_workspace_uuid
@@ -224,36 +218,24 @@ class UserRouterGroup(group.RouterGroup):
code, code,
workspace_uuids, workspace_uuids,
workspace_created_ats, workspace_created_ats,
redirect_uri=redirect_uri,
) )
access_token = token_data.get('access_token') access_token = token_data.get('access_token')
refresh_token = token_data.get('refresh_token') refresh_token = token_data.get('refresh_token')
expires_in = token_data.get('expires_in', 0) expires_in = token_data.get('expires_in', 0)
cloud_workspace_uuid = token_data.get('cloud_workspace_uuid')
if not access_token: if not access_token:
return self.fail(1, 'Failed to get access token from Space') return self.fail(1, 'Failed to get access token from Space')
cloud_mode = getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud' # Authenticate and create/update local user
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( jwt_token, user_obj = await self.ap.user_service.authenticate_space_user(
access_token, refresh_token, expires_in access_token, refresh_token, expires_in
) )
if target_workspace_uuid: if launch_workspace_uuid:
try: try:
access = await self.ap.workspace_collaboration_service.resolve_account_workspace( access = await self.ap.workspace_collaboration_service.resolve_account_workspace(
user_obj.uuid, user_obj.uuid,
target_workspace_uuid, launch_workspace_uuid,
) )
except Exception: except Exception:
self.ap.logger.warning('Rejected Space OAuth launch for unauthorized Workspace') self.ap.logger.warning('Rejected Space OAuth launch for unauthorized Workspace')
@@ -340,7 +322,6 @@ class UserRouterGroup(group.RouterGroup):
if cloud_mode: if cloud_mode:
capabilities['password_login_enabled'] = False capabilities['password_login_enabled'] = False
capabilities['authenticated_invitation_acceptance_enabled'] = cloud_mode capabilities['authenticated_invitation_acceptance_enabled'] = cloud_mode
capabilities['invitation_registration_enabled'] = not cloud_mode
return self.success(data={'initialized': True, **capabilities}) return self.success(data={'initialized': True, **capabilities})
@self.route('/set-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) @self.route('/set-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
@@ -385,17 +366,12 @@ class UserRouterGroup(group.RouterGroup):
json_data = await quart.request.json json_data = await quart.request.json
code = json_data.get('code') code = json_data.get('code')
state = json_data.get('state') 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: if not code:
return self.http_status(400, -1, 'Missing authorization code') return self.http_status(400, -1, 'Missing authorization code')
if not state: if not state:
return self.http_status(400, -1, 'Missing state parameter') 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: try:
user_obj = await self.ap.user_service.consume_space_oauth_state(state, 'bind') user_obj = await self.ap.user_service.consume_space_oauth_state(state, 'bind')
@@ -408,10 +384,7 @@ class UserRouterGroup(group.RouterGroup):
return self.http_status(400, -1, 'Only local accounts can bind to Space') return self.http_status(400, -1, 'Only local accounts can bind to Space')
try: try:
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)
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) jwt_token = await self.ap.user_service.generate_jwt_token(updated_user)
return self.success( return self.success(
data={ data={
@@ -454,10 +427,6 @@ 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']) account = await self.ap.user_service.get_user_by_uuid(launch['account_uuid'])
if account is None: if account is None:
raise SpaceLaunchError('Launch Account is not projected into Core') raise SpaceLaunchError('Launch Account is not projected into Core')
+1 -61
View File
@@ -1,7 +1,6 @@
from __future__ import annotations from __future__ import annotations
import uuid import uuid
import json
import sqlalchemy import sqlalchemy
from ....core import app from ....core import app
@@ -9,8 +8,6 @@ from ....entity.persistence import bot as persistence_bot
from ....entity.persistence import pipeline as persistence_pipeline from ....entity.persistence import pipeline as persistence_pipeline
from ....workspace.errors import WorkspaceNotFoundError from ....workspace.errors import WorkspaceNotFoundError
from .tenant import TenantContext, require_workspace_uuid, scope_statement from .tenant import TenantContext, require_workspace_uuid, scope_statement
from ....utils import httpclient
from ....platform.sources import http_bot_signing
class BotService: class BotService:
@@ -83,7 +80,6 @@ class BotService:
'wecomcs', 'wecomcs',
'LINE', 'LINE',
'lark', 'lark',
'http_bot',
]: ]:
webhook_prefix = self.ap.instance_config.data['api'].get('webhook_prefix', 'http://127.0.0.1:5300') webhook_prefix = self.ap.instance_config.data['api'].get('webhook_prefix', 'http://127.0.0.1:5300')
extra_webhook_prefix = self.ap.instance_config.data['api'].get('extra_webhook_prefix', '') extra_webhook_prefix = self.ap.instance_config.data['api'].get('extra_webhook_prefix', '')
@@ -137,16 +133,7 @@ class BotService:
bot = await self.get_bot(context, bot_data['uuid'], include_secret=True) bot = await self.get_bot(context, bot_data['uuid'], include_secret=True)
try: await self.ap.platform_mgr.load_bot(context, bot)
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'] return bot_data['uuid']
@@ -229,53 +216,6 @@ class BotService:
return [log.to_json() for log in logs], total_count return [log.to_json() for log in logs], total_count
async def send_http_bot_test_message(
self,
context: TenantContext,
bot_uuid: str,
message: str,
) -> dict:
"""Send a signed test message through the HTTP Bot public ingress."""
bot = await self.get_bot(context, bot_uuid, include_secret=True)
if bot is None:
raise WorkspaceNotFoundError('Bot not found')
if bot.get('adapter') != 'http_bot':
raise ValueError('Inbound test is only available for HTTP Bot')
if not bot.get('enable'):
raise ValueError('Bot must be enabled before sending a test message')
text = message.strip()
if not text or len(text) > 2000:
raise ValueError('Test message must contain 1 to 2000 characters')
payload = {
'session_id': f'wizard-{uuid.uuid4().hex}',
'sender': {'id': 'wizard-user', 'name': 'Wizard Test'},
'message': [{'type': 'Plain', 'text': text}],
}
body = json.dumps(payload, ensure_ascii=False, separators=(',', ':')).encode()
config = bot.get('adapter_config') or {}
headers = {'Content-Type': 'application/json'}
if config.get('signature_required', True):
secret = str(config.get('inbound_secret') or '')
if not secret:
raise ValueError('HTTP Bot inbound signing secret is required')
timestamp, signature = http_bot_signing.sign(secret, body)
headers[http_bot_signing.HEADER_TIMESTAMP] = timestamp
headers[http_bot_signing.HEADER_SIGNATURE] = signature
port = int(self.ap.instance_config.data.get('api', {}).get('port', 5300))
session = httpclient.get_session()
async with session.post(
f'http://127.0.0.1:{port}/bots/{bot_uuid}',
data=body,
headers=headers,
) as response:
result = await httpclient.read_json_limited(response)
if response.status not in {200, 202}:
raise ValueError(result.get('msg') or f'HTTP Bot test failed with status {response.status}')
return result.get('data') or {}
async def send_message( async def send_message(
self, self,
context: TenantContext, context: TenantContext,
+4 -20
View File
@@ -1257,7 +1257,6 @@ class MonitoringService:
pipeline_ids: list[str] | None = None, pipeline_ids: list[str] | None = None,
start_time: datetime.datetime | None = None, start_time: datetime.datetime | None = None,
end_time: datetime.datetime | None = None, end_time: datetime.datetime | None = None,
user_query: str | None = None,
is_active: bool | None = None, is_active: bool | None = None,
limit: int = 100, limit: int = 100,
offset: int = 0, offset: int = 0,
@@ -1275,14 +1274,6 @@ class MonitoringService:
conditions.append(persistence_monitoring.MonitoringSession.start_time >= start_time) conditions.append(persistence_monitoring.MonitoringSession.start_time >= start_time)
if end_time: if end_time:
conditions.append(persistence_monitoring.MonitoringSession.start_time <= 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: if is_active is not None:
conditions.append(persistence_monitoring.MonitoringSession.is_active == is_active) conditions.append(persistence_monitoring.MonitoringSession.is_active == is_active)
@@ -1374,8 +1365,6 @@ class MonitoringService:
self, self,
context: TenantContext, context: TenantContext,
session_id: str, session_id: str,
start_time: datetime.datetime | None = None,
end_time: datetime.datetime | None = None,
) -> dict: ) -> dict:
"""Get bounded session details with full statistics computed in SQL.""" """Get bounded session details with full statistics computed in SQL."""
workspace_uuid = require_workspace_uuid(context) workspace_uuid = require_workspace_uuid(context)
@@ -1489,17 +1478,12 @@ class MonitoringService:
) )
) )
tool_stats = tool_stats_result.one() 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 = ( tool_query = (
sqlalchemy.select(persistence_monitoring.MonitoringToolCall) sqlalchemy.select(persistence_monitoring.MonitoringToolCall)
.where(*tool_conditions) .where(
persistence_monitoring.MonitoringToolCall.workspace_uuid == workspace_uuid,
persistence_monitoring.MonitoringToolCall.session_id == session_id,
)
.order_by(persistence_monitoring.MonitoringToolCall.timestamp.asc()) .order_by(persistence_monitoring.MonitoringToolCall.timestamp.asc())
.limit(detail_limit + 1) .limit(detail_limit + 1)
) )
+1 -80
View File
@@ -11,9 +11,6 @@ import sqlalchemy
from ....core import app from ....core import app
from ....entity.persistence import user from ....entity.persistence import user
from ....entity.dto.space_model import SpaceModel from ....entity.dto.space_model import SpaceModel
from ....entity.dto.space_model import SpaceModelSelection
from ....entity.persistence import model as persistence_model
from ....cloud.model_catalog import LANGBOT_MODELS_PROVIDER_REQUESTER
_CREDITS_CACHE_TTL_SECONDS = 60 _CREDITS_CACHE_TTL_SECONDS = 60
@@ -119,7 +116,7 @@ class SpaceService:
space_config = self._get_space_config() space_config = self._get_space_config()
authorize_url = space_config['oauth_authorize_url'] authorize_url = space_config['oauth_authorize_url']
params = {'redirect_uri': redirect_uri, 'code_contract': 'redirect-v1'} params = {'redirect_uri': redirect_uri}
if state: if state:
params['state'] = state params['state'] = state
return f'{authorize_url}?{urlencode(params)}' return f'{authorize_url}?{urlencode(params)}'
@@ -129,8 +126,6 @@ class SpaceService:
code: str, code: str,
workspace_uuids: list[str] | None = None, workspace_uuids: list[str] | None = None,
workspace_created_ats: dict[str, int] | None = None, workspace_created_ats: dict[str, int] | None = None,
*,
redirect_uri: str = '',
) -> typing.Dict: ) -> typing.Dict:
"""Exchange OAuth authorization code for tokens""" """Exchange OAuth authorization code for tokens"""
from langbot.pkg.utils import constants from langbot.pkg.utils import constants
@@ -143,7 +138,6 @@ class SpaceService:
f'{space_url}/api/v1/accounts/oauth/token', f'{space_url}/api/v1/accounts/oauth/token',
json={ json={
'code': code, 'code': code,
'redirect_uri': redirect_uri,
'instance_id': constants.instance_id, 'instance_id': constants.instance_id,
# Sending an explicit empty list tells new Space servers not to # Sending an explicit empty list tells new Space servers not to
# synthesize a legacy instance-derived Workspace binding. # synthesize a legacy instance-derived Workspace binding.
@@ -244,76 +238,3 @@ class SpaceService:
raise ValueError(f'Failed to get models: {data.get("msg")}') raise ValueError(f'Failed to get models: {data.get("msg")}')
models_data = data.get('data', {}).get('models', []) models_data = data.get('data', {}).get('models', [])
return [SpaceModel.model_validate(model_dict) for model_dict in models_data] return [SpaceModel.model_validate(model_dict) for model_dict in models_data]
async def get_model_selection(self, category: str) -> typing.List[SpaceModelSelection]:
"""Return Space models in the availability-ranked selection order."""
space_url = self._get_space_config()['url']
session = httpclient.get_session()
async with session.get(
f'{space_url}/api/v1/models/selection',
params={'category': category},
) as response:
if response.status != 200:
error = await httpclient.read_text_limited(response)
raise ValueError(f'Failed to get model selection: {error}')
payload = await httpclient.read_json_limited(response)
if payload.get('code') != 0:
raise ValueError(f'Failed to get model selection: {payload.get("msg")}')
data = payload.get('data', [])
if isinstance(data, dict):
data = data.get('models', data.get('items', []))
if not isinstance(data, list):
raise ValueError('Failed to get model selection: invalid response')
models = []
for selection in data:
if isinstance(selection, dict) and isinstance(selection.get('model'), dict):
models.append(selection['model'])
else:
models.append(selection)
return [SpaceModelSelection.model_validate(model) for model in models]
async def get_recommended_chat_model(self, context: typing.Any) -> dict:
"""Resolve Space's first ranked chat model to a local Workspace model."""
selection = await self.get_model_selection('chat')
if not selection:
raise ValueError('No recommended chat model is available')
recommended = selection[0]
async def find_local_model():
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_model.LLMModel)
.join(
persistence_model.ModelProvider,
sqlalchemy.and_(
persistence_model.ModelProvider.workspace_uuid == persistence_model.LLMModel.workspace_uuid,
persistence_model.ModelProvider.uuid == persistence_model.LLMModel.provider_uuid,
),
)
.where(
persistence_model.LLMModel.workspace_uuid == context.workspace_uuid,
persistence_model.ModelProvider.requester == LANGBOT_MODELS_PROVIDER_REQUESTER,
sqlalchemy.or_(
persistence_model.LLMModel.uuid == recommended.uuid,
persistence_model.LLMModel.name == recommended.model_id,
),
)
)
return result.first()
local_model = await find_local_model()
if local_model is None:
# OSS synchronizes the public catalog locally. Refresh once in case
# the recommendation was published after this process started.
from ..context import ExecutionContext
try:
await self.ap.model_mgr.sync_new_models_from_space(ExecutionContext.from_request(context))
except Exception:
pass
local_model = await find_local_model()
if local_model is None:
raise ValueError('Recommended chat model is not available in this Workspace')
return {'uuid': local_model.uuid, 'name': local_model.name}
+2 -3
View File
@@ -774,7 +774,7 @@ class UserService:
f'email:{normalized_email}', f'email:{normalized_email}',
) )
async def bind_space_account(self, user_email: str, code: str, *, redirect_uri: str = '') -> user.User: async def bind_space_account(self, user_email: str, code: str) -> user.User:
"""Bind Space account to existing local account""" """Bind Space account to existing local account"""
local_account = await self.get_user_by_email(user_email) local_account = await self.get_user_by_email(user_email)
if local_account is None: if local_account is None:
@@ -794,13 +794,12 @@ class UserService:
code, code,
[binding.workspace_uuid], [binding.workspace_uuid],
{binding.workspace_uuid: created_ts}, {binding.workspace_uuid: created_ts},
redirect_uri=redirect_uri,
) )
else: else:
# Compatibility for early/bootstrap call sites that have not wired # Compatibility for early/bootstrap call sites that have not wired
# WorkspaceService yet; old Space servers still derive the legacy # WorkspaceService yet; old Space servers still derive the legacy
# Workspace identity from instance_id when the field is omitted. # Workspace identity from instance_id when the field is omitted.
token_data = await self.ap.space_service.exchange_oauth_code(code, redirect_uri=redirect_uri) token_data = await self.ap.space_service.exchange_oauth_code(code)
access_token = token_data.get('access_token') access_token = token_data.get('access_token')
refresh_token = token_data.get('refresh_token') refresh_token = token_data.get('refresh_token')
expires_in = token_data.get('expires_in', 0) expires_in = token_data.get('expires_in', 0)
+1 -10
View File
@@ -147,16 +147,7 @@ class LangBotMCPServer:
) )
async def create_pipeline(pipeline_data: dict) -> str: async def create_pipeline(pipeline_data: dict) -> str:
context = _authorized(Permission.RESOURCE_MANAGE) context = _authorized(Permission.RESOURCE_MANAGE)
create_as_default = pipeline_data.get('is_default') is True return _dump({'uuid': await ap.pipeline_service.create_pipeline(context, pipeline_data)})
return _dump(
{
'uuid': await ap.pipeline_service.create_pipeline(
context,
pipeline_data,
default=create_as_default,
)
}
)
@mcp.tool(description='Update a pipeline by UUID. `pipeline_data` matches the PUT body.') @mcp.tool(description='Update a pipeline by UUID. `pipeline_data` matches the PUT body.')
async def update_pipeline(pipeline_uuid: str, pipeline_data: dict) -> str: async def update_pipeline(pipeline_uuid: str, pipeline_data: dict) -> str:
+11 -27
View File
@@ -455,9 +455,7 @@ class BoxService:
async def _require_validated_workspace_sandbox(self, execution_context: ExecutionContext) -> None: async def _require_validated_workspace_sandbox(self, execution_context: ExecutionContext) -> None:
if not self._available: if not self._available:
raise BoxError( raise BoxError('Box runtime is not available. Install and start Docker to use sandbox features.')
'Box runtime is not available. Configure an available Box backend before using Box features.'
)
if self._cloud_managed: if self._cloud_managed:
if self._admission is None: if self._admission is None:
raise BoxAdmissionError('Cloud Box sandbox admission is unavailable') raise BoxAdmissionError('Cloud Box sandbox admission is unavailable')
@@ -567,9 +565,7 @@ class BoxService:
skip_host_mount_validation: bool = False, skip_host_mount_validation: bool = False,
) -> dict: ) -> dict:
if not self._available: if not self._available:
raise BoxError( raise BoxError('Box runtime is not available. Install and start Docker to use sandbox features.')
'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)) execution_context = await self._validated_execution_context(self._query_execution_context(query))
spec_payload = self._managed_policy_payload(execution_context, spec_payload) spec_payload = self._managed_policy_payload(execution_context, spec_payload)
await self._require_validated_workspace_sandbox(execution_context) await self._require_validated_workspace_sandbox(execution_context)
@@ -1214,9 +1210,8 @@ class BoxService:
async def _read_outbox_via_exec(self, query: pipeline_query.Query) -> list[dict]: async def _read_outbox_via_exec(self, query: pipeline_query.Query) -> list[dict]:
"""Fallback: read the outbox over the exec channel (E2B / remote). """Fallback: read the outbox over the exec channel (E2B / remote).
Uses ``client.execute`` directly (bypassing ``_serialize_result``) Note: exec stdout is truncated by ``output_limit_chars``, so this path
so stdout is NOT truncated by ``output_limit_chars`` - the raw only reliably transfers small files. The host path is preferred.
base64 payload can be far larger than the 4000-char display limit.
""" """
import json as _json import json as _json
@@ -1270,22 +1265,14 @@ class BoxService:
' break\n' ' break\n'
'print(json.dumps(out))\n' 'print(json.dumps(out))\n'
) )
spec_payload: dict = { result = await self.execute_tool(
'cmd': f"python3 - <<'LBPY'\n{script}\nLBPY", {'command': f"python3 - <<'LBPY'\n{script}\nLBPY", 'timeout_sec': 120},
'timeout_sec': 120, query,
'session_id': self.resolve_box_session_id(query), )
} if not result.get('ok'):
if 'extra_mounts' not in spec_payload:
spec_payload['extra_mounts'] = self.build_skill_extra_mounts(query)
try:
spec = self.build_spec(spec_payload)
result = await self.client.execute(spec)
except Exception:
return []
if not result.ok:
return [] return []
try: try:
return _json.loads(str(result.stdout or '').strip().splitlines()[-1]) return _json.loads(str(result.get('stdout') or '').strip().splitlines()[-1])
except Exception: except Exception:
return [] return []
@@ -2146,8 +2133,5 @@ class BoxService:
if backend_name: if backend_name:
payload['connector_error'] = f'Configured sandbox backend "{backend_name}" is unavailable' payload['connector_error'] = f'Configured sandbox backend "{backend_name}" is unavailable'
else: else:
payload['connector_error'] = ( payload['connector_error'] = 'No supported sandbox backend (Docker / nsjail / E2B) is available'
'No supported sandbox backend (Docker / nsjail / E2B) is available. '
'Trusted local development may explicitly select the unsafe host backend.'
)
return payload return payload
+2 -100
View File
@@ -125,21 +125,10 @@ class DirectoryProjectionService:
# The database cursor remains the shared projection high-water mark, # The database cursor remains the shared projection high-water mark,
# while this cursor tracks what this process has actually observed. # while this cursor tracks what this process has actually observed.
self._consumer_cursor: int | None = None self._consumer_cursor: int | None = None
self._sync_lock = asyncio.Lock()
async def initialize(self) -> None: async def initialize(self) -> None:
"""Block Cloud startup until one full signed snapshot is committed.""" """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 last_superseded: _DirectorySnapshotSuperseded | None = None
for _attempt in range(5): for _attempt in range(5):
snapshot = await self.provider.fetch_snapshot(self.instance_uuid) snapshot = await self.provider.fetch_snapshot(self.instance_uuid)
@@ -170,84 +159,9 @@ class DirectoryProjectionService:
delay = min(max(delay * 2, self.sync_interval_seconds), self.max_staleness_seconds / 2) delay = min(max(delay * 2, self.sync_interval_seconds), self.max_staleness_seconds / 2)
async def sync_once(self) -> None: 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 cursor = self._consumer_cursor
if cursor is None: if cursor is None:
await self._refresh_snapshot() await self.initialize()
return return
batch = await self.provider.fetch_events( batch = await self.provider.fetch_events(
self.instance_uuid, self.instance_uuid,
@@ -794,13 +708,7 @@ class DirectoryProjectionService:
for row in inbox_rows: for row in inbox_rows:
row.applied_at = now row.applied_at = now
async def _apply_accounts( async def _apply_accounts(self, session: Any, snapshot: DirectorySnapshot) -> dict[str, User]:
self,
session: Any,
snapshot: DirectorySnapshot,
*,
preserve_existing: bool = False,
) -> dict[str, User]:
selected: dict[str, DirectoryMember] = {} selected: dict[str, DirectoryMember] = {}
emails: dict[str, str] = {} emails: dict[str, str] = {}
for workspace in snapshot.workspaces: for workspace in snapshot.workspaces:
@@ -865,12 +773,6 @@ class DirectoryProjectionService:
continue continue
if account.source != AccountSource.CLOUD_PROJECTION.value: if account.source != AccountSource.CLOUD_PROJECTION.value:
raise DirectoryProjectionUnavailableError('Directory account UUID collides with a local Core account') 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: if account.projection_revision > snapshot.cursor:
raise DirectoryProjectionUnavailableError('Directory account revision rolled back') raise DirectoryProjectionUnavailableError('Directory account revision rolled back')
projected_account = self._account_projection(member) projected_account = self._account_projection(member)
+1 -9
View File
@@ -3,7 +3,6 @@ from __future__ import annotations
import typing import typing
import inspect import inspect
from ..api.http.context import ExecutionContext
from ..core import app from ..core import app
from . import operator from . import operator
from ..utils import importutil from ..utils import importutil
@@ -67,14 +66,7 @@ class CommandManager:
require_context = getattr(self.ap.plugin_connector, 'require_workspace_context', None) require_context = getattr(self.ap.plugin_connector, 'require_workspace_context', None)
if require_context is not None: if require_context is not None:
result = require_context( result = require_context(context)
ExecutionContext(
instance_uuid=context.instance_uuid,
workspace_uuid=context.workspace_uuid,
placement_generation=context.placement_generation,
query_uuid=context.query_uuid,
)
)
if inspect.isawaitable(result): if inspect.isawaitable(result):
await result await result
+5 -35
View File
@@ -301,36 +301,11 @@ class Application:
async def initialize(self): async def initialize(self):
pass pass
async def _initialize_plugin_runtime(self) -> None:
try:
await self.plugin_connector.initialize()
except asyncio.CancelledError:
raise
except Exception as exc:
self.logger.warning(f'Plugin runtime unavailable during startup; reconnecting in background: {exc}')
self.plugin_connector.schedule_reconnect()
def _start_plugin_runtime_initialization(self) -> asyncio.Task | None:
task = getattr(self, '_plugin_runtime_initialization_task', None)
if task is not None and not task.done():
return task
# This is application lifecycle work, not a request side effect. It must
# not wait on PersistenceManager's after-commit gate at boot.
task = asyncio.create_task(
self._initialize_plugin_runtime(),
name='plugin-runtime-initialization',
)
self._plugin_runtime_initialization_task = task
return task
async def run(self): async def run(self):
self.event_loop_monitor.start() self.event_loop_monitor.start()
try: try:
if ( if self.directory_projection_service is not None:
self.directory_projection_service is not None self.task_mgr.create_task(
and getattr(self, 'directory_projection_task', None) is None
):
self.directory_projection_task = self.task_mgr.create_task(
self.directory_projection_service.run(), self.directory_projection_service.run(),
name='cloud-directory-projection', name='cloud-directory-projection',
scopes=[core_entities.LifecycleControlScope.APPLICATION], scopes=[core_entities.LifecycleControlScope.APPLICATION],
@@ -347,6 +322,7 @@ class Application:
name='cloud-manifest-refresh', name='cloud-manifest-refresh',
scopes=[core_entities.LifecycleControlScope.APPLICATION], scopes=[core_entities.LifecycleControlScope.APPLICATION],
) )
await self.plugin_connector.initialize_plugins()
# 后续可能会允许动态重启其他任务 # 后续可能会允许动态重启其他任务
# 故为了防止程序在非 Ctrl-C 情况下退出,这里创建一个不会结束的协程 # 故为了防止程序在非 Ctrl-C 情况下退出,这里创建一个不会结束的协程
@@ -372,7 +348,6 @@ class Application:
name='http-api-controller', name='http-api-controller',
scopes=[core_entities.LifecycleControlScope.APPLICATION], scopes=[core_entities.LifecycleControlScope.APPLICATION],
) )
self._start_plugin_runtime_initialization()
# Telemetry instance heartbeat (startup + daily); respects # Telemetry instance heartbeat (startup + daily); respects
# space.disable_telemetry via TelemetryManager.send(). # space.disable_telemetry via TelemetryManager.send().
@@ -554,11 +529,6 @@ class Application:
if self.task_mgr is not None: if self.task_mgr is not None:
self.task_mgr.cancel_by_scope(core_entities.LifecycleControlScope.APPLICATION) self.task_mgr.cancel_by_scope(core_entities.LifecycleControlScope.APPLICATION)
plugin_runtime_task = getattr(self, '_plugin_runtime_initialization_task', None)
if plugin_runtime_task is not None and not plugin_runtime_task.done():
plugin_runtime_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await plugin_runtime_task
with contextlib.suppress(Exception): with contextlib.suppress(Exception):
await self.event_loop_monitor.stop() await self.event_loop_monitor.stop()
mcp_mount = getattr(self.http_ctrl, 'mcp_mount', None) mcp_mount = getattr(self.http_ctrl, 'mcp_mount', None)
@@ -635,9 +605,9 @@ class Application:
frontend_path = paths.get_frontend_path() frontend_path = paths.get_frontend_path()
if not os.path.exists(frontend_path): if not os.path.exists(frontend_path):
self.logger.warning('WebUI 文件缺失,请根据文档部署:https://langbot.app/docs/zh') self.logger.warning('WebUI 文件缺失,请根据文档部署:https://docs.langbot.app/zh')
self.logger.warning( self.logger.warning(
'WebUI files are missing, please deploy according to the documentation: https://langbot.app/docs/en' 'WebUI files are missing, please deploy according to the documentation: https://docs.langbot.app/en'
) )
return return
+8 -11
View File
@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from .. import stage, app, entities as core_entities from .. import stage, app
from ...utils import version, proxy, constants from ...utils import version, proxy, constants
from ...pipeline import pool, controller, pipelinemgr from ...pipeline import pool, controller, pipelinemgr
from ...pipeline import aggregator as message_aggregator from ...pipeline import aggregator as message_aggregator
@@ -292,17 +292,14 @@ class BuildAppStage(stage.BootingStage):
async def runtime_disconnect_callback(connector: plugin_connector.PluginRuntimeConnector) -> None: async def runtime_disconnect_callback(connector: plugin_connector.PluginRuntimeConnector) -> None:
connector.schedule_reconnect() connector.schedule_reconnect()
if ap.directory_projection_service is not None:
# Keep the projection fresh while shared Runtime cold restore runs.
# BuildApp initializes the connector before Application.run() starts
# its long-lived tasks, so start the single refresh task here.
ap.directory_projection_task = ap.task_mgr.create_task(
ap.directory_projection_service.run(),
name='cloud-directory-projection',
scopes=[core_entities.LifecycleControlScope.APPLICATION],
)
plugin_connector_inst = plugin_connector.PluginRuntimeConnector(ap, runtime_disconnect_callback) plugin_connector_inst = plugin_connector.PluginRuntimeConnector(ap, runtime_disconnect_callback)
try:
await plugin_connector_inst.initialize()
except Exception as exc:
# Keep the API/UI available while an external or managed runtime is
# starting, then recover in the background with bounded backoff.
ap.logger.warning(f'Plugin runtime unavailable during startup; reconnecting in background: {exc}')
plugin_connector_inst.schedule_reconnect()
ap.plugin_connector = plugin_connector_inst ap.plugin_connector = plugin_connector_inst
workspace_service_inst.release_startup_execution_bindings() workspace_service_inst.release_startup_execution_bindings()
@@ -47,10 +47,3 @@ class SpaceModel(pydantic.BaseModel):
status: str status: str
created_at: str | None = None created_at: str | None = None
updated_at: str | None = None updated_at: str | None = None
class SpaceModelSelection(pydantic.BaseModel):
"""Minimal model identity returned by the ranked selection endpoint."""
uuid: str
model_id: str
+1 -12
View File
@@ -177,6 +177,7 @@ class PersistenceManager:
await self._validate_cloud_runtime() await self._validate_cloud_runtime()
return return
self._enable_sqlite_foreign_keys()
if self.mode == PersistenceMode.RELEASE_MIGRATION: if self.mode == PersistenceMode.RELEASE_MIGRATION:
async with self._release_migration_lock(): async with self._release_migration_lock():
await self._initialize_managed_schema() await self._initialize_managed_schema()
@@ -184,7 +185,6 @@ class PersistenceManager:
return return
await self._initialize_managed_schema() await self._initialize_managed_schema()
await self._enable_sqlite_foreign_keys_after_migration()
if self.mode == PersistenceMode.OSS_COMPAT: if self.mode == PersistenceMode.OSS_COMPAT:
await self.write_space_model_providers() await self.write_space_model_providers()
@@ -373,17 +373,6 @@ class PersistenceManager:
sqlalchemy.event.listen(self.get_db_engine().sync_engine, 'begin', set_oss_tenant_scope) sqlalchemy.event.listen(self.get_db_engine().sync_engine, 'begin', set_oss_tenant_scope)
self._oss_tenant_scope_listener_installed = True self._oss_tenant_scope_listener_installed = True
async def _enable_sqlite_foreign_keys_after_migration(self) -> None:
"""Enable SQLite FK enforcement only after table-rebuilding migrations."""
engine = self.get_db_engine()
if engine.dialect.name != 'sqlite':
return
await engine.dispose()
self._enable_sqlite_foreign_keys()
# Dispose again so every runtime connection is opened through the new
# listener instead of reusing a pre-migration pooled connection.
await engine.dispose()
def _enable_sqlite_foreign_keys(self) -> None: def _enable_sqlite_foreign_keys(self) -> None:
"""Enable SQLite FK enforcement for every pooled runtime connection.""" """Enable SQLite FK enforcement for every pooled runtime connection."""
engine = self.get_db_engine() engine = self.get_db_engine()
@@ -3,7 +3,6 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import contextlib
import dataclasses import dataclasses
import datetime import datetime
import json import json
@@ -13,7 +12,6 @@ import re
import secrets import secrets
import sqlite3 import sqlite3
import tempfile import tempfile
import time
import typing import typing
from sqlalchemy.ext.asyncio import AsyncEngine from sqlalchemy.ext.asyncio import AsyncEngine
@@ -83,7 +81,7 @@ def _verify_connection(connection: sqlite3.Connection, expected_revision: str) -
def _verify_file(path: pathlib.Path, expected_revision: str) -> None: def _verify_file(path: pathlib.Path, expected_revision: str) -> None:
with contextlib.closing(_open_read_only(path)) as connection: with _open_read_only(path) as connection:
_verify_connection(connection, expected_revision) _verify_connection(connection, expected_revision)
@@ -119,23 +117,8 @@ def _write_manifest(backup: SQLiteMigrationBackup, status: str, **extra: typing.
temporary_path.unlink(missing_ok=True) temporary_path.unlink(missing_ok=True)
def _fsync_file(path: pathlib.Path, *, reopen_attempts: int = 20) -> None: def _fsync_file(path: pathlib.Path) -> None:
"""Sync a file, tolerating delayed visibility after replace on bind mounts. descriptor = os.open(path, os.O_RDONLY)
Uses O_RDWR so os.fsync works on Windows (where _commit requires write
access to the file descriptor).
"""
descriptor: int | None = None
for attempt in range(reopen_attempts):
try:
descriptor = os.open(path, os.O_RDWR)
break
except FileNotFoundError:
if attempt + 1 >= reopen_attempts:
raise
time.sleep(0.05)
assert descriptor is not None
try: try:
os.fsync(descriptor) os.fsync(descriptor)
finally: finally:
@@ -143,37 +126,13 @@ def _fsync_file(path: pathlib.Path, *, reopen_attempts: int = 20) -> None:
def _fsync_directory(path: pathlib.Path) -> None: def _fsync_directory(path: pathlib.Path) -> None:
if os.name == 'nt': descriptor = os.open(path, os.O_RDONLY)
# Windows cannot fsync directory handles opened through os.open.
return
descriptor = os.open(path, os.O_RDONLY | getattr(os, 'O_DIRECTORY', 0))
try: try:
os.fsync(descriptor) os.fsync(descriptor)
finally: finally:
os.close(descriptor) os.close(descriptor)
def _remove_stale_temporary_files(
directory: pathlib.Path,
*,
prefix: str,
suffix: str,
) -> None:
"""Remove temporary files left by an interrupted backup or restore."""
for candidate in directory.iterdir():
if candidate.is_dir() or not candidate.name.startswith(prefix) or not candidate.name.endswith(suffix):
continue
try:
candidate.unlink()
except FileNotFoundError:
continue
except PermissionError:
# Another process may still own this file. Do not turn harmless
# cleanup into a migration failure; its unique name cannot collide.
continue
def _create_backup( def _create_backup(
database_path: pathlib.Path, database_path: pathlib.Path,
source_revision: str, source_revision: str,
@@ -182,11 +141,6 @@ def _create_backup(
backup_directory = database_path.parent / 'migration-backups' backup_directory = database_path.parent / 'migration-backups'
backup_directory.mkdir(mode=0o700, parents=True, exist_ok=True) backup_directory.mkdir(mode=0o700, parents=True, exist_ok=True)
os.chmod(backup_directory, 0o700) os.chmod(backup_directory, 0o700)
_remove_stale_temporary_files(
backup_directory,
prefix=f'.{database_path.stem}-pre-',
suffix='.creating',
)
created_at = datetime.datetime.now(datetime.UTC).strftime('%Y-%m-%dT%H-%M-%S.%fZ') created_at = datetime.datetime.now(datetime.UTC).strftime('%Y-%m-%dT%H-%M-%S.%fZ')
stem = ( stem = (
f'{database_path.stem}-pre-{_safe_label(target_revision)}-' f'{database_path.stem}-pre-{_safe_label(target_revision)}-'
@@ -203,8 +157,11 @@ def _create_backup(
temporary_path = pathlib.Path(temporary_name) temporary_path = pathlib.Path(temporary_name)
try: try:
with ( with (
contextlib.closing(_open_read_only(database_path)) as source, _open_read_only(database_path) as source,
contextlib.closing(sqlite3.connect(temporary_path, timeout=30)) as destination, sqlite3.connect(
temporary_path,
timeout=30,
) as destination,
): ):
source.execute('PRAGMA busy_timeout = 30000') source.execute('PRAGMA busy_timeout = 30000')
source.backup(destination) source.backup(destination)
@@ -252,11 +209,6 @@ async def create_verified_backup(
def _restore_backup(backup: SQLiteMigrationBackup) -> None: def _restore_backup(backup: SQLiteMigrationBackup) -> None:
_verify_file(backup.backup_path, backup.source_revision) _verify_file(backup.backup_path, backup.source_revision)
_remove_stale_temporary_files(
backup.database_path.parent,
prefix=f'.{backup.database_path.name}.',
suffix='.restoring',
)
descriptor, temporary_name = tempfile.mkstemp( descriptor, temporary_name = tempfile.mkstemp(
prefix=f'.{backup.database_path.name}.', prefix=f'.{backup.database_path.name}.',
suffix='.restoring', suffix='.restoring',
@@ -266,8 +218,11 @@ def _restore_backup(backup: SQLiteMigrationBackup) -> None:
temporary_path = pathlib.Path(temporary_name) temporary_path = pathlib.Path(temporary_name)
try: try:
with ( with (
contextlib.closing(_open_read_only(backup.backup_path)) as source, _open_read_only(backup.backup_path) as source,
contextlib.closing(sqlite3.connect(temporary_path, timeout=30)) as destination, sqlite3.connect(
temporary_path,
timeout=30,
) as destination,
): ):
source.backup(destination) source.backup(destination)
destination.commit() destination.commit()
+1 -1
View File
@@ -209,7 +209,7 @@ _ALLOWED_SCOPED_BUILTIN_FUNCTION_TYPES = {
'now': sqlalchemy.sql.functions.now, 'now': sqlalchemy.sql.functions.now,
'sum': sqlalchemy.sql.functions.sum, 'sum': sqlalchemy.sql.functions.sum,
} }
_ALLOWED_SCOPED_GENERIC_FUNCTIONS = frozenset({'date_trunc', 'length', 'nullif', 'strftime'}) _ALLOWED_SCOPED_GENERIC_FUNCTIONS = frozenset({'date_trunc', 'length', 'nullif'})
_ALLOWED_SCOPED_CUSTOM_OPERATORS = frozenset({'<=>'}) _ALLOWED_SCOPED_CUSTOM_OPERATORS = frozenset({'<=>'})
_ALLOWED_SCOPED_STATEMENT_TYPES = ( _ALLOWED_SCOPED_STATEMENT_TYPES = (
sqlalchemy.sql.dml.UpdateBase, sqlalchemy.sql.dml.UpdateBase,
@@ -5,11 +5,6 @@ from .. import entities
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
from ....utils.safe_regex import SafeRegexError, mask_patterns from ....utils.safe_regex import SafeRegexError, mask_patterns
# Legacy sensitive-words.json files shipped ~70 rules, which exceeds the
# default safe_regex per-call cap of 64 and used to fail-close every message.
# Keep one 50ms CPU budget for the whole list; only raise the pattern cap.
_MAX_SENSITIVE_WORD_PATTERNS = 256
@filter_model.filter_class('ban-word-filter') @filter_model.filter_class('ban-word-filter')
class BanWordFilter(filter_model.ContentFilter): class BanWordFilter(filter_model.ContentFilter):
@@ -19,17 +14,12 @@ class BanWordFilter(filter_model.ContentFilter):
pass pass
async def process(self, query: pipeline_query.Query, message: str) -> entities.FilterResult: async def process(self, query: pipeline_query.Query, message: str) -> entities.FilterResult:
words = self.ap.sensitive_meta.data.get('words') or []
mask = self.ap.sensitive_meta.data['mask']
mask_word = self.ap.sensitive_meta.data['mask_word']
try: try:
found, current = await mask_patterns( found, message = await mask_patterns(
words, self.ap.sensitive_meta.data['words'],
message, message,
mask=mask, mask=self.ap.sensitive_meta.data['mask'],
mask_word=mask_word, mask_word=self.ap.sensitive_meta.data['mask_word'],
max_pattern_count=_MAX_SENSITIVE_WORD_PATTERNS,
) )
except SafeRegexError as exc: except SafeRegexError as exc:
return entities.FilterResult( return entities.FilterResult(
@@ -41,7 +31,7 @@ class BanWordFilter(filter_model.ContentFilter):
return entities.FilterResult( return entities.FilterResult(
level=entities.ResultLevel.MASKED if found else entities.ResultLevel.PASS, level=entities.ResultLevel.MASKED if found else entities.ResultLevel.PASS,
replacement=current, replacement=message,
user_notice='消息中存在不合适的内容, 请修改' if found else '', user_notice='消息中存在不合适的内容, 请修改' if found else '',
console_notice='', console_notice='',
) )
@@ -158,18 +158,6 @@ class ResponseWrapper(stage.PipelineStage):
result_type=entities.ResultType.CONTINUE, result_type=entities.ResultType.CONTINUE,
new_query=query, new_query=query,
) )
elif (
isinstance(result, provider_message.MessageChunk) and result.is_final and not result.tool_calls
):
# Final streaming chunk with no text content but
# possibly carrying sandbox outbox attachments.
reply_chain = platform_message.MessageChain([])
await self._append_outbound_attachments(query, reply_chain)
query.resp_message_chain.append(reply_chain)
yield entities.StageProcessResult(
result_type=entities.ResultType.CONTINUE,
new_query=query,
)
if result.tool_calls is not None and len(result.tool_calls) > 0: # 有函数调用 if result.tool_calls is not None and len(result.tool_calls) > 0: # 有函数调用
function_names = [tc.function.name for tc in result.tool_calls] function_names = [tc.function.name for tc in result.tool_calls]
@@ -15,9 +15,9 @@ spec:
categories: categories:
- protocol - protocol
help_links: help_links:
zh: https://langbot.app/docs/zh/usage/platforms/qq/aiocqhttp/napcat zh: https://link.langbot.app/zh/platforms/aiocqhttp
en: https://langbot.app/docs/en/usage/platforms/qq/aiocqhttp/napcat en: https://link.langbot.app/en/platforms/aiocqhttp
ja: https://langbot.app/docs/ja/usage/platforms/qq/aiocqhttp/napcat ja: https://link.langbot.app/ja/platforms/aiocqhttp
config: config:
- name: host - name: host
label: label:
@@ -15,9 +15,9 @@ spec:
categories: categories:
- china - china
help_links: help_links:
zh: https://langbot.app/docs/zh/usage/platforms/dingtalk zh: https://link.langbot.app/zh/platforms/dingtalk
en: https://langbot.app/docs/en/usage/platforms/dingtalk en: https://link.langbot.app/en/platforms/dingtalk
ja: https://langbot.app/docs/ja/usage/platforms/dingtalk ja: https://link.langbot.app/ja/platforms/dingtalk
config: config:
- name: one-click-create - name: one-click-create
label: label:
@@ -24,9 +24,9 @@ spec:
- popular - popular
- global - global
help_links: help_links:
zh: https://langbot.app/docs/zh/usage/platforms/discord zh: https://link.langbot.app/zh/platforms/discord
en: https://langbot.app/docs/en/usage/platforms/discord en: https://link.langbot.app/en/platforms/discord
ja: https://langbot.app/docs/ja/usage/platforms/discord ja: https://link.langbot.app/ja/platforms/discord
config: config:
- name: client_id - name: client_id
label: label:
@@ -18,9 +18,9 @@ spec:
- popular - popular
- global - global
help_links: help_links:
zh: https://langbot.app/docs/zh/platforms/http-bot zh: https://docs.langbot.app/zh/platforms/http-bot
en: https://langbot.app/docs/en/platforms/http-bot en: https://docs.langbot.app/en/platforms/http-bot
ja: https://langbot.app/docs/ja/platforms/http-bot ja: https://docs.langbot.app/ja/platforms/http-bot
config: config:
- name: webhook_url - name: webhook_url
label: label:
+3 -3
View File
@@ -15,9 +15,9 @@ spec:
categories: categories:
- china - china
help_links: help_links:
zh: https://langbot.app/docs/zh/usage/platforms/kook zh: https://link.langbot.app/zh/platforms/kook
en: https://langbot.app/docs/en/usage/platforms/kook en: https://link.langbot.app/en/platforms/kook
ja: https://langbot.app/docs/ja/usage/platforms/kook ja: https://link.langbot.app/ja/platforms/kook
config: config:
- name: token - name: token
label: label:
+4 -32
View File
@@ -160,29 +160,6 @@ def _lark_should_update_stream_element(
return not resume_from and not form_data and (msg_seq % 8 == 0 or is_final) 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: def _lark_display_input_value(field: dict, value: typing.Any) -> str:
field_type = _dify_field_type(field) field_type = _dify_field_type(field)
if field_type == 'file': if field_type == 'file':
@@ -2381,21 +2358,16 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
self.card_form_input_defs[card_id] = _lark_form_input_defs(form_data) 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 {}) self.card_form_inputs[card_id] = dict(form_data.get('inputs') or {})
else: else:
# Normal finish: remove buttons/notice and finalize the card. # Normal finish: keep pre-pause + resume content visible,
main_text, resume_text = _lark_final_layout_texts( # remove buttons/notice, drop the resume placeholder.
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( await self._update_card_layout(
card_id=card_id, card_id=card_id,
message_source=message_source, message_source=message_source,
text_message=main_text, text_message=pre_pause,
sequence=final_seq, sequence=final_seq,
form_data=None, form_data=None,
notice_text=selected_notice if resume_from else '', notice_text=selected_notice if resume_from else '',
resume_placeholder_text=resume_text, resume_placeholder_text=resume_cached,
) )
self._drop_card_state(card_id) self._drop_card_state(card_id)
self.card_id_dict.pop(message_id, None) self.card_id_dict.pop(message_id, None)
+3 -3
View File
@@ -19,9 +19,9 @@ spec:
- china - china
- global - global
help_links: help_links:
zh: https://langbot.app/docs/zh/usage/platforms/lark zh: https://link.langbot.app/zh/platforms/lark
en: https://langbot.app/docs/en/usage/platforms/lark en: https://link.langbot.app/en/platforms/lark
ja: https://langbot.app/docs/ja/usage/platforms/lark ja: https://link.langbot.app/ja/platforms/lark
config: config:
- name: domain - name: domain
label: label:
+12 -61
View File
@@ -25,7 +25,6 @@ from linebot.v3.webhooks import (
ImageMessageContent, ImageMessageContent,
VideoMessageContent, VideoMessageContent,
AudioMessageContent, AudioMessageContent,
UserMentionee,
) )
# from linebot import WebhookParser # from linebot import WebhookParser
@@ -59,19 +58,15 @@ class LINEMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
return content_list return content_list
def __init__(self, bot_account_id: str = ''): @staticmethod
self.bot_account_id = bot_account_id async def target2yiri(message, bot_client) -> platform_message.MessageChain:
async def target2yiri(self, message, bot_client) -> platform_message.MessageChain:
lb_msg_list = [] lb_msg_list = []
msg_create_time = datetime.datetime.fromtimestamp(int(message.timestamp) / 1000) msg_create_time = datetime.datetime.fromtimestamp(int(message.timestamp) / 1000)
lb_msg_list.append(platform_message.Source(id=message.webhook_event_id, time=msg_create_time)) lb_msg_list.append(platform_message.Source(id=message.webhook_event_id, time=msg_create_time))
if isinstance(message.message, TextMessageContent): if isinstance(message.message, TextMessageContent):
lb_msg_list.extend( lb_msg_list.append(platform_message.Plain(text=message.message.text))
self._build_text_components(message.message.text, getattr(message.message, 'mention', None))
)
elif isinstance(message.message, AudioMessageContent): elif isinstance(message.message, AudioMessageContent):
pass pass
elif isinstance(message.message, VideoMessageContent): elif isinstance(message.message, VideoMessageContent):
@@ -91,60 +86,22 @@ class LINEMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
lb_msg_list.append(platform_message.Image(base64=data_uri)) lb_msg_list.append(platform_message.Image(base64=data_uri))
return platform_message.MessageChain(lb_msg_list) return platform_message.MessageChain(lb_msg_list)
def _build_text_components(self, text: str, mention) -> list:
"""Build message components from text, inserting At components for mentions.
LINE provides mention positions (index/length) and is_self per mentionee in the
webhook payload. Mapping the bot mention to At(target=bot_account_id) makes the
'at-bot' group respond rule work for LINE, consistent with other adapters.
"""
components: list = []
if not mention or not mention.mentionees:
if text:
components.append(platform_message.Plain(text=text))
return components
segments: list[tuple[int, int, object]] = sorted((m.index, m.index + m.length, m) for m in mention.mentionees)
cursor = 0
for start, end, mentionee in segments:
if start < cursor:
start, end = cursor, min(end, len(text))
if start < cursor or end <= start or end > len(text):
continue
if start > cursor:
components.append(platform_message.Plain(text=text[cursor:start]))
if isinstance(mentionee, UserMentionee):
target = self.bot_account_id if mentionee.is_self else mentionee.user_id
if not target:
target = text[start:end]
else:
target = text[start:end]
# At.__str__ already prepends '@', so strip one from the LINE text token.
display = text[start:end].lstrip('@')
components.append(platform_message.At(target=str(target), display=display))
cursor = end
if cursor < len(text):
components.append(platform_message.Plain(text=text[cursor:]))
return components
class LINEEventConverter(abstract_platform_adapter.AbstractEventConverter): class LINEEventConverter(abstract_platform_adapter.AbstractEventConverter):
def __init__(self, bot_account_id: str = ''):
self.bot_account_id = bot_account_id
self.message_converter = LINEMessageConverter(bot_account_id)
@staticmethod @staticmethod
async def yiri2target( async def yiri2target(
event: platform_events.MessageEvent, event: platform_events.MessageEvent,
) -> MessageEvent: ) -> MessageEvent:
pass pass
async def target2yiri(self, event, bot_client) -> platform_events.Event: @staticmethod
message_chain = await self.message_converter.target2yiri(event, bot_client) async def target2yiri(event, bot_client) -> platform_events.Event:
message_chain = await LINEMessageConverter.target2yiri(event, bot_client)
if event.source.type == 'user': if event.source.type == 'user':
return platform_events.FriendMessage( return platform_events.FriendMessage(
sender=platform_entities.Friend( sender=platform_entities.Friend(
id=event.source.user_id, id=event.message.id,
nickname=event.source.user_id, nickname=event.source.user_id,
remark='', remark='',
), ),
@@ -153,19 +110,13 @@ class LINEEventConverter(abstract_platform_adapter.AbstractEventConverter):
source_platform_object=event, source_platform_object=event,
) )
else: else:
# 'group' and 'room' sources carry the stable chat id under different
# field names; user_id may be absent for some members, so fall back
# to the group/room id rather than the per-message id.
group_id = event.source.group_id if event.source.type == 'group' else event.source.room_id
member_id = event.source.user_id or group_id
return platform_events.GroupMessage( return platform_events.GroupMessage(
sender=platform_entities.GroupMember( sender=platform_entities.GroupMember(
id=member_id, id=event.event.sender.sender_id.open_id,
member_name=member_id, member_name=event.event.sender.sender_id.union_id,
permission=platform_entities.Permission.Member, permission=platform_entities.Permission.Member,
group=platform_entities.Group( group=platform_entities.Group(
id=group_id, id=event.message.id,
name='', name='',
permission=platform_entities.Permission.Member, permission=platform_entities.Permission.Member,
), ),
@@ -212,8 +163,8 @@ class LINEAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
listeners={}, listeners={},
card_id_dict={}, card_id_dict={},
seq=1, seq=1,
event_converter=LINEEventConverter(bot_account_id), event_converter=LINEEventConverter(),
message_converter=LINEMessageConverter(bot_account_id), message_converter=LINEMessageConverter(),
line_webhook=line_webhook, line_webhook=line_webhook,
parser=parser, parser=parser,
configuration=configuration, configuration=configuration,
+3 -3
View File
@@ -22,9 +22,9 @@ spec:
categories: categories:
- global - global
help_links: help_links:
zh: https://langbot.app/docs/zh/usage/platforms/line zh: https://link.langbot.app/zh/platforms/line
en: https://langbot.app/docs/en/usage/platforms/line en: https://link.langbot.app/en/platforms/line
ja: https://langbot.app/docs/ja/usage/platforms/line ja: https://link.langbot.app/ja/platforms/line
config: config:
- name: webhook_url - name: webhook_url
label: label:
@@ -15,9 +15,9 @@ spec:
categories: categories:
- china - china
help_links: help_links:
zh: https://langbot.app/docs/zh/usage/platforms/wxoa zh: https://link.langbot.app/zh/platforms/officialaccount
en: https://langbot.app/docs/en/usage/platforms/wxoa en: https://link.langbot.app/en/platforms/officialaccount
ja: https://langbot.app/docs/ja/usage/platforms/wxoa ja: https://link.langbot.app/ja/platforms/officialaccount
config: config:
- name: webhook_url - name: webhook_url
label: label:
@@ -16,9 +16,9 @@ spec:
- popular - popular
- china - china
help_links: help_links:
zh: https://langbot.app/docs/zh/usage/platforms/wechat/weixin zh: https://link.langbot.app/zh/platforms/openclaw_weixin
en: https://langbot.app/docs/en/usage/platforms/readme en: https://link.langbot.app/en/platforms/openclaw_weixin
ja: https://langbot.app/docs/ja/usage/platforms/readme ja: https://link.langbot.app/ja/platforms/openclaw_weixin
config: config:
- name: base_url - name: base_url
label: label:
+30 -51
View File
@@ -205,7 +205,7 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
bot = QQOfficialClient( bot = QQOfficialClient(
app_id=config['appid'], app_id=config['appid'],
secret=config['secret'], secret=config['secret'],
token=config.get('token', ''), token=config['token'],
logger=logger, logger=logger,
unified_mode=enable_webhook, unified_mode=enable_webhook,
) )
@@ -329,12 +329,17 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
content_type = content.get('type', 'text') content_type = content.get('type', 'text')
if content_type == 'text': if content_type == 'text':
if target_type in {'c2c', 'group'}: if target_type == 'c2c':
await self._send_c2c_or_group_text_reply( await self.bot.send_private_text_msg(
target_type,
target_id, target_id,
content['content'], content['content'],
msg_id=qq_official_event.d_id, qq_official_event.d_id,
)
elif target_type == 'group':
await self.bot.send_group_text_msg(
target_id,
content['content'],
qq_official_event.d_id,
) )
elif content_type == 'image': elif content_type == 'image':
@@ -378,39 +383,6 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain): async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
pass pass
async def _send_c2c_or_group_text_reply(
self,
target_type: str,
target_id: str,
content: str,
*,
msg_id: typing.Optional[str] = None,
event_id: typing.Optional[str] = None,
msg_seq: int = 1,
) -> None:
"""Send a text reply using the configured C2C/group render mode."""
use_markdown = self.config.get('enable-markdown-rendering', False)
if target_type == 'c2c':
send = self.bot.send_private_markdown_msg if use_markdown else self.bot.send_private_text_msg
await send(
user_openid=target_id,
content=content,
msg_id=msg_id,
event_id=event_id,
msg_seq=msg_seq,
)
elif target_type == 'group':
send = self.bot.send_group_markdown_msg if use_markdown else self.bot.send_group_text_msg
await send(
group_openid=target_id,
content=content,
msg_id=msg_id,
event_id=event_id,
msg_seq=msg_seq,
)
else:
raise ValueError(f'Unsupported QQ Official text reply target: {target_type}')
def register_listener( def register_listener(
self, self,
event_type: typing.Type[platform_events.Event], event_type: typing.Type[platform_events.Event],
@@ -678,13 +650,13 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
# 用第一个 chunk 的文本建立会话(不发 "..." 避免污染前缀) # 用第一个 chunk 的文本建立会话(不发 "..." 避免污染前缀)
ctx['session_started'] = True ctx['session_started'] = True
# `replace` mode requires every update to contain the previously # 发送内容 = 全量累积文本
# delivered content as its prefix. `sent_length` only tells us whether # QQ API 的 replace 模式不允许修改已下发前缀,所以:
# a non-final snapshot has new content; it must not truncate the # - 首次:发送全部文本,建立会话
# content sent to QQ. # - 后续:只能发送新增部分(append 行为)
if len(ctx['accumulated_text']) <= ctx['sent_length'] and not is_final: content_to_send = ctx['accumulated_text'][ctx['sent_length'] :]
if not content_to_send and not is_final:
return return
content_to_send = ctx['accumulated_text']
input_state = 10 if is_final else 1 input_state = 10 if is_final else 1
@@ -806,13 +778,20 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
return return
try: try:
await self._send_c2c_or_group_text_reply( if target_type == 'c2c':
target_type, await self.bot.send_private_text_msg(
target_id, user_openid=target_id,
text, content=text,
event_id=event_id, event_id=event_id,
msg_seq=msg_seq, msg_seq=msg_seq,
) )
elif target_type == 'group':
await self.bot.send_group_text_msg(
group_openid=target_id,
content=text,
event_id=event_id,
msg_seq=msg_seq,
)
except Exception: except Exception:
await self.logger.error(f'QQ Official: synthetic reply delivery failed: {traceback.format_exc()}') await self.logger.error(f'QQ Official: synthetic reply delivery failed: {traceback.format_exc()}')
@@ -15,9 +15,9 @@ spec:
categories: categories:
- china - china
help_links: help_links:
zh: https://langbot.app/docs/zh/usage/platforms/qq/official_webhook zh: https://link.langbot.app/zh/platforms/qqofficial
en: https://langbot.app/docs/en/usage/platforms/qq/official_webhook en: https://link.langbot.app/en/platforms/qqofficial
ja: https://langbot.app/docs/ja/usage/platforms/qq/official_webhook ja: https://link.langbot.app/ja/platforms/qqofficial
config: config:
- name: __system.outbound_ips - name: __system.outbound_ips
label: label:
@@ -95,18 +95,6 @@ spec:
type: boolean type: boolean
required: true required: true
default: false default: false
- name: enable-markdown-rendering
label:
en_US: Enable Markdown Rendering
zh_Hans: 启用 Markdown 渲染
zh_Hant: 啟用 Markdown 渲染
description:
en_US: Render non-stream C2C and QQ group text replies as Markdown. Channel messages always use plain text and are not affected by this setting.
zh_Hans: 将非流式 C2C 私聊和 QQ 群聊文本回复渲染为 Markdown。频道消息始终以纯文本发送,不受此设置影响。
zh_Hant: 將非串流 C2C 私聊與 QQ 群聊文字回覆渲染為 Markdown。頻道訊息一律以純文字傳送,不受此設定影響。
type: boolean
required: true
default: false
- name: webhook_url - name: webhook_url
label: label:
en_US: Webhook Callback URL en_US: Webhook Callback URL
+3 -3
View File
@@ -21,9 +21,9 @@ spec:
categories: categories:
- protocol - protocol
help_links: help_links:
zh: https://langbot.app/docs/zh/usage/platforms/readme zh: https://link.langbot.app/zh/platforms/satori
en: https://langbot.app/docs/en/usage/platforms/readme en: https://link.langbot.app/en/platforms/satori
ja: https://langbot.app/docs/ja/usage/platforms/readme ja: https://link.langbot.app/ja/platforms/satori
config: config:
- name: platform - name: platform
label: label:
+3 -3
View File
@@ -24,9 +24,9 @@ spec:
- popular - popular
- global - global
help_links: help_links:
zh: https://langbot.app/docs/zh/usage/platforms/slack zh: https://link.langbot.app/zh/platforms/slack
en: https://langbot.app/docs/en/usage/platforms/slack en: https://link.langbot.app/en/platforms/slack
ja: https://langbot.app/docs/ja/usage/platforms/slack ja: https://link.langbot.app/ja/platforms/slack
config: config:
- name: webhook_url - name: webhook_url
label: label:
@@ -24,9 +24,9 @@ spec:
- popular - popular
- global - global
help_links: help_links:
zh: https://langbot.app/docs/zh/usage/platforms/telegram zh: https://link.langbot.app/zh/platforms/telegram
en: https://langbot.app/docs/en/usage/platforms/telegram en: https://link.langbot.app/en/platforms/telegram
ja: https://langbot.app/docs/ja/usage/platforms/telegram ja: https://link.langbot.app/ja/platforms/telegram
config: config:
- name: token - name: token
label: label:
@@ -15,9 +15,9 @@ spec:
categories: categories:
- china - china
help_links: help_links:
zh: https://langbot.app/docs/zh/usage/platforms/wechat/wechatpad zh: https://link.langbot.app/zh/platforms/wechatpad
en: https://langbot.app/docs/en/usage/platforms/readme en: https://link.langbot.app/en/platforms/wechatpad
ja: https://langbot.app/docs/ja/usage/platforms/readme ja: https://link.langbot.app/ja/platforms/wechatpad
config: config:
- name: wechatpad_url - name: wechatpad_url
label: label:
+3 -3
View File
@@ -274,11 +274,11 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
if content['type'] == 'text': if content['type'] == 'text':
await self.bot.send_private_msg(user_id, agent_id, content['content']) await self.bot.send_private_msg(user_id, agent_id, content['content'])
if content['type'] == 'image': if content['type'] == 'image':
await self.bot.send_image(user_id, agent_id, content['media_id']) await self.bot.send_image(user_id, agent_id, content['media'])
if content['type'] == 'voice': if content['type'] == 'voice':
await self.bot.send_voice(user_id, agent_id, content['media_id']) await self.bot.send_voice(user_id, agent_id, content['media'])
if content['type'] == 'file': if content['type'] == 'file':
await self.bot.send_file(user_id, agent_id, content['media_id']) await self.bot.send_file(user_id, agent_id, content['media'])
def register_listener( def register_listener(
self, self,
+3 -3
View File
@@ -16,9 +16,9 @@ spec:
- popular - popular
- china - china
help_links: help_links:
zh: https://langbot.app/docs/zh/usage/platforms/wecom/wecom zh: https://link.langbot.app/zh/platforms/wecom
en: https://langbot.app/docs/en/usage/platforms/wecom/wecom en: https://link.langbot.app/en/platforms/wecom
ja: https://langbot.app/docs/ja/usage/platforms/wecom/wecom ja: https://link.langbot.app/ja/platforms/wecom
config: config:
- name: webhook_url - name: webhook_url
label: label:
+19 -107
View File
@@ -3,10 +3,8 @@ import typing
import asyncio import asyncio
import time import time
import traceback import traceback
import base64
import datetime import datetime
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
import langbot_plugin.api.entities.builtin.platform.message as platform_message import langbot_plugin.api.entities.builtin.platform.message as platform_message
import langbot_plugin.api.entities.builtin.platform.events as platform_events import langbot_plugin.api.entities.builtin.platform.events as platform_events
@@ -26,24 +24,11 @@ from langbot.libs.wecom_ai_bot_api.ws_client import WecomBotWsClient
class WecomBotMessageConverter(abstract_platform_adapter.AbstractMessageConverter): class WecomBotMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
@staticmethod @staticmethod
async def yiri2target(message_chain: platform_message.MessageChain): async def yiri2target(message_chain: platform_message.MessageChain):
"""Convert a MessageChain into a list of component dicts. content = ''
Each dict has a ``type`` key (``'text'``, ``'image'``,
``'voice'``, ``'file'``). Text items carry ``text``; media
items carry ``base64`` (may include a ``data:...;base64,``
prefix) and optionally ``name``.
"""
items: list[dict] = []
for msg in message_chain: for msg in message_chain:
if type(msg) is platform_message.Plain: if type(msg) is platform_message.Plain:
items.append({'type': 'text', 'text': msg.text}) content += msg.text
elif type(msg) is platform_message.Image: return content
items.append({'type': 'image', 'base64': msg.base64 or ''})
elif type(msg) is platform_message.Voice:
items.append({'type': 'voice', 'base64': msg.base64 or ''})
elif type(msg) is platform_message.File:
items.append({'type': 'file', 'base64': msg.base64 or '', 'name': msg.name or ''})
return items
@staticmethod @staticmethod
async def target2yiri(event: WecomBotEvent, bot_name: str = ''): async def target2yiri(event: WecomBotEvent, bot_name: str = ''):
@@ -377,76 +362,13 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
} }
) )
@staticmethod
def _join_text_components(items: list[dict]) -> str:
"""Concatenate ``text`` items in order, leaving media items alone."""
return ''.join(item['text'] for item in items if item.get('type') == 'text')
@staticmethod
def _iter_media_components(items: list[dict]):
"""Yield non-text items in order."""
for item in items:
if item.get('type') in {'image', 'voice', 'file'}:
yield item
@staticmethod
async def _send_media(
bot,
req_id: str,
item: dict,
) -> bool:
"""Upload *item* to the WeCom AI Bot CDN and send it as a media reply.
Returns True on success. Falls back to a no-op (with a warning log)
if the SDK does not yet implement ``upload_media`` /
``reply_image`` / ``reply_file`` / ``reply_voice`` the framework
will keep working, just without image delivery.
"""
kind = item.get('type')
upload = getattr(bot, 'upload_media', None)
if upload is None:
return False
b64_text = item.get('base64') or ''
if not b64_text:
return False
if b64_text.startswith('data:') and ',' in b64_text:
b64_text = b64_text.split(',', 1)[1]
try:
data = base64.b64decode(b64_text, validate=False)
except Exception:
return False
if not data:
return False
try:
upload_result = await upload(data, item.get('name') or f'attachment.{kind}', media_type=kind)
except Exception:
return False
media_id = getattr(upload_result, 'media_id', None) or (
isinstance(upload_result, dict) and upload_result.get('media_id')
)
if not media_id:
return False
reply_fn = {
'image': getattr(bot, 'reply_image', None),
'file': getattr(bot, 'reply_file', None),
'voice': getattr(bot, 'reply_voice', None),
}.get(kind)
if reply_fn is None:
return False
try:
await reply_fn(req_id, media_id)
return True
except Exception:
return False
async def reply_message( async def reply_message(
self, self,
message_source: platform_events.MessageEvent, message_source: platform_events.MessageEvent,
message: platform_message.MessageChain, message: platform_message.MessageChain,
quote_origin: bool = False, quote_origin: bool = False,
): ):
items = await self.message_converter.yiri2target(message) content = await self.message_converter.yiri2target(message)
text = self._join_text_components(items)
_ws_mode = not self.config.get('enable-webhook', False) _ws_mode = not self.config.get('enable-webhook', False)
event = message_source.source_platform_object event = message_source.source_platform_object
@@ -460,7 +382,7 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
else: else:
chat_id = str(message_source.sender.id) chat_id = str(message_source.sender.id)
try: try:
await self.bot.send_message(chat_id, text) await self.bot.send_message(chat_id, content)
except Exception: except Exception:
await self.logger.error( await self.logger.error(
f'WeComBot: proactive reply for synthetic event failed: {traceback.format_exc()}' f'WeComBot: proactive reply for synthetic event failed: {traceback.format_exc()}'
@@ -474,15 +396,12 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
if _ws_mode: if _ws_mode:
req_id = event.get('req_id', '') if isinstance(event, dict) else getattr(event, 'req_id', '') req_id = event.get('req_id', '') if isinstance(event, dict) else getattr(event, 'req_id', '')
if text: if req_id:
if req_id: await self.bot.reply_text(req_id, content)
await self.bot.reply_text(req_id, text) else:
else: await self.bot.set_message(event.message_id, content)
await self.bot.set_message(event.message_id, text)
for item in self._iter_media_components(items):
await self._send_media(self.bot, req_id, item)
else: else:
await self.bot.set_message(event.message_id, text) await self.bot.set_message(event.message_id, content)
async def reply_message_chunk( async def reply_message_chunk(
self, self,
@@ -492,8 +411,7 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
quote_origin: bool = False, quote_origin: bool = False,
is_final: bool = False, is_final: bool = False,
): ):
items = await self.message_converter.yiri2target(message) content = await self.message_converter.yiri2target(message)
text = self._join_text_components(items)
_ws_mode = not self.config.get('enable-webhook', False) _ws_mode = not self.config.get('enable-webhook', False)
# Synthetic events (e.g. button-click triggered form resume) have # Synthetic events (e.g. button-click triggered form resume) have
@@ -502,7 +420,7 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
# of the stream/reply path. # of the stream/reply path.
spo = message_source.source_platform_object spo = message_source.source_platform_object
if spo is None: if spo is None:
return await self._handle_synthetic_chunk(message_source, bot_message, text, is_final, _ws_mode) return await self._handle_synthetic_chunk(message_source, bot_message, content, is_final, _ws_mode)
msg_id = spo.message_id msg_id = spo.message_id
@@ -534,7 +452,7 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
form_data.get('actions', []) or [], form_data.get('actions', []) or [],
) )
except Exception: except Exception:
fallback = text or '(人工输入)' fallback = content or '(人工输入)'
if _ws_mode: if _ws_mode:
event = message_source.source_platform_object event = message_source.source_platform_object
req_id = event.get('req_id', '') if isinstance(event, dict) else getattr(event, 'req_id', '') req_id = event.get('req_id', '') if isinstance(event, dict) else getattr(event, 'req_id', '')
@@ -545,22 +463,17 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
return {'stream': False, 'form': True, 'fallback': True} return {'stream': False, 'form': True, 'fallback': True}
if _ws_mode: if _ws_mode:
success = await self.bot.push_stream_chunk(msg_id, text, is_final=is_final) success = await self.bot.push_stream_chunk(msg_id, content, is_final=is_final)
if not success and is_final: if not success and is_final:
event = message_source.source_platform_object event = message_source.source_platform_object
req_id = event.get('req_id', '') req_id = event.get('req_id', '')
if req_id: if req_id:
await self.bot.reply_text(req_id, text) await self.bot.reply_text(req_id, content)
if is_final:
event = message_source.source_platform_object
req_id = event.get('req_id', '')
for item in self._iter_media_components(items):
await self._send_media(self.bot, req_id, item)
return {'stream': success} return {'stream': success}
else: else:
success = await self.bot.push_stream_chunk(msg_id, text, is_final=is_final) success = await self.bot.push_stream_chunk(msg_id, content, is_final=is_final)
if not success and is_final: if not success and is_final:
await self.bot.set_message(msg_id, text) await self.bot.set_message(msg_id, content)
return {'stream': success} return {'stream': success}
async def is_stream_output_supported(self) -> bool: async def is_stream_output_supported(self) -> bool:
@@ -714,9 +627,8 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
async def send_message(self, target_type, target_id, message): async def send_message(self, target_type, target_id, message):
_ws_mode = not self.config.get('enable-webhook', False) _ws_mode = not self.config.get('enable-webhook', False)
if _ws_mode: if _ws_mode:
items = await self.message_converter.yiri2target(message) content = await self.message_converter.yiri2target(message)
text = self._join_text_components(items) await self.bot.send_message(target_id, content)
await self.bot.send_message(target_id, text)
else: else:
pass pass
@@ -15,9 +15,9 @@ spec:
categories: categories:
- china - china
help_links: help_links:
zh: https://langbot.app/docs/zh/usage/platforms/wecom/wecombot zh: https://link.langbot.app/zh/platforms/wecombot
en: https://langbot.app/docs/en/usage/platforms/wecom/wecombot en: https://link.langbot.app/en/platforms/wecombot
ja: https://langbot.app/docs/ja/usage/platforms/wecom/wecombot ja: https://link.langbot.app/ja/platforms/wecombot
config: config:
- name: one-click-create - name: one-click-create
label: label:
+3 -10
View File
@@ -107,7 +107,7 @@ class WecomEventConverter(abstract_platform_adapter.AbstractEventConverter):
if event.type == 'text': if event.type == 'text':
yiri_chain = await WecomMessageConverter.target2yiri(event.message, event.message_id) yiri_chain = await WecomMessageConverter.target2yiri(event.message, event.message_id)
friend = platform_entities.Friend( friend = platform_entities.Friend(
id=f'{event.receiver_id}|u{event.user_id}', id=f'u{event.user_id}',
nickname=nickname, nickname=nickname,
remark='', remark='',
) )
@@ -117,7 +117,7 @@ class WecomEventConverter(abstract_platform_adapter.AbstractEventConverter):
) )
elif event.type == 'image': elif event.type == 'image':
friend = platform_entities.Friend( friend = platform_entities.Friend(
id=f'{event.receiver_id}|u{event.user_id}', id=f'u{event.user_id}',
nickname=nickname, nickname=nickname,
remark='', remark='',
) )
@@ -197,7 +197,7 @@ class WecomCSAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
content_list = await WecomMessageConverter.yiri2target(message, self.bot) content_list = await WecomMessageConverter.yiri2target(message, self.bot)
for content in content_list: for content in content_list:
msgid = f'{uuid.uuid4().hex}' msgid = f'langbot_{uuid.uuid4().hex}'
if content['type'] == 'text': if content['type'] == 'text':
await self.bot.send_text_msg( await self.bot.send_text_msg(
open_kfid=open_kfid, open_kfid=open_kfid,
@@ -205,13 +205,6 @@ class WecomCSAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
msgid=msgid, msgid=msgid,
content=content['content'], content=content['content'],
) )
elif content['type'] == 'image':
await self.bot.send_image_msg(
open_kfid=open_kfid,
external_userid=external_userid,
msgid=msgid,
media_id=content['media_id'],
)
def set_bot_uuid(self, bot_uuid: str): def set_bot_uuid(self, bot_uuid: str):
"""设置 bot UUID(用于生成 webhook URL""" """设置 bot UUID(用于生成 webhook URL"""
@@ -15,9 +15,9 @@ spec:
categories: categories:
- china - china
help_links: help_links:
zh: https://langbot.app/docs/zh/usage/platforms/wecom/wecomcs zh: https://link.langbot.app/zh/platforms/wecomcs
en: https://langbot.app/docs/en/usage/platforms/wecom/wecomcs en: https://link.langbot.app/en/platforms/wecomcs
ja: https://langbot.app/docs/ja/usage/platforms/wecom/wecomcs ja: https://link.langbot.app/ja/platforms/wecomcs
config: config:
- name: webhook_url - name: webhook_url
label: label:
+4 -21
View File
@@ -701,13 +701,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
} }
self._known_desired_states.update({state.binding.installation_uuid: state for state in desired_states}) self._known_desired_states.update({state.binding.installation_uuid: state for state in desired_states})
reconcile_timeout_seconds = max( result = await runtime_handler.reconcile_plugin_installations(tuple(self._known_desired_states.values()))
300.0, self._runtime_connect_timeout(self.ap.instance_config.data.get('plugin', {}))
)
result = await runtime_handler.reconcile_plugin_installations(
tuple(self._known_desired_states.values()),
timeout=reconcile_timeout_seconds,
)
await self._repair_reconcile_missing_artifacts(self._known_desired_states, result) await self._repair_reconcile_missing_artifacts(self._known_desired_states, result)
self._record_reconcile_failures(self._known_desired_states, result) self._record_reconcile_failures(self._known_desired_states, result)
@@ -742,13 +736,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
if state.binding.installation_uuid in all_states: if state.binding.installation_uuid in all_states:
raise ValueError('Duplicate plugin installation UUID across projected Workspaces') raise ValueError('Duplicate plugin installation UUID across projected Workspaces')
all_states[state.binding.installation_uuid] = state all_states[state.binding.installation_uuid] = state
reconcile_timeout_seconds = max( result = await runtime_handler.reconcile_plugin_installations(tuple(all_states.values()))
300.0, self._runtime_connect_timeout(self.ap.instance_config.data.get('plugin', {}))
)
result = await runtime_handler.reconcile_plugin_installations(
tuple(all_states.values()),
timeout=reconcile_timeout_seconds,
)
await self._repair_reconcile_missing_artifacts(all_states, result) await self._repair_reconcile_missing_artifacts(all_states, result)
self._record_reconcile_failures(all_states, result) self._record_reconcile_failures(all_states, result)
for installation_uuid, previous in tuple(self._known_desired_states.items()): for installation_uuid, previous in tuple(self._known_desired_states.items()):
@@ -1913,14 +1901,9 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
return plugins return plugins
async def get_plugin_info(self, author: str, plugin_name: str) -> dict[str, Any] | None: async def get_plugin_info(self, author: str, plugin_name: str) -> dict[str, Any]:
runtime_handler = self._runtime_handler() runtime_handler = self._runtime_handler()
try: binding = await self._target_binding(author, plugin_name)
binding = await self._target_binding(author, plugin_name)
except ValueError as exc:
if str(exc) == f'Plugin {author}/{plugin_name} is not installed in this Workspace':
return None
raise
with runtime_handler.installation_scope(binding): with runtime_handler.installation_scope(binding):
return await runtime_handler.get_plugin_info(author, plugin_name) return await runtime_handler.get_plugin_info(author, plugin_name)
+14 -120
View File
@@ -11,8 +11,6 @@ import traceback
from dataclasses import dataclass from dataclasses import dataclass
import sqlalchemy import sqlalchemy
import sqlalchemy.dialects.postgresql
import sqlalchemy.dialects.sqlite
from langbot_plugin.runtime.io import handler from langbot_plugin.runtime.io import handler
from langbot_plugin.runtime.io.connection import Connection from langbot_plugin.runtime.io.connection import Connection
@@ -433,19 +431,6 @@ class RuntimeConnectionHandler(handler.Handler):
return f'{identity.plugin_author}/{identity.plugin_name}' return f'{identity.plugin_author}/{identity.plugin_name}'
raise ValueError(f'Unsupported binary storage owner_type {owner_type!r}') raise ValueError(f'Unsupported binary storage owner_type {owner_type!r}')
@staticmethod
def _legacy_binary_storage_key(
action_context: ActionContext,
*,
owner_type: str,
owner: str,
key: str,
) -> str:
"""Return the pre-tenancy key shape for a row already scoped to this Workspace."""
legacy_owner = action_context.workspace_uuid if owner_type == 'workspace' else owner
return f'{owner_type}:{legacy_owner}:{key}'
@classmethod @classmethod
def _binary_storage_key( def _binary_storage_key(
cls, cls,
@@ -911,82 +896,25 @@ class RuntimeConnectionHandler(handler.Handler):
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid) .where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
.where(persistence_bstorage.BinaryStorage.unique_key == unique_key) .where(persistence_bstorage.BinaryStorage.unique_key == unique_key)
) )
storage = result.first()
if storage is None:
legacy_key = self._legacy_binary_storage_key(
action_context,
owner_type=owner_type,
owner=owner,
key=key,
)
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_bstorage.BinaryStorage)
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
.where(persistence_bstorage.BinaryStorage.unique_key == legacy_key)
.where(persistence_bstorage.BinaryStorage.key == key)
.where(persistence_bstorage.BinaryStorage.owner_type == owner_type)
.where(persistence_bstorage.BinaryStorage.owner == owner)
)
storage = result.first()
if storage is not None:
update_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_bstorage.BinaryStorage)
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
.where(persistence_bstorage.BinaryStorage.unique_key == legacy_key)
.where(persistence_bstorage.BinaryStorage.key == key)
.where(persistence_bstorage.BinaryStorage.owner_type == owner_type)
.where(persistence_bstorage.BinaryStorage.owner == owner)
.values(unique_key=unique_key, value=value)
)
if update_result.rowcount:
return handler.ActionResponse.success(data={})
canonical_update = await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_bstorage.BinaryStorage)
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
.where(persistence_bstorage.BinaryStorage.unique_key == unique_key)
.where(persistence_bstorage.BinaryStorage.key == key)
.where(persistence_bstorage.BinaryStorage.owner_type == owner_type)
.where(persistence_bstorage.BinaryStorage.owner == owner)
.values(value=value)
)
if canonical_update.rowcount:
return handler.ActionResponse.success(data={})
storage = None
if storage is not None: if result.first() is not None:
await self.ap.persistence_mgr.execute_async( await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_bstorage.BinaryStorage) sqlalchemy.update(persistence_bstorage.BinaryStorage)
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid) .where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
.where(persistence_bstorage.BinaryStorage.unique_key == unique_key) .where(persistence_bstorage.BinaryStorage.unique_key == unique_key)
.where(persistence_bstorage.BinaryStorage.key == key)
.where(persistence_bstorage.BinaryStorage.owner_type == owner_type)
.where(persistence_bstorage.BinaryStorage.owner == owner)
.values(value=value) .values(value=value)
) )
return handler.ActionResponse.success(data={}) else:
await self.ap.persistence_mgr.execute_async(
dialect_name = self.ap.persistence_mgr.get_db_engine().dialect.name sqlalchemy.insert(persistence_bstorage.BinaryStorage).values(
insert = { workspace_uuid=action_context.workspace_uuid,
'postgresql': sqlalchemy.dialects.postgresql.insert, unique_key=unique_key,
'sqlite': sqlalchemy.dialects.sqlite.insert, key=key,
}.get(dialect_name) owner_type=owner_type,
if insert is None: owner=owner,
return handler.ActionResponse.error(message=f'Unsupported storage database dialect: {dialect_name}') value=value,
await self.ap.persistence_mgr.execute_async( )
insert(persistence_bstorage.BinaryStorage)
.values(
workspace_uuid=action_context.workspace_uuid,
unique_key=unique_key,
key=key,
owner_type=owner_type,
owner=owner,
value=value,
) )
.on_conflict_do_update(
index_elements=['workspace_uuid', 'unique_key'],
set_={'value': value},
)
)
return handler.ActionResponse.success( return handler.ActionResponse.success(
data={}, data={},
@@ -1018,29 +946,6 @@ class RuntimeConnectionHandler(handler.Handler):
) )
storage = result.first() storage = result.first()
if storage is None:
legacy_key = self._legacy_binary_storage_key(
action_context,
owner_type=owner_type,
owner=owner,
key=key,
)
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_bstorage.BinaryStorage)
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
.where(persistence_bstorage.BinaryStorage.unique_key == legacy_key)
.where(persistence_bstorage.BinaryStorage.key == key)
.where(persistence_bstorage.BinaryStorage.owner_type == owner_type)
.where(persistence_bstorage.BinaryStorage.owner == owner)
)
storage = result.first()
if storage is None:
retry_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_bstorage.BinaryStorage)
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
.where(persistence_bstorage.BinaryStorage.unique_key == unique_key)
)
storage = retry_result.first()
if storage is None: if storage is None:
return handler.ActionResponse.error( return handler.ActionResponse.error(
message=f'Storage with key {key} not found', message=f'Storage with key {key} not found',
@@ -1076,19 +981,10 @@ class RuntimeConnectionHandler(handler.Handler):
message=str(e), message=str(e),
) )
legacy_key = self._legacy_binary_storage_key(
action_context,
owner_type=owner_type,
owner=owner,
key=key,
)
await self.ap.persistence_mgr.execute_async( await self.ap.persistence_mgr.execute_async(
sqlalchemy.delete(persistence_bstorage.BinaryStorage) sqlalchemy.delete(persistence_bstorage.BinaryStorage)
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid) .where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
.where(persistence_bstorage.BinaryStorage.unique_key.in_((unique_key, legacy_key))) .where(persistence_bstorage.BinaryStorage.unique_key == unique_key)
.where(persistence_bstorage.BinaryStorage.key == key)
.where(persistence_bstorage.BinaryStorage.owner_type == owner_type)
.where(persistence_bstorage.BinaryStorage.owner == owner)
) )
return handler.ActionResponse.success( return handler.ActionResponse.success(
@@ -1116,7 +1012,7 @@ class RuntimeConnectionHandler(handler.Handler):
return handler.ActionResponse.success( return handler.ActionResponse.success(
data={ data={
'keys': list(dict.fromkeys(result.scalars().all())), 'keys': result.scalars().all(),
}, },
) )
@@ -1677,15 +1573,13 @@ class RuntimeConnectionHandler(handler.Handler):
async def reconcile_plugin_installations( async def reconcile_plugin_installations(
self, self,
installations: tuple[PluginInstallationDesiredState, ...], installations: tuple[PluginInstallationDesiredState, ...],
*,
timeout: float = 300,
) -> dict[str, Any]: ) -> dict[str, Any]:
request = ReconcilePluginInstallationsRequest(installations=installations) request = ReconcilePluginInstallationsRequest(installations=installations)
with self.installation_scope(None): with self.installation_scope(None):
return await self.call_action( return await self.call_action(
LangBotToRuntimeAction.RECONCILE_PLUGIN_INSTALLATIONS, LangBotToRuntimeAction.RECONCILE_PLUGIN_INSTALLATIONS,
request.model_dump(), request.model_dump(),
timeout=timeout, timeout=300,
) )
async def apply_plugin_installation( async def apply_plugin_installation(
@@ -573,7 +573,7 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
levels = ['provider_default', 'disabled', 'enabled'] levels = ['provider_default', 'disabled', 'enabled']
elif family == 'doubao': elif family == 'doubao':
levels = ['provider_default', 'disabled', 'low', 'medium', 'high'] levels = ['provider_default', 'disabled', 'low', 'medium', 'high']
elif family in ('ollama', 'ollama_chat'): elif family == 'ollama':
levels = ['provider_default'] levels = ['provider_default']
levels.append('disabled') levels.append('disabled')
if normalized_name.startswith('gpt-oss') or '/gpt-oss' in normalized_name: if normalized_name.startswith('gpt-oss') or '/gpt-oss' in normalized_name:
@@ -747,24 +747,9 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
converted_parts = [] converted_parts = []
for part in content: for part in content:
if isinstance(part, dict) and part.get('type') == 'image_base64': if isinstance(part, dict) and part.get('type') == 'image_base64':
# History trimming (SessionManager) clears image_base64 part['image_url'] = {'url': part['image_base64']}
# on past turns and exclude_none serialization drops part['type'] = 'image_url'
# the key entirely, so the replayed part may carry no del part['image_base64']
# payload. Prefer the base64 payload; fall back to an
# image_url that survived on the same element; drop
# hollow parts instead of raising KeyError (#2469).
image_b64 = part.get('image_base64')
fallback_url = None
if not image_b64:
raw_image_url = part.get('image_url')
if isinstance(raw_image_url, dict):
fallback_url = raw_image_url.get('url')
if image_b64 or fallback_url:
part['image_url'] = {'url': image_b64 or fallback_url}
part['type'] = 'image_url'
part.pop('image_base64', None)
else:
continue
# OpenAI-compatible chat models reject non-image file parts # OpenAI-compatible chat models reject non-image file parts
# (audio/document base64 or url). These originate from Voice / # (audio/document base64 or url). These originate from Voice /
# File attachments — including ones replayed from conversation # File attachments — including ones replayed from conversation
@@ -1345,14 +1330,7 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
extra_args: dict[str, typing.Any] = {}, extra_args: dict[str, typing.Any] = {},
) -> tuple[list[list[float]], dict]: ) -> tuple[list[list[float]], dict]:
"""Invoke embedding and return vectors with usage info.""" """Invoke embedding and return vectors with usage info."""
# litellm's embedding routing has no "ollama_chat" branch (that provider model_name = self._build_litellm_model_name(model.model_entity.name)
# 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() api_key = model.provider.token_mgr.get_token()
args = { args = {
@@ -1548,12 +1526,6 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
event_hooks=httpclient.httpx_response_limit_hooks(), event_hooks=httpclient.httpx_response_limit_hooks(),
) as client: ) as client:
response = await client.get(models_url, headers=headers) 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() response.raise_for_status()
payload = await httpclient.parse_json_response(response) payload = await httpclient.parse_json_response(response)
@@ -7,7 +7,7 @@ metadata:
zh_Hans: Ollama zh_Hans: Ollama
icon: ollama.svg icon: ollama.svg
spec: spec:
litellm_provider: ollama_chat litellm_provider: ollama
config: config:
- name: base_url - name: base_url
label: label:
@@ -24,10 +24,7 @@ class SeekDBEmbedding(requester.ProviderAPIRequester):
try: try:
import pyseekdb import pyseekdb
except ImportError: except ImportError:
raise ImportError( raise ImportError('pyseekdb is not installed. Install it with: pip install pyseekdb')
"SeekDB support is not installed. Install LangBot with the 'seekdb' extra: "
"uv sync --extra seekdb (source) or uvx --from 'langbot[seekdb]@latest' langbot (PyPI)."
)
self._embedding_function = pyseekdb.get_default_embedding_function() self._embedding_function = pyseekdb.get_default_embedding_function()
@@ -619,9 +619,7 @@ class LocalAgentRunner(runner.RequestRunner):
and len(func_ret) > 0 and len(func_ret) > 0
and isinstance(func_ret[0], provider_message.ContentElement) and isinstance(func_ret[0], provider_message.ContentElement)
): ):
# OpenAI-compatible APIs require tool-message content to be a tool_content = func_ret
# string; a raw list of ContentElement causes HTTP 500 (#2457).
tool_content = '\n'.join(str(ce) for ce in func_ret)
else: else:
tool_content = json.dumps(func_ret, ensure_ascii=False) tool_content = json.dumps(func_ret, ensure_ascii=False)
+1 -13
View File
@@ -39,9 +39,6 @@ class N8nServiceAPIRunner(runner.RequestRunner):
# 获取输出键名,默认为response # 获取输出键名,默认为response
self.output_key = self.pipeline_config['ai']['n8n-service-api'].get('output-key', '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 # 获取认证类型,默认为none
self.auth_type = self.pipeline_config['ai']['n8n-service-api'].get('auth-type', 'none') self.auth_type = self.pipeline_config['ai']['n8n-service-api'].get('auth-type', 'none')
@@ -265,11 +262,7 @@ class N8nServiceAPIRunner(runner.RequestRunner):
async with session.post( async with session.post(
self.webhook_url, json=payload, headers=headers, auth=auth, timeout=self.timeout self.webhook_url, json=payload, headers=headers, auth=auth, timeout=self.timeout
) as response: ) as response:
if self.response_handling == 'ignore': if response.status != 200:
status_ok = 200 <= response.status < 300
else:
status_ok = response.status == 200
if not status_ok:
error_text = ( error_text = (
await httpclient.read_limited( await httpclient.read_limited(
response, response,
@@ -279,11 +272,6 @@ class N8nServiceAPIRunner(runner.RequestRunner):
self.ap.logger.error(f'n8n webhook call failed: {response.status}, {error_text}') self.ap.logger.error(f'n8n webhook call failed: {response.status}, {error_text}')
raise Exception(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): async for chunk in self._process_response(response):
if is_stream: if is_stream:
yield chunk yield chunk
@@ -222,7 +222,6 @@ class NativeToolLoader(loader.ToolLoader):
self.ap.logger.warning( self.ap.logger.warning(
'Native sandbox tools (exec/read/write/edit/glob/grep) are NOT available. ' 'Native sandbox tools (exec/read/write/edit/glob/grep) 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. '
'The LLM will not have access to code execution or file operation tools.' 'The LLM will not have access to code execution or file operation tools.'
) )
@@ -42,8 +42,7 @@ class SkillToolLoader(loader.ToolLoader):
else: else:
self.ap.logger.info( self.ap.logger.info(
'Skill tools (activate/register_skill) are NOT available. ' '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: async def _check_sandbox_available(self) -> bool:
+4 -13
View File
@@ -27,16 +27,10 @@ class SafeRegexTimeoutError(SafeRegexError):
"""Raised when the regex engine exhausts the operation CPU budget.""" """Raised when the regex engine exhausts the operation CPU budget."""
def _validate_patterns( def _validate_patterns(patterns: Sequence[str]) -> tuple[str, ...]:
patterns: Sequence[str],
*,
max_pattern_count: int = MAX_PATTERN_COUNT,
) -> tuple[str, ...]:
if max_pattern_count < 1:
raise ValueError('max_pattern_count must be positive')
if len(patterns) > max_pattern_count:
raise SafeRegexLimitError(f'At most {max_pattern_count} regex patterns are allowed')
normalized = tuple(patterns) normalized = tuple(patterns)
if len(normalized) > MAX_PATTERN_COUNT:
raise SafeRegexLimitError(f'At most {MAX_PATTERN_COUNT} regex patterns are allowed')
for pattern in normalized: for pattern in normalized:
if not isinstance(pattern, str): if not isinstance(pattern, str):
raise SafeRegexError('Regex patterns must be strings') raise SafeRegexError('Regex patterns must be strings')
@@ -121,9 +115,8 @@ def _mask_patterns_sync(
mask: str, mask: str,
mask_word: str, mask_word: str,
timeout_seconds: float, timeout_seconds: float,
max_pattern_count: int,
) -> tuple[bool, str]: ) -> tuple[bool, str]:
normalized_patterns = _validate_patterns(patterns, max_pattern_count=max_pattern_count) normalized_patterns = _validate_patterns(patterns)
_validate_input(value) _validate_input(value)
if len(mask) > MAX_REPLACEMENT_CHARS or len(mask_word) > MAX_REPLACEMENT_CHARS: if len(mask) > MAX_REPLACEMENT_CHARS or len(mask_word) > MAX_REPLACEMENT_CHARS:
raise SafeRegexLimitError(f'Regex replacements may contain at most {MAX_REPLACEMENT_CHARS} characters') raise SafeRegexLimitError(f'Regex replacements may contain at most {MAX_REPLACEMENT_CHARS} characters')
@@ -169,7 +162,6 @@ async def mask_patterns(
mask: str, mask: str,
mask_word: str, mask_word: str,
timeout_seconds: float = DEFAULT_OPERATION_TIMEOUT_SECONDS, timeout_seconds: float = DEFAULT_OPERATION_TIMEOUT_SECONDS,
max_pattern_count: int = MAX_PATTERN_COUNT,
) -> tuple[bool, str]: ) -> tuple[bool, str]:
"""Apply untrusted masking patterns with bounded CPU and output growth.""" """Apply untrusted masking patterns with bounded CPU and output growth."""
@@ -182,5 +174,4 @@ async def mask_patterns(
mask=mask, mask=mask,
mask_word=mask_word, mask_word=mask_word,
timeout_seconds=timeout_seconds, timeout_seconds=timeout_seconds,
max_pattern_count=max_pattern_count,
) )
+1 -1
View File
@@ -83,7 +83,7 @@ class VersionManager:
try: try:
if await self.is_new_version_available(): if await self.is_new_version_available():
return ( return (
'New version available. Update guide: https://langbot.app/docs/en/deploy/update', 'New version available. Update guide: https://link.langbot.app/en/docs/update',
logging.INFO, logging.INFO,
) )
except Exception as e: except Exception as e:
+1 -4
View File
@@ -42,10 +42,7 @@ class SeekDBVectorDatabase(VectorDatabase):
def __init__(self, ap: app.Application): def __init__(self, ap: app.Application):
if not SEEKDB_AVAILABLE: if not SEEKDB_AVAILABLE:
raise ImportError( raise ImportError('pyseekdb is not installed. Install it with: pip install pyseekdb')
"SeekDB support is not installed. Install LangBot with the 'seekdb' extra: "
"uv sync --extra seekdb (source) or uvx --from 'langbot[seekdb]@latest' langbot (PyPI)."
)
self.ap = ap self.ap = ap
config = self.ap.instance_config.data['vdb']['seekdb'] config = self.ap.instance_config.data['vdb']['seekdb']
@@ -240,7 +240,7 @@ class InvitationDeliveryService:
@staticmethod @staticmethod
def _plain_text(workspace_name: str, invitation_link: str) -> str: def _plain_text(workspace_name: str, invitation_link: str) -> str:
return ( return (
'You have been invited to join a Workspace in LangBot\n\n' 'You have been invited to LangBot Cloud\n\n'
f'Join the Workspace “{workspace_name}” to collaborate with your team.\n\n' f'Join the Workspace “{workspace_name}” to collaborate with your team.\n\n'
f'Accept invitation: {invitation_link}\n\n' f'Accept invitation: {invitation_link}\n\n'
'This secure invitation expires in 7 days and can only be accepted by the email address ' 'This secure invitation expires in 7 days and can only be accepted by the email address '
@@ -258,77 +258,30 @@ class InvitationDeliveryService:
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <title>Join {escaped_workspace} on LangBot Cloud</title>
<title>Join {escaped_workspace} in LangBot</title>
</head> </head>
<body style="margin:0;padding:0;background:#f4f7fb;color:#111827;font-family:Arial,'Helvetica Neue',sans-serif;"> <body style="margin:0;background:#f4f7fb;color:#152033;font-family:Inter,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;">
<div style="display:none;max-height:0;overflow:hidden;opacity:0;">You have been invited to join {escaped_workspace} in LangBot.</div> <div style="display:none;max-height:0;overflow:hidden;opacity:0;">You have been invited to join {escaped_workspace} on LangBot Cloud.</div>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="width:100%;background:#f4f7fb;"> <table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#f4f7fb;padding:40px 16px;">
<tr> <tr><td align="center">
<td align="center" style="padding:48px 16px;"> <table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:600px;background:#ffffff;border:1px solid #e5eaf2;border-radius:16px;overflow:hidden;box-shadow:0 12px 32px rgba(20,49,93,.08);">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="width:100%;max-width:600px;"> <tr><td style="padding:28px 36px;background:linear-gradient(135deg,#0f172a,#1d4ed8);color:#ffffff;">
<tr> <div style="font-size:14px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;opacity:.78;">LangBot Cloud</div>
<td style="padding:0 4px 20px;"> <div style="font-size:26px;font-weight:700;margin-top:8px;line-height:1.25;">Youre invited</div>
<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;"> </td></tr>
<span style="display:inline-block;margin-left:10px;vertical-align:middle;font-size:18px;font-weight:700;letter-spacing:-.01em;">LangBot</span> <tr><td style="padding:36px;">
</td> <p style="margin:0 0 18px;font-size:16px;line-height:1.65;color:#475569;">You have been invited to collaborate in this Workspace:</p>
</tr> <div style="margin:0 0 26px;padding:18px 20px;background:#f8fafc;border:1px solid #e2e8f0;border-radius:12px;font-size:18px;font-weight:700;color:#0f172a;">{escaped_workspace}</div>
<tr> <table role="presentation" cellspacing="0" cellpadding="0"><tr><td style="border-radius:9px;background:#2563eb;">
<td style="background:#ffffff;border-radius:10px;overflow:hidden;"> <a href="{escaped_link}" style="display:inline-block;padding:13px 22px;color:#ffffff;text-decoration:none;font-size:15px;font-weight:700;">Accept invitation</a>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0"> </td></tr></table>
<tr> <p style="margin:26px 0 8px;font-size:14px;line-height:1.6;color:#64748b;">This invitation expires in 7 days and is bound to the email address that received it.</p>
<td style="padding:42px 42px 38px;"> <p style="margin:0 0 8px;font-size:13px;line-height:1.6;color:#94a3b8;">If the button does not work, copy and paste this URL into your browser:</p>
<div style="margin:0 0 12px;font-size:13px;line-height:1.4;font-weight:600;color:#5f6f84;">Workspace invitation</div> <p style="margin:0;padding:12px;background:#f8fafc;border-radius:8px;word-break:break-all;font-size:12px;line-height:1.55;color:#475569;">{escaped_link}</p>
<h1 style="margin:0 0 16px;font-size:28px;line-height:1.25;font-weight:700;letter-spacing:-.025em;color:#111827;">Youre invited to collaborate</h1> </td></tr>
<p style="margin:0 0 28px;font-size:15px;line-height:1.7;color:#526173;">Join your team in LangBot and start building together in this Workspace.</p> <tr><td style="padding:20px 36px;border-top:1px solid #eef2f7;font-size:12px;line-height:1.6;color:#94a3b8;">If you were not expecting this invitation, you can safely ignore this email.</td></tr>
</table>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background:#f6f8fb;border-radius:8px;"> </td></tr>
<tr>
<td style="padding:16px 18px;">
<div style="margin:0 0 4px;font-size:11px;line-height:1.4;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:#5f6f84;">Workspace</div>
<div style="font-size:18px;line-height:1.4;font-weight:700;color:#111827;">{escaped_workspace}</div>
</td>
</tr>
</table>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
<tr><td height="28" style="height:28px;font-size:0;line-height:0;">&nbsp;</td></tr>
</table>
<table role="presentation" cellspacing="0" cellpadding="0" border="0">
<tr>
<td style="background:#2563eb;border-radius:8px;">
<a href="{escaped_link}" target="_blank" style="display:inline-block;padding:13px 22px;font-size:15px;line-height:1.2;font-weight:700;color:#ffffff;text-decoration:none;border-radius:8px;">Accept invitation</a>
</td>
</tr>
</table>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
<tr><td height="32" style="height:32px;font-size:0;line-height:0;">&nbsp;</td></tr>
</table>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="border-top:1px solid #e8edf4;">
<tr>
<td style="padding-top:22px;">
<p style="margin:0 0 10px;font-size:13px;line-height:1.6;color:#5f6f84;">For your security, this invitation expires in 7 days and only works for the email address that received it.</p>
<a href="{escaped_link}" target="_blank" style="font-size:13px;line-height:1.6;font-weight:600;color:#2563eb;text-decoration:none;">Open invitation link&nbsp;&rarr;</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td align="center" style="padding:20px 24px 0;font-size:12px;line-height:1.6;color:#5f6f84;">
Sent by LangBot<br>
If you were not expecting this invitation, you can safely ignore this email.
</td>
</tr>
</table>
</td>
</tr>
</table> </table>
</body> </body>
</html>''' </html>'''
+1 -9
View File
@@ -181,11 +181,6 @@ vdb:
host: localhost host: localhost
port: 6333 port: 6333
api_key: '' api_key: ''
# SeekDB is optional. Native/package installs need the `seekdb` extra:
# `uv sync --extra seekdb` (source) or
# `uvx --from 'langbot[seekdb]@latest' langbot` (PyPI).
# The official Docker image already includes it.
# Embedded-mode platform support depends on the native pylibseekdb wheels.
seekdb: seekdb:
mode: embedded # 'embedded' or 'server' mode: embedded # 'embedded' or 'server'
# Embedded mode options: # Embedded mode options:
@@ -331,10 +326,7 @@ box:
# skill tool, skill add/edit, and stdio-mode MCP servers. Skills can still # 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. # be listed read-only and http/sse MCP servers continue to work.
enabled: true enabled: true
# 'host' runs commands directly as the Box Runtime user without sandbox backend: 'local' # 'local' (Docker/nsjail), 'docker', 'nsjail', or 'e2b'. Can be written via BOX__BACKEND.
# 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: runtime:
# LANGBOT_BOX_CONTROL_TOKEN is optional for OSS external WebSocket # LANGBOT_BOX_CONTROL_TOKEN is optional for OSS external WebSocket
# runtimes. To protect an exposed endpoint, set the same strong secret # runtimes. To protect an exposed endpoint, set the same strong secret
@@ -80,8 +80,7 @@
"header-name": "", "header-name": "",
"header-value": "", "header-value": "",
"timeout": 120, "timeout": 120,
"output-key": "response", "output-key": "response"
"response-handling": "reply"
}, },
"langflow-api": { "langflow-api": {
"base-url": "http://localhost:7860", "base-url": "http://localhost:7860",
+3 -24
View File
@@ -7,9 +7,6 @@
// Read config from script tag data attributes // Read config from script tag data attributes
var scriptEl = document.currentScript; var scriptEl = document.currentScript;
var scriptTitle = scriptEl ? scriptEl.getAttribute("data-title") : null; var scriptTitle = scriptEl ? scriptEl.getAttribute("data-title") : null;
var scriptTestNotice = scriptEl
? scriptEl.getAttribute("data-test-notice")
: null;
// ========== i18n ========== // ========== i18n ==========
var I18N = { var I18N = {
@@ -195,7 +192,6 @@
.lb-header-btn { background: none; border: none; color: #fff; cursor: pointer; padding: 4px; border-radius: 6px; display: flex; align-items: center; justify-content: center; opacity: 0.8; transition: opacity 0.15s; }\ .lb-header-btn { background: none; border: none; color: #fff; cursor: pointer; padding: 4px; border-radius: 6px; display: flex; align-items: center; justify-content: center; opacity: 0.8; transition: opacity 0.15s; }\
.lb-header-btn:hover { opacity: 1; }\ .lb-header-btn:hover { opacity: 1; }\
.lb-header-btn svg { width: 18px; height: 18px; fill: currentColor; }\ .lb-header-btn svg { width: 18px; height: 18px; fill: currentColor; }\
.lb-test-notice { padding: 8px 16px; border-bottom: 1px solid #fde68a; background: #fffbeb; color: #92400e; font-size: 12px; line-height: 1.5; text-align: center; flex-shrink: 0; }\
.lb-messages { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 16px; scroll-behavior: smooth; }\ .lb-messages { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 16px; scroll-behavior: smooth; }\
.lb-messages::-webkit-scrollbar { width: 6px; }\ .lb-messages::-webkit-scrollbar { width: 6px; }\
.lb-messages::-webkit-scrollbar-track { background: transparent; }\ .lb-messages::-webkit-scrollbar-track { background: transparent; }\
@@ -642,10 +638,9 @@
.replace(/\s+/g, " ") .replace(/\s+/g, " ")
.trim(); .trim();
if ( if (
prevContent && prevContent === content ||
(prevContent === content || prevContent.indexOf(content) >= 0 ||
prevContent.indexOf(content) >= 0 || content.indexOf(prevContent) >= 0
content.indexOf(prevContent) >= 0)
) )
return; return;
} }
@@ -1245,14 +1240,6 @@
// Root container // Root container
var root = document.createElement("div"); var root = document.createElement("div");
root.id = "langbot-widget-root"; root.id = "langbot-widget-root";
root.langbotDestroy = function () {
wsDisconnect();
if (state.historyReloadTimer) {
clearTimeout(state.historyReloadTimer);
state.historyReloadTimer = null;
}
root.remove();
};
document.body.appendChild(root); document.body.appendChild(root);
var shadow = root.attachShadow({ mode: "open" }); var shadow = root.attachShadow({ mode: "open" });
@@ -1341,14 +1328,6 @@
header.appendChild(headerActions); header.appendChild(headerActions);
panel.appendChild(header); panel.appendChild(header);
if (scriptTestNotice) {
var testNotice = document.createElement("div");
testNotice.className = "lb-test-notice";
testNotice.setAttribute("role", "note");
testNotice.textContent = scriptTestNotice;
panel.appendChild(testNotice);
}
// Messages area // Messages area
var messages = document.createElement("div"); var messages = document.createElement("div");
messages.className = "lb-messages"; messages.className = "lb-messages";
@@ -325,7 +325,7 @@ stages:
zh_Hans: API 密钥 zh_Hans: API 密钥
type: string type: string
required: true required: true
default: '' default: 'your-api-key'
- name: n8n-service-api - name: n8n-service-api
label: label:
en_US: n8n Workflow API en_US: n8n Workflow API
@@ -475,25 +475,6 @@ stages:
type: string type: string
required: false required: false
default: 'response' 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 - name: coze-api
label: label:
en_US: coze API en_US: coze API
+2 -24
View File
@@ -242,22 +242,6 @@ class TestMonitoringSessionsEndpoint:
assert response.status_code == 200 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') @pytest.mark.usefixtures('mock_circular_import_chain')
class TestMonitoringErrorsEndpoint: class TestMonitoringErrorsEndpoint:
@@ -294,19 +278,13 @@ class TestMonitoringDetailsEndpoints:
"""Tests for detail endpoints.""" """Tests for detail endpoints."""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_session_analysis(self, quart_test_client, fake_monitoring_app): async def test_get_session_analysis(self, quart_test_client):
"""GET /api/v1/monitoring/sessions/{id}/analysis.""" """GET /api/v1/monitoring/sessions/{id}/analysis."""
response = await quart_test_client.get( response = await quart_test_client.get(
'/api/v1/monitoring/sessions/sess-1/analysis' '/api/v1/monitoring/sessions/sess-1/analysis', headers={'Authorization': 'Bearer test_token'}
'?startTime=2026-08-31T16%3A00%3A00.000Z'
'&endTime=2026-09-01T15%3A59%3A59.999Z',
headers={'Authorization': 'Bearer test_token'},
) )
assert response.status_code == 200 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 @pytest.mark.asyncio
async def test_get_message_details(self, quart_test_client): async def test_get_message_details(self, quart_test_client):
-16
View File
@@ -254,22 +254,6 @@ class TestPipelinesCRUDEndpoints:
assert data['code'] == 0 assert data['code'] == 0
assert 'uuid' in data['data'] assert 'uuid' in data['data']
@pytest.mark.asyncio
async def test_create_default_pipeline_forwards_default_flag(self, quart_test_client, fake_pipeline_app):
"""POST /api/v1/pipelines explicitly creates a default pipeline."""
fake_pipeline_app.pipeline_service.create_pipeline.reset_mock()
response = await quart_test_client.post(
'/api/v1/pipelines',
headers={'Authorization': 'Bearer test_token'},
json={'name': 'Default Pipeline', 'config': {}, 'is_default': True},
)
assert response.status_code == 200
call = fake_pipeline_app.pipeline_service.create_pipeline.await_args
assert call.kwargs == {'default': True}
assert call.args[1]['is_default'] is True
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_update_pipeline_success(self, quart_test_client): async def test_update_pipeline_success(self, quart_test_client):
"""PUT /api/v1/pipelines/{uuid} updates pipeline.""" """PUT /api/v1/pipelines/{uuid} updates pipeline."""

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