Compare commits

..

7 Commits

Author SHA1 Message Date
fdc310 7f29d51c69 feat(wizard): streamline custom model onboarding 2026-08-25 19:33:36 +08:00
fdc310 6b60ad1678 fix(wizard): repair HTTP bot inbound test setup 2026-08-23 20:41:46 +08:00
langbot-dev 7a4fadc375 feat(wizard): add floating page bot verification 2026-08-14 23:58:39 +08:00
langbot-dev f2ba540ffb feat(wizard): add inbound bot verification 2026-08-14 23:14:22 +08:00
langbot-dev 97b176aef2 fix(wizard): parse ranked model selection entries 2026-08-14 17:43:36 +08:00
langbot-dev 7c387f75e1 fix(web): support LAN development access 2026-08-14 17:29:21 +08:00
langbot-dev b0566f4c9d feat(wizard): rework agent onboarding flow 2026-08-14 01:10:00 +08:00
111 changed files with 2854 additions and 2982 deletions
+13 -32
View File
@@ -7,42 +7,23 @@ on:
jobs:
build-dev-image:
runs-on: ubuntu-latest
# 如果是tag则跳过
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v2
with:
persist-credentials: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Generate image metadata
id: image
shell: bash
- name: Generate Tag
id: generate_tag
run: |
set -euo pipefail
branch_tag="${GITHUB_REF#refs/heads/}"
branch_tag="${branch_tag//\//-}"
echo "branch_tag=${branch_tag}" >> "$GITHUB_OUTPUT"
echo "sha_tag=sha-${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
- name: Login to Docker Hub
uses: docker/login-action@v3
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 }}
# 获取分支名称,把/替换为-
echo ${{ github.ref }} | sed 's/refs\/heads\///g' | sed 's/\//-/g'
echo ::set-output name=tag::$(echo ${{ github.ref }} | sed 's/refs\/heads\///g' | sed 's/\//-/g')
- name: Login to Registry
run: docker login --username=${{ secrets.DOCKER_USERNAME }} --password ${{ secrets.DOCKER_PASSWORD }}
- name: Build Docker Image
run: |
docker buildx create --name mybuilder --use
docker build -t rockchin/langbot:${{ steps.generate_tag.outputs.tag }} . --push
+59
View File
@@ -0,0 +1,59 @@
name: Build and deploy production
on:
push:
branches: [deploy/prod]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: langbot-production
cancel-in-progress: false
env:
CORE_IMAGE: ${{ secrets.DOCKER_USERNAME }}/langbot
CLOUD_IMAGE: ${{ secrets.DOCKER_USERNAME }}/langbot-cloud-core
SPACE_REF: 58253c53933f95d81b035fbe2efedb55b6c1a82b
jobs:
build-and-deploy:
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build exact Core image
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
${{ env.CORE_IMAGE }}:prod-${{ github.sha }}
${{ env.CORE_IMAGE }}:deploy-prod
cache-from: type=gha,scope=core-prod
cache-to: type=gha,mode=max,scope=core-prod
- name: Checkout production Cloud adapter
uses: actions/checkout@v4
with:
repository: langbot-app/langbot-space
ref: ${{ env.SPACE_REF }}
token: ${{ secrets.CLA_PAT }}
path: .space
- name: Build exact Cloud Core image
uses: docker/build-push-action@v6
with:
context: .space
file: .space/Dockerfile.cloud
push: true
build-args: LANGBOT_CORE_IMAGE=${{ env.CORE_IMAGE }}:prod-${{ github.sha }}
tags: |
${{ env.CLOUD_IMAGE }}:prod-${{ github.sha }}
${{ env.CLOUD_IMAGE }}:deploy-prod
cache-from: type=gha,scope=cloud-core-prod
cache-to: type=gha,mode=max,scope=cloud-core-prod
+2 -2
View File
@@ -1,4 +1,4 @@
FROM --platform=$BUILDPLATFORM node:22-alpine AS node
FROM node:22-alpine AS node
WORKDIR /app
@@ -62,7 +62,7 @@ RUN apt-get update \
&& apt-get install -y --no-install-recommends nodejs \
&& rm -f /tmp/nodesource_setup.sh \
&& python -m pip install --no-cache-dir uv \
&& uv sync --extra seekdb \
&& uv sync \
&& apt-get purge -y --auto-remove curl git gnupg \
&& rm -rf /var/lib/apt/lists/* \
&& touch /.dockerenv
-1
View File
@@ -83,7 +83,6 @@ cd LangBot/docker
docker compose --profile all up -d
```
### One-Click Cloud Deploy
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH)
-1
View File
@@ -83,7 +83,6 @@ cd LangBot/docker
docker compose --profile all up -d
```
### 一键云部署
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/zh-CN/templates/ZKTBDH)
-1
View File
@@ -82,7 +82,6 @@ cd LangBot/docker
docker compose --profile all up -d
```
### Despliegue en la Nube con un Clic
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH)
-1
View File
@@ -82,7 +82,6 @@ cd LangBot/docker
docker compose --profile all up -d
```
### Déploiement Cloud en un Clic
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH)
-1
View File
@@ -82,7 +82,6 @@ cd LangBot/docker
docker compose --profile all up -d
```
### ワンクリッククラウドデプロイ
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH)
-1
View File
@@ -82,7 +82,6 @@ cd LangBot/docker
docker compose --profile all up -d
```
### 원클릭 클라우드 배포
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH)
-1
View File
@@ -82,7 +82,6 @@ cd LangBot/docker
docker compose --profile all up -d
```
### Облачное развертывание одним кликом
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH)
-1
View File
@@ -84,7 +84,6 @@ cd LangBot/docker
docker compose --profile all up -d
```
### 一鍵雲端部署
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/zh-CN/templates/ZKTBDH)
-1
View File
@@ -82,7 +82,6 @@ cd LangBot/docker
docker compose --profile all up -d
```
### Triển khai đám mây một cú nhấp
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH)
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env bash
set -Eeuo pipefail
cd /opt/langbot-cloud-prod
TAG=${1:?usage: deploy.sh prod-<40-char-sha>}
[[ "$TAG" =~ ^prod-[0-9a-f]{40}$ ]] || { echo 'invalid immutable image tag' >&2; exit 2; }
[[ -s .env ]] || { echo '/opt/langbot-cloud-prod/.env is missing' >&2; exit 3; }
rendered_compose=$(docker compose config)
grep -Fq 'LANGBOT_SPACE_CONTROL_PLANE_URL: https://space.langbot.app' <<<"$rendered_compose" || {
echo 'Cloud control-plane URL must be https://space.langbot.app' >&2
exit 4
}
grep -Fq 'SPACE__URL: https://space.langbot.app' <<<"$rendered_compose" || {
echo 'Cloud user-facing Space URL must be https://space.langbot.app' >&2
exit 5
}
grep -Eq 'LANGBOT_TELEMETRY_INGEST_TOKEN: .+' <<<"$rendered_compose" || {
echo 'Cloud telemetry ingest token must be configured' >&2
exit 6
}
update_env() {
local key=$1 value=$2
python3 - "$key" "$value" <<'PY'
from pathlib import Path
import os
import sys
path = Path('.env')
key, value = sys.argv[1:]
lines = path.read_text().splitlines()
updated = False
for index, line in enumerate(lines):
if line.startswith(f'{key}='):
lines[index] = f'{key}={value}'
updated = True
break
if not updated:
lines.append(f'{key}={value}')
temporary = Path('.env.tmp')
temporary.write_text('\n'.join(lines) + '\n')
os.chmod(temporary, 0o600)
temporary.replace(path)
PY
}
update_env LANGBOT_IMAGE_TAG "$TAG"
set -a
. ./.env
set +a
: "${CLOUD_V2_CONTROL_PLANE_TOKEN:?CLOUD_V2_CONTROL_PLANE_TOKEN is required}"
for attempt in 1 2 3 4 5; do
if docker compose pull postgres redis migrate plugin-runtime core; then
break
fi
if [ "$attempt" -eq 5 ]; then
echo "docker compose pull failed after $attempt attempts" >&2
exit 1
fi
delay=$((attempt * 10))
echo "docker compose pull failed (attempt $attempt/5); retrying in ${delay}s" >&2
sleep "$delay"
done
docker compose up -d postgres redis
for _ in $(seq 1 60); do
if docker compose exec -T postgres pg_isready -U langbot_operator -d langbot >/dev/null 2>&1; then break; fi
sleep 2
done
docker compose exec -T postgres pg_isready -U langbot_operator -d langbot >/dev/null
docker compose exec -T postgres psql -v ON_ERROR_STOP=1 -U langbot_operator -d langbot \
-v runtime_password="$POSTGRES_RUNTIME_PASSWORD" <<'SQL'
SELECT format('CREATE ROLE langbot_runtime LOGIN PASSWORD %L', :'runtime_password')
WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'langbot_runtime')\gexec
ALTER ROLE langbot_runtime PASSWORD :'runtime_password';
GRANT CONNECT ON DATABASE langbot TO langbot_runtime;
REVOKE CREATE ON SCHEMA public FROM PUBLIC, langbot_runtime;
REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA public FROM langbot_runtime;
REVOKE ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public FROM langbot_runtime;
ALTER DEFAULT PRIVILEGES FOR ROLE langbot_operator IN SCHEMA public REVOKE ALL ON TABLES FROM langbot_runtime;
ALTER DEFAULT PRIVILEGES FOR ROLE langbot_operator IN SCHEMA public REVOKE ALL ON SEQUENCES FROM langbot_runtime;
GRANT USAGE ON SCHEMA public TO langbot_runtime;
SQL
docker compose --profile tools run --rm migrate
docker compose up -d --remove-orphans plugin-runtime core
for _ in $(seq 1 90); do
if docker compose exec -T core python -c 'import urllib.request; urllib.request.urlopen("http://127.0.0.1:5300/healthz", timeout=3)' >/dev/null 2>&1; then
docker compose ps
exit 0
fi
sleep 2
done
docker compose logs --tail=200 core plugin-runtime >&2
exit 1
+162
View File
@@ -0,0 +1,162 @@
services:
postgres:
image: pgvector/pgvector:pg17
container_name: langbot-cloud-postgres
restart: unless-stopped
environment:
POSTGRES_DB: langbot
POSTGRES_USER: langbot_operator
POSTGRES_PASSWORD: ${POSTGRES_OPERATOR_PASSWORD}
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: [CMD-SHELL, "pg_isready -U langbot_operator -d langbot"]
interval: 5s
timeout: 5s
retries: 30
networks: [internal]
redis:
image: redis:7.4-alpine
container_name: langbot-cloud-redis
restart: unless-stopped
command: [redis-server, --appendonly, "yes", --requirepass, "${REDIS_PASSWORD}"]
volumes:
- redis-data:/data
healthcheck:
test: [CMD-SHELL, "redis-cli -a \"$${REDIS_PASSWORD}\" ping | grep PONG"]
interval: 5s
timeout: 5s
retries: 20
environment:
REDIS_PASSWORD: ${REDIS_PASSWORD}
networks: [internal]
migrate:
image: rockchin/langbot-cloud-core:${LANGBOT_IMAGE_TAG}
profiles: [tools]
command: [uv, run, langbot, migrate, --cloud]
environment: &core-env
TZ: Asia/Shanghai
SYSTEM__INSTANCE_ID: ${CLOUD_V2_INSTANCE_UUID}
SYSTEM__EDITION: cloud
SYSTEM__RECOVERY_KEY: ${SYSTEM_RECOVERY_KEY}
SYSTEM__JWT__SECRET: ${JWT_SECRET}
SYSTEM__LIMITATION__MAX_BOTS: "2"
SYSTEM__LIMITATION__MAX_PIPELINES: "3"
SYSTEM__LIMITATION__MAX_EXTENSIONS: "3"
SYSTEM__LIMITATION__MAX_KNOWLEDGE_BASES: "2"
API__WEBHOOK_PREFIX: https://cloud.langbot.app
API__WEBUI_URL: https://cloud.langbot.app
WORKSPACE__INVITATIONS__PUBLIC_WEB_URL: https://cloud.langbot.app
DATABASE__USE: postgresql
DATABASE__POSTGRESQL__URL: postgresql+asyncpg://langbot_runtime:${POSTGRES_RUNTIME_PASSWORD}@postgres:5432/langbot
DATABASE__CLOUD_MIGRATION__OPERATOR_DSN_ENV: LANGBOT_CLOUD_MIGRATION_DSN
LANGBOT_CLOUD_MIGRATION_DSN: postgresql://langbot_operator:${POSTGRES_OPERATOR_PASSWORD}@postgres:5432/langbot
VDB__USE: pgvector
VDB__PGVECTOR__USE_BUSINESS_DATABASE: "true"
VDB__PGVECTOR__ALLOWED_DIMENSIONS: "384,512,768,1024,1536"
PLUGIN__ENABLE: "true"
PLUGIN__RUNTIME_WS_URL: ws://plugin-runtime:5400/control/ws
PLUGIN__DISPLAY_PLUGIN_DEBUG_URL: wss://cloud.langbot.app/plugin/debug/ws
PLUGIN__WORKER__MAX_CPUS: "0.25"
PLUGIN__WORKER__MAX_MEMORY_MB: "256"
PLUGIN__WORKER__MAX_PIDS: "128"
PLUGIN__WORKER__MAX_WORKERS: "16"
PLUGIN__WORKER__MAX_TOTAL_CPUS: "4.0"
PLUGIN__WORKER__MAX_TOTAL_MEMORY_MB: "4096"
PLUGIN__WORKER__REQUIRE_HARD_LIMITS: "true"
LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN: ${PLUGIN_RUNTIME_CONTROL_TOKEN}
# Cloud v2 currently grants no managed Box capability. Keep the shared
# runtime deployed but disable Core integration until a hard-quota-capable
# backend can satisfy the fail-closed Cloud readiness contract.
BOX__ENABLED: "false"
BOX__BACKEND: nsjail
BOX__RUNTIME__ENDPOINT: ws://box:5410
BOX__ADMISSION__REQUIRED: "true"
BOX__ADMISSION__LOGICAL_SESSION_ID: global
BOX__ADMISSION__REQUIRED_BACKEND: nsjail
BOX__ADMISSION__MAX_SESSIONS: "1"
BOX__ADMISSION__MAX_MANAGED_PROCESSES: "0"
BOX__ADMISSION__CPUS: "0.25"
BOX__ADMISSION__MEMORY_MB: "256"
BOX__ADMISSION__WORKSPACE_QUOTA_MB: "256"
BOX__LOCAL__HOST_ROOT: /app/data/box
BOX__LOCAL__DEFAULT_WORKSPACE: /app/data/box
BOX__LOCAL__ALLOWED_MOUNT_ROOTS: /app/data/box
LANGBOT_BOX_CONTROL_TOKEN: ${BOX_CONTROL_TOKEN}
MCP__STDIO__ENABLED: "false"
LANGBOT_SPACE_CONTROL_PLANE_URL: https://space.langbot.app
LANGBOT_SPACE_CONTROL_PLANE_TOKEN: ${CLOUD_V2_CONTROL_PLANE_TOKEN}
LANGBOT_TELEMETRY_INGEST_TOKEN: ${CLOUD_V2_CONTROL_PLANE_TOKEN}
LANGBOT_SPACE_CONTROL_PLANE_PUBLIC_KEY: ${CLOUD_V2_MANIFEST_PUBLIC_KEY}
LANGBOT_SPACE_CONTROL_PLANE_KEY_ID: ${CLOUD_V2_MANIFEST_KEY_ID}
SPACE__URL: https://space.langbot.app
depends_on:
postgres: {condition: service_healthy}
networks: [internal]
plugin-runtime:
image: rockchin/langbot:${LANGBOT_IMAGE_TAG}
container_name: langbot-cloud-plugin-runtime
restart: unless-stopped
command: [uv, run, python, -m, langbot_plugin.cli.__init__, rt]
environment:
LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN: ${PLUGIN_RUNTIME_CONTROL_TOKEN}
volumes:
- plugin-data:/app/data
- /sys/fs/cgroup:/sys/fs/cgroup:rw
cgroup: host
privileged: true
expose: ["5400"]
networks: [internal]
box:
image: rockchin/langbot:${LANGBOT_IMAGE_TAG}
container_name: langbot-cloud-box
restart: unless-stopped
command: [uv, run, lbp, box, --host, 0.0.0.0, --ws-control-port, "5410"]
environment:
LANGBOT_BOX_CONTROL_TOKEN: ${BOX_CONTROL_TOKEN}
LANGBOT_BOX_ROOT: /app/data/box
volumes:
- box-data:/app/data/box
- /sys/fs/cgroup:/sys/fs/cgroup:rw
cgroup: host
privileged: true
expose: ["5410"]
networks: [internal]
core:
image: rockchin/langbot-cloud-core:${LANGBOT_IMAGE_TAG}
container_name: langbot-cloud-core
restart: unless-stopped
environment: *core-env
volumes:
- core-data:/app/data
- box-data:/app/data/box
depends_on:
postgres: {condition: service_healthy}
redis: {condition: service_healthy}
plugin-runtime: {condition: service_started}
box: {condition: service_started}
expose: ["5300"]
healthcheck:
test: [CMD-SHELL, "python -c 'import urllib.request; urllib.request.urlopen(\"http://127.0.0.1:5300/healthz\", timeout=3)'" ]
interval: 10s
timeout: 5s
retries: 30
start_period: 30s
networks: [internal, shared-network]
networks:
internal:
shared-network:
external: true
volumes:
postgres-data:
redis-data:
plugin-data:
box-data:
core-data:
+7 -7
View File
@@ -47,10 +47,11 @@ services:
restart: on-failure
environment:
- TZ=Asia/Shanghai
# Optional shared control-plane secret used to authenticate both the RPC
# socket and managed-process relay. Leave unset on both OSS services, or
# generate one with ``openssl rand -hex 32`` and set the same value on
# both ends. Strongly recommended when the deployment is Internet-accessible.
# Shared control-plane secret used to authenticate both the RPC socket
# and managed-process relay. Generate once (for example with
# ``openssl rand -hex 32``) and export it before enabling this profile.
# An empty value is accepted by Compose so Box can remain optional, but
# the Box runtime itself fails closed when the profile is started.
- LANGBOT_BOX_CONTROL_TOKEN=${LANGBOT_BOX_CONTROL_TOKEN:-}
# Box has its own process-wide blocking-work budget.
- LANGBOT_BLOCKING_EXECUTOR_MAX_WORKERS=${LANGBOT_BLOCKING_EXECUTOR_MAX_WORKERS:-8}
@@ -78,9 +79,8 @@ services:
- TZ=Asia/Shanghai
# Optional. Leave unset on both OSS services, or match plugin Runtime.
- LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN=${LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN:-}
# When set, this must match langbot_box. If both ends leave it unset,
# OSS permits the connection without token authentication. The token is
# sent only in WebSocket handshake headers, never in URLs or payloads.
# Must match the value supplied to langbot_box. The token is sent only
# in WebSocket handshake headers, never in URLs or action payloads.
- LANGBOT_BOX_CONTROL_TOKEN=${LANGBOT_BOX_CONTROL_TOKEN:-}
# Core process-wide blocking-work admission. These are native config
# overrides and are persisted with the effective data/config.yaml.
+1 -18
View File
@@ -10,19 +10,6 @@ uvx 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
You can also install LangBot as a regular Python package:
@@ -33,10 +20,6 @@ pip install langbot
# Using uv
uv pip install langbot
# Include optional SeekDB support
pip install 'langbot[seekdb]'
# or: uv pip install 'langbot[seekdb]'
```
Then run it:
@@ -118,7 +101,7 @@ uvx langbot
## System Requirements
- Python 3.11 or higher (lower than Python 4)
- Python 3.10.1 or higher
- Operating System: Linux, macOS, or Windows
## Differences from Source Installation
+43 -34
View File
@@ -16,20 +16,12 @@ This document describes how to use OceanBase SeekDB as the vector database backe
## Installation
SeekDB is an optional LangBot feature. A normal LangBot installation uses
Chroma by default and does not install `pyseekdb` or its native bindings.
SeekDB support is automatically included when you install LangBot. The required dependency `pyseekdb` is listed in `pyproject.toml`.
Choose the command that matches how you run LangBot:
If you need to install it manually:
```bash
# PyPI / uvx
uvx --from 'langbot[seekdb]@latest' langbot
# Installed package
pip install 'langbot[seekdb]'
# Source checkout
uv sync --extra seekdb
pip install pyseekdb
```
## ⚠️ Platform Compatibility
@@ -38,36 +30,31 @@ uv sync --extra seekdb
| Platform | Status | Notes |
|----------|--------|-------|
| Linux x86_64 / ARM64 | ✅ Supported | Full embedded mode support via `pylibseekdb` |
| macOS 15+ on Apple Silicon | ✅ Supported | Requires the macOS ARM64 `pylibseekdb` wheel |
| 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 |
| Linux | ✅ Supported | Full embedded mode support via `pylibseekdb` |
| macOS | ❌ Not Supported | `pylibseekdb` is Linux-only; use server mode instead |
| Windows | ❌ Not Supported | `pylibseekdb` is Linux-only; use server mode instead |
**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.
**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.
### Server Mode (Docker)
| Platform | Status | Notes |
|----------|--------|-------|
| Linux | ✅ Supported | Full Docker support |
| macOS | ✅ Supported by Docker Desktop | The previous slow-disk startup issue was fixed upstream in [oceanbase/seekdb#36](https://github.com/oceanbase/seekdb/issues/36) |
| Windows | ⚠️ Depends on the container runtime | Use a Linux container and follow the upstream image documentation |
| macOS | ⚠️ Known Issue | Docker container initialization failure - [See Issue #36](https://github.com/oceanbase/seekdb/issues/36) |
| Windows | ⚠️ Untested | Should work but not yet tested |
**macOS Users**: Currently, SeekDB Docker containers have an initialization issue on macOS ([oceanbase/seekdb#36](https://github.com/oceanbase/seekdb/issues/36)). Until this is resolved, we recommend:
- Using ChromaDB or Qdrant as alternatives
- Connecting to a remote SeekDB server on Linux if available
### Server Mode (Remote Connection)
| Platform | Status | Notes |
|----------|--------|-------|
| 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 |
| All Platforms | ✅ Supported | Connect to SeekDB running on a remote Linux server |
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.
**Recommendation for macOS/Windows users**: Deploy SeekDB on a Linux server and connect via server mode configuration.
## Configuration
@@ -183,23 +170,22 @@ Key methods:
### Import Error
If you see: `SeekDB support is not installed`
If you see: `ImportError: pyseekdb is not installed`
Solution:
```bash
uv sync --extra seekdb
# or: uvx --from 'langbot[seekdb]@latest' langbot
pip install pyseekdb
```
### Embedded Mode Is Unavailable on the Current Platform
### Embedded Mode Error on macOS/Windows
**Error**:
```
RuntimeError: Embedded Client is not available because pylibseekdb is not available.
Please install pylibseekdb (Linux only) or use RemoteServerClient (host/port) instead.
```
**Cause**: No compatible `pylibseekdb` wheel is installed for the current OS,
CPU architecture, Python version, and macOS deployment target.
**Cause**: `pylibseekdb` is only available on Linux platforms.
**Solution**: Use server mode instead:
1. Deploy SeekDB on a Linux server or VM
@@ -222,6 +208,29 @@ vdb:
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)
If SeekDB server is not reachable, check:
@@ -103,11 +103,11 @@ This log records implementation choices made while delivering the Workspace arch
- Decision: New Core JWTs require `iss=langbot-core`, an audience derived from the immutable instance UUID, and an expiry. Legacy community tokens are accepted only when they have the historical issuer, carry no audience, and the active policy is the OSS singleton policy.
- Reason: A token issued by one instance must not authenticate against another instance that happens to share a secret, and a compatibility decoder must not become an alternate path around the SaaS trust boundary.
### Runtime control transports support opt-in shared-secret authentication
### Runtime control transports authenticate before protocol dispatch
- Decision: OSS external Plugin Runtime and Box WebSocket control channels preserve tokenless standalone compatibility when the corresponding control token is unset. When a Runtime configures a token, it validates the independent shared secret in the handshake before protocol dispatch. Locally managed child processes still receive ephemeral secrets through their environment; secrets are not placed in URLs, process arguments, request payloads, or logs. Box additionally pins the first control channel to one declared instance identity. Plugin Runtime debug and control credentials remain separate.
- Reason: Local OSS development must remain backward compatible, while exposed or shared Runtime endpoints can opt into transport authentication. Separating control and debug credentials also limits accidental privilege reuse.
- Deployment consequence: Docker Compose and Kubernetes should wire one strong shared secret to each host/runtime pair. Both sides must use the same value for protection to be effective; a Runtime configured with a token rejects clients that omit it or send a different value.
- Decision: External Plugin Runtime and Box WebSocket control channels require independent strong shared secrets in handshake headers. Locally managed child processes receive ephemeral secrets through their environment; secrets are not placed in URLs, process arguments, request payloads, or logs. Box additionally binds the first authenticated control channel to one trusted instance. Plugin Runtime debug and control credentials remain separate.
- Reason: Workspace context inside an RPC payload is not trustworthy until the transport peer itself is authenticated. Separating control and debug credentials also limits accidental privilege reuse.
- Deployment consequence: Docker Compose and Kubernetes wire one shared secret to each host/runtime pair. An empty external-runtime secret fails startup instead of silently exposing an unauthenticated socket.
### Dashboard WebSocket sessions are tenant runtime objects
+3 -7
View File
@@ -1,6 +1,6 @@
[project]
name = "langbot"
version = "4.10.8"
version = "4.10.7"
description = "Production-grade platform for building agentic IM bots"
readme = "README.md"
license-files = ["LICENSE"]
@@ -70,7 +70,8 @@ dependencies = [
"langchain-text-splitters>=1.1.2",
"chromadb>=1.0.0,<2.0.0",
"qdrant-client (>=1.15.1,<2.0.0)",
"langbot-plugin==0.5.5",
"pyseekdb==1.1.0.post3",
"langbot-plugin @ git+https://github.com/langbot-app/langbot-plugin-sdk.git@9d216208cdfb41f0cb7fcb64632e2a46816d6dc6",
"asyncpg>=0.30.0",
"line-bot-sdk>=3.19.0",
"matrix-nio>=0.25.2",
@@ -107,11 +108,6 @@ classifiers = [
"Topic :: Communications :: Chat",
]
[project.optional-dependencies]
seekdb = [
"pyseekdb==1.1.0.post3",
]
[project.urls]
Homepage = "https://langbot.app"
Documentation = "https://docs.langbot.app"
+4 -5
View File
@@ -27,11 +27,10 @@ The `all` / `box` profile starts three services:
- `langbot_box` — Box sandbox runtime (`:5410`). Uses the host Docker socket to
spawn sandbox containers, so the **Box root host path and in-container path
must be identical** (`BOX__LOCAL__HOST_ROOT=${LANGBOT_BOX_ROOT:-${PWD}/data/box}`).
OSS allows its RPC and managed-process relay to run without a token when both
sides leave `LANGBOT_BOX_CONTROL_TOKEN` unset. For an exposed endpoint, set
the same value of at least 32 non-whitespace characters in both the LangBot
and Box containers. Generate it once with `openssl rand -hex 32`; never put
it in `box.runtime.endpoint` or commit it to config.
Its RPC and managed-process relay require a shared
`LANGBOT_BOX_CONTROL_TOKEN` (at least 32 non-whitespace characters) in both
the LangBot and Box containers. Generate it once with `openssl rand -hex 32`;
never put it in `box.runtime.endpoint` or commit it to config.
A Compose deployment may optionally set
`LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN` on both `langbot` and
+2
View File
@@ -75,6 +75,8 @@ shape as the corresponding HTTP API request body. Discover resources with the
`list_*` / `get_*` tools before mutating; identifiers are UUIDs. Reads require
`resource.view`; mutations require `resource.manage`. All service calls inherit
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
+3 -10
View File
@@ -6,8 +6,7 @@ description: Browse and search the LangBot Space marketplaces (plugins, MCP serv
# LangBot Space MCP Operations
LangBot Space (space.langbot.app) exposes an **MCP server** so user-facing AI
agents can browse and search the marketplaces (plugins, MCP servers, skills) and
rank live models for automated setup.
agents can browse and search the marketplaces (plugins, MCP servers, skills).
## Endpoint
@@ -47,12 +46,10 @@ Authorization: Bearer lbpat_...uests without a valid PAT get `401 Unauthorized`.
| `list_plugins` / `search_plugins` / `get_plugin` | Plugin marketplace |
| `list_mcp_servers` / `search_mcp_servers` / `get_mcp_server` | MCP-server marketplace |
| `list_skills` / `search_skills` / `get_skill` | Skill marketplace |
| `select_models` | Live best-first model list for setup wizards; optional `category` filter |
`list_*` and `search_*` are paged (`page`, `page_size`). `get_*` takes
`author` + `name`. The tool surface mirrors the REST endpoints under
`/api/v1/marketplace/*`; `select_models` mirrors `/api/v1/models/selection`.
All tools are read-only.
`/api/v1/marketplace/*` and is read/browse only.
## How to use
@@ -61,16 +58,12 @@ All tools are read-only.
3. Use `search_plugins` / `search_mcp_servers` / `search_skills` to find items,
then `get_*` for details (e.g. to obtain author/name for installation in
LangBot itself).
4. For automatic local-agent setup, call `select_models` (optionally with
`category`) and choose the first compatible item. Ordering is latest probe
state (available, unprobed, unavailable), then Space recommendation. Each
item includes `availability.up`, `last_probed_at`, latency, and HTTP status.
## Implementation & maintenance (for Space developers)
- Server: `internal/controller/mcp/server.go` (official Go MCP SDK
`github.com/modelcontextprotocol/go-sdk`). Tools call the service layer
(`PluginService`, `MCPService`, `SkillService`, `ModelStatusService`) directly.
(`PluginService`, `MCPService`, `SkillService`) directly.
- Mount: `internal/controller/api.go` at `/mcp` and `/mcp/*any`.
- Auth: PAT via `AccountService.ValidatePersonalAccessToken`.
- Docs: `docs/MCP_SERVER.md`.
-63
View File
@@ -422,69 +422,6 @@ class QQOfficialClient:
await self.logger.error(f'Failed to send private message: {response_data}')
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(
self,
group_openid: str,
@@ -46,14 +46,6 @@ CMD_RESPOND_MSG = 'aibot_respond_msg'
CMD_RESPOND_WELCOME = 'aibot_respond_welcome_msg'
CMD_RESPOND_UPDATE = 'aibot_respond_update_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
_STREAM_CACHE_MAX = 1024
@@ -503,145 +495,6 @@ class WecomBotWsClient:
body['chatid'] = chat_id
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:
"""Push a streaming chunk for a given message ID.
@@ -39,7 +39,13 @@ class PipelinesRouterGroup(group.RouterGroup):
permission=Permission.RESOURCE_MANAGE,
)
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})
@self.route(
@@ -113,6 +113,24 @@ class BotsRouterGroup(group.RouterGroup):
)
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(
'/<bot_uuid>/admins',
methods=['GET'],
@@ -398,16 +398,7 @@ class PluginsRouterGroup(group.RouterGroup):
# Get debug URL from config
plugin_config = self.ap.instance_config.data.get('plugin', {})
debug_url = plugin_config.get(
'display_plugin_debug_url',
'ws://localhost:5401/plugin/debug/ws',
)
parsed_debug_url = urlparse(debug_url)
if parsed_debug_url.scheme in {'http', 'https'}:
debug_url = parsed_debug_url._replace(
scheme='wss' if parsed_debug_url.scheme == 'https' else 'ws',
path=parsed_debug_url.path or '/plugin/debug/ws',
).geturl()
debug_url = plugin_config.get('display_plugin_debug_url', 'http://localhost:5401')
return self.success(
data={
@@ -206,6 +206,20 @@ class SystemRouterGroup(group.RouterGroup):
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(
'/tasks',
methods=['GET'],
@@ -14,6 +14,13 @@ from ...service.user import ControlPlaneDirectoryRequiredError, PublicRegistrati
@group.group_class('user', '/api/v1/user')
class UserRouterGroup(group.RouterGroup):
@staticmethod
def _origin(value: str) -> tuple[str, str, int | None] | None:
parsed = urlsplit(value)
if parsed.scheme not in {'http', 'https'} or not parsed.hostname:
return None
return parsed.scheme, parsed.hostname.casefold(), parsed.port
def _validate_space_redirect_uri(self, redirect_uri: str, *, bind: bool) -> str:
parsed = urlsplit(redirect_uri)
if (
@@ -31,8 +38,17 @@ class UserRouterGroup(group.RouterGroup):
if query != {'mode': ['bind']}:
raise ValueError('Invalid Space binding redirect_uri')
elif query:
raise ValueError('Invalid LangBot Account login redirect_uri')
raise ValueError('Invalid Space login redirect_uri')
redirect_origin = self._origin(redirect_uri)
api_config = self.ap.instance_config.data.get('api', {})
trusted_origins = {
self._origin(str(api_config.get(config_key, '') or '').strip())
for config_key in ('webui_url', 'webhook_prefix')
}
trusted_origins.discard(None)
if redirect_origin not in trusted_origins:
raise ValueError('Untrusted redirect_uri origin')
return redirect_uri
async def initialize(self) -> None:
@@ -322,7 +338,6 @@ class UserRouterGroup(group.RouterGroup):
if cloud_mode:
capabilities['password_login_enabled'] = False
capabilities['authenticated_invitation_acceptance_enabled'] = cloud_mode
capabilities['invitation_registration_enabled'] = not cloud_mode
return self.success(data={'initialized': True, **capabilities})
@self.route('/set-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
@@ -401,7 +416,7 @@ class UserRouterGroup(group.RouterGroup):
'Bind the LangBot Account with the same email as this local Account',
)
except ValueError:
return self.http_status(400, -1, 'LangBot Account binding failed')
return self.http_status(400, -1, 'Space account binding failed')
except Exception:
raise
+51
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import uuid
import json
import sqlalchemy
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 ....workspace.errors import WorkspaceNotFoundError
from .tenant import TenantContext, require_workspace_uuid, scope_statement
from ....utils import httpclient
from ....platform.sources import http_bot_signing
class BotService:
@@ -80,6 +83,7 @@ class BotService:
'wecomcs',
'LINE',
'lark',
'http_bot',
]:
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', '')
@@ -216,6 +220,53 @@ class BotService:
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(
self,
context: TenantContext,
+79
View File
@@ -11,6 +11,9 @@ import sqlalchemy
from ....core import app
from ....entity.persistence import user
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
@@ -238,3 +241,79 @@ class SpaceService:
raise ValueError(f'Failed to get models: {data.get("msg")}')
models_data = data.get('data', {}).get('models', [])
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}
+4 -4
View File
@@ -114,7 +114,7 @@ class UserService:
if purpose == 'login' and account_uuid is not None:
raise ValueError('Login state cannot be bound to an Account')
if purpose != 'login' and launch_workspace_uuid is not None:
raise ValueError('Launch Workspace state is only valid for LangBot Account login')
raise ValueError('Launch Workspace state is only valid for Space login')
if ttl_seconds <= 0:
raise ValueError('OAuth state lifetime must be positive')
@@ -327,7 +327,7 @@ class UserService:
normalized_email = normalize_email(user_email)
if self._uses_control_plane_directory():
raise ControlPlaneDirectoryRequiredError(
'Cloud invitation registration must use a LangBot Account to preserve control-plane identity'
'Cloud invitation registration must use a Space account to preserve control-plane identity'
)
invitation, _ = await self.ap.workspace_collaboration_service.inspect_invitation(invitation_token)
if invitation.normalized_email != normalized_email:
@@ -394,7 +394,7 @@ class UserService:
# Check if this user has a local password set
if not user_obj.password:
raise ValueError('请使用 LangBot登录')
raise ValueError('请使用 Space登录')
await self._verify_password(user_obj.password, password)
@@ -825,7 +825,7 @@ class UserService:
# Check if this Space account is already bound to another user
existing_space_user = await self.get_user_by_space_account_uuid(space_account_uuid)
if existing_space_user and existing_space_user.normalized_email != normalize_email(user_email):
raise ValueError('This LangBot Account is already bound to another user')
raise ValueError('This Space account is already bound to another user')
# Update local account to Space account
normalized_email = normalize_email(user_email)
+10 -1
View File
@@ -147,7 +147,16 @@ class LangBotMCPServer:
)
async def create_pipeline(pipeline_data: dict) -> str:
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.')
async def update_pipeline(pipeline_uuid: str, pipeline_data: dict) -> str:
+6 -12
View File
@@ -367,12 +367,6 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
def _ensure_control_token(self, *, allow_generate: bool) -> str:
if not self._control_token and allow_generate:
self._control_token = secrets.token_urlsafe(48)
if not self._control_token:
if getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud':
raise BoxRuntimeUnavailableError(
f'{BOX_CONTROL_TOKEN_ENV} must be configured with a strong shared secret for a Cloud Box runtime'
)
return ''
try:
self._control_token = validate_control_token(self._control_token)
except ValueError as exc:
@@ -382,19 +376,19 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
return self._control_token
def get_control_headers(self) -> dict[str, str]:
"""Return instance-scoped RPC headers and the optional shared secret."""
"""Headers for the instance-authenticated RPC control handshake."""
self._ensure_control_token(allow_generate=False)
headers = {BOX_INSTANCE_HEADER: self._trusted_instance_uuid}
if self._control_token:
headers[BOX_CONTROL_TOKEN_HEADER] = self._control_token
return headers
return {
BOX_CONTROL_TOKEN_HEADER: self._control_token,
BOX_INSTANCE_HEADER: self._trusted_instance_uuid,
}
def get_relay_headers(
self,
action_context: ActionContext,
) -> dict[str, str]:
"""Return instance- and placement-scoped relay handshake headers."""
"""Return authenticated, placement-scoped relay handshake headers."""
context = ActionContext.model_validate(action_context).without_installation()
if context.instance_uuid != self._trusted_instance_uuid:
+8 -17
View File
@@ -1210,9 +1210,8 @@ class BoxService:
async def _read_outbox_via_exec(self, query: pipeline_query.Query) -> list[dict]:
"""Fallback: read the outbox over the exec channel (E2B / remote).
Uses ``client.execute`` directly (bypassing ``_serialize_result``)
so stdout is NOT truncated by ``output_limit_chars`` - the raw
base64 payload can be far larger than the 4000-char display limit.
Note: exec stdout is truncated by ``output_limit_chars``, so this path
only reliably transfers small files. The host path is preferred.
"""
import json as _json
@@ -1266,22 +1265,14 @@ class BoxService:
' break\n'
'print(json.dumps(out))\n'
)
spec_payload: dict = {
'cmd': f"python3 - <<'LBPY'\n{script}\nLBPY",
'timeout_sec': 120,
'session_id': self.resolve_box_session_id(query),
}
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:
result = await self.execute_tool(
{'command': f"python3 - <<'LBPY'\n{script}\nLBPY", 'timeout_sec': 120},
query,
)
if not result.get('ok'):
return []
try:
return _json.loads(str(result.stdout or '').strip().splitlines()[-1])
return _json.loads(str(result.get('stdout') or '').strip().splitlines()[-1])
except Exception:
return []
+1 -9
View File
@@ -3,7 +3,6 @@ from __future__ import annotations
import typing
import inspect
from ..api.http.context import ExecutionContext
from ..core import app
from . import operator
from ..utils import importutil
@@ -67,14 +66,7 @@ class CommandManager:
require_context = getattr(self.ap.plugin_connector, 'require_workspace_context', None)
if require_context is not None:
result = require_context(
ExecutionContext(
instance_uuid=context.instance_uuid,
workspace_uuid=context.workspace_uuid,
placement_generation=context.placement_generation,
query_uuid=context.query_uuid,
)
)
result = require_context(context)
if inspect.isawaitable(result):
await result
+3 -37
View File
@@ -249,10 +249,6 @@ class Application:
{},
)
),
'plugin_runtime_connected': bool(
self.plugin_connector is not None
and getattr(self.plugin_connector, '_runtime_available', lambda: False)()
),
}
mcp_loader = getattr(self.tool_mgr, 'mcp_tool_loader', None)
runtime_stats.update(
@@ -301,36 +297,11 @@ class Application:
async def initialize(self):
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):
self.event_loop_monitor.start()
try:
if (
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(
if self.directory_projection_service is not None:
self.task_mgr.create_task(
self.directory_projection_service.run(),
name='cloud-directory-projection',
scopes=[core_entities.LifecycleControlScope.APPLICATION],
@@ -347,6 +318,7 @@ class Application:
name='cloud-manifest-refresh',
scopes=[core_entities.LifecycleControlScope.APPLICATION],
)
await self.plugin_connector.initialize_plugins()
# 后续可能会允许动态重启其他任务
# 故为了防止程序在非 Ctrl-C 情况下退出,这里创建一个不会结束的协程
@@ -372,7 +344,6 @@ class Application:
name='http-api-controller',
scopes=[core_entities.LifecycleControlScope.APPLICATION],
)
self._start_plugin_runtime_initialization()
# Telemetry instance heartbeat (startup + daily); respects
# space.disable_telemetry via TelemetryManager.send().
@@ -554,11 +525,6 @@ class Application:
if self.task_mgr is not None:
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):
await self.event_loop_monitor.stop()
mcp_mount = getattr(self.http_ctrl, 'mcp_mount', None)
+8 -11
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from .. import stage, app, entities as core_entities
from .. import stage, app
from ...utils import version, proxy, constants
from ...pipeline import pool, controller, pipelinemgr
from ...pipeline import aggregator as message_aggregator
@@ -292,17 +292,14 @@ class BuildAppStage(stage.BootingStage):
async def runtime_disconnect_callback(connector: plugin_connector.PluginRuntimeConnector) -> None:
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)
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
workspace_service_inst.release_startup_execution_bindings()
@@ -47,3 +47,10 @@ class SpaceModel(pydantic.BaseModel):
status: str
created_at: str | None = None
updated_at: str | None = None
class SpaceModelSelection(pydantic.BaseModel):
"""Minimal model identity returned by the ranked selection endpoint."""
uuid: str
model_id: str
+1 -1
View File
@@ -17,4 +17,4 @@ class SpaceAccountBindingRequiredError(AccountEmailMismatchError):
code = 'space_account_binding_required'
def __str__(self) -> str:
return 'This local account must bind a LangBot Account from Account settings before LangBot Account login'
return 'This local Account must bind Space from Account settings before Space login'
+1 -12
View File
@@ -177,6 +177,7 @@ class PersistenceManager:
await self._validate_cloud_runtime()
return
self._enable_sqlite_foreign_keys()
if self.mode == PersistenceMode.RELEASE_MIGRATION:
async with self._release_migration_lock():
await self._initialize_managed_schema()
@@ -184,7 +185,6 @@ class PersistenceManager:
return
await self._initialize_managed_schema()
await self._enable_sqlite_foreign_keys_after_migration()
if self.mode == PersistenceMode.OSS_COMPAT:
await self.write_space_model_providers()
@@ -373,17 +373,6 @@ class PersistenceManager:
sqlalchemy.event.listen(self.get_db_engine().sync_engine, 'begin', set_oss_tenant_scope)
self._oss_tenant_scope_listener_installed = True
async def _enable_sqlite_foreign_keys_after_migration(self) -> None:
"""Enable SQLite FK enforcement only after table-rebuilding migrations."""
engine = self.get_db_engine()
if engine.dialect.name != 'sqlite':
return
await engine.dispose()
self._enable_sqlite_foreign_keys()
# Dispose again so every runtime connection is opened through the new
# listener instead of reusing a pre-migration pooled connection.
await engine.dispose()
def _enable_sqlite_foreign_keys(self) -> None:
"""Enable SQLite FK enforcement for every pooled runtime connection."""
engine = self.get_db_engine()
@@ -12,7 +12,6 @@ import re
import secrets
import sqlite3
import tempfile
import time
import typing
from sqlalchemy.ext.asyncio import AsyncEngine
@@ -118,19 +117,8 @@ def _write_manifest(backup: SQLiteMigrationBackup, status: str, **extra: typing.
temporary_path.unlink(missing_ok=True)
def _fsync_file(path: pathlib.Path, *, reopen_attempts: int = 20) -> None:
"""Sync a file, tolerating delayed visibility after replace on bind mounts."""
descriptor: int | None = None
for attempt in range(reopen_attempts):
try:
descriptor = os.open(path, os.O_RDONLY)
break
except FileNotFoundError:
if attempt + 1 >= reopen_attempts:
raise
time.sleep(0.05)
assert descriptor is not None
def _fsync_file(path: pathlib.Path) -> None:
descriptor = os.open(path, os.O_RDONLY)
try:
os.fsync(descriptor)
finally:
@@ -5,11 +5,6 @@ from .. import entities
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
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')
class BanWordFilter(filter_model.ContentFilter):
@@ -19,17 +14,12 @@ class BanWordFilter(filter_model.ContentFilter):
pass
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:
found, current = await mask_patterns(
words,
found, message = await mask_patterns(
self.ap.sensitive_meta.data['words'],
message,
mask=mask,
mask_word=mask_word,
max_pattern_count=_MAX_SENSITIVE_WORD_PATTERNS,
mask=self.ap.sensitive_meta.data['mask'],
mask_word=self.ap.sensitive_meta.data['mask_word'],
)
except SafeRegexError as exc:
return entities.FilterResult(
@@ -41,7 +31,7 @@ class BanWordFilter(filter_model.ContentFilter):
return entities.FilterResult(
level=entities.ResultLevel.MASKED if found else entities.ResultLevel.PASS,
replacement=current,
replacement=message,
user_notice='消息中存在不合适的内容, 请修改' if found else '',
console_notice='',
)
@@ -158,18 +158,6 @@ class ResponseWrapper(stage.PipelineStage):
result_type=entities.ResultType.CONTINUE,
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: # 有函数调用
function_names = [tc.function.name for tc in result.tool_calls]
+4 -10
View File
@@ -101,7 +101,7 @@ class LINEEventConverter(abstract_platform_adapter.AbstractEventConverter):
if event.source.type == 'user':
return platform_events.FriendMessage(
sender=platform_entities.Friend(
id=event.source.user_id,
id=event.message.id,
nickname=event.source.user_id,
remark='',
),
@@ -110,19 +110,13 @@ class LINEEventConverter(abstract_platform_adapter.AbstractEventConverter):
source_platform_object=event,
)
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(
sender=platform_entities.GroupMember(
id=member_id,
member_name=member_id,
id=event.event.sender.sender_id.open_id,
member_name=event.event.sender.sender_id.union_id,
permission=platform_entities.Permission.Member,
group=platform_entities.Group(
id=group_id,
id=event.message.id,
name='',
permission=platform_entities.Permission.Member,
),
+29 -50
View File
@@ -329,12 +329,17 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
content_type = content.get('type', 'text')
if content_type == 'text':
if target_type in {'c2c', 'group'}:
await self._send_c2c_or_group_text_reply(
target_type,
if target_type == 'c2c':
await self.bot.send_private_text_msg(
target_id,
content['content'],
msg_id=qq_official_event.d_id,
qq_official_event.d_id,
)
elif target_type == 'group':
await self.bot.send_group_text_msg(
target_id,
content['content'],
qq_official_event.d_id,
)
elif content_type == 'image':
@@ -378,39 +383,6 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
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(
self,
event_type: typing.Type[platform_events.Event],
@@ -678,13 +650,13 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
# 用第一个 chunk 的文本建立会话(不发 "..." 避免污染前缀)
ctx['session_started'] = True
# `replace` mode requires every update to contain the previously
# delivered content as its prefix. `sent_length` only tells us whether
# a non-final snapshot has new content; it must not truncate the
# content sent to QQ.
if len(ctx['accumulated_text']) <= ctx['sent_length'] and not is_final:
# 发送内容 = 全量累积文本
# QQ API 的 replace 模式不允许修改已下发前缀,所以:
# - 首次:发送全部文本,建立会话
# - 后续:只能发送新增部分(append 行为)
content_to_send = ctx['accumulated_text'][ctx['sent_length'] :]
if not content_to_send and not is_final:
return
content_to_send = ctx['accumulated_text']
input_state = 10 if is_final else 1
@@ -806,13 +778,20 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
return
try:
await self._send_c2c_or_group_text_reply(
target_type,
target_id,
text,
event_id=event_id,
msg_seq=msg_seq,
)
if target_type == 'c2c':
await self.bot.send_private_text_msg(
user_openid=target_id,
content=text,
event_id=event_id,
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:
await self.logger.error(f'QQ Official: synthetic reply delivery failed: {traceback.format_exc()}')
@@ -95,18 +95,6 @@ spec:
type: boolean
required: true
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
label:
en_US: Webhook Callback URL
+19 -107
View File
@@ -3,10 +3,8 @@ import typing
import asyncio
import time
import traceback
import base64
import datetime
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.events as platform_events
@@ -26,24 +24,11 @@ from langbot.libs.wecom_ai_bot_api.ws_client import WecomBotWsClient
class WecomBotMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
@staticmethod
async def yiri2target(message_chain: platform_message.MessageChain):
"""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] = []
content = ''
for msg in message_chain:
if type(msg) is platform_message.Plain:
items.append({'type': 'text', 'text': msg.text})
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
content += msg.text
return content
@staticmethod
async def target2yiri(event: WecomBotEvent, bot_name: str = ''):
@@ -377,76 +362,13 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
}
)
@staticmethod
def _join_text_components(items: list[dict]) -> str:
"""Concatenate ``text`` items in order, leaving media items alone."""
return ''.join(item['text'] for item in items if item.get('type') == 'text')
@staticmethod
def _iter_media_components(items: list[dict]):
"""Yield non-text items in order."""
for item in items:
if item.get('type') in {'image', 'voice', 'file'}:
yield item
@staticmethod
async def _send_media(
bot,
req_id: str,
item: dict,
) -> bool:
"""Upload *item* to the WeCom AI Bot CDN and send it as a media reply.
Returns True on success. Falls back to a no-op (with a warning log)
if the SDK does not yet implement ``upload_media`` /
``reply_image`` / ``reply_file`` / ``reply_voice`` the framework
will keep working, just without image delivery.
"""
kind = item.get('type')
upload = getattr(bot, 'upload_media', None)
if upload is None:
return False
b64_text = item.get('base64') or ''
if not b64_text:
return False
if b64_text.startswith('data:') and ',' in b64_text:
b64_text = b64_text.split(',', 1)[1]
try:
data = base64.b64decode(b64_text, validate=False)
except Exception:
return False
if not data:
return False
try:
upload_result = await upload(data, item.get('name') or f'attachment.{kind}', media_type=kind)
except Exception:
return False
media_id = getattr(upload_result, 'media_id', None) or (
isinstance(upload_result, dict) and upload_result.get('media_id')
)
if not media_id:
return False
reply_fn = {
'image': getattr(bot, 'reply_image', None),
'file': getattr(bot, 'reply_file', None),
'voice': getattr(bot, 'reply_voice', None),
}.get(kind)
if reply_fn is None:
return False
try:
await reply_fn(req_id, media_id)
return True
except Exception:
return False
async def reply_message(
self,
message_source: platform_events.MessageEvent,
message: platform_message.MessageChain,
quote_origin: bool = False,
):
items = await self.message_converter.yiri2target(message)
text = self._join_text_components(items)
content = await self.message_converter.yiri2target(message)
_ws_mode = not self.config.get('enable-webhook', False)
event = message_source.source_platform_object
@@ -460,7 +382,7 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
else:
chat_id = str(message_source.sender.id)
try:
await self.bot.send_message(chat_id, text)
await self.bot.send_message(chat_id, content)
except Exception:
await self.logger.error(
f'WeComBot: proactive reply for synthetic event failed: {traceback.format_exc()}'
@@ -474,15 +396,12 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
if _ws_mode:
req_id = event.get('req_id', '') if isinstance(event, dict) else getattr(event, 'req_id', '')
if text:
if req_id:
await self.bot.reply_text(req_id, text)
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)
if req_id:
await self.bot.reply_text(req_id, content)
else:
await self.bot.set_message(event.message_id, content)
else:
await self.bot.set_message(event.message_id, text)
await self.bot.set_message(event.message_id, content)
async def reply_message_chunk(
self,
@@ -492,8 +411,7 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
quote_origin: bool = False,
is_final: bool = False,
):
items = await self.message_converter.yiri2target(message)
text = self._join_text_components(items)
content = await self.message_converter.yiri2target(message)
_ws_mode = not self.config.get('enable-webhook', False)
# Synthetic events (e.g. button-click triggered form resume) have
@@ -502,7 +420,7 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
# of the stream/reply path.
spo = message_source.source_platform_object
if spo is None:
return await self._handle_synthetic_chunk(message_source, bot_message, text, is_final, _ws_mode)
return await self._handle_synthetic_chunk(message_source, bot_message, content, is_final, _ws_mode)
msg_id = spo.message_id
@@ -534,7 +452,7 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
form_data.get('actions', []) or [],
)
except Exception:
fallback = text or '(人工输入)'
fallback = content or '(人工输入)'
if _ws_mode:
event = message_source.source_platform_object
req_id = event.get('req_id', '') if isinstance(event, dict) else getattr(event, 'req_id', '')
@@ -545,22 +463,17 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
return {'stream': False, 'form': True, 'fallback': True}
if _ws_mode:
success = await self.bot.push_stream_chunk(msg_id, text, is_final=is_final)
success = await self.bot.push_stream_chunk(msg_id, content, is_final=is_final)
if not success and is_final:
event = message_source.source_platform_object
req_id = event.get('req_id', '')
if req_id:
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)
await self.bot.reply_text(req_id, content)
return {'stream': success}
else:
success = await self.bot.push_stream_chunk(msg_id, text, is_final=is_final)
success = await self.bot.push_stream_chunk(msg_id, content, is_final=is_final)
if not success and is_final:
await self.bot.set_message(msg_id, text)
await self.bot.set_message(msg_id, content)
return {'stream': success}
async def is_stream_output_supported(self) -> bool:
@@ -714,9 +627,8 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
async def send_message(self, target_type, target_id, message):
_ws_mode = not self.config.get('enable-webhook', False)
if _ws_mode:
items = await self.message_converter.yiri2target(message)
text = self._join_text_components(items)
await self.bot.send_message(target_id, text)
content = await self.message_converter.yiri2target(message)
await self.bot.send_message(target_id, content)
else:
pass
+2 -19
View File
@@ -264,11 +264,6 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
if not self._control_token and allow_generate:
self._control_token = secrets.token_urlsafe(48)
if not self._control_token:
if self.runtime_profile == 'shared':
raise PluginRuntimeNotConnectedError(
f'{PLUGIN_RUNTIME_CONTROL_TOKEN_ENV} must be configured with a strong shared secret '
'for a Cloud Plugin Runtime'
)
return {}
try:
self._control_token = validate_runtime_secret(
@@ -701,13 +696,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
}
self._known_desired_states.update({state.binding.installation_uuid: state for state in desired_states})
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,
)
result = await runtime_handler.reconcile_plugin_installations(tuple(self._known_desired_states.values()))
await self._repair_reconcile_missing_artifacts(self._known_desired_states, result)
self._record_reconcile_failures(self._known_desired_states, result)
@@ -742,13 +731,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
if state.binding.installation_uuid in all_states:
raise ValueError('Duplicate plugin installation UUID across projected Workspaces')
all_states[state.binding.installation_uuid] = state
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,
)
result = await runtime_handler.reconcile_plugin_installations(tuple(all_states.values()))
await self._repair_reconcile_missing_artifacts(all_states, result)
self._record_reconcile_failures(all_states, result)
for installation_uuid, previous in tuple(self._known_desired_states.items()):
+15 -126
View File
@@ -11,8 +11,6 @@ import traceback
from dataclasses import dataclass
import sqlalchemy
import sqlalchemy.dialects.postgresql
import sqlalchemy.dialects.sqlite
from langbot_plugin.runtime.io import handler
from langbot_plugin.runtime.io.connection import Connection
@@ -433,19 +431,6 @@ class RuntimeConnectionHandler(handler.Handler):
return f'{identity.plugin_author}/{identity.plugin_name}'
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
def _binary_storage_key(
cls,
@@ -911,82 +896,25 @@ class RuntimeConnectionHandler(handler.Handler):
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
.where(persistence_bstorage.BinaryStorage.unique_key == unique_key)
)
storage = result.first()
if storage is None:
legacy_key = self._legacy_binary_storage_key(
action_context,
owner_type=owner_type,
owner=owner,
key=key,
)
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_bstorage.BinaryStorage)
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
.where(persistence_bstorage.BinaryStorage.unique_key == legacy_key)
.where(persistence_bstorage.BinaryStorage.key == key)
.where(persistence_bstorage.BinaryStorage.owner_type == owner_type)
.where(persistence_bstorage.BinaryStorage.owner == owner)
)
storage = result.first()
if storage is not None:
update_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_bstorage.BinaryStorage)
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
.where(persistence_bstorage.BinaryStorage.unique_key == legacy_key)
.where(persistence_bstorage.BinaryStorage.key == key)
.where(persistence_bstorage.BinaryStorage.owner_type == owner_type)
.where(persistence_bstorage.BinaryStorage.owner == owner)
.values(unique_key=unique_key, value=value)
)
if update_result.rowcount:
return handler.ActionResponse.success(data={})
canonical_update = await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_bstorage.BinaryStorage)
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
.where(persistence_bstorage.BinaryStorage.unique_key == unique_key)
.where(persistence_bstorage.BinaryStorage.key == key)
.where(persistence_bstorage.BinaryStorage.owner_type == owner_type)
.where(persistence_bstorage.BinaryStorage.owner == owner)
.values(value=value)
)
if canonical_update.rowcount:
return handler.ActionResponse.success(data={})
storage = None
if storage is not None:
if result.first() is not None:
await self.ap.persistence_mgr.execute_async(
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)
)
return handler.ActionResponse.success(data={})
dialect_name = self.ap.persistence_mgr.get_db_engine().dialect.name
insert = {
'postgresql': sqlalchemy.dialects.postgresql.insert,
'sqlite': sqlalchemy.dialects.sqlite.insert,
}.get(dialect_name)
if insert is None:
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,
else:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.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(
data={},
@@ -1018,29 +946,6 @@ class RuntimeConnectionHandler(handler.Handler):
)
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:
return handler.ActionResponse.error(
message=f'Storage with key {key} not found',
@@ -1076,19 +981,10 @@ class RuntimeConnectionHandler(handler.Handler):
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(
sqlalchemy.delete(persistence_bstorage.BinaryStorage)
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
.where(persistence_bstorage.BinaryStorage.unique_key.in_((unique_key, legacy_key)))
.where(persistence_bstorage.BinaryStorage.key == key)
.where(persistence_bstorage.BinaryStorage.owner_type == owner_type)
.where(persistence_bstorage.BinaryStorage.owner == owner)
.where(persistence_bstorage.BinaryStorage.unique_key == unique_key)
)
return handler.ActionResponse.success(
@@ -1116,7 +1012,7 @@ class RuntimeConnectionHandler(handler.Handler):
return handler.ActionResponse.success(
data={
'keys': list(dict.fromkeys(result.scalars().all())),
'keys': result.scalars().all(),
},
)
@@ -1677,15 +1573,13 @@ class RuntimeConnectionHandler(handler.Handler):
async def reconcile_plugin_installations(
self,
installations: tuple[PluginInstallationDesiredState, ...],
*,
timeout: float = 300,
) -> dict[str, Any]:
request = ReconcilePluginInstallationsRequest(installations=installations)
with self.installation_scope(None):
return await self.call_action(
LangBotToRuntimeAction.RECONCILE_PLUGIN_INSTALLATIONS,
request.model_dump(),
timeout=timeout,
timeout=120,
)
async def apply_plugin_installation(
@@ -2068,16 +1962,11 @@ class RuntimeConnectionHandler(handler.Handler):
async def get_debug_info(self, execution_context: ExecutionContext) -> dict[str, Any]:
"""Get debug information including debug key and WS URL"""
action_context = ActionContext(
instance_uuid=execution_context.instance_uuid,
workspace_uuid=execution_context.workspace_uuid,
placement_generation=execution_context.placement_generation,
)
result = await self.call_action(
LangBotToRuntimeAction.GET_DEBUG_INFO,
{},
timeout=10,
action_context=action_context,
action_context=execution_context,
)
return result
@@ -24,10 +24,7 @@ class SeekDBEmbedding(requester.ProviderAPIRequester):
try:
import pyseekdb
except ImportError:
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)."
)
raise ImportError('pyseekdb is not installed. Install it with: pip install pyseekdb')
self._embedding_function = pyseekdb.get_default_embedding_function()
+4 -13
View File
@@ -27,16 +27,10 @@ class SafeRegexTimeoutError(SafeRegexError):
"""Raised when the regex engine exhausts the operation CPU budget."""
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')
def _validate_patterns(patterns: Sequence[str]) -> tuple[str, ...]:
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:
if not isinstance(pattern, str):
raise SafeRegexError('Regex patterns must be strings')
@@ -121,9 +115,8 @@ def _mask_patterns_sync(
mask: str,
mask_word: str,
timeout_seconds: float,
max_pattern_count: int,
) -> tuple[bool, str]:
normalized_patterns = _validate_patterns(patterns, max_pattern_count=max_pattern_count)
normalized_patterns = _validate_patterns(patterns)
_validate_input(value)
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')
@@ -169,7 +162,6 @@ async def mask_patterns(
mask: str,
mask_word: str,
timeout_seconds: float = DEFAULT_OPERATION_TIMEOUT_SECONDS,
max_pattern_count: int = MAX_PATTERN_COUNT,
) -> tuple[bool, str]:
"""Apply untrusted masking patterns with bounded CPU and output growth."""
@@ -182,5 +174,4 @@ async def mask_patterns(
mask=mask,
mask_word=mask_word,
timeout_seconds=timeout_seconds,
max_pattern_count=max_pattern_count,
)
+1 -4
View File
@@ -42,10 +42,7 @@ class SeekDBVectorDatabase(VectorDatabase):
def __init__(self, ap: app.Application):
if not SEEKDB_AVAILABLE:
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)."
)
raise ImportError('pyseekdb is not installed. Install it with: pip install pyseekdb')
self.ap = ap
config = self.ap.instance_config.data['vdb']['seekdb']
@@ -240,7 +240,7 @@ class InvitationDeliveryService:
@staticmethod
def _plain_text(workspace_name: str, invitation_link: str) -> str:
return (
'You have been invited to join a Workspace in LangBot\n\n'
'You have been invited to LangBot Cloud\n\n'
f'Join the Workspace “{workspace_name}” to collaborate with your team.\n\n'
f'Accept invitation: {invitation_link}\n\n'
'This secure invitation expires in 7 days and can only be accepted by the email address '
@@ -258,77 +258,30 @@ class InvitationDeliveryService:
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>Join {escaped_workspace} in LangBot</title>
<title>Join {escaped_workspace} on LangBot Cloud</title>
</head>
<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} in LangBot.</div>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="width:100%;background:#f4f7fb;">
<tr>
<td align="center" style="padding:48px 16px;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="width:100%;max-width:600px;">
<tr>
<td style="padding:0 4px 20px;">
<img src="https://docs.langbot.app/langbot-logo.png" alt="LangBot" width="34" height="34" style="display:inline-block;width:34px;height:34px;border:0;vertical-align:middle;">
<span style="display:inline-block;margin-left:10px;vertical-align:middle;font-size:18px;font-weight:700;letter-spacing:-.01em;">LangBot</span>
</td>
</tr>
<tr>
<td style="background:#ffffff;border-radius:10px;overflow:hidden;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
<tr>
<td style="padding:42px 42px 38px;">
<div style="margin:0 0 12px;font-size:13px;line-height:1.4;font-weight:600;color:#5f6f84;">Workspace invitation</div>
<h1 style="margin:0 0 16px;font-size:28px;line-height:1.25;font-weight:700;letter-spacing:-.025em;color:#111827;">Youre invited to collaborate</h1>
<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 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;">&nbsp;</td></tr>
</table>
<table role="presentation" cellspacing="0" cellpadding="0" border="0">
<tr>
<td style="background:#2563eb;border-radius:8px;">
<a href="{escaped_link}" target="_blank" style="display:inline-block;padding:13px 22px;font-size:15px;line-height:1.2;font-weight:700;color:#ffffff;text-decoration:none;border-radius:8px;">Accept invitation</a>
</td>
</tr>
</table>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
<tr><td height="32" style="height:32px;font-size:0;line-height:0;">&nbsp;</td></tr>
</table>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="border-top:1px solid #e8edf4;">
<tr>
<td style="padding-top:22px;">
<p style="margin:0 0 10px;font-size:13px;line-height:1.6;color:#5f6f84;">For your security, this invitation expires in 7 days and only works for the email address that received it.</p>
<a href="{escaped_link}" target="_blank" style="font-size:13px;line-height:1.6;font-weight:600;color:#2563eb;text-decoration:none;">Open invitation link&nbsp;&rarr;</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td align="center" style="padding:20px 24px 0;font-size:12px;line-height:1.6;color:#5f6f84;">
Sent by LangBot<br>
If you were not expecting this invitation, you can safely ignore this email.
</td>
</tr>
</table>
</td>
</tr>
<body style="margin:0;background:#f4f7fb;color:#152033;font-family:Inter,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;">
<div style="display:none;max-height:0;overflow:hidden;opacity:0;">You have been invited to join {escaped_workspace} on LangBot Cloud.</div>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#f4f7fb;padding:40px 16px;">
<tr><td align="center">
<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);">
<tr><td style="padding:28px 36px;background:linear-gradient(135deg,#0f172a,#1d4ed8);color:#ffffff;">
<div style="font-size:14px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;opacity:.78;">LangBot Cloud</div>
<div style="font-size:26px;font-weight:700;margin-top:8px;line-height:1.25;">Youre invited</div>
</td></tr>
<tr><td style="padding:36px;">
<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>
<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>
<table role="presentation" cellspacing="0" cellpadding="0"><tr><td style="border-radius:9px;background:#2563eb;">
<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></tr></table>
<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>
<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>
<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>
</td></tr>
<tr><td style="padding:20px 36px;border-top:1px solid #eef2f7;font-size:12px;line-height:1.6;color:#94a3b8;">If you were not expecting this invitation, you can safely ignore this email.</td></tr>
</table>
</td></tr>
</table>
</body>
</html>'''
+2 -8
View File
@@ -181,11 +181,6 @@ vdb:
host: localhost
port: 6333
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:
mode: embedded # 'embedded' or 'server'
# Embedded mode options:
@@ -333,9 +328,8 @@ box:
enabled: true
backend: 'local' # 'local' (Docker/nsjail), 'docker', 'nsjail', or 'e2b'. Can be written via BOX__BACKEND.
runtime:
# LANGBOT_BOX_CONTROL_TOKEN is optional for OSS external WebSocket
# runtimes. To protect an exposed endpoint, set the same strong secret
# in both LangBot and Box. Keep it out of this config file.
# External WebSocket runtimes also require LANGBOT_BOX_CONTROL_TOKEN in
# both LangBot and Box. Keep the shared secret out of this config file.
endpoint: '' # External Box Runtime base URL, e.g. 'ws://127.0.0.1:5410'. Leave empty for local auto-managed runtime.
limits:
max_sessions: 64
+8
View File
@@ -1240,6 +1240,14 @@
// Root container
var root = document.createElement("div");
root.id = "langbot-widget-root";
root.langbotDestroy = function () {
wsDisconnect();
if (state.historyReloadTimer) {
clearTimeout(state.historyReloadTimer);
state.historyReloadTimer = null;
}
root.remove();
};
document.body.appendChild(root);
var shadow = root.attachShadow({ mode: "open" });
+16
View File
@@ -254,6 +254,22 @@ class TestPipelinesCRUDEndpoints:
assert data['code'] == 0
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
async def test_update_pipeline_success(self, quart_test_client):
"""PUT /api/v1/pipelines/{uuid} updates pipeline."""
+1 -12
View File
@@ -235,24 +235,13 @@ async def test_debug_key_requires_resource_manage_permission(plugin_security_api
assert operator_denied.status_code == 403
assert allowed.status_code == 200
assert (await allowed.get_json())['data'] == {
'debug_url': 'ws://localhost:5401/plugin/debug/ws',
'debug_url': 'http://localhost:5401',
'plugin_debug_key': 'runtime-debug-secret',
'expires_at': '2026-08-04T12:00:00Z',
}
application.plugin_connector.get_debug_info.assert_awaited_once()
@pytest.mark.asyncio
async def test_debug_info_uses_websocket_endpoint_for_legacy_config(plugin_security_api):
application, client, _ = plugin_security_api
application.instance_config.data['plugin'].pop('display_plugin_debug_url')
response = await client.get('/api/v1/plugins/debug-info', headers=_headers('manager-token'))
assert response.status_code == 200
assert (await response.get_json())['data']['debug_url'] == 'ws://localhost:5401/plugin/debug/ws'
@pytest.mark.asyncio
async def test_viewer_cannot_read_plugin_runtime_logs(plugin_security_api):
application, client, _ = plugin_security_api
-23
View File
@@ -307,7 +307,6 @@ class TestUserInitEndpoint:
assert data['data'] == {
'initialized': True,
'authenticated_invitation_acceptance_enabled': False,
'invitation_registration_enabled': True,
'password_login_enabled': True,
'space_login_enabled': False,
}
@@ -331,28 +330,6 @@ class TestUserInitEndpoint:
assert data['data'] == {
'initialized': 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,
'space_login_enabled': True,
}
+25 -65
View File
@@ -165,51 +165,34 @@ async def test_bind_state_is_account_bound_and_requires_authentication(space_oau
@pytest.mark.asyncio
async def test_redirect_allows_any_http_or_https_origin(space_oauth_api):
async def test_redirect_origin_and_callback_path_are_restricted(space_oauth_api):
_, client = space_oauth_api
responses = [
await client.get(
'/api/v1/user/space/authorize-url',
query_string={'redirect_uri': redirect_uri},
headers={'Origin': 'https://irrelevant.example'},
)
for redirect_uri in (
'https://langbot.example/auth/space/callback',
'https://gateway.example:8443/auth/space/callback',
'https://192.0.2.10/auth/space/callback',
'http://localhost:5300/auth/space/callback',
'http://127.0.0.1:5300/auth/space/callback',
'http://[::1]:5300/auth/space/callback',
'http://langbot.example/auth/space/callback',
'http://192.0.2.10:5300/auth/space/callback',
)
]
wrong_origin = await client.get(
'/api/v1/user/space/authorize-url',
query_string={'redirect_uri': 'https://evil.example/auth/space/callback'},
headers={'Origin': 'http://localhost'},
)
wrong_path = await client.get(
'/api/v1/user/space/authorize-url',
query_string={'redirect_uri': 'http://localhost/arbitrary'},
headers={'Origin': 'http://localhost'},
)
forged_origin = await client.get(
'/api/v1/user/space/authorize-url',
query_string={'redirect_uri': 'https://evil.example/auth/space/callback'},
headers={'Origin': 'https://evil.example'},
)
forged_host = await client.get(
'/api/v1/user/space/authorize-url',
query_string={'redirect_uri': 'https://evil.example/auth/space/callback'},
headers={'Host': 'evil.example'},
)
assert all(response.status_code == 200 for response in responses)
payloads = [await response.get_json() for response in responses]
assert all(payload['code'] == 0 for payload in payloads)
@pytest.mark.asyncio
async def test_redirect_rejects_invalid_callback_shape(space_oauth_api):
_, client = space_oauth_api
responses = [
await client.get(
'/api/v1/user/space/authorize-url',
query_string={'redirect_uri': redirect_uri},
)
for redirect_uri in (
'https://langbot.example/arbitrary',
'https://langbot.example/auth/space/callback?next=https://evil.example',
'https://user@langbot.example/auth/space/callback',
'https://langbot.example/auth/space/callback#fragment',
)
]
payloads = [await response.get_json() for response in responses]
assert all(payload['code'] == 1 for payload in payloads)
assert (await wrong_origin.get_json())['code'] == 1
assert (await wrong_path.get_json())['code'] == 1
assert (await forged_origin.get_json())['code'] == 1
assert (await forged_host.get_json())['code'] == 1
@pytest.mark.asyncio
@@ -312,29 +295,6 @@ 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')
@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
async def test_cloud_workspace_owner_is_always_space_bound_after_login(space_oauth_api):
application, client = space_oauth_api
@@ -81,7 +81,6 @@ async def create_legacy_resource_schema(engine, *, instance_uuid: str) -> None:
sa.Column('key', sa.String(255), nullable=False),
sa.Column('owner_type', sa.String(255), nullable=False),
sa.Column('owner', sa.String(255), nullable=False),
sa.Column('value', sa.LargeBinary, nullable=False),
)
mcp_servers = _uuid_table(
metadata,
@@ -211,13 +210,7 @@ async def create_legacy_resource_schema(engine, *, instance_uuid: str) -> None:
await conn.execute(bots.insert().values(uuid='bot-1', name='bot', updated_at=now))
await conn.execute(bot_admins.insert().values(bot_uuid='bot-1', launcher_type='person', launcher_id='owner'))
await conn.execute(
binary_storages.insert().values(
unique_key='plugin:demo:key',
key='key',
owner_type='plugin',
owner='demo',
value=b'legacy-plugin-value',
)
binary_storages.insert().values(unique_key='plugin:demo:key', key='key', owner_type='plugin', owner='demo')
)
await conn.execute(mcp_servers.insert().values(uuid='mcp-1', name='shared-name', enable=True, updated_at=now))
await conn.execute(model_providers.insert().values(uuid='provider-1', name='provider', requester='openai'))
@@ -76,26 +76,6 @@ async def test_legacy_sqlite_resources_are_backfilled_and_contracted(tmp_path):
)
assert legacy_kb['collection_id'] == 'collection-1'
assert legacy_kb['legacy_vector_collection'] == 1
legacy_binary_storage = (
(
await conn.execute(
sa.text(
'SELECT workspace_uuid, unique_key, key, owner_type, owner, value '
"FROM binary_storages WHERE owner_type = 'plugin' AND owner = 'demo'"
)
)
)
.mappings()
.one()
)
assert legacy_binary_storage == {
'workspace_uuid': workspace_uuid,
'unique_key': 'plugin:demo:key',
'key': 'key',
'owner_type': 'plugin',
'owner': 'demo',
'value': b'legacy-plugin-value',
}
assert (
await conn.scalar(
sa.text(
@@ -229,8 +209,8 @@ async def test_sqlite_scoped_keys_allow_cross_workspace_but_reject_same_workspac
await conn.execute(
sa.text(
'INSERT INTO binary_storages '
'(workspace_uuid, unique_key, key, owner_type, owner, value) '
"VALUES (:workspace_uuid, 'plugin:demo:key', 'key', 'plugin', 'demo', X'')"
'(workspace_uuid, unique_key, key, owner_type, owner) '
"VALUES (:workspace_uuid, 'plugin:demo:key', 'key', 'plugin', 'demo')"
),
{'workspace_uuid': second_workspace_uuid},
)
@@ -2,7 +2,6 @@ from __future__ import annotations
import json
import logging
import os
import pathlib
import sqlite3
@@ -10,7 +9,7 @@ import pytest
import sqlalchemy as sa
from sqlalchemy.ext.asyncio import create_async_engine
from langbot.pkg.persistence import alembic_runner, sqlite_migration_backup
from langbot.pkg.persistence import alembic_runner
from langbot.pkg.persistence.mgr import PersistenceManager
from .resource_migration_support import create_legacy_resource_schema
@@ -106,31 +105,3 @@ async def test_failed_tenancy_migration_restores_backup_and_revision(
assert await alembic_runner.get_alembic_current(engine) == alembic_runner.get_alembic_head()
finally:
await engine.dispose()
async def test_backup_retries_transient_reopen_failure_after_replace(tmp_path, monkeypatch):
database_path = tmp_path / 'legacy-bind-mount.db'
engine = create_async_engine(f'sqlite+aiosqlite:///{database_path}')
real_open = os.open
transient_failures = 0
def transient_open(path, flags, *args, **kwargs):
nonlocal transient_failures
candidate = pathlib.Path(path)
if candidate.suffix == '.sqlite3' and candidate.parent.name == 'migration-backups' and transient_failures == 0:
transient_failures += 1
raise FileNotFoundError(2, 'simulated delayed bind-mount visibility', str(candidate))
return real_open(path, flags, *args, **kwargs)
try:
await create_legacy_resource_schema(engine, instance_uuid='backup-bind-mount')
await alembic_runner.run_alembic_stamp(engine, '0008_mcp_resource_prefs')
monkeypatch.setattr(sqlite_migration_backup.os, 'open', transient_open)
await _manager(engine)._run_alembic_migrations()
assert transient_failures == 1
assert await alembic_runner.get_alembic_current(engine) == alembic_runner.get_alembic_head()
assert len(_manifest_payloads(tmp_path / 'migration-backups')) == 2
finally:
await engine.dispose()
@@ -179,17 +179,13 @@ async def test_existing_oss_workspace_is_rekeyed_to_instance_identity(tmp_path):
)
async with engine.begin() as conn:
await conn.run_sync(schema.create_all)
await conn.execute(sa.text("INSERT INTO metadata (key, value) VALUES ('instance_uuid', :value)"), {'value': instance_id})
await conn.execute(
sa.text("INSERT INTO metadata (key, value) VALUES ('instance_uuid', :value)"), {'value': instance_id}
)
await conn.execute(
sa.text(
"INSERT INTO workspaces (uuid, instance_uuid, slug, source) VALUES (:uuid, :instance, 'default', 'local')"
),
sa.text("INSERT INTO workspaces (uuid, instance_uuid, slug, source) VALUES (:uuid, :instance, 'default', 'local')"),
{'uuid': old_workspace_uuid, 'instance': instance_id},
)
await conn.execute(
sa.text('INSERT INTO tenant_rows (id, workspace_uuid) VALUES (1, :uuid)'),
sa.text("INSERT INTO tenant_rows (id, workspace_uuid) VALUES (1, :uuid)"),
{'uuid': old_workspace_uuid},
)
await run_alembic_stamp(engine, '0016_support_admin_sessions')
@@ -197,8 +193,8 @@ async def test_existing_oss_workspace_is_rekeyed_to_instance_identity(tmp_path):
await run_alembic_upgrade(engine, 'head')
async with engine.connect() as conn:
assert (await conn.execute(sa.text('SELECT uuid FROM workspaces'))).scalar_one() == canonical_uuid
assert (await conn.execute(sa.text('SELECT workspace_uuid FROM tenant_rows'))).scalar_one() == canonical_uuid
assert (await conn.execute(sa.text("SELECT uuid FROM workspaces"))).scalar_one() == canonical_uuid
assert (await conn.execute(sa.text("SELECT workspace_uuid FROM tenant_rows"))).scalar_one() == canonical_uuid
await engine.dispose()
@@ -415,45 +411,6 @@ async def test_persistence_startup_defers_workspace_tables_until_account_upgrade
await engine.dispose()
async def test_persistence_startup_preserves_legacy_workspace_membership_with_foreign_keys(
tmp_path,
monkeypatch,
):
database_path = tmp_path / 'startup-foreign-keys.db'
engine = create_async_engine(f'sqlite+aiosqlite:///{database_path}')
try:
await _create_legacy_schema(engine)
await run_alembic_stamp(engine, '0008_mcp_resource_prefs')
finally:
await engine.dispose()
monkeypatch.setattr(constants, 'instance_id', 'instance_migration_test')
application = type('Application', (), {})()
application.logger = logging.getLogger('workspace-startup-foreign-keys-test')
application.instance_config = type(
'InstanceConfig',
(),
{'data': {'database': {'use': 'sqlite', 'sqlite': {'path': str(database_path)}}}},
)()
manager = PersistenceManager(application)
await manager.initialize()
try:
async with manager.get_db_engine().connect() as conn:
workspace = (
(await conn.execute(sa.text("SELECT * FROM workspaces WHERE source = 'local'"))).mappings().one()
)
membership = (await conn.execute(sa.text('SELECT * FROM workspace_memberships'))).mappings().one()
foreign_keys = await conn.scalar(sa.text('PRAGMA foreign_keys'))
assert workspace['created_by_account_uuid'] == membership['account_uuid']
assert membership['role'] == 'owner'
assert membership['status'] == 'active'
assert foreign_keys == 1
finally:
await manager.shutdown()
async def test_oss_workspace_identity_rekeys_fk_graph_and_metadata(tmp_path):
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "workspace-rekey.db"}')
try:
@@ -468,7 +425,7 @@ async def test_oss_workspace_identity_rekeys_fk_graph_and_metadata(tmp_path):
assert instance_uuid
await conn.execute(
sa.text(
'INSERT INTO workspace_metadata (workspace_uuid, key, value) '
"INSERT INTO workspace_metadata (workspace_uuid, key, value) "
"VALUES (:workspace_uuid, 'migration_probe', 'present')"
),
{'workspace_uuid': old_uuid},
@@ -476,7 +433,7 @@ async def test_oss_workspace_identity_rekeys_fk_graph_and_metadata(tmp_path):
await conn.execute(
sa.text(
"INSERT INTO metadata (key, value) VALUES ('oss_workspace_uuid', :workspace_uuid) "
'ON CONFLICT(key) DO UPDATE SET value = excluded.value'
"ON CONFLICT(key) DO UPDATE SET value = excluded.value"
),
{'workspace_uuid': old_uuid},
)
@@ -485,16 +442,12 @@ async def test_oss_workspace_identity_rekeys_fk_graph_and_metadata(tmp_path):
expected_uuid = workspace_uuid_from_instance_id(instance_uuid)
async with engine.connect() as conn:
assert await conn.scalar(sa.text("SELECT uuid FROM workspaces WHERE source = 'local'")) == expected_uuid
assert (
await conn.scalar(
sa.text("SELECT workspace_uuid FROM workspace_metadata WHERE key = 'migration_probe'")
)
== expected_uuid
)
assert (
await conn.scalar(sa.text("SELECT value FROM metadata WHERE key = 'oss_workspace_uuid'"))
== expected_uuid
)
assert await conn.scalar(
sa.text("SELECT workspace_uuid FROM workspace_metadata WHERE key = 'migration_probe'")
) == expected_uuid
assert await conn.scalar(
sa.text("SELECT value FROM metadata WHERE key = 'oss_workspace_uuid'")
) == expected_uuid
finally:
await engine.dispose()
@@ -9,8 +9,9 @@ Source: src/langbot/pkg/api/http/service/bot.py
from __future__ import annotations
import pytest
from unittest.mock import AsyncMock, Mock, patch
from unittest.mock import AsyncMock, MagicMock, Mock, patch
from types import SimpleNamespace
import json
import uuid
from langbot.pkg.api.http.service.bot import BotService
@@ -241,6 +242,29 @@ class TestBotServiceGetRuntimeBotInfo:
assert result['adapter_runtime_values']['webhook_url'] == '/bots/wecom-uuid'
assert result['adapter_runtime_values']['webhook_full_url'] == 'http://127.0.0.1:5300/bots/wecom-uuid'
async def test_get_runtime_bot_info_returns_webhook_for_http_bot(self):
ap = SimpleNamespace(
instance_config=SimpleNamespace(
data={'api': {'webhook_prefix': 'https://bot.example.com'}}
),
platform_mgr=SimpleNamespace(get_bot_by_uuid=AsyncMock(return_value=None)),
)
service = BotService(ap)
service.get_bot = AsyncMock(
return_value={
'uuid': 'http-bot-uuid',
'name': 'HTTP Bot',
'adapter': 'http_bot',
'adapter_config': {},
}
)
result = await service.get_runtime_bot_info(WORKSPACE_UUID, 'http-bot-uuid')
assert result['adapter_runtime_values']['webhook_full_url'] == (
'https://bot.example.com/bots/http-bot-uuid'
)
async def test_get_runtime_bot_info_no_webhook_for_telegram(self):
"""Returns no webhook URL for non-webhook adapters like telegram."""
# Setup
@@ -605,6 +629,77 @@ class TestBotServiceListEventLogs:
assert total == 5
class TestBotServiceHttpBotInboundTest:
async def test_sends_signed_message_through_public_ingress(self):
ap = SimpleNamespace(
instance_config=SimpleNamespace(data={'api': {'port': 5300}}),
)
service = BotService(ap)
service.get_bot = AsyncMock(
return_value={
'uuid': 'http-bot-uuid',
'adapter': 'http_bot',
'adapter_config': {
'signature_required': True,
'inbound_secret': 'test-secret',
},
'enable': True,
}
)
response = MagicMock(status=202)
session = MagicMock()
session.post.return_value.__aenter__ = AsyncMock(return_value=response)
session.post.return_value.__aexit__ = AsyncMock(return_value=None)
with (
patch('langbot.pkg.api.http.service.bot.httpclient.get_session', return_value=session),
patch(
'langbot.pkg.api.http.service.bot.httpclient.read_json_limited',
new=AsyncMock(
return_value={
'code': 0,
'data': {
'session_id': 'wizard-session',
'accepted_message_id': 'in-message',
},
}
),
),
):
result = await service.send_http_bot_test_message(
WORKSPACE_UUID,
'http-bot-uuid',
'hello',
)
assert result['accepted_message_id'] == 'in-message'
request = session.post.call_args
assert request.args[0] == 'http://127.0.0.1:5300/bots/http-bot-uuid'
payload = json.loads(request.kwargs['data'])
assert payload['message'] == [{'type': 'Plain', 'text': 'hello'}]
headers = request.kwargs['headers']
assert headers['X-LB-Timestamp']
assert headers['X-LB-Signature'].startswith('sha256=')
async def test_rejects_non_http_bot(self):
service = BotService(SimpleNamespace())
service.get_bot = AsyncMock(
return_value={
'uuid': 'telegram-bot',
'adapter': 'telegram',
'adapter_config': {},
'enable': True,
}
)
with pytest.raises(ValueError, match='only available for HTTP Bot'):
await service.send_http_bot_test_message(
WORKSPACE_UUID,
'telegram-bot',
'hello',
)
class TestBotServiceSendMessage:
"""Tests for send_message method."""
@@ -820,6 +820,100 @@ class TestSpaceServiceGetModels:
await service.get_models()
class TestSpaceServiceGetModelSelection:
"""Tests for availability-ranked model selection."""
@pytest.mark.parametrize('response_shape', ['direct', 'models-envelope', 'availability-wrapper'])
async def test_preserves_selection_order_and_category_query(self, response_shape):
ap = SimpleNamespace(instance_config=SimpleNamespace(data={}))
service = SpaceService(ap)
models = [
{
'uuid': 'best-model',
'model_id': 'best-chat-model',
'provider': 'provider-1',
'category': 'chat',
'status': 'active',
},
{
'uuid': 'fallback-model',
'model_id': 'fallback-chat-model',
'provider': 'provider-2',
'category': 'chat',
'status': 'active',
},
]
if response_shape == 'models-envelope':
data = {'models': models}
elif response_shape == 'availability-wrapper':
data = [
{'model': model, 'latency_ms': index + 10, 'http_code': 200}
for index, model in enumerate(models)
]
else:
data = models
payload = {'code': 0, 'data': data}
mock_response = MagicMock(status=200)
with (
patch('langbot.pkg.api.http.service.space.httpclient.get_session') as get_session,
patch(
'langbot.pkg.api.http.service.space.httpclient.read_json_limited',
new=AsyncMock(return_value=payload),
),
):
session = MagicMock()
session.get.return_value.__aenter__ = AsyncMock(return_value=mock_response)
session.get.return_value.__aexit__ = AsyncMock(return_value=None)
get_session.return_value = session
result = await service.get_model_selection('chat')
assert [model.uuid for model in result] == ['best-model', 'fallback-model']
session.get.assert_called_once_with(
'https://space.langbot.app/api/v1/models/selection',
params={'category': 'chat'},
)
async def test_recommended_model_uses_first_selection_and_refreshes_once(self):
local_model = SimpleNamespace(uuid='local-model-uuid', name='best-chat-model')
persistence = SimpleNamespace(
execute_async=AsyncMock(
side_effect=[
_create_mock_result(first_item=None),
_create_mock_result(first_item=local_model),
]
)
)
model_mgr = SimpleNamespace(sync_new_models_from_space=AsyncMock())
ap = SimpleNamespace(
instance_config=SimpleNamespace(data={}),
persistence_mgr=persistence,
model_mgr=model_mgr,
)
service = SpaceService(ap)
service.get_model_selection = AsyncMock(
return_value=[
SimpleNamespace(uuid='best-upstream-uuid', model_id='best-chat-model'),
SimpleNamespace(uuid='fallback-upstream-uuid', model_id='fallback-chat-model'),
]
)
context = SimpleNamespace(
instance_uuid='instance',
workspace_uuid='workspace',
placement_generation=1,
principal=SimpleNamespace(),
entitlement_revision=0,
)
result = await service.get_recommended_chat_model(context)
assert result == {'uuid': 'local-model-uuid', 'name': 'best-chat-model'}
service.get_model_selection.assert_awaited_once_with('chat')
model_mgr.sync_new_models_from_space.assert_awaited_once()
assert persistence.execute_async.await_count == 2
class TestSpaceServiceCreditsCache:
"""Tests for credits cache behavior."""
@@ -377,7 +377,7 @@ class TestUserServiceAuthenticate:
service = UserService(ap)
# Execute & Verify
with pytest.raises(ValueError, match='请使用 LangBot登录'):
with pytest.raises(ValueError, match='请使用 Space登录'):
await service.authenticate('space@example.com', 'password')
@@ -726,7 +726,7 @@ class TestUserServiceCreateOrUpdateSpaceUser:
)
service = UserService(ap)
with pytest.raises(ControlPlaneDirectoryRequiredError, match='LangBot Account'):
with pytest.raises(ControlPlaneDirectoryRequiredError, match='Space account'):
await service.register_invited_account('invite-token', 'member@example.com', 'password')
async def test_create_or_update_new_space_user_first_init(self):
+2 -20
View File
@@ -24,7 +24,7 @@ from langbot.pkg.box.connector import BoxRuntimeConnector
_CONTROL_TOKEN = 'box-control-token-that-is-longer-than-32-bytes'
def make_app(logger: Mock, runtime_endpoint: str = '', *, cloud: bool = False):
def make_app(logger: Mock, runtime_endpoint: str = ''):
return SimpleNamespace(
logger=logger,
workspace_service=SimpleNamespace(instance_uuid='instance-a'),
@@ -42,7 +42,6 @@ def make_app(logger: Mock, runtime_endpoint: str = '', *, cloud: bool = False):
}
}
),
deployment=SimpleNamespace(mode='cloud' if cloud else 'oss'),
)
@@ -307,27 +306,10 @@ def test_box_runtime_connector_rejects_relay_context_from_other_instance(
)
def test_external_box_runtime_control_headers_are_tokenless_when_secret_is_unset(
monkeypatch: pytest.MonkeyPatch,
):
def test_external_box_runtime_fails_closed_without_control_token(monkeypatch: pytest.MonkeyPatch):
monkeypatch.delenv(BOX_CONTROL_TOKEN_ENV, raising=False)
connector = BoxRuntimeConnector(make_app(Mock(), runtime_endpoint='http://box-runtime:5410'))
assert connector.get_control_headers() == {BOX_INSTANCE_HEADER: 'instance-a'}
def test_cloud_box_runtime_rejects_missing_control_secret(monkeypatch: pytest.MonkeyPatch):
monkeypatch.delenv(BOX_CONTROL_TOKEN_ENV, raising=False)
connector = BoxRuntimeConnector(make_app(Mock(), runtime_endpoint='http://box-runtime:5410', cloud=True))
with pytest.raises(BoxRuntimeUnavailableError, match=BOX_CONTROL_TOKEN_ENV):
connector.get_control_headers()
def test_external_box_runtime_rejects_invalid_configured_control_token(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setenv(BOX_CONTROL_TOKEN_ENV, 'too-short')
connector = BoxRuntimeConnector(make_app(Mock(), runtime_endpoint='http://box-runtime:5410'))
with pytest.raises(BoxRuntimeUnavailableError, match=BOX_CONTROL_TOKEN_ENV):
connector.get_control_headers()
+18 -48
View File
@@ -2163,38 +2163,25 @@ class TestInboundOutboundRoundTrip:
calls = []
async def fake_client_execute(spec):
cmd = spec.cmd
calls.append(cmd)
if 'os.scandir' in cmd:
return BoxExecutionResult(
session_id='s',
backend_name='test',
status=BoxExecutionStatus.COMPLETED,
exit_code=0,
stdout='[{"name": "out.png", "b64": "QUJD"}]',
duration_ms=10,
)
async def fake_execute_tool(parameters, q):
calls.append(parameters['command'])
if 'os.scandir' in parameters['command']:
return {
'ok': True,
'stdout': '[{"name": "out.png", "b64": "QUJD"}]',
'stderr': '',
}
# the rm -rf cleanup call
return BoxExecutionResult(
session_id='s',
backend_name='test',
status=BoxExecutionStatus.COMPLETED,
exit_code=0,
stdout='',
duration_ms=10,
)
return {'ok': True, 'stdout': '', 'stderr': ''}
service.client.execute = AsyncMock(side_effect=fake_client_execute)
service.execute_tool = AsyncMock(return_value={'ok': True, 'stdout': '', 'stderr': ''})
service.execute_tool = AsyncMock(side_effect=fake_execute_tool)
attachments = await service.collect_outbound_attachments(query)
assert len(attachments) == 1
assert attachments[0]['type'] == 'Image'
assert attachments[0]['name'] == 'out.png'
# cleanup (rm -rf) must have been issued after a successful collection
service.execute_tool.assert_awaited_once()
assert 'rm -rf' in service.execute_tool.await_args.args[0]['command']
assert any('rm -rf' in c for c in calls)
@pytest.mark.asyncio
async def test_collect_outbound_empty_still_clears(self):
@@ -2206,33 +2193,16 @@ class TestInboundOutboundRoundTrip:
calls = []
async def fake_client_execute(spec):
cmd = spec.cmd
calls.append(cmd)
if 'os.scandir' in cmd:
return BoxExecutionResult(
session_id='s',
backend_name='test',
status=BoxExecutionStatus.COMPLETED,
exit_code=0,
stdout='[]',
duration_ms=10,
)
return BoxExecutionResult(
session_id='s',
backend_name='test',
status=BoxExecutionStatus.COMPLETED,
exit_code=0,
stdout='',
duration_ms=10,
)
async def fake_execute_tool(parameters, q):
calls.append(parameters['command'])
if 'os.scandir' in parameters['command']:
return {'ok': True, 'stdout': '[]', 'stderr': ''}
return {'ok': True, 'stdout': '', 'stderr': ''}
service.client.execute = AsyncMock(side_effect=fake_client_execute)
service.execute_tool = AsyncMock(return_value={'ok': True, 'stdout': '', 'stderr': ''})
service.execute_tool = AsyncMock(side_effect=fake_execute_tool)
assert await service.collect_outbound_attachments(query) == []
# cleanup (rm -rf) is issued unconditionally now
service.execute_tool.assert_awaited_once()
assert 'rm -rf' in service.execute_tool.await_args.args[0]['command']
assert any('rm -rf' in c for c in calls)
@pytest.mark.asyncio
async def test_passthrough_noop_when_unavailable(self):
-27
View File
@@ -11,7 +11,6 @@ from unittest.mock import AsyncMock, Mock
from langbot.pkg.command import operator
from langbot.pkg.command.cmdmgr import CommandManager
from langbot.pkg.api.http.context import ExecutionContext
from tests.factories import FakeApp, command_query
import langbot_plugin.api.entities.builtin.provider.session as provider_session
@@ -394,32 +393,6 @@ class TestCommandManagerInternalExecute:
assert len(results) == 1
assert results[0].text == 'plugin response'
@pytest.mark.asyncio
async def test_execute_selects_workspace_with_trusted_context(self):
"""Plugin command discovery receives the typed runtime scope."""
fake_app = FakeApp()
mgr = CommandManager(fake_app)
mgr.cmd_list = []
fake_app.plugin_connector.require_workspace_context = AsyncMock()
fake_app.plugin_connector.list_commands = AsyncMock(return_value=[])
ctx = self._create_context(command='help')
ctx.instance_uuid = 'instance-a'
ctx.workspace_uuid = 'workspace-a'
ctx.placement_generation = 4
ctx.query_uuid = 'query-a'
async for _ in mgr._execute(ctx, mgr.cmd_list):
pass
selected = fake_app.plugin_connector.require_workspace_context.await_args.args[0]
assert isinstance(selected, ExecutionContext)
assert selected.instance_uuid == 'instance-a'
assert selected.workspace_uuid == 'workspace-a'
assert selected.placement_generation == 4
assert selected.query_uuid == 'query-a'
@pytest.mark.asyncio
async def test_execute_with_bound_plugins(self):
"""_execute passes bound_plugins to plugin connector."""
+1 -41
View File
@@ -87,10 +87,7 @@ async def test_runtime_resource_stats_are_aggregate_and_constant_time() -> None:
app.platform_mgr = SimpleNamespace(_bots_by_key={})
app.pipeline_mgr = SimpleNamespace(_pipelines_by_key={})
app.rag_mgr = SimpleNamespace(knowledge_bases={})
app.plugin_connector = SimpleNamespace(
_known_desired_states={'installation': object()},
_runtime_available=lambda: True,
)
app.plugin_connector = SimpleNamespace(_known_desired_states={'installation': object()})
app.persistence_mgr = SimpleNamespace(
get_resource_stats=lambda: {
'configured_capacity': 20,
@@ -143,40 +140,3 @@ async def test_runtime_resource_stats_are_aggregate_and_constant_time() -> None:
}
assert stats['models']['providers'] == 1
assert stats['runtimes']['plugin_installations'] == 1
assert stats['runtimes']['plugin_runtime_connected'] is True
@pytest.mark.asyncio
async def test_start_plugin_runtime_initialization_bypasses_after_commit_gate() -> None:
app = Application()
app.plugin_connector = SimpleNamespace(initialize=AsyncMock())
app.task_mgr = SimpleNamespace(create_task=AsyncMock())
task = app._start_plugin_runtime_initialization()
await task
app.plugin_connector.initialize.assert_awaited_once_with()
app.task_mgr.create_task.assert_not_called()
@pytest.mark.asyncio
async def test_shutdown_cancels_plugin_runtime_initialization_task() -> None:
app = Application()
app._plugin_runtime_initialization_task = asyncio.create_task(asyncio.sleep(60))
app.task_mgr = SimpleNamespace(cancel_by_scope=lambda *_: None, tasks=[])
app.event_loop_monitor = SimpleNamespace(stop=AsyncMock())
app.http_ctrl = SimpleNamespace(mcp_mount=None)
app.platform_mgr = None
app.tool_mgr = None
app.model_mgr = None
app.box_service = None
app.plugin_connector = None
app.telemetry = None
app.vector_db_mgr = None
app.storage_mgr = None
app.persistence_mgr = SimpleNamespace(db=SimpleNamespace(engine=SimpleNamespace(dispose=AsyncMock())))
app.deployment = None
await app.shutdown()
assert app._plugin_runtime_initialization_task.cancelled()
-113
View File
@@ -1,113 +0,0 @@
"""BanWordFilter regression tests for legacy sensitive-word lists.
v4.10.7 introduced a 64-pattern cap in safe_regex. Older installs still carry
the previous default list (~70 patterns). The filter must keep applying those
rules instead of blocking every message.
"""
from __future__ import annotations
from importlib import import_module
from unittest.mock import Mock
import pytest
from tests.factories import FakeApp
def _load_banwords():
import_module('langbot.pkg.pipeline.pipelinemgr')
banwords = import_module('langbot.pkg.pipeline.cntfilter.filters.banwords')
entities = import_module('langbot.pkg.pipeline.cntfilter.entities')
safe_regex = import_module('langbot.pkg.utils.safe_regex')
return banwords, entities, safe_regex
def _filter_with_words(words: list[str], *, mask: str = '*', mask_word: str = ''):
banwords, entities, _ = _load_banwords()
app = FakeApp()
app.sensitive_meta = Mock()
app.sensitive_meta.data = {
'words': words,
'mask': mask,
'mask_word': mask_word,
}
return banwords.BanWordFilter(app), entities, app
@pytest.mark.asyncio
async def test_legacy_word_list_over_pattern_cap_does_not_block_clean_message():
"""A pre-v4.10.7 word list must not fail closed on every message."""
_, _, safe_regex = _load_banwords()
words = [f'word{i}' for i in range(safe_regex.MAX_PATTERN_COUNT + 6)]
filt, entities, _ = _filter_with_words(words)
result = await filt.process(Mock(), 'hello there, nothing banned')
assert result.level == entities.ResultLevel.PASS
assert result.replacement == 'hello there, nothing banned'
assert result.user_notice == ''
@pytest.mark.asyncio
async def test_legacy_word_list_still_masks_match_beyond_first_batch():
"""Words past the first 64-pattern batch must still be applied."""
_, _, safe_regex = _load_banwords()
words = [f'word{i}' for i in range(safe_regex.MAX_PATTERN_COUNT)] + ['secret-token']
filt, entities, _ = _filter_with_words(words, mask_word='[hidden]')
result = await filt.process(Mock(), 'please hide secret-token now')
assert result.level == entities.ResultLevel.MASKED
assert 'secret-token' not in result.replacement
assert '[hidden]' in result.replacement
@pytest.mark.asyncio
async def test_legacy_word_list_masks_match_in_first_batch():
_, _, safe_regex = _load_banwords()
words = ['alpha-secret'] + [f'word{i}' for i in range(safe_regex.MAX_PATTERN_COUNT)]
filt, entities, _ = _filter_with_words(words, mask_word='[hidden]')
result = await filt.process(Mock(), 'alpha-secret is here')
assert result.level == entities.ResultLevel.MASKED
assert result.replacement == '[hidden] is here'
@pytest.mark.asyncio
async def test_invalid_sensitive_word_regex_still_blocks():
filt, entities, _ = _filter_with_words(['(unclosed'])
result = await filt.process(Mock(), 'any message')
assert result.level == entities.ResultLevel.BLOCK
assert result.user_notice == '内容检查规则执行失败,请联系管理员'
assert 'rejected' in result.console_notice.lower() or 'invalid' in result.console_notice.lower()
@pytest.mark.asyncio
async def test_oversized_word_list_is_blocked():
"""Configured rules must never be silently skipped when the list is oversized."""
banwords, _, _ = _load_banwords()
words = [f'word{i}' for i in range(banwords._MAX_SENSITIVE_WORD_PATTERNS + 10)]
filt, entities, _ = _filter_with_words(words)
result = await filt.process(Mock(), 'hello there, nothing banned')
assert result.level == entities.ResultLevel.BLOCK
assert result.replacement == ''
assert result.user_notice == '内容检查规则执行失败,请联系管理员'
assert 'at most 256 regex patterns are allowed' in result.console_notice.lower()
@pytest.mark.asyncio
async def test_match_beyond_total_cap_cannot_bypass_filter():
banwords, _, _ = _load_banwords()
words = [f'word{i}' for i in range(banwords._MAX_SENSITIVE_WORD_PATTERNS)] + ['late-secret']
filt, entities, _ = _filter_with_words(words, mask_word='[hidden]')
result = await filt.process(Mock(), 'please hide late-secret now')
assert result.level == entities.ResultLevel.BLOCK
assert result.replacement == ''
@@ -1,69 +0,0 @@
from __future__ import annotations
import pytest
from unittest.mock import MagicMock
from linebot.v3.webhooks import TextMessageContent
from langbot.pkg.platform import botmgr as _botmgr # noqa: F401
from langbot.pkg.platform.sources import line
def _make_event(*, source_type: str, user_id, group_id=None, room_id=None, message_id: str, text: str = 'hi'):
event = MagicMock()
event.timestamp = 1700000000000
event.message = MagicMock(spec=TextMessageContent)
event.message.id = message_id
event.message.text = text
event.message.webhook_event_id = f'webhook-{message_id}'
event.message.timestamp = event.timestamp
source = MagicMock()
source.type = source_type
source.user_id = user_id
if group_id is not None:
source.group_id = group_id
if room_id is not None:
source.room_id = room_id
event.source = source
return event
@pytest.mark.asyncio
async def test_user_message_launcher_id_stable_across_messages() -> None:
"""Two distinct messages from the same LINE user must resolve to the same
sender id, otherwise every message starts a brand new session (context loss).
"""
event1 = _make_event(source_type='user', user_id='U-stable-user', message_id='msg-1')
event2 = _make_event(source_type='user', user_id='U-stable-user', message_id='msg-2')
result1 = await line.LINEEventConverter.target2yiri(event1, bot_client=None)
result2 = await line.LINEEventConverter.target2yiri(event2, bot_client=None)
assert result1.sender.id == 'U-stable-user'
assert result1.sender.id == result2.sender.id
assert result1.sender.id != event1.message.id
@pytest.mark.asyncio
async def test_group_message_uses_group_id_not_message_id() -> None:
event1 = _make_event(source_type='group', user_id='U-member', group_id='G-stable-group', message_id='msg-1')
event2 = _make_event(source_type='group', user_id='U-member', group_id='G-stable-group', message_id='msg-2')
result1 = await line.LINEEventConverter.target2yiri(event1, bot_client=None)
result2 = await line.LINEEventConverter.target2yiri(event2, bot_client=None)
assert result1.sender.group.id == 'G-stable-group'
assert result1.sender.group.id == result2.sender.group.id
assert result1.sender.id == 'U-member'
@pytest.mark.asyncio
async def test_room_message_uses_room_id_and_falls_back_when_user_id_missing() -> None:
event = _make_event(source_type='room', user_id=None, room_id='R-stable-room', message_id='msg-1')
result = await line.LINEEventConverter.target2yiri(event, bot_client=None)
assert result.sender.group.id == 'R-stable-room'
assert result.sender.id == 'R-stable-room'
@@ -1,11 +1,9 @@
"""Tests for QQ Official message and keyboard payload helpers."""
"""Tests for QQ Official keyboard payload helpers."""
import asyncio
import json
import time
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
import langbot_plugin.api.entities.builtin.platform.message as platform_message
@@ -101,12 +99,6 @@ def _stream_test_adapter():
adapter.bot = MagicMock()
adapter.bot.send_stream_msg = AsyncMock(return_value={'id': 'stream-1'})
adapter.bot.send_markdown_keyboard = AsyncMock(return_value={'id': 'message-1'})
adapter.bot.send_private_text_msg = AsyncMock()
adapter.bot.send_group_text_msg = AsyncMock()
adapter.bot.send_private_markdown_msg = AsyncMock()
adapter.bot.send_group_markdown_msg = AsyncMock()
adapter.bot.send_channle_group_text_msg = AsyncMock()
adapter.bot.send_channle_private_text_msg = AsyncMock()
adapter.ap = None
adapter._stream_ctx = {}
adapter._stream_ctx_ts = {}
@@ -116,7 +108,7 @@ def _stream_test_adapter():
@pytest.mark.asyncio
async def test_qq_stream_replace_mode_sends_complete_snapshots():
async def test_qq_stream_uses_cumulative_chunks_as_snapshots():
adapter = _stream_test_adapter()
adapter._stream_ctx['message-1'] = {
'user_openid': 'user-1',
@@ -146,109 +138,10 @@ async def test_qq_stream_replace_mode_sends_complete_snapshots():
assert [call.kwargs['content'] for call in adapter.bot.send_stream_msg.await_args_list] == [
'<think>one',
'<think>one two',
' two',
]
@pytest.mark.asyncio
async def test_qq_markdown_messages_use_markdown_payloads():
requests = []
def capture_request(request: httpx.Request) -> httpx.Response:
requests.append((str(request.url), json.loads(request.content)))
return httpx.Response(200, json={})
client = QQOfficialClient('secret', 'token', 'app-id', AsyncMock())
client.access_token = 'access-token'
client.access_token_expiry_time = time.time() + 3600
client._http_clients[None] = httpx.AsyncClient(transport=httpx.MockTransport(capture_request))
try:
await client.send_private_markdown_msg('user-1', '# Hello', msg_id='message-1', msg_seq=2)
await client.send_group_markdown_msg('group-1', '* Hello', event_id='event-1', msg_seq=3)
finally:
await client.close()
assert requests == [
(
'https://api.sgroup.qq.com/v2/users/user-1/messages',
{'msg_type': 2, 'markdown': {'content': '# Hello'}, 'msg_seq': 2, 'msg_id': 'message-1'},
),
(
'https://api.sgroup.qq.com/v2/groups/group-1/messages',
{'msg_type': 2, 'markdown': {'content': '* Hello'}, 'msg_seq': 3, 'event_id': 'event-1'},
),
]
@pytest.mark.asyncio
async def test_qq_markdown_rendering_switches_c2c_and_group_text_replies():
adapter = _stream_test_adapter()
adapter.config = {'enable-markdown-rendering': True}
await adapter._send_c2c_or_group_text_reply('c2c', 'user-1', '# Hello', msg_id='message-1')
await adapter._send_c2c_or_group_text_reply('group', 'group-1', '* Hello', event_id='event-1')
adapter.bot.send_private_markdown_msg.assert_awaited_once_with(
user_openid='user-1',
content='# Hello',
msg_id='message-1',
event_id=None,
msg_seq=1,
)
adapter.bot.send_group_markdown_msg.assert_awaited_once_with(
group_openid='group-1',
content='* Hello',
msg_id=None,
event_id='event-1',
msg_seq=1,
)
adapter.bot.send_private_text_msg.assert_not_awaited()
adapter.bot.send_group_text_msg.assert_not_awaited()
@pytest.mark.asyncio
async def test_qq_markdown_rendering_defaults_to_plain_text_replies():
adapter = _stream_test_adapter()
adapter.config = {}
await adapter._send_c2c_or_group_text_reply('c2c', 'user-1', 'Hello')
await adapter._send_c2c_or_group_text_reply('group', 'group-1', 'Hello')
adapter.bot.send_private_text_msg.assert_awaited_once()
adapter.bot.send_group_text_msg.assert_awaited_once()
adapter.bot.send_private_markdown_msg.assert_not_awaited()
adapter.bot.send_group_markdown_msg.assert_not_awaited()
@pytest.mark.asyncio
async def test_qq_markdown_rendering_does_not_affect_channel_messages():
adapter = _stream_test_adapter()
adapter.config = {'enable-markdown-rendering': True}
message = platform_message.MessageChain([platform_message.Plain(text='# Hello')])
channel_source = MagicMock()
channel_source.t = 'AT_MESSAGE_CREATE'
channel_source.channel_id = 'channel-1'
channel_source.d_id = 'message-1'
channel_event = MagicMock()
channel_event.source_platform_object = channel_source
await adapter.reply_message(channel_event, message)
dm_source = MagicMock()
dm_source.t = 'DIRECT_MESSAGE_CREATE'
dm_source.guild_id = 'guild-1'
dm_source.d_id = 'message-2'
dm_event = MagicMock()
dm_event.source_platform_object = dm_source
await adapter.reply_message(dm_event, message)
adapter.bot.send_channle_group_text_msg.assert_awaited_once_with('channel-1', '# Hello', 'message-1')
adapter.bot.send_channle_private_text_msg.assert_awaited_once_with('guild-1', '# Hello', 'message-2')
adapter.bot.send_private_markdown_msg.assert_not_awaited()
adapter.bot.send_group_markdown_msg.assert_not_awaited()
@pytest.mark.asyncio
async def test_qq_non_streaming_fallback_keeps_latest_snapshot_only():
from langbot.pkg.platform.sources.qqofficial import QQOfficialAdapter
@@ -1,127 +0,0 @@
import base64
import pytest
import langbot.pkg.core.app # noqa: F401
import langbot_plugin.api.entities.builtin.platform.message as platform_message
from langbot.libs.wecom_ai_bot_api.ws_client import _UPLOAD_CHUNK_SIZE, WecomBotWsClient
from langbot.pkg.platform.sources.wecombot import WecomBotAdapter, WecomBotMessageConverter
class Logger:
def __init__(self):
self.warnings = []
self.errors = []
async def warning(self, message):
self.warnings.append(message)
async def error(self, message):
self.errors.append(message)
async def info(self, message):
return None
class UploadClient(WecomBotWsClient):
def __init__(self):
super().__init__(bot_id='bot', secret='secret', logger=Logger())
self.frames = []
async def _send_reply(self, req_id: str, body: dict, cmd: str = 'aibot_respond_msg'):
self.frames.append((cmd, body))
if cmd == 'aibot_upload_media_init':
return {'errcode': 0, 'body': {'upload_id': 'upload-1'}}
if cmd == 'aibot_upload_media_finish':
return {'errcode': 0, 'body': {'media_id': 'media-1'}}
return {'errcode': 0}
class Bot:
def __init__(self):
self.calls = []
async def upload_media(self, data, filename='attachment', media_type='file'):
self.calls.append(('upload_media', media_type, filename, data))
return {'media_id': 'media-1'}
async def reply_text(self, req_id, content):
self.calls.append(('reply_text', req_id, content))
async def reply_image(self, req_id, media_id):
self.calls.append(('reply_image', req_id, media_id))
async def send_message(self, target_id, content):
self.calls.append(('send_message', target_id, content))
def make_adapter(bot):
return WecomBotAdapter.model_construct(
bot=bot,
config={'enable-webhook': False},
logger=Logger(),
message_converter=WecomBotMessageConverter(),
)
@pytest.mark.asyncio
async def test_ws_client_upload_media_uses_chunk_protocol():
client = UploadClient()
data = b'a' * (_UPLOAD_CHUNK_SIZE + 1)
upload_result = await client.upload_media(data, 'image.png', media_type='image')
assert upload_result['media_id'] == 'media-1'
assert [cmd for cmd, _ in client.frames] == [
'aibot_upload_media_init',
'aibot_upload_media_chunk',
'aibot_upload_media_chunk',
'aibot_upload_media_finish',
]
init_body = client.frames[0][1]
assert init_body['type'] == 'image'
assert init_body['filename'] == 'image.png'
assert init_body['total_size'] == len(data)
assert init_body['total_chunks'] == 2
assert client.frames[1][1]['chunk_index'] == 0
assert base64.b64decode(client.frames[1][1]['base64_data']) == b'a' * _UPLOAD_CHUNK_SIZE
assert client.frames[2][1]['chunk_index'] == 1
assert base64.b64decode(client.frames[2][1]['base64_data']) == b'a'
@pytest.mark.asyncio
async def test_reply_message_uploads_and_replies_image_media():
bot = Bot()
adapter = make_adapter(bot)
png_data = b'\x89PNG\r\n\x1a\nimage'
image_b64 = base64.b64encode(png_data).decode('utf-8')
chain = platform_message.MessageChain([platform_message.Image(base64=f'data:image/png;base64,{image_b64}')])
items = await WecomBotMessageConverter.yiri2target(chain)
await adapter._send_media(bot, 'req-1', items[0])
assert bot.calls == [
('upload_media', 'image', 'attachment.image', png_data),
('reply_image', 'req-1', 'media-1'),
]
@pytest.mark.asyncio
async def test_send_message_sends_text_and_skips_proactive_image():
bot = Bot()
adapter = make_adapter(bot)
jpg_data = b'\xff\xd8\xffimage'
image_b64 = base64.b64encode(jpg_data).decode('utf-8')
chain = platform_message.MessageChain(
[
platform_message.Plain(text='before'),
platform_message.Image(base64=f'data:image/jpeg;base64,{image_b64}'),
platform_message.Plain(text='after'),
]
)
await adapter.send_message('group', 'chat-1', chain)
assert bot.calls == [
('send_message', 'chat-1', 'beforeafter'),
]
+1 -10
View File
@@ -15,7 +15,7 @@ from langbot_plugin.runtime.security import (
)
def make_connector(*, cloud: bool = False) -> PluginRuntimeConnector:
def make_connector() -> PluginRuntimeConnector:
app = SimpleNamespace(
logger=Mock(),
instance_config=SimpleNamespace(
@@ -34,7 +34,6 @@ def make_connector(*, cloud: bool = False) -> PluginRuntimeConnector:
'space': {'url': ''},
}
),
deployment=SimpleNamespace(mode='cloud' if cloud else 'oss'),
)
return PluginRuntimeConnector(app, AsyncMock())
@@ -333,14 +332,6 @@ def test_external_runtime_control_headers_are_empty_when_secret_is_unset(monkeyp
assert connector._control_headers(allow_generate=False) == {}
def test_cloud_runtime_rejects_missing_control_secret(monkeypatch):
monkeypatch.delenv(PLUGIN_RUNTIME_CONTROL_TOKEN_ENV, raising=False)
connector = make_connector(cloud=True)
with pytest.raises(PluginRuntimeNotConnectedError, match=PLUGIN_RUNTIME_CONTROL_TOKEN_ENV):
connector._control_headers(allow_generate=False)
def test_local_runtime_control_headers_generate_ephemeral_secret(monkeypatch):
monkeypatch.delenv(PLUGIN_RUNTIME_CONTROL_TOKEN_ENV, raising=False)
connector = make_connector()
@@ -107,19 +107,6 @@ def shared_connector(
return connector
@pytest.mark.asyncio
async def test_shared_reconcile_uses_configured_cold_start_timeout():
binding = execution_binding("workspace-a")
setting = plugin_setting("01", "a" * 64)
connector = shared_connector([[binding]], {"workspace-a": [setting]})
connector.ap.instance_config.data["plugin"]["connect_timeout_seconds"] = 900
connector.handler = runtime_handler()
await connector._prepare_connected_runtime()
assert connector.handler.reconcile_plugin_installations.await_args.kwargs["timeout"] == 900
@pytest.mark.asyncio
async def test_shared_reconnect_replays_two_workspaces_and_removes_missing_projection():
binding_a = execution_binding('workspace-a')
@@ -163,7 +150,7 @@ async def test_empty_projected_workspaces_do_not_retain_installation_sets():
assert connector._workspace_installations == {}
assert connector._known_desired_states == {}
connector.handler.reconcile_plugin_installations.assert_awaited_once_with((), timeout=300.0)
connector.handler.reconcile_plugin_installations.assert_awaited_once_with(())
@pytest.mark.asyncio
+2 -28
View File
@@ -9,8 +9,8 @@ from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, Mock
import pytest
from langbot_plugin.entities.io.actions.enums import LangBotToRuntimeAction, PluginToRuntimeAction
from langbot_plugin.entities.io.context import ActionContext, InstallationBinding, PluginInstallationDesiredState
from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction
from langbot_plugin.entities.io.context import ActionContext, InstallationBinding
def make_handler(app):
@@ -67,32 +67,6 @@ def make_handler(app):
return runtime_handler
@pytest.mark.asyncio
async def test_reconcile_plugin_installations_allows_cloud_cold_start_to_finish():
app = SimpleNamespace()
runtime_handler = make_handler(app)
runtime_handler.call_action = AsyncMock(return_value={})
binding = next(iter(runtime_handler._installation_bindings.values()))[0]
desired = PluginInstallationDesiredState(binding=binding, enabled=True)
await runtime_handler.reconcile_plugin_installations((desired,))
assert runtime_handler.call_action.await_args.args[0] == LangBotToRuntimeAction.RECONCILE_PLUGIN_INSTALLATIONS
assert runtime_handler.call_action.await_args.kwargs['timeout'] == 300
@pytest.mark.asyncio
async def test_reconcile_plugin_installations_accepts_configured_cold_start_timeout():
runtime_handler = make_handler(SimpleNamespace())
runtime_handler.call_action = AsyncMock(return_value={})
binding = next(iter(runtime_handler._installation_bindings.values()))[0]
desired = PluginInstallationDesiredState(binding=binding, enabled=True)
await runtime_handler.reconcile_plugin_installations((desired,), timeout=900)
assert runtime_handler.call_action.await_args.kwargs["timeout"] == 900
class TestHandlerQueryVariables:
"""Tests for handler query variable logic."""
+6 -136
View File
@@ -234,7 +234,6 @@ class TestSetBinaryStorage:
},
}
mock_app.persistence_mgr = Mock()
mock_app.persistence_mgr.get_db_engine.return_value = SimpleNamespace(dialect=SimpleNamespace(name='sqlite'))
mock_app.persistence_mgr.execute_async = AsyncMock(return_value=make_result())
mock_app.logger = Mock()
return mock_app
@@ -271,8 +270,8 @@ class TestSetBinaryStorage:
)
assert response.code == 0
assert app.persistence_mgr.execute_async.await_count == 3
insert_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[2].args[0])
assert app.persistence_mgr.execute_async.await_count == 2
insert_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[1].args[0])
assert insert_params['workspace_uuid'] == 'workspace-a'
assert insert_params['unique_key'] == canonical_binary_key(
'plugin',
@@ -302,69 +301,6 @@ class TestSetBinaryStorage:
assert expected_key in update_params.values()
assert update_params['value'] == b'new'
@pytest.mark.asyncio
async def test_adopts_legacy_storage_before_updating(self, app):
"""A migrated pre-tenancy row is updated in place rather than duplicated."""
runtime_handler = make_handler(app)
legacy_storage = SimpleNamespace(unique_key='plugin:test-author/test-plugin:test-key')
adopted = SimpleNamespace(rowcount=1)
app.persistence_mgr.execute_async.side_effect = [
make_result(),
make_result(legacy_storage),
adopted,
]
response = await runtime_handler.actions[RuntimeToLangBotAction.SET_BINARY_STORAGE.value](self.payload(b'new'))
assert response.code == 0
assert app.persistence_mgr.execute_async.await_count == 3
adoption_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[2].args[0])
expected_key = canonical_binary_key('plugin', 'test-author/test-plugin', 'test-key')
assert expected_key in adoption_params.values()
assert adoption_params['value'] == b'new'
@pytest.mark.asyncio
async def test_legacy_adoption_race_updates_winning_canonical_row(self, app):
runtime_handler = make_handler(app)
legacy_storage = SimpleNamespace(unique_key='plugin:test-author/test-plugin:test-key')
lost_race = SimpleNamespace(rowcount=0)
canonical_winner = SimpleNamespace(rowcount=1)
app.persistence_mgr.execute_async.side_effect = [
make_result(),
make_result(legacy_storage),
lost_race,
canonical_winner,
]
response = await runtime_handler.actions[RuntimeToLangBotAction.SET_BINARY_STORAGE.value](self.payload(b'new'))
assert response.code == 0
assert app.persistence_mgr.execute_async.await_count == 4
winner_update = compiled_params(app.persistence_mgr.execute_async.await_args_list[3].args[0])
assert canonical_binary_key('plugin', 'test-author/test-plugin', 'test-key') in winner_update.values()
assert winner_update['value'] == b'new'
@pytest.mark.asyncio
async def test_legacy_adoption_lost_to_delete_inserts_new_value(self, app):
runtime_handler = make_handler(app)
legacy_storage = SimpleNamespace(unique_key='plugin:test-author/test-plugin:test-key')
lost_race = SimpleNamespace(rowcount=0)
app.persistence_mgr.execute_async.side_effect = [
make_result(),
make_result(legacy_storage),
lost_race,
SimpleNamespace(rowcount=0),
make_result(),
]
response = await runtime_handler.actions[RuntimeToLangBotAction.SET_BINARY_STORAGE.value](self.payload(b'new'))
assert response.code == 0
assert app.persistence_mgr.execute_async.await_count == 5
insert_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[4].args[0])
assert insert_params['unique_key'] == canonical_binary_key('plugin', 'test-author/test-plugin', 'test-key')
assert insert_params['value'] == b'new'
@pytest.mark.asyncio
async def test_invalid_max_value_bytes_falls_back_to_default_limit(self, app):
"""Invalid max_value_bytes uses the 10MB default limit."""
@@ -589,46 +525,6 @@ class TestGetBinaryStorage:
in statement_params.values()
)
@pytest.mark.asyncio
async def test_reads_legacy_storage_without_mutating_key(self, app):
runtime_handler = make_handler(app)
legacy_storage = SimpleNamespace(
unique_key='plugin:test-author/test-plugin:test-key',
value=b'legacy bytes',
)
app.persistence_mgr.execute_async.side_effect = [
make_result(),
make_result(legacy_storage),
]
response = await runtime_handler.actions[RuntimeToLangBotAction.GET_BINARY_STORAGE.value](
{'key': 'test-key', 'owner_type': 'plugin', 'owner': 'ignored'}
)
assert response.code == 0
assert base64.b64decode(response.data['value_base64']) == b'legacy bytes'
assert app.persistence_mgr.execute_async.await_count == 2
@pytest.mark.asyncio
async def test_retries_canonical_after_concurrent_legacy_adoption(self, app):
runtime_handler = make_handler(app)
canonical_storage = SimpleNamespace(value=b'adopted bytes')
app.persistence_mgr.execute_async.side_effect = [
make_result(),
make_result(),
make_result(canonical_storage),
]
response = await runtime_handler.actions[RuntimeToLangBotAction.GET_BINARY_STORAGE.value](
{'key': 'test-key', 'owner_type': 'plugin', 'owner': 'ignored'}
)
assert response.code == 0
assert base64.b64decode(response.data['value_base64']) == b'adopted bytes'
assert app.persistence_mgr.execute_async.await_count == 3
retry_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[2].args[0])
assert canonical_binary_key('plugin', 'test-author/test-plugin', 'test-key') in retry_params.values()
@pytest.mark.asyncio
async def test_returns_error_when_not_found(self, app):
"""Missing binary storage rows return an error response."""
@@ -671,47 +567,21 @@ class TestDeleteAndListBinaryStorage:
assert response.code == 0
statement_params = compiled_params(app.persistence_mgr.execute_async.await_args.args[0])
flat_values = [
item for value in statement_params.values() for item in (value if isinstance(value, list) else [value])
]
assert 'workspace-a' in flat_values
assert 'workspace-a' in statement_params.values()
assert (
canonical_binary_key(
'plugin',
'test-author/test-plugin',
'test-key',
)
in flat_values
in statement_params.values()
)
assert 'forged-owner' not in flat_values
@pytest.mark.asyncio
async def test_delete_removes_canonical_and_legacy_scoped_keys(self, app):
runtime_handler = make_handler(app)
response = await runtime_handler.actions[RuntimeToLangBotAction.DELETE_BINARY_STORAGE.value](
{
'key': 'test-key',
'owner_type': 'plugin',
'owner': 'forged-owner',
}
)
assert response.code == 0
statement_params = compiled_params(app.persistence_mgr.execute_async.await_args.args[0])
values = [
item for value in statement_params.values() for item in (value if isinstance(value, list) else [value])
]
assert 'workspace-a' in values
assert canonical_binary_key('plugin', 'test-author/test-plugin', 'test-key') in values
assert 'plugin:test-author/test-plugin:test-key' in values
assert 'test-author/test-plugin' in values
assert 'forged-owner' not in values
assert 'forged-owner' not in statement_params.values()
@pytest.mark.asyncio
async def test_list_keys_uses_trusted_plugin_owner(self, app):
result = Mock()
result.scalars.return_value.all.return_value = ['first', 'second', 'first']
result.scalars.return_value.all.return_value = ['first', 'second']
app.persistence_mgr.execute_async.return_value = result
runtime_handler = make_handler(app)
@@ -444,23 +444,3 @@ async def test_host_to_runtime_action_carries_trusted_connector_context():
'runtime_id': 'runtime-a',
}
assert request.get('context') is None
@pytest.mark.asyncio
async def test_get_debug_info_converts_execution_context_to_sdk_action_context():
runtime_handler, _app, _installation_context = make_handler()
runtime_handler.call_action = AsyncMock(return_value={'plugin_debug_key': 'debug-key'})
execution_context = ExecutionContext(
instance_uuid='instance-a',
workspace_uuid='workspace-a',
placement_generation=7,
)
result = await runtime_handler.get_debug_info(execution_context)
assert result == {'plugin_debug_key': 'debug-key'}
assert runtime_handler.call_action.await_args.kwargs['action_context'] == ActionContext(
instance_uuid='instance-a',
workspace_uuid='workspace-a',
placement_generation=7,
)
@@ -1,15 +0,0 @@
from __future__ import annotations
import tomllib
from pathlib import Path
def test_seekdb_is_only_declared_as_an_optional_dependency() -> None:
project_root = Path(__file__).resolve().parents[2]
with (project_root / 'pyproject.toml').open('rb') as pyproject_file:
pyproject = tomllib.load(pyproject_file)
project = pyproject['project']
base_dependencies = project['dependencies']
assert not any(dependency.lower().startswith('pyseekdb') for dependency in base_dependencies)
assert project['optional-dependencies']['seekdb'] == ['pyseekdb==1.1.0.post3']
-39
View File
@@ -53,45 +53,6 @@ async def test_matches_any_rejects_pattern_and_input_amplification():
)
@pytest.mark.asyncio
async def test_mask_patterns_honors_explicit_pattern_count_cap():
patterns = ['a'] * (safe_regex.MAX_PATTERN_COUNT + 6)
found, masked = await safe_regex.mask_patterns(
patterns,
'hello',
mask='*',
mask_word='',
max_pattern_count=len(patterns),
)
assert found is False
assert masked == 'hello'
with pytest.raises(safe_regex.SafeRegexLimitError):
await safe_regex.mask_patterns(
patterns,
'hello',
mask='*',
mask_word='',
)
@pytest.mark.asyncio
async def test_mask_patterns_rejects_oversized_sequence_before_copying_it():
class OversizedPatterns(list):
def __iter__(self):
raise AssertionError('oversized patterns must not be materialized')
patterns = OversizedPatterns(['a'] * (safe_regex.MAX_PATTERN_COUNT + 1))
with pytest.raises(safe_regex.SafeRegexLimitError):
await safe_regex.mask_patterns(
patterns,
'hello',
mask='*',
mask_word='',
)
@pytest.mark.asyncio
async def test_mask_patterns_bounds_replacement_growth_and_masks_matches():
found, masked = await safe_regex.mask_patterns(
@@ -1,34 +0,0 @@
from __future__ import annotations
import importlib
from unittest.mock import MagicMock
import pytest
from tests.utils.import_isolation import isolated_sys_modules
_INSTALL_HINT = "Install LangBot with the 'seekdb' extra"
def test_seekdb_vector_backend_reports_missing_optional_extra() -> None:
module_name = 'langbot.pkg.vector.vdbs.seekdb'
with isolated_sys_modules({'pyseekdb': None}, clear=[module_name]):
seekdb_module = importlib.import_module(module_name)
assert seekdb_module.SEEKDB_AVAILABLE is False
with pytest.raises(ImportError, match=_INSTALL_HINT):
seekdb_module.SeekDBVectorDatabase(MagicMock())
@pytest.mark.asyncio
async def test_seekdb_embedding_reports_missing_optional_extra() -> None:
module_name = 'langbot.pkg.provider.modelmgr.requesters.seekdbembed'
with isolated_sys_modules({'pyseekdb': None}, clear=[module_name]):
seekdb_embedding_module = importlib.import_module(module_name)
requester = seekdb_embedding_module.SeekDBEmbedding.__new__(seekdb_embedding_module.SeekDBEmbedding)
with pytest.raises(ImportError, match=_INSTALL_HINT):
await requester.initialize()
@@ -88,15 +88,14 @@ async def test_environment_mapping_enables_provider_without_leaking_secret(monke
assert service.capability() == {'enabled': True, 'provider': 'smtp'}
async def test_invitation_email_has_generic_langbot_brand_plain_fallback_and_expiry_copy():
async def test_cloud_invitation_email_has_branded_html_plain_fallback_and_expiry_copy():
service = InvitationDeliveryService(_app({}))
link = 'https://cloud.langbot.app/invitations/accept#token=lbi_secret&next=<unsafe>'
text = service._plain_text('Research & Development', link)
html = service._html('Research & Development', link)
assert 'LangBot' in text
assert 'LangBot Cloud' not in text
assert 'LangBot Cloud' in text
assert 'Research & Development' in text
assert '7 days' in text
assert link in text
@@ -104,55 +103,3 @@ async def test_invitation_email_has_generic_langbot_brand_plain_fallback_and_exp
assert 'Research &amp; Development' in html
assert 'expires in 7 days' in html
assert 'lbi_secret&amp;next=&lt;unsafe&gt;' in html
assert 'LangBot Cloud' not in html
async def test_invitation_email_uses_quiet_brand_lockup_and_compact_fallback_link():
service = InvitationDeliveryService(_app({}))
link = 'https://cloud.langbot.app/invitations/accept#token=lbi_secret'
html = service._html("RockChinQ's Workspace", link)
assert 'https://docs.langbot.app/langbot-logo.png' in html
assert '>LangBot<' in html
assert 'Workspace invitation' in html
assert 'Open invitation link' in html
assert 'linear-gradient' not in html
assert 'box-shadow' not in html
assert 'border-top:4px solid' not in html
assert 'border:1px solid #dfe6f0' not in html
assert 'height="28"' in html
assert 'height="32"' in html
assert 'margin-top:32px' not in html
assert f'>{link}<' not in html
async def test_oss_smtp_configuration_delivers_the_generic_invitation_email():
service = InvitationDeliveryService(
_app(
{
'workspace': {
'invitations': {
'email': {
'provider': 'smtp',
'from': 'LangBot <noreply@example.com>',
'smtp': {'host': 'smtp.example.com'},
}
}
}
}
)
)
service._send_smtp = AsyncMock(return_value=True)
link = 'https://self-hosted.example/invitations/accept#token=lbi_secret'
result = await service.deliver_invitation(
recipient_email='member@example.com',
workspace_name='Self-hosted Workspace',
invitation_link=link,
)
assert result == InvitationDeliveryResult(status='sent', provider='smtp')
service._send_smtp.assert_awaited_once()
assert 'LangBot Cloud' not in service._plain_text('Self-hosted Workspace', link)
assert 'LangBot Cloud' not in service._html('Self-hosted Workspace', link)
Generated
+8 -17
View File
@@ -9,10 +9,10 @@ resolution-markers = [
"python_full_version == '3.13.*' and sys_platform == 'emscripten'",
"python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'",
"python_full_version == '3.12.*' and sys_platform == 'win32'",
"python_full_version == '3.12.*' and sys_platform == 'emscripten'",
"python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'",
"python_full_version < '3.12' and sys_platform == 'win32'",
"python_full_version == '3.12.*' and sys_platform == 'emscripten'",
"python_full_version < '3.12' and sys_platform == 'emscripten'",
"python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'",
"python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'",
]
@@ -2008,7 +2008,7 @@ wheels = [
[[package]]
name = "langbot"
version = "4.10.8"
version = "4.10.7"
source = { editable = "." }
dependencies = [
{ name = "aiocqhttp" },
@@ -2063,6 +2063,7 @@ dependencies = [
{ name = "pymilvus" },
{ name = "pynacl" },
{ name = "pypdf2" },
{ name = "pyseekdb" },
{ name = "python-docx" },
{ name = "python-multipart" },
{ name = "python-socks" },
@@ -2088,11 +2089,6 @@ dependencies = [
{ name = "websockets" },
]
[package.optional-dependencies]
seekdb = [
{ name = "pyseekdb" },
]
[package.dev-dependencies]
dev = [
{ name = "moto" },
@@ -2129,7 +2125,7 @@ requires-dist = [
{ name = "ebooklib", specifier = ">=0.18" },
{ name = "gewechat-client", specifier = ">=0.1.5" },
{ name = "html2text", specifier = ">=2024.2.26" },
{ name = "langbot-plugin", specifier = "==0.5.5" },
{ name = "langbot-plugin", git = "https://github.com/langbot-app/langbot-plugin-sdk.git?rev=9d216208cdfb41f0cb7fcb64632e2a46816d6dc6" },
{ name = "langchain", specifier = ">=1.3.9" },
{ name = "langchain-core", specifier = ">=1.3.3" },
{ name = "langchain-text-splitters", specifier = ">=1.1.2" },
@@ -2157,7 +2153,7 @@ requires-dist = [
{ name = "pymilvus", specifier = ">=2.6.4" },
{ name = "pynacl", specifier = ">=1.5.0" },
{ name = "pypdf2", specifier = ">=3.0.1" },
{ name = "pyseekdb", marker = "extra == 'seekdb'", specifier = "==1.1.0.post3" },
{ name = "pyseekdb", specifier = "==1.1.0.post3" },
{ name = "python-docx", specifier = ">=1.1.0" },
{ name = "python-multipart", specifier = ">=0.0.27" },
{ name = "python-socks", specifier = ">=2.7.1" },
@@ -2182,7 +2178,6 @@ requires-dist = [
{ name = "valkey-glide", marker = "sys_platform != 'win32'", specifier = ">=2.4.1,<3.0.0" },
{ name = "websockets", specifier = ">=15.0.1" },
]
provides-extras = ["seekdb"]
[package.metadata.requires-dev]
dev = [
@@ -2196,8 +2191,8 @@ dev = [
[[package]]
name = "langbot-plugin"
version = "0.5.5"
source = { registry = "https://pypi.org/simple" }
version = "0.5.0"
source = { git = "https://github.com/langbot-app/langbot-plugin-sdk.git?rev=9d216208cdfb41f0cb7fcb64632e2a46816d6dc6#9d216208cdfb41f0cb7fcb64632e2a46816d6dc6" }
dependencies = [
{ name = "aiofiles" },
{ name = "aiohttp" },
@@ -2217,10 +2212,6 @@ dependencies = [
{ name = "watchdog" },
{ name = "websockets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c3/be/1bbdf959d8c16b625e3721cde586b3bb22eaa22dd8c22d072c04f9b491ba/langbot_plugin-0.5.5.tar.gz", hash = "sha256:ea31b0ddf64c2ef8fdec012273b2d3dee6f0d140475f07694f31ea685be40695", size = 472639, upload-time = "2026-08-16T17:33:27.783Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/00/30/72caa601b571542fa4de5f2a3461d6f601f75c52d484d9fc95ebb82ce30c/langbot_plugin-0.5.5-py3-none-any.whl", hash = "sha256:a55d20a0c015414ef85d783b493f83d27b64f1d662887de94330df9d3d4ab64e", size = 304643, upload-time = "2026-08-16T17:33:26.687Z" },
]
[[package]]
name = "langchain"
+5 -1
View File
@@ -1 +1,5 @@
VITE_API_BASE_URL=http://localhost:5300
# Leave empty in development to use Vite's same-origin proxy. This keeps API,
# login, and WebSocket requests working when the UI is opened from another
# device on the local network.
VITE_API_BASE_URL=
VITE_API_PROXY_TARGET=http://127.0.0.1:5300
@@ -20,6 +20,7 @@ export function BotLogListComponent({
autoExpandImages = false,
hideDetailedLogsLink = false,
hideToolbar = false,
onMessageReceived,
}: {
botId: string;
/** When true, log entries with images are rendered expanded by default */
@@ -28,6 +29,8 @@ export function BotLogListComponent({
hideDetailedLogsLink?: boolean;
/** When true, hides the entire toolbar (auto-refresh, level filter, detailed logs link) */
hideToolbar?: boolean;
/** Called after an inbound person/group message appears in the bot log. */
onMessageReceived?: () => void;
}) {
const { t } = useTranslation();
const navigate = useNavigate();
@@ -41,6 +44,8 @@ export function BotLogListComponent({
]);
const listContainerRef = useRef<HTMLDivElement>(null);
const botLogListRef = useRef<BotLog[]>(botLogList);
const onMessageReceivedRef = useRef(onMessageReceived);
onMessageReceivedRef.current = onMessageReceived;
const logLevels = [
{ value: 'error', label: 'ERROR' },
@@ -108,6 +113,9 @@ export function BotLogListComponent({
manager.subscribeLogPush(handleBotLogPush);
manager.loadFirstPage().then((response) => {
setBotLogList(response.reverse());
if (response.some((log) => Boolean(log.message_session_id))) {
onMessageReceivedRef.current?.();
}
});
listenScroll();
}
@@ -138,6 +146,9 @@ export function BotLogListComponent({
function handleBotLogPush(response: BotLog[]) {
setBotLogList(response.reverse());
if (response.some((log) => Boolean(log.message_session_id))) {
onMessageReceivedRef.current?.();
}
}
const handleScroll = useCallback(
@@ -33,7 +33,7 @@ const getFormSchema = (t: (key: string) => string) =>
interface ProviderFormProps {
providerId?: string;
onFormSubmit: () => void;
onFormSubmit: (providerUuid: string) => void | Promise<void>;
onFormCancel: () => void;
}
@@ -171,14 +171,16 @@ export default function ProviderForm({
};
try {
let savedProviderUuid = providerId;
if (providerId) {
await httpClient.updateModelProvider(providerId, data);
toast.success(t('models.providerSaved'));
} else {
await httpClient.createModelProvider(data);
const response = await httpClient.createModelProvider(data);
savedProviderUuid = response.uuid;
toast.success(t('models.providerCreated'));
}
onFormSubmit();
await onFormSubmit(savedProviderUuid as string);
} catch (err) {
toast.error(t('models.providerSaveError') + (err as CustomApiError).msg);
}
@@ -15,7 +15,6 @@ import {
XCircle,
} from 'lucide-react';
import QRCode from 'qrcode';
import { getActiveWorkspaceUuid } from '@/app/infra/http/workspaceContext';
export type QrLoginPlatform =
| 'feishu'
@@ -56,12 +55,12 @@ const PLATFORM_CONFIGS: Record<QrLoginPlatform, PlatformConfig> = {
},
weixin: {
titleKey: 'weixin.scanLogin',
connectingKey: 'weixin.connecting',
connectingKey: 'feishu.connecting',
scanQRCodeKey: 'weixin.scanQRCode',
waitingKey: 'weixin.waitingForScan',
waitingKey: 'feishu.waitingForScan',
successKey: 'weixin.loginSuccess',
failedKey: 'weixin.loginFailed',
retryKey: 'weixin.retry',
retryKey: 'feishu.retry',
apiBase: '/api/v1/platform/adapters/weixin/login',
extractSuccess: (data) => ({
token: data.token,
@@ -147,8 +146,6 @@ export default function QrCodeLoginDialog({
const checkExpiredRef = useRef<ReturnType<typeof setInterval> | null>(null);
const abortRef = useRef<AbortController | null>(null);
const sessionIdRef = useRef<string | null>(null);
const sessionWorkspaceUuidRef = useRef<string | null>(null);
const sessionApiBaseRef = useRef('');
const baseUrlRef = useRef('');
const cleanedRef = useRef(false);
@@ -183,23 +180,18 @@ export default function QrCodeLoginDialog({
}
if (sessionIdRef.current) {
const token = localStorage.getItem('token');
const workspaceUuid = sessionWorkspaceUuidRef.current;
const baseUrl =
import.meta.env.VITE_API_BASE_URL || window.location.origin;
fetch(
`${baseUrlRef.current}${sessionApiBaseRef.current}/${sessionIdRef.current}`,
`${baseUrl}${platformConfigRef.current.apiBase}/${sessionIdRef.current}`,
{
method: 'DELETE',
headers: {
Authorization: `Bearer ${token}`,
...(workspaceUuid ? { 'X-Workspace-Id': workspaceUuid } : {}),
},
headers: { Authorization: `Bearer ${token}` },
keepalive: true,
},
).catch(() => {});
sessionIdRef.current = null;
}
sessionWorkspaceUuidRef.current = null;
sessionApiBaseRef.current = '';
baseUrlRef.current = '';
}, []);
const startLogin = useCallback(async () => {
@@ -212,7 +204,6 @@ export default function QrCodeLoginDialog({
setSuccessMeta('');
const token = localStorage.getItem('token');
const workspaceUuid = getActiveWorkspaceUuid();
const baseUrl = import.meta.env.VITE_API_BASE_URL || window.location.origin;
baseUrlRef.current = baseUrl;
const cfg = platformConfigRef.current;
@@ -223,10 +214,7 @@ export default function QrCodeLoginDialog({
const res = await fetch(`${baseUrl}${cfg.apiBase}`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
...(workspaceUuid ? { 'X-Workspace-Id': workspaceUuid } : {}),
},
headers: { Authorization: `Bearer ${token}` },
signal: controller.signal,
});
@@ -237,8 +225,6 @@ export default function QrCodeLoginDialog({
const { session_id, qr_data_url, qr_url, expire_at } = json.data;
sessionIdRef.current = session_id;
sessionWorkspaceUuidRef.current = workspaceUuid;
sessionApiBaseRef.current = cfg.apiBase;
if (qr_data_url) {
setQrDataUrl(qr_data_url);
@@ -284,19 +270,11 @@ export default function QrCodeLoginDialog({
`${baseUrlRef.current}${cfg.apiBase}/${sessionIdRef.current}`,
{
method: 'DELETE',
headers: {
Authorization: `Bearer ${token}`,
...(workspaceUuid
? { 'X-Workspace-Id': workspaceUuid }
: {}),
},
headers: { Authorization: `Bearer ${token}` },
keepalive: true,
},
).catch(() => {});
sessionIdRef.current = null;
sessionWorkspaceUuidRef.current = null;
sessionApiBaseRef.current = '';
baseUrlRef.current = '';
}
setState('expired');
}
@@ -308,12 +286,7 @@ export default function QrCodeLoginDialog({
try {
const pollRes = await fetch(
`${baseUrl}${cfg.apiBase}/status/${session_id}`,
{
headers: {
Authorization: `Bearer ${token}`,
...(workspaceUuid ? { 'X-Workspace-Id': workspaceUuid } : {}),
},
},
{ headers: { Authorization: `Bearer ${token}` } },
);
if (!pollRes.ok) return;
+2
View File
@@ -363,7 +363,9 @@ export interface WizardProgress {
step: number;
selected_adapter: string | null;
created_bot_uuid: string | null;
created_pipeline_uuid?: string | null;
bot_saved: boolean;
message_received?: boolean;
selected_runner: string | null;
}
+23 -26
View File
@@ -150,7 +150,9 @@ export class BackendClient extends BaseHttpClient {
return this.get(`/api/v1/provider/models/llm/${uuid}`);
}
public createProviderLLMModel(model: LLMModel): Promise<object> {
public createProviderLLMModel(
model: Omit<LLMModel, 'uuid'>,
): Promise<{ uuid: string }> {
return this.post('/api/v1/provider/models/llm', model);
}
@@ -461,6 +463,15 @@ export class BackendClient extends BaseHttpClient {
return this.post(`/api/v1/platform/bots/${botId}/logs`, request);
}
public testHttpBotInbound(
botId: string,
message: string,
): Promise<{ session_id: string; accepted_message_id: string }> {
return this.post(`/api/v1/platform/bots/${botId}/test-inbound`, {
message,
});
}
public getBotSessions(
botId: string,
limit: number = 100,
@@ -710,32 +721,11 @@ export class BackendClient extends BaseHttpClient {
);
}
private async getAuthenticatedObjectURL(
path: string,
rewritePluginPageSdk = false,
): Promise<string> {
private async getAuthenticatedObjectURL(path: string): Promise<string> {
const response = await this.instance.get<Blob>(path, {
responseType: 'blob',
});
let blob = response.data;
if (rewritePluginPageSdk && blob.type.startsWith('text/html')) {
const apiBase =
this.instance.defaults.baseURL === '/'
? window.location.origin
: this.instance.defaults.baseURL?.replace(/\/$/, '');
const pageSdkUrl = `${apiBase}/api/v1/plugins/_sdk/page-sdk.js`;
const html = await blob.text();
blob = new Blob(
[
html.replace(
/(<script\b[^>]*\bsrc\s*=\s*)(["'])\/api\/v1\/plugins\/_sdk\/page-sdk\.js\2/gi,
`$1$2${pageSdkUrl}$2`,
),
],
{ type: blob.type },
);
}
return URL.createObjectURL(blob);
return URL.createObjectURL(response.data);
}
public getAuthenticatedPluginAssetURL(
@@ -745,7 +735,6 @@ export class BackendClient extends BaseHttpClient {
): Promise<string> {
return this.getAuthenticatedObjectURL(
`/api/v1/plugins/${author}/${name}/authenticated-assets/${filepath}`,
true,
);
}
@@ -1068,12 +1057,21 @@ export class BackendClient extends BaseHttpClient {
step: number;
selected_adapter: string | null;
created_bot_uuid: string | null;
created_pipeline_uuid?: string | null;
bot_saved: boolean;
message_received?: boolean;
selected_runner: string | null;
}): Promise<void> {
return this.put('/api/v1/system/wizard/progress', progress);
}
public getWizardRecommendedModel(): Promise<{
uuid: string;
name: string;
}> {
return this.get('/api/v1/system/wizard/recommended-model');
}
public getAsyncTasks(params?: {
type?: string;
kind?: string;
@@ -1203,7 +1201,6 @@ export class BackendClient extends BaseHttpClient {
public getAccountInfo(): Promise<{
initialized: boolean;
authenticated_invitation_acceptance_enabled?: boolean;
invitation_registration_enabled?: boolean;
password_login_enabled?: boolean;
space_login_enabled?: boolean;
}> {
+22 -28
View File
@@ -91,9 +91,7 @@ export default function AcceptInvitationPage() {
const [errorMessage, setErrorMessage] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [invitationRegistrationEnabled, setInvitationRegistrationEnabled] =
useState(false);
const [invitationCapabilitiesLoaded, setInvitationCapabilitiesLoaded] =
const [passwordRegistrationEnabled, setPasswordRegistrationEnabled] =
useState(false);
const [
authenticatedInvitationAcceptanceEnabled,
@@ -118,16 +116,12 @@ export default function AcceptInvitationPage() {
backendClient
.getAccountInfo()
.then((info) => {
setInvitationRegistrationEnabled(
info.invitation_registration_enabled ??
info.password_login_enabled !== false,
);
setPasswordRegistrationEnabled(info.password_login_enabled !== false);
setAuthenticatedInvitationAcceptanceEnabled(
info.authenticated_invitation_acceptance_enabled === true,
);
})
.catch(() => setInvitationRegistrationEnabled(false))
.finally(() => setInvitationCapabilitiesLoaded(true));
.catch(() => setPasswordRegistrationEnabled(false));
if (!invitationToken) {
setErrorMessage(t('workspace.invitationMissing'));
setStatus('error');
@@ -317,11 +311,7 @@ export default function AcceptInvitationPage() {
</div>
)}
{!invitationCapabilitiesLoaded ? (
<div className="flex justify-center py-8">
<Loader2 className="size-6 animate-spin" />
</div>
) : hasLoginToken && authenticatedInvitationAcceptanceEnabled ? (
{hasLoginToken && authenticatedInvitationAcceptanceEnabled ? (
<Button
className="w-full"
disabled={status === 'submitting'}
@@ -341,7 +331,7 @@ export default function AcceptInvitationPage() {
{t('workspace.logoutAndReturn')}
</Button>
</div>
) : invitationRegistrationEnabled ? (
) : passwordRegistrationEnabled ? (
<>
<div className="space-y-2">
<label
@@ -386,19 +376,15 @@ export default function AcceptInvitationPage() {
>
{t('workspace.confirmPassword')}
</label>
<div className="relative">
<Lock className="absolute left-3 top-3 size-4 text-muted-foreground" />
<Input
id="invite-password-confirm"
type="password"
value={confirmPassword}
onChange={(event) =>
setConfirmPassword(event.target.value)
}
className="pl-10"
autoComplete="new-password"
/>
</div>
<Input
id="invite-password-confirm"
type="password"
value={confirmPassword}
onChange={(event) =>
setConfirmPassword(event.target.value)
}
autoComplete="new-password"
/>
</div>
<Button
className="w-full"
@@ -410,6 +396,14 @@ export default function AcceptInvitationPage() {
)}
{t('workspace.registerAndAccept')}
</Button>
<Button
variant="ghost"
className="w-full"
disabled={status === 'submitting'}
onClick={() => navigate('/login?invitation=1')}
>
{t('workspace.alreadyHaveAccount')}
</Button>
</>
) : (
<Button
@@ -0,0 +1,409 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
ArrowLeft,
Check,
Eye,
Loader2,
Pencil,
RefreshCw,
Wrench,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import ProviderForm from '@/app/home/components/models-dialog/component/provider-form/ProviderForm';
import type { ScannedProviderModel } from '@/app/infra/entities/api';
import { httpClient } from '@/app/infra/http/HttpClient';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { cn } from '@/lib/utils';
type ModelSetupMode = 'scan' | 'manual';
type ScanFallbackReason = 'failed' | 'empty' | null;
export interface OwnModelSelection {
source: ModelSetupMode;
providerUuid: string;
model: ScannedProviderModel;
}
interface OwnModelSetupProps {
onBack: () => void;
onSelectionChange: (selection: OwnModelSelection | null) => void;
}
export default function OwnModelSetup({
onBack,
onSelectionChange,
}: OwnModelSetupProps) {
const { t } = useTranslation();
const [providerUuid, setProviderUuid] = useState<string | null>(null);
const [showProviderForm, setShowProviderForm] = useState(true);
const [mode, setMode] = useState<ModelSetupMode>('scan');
const [models, setModels] = useState<ScannedProviderModel[]>([]);
const [selectedModelId, setSelectedModelId] = useState<string | null>(null);
const [isScanning, setIsScanning] = useState(false);
const [scanFallbackReason, setScanFallbackReason] =
useState<ScanFallbackReason>(null);
const [manualModelName, setManualModelName] = useState('');
const [manualContextLength, setManualContextLength] = useState('');
const [manualVision, setManualVision] = useState(false);
const [manualFunctionCall, setManualFunctionCall] = useState(false);
const parsedManualContextLength = useMemo(() => {
if (!manualContextLength.trim()) return null;
const value = Number(manualContextLength);
return Number.isInteger(value) && value > 0 ? value : undefined;
}, [manualContextLength]);
useEffect(() => {
if (mode !== 'manual' || !providerUuid) return;
if (!manualModelName.trim() || parsedManualContextLength === undefined) {
onSelectionChange(null);
return;
}
const abilities = [
...(manualVision ? ['vision'] : []),
...(manualFunctionCall ? ['func_call'] : []),
];
const modelName = manualModelName.trim();
onSelectionChange({
source: 'manual',
providerUuid,
model: {
id: modelName,
name: modelName,
type: 'llm',
abilities,
context_length: parsedManualContextLength,
already_added: false,
},
});
}, [
manualFunctionCall,
manualModelName,
manualVision,
mode,
onSelectionChange,
parsedManualContextLength,
providerUuid,
]);
const scanModels = useCallback(
async (uuid: string) => {
setMode('scan');
setIsScanning(true);
setScanFallbackReason(null);
setModels([]);
setSelectedModelId(null);
onSelectionChange(null);
try {
const response = await httpClient.scanProviderModels(uuid, 'llm');
const availableModels = response.models.filter(
(model) => model.type === 'llm' && !model.already_added,
);
setModels(availableModels);
if (availableModels.length === 0) {
setScanFallbackReason('empty');
setMode('manual');
}
} catch {
setScanFallbackReason('failed');
setMode('manual');
} finally {
setIsScanning(false);
}
},
[onSelectionChange],
);
const handleProviderSaved = useCallback(
async (uuid: string) => {
setProviderUuid(uuid);
setShowProviderForm(false);
await scanModels(uuid);
},
[scanModels],
);
const handleSelectModel = useCallback(
(model: ScannedProviderModel) => {
if (!providerUuid) return;
setSelectedModelId(model.id);
onSelectionChange({ source: 'scan', providerUuid, model });
},
[onSelectionChange, providerUuid],
);
const handleModeChange = useCallback(
(value: string) => {
setMode(value as ModelSetupMode);
setSelectedModelId(null);
onSelectionChange(null);
},
[onSelectionChange],
);
const handleBack = useCallback(() => {
onSelectionChange(null);
onBack();
}, [onBack, onSelectionChange]);
const handleEditProvider = useCallback(() => {
setSelectedModelId(null);
onSelectionChange(null);
setShowProviderForm(true);
}, [onSelectionChange]);
return (
<div className="mx-auto w-full max-w-3xl space-y-5">
<div>
<Button variant="ghost" size="sm" onClick={handleBack}>
<ArrowLeft className="mr-1.5 size-4" />
{t('wizard.aiEngine.backToChoices')}
</Button>
</div>
<div className="text-center">
<h2 className="text-xl font-semibold">
{t('wizard.aiEngine.ownModelSetupTitle')}
</h2>
<p className="mt-1 text-sm text-muted-foreground">
{t('wizard.aiEngine.ownModelSetupDescription')}
</p>
</div>
{showProviderForm ? (
<Card>
<CardHeader>
<CardTitle className="text-base">
{t('wizard.aiEngine.addProviderTitle')}
</CardTitle>
<CardDescription>
{t('wizard.aiEngine.addProviderDescription')}
</CardDescription>
</CardHeader>
<CardContent>
<ProviderForm
providerId={providerUuid ?? undefined}
onFormSubmit={handleProviderSaved}
onFormCancel={() =>
providerUuid ? setShowProviderForm(false) : handleBack()
}
/>
</CardContent>
</Card>
) : (
<div className="space-y-4">
<div className="flex flex-wrap items-start justify-between gap-3 border-b pb-3">
<div>
<h3 className="text-base font-semibold">
{t('wizard.aiEngine.selectModelTitle')}
</h3>
<p className="text-sm text-muted-foreground">
{t('wizard.aiEngine.selectScannedModelDescription')}
</p>
</div>
<Button
variant="outline"
size="icon"
title={t('wizard.aiEngine.editProvider')}
onClick={handleEditProvider}
>
<Pencil className="size-4" />
</Button>
</div>
<Tabs value={mode} onValueChange={handleModeChange}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="scan">
{t('wizard.aiEngine.scanModelMode')}
</TabsTrigger>
<TabsTrigger value="manual">
{t('wizard.aiEngine.manualModelMode')}
</TabsTrigger>
</TabsList>
<TabsContent value="scan" className="mt-4">
{isScanning ? (
<div className="flex min-h-48 items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
{t('wizard.aiEngine.scanningModels')}
</div>
) : models.length > 0 ? (
<div className="space-y-3">
<div className="grid gap-2 sm:grid-cols-2">
{models.map((model) => {
const selected = selectedModelId === model.id;
return (
<button
key={model.id}
type="button"
className={cn(
'flex min-h-20 items-center gap-3 rounded-md border p-3 text-left transition-colors hover:border-primary/60 hover:bg-accent/40',
selected &&
'border-primary bg-accent ring-1 ring-primary',
)}
onClick={() => handleSelectModel(model)}
>
<span
className={cn(
'flex size-5 shrink-0 items-center justify-center rounded-full border',
selected &&
'border-primary bg-primary text-primary-foreground',
)}
>
{selected && <Check className="size-3" />}
</span>
<span className="min-w-0">
<span className="block truncate text-sm font-medium">
{model.display_name || model.name}
</span>
<span className="block truncate text-xs text-muted-foreground">
{model.name}
</span>
</span>
</button>
);
})}
</div>
<div className="flex justify-end">
<Button
variant="outline"
size="sm"
disabled={isScanning || !providerUuid}
onClick={() => providerUuid && scanModels(providerUuid)}
>
<RefreshCw className="mr-1.5 size-4" />
{t('wizard.aiEngine.rescanModels')}
</Button>
</div>
</div>
) : (
<div className="flex min-h-48 flex-col items-center justify-center gap-3 border border-dashed p-6 text-center">
<p className="text-sm text-muted-foreground">
{t(
scanFallbackReason === 'failed'
? 'wizard.aiEngine.scanModelsFailed'
: 'wizard.aiEngine.noScannedModels',
)}
</p>
<Button
variant="outline"
size="sm"
disabled={!providerUuid}
onClick={() => providerUuid && scanModels(providerUuid)}
>
<RefreshCw className="mr-1.5 size-4" />
{t('wizard.aiEngine.rescanModels')}
</Button>
</div>
)}
</TabsContent>
<TabsContent value="manual" className="mt-4 space-y-5">
{scanFallbackReason && (
<div className="border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-200">
{t(
scanFallbackReason === 'failed'
? 'wizard.aiEngine.manualFallbackFailed'
: 'wizard.aiEngine.manualFallbackEmpty',
)}
</div>
)}
<div className="space-y-2">
<Label htmlFor="wizard-manual-model-name">
{t('wizard.aiEngine.manualModelId')}
<span className="text-red-500">*</span>
</Label>
<Input
id="wizard-manual-model-name"
value={manualModelName}
onChange={(event) => setManualModelName(event.target.value)}
placeholder={t('wizard.aiEngine.manualModelIdPlaceholder')}
/>
<p className="text-xs text-muted-foreground">
{t('wizard.aiEngine.manualModelIdDescription')}
</p>
</div>
<div className="space-y-3 border-t pt-4">
<p className="text-sm font-medium">
{t('wizard.aiEngine.manualModelOptions')}
</p>
<div className="space-y-2">
<Label htmlFor="wizard-manual-context-length">
{t('models.contextLength')}
</Label>
<Input
id="wizard-manual-context-length"
type="number"
min={1}
step={1}
value={manualContextLength}
onChange={(event) =>
setManualContextLength(event.target.value)
}
placeholder={t('models.contextLengthPlaceholder')}
/>
{parsedManualContextLength === undefined && (
<p className="text-xs text-destructive">
{t('models.contextLengthInvalid')}
</p>
)}
</div>
<div className="flex flex-wrap gap-5">
<div className="flex items-center gap-2">
<Checkbox
id="wizard-manual-vision"
checked={manualVision}
onCheckedChange={(checked) =>
setManualVision(checked === true)
}
/>
<Label
htmlFor="wizard-manual-vision"
className="flex items-center gap-1.5"
>
<Eye className="size-4" />
{t('models.visionAbility')}
</Label>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="wizard-manual-function-call"
checked={manualFunctionCall}
onCheckedChange={(checked) =>
setManualFunctionCall(checked === true)
}
/>
<Label
htmlFor="wizard-manual-function-call"
className="flex items-center gap-1.5"
>
<Wrench className="size-4" />
{t('models.functionCallAbility')}
</Label>
</div>
</div>
</div>
</TabsContent>
</Tabs>
</div>
)}
</div>
);
}
File diff suppressed because it is too large Load Diff
+104
View File
@@ -0,0 +1,104 @@
export function getErrorMessage(error: unknown): string {
if (error instanceof Error) return error.message;
if (typeof error === 'object' && error !== null && 'msg' in error) {
const message = (error as { msg?: unknown }).msg;
if (typeof message === 'string') return message;
}
return String(error);
}
function createSigningSecret(): string {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(
'',
);
}
export function ensureHttpBotSigningSecret(
adapterName: string,
config: Record<string, unknown>,
): Record<string, unknown> {
if (
adapterName !== 'http_bot' ||
config.signature_required === false ||
(typeof config.inbound_secret === 'string' && config.inbound_secret)
) {
return config;
}
return {
...config,
inbound_secret: createSigningSecret(),
};
}
export function findDefaultPipeline<
T extends { uuid?: string; is_default?: boolean },
>(pipelines: T[]): T | undefined {
return pipelines.find(
(pipeline) =>
pipeline.is_default === true &&
typeof pipeline.uuid === 'string' &&
pipeline.uuid.length > 0,
);
}
interface WebhookConfigItem {
name: string;
show_if?: {
field: string;
operator: 'eq' | 'neq' | 'in';
value: unknown;
};
}
export function isWebhookModeEnabled(
configItems: WebhookConfigItem[],
configValues: Record<string, unknown>,
): boolean {
const webhookField = configItems.find((item) => item.name === 'webhook_url');
if (!webhookField) return false;
if (!webhookField.show_if) return true;
const condition = webhookField.show_if;
const actualValue = configValues[condition.field];
if (condition.operator === 'eq') return actualValue === condition.value;
if (condition.operator === 'neq') return actualValue !== condition.value;
return (
Array.isArray(condition.value) && condition.value.includes(actualValue)
);
}
export function configureLocalAgentPrimaryModel(
config: Record<string, unknown>,
modelUuid: string,
): Record<string, unknown> {
const aiConfig = (config.ai ?? {}) as Record<string, unknown>;
const runnerConfig = (aiConfig.runner ?? {}) as Record<string, unknown>;
const localAgentConfig = (aiConfig['local-agent'] ?? {}) as Record<
string,
unknown
>;
const modelConfig = (localAgentConfig.model ?? {}) as Record<string, unknown>;
return {
...config,
ai: {
...aiConfig,
runner: { ...runnerConfig, runner: 'local-agent' },
'local-agent': {
...localAgentConfig,
model: {
...modelConfig,
primary: modelUuid,
fallbacks: Array.isArray(modelConfig.fallbacks)
? modelConfig.fallbacks
: [],
},
},
},
};
}
+91 -27
View File
@@ -85,18 +85,18 @@ const enUS = {
'Recommended: Use official stable model APIs and cloud services',
loginLocal: 'Login with local account',
loginWithPassword: 'Login with password',
spaceLoginTitle: 'Login with LangBot Account',
spaceLoginTitle: 'Login with Space',
spaceLoginDescription:
'Scan the QR code or visit the link below to authorize',
spaceLoginUserCode: 'Your code',
spaceLoginExpires: 'Code expires in {{seconds}} seconds',
spaceLoginWaiting: 'Waiting for authorization...',
spaceLoginSuccess: 'Authorization successful',
spaceLoginFailed: 'LangBot Account login failed',
spaceLoginFailed: 'Space login failed',
spaceLoginExpired: 'Authorization code expired, please try again',
spaceLoginCancel: 'Cancel',
spaceLoginVisitLink: 'Visit link',
spaceLoginProcessing: 'Logging in with LangBot Account',
spaceLoginProcessing: 'Logging in with Space',
spaceLoginProcessingDescription:
'Please wait while we complete your login...',
spaceLoginSuccessDescription: 'Redirecting to LangBot...',
@@ -105,7 +105,7 @@ const enUS = {
backToLogin: 'Back to Login',
backToHome: 'Back to Home',
spaceAccountCannotChangePassword:
'LangBot Accounts cannot change password here',
'Space accounts cannot change password here',
theme: 'Theme',
changePassword: 'Change Password',
currentPassword: 'Current Password',
@@ -254,9 +254,8 @@ const enUS = {
llmModels: 'LLM Models',
localProvider: 'Local',
localProviderDescription: 'Models configured and managed locally',
spaceProviderDescription: 'Models synced from your LangBot Account',
spaceDisabledForLocalAccount:
'Login with LangBot Account to use cloud models',
spaceProviderDescription: 'Models synced from your Space account',
spaceDisabledForLocalAccount: 'Login with Space to use cloud models',
syncModels: 'Sync',
syncSuccess: 'Sync complete: {{created}} created, {{updated}} updated',
syncError: 'Sync failed: ',
@@ -292,15 +291,15 @@ const enUS = {
langbotModelsDescription: 'Cloud models powered by LangBot Space',
credits: 'Credits',
loginWithSpace: 'Login with LangBot Account',
loginToUseModels: 'Login with LangBot Account to use cloud models',
loginToUseModels: 'Login with Space to use cloud models',
ownerMustBindSpace:
'The Workspace owner must connect a LangBot Account for LangBot Models.',
'The Workspace owner must connect Space for LangBot Models.',
usesOwnerSpaceBilling:
"Uses the Workspace owner's LangBot Account billing and credits.",
"Uses the Workspace owner's Space billing and credits.",
noModels: 'No models configured',
langbotModels: 'LangBot Models',
spaceTrialTooltip:
'Free trial credits available! Login with LangBot Account to access cloud models with zero configuration.',
'Free trial credits available! Login with Space to access cloud models with zero configuration.',
unlockModels: 'Login to use',
editProvider: 'Edit Provider',
addProvider: 'Add Provider',
@@ -1219,13 +1218,13 @@ const enUS = {
adminAccountNote:
'The account you use here will be set as the administrator account',
register: 'Register',
initWithSpace: 'Initialize with LangBot Account',
initWithSpace: 'Initialize with Space',
spaceRecommended:
'Recommended: Use official stable model APIs and cloud services',
spaceInfoTip1:
'Space provides unified account authentication services without uploading any of your sensitive information.',
spaceInfoTip2:
'Logging in with a LangBot Account gives you access to LangBot Models and other cloud services, including free model call credits to help you get started quickly.',
'Logging in with a Space account gives you access to LangBot Models and other cloud services, including free model call credits to help you get started quickly.',
spaceInfoTip3:
'Your login method does not affect other features. You can configure and use models from other sources at any time.',
registerLocal: 'Register local account',
@@ -1282,32 +1281,32 @@ const enUS = {
passwordNotSet: 'Not Set',
passwordSetDescription:
'Password is set, you can login with email and password',
spaceStatus: 'LangBot Account',
spaceStatus: 'Space Account',
spaceBound: 'Bound',
spaceNotBound: 'Not Bound',
spaceBoundDescription:
'LangBot Account bound, official model APIs and cloud services available',
bindSpace: 'Bind LangBot Account',
'Space account bound, official model APIs and cloud services available',
bindSpace: 'Bind Space Account',
bindSpaceDescription: 'Bind to use official model APIs and cloud services',
bindSpaceButton: 'Bind',
bindSpaceConfirmTitle: 'Confirm Binding',
bindSpaceConfirmDescription:
'You are about to bind your local instance to a LangBot Account',
'You are about to bind your local instance to a Space account',
bindSpaceWarning:
'After binding, your login email will be changed from {{localEmail}} to the LangBot Account email.',
bindSpaceSuccess: 'LangBot Account bound successfully',
bindSpaceFailed: 'Failed to bind LangBot Account',
'After binding, your login email will be changed from {{localEmail}} to the Space account email.',
bindSpaceSuccess: 'Space account bound successfully',
bindSpaceFailed: 'Failed to bind Space account',
bindSpaceInvalidState:
'Invalid bind request. Please try again from account settings.',
setPasswordHint: 'Set a password to login with email and password',
spaceEmailMismatch:
'The LangBot Account login email does not match the local account email.',
'The Space login email does not match the local account email.',
space_account_not_registeredTitle: 'Account not registered',
space_account_not_registered:
'No local account is registered for this LangBot Account email. Ask the Workspace owner for an invitation.',
space_account_binding_requiredTitle: 'LangBot Account connection required',
'No local account is registered for this Space email. Ask the Workspace owner for an invitation.',
space_account_binding_requiredTitle: 'Space connection required',
space_account_binding_required:
'This local account must connect a LangBot Account from Account settings before using LangBot Account login.',
'This local account must connect Space from Account settings before using Space login.',
},
workspace: {
title: 'Workspace',
@@ -1808,6 +1807,7 @@ const enUS = {
botCreateSuccess: 'Bot created successfully!',
botSaveSuccess: 'Bot configuration saved and enabled!',
createError: 'Failed to create resources',
spaceAuthError: 'Failed to initiate Space authorization',
skipSaveError: 'Failed to save skip status. Please try again.',
completeSaveError: 'Failed to save completion status. Please try again.',
step: {
@@ -1827,6 +1827,23 @@ const enUS = {
resaveBot: 'Re-save Configuration',
botSaved:
'Bot configuration saved and enabled. Check the logs to verify the connection.',
waitingForMessage:
'The bot is enabled. Send it a message from your IM platform to continue.',
messageReceived:
'The bot received an IM message. You can continue to the next step.',
pageBotTestPrompt:
'Page Bot is enabled. Click the chat bubble in the lower-right corner and send a message to verify the full conversation flow.',
webhookTestPrompt:
'The callback URL is ready. Configure it on the external platform, then send the bot a real message.',
httpTestPrompt:
'HTTP Bot is enabled. Send a real inbound message here to verify the connection.',
httpTestDefaultMessage: 'Hello, this is a connection test message.',
sendHttpTest: 'Send Test Message',
httpTestAccepted:
'The test message was accepted. It will appear in the log shortly.',
httpTestMissingSecret:
'Enter an inbound signing secret and save the configuration first.',
httpTestFailed: 'Failed to send the test message: {{error}}',
logsTitle: 'Bot Logs',
logsDescription:
'Monitor bot activity to verify the platform connection is working.',
@@ -1835,6 +1852,56 @@ const enUS = {
title: 'Select an AI Engine',
description:
"Choose the AI engine that will power your bot's intelligence.",
optionalDescription:
'This step is optional. Choose how you want to continue with the current agent.',
externalTitle: 'Connect an External Agent',
externalDescription:
'Connect Dify, n8n, Coze, or another platform and replace the bot pipeline.',
ownModelTitle: 'Use My Own Model',
ownModelDescription:
'Add a provider, then scan or manually enter a model to finish setup.',
ownModelSetupTitle: 'Add Your Own Model',
ownModelSetupDescription:
'Add a model provider. Chat models are scanned automatically, or you can enter a model ID manually.',
addProviderTitle: 'Add Provider',
addProviderDescription:
'Enter the provider details and API key used to connect and scan models.',
selectModelTitle: 'Choose a Model',
selectScannedModelTitle: 'Choose a Model',
selectScannedModelDescription:
'The selected model will be the primary model of a new pipeline, and the bot will switch to it.',
scanModelMode: 'Scan Models',
manualModelMode: 'Add Manually',
scanningModels: 'Scanning available models…',
noScannedModels:
'No available chat models were found. Check the provider configuration.',
scanModelsFailed:
'Model scanning failed. Check the URL and API key, then try again.',
manualFallbackFailed:
'Automatic scanning failed. Enter a model ID supported by the provider.',
manualFallbackEmpty:
'No models were found. Enter a model ID supported by the provider.',
manualModelId: 'Model ID',
manualModelIdPlaceholder: 'For example: gpt-4o',
manualModelIdDescription:
'Enter the model parameter used in model requests.',
manualModelOptions: 'Optional Model Capabilities',
editProvider: 'Edit provider',
rescanModels: 'Scan models again',
moreFeaturesTitle: 'Add More Agent Features',
moreFeaturesDescription:
'Open the workbench to add tools, knowledge, and other capabilities.',
runnerDescription:
'Select a runner for the external agent and configure its connection.',
backToChoices: 'Back to options',
createExternal: 'Create and Bind',
finishWithModel: 'Use Selected Model & Finish',
openWorkbench: 'Open Workbench',
},
spaceBanner: {
message:
'Connect to LangBot Space for free trial model credits and zero-config instant setup!',
action: 'Authorize with Space',
},
config: {
botInfo: 'Bot Information',
@@ -1916,9 +1983,6 @@ const enUS = {
'Scan the QR code below with WeChat to authorize and automatically fill in the token',
loginSuccess: 'Login successful! Token has been filled in',
loginFailed: 'Login failed',
connecting: 'Connecting to WeChat service...',
waitingForScan: 'Waiting for scan',
retry: 'Retry',
},
dingtalk: {
createApp: 'One-Click Create DingTalk App',
+29 -41
View File
@@ -88,19 +88,19 @@ const esES = {
'Recomendado: Usa API de modelos oficiales estables y servicios en la nube',
loginLocal: 'Iniciar sesión con cuenta local',
loginWithPassword: 'Iniciar sesión con contraseña',
spaceLoginTitle: 'Iniciar sesión con una cuenta de LangBot',
spaceLoginTitle: 'Iniciar sesión con Space',
spaceLoginDescription:
'Escanea el código QR o visita el enlace para autorizar',
spaceLoginUserCode: 'Tu código',
spaceLoginExpires: 'El código expira en {{seconds}} segundos',
spaceLoginWaiting: 'Esperando autorización...',
spaceLoginSuccess: 'Autorización exitosa',
spaceLoginFailed: 'Error de inicio de sesión con una cuenta de LangBot',
spaceLoginFailed: 'Error de inicio de sesión con Space',
spaceLoginExpired:
'El código de autorización ha expirado, por favor inténtalo de nuevo',
spaceLoginCancel: 'Cancelar',
spaceLoginVisitLink: 'Visitar enlace',
spaceLoginProcessing: 'Iniciando sesión con una cuenta de LangBot',
spaceLoginProcessing: 'Iniciando sesión con Space',
spaceLoginProcessingDescription:
'Por favor espera mientras completamos tu inicio de sesión...',
spaceLoginSuccessDescription: 'Redirigiendo a LangBot...',
@@ -109,7 +109,7 @@ const esES = {
backToLogin: 'Volver al inicio de sesión',
backToHome: 'Volver al inicio',
spaceAccountCannotChangePassword:
'Las cuentas de LangBot no pueden cambiar la contraseña aquí',
'Las cuentas de Space no pueden cambiar la contraseña aquí',
theme: 'Tema',
changePassword: 'Cambiar contraseña',
currentPassword: 'Contraseña actual',
@@ -220,19 +220,6 @@ const esES = {
selectModelAbilities: 'Seleccionar capacidades del modelo',
visionAbility: 'Capacidad de visión',
functionCallAbility: 'Llamada a funciones',
reasoningAbility: 'Razonamiento',
reasoningLevel: 'Nivel de razonamiento',
reasoningLevels: {
providerDefault: 'Predeterminado del proveedor',
disabled: 'Desactivado',
enabled: 'Activado',
minimal: 'Mínimo',
low: 'Bajo',
medium: 'Medio',
high: 'Alto',
xhigh: 'Extra alto',
max: 'Máximo',
},
contextLength: 'Ventana de contexto',
contextLengthPlaceholder: 'Desconocido',
contextLengthInvalid: 'La ventana de contexto debe ser un entero positivo',
@@ -261,10 +248,9 @@ const esES = {
llmModels: 'Modelos LLM',
localProvider: 'Local',
localProviderDescription: 'Modelos configurados y gestionados localmente',
spaceProviderDescription:
'Modelos sincronizados desde tu cuenta de LangBot',
spaceProviderDescription: 'Modelos sincronizados desde tu cuenta de Space',
spaceDisabledForLocalAccount:
'Inicia sesión con una cuenta de LangBot para usar modelos en la nube',
'Inicia sesión con Space para usar modelos en la nube',
syncModels: 'Sincronizar',
syncSuccess:
'Sincronización completa: {{created}} creados, {{updated}} actualizados',
@@ -303,12 +289,11 @@ const esES = {
langbotModelsDescription: 'Modelos en la nube impulsados por LangBot Space',
credits: 'Créditos',
loginWithSpace: 'Iniciar sesión con una cuenta de LangBot',
loginToUseModels:
'Inicia sesión con una cuenta de LangBot para usar modelos en la nube',
loginToUseModels: 'Inicia sesión con Space para usar modelos en la nube',
noModels: 'No hay modelos configurados',
langbotModels: 'Modelos LangBot',
spaceTrialTooltip:
'¡Créditos de prueba gratuitos disponibles! Inicia sesión con una cuenta de LangBot para acceder a modelos en la nube sin configuración.',
'¡Créditos de prueba gratuitos disponibles! Inicia sesión con Space para acceder a modelos en la nube sin configuración.',
unlockModels: 'Inicia sesión para usar',
editProvider: 'Editar proveedor',
addProvider: 'Añadir proveedor',
@@ -343,9 +328,9 @@ const esES = {
},
ownerMustBindSpace:
'The Workspace owner must connect a LangBot Account for LangBot Models.',
'The Workspace owner must connect Space for LangBot Models.',
usesOwnerSpaceBilling:
"Uses the Workspace owner's LangBot Account billing and credits.",
"Uses the Workspace owner's Space billing and credits.",
},
bots: {
title: 'Bots',
@@ -1253,13 +1238,13 @@ const esES = {
adminAccountNote:
'La cuenta que uses aquí se establecerá como cuenta de administrador',
register: 'Registrarse',
initWithSpace: 'Inicializar con una cuenta de LangBot',
initWithSpace: 'Inicializar con Space',
spaceRecommended:
'Recomendado: Usa API de modelos oficiales estables y servicios en la nube',
spaceInfoTip1:
'Space proporciona servicios de autenticación unificada de cuentas sin subir ninguna de tu información sensible.',
spaceInfoTip2:
'Iniciar sesión con una cuenta de LangBot te da acceso a los modelos de LangBot y otros servicios en la nube, incluyendo créditos gratuitos de llamadas a modelos para ayudarte a comenzar rápidamente.',
'Iniciar sesión con una cuenta de Space te da acceso a los modelos de LangBot y otros servicios en la nube, incluyendo créditos gratuitos de llamadas a modelos para ayudarte a comenzar rápidamente.',
spaceInfoTip3:
'Tu método de inicio de sesión no afecta otras funciones. Puedes configurar y usar modelos de otras fuentes en cualquier momento.',
registerLocal: 'Registrar cuenta local',
@@ -1317,35 +1302,35 @@ const esES = {
passwordNotSet: 'No establecida',
passwordSetDescription:
'La contraseña está establecida, puedes iniciar sesión con correo y contraseña',
spaceStatus: 'Cuenta de LangBot',
spaceStatus: 'Cuenta de Space',
spaceBound: 'Vinculada',
spaceNotBound: 'No vinculada',
spaceBoundDescription:
'Cuenta de LangBot vinculada, API de modelos oficiales y servicios en la nube disponibles',
bindSpace: 'Vincular cuenta de LangBot',
'Cuenta de Space vinculada, API de modelos oficiales y servicios en la nube disponibles',
bindSpace: 'Vincular cuenta de Space',
bindSpaceDescription:
'Vincular para usar API de modelos oficiales y servicios en la nube',
bindSpaceButton: 'Vincular',
bindSpaceConfirmTitle: 'Confirmar vinculación',
bindSpaceConfirmDescription:
'Estás a punto de vincular tu instancia local a una cuenta de LangBot',
'Estás a punto de vincular tu instancia local a una cuenta de Space',
bindSpaceWarning:
'Después de vincular, tu correo de inicio de sesión se cambiará de {{localEmail}} al correo de la cuenta de LangBot.',
bindSpaceSuccess: 'Cuenta de LangBot vinculada correctamente',
bindSpaceFailed: 'Error al vincular la cuenta de LangBot',
'Después de vincular, tu correo de inicio de sesión se cambiará de {{localEmail}} al correo de la cuenta de Space.',
bindSpaceSuccess: 'Cuenta de Space vinculada correctamente',
bindSpaceFailed: 'Error al vincular la cuenta de Space',
bindSpaceInvalidState:
'Solicitud de vinculación no válida. Por favor, inténtalo de nuevo desde la configuración de la cuenta.',
setPasswordHint:
'Establece una contraseña para iniciar sesión con correo y contraseña',
spaceEmailMismatch:
'El correo de la cuenta de LangBot no coincide con el correo de la cuenta local',
'El correo de inicio de sesión de Space no coincide con el correo de la cuenta local',
space_account_not_registeredTitle: 'Account not registered',
space_account_not_registered:
'No local account is registered for this LangBot Account email. Ask the Workspace owner for an invitation.',
space_account_binding_requiredTitle: 'LangBot Account connection required',
'No local account is registered for this Space email. Ask the Workspace owner for an invitation.',
space_account_binding_requiredTitle: 'Space connection required',
space_account_binding_required:
'This local account must connect a LangBot Account from Account settings before using LangBot Account login.',
'This local account must connect Space from Account settings before using Space login.',
},
monitoring: {
title: 'Panel de control',
@@ -1674,6 +1659,7 @@ const esES = {
botCreateSuccess: '¡Bot creado correctamente!',
botSaveSuccess: '¡Configuración del Bot guardada y activada!',
createError: 'Error al crear los recursos',
spaceAuthError: 'Error al iniciar la autorización de Space',
skipSaveError:
'Error al guardar el estado de omisión. Por favor, inténtalo de nuevo.',
completeSaveError:
@@ -1706,6 +1692,11 @@ const esES = {
description:
'Elige el motor de IA que impulsará la inteligencia de tu Bot.',
},
spaceBanner: {
message:
'¡Conéctate a LangBot Space para obtener créditos de prueba gratuitos y configuración instantánea sin esfuerzo!',
action: 'Autorizar con Space',
},
config: {
botInfo: 'Información del Bot',
botNamePlaceholder: 'Introduce el nombre del Bot',
@@ -1756,9 +1747,6 @@ const esES = {
loginSuccess:
'¡Inicio de sesión correcto! El token se ha rellenado automáticamente',
loginFailed: 'Error al iniciar sesión',
connecting: 'Conectando con el servicio de WeChat...',
waitingForScan: 'Esperando escaneo',
retry: 'Reintentar',
},
dingtalk: {
createApp: 'Crear aplicación de DingTalk con un clic',
+90 -27
View File
@@ -86,19 +86,19 @@ const jaJP = {
'おすすめ:公式の安定したモデル API とクラウドサービスを利用',
loginLocal: 'ローカルアカウントでログイン',
loginWithPassword: 'パスワードでログイン',
spaceLoginTitle: 'LangBot アカウントでログイン',
spaceLoginTitle: 'Space でログイン',
spaceLoginDescription:
'QRコードをスキャンするか、下のリンクにアクセスして認証してください',
spaceLoginUserCode: '認証コード',
spaceLoginExpires: 'コードは {{seconds}} 秒後に期限切れになります',
spaceLoginWaiting: '認証を待っています...',
spaceLoginSuccess: '認証に成功しました',
spaceLoginFailed: 'LangBot アカウントログインに失敗しました',
spaceLoginFailed: 'Space ログインに失敗しました',
spaceLoginExpired:
'認証コードの有効期限が切れました。もう一度お試しください',
spaceLoginCancel: 'キャンセル',
spaceLoginVisitLink: 'リンクにアクセス',
spaceLoginProcessing: 'LangBot アカウントでログイン中',
spaceLoginProcessing: 'Space でログイン中',
spaceLoginProcessingDescription:
'ログインを完了しています。しばらくお待ちください...',
spaceLoginSuccessDescription: 'LangBot にリダイレクト中...',
@@ -107,7 +107,7 @@ const jaJP = {
backToLogin: 'ログインに戻る',
backToHome: 'ホームに戻る',
spaceAccountCannotChangePassword:
'LangBot アカウントはここでパスワードを変更できません',
'Space アカウントはここでパスワードを変更できません',
theme: 'テーマ',
changePassword: 'パスワードを変更',
currentPassword: '現在のパスワード',
@@ -257,9 +257,8 @@ const jaJP = {
llmModels: 'LLM モデル',
localProvider: 'ローカル',
localProviderDescription: 'ローカルで設定・管理されているモデル',
spaceProviderDescription: 'LangBot アカウントから同期されたモデル',
spaceDisabledForLocalAccount:
'LangBot アカウントでログインしてクラウドモデルを使用',
spaceProviderDescription: 'Space アカウントから同期されたモデル',
spaceDisabledForLocalAccount: 'Space でログインしてクラウドモデルを使用',
syncModels: '同期',
syncSuccess: '同期完了:{{created}} 件作成、{{updated}} 件更新',
syncError: '同期に失敗しました:',
@@ -297,15 +296,15 @@ const jaJP = {
langbotModelsDescription: 'LangBot Space が提供するクラウドモデル',
credits: 'クレジット',
loginWithSpace: 'LangBot アカウントでログイン',
loginToUseModels: 'LangBot アカウントでログインしてクラウドモデルを使用',
loginToUseModels: 'Space でログインしてクラウドモデルを使用',
ownerMustBindSpace:
'LangBot モデルを使うにはワークスペース所有者が LangBot アカウントを連携する必要があります。',
'LangBot モデルを使うにはワークスペース所有者が Space を連携する必要があります。',
usesOwnerSpaceBilling:
'ワークスペース所有者の LangBot アカウント課金とクレジットを使用します。',
'ワークスペース所有者の Space 課金とクレジットを使用します。',
noModels: 'モデルがありません',
langbotModels: 'LangBot モデル',
spaceTrialTooltip:
'無料トライアルクレジットが利用可能!LangBot アカウントでログインして、設定不要でクラウドモデルを使用できます。',
'無料トライアルクレジットが利用可能!Space でログインして、設定不要でクラウドモデルを使用できます。',
unlockModels: 'ログインして使用',
editProvider: 'プロバイダーを編集',
addProvider: 'プロバイダーを追加',
@@ -1224,13 +1223,13 @@ const jaJP = {
adminAccountNote:
'ここで初期化されたアカウントは管理者アカウントとして使用されます',
register: '登録',
initWithSpace: 'LangBot アカウントで初期化',
initWithSpace: 'Space で初期化',
spaceRecommended:
'おすすめ:公式の安定したモデル API とクラウドサービスを利用',
spaceInfoTip1:
'Space は統一されたアカウント認証サービスを提供し、機密情報をアップロードすることはありません。',
spaceInfoTip2:
'LangBot アカウントでログインすると、LangBot Models などのクラウドサービスを利用でき、無料のモデル呼び出しクレジットで迅速に開始できます。',
'Space アカウントでログインすると、LangBot Models などのクラウドサービスを利用でき、無料のモデル呼び出しクレジットで迅速に開始できます。',
spaceInfoTip3:
'ログイン方法は他の機能に影響しません。いつでも他のソースからモデルを設定して使用できます。',
registerLocal: 'ローカルアカウントを登録',
@@ -1287,33 +1286,33 @@ const jaJP = {
passwordNotSet: '未設定',
passwordSetDescription:
'パスワードが設定されています。メールとパスワードでログインできます',
spaceStatus: 'LangBot アカウント',
spaceStatus: 'Space アカウント',
spaceBound: '連携済み',
spaceNotBound: '未連携',
spaceBoundDescription:
'LangBot アカウントと連携済み、公式モデル API とクラウドサービスが利用可能',
bindSpace: 'LangBot アカウントを連携',
'Space アカウントと連携済み、公式モデル API とクラウドサービスが利用可能',
bindSpace: 'Space アカウントを連携',
bindSpaceDescription: '連携して公式モデル API とクラウドサービスを利用',
bindSpaceButton: '連携',
bindSpaceConfirmTitle: '連携を確認',
bindSpaceConfirmDescription:
'ローカルインスタンスを LangBot アカウントに連携しようとしています',
'ローカルインスタンスを Space アカウントに連携しようとしています',
bindSpaceWarning:
'連携後、ログインメールアドレスは {{localEmail}} から LangBot アカウントのメールアドレスに変更されます。',
bindSpaceSuccess: 'LangBot アカウントの連携に成功しました',
bindSpaceFailed: 'LangBot アカウントの連携に失敗しました',
'連携後、ログインメールアドレスは {{localEmail}} から Space アカウントのメールアドレスに変更されます。',
bindSpaceSuccess: 'Space アカウントの連携に成功しました',
bindSpaceFailed: 'Space アカウントの連携に失敗しました',
bindSpaceInvalidState:
'無効な連携リクエストです。アカウント設定から再度お試しください。',
setPasswordHint:
'パスワードを設定するとメールとパスワードでログインできます',
spaceEmailMismatch:
'LangBot アカウントのメールアドレスがローカルアカウントのメールアドレスと一致しません',
'Spaceログインのメールアドレスがローカルアカウントのメールアドレスと一致しません',
space_account_not_registeredTitle: 'アカウントが登録されていません',
space_account_not_registered:
'この LangBot アカウントのメールアドレスのローカルアカウントはありません。ワークスペース所有者に招待を依頼してください。',
space_account_binding_requiredTitle: 'LangBot アカウントの連携が必要です',
'この Space メールアドレスのローカルアカウントはありません。ワークスペース所有者に招待を依頼してください。',
space_account_binding_requiredTitle: 'Space の連携が必要です',
space_account_binding_required:
'LangBot アカウントログインを使用する前に、アカウント設定でこのローカルアカウントを LangBot アカウントに連携してください。',
'Space ログインを使用する前に、アカウント設定でこのローカルアカウントを Space に連携してください。',
},
workspace: {
title: 'ワークスペース',
@@ -1723,6 +1722,7 @@ const jaJP = {
botCreateSuccess: 'ボットが正常に作成されました!',
botSaveSuccess: 'ボット設定が保存され、有効になりました!',
createError: 'リソースの作成に失敗しました',
spaceAuthError: 'Space 認証の開始に失敗しました',
skipSaveError: 'スキップ状態の保存に失敗しました。もう一度お試しください。',
completeSaveError: '完了状態の保存に失敗しました。もう一度お試しください。',
step: {
@@ -1744,6 +1744,23 @@ const jaJP = {
resaveBot: '設定を再保存',
botSaved:
'ボット設定が保存され、有効になりました。ログを確認して接続を検証してください。',
waitingForMessage:
'ボットが有効になりました。続行するには IM からメッセージを送信してください。',
messageReceived:
'ボットが IM メッセージを受信しました。次のステップに進めます。',
pageBotTestPrompt:
'ページボットが有効になりました。右下のチャットバブルをクリックしてメッセージを送信し、会話フロー全体を確認してください。',
webhookTestPrompt:
'コールバック URL の準備ができました。外部プラットフォームに設定し、ボットへ実際のメッセージを送信してください。',
httpTestPrompt:
'HTTP Bot が有効になりました。実際の受信メッセージを送信して接続を確認できます。',
httpTestDefaultMessage: 'こんにちは。これは接続テストメッセージです。',
sendHttpTest: 'テストメッセージを送信',
httpTestAccepted:
'テストメッセージを受け付けました。まもなくログに表示されます。',
httpTestMissingSecret:
'受信署名シークレットを入力し、先に設定を保存してください。',
httpTestFailed: 'テストメッセージの送信に失敗しました:{{error}}',
logsTitle: 'ボットログ',
logsDescription:
'ボットの活動を監視して、プラットフォーム接続が正常に動作していることを確認します。',
@@ -1752,6 +1769,55 @@ const jaJP = {
title: 'AIエンジンを選択',
description:
'ボットのインテリジェンスを駆動するAIエンジンを選択してください。',
optionalDescription:
'このステップは任意です。現在の Agent をどのように設定するか選択してください。',
externalTitle: '外部プラットフォームの Agent を接続',
externalDescription:
'Dify、n8n、Coze などを接続し、ボットのパイプラインを置き換えます。',
ownModelTitle: '自分のモデルを使用',
ownModelDescription:
'プロバイダーを追加し、モデルをスキャンまたは手動入力して設定を完了します。',
ownModelSetupTitle: '自分のモデルを追加',
ownModelSetupDescription:
'モデルプロバイダーを追加すると自動スキャンされます。モデル ID の手動入力も可能です。',
addProviderTitle: 'プロバイダーを追加',
addProviderDescription:
'接続とモデルスキャンに使用するプロバイダー情報と API キーを入力します。',
selectModelTitle: 'モデルを選択',
selectScannedModelTitle: 'モデルを選択',
selectScannedModelDescription:
'選択したモデルを新しいパイプラインのメインモデルに設定し、ボットをそのパイプラインへ切り替えます。',
scanModelMode: 'モデルをスキャン',
manualModelMode: '手動で追加',
scanningModels: '利用可能なモデルをスキャン中…',
noScannedModels:
'利用可能なチャットモデルが見つかりません。プロバイダー設定を確認してください。',
scanModelsFailed:
'モデルのスキャンに失敗しました。URL と API キーを確認して再試行してください。',
manualFallbackFailed:
'自動スキャンに失敗しました。プロバイダーが対応するモデル ID を直接入力できます。',
manualFallbackEmpty:
'モデルが見つかりませんでした。プロバイダーが対応するモデル ID を直接入力できます。',
manualModelId: 'モデル ID',
manualModelIdPlaceholder: '例:gpt-4o',
manualModelIdDescription:
'モデルリクエストで実際に使用する model パラメーターを入力します。',
manualModelOptions: '任意のモデル機能',
editProvider: 'プロバイダーを編集',
rescanModels: 'モデルを再スキャン',
moreFeaturesTitle: 'Agent に機能を追加',
moreFeaturesDescription:
'ワークベンチを開き、ツールやナレッジなどの機能を追加します。',
runnerDescription: '外部 Agent の Runner を選択し、接続を設定します。',
backToChoices: '選択肢に戻る',
createExternal: '作成して関連付ける',
finishWithModel: '選択したモデルを使用して完了',
openWorkbench: 'ワークベンチを開く',
},
spaceBanner: {
message:
'LangBot Spaceに接続して、無料トライアルモデルクレジットとゼロ設定の即時セットアップを入手!',
action: 'Spaceで認証',
},
config: {
botInfo: 'ボット情報',
@@ -1832,9 +1898,6 @@ const jaJP = {
scanQRCode: '以下のQRコードをWeChatでスキャンし、トークンを自動入力',
loginSuccess: 'ログイン成功!トークンが自動入力されました',
loginFailed: 'ログイン失敗',
connecting: 'WeChatサービスに接続中...',
waitingForScan: 'スキャン待ち',
retry: '再試行',
},
dingtalk: {
createApp: 'ワンクリックでDingTalkアプリ作成',
+29 -40
View File
@@ -85,18 +85,18 @@ const ruRU = {
'Рекомендуется: Используйте официальные стабильные API моделей и облачные сервисы',
loginLocal: 'Войти с локальной учётной записью',
loginWithPassword: 'Войти с паролем',
spaceLoginTitle: 'Войти с аккаунтом LangBot',
spaceLoginTitle: 'Войти через Space',
spaceLoginDescription:
'Отсканируйте QR-код или перейдите по ссылке ниже для авторизации',
spaceLoginUserCode: 'Ваш код',
spaceLoginExpires: 'Код истекает через {{seconds}} секунд',
spaceLoginWaiting: 'Ожидание авторизации...',
spaceLoginSuccess: 'Авторизация успешна',
spaceLoginFailed: 'Ошибка входа с аккаунтом LangBot',
spaceLoginFailed: 'Ошибка входа через Space',
spaceLoginExpired: 'Код авторизации истёк, попробуйте снова',
spaceLoginCancel: 'Отмена',
spaceLoginVisitLink: 'Перейти по ссылке',
spaceLoginProcessing: 'Вход с аккаунтом LangBot',
spaceLoginProcessing: 'Вход через Space',
spaceLoginProcessingDescription:
'Пожалуйста, подождите, пока мы завершим вход...',
spaceLoginSuccessDescription: 'Перенаправление в LangBot...',
@@ -105,7 +105,7 @@ const ruRU = {
backToLogin: 'Вернуться к входу',
backToHome: 'На главную',
spaceAccountCannotChangePassword:
'Для аккаунтов LangBot невозможно изменить пароль здесь',
'Для аккаунтов Space невозможно изменить пароль здесь',
theme: 'Тема',
changePassword: 'Изменить пароль',
currentPassword: 'Текущий пароль',
@@ -217,19 +217,6 @@ const ruRU = {
selectModelAbilities: 'Выберите возможности модели',
visionAbility: 'Распознавание изображений',
functionCallAbility: 'Вызов функций',
reasoningAbility: 'Рассуждение',
reasoningLevel: 'Уровень рассуждений',
reasoningLevels: {
providerDefault: 'По умолчанию провайдера',
disabled: 'Выключено',
enabled: 'Включено',
minimal: 'Минимальный',
low: 'Низкий',
medium: 'Средний',
high: 'Высокий',
xhigh: 'Очень высокий',
max: 'Максимальный',
},
contextLength: 'Контекстное окно',
contextLengthPlaceholder: 'Неизвестно',
contextLengthInvalid:
@@ -259,9 +246,9 @@ const ruRU = {
localProvider: 'Локальный',
localProviderDescription: 'Модели, настроенные и управляемые локально',
spaceProviderDescription:
'Модели, синхронизированные из вашего аккаунта LangBot',
'Модели, синхронизированные из вашего аккаунта Space',
spaceDisabledForLocalAccount:
'Войдите с аккаунтом LangBot, чтобы использовать облачные модели',
'Войдите через Space, чтобы использовать облачные модели',
syncModels: 'Синхронизировать',
syncSuccess:
'Синхронизация завершена: {{created}} создано, {{updated}} обновлено',
@@ -300,12 +287,11 @@ const ruRU = {
langbotModelsDescription: 'Облачные модели на базе LangBot Space',
credits: 'Кредиты',
loginWithSpace: 'Войти с аккаунтом LangBot',
loginToUseModels:
'Войдите с аккаунтом LangBot, чтобы использовать облачные модели',
loginToUseModels: 'Войдите через Space, чтобы использовать облачные модели',
noModels: 'Модели не настроены',
langbotModels: 'Модели LangBot',
spaceTrialTooltip:
'Доступны бесплатные пробные кредиты! Войдите с аккаунтом LangBot, чтобы получить доступ к облачным моделям без настройки.',
'Доступны бесплатные пробные кредиты! Войдите через Space, чтобы получить доступ к облачным моделям без настройки.',
unlockModels: 'Войдите для использования',
editProvider: 'Редактировать провайдера',
addProvider: 'Добавить провайдера',
@@ -341,9 +327,9 @@ const ruRU = {
},
ownerMustBindSpace:
'The Workspace owner must connect a LangBot Account for LangBot Models.',
'The Workspace owner must connect Space for LangBot Models.',
usesOwnerSpaceBilling:
"Uses the Workspace owner's LangBot Account billing and credits.",
"Uses the Workspace owner's Space billing and credits.",
},
bots: {
title: 'Боты',
@@ -1230,13 +1216,13 @@ const ruRU = {
adminAccountNote:
'Указанная учётная запись будет настроена как администратор',
register: 'Регистрация',
initWithSpace: 'Инициализация с аккаунтом LangBot',
initWithSpace: 'Инициализация через Space',
spaceRecommended:
'Рекомендуется: Используйте официальные стабильные API моделей и облачные сервисы',
spaceInfoTip1:
'Space предоставляет единую службу аутентификации без загрузки конфиденциальной информации.',
spaceInfoTip2:
'Вход с аккаунтом LangBot даёт доступ к моделям LangBot и облачным сервисам, включая бесплатные кредиты для быстрого старта.',
'Вход через Space даёт доступ к моделям LangBot и облачным сервисам, включая бесплатные кредиты для быстрого старта.',
spaceInfoTip3:
'Способ входа не влияет на другие функции. Вы можете настроить модели из других источников в любое время.',
registerLocal: 'Зарегистрировать локальную учётную запись',
@@ -1292,34 +1278,34 @@ const ruRU = {
passwordNotSet: 'Не установлен',
passwordSetDescription:
'Пароль установлен, вы можете входить с email и паролем',
spaceStatus: 'Аккаунт LangBot',
spaceStatus: 'Аккаунт Space',
spaceBound: 'Привязан',
spaceNotBound: 'Не привязан',
spaceBoundDescription:
'Аккаунт LangBot привязан, доступны официальные API моделей и облачные сервисы',
bindSpace: 'Привязать аккаунт LangBot',
'Аккаунт Space привязан, доступны официальные API моделей и облачные сервисы',
bindSpace: 'Привязать аккаунт Space',
bindSpaceDescription:
'Привяжите для использования официальных API моделей и облачных сервисов',
bindSpaceButton: 'Привязать',
bindSpaceConfirmTitle: 'Подтверждение привязки',
bindSpaceConfirmDescription:
'Вы собираетесь привязать локальный экземпляр к аккаунту LangBot',
'Вы собираетесь привязать локальный экземпляр к аккаунту Space',
bindSpaceWarning:
'После привязки ваш email для входа будет изменён с {{localEmail}} на email аккаунта LangBot.',
bindSpaceSuccess: 'Аккаунт LangBot успешно привязан',
bindSpaceFailed: 'Не удалось привязать аккаунт LangBot',
'После привязки ваш email для входа будет изменён с {{localEmail}} на email аккаунта Space.',
bindSpaceSuccess: 'Аккаунт Space успешно привязан',
bindSpaceFailed: 'Не удалось привязать аккаунт Space',
bindSpaceInvalidState:
'Недействительный запрос привязки. Повторите попытку из настроек аккаунта.',
setPasswordHint: 'Установите пароль для входа с email и паролем',
spaceEmailMismatch:
'Email входа с аккаунтом LangBot не совпадает с email локальной учётной записи',
'Email входа через Space не совпадает с email локальной учётной записи',
space_account_not_registeredTitle: 'Account not registered',
space_account_not_registered:
'No local account is registered for this LangBot Account email. Ask the Workspace owner for an invitation.',
space_account_binding_requiredTitle: 'LangBot Account connection required',
'No local account is registered for this Space email. Ask the Workspace owner for an invitation.',
space_account_binding_requiredTitle: 'Space connection required',
space_account_binding_required:
'This local account must connect a LangBot Account from Account settings before using LangBot Account login.',
'This local account must connect Space from Account settings before using Space login.',
},
monitoring: {
title: 'Мониторинг',
@@ -1646,6 +1632,7 @@ const ruRU = {
botCreateSuccess: 'Бот успешно создан!',
botSaveSuccess: 'Конфигурация бота сохранена и включена!',
createError: 'Не удалось создать ресурсы',
spaceAuthError: 'Не удалось инициировать авторизацию через Space',
skipSaveError: 'Не удалось сохранить статус пропуска. Повторите попытку.',
completeSaveError:
'Не удалось сохранить статус завершения. Повторите попытку.',
@@ -1675,6 +1662,11 @@ const ruRU = {
description:
'Выберите ИИ-движок, который будет управлять интеллектом вашего бота.',
},
spaceBanner: {
message:
'Подключитесь к LangBot Space для бесплатных пробных кредитов и мгновенной настройки!',
action: 'Авторизация через Space',
},
config: {
botInfo: 'Информация о боте',
botNamePlaceholder: 'Введите имя бота',
@@ -1725,9 +1717,6 @@ const ruRU = {
'Отсканируйте QR-код ниже в WeChat, чтобы авторизоваться и автоматически заполнить токен',
loginSuccess: 'Вход выполнен успешно! Токен заполнен автоматически',
loginFailed: 'Не удалось выполнить вход',
connecting: 'Подключение к сервису WeChat...',
waitingForScan: 'Ожидание сканирования',
retry: 'Повторить',
},
dingtalk: {
createApp: 'Создать приложение DingTalk в один клик',
+29 -42
View File
@@ -85,18 +85,18 @@ const thTH = {
'แนะนำ: ใช้ API โมเดลที่เสถียรอย่างเป็นทางการและบริการคลาวด์',
loginLocal: 'เข้าสู่ระบบด้วยบัญชีท้องถิ่น',
loginWithPassword: 'เข้าสู่ระบบด้วยรหัสผ่าน',
spaceLoginTitle: 'เข้าสู่ระบบด้วยบัญชี LangBot',
spaceLoginTitle: 'เข้าสู่ระบบด้วย Space',
spaceLoginDescription:
'สแกน QR code หรือเข้าชมลิงก์ด้านล่างเพื่อยืนยันสิทธิ์',
spaceLoginUserCode: 'รหัสของคุณ',
spaceLoginExpires: 'รหัสจะหมดอายุใน {{seconds}} วินาที',
spaceLoginWaiting: 'กำลังรอการยืนยันสิทธิ์...',
spaceLoginSuccess: 'ยืนยันสิทธิ์สำเร็จ',
spaceLoginFailed: 'เข้าสู่ระบบด้วยบัญชี LangBot ล้มเหลว',
spaceLoginFailed: 'เข้าสู่ระบบ Space ล้มเหลว',
spaceLoginExpired: 'รหัสยืนยันหมดอายุแล้ว กรุณาลองใหม่',
spaceLoginCancel: 'ยกเลิก',
spaceLoginVisitLink: 'เข้าชมลิงก์',
spaceLoginProcessing: 'กำลังเข้าสู่ระบบด้วยบัญชี LangBot',
spaceLoginProcessing: 'กำลังเข้าสู่ระบบด้วย Space',
spaceLoginProcessingDescription: 'กรุณารอสักครู่ขณะดำเนินการเข้าสู่ระบบ...',
spaceLoginSuccessDescription: 'กำลังเปลี่ยนเส้นทางไปยัง LangBot...',
spaceLoginError: 'เข้าสู่ระบบล้มเหลว',
@@ -104,7 +104,7 @@ const thTH = {
backToLogin: 'กลับไปหน้าเข้าสู่ระบบ',
backToHome: 'กลับไปหน้าแรก',
spaceAccountCannotChangePassword:
'บัญชี LangBot ไม่สามารถเปลี่ยนรหัสผ่านได้ที่นี่',
'บัญชี Space ไม่สามารถเปลี่ยนรหัสผ่านได้ที่นี่',
theme: 'ธีม',
changePassword: 'เปลี่ยนรหัสผ่าน',
currentPassword: 'รหัสผ่านปัจจุบัน',
@@ -213,19 +213,6 @@ const thTH = {
selectModelAbilities: 'เลือกความสามารถของโมเดล',
visionAbility: 'ความสามารถด้านภาพ',
functionCallAbility: 'การเรียกฟังก์ชัน',
reasoningAbility: 'ความสามารถในการให้เหตุผล',
reasoningLevel: 'ระดับการให้เหตุผล',
reasoningLevels: {
providerDefault: 'ค่าเริ่มต้นของผู้ให้บริการ',
disabled: 'ปิด',
enabled: 'เปิด',
minimal: 'ต่ำสุด',
low: 'ต่ำ',
medium: 'ปานกลาง',
high: 'สูง',
xhigh: 'สูงมาก',
max: 'สูงสุด',
},
contextLength: 'หน้าต่างบริบท',
contextLengthPlaceholder: 'ไม่ทราบ',
contextLengthInvalid: 'หน้าต่างบริบทต้องเป็นจำนวนเต็มบวก',
@@ -252,9 +239,8 @@ const thTH = {
llmModels: 'โมเดล LLM',
localProvider: 'ท้องถิ่น',
localProviderDescription: 'โมเดลที่กำหนดค่าและจัดการในเครื่อง',
spaceProviderDescription: 'โมเดลที่ซิงค์จากบัญชี LangBot ของคุณ',
spaceDisabledForLocalAccount:
'เข้าสู่ระบบด้วยบัญชี LangBot เพื่อใช้โมเดลคลาวด์',
spaceProviderDescription: 'โมเดลที่ซิงค์จากบัญชี Space ของคุณ',
spaceDisabledForLocalAccount: 'เข้าสู่ระบบด้วย Space เพื่อใช้โมเดลคลาวด์',
syncModels: 'ซิงค์',
syncSuccess:
'ซิงค์เสร็จสมบูรณ์: สร้าง {{created}} รายการ, อัปเดต {{updated}} รายการ',
@@ -290,11 +276,11 @@ const thTH = {
langbotModelsDescription: 'โมเดลคลาวด์ขับเคลื่อนโดย LangBot Space',
credits: 'เครดิต',
loginWithSpace: 'เข้าสู่ระบบด้วยบัญชี LangBot',
loginToUseModels: 'เข้าสู่ระบบด้วยบัญชี LangBot เพื่อใช้โมเดลคลาวด์',
loginToUseModels: 'เข้าสู่ระบบด้วย Space เพื่อใช้โมเดลคลาวด์',
noModels: 'ยังไม่มีโมเดลที่กำหนดค่า',
langbotModels: 'โมเดล LangBot',
spaceTrialTooltip:
'มีเครดิตทดลองใช้งานฟรี! เข้าสู่ระบบด้วยบัญชี LangBot เพื่อเข้าถึงโมเดลคลาวด์โดยไม่ต้องตั้งค่า',
'มีเครดิตทดลองใช้งานฟรี! เข้าสู่ระบบด้วย Space เพื่อเข้าถึงโมเดลคลาวด์โดยไม่ต้องตั้งค่า',
unlockModels: 'เข้าสู่ระบบเพื่อใช้งาน',
editProvider: 'แก้ไขผู้ให้บริการ',
addProvider: 'เพิ่มผู้ให้บริการ',
@@ -328,9 +314,9 @@ const thTH = {
},
ownerMustBindSpace:
'The Workspace owner must connect a LangBot Account for LangBot Models.',
'The Workspace owner must connect Space for LangBot Models.',
usesOwnerSpaceBilling:
"Uses the Workspace owner's LangBot Account billing and credits.",
"Uses the Workspace owner's Space billing and credits.",
},
bots: {
title: 'บอท',
@@ -1203,13 +1189,13 @@ const thTH = {
description: 'นี่เป็นครั้งแรกที่คุณเริ่มใช้งาน LangBot',
adminAccountNote: 'บัญชีที่คุณใช้ที่นี่จะถูกตั้งเป็นบัญชีผู้ดูแลระบบ',
register: 'ลงทะเบียน',
initWithSpace: 'เริ่มต้นด้วยบัญชี LangBot',
initWithSpace: 'เริ่มต้นด้วย Space',
spaceRecommended:
'แนะนำ: ใช้ API โมเดลที่เสถียรอย่างเป็นทางการและบริการคลาวด์',
spaceInfoTip1:
'Space ให้บริการยืนยันตัวตนแบบรวมโดยไม่อัปโหลดข้อมูลสำคัญใดๆ ของคุณ',
spaceInfoTip2:
'การเข้าสู่ระบบด้วยบัญชี LangBot ช่วยให้คุณเข้าถึงโมเดล LangBot และบริการคลาวด์อื่นๆ รวมถึงเครดิตเรียกใช้โมเดลฟรีเพื่อช่วยให้คุณเริ่มต้นได้อย่างรวดเร็ว',
'การเข้าสู่ระบบด้วยบัญชี Space ช่วยให้คุณเข้าถึงโมเดล LangBot และบริการคลาวด์อื่นๆ รวมถึงเครดิตเรียกใช้โมเดลฟรีเพื่อช่วยให้คุณเริ่มต้นได้อย่างรวดเร็ว',
spaceInfoTip3:
'วิธีการเข้าสู่ระบบของคุณไม่มีผลต่อฟีเจอร์อื่นๆ คุณสามารถกำหนดค่าและใช้โมเดลจากแหล่งอื่นได้ตลอดเวลา',
registerLocal: 'ลงทะเบียนบัญชีท้องถิ่น',
@@ -1264,32 +1250,30 @@ const thTH = {
passwordNotSet: 'ยังไม่ได้ตั้งค่า',
passwordSetDescription:
'ตั้งรหัสผ่านแล้ว คุณสามารถเข้าสู่ระบบด้วยอีเมลและรหัสผ่าน',
spaceStatus: 'บัญชี LangBot',
spaceStatus: 'บัญชี Space',
spaceBound: 'ผูกแล้ว',
spaceNotBound: 'ยังไม่ผูก',
spaceBoundDescription:
'ผูกบัญชี LangBot แล้ว สามารถใช้ API โมเดลอย่างเป็นทางการและบริการคลาวด์ได้',
bindSpace: 'ผูกบัญชี LangBot',
'ผูกบัญชี Space แล้ว สามารถใช้ API โมเดลอย่างเป็นทางการและบริการคลาวด์ได้',
bindSpace: 'ผูกบัญชี Space',
bindSpaceDescription: 'ผูกเพื่อใช้ API โมเดลอย่างเป็นทางการและบริการคลาวด์',
bindSpaceButton: 'ผูก',
bindSpaceConfirmTitle: 'ยืนยันการผูก',
bindSpaceConfirmDescription:
'คุณกำลังจะผูกอินสแตนซ์ท้องถิ่นกับบัญชี LangBot',
bindSpaceConfirmDescription: 'คุณกำลังจะผูกอินสแตนซ์ท้องถิ่นกับบัญชี Space',
bindSpaceWarning:
'หลังจากผูกแล้ว อีเมลเข้าสู่ระบบของคุณจะเปลี่ยนจาก {{localEmail}} เป็นอีเมลบัญชี LangBot',
bindSpaceSuccess: 'ผูกบัญชี LangBot สำเร็จ',
bindSpaceFailed: 'ผูกบัญชี LangBot ล้มเหลว',
'หลังจากผูกแล้ว อีเมลเข้าสู่ระบบของคุณจะเปลี่ยนจาก {{localEmail}} เป็นอีเมลบัญชี Space',
bindSpaceSuccess: 'ผูกบัญชี Space สำเร็จ',
bindSpaceFailed: 'ผูกบัญชี Space ล้มเหลว',
bindSpaceInvalidState: 'คำขอผูกไม่ถูกต้อง กรุณาลองใหม่จากการตั้งค่าบัญชี',
setPasswordHint: 'ตั้งรหัสผ่านเพื่อเข้าสู่ระบบด้วยอีเมลและรหัสผ่าน',
spaceEmailMismatch:
'อีเมลเข้าสู่ระบบด้วยบัญชี LangBot ไม่ตรงกับอีเมลบัญชีท้องถิ่น',
spaceEmailMismatch: 'อีเมลเข้าสู่ระบบ Space ไม่ตรงกับอีเมลบัญชีท้องถิ่น',
space_account_not_registeredTitle: 'Account not registered',
space_account_not_registered:
'No local account is registered for this LangBot Account email. Ask the Workspace owner for an invitation.',
space_account_binding_requiredTitle: 'LangBot Account connection required',
'No local account is registered for this Space email. Ask the Workspace owner for an invitation.',
space_account_binding_requiredTitle: 'Space connection required',
space_account_binding_required:
'This local account must connect a LangBot Account from Account settings before using LangBot Account login.',
'This local account must connect Space from Account settings before using Space login.',
},
monitoring: {
title: 'แดชบอร์ด',
@@ -1615,6 +1599,7 @@ const thTH = {
botCreateSuccess: 'สร้าง Bot สำเร็จ!',
botSaveSuccess: 'บันทึกและเปิดใช้งาน Bot สำเร็จ!',
createError: 'ไม่สามารถสร้างทรัพยากรได้',
spaceAuthError: 'ไม่สามารถเริ่มต้นการยืนยันสิทธิ์ Space ได้',
skipSaveError: 'ไม่สามารถบันทึกสถานะการข้ามได้ กรุณาลองใหม่',
completeSaveError: 'ไม่สามารถบันทึกสถานะการเสร็จสิ้นได้ กรุณาลองใหม่',
step: {
@@ -1642,6 +1627,11 @@ const thTH = {
title: 'เลือกเครื่องมือ AI',
description: 'เลือกเครื่องมือ AI ที่จะขับเคลื่อนความฉลาดของ Bot',
},
spaceBanner: {
message:
'เชื่อมต่อกับ LangBot Space เพื่อรับเครดิตทดลองใช้โมเดลฟรีและตั้งค่าทันทีโดยไม่ต้องกำหนดค่า!',
action: 'ยืนยันสิทธิ์กับ Space',
},
config: {
botInfo: 'ข้อมูล Bot',
botNamePlaceholder: 'กรอกชื่อ Bot',
@@ -1690,9 +1680,6 @@ const thTH = {
'สแกนคิวอาร์โค้ดด้านล่างด้วย WeChat เพื่ออนุญาตและกรอกโทเคนอัตโนมัติ',
loginSuccess: 'เข้าสู่ระบบสำเร็จ และกรอกโทเคนอัตโนมัติแล้ว',
loginFailed: 'เข้าสู่ระบบไม่สำเร็จ',
connecting: 'กำลังเชื่อมต่อบริการ WeChat...',
waitingForScan: 'กำลังรอการสแกน',
retry: 'ลองอีกครั้ง',
},
dingtalk: {
createApp: 'สร้างแอป DingTalk ด้วยคลิกเดียว',

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