mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-08 02:27:14 +00:00
Compare commits
52 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bc32eb3ca0 | |||
| ec63978ecf | |||
| 1cfe87186c | |||
| 9794df0933 | |||
| a63808caa6 | |||
| de3c0b00ad | |||
| cb45807b12 | |||
| d942bfe19a | |||
| b44b8f474d | |||
| ab52684a01 | |||
| d50957fc4f | |||
| c8d8b1aac4 | |||
| 018dd7a363 | |||
| 7b7d3f04e8 | |||
| 601c6975ea | |||
| 5ca30133a3 | |||
| 5c49cb60e3 | |||
| 8cf0015502 | |||
| bf8d418ad4 | |||
| 7aab0cee07 | |||
| 1b7ae791b3 | |||
| e69a80f5e9 | |||
| bafdaf0033 | |||
| aeff8d7e30 | |||
| be3734ffda | |||
| 855ae2bdba | |||
| b66db86bff | |||
| 95b8736e93 | |||
| cabde423a1 | |||
| 08307790e5 | |||
| 777fe1f20b | |||
| f0ee57c1e0 | |||
| a45e27e76e | |||
| 536fcdf29f | |||
| c87548c0b9 | |||
| 79634772da | |||
| bb366779af | |||
| 1336f47cb4 | |||
| 962366c507 | |||
| 23875b240f | |||
| e699358a5a | |||
| 14277d129c | |||
| 6bf1546df2 | |||
| 0bec72a3f9 | |||
| f36542135a | |||
| 693c59b726 | |||
| c3fe312a43 | |||
| c4bad508d2 | |||
| 3d4a726cd8 | |||
| e934f08adf | |||
| 7803d56254 | |||
| 54c96a18e1 |
@@ -1,5 +1,5 @@
|
|||||||
name: 漏洞反馈
|
name: 漏洞反馈
|
||||||
description: 【供中文用户】报错或漏洞请使用这个模板创建,不使用此模板创建的异常、漏洞相关issue将被直接关闭。由于自己操作不当/不甚了解所用技术栈引起的网络连接问题恕无法解决,请勿提 issue。容器间网络连接问题,参考文档 https://link.langbot.app/zh/docs/network
|
description: 【供中文用户】报错或漏洞请使用这个模板创建,不使用此模板创建的异常、漏洞相关issue将被直接关闭。由于自己操作不当/不甚了解所用技术栈引起的网络连接问题恕无法解决,请勿提 issue。容器间网络连接问题,参考文档 https://langbot.app/docs/zh/workshop/network-details
|
||||||
title: "[Bug]: "
|
title: "[Bug]: "
|
||||||
labels: ["bug?"]
|
labels: ["bug?"]
|
||||||
body:
|
body:
|
||||||
@@ -22,7 +22,7 @@ body:
|
|||||||
- type: textarea
|
- type: textarea
|
||||||
attributes:
|
attributes:
|
||||||
label: 异常情况
|
label: 异常情况
|
||||||
description: 完整描述异常情况,什么时候发生的、发生了什么。**请附带日志信息。**
|
description: 完整描述异常情况,什么时候发生的、发生了什么。**请附带日志信息。**
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: textarea
|
- type: textarea
|
||||||
|
|||||||
@@ -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://link.langbot.app/en/docs/network
|
description: Report bugs or vulnerabilities using this template. For container network connection issues, refer to the documentation https://langbot.app/docs/en/workshop/network-details
|
||||||
title: "[Bug]: "
|
title: "[Bug]: "
|
||||||
labels: ["bug?"]
|
labels: ["bug?"]
|
||||||
body:
|
body:
|
||||||
|
|||||||
@@ -7,23 +7,42 @@ 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@v2
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Generate Tag
|
- name: Set up Docker Buildx
|
||||||
id: generate_tag
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Generate image metadata
|
||||||
|
id: image
|
||||||
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
# 获取分支名称,把/替换为-
|
set -euo pipefail
|
||||||
echo ${{ github.ref }} | sed 's/refs\/heads\///g' | sed 's/\//-/g'
|
branch_tag="${GITHUB_REF#refs/heads/}"
|
||||||
echo ::set-output name=tag::$(echo ${{ github.ref }} | sed 's/refs\/heads\///g' | sed 's/\//-/g')
|
branch_tag="${branch_tag//\//-}"
|
||||||
- name: Login to Registry
|
echo "branch_tag=${branch_tag}" >> "$GITHUB_OUTPUT"
|
||||||
run: docker login --username=${{ secrets.DOCKER_USERNAME }} --password ${{ secrets.DOCKER_PASSWORD }}
|
echo "sha_tag=sha-${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
|
||||||
- name: Build Docker Image
|
|
||||||
run: |
|
- name: Login to Docker Hub
|
||||||
docker buildx create --name mybuilder --use
|
uses: docker/login-action@v3
|
||||||
docker build -t rockchin/langbot:${{ steps.generate_tag.outputs.tag }} . --push
|
with:
|
||||||
|
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 }}
|
||||||
|
|||||||
@@ -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://docs.langbot.app/zh/develop/dev-config.
|
- Dev environment guide: https://langbot.app/docs/zh/develop/dev-config.
|
||||||
- Plugin runtime / CLI / SDK debugging: https://docs.langbot.app/zh/develop/plugin-runtime.
|
- Plugin runtime / CLI / SDK debugging: https://langbot.app/docs/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.
|
||||||
|
|||||||
+1
-1
@@ -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 \
|
&& uv sync --extra seekdb \
|
||||||
&& 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
|
||||||
|
|||||||
@@ -19,9 +19,9 @@ English / [简体中文](README_CN.md) / [繁體中文](README_TW.md) / [日本
|
|||||||
[](https://github.com/langbot-app/LangBot/stargazers)
|
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||||
|
|
||||||
<a href="https://langbot.app">Website</a> |
|
<a href="https://langbot.app">Website</a> |
|
||||||
<a href="https://link.langbot.app/en/docs/features">Features</a> |
|
<a href="https://langbot.app/docs/en/insight/features">Features</a> |
|
||||||
<a href="https://link.langbot.app/en/docs/guide">Docs</a> |
|
<a href="https://langbot.app/docs/en/insight/guide">Docs</a> |
|
||||||
<a href="https://link.langbot.app/en/docs/api">API</a> |
|
<a href="https://langbot.app/docs/en/tags/readme">API</a> |
|
||||||
<a href="https://space.langbot.app/cloud">Cloud</a> |
|
<a href="https://space.langbot.app/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://link.langbot.app/en/docs/features)
|
[→ Learn more about all features](https://langbot.app/docs/en/insight/features)
|
||||||
|
|
||||||
📍 Practical guides: [deploy a multi-platform AI bot in 5 minutes](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [connect DeepSeek to WeChat, Discord, and Telegram](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [run a Dify Agent in Discord, Telegram, and Slack](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/), and [build an n8n-powered chatbot](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
|
📍 Practical guides: [deploy a multi-platform AI bot in 5 minutes](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [connect DeepSeek to WeChat, Discord, and Telegram](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [run a Dify Agent in Discord, Telegram, and Slack](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/), and [build an n8n-powered chatbot](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
|
||||||
|
|
||||||
@@ -89,7 +89,7 @@ docker compose --profile all up -d
|
|||||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||||
|
|
||||||
**More options:** [Docker](https://link.langbot.app/en/docs/docker) · [Manual](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes)
|
**More options:** [Docker](https://langbot.app/docs/en/deploy/langbot/docker) · [Manual](https://langbot.app/docs/en/deploy/langbot/manual) · [BTPanel](https://langbot.app/docs/en/deploy/langbot/one-click/bt) · [Kubernetes](https://langbot.app/docs/en/deploy/langbot/kubernetes)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -151,7 +151,7 @@ _Note: Public demo environment. Do not enter sensitive information._
|
|||||||
| [302.AI](https://share.302ai.cn/SuTG99) | Gateway | ✅ |
|
| [302.AI](https://share.302ai.cn/SuTG99) | Gateway | ✅ |
|
||||||
| [Qiniu](https://www.qiniu.com/ai/agent) | Gateway | ✅ |
|
| [Qiniu](https://www.qiniu.com/ai/agent) | Gateway | ✅ |
|
||||||
|
|
||||||
[→ View all integrations](https://link.langbot.app/en/docs/features)
|
[→ View all integrations](https://langbot.app/docs/en/insight/features)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+6
-6
@@ -21,9 +21,9 @@
|
|||||||
[](https://gitcode.com/RockChinQ/LangBot)
|
[](https://gitcode.com/RockChinQ/LangBot)
|
||||||
|
|
||||||
<a href="https://langbot.app">官网</a> |
|
<a href="https://langbot.app">官网</a> |
|
||||||
<a href="https://link.langbot.app/zh/docs/features">特性</a> |
|
<a href="https://langbot.app/docs/zh/insight/features">特性</a> |
|
||||||
<a href="https://link.langbot.app/zh/docs/guide">文档</a> |
|
<a href="https://langbot.app/docs/zh/insight/guide">文档</a> |
|
||||||
<a href="https://link.langbot.app/zh/docs/api">API</a> |
|
<a href="https://langbot.app/docs/zh/tags/readme">API</a> |
|
||||||
<a href="https://space.langbot.app/cloud">Cloud</a> |
|
<a href="https://space.langbot.app/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://link.langbot.app/zh/docs/features)
|
[→ 了解更多功能特性](https://langbot.app/docs/zh/insight/features)
|
||||||
|
|
||||||
📍 实践指南:[5 分钟部署多平台 AI 机器人](https://langbot.app/zh/blog/deploy-ai-bot-in-5-minutes/)、[将 DeepSeek 接入微信、企业微信与 Discord](https://langbot.app/zh/blog/connect-deepseek-to-wechat/)、[让 Dify Agent 跑在 Discord、Telegram 和 Slack 上](https://langbot.app/zh/blog/dify-agent-discord-telegram-slack/),以及[用 n8n 构建多平台 AI 聊天机器人](https://langbot.app/zh/blog/n8n-multi-platform-ai-chatbot/)。
|
📍 实践指南:[5 分钟部署多平台 AI 机器人](https://langbot.app/zh/blog/deploy-ai-bot-in-5-minutes/)、[将 DeepSeek 接入微信、企业微信与 Discord](https://langbot.app/zh/blog/connect-deepseek-to-wechat/)、[让 Dify Agent 跑在 Discord、Telegram 和 Slack 上](https://langbot.app/zh/blog/dify-agent-discord-telegram-slack/),以及[用 n8n 构建多平台 AI 聊天机器人](https://langbot.app/zh/blog/n8n-multi-platform-ai-chatbot/)。
|
||||||
|
|
||||||
@@ -89,7 +89,7 @@ docker compose --profile all up -d
|
|||||||
[](https://zeabur.com/zh-CN/templates/ZKTBDH)
|
[](https://zeabur.com/zh-CN/templates/ZKTBDH)
|
||||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||||
|
|
||||||
**更多方式:** [Docker](https://link.langbot.app/zh/docs/docker) · [手动部署](https://link.langbot.app/zh/docs/manual-deploy) · [宝塔面板](https://link.langbot.app/zh/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/zh/deploy/langbot/kubernetes)
|
**更多方式:** [Docker](https://langbot.app/docs/zh/deploy/langbot/docker) · [手动部署](https://langbot.app/docs/zh/deploy/langbot/manual) · [宝塔面板](https://langbot.app/docs/zh/deploy/langbot/one-click/bt) · [Kubernetes](https://langbot.app/docs/zh/deploy/langbot/kubernetes)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -152,7 +152,7 @@ docker compose --profile all up -d
|
|||||||
| [百宝箱Tbox](https://www.tbox.cn/open) | 智能体平台 | ✅ |
|
| [百宝箱Tbox](https://www.tbox.cn/open) | 智能体平台 | ✅ |
|
||||||
| [七牛云Qiniu](https://www.qiniu.com/ai/agent) | 聚合平台 | ✅ |
|
| [七牛云Qiniu](https://www.qiniu.com/ai/agent) | 聚合平台 | ✅ |
|
||||||
|
|
||||||
[→ 查看完整集成列表](https://link.langbot.app/zh/docs/features)
|
[→ 查看完整集成列表](https://langbot.app/docs/zh/insight/features)
|
||||||
|
|
||||||
### TTS(语音合成)
|
### TTS(语音合成)
|
||||||
|
|
||||||
|
|||||||
+6
-6
@@ -19,9 +19,9 @@
|
|||||||
[](https://github.com/langbot-app/LangBot/stargazers)
|
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||||
|
|
||||||
<a href="https://langbot.app">Inicio</a> |
|
<a href="https://langbot.app">Inicio</a> |
|
||||||
<a href="https://link.langbot.app/en/docs/features">Características</a> |
|
<a href="https://langbot.app/docs/en/insight/features">Características</a> |
|
||||||
<a href="https://link.langbot.app/en/docs/guide">Documentación</a> |
|
<a href="https://langbot.app/docs/en/insight/guide">Documentación</a> |
|
||||||
<a href="https://link.langbot.app/en/docs/api">API</a> |
|
<a href="https://langbot.app/docs/en/tags/readme">API</a> |
|
||||||
<a href="https://space.langbot.app">Mercado de Plugins</a> |
|
<a href="https://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://link.langbot.app/en/docs/features)
|
[→ Conocer más sobre todas las funcionalidades](https://langbot.app/docs/en/insight/features)
|
||||||
|
|
||||||
📍 Guías prácticas: [desplegar un bot de IA multiplataforma en 5 minutos](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [conectar DeepSeek a WeChat, Discord y Telegram](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [ejecutar un Dify Agent en Discord, Telegram y Slack](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/) y [crear un chatbot con n8n](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
|
📍 Guías prácticas: [desplegar un bot de IA multiplataforma en 5 minutos](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [conectar DeepSeek a WeChat, Discord y Telegram](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [ejecutar un Dify Agent en Discord, Telegram y Slack](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/) y [crear un chatbot con n8n](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
|
||||||
|
|
||||||
@@ -88,7 +88,7 @@ docker compose --profile all up -d
|
|||||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||||
|
|
||||||
**Más opciones:** [Docker](https://link.langbot.app/en/docs/docker) · [Manual](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes)
|
**Más opciones:** [Docker](https://langbot.app/docs/en/deploy/langbot/docker) · [Manual](https://langbot.app/docs/en/deploy/langbot/manual) · [BTPanel](https://langbot.app/docs/en/deploy/langbot/one-click/bt) · [Kubernetes](https://langbot.app/docs/en/deploy/langbot/kubernetes)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -149,7 +149,7 @@ docker compose --profile all up -d
|
|||||||
| [302.AI](https://share.302ai.cn/SuTG99) | Pasarela | ✅ |
|
| [302.AI](https://share.302ai.cn/SuTG99) | Pasarela | ✅ |
|
||||||
| [Qiniu](https://www.qiniu.com/ai/agent) | Pasarela | ✅ |
|
| [Qiniu](https://www.qiniu.com/ai/agent) | Pasarela | ✅ |
|
||||||
|
|
||||||
[→ Ver todas las integraciones](https://link.langbot.app/en/docs/features)
|
[→ Ver todas las integraciones](https://langbot.app/docs/en/insight/features)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+6
-6
@@ -19,9 +19,9 @@
|
|||||||
[](https://github.com/langbot-app/LangBot/stargazers)
|
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||||
|
|
||||||
<a href="https://langbot.app">Accueil</a> |
|
<a href="https://langbot.app">Accueil</a> |
|
||||||
<a href="https://link.langbot.app/en/docs/features">Fonctionnalités</a> |
|
<a href="https://langbot.app/docs/en/insight/features">Fonctionnalités</a> |
|
||||||
<a href="https://link.langbot.app/en/docs/guide">Documentation</a> |
|
<a href="https://langbot.app/docs/en/insight/guide">Documentation</a> |
|
||||||
<a href="https://link.langbot.app/en/docs/api">API</a> |
|
<a href="https://langbot.app/docs/en/tags/readme">API</a> |
|
||||||
<a href="https://space.langbot.app">Marché des Plugins</a> |
|
<a href="https://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://link.langbot.app/en/docs/features)
|
[→ En savoir plus sur toutes les fonctionnalités](https://langbot.app/docs/en/insight/features)
|
||||||
|
|
||||||
📍 Guides pratiques : [déployer un bot IA multiplateforme en 5 minutes](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [connecter DeepSeek à WeChat, Discord et Telegram](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [exécuter un Dify Agent dans Discord, Telegram et Slack](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/) et [créer un chatbot avec n8n](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
|
📍 Guides pratiques : [déployer un bot IA multiplateforme en 5 minutes](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [connecter DeepSeek à WeChat, Discord et Telegram](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [exécuter un Dify Agent dans Discord, Telegram et Slack](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/) et [créer un chatbot avec n8n](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
|
||||||
|
|
||||||
@@ -88,7 +88,7 @@ docker compose --profile all up -d
|
|||||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||||
|
|
||||||
**Plus d'options :** [Docker](https://link.langbot.app/en/docs/docker) · [Manuel](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes)
|
**Plus d'options :** [Docker](https://langbot.app/docs/en/deploy/langbot/docker) · [Manuel](https://langbot.app/docs/en/deploy/langbot/manual) · [BTPanel](https://langbot.app/docs/en/deploy/langbot/one-click/bt) · [Kubernetes](https://langbot.app/docs/en/deploy/langbot/kubernetes)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -149,7 +149,7 @@ docker compose --profile all up -d
|
|||||||
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Plateforme GPU | ✅ |
|
| [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://link.langbot.app/en/docs/features)
|
[→ Voir toutes les intégrations](https://langbot.app/docs/en/insight/features)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+6
-6
@@ -19,9 +19,9 @@
|
|||||||
[](https://github.com/langbot-app/LangBot/stargazers)
|
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||||
|
|
||||||
<a href="https://langbot.app">ホーム</a> |
|
<a href="https://langbot.app">ホーム</a> |
|
||||||
<a href="https://link.langbot.app/ja/docs/features">機能</a> |
|
<a href="https://langbot.app/docs/ja/insight/features">機能</a> |
|
||||||
<a href="https://link.langbot.app/ja/docs/guide">ドキュメント</a> |
|
<a href="https://langbot.app/docs/ja/insight/guide">ドキュメント</a> |
|
||||||
<a href="https://link.langbot.app/ja/docs/api">API</a> |
|
<a href="https://langbot.app/docs/ja/tags/readme">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://link.langbot.app/ja/docs/features)
|
[→ すべての機能について詳しく見る](https://langbot.app/docs/ja/insight/features)
|
||||||
|
|
||||||
📍 実践ガイド: [5分でマルチプラットフォームAIボットをデプロイ](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/)、[DeepSeekをWeChat・Discord・Telegramに接続](https://langbot.app/en/blog/connect-deepseek-to-wechat/)、[Dify AgentをDiscord・Telegram・Slackで動かす](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/)、[n8n連携チャットボットを構築](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/)。
|
📍 実践ガイド: [5分でマルチプラットフォームAIボットをデプロイ](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/)、[DeepSeekをWeChat・Discord・Telegramに接続](https://langbot.app/en/blog/connect-deepseek-to-wechat/)、[Dify AgentをDiscord・Telegram・Slackで動かす](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/)、[n8n連携チャットボットを構築](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/)。
|
||||||
|
|
||||||
@@ -88,7 +88,7 @@ docker compose --profile all up -d
|
|||||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||||
|
|
||||||
**その他:** [Docker](https://link.langbot.app/en/docs/docker) · [手動デプロイ](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes)
|
**その他:** [Docker](https://langbot.app/docs/en/deploy/langbot/docker) · [手動デプロイ](https://langbot.app/docs/en/deploy/langbot/manual) · [BTPanel](https://langbot.app/docs/en/deploy/langbot/one-click/bt) · [Kubernetes](https://langbot.app/docs/en/deploy/langbot/kubernetes)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -149,7 +149,7 @@ docker compose --profile all up -d
|
|||||||
| [302.AI](https://share.302ai.cn/SuTG99) | ゲートウェイ | ✅ |
|
| [302.AI](https://share.302ai.cn/SuTG99) | ゲートウェイ | ✅ |
|
||||||
| [Qiniu](https://www.qiniu.com/ai/agent) | ゲートウェイ | ✅ |
|
| [Qiniu](https://www.qiniu.com/ai/agent) | ゲートウェイ | ✅ |
|
||||||
|
|
||||||
[→ すべての統合を表示](https://link.langbot.app/en/docs/features)
|
[→ すべての統合を表示](https://langbot.app/docs/en/insight/features)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+6
-6
@@ -19,9 +19,9 @@
|
|||||||
[](https://github.com/langbot-app/LangBot/stargazers)
|
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||||
|
|
||||||
<a href="https://langbot.app">홈</a> |
|
<a href="https://langbot.app">홈</a> |
|
||||||
<a href="https://link.langbot.app/en/docs/features">기능</a> |
|
<a href="https://langbot.app/docs/en/insight/features">기능</a> |
|
||||||
<a href="https://link.langbot.app/en/docs/guide">문서</a> |
|
<a href="https://langbot.app/docs/en/insight/guide">문서</a> |
|
||||||
<a href="https://link.langbot.app/en/docs/api">API</a> |
|
<a href="https://langbot.app/docs/en/tags/readme">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://link.langbot.app/en/docs/features)
|
[→ 모든 기능 자세히 보기](https://langbot.app/docs/en/insight/features)
|
||||||
|
|
||||||
📍 실전 가이드: [5분 만에 멀티 플랫폼 AI 봇 배포하기](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [DeepSeek를 WeChat, Discord, Telegram에 연결하기](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [Dify Agent를 Discord, Telegram, Slack에서 실행하기](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/), [n8n 기반 챗봇 만들기](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
|
📍 실전 가이드: [5분 만에 멀티 플랫폼 AI 봇 배포하기](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [DeepSeek를 WeChat, Discord, Telegram에 연결하기](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [Dify Agent를 Discord, Telegram, Slack에서 실행하기](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/), [n8n 기반 챗봇 만들기](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
|
||||||
|
|
||||||
@@ -88,7 +88,7 @@ docker compose --profile all up -d
|
|||||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||||
|
|
||||||
**더 많은 옵션:** [Docker](https://link.langbot.app/en/docs/docker) · [수동 배포](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes)
|
**더 많은 옵션:** [Docker](https://langbot.app/docs/en/deploy/langbot/docker) · [수동 배포](https://langbot.app/docs/en/deploy/langbot/manual) · [BTPanel](https://langbot.app/docs/en/deploy/langbot/one-click/bt) · [Kubernetes](https://langbot.app/docs/en/deploy/langbot/kubernetes)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -149,7 +149,7 @@ docker compose --profile all up -d
|
|||||||
| [302.AI](https://share.302ai.cn/SuTG99) | 게이트웨이 | ✅ |
|
| [302.AI](https://share.302ai.cn/SuTG99) | 게이트웨이 | ✅ |
|
||||||
| [Qiniu](https://www.qiniu.com/ai/agent) | 게이트웨이 | ✅ |
|
| [Qiniu](https://www.qiniu.com/ai/agent) | 게이트웨이 | ✅ |
|
||||||
|
|
||||||
[→ 모든 통합 보기](https://link.langbot.app/en/docs/features)
|
[→ 모든 통합 보기](https://langbot.app/docs/en/insight/features)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+6
-6
@@ -19,9 +19,9 @@
|
|||||||
[](https://github.com/langbot-app/LangBot/stargazers)
|
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||||
|
|
||||||
<a href="https://langbot.app">Главная</a> |
|
<a href="https://langbot.app">Главная</a> |
|
||||||
<a href="https://link.langbot.app/en/docs/features">Возможности</a> |
|
<a href="https://langbot.app/docs/en/insight/features">Возможности</a> |
|
||||||
<a href="https://link.langbot.app/en/docs/guide">Документация</a> |
|
<a href="https://langbot.app/docs/en/insight/guide">Документация</a> |
|
||||||
<a href="https://link.langbot.app/en/docs/api">API</a> |
|
<a href="https://langbot.app/docs/en/tags/readme">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://link.langbot.app/en/docs/features)
|
[→ Подробнее обо всех возможностях](https://langbot.app/docs/en/insight/features)
|
||||||
|
|
||||||
📍 Практические руководства: [развернуть мультиплатформенного ИИ-бота за 5 минут](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [подключить DeepSeek к WeChat, Discord и Telegram](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [запустить Dify Agent в Discord, Telegram и Slack](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/) и [создать чат-бота на n8n](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
|
📍 Практические руководства: [развернуть мультиплатформенного ИИ-бота за 5 минут](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [подключить DeepSeek к WeChat, Discord и Telegram](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [запустить Dify Agent в Discord, Telegram и Slack](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/) и [создать чат-бота на n8n](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
|
||||||
|
|
||||||
@@ -88,7 +88,7 @@ docker compose --profile all up -d
|
|||||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||||
|
|
||||||
**Другие варианты:** [Docker](https://link.langbot.app/en/docs/docker) · [Ручная установка](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes)
|
**Другие варианты:** [Docker](https://langbot.app/docs/en/deploy/langbot/docker) · [Ручная установка](https://langbot.app/docs/en/deploy/langbot/manual) · [BTPanel](https://langbot.app/docs/en/deploy/langbot/one-click/bt) · [Kubernetes](https://langbot.app/docs/en/deploy/langbot/kubernetes)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -149,7 +149,7 @@ docker compose --profile all up -d
|
|||||||
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Платформа GPU | ✅ |
|
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Платформа GPU | ✅ |
|
||||||
| [Qiniu](https://www.qiniu.com/ai/agent) | Шлюз | ✅ |
|
| [Qiniu](https://www.qiniu.com/ai/agent) | Шлюз | ✅ |
|
||||||
|
|
||||||
[→ Смотреть все интеграции](https://link.langbot.app/en/docs/features)
|
[→ Смотреть все интеграции](https://langbot.app/docs/en/insight/features)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+6
-6
@@ -21,9 +21,9 @@
|
|||||||
[](https://gitcode.com/RockChinQ/LangBot)
|
[](https://gitcode.com/RockChinQ/LangBot)
|
||||||
|
|
||||||
<a href="https://langbot.app">官網</a> |
|
<a href="https://langbot.app">官網</a> |
|
||||||
<a href="https://link.langbot.app/zh/docs/features">特性</a> |
|
<a href="https://langbot.app/docs/zh/insight/features">特性</a> |
|
||||||
<a href="https://link.langbot.app/zh/docs/guide">文件</a> |
|
<a href="https://langbot.app/docs/zh/insight/guide">文件</a> |
|
||||||
<a href="https://link.langbot.app/zh/docs/api">API</a> |
|
<a href="https://langbot.app/docs/zh/tags/readme">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://link.langbot.app/zh/docs/features)
|
[→ 了解更多功能特性](https://langbot.app/docs/zh/insight/features)
|
||||||
|
|
||||||
📍 實踐指南:[5 分鐘部署多平台 AI 機器人](https://langbot.app/zh/blog/deploy-ai-bot-in-5-minutes/)、[將 DeepSeek 接入微信、企業微信與 Discord](https://langbot.app/zh/blog/connect-deepseek-to-wechat/)、[讓 Dify Agent 跑在 Discord、Telegram 和 Slack 上](https://langbot.app/zh/blog/dify-agent-discord-telegram-slack/),以及[用 n8n 建構多平台 AI 聊天機器人](https://langbot.app/zh/blog/n8n-multi-platform-ai-chatbot/)。
|
📍 實踐指南:[5 分鐘部署多平台 AI 機器人](https://langbot.app/zh/blog/deploy-ai-bot-in-5-minutes/)、[將 DeepSeek 接入微信、企業微信與 Discord](https://langbot.app/zh/blog/connect-deepseek-to-wechat/)、[讓 Dify Agent 跑在 Discord、Telegram 和 Slack 上](https://langbot.app/zh/blog/dify-agent-discord-telegram-slack/),以及[用 n8n 建構多平台 AI 聊天機器人](https://langbot.app/zh/blog/n8n-multi-platform-ai-chatbot/)。
|
||||||
|
|
||||||
@@ -90,7 +90,7 @@ docker compose --profile all up -d
|
|||||||
[](https://zeabur.com/zh-CN/templates/ZKTBDH)
|
[](https://zeabur.com/zh-CN/templates/ZKTBDH)
|
||||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||||
|
|
||||||
**更多方式:** [Docker](https://link.langbot.app/zh/docs/docker) · [手動部署](https://link.langbot.app/zh/docs/manual-deploy) · [寶塔面板](https://link.langbot.app/zh/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/zh/deploy/langbot/kubernetes)
|
**更多方式:** [Docker](https://langbot.app/docs/zh/deploy/langbot/docker) · [手動部署](https://langbot.app/docs/zh/deploy/langbot/manual) · [寶塔面板](https://langbot.app/docs/zh/deploy/langbot/one-click/bt) · [Kubernetes](https://langbot.app/docs/zh/deploy/langbot/kubernetes)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -165,7 +165,7 @@ docker compose --profile all up -d
|
|||||||
|-----------|------|
|
|-----------|------|
|
||||||
| 阿里雲百煉 | [外掛](https://github.com/Thetail001/LangBot_BailianTextToImagePlugin) |
|
| 阿里雲百煉 | [外掛](https://github.com/Thetail001/LangBot_BailianTextToImagePlugin) |
|
||||||
|
|
||||||
[→ 查看完整整合列表](https://link.langbot.app/zh/docs/features)
|
[→ 查看完整整合列表](https://langbot.app/docs/zh/insight/features)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+6
-6
@@ -19,9 +19,9 @@
|
|||||||
[](https://github.com/langbot-app/LangBot/stargazers)
|
[](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://link.langbot.app/en/docs/features">Tính năng</a> |
|
<a href="https://langbot.app/docs/en/insight/features">Tính năng</a> |
|
||||||
<a href="https://link.langbot.app/en/docs/guide">Tài liệu</a> |
|
<a href="https://langbot.app/docs/en/insight/guide">Tài liệu</a> |
|
||||||
<a href="https://link.langbot.app/en/docs/api">API</a> |
|
<a href="https://langbot.app/docs/en/tags/readme">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://link.langbot.app/en/docs/features)
|
[→ Tìm hiểu thêm về tất cả tính năng](https://langbot.app/docs/en/insight/features)
|
||||||
|
|
||||||
📍 Hướng dẫn thực hành: [triển khai bot AI đa nền tảng trong 5 phút](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [kết nối DeepSeek với WeChat, Discord và Telegram](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [chạy Dify Agent trên Discord, Telegram và Slack](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/) và [xây dựng chatbot với n8n](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
|
📍 Hướng dẫn thực hành: [triển khai bot AI đa nền tảng trong 5 phút](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [kết nối DeepSeek với WeChat, Discord và Telegram](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [chạy Dify Agent trên Discord, Telegram và Slack](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/) và [xây dựng chatbot với n8n](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
|
||||||
|
|
||||||
@@ -88,7 +88,7 @@ docker compose --profile all up -d
|
|||||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||||
|
|
||||||
**Thêm tùy chọn:** [Docker](https://link.langbot.app/en/docs/docker) · [Thủ công](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes)
|
**Thêm tùy chọn:** [Docker](https://langbot.app/docs/en/deploy/langbot/docker) · [Thủ công](https://langbot.app/docs/en/deploy/langbot/manual) · [BTPanel](https://langbot.app/docs/en/deploy/langbot/one-click/bt) · [Kubernetes](https://langbot.app/docs/en/deploy/langbot/kubernetes)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -149,7 +149,7 @@ docker compose --profile all up -d
|
|||||||
| [302.AI](https://share.302ai.cn/SuTG99) | Cổng | ✅ |
|
| [302.AI](https://share.302ai.cn/SuTG99) | Cổng | ✅ |
|
||||||
| [Qiniu](https://www.qiniu.com/ai/agent) | Cổng | ✅ |
|
| [Qiniu](https://www.qiniu.com/ai/agent) | Cổng | ✅ |
|
||||||
|
|
||||||
[→ Xem tất cả tích hợp](https://link.langbot.app/en/docs/features)
|
[→ Xem tất cả tích hợp](https://langbot.app/docs/en/insight/features)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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://docs.langbot.app
|
# For Kubernetes deployment, see kubernetes.yaml and the deployment guide at https://langbot.app/docs
|
||||||
version: "3"
|
version: "3"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
|||||||
@@ -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://docs.langbot.app -> Installation -> Kubernetes
|
# Full deployment guide (zh/en/ja): https://langbot.app/docs -> Installation -> Kubernetes
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# kubectl -n langbot create secret generic langbot-plugin-runtime-control \
|
# kubectl -n langbot create secret generic langbot-plugin-runtime-control \
|
||||||
|
|||||||
@@ -88,6 +88,23 @@ 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
|
||||||
|
|||||||
@@ -218,8 +218,8 @@ metadata:
|
|||||||
spec:
|
spec:
|
||||||
categories: [popular, global]
|
categories: [popular, global]
|
||||||
help_links:
|
help_links:
|
||||||
zh: https://docs.langbot.app/zh/platforms/http-bot
|
zh: https://langbot.app/docs/zh/platforms/http-bot
|
||||||
en: https://docs.langbot.app/en/platforms/http-bot
|
en: https://langbot.app/docs/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: "" }
|
||||||
|
|||||||
@@ -10,6 +10,19 @@ 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:
|
||||||
@@ -20,6 +33,10 @@ 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:
|
||||||
@@ -101,7 +118,7 @@ uvx langbot
|
|||||||
|
|
||||||
## System Requirements
|
## System Requirements
|
||||||
|
|
||||||
- Python 3.10.1 or higher
|
- Python 3.11 or higher (lower than Python 4)
|
||||||
- Operating System: Linux, macOS, or Windows
|
- Operating System: Linux, macOS, or Windows
|
||||||
|
|
||||||
## Differences from Source Installation
|
## Differences from Source Installation
|
||||||
|
|||||||
+35
-44
@@ -16,12 +16,20 @@ This document describes how to use OceanBase SeekDB as the vector database backe
|
|||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
SeekDB support is automatically included when you install LangBot. The required dependency `pyseekdb` is listed in `pyproject.toml`.
|
SeekDB is an optional LangBot feature. A normal LangBot installation uses
|
||||||
|
Chroma by default and does not install `pyseekdb` or its native bindings.
|
||||||
|
|
||||||
If you need to install it manually:
|
Choose the command that matches how you run LangBot:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install pyseekdb
|
# PyPI / uvx
|
||||||
|
uvx --from 'langbot[seekdb]@latest' langbot
|
||||||
|
|
||||||
|
# Installed package
|
||||||
|
pip install 'langbot[seekdb]'
|
||||||
|
|
||||||
|
# Source checkout
|
||||||
|
uv sync --extra seekdb
|
||||||
```
|
```
|
||||||
|
|
||||||
## ⚠️ Platform Compatibility
|
## ⚠️ Platform Compatibility
|
||||||
@@ -30,31 +38,36 @@ pip install pyseekdb
|
|||||||
|
|
||||||
| Platform | Status | Notes |
|
| Platform | Status | Notes |
|
||||||
|----------|--------|-------|
|
|----------|--------|-------|
|
||||||
| Linux | ✅ Supported | Full embedded mode support via `pylibseekdb` |
|
| Linux x86_64 / ARM64 | ✅ Supported | Full embedded mode support via `pylibseekdb` |
|
||||||
| macOS | ❌ Not Supported | `pylibseekdb` is Linux-only; use server mode instead |
|
| macOS 15+ on Apple Silicon | ✅ Supported | Requires the macOS ARM64 `pylibseekdb` wheel |
|
||||||
| Windows | ❌ 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) |
|
||||||
|
| 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 the `pylibseekdb` library, which is only available on Linux. If you're on macOS or Windows, you must use server mode.
|
**Important**: Embedded mode requires a compatible `pylibseekdb` wheel. Do not
|
||||||
|
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 | ⚠️ Known Issue | Docker container initialization failure - [See Issue #36](https://github.com/oceanbase/seekdb/issues/36) |
|
| 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) |
|
||||||
| Windows | ⚠️ Untested | Should work but not yet tested |
|
| Windows | ⚠️ Depends on the container runtime | Use a Linux container and follow the upstream image documentation |
|
||||||
|
|
||||||
**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 |
|
||||||
|----------|--------|-------|
|
|----------|--------|-------|
|
||||||
| All Platforms | ✅ Supported | Connect to SeekDB running on a remote Linux server |
|
| Linux | ✅ Supported | Install the `seekdb` extra and connect to the remote 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 |
|
||||||
|
|
||||||
**Recommendation for macOS/Windows users**: Deploy SeekDB on a Linux server and connect via server mode configuration.
|
Remote server mode does not use embedded storage at runtime. However, whether
|
||||||
|
the Python client can be installed still depends on `pyseekdb`'s package
|
||||||
|
metadata for the current platform.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
@@ -170,22 +183,23 @@ Key methods:
|
|||||||
|
|
||||||
### Import Error
|
### Import Error
|
||||||
|
|
||||||
If you see: `ImportError: pyseekdb is not installed`
|
If you see: `SeekDB support is not installed`
|
||||||
|
|
||||||
Solution:
|
Solution:
|
||||||
```bash
|
```bash
|
||||||
pip install pyseekdb
|
uv sync --extra seekdb
|
||||||
|
# or: uvx --from 'langbot[seekdb]@latest' langbot
|
||||||
```
|
```
|
||||||
|
|
||||||
### Embedded Mode Error on macOS/Windows
|
### Embedded Mode Is Unavailable on the Current Platform
|
||||||
|
|
||||||
**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**: `pylibseekdb` is only available on Linux platforms.
|
**Cause**: No compatible `pylibseekdb` wheel is installed for the current OS,
|
||||||
|
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
|
||||||
@@ -208,29 +222,6 @@ 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:
|
||||||
@@ -252,7 +243,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://docs.langbot.app
|
- LangBot Documentation: https://langbot.app/docs
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -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://docs.langbot.app/en/usage/platforms/http-bot).
|
Full guide: [docs.langbot.app — HTTP Bot](https://langbot.app/docs/en/usage/platforms/http-bot).
|
||||||
Machine-readable contract: [`docs/http-bot-openapi.json`](../../docs/http-bot-openapi.json).
|
Machine-readable contract: [`docs/http-bot-openapi.json`](../../docs/http-bot-openapi.json).
|
||||||
|
|
||||||
## Files
|
## Files
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
它们完整展示了整条链路:对请求签名、推送一条消息、在回调端点接收
|
它们完整展示了整条链路:对请求签名、推送一条消息、在回调端点接收
|
||||||
1→M 的多段回复。
|
1→M 的多段回复。
|
||||||
|
|
||||||
完整指南:[docs.langbot.app —— HTTP Bot](https://docs.langbot.app/zh/usage/platforms/http-bot)。
|
完整指南:[docs.langbot.app —— HTTP Bot](https://langbot.app/docs/zh/usage/platforms/http-bot)。
|
||||||
机器可读的接口契约:[`docs/http-bot-openapi.json`](../../docs/http-bot-openapi.json)。
|
机器可读的接口契约:[`docs/http-bot-openapi.json`](../../docs/http-bot-openapi.json)。
|
||||||
|
|
||||||
## 文件清单
|
## 文件清单
|
||||||
|
|||||||
@@ -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://docs.langbot.app/en/usage/platforms/webpage).
|
Full guide: [docs.langbot.app — Page Bot](https://langbot.app/docs/en/usage/platforms/webpage).
|
||||||
|
|
||||||
## Files
|
## Files
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
(`web_page_bot`) 的可嵌入聊天组件 —— 也就是你用一行 `<script>` 标签就能放到任意
|
(`web_page_bot`) 的可嵌入聊天组件 —— 也就是你用一行 `<script>` 标签就能放到任意
|
||||||
网站上的那个组件。
|
网站上的那个组件。
|
||||||
|
|
||||||
完整指南:[docs.langbot.app —— 页面机器人](https://docs.langbot.app/zh/usage/platforms/webpage)。
|
完整指南:[docs.langbot.app —— 页面机器人](https://langbot.app/docs/zh/usage/platforms/webpage)。
|
||||||
|
|
||||||
## 文件清单
|
## 文件清单
|
||||||
|
|
||||||
|
|||||||
+8
-4
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "langbot"
|
name = "langbot"
|
||||||
version = "4.10.7"
|
version = "4.10.10"
|
||||||
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,8 +70,7 @@ 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)",
|
||||||
"pyseekdb==1.1.0.post3",
|
"langbot-plugin==0.5.7",
|
||||||
"langbot-plugin==0.5.5",
|
|
||||||
"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",
|
||||||
@@ -108,9 +107,14 @@ 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://docs.langbot.app"
|
Documentation = "https://langbot.app/docs"
|
||||||
Repository = "https://github.com/langbot-app/LangBot"
|
Repository = "https://github.com/langbot-app/LangBot"
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
|||||||
@@ -1349,7 +1349,8 @@
|
|||||||
"local-agent",
|
"local-agent",
|
||||||
"tools",
|
"tools",
|
||||||
"e2b",
|
"e2b",
|
||||||
"nsjail"
|
"nsjail",
|
||||||
|
"host"
|
||||||
],
|
],
|
||||||
"automation": "",
|
"automation": "",
|
||||||
"setup_automation": [],
|
"setup_automation": [],
|
||||||
|
|||||||
@@ -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://docs.langbot.app. `docker/deploy-k8s-test.sh` is a test helper.
|
https://langbot.app/docs. `docker/deploy-k8s-test.sh` is a test helper.
|
||||||
|
|
||||||
## config.yaml (generated at `data/config.yaml` on first run)
|
## 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`; env override `BOX__BACKEND` |
|
| `box.backend` | `local` (Docker/nsjail autopick) / `docker` / `nsjail` / `e2b` / explicit unsafe `host`; env override `BOX__BACKEND` |
|
||||||
| `box.runtime.endpoint` | External Box runtime URL (e.g. `ws://127.0.0.1:5410`); empty = local auto-managed |
|
| `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,6 +75,10 @@ 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
|
||||||
|
|
||||||
@@ -93,5 +97,7 @@ login session. See `langbot-mcp-ops` for using it, and `docs/API_KEY_AUTH.md`.
|
|||||||
- "No supported sandbox backend (Docker / nsjail / E2B)" with Docker running
|
- "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.
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ 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
|
||||||
@@ -75,6 +77,8 @@ 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,6 +13,7 @@ tags:
|
|||||||
- tools
|
- tools
|
||||||
- e2b
|
- e2b
|
||||||
- nsjail
|
- nsjail
|
||||||
|
- host
|
||||||
skills:
|
skills:
|
||||||
- langbot-env-setup
|
- langbot-env-setup
|
||||||
- langbot-testing
|
- langbot-testing
|
||||||
@@ -23,7 +24,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 sandbox backend intended for this run, such as e2b or nsjail."
|
- "LangBot is started with the Box backend intended for this run, such as e2b, nsjail, or explicit host development mode."
|
||||||
- "The selected model route supports tool/function calling strongly enough to invoke sandbox tools."
|
- "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."
|
||||||
@@ -33,7 +34,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 or nsjail."
|
- "Logs: The selected backend name is the expected one, such as e2b, nsjail, or host."
|
||||||
- "Skill store: The registered package and activated writeback match references/sandbox-skill-authoring.md."
|
- "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, and E2B backends. API calls are useful diagnostics, but the primary pass/fail signal is the model-driven Debug Chat tool sequence.
|
This flow applies to Docker, nsjail, E2B, and the explicit host development backend. Host runs commands directly as the Box Runtime user and must never be treated as sandbox-isolation coverage. API calls are useful diagnostics, but the primary pass/fail signal is the model-driven Debug Chat tool sequence.
|
||||||
|
|
||||||
## Preconditions
|
## Preconditions
|
||||||
|
|
||||||
@@ -13,6 +13,7 @@ This flow applies to Docker, nsjail, and E2B backends. API calls are useful diag
|
|||||||
- `BOX_BACKEND=e2b` when validating E2B.
|
- `BOX_BACKEND=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.
|
||||||
@@ -71,7 +72,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`, or the expected local backend
|
- `backend=e2b`, `backend=nsjail`, `backend=host`, or the expected local backend
|
||||||
|
|
||||||
After the run, verify the skill store through the UI or API:
|
After the run, verify the skill store through the UI or API:
|
||||||
|
|
||||||
@@ -125,6 +126,8 @@ For E2B raw HTTP diagnostics, include a valid template id such as `base`; a miss
|
|||||||
- Session metadata should keep LangBot logical paths such as `/workspace`; storing provider-internal paths can make later requests look incompatible.
|
- 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, or Docker is configured."
|
- "The Box runtime later reports that E2B, nsjail, Docker, or explicit host mode is configured."
|
||||||
- "Debug Chat does not expose exec, register_skill, or activate as usable tools."
|
- "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,6 +19,7 @@ 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
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ asciiart = r"""
|
|||||||
|___/
|
|___/
|
||||||
|
|
||||||
⭐️ Open Source 开源地址: https://github.com/langbot-app/LangBot
|
⭐️ Open Source 开源地址: https://github.com/langbot-app/LangBot
|
||||||
📖 Documentation 文档地址: https://docs.langbot.app
|
📖 Documentation 文档地址: https://langbot.app/docs
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import httpx
|
|
||||||
import typing
|
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
|
import typing
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
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
|
||||||
@@ -15,6 +16,32 @@ _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,
|
||||||
*,
|
*,
|
||||||
@@ -56,16 +83,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 = json.loads(line[5:].decode('utf-8', errors='replace'))
|
payload = _decode_sse_data(line)
|
||||||
if isinstance(payload, dict):
|
if payload is not None:
|
||||||
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 = json.loads(line[5:].decode('utf-8', errors='replace'))
|
payload = _decode_sse_data(line)
|
||||||
if isinstance(payload, dict):
|
if payload is not None:
|
||||||
yield payload
|
yield payload
|
||||||
|
|
||||||
|
|
||||||
@@ -242,7 +269,7 @@ class AsyncDifyServiceClient:
|
|||||||
file: httpx._types.FileTypes,
|
file: httpx._types.FileTypes,
|
||||||
user: str,
|
user: str,
|
||||||
timeout: float = 30.0,
|
timeout: float = 30.0,
|
||||||
) -> str:
|
) -> dict[str, typing.Any]:
|
||||||
# 处理 Path 对象
|
# 处理 Path 对象
|
||||||
if isinstance(file, Path):
|
if isinstance(file, Path):
|
||||||
if not file.exists():
|
if not file.exists():
|
||||||
@@ -271,6 +298,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 != 201:
|
if response.status_code not in (200, 201):
|
||||||
raise DifyAPIError(f'{response.status_code} {body.decode(errors="replace")}')
|
raise DifyAPIError(f'{response.status_code} {body.decode(errors="replace")}')
|
||||||
return json.loads(body)
|
return _decode_upload_response(body)
|
||||||
|
|||||||
@@ -697,9 +697,10 @@ 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()
|
||||||
|
|
||||||
cardData: dict = {'cardParamMap': _stringify_card_param_map(card_param_map)}
|
template_params = dict(card_param_map or {})
|
||||||
if card_data_config is not None:
|
if card_data_config is not None:
|
||||||
cardData['config'] = json.dumps(card_data_config)
|
template_params['config'] = card_data_config
|
||||||
|
cardData: dict = {'cardParamMap': _stringify_card_param_map(template_params)}
|
||||||
|
|
||||||
body: dict = {
|
body: dict = {
|
||||||
'cardTemplateId': card_template_id,
|
'cardTemplateId': card_template_id,
|
||||||
|
|||||||
@@ -422,6 +422,69 @@ 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,6 +46,14 @@ 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
|
||||||
@@ -495,6 +503,145 @@ 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.
|
||||||
|
|
||||||
@@ -789,6 +936,13 @@ 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,6 +295,34 @@ 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,6 +218,7 @@ 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))
|
||||||
@@ -237,6 +238,7 @@ 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,
|
||||||
@@ -396,7 +398,14 @@ 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"""
|
||||||
analysis = await self.ap.monitoring_service.get_session_analysis(request_context, session_id)
|
start_time = parse_iso_datetime(quart.request.args.get('startTime'))
|
||||||
|
end_time = parse_iso_datetime(quart.request.args.get('endTime'))
|
||||||
|
analysis = await self.ap.monitoring_service.get_session_analysis(
|
||||||
|
request_context,
|
||||||
|
session_id,
|
||||||
|
start_time=start_time,
|
||||||
|
end_time=end_time,
|
||||||
|
)
|
||||||
|
|
||||||
# Always return success with the analysis data
|
# Always return success with the analysis data
|
||||||
# The frontend will handle the 'found: false' case
|
# The frontend will handle the 'found: false' case
|
||||||
|
|||||||
@@ -39,7 +39,13 @@ 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_uuid = await self.ap.pipeline_service.create_pipeline(request_context, await quart.request.json)
|
pipeline_data = 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,6 +113,24 @@ 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'],
|
||||||
|
|||||||
@@ -15,6 +15,17 @@ from .....workspace.invitation_delivery import InvitationDeliveryService
|
|||||||
@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('/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
|
||||||
@@ -206,6 +217,20 @@ 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,6 +186,9 @@ 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')
|
||||||
|
|
||||||
@@ -199,8 +202,11 @@ 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
|
||||||
@@ -218,24 +224,36 @@ 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')
|
||||||
|
|
||||||
# Authenticate and create/update local user
|
cloud_mode = getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud'
|
||||||
|
if cloud_mode and launch_workspace_uuid and launch_workspace_uuid != cloud_workspace_uuid:
|
||||||
|
return self.fail(1, 'Space OAuth Workspace binding mismatch')
|
||||||
|
target_workspace_uuid = launch_workspace_uuid or cloud_workspace_uuid
|
||||||
|
if cloud_mode:
|
||||||
|
if not target_workspace_uuid:
|
||||||
|
return self.fail(1, 'Space OAuth response is missing the Cloud Workspace binding')
|
||||||
|
await self.ap.directory_projection_service.reconcile_workspaces((target_workspace_uuid,))
|
||||||
|
|
||||||
|
# Authenticate only after the signed, exact Workspace delta has
|
||||||
|
# established the Account and membership runtime shadow rows.
|
||||||
jwt_token, user_obj = await self.ap.user_service.authenticate_space_user(
|
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 launch_workspace_uuid:
|
if target_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,
|
||||||
launch_workspace_uuid,
|
target_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')
|
||||||
@@ -322,6 +340,7 @@ 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)
|
||||||
@@ -366,12 +385,17 @@ 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')
|
||||||
@@ -384,7 +408,10 @@ 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:
|
||||||
updated_user = await self.ap.user_service.bind_space_account(user_obj.user, code)
|
redirect_uri = self._validate_space_redirect_uri(str(redirect_uri), bind=True)
|
||||||
|
updated_user = await self.ap.user_service.bind_space_account(
|
||||||
|
user_obj.user, code, redirect_uri=redirect_uri
|
||||||
|
)
|
||||||
jwt_token = await self.ap.user_service.generate_jwt_token(updated_user)
|
jwt_token = await self.ap.user_service.generate_jwt_token(updated_user)
|
||||||
return self.success(
|
return self.success(
|
||||||
data={
|
data={
|
||||||
@@ -427,6 +454,10 @@ class UserRouterGroup(group.RouterGroup):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
projection_service = self.ap.directory_projection_service
|
||||||
|
if projection_service is None:
|
||||||
|
raise SpaceLaunchError('Cloud directory projection is unavailable')
|
||||||
|
await projection_service.reconcile_workspaces((launch['workspace_uuid'],))
|
||||||
account = await self.ap.user_service.get_user_by_uuid(launch['account_uuid'])
|
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,6 +1,7 @@
|
|||||||
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
|
||||||
@@ -8,6 +9,8 @@ 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:
|
||||||
@@ -80,6 +83,7 @@ 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', '')
|
||||||
@@ -133,7 +137,16 @@ 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)
|
||||||
|
|
||||||
await self.ap.platform_mgr.load_bot(context, bot)
|
try:
|
||||||
|
await self.ap.platform_mgr.load_bot(context, bot)
|
||||||
|
except Exception:
|
||||||
|
# The bot row was already inserted above; without this rollback a
|
||||||
|
# failing adapter constructor (e.g. a missing optional credential
|
||||||
|
# key) would leave a permanently disabled orphan bot in the DB.
|
||||||
|
await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.delete(persistence_bot.Bot).where(persistence_bot.Bot.uuid == bot_data['uuid'])
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
return bot_data['uuid']
|
return bot_data['uuid']
|
||||||
|
|
||||||
@@ -216,6 +229,53 @@ 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,
|
||||||
|
|||||||
@@ -1257,6 +1257,7 @@ 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,
|
||||||
@@ -1274,6 +1275,14 @@ 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)
|
||||||
|
|
||||||
@@ -1365,6 +1374,8 @@ 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)
|
||||||
@@ -1478,12 +1489,17 @@ 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(
|
.where(*tool_conditions)
|
||||||
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)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ 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
|
||||||
@@ -116,7 +119,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}
|
params = {'redirect_uri': redirect_uri, 'code_contract': 'redirect-v1'}
|
||||||
if state:
|
if state:
|
||||||
params['state'] = state
|
params['state'] = state
|
||||||
return f'{authorize_url}?{urlencode(params)}'
|
return f'{authorize_url}?{urlencode(params)}'
|
||||||
@@ -126,6 +129,8 @@ 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
|
||||||
@@ -138,6 +143,7 @@ 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.
|
||||||
@@ -238,3 +244,76 @@ 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}
|
||||||
|
|||||||
@@ -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) -> user.User:
|
async def bind_space_account(self, user_email: str, code: str, *, redirect_uri: str = '') -> user.User:
|
||||||
"""Bind Space account to existing local account"""
|
"""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,12 +794,13 @@ 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)
|
token_data = await self.ap.space_service.exchange_oauth_code(code, 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)
|
||||||
|
|||||||
@@ -147,7 +147,16 @@ 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)
|
||||||
return _dump({'uuid': await ap.pipeline_service.create_pipeline(context, pipeline_data)})
|
create_as_default = pipeline_data.get('is_default') is True
|
||||||
|
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:
|
||||||
|
|||||||
@@ -455,7 +455,9 @@ 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('Box runtime is not available. Install and start Docker to use sandbox features.')
|
raise BoxError(
|
||||||
|
'Box runtime is not available. Configure an available Box backend before using Box features.'
|
||||||
|
)
|
||||||
if self._cloud_managed:
|
if self._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')
|
||||||
@@ -565,7 +567,9 @@ 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('Box runtime is not available. Install and start Docker to use sandbox features.')
|
raise BoxError(
|
||||||
|
'Box runtime is not available. Configure an available Box backend before using Box features.'
|
||||||
|
)
|
||||||
execution_context = await self._validated_execution_context(self._query_execution_context(query))
|
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)
|
||||||
@@ -1210,8 +1214,9 @@ 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).
|
||||||
|
|
||||||
Note: exec stdout is truncated by ``output_limit_chars``, so this path
|
Uses ``client.execute`` directly (bypassing ``_serialize_result``)
|
||||||
only reliably transfers small files. The host path is preferred.
|
so stdout is NOT truncated by ``output_limit_chars`` - the raw
|
||||||
|
base64 payload can be far larger than the 4000-char display limit.
|
||||||
"""
|
"""
|
||||||
import json as _json
|
import json as _json
|
||||||
|
|
||||||
@@ -1265,14 +1270,22 @@ class BoxService:
|
|||||||
' break\n'
|
' break\n'
|
||||||
'print(json.dumps(out))\n'
|
'print(json.dumps(out))\n'
|
||||||
)
|
)
|
||||||
result = await self.execute_tool(
|
spec_payload: dict = {
|
||||||
{'command': f"python3 - <<'LBPY'\n{script}\nLBPY", 'timeout_sec': 120},
|
'cmd': f"python3 - <<'LBPY'\n{script}\nLBPY",
|
||||||
query,
|
'timeout_sec': 120,
|
||||||
)
|
'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.get('stdout') or '').strip().splitlines()[-1])
|
return _json.loads(str(result.stdout or '').strip().splitlines()[-1])
|
||||||
except Exception:
|
except Exception:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
@@ -2133,5 +2146,8 @@ 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'] = 'No supported sandbox backend (Docker / nsjail / E2B) is available'
|
payload['connector_error'] = (
|
||||||
|
'No supported sandbox backend (Docker / nsjail / E2B) is available. '
|
||||||
|
'Trusted local development may explicitly select the unsafe host backend.'
|
||||||
|
)
|
||||||
return payload
|
return payload
|
||||||
|
|||||||
@@ -125,10 +125,21 @@ 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)
|
||||||
@@ -159,9 +170,84 @@ 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.initialize()
|
await self._refresh_snapshot()
|
||||||
return
|
return
|
||||||
batch = await self.provider.fetch_events(
|
batch = await self.provider.fetch_events(
|
||||||
self.instance_uuid,
|
self.instance_uuid,
|
||||||
@@ -708,7 +794,13 @@ class DirectoryProjectionService:
|
|||||||
for row in inbox_rows:
|
for row in inbox_rows:
|
||||||
row.applied_at = now
|
row.applied_at = now
|
||||||
|
|
||||||
async def _apply_accounts(self, session: Any, snapshot: DirectorySnapshot) -> dict[str, User]:
|
async def _apply_accounts(
|
||||||
|
self,
|
||||||
|
session: Any,
|
||||||
|
snapshot: DirectorySnapshot,
|
||||||
|
*,
|
||||||
|
preserve_existing: bool = False,
|
||||||
|
) -> dict[str, User]:
|
||||||
selected: dict[str, DirectoryMember] = {}
|
selected: dict[str, DirectoryMember] = {}
|
||||||
emails: dict[str, str] = {}
|
emails: dict[str, str] = {}
|
||||||
for workspace in snapshot.workspaces:
|
for workspace in snapshot.workspaces:
|
||||||
@@ -773,6 +865,12 @@ 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)
|
||||||
|
|||||||
@@ -301,11 +301,36 @@ 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 self.directory_projection_service is not None:
|
if (
|
||||||
self.task_mgr.create_task(
|
self.directory_projection_service is not None
|
||||||
|
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],
|
||||||
@@ -322,7 +347,6 @@ 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 情况下退出,这里创建一个不会结束的协程
|
||||||
@@ -348,6 +372,7 @@ 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().
|
||||||
@@ -529,6 +554,11 @@ 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)
|
||||||
@@ -605,9 +635,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://docs.langbot.app/zh')
|
self.logger.warning('WebUI 文件缺失,请根据文档部署:https://langbot.app/docs/zh')
|
||||||
self.logger.warning(
|
self.logger.warning(
|
||||||
'WebUI files are missing, please deploy according to the documentation: https://docs.langbot.app/en'
|
'WebUI files are missing, please deploy according to the documentation: https://langbot.app/docs/en'
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from .. import stage, app
|
from .. import stage, app, entities as core_entities
|
||||||
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,14 +292,17 @@ 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,3 +47,10 @@ 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
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
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
|
||||||
@@ -82,7 +83,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 _open_read_only(path) as connection:
|
with contextlib.closing(_open_read_only(path)) as connection:
|
||||||
_verify_connection(connection, expected_revision)
|
_verify_connection(connection, expected_revision)
|
||||||
|
|
||||||
|
|
||||||
@@ -119,12 +120,16 @@ def _write_manifest(backup: SQLiteMigrationBackup, status: str, **extra: typing.
|
|||||||
|
|
||||||
|
|
||||||
def _fsync_file(path: pathlib.Path, *, reopen_attempts: int = 20) -> None:
|
def _fsync_file(path: pathlib.Path, *, reopen_attempts: int = 20) -> None:
|
||||||
"""Sync a file, tolerating delayed visibility after replace on bind mounts."""
|
"""Sync a file, tolerating delayed visibility after replace on bind mounts.
|
||||||
|
|
||||||
|
Uses O_RDWR so os.fsync works on Windows (where _commit requires write
|
||||||
|
access to the file descriptor).
|
||||||
|
"""
|
||||||
|
|
||||||
descriptor: int | None = None
|
descriptor: int | None = None
|
||||||
for attempt in range(reopen_attempts):
|
for attempt in range(reopen_attempts):
|
||||||
try:
|
try:
|
||||||
descriptor = os.open(path, os.O_RDONLY)
|
descriptor = os.open(path, os.O_RDWR)
|
||||||
break
|
break
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
if attempt + 1 >= reopen_attempts:
|
if attempt + 1 >= reopen_attempts:
|
||||||
@@ -138,13 +143,37 @@ 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:
|
||||||
descriptor = os.open(path, os.O_RDONLY)
|
if os.name == 'nt':
|
||||||
|
# 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,
|
||||||
@@ -153,6 +182,11 @@ 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)}-'
|
||||||
@@ -169,11 +203,8 @@ def _create_backup(
|
|||||||
temporary_path = pathlib.Path(temporary_name)
|
temporary_path = pathlib.Path(temporary_name)
|
||||||
try:
|
try:
|
||||||
with (
|
with (
|
||||||
_open_read_only(database_path) as source,
|
contextlib.closing(_open_read_only(database_path)) as source,
|
||||||
sqlite3.connect(
|
contextlib.closing(sqlite3.connect(temporary_path, timeout=30)) as destination,
|
||||||
temporary_path,
|
|
||||||
timeout=30,
|
|
||||||
) as destination,
|
|
||||||
):
|
):
|
||||||
source.execute('PRAGMA busy_timeout = 30000')
|
source.execute('PRAGMA busy_timeout = 30000')
|
||||||
source.backup(destination)
|
source.backup(destination)
|
||||||
@@ -221,6 +252,11 @@ 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',
|
||||||
@@ -230,11 +266,8 @@ def _restore_backup(backup: SQLiteMigrationBackup) -> None:
|
|||||||
temporary_path = pathlib.Path(temporary_name)
|
temporary_path = pathlib.Path(temporary_name)
|
||||||
try:
|
try:
|
||||||
with (
|
with (
|
||||||
_open_read_only(backup.backup_path) as source,
|
contextlib.closing(_open_read_only(backup.backup_path)) as source,
|
||||||
sqlite3.connect(
|
contextlib.closing(sqlite3.connect(temporary_path, timeout=30)) as destination,
|
||||||
temporary_path,
|
|
||||||
timeout=30,
|
|
||||||
) as destination,
|
|
||||||
):
|
):
|
||||||
source.backup(destination)
|
source.backup(destination)
|
||||||
destination.commit()
|
destination.commit()
|
||||||
|
|||||||
@@ -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'})
|
_ALLOWED_SCOPED_GENERIC_FUNCTIONS = frozenset({'date_trunc', 'length', 'nullif', 'strftime'})
|
||||||
_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,6 +5,11 @@ 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):
|
||||||
@@ -14,12 +19,17 @@ 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, message = await mask_patterns(
|
found, current = await mask_patterns(
|
||||||
self.ap.sensitive_meta.data['words'],
|
words,
|
||||||
message,
|
message,
|
||||||
mask=self.ap.sensitive_meta.data['mask'],
|
mask=mask,
|
||||||
mask_word=self.ap.sensitive_meta.data['mask_word'],
|
mask_word=mask_word,
|
||||||
|
max_pattern_count=_MAX_SENSITIVE_WORD_PATTERNS,
|
||||||
)
|
)
|
||||||
except SafeRegexError as exc:
|
except SafeRegexError as exc:
|
||||||
return entities.FilterResult(
|
return entities.FilterResult(
|
||||||
@@ -31,7 +41,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=message,
|
replacement=current,
|
||||||
user_notice='消息中存在不合适的内容, 请修改' if found else '',
|
user_notice='消息中存在不合适的内容, 请修改' if found else '',
|
||||||
console_notice='',
|
console_notice='',
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -158,6 +158,18 @@ 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://link.langbot.app/zh/platforms/aiocqhttp
|
zh: https://langbot.app/docs/zh/usage/platforms/qq/aiocqhttp/napcat
|
||||||
en: https://link.langbot.app/en/platforms/aiocqhttp
|
en: https://langbot.app/docs/en/usage/platforms/qq/aiocqhttp/napcat
|
||||||
ja: https://link.langbot.app/ja/platforms/aiocqhttp
|
ja: https://langbot.app/docs/ja/usage/platforms/qq/aiocqhttp/napcat
|
||||||
config:
|
config:
|
||||||
- name: host
|
- name: host
|
||||||
label:
|
label:
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ spec:
|
|||||||
categories:
|
categories:
|
||||||
- china
|
- china
|
||||||
help_links:
|
help_links:
|
||||||
zh: https://link.langbot.app/zh/platforms/dingtalk
|
zh: https://langbot.app/docs/zh/usage/platforms/dingtalk
|
||||||
en: https://link.langbot.app/en/platforms/dingtalk
|
en: https://langbot.app/docs/en/usage/platforms/dingtalk
|
||||||
ja: https://link.langbot.app/ja/platforms/dingtalk
|
ja: https://langbot.app/docs/ja/usage/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://link.langbot.app/zh/platforms/discord
|
zh: https://langbot.app/docs/zh/usage/platforms/discord
|
||||||
en: https://link.langbot.app/en/platforms/discord
|
en: https://langbot.app/docs/en/usage/platforms/discord
|
||||||
ja: https://link.langbot.app/ja/platforms/discord
|
ja: https://langbot.app/docs/ja/usage/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://docs.langbot.app/zh/platforms/http-bot
|
zh: https://langbot.app/docs/zh/platforms/http-bot
|
||||||
en: https://docs.langbot.app/en/platforms/http-bot
|
en: https://langbot.app/docs/en/platforms/http-bot
|
||||||
ja: https://docs.langbot.app/ja/platforms/http-bot
|
ja: https://langbot.app/docs/ja/platforms/http-bot
|
||||||
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://link.langbot.app/zh/platforms/kook
|
zh: https://langbot.app/docs/zh/usage/platforms/kook
|
||||||
en: https://link.langbot.app/en/platforms/kook
|
en: https://langbot.app/docs/en/usage/platforms/kook
|
||||||
ja: https://link.langbot.app/ja/platforms/kook
|
ja: https://langbot.app/docs/ja/usage/platforms/kook
|
||||||
config:
|
config:
|
||||||
- name: token
|
- name: token
|
||||||
label:
|
label:
|
||||||
|
|||||||
@@ -160,6 +160,29 @@ def _lark_should_update_stream_element(
|
|||||||
return not resume_from and not form_data and (msg_seq % 8 == 0 or is_final)
|
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':
|
||||||
@@ -2358,16 +2381,21 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
self.card_form_input_defs[card_id] = _lark_form_input_defs(form_data)
|
self.card_form_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: keep pre-pause + resume content visible,
|
# Normal finish: remove buttons/notice and finalize the card.
|
||||||
# remove buttons/notice, drop the resume placeholder.
|
main_text, resume_text = _lark_final_layout_texts(
|
||||||
|
resume_from=resume_from,
|
||||||
|
text_message=text_message,
|
||||||
|
pre_pause_cached=self.card_pre_pause_text.get(card_id),
|
||||||
|
resume_cached=resume_cached,
|
||||||
|
)
|
||||||
await self._update_card_layout(
|
await self._update_card_layout(
|
||||||
card_id=card_id,
|
card_id=card_id,
|
||||||
message_source=message_source,
|
message_source=message_source,
|
||||||
text_message=pre_pause,
|
text_message=main_text,
|
||||||
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_cached,
|
resume_placeholder_text=resume_text,
|
||||||
)
|
)
|
||||||
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)
|
||||||
|
|||||||
@@ -19,9 +19,9 @@ spec:
|
|||||||
- china
|
- china
|
||||||
- global
|
- global
|
||||||
help_links:
|
help_links:
|
||||||
zh: https://link.langbot.app/zh/platforms/lark
|
zh: https://langbot.app/docs/zh/usage/platforms/lark
|
||||||
en: https://link.langbot.app/en/platforms/lark
|
en: https://langbot.app/docs/en/usage/platforms/lark
|
||||||
ja: https://link.langbot.app/ja/platforms/lark
|
ja: https://langbot.app/docs/ja/usage/platforms/lark
|
||||||
config:
|
config:
|
||||||
- name: domain
|
- name: domain
|
||||||
label:
|
label:
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ from linebot.v3.webhooks import (
|
|||||||
ImageMessageContent,
|
ImageMessageContent,
|
||||||
VideoMessageContent,
|
VideoMessageContent,
|
||||||
AudioMessageContent,
|
AudioMessageContent,
|
||||||
|
UserMentionee,
|
||||||
)
|
)
|
||||||
|
|
||||||
# from linebot import WebhookParser
|
# from linebot import WebhookParser
|
||||||
@@ -58,15 +59,19 @@ class LINEMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
|||||||
|
|
||||||
return content_list
|
return content_list
|
||||||
|
|
||||||
@staticmethod
|
def __init__(self, bot_account_id: str = ''):
|
||||||
async def target2yiri(message, bot_client) -> platform_message.MessageChain:
|
self.bot_account_id = bot_account_id
|
||||||
|
|
||||||
|
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.append(platform_message.Plain(text=message.message.text))
|
lb_msg_list.extend(
|
||||||
|
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):
|
||||||
@@ -86,22 +91,60 @@ 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
|
||||||
|
|
||||||
@staticmethod
|
async def target2yiri(self, event, bot_client) -> platform_events.Event:
|
||||||
async def target2yiri(event, bot_client) -> platform_events.Event:
|
message_chain = await self.message_converter.target2yiri(event, bot_client)
|
||||||
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.message.id,
|
id=event.source.user_id,
|
||||||
nickname=event.source.user_id,
|
nickname=event.source.user_id,
|
||||||
remark='',
|
remark='',
|
||||||
),
|
),
|
||||||
@@ -110,13 +153,19 @@ 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=event.event.sender.sender_id.open_id,
|
id=member_id,
|
||||||
member_name=event.event.sender.sender_id.union_id,
|
member_name=member_id,
|
||||||
permission=platform_entities.Permission.Member,
|
permission=platform_entities.Permission.Member,
|
||||||
group=platform_entities.Group(
|
group=platform_entities.Group(
|
||||||
id=event.message.id,
|
id=group_id,
|
||||||
name='',
|
name='',
|
||||||
permission=platform_entities.Permission.Member,
|
permission=platform_entities.Permission.Member,
|
||||||
),
|
),
|
||||||
@@ -163,8 +212,8 @@ class LINEAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
listeners={},
|
listeners={},
|
||||||
card_id_dict={},
|
card_id_dict={},
|
||||||
seq=1,
|
seq=1,
|
||||||
event_converter=LINEEventConverter(),
|
event_converter=LINEEventConverter(bot_account_id),
|
||||||
message_converter=LINEMessageConverter(),
|
message_converter=LINEMessageConverter(bot_account_id),
|
||||||
line_webhook=line_webhook,
|
line_webhook=line_webhook,
|
||||||
parser=parser,
|
parser=parser,
|
||||||
configuration=configuration,
|
configuration=configuration,
|
||||||
|
|||||||
@@ -22,9 +22,9 @@ spec:
|
|||||||
categories:
|
categories:
|
||||||
- global
|
- global
|
||||||
help_links:
|
help_links:
|
||||||
zh: https://link.langbot.app/zh/platforms/line
|
zh: https://langbot.app/docs/zh/usage/platforms/line
|
||||||
en: https://link.langbot.app/en/platforms/line
|
en: https://langbot.app/docs/en/usage/platforms/line
|
||||||
ja: https://link.langbot.app/ja/platforms/line
|
ja: https://langbot.app/docs/ja/usage/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://link.langbot.app/zh/platforms/officialaccount
|
zh: https://langbot.app/docs/zh/usage/platforms/wxoa
|
||||||
en: https://link.langbot.app/en/platforms/officialaccount
|
en: https://langbot.app/docs/en/usage/platforms/wxoa
|
||||||
ja: https://link.langbot.app/ja/platforms/officialaccount
|
ja: https://langbot.app/docs/ja/usage/platforms/wxoa
|
||||||
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://link.langbot.app/zh/platforms/openclaw_weixin
|
zh: https://langbot.app/docs/zh/usage/platforms/wechat/weixin
|
||||||
en: https://link.langbot.app/en/platforms/openclaw_weixin
|
en: https://langbot.app/docs/en/usage/platforms/readme
|
||||||
ja: https://link.langbot.app/ja/platforms/openclaw_weixin
|
ja: https://langbot.app/docs/ja/usage/platforms/readme
|
||||||
config:
|
config:
|
||||||
- name: base_url
|
- name: base_url
|
||||||
label:
|
label:
|
||||||
|
|||||||
@@ -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['token'],
|
token=config.get('token', ''),
|
||||||
logger=logger,
|
logger=logger,
|
||||||
unified_mode=enable_webhook,
|
unified_mode=enable_webhook,
|
||||||
)
|
)
|
||||||
@@ -329,17 +329,12 @@ 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 == 'c2c':
|
if target_type in {'c2c', 'group'}:
|
||||||
await self.bot.send_private_text_msg(
|
await self._send_c2c_or_group_text_reply(
|
||||||
|
target_type,
|
||||||
target_id,
|
target_id,
|
||||||
content['content'],
|
content['content'],
|
||||||
qq_official_event.d_id,
|
msg_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':
|
||||||
@@ -383,6 +378,39 @@ 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],
|
||||||
@@ -650,13 +678,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
|
||||||
# QQ API 的 replace 模式不允许修改已下发前缀,所以:
|
# delivered content as its prefix. `sent_length` only tells us whether
|
||||||
# - 首次:发送全部文本,建立会话
|
# a non-final snapshot has new content; it must not truncate the
|
||||||
# - 后续:只能发送新增部分(append 行为)
|
# content sent to QQ.
|
||||||
content_to_send = ctx['accumulated_text'][ctx['sent_length'] :]
|
if len(ctx['accumulated_text']) <= ctx['sent_length'] and not is_final:
|
||||||
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
|
||||||
|
|
||||||
@@ -778,20 +806,13 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
|||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if target_type == 'c2c':
|
await self._send_c2c_or_group_text_reply(
|
||||||
await self.bot.send_private_text_msg(
|
target_type,
|
||||||
user_openid=target_id,
|
target_id,
|
||||||
content=text,
|
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://link.langbot.app/zh/platforms/qqofficial
|
zh: https://langbot.app/docs/zh/usage/platforms/qq/official_webhook
|
||||||
en: https://link.langbot.app/en/platforms/qqofficial
|
en: https://langbot.app/docs/en/usage/platforms/qq/official_webhook
|
||||||
ja: https://link.langbot.app/ja/platforms/qqofficial
|
ja: https://langbot.app/docs/ja/usage/platforms/qq/official_webhook
|
||||||
config:
|
config:
|
||||||
- name: __system.outbound_ips
|
- name: __system.outbound_ips
|
||||||
label:
|
label:
|
||||||
@@ -95,6 +95,18 @@ 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
|
||||||
|
|||||||
@@ -21,9 +21,9 @@ spec:
|
|||||||
categories:
|
categories:
|
||||||
- protocol
|
- protocol
|
||||||
help_links:
|
help_links:
|
||||||
zh: https://link.langbot.app/zh/platforms/satori
|
zh: https://langbot.app/docs/zh/usage/platforms/readme
|
||||||
en: https://link.langbot.app/en/platforms/satori
|
en: https://langbot.app/docs/en/usage/platforms/readme
|
||||||
ja: https://link.langbot.app/ja/platforms/satori
|
ja: https://langbot.app/docs/ja/usage/platforms/readme
|
||||||
config:
|
config:
|
||||||
- name: platform
|
- name: platform
|
||||||
label:
|
label:
|
||||||
|
|||||||
@@ -24,9 +24,9 @@ spec:
|
|||||||
- popular
|
- popular
|
||||||
- global
|
- global
|
||||||
help_links:
|
help_links:
|
||||||
zh: https://link.langbot.app/zh/platforms/slack
|
zh: https://langbot.app/docs/zh/usage/platforms/slack
|
||||||
en: https://link.langbot.app/en/platforms/slack
|
en: https://langbot.app/docs/en/usage/platforms/slack
|
||||||
ja: https://link.langbot.app/ja/platforms/slack
|
ja: https://langbot.app/docs/ja/usage/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://link.langbot.app/zh/platforms/telegram
|
zh: https://langbot.app/docs/zh/usage/platforms/telegram
|
||||||
en: https://link.langbot.app/en/platforms/telegram
|
en: https://langbot.app/docs/en/usage/platforms/telegram
|
||||||
ja: https://link.langbot.app/ja/platforms/telegram
|
ja: https://langbot.app/docs/ja/usage/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://link.langbot.app/zh/platforms/wechatpad
|
zh: https://langbot.app/docs/zh/usage/platforms/wechat/wechatpad
|
||||||
en: https://link.langbot.app/en/platforms/wechatpad
|
en: https://langbot.app/docs/en/usage/platforms/readme
|
||||||
ja: https://link.langbot.app/ja/platforms/wechatpad
|
ja: https://langbot.app/docs/ja/usage/platforms/readme
|
||||||
config:
|
config:
|
||||||
- name: wechatpad_url
|
- name: wechatpad_url
|
||||||
label:
|
label:
|
||||||
|
|||||||
@@ -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'])
|
await self.bot.send_image(user_id, agent_id, content['media_id'])
|
||||||
if content['type'] == 'voice':
|
if content['type'] == 'voice':
|
||||||
await self.bot.send_voice(user_id, agent_id, content['media'])
|
await self.bot.send_voice(user_id, agent_id, content['media_id'])
|
||||||
if content['type'] == 'file':
|
if content['type'] == 'file':
|
||||||
await self.bot.send_file(user_id, agent_id, content['media'])
|
await self.bot.send_file(user_id, agent_id, content['media_id'])
|
||||||
|
|
||||||
def register_listener(
|
def register_listener(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ spec:
|
|||||||
- popular
|
- popular
|
||||||
- china
|
- china
|
||||||
help_links:
|
help_links:
|
||||||
zh: https://link.langbot.app/zh/platforms/wecom
|
zh: https://langbot.app/docs/zh/usage/platforms/wecom/wecom
|
||||||
en: https://link.langbot.app/en/platforms/wecom
|
en: https://langbot.app/docs/en/usage/platforms/wecom/wecom
|
||||||
ja: https://link.langbot.app/ja/platforms/wecom
|
ja: https://langbot.app/docs/ja/usage/platforms/wecom/wecom
|
||||||
config:
|
config:
|
||||||
- name: webhook_url
|
- name: webhook_url
|
||||||
label:
|
label:
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ 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
|
||||||
@@ -24,11 +26,24 @@ 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):
|
||||||
content = ''
|
"""Convert a MessageChain into a list of component dicts.
|
||||||
|
|
||||||
|
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:
|
||||||
content += msg.text
|
items.append({'type': 'text', 'text': msg.text})
|
||||||
return content
|
elif type(msg) is platform_message.Image:
|
||||||
|
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 = ''):
|
||||||
@@ -362,13 +377,76 @@ 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,
|
||||||
):
|
):
|
||||||
content = await self.message_converter.yiri2target(message)
|
items = 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
|
||||||
@@ -382,7 +460,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, content)
|
await self.bot.send_message(chat_id, text)
|
||||||
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()}'
|
||||||
@@ -396,12 +474,15 @@ 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 req_id:
|
if text:
|
||||||
await self.bot.reply_text(req_id, content)
|
if req_id:
|
||||||
else:
|
await self.bot.reply_text(req_id, text)
|
||||||
await self.bot.set_message(event.message_id, content)
|
else:
|
||||||
|
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, content)
|
await self.bot.set_message(event.message_id, text)
|
||||||
|
|
||||||
async def reply_message_chunk(
|
async def reply_message_chunk(
|
||||||
self,
|
self,
|
||||||
@@ -411,7 +492,8 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
quote_origin: bool = False,
|
quote_origin: bool = False,
|
||||||
is_final: bool = False,
|
is_final: bool = False,
|
||||||
):
|
):
|
||||||
content = await self.message_converter.yiri2target(message)
|
items = 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
|
||||||
@@ -420,7 +502,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, content, is_final, _ws_mode)
|
return await self._handle_synthetic_chunk(message_source, bot_message, text, is_final, _ws_mode)
|
||||||
|
|
||||||
msg_id = spo.message_id
|
msg_id = spo.message_id
|
||||||
|
|
||||||
@@ -452,7 +534,7 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
form_data.get('actions', []) or [],
|
form_data.get('actions', []) or [],
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
fallback = content or '(人工输入)'
|
fallback = text 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', '')
|
||||||
@@ -463,17 +545,22 @@ 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, content, is_final=is_final)
|
success = await self.bot.push_stream_chunk(msg_id, text, 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, content)
|
await self.bot.reply_text(req_id, text)
|
||||||
|
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, content, is_final=is_final)
|
success = await self.bot.push_stream_chunk(msg_id, text, is_final=is_final)
|
||||||
if not success and is_final:
|
if not success and is_final:
|
||||||
await self.bot.set_message(msg_id, content)
|
await self.bot.set_message(msg_id, text)
|
||||||
return {'stream': success}
|
return {'stream': success}
|
||||||
|
|
||||||
async def is_stream_output_supported(self) -> bool:
|
async def is_stream_output_supported(self) -> bool:
|
||||||
@@ -627,8 +714,9 @@ 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:
|
||||||
content = await self.message_converter.yiri2target(message)
|
items = await self.message_converter.yiri2target(message)
|
||||||
await self.bot.send_message(target_id, content)
|
text = self._join_text_components(items)
|
||||||
|
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://link.langbot.app/zh/platforms/wecombot
|
zh: https://langbot.app/docs/zh/usage/platforms/wecom/wecombot
|
||||||
en: https://link.langbot.app/en/platforms/wecombot
|
en: https://langbot.app/docs/en/usage/platforms/wecom/wecombot
|
||||||
ja: https://link.langbot.app/ja/platforms/wecombot
|
ja: https://langbot.app/docs/ja/usage/platforms/wecom/wecombot
|
||||||
config:
|
config:
|
||||||
- name: one-click-create
|
- name: one-click-create
|
||||||
label:
|
label:
|
||||||
|
|||||||
@@ -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'u{event.user_id}',
|
id=f'{event.receiver_id}|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'u{event.user_id}',
|
id=f'{event.receiver_id}|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'langbot_{uuid.uuid4().hex}'
|
msgid = f'{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,6 +205,13 @@ 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://link.langbot.app/zh/platforms/wecomcs
|
zh: https://langbot.app/docs/zh/usage/platforms/wecom/wecomcs
|
||||||
en: https://link.langbot.app/en/platforms/wecomcs
|
en: https://langbot.app/docs/en/usage/platforms/wecom/wecomcs
|
||||||
ja: https://link.langbot.app/ja/platforms/wecomcs
|
ja: https://langbot.app/docs/ja/usage/platforms/wecom/wecomcs
|
||||||
config:
|
config:
|
||||||
- name: webhook_url
|
- name: webhook_url
|
||||||
label:
|
label:
|
||||||
|
|||||||
@@ -701,7 +701,13 @@ 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})
|
||||||
|
|
||||||
result = await runtime_handler.reconcile_plugin_installations(tuple(self._known_desired_states.values()))
|
reconcile_timeout_seconds = max(
|
||||||
|
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)
|
||||||
|
|
||||||
@@ -736,7 +742,13 @@ 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
|
||||||
result = await runtime_handler.reconcile_plugin_installations(tuple(all_states.values()))
|
reconcile_timeout_seconds = max(
|
||||||
|
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()):
|
||||||
@@ -1901,9 +1913,14 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
|
|
||||||
return plugins
|
return plugins
|
||||||
|
|
||||||
async def get_plugin_info(self, author: str, plugin_name: str) -> dict[str, Any]:
|
async def get_plugin_info(self, author: str, plugin_name: str) -> dict[str, Any] | None:
|
||||||
runtime_handler = self._runtime_handler()
|
runtime_handler = self._runtime_handler()
|
||||||
binding = await self._target_binding(author, plugin_name)
|
try:
|
||||||
|
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)
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ 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
|
||||||
@@ -431,6 +433,19 @@ 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,
|
||||||
@@ -896,25 +911,82 @@ 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 result.first() is not None:
|
if storage 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)
|
||||||
)
|
)
|
||||||
else:
|
return handler.ActionResponse.success(data={})
|
||||||
await self.ap.persistence_mgr.execute_async(
|
|
||||||
sqlalchemy.insert(persistence_bstorage.BinaryStorage).values(
|
dialect_name = self.ap.persistence_mgr.get_db_engine().dialect.name
|
||||||
workspace_uuid=action_context.workspace_uuid,
|
insert = {
|
||||||
unique_key=unique_key,
|
'postgresql': sqlalchemy.dialects.postgresql.insert,
|
||||||
key=key,
|
'sqlite': sqlalchemy.dialects.sqlite.insert,
|
||||||
owner_type=owner_type,
|
}.get(dialect_name)
|
||||||
owner=owner,
|
if insert is None:
|
||||||
value=value,
|
return handler.ActionResponse.error(message=f'Unsupported storage database dialect: {dialect_name}')
|
||||||
)
|
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={},
|
||||||
@@ -946,6 +1018,29 @@ 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',
|
||||||
@@ -981,10 +1076,19 @@ 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 == unique_key)
|
.where(persistence_bstorage.BinaryStorage.unique_key.in_((unique_key, legacy_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(
|
||||||
@@ -1012,7 +1116,7 @@ class RuntimeConnectionHandler(handler.Handler):
|
|||||||
|
|
||||||
return handler.ActionResponse.success(
|
return handler.ActionResponse.success(
|
||||||
data={
|
data={
|
||||||
'keys': result.scalars().all(),
|
'keys': list(dict.fromkeys(result.scalars().all())),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1573,13 +1677,15 @@ 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=300,
|
timeout=timeout,
|
||||||
)
|
)
|
||||||
|
|
||||||
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 == 'ollama':
|
elif family in ('ollama', 'ollama_chat'):
|
||||||
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,9 +747,24 @@ 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':
|
||||||
part['image_url'] = {'url': part['image_base64']}
|
# History trimming (SessionManager) clears image_base64
|
||||||
part['type'] = 'image_url'
|
# on past turns and exclude_none serialization drops
|
||||||
del part['image_base64']
|
# the key entirely, so the replayed part may carry no
|
||||||
|
# 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
|
||||||
@@ -1330,7 +1345,14 @@ 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."""
|
||||||
model_name = self._build_litellm_model_name(model.model_entity.name)
|
# litellm's embedding routing has no "ollama_chat" branch (that provider
|
||||||
|
# exists only for /api/chat completions) — embeddings still go through
|
||||||
|
# the plain "ollama" provider. Requesters configured for ollama_chat
|
||||||
|
# (to get native tool-calling on the chat path) must fall back to
|
||||||
|
# "ollama" here specifically, or embedding calls raise "Unmapped LLM
|
||||||
|
# provider for this endpoint".
|
||||||
|
embedding_provider = 'ollama' if self._get_custom_llm_provider() == 'ollama_chat' else None
|
||||||
|
model_name = self._build_litellm_model_name(model.model_entity.name, embedding_provider)
|
||||||
api_key = model.provider.token_mgr.get_token()
|
api_key = model.provider.token_mgr.get_token()
|
||||||
|
|
||||||
args = {
|
args = {
|
||||||
@@ -1526,6 +1548,12 @@ 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
|
litellm_provider: ollama_chat
|
||||||
config:
|
config:
|
||||||
- name: base_url
|
- name: base_url
|
||||||
label:
|
label:
|
||||||
|
|||||||
@@ -24,7 +24,10 @@ class SeekDBEmbedding(requester.ProviderAPIRequester):
|
|||||||
try:
|
try:
|
||||||
import pyseekdb
|
import pyseekdb
|
||||||
except ImportError:
|
except ImportError:
|
||||||
raise ImportError('pyseekdb is not installed. Install it with: pip install pyseekdb')
|
raise ImportError(
|
||||||
|
"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,7 +619,9 @@ 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)
|
||||||
):
|
):
|
||||||
tool_content = func_ret
|
# OpenAI-compatible APIs require tool-message content to be a
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ 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')
|
||||||
@@ -262,7 +265,11 @@ 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 response.status != 200:
|
if self.response_handling == 'ignore':
|
||||||
|
status_ok = 200 <= response.status < 300
|
||||||
|
else:
|
||||||
|
status_ok = response.status == 200
|
||||||
|
if not status_ok:
|
||||||
error_text = (
|
error_text = (
|
||||||
await httpclient.read_limited(
|
await httpclient.read_limited(
|
||||||
response,
|
response,
|
||||||
@@ -272,6 +279,11 @@ 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,6 +222,7 @@ 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,7 +42,8 @@ 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:
|
||||||
|
|||||||
@@ -27,10 +27,16 @@ 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(patterns: Sequence[str]) -> tuple[str, ...]:
|
def _validate_patterns(
|
||||||
|
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')
|
||||||
@@ -115,8 +121,9 @@ 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)
|
normalized_patterns = _validate_patterns(patterns, max_pattern_count=max_pattern_count)
|
||||||
_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')
|
||||||
@@ -162,6 +169,7 @@ 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."""
|
||||||
|
|
||||||
@@ -174,4 +182,5 @@ 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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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://link.langbot.app/en/docs/update',
|
'New version available. Update guide: https://langbot.app/docs/en/deploy/update',
|
||||||
logging.INFO,
|
logging.INFO,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -42,7 +42,10 @@ 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('pyseekdb is not installed. Install it with: pip install pyseekdb')
|
raise ImportError(
|
||||||
|
"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 LangBot Cloud\n\n'
|
'You have been invited to join a Workspace in LangBot\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,30 +258,77 @@ 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">
|
||||||
<title>Join {escaped_workspace} on LangBot Cloud</title>
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<title>Join {escaped_workspace} in LangBot</title>
|
||||||
</head>
|
</head>
|
||||||
<body style="margin:0;background:#f4f7fb;color:#152033;font-family:Inter,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;">
|
<body style="margin:0;padding:0;background:#f4f7fb;color:#111827;font-family:Arial,'Helvetica Neue',sans-serif;">
|
||||||
<div style="display:none;max-height:0;overflow:hidden;opacity:0;">You have been invited to join {escaped_workspace} on LangBot Cloud.</div>
|
<div style="display:none;max-height:0;overflow:hidden;opacity:0;">You have been invited to join {escaped_workspace} in LangBot.</div>
|
||||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#f4f7fb;padding:40px 16px;">
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="width:100%;background:#f4f7fb;">
|
||||||
<tr><td align="center">
|
<tr>
|
||||||
<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);">
|
<td align="center" style="padding:48px 16px;">
|
||||||
<tr><td style="padding:28px 36px;background:linear-gradient(135deg,#0f172a,#1d4ed8);color:#ffffff;">
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="width:100%;max-width:600px;">
|
||||||
<div style="font-size:14px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;opacity:.78;">LangBot Cloud</div>
|
<tr>
|
||||||
<div style="font-size:26px;font-weight:700;margin-top:8px;line-height:1.25;">You’re invited</div>
|
<td style="padding:0 4px 20px;">
|
||||||
</td></tr>
|
<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;">
|
||||||
<tr><td style="padding:36px;">
|
<span style="display:inline-block;margin-left:10px;vertical-align:middle;font-size:18px;font-weight:700;letter-spacing:-.01em;">LangBot</span>
|
||||||
<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>
|
</td>
|
||||||
<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;">
|
<tr>
|
||||||
<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>
|
<td style="background:#ffffff;border-radius:10px;overflow:hidden;">
|
||||||
</td></tr></table>
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
|
||||||
<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>
|
<tr>
|
||||||
<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>
|
<td style="padding:42px 42px 38px;">
|
||||||
<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>
|
<div style="margin:0 0 12px;font-size:13px;line-height:1.4;font-weight:600;color:#5f6f84;">Workspace invitation</div>
|
||||||
</td></tr>
|
<h1 style="margin:0 0 16px;font-size:28px;line-height:1.25;font-weight:700;letter-spacing:-.025em;color:#111827;">You’re invited to collaborate</h1>
|
||||||
<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>
|
<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>
|
||||||
</table>
|
|
||||||
</td></tr>
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background:#f6f8fb;border-radius:8px;">
|
||||||
|
<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;"> </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;"> </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 →</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>'''
|
||||||
|
|||||||
@@ -181,6 +181,11 @@ 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:
|
||||||
@@ -326,7 +331,10 @@ 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
|
||||||
backend: 'local' # 'local' (Docker/nsjail), 'docker', 'nsjail', or 'e2b'. Can be written via BOX__BACKEND.
|
# 'host' runs commands directly as the Box Runtime user without sandbox
|
||||||
|
# isolation. It is never auto-selected and is only for trusted local
|
||||||
|
# development. Can be written via BOX__BACKEND.
|
||||||
|
backend: 'local' # 'local' (Docker/nsjail), 'docker', 'nsjail', 'e2b', or explicit unsafe 'host'.
|
||||||
runtime:
|
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,7 +80,8 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -7,6 +7,9 @@
|
|||||||
// 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 = {
|
||||||
@@ -192,6 +195,7 @@
|
|||||||
.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; }\
|
||||||
@@ -638,9 +642,10 @@
|
|||||||
.replace(/\s+/g, " ")
|
.replace(/\s+/g, " ")
|
||||||
.trim();
|
.trim();
|
||||||
if (
|
if (
|
||||||
prevContent === content ||
|
prevContent &&
|
||||||
prevContent.indexOf(content) >= 0 ||
|
(prevContent === content ||
|
||||||
content.indexOf(prevContent) >= 0
|
prevContent.indexOf(content) >= 0 ||
|
||||||
|
content.indexOf(prevContent) >= 0)
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1240,6 +1245,14 @@
|
|||||||
// 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" });
|
||||||
@@ -1328,6 +1341,14 @@
|
|||||||
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: 'your-api-key'
|
default: ''
|
||||||
- name: n8n-service-api
|
- name: n8n-service-api
|
||||||
label:
|
label:
|
||||||
en_US: n8n Workflow API
|
en_US: n8n Workflow API
|
||||||
@@ -475,6 +475,25 @@ 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
|
||||||
|
|||||||
@@ -242,6 +242,22 @@ 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:
|
||||||
@@ -278,13 +294,19 @@ 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):
|
async def test_get_session_analysis(self, quart_test_client, fake_monitoring_app):
|
||||||
"""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', headers={'Authorization': 'Bearer test_token'}
|
'/api/v1/monitoring/sessions/sess-1/analysis'
|
||||||
|
'?startTime=2026-08-31T16%3A00%3A00.000Z'
|
||||||
|
'&endTime=2026-09-01T15%3A59%3A59.999Z',
|
||||||
|
headers={'Authorization': 'Bearer test_token'},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
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):
|
||||||
|
|||||||
@@ -254,6 +254,22 @@ 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."""
|
||||||
|
|||||||
@@ -307,6 +307,7 @@ class TestUserInitEndpoint:
|
|||||||
assert data['data'] == {
|
assert data['data'] == {
|
||||||
'initialized': True,
|
'initialized': True,
|
||||||
'authenticated_invitation_acceptance_enabled': False,
|
'authenticated_invitation_acceptance_enabled': False,
|
||||||
|
'invitation_registration_enabled': True,
|
||||||
'password_login_enabled': True,
|
'password_login_enabled': True,
|
||||||
'space_login_enabled': False,
|
'space_login_enabled': False,
|
||||||
}
|
}
|
||||||
@@ -330,6 +331,28 @@ class TestUserInitEndpoint:
|
|||||||
assert data['data'] == {
|
assert data['data'] == {
|
||||||
'initialized': True,
|
'initialized': True,
|
||||||
'authenticated_invitation_acceptance_enabled': True,
|
'authenticated_invitation_acceptance_enabled': True,
|
||||||
|
'invitation_registration_enabled': False,
|
||||||
|
'password_login_enabled': False,
|
||||||
|
'space_login_enabled': True,
|
||||||
|
}
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_account_info_enables_local_invitation_registration_for_oauth_only_oss(
|
||||||
|
self, quart_test_client, fake_api_app
|
||||||
|
):
|
||||||
|
fake_api_app.user_service.is_initialized.return_value = True
|
||||||
|
fake_api_app.user_service.get_login_capabilities = AsyncMock(
|
||||||
|
return_value={'password_login_enabled': False, 'space_login_enabled': True}
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await quart_test_client.get('/api/v1/user/account-info')
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = await response.get_json()
|
||||||
|
assert data['data'] == {
|
||||||
|
'initialized': True,
|
||||||
|
'authenticated_invitation_acceptance_enabled': False,
|
||||||
|
'invitation_registration_enabled': True,
|
||||||
'password_login_enabled': False,
|
'password_login_enabled': False,
|
||||||
'space_login_enabled': True,
|
'space_login_enabled': True,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ async def space_oauth_api():
|
|||||||
execution=SimpleNamespace(instance_uuid='instance-a', placement_generation=1),
|
execution=SimpleNamespace(instance_uuid='instance-a', placement_generation=1),
|
||||||
)
|
)
|
||||||
application = Mock()
|
application = Mock()
|
||||||
application.deployment = SimpleNamespace(multi_workspace_enabled=False)
|
application.deployment = SimpleNamespace(multi_workspace_enabled=False, mode='oss')
|
||||||
|
application.directory_projection_service = None
|
||||||
application.persistence_mgr = None
|
application.persistence_mgr = None
|
||||||
application.user_service.get_authenticated_account = AsyncMock(return_value=account)
|
application.user_service.get_authenticated_account = AsyncMock(return_value=account)
|
||||||
application.user_service.issue_space_oauth_state = AsyncMock(
|
application.user_service.issue_space_oauth_state = AsyncMock(
|
||||||
@@ -125,6 +126,26 @@ async def test_cloud_launch_state_is_server_issued_and_workspace_bound(space_oau
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cloud_login_entry_uses_normal_stateful_oauth(space_oauth_api):
|
||||||
|
application, client = space_oauth_api
|
||||||
|
application.deployment.mode = 'cloud'
|
||||||
|
|
||||||
|
response = await client.get(
|
||||||
|
'/api/v1/user/space/authorize-url',
|
||||||
|
query_string={
|
||||||
|
'redirect_uri': 'http://localhost/auth/space/callback',
|
||||||
|
'cloud_entry': '1',
|
||||||
|
},
|
||||||
|
headers={'Origin': 'http://localhost'},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
authorize_url = (await response.get_json())['data']['authorize_url']
|
||||||
|
assert authorize_url.startswith('https://space.example/authorize?state=')
|
||||||
|
application.user_service.issue_space_oauth_state.assert_awaited_once_with('login')
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_public_login_rejects_caller_supplied_state(space_oauth_api):
|
async def test_public_login_rejects_caller_supplied_state(space_oauth_api):
|
||||||
application, client = space_oauth_api
|
application, client = space_oauth_api
|
||||||
@@ -249,10 +270,14 @@ async def test_server_side_webhook_origin_supports_bundled_ui(space_oauth_api):
|
|||||||
async def test_login_callback_requires_and_consumes_server_state(space_oauth_api):
|
async def test_login_callback_requires_and_consumes_server_state(space_oauth_api):
|
||||||
application, client = space_oauth_api
|
application, client = space_oauth_api
|
||||||
|
|
||||||
missing = await client.post('/api/v1/user/space/callback', json={'code': 'oauth-code'})
|
missing = await client.post('/api/v1/user/space/callback', json={'code': 'v4_oauth-code'})
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
'/api/v1/user/space/callback',
|
'/api/v1/user/space/callback',
|
||||||
json={'code': 'oauth-code', 'state': 'opaque-login-state'},
|
json={
|
||||||
|
'code': 'v4_oauth-code',
|
||||||
|
'state': 'opaque-login-state',
|
||||||
|
'redirect_uri': 'https://oss.example/auth/space/callback',
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert (await missing.get_json())['code'] == 1
|
assert (await missing.get_json())['code'] == 1
|
||||||
@@ -260,12 +285,146 @@ async def test_login_callback_requires_and_consumes_server_state(space_oauth_api
|
|||||||
assert (await response.get_json())['data']['token'] == 'space-login-token'
|
assert (await response.get_json())['data']['token'] == 'space-login-token'
|
||||||
application.user_service.consume_space_oauth_state_details.assert_awaited_once_with('opaque-login-state', 'login')
|
application.user_service.consume_space_oauth_state_details.assert_awaited_once_with('opaque-login-state', 'login')
|
||||||
application.space_service.exchange_oauth_code.assert_awaited_once_with(
|
application.space_service.exchange_oauth_code.assert_awaited_once_with(
|
||||||
'oauth-code',
|
'v4_oauth-code',
|
||||||
[WORKSPACE_UUID],
|
[WORKSPACE_UUID],
|
||||||
{WORKSPACE_UUID: int(WORKSPACE_CREATED_AT.timestamp())},
|
{WORKSPACE_UUID: int(WORKSPACE_CREATED_AT.timestamp())},
|
||||||
|
redirect_uri='https://oss.example/auth/space/callback',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_login_callback_rejects_downgraded_legacy_code(space_oauth_api):
|
||||||
|
application, client = space_oauth_api
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
'/api/v1/user/space/callback',
|
||||||
|
json={'code': 'v2_legacy-code', 'state': 'opaque-login-state'},
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = await response.get_json()
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert payload['code'] == 1
|
||||||
|
assert 'code contract' in payload['msg']
|
||||||
|
application.space_service.exchange_oauth_code.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cloud_login_callback_reconciles_authorized_workspace_before_local_authentication(space_oauth_api):
|
||||||
|
application, client = space_oauth_api
|
||||||
|
application.deployment.mode = 'cloud'
|
||||||
|
calls: list[str] = []
|
||||||
|
application.directory_projection_service = SimpleNamespace(
|
||||||
|
reconcile_workspaces=AsyncMock(side_effect=lambda _workspace_uuids: calls.append('reconcile'))
|
||||||
|
)
|
||||||
|
application.space_service.exchange_oauth_code.return_value = {
|
||||||
|
'access_token': 'space-access-token',
|
||||||
|
'refresh_token': 'space-refresh-token',
|
||||||
|
'expires_in': 3600,
|
||||||
|
'cloud_workspace_uuid': WORKSPACE_UUID,
|
||||||
|
}
|
||||||
|
|
||||||
|
authenticated_account = application.user_service.authenticate_space_user.return_value[1]
|
||||||
|
|
||||||
|
async def authenticate(*_args):
|
||||||
|
calls.append('authenticate')
|
||||||
|
return 'space-login-token', authenticated_account
|
||||||
|
|
||||||
|
application.user_service.authenticate_space_user.side_effect = authenticate
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
'/api/v1/user/space/callback',
|
||||||
|
json={'code': 'v4_oauth-code', 'state': 'opaque-login-state'},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert (await response.get_json())['data']['workspace_uuid'] == WORKSPACE_UUID
|
||||||
|
assert calls == ['reconcile', 'authenticate']
|
||||||
|
application.directory_projection_service.reconcile_workspaces.assert_awaited_once_with((WORKSPACE_UUID,))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cloud_login_callback_fails_closed_without_workspace_binding(space_oauth_api):
|
||||||
|
application, client = space_oauth_api
|
||||||
|
application.deployment.mode = 'cloud'
|
||||||
|
application.directory_projection_service = SimpleNamespace(reconcile_workspaces=AsyncMock())
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
'/api/v1/user/space/callback',
|
||||||
|
json={'code': 'v4_oauth-code', 'state': 'opaque-login-state'},
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = await response.get_json()
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert payload['code'] == 1
|
||||||
|
assert 'Cloud Workspace binding' in payload['msg']
|
||||||
|
application.directory_projection_service.reconcile_workspaces.assert_not_awaited()
|
||||||
|
application.user_service.authenticate_space_user.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cloud_login_callback_requires_code_binding_for_launch_state(space_oauth_api):
|
||||||
|
application, client = space_oauth_api
|
||||||
|
application.deployment.mode = 'cloud'
|
||||||
|
application.directory_projection_service = SimpleNamespace(reconcile_workspaces=AsyncMock())
|
||||||
|
application.user_service.consume_space_oauth_state_details.return_value = SimpleNamespace(
|
||||||
|
launch_workspace_uuid=WORKSPACE_UUID
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
'/api/v1/user/space/callback',
|
||||||
|
json={'code': 'v4_oauth-code', 'state': 'opaque-login-state'},
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = await response.get_json()
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert payload['code'] == 1
|
||||||
|
assert 'Workspace binding' in payload['msg']
|
||||||
|
application.directory_projection_service.reconcile_workspaces.assert_not_awaited()
|
||||||
|
application.user_service.authenticate_space_user.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cloud_login_callback_rejects_conflicting_state_and_code_workspace_bindings(space_oauth_api):
|
||||||
|
application, client = space_oauth_api
|
||||||
|
application.deployment.mode = 'cloud'
|
||||||
|
application.directory_projection_service = SimpleNamespace(reconcile_workspaces=AsyncMock())
|
||||||
|
application.user_service.consume_space_oauth_state_details.return_value = SimpleNamespace(
|
||||||
|
launch_workspace_uuid=WORKSPACE_UUID
|
||||||
|
)
|
||||||
|
application.space_service.exchange_oauth_code.return_value = {
|
||||||
|
'access_token': 'space-access-token',
|
||||||
|
'refresh_token': 'space-refresh-token',
|
||||||
|
'expires_in': 3600,
|
||||||
|
'cloud_workspace_uuid': 'workspace-from-another-flow',
|
||||||
|
}
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
'/api/v1/user/space/callback',
|
||||||
|
json={'code': 'v4_oauth-code', 'state': 'opaque-login-state'},
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = await response.get_json()
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert payload['code'] == 1
|
||||||
|
assert 'Workspace binding' in payload['msg']
|
||||||
|
application.directory_projection_service.reconcile_workspaces.assert_not_awaited()
|
||||||
|
application.user_service.authenticate_space_user.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_oss_login_callback_does_not_request_cloud_reconciliation(space_oauth_api):
|
||||||
|
application, client = space_oauth_api
|
||||||
|
application.directory_projection_service = SimpleNamespace(reconcile_workspaces=AsyncMock())
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
'/api/v1/user/space/callback',
|
||||||
|
json={'code': 'v4_oauth-code', 'state': 'opaque-login-state'},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
application.directory_projection_service.reconcile_workspaces.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_login_callback_launch_state_selects_asserted_workspace(space_oauth_api):
|
async def test_login_callback_launch_state_selects_asserted_workspace(space_oauth_api):
|
||||||
application, client = space_oauth_api
|
application, client = space_oauth_api
|
||||||
@@ -276,7 +435,7 @@ async def test_login_callback_launch_state_selects_asserted_workspace(space_oaut
|
|||||||
|
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
'/api/v1/user/space/callback',
|
'/api/v1/user/space/callback',
|
||||||
json={'code': 'oauth-code', 'state': 'opaque-login-state'},
|
json={'code': 'v4_oauth-code', 'state': 'opaque-login-state'},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@@ -312,6 +471,29 @@ async def test_space_credits_are_resolved_from_workspace_owner(space_oauth_api):
|
|||||||
application.space_service.get_credits.assert_awaited_once_with('owner@example.com')
|
application.space_service.get_credits.assert_awaited_once_with('owner@example.com')
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_oss_local_only_owner_requires_space_binding_for_langbot_models(space_oauth_api):
|
||||||
|
application, client = space_oauth_api
|
||||||
|
application.user_service.get_workspace_owner = AsyncMock(
|
||||||
|
return_value=SimpleNamespace(user='owner@example.com', space_account_uuid=None)
|
||||||
|
)
|
||||||
|
application.space_service.get_credits = AsyncMock()
|
||||||
|
|
||||||
|
response = await client.get(
|
||||||
|
'/api/v1/user/space-credits',
|
||||||
|
headers={'Authorization': 'Bearer account-token', 'X-Workspace-Id': WORKSPACE_UUID},
|
||||||
|
)
|
||||||
|
payload = await response.get_json()
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert payload['data'] == {
|
||||||
|
'credits': None,
|
||||||
|
'owner_space_bound': False,
|
||||||
|
'is_workspace_owner': True,
|
||||||
|
}
|
||||||
|
application.space_service.get_credits.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_cloud_workspace_owner_is_always_space_bound_after_login(space_oauth_api):
|
async def test_cloud_workspace_owner_is_always_space_bound_after_login(space_oauth_api):
|
||||||
application, client = space_oauth_api
|
application, client = space_oauth_api
|
||||||
@@ -352,18 +534,22 @@ async def test_bind_callback_uses_opaque_state_and_never_treats_it_as_jwt(space_
|
|||||||
|
|
||||||
rejected = await client.post(
|
rejected = await client.post(
|
||||||
'/api/v1/user/bind-space',
|
'/api/v1/user/bind-space',
|
||||||
json={'code': 'attacker-code', 'state': 'jwt.must-not-be-used'},
|
json={'code': 'v4_attacker-code', 'state': 'jwt.must-not-be-used'},
|
||||||
)
|
)
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
'/api/v1/user/bind-space',
|
'/api/v1/user/bind-space',
|
||||||
json={'code': 'oauth-code', 'state': 'opaque-bind-state'},
|
json={'code': 'v4_oauth-code', 'state': 'opaque-bind-state'},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert rejected.status_code == 401
|
assert rejected.status_code == 401
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert (await response.get_json())['data']['token'] == 'rotated-account-token'
|
assert (await response.get_json())['data']['token'] == 'rotated-account-token'
|
||||||
application.user_service.verify_jwt_token.assert_not_awaited()
|
application.user_service.verify_jwt_token.assert_not_awaited()
|
||||||
application.user_service.bind_space_account.assert_awaited_once_with('owner@example.com', 'oauth-code')
|
application.user_service.bind_space_account.assert_awaited_once_with(
|
||||||
|
'owner@example.com',
|
||||||
|
'v4_oauth-code',
|
||||||
|
redirect_uri='http://localhost/auth/space/callback?mode=bind',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -371,6 +557,7 @@ async def test_direct_launch_assertion_does_not_consume_normal_oauth_state(space
|
|||||||
application, client = space_oauth_api
|
application, client = space_oauth_api
|
||||||
application.user_service.consume_space_oauth_state.reset_mock()
|
application.user_service.consume_space_oauth_state.reset_mock()
|
||||||
application.space_service.exchange_oauth_code.reset_mock()
|
application.space_service.exchange_oauth_code.reset_mock()
|
||||||
|
application.directory_projection_service = SimpleNamespace(reconcile_workspaces=AsyncMock())
|
||||||
|
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
'/api/v1/user/space/callback',
|
'/api/v1/user/space/callback',
|
||||||
@@ -391,3 +578,29 @@ async def test_direct_launch_assertion_does_not_consume_normal_oauth_state(space
|
|||||||
)
|
)
|
||||||
application.user_service.consume_space_oauth_state.assert_not_awaited()
|
application.user_service.consume_space_oauth_state.assert_not_awaited()
|
||||||
application.space_service.exchange_oauth_code.assert_not_awaited()
|
application.space_service.exchange_oauth_code.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_direct_launch_reconciles_exact_workspace_before_resolving_access(space_oauth_api):
|
||||||
|
application, client = space_oauth_api
|
||||||
|
projected_account = SimpleNamespace(
|
||||||
|
uuid='account-a',
|
||||||
|
user='owner@example.com',
|
||||||
|
account_type='space',
|
||||||
|
status='active',
|
||||||
|
)
|
||||||
|
application.user_service.get_user_by_uuid = AsyncMock(return_value=projected_account)
|
||||||
|
application.directory_projection_service = SimpleNamespace(reconcile_workspaces=AsyncMock())
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
'/api/v1/user/space/callback',
|
||||||
|
json={
|
||||||
|
'workspace_uuid': WORKSPACE_UUID,
|
||||||
|
'launch_assertion': 'signed-launch-token',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert (await response.get_json())['data']['workspace_uuid'] == WORKSPACE_UUID
|
||||||
|
application.directory_projection_service.reconcile_workspaces.assert_awaited_once_with((WORKSPACE_UUID,))
|
||||||
|
application.user_service.get_user_by_uuid.assert_awaited_once_with('account-a')
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user