mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-27 04:37:13 +00:00
Compare commits
34 Commits
b121c66f18
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 08307790e5 | |||
| 777fe1f20b | |||
| f0ee57c1e0 | |||
| a45e27e76e | |||
| 536fcdf29f | |||
| c87548c0b9 | |||
| 79634772da | |||
| bb366779af | |||
| 1336f47cb4 | |||
| 962366c507 | |||
| 23875b240f | |||
| e699358a5a | |||
| 14277d129c | |||
| 6bf1546df2 | |||
| 0bec72a3f9 | |||
| f36542135a | |||
| 693c59b726 | |||
| c3fe312a43 | |||
| c4bad508d2 | |||
| 3d4a726cd8 | |||
| e934f08adf | |||
| 7803d56254 | |||
| 54c96a18e1 | |||
| 579e3556e4 | |||
| a08a177a11 | |||
| c224f61c8c | |||
| b62cc9da45 | |||
| 700104c015 | |||
| 717bd4b8bf | |||
| 0cc0e1b02d | |||
| 1f1a3aff55 | |||
| de28b3160c | |||
| 79773b669a | |||
| 5084f2391d |
@@ -7,23 +7,42 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
build-dev-image:
|
build-dev-image:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
# 如果是tag则跳过
|
|
||||||
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Generate Tag
|
- name: Set up Docker Buildx
|
||||||
id: generate_tag
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Generate image metadata
|
||||||
|
id: image
|
||||||
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
# 获取分支名称,把/替换为-
|
set -euo pipefail
|
||||||
echo ${{ github.ref }} | sed 's/refs\/heads\///g' | sed 's/\//-/g'
|
branch_tag="${GITHUB_REF#refs/heads/}"
|
||||||
echo ::set-output name=tag::$(echo ${{ github.ref }} | sed 's/refs\/heads\///g' | sed 's/\//-/g')
|
branch_tag="${branch_tag//\//-}"
|
||||||
- name: Login to Registry
|
echo "branch_tag=${branch_tag}" >> "$GITHUB_OUTPUT"
|
||||||
run: docker login --username=${{ secrets.DOCKER_USERNAME }} --password ${{ secrets.DOCKER_PASSWORD }}
|
echo "sha_tag=sha-${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
|
||||||
- name: Build Docker Image
|
|
||||||
run: |
|
- name: Login to Docker Hub
|
||||||
docker buildx create --name mybuilder --use
|
uses: docker/login-action@v3
|
||||||
docker build -t rockchin/langbot:${{ steps.generate_tag.outputs.tag }} . --push
|
with:
|
||||||
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Build and push immutable Core image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
rockchin/langbot:${{ steps.image.outputs.branch_tag }}
|
||||||
|
rockchin/langbot:${{ steps.image.outputs.sha_tag }}
|
||||||
|
labels: |
|
||||||
|
org.opencontainers.image.revision=${{ github.sha }}
|
||||||
|
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
|
||||||
|
|||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
FROM node:22-alpine AS node
|
FROM --platform=$BUILDPLATFORM node:22-alpine AS node
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@ RUN apt-get update \
|
|||||||
&& apt-get install -y --no-install-recommends nodejs \
|
&& apt-get install -y --no-install-recommends nodejs \
|
||||||
&& rm -f /tmp/nodesource_setup.sh \
|
&& rm -f /tmp/nodesource_setup.sh \
|
||||||
&& python -m pip install --no-cache-dir uv \
|
&& python -m pip install --no-cache-dir uv \
|
||||||
&& uv sync \
|
&& uv sync --extra seekdb \
|
||||||
&& apt-get purge -y --auto-remove curl git gnupg \
|
&& apt-get purge -y --auto-remove curl git gnupg \
|
||||||
&& rm -rf /var/lib/apt/lists/* \
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
&& touch /.dockerenv
|
&& touch /.dockerenv
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ cd LangBot/docker
|
|||||||
docker compose --profile all up -d
|
docker compose --profile all up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
### One-Click Cloud Deploy
|
### One-Click Cloud Deploy
|
||||||
|
|
||||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ cd LangBot/docker
|
|||||||
docker compose --profile all up -d
|
docker compose --profile all up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
### 一键云部署
|
### 一键云部署
|
||||||
|
|
||||||
[](https://zeabur.com/zh-CN/templates/ZKTBDH)
|
[](https://zeabur.com/zh-CN/templates/ZKTBDH)
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ cd LangBot/docker
|
|||||||
docker compose --profile all up -d
|
docker compose --profile all up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
### Despliegue en la Nube con un Clic
|
### Despliegue en la Nube con un Clic
|
||||||
|
|
||||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ cd LangBot/docker
|
|||||||
docker compose --profile all up -d
|
docker compose --profile all up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
### Déploiement Cloud en un Clic
|
### Déploiement Cloud en un Clic
|
||||||
|
|
||||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ cd LangBot/docker
|
|||||||
docker compose --profile all up -d
|
docker compose --profile all up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
### ワンクリッククラウドデプロイ
|
### ワンクリッククラウドデプロイ
|
||||||
|
|
||||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ cd LangBot/docker
|
|||||||
docker compose --profile all up -d
|
docker compose --profile all up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
### 원클릭 클라우드 배포
|
### 원클릭 클라우드 배포
|
||||||
|
|
||||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ cd LangBot/docker
|
|||||||
docker compose --profile all up -d
|
docker compose --profile all up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
### Облачное развертывание одним кликом
|
### Облачное развертывание одним кликом
|
||||||
|
|
||||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ cd LangBot/docker
|
|||||||
docker compose --profile all up -d
|
docker compose --profile all up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
### 一鍵雲端部署
|
### 一鍵雲端部署
|
||||||
|
|
||||||
[](https://zeabur.com/zh-CN/templates/ZKTBDH)
|
[](https://zeabur.com/zh-CN/templates/ZKTBDH)
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ cd LangBot/docker
|
|||||||
docker compose --profile all up -d
|
docker compose --profile all up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
### Triển khai đám mây một cú nhấp
|
### Triển khai đám mây một cú nhấp
|
||||||
|
|
||||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||||
|
|||||||
@@ -47,11 +47,10 @@ services:
|
|||||||
restart: on-failure
|
restart: on-failure
|
||||||
environment:
|
environment:
|
||||||
- TZ=Asia/Shanghai
|
- TZ=Asia/Shanghai
|
||||||
# Shared control-plane secret used to authenticate both the RPC socket
|
# Optional shared control-plane secret used to authenticate both the RPC
|
||||||
# and managed-process relay. Generate once (for example with
|
# socket and managed-process relay. Leave unset on both OSS services, or
|
||||||
# ``openssl rand -hex 32``) and export it before enabling this profile.
|
# generate one with ``openssl rand -hex 32`` and set the same value on
|
||||||
# An empty value is accepted by Compose so Box can remain optional, but
|
# both ends. Strongly recommended when the deployment is Internet-accessible.
|
||||||
# the Box runtime itself fails closed when the profile is started.
|
|
||||||
- LANGBOT_BOX_CONTROL_TOKEN=${LANGBOT_BOX_CONTROL_TOKEN:-}
|
- LANGBOT_BOX_CONTROL_TOKEN=${LANGBOT_BOX_CONTROL_TOKEN:-}
|
||||||
# Box has its own process-wide blocking-work budget.
|
# Box has its own process-wide blocking-work budget.
|
||||||
- LANGBOT_BLOCKING_EXECUTOR_MAX_WORKERS=${LANGBOT_BLOCKING_EXECUTOR_MAX_WORKERS:-8}
|
- LANGBOT_BLOCKING_EXECUTOR_MAX_WORKERS=${LANGBOT_BLOCKING_EXECUTOR_MAX_WORKERS:-8}
|
||||||
@@ -79,8 +78,9 @@ services:
|
|||||||
- TZ=Asia/Shanghai
|
- TZ=Asia/Shanghai
|
||||||
# Optional. Leave unset on both OSS services, or match plugin Runtime.
|
# Optional. Leave unset on both OSS services, or match plugin Runtime.
|
||||||
- LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN=${LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN:-}
|
- LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN=${LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN:-}
|
||||||
# Must match the value supplied to langbot_box. The token is sent only
|
# When set, this must match langbot_box. If both ends leave it unset,
|
||||||
# in WebSocket handshake headers, never in URLs or action payloads.
|
# OSS permits the connection without token authentication. The token is
|
||||||
|
# sent only in WebSocket handshake headers, never in URLs or payloads.
|
||||||
- LANGBOT_BOX_CONTROL_TOKEN=${LANGBOT_BOX_CONTROL_TOKEN:-}
|
- LANGBOT_BOX_CONTROL_TOKEN=${LANGBOT_BOX_CONTROL_TOKEN:-}
|
||||||
# Core process-wide blocking-work admission. These are native config
|
# Core process-wide blocking-work admission. These are native config
|
||||||
# overrides and are persisted with the effective data/config.yaml.
|
# overrides and are persisted with the effective data/config.yaml.
|
||||||
|
|||||||
@@ -10,6 +10,19 @@ uvx langbot
|
|||||||
|
|
||||||
This will automatically download and run the latest version of LangBot.
|
This will automatically download and run the latest version of LangBot.
|
||||||
|
|
||||||
|
SeekDB support is optional and is not installed by the command above. If you
|
||||||
|
want to use the SeekDB vector database or the built-in SeekDB embedding model,
|
||||||
|
run LangBot with the `seekdb` extra:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uvx --from 'langbot[seekdb]@latest' langbot
|
||||||
|
```
|
||||||
|
|
||||||
|
The extra includes native dependencies whose supported operating systems may
|
||||||
|
be narrower than LangBot's. In particular, the current Apple Silicon wheels
|
||||||
|
require macOS 15 or later. The default Chroma backend does not have this
|
||||||
|
requirement.
|
||||||
|
|
||||||
## Install with pip/uv
|
## Install with pip/uv
|
||||||
|
|
||||||
You can also install LangBot as a regular Python package:
|
You can also install LangBot as a regular Python package:
|
||||||
@@ -20,6 +33,10 @@ pip install langbot
|
|||||||
|
|
||||||
# Using uv
|
# Using uv
|
||||||
uv pip install langbot
|
uv pip install langbot
|
||||||
|
|
||||||
|
# Include optional SeekDB support
|
||||||
|
pip install 'langbot[seekdb]'
|
||||||
|
# or: uv pip install 'langbot[seekdb]'
|
||||||
```
|
```
|
||||||
|
|
||||||
Then run it:
|
Then run it:
|
||||||
@@ -101,7 +118,7 @@ uvx langbot
|
|||||||
|
|
||||||
## System Requirements
|
## System Requirements
|
||||||
|
|
||||||
- Python 3.10.1 or higher
|
- Python 3.11 or higher (lower than Python 4)
|
||||||
- Operating System: Linux, macOS, or Windows
|
- Operating System: Linux, macOS, or Windows
|
||||||
|
|
||||||
## Differences from Source Installation
|
## Differences from Source Installation
|
||||||
|
|||||||
+34
-43
@@ -16,12 +16,20 @@ This document describes how to use OceanBase SeekDB as the vector database backe
|
|||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
SeekDB support is automatically included when you install LangBot. The required dependency `pyseekdb` is listed in `pyproject.toml`.
|
SeekDB is an optional LangBot feature. A normal LangBot installation uses
|
||||||
|
Chroma by default and does not install `pyseekdb` or its native bindings.
|
||||||
|
|
||||||
If you need to install it manually:
|
Choose the command that matches how you run LangBot:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install pyseekdb
|
# PyPI / uvx
|
||||||
|
uvx --from 'langbot[seekdb]@latest' langbot
|
||||||
|
|
||||||
|
# Installed package
|
||||||
|
pip install 'langbot[seekdb]'
|
||||||
|
|
||||||
|
# Source checkout
|
||||||
|
uv sync --extra seekdb
|
||||||
```
|
```
|
||||||
|
|
||||||
## ⚠️ Platform Compatibility
|
## ⚠️ Platform Compatibility
|
||||||
@@ -30,31 +38,36 @@ pip install pyseekdb
|
|||||||
|
|
||||||
| Platform | Status | Notes |
|
| Platform | Status | Notes |
|
||||||
|----------|--------|-------|
|
|----------|--------|-------|
|
||||||
| Linux | ✅ Supported | Full embedded mode support via `pylibseekdb` |
|
| Linux x86_64 / ARM64 | ✅ Supported | Full embedded mode support via `pylibseekdb` |
|
||||||
| macOS | ❌ Not Supported | `pylibseekdb` is Linux-only; use server mode instead |
|
| macOS 15+ on Apple Silicon | ✅ Supported | Requires the macOS ARM64 `pylibseekdb` wheel |
|
||||||
| Windows | ❌ Not Supported | `pylibseekdb` is Linux-only; use server mode instead |
|
| macOS 14 or earlier on Apple Silicon | ❌ Not currently supported | The published native wheel requires macOS 15+; follow [oceanbase/seekdb#1324](https://github.com/oceanbase/seekdb/issues/1324) |
|
||||||
|
| macOS on Intel | ❌ Not currently supported | No embedded binding is selected by `pyseekdb` |
|
||||||
|
| Windows | ❌ Not currently supported | No Windows `pylibseekdb` wheel is published |
|
||||||
|
|
||||||
**Important**: Embedded mode requires the `pylibseekdb` library, which is only available on Linux. If you're on macOS or Windows, you must use server mode.
|
**Important**: Embedded mode requires a compatible `pylibseekdb` wheel. Do not
|
||||||
|
force-install or retag a wheel built for a newer macOS release: the bundled
|
||||||
|
binaries also declare macOS 15 as their minimum deployment target.
|
||||||
|
|
||||||
### Server Mode (Docker)
|
### Server Mode (Docker)
|
||||||
|
|
||||||
| Platform | Status | Notes |
|
| Platform | Status | Notes |
|
||||||
|----------|--------|-------|
|
|----------|--------|-------|
|
||||||
| Linux | ✅ Supported | Full Docker support |
|
| Linux | ✅ Supported | Full Docker support |
|
||||||
| macOS | ⚠️ Known Issue | Docker container initialization failure - [See Issue #36](https://github.com/oceanbase/seekdb/issues/36) |
|
| macOS | ✅ Supported by Docker Desktop | The previous slow-disk startup issue was fixed upstream in [oceanbase/seekdb#36](https://github.com/oceanbase/seekdb/issues/36) |
|
||||||
| Windows | ⚠️ Untested | Should work but not yet tested |
|
| Windows | ⚠️ Depends on the container runtime | Use a Linux container and follow the upstream image documentation |
|
||||||
|
|
||||||
**macOS Users**: Currently, SeekDB Docker containers have an initialization issue on macOS ([oceanbase/seekdb#36](https://github.com/oceanbase/seekdb/issues/36)). Until this is resolved, we recommend:
|
|
||||||
- Using ChromaDB or Qdrant as alternatives
|
|
||||||
- Connecting to a remote SeekDB server on Linux if available
|
|
||||||
|
|
||||||
### Server Mode (Remote Connection)
|
### Server Mode (Remote Connection)
|
||||||
|
|
||||||
| Platform | Status | Notes |
|
| Platform | Status | Notes |
|
||||||
|----------|--------|-------|
|
|----------|--------|-------|
|
||||||
| All Platforms | ✅ Supported | Connect to SeekDB running on a remote Linux server |
|
| Linux | ✅ Supported | Install the `seekdb` extra and connect to the remote server |
|
||||||
|
| macOS 15+ on Apple Silicon | ✅ Supported | Install the `seekdb` extra and connect to the remote server |
|
||||||
|
| macOS 14 or earlier on Apple Silicon | ⚠️ Blocked by upstream packaging | `pyseekdb` currently requires the unavailable native wheel even for server-only use; follow [#1324](https://github.com/oceanbase/seekdb/issues/1324) |
|
||||||
|
| macOS on Intel / Windows | ✅ Server mode only | Embedded bindings are not available |
|
||||||
|
|
||||||
**Recommendation for macOS/Windows users**: Deploy SeekDB on a Linux server and connect via server mode configuration.
|
Remote server mode does not use embedded storage at runtime. However, whether
|
||||||
|
the Python client can be installed still depends on `pyseekdb`'s package
|
||||||
|
metadata for the current platform.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
@@ -170,22 +183,23 @@ Key methods:
|
|||||||
|
|
||||||
### Import Error
|
### Import Error
|
||||||
|
|
||||||
If you see: `ImportError: pyseekdb is not installed`
|
If you see: `SeekDB support is not installed`
|
||||||
|
|
||||||
Solution:
|
Solution:
|
||||||
```bash
|
```bash
|
||||||
pip install pyseekdb
|
uv sync --extra seekdb
|
||||||
|
# or: uvx --from 'langbot[seekdb]@latest' langbot
|
||||||
```
|
```
|
||||||
|
|
||||||
### Embedded Mode Error on macOS/Windows
|
### Embedded Mode Is Unavailable on the Current Platform
|
||||||
|
|
||||||
**Error**:
|
**Error**:
|
||||||
```
|
```
|
||||||
RuntimeError: Embedded Client is not available because pylibseekdb is not available.
|
RuntimeError: Embedded Client is not available because pylibseekdb is not available.
|
||||||
Please install pylibseekdb (Linux only) or use RemoteServerClient (host/port) instead.
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Cause**: `pylibseekdb` is only available on Linux platforms.
|
**Cause**: No compatible `pylibseekdb` wheel is installed for the current OS,
|
||||||
|
CPU architecture, Python version, and macOS deployment target.
|
||||||
|
|
||||||
**Solution**: Use server mode instead:
|
**Solution**: Use server mode instead:
|
||||||
1. Deploy SeekDB on a Linux server or VM
|
1. Deploy SeekDB on a Linux server or VM
|
||||||
@@ -208,29 +222,6 @@ vdb:
|
|||||||
use: chroma # or qdrant
|
use: chroma # or qdrant
|
||||||
```
|
```
|
||||||
|
|
||||||
### Docker Container Fails on macOS
|
|
||||||
|
|
||||||
**Symptoms**:
|
|
||||||
```bash
|
|
||||||
docker run -d -p 2881:2881 oceanbase/seekdb:latest
|
|
||||||
# Container exits immediately with code 30
|
|
||||||
```
|
|
||||||
|
|
||||||
**Error in logs**:
|
|
||||||
```
|
|
||||||
[ERROR] Code: Agent.SeekDB.Not.Exists
|
|
||||||
Message: initialize failed: init agent failed: SeekDB not exists in current directory.
|
|
||||||
```
|
|
||||||
|
|
||||||
**Cause**: This is a known issue with SeekDB Docker containers on macOS. See [oceanbase/seekdb#36](https://github.com/oceanbase/seekdb/issues/36).
|
|
||||||
|
|
||||||
**Status**: Under investigation by OceanBase team.
|
|
||||||
|
|
||||||
**Workaround Options**:
|
|
||||||
1. **Use alternatives**: ChromaDB or Qdrant work perfectly on macOS
|
|
||||||
2. **Remote server**: Deploy SeekDB on a Linux server and connect remotely
|
|
||||||
3. **Wait for fix**: Monitor the GitHub issue for updates
|
|
||||||
|
|
||||||
### Connection Error (Server Mode)
|
### Connection Error (Server Mode)
|
||||||
|
|
||||||
If SeekDB server is not reachable, check:
|
If SeekDB server is not reachable, check:
|
||||||
|
|||||||
+7
-3
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "langbot"
|
name = "langbot"
|
||||||
version = "4.10.7"
|
version = "4.10.8"
|
||||||
description = "Production-grade platform for building agentic IM bots"
|
description = "Production-grade platform for building agentic IM bots"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license-files = ["LICENSE"]
|
license-files = ["LICENSE"]
|
||||||
@@ -70,8 +70,7 @@ dependencies = [
|
|||||||
"langchain-text-splitters>=1.1.2",
|
"langchain-text-splitters>=1.1.2",
|
||||||
"chromadb>=1.0.0,<2.0.0",
|
"chromadb>=1.0.0,<2.0.0",
|
||||||
"qdrant-client (>=1.15.1,<2.0.0)",
|
"qdrant-client (>=1.15.1,<2.0.0)",
|
||||||
"pyseekdb==1.1.0.post3",
|
"langbot-plugin==0.5.5",
|
||||||
"langbot-plugin @ git+https://github.com/langbot-app/langbot-plugin-sdk.git@555a58e5db3de28e977b08dd4cd116b332848a19",
|
|
||||||
"asyncpg>=0.30.0",
|
"asyncpg>=0.30.0",
|
||||||
"line-bot-sdk>=3.19.0",
|
"line-bot-sdk>=3.19.0",
|
||||||
"matrix-nio>=0.25.2",
|
"matrix-nio>=0.25.2",
|
||||||
@@ -108,6 +107,11 @@ classifiers = [
|
|||||||
"Topic :: Communications :: Chat",
|
"Topic :: Communications :: Chat",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
seekdb = [
|
||||||
|
"pyseekdb==1.1.0.post3",
|
||||||
|
]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
Homepage = "https://langbot.app"
|
Homepage = "https://langbot.app"
|
||||||
Documentation = "https://docs.langbot.app"
|
Documentation = "https://docs.langbot.app"
|
||||||
|
|||||||
@@ -422,6 +422,69 @@ class QQOfficialClient:
|
|||||||
await self.logger.error(f'Failed to send private message: {response_data}')
|
await self.logger.error(f'Failed to send private message: {response_data}')
|
||||||
raise ValueError(response)
|
raise ValueError(response)
|
||||||
|
|
||||||
|
async def _send_markdown_msg(
|
||||||
|
self,
|
||||||
|
target_type: str,
|
||||||
|
target_id: str,
|
||||||
|
content: str,
|
||||||
|
msg_id: Optional[str] = None,
|
||||||
|
event_id: Optional[str] = None,
|
||||||
|
msg_seq: int = 1,
|
||||||
|
) -> None:
|
||||||
|
"""Send a Markdown message to a C2C user or QQ group."""
|
||||||
|
if not await self.check_access_token():
|
||||||
|
await self.get_access_token()
|
||||||
|
|
||||||
|
if target_type == 'c2c':
|
||||||
|
url = f'{self.base_url}/v2/users/{target_id}/messages'
|
||||||
|
elif target_type == 'group':
|
||||||
|
url = f'{self.base_url}/v2/groups/{target_id}/messages'
|
||||||
|
else:
|
||||||
|
raise ValueError(f'Unsupported Markdown target type: {target_type}')
|
||||||
|
|
||||||
|
data: dict[str, Any] = {
|
||||||
|
'msg_type': 2,
|
||||||
|
'markdown': {'content': content},
|
||||||
|
'msg_seq': msg_seq,
|
||||||
|
}
|
||||||
|
if msg_id:
|
||||||
|
data['msg_id'] = msg_id
|
||||||
|
if event_id:
|
||||||
|
data['event_id'] = event_id
|
||||||
|
|
||||||
|
async with self._http_client_context() as client:
|
||||||
|
headers = {
|
||||||
|
'Authorization': f'QQBot {self.access_token}',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
response = await client.post(url, headers=headers, json=data)
|
||||||
|
if response.status_code != 200:
|
||||||
|
response_data = await httpclient.parse_json_response(response)
|
||||||
|
await self.logger.error(f'Failed to send Markdown message: {response_data}')
|
||||||
|
raise ValueError(response)
|
||||||
|
|
||||||
|
async def send_private_markdown_msg(
|
||||||
|
self,
|
||||||
|
user_openid: str,
|
||||||
|
content: str,
|
||||||
|
msg_id: Optional[str] = None,
|
||||||
|
event_id: Optional[str] = None,
|
||||||
|
msg_seq: int = 1,
|
||||||
|
) -> None:
|
||||||
|
"""Send a Markdown C2C message."""
|
||||||
|
await self._send_markdown_msg('c2c', user_openid, content, msg_id, event_id, msg_seq)
|
||||||
|
|
||||||
|
async def send_group_markdown_msg(
|
||||||
|
self,
|
||||||
|
group_openid: str,
|
||||||
|
content: str,
|
||||||
|
msg_id: Optional[str] = None,
|
||||||
|
event_id: Optional[str] = None,
|
||||||
|
msg_seq: int = 1,
|
||||||
|
) -> None:
|
||||||
|
"""Send a Markdown QQ group message."""
|
||||||
|
await self._send_markdown_msg('group', group_openid, content, msg_id, event_id, msg_seq)
|
||||||
|
|
||||||
async def send_group_text_msg(
|
async def send_group_text_msg(
|
||||||
self,
|
self,
|
||||||
group_openid: str,
|
group_openid: str,
|
||||||
|
|||||||
@@ -46,6 +46,14 @@ CMD_RESPOND_MSG = 'aibot_respond_msg'
|
|||||||
CMD_RESPOND_WELCOME = 'aibot_respond_welcome_msg'
|
CMD_RESPOND_WELCOME = 'aibot_respond_welcome_msg'
|
||||||
CMD_RESPOND_UPDATE = 'aibot_respond_update_msg'
|
CMD_RESPOND_UPDATE = 'aibot_respond_update_msg'
|
||||||
CMD_SEND_MSG = 'aibot_send_msg'
|
CMD_SEND_MSG = 'aibot_send_msg'
|
||||||
|
# Media upload protocol (3 steps: init -> chunk * N -> finish). The
|
||||||
|
# command names below match the WeCom AI Bot long-connection protocol.
|
||||||
|
CMD_UPLOAD_INIT = 'aibot_upload_media_init'
|
||||||
|
CMD_UPLOAD_CHUNK = 'aibot_upload_media_chunk'
|
||||||
|
CMD_UPLOAD_FINISH = 'aibot_upload_media_finish'
|
||||||
|
|
||||||
|
# Default upload chunk size: 512 KB before base64 encoding.
|
||||||
|
_UPLOAD_CHUNK_SIZE = 512 * 1024
|
||||||
|
|
||||||
_DEDUP_CACHE_MAX = 4096
|
_DEDUP_CACHE_MAX = 4096
|
||||||
_STREAM_CACHE_MAX = 1024
|
_STREAM_CACHE_MAX = 1024
|
||||||
@@ -495,6 +503,145 @@ class WecomBotWsClient:
|
|||||||
body['chatid'] = chat_id
|
body['chatid'] = chat_id
|
||||||
return await self._send_reply(req_id, body, cmd=CMD_SEND_MSG)
|
return await self._send_reply(req_id, body, cmd=CMD_SEND_MSG)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Media upload (image / voice / file)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def upload_media(
|
||||||
|
self,
|
||||||
|
data: bytes,
|
||||||
|
filename: str = 'attachment',
|
||||||
|
media_type: str = 'file',
|
||||||
|
) -> Optional[dict]:
|
||||||
|
"""Upload *data* to the WeCom AI Bot CDN and return the parsed ACK.
|
||||||
|
|
||||||
|
Implements the three-step protocol documented for the WeCom
|
||||||
|
AI Bot:
|
||||||
|
|
||||||
|
1. ``aibot_upload_media_init`` — declare media type, file name,
|
||||||
|
size, MD5 and chunk count; receive ``upload_id``.
|
||||||
|
2. ``aibot_upload_media_chunk`` — send each chunk (base64-encoded
|
||||||
|
bytes) until done; receive per-chunk ACK.
|
||||||
|
3. ``aibot_upload_media_finish`` — finalize the upload; receive
|
||||||
|
``media_id``.
|
||||||
|
|
||||||
|
Returns a dict with the final ``media_id`` (and the raw
|
||||||
|
``finish`` ACK) on success, or ``None`` on any failure. The
|
||||||
|
caller is expected to ignore the result and continue
|
||||||
|
gracefully — the framework will keep working without media
|
||||||
|
delivery.
|
||||||
|
"""
|
||||||
|
import base64 as _b64
|
||||||
|
import hashlib as _hl
|
||||||
|
|
||||||
|
if not data:
|
||||||
|
return None
|
||||||
|
|
||||||
|
file_size = len(data)
|
||||||
|
file_md5 = _hl.md5(data).hexdigest()
|
||||||
|
total_chunks = (file_size + _UPLOAD_CHUNK_SIZE - 1) // _UPLOAD_CHUNK_SIZE
|
||||||
|
if total_chunks == 0:
|
||||||
|
total_chunks = 1
|
||||||
|
|
||||||
|
# Step 1: init.
|
||||||
|
init_req_id = _generate_req_id(CMD_UPLOAD_INIT)
|
||||||
|
init_body = {
|
||||||
|
'type': media_type,
|
||||||
|
'filename': filename,
|
||||||
|
'total_size': file_size,
|
||||||
|
'total_chunks': total_chunks,
|
||||||
|
'md5': file_md5,
|
||||||
|
}
|
||||||
|
init_ack = await self._send_reply(
|
||||||
|
init_req_id,
|
||||||
|
init_body,
|
||||||
|
cmd=CMD_UPLOAD_INIT,
|
||||||
|
)
|
||||||
|
if not init_ack or init_ack.get('errcode', 0) != 0:
|
||||||
|
await self.logger.warning(f'upload_media init failed: ack={init_ack!r}')
|
||||||
|
return None
|
||||||
|
upload_id = (
|
||||||
|
init_ack.get('upload_id')
|
||||||
|
or init_ack.get('body', {}).get('upload_id')
|
||||||
|
or init_ack.get('data', {}).get('upload_id')
|
||||||
|
)
|
||||||
|
if not upload_id:
|
||||||
|
await self.logger.warning(f'upload_media init returned no upload_id: ack={init_ack!r}')
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Step 2: chunks.
|
||||||
|
for index in range(total_chunks):
|
||||||
|
start = index * _UPLOAD_CHUNK_SIZE
|
||||||
|
end = min(start + _UPLOAD_CHUNK_SIZE, file_size)
|
||||||
|
chunk_bytes = data[start:end]
|
||||||
|
chunk_req_id = _generate_req_id(CMD_UPLOAD_CHUNK)
|
||||||
|
chunk_body = {
|
||||||
|
'upload_id': upload_id,
|
||||||
|
'chunk_index': index,
|
||||||
|
'base64_data': _b64.b64encode(chunk_bytes).decode('ascii'),
|
||||||
|
}
|
||||||
|
chunk_ack = await self._send_reply(
|
||||||
|
chunk_req_id,
|
||||||
|
chunk_body,
|
||||||
|
cmd=CMD_UPLOAD_CHUNK,
|
||||||
|
)
|
||||||
|
if not chunk_ack or chunk_ack.get('errcode', 0) != 0:
|
||||||
|
await self.logger.warning(f'upload_media chunk {index} failed: ack={chunk_ack!r}')
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Step 3: finish.
|
||||||
|
finish_req_id = _generate_req_id(CMD_UPLOAD_FINISH)
|
||||||
|
finish_body = {'upload_id': upload_id}
|
||||||
|
finish_ack = await self._send_reply(
|
||||||
|
finish_req_id,
|
||||||
|
finish_body,
|
||||||
|
cmd=CMD_UPLOAD_FINISH,
|
||||||
|
)
|
||||||
|
if not finish_ack or finish_ack.get('errcode', 0) != 0:
|
||||||
|
await self.logger.warning(f'upload_media finish failed: ack={finish_ack!r}')
|
||||||
|
return None
|
||||||
|
|
||||||
|
media_id = (
|
||||||
|
finish_ack.get('media_id')
|
||||||
|
or finish_ack.get('body', {}).get('media_id')
|
||||||
|
or finish_ack.get('data', {}).get('media_id')
|
||||||
|
)
|
||||||
|
if not media_id:
|
||||||
|
await self.logger.warning(f'upload_media finish returned no media_id: ack={finish_ack!r}')
|
||||||
|
return None
|
||||||
|
return {'media_id': media_id, 'ack': finish_ack}
|
||||||
|
|
||||||
|
async def _reply_media(
|
||||||
|
self,
|
||||||
|
req_id: str,
|
||||||
|
media_id: str,
|
||||||
|
kind: str,
|
||||||
|
) -> Optional[dict]:
|
||||||
|
"""Send a media reply (image / voice / file) referencing *media_id*.
|
||||||
|
|
||||||
|
``kind`` is one of ``'image'``, ``'voice'``, ``'file'``. Uses
|
||||||
|
the standard ``aibot_respond_msg`` command with a per-kind
|
||||||
|
body key (matches the convention documented for the WeCom
|
||||||
|
AI Bot SDK).
|
||||||
|
"""
|
||||||
|
if kind not in {'image', 'voice', 'file'}:
|
||||||
|
await self.logger.warning(f'_reply_media called with unknown kind={kind!r}')
|
||||||
|
return None
|
||||||
|
body = {
|
||||||
|
'msgtype': kind,
|
||||||
|
kind: {'media_id': media_id},
|
||||||
|
}
|
||||||
|
return await self._send_reply(req_id, body, cmd=CMD_RESPOND_MSG)
|
||||||
|
|
||||||
|
async def reply_image(self, req_id: str, media_id: str) -> Optional[dict]:
|
||||||
|
return await self._reply_media(req_id, media_id, 'image')
|
||||||
|
|
||||||
|
async def reply_file(self, req_id: str, media_id: str) -> Optional[dict]:
|
||||||
|
return await self._reply_media(req_id, media_id, 'file')
|
||||||
|
|
||||||
|
async def reply_voice(self, req_id: str, media_id: str) -> Optional[dict]:
|
||||||
|
return await self._reply_media(req_id, media_id, 'voice')
|
||||||
|
|
||||||
async def push_stream_chunk(self, msg_id: str, content: str, is_final: bool = False) -> bool:
|
async def push_stream_chunk(self, msg_id: str, content: str, is_final: bool = False) -> bool:
|
||||||
"""Push a streaming chunk for a given message ID.
|
"""Push a streaming chunk for a given message ID.
|
||||||
|
|
||||||
|
|||||||
@@ -14,13 +14,6 @@ from ...service.user import ControlPlaneDirectoryRequiredError, PublicRegistrati
|
|||||||
|
|
||||||
@group.group_class('user', '/api/v1/user')
|
@group.group_class('user', '/api/v1/user')
|
||||||
class UserRouterGroup(group.RouterGroup):
|
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:
|
def _validate_space_redirect_uri(self, redirect_uri: str, *, bind: bool) -> str:
|
||||||
parsed = urlsplit(redirect_uri)
|
parsed = urlsplit(redirect_uri)
|
||||||
if (
|
if (
|
||||||
@@ -38,17 +31,8 @@ class UserRouterGroup(group.RouterGroup):
|
|||||||
if query != {'mode': ['bind']}:
|
if query != {'mode': ['bind']}:
|
||||||
raise ValueError('Invalid Space binding redirect_uri')
|
raise ValueError('Invalid Space binding redirect_uri')
|
||||||
elif query:
|
elif query:
|
||||||
raise ValueError('Invalid Space login redirect_uri')
|
raise ValueError('Invalid LangBot Account 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
|
return redirect_uri
|
||||||
|
|
||||||
async def initialize(self) -> None:
|
async def initialize(self) -> None:
|
||||||
@@ -338,6 +322,7 @@ class UserRouterGroup(group.RouterGroup):
|
|||||||
if cloud_mode:
|
if cloud_mode:
|
||||||
capabilities['password_login_enabled'] = False
|
capabilities['password_login_enabled'] = False
|
||||||
capabilities['authenticated_invitation_acceptance_enabled'] = cloud_mode
|
capabilities['authenticated_invitation_acceptance_enabled'] = cloud_mode
|
||||||
|
capabilities['invitation_registration_enabled'] = not cloud_mode
|
||||||
return self.success(data={'initialized': True, **capabilities})
|
return self.success(data={'initialized': True, **capabilities})
|
||||||
|
|
||||||
@self.route('/set-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
@self.route('/set-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
@@ -416,7 +401,7 @@ class UserRouterGroup(group.RouterGroup):
|
|||||||
'Bind the LangBot Account with the same email as this local Account',
|
'Bind the LangBot Account with the same email as this local Account',
|
||||||
)
|
)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return self.http_status(400, -1, 'Space account binding failed')
|
return self.http_status(400, -1, 'LangBot Account binding failed')
|
||||||
except Exception:
|
except Exception:
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ class UserService:
|
|||||||
if purpose == 'login' and account_uuid is not None:
|
if purpose == 'login' and account_uuid is not None:
|
||||||
raise ValueError('Login state cannot be bound to an Account')
|
raise ValueError('Login state cannot be bound to an Account')
|
||||||
if purpose != 'login' and launch_workspace_uuid is not None:
|
if purpose != 'login' and launch_workspace_uuid is not None:
|
||||||
raise ValueError('Launch Workspace state is only valid for Space login')
|
raise ValueError('Launch Workspace state is only valid for LangBot Account login')
|
||||||
if ttl_seconds <= 0:
|
if ttl_seconds <= 0:
|
||||||
raise ValueError('OAuth state lifetime must be positive')
|
raise ValueError('OAuth state lifetime must be positive')
|
||||||
|
|
||||||
@@ -327,7 +327,7 @@ class UserService:
|
|||||||
normalized_email = normalize_email(user_email)
|
normalized_email = normalize_email(user_email)
|
||||||
if self._uses_control_plane_directory():
|
if self._uses_control_plane_directory():
|
||||||
raise ControlPlaneDirectoryRequiredError(
|
raise ControlPlaneDirectoryRequiredError(
|
||||||
'Cloud invitation registration must use a Space account to preserve control-plane identity'
|
'Cloud invitation registration must use a LangBot Account to preserve control-plane identity'
|
||||||
)
|
)
|
||||||
invitation, _ = await self.ap.workspace_collaboration_service.inspect_invitation(invitation_token)
|
invitation, _ = await self.ap.workspace_collaboration_service.inspect_invitation(invitation_token)
|
||||||
if invitation.normalized_email != normalized_email:
|
if invitation.normalized_email != normalized_email:
|
||||||
@@ -394,7 +394,7 @@ class UserService:
|
|||||||
|
|
||||||
# Check if this user has a local password set
|
# Check if this user has a local password set
|
||||||
if not user_obj.password:
|
if not user_obj.password:
|
||||||
raise ValueError('请使用 Space 账户登录')
|
raise ValueError('请使用 LangBot 账号登录')
|
||||||
|
|
||||||
await self._verify_password(user_obj.password, password)
|
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
|
# 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)
|
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):
|
if existing_space_user and existing_space_user.normalized_email != normalize_email(user_email):
|
||||||
raise ValueError('This Space account is already bound to another user')
|
raise ValueError('This LangBot Account is already bound to another user')
|
||||||
|
|
||||||
# Update local account to Space account
|
# Update local account to Space account
|
||||||
normalized_email = normalize_email(user_email)
|
normalized_email = normalize_email(user_email)
|
||||||
|
|||||||
@@ -1210,8 +1210,9 @@ class BoxService:
|
|||||||
async def _read_outbox_via_exec(self, query: pipeline_query.Query) -> list[dict]:
|
async def _read_outbox_via_exec(self, query: pipeline_query.Query) -> list[dict]:
|
||||||
"""Fallback: read the outbox over the exec channel (E2B / remote).
|
"""Fallback: read the outbox over the exec channel (E2B / remote).
|
||||||
|
|
||||||
Note: exec stdout is truncated by ``output_limit_chars``, so this path
|
Uses ``client.execute`` directly (bypassing ``_serialize_result``)
|
||||||
only reliably transfers small files. The host path is preferred.
|
so stdout is NOT truncated by ``output_limit_chars`` - the raw
|
||||||
|
base64 payload can be far larger than the 4000-char display limit.
|
||||||
"""
|
"""
|
||||||
import json as _json
|
import json as _json
|
||||||
|
|
||||||
@@ -1265,14 +1266,22 @@ class BoxService:
|
|||||||
' break\n'
|
' break\n'
|
||||||
'print(json.dumps(out))\n'
|
'print(json.dumps(out))\n'
|
||||||
)
|
)
|
||||||
result = await self.execute_tool(
|
spec_payload: dict = {
|
||||||
{'command': f"python3 - <<'LBPY'\n{script}\nLBPY", 'timeout_sec': 120},
|
'cmd': f"python3 - <<'LBPY'\n{script}\nLBPY",
|
||||||
query,
|
'timeout_sec': 120,
|
||||||
)
|
'session_id': self.resolve_box_session_id(query),
|
||||||
if not result.get('ok'):
|
}
|
||||||
|
if 'extra_mounts' not in spec_payload:
|
||||||
|
spec_payload['extra_mounts'] = self.build_skill_extra_mounts(query)
|
||||||
|
try:
|
||||||
|
spec = self.build_spec(spec_payload)
|
||||||
|
result = await self.client.execute(spec)
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
if not result.ok:
|
||||||
return []
|
return []
|
||||||
try:
|
try:
|
||||||
return _json.loads(str(result.get('stdout') or '').strip().splitlines()[-1])
|
return _json.loads(str(result.stdout or '').strip().splitlines()[-1])
|
||||||
except Exception:
|
except Exception:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
import typing
|
import typing
|
||||||
import inspect
|
import inspect
|
||||||
|
|
||||||
|
from ..api.http.context import ExecutionContext
|
||||||
from ..core import app
|
from ..core import app
|
||||||
from . import operator
|
from . import operator
|
||||||
from ..utils import importutil
|
from ..utils import importutil
|
||||||
@@ -66,7 +67,14 @@ class CommandManager:
|
|||||||
|
|
||||||
require_context = getattr(self.ap.plugin_connector, 'require_workspace_context', None)
|
require_context = getattr(self.ap.plugin_connector, 'require_workspace_context', None)
|
||||||
if require_context is not None:
|
if require_context is not None:
|
||||||
result = require_context(context)
|
result = require_context(
|
||||||
|
ExecutionContext(
|
||||||
|
instance_uuid=context.instance_uuid,
|
||||||
|
workspace_uuid=context.workspace_uuid,
|
||||||
|
placement_generation=context.placement_generation,
|
||||||
|
query_uuid=context.query_uuid,
|
||||||
|
)
|
||||||
|
)
|
||||||
if inspect.isawaitable(result):
|
if inspect.isawaitable(result):
|
||||||
await result
|
await result
|
||||||
|
|
||||||
|
|||||||
@@ -301,11 +301,36 @@ class Application:
|
|||||||
async def initialize(self):
|
async def initialize(self):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
async def _initialize_plugin_runtime(self) -> None:
|
||||||
|
try:
|
||||||
|
await self.plugin_connector.initialize()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
self.logger.warning(f'Plugin runtime unavailable during startup; reconnecting in background: {exc}')
|
||||||
|
self.plugin_connector.schedule_reconnect()
|
||||||
|
|
||||||
|
def _start_plugin_runtime_initialization(self) -> asyncio.Task | None:
|
||||||
|
task = getattr(self, '_plugin_runtime_initialization_task', None)
|
||||||
|
if task is not None and not task.done():
|
||||||
|
return task
|
||||||
|
# This is application lifecycle work, not a request side effect. It must
|
||||||
|
# not wait on PersistenceManager's after-commit gate at boot.
|
||||||
|
task = asyncio.create_task(
|
||||||
|
self._initialize_plugin_runtime(),
|
||||||
|
name='plugin-runtime-initialization',
|
||||||
|
)
|
||||||
|
self._plugin_runtime_initialization_task = task
|
||||||
|
return task
|
||||||
|
|
||||||
async def run(self):
|
async def run(self):
|
||||||
self.event_loop_monitor.start()
|
self.event_loop_monitor.start()
|
||||||
try:
|
try:
|
||||||
if self.directory_projection_service is not None:
|
if (
|
||||||
self.task_mgr.create_task(
|
self.directory_projection_service is not None
|
||||||
|
and getattr(self, 'directory_projection_task', None) is None
|
||||||
|
):
|
||||||
|
self.directory_projection_task = self.task_mgr.create_task(
|
||||||
self.directory_projection_service.run(),
|
self.directory_projection_service.run(),
|
||||||
name='cloud-directory-projection',
|
name='cloud-directory-projection',
|
||||||
scopes=[core_entities.LifecycleControlScope.APPLICATION],
|
scopes=[core_entities.LifecycleControlScope.APPLICATION],
|
||||||
@@ -322,7 +347,6 @@ class Application:
|
|||||||
name='cloud-manifest-refresh',
|
name='cloud-manifest-refresh',
|
||||||
scopes=[core_entities.LifecycleControlScope.APPLICATION],
|
scopes=[core_entities.LifecycleControlScope.APPLICATION],
|
||||||
)
|
)
|
||||||
await self.plugin_connector.initialize_plugins()
|
|
||||||
|
|
||||||
# 后续可能会允许动态重启其他任务
|
# 后续可能会允许动态重启其他任务
|
||||||
# 故为了防止程序在非 Ctrl-C 情况下退出,这里创建一个不会结束的协程
|
# 故为了防止程序在非 Ctrl-C 情况下退出,这里创建一个不会结束的协程
|
||||||
@@ -348,6 +372,7 @@ class Application:
|
|||||||
name='http-api-controller',
|
name='http-api-controller',
|
||||||
scopes=[core_entities.LifecycleControlScope.APPLICATION],
|
scopes=[core_entities.LifecycleControlScope.APPLICATION],
|
||||||
)
|
)
|
||||||
|
self._start_plugin_runtime_initialization()
|
||||||
|
|
||||||
# Telemetry instance heartbeat (startup + daily); respects
|
# Telemetry instance heartbeat (startup + daily); respects
|
||||||
# space.disable_telemetry via TelemetryManager.send().
|
# space.disable_telemetry via TelemetryManager.send().
|
||||||
@@ -529,6 +554,11 @@ class Application:
|
|||||||
|
|
||||||
if self.task_mgr is not None:
|
if self.task_mgr is not None:
|
||||||
self.task_mgr.cancel_by_scope(core_entities.LifecycleControlScope.APPLICATION)
|
self.task_mgr.cancel_by_scope(core_entities.LifecycleControlScope.APPLICATION)
|
||||||
|
plugin_runtime_task = getattr(self, '_plugin_runtime_initialization_task', None)
|
||||||
|
if plugin_runtime_task is not None and not plugin_runtime_task.done():
|
||||||
|
plugin_runtime_task.cancel()
|
||||||
|
with contextlib.suppress(asyncio.CancelledError):
|
||||||
|
await plugin_runtime_task
|
||||||
with contextlib.suppress(Exception):
|
with contextlib.suppress(Exception):
|
||||||
await self.event_loop_monitor.stop()
|
await self.event_loop_monitor.stop()
|
||||||
mcp_mount = getattr(self.http_ctrl, 'mcp_mount', None)
|
mcp_mount = getattr(self.http_ctrl, 'mcp_mount', None)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from .. import stage, app
|
from .. import stage, app, entities as core_entities
|
||||||
from ...utils import version, proxy, constants
|
from ...utils import version, proxy, constants
|
||||||
from ...pipeline import pool, controller, pipelinemgr
|
from ...pipeline import pool, controller, pipelinemgr
|
||||||
from ...pipeline import aggregator as message_aggregator
|
from ...pipeline import aggregator as message_aggregator
|
||||||
@@ -292,14 +292,17 @@ class BuildAppStage(stage.BootingStage):
|
|||||||
async def runtime_disconnect_callback(connector: plugin_connector.PluginRuntimeConnector) -> None:
|
async def runtime_disconnect_callback(connector: plugin_connector.PluginRuntimeConnector) -> None:
|
||||||
connector.schedule_reconnect()
|
connector.schedule_reconnect()
|
||||||
|
|
||||||
|
if ap.directory_projection_service is not None:
|
||||||
|
# Keep the projection fresh while shared Runtime cold restore runs.
|
||||||
|
# BuildApp initializes the connector before Application.run() starts
|
||||||
|
# its long-lived tasks, so start the single refresh task here.
|
||||||
|
ap.directory_projection_task = ap.task_mgr.create_task(
|
||||||
|
ap.directory_projection_service.run(),
|
||||||
|
name='cloud-directory-projection',
|
||||||
|
scopes=[core_entities.LifecycleControlScope.APPLICATION],
|
||||||
|
)
|
||||||
|
|
||||||
plugin_connector_inst = plugin_connector.PluginRuntimeConnector(ap, runtime_disconnect_callback)
|
plugin_connector_inst = plugin_connector.PluginRuntimeConnector(ap, runtime_disconnect_callback)
|
||||||
try:
|
|
||||||
await plugin_connector_inst.initialize()
|
|
||||||
except Exception as exc:
|
|
||||||
# Keep the API/UI available while an external or managed runtime is
|
|
||||||
# starting, then recover in the background with bounded backoff.
|
|
||||||
ap.logger.warning(f'Plugin runtime unavailable during startup; reconnecting in background: {exc}')
|
|
||||||
plugin_connector_inst.schedule_reconnect()
|
|
||||||
ap.plugin_connector = plugin_connector_inst
|
ap.plugin_connector = plugin_connector_inst
|
||||||
workspace_service_inst.release_startup_execution_bindings()
|
workspace_service_inst.release_startup_execution_bindings()
|
||||||
|
|
||||||
|
|||||||
@@ -17,4 +17,4 @@ class SpaceAccountBindingRequiredError(AccountEmailMismatchError):
|
|||||||
code = 'space_account_binding_required'
|
code = 'space_account_binding_required'
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return 'This local Account must bind Space from Account settings before Space login'
|
return 'This local account must bind a LangBot Account from Account settings before LangBot Account login'
|
||||||
|
|||||||
@@ -177,7 +177,6 @@ class PersistenceManager:
|
|||||||
await self._validate_cloud_runtime()
|
await self._validate_cloud_runtime()
|
||||||
return
|
return
|
||||||
|
|
||||||
self._enable_sqlite_foreign_keys()
|
|
||||||
if self.mode == PersistenceMode.RELEASE_MIGRATION:
|
if self.mode == PersistenceMode.RELEASE_MIGRATION:
|
||||||
async with self._release_migration_lock():
|
async with self._release_migration_lock():
|
||||||
await self._initialize_managed_schema()
|
await self._initialize_managed_schema()
|
||||||
@@ -185,6 +184,7 @@ class PersistenceManager:
|
|||||||
return
|
return
|
||||||
|
|
||||||
await self._initialize_managed_schema()
|
await self._initialize_managed_schema()
|
||||||
|
await self._enable_sqlite_foreign_keys_after_migration()
|
||||||
|
|
||||||
if self.mode == PersistenceMode.OSS_COMPAT:
|
if self.mode == PersistenceMode.OSS_COMPAT:
|
||||||
await self.write_space_model_providers()
|
await self.write_space_model_providers()
|
||||||
@@ -373,6 +373,17 @@ class PersistenceManager:
|
|||||||
sqlalchemy.event.listen(self.get_db_engine().sync_engine, 'begin', set_oss_tenant_scope)
|
sqlalchemy.event.listen(self.get_db_engine().sync_engine, 'begin', set_oss_tenant_scope)
|
||||||
self._oss_tenant_scope_listener_installed = True
|
self._oss_tenant_scope_listener_installed = True
|
||||||
|
|
||||||
|
async def _enable_sqlite_foreign_keys_after_migration(self) -> None:
|
||||||
|
"""Enable SQLite FK enforcement only after table-rebuilding migrations."""
|
||||||
|
engine = self.get_db_engine()
|
||||||
|
if engine.dialect.name != 'sqlite':
|
||||||
|
return
|
||||||
|
await engine.dispose()
|
||||||
|
self._enable_sqlite_foreign_keys()
|
||||||
|
# Dispose again so every runtime connection is opened through the new
|
||||||
|
# listener instead of reusing a pre-migration pooled connection.
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
def _enable_sqlite_foreign_keys(self) -> None:
|
def _enable_sqlite_foreign_keys(self) -> None:
|
||||||
"""Enable SQLite FK enforcement for every pooled runtime connection."""
|
"""Enable SQLite FK enforcement for every pooled runtime connection."""
|
||||||
engine = self.get_db_engine()
|
engine = self.get_db_engine()
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import re
|
|||||||
import secrets
|
import secrets
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import time
|
||||||
import typing
|
import typing
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||||
@@ -117,8 +118,19 @@ def _write_manifest(backup: SQLiteMigrationBackup, status: str, **extra: typing.
|
|||||||
temporary_path.unlink(missing_ok=True)
|
temporary_path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
def _fsync_file(path: pathlib.Path) -> None:
|
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)
|
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
|
||||||
try:
|
try:
|
||||||
os.fsync(descriptor)
|
os.fsync(descriptor)
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ from .. import entities
|
|||||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||||
from ....utils.safe_regex import SafeRegexError, mask_patterns
|
from ....utils.safe_regex import SafeRegexError, mask_patterns
|
||||||
|
|
||||||
|
# Legacy sensitive-words.json files shipped ~70 rules, which exceeds the
|
||||||
|
# default safe_regex per-call cap of 64 and used to fail-close every message.
|
||||||
|
# Keep one 50ms CPU budget for the whole list; only raise the pattern cap.
|
||||||
|
_MAX_SENSITIVE_WORD_PATTERNS = 256
|
||||||
|
|
||||||
|
|
||||||
@filter_model.filter_class('ban-word-filter')
|
@filter_model.filter_class('ban-word-filter')
|
||||||
class BanWordFilter(filter_model.ContentFilter):
|
class BanWordFilter(filter_model.ContentFilter):
|
||||||
@@ -14,12 +19,17 @@ class BanWordFilter(filter_model.ContentFilter):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
async def process(self, query: pipeline_query.Query, message: str) -> entities.FilterResult:
|
async def process(self, query: pipeline_query.Query, message: str) -> entities.FilterResult:
|
||||||
|
words = self.ap.sensitive_meta.data.get('words') or []
|
||||||
|
mask = self.ap.sensitive_meta.data['mask']
|
||||||
|
mask_word = self.ap.sensitive_meta.data['mask_word']
|
||||||
|
|
||||||
try:
|
try:
|
||||||
found, message = await mask_patterns(
|
found, current = await mask_patterns(
|
||||||
self.ap.sensitive_meta.data['words'],
|
words,
|
||||||
message,
|
message,
|
||||||
mask=self.ap.sensitive_meta.data['mask'],
|
mask=mask,
|
||||||
mask_word=self.ap.sensitive_meta.data['mask_word'],
|
mask_word=mask_word,
|
||||||
|
max_pattern_count=_MAX_SENSITIVE_WORD_PATTERNS,
|
||||||
)
|
)
|
||||||
except SafeRegexError as exc:
|
except SafeRegexError as exc:
|
||||||
return entities.FilterResult(
|
return entities.FilterResult(
|
||||||
@@ -31,7 +41,7 @@ class BanWordFilter(filter_model.ContentFilter):
|
|||||||
|
|
||||||
return entities.FilterResult(
|
return entities.FilterResult(
|
||||||
level=entities.ResultLevel.MASKED if found else entities.ResultLevel.PASS,
|
level=entities.ResultLevel.MASKED if found else entities.ResultLevel.PASS,
|
||||||
replacement=message,
|
replacement=current,
|
||||||
user_notice='消息中存在不合适的内容, 请修改' if found else '',
|
user_notice='消息中存在不合适的内容, 请修改' if found else '',
|
||||||
console_notice='',
|
console_notice='',
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -158,6 +158,18 @@ class ResponseWrapper(stage.PipelineStage):
|
|||||||
result_type=entities.ResultType.CONTINUE,
|
result_type=entities.ResultType.CONTINUE,
|
||||||
new_query=query,
|
new_query=query,
|
||||||
)
|
)
|
||||||
|
elif (
|
||||||
|
isinstance(result, provider_message.MessageChunk) and result.is_final and not result.tool_calls
|
||||||
|
):
|
||||||
|
# Final streaming chunk with no text content but
|
||||||
|
# possibly carrying sandbox outbox attachments.
|
||||||
|
reply_chain = platform_message.MessageChain([])
|
||||||
|
await self._append_outbound_attachments(query, reply_chain)
|
||||||
|
query.resp_message_chain.append(reply_chain)
|
||||||
|
yield entities.StageProcessResult(
|
||||||
|
result_type=entities.ResultType.CONTINUE,
|
||||||
|
new_query=query,
|
||||||
|
)
|
||||||
|
|
||||||
if result.tool_calls is not None and len(result.tool_calls) > 0: # 有函数调用
|
if result.tool_calls is not None and len(result.tool_calls) > 0: # 有函数调用
|
||||||
function_names = [tc.function.name for tc in result.tool_calls]
|
function_names = [tc.function.name for tc in result.tool_calls]
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ class LINEEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
if event.source.type == 'user':
|
if event.source.type == 'user':
|
||||||
return platform_events.FriendMessage(
|
return platform_events.FriendMessage(
|
||||||
sender=platform_entities.Friend(
|
sender=platform_entities.Friend(
|
||||||
id=event.message.id,
|
id=event.source.user_id,
|
||||||
nickname=event.source.user_id,
|
nickname=event.source.user_id,
|
||||||
remark='',
|
remark='',
|
||||||
),
|
),
|
||||||
@@ -110,13 +110,19 @@ class LINEEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
source_platform_object=event,
|
source_platform_object=event,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
# 'group' and 'room' sources carry the stable chat id under different
|
||||||
|
# field names; user_id may be absent for some members, so fall back
|
||||||
|
# to the group/room id rather than the per-message id.
|
||||||
|
group_id = event.source.group_id if event.source.type == 'group' else event.source.room_id
|
||||||
|
member_id = event.source.user_id or group_id
|
||||||
|
|
||||||
return platform_events.GroupMessage(
|
return platform_events.GroupMessage(
|
||||||
sender=platform_entities.GroupMember(
|
sender=platform_entities.GroupMember(
|
||||||
id=event.event.sender.sender_id.open_id,
|
id=member_id,
|
||||||
member_name=event.event.sender.sender_id.union_id,
|
member_name=member_id,
|
||||||
permission=platform_entities.Permission.Member,
|
permission=platform_entities.Permission.Member,
|
||||||
group=platform_entities.Group(
|
group=platform_entities.Group(
|
||||||
id=event.message.id,
|
id=group_id,
|
||||||
name='',
|
name='',
|
||||||
permission=platform_entities.Permission.Member,
|
permission=platform_entities.Permission.Member,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -329,17 +329,12 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
|||||||
content_type = content.get('type', 'text')
|
content_type = content.get('type', 'text')
|
||||||
|
|
||||||
if content_type == 'text':
|
if content_type == 'text':
|
||||||
if target_type == 'c2c':
|
if target_type in {'c2c', 'group'}:
|
||||||
await self.bot.send_private_text_msg(
|
await self._send_c2c_or_group_text_reply(
|
||||||
|
target_type,
|
||||||
target_id,
|
target_id,
|
||||||
content['content'],
|
content['content'],
|
||||||
qq_official_event.d_id,
|
msg_id=qq_official_event.d_id,
|
||||||
)
|
|
||||||
elif target_type == 'group':
|
|
||||||
await self.bot.send_group_text_msg(
|
|
||||||
target_id,
|
|
||||||
content['content'],
|
|
||||||
qq_official_event.d_id,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
elif content_type == 'image':
|
elif content_type == 'image':
|
||||||
@@ -383,6 +378,39 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
|||||||
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
async def _send_c2c_or_group_text_reply(
|
||||||
|
self,
|
||||||
|
target_type: str,
|
||||||
|
target_id: str,
|
||||||
|
content: str,
|
||||||
|
*,
|
||||||
|
msg_id: typing.Optional[str] = None,
|
||||||
|
event_id: typing.Optional[str] = None,
|
||||||
|
msg_seq: int = 1,
|
||||||
|
) -> None:
|
||||||
|
"""Send a text reply using the configured C2C/group render mode."""
|
||||||
|
use_markdown = self.config.get('enable-markdown-rendering', False)
|
||||||
|
if target_type == 'c2c':
|
||||||
|
send = self.bot.send_private_markdown_msg if use_markdown else self.bot.send_private_text_msg
|
||||||
|
await send(
|
||||||
|
user_openid=target_id,
|
||||||
|
content=content,
|
||||||
|
msg_id=msg_id,
|
||||||
|
event_id=event_id,
|
||||||
|
msg_seq=msg_seq,
|
||||||
|
)
|
||||||
|
elif target_type == 'group':
|
||||||
|
send = self.bot.send_group_markdown_msg if use_markdown else self.bot.send_group_text_msg
|
||||||
|
await send(
|
||||||
|
group_openid=target_id,
|
||||||
|
content=content,
|
||||||
|
msg_id=msg_id,
|
||||||
|
event_id=event_id,
|
||||||
|
msg_seq=msg_seq,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise ValueError(f'Unsupported QQ Official text reply target: {target_type}')
|
||||||
|
|
||||||
def register_listener(
|
def register_listener(
|
||||||
self,
|
self,
|
||||||
event_type: typing.Type[platform_events.Event],
|
event_type: typing.Type[platform_events.Event],
|
||||||
@@ -650,13 +678,13 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
|||||||
# 用第一个 chunk 的文本建立会话(不发 "..." 避免污染前缀)
|
# 用第一个 chunk 的文本建立会话(不发 "..." 避免污染前缀)
|
||||||
ctx['session_started'] = True
|
ctx['session_started'] = True
|
||||||
|
|
||||||
# 发送内容 = 全量累积文本
|
# `replace` mode requires every update to contain the previously
|
||||||
# QQ API 的 replace 模式不允许修改已下发前缀,所以:
|
# delivered content as its prefix. `sent_length` only tells us whether
|
||||||
# - 首次:发送全部文本,建立会话
|
# a non-final snapshot has new content; it must not truncate the
|
||||||
# - 后续:只能发送新增部分(append 行为)
|
# content sent to QQ.
|
||||||
content_to_send = ctx['accumulated_text'][ctx['sent_length'] :]
|
if len(ctx['accumulated_text']) <= ctx['sent_length'] and not is_final:
|
||||||
if not content_to_send and not is_final:
|
|
||||||
return
|
return
|
||||||
|
content_to_send = ctx['accumulated_text']
|
||||||
|
|
||||||
input_state = 10 if is_final else 1
|
input_state = 10 if is_final else 1
|
||||||
|
|
||||||
@@ -778,17 +806,10 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
|||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if target_type == 'c2c':
|
await self._send_c2c_or_group_text_reply(
|
||||||
await self.bot.send_private_text_msg(
|
target_type,
|
||||||
user_openid=target_id,
|
target_id,
|
||||||
content=text,
|
text,
|
||||||
event_id=event_id,
|
|
||||||
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,
|
event_id=event_id,
|
||||||
msg_seq=msg_seq,
|
msg_seq=msg_seq,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -95,6 +95,18 @@ spec:
|
|||||||
type: boolean
|
type: boolean
|
||||||
required: true
|
required: true
|
||||||
default: false
|
default: false
|
||||||
|
- name: enable-markdown-rendering
|
||||||
|
label:
|
||||||
|
en_US: Enable Markdown Rendering
|
||||||
|
zh_Hans: 启用 Markdown 渲染
|
||||||
|
zh_Hant: 啟用 Markdown 渲染
|
||||||
|
description:
|
||||||
|
en_US: Render non-stream C2C and QQ group text replies as Markdown. Channel messages always use plain text and are not affected by this setting.
|
||||||
|
zh_Hans: 将非流式 C2C 私聊和 QQ 群聊文本回复渲染为 Markdown。频道消息始终以纯文本发送,不受此设置影响。
|
||||||
|
zh_Hant: 將非串流 C2C 私聊與 QQ 群聊文字回覆渲染為 Markdown。頻道訊息一律以純文字傳送,不受此設定影響。
|
||||||
|
type: boolean
|
||||||
|
required: true
|
||||||
|
default: false
|
||||||
- name: webhook_url
|
- name: webhook_url
|
||||||
label:
|
label:
|
||||||
en_US: Webhook Callback URL
|
en_US: Webhook Callback URL
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ import typing
|
|||||||
import asyncio
|
import asyncio
|
||||||
import time
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
|
import base64
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
||||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||||
@@ -24,11 +26,24 @@ from langbot.libs.wecom_ai_bot_api.ws_client import WecomBotWsClient
|
|||||||
class WecomBotMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
class WecomBotMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def yiri2target(message_chain: platform_message.MessageChain):
|
async def yiri2target(message_chain: platform_message.MessageChain):
|
||||||
content = ''
|
"""Convert a MessageChain into a list of component dicts.
|
||||||
|
|
||||||
|
Each dict has a ``type`` key (``'text'``, ``'image'``,
|
||||||
|
``'voice'``, ``'file'``). Text items carry ``text``; media
|
||||||
|
items carry ``base64`` (may include a ``data:...;base64,``
|
||||||
|
prefix) and optionally ``name``.
|
||||||
|
"""
|
||||||
|
items: list[dict] = []
|
||||||
for msg in message_chain:
|
for msg in message_chain:
|
||||||
if type(msg) is platform_message.Plain:
|
if type(msg) is platform_message.Plain:
|
||||||
content += msg.text
|
items.append({'type': 'text', 'text': msg.text})
|
||||||
return content
|
elif type(msg) is platform_message.Image:
|
||||||
|
items.append({'type': 'image', 'base64': msg.base64 or ''})
|
||||||
|
elif type(msg) is platform_message.Voice:
|
||||||
|
items.append({'type': 'voice', 'base64': msg.base64 or ''})
|
||||||
|
elif type(msg) is platform_message.File:
|
||||||
|
items.append({'type': 'file', 'base64': msg.base64 or '', 'name': msg.name or ''})
|
||||||
|
return items
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def target2yiri(event: WecomBotEvent, bot_name: str = ''):
|
async def target2yiri(event: WecomBotEvent, bot_name: str = ''):
|
||||||
@@ -362,13 +377,76 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _join_text_components(items: list[dict]) -> str:
|
||||||
|
"""Concatenate ``text`` items in order, leaving media items alone."""
|
||||||
|
return ''.join(item['text'] for item in items if item.get('type') == 'text')
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _iter_media_components(items: list[dict]):
|
||||||
|
"""Yield non-text items in order."""
|
||||||
|
for item in items:
|
||||||
|
if item.get('type') in {'image', 'voice', 'file'}:
|
||||||
|
yield item
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _send_media(
|
||||||
|
bot,
|
||||||
|
req_id: str,
|
||||||
|
item: dict,
|
||||||
|
) -> bool:
|
||||||
|
"""Upload *item* to the WeCom AI Bot CDN and send it as a media reply.
|
||||||
|
|
||||||
|
Returns True on success. Falls back to a no-op (with a warning log)
|
||||||
|
if the SDK does not yet implement ``upload_media`` /
|
||||||
|
``reply_image`` / ``reply_file`` / ``reply_voice`` — the framework
|
||||||
|
will keep working, just without image delivery.
|
||||||
|
"""
|
||||||
|
kind = item.get('type')
|
||||||
|
upload = getattr(bot, 'upload_media', None)
|
||||||
|
if upload is None:
|
||||||
|
return False
|
||||||
|
b64_text = item.get('base64') or ''
|
||||||
|
if not b64_text:
|
||||||
|
return False
|
||||||
|
if b64_text.startswith('data:') and ',' in b64_text:
|
||||||
|
b64_text = b64_text.split(',', 1)[1]
|
||||||
|
try:
|
||||||
|
data = base64.b64decode(b64_text, validate=False)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
if not data:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
upload_result = await upload(data, item.get('name') or f'attachment.{kind}', media_type=kind)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
media_id = getattr(upload_result, 'media_id', None) or (
|
||||||
|
isinstance(upload_result, dict) and upload_result.get('media_id')
|
||||||
|
)
|
||||||
|
if not media_id:
|
||||||
|
return False
|
||||||
|
reply_fn = {
|
||||||
|
'image': getattr(bot, 'reply_image', None),
|
||||||
|
'file': getattr(bot, 'reply_file', None),
|
||||||
|
'voice': getattr(bot, 'reply_voice', None),
|
||||||
|
}.get(kind)
|
||||||
|
if reply_fn is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
await reply_fn(req_id, media_id)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
async def reply_message(
|
async def reply_message(
|
||||||
self,
|
self,
|
||||||
message_source: platform_events.MessageEvent,
|
message_source: platform_events.MessageEvent,
|
||||||
message: platform_message.MessageChain,
|
message: platform_message.MessageChain,
|
||||||
quote_origin: bool = False,
|
quote_origin: bool = False,
|
||||||
):
|
):
|
||||||
content = await self.message_converter.yiri2target(message)
|
items = await self.message_converter.yiri2target(message)
|
||||||
|
text = self._join_text_components(items)
|
||||||
_ws_mode = not self.config.get('enable-webhook', False)
|
_ws_mode = not self.config.get('enable-webhook', False)
|
||||||
|
|
||||||
event = message_source.source_platform_object
|
event = message_source.source_platform_object
|
||||||
@@ -382,7 +460,7 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
else:
|
else:
|
||||||
chat_id = str(message_source.sender.id)
|
chat_id = str(message_source.sender.id)
|
||||||
try:
|
try:
|
||||||
await self.bot.send_message(chat_id, content)
|
await self.bot.send_message(chat_id, text)
|
||||||
except Exception:
|
except Exception:
|
||||||
await self.logger.error(
|
await self.logger.error(
|
||||||
f'WeComBot: proactive reply for synthetic event failed: {traceback.format_exc()}'
|
f'WeComBot: proactive reply for synthetic event failed: {traceback.format_exc()}'
|
||||||
@@ -396,12 +474,15 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
|
|
||||||
if _ws_mode:
|
if _ws_mode:
|
||||||
req_id = event.get('req_id', '') if isinstance(event, dict) else getattr(event, 'req_id', '')
|
req_id = event.get('req_id', '') if isinstance(event, dict) else getattr(event, 'req_id', '')
|
||||||
|
if text:
|
||||||
if req_id:
|
if req_id:
|
||||||
await self.bot.reply_text(req_id, content)
|
await self.bot.reply_text(req_id, text)
|
||||||
else:
|
else:
|
||||||
await self.bot.set_message(event.message_id, content)
|
await self.bot.set_message(event.message_id, text)
|
||||||
|
for item in self._iter_media_components(items):
|
||||||
|
await self._send_media(self.bot, req_id, item)
|
||||||
else:
|
else:
|
||||||
await self.bot.set_message(event.message_id, content)
|
await self.bot.set_message(event.message_id, text)
|
||||||
|
|
||||||
async def reply_message_chunk(
|
async def reply_message_chunk(
|
||||||
self,
|
self,
|
||||||
@@ -411,7 +492,8 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
quote_origin: bool = False,
|
quote_origin: bool = False,
|
||||||
is_final: bool = False,
|
is_final: bool = False,
|
||||||
):
|
):
|
||||||
content = await self.message_converter.yiri2target(message)
|
items = await self.message_converter.yiri2target(message)
|
||||||
|
text = self._join_text_components(items)
|
||||||
_ws_mode = not self.config.get('enable-webhook', False)
|
_ws_mode = not self.config.get('enable-webhook', False)
|
||||||
|
|
||||||
# Synthetic events (e.g. button-click triggered form resume) have
|
# Synthetic events (e.g. button-click triggered form resume) have
|
||||||
@@ -420,7 +502,7 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
# of the stream/reply path.
|
# of the stream/reply path.
|
||||||
spo = message_source.source_platform_object
|
spo = message_source.source_platform_object
|
||||||
if spo is None:
|
if spo is None:
|
||||||
return await self._handle_synthetic_chunk(message_source, bot_message, content, is_final, _ws_mode)
|
return await self._handle_synthetic_chunk(message_source, bot_message, text, is_final, _ws_mode)
|
||||||
|
|
||||||
msg_id = spo.message_id
|
msg_id = spo.message_id
|
||||||
|
|
||||||
@@ -452,7 +534,7 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
form_data.get('actions', []) or [],
|
form_data.get('actions', []) or [],
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
fallback = content or '(人工输入)'
|
fallback = text or '(人工输入)'
|
||||||
if _ws_mode:
|
if _ws_mode:
|
||||||
event = message_source.source_platform_object
|
event = message_source.source_platform_object
|
||||||
req_id = event.get('req_id', '') if isinstance(event, dict) else getattr(event, 'req_id', '')
|
req_id = event.get('req_id', '') if isinstance(event, dict) else getattr(event, 'req_id', '')
|
||||||
@@ -463,17 +545,22 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
return {'stream': False, 'form': True, 'fallback': True}
|
return {'stream': False, 'form': True, 'fallback': True}
|
||||||
|
|
||||||
if _ws_mode:
|
if _ws_mode:
|
||||||
success = await self.bot.push_stream_chunk(msg_id, content, is_final=is_final)
|
success = await self.bot.push_stream_chunk(msg_id, text, is_final=is_final)
|
||||||
if not success and is_final:
|
if not success and is_final:
|
||||||
event = message_source.source_platform_object
|
event = message_source.source_platform_object
|
||||||
req_id = event.get('req_id', '')
|
req_id = event.get('req_id', '')
|
||||||
if req_id:
|
if req_id:
|
||||||
await self.bot.reply_text(req_id, content)
|
await self.bot.reply_text(req_id, text)
|
||||||
|
if is_final:
|
||||||
|
event = message_source.source_platform_object
|
||||||
|
req_id = event.get('req_id', '')
|
||||||
|
for item in self._iter_media_components(items):
|
||||||
|
await self._send_media(self.bot, req_id, item)
|
||||||
return {'stream': success}
|
return {'stream': success}
|
||||||
else:
|
else:
|
||||||
success = await self.bot.push_stream_chunk(msg_id, content, is_final=is_final)
|
success = await self.bot.push_stream_chunk(msg_id, text, is_final=is_final)
|
||||||
if not success and is_final:
|
if not success and is_final:
|
||||||
await self.bot.set_message(msg_id, content)
|
await self.bot.set_message(msg_id, text)
|
||||||
return {'stream': success}
|
return {'stream': success}
|
||||||
|
|
||||||
async def is_stream_output_supported(self) -> bool:
|
async def is_stream_output_supported(self) -> bool:
|
||||||
@@ -627,8 +714,9 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
async def send_message(self, target_type, target_id, message):
|
async def send_message(self, target_type, target_id, message):
|
||||||
_ws_mode = not self.config.get('enable-webhook', False)
|
_ws_mode = not self.config.get('enable-webhook', False)
|
||||||
if _ws_mode:
|
if _ws_mode:
|
||||||
content = await self.message_converter.yiri2target(message)
|
items = await self.message_converter.yiri2target(message)
|
||||||
await self.bot.send_message(target_id, content)
|
text = self._join_text_components(items)
|
||||||
|
await self.bot.send_message(target_id, text)
|
||||||
else:
|
else:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
@@ -701,7 +701,13 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
}
|
}
|
||||||
self._known_desired_states.update({state.binding.installation_uuid: state for state in desired_states})
|
self._known_desired_states.update({state.binding.installation_uuid: state for state in desired_states})
|
||||||
|
|
||||||
result = await runtime_handler.reconcile_plugin_installations(tuple(self._known_desired_states.values()))
|
reconcile_timeout_seconds = max(
|
||||||
|
300.0, self._runtime_connect_timeout(self.ap.instance_config.data.get('plugin', {}))
|
||||||
|
)
|
||||||
|
result = await runtime_handler.reconcile_plugin_installations(
|
||||||
|
tuple(self._known_desired_states.values()),
|
||||||
|
timeout=reconcile_timeout_seconds,
|
||||||
|
)
|
||||||
await self._repair_reconcile_missing_artifacts(self._known_desired_states, result)
|
await self._repair_reconcile_missing_artifacts(self._known_desired_states, result)
|
||||||
self._record_reconcile_failures(self._known_desired_states, result)
|
self._record_reconcile_failures(self._known_desired_states, result)
|
||||||
|
|
||||||
@@ -736,7 +742,13 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
if state.binding.installation_uuid in all_states:
|
if state.binding.installation_uuid in all_states:
|
||||||
raise ValueError('Duplicate plugin installation UUID across projected Workspaces')
|
raise ValueError('Duplicate plugin installation UUID across projected Workspaces')
|
||||||
all_states[state.binding.installation_uuid] = state
|
all_states[state.binding.installation_uuid] = state
|
||||||
result = await runtime_handler.reconcile_plugin_installations(tuple(all_states.values()))
|
reconcile_timeout_seconds = max(
|
||||||
|
300.0, self._runtime_connect_timeout(self.ap.instance_config.data.get('plugin', {}))
|
||||||
|
)
|
||||||
|
result = await runtime_handler.reconcile_plugin_installations(
|
||||||
|
tuple(all_states.values()),
|
||||||
|
timeout=reconcile_timeout_seconds,
|
||||||
|
)
|
||||||
await self._repair_reconcile_missing_artifacts(all_states, result)
|
await self._repair_reconcile_missing_artifacts(all_states, result)
|
||||||
self._record_reconcile_failures(all_states, result)
|
self._record_reconcile_failures(all_states, result)
|
||||||
for installation_uuid, previous in tuple(self._known_desired_states.items()):
|
for installation_uuid, previous in tuple(self._known_desired_states.items()):
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import traceback
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
import sqlalchemy
|
import sqlalchemy
|
||||||
|
import sqlalchemy.dialects.postgresql
|
||||||
|
import sqlalchemy.dialects.sqlite
|
||||||
|
|
||||||
from langbot_plugin.runtime.io import handler
|
from langbot_plugin.runtime.io import handler
|
||||||
from langbot_plugin.runtime.io.connection import Connection
|
from langbot_plugin.runtime.io.connection import Connection
|
||||||
@@ -431,6 +433,19 @@ class RuntimeConnectionHandler(handler.Handler):
|
|||||||
return f'{identity.plugin_author}/{identity.plugin_name}'
|
return f'{identity.plugin_author}/{identity.plugin_name}'
|
||||||
raise ValueError(f'Unsupported binary storage owner_type {owner_type!r}')
|
raise ValueError(f'Unsupported binary storage owner_type {owner_type!r}')
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _legacy_binary_storage_key(
|
||||||
|
action_context: ActionContext,
|
||||||
|
*,
|
||||||
|
owner_type: str,
|
||||||
|
owner: str,
|
||||||
|
key: str,
|
||||||
|
) -> str:
|
||||||
|
"""Return the pre-tenancy key shape for a row already scoped to this Workspace."""
|
||||||
|
|
||||||
|
legacy_owner = action_context.workspace_uuid if owner_type == 'workspace' else owner
|
||||||
|
return f'{owner_type}:{legacy_owner}:{key}'
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _binary_storage_key(
|
def _binary_storage_key(
|
||||||
cls,
|
cls,
|
||||||
@@ -896,17 +911,70 @@ class RuntimeConnectionHandler(handler.Handler):
|
|||||||
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
|
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
|
||||||
.where(persistence_bstorage.BinaryStorage.unique_key == unique_key)
|
.where(persistence_bstorage.BinaryStorage.unique_key == unique_key)
|
||||||
)
|
)
|
||||||
|
storage = result.first()
|
||||||
|
if storage is None:
|
||||||
|
legacy_key = self._legacy_binary_storage_key(
|
||||||
|
action_context,
|
||||||
|
owner_type=owner_type,
|
||||||
|
owner=owner,
|
||||||
|
key=key,
|
||||||
|
)
|
||||||
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.select(persistence_bstorage.BinaryStorage)
|
||||||
|
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
|
||||||
|
.where(persistence_bstorage.BinaryStorage.unique_key == legacy_key)
|
||||||
|
.where(persistence_bstorage.BinaryStorage.key == key)
|
||||||
|
.where(persistence_bstorage.BinaryStorage.owner_type == owner_type)
|
||||||
|
.where(persistence_bstorage.BinaryStorage.owner == owner)
|
||||||
|
)
|
||||||
|
storage = result.first()
|
||||||
|
if storage is not None:
|
||||||
|
update_result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.update(persistence_bstorage.BinaryStorage)
|
||||||
|
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
|
||||||
|
.where(persistence_bstorage.BinaryStorage.unique_key == legacy_key)
|
||||||
|
.where(persistence_bstorage.BinaryStorage.key == key)
|
||||||
|
.where(persistence_bstorage.BinaryStorage.owner_type == owner_type)
|
||||||
|
.where(persistence_bstorage.BinaryStorage.owner == owner)
|
||||||
|
.values(unique_key=unique_key, value=value)
|
||||||
|
)
|
||||||
|
if update_result.rowcount:
|
||||||
|
return handler.ActionResponse.success(data={})
|
||||||
|
canonical_update = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.update(persistence_bstorage.BinaryStorage)
|
||||||
|
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
|
||||||
|
.where(persistence_bstorage.BinaryStorage.unique_key == unique_key)
|
||||||
|
.where(persistence_bstorage.BinaryStorage.key == key)
|
||||||
|
.where(persistence_bstorage.BinaryStorage.owner_type == owner_type)
|
||||||
|
.where(persistence_bstorage.BinaryStorage.owner == owner)
|
||||||
|
.values(value=value)
|
||||||
|
)
|
||||||
|
if canonical_update.rowcount:
|
||||||
|
return handler.ActionResponse.success(data={})
|
||||||
|
storage = None
|
||||||
|
|
||||||
if result.first() is not None:
|
if storage is not None:
|
||||||
await self.ap.persistence_mgr.execute_async(
|
await self.ap.persistence_mgr.execute_async(
|
||||||
sqlalchemy.update(persistence_bstorage.BinaryStorage)
|
sqlalchemy.update(persistence_bstorage.BinaryStorage)
|
||||||
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
|
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
|
||||||
.where(persistence_bstorage.BinaryStorage.unique_key == unique_key)
|
.where(persistence_bstorage.BinaryStorage.unique_key == unique_key)
|
||||||
|
.where(persistence_bstorage.BinaryStorage.key == key)
|
||||||
|
.where(persistence_bstorage.BinaryStorage.owner_type == owner_type)
|
||||||
|
.where(persistence_bstorage.BinaryStorage.owner == owner)
|
||||||
.values(value=value)
|
.values(value=value)
|
||||||
)
|
)
|
||||||
else:
|
return handler.ActionResponse.success(data={})
|
||||||
|
|
||||||
|
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(
|
await self.ap.persistence_mgr.execute_async(
|
||||||
sqlalchemy.insert(persistence_bstorage.BinaryStorage).values(
|
insert(persistence_bstorage.BinaryStorage)
|
||||||
|
.values(
|
||||||
workspace_uuid=action_context.workspace_uuid,
|
workspace_uuid=action_context.workspace_uuid,
|
||||||
unique_key=unique_key,
|
unique_key=unique_key,
|
||||||
key=key,
|
key=key,
|
||||||
@@ -914,6 +982,10 @@ class RuntimeConnectionHandler(handler.Handler):
|
|||||||
owner=owner,
|
owner=owner,
|
||||||
value=value,
|
value=value,
|
||||||
)
|
)
|
||||||
|
.on_conflict_do_update(
|
||||||
|
index_elements=['workspace_uuid', 'unique_key'],
|
||||||
|
set_={'value': value},
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
return handler.ActionResponse.success(
|
return handler.ActionResponse.success(
|
||||||
@@ -946,6 +1018,29 @@ class RuntimeConnectionHandler(handler.Handler):
|
|||||||
)
|
)
|
||||||
|
|
||||||
storage = result.first()
|
storage = result.first()
|
||||||
|
if storage is None:
|
||||||
|
legacy_key = self._legacy_binary_storage_key(
|
||||||
|
action_context,
|
||||||
|
owner_type=owner_type,
|
||||||
|
owner=owner,
|
||||||
|
key=key,
|
||||||
|
)
|
||||||
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.select(persistence_bstorage.BinaryStorage)
|
||||||
|
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
|
||||||
|
.where(persistence_bstorage.BinaryStorage.unique_key == legacy_key)
|
||||||
|
.where(persistence_bstorage.BinaryStorage.key == key)
|
||||||
|
.where(persistence_bstorage.BinaryStorage.owner_type == owner_type)
|
||||||
|
.where(persistence_bstorage.BinaryStorage.owner == owner)
|
||||||
|
)
|
||||||
|
storage = result.first()
|
||||||
|
if storage is None:
|
||||||
|
retry_result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.select(persistence_bstorage.BinaryStorage)
|
||||||
|
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
|
||||||
|
.where(persistence_bstorage.BinaryStorage.unique_key == unique_key)
|
||||||
|
)
|
||||||
|
storage = retry_result.first()
|
||||||
if storage is None:
|
if storage is None:
|
||||||
return handler.ActionResponse.error(
|
return handler.ActionResponse.error(
|
||||||
message=f'Storage with key {key} not found',
|
message=f'Storage with key {key} not found',
|
||||||
@@ -981,10 +1076,19 @@ class RuntimeConnectionHandler(handler.Handler):
|
|||||||
message=str(e),
|
message=str(e),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
legacy_key = self._legacy_binary_storage_key(
|
||||||
|
action_context,
|
||||||
|
owner_type=owner_type,
|
||||||
|
owner=owner,
|
||||||
|
key=key,
|
||||||
|
)
|
||||||
await self.ap.persistence_mgr.execute_async(
|
await self.ap.persistence_mgr.execute_async(
|
||||||
sqlalchemy.delete(persistence_bstorage.BinaryStorage)
|
sqlalchemy.delete(persistence_bstorage.BinaryStorage)
|
||||||
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
|
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
|
||||||
.where(persistence_bstorage.BinaryStorage.unique_key == unique_key)
|
.where(persistence_bstorage.BinaryStorage.unique_key.in_((unique_key, legacy_key)))
|
||||||
|
.where(persistence_bstorage.BinaryStorage.key == key)
|
||||||
|
.where(persistence_bstorage.BinaryStorage.owner_type == owner_type)
|
||||||
|
.where(persistence_bstorage.BinaryStorage.owner == owner)
|
||||||
)
|
)
|
||||||
|
|
||||||
return handler.ActionResponse.success(
|
return handler.ActionResponse.success(
|
||||||
@@ -1012,7 +1116,7 @@ class RuntimeConnectionHandler(handler.Handler):
|
|||||||
|
|
||||||
return handler.ActionResponse.success(
|
return handler.ActionResponse.success(
|
||||||
data={
|
data={
|
||||||
'keys': result.scalars().all(),
|
'keys': list(dict.fromkeys(result.scalars().all())),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1573,13 +1677,15 @@ class RuntimeConnectionHandler(handler.Handler):
|
|||||||
async def reconcile_plugin_installations(
|
async def reconcile_plugin_installations(
|
||||||
self,
|
self,
|
||||||
installations: tuple[PluginInstallationDesiredState, ...],
|
installations: tuple[PluginInstallationDesiredState, ...],
|
||||||
|
*,
|
||||||
|
timeout: float = 300,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
request = ReconcilePluginInstallationsRequest(installations=installations)
|
request = ReconcilePluginInstallationsRequest(installations=installations)
|
||||||
with self.installation_scope(None):
|
with self.installation_scope(None):
|
||||||
return await self.call_action(
|
return await self.call_action(
|
||||||
LangBotToRuntimeAction.RECONCILE_PLUGIN_INSTALLATIONS,
|
LangBotToRuntimeAction.RECONCILE_PLUGIN_INSTALLATIONS,
|
||||||
request.model_dump(),
|
request.model_dump(),
|
||||||
timeout=300,
|
timeout=timeout,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def apply_plugin_installation(
|
async def apply_plugin_installation(
|
||||||
|
|||||||
@@ -24,7 +24,10 @@ class SeekDBEmbedding(requester.ProviderAPIRequester):
|
|||||||
try:
|
try:
|
||||||
import pyseekdb
|
import pyseekdb
|
||||||
except ImportError:
|
except ImportError:
|
||||||
raise ImportError('pyseekdb is not installed. Install it with: pip install pyseekdb')
|
raise ImportError(
|
||||||
|
"SeekDB support is not installed. Install LangBot with the 'seekdb' extra: "
|
||||||
|
"uv sync --extra seekdb (source) or uvx --from 'langbot[seekdb]@latest' langbot (PyPI)."
|
||||||
|
)
|
||||||
|
|
||||||
self._embedding_function = pyseekdb.get_default_embedding_function()
|
self._embedding_function = pyseekdb.get_default_embedding_function()
|
||||||
|
|
||||||
|
|||||||
@@ -27,10 +27,16 @@ class SafeRegexTimeoutError(SafeRegexError):
|
|||||||
"""Raised when the regex engine exhausts the operation CPU budget."""
|
"""Raised when the regex engine exhausts the operation CPU budget."""
|
||||||
|
|
||||||
|
|
||||||
def _validate_patterns(patterns: Sequence[str]) -> tuple[str, ...]:
|
def _validate_patterns(
|
||||||
|
patterns: Sequence[str],
|
||||||
|
*,
|
||||||
|
max_pattern_count: int = MAX_PATTERN_COUNT,
|
||||||
|
) -> tuple[str, ...]:
|
||||||
|
if max_pattern_count < 1:
|
||||||
|
raise ValueError('max_pattern_count must be positive')
|
||||||
|
if len(patterns) > max_pattern_count:
|
||||||
|
raise SafeRegexLimitError(f'At most {max_pattern_count} regex patterns are allowed')
|
||||||
normalized = tuple(patterns)
|
normalized = tuple(patterns)
|
||||||
if len(normalized) > MAX_PATTERN_COUNT:
|
|
||||||
raise SafeRegexLimitError(f'At most {MAX_PATTERN_COUNT} regex patterns are allowed')
|
|
||||||
for pattern in normalized:
|
for pattern in normalized:
|
||||||
if not isinstance(pattern, str):
|
if not isinstance(pattern, str):
|
||||||
raise SafeRegexError('Regex patterns must be strings')
|
raise SafeRegexError('Regex patterns must be strings')
|
||||||
@@ -115,8 +121,9 @@ def _mask_patterns_sync(
|
|||||||
mask: str,
|
mask: str,
|
||||||
mask_word: str,
|
mask_word: str,
|
||||||
timeout_seconds: float,
|
timeout_seconds: float,
|
||||||
|
max_pattern_count: int,
|
||||||
) -> tuple[bool, str]:
|
) -> tuple[bool, str]:
|
||||||
normalized_patterns = _validate_patterns(patterns)
|
normalized_patterns = _validate_patterns(patterns, max_pattern_count=max_pattern_count)
|
||||||
_validate_input(value)
|
_validate_input(value)
|
||||||
if len(mask) > MAX_REPLACEMENT_CHARS or len(mask_word) > MAX_REPLACEMENT_CHARS:
|
if len(mask) > MAX_REPLACEMENT_CHARS or len(mask_word) > MAX_REPLACEMENT_CHARS:
|
||||||
raise SafeRegexLimitError(f'Regex replacements may contain at most {MAX_REPLACEMENT_CHARS} characters')
|
raise SafeRegexLimitError(f'Regex replacements may contain at most {MAX_REPLACEMENT_CHARS} characters')
|
||||||
@@ -162,6 +169,7 @@ async def mask_patterns(
|
|||||||
mask: str,
|
mask: str,
|
||||||
mask_word: str,
|
mask_word: str,
|
||||||
timeout_seconds: float = DEFAULT_OPERATION_TIMEOUT_SECONDS,
|
timeout_seconds: float = DEFAULT_OPERATION_TIMEOUT_SECONDS,
|
||||||
|
max_pattern_count: int = MAX_PATTERN_COUNT,
|
||||||
) -> tuple[bool, str]:
|
) -> tuple[bool, str]:
|
||||||
"""Apply untrusted masking patterns with bounded CPU and output growth."""
|
"""Apply untrusted masking patterns with bounded CPU and output growth."""
|
||||||
|
|
||||||
@@ -174,4 +182,5 @@ async def mask_patterns(
|
|||||||
mask=mask,
|
mask=mask,
|
||||||
mask_word=mask_word,
|
mask_word=mask_word,
|
||||||
timeout_seconds=timeout_seconds,
|
timeout_seconds=timeout_seconds,
|
||||||
|
max_pattern_count=max_pattern_count,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -42,7 +42,10 @@ class SeekDBVectorDatabase(VectorDatabase):
|
|||||||
|
|
||||||
def __init__(self, ap: app.Application):
|
def __init__(self, ap: app.Application):
|
||||||
if not SEEKDB_AVAILABLE:
|
if not SEEKDB_AVAILABLE:
|
||||||
raise ImportError('pyseekdb is not installed. Install it with: pip install pyseekdb')
|
raise ImportError(
|
||||||
|
"SeekDB support is not installed. Install LangBot with the 'seekdb' extra: "
|
||||||
|
"uv sync --extra seekdb (source) or uvx --from 'langbot[seekdb]@latest' langbot (PyPI)."
|
||||||
|
)
|
||||||
|
|
||||||
self.ap = ap
|
self.ap = ap
|
||||||
config = self.ap.instance_config.data['vdb']['seekdb']
|
config = self.ap.instance_config.data['vdb']['seekdb']
|
||||||
|
|||||||
@@ -240,7 +240,7 @@ class InvitationDeliveryService:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _plain_text(workspace_name: str, invitation_link: str) -> str:
|
def _plain_text(workspace_name: str, invitation_link: str) -> str:
|
||||||
return (
|
return (
|
||||||
'You have been invited to LangBot Cloud\n\n'
|
'You have been invited to join a Workspace in LangBot\n\n'
|
||||||
f'Join the Workspace “{workspace_name}” to collaborate with your team.\n\n'
|
f'Join the Workspace “{workspace_name}” to collaborate with your team.\n\n'
|
||||||
f'Accept invitation: {invitation_link}\n\n'
|
f'Accept invitation: {invitation_link}\n\n'
|
||||||
'This secure invitation expires in 7 days and can only be accepted by the email address '
|
'This secure invitation expires in 7 days and can only be accepted by the email address '
|
||||||
@@ -258,30 +258,77 @@ class InvitationDeliveryService:
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<title>Join {escaped_workspace} on LangBot Cloud</title>
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<title>Join {escaped_workspace} in LangBot</title>
|
||||||
</head>
|
</head>
|
||||||
<body style="margin:0;background:#f4f7fb;color:#152033;font-family:Inter,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;">
|
<body style="margin:0;padding:0;background:#f4f7fb;color:#111827;font-family:Arial,'Helvetica Neue',sans-serif;">
|
||||||
<div style="display:none;max-height:0;overflow:hidden;opacity:0;">You have been invited to join {escaped_workspace} on LangBot Cloud.</div>
|
<div style="display:none;max-height:0;overflow:hidden;opacity:0;">You have been invited to join {escaped_workspace} in LangBot.</div>
|
||||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#f4f7fb;padding:40px 16px;">
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="width:100%;background:#f4f7fb;">
|
||||||
<tr><td align="center">
|
<tr>
|
||||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:600px;background:#ffffff;border:1px solid #e5eaf2;border-radius:16px;overflow:hidden;box-shadow:0 12px 32px rgba(20,49,93,.08);">
|
<td align="center" style="padding:48px 16px;">
|
||||||
<tr><td style="padding:28px 36px;background:linear-gradient(135deg,#0f172a,#1d4ed8);color:#ffffff;">
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="width:100%;max-width:600px;">
|
||||||
<div style="font-size:14px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;opacity:.78;">LangBot Cloud</div>
|
<tr>
|
||||||
<div style="font-size:26px;font-weight:700;margin-top:8px;line-height:1.25;">You’re invited</div>
|
<td style="padding:0 4px 20px;">
|
||||||
</td></tr>
|
<img src="https://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;">
|
||||||
<tr><td style="padding:36px;">
|
<span style="display:inline-block;margin-left:10px;vertical-align:middle;font-size:18px;font-weight:700;letter-spacing:-.01em;">LangBot</span>
|
||||||
<p style="margin:0 0 18px;font-size:16px;line-height:1.65;color:#475569;">You have been invited to collaborate in this Workspace:</p>
|
</td>
|
||||||
<div style="margin:0 0 26px;padding:18px 20px;background:#f8fafc;border:1px solid #e2e8f0;border-radius:12px;font-size:18px;font-weight:700;color:#0f172a;">{escaped_workspace}</div>
|
</tr>
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0"><tr><td style="border-radius:9px;background:#2563eb;">
|
<tr>
|
||||||
<a href="{escaped_link}" style="display:inline-block;padding:13px 22px;color:#ffffff;text-decoration:none;font-size:15px;font-weight:700;">Accept invitation</a>
|
<td style="background:#ffffff;border-radius:10px;overflow:hidden;">
|
||||||
</td></tr></table>
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
|
||||||
<p style="margin:26px 0 8px;font-size:14px;line-height:1.6;color:#64748b;">This invitation expires in 7 days and is bound to the email address that received it.</p>
|
<tr>
|
||||||
<p style="margin:0 0 8px;font-size:13px;line-height:1.6;color:#94a3b8;">If the button does not work, copy and paste this URL into your browser:</p>
|
<td style="padding:42px 42px 38px;">
|
||||||
<p style="margin:0;padding:12px;background:#f8fafc;border-radius:8px;word-break:break-all;font-size:12px;line-height:1.55;color:#475569;">{escaped_link}</p>
|
<div style="margin:0 0 12px;font-size:13px;line-height:1.4;font-weight:600;color:#5f6f84;">Workspace invitation</div>
|
||||||
</td></tr>
|
<h1 style="margin:0 0 16px;font-size:28px;line-height:1.25;font-weight:700;letter-spacing:-.025em;color:#111827;">You’re invited to collaborate</h1>
|
||||||
<tr><td style="padding:20px 36px;border-top:1px solid #eef2f7;font-size:12px;line-height:1.6;color:#94a3b8;">If you were not expecting this invitation, you can safely ignore this email.</td></tr>
|
<p style="margin:0 0 28px;font-size:15px;line-height:1.7;color:#526173;">Join your team in LangBot and start building together in this Workspace.</p>
|
||||||
|
|
||||||
|
<table 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>
|
||||||
</td></tr>
|
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
|
||||||
|
<tr><td height="28" style="height:28px;font-size:0;line-height:0;"> </td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0">
|
||||||
|
<tr>
|
||||||
|
<td style="background:#2563eb;border-radius:8px;">
|
||||||
|
<a href="{escaped_link}" target="_blank" style="display:inline-block;padding:13px 22px;font-size:15px;line-height:1.2;font-weight:700;color:#ffffff;text-decoration:none;border-radius:8px;">Accept invitation</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
|
||||||
|
<tr><td height="32" style="height:32px;font-size:0;line-height:0;"> </td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="border-top:1px solid #e8edf4;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding-top:22px;">
|
||||||
|
<p style="margin:0 0 10px;font-size:13px;line-height:1.6;color:#5f6f84;">For your security, this invitation expires in 7 days and only works for the email address that received it.</p>
|
||||||
|
<a href="{escaped_link}" target="_blank" style="font-size:13px;line-height:1.6;font-weight:600;color:#2563eb;text-decoration:none;">Open invitation link →</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding:20px 24px 0;font-size:12px;line-height:1.6;color:#5f6f84;">
|
||||||
|
Sent by LangBot<br>
|
||||||
|
If you were not expecting this invitation, you can safely ignore this email.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</body>
|
</body>
|
||||||
</html>'''
|
</html>'''
|
||||||
|
|||||||
@@ -181,6 +181,11 @@ vdb:
|
|||||||
host: localhost
|
host: localhost
|
||||||
port: 6333
|
port: 6333
|
||||||
api_key: ''
|
api_key: ''
|
||||||
|
# SeekDB is optional. Native/package installs need the `seekdb` extra:
|
||||||
|
# `uv sync --extra seekdb` (source) or
|
||||||
|
# `uvx --from 'langbot[seekdb]@latest' langbot` (PyPI).
|
||||||
|
# The official Docker image already includes it.
|
||||||
|
# Embedded-mode platform support depends on the native pylibseekdb wheels.
|
||||||
seekdb:
|
seekdb:
|
||||||
mode: embedded # 'embedded' or 'server'
|
mode: embedded # 'embedded' or 'server'
|
||||||
# Embedded mode options:
|
# Embedded mode options:
|
||||||
|
|||||||
@@ -307,6 +307,7 @@ class TestUserInitEndpoint:
|
|||||||
assert data['data'] == {
|
assert data['data'] == {
|
||||||
'initialized': True,
|
'initialized': True,
|
||||||
'authenticated_invitation_acceptance_enabled': False,
|
'authenticated_invitation_acceptance_enabled': False,
|
||||||
|
'invitation_registration_enabled': True,
|
||||||
'password_login_enabled': True,
|
'password_login_enabled': True,
|
||||||
'space_login_enabled': False,
|
'space_login_enabled': False,
|
||||||
}
|
}
|
||||||
@@ -330,6 +331,28 @@ class TestUserInitEndpoint:
|
|||||||
assert data['data'] == {
|
assert data['data'] == {
|
||||||
'initialized': True,
|
'initialized': True,
|
||||||
'authenticated_invitation_acceptance_enabled': True,
|
'authenticated_invitation_acceptance_enabled': True,
|
||||||
|
'invitation_registration_enabled': False,
|
||||||
|
'password_login_enabled': False,
|
||||||
|
'space_login_enabled': True,
|
||||||
|
}
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_account_info_enables_local_invitation_registration_for_oauth_only_oss(
|
||||||
|
self, quart_test_client, fake_api_app
|
||||||
|
):
|
||||||
|
fake_api_app.user_service.is_initialized.return_value = True
|
||||||
|
fake_api_app.user_service.get_login_capabilities = AsyncMock(
|
||||||
|
return_value={'password_login_enabled': False, 'space_login_enabled': True}
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await quart_test_client.get('/api/v1/user/account-info')
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = await response.get_json()
|
||||||
|
assert data['data'] == {
|
||||||
|
'initialized': True,
|
||||||
|
'authenticated_invitation_acceptance_enabled': False,
|
||||||
|
'invitation_registration_enabled': True,
|
||||||
'password_login_enabled': False,
|
'password_login_enabled': False,
|
||||||
'space_login_enabled': True,
|
'space_login_enabled': True,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -165,34 +165,51 @@ async def test_bind_state_is_account_bound_and_requires_authentication(space_oau
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_redirect_origin_and_callback_path_are_restricted(space_oauth_api):
|
async def test_redirect_allows_any_http_or_https_origin(space_oauth_api):
|
||||||
_, client = space_oauth_api
|
_, client = space_oauth_api
|
||||||
|
|
||||||
wrong_origin = await client.get(
|
responses = [
|
||||||
|
await client.get(
|
||||||
'/api/v1/user/space/authorize-url',
|
'/api/v1/user/space/authorize-url',
|
||||||
query_string={'redirect_uri': 'https://evil.example/auth/space/callback'},
|
query_string={'redirect_uri': redirect_uri},
|
||||||
headers={'Origin': 'http://localhost'},
|
headers={'Origin': 'https://irrelevant.example'},
|
||||||
)
|
)
|
||||||
wrong_path = await client.get(
|
for redirect_uri in (
|
||||||
'/api/v1/user/space/authorize-url',
|
'https://langbot.example/auth/space/callback',
|
||||||
query_string={'redirect_uri': 'http://localhost/arbitrary'},
|
'https://gateway.example:8443/auth/space/callback',
|
||||||
headers={'Origin': 'http://localhost'},
|
'https://192.0.2.10/auth/space/callback',
|
||||||
)
|
'http://localhost:5300/auth/space/callback',
|
||||||
forged_origin = await client.get(
|
'http://127.0.0.1:5300/auth/space/callback',
|
||||||
'/api/v1/user/space/authorize-url',
|
'http://[::1]:5300/auth/space/callback',
|
||||||
query_string={'redirect_uri': 'https://evil.example/auth/space/callback'},
|
'http://langbot.example/auth/space/callback',
|
||||||
headers={'Origin': 'https://evil.example'},
|
'http://192.0.2.10:5300/auth/space/callback',
|
||||||
)
|
|
||||||
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 (await wrong_origin.get_json())['code'] == 1
|
assert all(response.status_code == 200 for response in responses)
|
||||||
assert (await wrong_path.get_json())['code'] == 1
|
payloads = [await response.get_json() for response in responses]
|
||||||
assert (await forged_origin.get_json())['code'] == 1
|
assert all(payload['code'] == 0 for payload in payloads)
|
||||||
assert (await forged_host.get_json())['code'] == 1
|
|
||||||
|
|
||||||
|
@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)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -295,6 +312,29 @@ async def test_space_credits_are_resolved_from_workspace_owner(space_oauth_api):
|
|||||||
application.space_service.get_credits.assert_awaited_once_with('owner@example.com')
|
application.space_service.get_credits.assert_awaited_once_with('owner@example.com')
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_oss_local_only_owner_requires_space_binding_for_langbot_models(space_oauth_api):
|
||||||
|
application, client = space_oauth_api
|
||||||
|
application.user_service.get_workspace_owner = AsyncMock(
|
||||||
|
return_value=SimpleNamespace(user='owner@example.com', space_account_uuid=None)
|
||||||
|
)
|
||||||
|
application.space_service.get_credits = AsyncMock()
|
||||||
|
|
||||||
|
response = await client.get(
|
||||||
|
'/api/v1/user/space-credits',
|
||||||
|
headers={'Authorization': 'Bearer account-token', 'X-Workspace-Id': WORKSPACE_UUID},
|
||||||
|
)
|
||||||
|
payload = await response.get_json()
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert payload['data'] == {
|
||||||
|
'credits': None,
|
||||||
|
'owner_space_bound': False,
|
||||||
|
'is_workspace_owner': True,
|
||||||
|
}
|
||||||
|
application.space_service.get_credits.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_cloud_workspace_owner_is_always_space_bound_after_login(space_oauth_api):
|
async def test_cloud_workspace_owner_is_always_space_bound_after_login(space_oauth_api):
|
||||||
application, client = space_oauth_api
|
application, client = space_oauth_api
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ async def create_legacy_resource_schema(engine, *, instance_uuid: str) -> None:
|
|||||||
sa.Column('key', sa.String(255), nullable=False),
|
sa.Column('key', sa.String(255), nullable=False),
|
||||||
sa.Column('owner_type', sa.String(255), nullable=False),
|
sa.Column('owner_type', sa.String(255), nullable=False),
|
||||||
sa.Column('owner', sa.String(255), nullable=False),
|
sa.Column('owner', sa.String(255), nullable=False),
|
||||||
|
sa.Column('value', sa.LargeBinary, nullable=False),
|
||||||
)
|
)
|
||||||
mcp_servers = _uuid_table(
|
mcp_servers = _uuid_table(
|
||||||
metadata,
|
metadata,
|
||||||
@@ -210,7 +211,13 @@ 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(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(bot_admins.insert().values(bot_uuid='bot-1', launcher_type='person', launcher_id='owner'))
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
binary_storages.insert().values(unique_key='plugin:demo:key', key='key', owner_type='plugin', owner='demo')
|
binary_storages.insert().values(
|
||||||
|
unique_key='plugin:demo:key',
|
||||||
|
key='key',
|
||||||
|
owner_type='plugin',
|
||||||
|
owner='demo',
|
||||||
|
value=b'legacy-plugin-value',
|
||||||
|
)
|
||||||
)
|
)
|
||||||
await conn.execute(mcp_servers.insert().values(uuid='mcp-1', name='shared-name', enable=True, updated_at=now))
|
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'))
|
await conn.execute(model_providers.insert().values(uuid='provider-1', name='provider', requester='openai'))
|
||||||
|
|||||||
@@ -76,6 +76,26 @@ async def test_legacy_sqlite_resources_are_backfilled_and_contracted(tmp_path):
|
|||||||
)
|
)
|
||||||
assert legacy_kb['collection_id'] == 'collection-1'
|
assert legacy_kb['collection_id'] == 'collection-1'
|
||||||
assert legacy_kb['legacy_vector_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 (
|
assert (
|
||||||
await conn.scalar(
|
await conn.scalar(
|
||||||
sa.text(
|
sa.text(
|
||||||
@@ -209,8 +229,8 @@ async def test_sqlite_scoped_keys_allow_cross_workspace_but_reject_same_workspac
|
|||||||
await conn.execute(
|
await conn.execute(
|
||||||
sa.text(
|
sa.text(
|
||||||
'INSERT INTO binary_storages '
|
'INSERT INTO binary_storages '
|
||||||
'(workspace_uuid, unique_key, key, owner_type, owner) '
|
'(workspace_uuid, unique_key, key, owner_type, owner, value) '
|
||||||
"VALUES (:workspace_uuid, 'plugin:demo:key', 'key', 'plugin', 'demo')"
|
"VALUES (:workspace_uuid, 'plugin:demo:key', 'key', 'plugin', 'demo', X'')"
|
||||||
),
|
),
|
||||||
{'workspace_uuid': second_workspace_uuid},
|
{'workspace_uuid': second_workspace_uuid},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
|
||||||
@@ -9,7 +10,7 @@ import pytest
|
|||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from sqlalchemy.ext.asyncio import create_async_engine
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
|
|
||||||
from langbot.pkg.persistence import alembic_runner
|
from langbot.pkg.persistence import alembic_runner, sqlite_migration_backup
|
||||||
from langbot.pkg.persistence.mgr import PersistenceManager
|
from langbot.pkg.persistence.mgr import PersistenceManager
|
||||||
|
|
||||||
from .resource_migration_support import create_legacy_resource_schema
|
from .resource_migration_support import create_legacy_resource_schema
|
||||||
@@ -105,3 +106,31 @@ async def test_failed_tenancy_migration_restores_backup_and_revision(
|
|||||||
assert await alembic_runner.get_alembic_current(engine) == alembic_runner.get_alembic_head()
|
assert await alembic_runner.get_alembic_current(engine) == alembic_runner.get_alembic_head()
|
||||||
finally:
|
finally:
|
||||||
await engine.dispose()
|
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,13 +179,17 @@ async def test_existing_oss_workspace_is_rekeyed_to_instance_identity(tmp_path):
|
|||||||
)
|
)
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
await conn.run_sync(schema.create_all)
|
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(
|
await conn.execute(
|
||||||
sa.text("INSERT INTO workspaces (uuid, instance_uuid, slug, source) VALUES (:uuid, :instance, 'default', 'local')"),
|
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')"
|
||||||
|
),
|
||||||
{'uuid': old_workspace_uuid, 'instance': instance_id},
|
{'uuid': old_workspace_uuid, 'instance': instance_id},
|
||||||
)
|
)
|
||||||
await conn.execute(
|
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},
|
{'uuid': old_workspace_uuid},
|
||||||
)
|
)
|
||||||
await run_alembic_stamp(engine, '0016_support_admin_sessions')
|
await run_alembic_stamp(engine, '0016_support_admin_sessions')
|
||||||
@@ -193,8 +197,8 @@ async def test_existing_oss_workspace_is_rekeyed_to_instance_identity(tmp_path):
|
|||||||
await run_alembic_upgrade(engine, 'head')
|
await run_alembic_upgrade(engine, 'head')
|
||||||
|
|
||||||
async with engine.connect() as conn:
|
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 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 workspace_uuid FROM tenant_rows'))).scalar_one() == canonical_uuid
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
@@ -411,6 +415,45 @@ async def test_persistence_startup_defers_workspace_tables_until_account_upgrade
|
|||||||
await engine.dispose()
|
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):
|
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"}')
|
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "workspace-rekey.db"}')
|
||||||
try:
|
try:
|
||||||
@@ -425,7 +468,7 @@ async def test_oss_workspace_identity_rekeys_fk_graph_and_metadata(tmp_path):
|
|||||||
assert instance_uuid
|
assert instance_uuid
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
sa.text(
|
sa.text(
|
||||||
"INSERT INTO workspace_metadata (workspace_uuid, key, value) "
|
'INSERT INTO workspace_metadata (workspace_uuid, key, value) '
|
||||||
"VALUES (:workspace_uuid, 'migration_probe', 'present')"
|
"VALUES (:workspace_uuid, 'migration_probe', 'present')"
|
||||||
),
|
),
|
||||||
{'workspace_uuid': old_uuid},
|
{'workspace_uuid': old_uuid},
|
||||||
@@ -433,7 +476,7 @@ async def test_oss_workspace_identity_rekeys_fk_graph_and_metadata(tmp_path):
|
|||||||
await conn.execute(
|
await conn.execute(
|
||||||
sa.text(
|
sa.text(
|
||||||
"INSERT INTO metadata (key, value) VALUES ('oss_workspace_uuid', :workspace_uuid) "
|
"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},
|
{'workspace_uuid': old_uuid},
|
||||||
)
|
)
|
||||||
@@ -442,12 +485,16 @@ async def test_oss_workspace_identity_rekeys_fk_graph_and_metadata(tmp_path):
|
|||||||
expected_uuid = workspace_uuid_from_instance_id(instance_uuid)
|
expected_uuid = workspace_uuid_from_instance_id(instance_uuid)
|
||||||
async with engine.connect() as conn:
|
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 uuid FROM workspaces WHERE source = 'local'")) == expected_uuid
|
||||||
assert await conn.scalar(
|
assert (
|
||||||
|
await conn.scalar(
|
||||||
sa.text("SELECT workspace_uuid FROM workspace_metadata WHERE key = 'migration_probe'")
|
sa.text("SELECT workspace_uuid FROM workspace_metadata WHERE key = 'migration_probe'")
|
||||||
) == expected_uuid
|
)
|
||||||
assert await conn.scalar(
|
== expected_uuid
|
||||||
sa.text("SELECT value FROM metadata WHERE key = 'oss_workspace_uuid'")
|
)
|
||||||
) == expected_uuid
|
assert (
|
||||||
|
await conn.scalar(sa.text("SELECT value FROM metadata WHERE key = 'oss_workspace_uuid'"))
|
||||||
|
== expected_uuid
|
||||||
|
)
|
||||||
finally:
|
finally:
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|
||||||
|
|||||||
@@ -377,7 +377,7 @@ class TestUserServiceAuthenticate:
|
|||||||
service = UserService(ap)
|
service = UserService(ap)
|
||||||
|
|
||||||
# Execute & Verify
|
# Execute & Verify
|
||||||
with pytest.raises(ValueError, match='请使用 Space 账户登录'):
|
with pytest.raises(ValueError, match='请使用 LangBot 账号登录'):
|
||||||
await service.authenticate('space@example.com', 'password')
|
await service.authenticate('space@example.com', 'password')
|
||||||
|
|
||||||
|
|
||||||
@@ -726,7 +726,7 @@ class TestUserServiceCreateOrUpdateSpaceUser:
|
|||||||
)
|
)
|
||||||
service = UserService(ap)
|
service = UserService(ap)
|
||||||
|
|
||||||
with pytest.raises(ControlPlaneDirectoryRequiredError, match='Space account'):
|
with pytest.raises(ControlPlaneDirectoryRequiredError, match='LangBot Account'):
|
||||||
await service.register_invited_account('invite-token', 'member@example.com', 'password')
|
await service.register_invited_account('invite-token', 'member@example.com', 'password')
|
||||||
|
|
||||||
async def test_create_or_update_new_space_user_first_init(self):
|
async def test_create_or_update_new_space_user_first_init(self):
|
||||||
|
|||||||
@@ -2163,25 +2163,38 @@ class TestInboundOutboundRoundTrip:
|
|||||||
|
|
||||||
calls = []
|
calls = []
|
||||||
|
|
||||||
async def fake_execute_tool(parameters, q):
|
async def fake_client_execute(spec):
|
||||||
calls.append(parameters['command'])
|
cmd = spec.cmd
|
||||||
if 'os.scandir' in parameters['command']:
|
calls.append(cmd)
|
||||||
return {
|
if 'os.scandir' in cmd:
|
||||||
'ok': True,
|
return BoxExecutionResult(
|
||||||
'stdout': '[{"name": "out.png", "b64": "QUJD"}]',
|
session_id='s',
|
||||||
'stderr': '',
|
backend_name='test',
|
||||||
}
|
status=BoxExecutionStatus.COMPLETED,
|
||||||
|
exit_code=0,
|
||||||
|
stdout='[{"name": "out.png", "b64": "QUJD"}]',
|
||||||
|
duration_ms=10,
|
||||||
|
)
|
||||||
# the rm -rf cleanup call
|
# the rm -rf cleanup call
|
||||||
return {'ok': True, 'stdout': '', 'stderr': ''}
|
return BoxExecutionResult(
|
||||||
|
session_id='s',
|
||||||
|
backend_name='test',
|
||||||
|
status=BoxExecutionStatus.COMPLETED,
|
||||||
|
exit_code=0,
|
||||||
|
stdout='',
|
||||||
|
duration_ms=10,
|
||||||
|
)
|
||||||
|
|
||||||
service.execute_tool = AsyncMock(side_effect=fake_execute_tool)
|
service.client.execute = AsyncMock(side_effect=fake_client_execute)
|
||||||
|
service.execute_tool = AsyncMock(return_value={'ok': True, 'stdout': '', 'stderr': ''})
|
||||||
|
|
||||||
attachments = await service.collect_outbound_attachments(query)
|
attachments = await service.collect_outbound_attachments(query)
|
||||||
assert len(attachments) == 1
|
assert len(attachments) == 1
|
||||||
assert attachments[0]['type'] == 'Image'
|
assert attachments[0]['type'] == 'Image'
|
||||||
assert attachments[0]['name'] == 'out.png'
|
assert attachments[0]['name'] == 'out.png'
|
||||||
# cleanup (rm -rf) must have been issued after a successful collection
|
# cleanup (rm -rf) must have been issued after a successful collection
|
||||||
assert any('rm -rf' in c for c in calls)
|
service.execute_tool.assert_awaited_once()
|
||||||
|
assert 'rm -rf' in service.execute_tool.await_args.args[0]['command']
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_collect_outbound_empty_still_clears(self):
|
async def test_collect_outbound_empty_still_clears(self):
|
||||||
@@ -2193,16 +2206,33 @@ class TestInboundOutboundRoundTrip:
|
|||||||
|
|
||||||
calls = []
|
calls = []
|
||||||
|
|
||||||
async def fake_execute_tool(parameters, q):
|
async def fake_client_execute(spec):
|
||||||
calls.append(parameters['command'])
|
cmd = spec.cmd
|
||||||
if 'os.scandir' in parameters['command']:
|
calls.append(cmd)
|
||||||
return {'ok': True, 'stdout': '[]', 'stderr': ''}
|
if 'os.scandir' in cmd:
|
||||||
return {'ok': True, 'stdout': '', 'stderr': ''}
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
service.execute_tool = AsyncMock(side_effect=fake_execute_tool)
|
service.client.execute = AsyncMock(side_effect=fake_client_execute)
|
||||||
|
service.execute_tool = AsyncMock(return_value={'ok': True, 'stdout': '', 'stderr': ''})
|
||||||
assert await service.collect_outbound_attachments(query) == []
|
assert await service.collect_outbound_attachments(query) == []
|
||||||
# cleanup (rm -rf) is issued unconditionally now
|
# cleanup (rm -rf) is issued unconditionally now
|
||||||
assert any('rm -rf' in c for c in calls)
|
service.execute_tool.assert_awaited_once()
|
||||||
|
assert 'rm -rf' in service.execute_tool.await_args.args[0]['command']
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_passthrough_noop_when_unavailable(self):
|
async def test_passthrough_noop_when_unavailable(self):
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from unittest.mock import AsyncMock, Mock
|
|||||||
|
|
||||||
from langbot.pkg.command import operator
|
from langbot.pkg.command import operator
|
||||||
from langbot.pkg.command.cmdmgr import CommandManager
|
from langbot.pkg.command.cmdmgr import CommandManager
|
||||||
|
from langbot.pkg.api.http.context import ExecutionContext
|
||||||
from tests.factories import FakeApp, command_query
|
from tests.factories import FakeApp, command_query
|
||||||
|
|
||||||
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
||||||
@@ -393,6 +394,32 @@ class TestCommandManagerInternalExecute:
|
|||||||
assert len(results) == 1
|
assert len(results) == 1
|
||||||
assert results[0].text == 'plugin response'
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_execute_with_bound_plugins(self):
|
async def test_execute_with_bound_plugins(self):
|
||||||
"""_execute passes bound_plugins to plugin connector."""
|
"""_execute passes bound_plugins to plugin connector."""
|
||||||
|
|||||||
@@ -144,3 +144,39 @@ async def test_runtime_resource_stats_are_aggregate_and_constant_time() -> None:
|
|||||||
assert stats['models']['providers'] == 1
|
assert stats['models']['providers'] == 1
|
||||||
assert stats['runtimes']['plugin_installations'] == 1
|
assert stats['runtimes']['plugin_installations'] == 1
|
||||||
assert stats['runtimes']['plugin_runtime_connected'] is True
|
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()
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"""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 == ''
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
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,9 +1,11 @@
|
|||||||
"""Tests for QQ Official keyboard payload helpers."""
|
"""Tests for QQ Official message and keyboard payload helpers."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
import time
|
import time
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||||
@@ -99,6 +101,12 @@ def _stream_test_adapter():
|
|||||||
adapter.bot = MagicMock()
|
adapter.bot = MagicMock()
|
||||||
adapter.bot.send_stream_msg = AsyncMock(return_value={'id': 'stream-1'})
|
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_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.ap = None
|
||||||
adapter._stream_ctx = {}
|
adapter._stream_ctx = {}
|
||||||
adapter._stream_ctx_ts = {}
|
adapter._stream_ctx_ts = {}
|
||||||
@@ -108,7 +116,7 @@ def _stream_test_adapter():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_qq_stream_uses_cumulative_chunks_as_snapshots():
|
async def test_qq_stream_replace_mode_sends_complete_snapshots():
|
||||||
adapter = _stream_test_adapter()
|
adapter = _stream_test_adapter()
|
||||||
adapter._stream_ctx['message-1'] = {
|
adapter._stream_ctx['message-1'] = {
|
||||||
'user_openid': 'user-1',
|
'user_openid': 'user-1',
|
||||||
@@ -138,10 +146,109 @@ async def test_qq_stream_uses_cumulative_chunks_as_snapshots():
|
|||||||
|
|
||||||
assert [call.kwargs['content'] for call in adapter.bot.send_stream_msg.await_args_list] == [
|
assert [call.kwargs['content'] for call in adapter.bot.send_stream_msg.await_args_list] == [
|
||||||
'<think>one',
|
'<think>one',
|
||||||
' two',
|
'<think>one 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
|
@pytest.mark.asyncio
|
||||||
async def test_qq_non_streaming_fallback_keeps_latest_snapshot_only():
|
async def test_qq_non_streaming_fallback_keeps_latest_snapshot_only():
|
||||||
from langbot.pkg.platform.sources.qqofficial import QQOfficialAdapter
|
from langbot.pkg.platform.sources.qqofficial import QQOfficialAdapter
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
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'),
|
||||||
|
]
|
||||||
@@ -107,6 +107,19 @@ def shared_connector(
|
|||||||
return 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
|
@pytest.mark.asyncio
|
||||||
async def test_shared_reconnect_replays_two_workspaces_and_removes_missing_projection():
|
async def test_shared_reconnect_replays_two_workspaces_and_removes_missing_projection():
|
||||||
binding_a = execution_binding('workspace-a')
|
binding_a = execution_binding('workspace-a')
|
||||||
@@ -150,7 +163,7 @@ async def test_empty_projected_workspaces_do_not_retain_installation_sets():
|
|||||||
|
|
||||||
assert connector._workspace_installations == {}
|
assert connector._workspace_installations == {}
|
||||||
assert connector._known_desired_states == {}
|
assert connector._known_desired_states == {}
|
||||||
connector.handler.reconcile_plugin_installations.assert_awaited_once_with(())
|
connector.handler.reconcile_plugin_installations.assert_awaited_once_with((), timeout=300.0)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -81,6 +81,18 @@ async def test_reconcile_plugin_installations_allows_cloud_cold_start_to_finish(
|
|||||||
assert runtime_handler.call_action.await_args.kwargs['timeout'] == 300
|
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:
|
class TestHandlerQueryVariables:
|
||||||
"""Tests for handler query variable logic."""
|
"""Tests for handler query variable logic."""
|
||||||
|
|
||||||
|
|||||||
@@ -234,6 +234,7 @@ class TestSetBinaryStorage:
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
mock_app.persistence_mgr = Mock()
|
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.persistence_mgr.execute_async = AsyncMock(return_value=make_result())
|
||||||
mock_app.logger = Mock()
|
mock_app.logger = Mock()
|
||||||
return mock_app
|
return mock_app
|
||||||
@@ -270,8 +271,8 @@ class TestSetBinaryStorage:
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert response.code == 0
|
assert response.code == 0
|
||||||
assert app.persistence_mgr.execute_async.await_count == 2
|
assert app.persistence_mgr.execute_async.await_count == 3
|
||||||
insert_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[1].args[0])
|
insert_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[2].args[0])
|
||||||
assert insert_params['workspace_uuid'] == 'workspace-a'
|
assert insert_params['workspace_uuid'] == 'workspace-a'
|
||||||
assert insert_params['unique_key'] == canonical_binary_key(
|
assert insert_params['unique_key'] == canonical_binary_key(
|
||||||
'plugin',
|
'plugin',
|
||||||
@@ -301,6 +302,69 @@ class TestSetBinaryStorage:
|
|||||||
assert expected_key in update_params.values()
|
assert expected_key in update_params.values()
|
||||||
assert update_params['value'] == b'new'
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_invalid_max_value_bytes_falls_back_to_default_limit(self, app):
|
async def test_invalid_max_value_bytes_falls_back_to_default_limit(self, app):
|
||||||
"""Invalid max_value_bytes uses the 10MB default limit."""
|
"""Invalid max_value_bytes uses the 10MB default limit."""
|
||||||
@@ -525,6 +589,46 @@ class TestGetBinaryStorage:
|
|||||||
in statement_params.values()
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_returns_error_when_not_found(self, app):
|
async def test_returns_error_when_not_found(self, app):
|
||||||
"""Missing binary storage rows return an error response."""
|
"""Missing binary storage rows return an error response."""
|
||||||
@@ -567,21 +671,47 @@ class TestDeleteAndListBinaryStorage:
|
|||||||
|
|
||||||
assert response.code == 0
|
assert response.code == 0
|
||||||
statement_params = compiled_params(app.persistence_mgr.execute_async.await_args.args[0])
|
statement_params = compiled_params(app.persistence_mgr.execute_async.await_args.args[0])
|
||||||
assert 'workspace-a' in statement_params.values()
|
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 (
|
assert (
|
||||||
canonical_binary_key(
|
canonical_binary_key(
|
||||||
'plugin',
|
'plugin',
|
||||||
'test-author/test-plugin',
|
'test-author/test-plugin',
|
||||||
'test-key',
|
'test-key',
|
||||||
)
|
)
|
||||||
in statement_params.values()
|
in flat_values
|
||||||
)
|
)
|
||||||
assert 'forged-owner' not 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
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_list_keys_uses_trusted_plugin_owner(self, app):
|
async def test_list_keys_uses_trusted_plugin_owner(self, app):
|
||||||
result = Mock()
|
result = Mock()
|
||||||
result.scalars.return_value.all.return_value = ['first', 'second']
|
result.scalars.return_value.all.return_value = ['first', 'second', 'first']
|
||||||
app.persistence_mgr.execute_async.return_value = result
|
app.persistence_mgr.execute_async.return_value = result
|
||||||
runtime_handler = make_handler(app)
|
runtime_handler = make_handler(app)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
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']
|
||||||
@@ -53,6 +53,45 @@ 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
|
@pytest.mark.asyncio
|
||||||
async def test_mask_patterns_bounds_replacement_growth_and_masks_matches():
|
async def test_mask_patterns_bounds_replacement_growth_and_masks_matches():
|
||||||
found, masked = await safe_regex.mask_patterns(
|
found, masked = await safe_regex.mask_patterns(
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
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,14 +88,15 @@ async def test_environment_mapping_enables_provider_without_leaking_secret(monke
|
|||||||
assert service.capability() == {'enabled': True, 'provider': 'smtp'}
|
assert service.capability() == {'enabled': True, 'provider': 'smtp'}
|
||||||
|
|
||||||
|
|
||||||
async def test_cloud_invitation_email_has_branded_html_plain_fallback_and_expiry_copy():
|
async def test_invitation_email_has_generic_langbot_brand_plain_fallback_and_expiry_copy():
|
||||||
service = InvitationDeliveryService(_app({}))
|
service = InvitationDeliveryService(_app({}))
|
||||||
link = 'https://cloud.langbot.app/invitations/accept#token=lbi_secret&next=<unsafe>'
|
link = 'https://cloud.langbot.app/invitations/accept#token=lbi_secret&next=<unsafe>'
|
||||||
|
|
||||||
text = service._plain_text('Research & Development', link)
|
text = service._plain_text('Research & Development', link)
|
||||||
html = service._html('Research & Development', link)
|
html = service._html('Research & Development', link)
|
||||||
|
|
||||||
assert 'LangBot Cloud' in text
|
assert 'LangBot' in text
|
||||||
|
assert 'LangBot Cloud' not in text
|
||||||
assert 'Research & Development' in text
|
assert 'Research & Development' in text
|
||||||
assert '7 days' in text
|
assert '7 days' in text
|
||||||
assert link in text
|
assert link in text
|
||||||
@@ -103,3 +104,55 @@ async def test_cloud_invitation_email_has_branded_html_plain_fallback_and_expiry
|
|||||||
assert 'Research & Development' in html
|
assert 'Research & Development' in html
|
||||||
assert 'expires in 7 days' in html
|
assert 'expires in 7 days' in html
|
||||||
assert 'lbi_secret&next=<unsafe>' in html
|
assert 'lbi_secret&next=<unsafe>' 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)
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ resolution-markers = [
|
|||||||
"python_full_version == '3.13.*' and sys_platform == 'emscripten'",
|
"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.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 == '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'",
|
|
||||||
"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'",
|
||||||
|
"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 != 'emscripten' and sys_platform != 'win32'",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1018,7 +1018,7 @@ name = "cuda-bindings"
|
|||||||
version = "13.3.1"
|
version = "13.3.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "cuda-pathfinder", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
{ name = "cuda-pathfinder" },
|
||||||
]
|
]
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/51/6b/457ca12dad3ee9bfcc9a545cfd6b64b359ba49de40f776f6e028e678f262/cuda_bindings-13.3.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5879712accf6e14bb01aa5e67440eb84998b8d104b509cc7a6dc0b8f656a474", size = 6053539, upload-time = "2026-05-29T23:11:43.19Z" },
|
{ url = "https://files.pythonhosted.org/packages/51/6b/457ca12dad3ee9bfcc9a545cfd6b64b359ba49de40f776f6e028e678f262/cuda_bindings-13.3.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5879712accf6e14bb01aa5e67440eb84998b8d104b509cc7a6dc0b8f656a474", size = 6053539, upload-time = "2026-05-29T23:11:43.19Z" },
|
||||||
@@ -1051,34 +1051,34 @@ wheels = [
|
|||||||
|
|
||||||
[package.optional-dependencies]
|
[package.optional-dependencies]
|
||||||
cudart = [
|
cudart = [
|
||||||
{ name = "nvidia-cuda-runtime", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
|
{ name = "nvidia-cuda-runtime" },
|
||||||
]
|
]
|
||||||
cufft = [
|
cufft = [
|
||||||
{ name = "nvidia-cufft", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
|
{ name = "nvidia-cufft" },
|
||||||
]
|
]
|
||||||
cufile = [
|
cufile = [
|
||||||
{ name = "nvidia-cufile", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
|
{ name = "nvidia-cufile" },
|
||||||
]
|
]
|
||||||
cupti = [
|
cupti = [
|
||||||
{ name = "nvidia-cuda-cupti", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
|
{ name = "nvidia-cuda-cupti" },
|
||||||
]
|
]
|
||||||
curand = [
|
curand = [
|
||||||
{ name = "nvidia-curand", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
|
{ name = "nvidia-curand" },
|
||||||
]
|
]
|
||||||
cusolver = [
|
cusolver = [
|
||||||
{ name = "nvidia-cusolver", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
|
{ name = "nvidia-cusolver" },
|
||||||
]
|
]
|
||||||
cusparse = [
|
cusparse = [
|
||||||
{ name = "nvidia-cusparse", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
|
{ name = "nvidia-cusparse" },
|
||||||
]
|
]
|
||||||
nvjitlink = [
|
nvjitlink = [
|
||||||
{ name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
|
{ name = "nvidia-nvjitlink" },
|
||||||
]
|
]
|
||||||
nvrtc = [
|
nvrtc = [
|
||||||
{ name = "nvidia-cuda-nvrtc", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
|
{ name = "nvidia-cuda-nvrtc" },
|
||||||
]
|
]
|
||||||
nvtx = [
|
nvtx = [
|
||||||
{ name = "nvidia-nvtx", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
|
{ name = "nvidia-nvtx" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2008,7 +2008,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "langbot"
|
name = "langbot"
|
||||||
version = "4.10.7"
|
version = "4.10.8"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiocqhttp" },
|
{ name = "aiocqhttp" },
|
||||||
@@ -2063,7 +2063,6 @@ dependencies = [
|
|||||||
{ name = "pymilvus" },
|
{ name = "pymilvus" },
|
||||||
{ name = "pynacl" },
|
{ name = "pynacl" },
|
||||||
{ name = "pypdf2" },
|
{ name = "pypdf2" },
|
||||||
{ name = "pyseekdb" },
|
|
||||||
{ name = "python-docx" },
|
{ name = "python-docx" },
|
||||||
{ name = "python-multipart" },
|
{ name = "python-multipart" },
|
||||||
{ name = "python-socks" },
|
{ name = "python-socks" },
|
||||||
@@ -2089,6 +2088,11 @@ dependencies = [
|
|||||||
{ name = "websockets" },
|
{ name = "websockets" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[package.optional-dependencies]
|
||||||
|
seekdb = [
|
||||||
|
{ name = "pyseekdb" },
|
||||||
|
]
|
||||||
|
|
||||||
[package.dev-dependencies]
|
[package.dev-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
{ name = "moto" },
|
{ name = "moto" },
|
||||||
@@ -2125,7 +2129,7 @@ requires-dist = [
|
|||||||
{ name = "ebooklib", specifier = ">=0.18" },
|
{ name = "ebooklib", specifier = ">=0.18" },
|
||||||
{ name = "gewechat-client", specifier = ">=0.1.5" },
|
{ name = "gewechat-client", specifier = ">=0.1.5" },
|
||||||
{ name = "html2text", specifier = ">=2024.2.26" },
|
{ name = "html2text", specifier = ">=2024.2.26" },
|
||||||
{ name = "langbot-plugin", git = "https://github.com/langbot-app/langbot-plugin-sdk.git?rev=555a58e5db3de28e977b08dd4cd116b332848a19" },
|
{ name = "langbot-plugin", specifier = "==0.5.5" },
|
||||||
{ name = "langchain", specifier = ">=1.3.9" },
|
{ name = "langchain", specifier = ">=1.3.9" },
|
||||||
{ name = "langchain-core", specifier = ">=1.3.3" },
|
{ name = "langchain-core", specifier = ">=1.3.3" },
|
||||||
{ name = "langchain-text-splitters", specifier = ">=1.1.2" },
|
{ name = "langchain-text-splitters", specifier = ">=1.1.2" },
|
||||||
@@ -2153,7 +2157,7 @@ requires-dist = [
|
|||||||
{ name = "pymilvus", specifier = ">=2.6.4" },
|
{ name = "pymilvus", specifier = ">=2.6.4" },
|
||||||
{ name = "pynacl", specifier = ">=1.5.0" },
|
{ name = "pynacl", specifier = ">=1.5.0" },
|
||||||
{ name = "pypdf2", specifier = ">=3.0.1" },
|
{ name = "pypdf2", specifier = ">=3.0.1" },
|
||||||
{ name = "pyseekdb", specifier = "==1.1.0.post3" },
|
{ name = "pyseekdb", marker = "extra == 'seekdb'", specifier = "==1.1.0.post3" },
|
||||||
{ name = "python-docx", specifier = ">=1.1.0" },
|
{ name = "python-docx", specifier = ">=1.1.0" },
|
||||||
{ name = "python-multipart", specifier = ">=0.0.27" },
|
{ name = "python-multipart", specifier = ">=0.0.27" },
|
||||||
{ name = "python-socks", specifier = ">=2.7.1" },
|
{ name = "python-socks", specifier = ">=2.7.1" },
|
||||||
@@ -2178,6 +2182,7 @@ requires-dist = [
|
|||||||
{ name = "valkey-glide", marker = "sys_platform != 'win32'", specifier = ">=2.4.1,<3.0.0" },
|
{ name = "valkey-glide", marker = "sys_platform != 'win32'", specifier = ">=2.4.1,<3.0.0" },
|
||||||
{ name = "websockets", specifier = ">=15.0.1" },
|
{ name = "websockets", specifier = ">=15.0.1" },
|
||||||
]
|
]
|
||||||
|
provides-extras = ["seekdb"]
|
||||||
|
|
||||||
[package.metadata.requires-dev]
|
[package.metadata.requires-dev]
|
||||||
dev = [
|
dev = [
|
||||||
@@ -2191,8 +2196,8 @@ dev = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "langbot-plugin"
|
name = "langbot-plugin"
|
||||||
version = "0.5.3"
|
version = "0.5.5"
|
||||||
source = { git = "https://github.com/langbot-app/langbot-plugin-sdk.git?rev=555a58e5db3de28e977b08dd4cd116b332848a19#555a58e5db3de28e977b08dd4cd116b332848a19" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiofiles" },
|
{ name = "aiofiles" },
|
||||||
{ name = "aiohttp" },
|
{ name = "aiohttp" },
|
||||||
@@ -2212,6 +2217,10 @@ dependencies = [
|
|||||||
{ name = "watchdog" },
|
{ name = "watchdog" },
|
||||||
{ name = "websockets" },
|
{ 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]]
|
[[package]]
|
||||||
name = "langchain"
|
name = "langchain"
|
||||||
@@ -3238,7 +3247,7 @@ name = "nvidia-cublas"
|
|||||||
version = "13.1.1.3"
|
version = "13.1.1.3"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "nvidia-cuda-nvrtc", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
{ name = "nvidia-cuda-nvrtc" },
|
||||||
]
|
]
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/a7/a1/0bd24ee8c8d03adac032fd2909426a00c88f8c57961b1277ded97f91119f/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b7a210458267ac818974c53038fbec2e969d5c99f305ab15c72522fa9f001dd5", size = 542848918, upload-time = "2026-04-08T18:46:22.985Z" },
|
{ url = "https://files.pythonhosted.org/packages/a7/a1/0bd24ee8c8d03adac032fd2909426a00c88f8c57961b1277ded97f91119f/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b7a210458267ac818974c53038fbec2e969d5c99f305ab15c72522fa9f001dd5", size = 542848918, upload-time = "2026-04-08T18:46:22.985Z" },
|
||||||
@@ -3277,7 +3286,7 @@ name = "nvidia-cudnn-cu13"
|
|||||||
version = "9.20.0.48"
|
version = "9.20.0.48"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "nvidia-cublas", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
{ name = "nvidia-cublas" },
|
||||||
]
|
]
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/56/c5/83384d846b2fd17c44bd499b36c75a45ed4f095fbbb2252294e89cea5c5c/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:e31454ae00094b0c55319d9d15b6fa2fc50a9e1c0f5c8c80fb75258234e731e1", size = 444574296, upload-time = "2026-03-09T19:28:27.751Z" },
|
{ url = "https://files.pythonhosted.org/packages/56/c5/83384d846b2fd17c44bd499b36c75a45ed4f095fbbb2252294e89cea5c5c/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:e31454ae00094b0c55319d9d15b6fa2fc50a9e1c0f5c8c80fb75258234e731e1", size = 444574296, upload-time = "2026-03-09T19:28:27.751Z" },
|
||||||
@@ -3289,7 +3298,7 @@ name = "nvidia-cufft"
|
|||||||
version = "12.0.0.61"
|
version = "12.0.0.61"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
{ name = "nvidia-nvjitlink" },
|
||||||
]
|
]
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" },
|
{ url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" },
|
||||||
@@ -3319,9 +3328,9 @@ name = "nvidia-cusolver"
|
|||||||
version = "12.0.4.66"
|
version = "12.0.4.66"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "nvidia-cublas", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
{ name = "nvidia-cublas" },
|
||||||
{ name = "nvidia-cusparse", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
{ name = "nvidia-cusparse" },
|
||||||
{ name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
{ name = "nvidia-nvjitlink" },
|
||||||
]
|
]
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" },
|
{ url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" },
|
||||||
@@ -3333,7 +3342,7 @@ name = "nvidia-cusparse"
|
|||||||
version = "12.6.3.3"
|
version = "12.6.3.3"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
{ name = "nvidia-nvjitlink" },
|
||||||
]
|
]
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" },
|
{ url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" },
|
||||||
@@ -5163,10 +5172,10 @@ name = "scikit-learn"
|
|||||||
version = "1.8.0"
|
version = "1.8.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "joblib", marker = "python_full_version >= '3.14'" },
|
{ name = "joblib" },
|
||||||
{ name = "numpy", marker = "python_full_version >= '3.14'" },
|
{ name = "numpy" },
|
||||||
{ name = "scipy", marker = "python_full_version >= '3.14'" },
|
{ name = "scipy" },
|
||||||
{ name = "threadpoolctl", marker = "python_full_version >= '3.14'" },
|
{ name = "threadpoolctl" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/0e/d4/40988bf3b8e34feec1d0e6a051446b1f66225f8529b9309becaeef62b6c4/scikit_learn-1.8.0.tar.gz", hash = "sha256:9bccbb3b40e3de10351f8f5068e105d0f4083b1a65fa07b6634fbc401a6287fd", size = 7335585, upload-time = "2025-12-10T07:08:53.618Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/0e/d4/40988bf3b8e34feec1d0e6a051446b1f66225f8529b9309becaeef62b6c4/scikit_learn-1.8.0.tar.gz", hash = "sha256:9bccbb3b40e3de10351f8f5068e105d0f4083b1a65fa07b6634fbc401a6287fd", size = 7335585, upload-time = "2025-12-10T07:08:53.618Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
@@ -5213,7 +5222,7 @@ name = "scipy"
|
|||||||
version = "1.17.1"
|
version = "1.17.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "numpy", marker = "python_full_version >= '3.14'" },
|
{ name = "numpy" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
@@ -5284,14 +5293,14 @@ name = "sentence-transformers"
|
|||||||
version = "5.2.3"
|
version = "5.2.3"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "huggingface-hub", marker = "python_full_version >= '3.14'" },
|
{ name = "huggingface-hub" },
|
||||||
{ name = "numpy", marker = "python_full_version >= '3.14'" },
|
{ name = "numpy" },
|
||||||
{ name = "scikit-learn", marker = "python_full_version >= '3.14'" },
|
{ name = "scikit-learn" },
|
||||||
{ name = "scipy", marker = "python_full_version >= '3.14'" },
|
{ name = "scipy" },
|
||||||
{ name = "torch", marker = "python_full_version >= '3.14'" },
|
{ name = "torch" },
|
||||||
{ name = "tqdm", marker = "python_full_version >= '3.14'" },
|
{ name = "tqdm" },
|
||||||
{ name = "transformers", marker = "python_full_version >= '3.14'" },
|
{ name = "transformers" },
|
||||||
{ name = "typing-extensions", marker = "python_full_version >= '3.14'" },
|
{ name = "typing-extensions" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/5b/30/21664028fc0776eb1ca024879480bbbab36f02923a8ff9e4cae5a150fa35/sentence_transformers-5.2.3.tar.gz", hash = "sha256:3cd3044e1f3fe859b6a1b66336aac502eaae5d3dd7d5c8fc237f37fbf58137c7", size = 381623, upload-time = "2026-02-17T14:05:20.238Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/5b/30/21664028fc0776eb1ca024879480bbbab36f02923a8ff9e4cae5a150fa35/sentence_transformers-5.2.3.tar.gz", hash = "sha256:3cd3044e1f3fe859b6a1b66336aac502eaae5d3dd7d5c8fc237f37fbf58137c7", size = 381623, upload-time = "2026-02-17T14:05:20.238Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
@@ -5664,21 +5673,21 @@ name = "torch"
|
|||||||
version = "2.12.1"
|
version = "2.12.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "cuda-bindings", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
|
{ name = "cuda-bindings", marker = "sys_platform == 'linux'" },
|
||||||
{ name = "cuda-toolkit", extra = ["cudart", "cufft", "cufile", "cupti", "curand", "cusolver", "cusparse", "nvjitlink", "nvrtc", "nvtx"], marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
|
{ name = "cuda-toolkit", extra = ["cudart", "cufft", "cufile", "cupti", "curand", "cusolver", "cusparse", "nvjitlink", "nvrtc", "nvtx"], marker = "sys_platform == 'linux'" },
|
||||||
{ name = "filelock", marker = "python_full_version >= '3.14'" },
|
{ name = "filelock" },
|
||||||
{ name = "fsspec", marker = "python_full_version >= '3.14'" },
|
{ name = "fsspec" },
|
||||||
{ name = "jinja2", marker = "python_full_version >= '3.14'" },
|
{ name = "jinja2" },
|
||||||
{ name = "networkx", marker = "python_full_version >= '3.14'" },
|
{ name = "networkx" },
|
||||||
{ name = "nvidia-cublas", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
|
{ name = "nvidia-cublas", marker = "sys_platform == 'linux'" },
|
||||||
{ name = "nvidia-cudnn-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
|
{ name = "nvidia-cudnn-cu13", marker = "sys_platform == 'linux'" },
|
||||||
{ name = "nvidia-cusparselt-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
|
{ name = "nvidia-cusparselt-cu13", marker = "sys_platform == 'linux'" },
|
||||||
{ name = "nvidia-nccl-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
|
{ name = "nvidia-nccl-cu13", marker = "sys_platform == 'linux'" },
|
||||||
{ name = "nvidia-nvshmem-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
|
{ name = "nvidia-nvshmem-cu13", marker = "sys_platform == 'linux'" },
|
||||||
{ name = "setuptools", marker = "python_full_version >= '3.14'" },
|
{ name = "setuptools" },
|
||||||
{ name = "sympy", marker = "python_full_version >= '3.14'" },
|
{ name = "sympy" },
|
||||||
{ name = "triton", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
|
{ name = "triton", marker = "sys_platform == 'linux'" },
|
||||||
{ name = "typing-extensions", marker = "python_full_version >= '3.14'" },
|
{ name = "typing-extensions" },
|
||||||
]
|
]
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/59/38/7028d3be540f1dcdf41660a2b01d0c51d2cb73915fe370d84e4d277a6d47/torch-2.12.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:ef81f503912effea2ce3d9b12a2e3a6ed488943e91271c90c7a829f60baf6aa2", size = 87975425, upload-time = "2026-06-17T21:08:34.094Z" },
|
{ url = "https://files.pythonhosted.org/packages/59/38/7028d3be540f1dcdf41660a2b01d0c51d2cb73915fe370d84e4d277a6d47/torch-2.12.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:ef81f503912effea2ce3d9b12a2e3a6ed488943e91271c90c7a829f60baf6aa2", size = 87975425, upload-time = "2026-06-17T21:08:34.094Z" },
|
||||||
@@ -5720,15 +5729,15 @@ name = "transformers"
|
|||||||
version = "5.3.0"
|
version = "5.3.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "huggingface-hub", marker = "python_full_version >= '3.14'" },
|
{ name = "huggingface-hub" },
|
||||||
{ name = "numpy", marker = "python_full_version >= '3.14'" },
|
{ name = "numpy" },
|
||||||
{ name = "packaging", marker = "python_full_version >= '3.14'" },
|
{ name = "packaging" },
|
||||||
{ name = "pyyaml", marker = "python_full_version >= '3.14'" },
|
{ name = "pyyaml" },
|
||||||
{ name = "regex", marker = "python_full_version >= '3.14'" },
|
{ name = "regex" },
|
||||||
{ name = "safetensors", marker = "python_full_version >= '3.14'" },
|
{ name = "safetensors" },
|
||||||
{ name = "tokenizers", marker = "python_full_version >= '3.14'" },
|
{ name = "tokenizers" },
|
||||||
{ name = "tqdm", marker = "python_full_version >= '3.14'" },
|
{ name = "tqdm" },
|
||||||
{ name = "typer", marker = "python_full_version >= '3.14'" },
|
{ name = "typer" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/fc/1a/70e830d53ecc96ce69cfa8de38f163712d2b43ac52fbd743f39f56025c31/transformers-5.3.0.tar.gz", hash = "sha256:009555b364029da9e2946d41f1c5de9f15e6b1df46b189b7293f33a161b9c557", size = 8830831, upload-time = "2026-03-04T17:41:46.119Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/fc/1a/70e830d53ecc96ce69cfa8de38f163712d2b43ac52fbd743f39f56025c31/transformers-5.3.0.tar.gz", hash = "sha256:009555b364029da9e2946d41f1c5de9f15e6b1df46b189b7293f33a161b9c557", size = 8830831, upload-time = "2026-03-04T17:41:46.119Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
@@ -5989,9 +5998,9 @@ name = "valkey-glide"
|
|||||||
version = "2.4.1"
|
version = "2.4.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "anyio", marker = "sys_platform != 'win32'" },
|
{ name = "anyio" },
|
||||||
{ name = "protobuf", marker = "sys_platform != 'win32'" },
|
{ name = "protobuf" },
|
||||||
{ name = "sniffio", marker = "sys_platform != 'win32'" },
|
{ name = "sniffio" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/72/a2/582b34c6acc8dc857c537f6007459cba48dfa0dc404789a657e5c1a998c0/valkey_glide-2.4.1.tar.gz", hash = "sha256:f1155d84156d11b90488aa67e90102f0bf98a45314f5b99308ac9074c05f7241", size = 898030, upload-time = "2026-05-28T21:41:55.881Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/72/a2/582b34c6acc8dc857c537f6007459cba48dfa0dc404789a657e5c1a998c0/valkey_glide-2.4.1.tar.gz", hash = "sha256:f1155d84156d11b90488aa67e90102f0bf98a45314f5b99308ac9074c05f7241", size = 898030, upload-time = "2026-05-28T21:41:55.881Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
|
|||||||
@@ -710,11 +710,32 @@ export class BackendClient extends BaseHttpClient {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getAuthenticatedObjectURL(path: string): Promise<string> {
|
private async getAuthenticatedObjectURL(
|
||||||
|
path: string,
|
||||||
|
rewritePluginPageSdk = false,
|
||||||
|
): Promise<string> {
|
||||||
const response = await this.instance.get<Blob>(path, {
|
const response = await this.instance.get<Blob>(path, {
|
||||||
responseType: 'blob',
|
responseType: 'blob',
|
||||||
});
|
});
|
||||||
return URL.createObjectURL(response.data);
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
public getAuthenticatedPluginAssetURL(
|
public getAuthenticatedPluginAssetURL(
|
||||||
@@ -724,6 +745,7 @@ export class BackendClient extends BaseHttpClient {
|
|||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
return this.getAuthenticatedObjectURL(
|
return this.getAuthenticatedObjectURL(
|
||||||
`/api/v1/plugins/${author}/${name}/authenticated-assets/${filepath}`,
|
`/api/v1/plugins/${author}/${name}/authenticated-assets/${filepath}`,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1181,6 +1203,7 @@ export class BackendClient extends BaseHttpClient {
|
|||||||
public getAccountInfo(): Promise<{
|
public getAccountInfo(): Promise<{
|
||||||
initialized: boolean;
|
initialized: boolean;
|
||||||
authenticated_invitation_acceptance_enabled?: boolean;
|
authenticated_invitation_acceptance_enabled?: boolean;
|
||||||
|
invitation_registration_enabled?: boolean;
|
||||||
password_login_enabled?: boolean;
|
password_login_enabled?: boolean;
|
||||||
space_login_enabled?: boolean;
|
space_login_enabled?: boolean;
|
||||||
}> {
|
}> {
|
||||||
|
|||||||
@@ -91,7 +91,9 @@ export default function AcceptInvitationPage() {
|
|||||||
const [errorMessage, setErrorMessage] = useState('');
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [confirmPassword, setConfirmPassword] = useState('');
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
const [passwordRegistrationEnabled, setPasswordRegistrationEnabled] =
|
const [invitationRegistrationEnabled, setInvitationRegistrationEnabled] =
|
||||||
|
useState(false);
|
||||||
|
const [invitationCapabilitiesLoaded, setInvitationCapabilitiesLoaded] =
|
||||||
useState(false);
|
useState(false);
|
||||||
const [
|
const [
|
||||||
authenticatedInvitationAcceptanceEnabled,
|
authenticatedInvitationAcceptanceEnabled,
|
||||||
@@ -116,12 +118,16 @@ export default function AcceptInvitationPage() {
|
|||||||
backendClient
|
backendClient
|
||||||
.getAccountInfo()
|
.getAccountInfo()
|
||||||
.then((info) => {
|
.then((info) => {
|
||||||
setPasswordRegistrationEnabled(info.password_login_enabled !== false);
|
setInvitationRegistrationEnabled(
|
||||||
|
info.invitation_registration_enabled ??
|
||||||
|
info.password_login_enabled !== false,
|
||||||
|
);
|
||||||
setAuthenticatedInvitationAcceptanceEnabled(
|
setAuthenticatedInvitationAcceptanceEnabled(
|
||||||
info.authenticated_invitation_acceptance_enabled === true,
|
info.authenticated_invitation_acceptance_enabled === true,
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.catch(() => setPasswordRegistrationEnabled(false));
|
.catch(() => setInvitationRegistrationEnabled(false))
|
||||||
|
.finally(() => setInvitationCapabilitiesLoaded(true));
|
||||||
if (!invitationToken) {
|
if (!invitationToken) {
|
||||||
setErrorMessage(t('workspace.invitationMissing'));
|
setErrorMessage(t('workspace.invitationMissing'));
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
@@ -311,7 +317,11 @@ export default function AcceptInvitationPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{hasLoginToken && authenticatedInvitationAcceptanceEnabled ? (
|
{!invitationCapabilitiesLoaded ? (
|
||||||
|
<div className="flex justify-center py-8">
|
||||||
|
<Loader2 className="size-6 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : hasLoginToken && authenticatedInvitationAcceptanceEnabled ? (
|
||||||
<Button
|
<Button
|
||||||
className="w-full"
|
className="w-full"
|
||||||
disabled={status === 'submitting'}
|
disabled={status === 'submitting'}
|
||||||
@@ -331,7 +341,7 @@ export default function AcceptInvitationPage() {
|
|||||||
{t('workspace.logoutAndReturn')}
|
{t('workspace.logoutAndReturn')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : passwordRegistrationEnabled ? (
|
) : invitationRegistrationEnabled ? (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label
|
<label
|
||||||
@@ -376,6 +386,8 @@ export default function AcceptInvitationPage() {
|
|||||||
>
|
>
|
||||||
{t('workspace.confirmPassword')}
|
{t('workspace.confirmPassword')}
|
||||||
</label>
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Lock className="absolute left-3 top-3 size-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
id="invite-password-confirm"
|
id="invite-password-confirm"
|
||||||
type="password"
|
type="password"
|
||||||
@@ -383,9 +395,11 @@ export default function AcceptInvitationPage() {
|
|||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setConfirmPassword(event.target.value)
|
setConfirmPassword(event.target.value)
|
||||||
}
|
}
|
||||||
|
className="pl-10"
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
className="w-full"
|
className="w-full"
|
||||||
disabled={status === 'submitting'}
|
disabled={status === 'submitting'}
|
||||||
@@ -396,14 +410,6 @@ export default function AcceptInvitationPage() {
|
|||||||
)}
|
)}
|
||||||
{t('workspace.registerAndAccept')}
|
{t('workspace.registerAndAccept')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="w-full"
|
|
||||||
disabled={status === 'submitting'}
|
|
||||||
onClick={() => navigate('/login?invitation=1')}
|
|
||||||
>
|
|
||||||
{t('workspace.alreadyHaveAccount')}
|
|
||||||
</Button>
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -85,18 +85,18 @@ const enUS = {
|
|||||||
'Recommended: Use official stable model APIs and cloud services',
|
'Recommended: Use official stable model APIs and cloud services',
|
||||||
loginLocal: 'Login with local account',
|
loginLocal: 'Login with local account',
|
||||||
loginWithPassword: 'Login with password',
|
loginWithPassword: 'Login with password',
|
||||||
spaceLoginTitle: 'Login with Space',
|
spaceLoginTitle: 'Login with LangBot Account',
|
||||||
spaceLoginDescription:
|
spaceLoginDescription:
|
||||||
'Scan the QR code or visit the link below to authorize',
|
'Scan the QR code or visit the link below to authorize',
|
||||||
spaceLoginUserCode: 'Your code',
|
spaceLoginUserCode: 'Your code',
|
||||||
spaceLoginExpires: 'Code expires in {{seconds}} seconds',
|
spaceLoginExpires: 'Code expires in {{seconds}} seconds',
|
||||||
spaceLoginWaiting: 'Waiting for authorization...',
|
spaceLoginWaiting: 'Waiting for authorization...',
|
||||||
spaceLoginSuccess: 'Authorization successful',
|
spaceLoginSuccess: 'Authorization successful',
|
||||||
spaceLoginFailed: 'Space login failed',
|
spaceLoginFailed: 'LangBot Account login failed',
|
||||||
spaceLoginExpired: 'Authorization code expired, please try again',
|
spaceLoginExpired: 'Authorization code expired, please try again',
|
||||||
spaceLoginCancel: 'Cancel',
|
spaceLoginCancel: 'Cancel',
|
||||||
spaceLoginVisitLink: 'Visit link',
|
spaceLoginVisitLink: 'Visit link',
|
||||||
spaceLoginProcessing: 'Logging in with Space',
|
spaceLoginProcessing: 'Logging in with LangBot Account',
|
||||||
spaceLoginProcessingDescription:
|
spaceLoginProcessingDescription:
|
||||||
'Please wait while we complete your login...',
|
'Please wait while we complete your login...',
|
||||||
spaceLoginSuccessDescription: 'Redirecting to LangBot...',
|
spaceLoginSuccessDescription: 'Redirecting to LangBot...',
|
||||||
@@ -105,7 +105,7 @@ const enUS = {
|
|||||||
backToLogin: 'Back to Login',
|
backToLogin: 'Back to Login',
|
||||||
backToHome: 'Back to Home',
|
backToHome: 'Back to Home',
|
||||||
spaceAccountCannotChangePassword:
|
spaceAccountCannotChangePassword:
|
||||||
'Space accounts cannot change password here',
|
'LangBot Accounts cannot change password here',
|
||||||
theme: 'Theme',
|
theme: 'Theme',
|
||||||
changePassword: 'Change Password',
|
changePassword: 'Change Password',
|
||||||
currentPassword: 'Current Password',
|
currentPassword: 'Current Password',
|
||||||
@@ -254,8 +254,9 @@ const enUS = {
|
|||||||
llmModels: 'LLM Models',
|
llmModels: 'LLM Models',
|
||||||
localProvider: 'Local',
|
localProvider: 'Local',
|
||||||
localProviderDescription: 'Models configured and managed locally',
|
localProviderDescription: 'Models configured and managed locally',
|
||||||
spaceProviderDescription: 'Models synced from your Space account',
|
spaceProviderDescription: 'Models synced from your LangBot Account',
|
||||||
spaceDisabledForLocalAccount: 'Login with Space to use cloud models',
|
spaceDisabledForLocalAccount:
|
||||||
|
'Login with LangBot Account to use cloud models',
|
||||||
syncModels: 'Sync',
|
syncModels: 'Sync',
|
||||||
syncSuccess: 'Sync complete: {{created}} created, {{updated}} updated',
|
syncSuccess: 'Sync complete: {{created}} created, {{updated}} updated',
|
||||||
syncError: 'Sync failed: ',
|
syncError: 'Sync failed: ',
|
||||||
@@ -291,15 +292,15 @@ const enUS = {
|
|||||||
langbotModelsDescription: 'Cloud models powered by LangBot Space',
|
langbotModelsDescription: 'Cloud models powered by LangBot Space',
|
||||||
credits: 'Credits',
|
credits: 'Credits',
|
||||||
loginWithSpace: 'Login with LangBot Account',
|
loginWithSpace: 'Login with LangBot Account',
|
||||||
loginToUseModels: 'Login with Space to use cloud models',
|
loginToUseModels: 'Login with LangBot Account to use cloud models',
|
||||||
ownerMustBindSpace:
|
ownerMustBindSpace:
|
||||||
'The Workspace owner must connect Space for LangBot Models.',
|
'The Workspace owner must connect a LangBot Account for LangBot Models.',
|
||||||
usesOwnerSpaceBilling:
|
usesOwnerSpaceBilling:
|
||||||
"Uses the Workspace owner's Space billing and credits.",
|
"Uses the Workspace owner's LangBot Account billing and credits.",
|
||||||
noModels: 'No models configured',
|
noModels: 'No models configured',
|
||||||
langbotModels: 'LangBot Models',
|
langbotModels: 'LangBot Models',
|
||||||
spaceTrialTooltip:
|
spaceTrialTooltip:
|
||||||
'Free trial credits available! Login with Space to access cloud models with zero configuration.',
|
'Free trial credits available! Login with LangBot Account to access cloud models with zero configuration.',
|
||||||
unlockModels: 'Login to use',
|
unlockModels: 'Login to use',
|
||||||
editProvider: 'Edit Provider',
|
editProvider: 'Edit Provider',
|
||||||
addProvider: 'Add Provider',
|
addProvider: 'Add Provider',
|
||||||
@@ -1218,13 +1219,13 @@ const enUS = {
|
|||||||
adminAccountNote:
|
adminAccountNote:
|
||||||
'The account you use here will be set as the administrator account',
|
'The account you use here will be set as the administrator account',
|
||||||
register: 'Register',
|
register: 'Register',
|
||||||
initWithSpace: 'Initialize with Space',
|
initWithSpace: 'Initialize with LangBot Account',
|
||||||
spaceRecommended:
|
spaceRecommended:
|
||||||
'Recommended: Use official stable model APIs and cloud services',
|
'Recommended: Use official stable model APIs and cloud services',
|
||||||
spaceInfoTip1:
|
spaceInfoTip1:
|
||||||
'Space provides unified account authentication services without uploading any of your sensitive information.',
|
'Space provides unified account authentication services without uploading any of your sensitive information.',
|
||||||
spaceInfoTip2:
|
spaceInfoTip2:
|
||||||
'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.',
|
'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.',
|
||||||
spaceInfoTip3:
|
spaceInfoTip3:
|
||||||
'Your login method does not affect other features. You can configure and use models from other sources at any time.',
|
'Your login method does not affect other features. You can configure and use models from other sources at any time.',
|
||||||
registerLocal: 'Register local account',
|
registerLocal: 'Register local account',
|
||||||
@@ -1281,32 +1282,32 @@ const enUS = {
|
|||||||
passwordNotSet: 'Not Set',
|
passwordNotSet: 'Not Set',
|
||||||
passwordSetDescription:
|
passwordSetDescription:
|
||||||
'Password is set, you can login with email and password',
|
'Password is set, you can login with email and password',
|
||||||
spaceStatus: 'Space Account',
|
spaceStatus: 'LangBot Account',
|
||||||
spaceBound: 'Bound',
|
spaceBound: 'Bound',
|
||||||
spaceNotBound: 'Not Bound',
|
spaceNotBound: 'Not Bound',
|
||||||
spaceBoundDescription:
|
spaceBoundDescription:
|
||||||
'Space account bound, official model APIs and cloud services available',
|
'LangBot Account bound, official model APIs and cloud services available',
|
||||||
bindSpace: 'Bind Space Account',
|
bindSpace: 'Bind LangBot Account',
|
||||||
bindSpaceDescription: 'Bind to use official model APIs and cloud services',
|
bindSpaceDescription: 'Bind to use official model APIs and cloud services',
|
||||||
bindSpaceButton: 'Bind',
|
bindSpaceButton: 'Bind',
|
||||||
bindSpaceConfirmTitle: 'Confirm Binding',
|
bindSpaceConfirmTitle: 'Confirm Binding',
|
||||||
bindSpaceConfirmDescription:
|
bindSpaceConfirmDescription:
|
||||||
'You are about to bind your local instance to a Space account',
|
'You are about to bind your local instance to a LangBot Account',
|
||||||
bindSpaceWarning:
|
bindSpaceWarning:
|
||||||
'After binding, your login email will be changed from {{localEmail}} to the Space account email.',
|
'After binding, your login email will be changed from {{localEmail}} to the LangBot Account email.',
|
||||||
bindSpaceSuccess: 'Space account bound successfully',
|
bindSpaceSuccess: 'LangBot Account bound successfully',
|
||||||
bindSpaceFailed: 'Failed to bind Space account',
|
bindSpaceFailed: 'Failed to bind LangBot Account',
|
||||||
bindSpaceInvalidState:
|
bindSpaceInvalidState:
|
||||||
'Invalid bind request. Please try again from account settings.',
|
'Invalid bind request. Please try again from account settings.',
|
||||||
setPasswordHint: 'Set a password to login with email and password',
|
setPasswordHint: 'Set a password to login with email and password',
|
||||||
spaceEmailMismatch:
|
spaceEmailMismatch:
|
||||||
'The Space login email does not match the local account email.',
|
'The LangBot Account login email does not match the local account email.',
|
||||||
space_account_not_registeredTitle: 'Account not registered',
|
space_account_not_registeredTitle: 'Account not registered',
|
||||||
space_account_not_registered:
|
space_account_not_registered:
|
||||||
'No local account is registered for this Space email. Ask the Workspace owner for an invitation.',
|
'No local account is registered for this LangBot Account email. Ask the Workspace owner for an invitation.',
|
||||||
space_account_binding_requiredTitle: 'Space connection required',
|
space_account_binding_requiredTitle: 'LangBot Account connection required',
|
||||||
space_account_binding_required:
|
space_account_binding_required:
|
||||||
'This local account must connect Space from Account settings before using Space login.',
|
'This local account must connect a LangBot Account from Account settings before using LangBot Account login.',
|
||||||
},
|
},
|
||||||
workspace: {
|
workspace: {
|
||||||
title: 'Workspace',
|
title: 'Workspace',
|
||||||
|
|||||||
@@ -88,19 +88,19 @@ const esES = {
|
|||||||
'Recomendado: Usa API de modelos oficiales estables y servicios en la nube',
|
'Recomendado: Usa API de modelos oficiales estables y servicios en la nube',
|
||||||
loginLocal: 'Iniciar sesión con cuenta local',
|
loginLocal: 'Iniciar sesión con cuenta local',
|
||||||
loginWithPassword: 'Iniciar sesión con contraseña',
|
loginWithPassword: 'Iniciar sesión con contraseña',
|
||||||
spaceLoginTitle: 'Iniciar sesión con Space',
|
spaceLoginTitle: 'Iniciar sesión con una cuenta de LangBot',
|
||||||
spaceLoginDescription:
|
spaceLoginDescription:
|
||||||
'Escanea el código QR o visita el enlace para autorizar',
|
'Escanea el código QR o visita el enlace para autorizar',
|
||||||
spaceLoginUserCode: 'Tu código',
|
spaceLoginUserCode: 'Tu código',
|
||||||
spaceLoginExpires: 'El código expira en {{seconds}} segundos',
|
spaceLoginExpires: 'El código expira en {{seconds}} segundos',
|
||||||
spaceLoginWaiting: 'Esperando autorización...',
|
spaceLoginWaiting: 'Esperando autorización...',
|
||||||
spaceLoginSuccess: 'Autorización exitosa',
|
spaceLoginSuccess: 'Autorización exitosa',
|
||||||
spaceLoginFailed: 'Error de inicio de sesión con Space',
|
spaceLoginFailed: 'Error de inicio de sesión con una cuenta de LangBot',
|
||||||
spaceLoginExpired:
|
spaceLoginExpired:
|
||||||
'El código de autorización ha expirado, por favor inténtalo de nuevo',
|
'El código de autorización ha expirado, por favor inténtalo de nuevo',
|
||||||
spaceLoginCancel: 'Cancelar',
|
spaceLoginCancel: 'Cancelar',
|
||||||
spaceLoginVisitLink: 'Visitar enlace',
|
spaceLoginVisitLink: 'Visitar enlace',
|
||||||
spaceLoginProcessing: 'Iniciando sesión con Space',
|
spaceLoginProcessing: 'Iniciando sesión con una cuenta de LangBot',
|
||||||
spaceLoginProcessingDescription:
|
spaceLoginProcessingDescription:
|
||||||
'Por favor espera mientras completamos tu inicio de sesión...',
|
'Por favor espera mientras completamos tu inicio de sesión...',
|
||||||
spaceLoginSuccessDescription: 'Redirigiendo a LangBot...',
|
spaceLoginSuccessDescription: 'Redirigiendo a LangBot...',
|
||||||
@@ -109,7 +109,7 @@ const esES = {
|
|||||||
backToLogin: 'Volver al inicio de sesión',
|
backToLogin: 'Volver al inicio de sesión',
|
||||||
backToHome: 'Volver al inicio',
|
backToHome: 'Volver al inicio',
|
||||||
spaceAccountCannotChangePassword:
|
spaceAccountCannotChangePassword:
|
||||||
'Las cuentas de Space no pueden cambiar la contraseña aquí',
|
'Las cuentas de LangBot no pueden cambiar la contraseña aquí',
|
||||||
theme: 'Tema',
|
theme: 'Tema',
|
||||||
changePassword: 'Cambiar contraseña',
|
changePassword: 'Cambiar contraseña',
|
||||||
currentPassword: 'Contraseña actual',
|
currentPassword: 'Contraseña actual',
|
||||||
@@ -261,9 +261,10 @@ const esES = {
|
|||||||
llmModels: 'Modelos LLM',
|
llmModels: 'Modelos LLM',
|
||||||
localProvider: 'Local',
|
localProvider: 'Local',
|
||||||
localProviderDescription: 'Modelos configurados y gestionados localmente',
|
localProviderDescription: 'Modelos configurados y gestionados localmente',
|
||||||
spaceProviderDescription: 'Modelos sincronizados desde tu cuenta de Space',
|
spaceProviderDescription:
|
||||||
|
'Modelos sincronizados desde tu cuenta de LangBot',
|
||||||
spaceDisabledForLocalAccount:
|
spaceDisabledForLocalAccount:
|
||||||
'Inicia sesión con Space para usar modelos en la nube',
|
'Inicia sesión con una cuenta de LangBot para usar modelos en la nube',
|
||||||
syncModels: 'Sincronizar',
|
syncModels: 'Sincronizar',
|
||||||
syncSuccess:
|
syncSuccess:
|
||||||
'Sincronización completa: {{created}} creados, {{updated}} actualizados',
|
'Sincronización completa: {{created}} creados, {{updated}} actualizados',
|
||||||
@@ -302,11 +303,12 @@ const esES = {
|
|||||||
langbotModelsDescription: 'Modelos en la nube impulsados por LangBot Space',
|
langbotModelsDescription: 'Modelos en la nube impulsados por LangBot Space',
|
||||||
credits: 'Créditos',
|
credits: 'Créditos',
|
||||||
loginWithSpace: 'Iniciar sesión con una cuenta de LangBot',
|
loginWithSpace: 'Iniciar sesión con una cuenta de LangBot',
|
||||||
loginToUseModels: 'Inicia sesión con Space para usar modelos en la nube',
|
loginToUseModels:
|
||||||
|
'Inicia sesión con una cuenta de LangBot para usar modelos en la nube',
|
||||||
noModels: 'No hay modelos configurados',
|
noModels: 'No hay modelos configurados',
|
||||||
langbotModels: 'Modelos LangBot',
|
langbotModels: 'Modelos LangBot',
|
||||||
spaceTrialTooltip:
|
spaceTrialTooltip:
|
||||||
'¡Créditos de prueba gratuitos disponibles! Inicia sesión con Space para acceder a modelos en la nube sin configuración.',
|
'¡Créditos de prueba gratuitos disponibles! Inicia sesión con una cuenta de LangBot para acceder a modelos en la nube sin configuración.',
|
||||||
unlockModels: 'Inicia sesión para usar',
|
unlockModels: 'Inicia sesión para usar',
|
||||||
editProvider: 'Editar proveedor',
|
editProvider: 'Editar proveedor',
|
||||||
addProvider: 'Añadir proveedor',
|
addProvider: 'Añadir proveedor',
|
||||||
@@ -341,9 +343,9 @@ const esES = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
ownerMustBindSpace:
|
ownerMustBindSpace:
|
||||||
'The Workspace owner must connect Space for LangBot Models.',
|
'The Workspace owner must connect a LangBot Account for LangBot Models.',
|
||||||
usesOwnerSpaceBilling:
|
usesOwnerSpaceBilling:
|
||||||
"Uses the Workspace owner's Space billing and credits.",
|
"Uses the Workspace owner's LangBot Account billing and credits.",
|
||||||
},
|
},
|
||||||
bots: {
|
bots: {
|
||||||
title: 'Bots',
|
title: 'Bots',
|
||||||
@@ -1251,13 +1253,13 @@ const esES = {
|
|||||||
adminAccountNote:
|
adminAccountNote:
|
||||||
'La cuenta que uses aquí se establecerá como cuenta de administrador',
|
'La cuenta que uses aquí se establecerá como cuenta de administrador',
|
||||||
register: 'Registrarse',
|
register: 'Registrarse',
|
||||||
initWithSpace: 'Inicializar con Space',
|
initWithSpace: 'Inicializar con una cuenta de LangBot',
|
||||||
spaceRecommended:
|
spaceRecommended:
|
||||||
'Recomendado: Usa API de modelos oficiales estables y servicios en la nube',
|
'Recomendado: Usa API de modelos oficiales estables y servicios en la nube',
|
||||||
spaceInfoTip1:
|
spaceInfoTip1:
|
||||||
'Space proporciona servicios de autenticación unificada de cuentas sin subir ninguna de tu información sensible.',
|
'Space proporciona servicios de autenticación unificada de cuentas sin subir ninguna de tu información sensible.',
|
||||||
spaceInfoTip2:
|
spaceInfoTip2:
|
||||||
'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.',
|
'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.',
|
||||||
spaceInfoTip3:
|
spaceInfoTip3:
|
||||||
'Tu método de inicio de sesión no afecta otras funciones. Puedes configurar y usar modelos de otras fuentes en cualquier momento.',
|
'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',
|
registerLocal: 'Registrar cuenta local',
|
||||||
@@ -1315,35 +1317,35 @@ const esES = {
|
|||||||
passwordNotSet: 'No establecida',
|
passwordNotSet: 'No establecida',
|
||||||
passwordSetDescription:
|
passwordSetDescription:
|
||||||
'La contraseña está establecida, puedes iniciar sesión con correo y contraseña',
|
'La contraseña está establecida, puedes iniciar sesión con correo y contraseña',
|
||||||
spaceStatus: 'Cuenta de Space',
|
spaceStatus: 'Cuenta de LangBot',
|
||||||
spaceBound: 'Vinculada',
|
spaceBound: 'Vinculada',
|
||||||
spaceNotBound: 'No vinculada',
|
spaceNotBound: 'No vinculada',
|
||||||
spaceBoundDescription:
|
spaceBoundDescription:
|
||||||
'Cuenta de Space vinculada, API de modelos oficiales y servicios en la nube disponibles',
|
'Cuenta de LangBot vinculada, API de modelos oficiales y servicios en la nube disponibles',
|
||||||
bindSpace: 'Vincular cuenta de Space',
|
bindSpace: 'Vincular cuenta de LangBot',
|
||||||
bindSpaceDescription:
|
bindSpaceDescription:
|
||||||
'Vincular para usar API de modelos oficiales y servicios en la nube',
|
'Vincular para usar API de modelos oficiales y servicios en la nube',
|
||||||
bindSpaceButton: 'Vincular',
|
bindSpaceButton: 'Vincular',
|
||||||
bindSpaceConfirmTitle: 'Confirmar vinculación',
|
bindSpaceConfirmTitle: 'Confirmar vinculación',
|
||||||
bindSpaceConfirmDescription:
|
bindSpaceConfirmDescription:
|
||||||
'Estás a punto de vincular tu instancia local a una cuenta de Space',
|
'Estás a punto de vincular tu instancia local a una cuenta de LangBot',
|
||||||
bindSpaceWarning:
|
bindSpaceWarning:
|
||||||
'Después de vincular, tu correo de inicio de sesión se cambiará de {{localEmail}} al correo de la cuenta de Space.',
|
'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 Space vinculada correctamente',
|
bindSpaceSuccess: 'Cuenta de LangBot vinculada correctamente',
|
||||||
bindSpaceFailed: 'Error al vincular la cuenta de Space',
|
bindSpaceFailed: 'Error al vincular la cuenta de LangBot',
|
||||||
bindSpaceInvalidState:
|
bindSpaceInvalidState:
|
||||||
'Solicitud de vinculación no válida. Por favor, inténtalo de nuevo desde la configuración de la cuenta.',
|
'Solicitud de vinculación no válida. Por favor, inténtalo de nuevo desde la configuración de la cuenta.',
|
||||||
setPasswordHint:
|
setPasswordHint:
|
||||||
'Establece una contraseña para iniciar sesión con correo y contraseña',
|
'Establece una contraseña para iniciar sesión con correo y contraseña',
|
||||||
spaceEmailMismatch:
|
spaceEmailMismatch:
|
||||||
'El correo de inicio de sesión de Space no coincide con el correo de la cuenta local',
|
'El correo de la cuenta de LangBot no coincide con el correo de la cuenta local',
|
||||||
|
|
||||||
space_account_not_registeredTitle: 'Account not registered',
|
space_account_not_registeredTitle: 'Account not registered',
|
||||||
space_account_not_registered:
|
space_account_not_registered:
|
||||||
'No local account is registered for this Space email. Ask the Workspace owner for an invitation.',
|
'No local account is registered for this LangBot Account email. Ask the Workspace owner for an invitation.',
|
||||||
space_account_binding_requiredTitle: 'Space connection required',
|
space_account_binding_requiredTitle: 'LangBot Account connection required',
|
||||||
space_account_binding_required:
|
space_account_binding_required:
|
||||||
'This local account must connect Space from Account settings before using Space login.',
|
'This local account must connect a LangBot Account from Account settings before using LangBot Account login.',
|
||||||
},
|
},
|
||||||
monitoring: {
|
monitoring: {
|
||||||
title: 'Panel de control',
|
title: 'Panel de control',
|
||||||
|
|||||||
@@ -86,19 +86,19 @@ const jaJP = {
|
|||||||
'おすすめ:公式の安定したモデル API とクラウドサービスを利用',
|
'おすすめ:公式の安定したモデル API とクラウドサービスを利用',
|
||||||
loginLocal: 'ローカルアカウントでログイン',
|
loginLocal: 'ローカルアカウントでログイン',
|
||||||
loginWithPassword: 'パスワードでログイン',
|
loginWithPassword: 'パスワードでログイン',
|
||||||
spaceLoginTitle: 'Space でログイン',
|
spaceLoginTitle: 'LangBot アカウントでログイン',
|
||||||
spaceLoginDescription:
|
spaceLoginDescription:
|
||||||
'QRコードをスキャンするか、下のリンクにアクセスして認証してください',
|
'QRコードをスキャンするか、下のリンクにアクセスして認証してください',
|
||||||
spaceLoginUserCode: '認証コード',
|
spaceLoginUserCode: '認証コード',
|
||||||
spaceLoginExpires: 'コードは {{seconds}} 秒後に期限切れになります',
|
spaceLoginExpires: 'コードは {{seconds}} 秒後に期限切れになります',
|
||||||
spaceLoginWaiting: '認証を待っています...',
|
spaceLoginWaiting: '認証を待っています...',
|
||||||
spaceLoginSuccess: '認証に成功しました',
|
spaceLoginSuccess: '認証に成功しました',
|
||||||
spaceLoginFailed: 'Space ログインに失敗しました',
|
spaceLoginFailed: 'LangBot アカウントログインに失敗しました',
|
||||||
spaceLoginExpired:
|
spaceLoginExpired:
|
||||||
'認証コードの有効期限が切れました。もう一度お試しください',
|
'認証コードの有効期限が切れました。もう一度お試しください',
|
||||||
spaceLoginCancel: 'キャンセル',
|
spaceLoginCancel: 'キャンセル',
|
||||||
spaceLoginVisitLink: 'リンクにアクセス',
|
spaceLoginVisitLink: 'リンクにアクセス',
|
||||||
spaceLoginProcessing: 'Space でログイン中',
|
spaceLoginProcessing: 'LangBot アカウントでログイン中',
|
||||||
spaceLoginProcessingDescription:
|
spaceLoginProcessingDescription:
|
||||||
'ログインを完了しています。しばらくお待ちください...',
|
'ログインを完了しています。しばらくお待ちください...',
|
||||||
spaceLoginSuccessDescription: 'LangBot にリダイレクト中...',
|
spaceLoginSuccessDescription: 'LangBot にリダイレクト中...',
|
||||||
@@ -107,7 +107,7 @@ const jaJP = {
|
|||||||
backToLogin: 'ログインに戻る',
|
backToLogin: 'ログインに戻る',
|
||||||
backToHome: 'ホームに戻る',
|
backToHome: 'ホームに戻る',
|
||||||
spaceAccountCannotChangePassword:
|
spaceAccountCannotChangePassword:
|
||||||
'Space アカウントはここでパスワードを変更できません',
|
'LangBot アカウントはここでパスワードを変更できません',
|
||||||
theme: 'テーマ',
|
theme: 'テーマ',
|
||||||
changePassword: 'パスワードを変更',
|
changePassword: 'パスワードを変更',
|
||||||
currentPassword: '現在のパスワード',
|
currentPassword: '現在のパスワード',
|
||||||
@@ -257,8 +257,9 @@ const jaJP = {
|
|||||||
llmModels: 'LLM モデル',
|
llmModels: 'LLM モデル',
|
||||||
localProvider: 'ローカル',
|
localProvider: 'ローカル',
|
||||||
localProviderDescription: 'ローカルで設定・管理されているモデル',
|
localProviderDescription: 'ローカルで設定・管理されているモデル',
|
||||||
spaceProviderDescription: 'Space アカウントから同期されたモデル',
|
spaceProviderDescription: 'LangBot アカウントから同期されたモデル',
|
||||||
spaceDisabledForLocalAccount: 'Space でログインしてクラウドモデルを使用',
|
spaceDisabledForLocalAccount:
|
||||||
|
'LangBot アカウントでログインしてクラウドモデルを使用',
|
||||||
syncModels: '同期',
|
syncModels: '同期',
|
||||||
syncSuccess: '同期完了:{{created}} 件作成、{{updated}} 件更新',
|
syncSuccess: '同期完了:{{created}} 件作成、{{updated}} 件更新',
|
||||||
syncError: '同期に失敗しました:',
|
syncError: '同期に失敗しました:',
|
||||||
@@ -296,15 +297,15 @@ const jaJP = {
|
|||||||
langbotModelsDescription: 'LangBot Space が提供するクラウドモデル',
|
langbotModelsDescription: 'LangBot Space が提供するクラウドモデル',
|
||||||
credits: 'クレジット',
|
credits: 'クレジット',
|
||||||
loginWithSpace: 'LangBot アカウントでログイン',
|
loginWithSpace: 'LangBot アカウントでログイン',
|
||||||
loginToUseModels: 'Space でログインしてクラウドモデルを使用',
|
loginToUseModels: 'LangBot アカウントでログインしてクラウドモデルを使用',
|
||||||
ownerMustBindSpace:
|
ownerMustBindSpace:
|
||||||
'LangBot モデルを使うにはワークスペース所有者が Space を連携する必要があります。',
|
'LangBot モデルを使うにはワークスペース所有者が LangBot アカウントを連携する必要があります。',
|
||||||
usesOwnerSpaceBilling:
|
usesOwnerSpaceBilling:
|
||||||
'ワークスペース所有者の Space 課金とクレジットを使用します。',
|
'ワークスペース所有者の LangBot アカウント課金とクレジットを使用します。',
|
||||||
noModels: 'モデルがありません',
|
noModels: 'モデルがありません',
|
||||||
langbotModels: 'LangBot モデル',
|
langbotModels: 'LangBot モデル',
|
||||||
spaceTrialTooltip:
|
spaceTrialTooltip:
|
||||||
'無料トライアルクレジットが利用可能!Space でログインして、設定不要でクラウドモデルを使用できます。',
|
'無料トライアルクレジットが利用可能!LangBot アカウントでログインして、設定不要でクラウドモデルを使用できます。',
|
||||||
unlockModels: 'ログインして使用',
|
unlockModels: 'ログインして使用',
|
||||||
editProvider: 'プロバイダーを編集',
|
editProvider: 'プロバイダーを編集',
|
||||||
addProvider: 'プロバイダーを追加',
|
addProvider: 'プロバイダーを追加',
|
||||||
@@ -1223,13 +1224,13 @@ const jaJP = {
|
|||||||
adminAccountNote:
|
adminAccountNote:
|
||||||
'ここで初期化されたアカウントは管理者アカウントとして使用されます',
|
'ここで初期化されたアカウントは管理者アカウントとして使用されます',
|
||||||
register: '登録',
|
register: '登録',
|
||||||
initWithSpace: 'Space で初期化',
|
initWithSpace: 'LangBot アカウントで初期化',
|
||||||
spaceRecommended:
|
spaceRecommended:
|
||||||
'おすすめ:公式の安定したモデル API とクラウドサービスを利用',
|
'おすすめ:公式の安定したモデル API とクラウドサービスを利用',
|
||||||
spaceInfoTip1:
|
spaceInfoTip1:
|
||||||
'Space は統一されたアカウント認証サービスを提供し、機密情報をアップロードすることはありません。',
|
'Space は統一されたアカウント認証サービスを提供し、機密情報をアップロードすることはありません。',
|
||||||
spaceInfoTip2:
|
spaceInfoTip2:
|
||||||
'Space アカウントでログインすると、LangBot Models などのクラウドサービスを利用でき、無料のモデル呼び出しクレジットで迅速に開始できます。',
|
'LangBot アカウントでログインすると、LangBot Models などのクラウドサービスを利用でき、無料のモデル呼び出しクレジットで迅速に開始できます。',
|
||||||
spaceInfoTip3:
|
spaceInfoTip3:
|
||||||
'ログイン方法は他の機能に影響しません。いつでも他のソースからモデルを設定して使用できます。',
|
'ログイン方法は他の機能に影響しません。いつでも他のソースからモデルを設定して使用できます。',
|
||||||
registerLocal: 'ローカルアカウントを登録',
|
registerLocal: 'ローカルアカウントを登録',
|
||||||
@@ -1286,33 +1287,33 @@ const jaJP = {
|
|||||||
passwordNotSet: '未設定',
|
passwordNotSet: '未設定',
|
||||||
passwordSetDescription:
|
passwordSetDescription:
|
||||||
'パスワードが設定されています。メールとパスワードでログインできます',
|
'パスワードが設定されています。メールとパスワードでログインできます',
|
||||||
spaceStatus: 'Space アカウント',
|
spaceStatus: 'LangBot アカウント',
|
||||||
spaceBound: '連携済み',
|
spaceBound: '連携済み',
|
||||||
spaceNotBound: '未連携',
|
spaceNotBound: '未連携',
|
||||||
spaceBoundDescription:
|
spaceBoundDescription:
|
||||||
'Space アカウントと連携済み、公式モデル API とクラウドサービスが利用可能',
|
'LangBot アカウントと連携済み、公式モデル API とクラウドサービスが利用可能',
|
||||||
bindSpace: 'Space アカウントを連携',
|
bindSpace: 'LangBot アカウントを連携',
|
||||||
bindSpaceDescription: '連携して公式モデル API とクラウドサービスを利用',
|
bindSpaceDescription: '連携して公式モデル API とクラウドサービスを利用',
|
||||||
bindSpaceButton: '連携',
|
bindSpaceButton: '連携',
|
||||||
bindSpaceConfirmTitle: '連携を確認',
|
bindSpaceConfirmTitle: '連携を確認',
|
||||||
bindSpaceConfirmDescription:
|
bindSpaceConfirmDescription:
|
||||||
'ローカルインスタンスを Space アカウントに連携しようとしています',
|
'ローカルインスタンスを LangBot アカウントに連携しようとしています',
|
||||||
bindSpaceWarning:
|
bindSpaceWarning:
|
||||||
'連携後、ログインメールアドレスは {{localEmail}} から Space アカウントのメールアドレスに変更されます。',
|
'連携後、ログインメールアドレスは {{localEmail}} から LangBot アカウントのメールアドレスに変更されます。',
|
||||||
bindSpaceSuccess: 'Space アカウントの連携に成功しました',
|
bindSpaceSuccess: 'LangBot アカウントの連携に成功しました',
|
||||||
bindSpaceFailed: 'Space アカウントの連携に失敗しました',
|
bindSpaceFailed: 'LangBot アカウントの連携に失敗しました',
|
||||||
bindSpaceInvalidState:
|
bindSpaceInvalidState:
|
||||||
'無効な連携リクエストです。アカウント設定から再度お試しください。',
|
'無効な連携リクエストです。アカウント設定から再度お試しください。',
|
||||||
setPasswordHint:
|
setPasswordHint:
|
||||||
'パスワードを設定するとメールとパスワードでログインできます',
|
'パスワードを設定するとメールとパスワードでログインできます',
|
||||||
spaceEmailMismatch:
|
spaceEmailMismatch:
|
||||||
'Spaceログインのメールアドレスがローカルアカウントのメールアドレスと一致しません',
|
'LangBot アカウントのメールアドレスがローカルアカウントのメールアドレスと一致しません',
|
||||||
space_account_not_registeredTitle: 'アカウントが登録されていません',
|
space_account_not_registeredTitle: 'アカウントが登録されていません',
|
||||||
space_account_not_registered:
|
space_account_not_registered:
|
||||||
'この Space メールアドレスのローカルアカウントはありません。ワークスペース所有者に招待を依頼してください。',
|
'この LangBot アカウントのメールアドレスのローカルアカウントはありません。ワークスペース所有者に招待を依頼してください。',
|
||||||
space_account_binding_requiredTitle: 'Space の連携が必要です',
|
space_account_binding_requiredTitle: 'LangBot アカウントの連携が必要です',
|
||||||
space_account_binding_required:
|
space_account_binding_required:
|
||||||
'Space ログインを使用する前に、アカウント設定でこのローカルアカウントを Space に連携してください。',
|
'LangBot アカウントログインを使用する前に、アカウント設定でこのローカルアカウントを LangBot アカウントに連携してください。',
|
||||||
},
|
},
|
||||||
workspace: {
|
workspace: {
|
||||||
title: 'ワークスペース',
|
title: 'ワークスペース',
|
||||||
|
|||||||
@@ -85,18 +85,18 @@ const ruRU = {
|
|||||||
'Рекомендуется: Используйте официальные стабильные API моделей и облачные сервисы',
|
'Рекомендуется: Используйте официальные стабильные API моделей и облачные сервисы',
|
||||||
loginLocal: 'Войти с локальной учётной записью',
|
loginLocal: 'Войти с локальной учётной записью',
|
||||||
loginWithPassword: 'Войти с паролем',
|
loginWithPassword: 'Войти с паролем',
|
||||||
spaceLoginTitle: 'Войти через Space',
|
spaceLoginTitle: 'Войти с аккаунтом LangBot',
|
||||||
spaceLoginDescription:
|
spaceLoginDescription:
|
||||||
'Отсканируйте QR-код или перейдите по ссылке ниже для авторизации',
|
'Отсканируйте QR-код или перейдите по ссылке ниже для авторизации',
|
||||||
spaceLoginUserCode: 'Ваш код',
|
spaceLoginUserCode: 'Ваш код',
|
||||||
spaceLoginExpires: 'Код истекает через {{seconds}} секунд',
|
spaceLoginExpires: 'Код истекает через {{seconds}} секунд',
|
||||||
spaceLoginWaiting: 'Ожидание авторизации...',
|
spaceLoginWaiting: 'Ожидание авторизации...',
|
||||||
spaceLoginSuccess: 'Авторизация успешна',
|
spaceLoginSuccess: 'Авторизация успешна',
|
||||||
spaceLoginFailed: 'Ошибка входа через Space',
|
spaceLoginFailed: 'Ошибка входа с аккаунтом LangBot',
|
||||||
spaceLoginExpired: 'Код авторизации истёк, попробуйте снова',
|
spaceLoginExpired: 'Код авторизации истёк, попробуйте снова',
|
||||||
spaceLoginCancel: 'Отмена',
|
spaceLoginCancel: 'Отмена',
|
||||||
spaceLoginVisitLink: 'Перейти по ссылке',
|
spaceLoginVisitLink: 'Перейти по ссылке',
|
||||||
spaceLoginProcessing: 'Вход через Space',
|
spaceLoginProcessing: 'Вход с аккаунтом LangBot',
|
||||||
spaceLoginProcessingDescription:
|
spaceLoginProcessingDescription:
|
||||||
'Пожалуйста, подождите, пока мы завершим вход...',
|
'Пожалуйста, подождите, пока мы завершим вход...',
|
||||||
spaceLoginSuccessDescription: 'Перенаправление в LangBot...',
|
spaceLoginSuccessDescription: 'Перенаправление в LangBot...',
|
||||||
@@ -105,7 +105,7 @@ const ruRU = {
|
|||||||
backToLogin: 'Вернуться к входу',
|
backToLogin: 'Вернуться к входу',
|
||||||
backToHome: 'На главную',
|
backToHome: 'На главную',
|
||||||
spaceAccountCannotChangePassword:
|
spaceAccountCannotChangePassword:
|
||||||
'Для аккаунтов Space невозможно изменить пароль здесь',
|
'Для аккаунтов LangBot невозможно изменить пароль здесь',
|
||||||
theme: 'Тема',
|
theme: 'Тема',
|
||||||
changePassword: 'Изменить пароль',
|
changePassword: 'Изменить пароль',
|
||||||
currentPassword: 'Текущий пароль',
|
currentPassword: 'Текущий пароль',
|
||||||
@@ -259,9 +259,9 @@ const ruRU = {
|
|||||||
localProvider: 'Локальный',
|
localProvider: 'Локальный',
|
||||||
localProviderDescription: 'Модели, настроенные и управляемые локально',
|
localProviderDescription: 'Модели, настроенные и управляемые локально',
|
||||||
spaceProviderDescription:
|
spaceProviderDescription:
|
||||||
'Модели, синхронизированные из вашего аккаунта Space',
|
'Модели, синхронизированные из вашего аккаунта LangBot',
|
||||||
spaceDisabledForLocalAccount:
|
spaceDisabledForLocalAccount:
|
||||||
'Войдите через Space, чтобы использовать облачные модели',
|
'Войдите с аккаунтом LangBot, чтобы использовать облачные модели',
|
||||||
syncModels: 'Синхронизировать',
|
syncModels: 'Синхронизировать',
|
||||||
syncSuccess:
|
syncSuccess:
|
||||||
'Синхронизация завершена: {{created}} создано, {{updated}} обновлено',
|
'Синхронизация завершена: {{created}} создано, {{updated}} обновлено',
|
||||||
@@ -300,11 +300,12 @@ const ruRU = {
|
|||||||
langbotModelsDescription: 'Облачные модели на базе LangBot Space',
|
langbotModelsDescription: 'Облачные модели на базе LangBot Space',
|
||||||
credits: 'Кредиты',
|
credits: 'Кредиты',
|
||||||
loginWithSpace: 'Войти с аккаунтом LangBot',
|
loginWithSpace: 'Войти с аккаунтом LangBot',
|
||||||
loginToUseModels: 'Войдите через Space, чтобы использовать облачные модели',
|
loginToUseModels:
|
||||||
|
'Войдите с аккаунтом LangBot, чтобы использовать облачные модели',
|
||||||
noModels: 'Модели не настроены',
|
noModels: 'Модели не настроены',
|
||||||
langbotModels: 'Модели LangBot',
|
langbotModels: 'Модели LangBot',
|
||||||
spaceTrialTooltip:
|
spaceTrialTooltip:
|
||||||
'Доступны бесплатные пробные кредиты! Войдите через Space, чтобы получить доступ к облачным моделям без настройки.',
|
'Доступны бесплатные пробные кредиты! Войдите с аккаунтом LangBot, чтобы получить доступ к облачным моделям без настройки.',
|
||||||
unlockModels: 'Войдите для использования',
|
unlockModels: 'Войдите для использования',
|
||||||
editProvider: 'Редактировать провайдера',
|
editProvider: 'Редактировать провайдера',
|
||||||
addProvider: 'Добавить провайдера',
|
addProvider: 'Добавить провайдера',
|
||||||
@@ -340,9 +341,9 @@ const ruRU = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
ownerMustBindSpace:
|
ownerMustBindSpace:
|
||||||
'The Workspace owner must connect Space for LangBot Models.',
|
'The Workspace owner must connect a LangBot Account for LangBot Models.',
|
||||||
usesOwnerSpaceBilling:
|
usesOwnerSpaceBilling:
|
||||||
"Uses the Workspace owner's Space billing and credits.",
|
"Uses the Workspace owner's LangBot Account billing and credits.",
|
||||||
},
|
},
|
||||||
bots: {
|
bots: {
|
||||||
title: 'Боты',
|
title: 'Боты',
|
||||||
@@ -1229,13 +1230,13 @@ const ruRU = {
|
|||||||
adminAccountNote:
|
adminAccountNote:
|
||||||
'Указанная учётная запись будет настроена как администратор',
|
'Указанная учётная запись будет настроена как администратор',
|
||||||
register: 'Регистрация',
|
register: 'Регистрация',
|
||||||
initWithSpace: 'Инициализация через Space',
|
initWithSpace: 'Инициализация с аккаунтом LangBot',
|
||||||
spaceRecommended:
|
spaceRecommended:
|
||||||
'Рекомендуется: Используйте официальные стабильные API моделей и облачные сервисы',
|
'Рекомендуется: Используйте официальные стабильные API моделей и облачные сервисы',
|
||||||
spaceInfoTip1:
|
spaceInfoTip1:
|
||||||
'Space предоставляет единую службу аутентификации без загрузки конфиденциальной информации.',
|
'Space предоставляет единую службу аутентификации без загрузки конфиденциальной информации.',
|
||||||
spaceInfoTip2:
|
spaceInfoTip2:
|
||||||
'Вход через Space даёт доступ к моделям LangBot и облачным сервисам, включая бесплатные кредиты для быстрого старта.',
|
'Вход с аккаунтом LangBot даёт доступ к моделям LangBot и облачным сервисам, включая бесплатные кредиты для быстрого старта.',
|
||||||
spaceInfoTip3:
|
spaceInfoTip3:
|
||||||
'Способ входа не влияет на другие функции. Вы можете настроить модели из других источников в любое время.',
|
'Способ входа не влияет на другие функции. Вы можете настроить модели из других источников в любое время.',
|
||||||
registerLocal: 'Зарегистрировать локальную учётную запись',
|
registerLocal: 'Зарегистрировать локальную учётную запись',
|
||||||
@@ -1291,34 +1292,34 @@ const ruRU = {
|
|||||||
passwordNotSet: 'Не установлен',
|
passwordNotSet: 'Не установлен',
|
||||||
passwordSetDescription:
|
passwordSetDescription:
|
||||||
'Пароль установлен, вы можете входить с email и паролем',
|
'Пароль установлен, вы можете входить с email и паролем',
|
||||||
spaceStatus: 'Аккаунт Space',
|
spaceStatus: 'Аккаунт LangBot',
|
||||||
spaceBound: 'Привязан',
|
spaceBound: 'Привязан',
|
||||||
spaceNotBound: 'Не привязан',
|
spaceNotBound: 'Не привязан',
|
||||||
spaceBoundDescription:
|
spaceBoundDescription:
|
||||||
'Аккаунт Space привязан, доступны официальные API моделей и облачные сервисы',
|
'Аккаунт LangBot привязан, доступны официальные API моделей и облачные сервисы',
|
||||||
bindSpace: 'Привязать аккаунт Space',
|
bindSpace: 'Привязать аккаунт LangBot',
|
||||||
bindSpaceDescription:
|
bindSpaceDescription:
|
||||||
'Привяжите для использования официальных API моделей и облачных сервисов',
|
'Привяжите для использования официальных API моделей и облачных сервисов',
|
||||||
bindSpaceButton: 'Привязать',
|
bindSpaceButton: 'Привязать',
|
||||||
bindSpaceConfirmTitle: 'Подтверждение привязки',
|
bindSpaceConfirmTitle: 'Подтверждение привязки',
|
||||||
bindSpaceConfirmDescription:
|
bindSpaceConfirmDescription:
|
||||||
'Вы собираетесь привязать локальный экземпляр к аккаунту Space',
|
'Вы собираетесь привязать локальный экземпляр к аккаунту LangBot',
|
||||||
bindSpaceWarning:
|
bindSpaceWarning:
|
||||||
'После привязки ваш email для входа будет изменён с {{localEmail}} на email аккаунта Space.',
|
'После привязки ваш email для входа будет изменён с {{localEmail}} на email аккаунта LangBot.',
|
||||||
bindSpaceSuccess: 'Аккаунт Space успешно привязан',
|
bindSpaceSuccess: 'Аккаунт LangBot успешно привязан',
|
||||||
bindSpaceFailed: 'Не удалось привязать аккаунт Space',
|
bindSpaceFailed: 'Не удалось привязать аккаунт LangBot',
|
||||||
bindSpaceInvalidState:
|
bindSpaceInvalidState:
|
||||||
'Недействительный запрос привязки. Повторите попытку из настроек аккаунта.',
|
'Недействительный запрос привязки. Повторите попытку из настроек аккаунта.',
|
||||||
setPasswordHint: 'Установите пароль для входа с email и паролем',
|
setPasswordHint: 'Установите пароль для входа с email и паролем',
|
||||||
spaceEmailMismatch:
|
spaceEmailMismatch:
|
||||||
'Email входа через Space не совпадает с email локальной учётной записи',
|
'Email входа с аккаунтом LangBot не совпадает с email локальной учётной записи',
|
||||||
|
|
||||||
space_account_not_registeredTitle: 'Account not registered',
|
space_account_not_registeredTitle: 'Account not registered',
|
||||||
space_account_not_registered:
|
space_account_not_registered:
|
||||||
'No local account is registered for this Space email. Ask the Workspace owner for an invitation.',
|
'No local account is registered for this LangBot Account email. Ask the Workspace owner for an invitation.',
|
||||||
space_account_binding_requiredTitle: 'Space connection required',
|
space_account_binding_requiredTitle: 'LangBot Account connection required',
|
||||||
space_account_binding_required:
|
space_account_binding_required:
|
||||||
'This local account must connect Space from Account settings before using Space login.',
|
'This local account must connect a LangBot Account from Account settings before using LangBot Account login.',
|
||||||
},
|
},
|
||||||
monitoring: {
|
monitoring: {
|
||||||
title: 'Мониторинг',
|
title: 'Мониторинг',
|
||||||
|
|||||||
@@ -85,18 +85,18 @@ const thTH = {
|
|||||||
'แนะนำ: ใช้ API โมเดลที่เสถียรอย่างเป็นทางการและบริการคลาวด์',
|
'แนะนำ: ใช้ API โมเดลที่เสถียรอย่างเป็นทางการและบริการคลาวด์',
|
||||||
loginLocal: 'เข้าสู่ระบบด้วยบัญชีท้องถิ่น',
|
loginLocal: 'เข้าสู่ระบบด้วยบัญชีท้องถิ่น',
|
||||||
loginWithPassword: 'เข้าสู่ระบบด้วยรหัสผ่าน',
|
loginWithPassword: 'เข้าสู่ระบบด้วยรหัสผ่าน',
|
||||||
spaceLoginTitle: 'เข้าสู่ระบบด้วย Space',
|
spaceLoginTitle: 'เข้าสู่ระบบด้วยบัญชี LangBot',
|
||||||
spaceLoginDescription:
|
spaceLoginDescription:
|
||||||
'สแกน QR code หรือเข้าชมลิงก์ด้านล่างเพื่อยืนยันสิทธิ์',
|
'สแกน QR code หรือเข้าชมลิงก์ด้านล่างเพื่อยืนยันสิทธิ์',
|
||||||
spaceLoginUserCode: 'รหัสของคุณ',
|
spaceLoginUserCode: 'รหัสของคุณ',
|
||||||
spaceLoginExpires: 'รหัสจะหมดอายุใน {{seconds}} วินาที',
|
spaceLoginExpires: 'รหัสจะหมดอายุใน {{seconds}} วินาที',
|
||||||
spaceLoginWaiting: 'กำลังรอการยืนยันสิทธิ์...',
|
spaceLoginWaiting: 'กำลังรอการยืนยันสิทธิ์...',
|
||||||
spaceLoginSuccess: 'ยืนยันสิทธิ์สำเร็จ',
|
spaceLoginSuccess: 'ยืนยันสิทธิ์สำเร็จ',
|
||||||
spaceLoginFailed: 'เข้าสู่ระบบ Space ล้มเหลว',
|
spaceLoginFailed: 'เข้าสู่ระบบด้วยบัญชี LangBot ล้มเหลว',
|
||||||
spaceLoginExpired: 'รหัสยืนยันหมดอายุแล้ว กรุณาลองใหม่',
|
spaceLoginExpired: 'รหัสยืนยันหมดอายุแล้ว กรุณาลองใหม่',
|
||||||
spaceLoginCancel: 'ยกเลิก',
|
spaceLoginCancel: 'ยกเลิก',
|
||||||
spaceLoginVisitLink: 'เข้าชมลิงก์',
|
spaceLoginVisitLink: 'เข้าชมลิงก์',
|
||||||
spaceLoginProcessing: 'กำลังเข้าสู่ระบบด้วย Space',
|
spaceLoginProcessing: 'กำลังเข้าสู่ระบบด้วยบัญชี LangBot',
|
||||||
spaceLoginProcessingDescription: 'กรุณารอสักครู่ขณะดำเนินการเข้าสู่ระบบ...',
|
spaceLoginProcessingDescription: 'กรุณารอสักครู่ขณะดำเนินการเข้าสู่ระบบ...',
|
||||||
spaceLoginSuccessDescription: 'กำลังเปลี่ยนเส้นทางไปยัง LangBot...',
|
spaceLoginSuccessDescription: 'กำลังเปลี่ยนเส้นทางไปยัง LangBot...',
|
||||||
spaceLoginError: 'เข้าสู่ระบบล้มเหลว',
|
spaceLoginError: 'เข้าสู่ระบบล้มเหลว',
|
||||||
@@ -104,7 +104,7 @@ const thTH = {
|
|||||||
backToLogin: 'กลับไปหน้าเข้าสู่ระบบ',
|
backToLogin: 'กลับไปหน้าเข้าสู่ระบบ',
|
||||||
backToHome: 'กลับไปหน้าแรก',
|
backToHome: 'กลับไปหน้าแรก',
|
||||||
spaceAccountCannotChangePassword:
|
spaceAccountCannotChangePassword:
|
||||||
'บัญชี Space ไม่สามารถเปลี่ยนรหัสผ่านได้ที่นี่',
|
'บัญชี LangBot ไม่สามารถเปลี่ยนรหัสผ่านได้ที่นี่',
|
||||||
theme: 'ธีม',
|
theme: 'ธีม',
|
||||||
changePassword: 'เปลี่ยนรหัสผ่าน',
|
changePassword: 'เปลี่ยนรหัสผ่าน',
|
||||||
currentPassword: 'รหัสผ่านปัจจุบัน',
|
currentPassword: 'รหัสผ่านปัจจุบัน',
|
||||||
@@ -252,8 +252,9 @@ const thTH = {
|
|||||||
llmModels: 'โมเดล LLM',
|
llmModels: 'โมเดล LLM',
|
||||||
localProvider: 'ท้องถิ่น',
|
localProvider: 'ท้องถิ่น',
|
||||||
localProviderDescription: 'โมเดลที่กำหนดค่าและจัดการในเครื่อง',
|
localProviderDescription: 'โมเดลที่กำหนดค่าและจัดการในเครื่อง',
|
||||||
spaceProviderDescription: 'โมเดลที่ซิงค์จากบัญชี Space ของคุณ',
|
spaceProviderDescription: 'โมเดลที่ซิงค์จากบัญชี LangBot ของคุณ',
|
||||||
spaceDisabledForLocalAccount: 'เข้าสู่ระบบด้วย Space เพื่อใช้โมเดลคลาวด์',
|
spaceDisabledForLocalAccount:
|
||||||
|
'เข้าสู่ระบบด้วยบัญชี LangBot เพื่อใช้โมเดลคลาวด์',
|
||||||
syncModels: 'ซิงค์',
|
syncModels: 'ซิงค์',
|
||||||
syncSuccess:
|
syncSuccess:
|
||||||
'ซิงค์เสร็จสมบูรณ์: สร้าง {{created}} รายการ, อัปเดต {{updated}} รายการ',
|
'ซิงค์เสร็จสมบูรณ์: สร้าง {{created}} รายการ, อัปเดต {{updated}} รายการ',
|
||||||
@@ -289,11 +290,11 @@ const thTH = {
|
|||||||
langbotModelsDescription: 'โมเดลคลาวด์ขับเคลื่อนโดย LangBot Space',
|
langbotModelsDescription: 'โมเดลคลาวด์ขับเคลื่อนโดย LangBot Space',
|
||||||
credits: 'เครดิต',
|
credits: 'เครดิต',
|
||||||
loginWithSpace: 'เข้าสู่ระบบด้วยบัญชี LangBot',
|
loginWithSpace: 'เข้าสู่ระบบด้วยบัญชี LangBot',
|
||||||
loginToUseModels: 'เข้าสู่ระบบด้วย Space เพื่อใช้โมเดลคลาวด์',
|
loginToUseModels: 'เข้าสู่ระบบด้วยบัญชี LangBot เพื่อใช้โมเดลคลาวด์',
|
||||||
noModels: 'ยังไม่มีโมเดลที่กำหนดค่า',
|
noModels: 'ยังไม่มีโมเดลที่กำหนดค่า',
|
||||||
langbotModels: 'โมเดล LangBot',
|
langbotModels: 'โมเดล LangBot',
|
||||||
spaceTrialTooltip:
|
spaceTrialTooltip:
|
||||||
'มีเครดิตทดลองใช้งานฟรี! เข้าสู่ระบบด้วย Space เพื่อเข้าถึงโมเดลคลาวด์โดยไม่ต้องตั้งค่า',
|
'มีเครดิตทดลองใช้งานฟรี! เข้าสู่ระบบด้วยบัญชี LangBot เพื่อเข้าถึงโมเดลคลาวด์โดยไม่ต้องตั้งค่า',
|
||||||
unlockModels: 'เข้าสู่ระบบเพื่อใช้งาน',
|
unlockModels: 'เข้าสู่ระบบเพื่อใช้งาน',
|
||||||
editProvider: 'แก้ไขผู้ให้บริการ',
|
editProvider: 'แก้ไขผู้ให้บริการ',
|
||||||
addProvider: 'เพิ่มผู้ให้บริการ',
|
addProvider: 'เพิ่มผู้ให้บริการ',
|
||||||
@@ -327,9 +328,9 @@ const thTH = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
ownerMustBindSpace:
|
ownerMustBindSpace:
|
||||||
'The Workspace owner must connect Space for LangBot Models.',
|
'The Workspace owner must connect a LangBot Account for LangBot Models.',
|
||||||
usesOwnerSpaceBilling:
|
usesOwnerSpaceBilling:
|
||||||
"Uses the Workspace owner's Space billing and credits.",
|
"Uses the Workspace owner's LangBot Account billing and credits.",
|
||||||
},
|
},
|
||||||
bots: {
|
bots: {
|
||||||
title: 'บอท',
|
title: 'บอท',
|
||||||
@@ -1202,13 +1203,13 @@ const thTH = {
|
|||||||
description: 'นี่เป็นครั้งแรกที่คุณเริ่มใช้งาน LangBot',
|
description: 'นี่เป็นครั้งแรกที่คุณเริ่มใช้งาน LangBot',
|
||||||
adminAccountNote: 'บัญชีที่คุณใช้ที่นี่จะถูกตั้งเป็นบัญชีผู้ดูแลระบบ',
|
adminAccountNote: 'บัญชีที่คุณใช้ที่นี่จะถูกตั้งเป็นบัญชีผู้ดูแลระบบ',
|
||||||
register: 'ลงทะเบียน',
|
register: 'ลงทะเบียน',
|
||||||
initWithSpace: 'เริ่มต้นด้วย Space',
|
initWithSpace: 'เริ่มต้นด้วยบัญชี LangBot',
|
||||||
spaceRecommended:
|
spaceRecommended:
|
||||||
'แนะนำ: ใช้ API โมเดลที่เสถียรอย่างเป็นทางการและบริการคลาวด์',
|
'แนะนำ: ใช้ API โมเดลที่เสถียรอย่างเป็นทางการและบริการคลาวด์',
|
||||||
spaceInfoTip1:
|
spaceInfoTip1:
|
||||||
'Space ให้บริการยืนยันตัวตนแบบรวมโดยไม่อัปโหลดข้อมูลสำคัญใดๆ ของคุณ',
|
'Space ให้บริการยืนยันตัวตนแบบรวมโดยไม่อัปโหลดข้อมูลสำคัญใดๆ ของคุณ',
|
||||||
spaceInfoTip2:
|
spaceInfoTip2:
|
||||||
'การเข้าสู่ระบบด้วยบัญชี Space ช่วยให้คุณเข้าถึงโมเดล LangBot และบริการคลาวด์อื่นๆ รวมถึงเครดิตเรียกใช้โมเดลฟรีเพื่อช่วยให้คุณเริ่มต้นได้อย่างรวดเร็ว',
|
'การเข้าสู่ระบบด้วยบัญชี LangBot ช่วยให้คุณเข้าถึงโมเดล LangBot และบริการคลาวด์อื่นๆ รวมถึงเครดิตเรียกใช้โมเดลฟรีเพื่อช่วยให้คุณเริ่มต้นได้อย่างรวดเร็ว',
|
||||||
spaceInfoTip3:
|
spaceInfoTip3:
|
||||||
'วิธีการเข้าสู่ระบบของคุณไม่มีผลต่อฟีเจอร์อื่นๆ คุณสามารถกำหนดค่าและใช้โมเดลจากแหล่งอื่นได้ตลอดเวลา',
|
'วิธีการเข้าสู่ระบบของคุณไม่มีผลต่อฟีเจอร์อื่นๆ คุณสามารถกำหนดค่าและใช้โมเดลจากแหล่งอื่นได้ตลอดเวลา',
|
||||||
registerLocal: 'ลงทะเบียนบัญชีท้องถิ่น',
|
registerLocal: 'ลงทะเบียนบัญชีท้องถิ่น',
|
||||||
@@ -1263,30 +1264,32 @@ const thTH = {
|
|||||||
passwordNotSet: 'ยังไม่ได้ตั้งค่า',
|
passwordNotSet: 'ยังไม่ได้ตั้งค่า',
|
||||||
passwordSetDescription:
|
passwordSetDescription:
|
||||||
'ตั้งรหัสผ่านแล้ว คุณสามารถเข้าสู่ระบบด้วยอีเมลและรหัสผ่าน',
|
'ตั้งรหัสผ่านแล้ว คุณสามารถเข้าสู่ระบบด้วยอีเมลและรหัสผ่าน',
|
||||||
spaceStatus: 'บัญชี Space',
|
spaceStatus: 'บัญชี LangBot',
|
||||||
spaceBound: 'ผูกแล้ว',
|
spaceBound: 'ผูกแล้ว',
|
||||||
spaceNotBound: 'ยังไม่ผูก',
|
spaceNotBound: 'ยังไม่ผูก',
|
||||||
spaceBoundDescription:
|
spaceBoundDescription:
|
||||||
'ผูกบัญชี Space แล้ว สามารถใช้ API โมเดลอย่างเป็นทางการและบริการคลาวด์ได้',
|
'ผูกบัญชี LangBot แล้ว สามารถใช้ API โมเดลอย่างเป็นทางการและบริการคลาวด์ได้',
|
||||||
bindSpace: 'ผูกบัญชี Space',
|
bindSpace: 'ผูกบัญชี LangBot',
|
||||||
bindSpaceDescription: 'ผูกเพื่อใช้ API โมเดลอย่างเป็นทางการและบริการคลาวด์',
|
bindSpaceDescription: 'ผูกเพื่อใช้ API โมเดลอย่างเป็นทางการและบริการคลาวด์',
|
||||||
bindSpaceButton: 'ผูก',
|
bindSpaceButton: 'ผูก',
|
||||||
bindSpaceConfirmTitle: 'ยืนยันการผูก',
|
bindSpaceConfirmTitle: 'ยืนยันการผูก',
|
||||||
bindSpaceConfirmDescription: 'คุณกำลังจะผูกอินสแตนซ์ท้องถิ่นกับบัญชี Space',
|
bindSpaceConfirmDescription:
|
||||||
|
'คุณกำลังจะผูกอินสแตนซ์ท้องถิ่นกับบัญชี LangBot',
|
||||||
bindSpaceWarning:
|
bindSpaceWarning:
|
||||||
'หลังจากผูกแล้ว อีเมลเข้าสู่ระบบของคุณจะเปลี่ยนจาก {{localEmail}} เป็นอีเมลบัญชี Space',
|
'หลังจากผูกแล้ว อีเมลเข้าสู่ระบบของคุณจะเปลี่ยนจาก {{localEmail}} เป็นอีเมลบัญชี LangBot',
|
||||||
bindSpaceSuccess: 'ผูกบัญชี Space สำเร็จ',
|
bindSpaceSuccess: 'ผูกบัญชี LangBot สำเร็จ',
|
||||||
bindSpaceFailed: 'ผูกบัญชี Space ล้มเหลว',
|
bindSpaceFailed: 'ผูกบัญชี LangBot ล้มเหลว',
|
||||||
bindSpaceInvalidState: 'คำขอผูกไม่ถูกต้อง กรุณาลองใหม่จากการตั้งค่าบัญชี',
|
bindSpaceInvalidState: 'คำขอผูกไม่ถูกต้อง กรุณาลองใหม่จากการตั้งค่าบัญชี',
|
||||||
setPasswordHint: 'ตั้งรหัสผ่านเพื่อเข้าสู่ระบบด้วยอีเมลและรหัสผ่าน',
|
setPasswordHint: 'ตั้งรหัสผ่านเพื่อเข้าสู่ระบบด้วยอีเมลและรหัสผ่าน',
|
||||||
spaceEmailMismatch: 'อีเมลเข้าสู่ระบบ Space ไม่ตรงกับอีเมลบัญชีท้องถิ่น',
|
spaceEmailMismatch:
|
||||||
|
'อีเมลเข้าสู่ระบบด้วยบัญชี LangBot ไม่ตรงกับอีเมลบัญชีท้องถิ่น',
|
||||||
|
|
||||||
space_account_not_registeredTitle: 'Account not registered',
|
space_account_not_registeredTitle: 'Account not registered',
|
||||||
space_account_not_registered:
|
space_account_not_registered:
|
||||||
'No local account is registered for this Space email. Ask the Workspace owner for an invitation.',
|
'No local account is registered for this LangBot Account email. Ask the Workspace owner for an invitation.',
|
||||||
space_account_binding_requiredTitle: 'Space connection required',
|
space_account_binding_requiredTitle: 'LangBot Account connection required',
|
||||||
space_account_binding_required:
|
space_account_binding_required:
|
||||||
'This local account must connect Space from Account settings before using Space login.',
|
'This local account must connect a LangBot Account from Account settings before using LangBot Account login.',
|
||||||
},
|
},
|
||||||
monitoring: {
|
monitoring: {
|
||||||
title: 'แดชบอร์ด',
|
title: 'แดชบอร์ด',
|
||||||
|
|||||||
@@ -86,18 +86,18 @@ const viVN = {
|
|||||||
'Khuyến nghị: Sử dụng API mô hình ổn định chính thức và dịch vụ đám mây',
|
'Khuyến nghị: Sử dụng API mô hình ổn định chính thức và dịch vụ đám mây',
|
||||||
loginLocal: 'Đăng nhập với tài khoản cục bộ',
|
loginLocal: 'Đăng nhập với tài khoản cục bộ',
|
||||||
loginWithPassword: 'Đăng nhập bằng mật khẩu',
|
loginWithPassword: 'Đăng nhập bằng mật khẩu',
|
||||||
spaceLoginTitle: 'Đăng nhập với Space',
|
spaceLoginTitle: 'Đăng nhập bằng tài khoản LangBot',
|
||||||
spaceLoginDescription:
|
spaceLoginDescription:
|
||||||
'Quét mã QR hoặc truy cập liên kết bên dưới để ủy quyền',
|
'Quét mã QR hoặc truy cập liên kết bên dưới để ủy quyền',
|
||||||
spaceLoginUserCode: 'Mã của bạn',
|
spaceLoginUserCode: 'Mã của bạn',
|
||||||
spaceLoginExpires: 'Mã hết hạn sau {{seconds}} giây',
|
spaceLoginExpires: 'Mã hết hạn sau {{seconds}} giây',
|
||||||
spaceLoginWaiting: 'Đang chờ ủy quyền...',
|
spaceLoginWaiting: 'Đang chờ ủy quyền...',
|
||||||
spaceLoginSuccess: 'Ủy quyền thành công',
|
spaceLoginSuccess: 'Ủy quyền thành công',
|
||||||
spaceLoginFailed: 'Đăng nhập Space thất bại',
|
spaceLoginFailed: 'Đăng nhập bằng tài khoản LangBot thất bại',
|
||||||
spaceLoginExpired: 'Mã ủy quyền đã hết hạn, vui lòng thử lại',
|
spaceLoginExpired: 'Mã ủy quyền đã hết hạn, vui lòng thử lại',
|
||||||
spaceLoginCancel: 'Hủy',
|
spaceLoginCancel: 'Hủy',
|
||||||
spaceLoginVisitLink: 'Truy cập liên kết',
|
spaceLoginVisitLink: 'Truy cập liên kết',
|
||||||
spaceLoginProcessing: 'Đang đăng nhập với Space',
|
spaceLoginProcessing: 'Đang đăng nhập bằng tài khoản LangBot',
|
||||||
spaceLoginProcessingDescription:
|
spaceLoginProcessingDescription:
|
||||||
'Vui lòng chờ trong khi chúng tôi hoàn tất đăng nhập...',
|
'Vui lòng chờ trong khi chúng tôi hoàn tất đăng nhập...',
|
||||||
spaceLoginSuccessDescription: 'Đang chuyển hướng đến LangBot...',
|
spaceLoginSuccessDescription: 'Đang chuyển hướng đến LangBot...',
|
||||||
@@ -106,7 +106,7 @@ const viVN = {
|
|||||||
backToLogin: 'Quay lại đăng nhập',
|
backToLogin: 'Quay lại đăng nhập',
|
||||||
backToHome: 'Quay lại trang chủ',
|
backToHome: 'Quay lại trang chủ',
|
||||||
spaceAccountCannotChangePassword:
|
spaceAccountCannotChangePassword:
|
||||||
'Tài khoản Space không thể đổi mật khẩu tại đây',
|
'Tài khoản LangBot không thể đổi mật khẩu tại đây',
|
||||||
theme: 'Giao diện',
|
theme: 'Giao diện',
|
||||||
changePassword: 'Đổi mật khẩu',
|
changePassword: 'Đổi mật khẩu',
|
||||||
currentPassword: 'Mật khẩu hiện tại',
|
currentPassword: 'Mật khẩu hiện tại',
|
||||||
@@ -258,9 +258,9 @@ const viVN = {
|
|||||||
localProvider: 'Cục bộ',
|
localProvider: 'Cục bộ',
|
||||||
localProviderDescription: 'Các mô hình được cấu hình và quản lý cục bộ',
|
localProviderDescription: 'Các mô hình được cấu hình và quản lý cục bộ',
|
||||||
spaceProviderDescription:
|
spaceProviderDescription:
|
||||||
'Các mô hình được đồng bộ từ tài khoản Space của bạn',
|
'Các mô hình được đồng bộ từ tài khoản LangBot của bạn',
|
||||||
spaceDisabledForLocalAccount:
|
spaceDisabledForLocalAccount:
|
||||||
'Đăng nhập với Space để sử dụng mô hình đám mây',
|
'Đăng nhập bằng tài khoản LangBot để sử dụng mô hình đám mây',
|
||||||
syncModels: 'Đồng bộ',
|
syncModels: 'Đồng bộ',
|
||||||
syncSuccess:
|
syncSuccess:
|
||||||
'Đồng bộ hoàn tất: {{created}} đã tạo, {{updated}} đã cập nhật',
|
'Đồng bộ hoàn tất: {{created}} đã tạo, {{updated}} đã cập nhật',
|
||||||
@@ -297,11 +297,12 @@ const viVN = {
|
|||||||
langbotModelsDescription: 'Mô hình đám mây được cung cấp bởi LangBot Space',
|
langbotModelsDescription: 'Mô hình đám mây được cung cấp bởi LangBot Space',
|
||||||
credits: 'Tín dụng',
|
credits: 'Tín dụng',
|
||||||
loginWithSpace: 'Đăng nhập bằng tài khoản LangBot',
|
loginWithSpace: 'Đăng nhập bằng tài khoản LangBot',
|
||||||
loginToUseModels: 'Đăng nhập với Space để sử dụng mô hình đám mây',
|
loginToUseModels:
|
||||||
|
'Đăng nhập bằng tài khoản LangBot để sử dụng mô hình đám mây',
|
||||||
noModels: 'Chưa cấu hình mô hình nào',
|
noModels: 'Chưa cấu hình mô hình nào',
|
||||||
langbotModels: 'Mô hình LangBot',
|
langbotModels: 'Mô hình LangBot',
|
||||||
spaceTrialTooltip:
|
spaceTrialTooltip:
|
||||||
'Có tín dụng dùng thử miễn phí! Đăng nhập với Space để truy cập mô hình đám mây không cần cấu hình.',
|
'Có tín dụng dùng thử miễn phí! Đăng nhập bằng tài khoản LangBot để truy cập mô hình đám mây không cần cấu hình.',
|
||||||
unlockModels: 'Đăng nhập để sử dụng',
|
unlockModels: 'Đăng nhập để sử dụng',
|
||||||
editProvider: 'Chỉnh sửa nhà cung cấp',
|
editProvider: 'Chỉnh sửa nhà cung cấp',
|
||||||
addProvider: 'Thêm nhà cung cấp',
|
addProvider: 'Thêm nhà cung cấp',
|
||||||
@@ -336,9 +337,9 @@ const viVN = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
ownerMustBindSpace:
|
ownerMustBindSpace:
|
||||||
'The Workspace owner must connect Space for LangBot Models.',
|
'The Workspace owner must connect a LangBot Account for LangBot Models.',
|
||||||
usesOwnerSpaceBilling:
|
usesOwnerSpaceBilling:
|
||||||
"Uses the Workspace owner's Space billing and credits.",
|
"Uses the Workspace owner's LangBot Account billing and credits.",
|
||||||
},
|
},
|
||||||
bots: {
|
bots: {
|
||||||
title: 'Bot',
|
title: 'Bot',
|
||||||
@@ -1222,13 +1223,13 @@ const viVN = {
|
|||||||
adminAccountNote:
|
adminAccountNote:
|
||||||
'Tài khoản bạn sử dụng ở đây sẽ được đặt làm tài khoản quản trị viên',
|
'Tài khoản bạn sử dụng ở đây sẽ được đặt làm tài khoản quản trị viên',
|
||||||
register: 'Đăng ký',
|
register: 'Đăng ký',
|
||||||
initWithSpace: 'Khởi tạo với Space',
|
initWithSpace: 'Khởi tạo bằng tài khoản LangBot',
|
||||||
spaceRecommended:
|
spaceRecommended:
|
||||||
'Khuyến nghị: Sử dụng API mô hình ổn định chính thức và dịch vụ đám mây',
|
'Khuyến nghị: Sử dụng API mô hình ổn định chính thức và dịch vụ đám mây',
|
||||||
spaceInfoTip1:
|
spaceInfoTip1:
|
||||||
'Space cung cấp dịch vụ xác thực tài khoản thống nhất mà không tải lên bất kỳ thông tin nhạy cảm nào của bạn.',
|
'Space cung cấp dịch vụ xác thực tài khoản thống nhất mà không tải lên bất kỳ thông tin nhạy cảm nào của bạn.',
|
||||||
spaceInfoTip2:
|
spaceInfoTip2:
|
||||||
'Đăng nhập bằng tài khoản Space cho phép bạn truy cập Mô hình LangBot và các dịch vụ đám mây khác, bao gồm tín dụng gọi mô hình miễn phí để giúp bạn bắt đầu nhanh chóng.',
|
'Đăng nhập bằng tài khoản LangBot cho phép bạn truy cập Mô hình LangBot và các dịch vụ đám mây khác, bao gồm tín dụng gọi mô hình miễn phí để giúp bạn bắt đầu nhanh chóng.',
|
||||||
spaceInfoTip3:
|
spaceInfoTip3:
|
||||||
'Phương thức đăng nhập của bạn không ảnh hưởng đến các tính năng khác. Bạn có thể cấu hình và sử dụng mô hình từ các nguồn khác bất cứ lúc nào.',
|
'Phương thức đăng nhập của bạn không ảnh hưởng đến các tính năng khác. Bạn có thể cấu hình và sử dụng mô hình từ các nguồn khác bất cứ lúc nào.',
|
||||||
registerLocal: 'Đăng ký tài khoản cục bộ',
|
registerLocal: 'Đăng ký tài khoản cục bộ',
|
||||||
@@ -1285,34 +1286,34 @@ const viVN = {
|
|||||||
passwordNotSet: 'Chưa đặt',
|
passwordNotSet: 'Chưa đặt',
|
||||||
passwordSetDescription:
|
passwordSetDescription:
|
||||||
'Mật khẩu đã được đặt, bạn có thể đăng nhập bằng email và mật khẩu',
|
'Mật khẩu đã được đặt, bạn có thể đăng nhập bằng email và mật khẩu',
|
||||||
spaceStatus: 'Tài khoản Space',
|
spaceStatus: 'Tài khoản LangBot',
|
||||||
spaceBound: 'Đã liên kết',
|
spaceBound: 'Đã liên kết',
|
||||||
spaceNotBound: 'Chưa liên kết',
|
spaceNotBound: 'Chưa liên kết',
|
||||||
spaceBoundDescription:
|
spaceBoundDescription:
|
||||||
'Tài khoản Space đã liên kết, có thể sử dụng API mô hình chính thức và dịch vụ đám mây',
|
'Tài khoản LangBot đã liên kết, có thể sử dụng API mô hình chính thức và dịch vụ đám mây',
|
||||||
bindSpace: 'Liên kết tài khoản Space',
|
bindSpace: 'Liên kết tài khoản LangBot',
|
||||||
bindSpaceDescription:
|
bindSpaceDescription:
|
||||||
'Liên kết để sử dụng API mô hình chính thức và dịch vụ đám mây',
|
'Liên kết để sử dụng API mô hình chính thức và dịch vụ đám mây',
|
||||||
bindSpaceButton: 'Liên kết',
|
bindSpaceButton: 'Liên kết',
|
||||||
bindSpaceConfirmTitle: 'Xác nhận liên kết',
|
bindSpaceConfirmTitle: 'Xác nhận liên kết',
|
||||||
bindSpaceConfirmDescription:
|
bindSpaceConfirmDescription:
|
||||||
'Bạn sắp liên kết phiên bản cục bộ với tài khoản Space',
|
'Bạn sắp liên kết phiên bản cục bộ với tài khoản LangBot',
|
||||||
bindSpaceWarning:
|
bindSpaceWarning:
|
||||||
'Sau khi liên kết, email đăng nhập của bạn sẽ được đổi từ {{localEmail}} sang email tài khoản Space.',
|
'Sau khi liên kết, email đăng nhập của bạn sẽ được đổi từ {{localEmail}} sang email tài khoản LangBot.',
|
||||||
bindSpaceSuccess: 'Liên kết tài khoản Space thành công',
|
bindSpaceSuccess: 'Liên kết tài khoản LangBot thành công',
|
||||||
bindSpaceFailed: 'Liên kết tài khoản Space thất bại',
|
bindSpaceFailed: 'Liên kết tài khoản LangBot thất bại',
|
||||||
bindSpaceInvalidState:
|
bindSpaceInvalidState:
|
||||||
'Yêu cầu liên kết không hợp lệ. Vui lòng thử lại từ cài đặt tài khoản.',
|
'Yêu cầu liên kết không hợp lệ. Vui lòng thử lại từ cài đặt tài khoản.',
|
||||||
setPasswordHint: 'Đặt mật khẩu để đăng nhập bằng email và mật khẩu',
|
setPasswordHint: 'Đặt mật khẩu để đăng nhập bằng email và mật khẩu',
|
||||||
spaceEmailMismatch:
|
spaceEmailMismatch:
|
||||||
'Email đăng nhập Space không khớp với email tài khoản cục bộ',
|
'Email tài khoản LangBot không khớp với email tài khoản cục bộ',
|
||||||
|
|
||||||
space_account_not_registeredTitle: 'Account not registered',
|
space_account_not_registeredTitle: 'Account not registered',
|
||||||
space_account_not_registered:
|
space_account_not_registered:
|
||||||
'No local account is registered for this Space email. Ask the Workspace owner for an invitation.',
|
'No local account is registered for this LangBot Account email. Ask the Workspace owner for an invitation.',
|
||||||
space_account_binding_requiredTitle: 'Space connection required',
|
space_account_binding_requiredTitle: 'LangBot Account connection required',
|
||||||
space_account_binding_required:
|
space_account_binding_required:
|
||||||
'This local account must connect Space from Account settings before using Space login.',
|
'This local account must connect a LangBot Account from Account settings before using LangBot Account login.',
|
||||||
},
|
},
|
||||||
monitoring: {
|
monitoring: {
|
||||||
title: 'Bảng điều khiển',
|
title: 'Bảng điều khiển',
|
||||||
|
|||||||
@@ -83,24 +83,24 @@ const zhHans = {
|
|||||||
spaceLoginRecommended: '推荐:使用官方提供的稳定模型 API 和云服务',
|
spaceLoginRecommended: '推荐:使用官方提供的稳定模型 API 和云服务',
|
||||||
loginLocal: '使用本地账号登录',
|
loginLocal: '使用本地账号登录',
|
||||||
loginWithPassword: '通过密码登录',
|
loginWithPassword: '通过密码登录',
|
||||||
spaceLoginTitle: '通过 Space 登录',
|
spaceLoginTitle: '通过 LangBot 账号登录',
|
||||||
spaceLoginDescription: '扫描二维码或访问下方链接进行授权',
|
spaceLoginDescription: '扫描二维码或访问下方链接进行授权',
|
||||||
spaceLoginUserCode: '您的验证码',
|
spaceLoginUserCode: '您的验证码',
|
||||||
spaceLoginExpires: '验证码将在 {{seconds}} 秒后过期',
|
spaceLoginExpires: '验证码将在 {{seconds}} 秒后过期',
|
||||||
spaceLoginWaiting: '等待授权中...',
|
spaceLoginWaiting: '等待授权中...',
|
||||||
spaceLoginSuccess: '授权成功',
|
spaceLoginSuccess: '授权成功',
|
||||||
spaceLoginFailed: 'Space 登录失败',
|
spaceLoginFailed: 'LangBot 账号登录失败',
|
||||||
spaceLoginExpired: '验证码已过期,请重试',
|
spaceLoginExpired: '验证码已过期,请重试',
|
||||||
spaceLoginCancel: '取消',
|
spaceLoginCancel: '取消',
|
||||||
spaceLoginVisitLink: '访问链接',
|
spaceLoginVisitLink: '访问链接',
|
||||||
spaceLoginProcessing: '正在通过 Space 登录',
|
spaceLoginProcessing: '正在通过 LangBot 账号登录',
|
||||||
spaceLoginProcessingDescription: '请稍候,正在完成登录...',
|
spaceLoginProcessingDescription: '请稍候,正在完成登录...',
|
||||||
spaceLoginSuccessDescription: '正在跳转到 LangBot...',
|
spaceLoginSuccessDescription: '正在跳转到 LangBot...',
|
||||||
spaceLoginError: '登录失败',
|
spaceLoginError: '登录失败',
|
||||||
spaceLoginNoCode: '缺少授权码',
|
spaceLoginNoCode: '缺少授权码',
|
||||||
backToLogin: '返回登录',
|
backToLogin: '返回登录',
|
||||||
backToHome: '返回首页',
|
backToHome: '返回首页',
|
||||||
spaceAccountCannotChangePassword: 'Space 账户无法在此修改密码',
|
spaceAccountCannotChangePassword: 'LangBot 账号无法在此修改密码',
|
||||||
theme: '主题',
|
theme: '主题',
|
||||||
changePassword: '修改密码',
|
changePassword: '修改密码',
|
||||||
currentPassword: '当前密码',
|
currentPassword: '当前密码',
|
||||||
@@ -243,8 +243,8 @@ const zhHans = {
|
|||||||
llmModels: '对话模型',
|
llmModels: '对话模型',
|
||||||
localProvider: '本地',
|
localProvider: '本地',
|
||||||
localProviderDescription: '在本地配置和管理的模型',
|
localProviderDescription: '在本地配置和管理的模型',
|
||||||
spaceProviderDescription: '从您的 Space 账户同步的模型',
|
spaceProviderDescription: '从您的 LangBot 账号同步的模型',
|
||||||
spaceDisabledForLocalAccount: '使用 Space 登录以使用云端模型',
|
spaceDisabledForLocalAccount: '使用 LangBot 账号登录以使用云端模型',
|
||||||
syncModels: '同步',
|
syncModels: '同步',
|
||||||
syncSuccess: '同步完成:创建 {{created}} 个,更新 {{updated}} 个',
|
syncSuccess: '同步完成:创建 {{created}} 个,更新 {{updated}} 个',
|
||||||
syncError: '同步失败:',
|
syncError: '同步失败:',
|
||||||
@@ -279,13 +279,14 @@ const zhHans = {
|
|||||||
langbotModelsDescription: 'LangBot Space 提供的云端模型',
|
langbotModelsDescription: 'LangBot Space 提供的云端模型',
|
||||||
credits: '积分',
|
credits: '积分',
|
||||||
loginWithSpace: '使用 LangBot 账号登录',
|
loginWithSpace: '使用 LangBot 账号登录',
|
||||||
loginToUseModels: '通过 Space 登录以使用云端模型',
|
loginToUseModels: '通过 LangBot 账号登录以使用云端模型',
|
||||||
ownerMustBindSpace: '工作区所有者需要绑定 Space 才能使用 LangBot 模型。',
|
ownerMustBindSpace:
|
||||||
usesOwnerSpaceBilling: '使用工作区所有者的 Space 计费与积分。',
|
'工作区所有者需要绑定 LangBot 账号才能使用 LangBot 模型。',
|
||||||
|
usesOwnerSpaceBilling: '使用工作区所有者的 LangBot 账号计费与积分。',
|
||||||
noModels: '暂无模型',
|
noModels: '暂无模型',
|
||||||
langbotModels: 'LangBot 模型',
|
langbotModels: 'LangBot 模型',
|
||||||
spaceTrialTooltip:
|
spaceTrialTooltip:
|
||||||
'免费试用积分已就绪!通过 Space 登录即可零配置使用云端模型。',
|
'免费试用积分已就绪!通过 LangBot 账号登录即可零配置使用云端模型。',
|
||||||
unlockModels: '登录以使用',
|
unlockModels: '登录以使用',
|
||||||
editProvider: '编辑供应商',
|
editProvider: '编辑供应商',
|
||||||
addProvider: '添加供应商',
|
addProvider: '添加供应商',
|
||||||
@@ -1161,11 +1162,11 @@ const zhHans = {
|
|||||||
description: '这是您首次启动 LangBot',
|
description: '这是您首次启动 LangBot',
|
||||||
adminAccountNote: '您在此处初始化使用的账号将作为管理员账号',
|
adminAccountNote: '您在此处初始化使用的账号将作为管理员账号',
|
||||||
register: '注册',
|
register: '注册',
|
||||||
initWithSpace: '通过 Space 初始化',
|
initWithSpace: '通过 LangBot 账号初始化',
|
||||||
spaceRecommended: '推荐:使用官方提供的稳定模型 API 和云服务',
|
spaceRecommended: '推荐:使用官方提供的稳定模型 API 和云服务',
|
||||||
spaceInfoTip1: 'Space 提供统一的账户鉴权服务,不会上传您的任何敏感信息。',
|
spaceInfoTip1: 'Space 提供统一的账户鉴权服务,不会上传您的任何敏感信息。',
|
||||||
spaceInfoTip2:
|
spaceInfoTip2:
|
||||||
'使用 Space 账户登录可使用 LangBot Models 等云服务,您将会获得一定的免费模型调用额度帮助您快速起步。',
|
'使用 LangBot 账号登录可使用 LangBot Models 等云服务,您将会获得一定的免费模型调用额度帮助您快速起步。',
|
||||||
spaceInfoTip3:
|
spaceInfoTip3:
|
||||||
'登录方式不会影响其他功能,您在任何情况下都可以配置使用其他来源的模型。',
|
'登录方式不会影响其他功能,您在任何情况下都可以配置使用其他来源的模型。',
|
||||||
registerLocal: '注册本地账号',
|
registerLocal: '注册本地账号',
|
||||||
@@ -1219,28 +1220,28 @@ const zhHans = {
|
|||||||
passwordSet: '已设置',
|
passwordSet: '已设置',
|
||||||
passwordNotSet: '未设置',
|
passwordNotSet: '未设置',
|
||||||
passwordSetDescription: '您已设置本地密码,可使用邮箱密码登录',
|
passwordSetDescription: '您已设置本地密码,可使用邮箱密码登录',
|
||||||
spaceStatus: 'Space 账户',
|
spaceStatus: 'LangBot 账号',
|
||||||
spaceBound: '已绑定',
|
spaceBound: '已绑定',
|
||||||
spaceNotBound: '未绑定',
|
spaceNotBound: '未绑定',
|
||||||
spaceBoundDescription: '已绑定 Space 账户,可使用官方模型 API 和云服务',
|
spaceBoundDescription: '已绑定 LangBot 账号,可使用官方模型 API 和云服务',
|
||||||
bindSpace: '绑定 Space 账户',
|
bindSpace: '绑定 LangBot 账号',
|
||||||
bindSpaceDescription: '绑定后可使用官方模型 API 和云服务',
|
bindSpaceDescription: '绑定后可使用官方模型 API 和云服务',
|
||||||
bindSpaceButton: '绑定',
|
bindSpaceButton: '绑定',
|
||||||
bindSpaceConfirmTitle: '确认绑定',
|
bindSpaceConfirmTitle: '确认绑定',
|
||||||
bindSpaceConfirmDescription: '您即将把本地实例绑定到 Space 账户',
|
bindSpaceConfirmDescription: '您即将把本地实例绑定到 LangBot 账号',
|
||||||
bindSpaceWarning:
|
bindSpaceWarning:
|
||||||
'绑定后,您的登录邮箱将从 {{localEmail}} 更改为 Space 账户的邮箱。',
|
'绑定后,您的登录邮箱将从 {{localEmail}} 更改为 LangBot 账号的邮箱。',
|
||||||
bindSpaceSuccess: 'Space 账户绑定成功',
|
bindSpaceSuccess: 'LangBot 账号绑定成功',
|
||||||
bindSpaceFailed: '绑定 Space 账户失败',
|
bindSpaceFailed: '绑定 LangBot 账号失败',
|
||||||
bindSpaceInvalidState: '无效的绑定请求,请从账户设置重新发起',
|
bindSpaceInvalidState: '无效的绑定请求,请从账户设置重新发起',
|
||||||
setPasswordHint: '设置密码后可使用邮箱密码登录',
|
setPasswordHint: '设置密码后可使用邮箱密码登录',
|
||||||
spaceEmailMismatch: 'Space登录账号邮箱与本实例账号邮箱不匹配',
|
spaceEmailMismatch: 'LangBot 账号邮箱与本实例账号邮箱不匹配',
|
||||||
space_account_not_registeredTitle: '账户尚未注册',
|
space_account_not_registeredTitle: '账户尚未注册',
|
||||||
space_account_not_registered:
|
space_account_not_registered:
|
||||||
'此 Space 邮箱尚无本地账户,请联系工作区所有者获取邀请。',
|
'此 LangBot 账号邮箱尚无本地账户,请联系工作区所有者获取邀请。',
|
||||||
space_account_binding_requiredTitle: '需要绑定 Space',
|
space_account_binding_requiredTitle: '需要绑定 LangBot 账号',
|
||||||
space_account_binding_required:
|
space_account_binding_required:
|
||||||
'此本地账户必须先在账户设置中绑定 Space,才能使用 Space 登录。',
|
'此本地账户必须先在账户设置中绑定 LangBot 账号,才能使用 LangBot 账号登录。',
|
||||||
},
|
},
|
||||||
workspace: {
|
workspace: {
|
||||||
title: '工作区',
|
title: '工作区',
|
||||||
|
|||||||
@@ -83,24 +83,24 @@ const zhHant = {
|
|||||||
spaceLoginRecommended: '推薦:使用官方提供的穩定模型 API 和雲服務',
|
spaceLoginRecommended: '推薦:使用官方提供的穩定模型 API 和雲服務',
|
||||||
loginLocal: '使用本地帳號登入',
|
loginLocal: '使用本地帳號登入',
|
||||||
loginWithPassword: '透過密碼登入',
|
loginWithPassword: '透過密碼登入',
|
||||||
spaceLoginTitle: '透過 Space 登入',
|
spaceLoginTitle: '透過 LangBot 帳號登入',
|
||||||
spaceLoginDescription: '掃描二維碼或訪問下方連結進行授權',
|
spaceLoginDescription: '掃描二維碼或訪問下方連結進行授權',
|
||||||
spaceLoginUserCode: '您的驗證碼',
|
spaceLoginUserCode: '您的驗證碼',
|
||||||
spaceLoginExpires: '驗證碼將在 {{seconds}} 秒後過期',
|
spaceLoginExpires: '驗證碼將在 {{seconds}} 秒後過期',
|
||||||
spaceLoginWaiting: '等待授權中...',
|
spaceLoginWaiting: '等待授權中...',
|
||||||
spaceLoginSuccess: '授權成功',
|
spaceLoginSuccess: '授權成功',
|
||||||
spaceLoginFailed: 'Space 登入失敗',
|
spaceLoginFailed: 'LangBot 帳號登入失敗',
|
||||||
spaceLoginExpired: '驗證碼已過期,請重試',
|
spaceLoginExpired: '驗證碼已過期,請重試',
|
||||||
spaceLoginCancel: '取消',
|
spaceLoginCancel: '取消',
|
||||||
spaceLoginVisitLink: '訪問連結',
|
spaceLoginVisitLink: '訪問連結',
|
||||||
spaceLoginProcessing: '正在透過 Space 登入',
|
spaceLoginProcessing: '正在透過 LangBot 帳號登入',
|
||||||
spaceLoginProcessingDescription: '請稍候,正在完成登入...',
|
spaceLoginProcessingDescription: '請稍候,正在完成登入...',
|
||||||
spaceLoginSuccessDescription: '正在跳轉到 LangBot...',
|
spaceLoginSuccessDescription: '正在跳轉到 LangBot...',
|
||||||
spaceLoginError: '登入失敗',
|
spaceLoginError: '登入失敗',
|
||||||
spaceLoginNoCode: '缺少授權碼',
|
spaceLoginNoCode: '缺少授權碼',
|
||||||
backToLogin: '返回登入',
|
backToLogin: '返回登入',
|
||||||
backToHome: '返回首頁',
|
backToHome: '返回首頁',
|
||||||
spaceAccountCannotChangePassword: 'Space 帳戶無法在此修改密碼',
|
spaceAccountCannotChangePassword: 'LangBot 帳號無法在此修改密碼',
|
||||||
theme: '主題',
|
theme: '主題',
|
||||||
changePassword: '修改密碼',
|
changePassword: '修改密碼',
|
||||||
currentPassword: '當前密碼',
|
currentPassword: '當前密碼',
|
||||||
@@ -244,8 +244,8 @@ const zhHant = {
|
|||||||
llmModels: '對話模型',
|
llmModels: '對話模型',
|
||||||
localProvider: '本地',
|
localProvider: '本地',
|
||||||
localProviderDescription: '在本地設定和管理的模型',
|
localProviderDescription: '在本地設定和管理的模型',
|
||||||
spaceProviderDescription: '從您的 Space 帳戶同步的模型',
|
spaceProviderDescription: '從您的 LangBot 帳號同步的模型',
|
||||||
spaceDisabledForLocalAccount: '使用 Space 登入以使用雲端模型',
|
spaceDisabledForLocalAccount: '使用 LangBot 帳號登入以使用雲端模型',
|
||||||
syncModels: '同步',
|
syncModels: '同步',
|
||||||
syncSuccess: '同步完成:建立 {{created}} 個,更新 {{updated}} 個',
|
syncSuccess: '同步完成:建立 {{created}} 個,更新 {{updated}} 個',
|
||||||
syncError: '同步失敗:',
|
syncError: '同步失敗:',
|
||||||
@@ -279,11 +279,11 @@ const zhHant = {
|
|||||||
langbotModelsDescription: '由 LangBot Space 提供的雲端模型',
|
langbotModelsDescription: '由 LangBot Space 提供的雲端模型',
|
||||||
credits: '積分',
|
credits: '積分',
|
||||||
loginWithSpace: '使用 LangBot 帳號登入',
|
loginWithSpace: '使用 LangBot 帳號登入',
|
||||||
loginToUseModels: '使用 Space 登入以使用雲端模型',
|
loginToUseModels: '使用 LangBot 帳號登入以使用雲端模型',
|
||||||
noModels: '暫無模型',
|
noModels: '暫無模型',
|
||||||
langbotModels: 'LangBot 模型',
|
langbotModels: 'LangBot 模型',
|
||||||
spaceTrialTooltip:
|
spaceTrialTooltip:
|
||||||
'免費試用積分已就緒!使用 Space 登入即可零設定使用雲端模型。',
|
'免費試用積分已就緒!使用 LangBot 帳號登入即可零設定使用雲端模型。',
|
||||||
unlockModels: '登入以使用',
|
unlockModels: '登入以使用',
|
||||||
editProvider: '編輯供應商',
|
editProvider: '編輯供應商',
|
||||||
addProvider: '新增供應商',
|
addProvider: '新增供應商',
|
||||||
@@ -317,9 +317,9 @@ const zhHant = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
ownerMustBindSpace:
|
ownerMustBindSpace:
|
||||||
'The Workspace owner must connect Space for LangBot Models.',
|
'The Workspace owner must connect a LangBot Account for LangBot Models.',
|
||||||
usesOwnerSpaceBilling:
|
usesOwnerSpaceBilling:
|
||||||
"Uses the Workspace owner's Space billing and credits.",
|
"Uses the Workspace owner's LangBot Account billing and credits.",
|
||||||
},
|
},
|
||||||
bots: {
|
bots: {
|
||||||
title: '機器人',
|
title: '機器人',
|
||||||
@@ -1163,11 +1163,11 @@ const zhHant = {
|
|||||||
description: '這是您首次啟動 LangBot',
|
description: '這是您首次啟動 LangBot',
|
||||||
adminAccountNote: '您在此處初始化使用的帳號將作為管理員帳號',
|
adminAccountNote: '您在此處初始化使用的帳號將作為管理員帳號',
|
||||||
register: '註冊',
|
register: '註冊',
|
||||||
initWithSpace: '透過 Space 初始化',
|
initWithSpace: '透過 LangBot 帳號初始化',
|
||||||
spaceRecommended: '推薦:使用官方提供的穩定模型 API 和雲服務',
|
spaceRecommended: '推薦:使用官方提供的穩定模型 API 和雲服務',
|
||||||
spaceInfoTip1: 'Space 提供統一的帳戶鑑權服務,不會上傳您的任何敏感資訊。',
|
spaceInfoTip1: 'Space 提供統一的帳戶鑑權服務,不會上傳您的任何敏感資訊。',
|
||||||
spaceInfoTip2:
|
spaceInfoTip2:
|
||||||
'使用 Space 帳戶登入可使用 LangBot Models 等雲服務,您將會獲得一定的免費模型調用額度幫助您快速起步。',
|
'使用 LangBot 帳號登入可使用 LangBot Models 等雲服務,您將會獲得一定的免費模型調用額度幫助您快速起步。',
|
||||||
spaceInfoTip3:
|
spaceInfoTip3:
|
||||||
'登入方式不會影響其他功能,您在任何情況下都可以配置使用其他來源的模型。',
|
'登入方式不會影響其他功能,您在任何情況下都可以配置使用其他來源的模型。',
|
||||||
registerLocal: '註冊本地帳號',
|
registerLocal: '註冊本地帳號',
|
||||||
@@ -1221,29 +1221,29 @@ const zhHant = {
|
|||||||
passwordSet: '已設定',
|
passwordSet: '已設定',
|
||||||
passwordNotSet: '未設定',
|
passwordNotSet: '未設定',
|
||||||
passwordSetDescription: '您已設定本地密碼,可使用電子郵件密碼登入',
|
passwordSetDescription: '您已設定本地密碼,可使用電子郵件密碼登入',
|
||||||
spaceStatus: 'Space 帳戶',
|
spaceStatus: 'LangBot 帳號',
|
||||||
spaceBound: '已綁定',
|
spaceBound: '已綁定',
|
||||||
spaceNotBound: '未綁定',
|
spaceNotBound: '未綁定',
|
||||||
spaceBoundDescription: '已綁定 Space 帳戶,可使用官方模型 API 和雲服務',
|
spaceBoundDescription: '已綁定 LangBot 帳號,可使用官方模型 API 和雲服務',
|
||||||
bindSpace: '綁定 Space 帳戶',
|
bindSpace: '綁定 LangBot 帳號',
|
||||||
bindSpaceDescription: '綁定後可使用官方模型 API 和雲服務',
|
bindSpaceDescription: '綁定後可使用官方模型 API 和雲服務',
|
||||||
bindSpaceButton: '綁定',
|
bindSpaceButton: '綁定',
|
||||||
bindSpaceConfirmTitle: '確認綁定',
|
bindSpaceConfirmTitle: '確認綁定',
|
||||||
bindSpaceConfirmDescription: '您即將把本地實例綁定到 Space 帳戶',
|
bindSpaceConfirmDescription: '您即將把本地實例綁定到 LangBot 帳號',
|
||||||
bindSpaceWarning:
|
bindSpaceWarning:
|
||||||
'綁定後,您的登入電子郵件將從 {{localEmail}} 更改為 Space 帳戶的電子郵件。',
|
'綁定後,您的登入電子郵件將從 {{localEmail}} 更改為 LangBot 帳號的電子郵件。',
|
||||||
bindSpaceSuccess: 'Space 帳戶綁定成功',
|
bindSpaceSuccess: 'LangBot 帳號綁定成功',
|
||||||
bindSpaceFailed: '綁定 Space 帳戶失敗',
|
bindSpaceFailed: '綁定 LangBot 帳號失敗',
|
||||||
bindSpaceInvalidState: '無效的綁定請求,請從帳戶設定重新發起',
|
bindSpaceInvalidState: '無效的綁定請求,請從帳戶設定重新發起',
|
||||||
setPasswordHint: '設定密碼後可使用電子郵件密碼登入',
|
setPasswordHint: '設定密碼後可使用電子郵件密碼登入',
|
||||||
spaceEmailMismatch: 'Space登入帳號電子郵件與本實例帳號電子郵件不匹配',
|
spaceEmailMismatch: 'LangBot 帳號電子郵件與本實例帳號電子郵件不匹配',
|
||||||
|
|
||||||
space_account_not_registeredTitle: 'Account not registered',
|
space_account_not_registeredTitle: 'Account not registered',
|
||||||
space_account_not_registered:
|
space_account_not_registered:
|
||||||
'No local account is registered for this Space email. Ask the Workspace owner for an invitation.',
|
'No local account is registered for this LangBot Account email. Ask the Workspace owner for an invitation.',
|
||||||
space_account_binding_requiredTitle: 'Space connection required',
|
space_account_binding_requiredTitle: 'LangBot Account connection required',
|
||||||
space_account_binding_required:
|
space_account_binding_required:
|
||||||
'This local account must connect Space from Account settings before using Space login.',
|
'This local account must connect a LangBot Account from Account settings before using LangBot Account login.',
|
||||||
},
|
},
|
||||||
monitoring: {
|
monitoring: {
|
||||||
title: '儀表盤',
|
title: '儀表盤',
|
||||||
|
|||||||
@@ -503,6 +503,8 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) {
|
|||||||
if (path === '/api/v1/user/account-info') {
|
if (path === '/api/v1/user/account-info') {
|
||||||
return fulfillJson(route, {
|
return fulfillJson(route, {
|
||||||
initialized: true,
|
initialized: true,
|
||||||
|
authenticated_invitation_acceptance_enabled: false,
|
||||||
|
invitation_registration_enabled: true,
|
||||||
password_login_enabled: true,
|
password_login_enabled: true,
|
||||||
space_login_enabled: false,
|
space_login_enabled: false,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ test('login preserves an explicit invitation email mismatch error', async ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
await page.goto('/invitations/accept#token=mismatch-invitation');
|
await page.goto('/invitations/accept#token=mismatch-invitation');
|
||||||
await page.getByRole('button', { name: 'I already have an account' }).click();
|
await page.goto('/login?invitation=1');
|
||||||
await page.getByPlaceholder('Enter email address').fill('other@example.com');
|
await page.getByPlaceholder('Enter email address').fill('other@example.com');
|
||||||
await page.getByPlaceholder('Enter password').fill('password');
|
await page.getByPlaceholder('Enter password').fill('password');
|
||||||
await page.getByRole('button', { name: 'Login with password' }).click();
|
await page.getByRole('button', { name: 'Login with password' }).click();
|
||||||
@@ -107,6 +107,111 @@ test('login preserves an explicit invitation email mismatch error', async ({
|
|||||||
await expect(page.getByText('Login successful')).toHaveCount(0);
|
await expect(page.getByText('Login successful')).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('an OAuth-only OSS instance registers the invited email with a local password', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
let registration: { email?: string; password?: string } | undefined;
|
||||||
|
await installLangBotApiMocks(page, { authenticated: false });
|
||||||
|
await page.route('**/api/v1/user/account-info', async (route) => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 800));
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
code: 0,
|
||||||
|
data: {
|
||||||
|
initialized: true,
|
||||||
|
authenticated_invitation_acceptance_enabled: false,
|
||||||
|
invitation_registration_enabled: true,
|
||||||
|
password_login_enabled: false,
|
||||||
|
space_login_enabled: true,
|
||||||
|
},
|
||||||
|
msg: 'ok',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.route('**/api/v1/invitations/inspect', async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
code: 0,
|
||||||
|
data: {
|
||||||
|
invitation: {
|
||||||
|
uuid: 'oss-local-registration',
|
||||||
|
workspace_uuid: 'workspace-playwright',
|
||||||
|
normalized_email: 'invited@example.com',
|
||||||
|
role: 'viewer',
|
||||||
|
status: 'pending',
|
||||||
|
},
|
||||||
|
workspace: {
|
||||||
|
uuid: 'workspace-playwright',
|
||||||
|
name: 'Playwright Workspace',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
msg: 'ok',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.route('**/api/v1/invitations/accept', async (route) => {
|
||||||
|
const body = JSON.parse(route.request().postData() || '{}') as {
|
||||||
|
registration?: { email?: string; password?: string };
|
||||||
|
};
|
||||||
|
registration = body.registration;
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
code: 0,
|
||||||
|
data: {
|
||||||
|
login_required: true,
|
||||||
|
workspace_uuid: 'workspace-playwright',
|
||||||
|
},
|
||||||
|
msg: 'ok',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto('/invitations/accept#token=oss-local-registration');
|
||||||
|
|
||||||
|
await expect(page.getByText('Playwright Workspace')).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByRole('button', { name: 'Login with LangBot Account' }),
|
||||||
|
).toHaveCount(0);
|
||||||
|
await expect(page.locator('#invite-email')).toHaveValue(
|
||||||
|
'invited@example.com',
|
||||||
|
);
|
||||||
|
await expect(page.locator('#invite-email')).toHaveAttribute('readonly', '');
|
||||||
|
await expect(page.locator('#invite-password')).toBeVisible();
|
||||||
|
await expect(page.locator('#invite-password-confirm')).toBeVisible();
|
||||||
|
for (const inputId of ['invite-password', 'invite-password-confirm']) {
|
||||||
|
const input = page.locator(`#${inputId}`);
|
||||||
|
const field = input.locator('xpath=..');
|
||||||
|
await expect(field).toHaveClass(/relative/);
|
||||||
|
await expect(field.locator('svg')).toBeVisible();
|
||||||
|
await expect(input).toHaveClass(/pl-10/);
|
||||||
|
}
|
||||||
|
await expect(
|
||||||
|
page.getByRole('button', { name: 'Create account and accept' }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByRole('button', { name: 'I already have an account' }),
|
||||||
|
).toHaveCount(0);
|
||||||
|
await expect(
|
||||||
|
page.getByRole('button', { name: 'Login with LangBot Account' }),
|
||||||
|
).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.locator('#invite-password').fill('invite-password-123');
|
||||||
|
await page.locator('#invite-password-confirm').fill('invite-password-123');
|
||||||
|
await page.getByRole('button', { name: 'Create account and accept' }).click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/login\?invitation=1$/);
|
||||||
|
expect(registration).toEqual({
|
||||||
|
email: 'invited@example.com',
|
||||||
|
password: 'invite-password-123',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('an authenticated OSS invitation requires logout before registration', async ({
|
test('an authenticated OSS invitation requires logout before registration', async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -185,6 +290,7 @@ test('an authenticated Cloud Account can accept its invitation directly', async
|
|||||||
data: {
|
data: {
|
||||||
initialized: true,
|
initialized: true,
|
||||||
authenticated_invitation_acceptance_enabled: true,
|
authenticated_invitation_acceptance_enabled: true,
|
||||||
|
invitation_registration_enabled: false,
|
||||||
password_login_enabled: false,
|
password_login_enabled: false,
|
||||||
space_login_enabled: true,
|
space_login_enabled: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
import { installLangBotApiMocks } from './fixtures/langbot-api';
|
||||||
|
|
||||||
|
test('an OSS local-only owner is prompted to bind before using LangBot Models', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await installLangBotApiMocks(page, { authenticated: true });
|
||||||
|
await page.route('**/api/v1/user/space-credits', (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
code: 0,
|
||||||
|
data: {
|
||||||
|
credits: null,
|
||||||
|
owner_space_bound: false,
|
||||||
|
is_workspace_owner: true,
|
||||||
|
},
|
||||||
|
msg: 'ok',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.route('**/api/v1/provider/providers', (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
code: 0,
|
||||||
|
data: {
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
uuid: 'langbot-models-provider',
|
||||||
|
name: 'LangBot Models',
|
||||||
|
requester: 'space-chat-completions',
|
||||||
|
base_url: '',
|
||||||
|
api_keys: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
msg: 'ok',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.route('**/api/v1/provider/requesters**', (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ code: 0, data: { requesters: [] }, msg: 'ok' }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.route('**/api/v1/provider/models/**', (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ code: 0, data: { models: [] }, msg: 'ok' }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto('/home?action=showModelSettings');
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByRole('button', {
|
||||||
|
name: 'The Workspace owner must connect a LangBot Account for LangBot Models.',
|
||||||
|
}),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
@@ -69,7 +69,55 @@ test('loads a Cloud plugin page through the authenticated asset route', async ({
|
|||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: 'text/html',
|
contentType: 'text/html',
|
||||||
body: '<!doctype html><html><body><h1>LangRAG Observability</h1></body></html>',
|
body: `<!doctype html>
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<h1>LangRAG Observability</h1>
|
||||||
|
<button id="save">Save</button>
|
||||||
|
<script src="/api/v1/plugins/_sdk/page-sdk.js"></script>
|
||||||
|
<script>
|
||||||
|
document.querySelector('#save').addEventListener('click', async () => {
|
||||||
|
await window.langbot.api('/settings', { enabled: true }, 'POST');
|
||||||
|
document.body.dataset.saved = 'true';
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let pageSdkRequests = 0;
|
||||||
|
await page.route('**/api/v1/plugins/_sdk/page-sdk.js', async (route) => {
|
||||||
|
pageSdkRequests += 1;
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/javascript',
|
||||||
|
body: `window.langbot = {
|
||||||
|
api(endpoint, body, method) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const requestId = 'request-' + Date.now();
|
||||||
|
const handler = (event) => {
|
||||||
|
if (event.data?.type === 'langbot:api:response' && event.data.requestId === requestId) {
|
||||||
|
window.removeEventListener('message', handler);
|
||||||
|
resolve(event.data.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('message', handler);
|
||||||
|
window.parent.postMessage({ type: 'langbot:api', requestId, endpoint, body, method }, '*');
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
let pageApiRequests = 0;
|
||||||
|
await page.route(
|
||||||
|
'**/api/v1/plugins/langbot-team/LangRAG/page-api',
|
||||||
|
async (route) => {
|
||||||
|
pageApiRequests += 1;
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: wrapped({ saved: true }),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -78,11 +126,17 @@ test('loads a Cloud plugin page through the authenticated asset route', async ({
|
|||||||
'/home/plugin-pages?id=langbot-team%2FLangRAG%2Fobservability',
|
'/home/plugin-pages?id=langbot-team%2FLangRAG%2Fobservability',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const pluginFrame = page.frameLocator('iframe');
|
||||||
await expect(
|
await expect(
|
||||||
page
|
pluginFrame.getByRole('heading', { name: 'LangRAG Observability' }),
|
||||||
.frameLocator('iframe')
|
|
||||||
.getByRole('heading', { name: 'LangRAG Observability' }),
|
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
|
await pluginFrame.getByRole('button', { name: 'Save' }).click();
|
||||||
|
await expect(pluginFrame.locator('body')).toHaveAttribute(
|
||||||
|
'data-saved',
|
||||||
|
'true',
|
||||||
|
);
|
||||||
expect(authenticatedAssetRequests).toBeGreaterThan(0);
|
expect(authenticatedAssetRequests).toBeGreaterThan(0);
|
||||||
|
expect(pageSdkRequests).toBe(1);
|
||||||
|
expect(pageApiRequests).toBe(1);
|
||||||
await expect(page.getByText('Loading...')).toHaveCount(0);
|
await expect(page.getByText('Loading...')).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readdirSync, readFileSync } from 'node:fs';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
const localeDir = new URL('../../src/i18n/locales/', import.meta.url);
|
||||||
|
const localeFiles = readdirSync(localeDir).filter((name) =>
|
||||||
|
name.endsWith('.ts'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const deprecatedAccountCopy = [
|
||||||
|
/Initialize with Space/i,
|
||||||
|
/Login with Space/i,
|
||||||
|
/Logging in with Space/i,
|
||||||
|
/Space login/i,
|
||||||
|
/Space accounts?/i,
|
||||||
|
/Bind Space Account/i,
|
||||||
|
/Authorize with Space/i,
|
||||||
|
/通过 Space 登录/,
|
||||||
|
/使用 Space 登录/,
|
||||||
|
/Space 登录/,
|
||||||
|
/Space 账户/,
|
||||||
|
/Space 帳戶/,
|
||||||
|
/绑定 Space/,
|
||||||
|
/綁定 Space/,
|
||||||
|
/Space アカウント/,
|
||||||
|
/Space でログイン/,
|
||||||
|
/cuenta de Space/i,
|
||||||
|
/cuentas de Space/i,
|
||||||
|
/cuenta Space/i,
|
||||||
|
/tài khoản Space/i,
|
||||||
|
/บัญชี Space/,
|
||||||
|
/аккаунт(?:ов|а)? Space/i,
|
||||||
|
/аккаунт Space/i,
|
||||||
|
];
|
||||||
|
|
||||||
|
test('user-facing account authentication copy uses LangBot Account terminology', () => {
|
||||||
|
const violations = [];
|
||||||
|
|
||||||
|
for (const file of localeFiles) {
|
||||||
|
const source = readFileSync(new URL(file, localeDir), 'utf8');
|
||||||
|
for (const pattern of deprecatedAccountCopy) {
|
||||||
|
if (pattern.test(source)) violations.push(`${file}: ${pattern}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.deepEqual(violations, []);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user