mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-02 15:47:16 +00:00
Compare commits
38 Commits
e934f08adf
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| c8d8b1aac4 | |||
| 018dd7a363 | |||
| 7b7d3f04e8 | |||
| 601c6975ea | |||
| 5ca30133a3 | |||
| 5c49cb60e3 | |||
| 8cf0015502 | |||
| bf8d418ad4 | |||
| 7aab0cee07 | |||
| 1b7ae791b3 | |||
| e69a80f5e9 | |||
| bafdaf0033 | |||
| aeff8d7e30 | |||
| be3734ffda | |||
| 855ae2bdba | |||
| b66db86bff | |||
| 95b8736e93 | |||
| cabde423a1 | |||
| 08307790e5 | |||
| 777fe1f20b | |||
| f0ee57c1e0 | |||
| a45e27e76e | |||
| 536fcdf29f | |||
| c87548c0b9 | |||
| 79634772da | |||
| bb366779af | |||
| 1336f47cb4 | |||
| 962366c507 | |||
| 23875b240f | |||
| e699358a5a | |||
| 14277d129c | |||
| 6bf1546df2 | |||
| 0bec72a3f9 | |||
| f36542135a | |||
| 693c59b726 | |||
| c3fe312a43 | |||
| c4bad508d2 | |||
| 3d4a726cd8 |
@@ -7,23 +7,42 @@ on:
|
||||
jobs:
|
||||
build-dev-image:
|
||||
runs-on: ubuntu-latest
|
||||
# 如果是tag则跳过
|
||||
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Generate Tag
|
||||
id: generate_tag
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Generate image metadata
|
||||
id: image
|
||||
shell: bash
|
||||
run: |
|
||||
# 获取分支名称,把/替换为-
|
||||
echo ${{ github.ref }} | sed 's/refs\/heads\///g' | sed 's/\//-/g'
|
||||
echo ::set-output name=tag::$(echo ${{ github.ref }} | sed 's/refs\/heads\///g' | sed 's/\//-/g')
|
||||
- name: Login to Registry
|
||||
run: docker login --username=${{ secrets.DOCKER_USERNAME }} --password ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Build Docker Image
|
||||
run: |
|
||||
docker buildx create --name mybuilder --use
|
||||
docker build -t rockchin/langbot:${{ steps.generate_tag.outputs.tag }} . --push
|
||||
set -euo pipefail
|
||||
branch_tag="${GITHUB_REF#refs/heads/}"
|
||||
branch_tag="${branch_tag//\//-}"
|
||||
echo "branch_tag=${branch_tag}" >> "$GITHUB_OUTPUT"
|
||||
echo "sha_tag=sha-${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Build and push immutable Core image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: |
|
||||
rockchin/langbot:${{ steps.image.outputs.branch_tag }}
|
||||
rockchin/langbot:${{ steps.image.outputs.sha_tag }}
|
||||
labels: |
|
||||
org.opencontainers.image.revision=${{ github.sha }}
|
||||
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
|
||||
|
||||
+1
-1
@@ -62,7 +62,7 @@ RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends nodejs \
|
||||
&& rm -f /tmp/nodesource_setup.sh \
|
||||
&& python -m pip install --no-cache-dir uv \
|
||||
&& uv sync \
|
||||
&& uv sync --extra seekdb \
|
||||
&& apt-get purge -y --auto-remove curl git gnupg \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& touch /.dockerenv
|
||||
|
||||
@@ -10,6 +10,19 @@ uvx langbot
|
||||
|
||||
This will automatically download and run the latest version of LangBot.
|
||||
|
||||
SeekDB support is optional and is not installed by the command above. If you
|
||||
want to use the SeekDB vector database or the built-in SeekDB embedding model,
|
||||
run LangBot with the `seekdb` extra:
|
||||
|
||||
```bash
|
||||
uvx --from 'langbot[seekdb]@latest' langbot
|
||||
```
|
||||
|
||||
The extra includes native dependencies whose supported operating systems may
|
||||
be narrower than LangBot's. In particular, the current Apple Silicon wheels
|
||||
require macOS 15 or later. The default Chroma backend does not have this
|
||||
requirement.
|
||||
|
||||
## Install with pip/uv
|
||||
|
||||
You can also install LangBot as a regular Python package:
|
||||
@@ -20,6 +33,10 @@ pip install langbot
|
||||
|
||||
# Using uv
|
||||
uv pip install langbot
|
||||
|
||||
# Include optional SeekDB support
|
||||
pip install 'langbot[seekdb]'
|
||||
# or: uv pip install 'langbot[seekdb]'
|
||||
```
|
||||
|
||||
Then run it:
|
||||
@@ -101,7 +118,7 @@ uvx langbot
|
||||
|
||||
## System Requirements
|
||||
|
||||
- Python 3.10.1 or higher
|
||||
- Python 3.11 or higher (lower than Python 4)
|
||||
- Operating System: Linux, macOS, or Windows
|
||||
|
||||
## Differences from Source Installation
|
||||
|
||||
+34
-43
@@ -16,12 +16,20 @@ This document describes how to use OceanBase SeekDB as the vector database backe
|
||||
|
||||
## 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
|
||||
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
|
||||
@@ -30,31 +38,36 @@ pip install pyseekdb
|
||||
|
||||
| Platform | Status | Notes |
|
||||
|----------|--------|-------|
|
||||
| Linux | ✅ Supported | Full embedded mode support via `pylibseekdb` |
|
||||
| macOS | ❌ Not Supported | `pylibseekdb` is Linux-only; use server mode instead |
|
||||
| Windows | ❌ Not Supported | `pylibseekdb` is Linux-only; use server mode instead |
|
||||
| Linux x86_64 / ARM64 | ✅ Supported | Full embedded mode support via `pylibseekdb` |
|
||||
| macOS 15+ on Apple Silicon | ✅ Supported | Requires the macOS ARM64 `pylibseekdb` wheel |
|
||||
| macOS 14 or earlier on Apple Silicon | ❌ Not currently supported | The published native wheel requires macOS 15+; follow [oceanbase/seekdb#1324](https://github.com/oceanbase/seekdb/issues/1324) |
|
||||
| macOS on Intel | ❌ Not currently supported | No embedded binding is selected by `pyseekdb` |
|
||||
| Windows | ❌ Not currently supported | No Windows `pylibseekdb` wheel is published |
|
||||
|
||||
**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)
|
||||
|
||||
| Platform | Status | Notes |
|
||||
|----------|--------|-------|
|
||||
| Linux | ✅ Supported | Full Docker support |
|
||||
| macOS | ⚠️ Known Issue | Docker container initialization failure - [See Issue #36](https://github.com/oceanbase/seekdb/issues/36) |
|
||||
| Windows | ⚠️ Untested | Should work but not yet tested |
|
||||
|
||||
**macOS Users**: Currently, SeekDB Docker containers have an initialization issue on macOS ([oceanbase/seekdb#36](https://github.com/oceanbase/seekdb/issues/36)). Until this is resolved, we recommend:
|
||||
- Using ChromaDB or Qdrant as alternatives
|
||||
- Connecting to a remote SeekDB server on Linux if available
|
||||
| macOS | ✅ Supported by Docker Desktop | The previous slow-disk startup issue was fixed upstream in [oceanbase/seekdb#36](https://github.com/oceanbase/seekdb/issues/36) |
|
||||
| Windows | ⚠️ Depends on the container runtime | Use a Linux container and follow the upstream image documentation |
|
||||
|
||||
### Server Mode (Remote Connection)
|
||||
|
||||
| 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
|
||||
|
||||
@@ -170,22 +183,23 @@ Key methods:
|
||||
|
||||
### Import Error
|
||||
|
||||
If you see: `ImportError: pyseekdb is not installed`
|
||||
If you see: `SeekDB support is not installed`
|
||||
|
||||
Solution:
|
||||
```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**:
|
||||
```
|
||||
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:
|
||||
1. Deploy SeekDB on a Linux server or VM
|
||||
@@ -208,29 +222,6 @@ vdb:
|
||||
use: chroma # or qdrant
|
||||
```
|
||||
|
||||
### Docker Container Fails on macOS
|
||||
|
||||
**Symptoms**:
|
||||
```bash
|
||||
docker run -d -p 2881:2881 oceanbase/seekdb:latest
|
||||
# Container exits immediately with code 30
|
||||
```
|
||||
|
||||
**Error in logs**:
|
||||
```
|
||||
[ERROR] Code: Agent.SeekDB.Not.Exists
|
||||
Message: initialize failed: init agent failed: SeekDB not exists in current directory.
|
||||
```
|
||||
|
||||
**Cause**: This is a known issue with SeekDB Docker containers on macOS. See [oceanbase/seekdb#36](https://github.com/oceanbase/seekdb/issues/36).
|
||||
|
||||
**Status**: Under investigation by OceanBase team.
|
||||
|
||||
**Workaround Options**:
|
||||
1. **Use alternatives**: ChromaDB or Qdrant work perfectly on macOS
|
||||
2. **Remote server**: Deploy SeekDB on a Linux server and connect remotely
|
||||
3. **Wait for fix**: Monitor the GitHub issue for updates
|
||||
|
||||
### Connection Error (Server Mode)
|
||||
|
||||
If SeekDB server is not reachable, check:
|
||||
|
||||
+7
-3
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "langbot"
|
||||
version = "4.10.7"
|
||||
version = "4.10.9"
|
||||
description = "Production-grade platform for building agentic IM bots"
|
||||
readme = "README.md"
|
||||
license-files = ["LICENSE"]
|
||||
@@ -70,8 +70,7 @@ dependencies = [
|
||||
"langchain-text-splitters>=1.1.2",
|
||||
"chromadb>=1.0.0,<2.0.0",
|
||||
"qdrant-client (>=1.15.1,<2.0.0)",
|
||||
"pyseekdb==1.1.0.post3",
|
||||
"langbot-plugin==0.5.5",
|
||||
"langbot-plugin==0.5.6",
|
||||
"asyncpg>=0.30.0",
|
||||
"line-bot-sdk>=3.19.0",
|
||||
"matrix-nio>=0.25.2",
|
||||
@@ -108,6 +107,11 @@ classifiers = [
|
||||
"Topic :: Communications :: Chat",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
seekdb = [
|
||||
"pyseekdb==1.1.0.post3",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://langbot.app"
|
||||
Documentation = "https://docs.langbot.app"
|
||||
|
||||
@@ -1349,7 +1349,8 @@
|
||||
"local-agent",
|
||||
"tools",
|
||||
"e2b",
|
||||
"nsjail"
|
||||
"nsjail",
|
||||
"host"
|
||||
],
|
||||
"automation": "",
|
||||
"setup_automation": [],
|
||||
|
||||
@@ -63,7 +63,7 @@ Key settings:
|
||||
| `api.global_api_key` | **Global API key** for the HTTP API + MCP server. Non-empty = accepted with no login/DB record; no `lbk_` prefix required. Empty = disabled. Plaintext — trusted/internal only, serve over HTTPS. |
|
||||
| `plugin.runtime_ws_url` | Standalone plugin runtime WS URL (e.g. `ws://langbot_plugin_runtime:5400/control/ws`) |
|
||||
| `box.enabled` | Master switch for the Box sandbox runtime |
|
||||
| `box.backend` | `local` (Docker/nsjail autopick) / `docker` / `nsjail` / `e2b`; env override `BOX__BACKEND` |
|
||||
| `box.backend` | `local` (Docker/nsjail autopick) / `docker` / `nsjail` / `e2b` / explicit unsafe `host`; env override `BOX__BACKEND` |
|
||||
| `box.runtime.endpoint` | External Box runtime URL (e.g. `ws://127.0.0.1:5410`); empty = local auto-managed |
|
||||
|
||||
Many keys have `ENV__SUBKEY` overrides (e.g. `BOX__BACKEND`, `BOX__ENABLED`).
|
||||
@@ -75,6 +75,10 @@ Many keys have `ENV__SUBKEY` overrides (e.g. `BOX__BACKEND`, `BOX__ENABLED`).
|
||||
with `--standalone-runtime`.
|
||||
- Box has a parallel `--standalone-box` flag; the Docker box host is
|
||||
`langbot_box:5410`.
|
||||
- `box.backend: host` runs commands directly as the Box Runtime system user.
|
||||
It is never auto-selected, provides no sandbox isolation, and is only for
|
||||
trusted local development. A WebSocket-controlled host backend requires
|
||||
`LANGBOT_BOX_CONTROL_TOKEN`; local stdio control is allowed.
|
||||
|
||||
## Global API key — enabling for agents/automation
|
||||
|
||||
@@ -93,5 +97,7 @@ login session. See `langbot-mcp-ops` for using it, and `docs/API_KEY_AUTH.md`.
|
||||
- "No supported sandbox backend (Docker / nsjail / E2B)" with Docker running
|
||||
usually means the user isn't in the `docker` group →
|
||||
`sudo usermod -aG docker <user>` and restart in a new shell.
|
||||
- Do not use `box.backend: host` as a production fallback. It cannot enforce
|
||||
image, filesystem, network, PID, CPU, memory, or storage isolation.
|
||||
- Box root host/container path mismatch breaks sandbox container creation.
|
||||
- Don't commit a non-empty `api.global_api_key` to version control.
|
||||
|
||||
@@ -75,6 +75,8 @@ shape as the corresponding HTTP API request body. Discover resources with the
|
||||
`list_*` / `get_*` tools before mutating; identifiers are UUIDs. Reads require
|
||||
`resource.view`; mutations require `resource.manage`. All service calls inherit
|
||||
the immutable Workspace context authenticated at the MCP transport boundary.
|
||||
Pass `is_default: true` to `create_pipeline` only when the Workspace does not
|
||||
already have a default pipeline.
|
||||
|
||||
## How to use
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ tags:
|
||||
- tools
|
||||
- e2b
|
||||
- nsjail
|
||||
- host
|
||||
skills:
|
||||
- langbot-env-setup
|
||||
- langbot-testing
|
||||
@@ -23,7 +24,7 @@ env:
|
||||
- LANGBOT_LOCAL_AGENT_PIPELINE_NAME
|
||||
preconditions:
|
||||
- "LANGBOT_LOCAL_AGENT_PIPELINE_URL or LANGBOT_LOCAL_AGENT_PIPELINE_NAME points to the local-agent pipeline under test."
|
||||
- "LangBot is started with the sandbox backend intended for this run, such as e2b or nsjail."
|
||||
- "LangBot is started with the Box backend intended for this run, such as e2b, nsjail, or explicit host development mode."
|
||||
- "The selected model route supports tool/function calling strongly enough to invoke sandbox tools."
|
||||
steps:
|
||||
- "Start LangBot with the target sandbox backend and confirm the Box status UI or LANGBOT_BACKEND_URL /api/v1/box/status reports the expected backend."
|
||||
@@ -33,7 +34,7 @@ steps:
|
||||
checks:
|
||||
- "UI: Debug Chat final assistant response contains E2E_OK:<skill-name>."
|
||||
- "Logs: The model called exec, register_skill, activate, then exec again from the activated skill path."
|
||||
- "Logs: The selected backend name is the expected one, such as e2b or nsjail."
|
||||
- "Logs: The selected backend name is the expected one, such as e2b, nsjail, or host."
|
||||
- "Skill store: The registered package and activated writeback match references/sandbox-skill-authoring.md."
|
||||
- "Box status: recent_error_count is 0 after the run."
|
||||
evidence_required:
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
Verify that Local Agent can use sandbox tools to create, register, activate, and use a LangBot skill package through the same path a user would exercise in Debug Chat.
|
||||
|
||||
This flow applies to Docker, nsjail, and E2B backends. API calls are useful diagnostics, but the primary pass/fail signal is the model-driven Debug Chat tool sequence.
|
||||
This flow applies to Docker, nsjail, E2B, and the explicit host development backend. Host runs commands directly as the Box Runtime user and must never be treated as sandbox-isolation coverage. API calls are useful diagnostics, but the primary pass/fail signal is the model-driven Debug Chat tool sequence.
|
||||
|
||||
## Preconditions
|
||||
|
||||
@@ -13,6 +13,7 @@ This flow applies to Docker, nsjail, and E2B backends. API calls are useful diag
|
||||
- `BOX_BACKEND=e2b` when validating E2B.
|
||||
- `BOX_BACKEND=nsjail` when validating nsjail.
|
||||
- `BOX_BACKEND=local` or `docker` when validating local container fallback.
|
||||
- `BOX_BACKEND=host` only when validating explicit, trusted local direct execution.
|
||||
3. Confirm `/api/v1/box/status` reports `available: true` and the expected backend name.
|
||||
4. Confirm Debug Chat uses a model with function-calling ability.
|
||||
5. Confirm backend logs say native sandbox tools are available.
|
||||
@@ -71,7 +72,7 @@ Backend logs should show:
|
||||
- `register_skill`
|
||||
- `activate`
|
||||
- a second `exec` whose workdir is `/workspace/.skills/<skill-name>`
|
||||
- `backend=e2b`, `backend=nsjail`, or the expected local backend
|
||||
- `backend=e2b`, `backend=nsjail`, `backend=host`, or the expected local backend
|
||||
|
||||
After the run, verify the skill store through the UI or API:
|
||||
|
||||
@@ -125,6 +126,8 @@ For E2B raw HTTP diagnostics, include a valid template id such as `base`; a miss
|
||||
- Session metadata should keep LangBot logical paths such as `/workspace`; storing provider-internal paths can make later requests look incompatible.
|
||||
- nsjail versions differ. Some expose only `--disable_clone_new*` flags and use `--bindmount` instead of `--rw_bind`.
|
||||
- On WSL, cgroup v2 may exist but not be writable. The backend should warn and fall back to rlimits rather than fail the sandbox.
|
||||
- The host backend does not honor sandbox image, network, rootfs, process, or
|
||||
resource isolation. Use a disposable workspace and low-privilege account.
|
||||
- If `ALL_PROXY` uses a SOCKS URL and `socksio` is not installed, some Python HTTP clients can fail during startup. Prefer consistent HTTP proxy variables unless SOCKS support is installed.
|
||||
|
||||
## Related Troubleshooting
|
||||
|
||||
@@ -3,7 +3,7 @@ title: "Native sandbox tools are unavailable even though a backend is configured
|
||||
date: 2026-05-18
|
||||
symptoms:
|
||||
- "Backend logs show Native sandbox tools (exec/read/write/edit/glob/grep) are NOT available."
|
||||
- "The Box runtime later reports that E2B, nsjail, or Docker is configured."
|
||||
- "The Box runtime later reports that E2B, nsjail, Docker, or explicit host mode is configured."
|
||||
- "Debug Chat does not expose exec, register_skill, or activate as usable tools."
|
||||
patterns:
|
||||
- "Native sandbox tools ... are NOT available"
|
||||
@@ -19,6 +19,7 @@ fix_steps:
|
||||
- "Ensure the Box runtime reselects a backend when get_backend_info is called and the cached backend is empty."
|
||||
- "For E2B, verify the key without printing it and confirm any required template setting."
|
||||
- "For nsjail, run nsjail --help and confirm the binary is on PATH for the LangBot process."
|
||||
- "For trusted local development only, explicitly set box.backend=host; never use host as a production sandbox fallback."
|
||||
verification: "Run sandbox-skill-authoring-e2e. Logs should show Native sandbox tools are available and /api/v1/box/status should report available=true with the expected backend."
|
||||
related_cases:
|
||||
- sandbox-skill-authoring-e2e
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import httpx
|
||||
import typing
|
||||
import json
|
||||
import os
|
||||
import typing
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from .errors import DifyAPIError
|
||||
from pathlib import Path
|
||||
import os
|
||||
|
||||
_MAX_DIFY_RESPONSE_BYTES = 1024 * 1024
|
||||
_MAX_DIFY_SSE_LINE_BYTES = 1024 * 1024
|
||||
@@ -15,6 +16,32 @@ _MAX_DIFY_STREAM_BYTES = 16 * 1024 * 1024
|
||||
_MAX_DIFY_UPLOAD_BYTES = 10 * 1024 * 1024
|
||||
|
||||
|
||||
def _decode_sse_data(line: bytes) -> dict[str, typing.Any] | None:
|
||||
data = line[5:].strip()
|
||||
if not data or data == b'[DONE]':
|
||||
return None
|
||||
try:
|
||||
payload = json.loads(data.decode('utf-8'))
|
||||
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
||||
raise DifyAPIError('Dify SSE data line is not valid JSON') from exc
|
||||
if not isinstance(payload, dict):
|
||||
raise DifyAPIError('Dify SSE event is not a JSON object')
|
||||
return payload
|
||||
|
||||
|
||||
def _decode_upload_response(body: bytes) -> dict[str, typing.Any]:
|
||||
try:
|
||||
response = json.loads(body)
|
||||
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
||||
raise DifyAPIError('Dify upload response is not valid JSON') from exc
|
||||
if not isinstance(response, dict):
|
||||
raise DifyAPIError('Dify upload response is not a JSON object')
|
||||
payload = response.get('data', response)
|
||||
if not isinstance(payload, dict) or not isinstance(payload.get('id'), str) or not payload['id']:
|
||||
raise DifyAPIError('Dify upload response does not contain a valid file id')
|
||||
return payload
|
||||
|
||||
|
||||
async def _read_limited_response(
|
||||
response: httpx.Response,
|
||||
*,
|
||||
@@ -56,16 +83,16 @@ async def _iter_sse_json(
|
||||
line = raw_line.rstrip(b'\r').strip()
|
||||
if not line or not line.startswith(b'data:'):
|
||||
continue
|
||||
payload = json.loads(line[5:].decode('utf-8', errors='replace'))
|
||||
if isinstance(payload, dict):
|
||||
payload = _decode_sse_data(line)
|
||||
if payload is not None:
|
||||
yield payload
|
||||
if len(buffer) > _MAX_DIFY_SSE_LINE_BYTES:
|
||||
raise DifyAPIError('Dify SSE event exceeds the runtime limit')
|
||||
|
||||
line = bytes(buffer).rstrip(b'\r').strip()
|
||||
if line.startswith(b'data:'):
|
||||
payload = json.loads(line[5:].decode('utf-8', errors='replace'))
|
||||
if isinstance(payload, dict):
|
||||
payload = _decode_sse_data(line)
|
||||
if payload is not None:
|
||||
yield payload
|
||||
|
||||
|
||||
@@ -242,7 +269,7 @@ class AsyncDifyServiceClient:
|
||||
file: httpx._types.FileTypes,
|
||||
user: str,
|
||||
timeout: float = 30.0,
|
||||
) -> str:
|
||||
) -> dict[str, typing.Any]:
|
||||
# 处理 Path 对象
|
||||
if isinstance(file, Path):
|
||||
if not file.exists():
|
||||
@@ -271,6 +298,6 @@ class AsyncDifyServiceClient:
|
||||
timeout=timeout,
|
||||
) as response:
|
||||
body = await _read_limited_response(response)
|
||||
if response.status_code != 201:
|
||||
if response.status_code not in (200, 201):
|
||||
raise DifyAPIError(f'{response.status_code} {body.decode(errors="replace")}')
|
||||
return json.loads(body)
|
||||
return _decode_upload_response(body)
|
||||
|
||||
@@ -697,9 +697,10 @@ class DingTalkClient:
|
||||
if not await self.check_access_token():
|
||||
await self.get_access_token()
|
||||
|
||||
cardData: dict = {'cardParamMap': _stringify_card_param_map(card_param_map)}
|
||||
template_params = dict(card_param_map or {})
|
||||
if card_data_config is not None:
|
||||
cardData['config'] = json.dumps(card_data_config)
|
||||
template_params['config'] = card_data_config
|
||||
cardData: dict = {'cardParamMap': _stringify_card_param_map(template_params)}
|
||||
|
||||
body: dict = {
|
||||
'cardTemplateId': card_template_id,
|
||||
|
||||
@@ -422,6 +422,69 @@ class QQOfficialClient:
|
||||
await self.logger.error(f'Failed to send private message: {response_data}')
|
||||
raise ValueError(response)
|
||||
|
||||
async def _send_markdown_msg(
|
||||
self,
|
||||
target_type: str,
|
||||
target_id: str,
|
||||
content: str,
|
||||
msg_id: Optional[str] = None,
|
||||
event_id: Optional[str] = None,
|
||||
msg_seq: int = 1,
|
||||
) -> None:
|
||||
"""Send a Markdown message to a C2C user or QQ group."""
|
||||
if not await self.check_access_token():
|
||||
await self.get_access_token()
|
||||
|
||||
if target_type == 'c2c':
|
||||
url = f'{self.base_url}/v2/users/{target_id}/messages'
|
||||
elif target_type == 'group':
|
||||
url = f'{self.base_url}/v2/groups/{target_id}/messages'
|
||||
else:
|
||||
raise ValueError(f'Unsupported Markdown target type: {target_type}')
|
||||
|
||||
data: dict[str, Any] = {
|
||||
'msg_type': 2,
|
||||
'markdown': {'content': content},
|
||||
'msg_seq': msg_seq,
|
||||
}
|
||||
if msg_id:
|
||||
data['msg_id'] = msg_id
|
||||
if event_id:
|
||||
data['event_id'] = event_id
|
||||
|
||||
async with self._http_client_context() as client:
|
||||
headers = {
|
||||
'Authorization': f'QQBot {self.access_token}',
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
response = await client.post(url, headers=headers, json=data)
|
||||
if response.status_code != 200:
|
||||
response_data = await httpclient.parse_json_response(response)
|
||||
await self.logger.error(f'Failed to send Markdown message: {response_data}')
|
||||
raise ValueError(response)
|
||||
|
||||
async def send_private_markdown_msg(
|
||||
self,
|
||||
user_openid: str,
|
||||
content: str,
|
||||
msg_id: Optional[str] = None,
|
||||
event_id: Optional[str] = None,
|
||||
msg_seq: int = 1,
|
||||
) -> None:
|
||||
"""Send a Markdown C2C message."""
|
||||
await self._send_markdown_msg('c2c', user_openid, content, msg_id, event_id, msg_seq)
|
||||
|
||||
async def send_group_markdown_msg(
|
||||
self,
|
||||
group_openid: str,
|
||||
content: str,
|
||||
msg_id: Optional[str] = None,
|
||||
event_id: Optional[str] = None,
|
||||
msg_seq: int = 1,
|
||||
) -> None:
|
||||
"""Send a Markdown QQ group message."""
|
||||
await self._send_markdown_msg('group', group_openid, content, msg_id, event_id, msg_seq)
|
||||
|
||||
async def send_group_text_msg(
|
||||
self,
|
||||
group_openid: str,
|
||||
|
||||
@@ -936,6 +936,13 @@ class WecomBotWsClient:
|
||||
'chat_type': message_data.get('type', 'single'),
|
||||
}
|
||||
self._prune_stream_state()
|
||||
# Send an initial empty stream frame so the WeCom client
|
||||
# shows its built-in loading spinner while the pipeline
|
||||
# processes the message (e.g. RAG retrieval).
|
||||
try:
|
||||
await self.reply_stream(req_id, stream_id, '', finish=False)
|
||||
except Exception:
|
||||
await self.logger.warning(f'Failed to send initial stream frame: {traceback.format_exc()}')
|
||||
message_data['stream_id'] = stream_id
|
||||
message_data['req_id'] = req_id
|
||||
|
||||
|
||||
@@ -295,6 +295,34 @@ class WecomCSClient:
|
||||
raise Exception('Failed to send message')
|
||||
return data
|
||||
|
||||
@_bounded_token_retry
|
||||
async def send_image_msg(self, open_kfid: str, external_userid: str, msgid: str, media_id: str):
|
||||
if not await self.check_access_token():
|
||||
self.access_token = await self.get_access_token(self.secret)
|
||||
|
||||
url = f'{self.base_url}/kf/send_msg?access_token={self.access_token}'
|
||||
payload = {
|
||||
'touser': external_userid,
|
||||
'open_kfid': open_kfid,
|
||||
'msgid': msgid,
|
||||
'msgtype': 'image',
|
||||
'image': {
|
||||
'media_id': media_id,
|
||||
},
|
||||
}
|
||||
|
||||
async with self._http_client_context() as client:
|
||||
response = await client.post(url, json=payload)
|
||||
|
||||
data = await httpclient.parse_json_response(response)
|
||||
if data['errcode'] == 40014 or data['errcode'] == 42001:
|
||||
self.access_token = await self.get_access_token(self.secret)
|
||||
return await self.send_image_msg(open_kfid, external_userid, msgid, media_id)
|
||||
if data['errcode'] != 0:
|
||||
await self.logger.error(f'发送图片失败:{data}')
|
||||
raise Exception('Failed to send image message')
|
||||
return data
|
||||
|
||||
async def handle_callback_request(self):
|
||||
"""处理回调请求(独立端口模式,使用全局 request)。"""
|
||||
return await self._handle_callback_internal(request)
|
||||
|
||||
@@ -218,6 +218,7 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
pipeline_ids = quart.request.args.getlist('pipelineId')
|
||||
start_time_str = quart.request.args.get('startTime')
|
||||
end_time_str = quart.request.args.get('endTime')
|
||||
user_query = quart.request.args.get('userQuery')
|
||||
is_active_str = quart.request.args.get('isActive')
|
||||
limit = int(quart.request.args.get('limit', 100))
|
||||
offset = int(quart.request.args.get('offset', 0))
|
||||
@@ -237,6 +238,7 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
pipeline_ids=pipeline_ids if pipeline_ids else None,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
user_query=user_query,
|
||||
is_active=is_active,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
@@ -396,7 +398,14 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
@self.route('/sessions/<session_id>/analysis', methods=['GET'], permission=Permission.RESOURCE_VIEW)
|
||||
async def get_session_analysis(session_id: str, request_context: RequestContext) -> str:
|
||||
"""Get detailed analysis for a specific session"""
|
||||
analysis = await self.ap.monitoring_service.get_session_analysis(request_context, session_id)
|
||||
start_time = parse_iso_datetime(quart.request.args.get('startTime'))
|
||||
end_time = parse_iso_datetime(quart.request.args.get('endTime'))
|
||||
analysis = await self.ap.monitoring_service.get_session_analysis(
|
||||
request_context,
|
||||
session_id,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
)
|
||||
|
||||
# Always return success with the analysis data
|
||||
# The frontend will handle the 'found: false' case
|
||||
|
||||
@@ -39,7 +39,13 @@ class PipelinesRouterGroup(group.RouterGroup):
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
pipeline_uuid = await self.ap.pipeline_service.create_pipeline(request_context, await quart.request.json)
|
||||
pipeline_data = await quart.request.json
|
||||
create_as_default = pipeline_data.get('is_default') is True
|
||||
pipeline_uuid = await self.ap.pipeline_service.create_pipeline(
|
||||
request_context,
|
||||
pipeline_data,
|
||||
default=create_as_default,
|
||||
)
|
||||
return self.success(data={'uuid': pipeline_uuid})
|
||||
|
||||
@self.route(
|
||||
|
||||
@@ -113,6 +113,24 @@ class BotsRouterGroup(group.RouterGroup):
|
||||
)
|
||||
return self.success(data={'sent': True})
|
||||
|
||||
@self.route(
|
||||
'/<bot_uuid>/test-inbound',
|
||||
methods=['POST'],
|
||||
auth_type=group.AuthType.USER_TOKEN,
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def _(bot_uuid: str, request_context: RequestContext) -> str:
|
||||
json_data = await quart.request.get_json(silent=True) or {}
|
||||
try:
|
||||
result = await self.ap.bot_service.send_http_bot_test_message(
|
||||
request_context,
|
||||
bot_uuid,
|
||||
str(json_data.get('message') or ''),
|
||||
)
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
return self.success(data=result)
|
||||
|
||||
@self.route(
|
||||
'/<bot_uuid>/admins',
|
||||
methods=['GET'],
|
||||
|
||||
@@ -206,6 +206,20 @@ class SystemRouterGroup(group.RouterGroup):
|
||||
|
||||
return self.success(data={})
|
||||
|
||||
@self.route(
|
||||
'/wizard/recommended-model',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN,
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
"""Resolve Space's best available chat model to this Workspace."""
|
||||
try:
|
||||
model = await self.ap.space_service.get_recommended_chat_model(request_context)
|
||||
except ValueError as exc:
|
||||
return self.http_status(503, -1, str(exc))
|
||||
return self.success(data=model)
|
||||
|
||||
@self.route(
|
||||
'/tasks',
|
||||
methods=['GET'],
|
||||
|
||||
@@ -9,6 +9,7 @@ from .. import group
|
||||
from .....entity.errors import account as account_errors
|
||||
from ...context import RequestContext
|
||||
from .....cloud.launch import SpaceLaunchError
|
||||
from .....workspace.errors import WorkspaceNotFoundError
|
||||
from ...service.user import ControlPlaneDirectoryRequiredError, PublicRegistrationClosedError
|
||||
|
||||
|
||||
@@ -143,6 +144,13 @@ class UserRouterGroup(group.RouterGroup):
|
||||
try:
|
||||
redirect_uri = self._validate_space_redirect_uri(redirect_uri, bind=False)
|
||||
launch_workspace_uuid = quart.request.args.get('launch_workspace_uuid')
|
||||
cloud_entry = quart.request.args.get('cloud_entry') == '1'
|
||||
if (
|
||||
cloud_entry
|
||||
and not launch_workspace_uuid
|
||||
and getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud'
|
||||
):
|
||||
return self.success(data={'authorize_url': self.ap.space_service.get_cloud_entry_url()})
|
||||
if launch_workspace_uuid:
|
||||
if not getattr(getattr(self.ap, 'deployment', None), 'multi_workspace_enabled', False):
|
||||
return self.fail(1, 'Space launch requires Cloud mode')
|
||||
@@ -322,6 +330,7 @@ class UserRouterGroup(group.RouterGroup):
|
||||
if cloud_mode:
|
||||
capabilities['password_login_enabled'] = False
|
||||
capabilities['authenticated_invitation_acceptance_enabled'] = cloud_mode
|
||||
capabilities['invitation_registration_enabled'] = not cloud_mode
|
||||
return self.success(data={'initialized': True, **capabilities})
|
||||
|
||||
@self.route('/set-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||
@@ -428,13 +437,48 @@ class UserRouterGroup(group.RouterGroup):
|
||||
)
|
||||
|
||||
account = await self.ap.user_service.get_user_by_uuid(launch['account_uuid'])
|
||||
projection_service = self.ap.directory_projection_service
|
||||
access = None
|
||||
# A first Cloud launch creates the personal Workspace immediately
|
||||
# before redirecting here. Pull a bounded number of signed event
|
||||
# pages until both the Account and its target Workspace membership
|
||||
# are visible instead of rejecting during the background-sync window.
|
||||
for attempt in range(4):
|
||||
if account is not None:
|
||||
self.ap.user_service._require_active_account(account)
|
||||
try:
|
||||
access = await self.ap.workspace_collaboration_service.resolve_account_workspace(
|
||||
account.uuid,
|
||||
launch['workspace_uuid'],
|
||||
)
|
||||
break
|
||||
except WorkspaceNotFoundError:
|
||||
if projection_service is None:
|
||||
raise
|
||||
elif projection_service is None:
|
||||
break
|
||||
|
||||
if attempt == 3:
|
||||
break
|
||||
await projection_service.sync_once()
|
||||
account = await self.ap.user_service.get_user_by_uuid(launch['account_uuid'])
|
||||
|
||||
if access is None and projection_service is not None:
|
||||
# The target event may be deeper than the bounded incremental
|
||||
# page budget. One authoritative signed snapshot catches this
|
||||
# process up without turning the callback into unbounded polling.
|
||||
await projection_service.refresh_snapshot()
|
||||
account = await self.ap.user_service.get_user_by_uuid(launch['account_uuid'])
|
||||
if account is not None:
|
||||
self.ap.user_service._require_active_account(account)
|
||||
access = await self.ap.workspace_collaboration_service.resolve_account_workspace(
|
||||
account.uuid,
|
||||
launch['workspace_uuid'],
|
||||
)
|
||||
if account is None:
|
||||
raise SpaceLaunchError('Launch Account is not projected into Core')
|
||||
self.ap.user_service._require_active_account(account)
|
||||
access = await self.ap.workspace_collaboration_service.resolve_account_workspace(
|
||||
account.uuid,
|
||||
launch['workspace_uuid'],
|
||||
)
|
||||
if access is None: # pragma: no cover - bounded loop resolves or raises.
|
||||
raise SpaceLaunchError('Launch Workspace is not projected into Core')
|
||||
token = await self.ap.user_service.generate_jwt_token(account)
|
||||
return self.success(
|
||||
data={
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
import json
|
||||
import sqlalchemy
|
||||
|
||||
from ....core import app
|
||||
@@ -8,6 +9,8 @@ from ....entity.persistence import bot as persistence_bot
|
||||
from ....entity.persistence import pipeline as persistence_pipeline
|
||||
from ....workspace.errors import WorkspaceNotFoundError
|
||||
from .tenant import TenantContext, require_workspace_uuid, scope_statement
|
||||
from ....utils import httpclient
|
||||
from ....platform.sources import http_bot_signing
|
||||
|
||||
|
||||
class BotService:
|
||||
@@ -80,6 +83,7 @@ class BotService:
|
||||
'wecomcs',
|
||||
'LINE',
|
||||
'lark',
|
||||
'http_bot',
|
||||
]:
|
||||
webhook_prefix = self.ap.instance_config.data['api'].get('webhook_prefix', 'http://127.0.0.1:5300')
|
||||
extra_webhook_prefix = self.ap.instance_config.data['api'].get('extra_webhook_prefix', '')
|
||||
@@ -216,6 +220,53 @@ class BotService:
|
||||
|
||||
return [log.to_json() for log in logs], total_count
|
||||
|
||||
async def send_http_bot_test_message(
|
||||
self,
|
||||
context: TenantContext,
|
||||
bot_uuid: str,
|
||||
message: str,
|
||||
) -> dict:
|
||||
"""Send a signed test message through the HTTP Bot public ingress."""
|
||||
bot = await self.get_bot(context, bot_uuid, include_secret=True)
|
||||
if bot is None:
|
||||
raise WorkspaceNotFoundError('Bot not found')
|
||||
if bot.get('adapter') != 'http_bot':
|
||||
raise ValueError('Inbound test is only available for HTTP Bot')
|
||||
if not bot.get('enable'):
|
||||
raise ValueError('Bot must be enabled before sending a test message')
|
||||
|
||||
text = message.strip()
|
||||
if not text or len(text) > 2000:
|
||||
raise ValueError('Test message must contain 1 to 2000 characters')
|
||||
|
||||
payload = {
|
||||
'session_id': f'wizard-{uuid.uuid4().hex}',
|
||||
'sender': {'id': 'wizard-user', 'name': 'Wizard Test'},
|
||||
'message': [{'type': 'Plain', 'text': text}],
|
||||
}
|
||||
body = json.dumps(payload, ensure_ascii=False, separators=(',', ':')).encode()
|
||||
config = bot.get('adapter_config') or {}
|
||||
headers = {'Content-Type': 'application/json'}
|
||||
if config.get('signature_required', True):
|
||||
secret = str(config.get('inbound_secret') or '')
|
||||
if not secret:
|
||||
raise ValueError('HTTP Bot inbound signing secret is required')
|
||||
timestamp, signature = http_bot_signing.sign(secret, body)
|
||||
headers[http_bot_signing.HEADER_TIMESTAMP] = timestamp
|
||||
headers[http_bot_signing.HEADER_SIGNATURE] = signature
|
||||
|
||||
port = int(self.ap.instance_config.data.get('api', {}).get('port', 5300))
|
||||
session = httpclient.get_session()
|
||||
async with session.post(
|
||||
f'http://127.0.0.1:{port}/bots/{bot_uuid}',
|
||||
data=body,
|
||||
headers=headers,
|
||||
) as response:
|
||||
result = await httpclient.read_json_limited(response)
|
||||
if response.status not in {200, 202}:
|
||||
raise ValueError(result.get('msg') or f'HTTP Bot test failed with status {response.status}')
|
||||
return result.get('data') or {}
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
context: TenantContext,
|
||||
|
||||
@@ -1257,6 +1257,7 @@ class MonitoringService:
|
||||
pipeline_ids: list[str] | None = None,
|
||||
start_time: datetime.datetime | None = None,
|
||||
end_time: datetime.datetime | None = None,
|
||||
user_query: str | None = None,
|
||||
is_active: bool | None = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
@@ -1274,6 +1275,14 @@ class MonitoringService:
|
||||
conditions.append(persistence_monitoring.MonitoringSession.start_time >= start_time)
|
||||
if end_time:
|
||||
conditions.append(persistence_monitoring.MonitoringSession.start_time <= end_time)
|
||||
if user_query and user_query.strip():
|
||||
user_pattern = f'%{user_query.strip()}%'
|
||||
conditions.append(
|
||||
sqlalchemy.or_(
|
||||
persistence_monitoring.MonitoringSession.user_id.ilike(user_pattern),
|
||||
persistence_monitoring.MonitoringSession.user_name.ilike(user_pattern),
|
||||
)
|
||||
)
|
||||
if is_active is not None:
|
||||
conditions.append(persistence_monitoring.MonitoringSession.is_active == is_active)
|
||||
|
||||
@@ -1365,6 +1374,8 @@ class MonitoringService:
|
||||
self,
|
||||
context: TenantContext,
|
||||
session_id: str,
|
||||
start_time: datetime.datetime | None = None,
|
||||
end_time: datetime.datetime | None = None,
|
||||
) -> dict:
|
||||
"""Get bounded session details with full statistics computed in SQL."""
|
||||
workspace_uuid = require_workspace_uuid(context)
|
||||
@@ -1478,12 +1489,17 @@ class MonitoringService:
|
||||
)
|
||||
)
|
||||
tool_stats = tool_stats_result.one()
|
||||
tool_conditions = [
|
||||
persistence_monitoring.MonitoringToolCall.workspace_uuid == workspace_uuid,
|
||||
persistence_monitoring.MonitoringToolCall.session_id == session_id,
|
||||
]
|
||||
if start_time is not None:
|
||||
tool_conditions.append(persistence_monitoring.MonitoringToolCall.timestamp >= start_time)
|
||||
if end_time is not None:
|
||||
tool_conditions.append(persistence_monitoring.MonitoringToolCall.timestamp <= end_time)
|
||||
tool_query = (
|
||||
sqlalchemy.select(persistence_monitoring.MonitoringToolCall)
|
||||
.where(
|
||||
persistence_monitoring.MonitoringToolCall.workspace_uuid == workspace_uuid,
|
||||
persistence_monitoring.MonitoringToolCall.session_id == session_id,
|
||||
)
|
||||
.where(*tool_conditions)
|
||||
.order_by(persistence_monitoring.MonitoringToolCall.timestamp.asc())
|
||||
.limit(detail_limit + 1)
|
||||
)
|
||||
|
||||
@@ -11,6 +11,9 @@ import sqlalchemy
|
||||
from ....core import app
|
||||
from ....entity.persistence import user
|
||||
from ....entity.dto.space_model import SpaceModel
|
||||
from ....entity.dto.space_model import SpaceModelSelection
|
||||
from ....entity.persistence import model as persistence_model
|
||||
from ....cloud.model_catalog import LANGBOT_MODELS_PROVIDER_REQUESTER
|
||||
|
||||
|
||||
_CREDITS_CACHE_TTL_SECONDS = 60
|
||||
@@ -121,6 +124,11 @@ class SpaceService:
|
||||
params['state'] = state
|
||||
return f'{authorize_url}?{urlencode(params)}'
|
||||
|
||||
def get_cloud_entry_url(self) -> str:
|
||||
"""Return the Space-owned Cloud selector for a Cloud Account login."""
|
||||
|
||||
return f'{self._get_space_config()["url"].rstrip("/")}/cloud?environment=beta'
|
||||
|
||||
async def exchange_oauth_code(
|
||||
self,
|
||||
code: str,
|
||||
@@ -238,3 +246,76 @@ class SpaceService:
|
||||
raise ValueError(f'Failed to get models: {data.get("msg")}')
|
||||
models_data = data.get('data', {}).get('models', [])
|
||||
return [SpaceModel.model_validate(model_dict) for model_dict in models_data]
|
||||
|
||||
async def get_model_selection(self, category: str) -> typing.List[SpaceModelSelection]:
|
||||
"""Return Space models in the availability-ranked selection order."""
|
||||
space_url = self._get_space_config()['url']
|
||||
session = httpclient.get_session()
|
||||
async with session.get(
|
||||
f'{space_url}/api/v1/models/selection',
|
||||
params={'category': category},
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
error = await httpclient.read_text_limited(response)
|
||||
raise ValueError(f'Failed to get model selection: {error}')
|
||||
payload = await httpclient.read_json_limited(response)
|
||||
if payload.get('code') != 0:
|
||||
raise ValueError(f'Failed to get model selection: {payload.get("msg")}')
|
||||
|
||||
data = payload.get('data', [])
|
||||
if isinstance(data, dict):
|
||||
data = data.get('models', data.get('items', []))
|
||||
if not isinstance(data, list):
|
||||
raise ValueError('Failed to get model selection: invalid response')
|
||||
|
||||
models = []
|
||||
for selection in data:
|
||||
if isinstance(selection, dict) and isinstance(selection.get('model'), dict):
|
||||
models.append(selection['model'])
|
||||
else:
|
||||
models.append(selection)
|
||||
return [SpaceModelSelection.model_validate(model) for model in models]
|
||||
|
||||
async def get_recommended_chat_model(self, context: typing.Any) -> dict:
|
||||
"""Resolve Space's first ranked chat model to a local Workspace model."""
|
||||
selection = await self.get_model_selection('chat')
|
||||
if not selection:
|
||||
raise ValueError('No recommended chat model is available')
|
||||
recommended = selection[0]
|
||||
|
||||
async def find_local_model():
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_model.LLMModel)
|
||||
.join(
|
||||
persistence_model.ModelProvider,
|
||||
sqlalchemy.and_(
|
||||
persistence_model.ModelProvider.workspace_uuid == persistence_model.LLMModel.workspace_uuid,
|
||||
persistence_model.ModelProvider.uuid == persistence_model.LLMModel.provider_uuid,
|
||||
),
|
||||
)
|
||||
.where(
|
||||
persistence_model.LLMModel.workspace_uuid == context.workspace_uuid,
|
||||
persistence_model.ModelProvider.requester == LANGBOT_MODELS_PROVIDER_REQUESTER,
|
||||
sqlalchemy.or_(
|
||||
persistence_model.LLMModel.uuid == recommended.uuid,
|
||||
persistence_model.LLMModel.name == recommended.model_id,
|
||||
),
|
||||
)
|
||||
)
|
||||
return result.first()
|
||||
|
||||
local_model = await find_local_model()
|
||||
if local_model is None:
|
||||
# OSS synchronizes the public catalog locally. Refresh once in case
|
||||
# the recommendation was published after this process started.
|
||||
from ..context import ExecutionContext
|
||||
|
||||
try:
|
||||
await self.ap.model_mgr.sync_new_models_from_space(ExecutionContext.from_request(context))
|
||||
except Exception:
|
||||
pass
|
||||
local_model = await find_local_model()
|
||||
|
||||
if local_model is None:
|
||||
raise ValueError('Recommended chat model is not available in this Workspace')
|
||||
return {'uuid': local_model.uuid, 'name': local_model.name}
|
||||
|
||||
@@ -147,7 +147,16 @@ class LangBotMCPServer:
|
||||
)
|
||||
async def create_pipeline(pipeline_data: dict) -> str:
|
||||
context = _authorized(Permission.RESOURCE_MANAGE)
|
||||
return _dump({'uuid': await ap.pipeline_service.create_pipeline(context, pipeline_data)})
|
||||
create_as_default = pipeline_data.get('is_default') is True
|
||||
return _dump(
|
||||
{
|
||||
'uuid': await ap.pipeline_service.create_pipeline(
|
||||
context,
|
||||
pipeline_data,
|
||||
default=create_as_default,
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@mcp.tool(description='Update a pipeline by UUID. `pipeline_data` matches the PUT body.')
|
||||
async def update_pipeline(pipeline_uuid: str, pipeline_data: dict) -> str:
|
||||
|
||||
@@ -455,7 +455,9 @@ class BoxService:
|
||||
|
||||
async def _require_validated_workspace_sandbox(self, execution_context: ExecutionContext) -> None:
|
||||
if not self._available:
|
||||
raise BoxError('Box runtime is not available. Install and start Docker to use sandbox features.')
|
||||
raise BoxError(
|
||||
'Box runtime is not available. Configure an available Box backend before using Box features.'
|
||||
)
|
||||
if self._cloud_managed:
|
||||
if self._admission is None:
|
||||
raise BoxAdmissionError('Cloud Box sandbox admission is unavailable')
|
||||
@@ -565,7 +567,9 @@ class BoxService:
|
||||
skip_host_mount_validation: bool = False,
|
||||
) -> dict:
|
||||
if not self._available:
|
||||
raise BoxError('Box runtime is not available. Install and start Docker to use sandbox features.')
|
||||
raise BoxError(
|
||||
'Box runtime is not available. Configure an available Box backend before using Box features.'
|
||||
)
|
||||
execution_context = await self._validated_execution_context(self._query_execution_context(query))
|
||||
spec_payload = self._managed_policy_payload(execution_context, spec_payload)
|
||||
await self._require_validated_workspace_sandbox(execution_context)
|
||||
@@ -2142,5 +2146,8 @@ class BoxService:
|
||||
if backend_name:
|
||||
payload['connector_error'] = f'Configured sandbox backend "{backend_name}" is unavailable'
|
||||
else:
|
||||
payload['connector_error'] = 'No supported sandbox backend (Docker / nsjail / E2B) is available'
|
||||
payload['connector_error'] = (
|
||||
'No supported sandbox backend (Docker / nsjail / E2B) is available. '
|
||||
'Trusted local development may explicitly select the unsafe host backend.'
|
||||
)
|
||||
return payload
|
||||
|
||||
@@ -125,10 +125,21 @@ class DirectoryProjectionService:
|
||||
# The database cursor remains the shared projection high-water mark,
|
||||
# while this cursor tracks what this process has actually observed.
|
||||
self._consumer_cursor: int | None = None
|
||||
self._sync_lock = asyncio.Lock()
|
||||
|
||||
async def initialize(self) -> None:
|
||||
"""Block Cloud startup until one full signed snapshot is committed."""
|
||||
|
||||
async with self._sync_lock:
|
||||
await self._refresh_snapshot()
|
||||
|
||||
async def refresh_snapshot(self) -> None:
|
||||
"""Refresh from one full signed snapshot within the sync single-flight."""
|
||||
|
||||
async with self._sync_lock:
|
||||
await self._refresh_snapshot()
|
||||
|
||||
async def _refresh_snapshot(self) -> None:
|
||||
last_superseded: _DirectorySnapshotSuperseded | None = None
|
||||
for _attempt in range(5):
|
||||
snapshot = await self.provider.fetch_snapshot(self.instance_uuid)
|
||||
@@ -159,9 +170,13 @@ class DirectoryProjectionService:
|
||||
delay = min(max(delay * 2, self.sync_interval_seconds), self.max_staleness_seconds / 2)
|
||||
|
||||
async def sync_once(self) -> None:
|
||||
async with self._sync_lock:
|
||||
await self._sync_once()
|
||||
|
||||
async def _sync_once(self) -> None:
|
||||
cursor = self._consumer_cursor
|
||||
if cursor is None:
|
||||
await self.initialize()
|
||||
await self._refresh_snapshot()
|
||||
return
|
||||
batch = await self.provider.fetch_events(
|
||||
self.instance_uuid,
|
||||
|
||||
@@ -301,11 +301,36 @@ class Application:
|
||||
async def initialize(self):
|
||||
pass
|
||||
|
||||
async def _initialize_plugin_runtime(self) -> None:
|
||||
try:
|
||||
await self.plugin_connector.initialize()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
self.logger.warning(f'Plugin runtime unavailable during startup; reconnecting in background: {exc}')
|
||||
self.plugin_connector.schedule_reconnect()
|
||||
|
||||
def _start_plugin_runtime_initialization(self) -> asyncio.Task | None:
|
||||
task = getattr(self, '_plugin_runtime_initialization_task', None)
|
||||
if task is not None and not task.done():
|
||||
return task
|
||||
# This is application lifecycle work, not a request side effect. It must
|
||||
# not wait on PersistenceManager's after-commit gate at boot.
|
||||
task = asyncio.create_task(
|
||||
self._initialize_plugin_runtime(),
|
||||
name='plugin-runtime-initialization',
|
||||
)
|
||||
self._plugin_runtime_initialization_task = task
|
||||
return task
|
||||
|
||||
async def run(self):
|
||||
self.event_loop_monitor.start()
|
||||
try:
|
||||
if self.directory_projection_service is not None:
|
||||
self.task_mgr.create_task(
|
||||
if (
|
||||
self.directory_projection_service is not None
|
||||
and getattr(self, 'directory_projection_task', None) is None
|
||||
):
|
||||
self.directory_projection_task = self.task_mgr.create_task(
|
||||
self.directory_projection_service.run(),
|
||||
name='cloud-directory-projection',
|
||||
scopes=[core_entities.LifecycleControlScope.APPLICATION],
|
||||
@@ -322,7 +347,6 @@ class Application:
|
||||
name='cloud-manifest-refresh',
|
||||
scopes=[core_entities.LifecycleControlScope.APPLICATION],
|
||||
)
|
||||
await self.plugin_connector.initialize_plugins()
|
||||
|
||||
# 后续可能会允许动态重启其他任务
|
||||
# 故为了防止程序在非 Ctrl-C 情况下退出,这里创建一个不会结束的协程
|
||||
@@ -348,6 +372,7 @@ class Application:
|
||||
name='http-api-controller',
|
||||
scopes=[core_entities.LifecycleControlScope.APPLICATION],
|
||||
)
|
||||
self._start_plugin_runtime_initialization()
|
||||
|
||||
# Telemetry instance heartbeat (startup + daily); respects
|
||||
# space.disable_telemetry via TelemetryManager.send().
|
||||
@@ -529,6 +554,11 @@ class Application:
|
||||
|
||||
if self.task_mgr is not None:
|
||||
self.task_mgr.cancel_by_scope(core_entities.LifecycleControlScope.APPLICATION)
|
||||
plugin_runtime_task = getattr(self, '_plugin_runtime_initialization_task', None)
|
||||
if plugin_runtime_task is not None and not plugin_runtime_task.done():
|
||||
plugin_runtime_task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await plugin_runtime_task
|
||||
with contextlib.suppress(Exception):
|
||||
await self.event_loop_monitor.stop()
|
||||
mcp_mount = getattr(self.http_ctrl, 'mcp_mount', None)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .. import stage, app
|
||||
from .. import stage, app, entities as core_entities
|
||||
from ...utils import version, proxy, constants
|
||||
from ...pipeline import pool, controller, pipelinemgr
|
||||
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:
|
||||
connector.schedule_reconnect()
|
||||
|
||||
if ap.directory_projection_service is not None:
|
||||
# Keep the projection fresh while shared Runtime cold restore runs.
|
||||
# BuildApp initializes the connector before Application.run() starts
|
||||
# its long-lived tasks, so start the single refresh task here.
|
||||
ap.directory_projection_task = ap.task_mgr.create_task(
|
||||
ap.directory_projection_service.run(),
|
||||
name='cloud-directory-projection',
|
||||
scopes=[core_entities.LifecycleControlScope.APPLICATION],
|
||||
)
|
||||
|
||||
plugin_connector_inst = plugin_connector.PluginRuntimeConnector(ap, runtime_disconnect_callback)
|
||||
try:
|
||||
await plugin_connector_inst.initialize()
|
||||
except Exception as exc:
|
||||
# Keep the API/UI available while an external or managed runtime is
|
||||
# starting, then recover in the background with bounded backoff.
|
||||
ap.logger.warning(f'Plugin runtime unavailable during startup; reconnecting in background: {exc}')
|
||||
plugin_connector_inst.schedule_reconnect()
|
||||
ap.plugin_connector = plugin_connector_inst
|
||||
workspace_service_inst.release_startup_execution_bindings()
|
||||
|
||||
|
||||
@@ -47,3 +47,10 @@ class SpaceModel(pydantic.BaseModel):
|
||||
status: str
|
||||
created_at: str | None = None
|
||||
updated_at: str | None = None
|
||||
|
||||
|
||||
class SpaceModelSelection(pydantic.BaseModel):
|
||||
"""Minimal model identity returned by the ranked selection endpoint."""
|
||||
|
||||
uuid: str
|
||||
model_id: str
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import dataclasses
|
||||
import datetime
|
||||
import json
|
||||
@@ -82,7 +83,7 @@ def _verify_connection(connection: sqlite3.Connection, expected_revision: str) -
|
||||
|
||||
|
||||
def _verify_file(path: pathlib.Path, expected_revision: str) -> None:
|
||||
with _open_read_only(path) as connection:
|
||||
with contextlib.closing(_open_read_only(path)) as connection:
|
||||
_verify_connection(connection, expected_revision)
|
||||
|
||||
|
||||
@@ -119,12 +120,16 @@ def _write_manifest(backup: SQLiteMigrationBackup, status: str, **extra: typing.
|
||||
|
||||
|
||||
def _fsync_file(path: pathlib.Path, *, reopen_attempts: int = 20) -> None:
|
||||
"""Sync a file, tolerating delayed visibility after replace on bind mounts."""
|
||||
"""Sync a file, tolerating delayed visibility after replace on bind mounts.
|
||||
|
||||
Uses O_RDWR so os.fsync works on Windows (where _commit requires write
|
||||
access to the file descriptor).
|
||||
"""
|
||||
|
||||
descriptor: int | None = None
|
||||
for attempt in range(reopen_attempts):
|
||||
try:
|
||||
descriptor = os.open(path, os.O_RDONLY)
|
||||
descriptor = os.open(path, os.O_RDWR)
|
||||
break
|
||||
except FileNotFoundError:
|
||||
if attempt + 1 >= reopen_attempts:
|
||||
@@ -138,13 +143,37 @@ def _fsync_file(path: pathlib.Path, *, reopen_attempts: int = 20) -> None:
|
||||
|
||||
|
||||
def _fsync_directory(path: pathlib.Path) -> None:
|
||||
descriptor = os.open(path, os.O_RDONLY)
|
||||
if os.name == 'nt':
|
||||
# Windows cannot fsync directory handles opened through os.open.
|
||||
return
|
||||
descriptor = os.open(path, os.O_RDONLY | getattr(os, 'O_DIRECTORY', 0))
|
||||
try:
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def _remove_stale_temporary_files(
|
||||
directory: pathlib.Path,
|
||||
*,
|
||||
prefix: str,
|
||||
suffix: str,
|
||||
) -> None:
|
||||
"""Remove temporary files left by an interrupted backup or restore."""
|
||||
|
||||
for candidate in directory.iterdir():
|
||||
if candidate.is_dir() or not candidate.name.startswith(prefix) or not candidate.name.endswith(suffix):
|
||||
continue
|
||||
try:
|
||||
candidate.unlink()
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
except PermissionError:
|
||||
# Another process may still own this file. Do not turn harmless
|
||||
# cleanup into a migration failure; its unique name cannot collide.
|
||||
continue
|
||||
|
||||
|
||||
def _create_backup(
|
||||
database_path: pathlib.Path,
|
||||
source_revision: str,
|
||||
@@ -153,6 +182,11 @@ def _create_backup(
|
||||
backup_directory = database_path.parent / 'migration-backups'
|
||||
backup_directory.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
os.chmod(backup_directory, 0o700)
|
||||
_remove_stale_temporary_files(
|
||||
backup_directory,
|
||||
prefix=f'.{database_path.stem}-pre-',
|
||||
suffix='.creating',
|
||||
)
|
||||
created_at = datetime.datetime.now(datetime.UTC).strftime('%Y-%m-%dT%H-%M-%S.%fZ')
|
||||
stem = (
|
||||
f'{database_path.stem}-pre-{_safe_label(target_revision)}-'
|
||||
@@ -169,11 +203,8 @@ def _create_backup(
|
||||
temporary_path = pathlib.Path(temporary_name)
|
||||
try:
|
||||
with (
|
||||
_open_read_only(database_path) as source,
|
||||
sqlite3.connect(
|
||||
temporary_path,
|
||||
timeout=30,
|
||||
) as destination,
|
||||
contextlib.closing(_open_read_only(database_path)) as source,
|
||||
contextlib.closing(sqlite3.connect(temporary_path, timeout=30)) as destination,
|
||||
):
|
||||
source.execute('PRAGMA busy_timeout = 30000')
|
||||
source.backup(destination)
|
||||
@@ -221,6 +252,11 @@ async def create_verified_backup(
|
||||
|
||||
def _restore_backup(backup: SQLiteMigrationBackup) -> None:
|
||||
_verify_file(backup.backup_path, backup.source_revision)
|
||||
_remove_stale_temporary_files(
|
||||
backup.database_path.parent,
|
||||
prefix=f'.{backup.database_path.name}.',
|
||||
suffix='.restoring',
|
||||
)
|
||||
descriptor, temporary_name = tempfile.mkstemp(
|
||||
prefix=f'.{backup.database_path.name}.',
|
||||
suffix='.restoring',
|
||||
@@ -230,11 +266,8 @@ def _restore_backup(backup: SQLiteMigrationBackup) -> None:
|
||||
temporary_path = pathlib.Path(temporary_name)
|
||||
try:
|
||||
with (
|
||||
_open_read_only(backup.backup_path) as source,
|
||||
sqlite3.connect(
|
||||
temporary_path,
|
||||
timeout=30,
|
||||
) as destination,
|
||||
contextlib.closing(_open_read_only(backup.backup_path)) as source,
|
||||
contextlib.closing(sqlite3.connect(temporary_path, timeout=30)) as destination,
|
||||
):
|
||||
source.backup(destination)
|
||||
destination.commit()
|
||||
|
||||
@@ -209,7 +209,7 @@ _ALLOWED_SCOPED_BUILTIN_FUNCTION_TYPES = {
|
||||
'now': sqlalchemy.sql.functions.now,
|
||||
'sum': sqlalchemy.sql.functions.sum,
|
||||
}
|
||||
_ALLOWED_SCOPED_GENERIC_FUNCTIONS = frozenset({'date_trunc', 'length', 'nullif'})
|
||||
_ALLOWED_SCOPED_GENERIC_FUNCTIONS = frozenset({'date_trunc', 'length', 'nullif', 'strftime'})
|
||||
_ALLOWED_SCOPED_CUSTOM_OPERATORS = frozenset({'<=>'})
|
||||
_ALLOWED_SCOPED_STATEMENT_TYPES = (
|
||||
sqlalchemy.sql.dml.UpdateBase,
|
||||
|
||||
@@ -5,6 +5,11 @@ from .. import entities
|
||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
from ....utils.safe_regex import SafeRegexError, mask_patterns
|
||||
|
||||
# Legacy sensitive-words.json files shipped ~70 rules, which exceeds the
|
||||
# default safe_regex per-call cap of 64 and used to fail-close every message.
|
||||
# Keep one 50ms CPU budget for the whole list; only raise the pattern cap.
|
||||
_MAX_SENSITIVE_WORD_PATTERNS = 256
|
||||
|
||||
|
||||
@filter_model.filter_class('ban-word-filter')
|
||||
class BanWordFilter(filter_model.ContentFilter):
|
||||
@@ -14,12 +19,17 @@ class BanWordFilter(filter_model.ContentFilter):
|
||||
pass
|
||||
|
||||
async def process(self, query: pipeline_query.Query, message: str) -> entities.FilterResult:
|
||||
words = self.ap.sensitive_meta.data.get('words') or []
|
||||
mask = self.ap.sensitive_meta.data['mask']
|
||||
mask_word = self.ap.sensitive_meta.data['mask_word']
|
||||
|
||||
try:
|
||||
found, message = await mask_patterns(
|
||||
self.ap.sensitive_meta.data['words'],
|
||||
found, current = await mask_patterns(
|
||||
words,
|
||||
message,
|
||||
mask=self.ap.sensitive_meta.data['mask'],
|
||||
mask_word=self.ap.sensitive_meta.data['mask_word'],
|
||||
mask=mask,
|
||||
mask_word=mask_word,
|
||||
max_pattern_count=_MAX_SENSITIVE_WORD_PATTERNS,
|
||||
)
|
||||
except SafeRegexError as exc:
|
||||
return entities.FilterResult(
|
||||
@@ -31,7 +41,7 @@ class BanWordFilter(filter_model.ContentFilter):
|
||||
|
||||
return entities.FilterResult(
|
||||
level=entities.ResultLevel.MASKED if found else entities.ResultLevel.PASS,
|
||||
replacement=message,
|
||||
replacement=current,
|
||||
user_notice='消息中存在不合适的内容, 请修改' if found else '',
|
||||
console_notice='',
|
||||
)
|
||||
|
||||
@@ -160,6 +160,29 @@ def _lark_should_update_stream_element(
|
||||
return not resume_from and not form_data and (msg_seq % 8 == 0 or is_final)
|
||||
|
||||
|
||||
def _lark_final_layout_texts(
|
||||
*,
|
||||
resume_from: bool,
|
||||
text_message: str,
|
||||
pre_pause_cached: str | None,
|
||||
resume_cached: str,
|
||||
) -> tuple[str, str]:
|
||||
"""Return (main_text, resume_placeholder_text) for the final card update.
|
||||
|
||||
Non-resume round: the full reply belongs in the main streaming element
|
||||
only — also rendering the resume placeholder duplicates the reply, since
|
||||
both hold the same accumulated text. Resume round (Dify HITL): keep the
|
||||
pre-pause text in the main element and the resumed text in the
|
||||
placeholder, as they are distinct segments.
|
||||
"""
|
||||
if resume_from:
|
||||
# An empty pre-pause cache is valid (Dify paused before emitting any
|
||||
# text); only a missing entry (None) falls back to the full text.
|
||||
main_text = text_message if pre_pause_cached is None else pre_pause_cached
|
||||
return main_text, resume_cached
|
||||
return text_message, ''
|
||||
|
||||
|
||||
def _lark_display_input_value(field: dict, value: typing.Any) -> str:
|
||||
field_type = _dify_field_type(field)
|
||||
if field_type == 'file':
|
||||
@@ -2358,16 +2381,21 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
self.card_form_input_defs[card_id] = _lark_form_input_defs(form_data)
|
||||
self.card_form_inputs[card_id] = dict(form_data.get('inputs') or {})
|
||||
else:
|
||||
# Normal finish: keep pre-pause + resume content visible,
|
||||
# remove buttons/notice, drop the resume placeholder.
|
||||
# Normal finish: remove buttons/notice and finalize the card.
|
||||
main_text, resume_text = _lark_final_layout_texts(
|
||||
resume_from=resume_from,
|
||||
text_message=text_message,
|
||||
pre_pause_cached=self.card_pre_pause_text.get(card_id),
|
||||
resume_cached=resume_cached,
|
||||
)
|
||||
await self._update_card_layout(
|
||||
card_id=card_id,
|
||||
message_source=message_source,
|
||||
text_message=pre_pause,
|
||||
text_message=main_text,
|
||||
sequence=final_seq,
|
||||
form_data=None,
|
||||
notice_text=selected_notice if resume_from else '',
|
||||
resume_placeholder_text=resume_cached,
|
||||
resume_placeholder_text=resume_text,
|
||||
)
|
||||
self._drop_card_state(card_id)
|
||||
self.card_id_dict.pop(message_id, None)
|
||||
|
||||
@@ -25,6 +25,7 @@ from linebot.v3.webhooks import (
|
||||
ImageMessageContent,
|
||||
VideoMessageContent,
|
||||
AudioMessageContent,
|
||||
UserMentionee,
|
||||
)
|
||||
|
||||
# from linebot import WebhookParser
|
||||
@@ -58,15 +59,19 @@ class LINEMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
|
||||
return content_list
|
||||
|
||||
@staticmethod
|
||||
async def target2yiri(message, bot_client) -> platform_message.MessageChain:
|
||||
def __init__(self, bot_account_id: str = ''):
|
||||
self.bot_account_id = bot_account_id
|
||||
|
||||
async def target2yiri(self, message, bot_client) -> platform_message.MessageChain:
|
||||
lb_msg_list = []
|
||||
msg_create_time = datetime.datetime.fromtimestamp(int(message.timestamp) / 1000)
|
||||
|
||||
lb_msg_list.append(platform_message.Source(id=message.webhook_event_id, time=msg_create_time))
|
||||
|
||||
if isinstance(message.message, TextMessageContent):
|
||||
lb_msg_list.append(platform_message.Plain(text=message.message.text))
|
||||
lb_msg_list.extend(
|
||||
self._build_text_components(message.message.text, getattr(message.message, 'mention', None))
|
||||
)
|
||||
elif isinstance(message.message, AudioMessageContent):
|
||||
pass
|
||||
elif isinstance(message.message, VideoMessageContent):
|
||||
@@ -86,22 +91,60 @@ class LINEMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
lb_msg_list.append(platform_message.Image(base64=data_uri))
|
||||
return platform_message.MessageChain(lb_msg_list)
|
||||
|
||||
def _build_text_components(self, text: str, mention) -> list:
|
||||
"""Build message components from text, inserting At components for mentions.
|
||||
|
||||
LINE provides mention positions (index/length) and is_self per mentionee in the
|
||||
webhook payload. Mapping the bot mention to At(target=bot_account_id) makes the
|
||||
'at-bot' group respond rule work for LINE, consistent with other adapters.
|
||||
"""
|
||||
components: list = []
|
||||
if not mention or not mention.mentionees:
|
||||
if text:
|
||||
components.append(platform_message.Plain(text=text))
|
||||
return components
|
||||
segments: list[tuple[int, int, object]] = sorted((m.index, m.index + m.length, m) for m in mention.mentionees)
|
||||
cursor = 0
|
||||
for start, end, mentionee in segments:
|
||||
if start < cursor:
|
||||
start, end = cursor, min(end, len(text))
|
||||
if start < cursor or end <= start or end > len(text):
|
||||
continue
|
||||
if start > cursor:
|
||||
components.append(platform_message.Plain(text=text[cursor:start]))
|
||||
if isinstance(mentionee, UserMentionee):
|
||||
target = self.bot_account_id if mentionee.is_self else mentionee.user_id
|
||||
if not target:
|
||||
target = text[start:end]
|
||||
else:
|
||||
target = text[start:end]
|
||||
# At.__str__ already prepends '@', so strip one from the LINE text token.
|
||||
display = text[start:end].lstrip('@')
|
||||
components.append(platform_message.At(target=str(target), display=display))
|
||||
cursor = end
|
||||
if cursor < len(text):
|
||||
components.append(platform_message.Plain(text=text[cursor:]))
|
||||
return components
|
||||
|
||||
|
||||
class LINEEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
def __init__(self, bot_account_id: str = ''):
|
||||
self.bot_account_id = bot_account_id
|
||||
self.message_converter = LINEMessageConverter(bot_account_id)
|
||||
|
||||
@staticmethod
|
||||
async def yiri2target(
|
||||
event: platform_events.MessageEvent,
|
||||
) -> MessageEvent:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
async def target2yiri(event, bot_client) -> platform_events.Event:
|
||||
message_chain = await LINEMessageConverter.target2yiri(event, bot_client)
|
||||
async def target2yiri(self, event, bot_client) -> platform_events.Event:
|
||||
message_chain = await self.message_converter.target2yiri(event, bot_client)
|
||||
|
||||
if event.source.type == 'user':
|
||||
return platform_events.FriendMessage(
|
||||
sender=platform_entities.Friend(
|
||||
id=event.message.id,
|
||||
id=event.source.user_id,
|
||||
nickname=event.source.user_id,
|
||||
remark='',
|
||||
),
|
||||
@@ -110,13 +153,19 @@ class LINEEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
source_platform_object=event,
|
||||
)
|
||||
else:
|
||||
# 'group' and 'room' sources carry the stable chat id under different
|
||||
# field names; user_id may be absent for some members, so fall back
|
||||
# to the group/room id rather than the per-message id.
|
||||
group_id = event.source.group_id if event.source.type == 'group' else event.source.room_id
|
||||
member_id = event.source.user_id or group_id
|
||||
|
||||
return platform_events.GroupMessage(
|
||||
sender=platform_entities.GroupMember(
|
||||
id=event.event.sender.sender_id.open_id,
|
||||
member_name=event.event.sender.sender_id.union_id,
|
||||
id=member_id,
|
||||
member_name=member_id,
|
||||
permission=platform_entities.Permission.Member,
|
||||
group=platform_entities.Group(
|
||||
id=event.message.id,
|
||||
id=group_id,
|
||||
name='',
|
||||
permission=platform_entities.Permission.Member,
|
||||
),
|
||||
@@ -163,8 +212,8 @@ class LINEAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
listeners={},
|
||||
card_id_dict={},
|
||||
seq=1,
|
||||
event_converter=LINEEventConverter(),
|
||||
message_converter=LINEMessageConverter(),
|
||||
event_converter=LINEEventConverter(bot_account_id),
|
||||
message_converter=LINEMessageConverter(bot_account_id),
|
||||
line_webhook=line_webhook,
|
||||
parser=parser,
|
||||
configuration=configuration,
|
||||
|
||||
@@ -329,17 +329,12 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
||||
content_type = content.get('type', 'text')
|
||||
|
||||
if content_type == 'text':
|
||||
if target_type == 'c2c':
|
||||
await self.bot.send_private_text_msg(
|
||||
if target_type in {'c2c', 'group'}:
|
||||
await self._send_c2c_or_group_text_reply(
|
||||
target_type,
|
||||
target_id,
|
||||
content['content'],
|
||||
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,
|
||||
msg_id=qq_official_event.d_id,
|
||||
)
|
||||
|
||||
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):
|
||||
pass
|
||||
|
||||
async def _send_c2c_or_group_text_reply(
|
||||
self,
|
||||
target_type: str,
|
||||
target_id: str,
|
||||
content: str,
|
||||
*,
|
||||
msg_id: typing.Optional[str] = None,
|
||||
event_id: typing.Optional[str] = None,
|
||||
msg_seq: int = 1,
|
||||
) -> None:
|
||||
"""Send a text reply using the configured C2C/group render mode."""
|
||||
use_markdown = self.config.get('enable-markdown-rendering', False)
|
||||
if target_type == 'c2c':
|
||||
send = self.bot.send_private_markdown_msg if use_markdown else self.bot.send_private_text_msg
|
||||
await send(
|
||||
user_openid=target_id,
|
||||
content=content,
|
||||
msg_id=msg_id,
|
||||
event_id=event_id,
|
||||
msg_seq=msg_seq,
|
||||
)
|
||||
elif target_type == 'group':
|
||||
send = self.bot.send_group_markdown_msg if use_markdown else self.bot.send_group_text_msg
|
||||
await send(
|
||||
group_openid=target_id,
|
||||
content=content,
|
||||
msg_id=msg_id,
|
||||
event_id=event_id,
|
||||
msg_seq=msg_seq,
|
||||
)
|
||||
else:
|
||||
raise ValueError(f'Unsupported QQ Official text reply target: {target_type}')
|
||||
|
||||
def register_listener(
|
||||
self,
|
||||
event_type: typing.Type[platform_events.Event],
|
||||
@@ -650,13 +678,13 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
||||
# 用第一个 chunk 的文本建立会话(不发 "..." 避免污染前缀)
|
||||
ctx['session_started'] = True
|
||||
|
||||
# 发送内容 = 全量累积文本
|
||||
# QQ API 的 replace 模式不允许修改已下发前缀,所以:
|
||||
# - 首次:发送全部文本,建立会话
|
||||
# - 后续:只能发送新增部分(append 行为)
|
||||
content_to_send = ctx['accumulated_text'][ctx['sent_length'] :]
|
||||
if not content_to_send and not is_final:
|
||||
# `replace` mode requires every update to contain the previously
|
||||
# delivered content as its prefix. `sent_length` only tells us whether
|
||||
# a non-final snapshot has new content; it must not truncate the
|
||||
# content sent to QQ.
|
||||
if len(ctx['accumulated_text']) <= ctx['sent_length'] and not is_final:
|
||||
return
|
||||
content_to_send = ctx['accumulated_text']
|
||||
|
||||
input_state = 10 if is_final else 1
|
||||
|
||||
@@ -778,20 +806,13 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
||||
return
|
||||
|
||||
try:
|
||||
if target_type == 'c2c':
|
||||
await self.bot.send_private_text_msg(
|
||||
user_openid=target_id,
|
||||
content=text,
|
||||
event_id=event_id,
|
||||
msg_seq=msg_seq,
|
||||
)
|
||||
elif target_type == 'group':
|
||||
await self.bot.send_group_text_msg(
|
||||
group_openid=target_id,
|
||||
content=text,
|
||||
event_id=event_id,
|
||||
msg_seq=msg_seq,
|
||||
)
|
||||
await self._send_c2c_or_group_text_reply(
|
||||
target_type,
|
||||
target_id,
|
||||
text,
|
||||
event_id=event_id,
|
||||
msg_seq=msg_seq,
|
||||
)
|
||||
except Exception:
|
||||
await self.logger.error(f'QQ Official: synthetic reply delivery failed: {traceback.format_exc()}')
|
||||
|
||||
|
||||
@@ -95,6 +95,18 @@ spec:
|
||||
type: boolean
|
||||
required: true
|
||||
default: false
|
||||
- name: enable-markdown-rendering
|
||||
label:
|
||||
en_US: Enable Markdown Rendering
|
||||
zh_Hans: 启用 Markdown 渲染
|
||||
zh_Hant: 啟用 Markdown 渲染
|
||||
description:
|
||||
en_US: Render non-stream C2C and QQ group text replies as Markdown. Channel messages always use plain text and are not affected by this setting.
|
||||
zh_Hans: 将非流式 C2C 私聊和 QQ 群聊文本回复渲染为 Markdown。频道消息始终以纯文本发送,不受此设置影响。
|
||||
zh_Hant: 將非串流 C2C 私聊與 QQ 群聊文字回覆渲染為 Markdown。頻道訊息一律以純文字傳送,不受此設定影響。
|
||||
type: boolean
|
||||
required: true
|
||||
default: false
|
||||
- name: webhook_url
|
||||
label:
|
||||
en_US: Webhook Callback URL
|
||||
|
||||
@@ -107,7 +107,7 @@ class WecomEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
if event.type == 'text':
|
||||
yiri_chain = await WecomMessageConverter.target2yiri(event.message, event.message_id)
|
||||
friend = platform_entities.Friend(
|
||||
id=f'u{event.user_id}',
|
||||
id=f'{event.receiver_id}|u{event.user_id}',
|
||||
nickname=nickname,
|
||||
remark='',
|
||||
)
|
||||
@@ -117,7 +117,7 @@ class WecomEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
)
|
||||
elif event.type == 'image':
|
||||
friend = platform_entities.Friend(
|
||||
id=f'u{event.user_id}',
|
||||
id=f'{event.receiver_id}|u{event.user_id}',
|
||||
nickname=nickname,
|
||||
remark='',
|
||||
)
|
||||
@@ -197,7 +197,7 @@ class WecomCSAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
|
||||
content_list = await WecomMessageConverter.yiri2target(message, self.bot)
|
||||
for content in content_list:
|
||||
msgid = f'langbot_{uuid.uuid4().hex}'
|
||||
msgid = f'{uuid.uuid4().hex}'
|
||||
if content['type'] == 'text':
|
||||
await self.bot.send_text_msg(
|
||||
open_kfid=open_kfid,
|
||||
@@ -205,6 +205,13 @@ class WecomCSAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
msgid=msgid,
|
||||
content=content['content'],
|
||||
)
|
||||
elif content['type'] == 'image':
|
||||
await self.bot.send_image_msg(
|
||||
open_kfid=open_kfid,
|
||||
external_userid=external_userid,
|
||||
msgid=msgid,
|
||||
media_id=content['media_id'],
|
||||
)
|
||||
|
||||
def set_bot_uuid(self, bot_uuid: str):
|
||||
"""设置 bot UUID(用于生成 webhook URL)"""
|
||||
|
||||
@@ -701,7 +701,13 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
}
|
||||
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)
|
||||
self._record_reconcile_failures(self._known_desired_states, result)
|
||||
|
||||
@@ -736,7 +742,13 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
if state.binding.installation_uuid in all_states:
|
||||
raise ValueError('Duplicate plugin installation UUID across projected Workspaces')
|
||||
all_states[state.binding.installation_uuid] = state
|
||||
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)
|
||||
self._record_reconcile_failures(all_states, result)
|
||||
for installation_uuid, previous in tuple(self._known_desired_states.items()):
|
||||
@@ -1901,9 +1913,14 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
|
||||
return plugins
|
||||
|
||||
async def get_plugin_info(self, author: str, plugin_name: str) -> dict[str, Any]:
|
||||
async def get_plugin_info(self, author: str, plugin_name: str) -> dict[str, Any] | None:
|
||||
runtime_handler = self._runtime_handler()
|
||||
binding = await self._target_binding(author, plugin_name)
|
||||
try:
|
||||
binding = await self._target_binding(author, plugin_name)
|
||||
except ValueError as exc:
|
||||
if str(exc) == f'Plugin {author}/{plugin_name} is not installed in this Workspace':
|
||||
return None
|
||||
raise
|
||||
with runtime_handler.installation_scope(binding):
|
||||
return await runtime_handler.get_plugin_info(author, plugin_name)
|
||||
|
||||
|
||||
@@ -1677,13 +1677,15 @@ class RuntimeConnectionHandler(handler.Handler):
|
||||
async def reconcile_plugin_installations(
|
||||
self,
|
||||
installations: tuple[PluginInstallationDesiredState, ...],
|
||||
*,
|
||||
timeout: float = 300,
|
||||
) -> dict[str, Any]:
|
||||
request = ReconcilePluginInstallationsRequest(installations=installations)
|
||||
with self.installation_scope(None):
|
||||
return await self.call_action(
|
||||
LangBotToRuntimeAction.RECONCILE_PLUGIN_INSTALLATIONS,
|
||||
request.model_dump(),
|
||||
timeout=300,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
async def apply_plugin_installation(
|
||||
|
||||
@@ -573,7 +573,7 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
||||
levels = ['provider_default', 'disabled', 'enabled']
|
||||
elif family == 'doubao':
|
||||
levels = ['provider_default', 'disabled', 'low', 'medium', 'high']
|
||||
elif family == 'ollama':
|
||||
elif family in ('ollama', 'ollama_chat'):
|
||||
levels = ['provider_default']
|
||||
levels.append('disabled')
|
||||
if normalized_name.startswith('gpt-oss') or '/gpt-oss' in normalized_name:
|
||||
@@ -747,9 +747,24 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
||||
converted_parts = []
|
||||
for part in content:
|
||||
if isinstance(part, dict) and part.get('type') == 'image_base64':
|
||||
part['image_url'] = {'url': part['image_base64']}
|
||||
part['type'] = 'image_url'
|
||||
del part['image_base64']
|
||||
# History trimming (SessionManager) clears image_base64
|
||||
# on past turns and exclude_none serialization drops
|
||||
# the key entirely, so the replayed part may carry no
|
||||
# payload. Prefer the base64 payload; fall back to an
|
||||
# image_url that survived on the same element; drop
|
||||
# hollow parts instead of raising KeyError (#2469).
|
||||
image_b64 = part.get('image_base64')
|
||||
fallback_url = None
|
||||
if not image_b64:
|
||||
raw_image_url = part.get('image_url')
|
||||
if isinstance(raw_image_url, dict):
|
||||
fallback_url = raw_image_url.get('url')
|
||||
if image_b64 or fallback_url:
|
||||
part['image_url'] = {'url': image_b64 or fallback_url}
|
||||
part['type'] = 'image_url'
|
||||
part.pop('image_base64', None)
|
||||
else:
|
||||
continue
|
||||
# OpenAI-compatible chat models reject non-image file parts
|
||||
# (audio/document base64 or url). These originate from Voice /
|
||||
# File attachments — including ones replayed from conversation
|
||||
@@ -1330,7 +1345,14 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
||||
extra_args: dict[str, typing.Any] = {},
|
||||
) -> tuple[list[list[float]], dict]:
|
||||
"""Invoke embedding and return vectors with usage info."""
|
||||
model_name = self._build_litellm_model_name(model.model_entity.name)
|
||||
# litellm's embedding routing has no "ollama_chat" branch (that provider
|
||||
# exists only for /api/chat completions) — embeddings still go through
|
||||
# the plain "ollama" provider. Requesters configured for ollama_chat
|
||||
# (to get native tool-calling on the chat path) must fall back to
|
||||
# "ollama" here specifically, or embedding calls raise "Unmapped LLM
|
||||
# provider for this endpoint".
|
||||
embedding_provider = 'ollama' if self._get_custom_llm_provider() == 'ollama_chat' else None
|
||||
model_name = self._build_litellm_model_name(model.model_entity.name, embedding_provider)
|
||||
api_key = model.provider.token_mgr.get_token()
|
||||
|
||||
args = {
|
||||
@@ -1526,6 +1548,12 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
||||
event_hooks=httpclient.httpx_response_limit_hooks(),
|
||||
) as client:
|
||||
response = await client.get(models_url, headers=headers)
|
||||
if response.status_code == 404 and not base_url.rstrip('/').endswith('/v1'):
|
||||
# Some OpenAI-compatible servers (notably a bare Ollama host,
|
||||
# e.g. http://host:11434) expose the model list under /v1/models
|
||||
# rather than /models. Providers whose configured base_url
|
||||
# already ends in /v1 keep their original (working) URL.
|
||||
response = await client.get(f'{base_url}/v1/models', headers=headers)
|
||||
response.raise_for_status()
|
||||
payload = await httpclient.parse_json_response(response)
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ metadata:
|
||||
zh_Hans: Ollama
|
||||
icon: ollama.svg
|
||||
spec:
|
||||
litellm_provider: ollama
|
||||
litellm_provider: ollama_chat
|
||||
config:
|
||||
- name: base_url
|
||||
label:
|
||||
|
||||
@@ -24,7 +24,10 @@ class SeekDBEmbedding(requester.ProviderAPIRequester):
|
||||
try:
|
||||
import pyseekdb
|
||||
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()
|
||||
|
||||
|
||||
@@ -619,7 +619,9 @@ class LocalAgentRunner(runner.RequestRunner):
|
||||
and len(func_ret) > 0
|
||||
and isinstance(func_ret[0], provider_message.ContentElement)
|
||||
):
|
||||
tool_content = func_ret
|
||||
# OpenAI-compatible APIs require tool-message content to be a
|
||||
# string; a raw list of ContentElement causes HTTP 500 (#2457).
|
||||
tool_content = '\n'.join(str(ce) for ce in func_ret)
|
||||
else:
|
||||
tool_content = json.dumps(func_ret, ensure_ascii=False)
|
||||
|
||||
|
||||
@@ -222,6 +222,7 @@ class NativeToolLoader(loader.ToolLoader):
|
||||
self.ap.logger.warning(
|
||||
'Native sandbox tools (exec/read/write/edit/glob/grep) are NOT available. '
|
||||
'No sandbox backend (Docker/nsjail/E2B) is ready. '
|
||||
'Trusted local development may explicitly select box.backend=host. '
|
||||
'The LLM will not have access to code execution or file operation tools.'
|
||||
)
|
||||
|
||||
|
||||
@@ -42,7 +42,8 @@ class SkillToolLoader(loader.ToolLoader):
|
||||
else:
|
||||
self.ap.logger.info(
|
||||
'Skill tools (activate/register_skill) are NOT available. '
|
||||
'No sandbox backend (Docker/nsjail/E2B) is ready.'
|
||||
'No sandbox backend (Docker/nsjail/E2B) is ready. '
|
||||
'Trusted local development may explicitly select box.backend=host.'
|
||||
)
|
||||
|
||||
async def _check_sandbox_available(self) -> bool:
|
||||
|
||||
@@ -27,10 +27,16 @@ class SafeRegexTimeoutError(SafeRegexError):
|
||||
"""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)
|
||||
if len(normalized) > MAX_PATTERN_COUNT:
|
||||
raise SafeRegexLimitError(f'At most {MAX_PATTERN_COUNT} regex patterns are allowed')
|
||||
for pattern in normalized:
|
||||
if not isinstance(pattern, str):
|
||||
raise SafeRegexError('Regex patterns must be strings')
|
||||
@@ -115,8 +121,9 @@ def _mask_patterns_sync(
|
||||
mask: str,
|
||||
mask_word: str,
|
||||
timeout_seconds: float,
|
||||
max_pattern_count: int,
|
||||
) -> tuple[bool, str]:
|
||||
normalized_patterns = _validate_patterns(patterns)
|
||||
normalized_patterns = _validate_patterns(patterns, max_pattern_count=max_pattern_count)
|
||||
_validate_input(value)
|
||||
if len(mask) > MAX_REPLACEMENT_CHARS or len(mask_word) > MAX_REPLACEMENT_CHARS:
|
||||
raise SafeRegexLimitError(f'Regex replacements may contain at most {MAX_REPLACEMENT_CHARS} characters')
|
||||
@@ -162,6 +169,7 @@ async def mask_patterns(
|
||||
mask: str,
|
||||
mask_word: str,
|
||||
timeout_seconds: float = DEFAULT_OPERATION_TIMEOUT_SECONDS,
|
||||
max_pattern_count: int = MAX_PATTERN_COUNT,
|
||||
) -> tuple[bool, str]:
|
||||
"""Apply untrusted masking patterns with bounded CPU and output growth."""
|
||||
|
||||
@@ -174,4 +182,5 @@ async def mask_patterns(
|
||||
mask=mask,
|
||||
mask_word=mask_word,
|
||||
timeout_seconds=timeout_seconds,
|
||||
max_pattern_count=max_pattern_count,
|
||||
)
|
||||
|
||||
@@ -42,7 +42,10 @@ class SeekDBVectorDatabase(VectorDatabase):
|
||||
|
||||
def __init__(self, ap: app.Application):
|
||||
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
|
||||
config = self.ap.instance_config.data['vdb']['seekdb']
|
||||
|
||||
@@ -240,7 +240,7 @@ class InvitationDeliveryService:
|
||||
@staticmethod
|
||||
def _plain_text(workspace_name: str, invitation_link: str) -> str:
|
||||
return (
|
||||
'You have been invited to 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'Accept invitation: {invitation_link}\n\n'
|
||||
'This secure invitation expires in 7 days and can only be accepted by the email address '
|
||||
@@ -258,30 +258,77 @@ class InvitationDeliveryService:
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<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>
|
||||
<body style="margin:0;background:#f4f7fb;color:#152033;font-family:Inter,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;">
|
||||
<div style="display:none;max-height:0;overflow:hidden;opacity:0;">You have been invited to join {escaped_workspace} on LangBot Cloud.</div>
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#f4f7fb;padding:40px 16px;">
|
||||
<tr><td align="center">
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:600px;background:#ffffff;border:1px solid #e5eaf2;border-radius:16px;overflow:hidden;box-shadow:0 12px 32px rgba(20,49,93,.08);">
|
||||
<tr><td style="padding:28px 36px;background:linear-gradient(135deg,#0f172a,#1d4ed8);color:#ffffff;">
|
||||
<div style="font-size:14px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;opacity:.78;">LangBot Cloud</div>
|
||||
<div style="font-size:26px;font-weight:700;margin-top:8px;line-height:1.25;">You’re invited</div>
|
||||
</td></tr>
|
||||
<tr><td style="padding:36px;">
|
||||
<p style="margin:0 0 18px;font-size:16px;line-height:1.65;color:#475569;">You have been invited to collaborate in this Workspace:</p>
|
||||
<div style="margin:0 0 26px;padding:18px 20px;background:#f8fafc;border:1px solid #e2e8f0;border-radius:12px;font-size:18px;font-weight:700;color:#0f172a;">{escaped_workspace}</div>
|
||||
<table role="presentation" cellspacing="0" cellpadding="0"><tr><td style="border-radius:9px;background:#2563eb;">
|
||||
<a href="{escaped_link}" style="display:inline-block;padding:13px 22px;color:#ffffff;text-decoration:none;font-size:15px;font-weight:700;">Accept invitation</a>
|
||||
</td></tr></table>
|
||||
<p style="margin:26px 0 8px;font-size:14px;line-height:1.6;color:#64748b;">This invitation expires in 7 days and is bound to the email address that received it.</p>
|
||||
<p style="margin:0 0 8px;font-size:13px;line-height:1.6;color:#94a3b8;">If the button does not work, copy and paste this URL into your browser:</p>
|
||||
<p style="margin:0;padding:12px;background:#f8fafc;border-radius:8px;word-break:break-all;font-size:12px;line-height:1.55;color:#475569;">{escaped_link}</p>
|
||||
</td></tr>
|
||||
<tr><td style="padding:20px 36px;border-top:1px solid #eef2f7;font-size:12px;line-height:1.6;color:#94a3b8;">If you were not expecting this invitation, you can safely ignore this email.</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
<body style="margin:0;padding:0;background:#f4f7fb;color:#111827;font-family:Arial,'Helvetica Neue',sans-serif;">
|
||||
<div style="display:none;max-height:0;overflow:hidden;opacity:0;">You have been invited to join {escaped_workspace} in LangBot.</div>
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="width:100%;background:#f4f7fb;">
|
||||
<tr>
|
||||
<td align="center" style="padding:48px 16px;">
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="width:100%;max-width:600px;">
|
||||
<tr>
|
||||
<td style="padding:0 4px 20px;">
|
||||
<img src="https://docs.langbot.app/langbot-logo.png" alt="LangBot" width="34" height="34" style="display:inline-block;width:34px;height:34px;border:0;vertical-align:middle;">
|
||||
<span style="display:inline-block;margin-left:10px;vertical-align:middle;font-size:18px;font-weight:700;letter-spacing:-.01em;">LangBot</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="background:#ffffff;border-radius:10px;overflow:hidden;">
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
|
||||
<tr>
|
||||
<td style="padding:42px 42px 38px;">
|
||||
<div style="margin:0 0 12px;font-size:13px;line-height:1.4;font-weight:600;color:#5f6f84;">Workspace invitation</div>
|
||||
<h1 style="margin:0 0 16px;font-size:28px;line-height:1.25;font-weight:700;letter-spacing:-.025em;color:#111827;">You’re invited to collaborate</h1>
|
||||
<p style="margin:0 0 28px;font-size:15px;line-height:1.7;color:#526173;">Join your team in LangBot and start building together in this Workspace.</p>
|
||||
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background:#f6f8fb;border-radius:8px;">
|
||||
<tr>
|
||||
<td style="padding:16px 18px;">
|
||||
<div style="margin:0 0 4px;font-size:11px;line-height:1.4;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:#5f6f84;">Workspace</div>
|
||||
<div style="font-size:18px;line-height:1.4;font-weight:700;color:#111827;">{escaped_workspace}</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
|
||||
<tr><td height="28" style="height:28px;font-size:0;line-height:0;"> </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>
|
||||
</body>
|
||||
</html>'''
|
||||
|
||||
@@ -181,6 +181,11 @@ vdb:
|
||||
host: localhost
|
||||
port: 6333
|
||||
api_key: ''
|
||||
# SeekDB is optional. Native/package installs need the `seekdb` extra:
|
||||
# `uv sync --extra seekdb` (source) or
|
||||
# `uvx --from 'langbot[seekdb]@latest' langbot` (PyPI).
|
||||
# The official Docker image already includes it.
|
||||
# Embedded-mode platform support depends on the native pylibseekdb wheels.
|
||||
seekdb:
|
||||
mode: embedded # 'embedded' or 'server'
|
||||
# Embedded mode options:
|
||||
@@ -326,7 +331,10 @@ box:
|
||||
# skill tool, skill add/edit, and stdio-mode MCP servers. Skills can still
|
||||
# be listed read-only and http/sse MCP servers continue to work.
|
||||
enabled: true
|
||||
backend: 'local' # 'local' (Docker/nsjail), 'docker', 'nsjail', or 'e2b'. Can be written via BOX__BACKEND.
|
||||
# 'host' runs commands directly as the Box Runtime user without sandbox
|
||||
# isolation. It is never auto-selected and is only for trusted local
|
||||
# development. Can be written via BOX__BACKEND.
|
||||
backend: 'local' # 'local' (Docker/nsjail), 'docker', 'nsjail', 'e2b', or explicit unsafe 'host'.
|
||||
runtime:
|
||||
# LANGBOT_BOX_CONTROL_TOKEN is optional for OSS external WebSocket
|
||||
# runtimes. To protect an exposed endpoint, set the same strong secret
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
// Read config from script tag data attributes
|
||||
var scriptEl = document.currentScript;
|
||||
var scriptTitle = scriptEl ? scriptEl.getAttribute("data-title") : null;
|
||||
var scriptTestNotice = scriptEl
|
||||
? scriptEl.getAttribute("data-test-notice")
|
||||
: null;
|
||||
|
||||
// ========== i18n ==========
|
||||
var I18N = {
|
||||
@@ -192,6 +195,7 @@
|
||||
.lb-header-btn { background: none; border: none; color: #fff; cursor: pointer; padding: 4px; border-radius: 6px; display: flex; align-items: center; justify-content: center; opacity: 0.8; transition: opacity 0.15s; }\
|
||||
.lb-header-btn:hover { opacity: 1; }\
|
||||
.lb-header-btn svg { width: 18px; height: 18px; fill: currentColor; }\
|
||||
.lb-test-notice { padding: 8px 16px; border-bottom: 1px solid #fde68a; background: #fffbeb; color: #92400e; font-size: 12px; line-height: 1.5; text-align: center; flex-shrink: 0; }\
|
||||
.lb-messages { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 16px; scroll-behavior: smooth; }\
|
||||
.lb-messages::-webkit-scrollbar { width: 6px; }\
|
||||
.lb-messages::-webkit-scrollbar-track { background: transparent; }\
|
||||
@@ -638,9 +642,10 @@
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
if (
|
||||
prevContent === content ||
|
||||
prevContent.indexOf(content) >= 0 ||
|
||||
content.indexOf(prevContent) >= 0
|
||||
prevContent &&
|
||||
(prevContent === content ||
|
||||
prevContent.indexOf(content) >= 0 ||
|
||||
content.indexOf(prevContent) >= 0)
|
||||
)
|
||||
return;
|
||||
}
|
||||
@@ -1240,6 +1245,14 @@
|
||||
// Root container
|
||||
var root = document.createElement("div");
|
||||
root.id = "langbot-widget-root";
|
||||
root.langbotDestroy = function () {
|
||||
wsDisconnect();
|
||||
if (state.historyReloadTimer) {
|
||||
clearTimeout(state.historyReloadTimer);
|
||||
state.historyReloadTimer = null;
|
||||
}
|
||||
root.remove();
|
||||
};
|
||||
document.body.appendChild(root);
|
||||
|
||||
var shadow = root.attachShadow({ mode: "open" });
|
||||
@@ -1328,6 +1341,14 @@
|
||||
header.appendChild(headerActions);
|
||||
panel.appendChild(header);
|
||||
|
||||
if (scriptTestNotice) {
|
||||
var testNotice = document.createElement("div");
|
||||
testNotice.className = "lb-test-notice";
|
||||
testNotice.setAttribute("role", "note");
|
||||
testNotice.textContent = scriptTestNotice;
|
||||
panel.appendChild(testNotice);
|
||||
}
|
||||
|
||||
// Messages area
|
||||
var messages = document.createElement("div");
|
||||
messages.className = "lb-messages";
|
||||
|
||||
@@ -325,7 +325,7 @@ stages:
|
||||
zh_Hans: API 密钥
|
||||
type: string
|
||||
required: true
|
||||
default: 'your-api-key'
|
||||
default: ''
|
||||
- name: n8n-service-api
|
||||
label:
|
||||
en_US: n8n Workflow API
|
||||
|
||||
@@ -242,6 +242,22 @@ class TestMonitoringSessionsEndpoint:
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_sessions_forwards_user_search_and_page_window(self, quart_test_client, fake_monitoring_app):
|
||||
fake_monitoring_app.monitoring_service.get_sessions.reset_mock()
|
||||
|
||||
response = await quart_test_client.get(
|
||||
'/api/v1/monitoring/sessions?botId=bot-1&userQuery=alice&limit=20&offset=40',
|
||||
headers={'Authorization': 'Bearer test_token'},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
kwargs = fake_monitoring_app.monitoring_service.get_sessions.await_args.kwargs
|
||||
assert kwargs['bot_ids'] == ['bot-1']
|
||||
assert kwargs['user_query'] == 'alice'
|
||||
assert kwargs['limit'] == 20
|
||||
assert kwargs['offset'] == 40
|
||||
|
||||
|
||||
@pytest.mark.usefixtures('mock_circular_import_chain')
|
||||
class TestMonitoringErrorsEndpoint:
|
||||
@@ -278,13 +294,19 @@ class TestMonitoringDetailsEndpoints:
|
||||
"""Tests for detail endpoints."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_session_analysis(self, quart_test_client):
|
||||
async def test_get_session_analysis(self, quart_test_client, fake_monitoring_app):
|
||||
"""GET /api/v1/monitoring/sessions/{id}/analysis."""
|
||||
response = await quart_test_client.get(
|
||||
'/api/v1/monitoring/sessions/sess-1/analysis', headers={'Authorization': 'Bearer test_token'}
|
||||
'/api/v1/monitoring/sessions/sess-1/analysis'
|
||||
'?startTime=2026-08-31T16%3A00%3A00.000Z'
|
||||
'&endTime=2026-09-01T15%3A59%3A59.999Z',
|
||||
headers={'Authorization': 'Bearer test_token'},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
kwargs = fake_monitoring_app.monitoring_service.get_session_analysis.await_args.kwargs
|
||||
assert kwargs['start_time'].isoformat() == '2026-08-31T16:00:00'
|
||||
assert kwargs['end_time'].isoformat() == '2026-09-01T15:59:59.999000'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_message_details(self, quart_test_client):
|
||||
|
||||
@@ -254,6 +254,22 @@ class TestPipelinesCRUDEndpoints:
|
||||
assert data['code'] == 0
|
||||
assert 'uuid' in data['data']
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_default_pipeline_forwards_default_flag(self, quart_test_client, fake_pipeline_app):
|
||||
"""POST /api/v1/pipelines explicitly creates a default pipeline."""
|
||||
fake_pipeline_app.pipeline_service.create_pipeline.reset_mock()
|
||||
|
||||
response = await quart_test_client.post(
|
||||
'/api/v1/pipelines',
|
||||
headers={'Authorization': 'Bearer test_token'},
|
||||
json={'name': 'Default Pipeline', 'config': {}, 'is_default': True},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
call = fake_pipeline_app.pipeline_service.create_pipeline.await_args
|
||||
assert call.kwargs == {'default': True}
|
||||
assert call.args[1]['is_default'] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_pipeline_success(self, quart_test_client):
|
||||
"""PUT /api/v1/pipelines/{uuid} updates pipeline."""
|
||||
|
||||
@@ -307,6 +307,7 @@ class TestUserInitEndpoint:
|
||||
assert data['data'] == {
|
||||
'initialized': True,
|
||||
'authenticated_invitation_acceptance_enabled': False,
|
||||
'invitation_registration_enabled': True,
|
||||
'password_login_enabled': True,
|
||||
'space_login_enabled': False,
|
||||
}
|
||||
@@ -330,6 +331,28 @@ class TestUserInitEndpoint:
|
||||
assert data['data'] == {
|
||||
'initialized': True,
|
||||
'authenticated_invitation_acceptance_enabled': True,
|
||||
'invitation_registration_enabled': False,
|
||||
'password_login_enabled': False,
|
||||
'space_login_enabled': True,
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_account_info_enables_local_invitation_registration_for_oauth_only_oss(
|
||||
self, quart_test_client, fake_api_app
|
||||
):
|
||||
fake_api_app.user_service.is_initialized.return_value = True
|
||||
fake_api_app.user_service.get_login_capabilities = AsyncMock(
|
||||
return_value={'password_login_enabled': False, 'space_login_enabled': True}
|
||||
)
|
||||
|
||||
response = await quart_test_client.get('/api/v1/user/account-info')
|
||||
|
||||
assert response.status_code == 200
|
||||
data = await response.get_json()
|
||||
assert data['data'] == {
|
||||
'initialized': True,
|
||||
'authenticated_invitation_acceptance_enabled': False,
|
||||
'invitation_registration_enabled': True,
|
||||
'password_login_enabled': False,
|
||||
'space_login_enabled': True,
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import pytest
|
||||
import quart
|
||||
|
||||
from langbot.pkg.api.http.controller.groups.user import UserRouterGroup
|
||||
from langbot.pkg.workspace.errors import WorkspaceNotFoundError
|
||||
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
@@ -27,7 +28,8 @@ async def space_oauth_api():
|
||||
execution=SimpleNamespace(instance_uuid='instance-a', placement_generation=1),
|
||||
)
|
||||
application = Mock()
|
||||
application.deployment = SimpleNamespace(multi_workspace_enabled=False)
|
||||
application.deployment = SimpleNamespace(multi_workspace_enabled=False, mode='oss')
|
||||
application.directory_projection_service = None
|
||||
application.persistence_mgr = None
|
||||
application.user_service.get_authenticated_account = AsyncMock(return_value=account)
|
||||
application.user_service.issue_space_oauth_state = AsyncMock(
|
||||
@@ -69,6 +71,7 @@ async def space_oauth_api():
|
||||
application.space_service.get_oauth_authorize_url = Mock(
|
||||
side_effect=lambda redirect_uri, state: f'https://space.example/authorize?state={state}'
|
||||
)
|
||||
application.space_service.get_cloud_entry_url = Mock(return_value='https://space.example/cloud?environment=beta')
|
||||
application.space_service.exchange_oauth_code = AsyncMock(
|
||||
return_value={
|
||||
'access_token': 'space-access-token',
|
||||
@@ -125,6 +128,26 @@ async def test_cloud_launch_state_is_server_issued_and_workspace_bound(space_oau
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cloud_login_entry_redirects_to_space_workspace_launcher(space_oauth_api):
|
||||
application, client = space_oauth_api
|
||||
application.deployment.mode = 'cloud'
|
||||
|
||||
response = await client.get(
|
||||
'/api/v1/user/space/authorize-url',
|
||||
query_string={
|
||||
'redirect_uri': 'http://localhost/auth/space/callback',
|
||||
'cloud_entry': '1',
|
||||
},
|
||||
headers={'Origin': 'http://localhost'},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert (await response.get_json())['data']['authorize_url'] == ('https://space.example/cloud?environment=beta')
|
||||
application.space_service.get_cloud_entry_url.assert_called_once_with()
|
||||
application.user_service.issue_space_oauth_state.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_public_login_rejects_caller_supplied_state(space_oauth_api):
|
||||
application, client = space_oauth_api
|
||||
@@ -312,6 +335,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')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_oss_local_only_owner_requires_space_binding_for_langbot_models(space_oauth_api):
|
||||
application, client = space_oauth_api
|
||||
application.user_service.get_workspace_owner = AsyncMock(
|
||||
return_value=SimpleNamespace(user='owner@example.com', space_account_uuid=None)
|
||||
)
|
||||
application.space_service.get_credits = AsyncMock()
|
||||
|
||||
response = await client.get(
|
||||
'/api/v1/user/space-credits',
|
||||
headers={'Authorization': 'Bearer account-token', 'X-Workspace-Id': WORKSPACE_UUID},
|
||||
)
|
||||
payload = await response.get_json()
|
||||
|
||||
assert response.status_code == 200
|
||||
assert payload['data'] == {
|
||||
'credits': None,
|
||||
'owner_space_bound': False,
|
||||
'is_workspace_owner': True,
|
||||
}
|
||||
application.space_service.get_credits.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cloud_workspace_owner_is_always_space_bound_after_login(space_oauth_api):
|
||||
application, client = space_oauth_api
|
||||
@@ -391,3 +437,85 @@ async def test_direct_launch_assertion_does_not_consume_normal_oauth_state(space
|
||||
)
|
||||
application.user_service.consume_space_oauth_state.assert_not_awaited()
|
||||
application.space_service.exchange_oauth_code.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_direct_launch_refreshes_new_workspace_projection_before_rejecting_account(space_oauth_api):
|
||||
application, client = space_oauth_api
|
||||
projected_account = SimpleNamespace(
|
||||
uuid='account-a',
|
||||
user='owner@example.com',
|
||||
account_type='space',
|
||||
status='active',
|
||||
)
|
||||
application.user_service.get_user_by_uuid = AsyncMock(side_effect=[None, None, projected_account])
|
||||
application.directory_projection_service = SimpleNamespace(sync_once=AsyncMock())
|
||||
|
||||
response = await client.post(
|
||||
'/api/v1/user/space/callback',
|
||||
json={
|
||||
'workspace_uuid': WORKSPACE_UUID,
|
||||
'launch_assertion': 'signed-launch-token',
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert (await response.get_json())['data']['workspace_uuid'] == WORKSPACE_UUID
|
||||
assert application.directory_projection_service.sync_once.await_count == 2
|
||||
assert application.user_service.get_user_by_uuid.await_count == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_direct_launch_refreshes_projection_when_account_exists_before_workspace(space_oauth_api):
|
||||
application, client = space_oauth_api
|
||||
projected_access = application.workspace_collaboration_service.resolve_account_workspace.return_value
|
||||
application.workspace_collaboration_service.resolve_account_workspace = AsyncMock(
|
||||
side_effect=[WorkspaceNotFoundError('Workspace not found'), projected_access]
|
||||
)
|
||||
application.directory_projection_service = SimpleNamespace(sync_once=AsyncMock())
|
||||
|
||||
response = await client.post(
|
||||
'/api/v1/user/space/callback',
|
||||
json={
|
||||
'workspace_uuid': WORKSPACE_UUID,
|
||||
'launch_assertion': 'signed-launch-token',
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert (await response.get_json())['data']['workspace_uuid'] == WORKSPACE_UUID
|
||||
application.directory_projection_service.sync_once.assert_awaited_once_with()
|
||||
assert application.workspace_collaboration_service.resolve_account_workspace.await_count == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_direct_launch_falls_back_to_snapshot_when_event_backlog_exceeds_page_budget(space_oauth_api):
|
||||
application, client = space_oauth_api
|
||||
projected_access = application.workspace_collaboration_service.resolve_account_workspace.return_value
|
||||
application.workspace_collaboration_service.resolve_account_workspace = AsyncMock(
|
||||
side_effect=[
|
||||
WorkspaceNotFoundError('Workspace not found'),
|
||||
WorkspaceNotFoundError('Workspace not found'),
|
||||
WorkspaceNotFoundError('Workspace not found'),
|
||||
WorkspaceNotFoundError('Workspace not found'),
|
||||
projected_access,
|
||||
]
|
||||
)
|
||||
application.directory_projection_service = SimpleNamespace(
|
||||
sync_once=AsyncMock(),
|
||||
refresh_snapshot=AsyncMock(),
|
||||
)
|
||||
|
||||
response = await client.post(
|
||||
'/api/v1/user/space/callback',
|
||||
json={
|
||||
'workspace_uuid': WORKSPACE_UUID,
|
||||
'launch_assertion': 'signed-launch-token',
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert (await response.get_json())['data']['workspace_uuid'] == WORKSPACE_UUID
|
||||
assert application.directory_projection_service.sync_once.await_count == 3
|
||||
application.directory_projection_service.refresh_snapshot.assert_awaited_once_with()
|
||||
assert application.workspace_collaboration_service.resolve_account_workspace.await_count == 5
|
||||
|
||||
@@ -115,6 +115,7 @@ class _CapacityPluginRuntimeHandler:
|
||||
def __init__(self) -> None:
|
||||
self.bindings: dict[str, typing.Any] = {}
|
||||
self.reconciled: tuple[typing.Any, ...] = ()
|
||||
self.reconcile_timeout: float | None = None
|
||||
|
||||
def register_installation_binding(
|
||||
self,
|
||||
@@ -132,8 +133,14 @@ class _CapacityPluginRuntimeHandler:
|
||||
def unregister_installation_binding(self, binding) -> None:
|
||||
self.bindings.pop(binding.installation_uuid, None)
|
||||
|
||||
async def reconcile_plugin_installations(self, desired_states) -> dict:
|
||||
async def reconcile_plugin_installations(
|
||||
self,
|
||||
desired_states,
|
||||
*,
|
||||
timeout: float | None = None,
|
||||
) -> dict:
|
||||
self.reconciled = tuple(desired_states)
|
||||
self.reconcile_timeout = timeout
|
||||
return {
|
||||
'applied': [],
|
||||
'removed': [],
|
||||
@@ -1034,6 +1041,7 @@ class TestPostgreSQLTenantRuntime:
|
||||
assert not mcp_loader._hosted_mcp_tasks
|
||||
assert len(plugin_handler.reconciled) == workspace_count
|
||||
assert len(plugin_handler.bindings) == workspace_count
|
||||
assert plugin_handler.reconcile_timeout == 300.0
|
||||
assert all(count == workspace_count for count in statement_counts.values()), statement_counts
|
||||
if max_elapsed is not None:
|
||||
assert elapsed <= max_elapsed
|
||||
|
||||
@@ -39,6 +39,35 @@ def _assert_verified_backup(payload: dict) -> None:
|
||||
assert connection.execute('SELECT version_num FROM alembic_version').fetchone()[0] == payload['source_revision']
|
||||
|
||||
|
||||
def _temporary_sqlite_files(root: pathlib.Path) -> list[pathlib.Path]:
|
||||
return [*root.rglob('*.creating'), *root.rglob('*.restoring')]
|
||||
|
||||
|
||||
async def test_backup_removes_stale_temporary_file_from_interrupted_run(tmp_path):
|
||||
database_path = tmp_path / 'legacy-stale-backup.db'
|
||||
engine = create_async_engine(f'sqlite+aiosqlite:///{database_path}')
|
||||
try:
|
||||
await create_legacy_resource_schema(engine, instance_uuid='stale-backup')
|
||||
await alembic_runner.run_alembic_stamp(engine, '0008_mcp_resource_prefs')
|
||||
backup_directory = tmp_path / 'migration-backups'
|
||||
backup_directory.mkdir()
|
||||
stale_path = backup_directory / '.legacy-stale-backup-pre-0009-old.creating'
|
||||
unrelated_path = backup_directory / '.another-database-pre-0009-old.creating'
|
||||
stale_path.write_bytes(b'interrupted backup')
|
||||
unrelated_path.write_bytes(b'unrelated backup')
|
||||
|
||||
await sqlite_migration_backup.create_verified_backup(
|
||||
engine,
|
||||
source_revision='0008_mcp_resource_prefs',
|
||||
target_revision='0009_workspace_tenancy',
|
||||
)
|
||||
|
||||
assert not stale_path.exists()
|
||||
assert unrelated_path.read_bytes() == b'unrelated backup'
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_tenancy_migrations_retain_verified_boundary_backups(tmp_path):
|
||||
database_path = tmp_path / 'legacy-with-backups.db'
|
||||
engine = create_async_engine(f'sqlite+aiosqlite:///{database_path}')
|
||||
@@ -59,6 +88,7 @@ async def test_tenancy_migrations_retain_verified_boundary_backups(tmp_path):
|
||||
}
|
||||
for payload in payloads:
|
||||
_assert_verified_backup(payload)
|
||||
assert _temporary_sqlite_files(tmp_path) == []
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
@@ -100,6 +130,7 @@ async def test_failed_tenancy_migration_restores_backup_and_revision(
|
||||
assert restored[0]['status'] == 'restored_after_failure'
|
||||
assert restored[0]['source_revision'] == '0009_workspace_tenancy'
|
||||
_assert_verified_backup(restored[0])
|
||||
assert _temporary_sqlite_files(tmp_path) == []
|
||||
|
||||
monkeypatch.setattr(alembic_runner, 'run_alembic_upgrade', real_upgrade)
|
||||
await _manager(engine)._run_alembic_migrations()
|
||||
@@ -108,6 +139,41 @@ async def test_failed_tenancy_migration_restores_backup_and_revision(
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_restore_publish_failure_preserves_current_database(tmp_path, monkeypatch):
|
||||
database_path = tmp_path / 'restore-publish-failure.db'
|
||||
engine = create_async_engine(f'sqlite+aiosqlite:///{database_path}')
|
||||
try:
|
||||
await create_legacy_resource_schema(engine, instance_uuid='restore-publish-failure')
|
||||
await alembic_runner.run_alembic_stamp(engine, '0008_mcp_resource_prefs')
|
||||
backup = await sqlite_migration_backup.create_verified_backup(
|
||||
engine,
|
||||
source_revision='0008_mcp_resource_prefs',
|
||||
target_revision='0009_workspace_tenancy',
|
||||
)
|
||||
stale_restore_path = tmp_path / f'.{database_path.name}.interrupted.restoring'
|
||||
stale_restore_path.write_bytes(b'interrupted restore')
|
||||
async with engine.begin() as connection:
|
||||
await connection.execute(sa.text("UPDATE alembic_version SET version_num = 'failed-revision'"))
|
||||
await engine.dispose()
|
||||
database_before_restore = database_path.read_bytes()
|
||||
real_replace = os.replace
|
||||
|
||||
def fail_restore_publish(source, destination):
|
||||
if pathlib.Path(destination) == database_path:
|
||||
raise OSError('simulated atomic publish failure')
|
||||
return real_replace(source, destination)
|
||||
|
||||
monkeypatch.setattr(sqlite_migration_backup.os, 'replace', fail_restore_publish)
|
||||
|
||||
with pytest.raises(OSError, match='atomic publish failure'):
|
||||
await sqlite_migration_backup.restore_verified_backup(engine, backup)
|
||||
|
||||
assert database_path.read_bytes() == database_before_restore
|
||||
assert _temporary_sqlite_files(tmp_path) == []
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_backup_retries_transient_reopen_failure_after_replace(tmp_path, monkeypatch):
|
||||
database_path = tmp_path / 'legacy-bind-mount.db'
|
||||
engine = create_async_engine(f'sqlite+aiosqlite:///{database_path}')
|
||||
|
||||
@@ -9,8 +9,9 @@ Source: src/langbot/pkg/api/http/service/bot.py
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||
from types import SimpleNamespace
|
||||
import json
|
||||
import uuid
|
||||
|
||||
from langbot.pkg.api.http.service.bot import BotService
|
||||
@@ -241,6 +242,29 @@ class TestBotServiceGetRuntimeBotInfo:
|
||||
assert result['adapter_runtime_values']['webhook_url'] == '/bots/wecom-uuid'
|
||||
assert result['adapter_runtime_values']['webhook_full_url'] == 'http://127.0.0.1:5300/bots/wecom-uuid'
|
||||
|
||||
async def test_get_runtime_bot_info_returns_webhook_for_http_bot(self):
|
||||
ap = SimpleNamespace(
|
||||
instance_config=SimpleNamespace(
|
||||
data={'api': {'webhook_prefix': 'https://bot.example.com'}}
|
||||
),
|
||||
platform_mgr=SimpleNamespace(get_bot_by_uuid=AsyncMock(return_value=None)),
|
||||
)
|
||||
service = BotService(ap)
|
||||
service.get_bot = AsyncMock(
|
||||
return_value={
|
||||
'uuid': 'http-bot-uuid',
|
||||
'name': 'HTTP Bot',
|
||||
'adapter': 'http_bot',
|
||||
'adapter_config': {},
|
||||
}
|
||||
)
|
||||
|
||||
result = await service.get_runtime_bot_info(WORKSPACE_UUID, 'http-bot-uuid')
|
||||
|
||||
assert result['adapter_runtime_values']['webhook_full_url'] == (
|
||||
'https://bot.example.com/bots/http-bot-uuid'
|
||||
)
|
||||
|
||||
async def test_get_runtime_bot_info_no_webhook_for_telegram(self):
|
||||
"""Returns no webhook URL for non-webhook adapters like telegram."""
|
||||
# Setup
|
||||
@@ -605,6 +629,77 @@ class TestBotServiceListEventLogs:
|
||||
assert total == 5
|
||||
|
||||
|
||||
class TestBotServiceHttpBotInboundTest:
|
||||
async def test_sends_signed_message_through_public_ingress(self):
|
||||
ap = SimpleNamespace(
|
||||
instance_config=SimpleNamespace(data={'api': {'port': 5300}}),
|
||||
)
|
||||
service = BotService(ap)
|
||||
service.get_bot = AsyncMock(
|
||||
return_value={
|
||||
'uuid': 'http-bot-uuid',
|
||||
'adapter': 'http_bot',
|
||||
'adapter_config': {
|
||||
'signature_required': True,
|
||||
'inbound_secret': 'test-secret',
|
||||
},
|
||||
'enable': True,
|
||||
}
|
||||
)
|
||||
response = MagicMock(status=202)
|
||||
session = MagicMock()
|
||||
session.post.return_value.__aenter__ = AsyncMock(return_value=response)
|
||||
session.post.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
with (
|
||||
patch('langbot.pkg.api.http.service.bot.httpclient.get_session', return_value=session),
|
||||
patch(
|
||||
'langbot.pkg.api.http.service.bot.httpclient.read_json_limited',
|
||||
new=AsyncMock(
|
||||
return_value={
|
||||
'code': 0,
|
||||
'data': {
|
||||
'session_id': 'wizard-session',
|
||||
'accepted_message_id': 'in-message',
|
||||
},
|
||||
}
|
||||
),
|
||||
),
|
||||
):
|
||||
result = await service.send_http_bot_test_message(
|
||||
WORKSPACE_UUID,
|
||||
'http-bot-uuid',
|
||||
'hello',
|
||||
)
|
||||
|
||||
assert result['accepted_message_id'] == 'in-message'
|
||||
request = session.post.call_args
|
||||
assert request.args[0] == 'http://127.0.0.1:5300/bots/http-bot-uuid'
|
||||
payload = json.loads(request.kwargs['data'])
|
||||
assert payload['message'] == [{'type': 'Plain', 'text': 'hello'}]
|
||||
headers = request.kwargs['headers']
|
||||
assert headers['X-LB-Timestamp']
|
||||
assert headers['X-LB-Signature'].startswith('sha256=')
|
||||
|
||||
async def test_rejects_non_http_bot(self):
|
||||
service = BotService(SimpleNamespace())
|
||||
service.get_bot = AsyncMock(
|
||||
return_value={
|
||||
'uuid': 'telegram-bot',
|
||||
'adapter': 'telegram',
|
||||
'adapter_config': {},
|
||||
'enable': True,
|
||||
}
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match='only available for HTTP Bot'):
|
||||
await service.send_http_bot_test_message(
|
||||
WORKSPACE_UUID,
|
||||
'telegram-bot',
|
||||
'hello',
|
||||
)
|
||||
|
||||
|
||||
class TestBotServiceSendMessage:
|
||||
"""Tests for send_message method."""
|
||||
|
||||
|
||||
@@ -138,6 +138,39 @@ async def test_same_session_and_resource_ids_do_not_collide(service):
|
||||
assert (await service.get_message_details(context_a, message_b))['found'] is False
|
||||
|
||||
|
||||
async def test_session_search_matches_user_id_or_name_within_workspace(service):
|
||||
context_a = _context(WORKSPACE_A)
|
||||
context_b = _context(WORKSPACE_B)
|
||||
fixtures = [
|
||||
(context_a, 'session-id-match', 'customer-42', 'Alice'),
|
||||
(context_a, 'session-name-match', 'customer-99', 'Bob Alice Cooper'),
|
||||
(context_a, 'session-no-match', 'customer-7', 'Bob'),
|
||||
(context_b, 'session-other-workspace', 'customer-42', 'Alice'),
|
||||
]
|
||||
for context, session_id, user_id, user_name in fixtures:
|
||||
await service.record_session_start(
|
||||
context,
|
||||
session_id=session_id,
|
||||
bot_id='same-bot',
|
||||
bot_name='Same Bot',
|
||||
pipeline_id='same-pipeline',
|
||||
pipeline_name='Same Pipeline',
|
||||
user_id=user_id,
|
||||
user_name=user_name,
|
||||
)
|
||||
|
||||
by_id, id_total = await service.get_sessions(context_a, user_query='customer-42')
|
||||
by_name, name_total = await service.get_sessions(context_a, user_query='alice')
|
||||
|
||||
assert id_total == 1
|
||||
assert [session['session_id'] for session in by_id] == ['session-id-match']
|
||||
assert name_total == 2
|
||||
assert {session['session_id'] for session in by_name} == {
|
||||
'session-id-match',
|
||||
'session-name-match',
|
||||
}
|
||||
|
||||
|
||||
async def test_tool_call_inherits_context_from_connection_message_row(service):
|
||||
context = _context(WORKSPACE_A)
|
||||
message_id = await _record_message(service, context, 'tool context')
|
||||
|
||||
@@ -820,6 +820,100 @@ class TestSpaceServiceGetModels:
|
||||
await service.get_models()
|
||||
|
||||
|
||||
class TestSpaceServiceGetModelSelection:
|
||||
"""Tests for availability-ranked model selection."""
|
||||
|
||||
@pytest.mark.parametrize('response_shape', ['direct', 'models-envelope', 'availability-wrapper'])
|
||||
async def test_preserves_selection_order_and_category_query(self, response_shape):
|
||||
ap = SimpleNamespace(instance_config=SimpleNamespace(data={}))
|
||||
service = SpaceService(ap)
|
||||
models = [
|
||||
{
|
||||
'uuid': 'best-model',
|
||||
'model_id': 'best-chat-model',
|
||||
'provider': 'provider-1',
|
||||
'category': 'chat',
|
||||
'status': 'active',
|
||||
},
|
||||
{
|
||||
'uuid': 'fallback-model',
|
||||
'model_id': 'fallback-chat-model',
|
||||
'provider': 'provider-2',
|
||||
'category': 'chat',
|
||||
'status': 'active',
|
||||
},
|
||||
]
|
||||
if response_shape == 'models-envelope':
|
||||
data = {'models': models}
|
||||
elif response_shape == 'availability-wrapper':
|
||||
data = [
|
||||
{'model': model, 'latency_ms': index + 10, 'http_code': 200}
|
||||
for index, model in enumerate(models)
|
||||
]
|
||||
else:
|
||||
data = models
|
||||
payload = {'code': 0, 'data': data}
|
||||
mock_response = MagicMock(status=200)
|
||||
|
||||
with (
|
||||
patch('langbot.pkg.api.http.service.space.httpclient.get_session') as get_session,
|
||||
patch(
|
||||
'langbot.pkg.api.http.service.space.httpclient.read_json_limited',
|
||||
new=AsyncMock(return_value=payload),
|
||||
),
|
||||
):
|
||||
session = MagicMock()
|
||||
session.get.return_value.__aenter__ = AsyncMock(return_value=mock_response)
|
||||
session.get.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||
get_session.return_value = session
|
||||
|
||||
result = await service.get_model_selection('chat')
|
||||
|
||||
assert [model.uuid for model in result] == ['best-model', 'fallback-model']
|
||||
session.get.assert_called_once_with(
|
||||
'https://space.langbot.app/api/v1/models/selection',
|
||||
params={'category': 'chat'},
|
||||
)
|
||||
|
||||
async def test_recommended_model_uses_first_selection_and_refreshes_once(self):
|
||||
local_model = SimpleNamespace(uuid='local-model-uuid', name='best-chat-model')
|
||||
persistence = SimpleNamespace(
|
||||
execute_async=AsyncMock(
|
||||
side_effect=[
|
||||
_create_mock_result(first_item=None),
|
||||
_create_mock_result(first_item=local_model),
|
||||
]
|
||||
)
|
||||
)
|
||||
model_mgr = SimpleNamespace(sync_new_models_from_space=AsyncMock())
|
||||
ap = SimpleNamespace(
|
||||
instance_config=SimpleNamespace(data={}),
|
||||
persistence_mgr=persistence,
|
||||
model_mgr=model_mgr,
|
||||
)
|
||||
service = SpaceService(ap)
|
||||
service.get_model_selection = AsyncMock(
|
||||
return_value=[
|
||||
SimpleNamespace(uuid='best-upstream-uuid', model_id='best-chat-model'),
|
||||
SimpleNamespace(uuid='fallback-upstream-uuid', model_id='fallback-chat-model'),
|
||||
]
|
||||
)
|
||||
context = SimpleNamespace(
|
||||
instance_uuid='instance',
|
||||
workspace_uuid='workspace',
|
||||
placement_generation=1,
|
||||
principal=SimpleNamespace(),
|
||||
entitlement_revision=0,
|
||||
)
|
||||
|
||||
result = await service.get_recommended_chat_model(context)
|
||||
|
||||
assert result == {'uuid': 'local-model-uuid', 'name': 'best-chat-model'}
|
||||
service.get_model_selection.assert_awaited_once_with('chat')
|
||||
model_mgr.sync_new_models_from_space.assert_awaited_once()
|
||||
assert persistence.execute_async.await_count == 2
|
||||
|
||||
|
||||
class TestSpaceServiceCreditsCache:
|
||||
"""Tests for credits cache behavior."""
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import logging
|
||||
from types import SimpleNamespace
|
||||
@@ -735,6 +736,72 @@ async def test_each_replica_consumes_events_with_its_own_cursor(projection_conte
|
||||
assert second_provider.after_cursors == [1, 2]
|
||||
|
||||
|
||||
async def test_concurrent_sync_once_calls_are_serialized_per_service(projection_context):
|
||||
application, _session_factory = projection_context
|
||||
|
||||
class _ConcurrentProvider(_Provider):
|
||||
def __init__(self) -> None:
|
||||
super().__init__([_snapshot(1)])
|
||||
self.first_fetch_started = asyncio.Event()
|
||||
self.release_first_fetch = asyncio.Event()
|
||||
self.active_fetches = 0
|
||||
self.max_active_fetches = 0
|
||||
|
||||
async def fetch_events(
|
||||
self,
|
||||
instance_uuid: str,
|
||||
after_cursor: int,
|
||||
limit: int,
|
||||
) -> DirectoryEventBatch:
|
||||
assert instance_uuid == INSTANCE_UUID
|
||||
assert limit == 100
|
||||
self.after_cursors.append(after_cursor)
|
||||
self.active_fetches += 1
|
||||
self.max_active_fetches = max(self.max_active_fetches, self.active_fetches)
|
||||
try:
|
||||
if len(self.after_cursors) == 1:
|
||||
self.first_fetch_started.set()
|
||||
await self.release_first_fetch.wait()
|
||||
cursor = after_cursor + 1
|
||||
return DirectoryEventBatch(
|
||||
instance_uuid=instance_uuid,
|
||||
after_cursor=after_cursor,
|
||||
cursor=cursor,
|
||||
high_water_cursor=cursor,
|
||||
events=(
|
||||
DirectoryEvent(
|
||||
cursor=cursor,
|
||||
uuid=f'40000000-0000-4000-8000-{cursor:012d}',
|
||||
aggregate_uuid=WORKSPACE_UUID,
|
||||
event_type='entitlement.changed',
|
||||
revision=cursor,
|
||||
payload={
|
||||
'workspace_uuid': WORKSPACE_UUID,
|
||||
'entitlement_revision': cursor,
|
||||
},
|
||||
created_at=datetime.datetime(2026, 7, 24, 12, cursor, tzinfo=datetime.UTC),
|
||||
),
|
||||
),
|
||||
)
|
||||
finally:
|
||||
self.active_fetches -= 1
|
||||
|
||||
provider = _ConcurrentProvider()
|
||||
service = DirectoryProjectionService(application, provider, INSTANCE_UUID)
|
||||
await service.initialize()
|
||||
|
||||
first = asyncio.create_task(service.sync_once())
|
||||
await provider.first_fetch_started.wait()
|
||||
second = asyncio.create_task(service.sync_once())
|
||||
await asyncio.sleep(0)
|
||||
provider.release_first_fetch.set()
|
||||
await asyncio.gather(first, second)
|
||||
|
||||
assert provider.max_active_fetches == 1
|
||||
assert provider.after_cursors == [1, 2]
|
||||
assert service._consumer_cursor == 3
|
||||
|
||||
|
||||
async def test_snapshot_coverage_allows_lagging_replica_to_replay_receipts(projection_context):
|
||||
application, session_factory = projection_context
|
||||
event_two = DirectoryEvent(
|
||||
|
||||
@@ -144,3 +144,39 @@ async def test_runtime_resource_stats_are_aggregate_and_constant_time() -> None:
|
||||
assert stats['models']['providers'] == 1
|
||||
assert stats['runtimes']['plugin_installations'] == 1
|
||||
assert stats['runtimes']['plugin_runtime_connected'] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_plugin_runtime_initialization_bypasses_after_commit_gate() -> None:
|
||||
app = Application()
|
||||
app.plugin_connector = SimpleNamespace(initialize=AsyncMock())
|
||||
app.task_mgr = SimpleNamespace(create_task=AsyncMock())
|
||||
|
||||
task = app._start_plugin_runtime_initialization()
|
||||
await task
|
||||
|
||||
app.plugin_connector.initialize.assert_awaited_once_with()
|
||||
app.task_mgr.create_task.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_shutdown_cancels_plugin_runtime_initialization_task() -> None:
|
||||
app = Application()
|
||||
app._plugin_runtime_initialization_task = asyncio.create_task(asyncio.sleep(60))
|
||||
app.task_mgr = SimpleNamespace(cancel_by_scope=lambda *_: None, tasks=[])
|
||||
app.event_loop_monitor = SimpleNamespace(stop=AsyncMock())
|
||||
app.http_ctrl = SimpleNamespace(mcp_mount=None)
|
||||
app.platform_mgr = None
|
||||
app.tool_mgr = None
|
||||
app.model_mgr = None
|
||||
app.box_service = None
|
||||
app.plugin_connector = None
|
||||
app.telemetry = None
|
||||
app.vector_db_mgr = None
|
||||
app.storage_mgr = None
|
||||
app.persistence_mgr = SimpleNamespace(db=SimpleNamespace(engine=SimpleNamespace(dispose=AsyncMock())))
|
||||
app.deployment = None
|
||||
|
||||
await app.shutdown()
|
||||
|
||||
assert app._plugin_runtime_initialization_task.cancelled()
|
||||
|
||||
@@ -964,6 +964,7 @@ async def test_scoped_session_rejects_raw_or_unapproved_sql(
|
||||
sa.func.date_trunc('hour', sa.column('timestamp')),
|
||||
sa.func.length(sa.literal('value')),
|
||||
sa.func.nullif(sa.literal('value'), sa.literal('')),
|
||||
sa.func.strftime('%Y-%m-%d %H:00', sa.column('timestamp')),
|
||||
),
|
||||
sa.select(sa.column('embedding').op('<=>')(sa.literal([0.1]))),
|
||||
sa.select(sa.cast(sa.column('embedding'), Vector(384))),
|
||||
@@ -977,6 +978,19 @@ async def test_scoped_sql_structure_allows_only_the_production_vocabulary(statem
|
||||
_validate_scoped_statement_call((statement,), {})
|
||||
|
||||
|
||||
async def test_scoped_session_executes_sqlite_strftime() -> None:
|
||||
engine = create_async_engine('sqlite+aiosqlite:///:memory:')
|
||||
try:
|
||||
async with TenantUnitOfWork(engine, 'workspace-a') as uow:
|
||||
result = await uow.session.execute(
|
||||
sa.select(sa.func.strftime('%Y-%m-%d %H:00', sa.literal('2026-08-28 03:45:00')))
|
||||
)
|
||||
|
||||
assert result.scalar_one() == '2026-08-28 03:00'
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_scoped_sql_rejects_public_execution_options() -> None:
|
||||
statement = sa.select(sa.literal(1))
|
||||
with pytest.raises(ScopedSessionTransactionError, match='execution options'):
|
||||
|
||||
@@ -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 == ''
|
||||
@@ -1,8 +1,11 @@
|
||||
"""Tests for DingTalk API payload helpers."""
|
||||
|
||||
import json
|
||||
from contextlib import asynccontextmanager
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from langbot.libs.dingtalk_api.api import _stringify_card_param_map
|
||||
from langbot.libs.dingtalk_api.api import DingTalkClient, _stringify_card_param_map
|
||||
from langbot.pkg.utils import httpclient
|
||||
|
||||
|
||||
def test_dingtalk_card_param_map_stringifies_select_component_arrays():
|
||||
@@ -40,3 +43,41 @@ def test_dingtalk_card_param_map_stringifies_unregistered_structures():
|
||||
|
||||
assert params['other'] == '["A"]'
|
||||
assert params['empty'] == ''
|
||||
|
||||
|
||||
async def test_create_card_embeds_layout_config_as_template_parameter(monkeypatch):
|
||||
response = type('Response', (), {'status_code': 200})()
|
||||
post = AsyncMock(return_value=response)
|
||||
|
||||
@asynccontextmanager
|
||||
async def client_context():
|
||||
yield type('HttpClient', (), {'post': post})()
|
||||
|
||||
client = object.__new__(DingTalkClient)
|
||||
client.access_token = 'access-token'
|
||||
client.robot_code = 'robot-code'
|
||||
client.key = 'client-id'
|
||||
client.logger = None
|
||||
client.check_access_token = AsyncMock(return_value=True)
|
||||
client._http_client_context = client_context
|
||||
monkeypatch.setattr(httpclient, 'response_text', AsyncMock(return_value='{}'))
|
||||
|
||||
original_params = {'content': 'hello'}
|
||||
delivered = await client.create_and_deliver_card(
|
||||
card_template_id='template-id',
|
||||
out_track_id='track-id',
|
||||
open_space_id='dtv1.card//IM_ROBOT.user-id',
|
||||
is_group=False,
|
||||
card_param_map=original_params,
|
||||
card_data_config={'autoLayout': True},
|
||||
)
|
||||
|
||||
request_body = post.await_args.kwargs['json']
|
||||
assert delivered is True
|
||||
assert request_body['cardData'] == {
|
||||
'cardParamMap': {
|
||||
'content': 'hello',
|
||||
'config': '{"autoLayout": true}',
|
||||
}
|
||||
}
|
||||
assert original_params == {'content': 'hello'}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Tests for Lark adapter helper behavior."""
|
||||
|
||||
import threading
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -12,6 +12,7 @@ from langbot.pkg.platform.sources.lark import (
|
||||
_lark_completed_input_lines,
|
||||
_lark_current_input_defs,
|
||||
_lark_extract_action_form_inputs,
|
||||
_lark_final_layout_texts,
|
||||
_lark_should_update_stream_element,
|
||||
_lark_visible_form_content,
|
||||
)
|
||||
@@ -221,3 +222,121 @@ def test_lark_completed_input_lines_display_select_value_from_object():
|
||||
)
|
||||
|
||||
assert lines == ['✅ xiala:B']
|
||||
|
||||
|
||||
def test_lark_final_layout_texts_normal_round_drops_resume_placeholder():
|
||||
"""Non-resume final chunk: the reply must land in the main element only.
|
||||
|
||||
Regression: rendering the resume placeholder too duplicated the reply,
|
||||
because the accumulated streaming text equals the final text on a normal
|
||||
round (e.g. 'It is Sep 1, 2026.\nIt is Sep 1, 2026.' in the card).
|
||||
"""
|
||||
main_text, resume_text = _lark_final_layout_texts(
|
||||
resume_from=False,
|
||||
text_message='It is Sep 1, 2026, 15:09:15.',
|
||||
pre_pause_cached=None,
|
||||
resume_cached='It is Sep 1, 2026, 15:09:15.',
|
||||
)
|
||||
|
||||
assert main_text == 'It is Sep 1, 2026, 15:09:15.'
|
||||
assert resume_text == ''
|
||||
|
||||
|
||||
def test_lark_final_layout_texts_resume_round_keeps_both_segments():
|
||||
"""Dify HITL resume final chunk: pre-pause text and resumed text differ,
|
||||
both segments stay visible."""
|
||||
main_text, resume_text = _lark_final_layout_texts(
|
||||
resume_from=True,
|
||||
text_message='resumed answer',
|
||||
pre_pause_cached='partial answer before pause',
|
||||
resume_cached='resumed answer',
|
||||
)
|
||||
|
||||
assert main_text == 'partial answer before pause'
|
||||
assert resume_text == 'resumed answer'
|
||||
|
||||
|
||||
def test_lark_final_layout_texts_resume_round_without_pre_pause_falls_back():
|
||||
main_text, resume_text = _lark_final_layout_texts(
|
||||
resume_from=True,
|
||||
text_message='answer',
|
||||
pre_pause_cached=None,
|
||||
resume_cached='answer',
|
||||
)
|
||||
|
||||
assert main_text == 'answer'
|
||||
assert resume_text == 'answer'
|
||||
|
||||
|
||||
def test_lark_final_layout_texts_resume_round_empty_pre_pause_kept_empty():
|
||||
"""Dify paused before emitting any text: the pre-pause cache is a valid
|
||||
empty string and must NOT be treated as a cache miss.
|
||||
|
||||
Regression: `pre_pause_cached or text_message` fell back to the full
|
||||
text, so the final card rendered ('resumed answer', 'resumed answer')
|
||||
and duplicated the reply.
|
||||
"""
|
||||
main_text, resume_text = _lark_final_layout_texts(
|
||||
resume_from=True,
|
||||
text_message='resumed answer',
|
||||
pre_pause_cached='',
|
||||
resume_cached='resumed answer',
|
||||
)
|
||||
|
||||
assert main_text == ''
|
||||
assert resume_text == 'resumed answer'
|
||||
|
||||
|
||||
def _build_resume_final_chunk_adapter(message_text: str):
|
||||
"""Build a LarkAdapter whose card state mimics a Dify HITL round that
|
||||
paused before emitting any text, then resumed and completed."""
|
||||
adapter = LarkAdapter.model_construct(
|
||||
api_client=MagicMock(),
|
||||
message_converter=MagicMock(yiri2target=AsyncMock(return_value=([[{'tag': 'text', 'text': message_text}]], []))),
|
||||
)
|
||||
adapter.config = {'app_type': 'self'}
|
||||
LarkAdapter.get_app_access_token = lambda self: None
|
||||
LarkAdapter.get_tenant_access_token = lambda self, tenant_key: None
|
||||
adapter.card_id_dict = {'msg-1': 'card-1'}
|
||||
adapter.card_streaming_text = {'card-1': message_text}
|
||||
adapter.card_pre_pause_text = {'card-1': ''}
|
||||
adapter.card_resume_transitioned = {'card-1'}
|
||||
adapter.card_sequence_dict = {}
|
||||
adapter.card_last_accessed = {}
|
||||
adapter.card_cleanup_at = 0.0
|
||||
adapter.card_id_to_source_ids = {}
|
||||
adapter.reply_message_card_ids = {}
|
||||
adapter.card_form_content = {}
|
||||
adapter.card_form_input_defs = {}
|
||||
adapter.card_form_inputs = {}
|
||||
adapter._update_card_layout = AsyncMock()
|
||||
return adapter
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reply_message_chunk_resume_final_with_empty_pre_pause_keeps_main_empty():
|
||||
"""End-to-end regression via reply_message_chunk: Dify paused before any
|
||||
text, so the pre-pause cache is ''. The final card update must render the
|
||||
resumed answer only once (empty main text + resume placeholder), not
|
||||
twice as ('resumed answer', 'resumed answer')."""
|
||||
adapter = _build_resume_final_chunk_adapter('resumed answer')
|
||||
|
||||
bot_message = MagicMock(
|
||||
resp_message_id='msg-1',
|
||||
msg_sequence=1,
|
||||
spec=['resp_message_id', 'msg_sequence', '_resume_from_form'],
|
||||
)
|
||||
bot_message._resume_from_form = True
|
||||
message_source = MagicMock(source_platform_object=None)
|
||||
|
||||
await adapter.reply_message_chunk(
|
||||
message_source,
|
||||
bot_message,
|
||||
MagicMock(),
|
||||
is_final=True,
|
||||
)
|
||||
|
||||
adapter._update_card_layout.assert_awaited_once()
|
||||
layout_kwargs = adapter._update_card_layout.await_args.kwargs
|
||||
assert layout_kwargs['text_message'] == ''
|
||||
assert layout_kwargs['resume_placeholder_text'] == 'resumed answer'
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from linebot.v3.webhooks import TextMessageContent, UserMentionee, AllMentionee
|
||||
|
||||
from langbot.pkg.platform import botmgr as _botmgr # noqa: F401
|
||||
from langbot.pkg.platform.sources import line
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
|
||||
BOT_ACCOUNT_ID = 'line-bot-account'
|
||||
|
||||
|
||||
def _make_event(
|
||||
*, source_type: str, user_id, group_id=None, room_id=None, message_id: str, text: str = 'hi', mention=None
|
||||
):
|
||||
event = MagicMock()
|
||||
event.timestamp = 1700000000000
|
||||
message = MagicMock(spec=TextMessageContent)
|
||||
message.id = message_id
|
||||
message.text = text
|
||||
message.mention = mention
|
||||
event.message = message
|
||||
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
|
||||
|
||||
|
||||
def _make_converter(bot_account_id: str = BOT_ACCOUNT_ID) -> line.LINEEventConverter:
|
||||
return line.LINEEventConverter(bot_account_id=bot_account_id)
|
||||
|
||||
|
||||
@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).
|
||||
"""
|
||||
converter = _make_converter()
|
||||
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 converter.target2yiri(event1, bot_client=None)
|
||||
result2 = await converter.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:
|
||||
converter = _make_converter()
|
||||
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 converter.target2yiri(event1, bot_client=None)
|
||||
result2 = await converter.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:
|
||||
converter = _make_converter()
|
||||
event = _make_event(source_type='room', user_id=None, room_id='R-stable-room', message_id='msg-1')
|
||||
|
||||
result = await converter.target2yiri(event, bot_client=None)
|
||||
|
||||
assert result.sender.group.id == 'R-stable-room'
|
||||
assert result.sender.id == 'R-stable-room'
|
||||
|
||||
|
||||
def _plain_texts(chain: platform_message.MessageChain) -> list[str]:
|
||||
return [c.text for c in chain if isinstance(c, platform_message.Plain)]
|
||||
|
||||
|
||||
def _ats(chain: platform_message.MessageChain) -> list[platform_message.At]:
|
||||
return [c for c in chain if isinstance(c, platform_message.At)]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_mention_keeps_plain_text() -> None:
|
||||
converter = _make_converter()
|
||||
event = _make_event(source_type='group', user_id='U-member', group_id='G1', message_id='m1', text='hello world')
|
||||
|
||||
chain = await converter.message_converter.target2yiri(event, bot_client=None)
|
||||
|
||||
assert _plain_texts(chain) == ['hello world']
|
||||
assert _ats(chain) == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bot_mention_maps_to_at_with_bot_account_id() -> None:
|
||||
"""A @bot mention must become At(target=bot_account_id) so the 'at-bot'
|
||||
group respond rule matches (previously the mention was lost and the message
|
||||
was silently dropped in groups with at-only rules).
|
||||
"""
|
||||
mention = MagicMock()
|
||||
mention.mentionees = [
|
||||
UserMentionee(type='user', index=0, length=4, userId='U-bot-user-id', isSelf=True),
|
||||
]
|
||||
converter = _make_converter()
|
||||
event = _make_event(
|
||||
source_type='group',
|
||||
user_id='U-member',
|
||||
group_id='G1',
|
||||
message_id='m1',
|
||||
text='@BOT hey',
|
||||
mention=mention,
|
||||
)
|
||||
|
||||
chain = await converter.message_converter.target2yiri(event, bot_client=None)
|
||||
|
||||
ats = _ats(chain)
|
||||
assert len(ats) == 1
|
||||
assert ats[0].target == BOT_ACCOUNT_ID
|
||||
assert _plain_texts(chain) == [' hey']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_other_user_mention_keeps_display_text() -> None:
|
||||
"""Mentions of other users keep their display text in the message string,
|
||||
so prefix/regexp rules that match the raw '@Name ...' text still work.
|
||||
"""
|
||||
mention = MagicMock()
|
||||
mention.mentionees = [
|
||||
UserMentionee(type='user', index=0, length=6, userId='U-other', isSelf=False),
|
||||
]
|
||||
converter = _make_converter()
|
||||
event = _make_event(
|
||||
source_type='group',
|
||||
user_id='U-member',
|
||||
group_id='G1',
|
||||
message_id='m1',
|
||||
text='@Alice hello',
|
||||
mention=mention,
|
||||
)
|
||||
|
||||
chain = await converter.message_converter.target2yiri(event, bot_client=None)
|
||||
|
||||
ats = _ats(chain)
|
||||
assert len(ats) == 1
|
||||
assert ats[0].target == 'U-other'
|
||||
# str() of the At component falls back to display when set
|
||||
assert str(chain) == '@Alice hello'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bot_mention_triggers_atbot_rule() -> None:
|
||||
"""End-to-end: a group message that @mentions the bot must be accepted by
|
||||
the at-bot respond rule (this is the regression that silently dropped
|
||||
'@bot' messages in LINE groups).
|
||||
"""
|
||||
from langbot.pkg.pipeline.resprule.rules.atbot import AtBotRule
|
||||
|
||||
mention = MagicMock()
|
||||
mention.mentionees = [
|
||||
UserMentionee(type='user', index=0, length=6, userId='U-bot-user-id', isSelf=True),
|
||||
]
|
||||
converter = _make_converter()
|
||||
event = _make_event(
|
||||
source_type='group',
|
||||
user_id='U-member',
|
||||
group_id='G1',
|
||||
message_id='m1',
|
||||
text='@RAIQt hi',
|
||||
mention=mention,
|
||||
)
|
||||
|
||||
chain = await converter.message_converter.target2yiri(event, bot_client=None)
|
||||
|
||||
query = MagicMock()
|
||||
query.adapter = MagicMock()
|
||||
query.adapter.bot_account_id = BOT_ACCOUNT_ID
|
||||
|
||||
rule = AtBotRule(ap=MagicMock())
|
||||
result = await rule.match(str(chain), chain, {'at': True}, query)
|
||||
|
||||
assert result.matching is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_without_bot_mention_still_dropped_by_atbot_rule() -> None:
|
||||
from langbot.pkg.pipeline.resprule.rules.atbot import AtBotRule
|
||||
|
||||
converter = _make_converter()
|
||||
event = _make_event(source_type='group', user_id='U-member', group_id='G1', message_id='m1', text='hello')
|
||||
|
||||
chain = await converter.message_converter.target2yiri(event, bot_client=None)
|
||||
|
||||
query = MagicMock()
|
||||
query.adapter = MagicMock()
|
||||
query.adapter.bot_account_id = BOT_ACCOUNT_ID
|
||||
|
||||
rule = AtBotRule(ap=MagicMock())
|
||||
result = await rule.match(str(chain), chain, {'at': True}, query)
|
||||
|
||||
assert result.matching is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_at_all_mention_preserved_as_at_component() -> None:
|
||||
mention = MagicMock()
|
||||
mention.mentionees = [
|
||||
AllMentionee(type='all', index=0, length=4),
|
||||
]
|
||||
converter = _make_converter()
|
||||
event = _make_event(
|
||||
source_type='group',
|
||||
user_id='U-member',
|
||||
group_id='G1',
|
||||
message_id='m1',
|
||||
text='@All hello',
|
||||
mention=mention,
|
||||
)
|
||||
|
||||
chain = await converter.message_converter.target2yiri(event, bot_client=None)
|
||||
|
||||
ats = _ats(chain)
|
||||
assert len(ats) == 1
|
||||
assert str(chain) == '@All hello'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_mentions_sorted_by_position() -> None:
|
||||
mention = MagicMock()
|
||||
# Intentionally out of order to exercise sorting
|
||||
mention.mentionees = [
|
||||
UserMentionee(type='user', index=9, length=4, userId='U-b', isSelf=False),
|
||||
UserMentionee(type='user', index=0, length=4, userId='U-a', isSelf=False),
|
||||
]
|
||||
converter = _make_converter()
|
||||
event = _make_event(
|
||||
source_type='group',
|
||||
user_id='U-member',
|
||||
group_id='G1',
|
||||
message_id='m1',
|
||||
text='@aaa mid @bbb tail',
|
||||
mention=mention,
|
||||
)
|
||||
|
||||
chain = await converter.message_converter.target2yiri(event, bot_client=None)
|
||||
|
||||
ats = _ats(chain)
|
||||
assert [a.target for a in ats] == ['U-a', 'U-b']
|
||||
assert str(chain) == '@aaa mid @bbb tail'
|
||||
@@ -1,9 +1,11 @@
|
||||
"""Tests for QQ Official keyboard payload helpers."""
|
||||
"""Tests for QQ Official message and keyboard payload helpers."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
@@ -99,6 +101,12 @@ def _stream_test_adapter():
|
||||
adapter.bot = MagicMock()
|
||||
adapter.bot.send_stream_msg = AsyncMock(return_value={'id': 'stream-1'})
|
||||
adapter.bot.send_markdown_keyboard = AsyncMock(return_value={'id': 'message-1'})
|
||||
adapter.bot.send_private_text_msg = AsyncMock()
|
||||
adapter.bot.send_group_text_msg = AsyncMock()
|
||||
adapter.bot.send_private_markdown_msg = AsyncMock()
|
||||
adapter.bot.send_group_markdown_msg = AsyncMock()
|
||||
adapter.bot.send_channle_group_text_msg = AsyncMock()
|
||||
adapter.bot.send_channle_private_text_msg = AsyncMock()
|
||||
adapter.ap = None
|
||||
adapter._stream_ctx = {}
|
||||
adapter._stream_ctx_ts = {}
|
||||
@@ -108,7 +116,7 @@ def _stream_test_adapter():
|
||||
|
||||
|
||||
@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_ctx['message-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] == [
|
||||
'<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
|
||||
async def test_qq_non_streaming_fallback_keeps_latest_snapshot_only():
|
||||
from langbot.pkg.platform.sources.qqofficial import QQOfficialAdapter
|
||||
|
||||
@@ -44,6 +44,86 @@ def test_webhook_dispatch_tasks_are_bounded():
|
||||
assert len(client._dispatch_tasks) == 100
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ws_initial_stream_frame_precedes_pipeline_dispatch(monkeypatch):
|
||||
from langbot.libs.wecom_ai_bot_api import ws_client as ws_client_module
|
||||
|
||||
order = []
|
||||
logger = types.SimpleNamespace(
|
||||
debug=Mock(),
|
||||
error=Mock(),
|
||||
warning=Mock(),
|
||||
)
|
||||
client = WecomBotWsClient('bot-id', 'secret', logger)
|
||||
|
||||
async def parse_message(*args, **kwargs):
|
||||
del args, kwargs
|
||||
return {'msgid': 'msg-1', 'type': 'single', 'userid': 'user-1'}
|
||||
|
||||
async def reply_stream(*args, **kwargs):
|
||||
del args, kwargs
|
||||
order.append('initial-frame')
|
||||
return {}
|
||||
|
||||
async def dispatch_event(event):
|
||||
del event
|
||||
order.append('pipeline-dispatch')
|
||||
|
||||
monkeypatch.setattr(ws_client_module, 'parse_wecom_bot_message', parse_message)
|
||||
monkeypatch.setattr(ws_client_module.wecombotevent, 'WecomBotEvent', lambda data: data)
|
||||
client.reply_stream = reply_stream
|
||||
client._dispatch_event = dispatch_event
|
||||
|
||||
await client._handle_message_callback({'headers': {'req_id': 'req-1'}, 'body': {}})
|
||||
|
||||
assert order == ['initial-frame', 'pipeline-dispatch']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ws_initial_stream_failure_still_dispatches_message(monkeypatch):
|
||||
from langbot.libs.wecom_ai_bot_api import ws_client as ws_client_module
|
||||
|
||||
dispatched = []
|
||||
|
||||
class Logger:
|
||||
def __init__(self):
|
||||
self.warnings = []
|
||||
|
||||
async def debug(self, message):
|
||||
del message
|
||||
|
||||
async def error(self, message):
|
||||
raise AssertionError(message)
|
||||
|
||||
async def warning(self, message):
|
||||
self.warnings.append(message)
|
||||
|
||||
logger = Logger()
|
||||
client = WecomBotWsClient('bot-id', 'secret', logger)
|
||||
|
||||
async def parse_message(*args, **kwargs):
|
||||
del args, kwargs
|
||||
return {'msgid': 'msg-1', 'type': 'single', 'userid': 'user-1'}
|
||||
|
||||
async def reply_stream(*args, **kwargs):
|
||||
del args, kwargs
|
||||
raise ConnectionError('simulated reply failure')
|
||||
|
||||
async def dispatch_event(event):
|
||||
dispatched.append(event)
|
||||
|
||||
monkeypatch.setattr(ws_client_module, 'parse_wecom_bot_message', parse_message)
|
||||
monkeypatch.setattr(ws_client_module.wecombotevent, 'WecomBotEvent', lambda data: data)
|
||||
client.reply_stream = reply_stream
|
||||
client._dispatch_event = dispatch_event
|
||||
|
||||
await client._handle_message_callback({'headers': {'req_id': 'req-1'}, 'body': {}})
|
||||
|
||||
assert len(dispatched) == 1
|
||||
assert len(logger.warnings) == 1
|
||||
assert 'simulated reply failure' in logger.warnings[0]
|
||||
|
||||
|
||||
def test_extract_template_card_action_supports_nested_button_key():
|
||||
task_id, event_key, card_type = extract_template_card_action(
|
||||
{
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import uuid
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
@@ -49,7 +50,29 @@ async def test_send_message_sends_text_to_customer_service_user():
|
||||
assert kwargs['open_kfid'] == 'kf-test'
|
||||
assert kwargs['external_userid'] == 'external-user'
|
||||
assert kwargs['content'] == 'hello'
|
||||
assert kwargs['msgid'].startswith('langbot_')
|
||||
assert len(kwargs['msgid'].encode()) <= 32
|
||||
assert uuid.UUID(hex=kwargs['msgid']).hex == kwargs['msgid']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_message_sends_image_to_customer_service_user():
|
||||
adapter = make_adapter()
|
||||
adapter.bot_account_id = 'kf-test'
|
||||
adapter.bot = SimpleNamespace(
|
||||
get_media_id=AsyncMock(return_value='media-id'),
|
||||
send_image_msg=AsyncMock(),
|
||||
)
|
||||
|
||||
message = platform_message.MessageChain([platform_message.Image(base64='aW1hZ2U=')])
|
||||
|
||||
await adapter.send_message('person', 'uexternal-user', message)
|
||||
|
||||
adapter.bot.send_image_msg.assert_awaited_once()
|
||||
kwargs = adapter.bot.send_image_msg.await_args.kwargs
|
||||
assert kwargs['open_kfid'] == 'kf-test'
|
||||
assert kwargs['external_userid'] == 'external-user'
|
||||
assert kwargs['media_id'] == 'media-id'
|
||||
assert len(kwargs['msgid'].encode()) <= 32
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from langbot.libs.wecom_customer_service_api.api import WecomCSClient
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_image_msg_posts_customer_service_image_payload() -> None:
|
||||
captured_request: httpx.Request | None = None
|
||||
|
||||
def handle_request(request: httpx.Request) -> httpx.Response:
|
||||
nonlocal captured_request
|
||||
captured_request = request
|
||||
return httpx.Response(200, json={'errcode': 0})
|
||||
|
||||
client = WecomCSClient(
|
||||
corpid='corp-id',
|
||||
secret='secret',
|
||||
token='token',
|
||||
EncodingAESKey='encoding-key',
|
||||
logger=None,
|
||||
unified_mode=True,
|
||||
)
|
||||
client.access_token = 'access-token'
|
||||
client._http_client = httpx.AsyncClient(transport=httpx.MockTransport(handle_request))
|
||||
|
||||
try:
|
||||
await client.send_image_msg(
|
||||
open_kfid='kf-test',
|
||||
external_userid='external-user',
|
||||
msgid='a' * 32,
|
||||
media_id='media-id',
|
||||
)
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
assert captured_request is not None
|
||||
assert captured_request.url.path == '/cgi-bin/kf/send_msg'
|
||||
assert captured_request.url.params['access_token'] == 'access-token'
|
||||
assert captured_request.method == 'POST'
|
||||
assert captured_request.read().decode() == (
|
||||
'{"touser":"external-user","open_kfid":"kf-test","msgid":"'
|
||||
+ 'a' * 32
|
||||
+ '","msgtype":"image","image":{"media_id":"media-id"}}'
|
||||
)
|
||||
@@ -640,6 +640,19 @@ class TestGetPluginInfo:
|
||||
connector.handler.get_plugin_info.assert_called_once_with('author', 'plugin')
|
||||
assert result == {'manifest': {'metadata': {'name': 'plugin'}}}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_none_when_plugin_is_not_installed(self):
|
||||
connector = create_mock_connector()
|
||||
configure_handler(connector, AsyncMock())
|
||||
connector._target_binding = AsyncMock(
|
||||
side_effect=ValueError('Plugin author/plugin is not installed in this Workspace')
|
||||
)
|
||||
|
||||
result = await connector.get_plugin_info('author', 'plugin')
|
||||
|
||||
assert result is None
|
||||
connector.handler.get_plugin_info.assert_not_awaited()
|
||||
|
||||
|
||||
class TestSetPluginConfig:
|
||||
"""Tests for set_plugin_config method."""
|
||||
|
||||
@@ -107,6 +107,19 @@ def shared_connector(
|
||||
return connector
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_shared_reconcile_uses_configured_cold_start_timeout():
|
||||
binding = execution_binding("workspace-a")
|
||||
setting = plugin_setting("01", "a" * 64)
|
||||
connector = shared_connector([[binding]], {"workspace-a": [setting]})
|
||||
connector.ap.instance_config.data["plugin"]["connect_timeout_seconds"] = 900
|
||||
connector.handler = runtime_handler()
|
||||
|
||||
await connector._prepare_connected_runtime()
|
||||
|
||||
assert connector.handler.reconcile_plugin_installations.await_args.kwargs["timeout"] == 900
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_shared_reconnect_replays_two_workspaces_and_removes_missing_projection():
|
||||
binding_a = execution_binding('workspace-a')
|
||||
@@ -150,7 +163,7 @@ async def test_empty_projected_workspaces_do_not_retain_installation_sets():
|
||||
|
||||
assert connector._workspace_installations == {}
|
||||
assert connector._known_desired_states == {}
|
||||
connector.handler.reconcile_plugin_installations.assert_awaited_once_with(())
|
||||
connector.handler.reconcile_plugin_installations.assert_awaited_once_with((), timeout=300.0)
|
||||
|
||||
|
||||
@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
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reconcile_plugin_installations_accepts_configured_cold_start_timeout():
|
||||
runtime_handler = make_handler(SimpleNamespace())
|
||||
runtime_handler.call_action = AsyncMock(return_value={})
|
||||
binding = next(iter(runtime_handler._installation_bindings.values()))[0]
|
||||
desired = PluginInstallationDesiredState(binding=binding, enabled=True)
|
||||
|
||||
await runtime_handler.reconcile_plugin_installations((desired,), timeout=900)
|
||||
|
||||
assert runtime_handler.call_action.await_args.kwargs["timeout"] == 900
|
||||
|
||||
|
||||
class TestHandlerQueryVariables:
|
||||
"""Tests for handler query variable logic."""
|
||||
|
||||
|
||||
@@ -79,6 +79,32 @@ class TestDifyWorkflowSubmitClient:
|
||||
with pytest.raises(errors.DifyAPIError, match='SSE event exceeds'):
|
||||
await anext(client._iter_sse_json(FakeResponse()))
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sse_parser_skips_empty_data_and_done_lines(self):
|
||||
from langbot.libs.dify_service_api.v1 import client
|
||||
|
||||
class FakeResponse:
|
||||
async def aiter_bytes(self, chunk_size=None):
|
||||
del chunk_size
|
||||
yield b'data:\n\ndata: {"event":"message",'
|
||||
yield b'"answer":"ok"}\n\ndata: [DONE]\n'
|
||||
|
||||
events = [event async for event in client._iter_sse_json(FakeResponse())]
|
||||
|
||||
assert events == [{'event': 'message', 'answer': 'ok'}]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sse_parser_rejects_malformed_nonempty_data(self):
|
||||
from langbot.libs.dify_service_api.v1 import client, errors
|
||||
|
||||
class FakeResponse:
|
||||
async def aiter_bytes(self, chunk_size=None):
|
||||
del chunk_size
|
||||
yield b'data: not-json\n'
|
||||
|
||||
with pytest.raises(errors.DifyAPIError, match='not valid JSON'):
|
||||
await anext(client._iter_sse_json(FakeResponse()))
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_rejects_oversized_local_file(self, tmp_path):
|
||||
from langbot.libs.dify_service_api.v1 import client
|
||||
@@ -93,6 +119,62 @@ class TestDifyWorkflowSubmitClient:
|
||||
with pytest.raises(ValueError, match='exceeds the size limit'):
|
||||
await dify_client.upload_file(file_path, 'person_user-1')
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
('status_code', 'body', 'expected_id'),
|
||||
[
|
||||
(200, b'{"data":{"id":"wrapped-id"}}', 'wrapped-id'),
|
||||
(201, b'{"id":"flat-id"}', 'flat-id'),
|
||||
],
|
||||
)
|
||||
async def test_upload_accepts_supported_success_responses(self, status_code, body, expected_id):
|
||||
from langbot.libs.dify_service_api.v1 import client
|
||||
|
||||
class FakeResponse:
|
||||
headers = {}
|
||||
|
||||
def __init__(self):
|
||||
self.status_code = status_code
|
||||
|
||||
async def aiter_bytes(self, chunk_size=None):
|
||||
del chunk_size
|
||||
yield body
|
||||
|
||||
class FakeStreamContext:
|
||||
async def __aenter__(self):
|
||||
return FakeResponse()
|
||||
|
||||
async def __aexit__(self, exc_type, exc, traceback):
|
||||
del exc_type, exc, traceback
|
||||
return False
|
||||
|
||||
class FakeClient:
|
||||
def stream(self, *args, **kwargs):
|
||||
del args, kwargs
|
||||
return FakeStreamContext()
|
||||
|
||||
dify_client = client.AsyncDifyServiceClient('test-key', 'https://dify.example/v1')
|
||||
dify_client._client = FakeClient()
|
||||
|
||||
response = await dify_client.upload_file(('hello.txt', b'hello', 'text/plain'), 'person_user-1')
|
||||
|
||||
assert response['id'] == expected_id
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'body',
|
||||
[
|
||||
b'not-json',
|
||||
b'[]',
|
||||
b'{"data":null}',
|
||||
b'{"data":{}}',
|
||||
],
|
||||
)
|
||||
def test_upload_rejects_invalid_success_payload(self, body):
|
||||
from langbot.libs.dify_service_api.v1 import client, errors
|
||||
|
||||
with pytest.raises(errors.DifyAPIError):
|
||||
client._decode_upload_response(body)
|
||||
|
||||
|
||||
class TestDifyExtractTextOutput:
|
||||
"""Tests for _extract_dify_text_output method."""
|
||||
|
||||
@@ -91,3 +91,42 @@ def test_convert_messages_plain_string_content_untouched():
|
||||
msg = provider_message.Message(role='user', content='just text')
|
||||
out = req._convert_messages([msg])
|
||||
assert out[0]['content'] == 'just text'
|
||||
|
||||
|
||||
def test_convert_messages_replayed_image_without_base64_does_not_crash():
|
||||
"""Replayed image parts hollowed out by history trimming must not raise KeyError (#2469).
|
||||
|
||||
SessionManager clears image_base64 on past turns, and URL-less platform
|
||||
images never had a URL, so the replayed part serializes as
|
||||
{'type': 'image_base64'} with no payload keys. The hollow part should be
|
||||
dropped while the sibling text part survives.
|
||||
"""
|
||||
req = _make_requester()
|
||||
image = provider_message.ContentElement.from_image_base64('data:image/jpeg;base64,AAAA')
|
||||
# Simulate SessionManager.trim_conversation_messages clearing binary payloads.
|
||||
image.image_base64 = None
|
||||
msg = provider_message.Message(
|
||||
role='user',
|
||||
content=[
|
||||
provider_message.ContentElement.from_text('describe the photo'),
|
||||
image,
|
||||
],
|
||||
)
|
||||
out = req._convert_messages([msg])
|
||||
assert [p.get('type') for p in out[0]['content']] == ['text']
|
||||
|
||||
|
||||
def test_convert_messages_replayed_image_with_url_falls_back_to_url():
|
||||
"""When base64 was trimmed but image_url survived, rebuild the OpenAI image_url part from the URL."""
|
||||
req = _make_requester()
|
||||
image = provider_message.ContentElement(
|
||||
type='image_base64',
|
||||
image_base64=None,
|
||||
image_url=provider_message.ImageURLContentObject(url='https://example.com/pic.jpg'),
|
||||
)
|
||||
msg = provider_message.Message(role='user', content=[image])
|
||||
out = req._convert_messages([msg])
|
||||
parts = out[0]['content']
|
||||
assert [p.get('type') for p in parts] == ['image_url']
|
||||
assert parts[0]['image_url'] == {'url': 'https://example.com/pic.jpg'}
|
||||
assert 'image_base64' not in parts[0]
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
"""Regression tests for tool-message content serialization (#2457).
|
||||
|
||||
MCP tools return ``list[ContentElement]`` from ``execute_func_call``.
|
||||
The runner must serialize that list to a string before placing it in a
|
||||
``role='tool'`` message, because the OpenAI chat-completions spec
|
||||
requires tool-message content to be a string. Sending the raw list
|
||||
causes OpenAI-compatible endpoints to return HTTP 500.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
||||
|
||||
from langbot.pkg.api.http.context import ExecutionContext, PrincipalContext, PrincipalType
|
||||
from langbot.pkg.provider.runners.localagent import LocalAgentRunner
|
||||
|
||||
|
||||
class _ToolCallProvider:
|
||||
"""Non-streaming provider: round 1 issues a tool call, round 2 returns text."""
|
||||
|
||||
def __init__(self):
|
||||
self.requests: list[dict] = []
|
||||
|
||||
async def invoke_llm(self, query, model, messages, funcs, extra_args=None, remove_think=None):
|
||||
self.requests.append({'messages': list(messages)})
|
||||
|
||||
if len(self.requests) == 1:
|
||||
return provider_message.Message(
|
||||
role='assistant',
|
||||
content='Let me search that.',
|
||||
tool_calls=[
|
||||
provider_message.ToolCall(
|
||||
id='call-mcp-1',
|
||||
type='function',
|
||||
function=provider_message.FunctionCall(
|
||||
name='duckduckgo_search',
|
||||
arguments=json.dumps({'query': 'swift'}),
|
||||
),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
return provider_message.Message(role='assistant', content='Done.')
|
||||
|
||||
|
||||
class _ToolCallStreamProvider:
|
||||
"""Streaming variant of _ToolCallProvider."""
|
||||
|
||||
def __init__(self):
|
||||
self.requests: list[dict] = []
|
||||
|
||||
def invoke_llm_stream(self, query, model, messages, funcs, extra_args=None, remove_think=None):
|
||||
self.requests.append({'messages': list(messages)})
|
||||
|
||||
async def _stream():
|
||||
if len(self.requests) == 1:
|
||||
yield provider_message.MessageChunk(
|
||||
role='assistant',
|
||||
content='Let me search that.',
|
||||
tool_calls=[
|
||||
provider_message.ToolCall(
|
||||
id='call-mcp-1',
|
||||
type='function',
|
||||
function=provider_message.FunctionCall(
|
||||
name='duckduckgo_search',
|
||||
arguments=json.dumps({'query': 'swift'}),
|
||||
),
|
||||
)
|
||||
],
|
||||
is_final=True,
|
||||
)
|
||||
return
|
||||
|
||||
yield provider_message.MessageChunk(
|
||||
role='assistant',
|
||||
content='Done.',
|
||||
is_final=True,
|
||||
)
|
||||
|
||||
return _stream()
|
||||
|
||||
|
||||
def _make_query(stream: bool = False) -> pipeline_query.Query:
|
||||
adapter = AsyncMock()
|
||||
adapter.is_stream_output_supported = AsyncMock(return_value=stream)
|
||||
|
||||
query = pipeline_query.Query.model_construct(
|
||||
query_id='mcp-tool-query',
|
||||
launcher_type=provider_session.LauncherTypes.PERSON,
|
||||
launcher_id=12345,
|
||||
sender_id=12345,
|
||||
message_chain=[],
|
||||
message_event=None,
|
||||
adapter=adapter,
|
||||
pipeline_uuid='pipeline-uuid',
|
||||
bot_uuid='bot-uuid',
|
||||
pipeline_config={
|
||||
'ai': {
|
||||
'runner': {'runner': 'local-agent'},
|
||||
'local-agent': {'model': {'primary': 'test-model-uuid', 'fallbacks': []}, 'prompt': 'test-prompt'},
|
||||
},
|
||||
'output': {'misc': {'remove-think': False}},
|
||||
},
|
||||
prompt=SimpleNamespace(messages=[]),
|
||||
messages=[],
|
||||
user_message=provider_message.Message(role='user', content='search swift'),
|
||||
use_funcs=[SimpleNamespace(name='duckduckgo_search')],
|
||||
use_llm_model_uuid='test-model-uuid',
|
||||
variables={},
|
||||
)
|
||||
object.__setattr__(
|
||||
query,
|
||||
'_execution_context',
|
||||
ExecutionContext(
|
||||
instance_uuid='instance-test',
|
||||
workspace_uuid='workspace-test',
|
||||
placement_generation=1,
|
||||
trigger_principal=PrincipalContext(PrincipalType.SYSTEM),
|
||||
),
|
||||
)
|
||||
return query
|
||||
|
||||
|
||||
def _make_app(provider, func_ret) -> SimpleNamespace:
|
||||
"""Build a minimal app whose tool_mgr returns *func_ret*."""
|
||||
model = SimpleNamespace(
|
||||
provider=provider,
|
||||
model_entity=SimpleNamespace(
|
||||
uuid='test-model-uuid',
|
||||
name='test-model',
|
||||
abilities=['func_call'],
|
||||
extra_args={},
|
||||
),
|
||||
)
|
||||
return SimpleNamespace(
|
||||
logger=Mock(),
|
||||
model_mgr=SimpleNamespace(get_model_by_uuid=AsyncMock(return_value=model)),
|
||||
tool_mgr=SimpleNamespace(execute_func_call=AsyncMock(return_value=func_ret)),
|
||||
rag_mgr=SimpleNamespace(),
|
||||
box_service=SimpleNamespace(get_system_guidance=Mock(return_value='sandbox guidance')),
|
||||
skill_mgr=SimpleNamespace(
|
||||
get_skills_for_pipeline=AsyncMock(return_value=[]),
|
||||
detect_skill_activation=AsyncMock(return_value=None),
|
||||
build_activation_prompt=Mock(return_value=None),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# The actual shape returned by MCP tools: a list of ContentElement objects.
|
||||
_MCP_FUNC_RET = [
|
||||
provider_message.ContentElement.from_text('Title: Swift - Wikipedia\nURL: https://en.wikipedia.org/wiki/Swift'),
|
||||
provider_message.ContentElement.from_text('Title: Swift Programming Language\nURL: https://swift.org'),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_message_content_is_string_not_list():
|
||||
"""Non-streaming: tool message content must be a string (#2457).
|
||||
|
||||
Before the fix, ``func_ret`` (a ``list[ContentElement]``) was assigned
|
||||
to ``tool_content`` as-is, so the tool message carried a list instead
|
||||
of a string, causing OpenAI-compatible APIs to return 500.
|
||||
"""
|
||||
provider = _ToolCallProvider()
|
||||
app = _make_app(provider, _MCP_FUNC_RET)
|
||||
runner = LocalAgentRunner(app, pipeline_config={})
|
||||
query = _make_query(stream=False)
|
||||
|
||||
results = [msg async for msg in runner.run(query)]
|
||||
|
||||
tool_msgs = [m for m in results if m.role == 'tool']
|
||||
assert len(tool_msgs) == 1
|
||||
|
||||
# The content must be a string, not a list.
|
||||
assert isinstance(tool_msgs[0].content, str), (
|
||||
f'tool message content should be str, got {type(tool_msgs[0].content).__name__}'
|
||||
)
|
||||
# And it should contain the text of both ContentElements.
|
||||
assert 'Swift - Wikipedia' in tool_msgs[0].content
|
||||
assert 'Swift Programming Language' in tool_msgs[0].content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_message_content_is_string_in_stream():
|
||||
"""Streaming: same regression check for the streaming path (#2457)."""
|
||||
provider = _ToolCallStreamProvider()
|
||||
app = _make_app(provider, _MCP_FUNC_RET)
|
||||
runner = LocalAgentRunner(app, pipeline_config={})
|
||||
query = _make_query(stream=True)
|
||||
|
||||
results = [msg async for msg in runner.run(query)]
|
||||
|
||||
tool_msgs = [m for m in results if m.role == 'tool']
|
||||
assert len(tool_msgs) == 1
|
||||
|
||||
assert isinstance(tool_msgs[0].content, str), (
|
||||
f'tool message content should be str, got {type(tool_msgs[0].content).__name__}'
|
||||
)
|
||||
assert 'Swift - Wikipedia' in tool_msgs[0].content
|
||||
assert 'Swift Programming Language' in tool_msgs[0].content
|
||||
@@ -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
|
||||
async def test_mask_patterns_bounds_replacement_growth_and_masks_matches():
|
||||
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'}
|
||||
|
||||
|
||||
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({}))
|
||||
link = 'https://cloud.langbot.app/invitations/accept#token=lbi_secret&next=<unsafe>'
|
||||
|
||||
text = service._plain_text('Research & Development', link)
|
||||
html = service._html('Research & Development', link)
|
||||
|
||||
assert 'LangBot Cloud' in text
|
||||
assert 'LangBot' in text
|
||||
assert 'LangBot Cloud' not in text
|
||||
assert 'Research & Development' in text
|
||||
assert '7 days' 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 'expires in 7 days' 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' 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' 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'",
|
||||
]
|
||||
|
||||
@@ -2008,7 +2008,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langbot"
|
||||
version = "4.10.7"
|
||||
version = "4.10.9"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiocqhttp" },
|
||||
@@ -2063,7 +2063,6 @@ dependencies = [
|
||||
{ name = "pymilvus" },
|
||||
{ name = "pynacl" },
|
||||
{ name = "pypdf2" },
|
||||
{ name = "pyseekdb" },
|
||||
{ name = "python-docx" },
|
||||
{ name = "python-multipart" },
|
||||
{ name = "python-socks" },
|
||||
@@ -2089,6 +2088,11 @@ dependencies = [
|
||||
{ name = "websockets" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
seekdb = [
|
||||
{ name = "pyseekdb" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "moto" },
|
||||
@@ -2125,7 +2129,7 @@ requires-dist = [
|
||||
{ name = "ebooklib", specifier = ">=0.18" },
|
||||
{ name = "gewechat-client", specifier = ">=0.1.5" },
|
||||
{ name = "html2text", specifier = ">=2024.2.26" },
|
||||
{ name = "langbot-plugin", specifier = "==0.5.5" },
|
||||
{ name = "langbot-plugin", specifier = "==0.5.6" },
|
||||
{ name = "langchain", specifier = ">=1.3.9" },
|
||||
{ name = "langchain-core", specifier = ">=1.3.3" },
|
||||
{ name = "langchain-text-splitters", specifier = ">=1.1.2" },
|
||||
@@ -2153,7 +2157,7 @@ requires-dist = [
|
||||
{ name = "pymilvus", specifier = ">=2.6.4" },
|
||||
{ name = "pynacl", specifier = ">=1.5.0" },
|
||||
{ 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-multipart", specifier = ">=0.0.27" },
|
||||
{ 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 = "websockets", specifier = ">=15.0.1" },
|
||||
]
|
||||
provides-extras = ["seekdb"]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
@@ -2191,7 +2196,7 @@ dev = [
|
||||
|
||||
[[package]]
|
||||
name = "langbot-plugin"
|
||||
version = "0.5.5"
|
||||
version = "0.5.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiofiles" },
|
||||
@@ -2212,9 +2217,9 @@ dependencies = [
|
||||
{ name = "watchdog" },
|
||||
{ name = "websockets" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c3/be/1bbdf959d8c16b625e3721cde586b3bb22eaa22dd8c22d072c04f9b491ba/langbot_plugin-0.5.5.tar.gz", hash = "sha256:ea31b0ddf64c2ef8fdec012273b2d3dee6f0d140475f07694f31ea685be40695", size = 472639, upload-time = "2026-08-16T17:33:27.783Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0b/1b/0c2e1f457abedf7ce052f47ad193937322b5f25f4e09e35d92bb5bd0346f/langbot_plugin-0.5.6.tar.gz", hash = "sha256:b7d6bb170ceffead6929e8d95ac388dd9a90a6d971ec4fcdaf7f7b46e894fa9e", size = 475814, upload-time = "2026-08-31T16:04:51.604Z" }
|
||||
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" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/40/1bb5d3562f66c88ac45b3b5b6ee77e9f8a6943599aea95731ea4a4e8b005/langbot_plugin-0.5.6-py3-none-any.whl", hash = "sha256:8f35a07be667abeb84147c4299d7afcc394125c73455fc74d9fcc887eae3a7d4", size = 306108, upload-time = "2026-08-31T16:04:50.427Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+5
-1
@@ -1 +1,5 @@
|
||||
VITE_API_BASE_URL=http://localhost:5300
|
||||
# Leave empty in development to use Vite's same-origin proxy. This keeps API,
|
||||
# login, and WebSocket requests working when the UI is opened from another
|
||||
# device on the local network.
|
||||
VITE_API_BASE_URL=
|
||||
VITE_API_PROXY_TARGET=http://127.0.0.1:5300
|
||||
|
||||
@@ -20,6 +20,7 @@ export function BotLogListComponent({
|
||||
autoExpandImages = false,
|
||||
hideDetailedLogsLink = false,
|
||||
hideToolbar = false,
|
||||
onMessageReceived,
|
||||
}: {
|
||||
botId: string;
|
||||
/** When true, log entries with images are rendered expanded by default */
|
||||
@@ -28,6 +29,8 @@ export function BotLogListComponent({
|
||||
hideDetailedLogsLink?: boolean;
|
||||
/** When true, hides the entire toolbar (auto-refresh, level filter, detailed logs link) */
|
||||
hideToolbar?: boolean;
|
||||
/** Called after an inbound person/group message appears in the bot log. */
|
||||
onMessageReceived?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
@@ -41,6 +44,8 @@ export function BotLogListComponent({
|
||||
]);
|
||||
const listContainerRef = useRef<HTMLDivElement>(null);
|
||||
const botLogListRef = useRef<BotLog[]>(botLogList);
|
||||
const onMessageReceivedRef = useRef(onMessageReceived);
|
||||
onMessageReceivedRef.current = onMessageReceived;
|
||||
|
||||
const logLevels = [
|
||||
{ value: 'error', label: 'ERROR' },
|
||||
@@ -108,6 +113,9 @@ export function BotLogListComponent({
|
||||
manager.subscribeLogPush(handleBotLogPush);
|
||||
manager.loadFirstPage().then((response) => {
|
||||
setBotLogList(response.reverse());
|
||||
if (response.some((log) => Boolean(log.message_session_id))) {
|
||||
onMessageReceivedRef.current?.();
|
||||
}
|
||||
});
|
||||
listenScroll();
|
||||
}
|
||||
@@ -138,6 +146,9 @@ export function BotLogListComponent({
|
||||
|
||||
function handleBotLogPush(response: BotLog[]) {
|
||||
setBotLogList(response.reverse());
|
||||
if (response.some((log) => Boolean(log.message_session_id))) {
|
||||
onMessageReceivedRef.current?.();
|
||||
}
|
||||
}
|
||||
|
||||
const handleScroll = useCallback(
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
Copy,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Workflow,
|
||||
ThumbsUp,
|
||||
@@ -117,16 +118,43 @@ interface BotSessionMonitorProps {
|
||||
botId: string;
|
||||
}
|
||||
|
||||
const SESSION_PAGE_SIZE = 20;
|
||||
const MESSAGE_PAGE_SIZE = 50;
|
||||
|
||||
const localDateBoundaryToISOString = (
|
||||
dateValue: string,
|
||||
endOfDay: boolean,
|
||||
): string => {
|
||||
const [year, month, day] = dateValue.split('-').map(Number);
|
||||
return new Date(
|
||||
year,
|
||||
month - 1,
|
||||
day,
|
||||
endOfDay ? 23 : 0,
|
||||
endOfDay ? 59 : 0,
|
||||
endOfDay ? 59 : 0,
|
||||
endOfDay ? 999 : 0,
|
||||
).toISOString();
|
||||
};
|
||||
|
||||
const BotSessionMonitor = forwardRef<
|
||||
BotSessionMonitorHandle,
|
||||
BotSessionMonitorProps
|
||||
>(function BotSessionMonitor({ botId }, ref) {
|
||||
const { t } = useTranslation();
|
||||
const [sessions, setSessions] = useState<SessionInfo[]>([]);
|
||||
const [sessionTotal, setSessionTotal] = useState(0);
|
||||
const [sessionPage, setSessionPage] = useState(0);
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [endDate, setEndDate] = useState('');
|
||||
const [userQuery, setUserQuery] = useState('');
|
||||
const [appliedUserQuery, setAppliedUserQuery] = useState('');
|
||||
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [messages, setMessages] = useState<SessionMessage[]>([]);
|
||||
const [messageTotal, setMessageTotal] = useState(0);
|
||||
const [messagePage, setMessagePage] = useState(0);
|
||||
const [loadingSessions, setLoadingSessions] = useState(false);
|
||||
const [loadingMessages, setLoadingMessages] = useState(false);
|
||||
const [copiedUserId, setCopiedUserId] = useState(false);
|
||||
@@ -138,6 +166,8 @@ const BotSessionMonitor = forwardRef<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
||||
const sessionRequestIdRef = useRef(0);
|
||||
const messageRequestIdRef = useRef(0);
|
||||
const { admins, reload: reloadAdmins } = useBotAdmins(botId);
|
||||
const [adminsDialogOpen, setAdminsDialogOpen] = useState(false);
|
||||
const [togglingAdmin, setTogglingAdmin] = useState<string | null>(null);
|
||||
@@ -204,16 +234,33 @@ const BotSessionMonitor = forwardRef<
|
||||
};
|
||||
|
||||
const loadSessions = useCallback(async () => {
|
||||
const requestId = ++sessionRequestIdRef.current;
|
||||
setLoadingSessions(true);
|
||||
try {
|
||||
const response = await httpClient.getBotSessions(botId);
|
||||
const response = await httpClient.getBotSessions(botId, {
|
||||
limit: SESSION_PAGE_SIZE,
|
||||
offset: sessionPage * SESSION_PAGE_SIZE,
|
||||
startTime: startDate
|
||||
? localDateBoundaryToISOString(startDate, false)
|
||||
: undefined,
|
||||
endTime: endDate
|
||||
? localDateBoundaryToISOString(endDate, true)
|
||||
: undefined,
|
||||
userQuery: appliedUserQuery || undefined,
|
||||
});
|
||||
if (requestId !== sessionRequestIdRef.current) return;
|
||||
setSessions(response.sessions ?? []);
|
||||
setSessionTotal(response.total ?? 0);
|
||||
} catch (error) {
|
||||
console.error('Failed to load sessions:', error);
|
||||
if (requestId === sessionRequestIdRef.current) {
|
||||
console.error('Failed to load sessions:', error);
|
||||
}
|
||||
} finally {
|
||||
setLoadingSessions(false);
|
||||
if (requestId === sessionRequestIdRef.current) {
|
||||
setLoadingSessions(false);
|
||||
}
|
||||
}
|
||||
}, [botId]);
|
||||
}, [appliedUserQuery, botId, endDate, sessionPage, startDate]);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
@@ -224,25 +271,39 @@ const BotSessionMonitor = forwardRef<
|
||||
);
|
||||
|
||||
const loadMessages = useCallback(
|
||||
async (sessionId: string) => {
|
||||
async (sessionId: string, page: number) => {
|
||||
const requestId = ++messageRequestIdRef.current;
|
||||
setLoadingMessages(true);
|
||||
setExpandedToolCallIds({});
|
||||
try {
|
||||
const messagesRes = await httpClient.getSessionMessages(sessionId);
|
||||
const messagesRes = await httpClient.getSessionMessages(
|
||||
sessionId,
|
||||
MESSAGE_PAGE_SIZE,
|
||||
page * MESSAGE_PAGE_SIZE,
|
||||
);
|
||||
if (requestId !== messageRequestIdRef.current) return;
|
||||
const sorted = (messagesRes.messages ?? []).sort(
|
||||
(a, b) =>
|
||||
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
|
||||
);
|
||||
setMessages(sorted);
|
||||
setMessageTotal(messagesRes.total ?? 0);
|
||||
|
||||
try {
|
||||
const analysisParams = new URLSearchParams();
|
||||
if (sorted.length > 0) {
|
||||
analysisParams.set('startTime', sorted[0].timestamp);
|
||||
analysisParams.set('endTime', sorted[sorted.length - 1].timestamp);
|
||||
}
|
||||
const analysisRes = await httpClient.get<{
|
||||
tool_calls?: SessionToolCall[];
|
||||
}>(
|
||||
`/api/v1/monitoring/sessions/${encodeURIComponent(sessionId)}/analysis`,
|
||||
`/api/v1/monitoring/sessions/${encodeURIComponent(sessionId)}/analysis?${analysisParams.toString()}`,
|
||||
);
|
||||
if (requestId !== messageRequestIdRef.current) return;
|
||||
setToolCalls(analysisRes?.tool_calls ?? []);
|
||||
} catch (analysisError) {
|
||||
if (requestId !== messageRequestIdRef.current) return;
|
||||
console.error('Failed to load session tool calls:', analysisError);
|
||||
setToolCalls([]);
|
||||
}
|
||||
@@ -259,6 +320,7 @@ const BotSessionMonitor = forwardRef<
|
||||
}>(
|
||||
`/api/v1/monitoring/feedback?botId=${encodeURIComponent(botId)}&limit=200`,
|
||||
);
|
||||
if (requestId !== messageRequestIdRef.current) return;
|
||||
|
||||
const map: Record<string, SessionFeedback> = {};
|
||||
if (feedbackRes?.feedback) {
|
||||
@@ -273,9 +335,13 @@ const BotSessionMonitor = forwardRef<
|
||||
setFeedbackMap({});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load session messages:', error);
|
||||
if (requestId === messageRequestIdRef.current) {
|
||||
console.error('Failed to load session messages:', error);
|
||||
}
|
||||
} finally {
|
||||
setLoadingMessages(false);
|
||||
if (requestId === messageRequestIdRef.current) {
|
||||
setLoadingMessages(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[botId],
|
||||
@@ -285,16 +351,24 @@ const BotSessionMonitor = forwardRef<
|
||||
loadSessions();
|
||||
}, [loadSessions]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedSessionId(null);
|
||||
setMessagePage(0);
|
||||
}, [appliedUserQuery, botId, endDate, sessionPage, startDate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedSessionId) {
|
||||
loadMessages(selectedSessionId);
|
||||
loadMessages(selectedSessionId, messagePage);
|
||||
} else {
|
||||
messageRequestIdRef.current += 1;
|
||||
setLoadingMessages(false);
|
||||
setMessages([]);
|
||||
setMessageTotal(0);
|
||||
setToolCalls([]);
|
||||
setExpandedToolCallIds({});
|
||||
setFeedbackMap({});
|
||||
}
|
||||
}, [selectedSessionId, loadMessages]);
|
||||
}, [selectedSessionId, messagePage, loadMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (messages.length === 0 && toolCalls.length === 0) return;
|
||||
@@ -552,6 +626,19 @@ const BotSessionMonitor = forwardRef<
|
||||
const selectedSession = sessions.find(
|
||||
(s) => s.session_id === selectedSessionId,
|
||||
);
|
||||
const sessionPageCount = Math.max(
|
||||
1,
|
||||
Math.ceil(sessionTotal / SESSION_PAGE_SIZE),
|
||||
);
|
||||
const messagePageCount = Math.max(
|
||||
1,
|
||||
Math.ceil(messageTotal / MESSAGE_PAGE_SIZE),
|
||||
);
|
||||
|
||||
const applyUserSearch = () => {
|
||||
setSessionPage(0);
|
||||
setAppliedUserQuery(userQuery.trim());
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -575,6 +662,65 @@ const BotSessionMonitor = forwardRef<
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
<span className="text-[11px] text-muted-foreground tabular-nums">
|
||||
{t('bots.sessionMonitor.totalSessions', {
|
||||
defaultValue: '{{count}} sessions',
|
||||
count: sessionTotal,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="p-1.5 border-b shrink-0 space-y-1.5">
|
||||
<div className="flex gap-1">
|
||||
<input
|
||||
value={userQuery}
|
||||
onChange={(event) => setUserQuery(event.target.value)}
|
||||
onKeyDown={(event) =>
|
||||
event.key === 'Enter' && applyUserSearch()
|
||||
}
|
||||
aria-label={t('bots.sessionMonitor.userSearch', {
|
||||
defaultValue: 'User ID or name',
|
||||
})}
|
||||
placeholder={t('bots.sessionMonitor.userSearch', {
|
||||
defaultValue: 'User ID or name',
|
||||
})}
|
||||
className="h-7 min-w-0 flex-1 rounded border bg-background px-2 text-xs"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={applyUserSearch}
|
||||
className="h-7 rounded border px-2 text-[11px] hover:bg-accent"
|
||||
>
|
||||
{t('common.search', { defaultValue: 'Search' })}
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
<input
|
||||
type="date"
|
||||
value={startDate}
|
||||
max={endDate || undefined}
|
||||
onChange={(event) => {
|
||||
setSessionPage(0);
|
||||
setStartDate(event.target.value);
|
||||
}}
|
||||
aria-label={t('bots.sessionMonitor.startDate', {
|
||||
defaultValue: 'Start date',
|
||||
})}
|
||||
className="h-7 min-w-0 rounded border bg-background px-1 text-[10px]"
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
value={endDate}
|
||||
min={startDate || undefined}
|
||||
onChange={(event) => {
|
||||
setSessionPage(0);
|
||||
setEndDate(event.target.value);
|
||||
}}
|
||||
aria-label={t('bots.sessionMonitor.endDate', {
|
||||
defaultValue: 'End date',
|
||||
})}
|
||||
className="h-7 min-w-0 rounded border bg-background px-1 text-[10px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Session List */}
|
||||
<ScrollArea className="flex-1 min-h-0">
|
||||
@@ -601,7 +747,10 @@ const BotSessionMonitor = forwardRef<
|
||||
'w-full text-left px-2.5 py-2 rounded-md transition-colors cursor-pointer',
|
||||
isSelected ? 'bg-accent' : 'hover:bg-accent/50',
|
||||
)}
|
||||
onClick={() => setSelectedSessionId(session.session_id)}
|
||||
onClick={() => {
|
||||
setSelectedSessionId(session.session_id);
|
||||
setMessagePage(0);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-0.5">
|
||||
<span className="text-sm font-medium truncate mr-2">
|
||||
@@ -637,6 +786,29 @@ const BotSessionMonitor = forwardRef<
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
<div className="h-8 border-t px-1.5 flex items-center justify-between shrink-0 text-[11px]">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.previous', { defaultValue: 'Previous' })}
|
||||
disabled={sessionPage === 0 || loadingSessions}
|
||||
onClick={() => setSessionPage((page) => Math.max(0, page - 1))}
|
||||
className="p-1 rounded hover:bg-accent disabled:opacity-40"
|
||||
>
|
||||
<ChevronLeft className="size-3.5" />
|
||||
</button>
|
||||
<span className="tabular-nums text-muted-foreground">
|
||||
{sessionPage + 1} / {sessionPageCount}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.next', { defaultValue: 'Next' })}
|
||||
disabled={sessionPage + 1 >= sessionPageCount || loadingSessions}
|
||||
onClick={() => setSessionPage((page) => page + 1)}
|
||||
className="p-1 rounded hover:bg-accent disabled:opacity-40"
|
||||
>
|
||||
<ChevronRight className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Panel: Messages */}
|
||||
@@ -975,6 +1147,33 @@ const BotSessionMonitor = forwardRef<
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<div className="h-9 border-t px-3 flex items-center justify-center gap-3 shrink-0 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
disabled={messagePage === 0 || loadingMessages}
|
||||
onClick={() =>
|
||||
setMessagePage((page) => Math.max(0, page - 1))
|
||||
}
|
||||
className="inline-flex items-center gap-1 rounded px-2 py-1 hover:bg-accent disabled:opacity-40"
|
||||
>
|
||||
<ChevronLeft className="size-3.5" />
|
||||
{t('common.previous', { defaultValue: 'Previous' })}
|
||||
</button>
|
||||
<span className="tabular-nums text-muted-foreground">
|
||||
{messagePage + 1} / {messagePageCount} · {messageTotal}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
messagePage + 1 >= messagePageCount || loadingMessages
|
||||
}
|
||||
onClick={() => setMessagePage((page) => page + 1)}
|
||||
className="inline-flex items-center gap-1 rounded px-2 py-1 hover:bg-accent disabled:opacity-40"
|
||||
>
|
||||
{t('common.next', { defaultValue: 'Next' })}
|
||||
<ChevronRight className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -15,7 +15,10 @@ import {
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import DynamicFormItemComponent from '@/app/home/components/dynamic-form/DynamicFormItemComponent';
|
||||
import { normalizeDynamicFormValuesForSave } from '@/app/home/components/dynamic-form/DynamicFormSaveValues';
|
||||
import {
|
||||
normalizeDynamicFormFieldValue,
|
||||
normalizeDynamicFormValuesForSave,
|
||||
} from '@/app/home/components/dynamic-form/DynamicFormSaveValues';
|
||||
import QrCodeLoginDialog, {
|
||||
QrLoginPlatform,
|
||||
} from '@/app/home/components/qrcode-login/QrCodeLoginDialog';
|
||||
@@ -464,61 +467,6 @@ export default function DynamicFormComponent({
|
||||
const previousInitialValues = useRef(initialValues);
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
// Normalize a form value according to its field type.
|
||||
// This ensures legacy/malformed data (e.g. a plain string for
|
||||
// model-fallback-selector) is coerced to the expected shape
|
||||
// so that downstream components never crash.
|
||||
const normalizeFieldValue = (
|
||||
item: DynamicFormValueSpec,
|
||||
value: unknown,
|
||||
): unknown => {
|
||||
if (
|
||||
item.name === 'mcp-resources' ||
|
||||
item.type === DynamicFormItemType.RESOURCES_SELECTOR ||
|
||||
item.type === DynamicFormItemType.RICH_TOOLS_SELECTOR
|
||||
) {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
if (item.type === 'model-fallback-selector') {
|
||||
if (value != null && typeof value === 'object' && !Array.isArray(value)) {
|
||||
const obj = value as Record<string, unknown>;
|
||||
return {
|
||||
primary: typeof obj.primary === 'string' ? obj.primary : '',
|
||||
fallbacks: Array.isArray(obj.fallbacks)
|
||||
? (obj.fallbacks as unknown[]).filter(
|
||||
(v): v is string => typeof v === 'string',
|
||||
)
|
||||
: [],
|
||||
reasoning:
|
||||
obj.reasoning != null &&
|
||||
typeof obj.reasoning === 'object' &&
|
||||
!Array.isArray(obj.reasoning)
|
||||
? Object.fromEntries(
|
||||
Object.entries(obj.reasoning).filter(
|
||||
(entry): entry is [string, string] =>
|
||||
typeof entry[1] === 'string',
|
||||
),
|
||||
)
|
||||
: {},
|
||||
};
|
||||
}
|
||||
// Legacy string format or any other unexpected type
|
||||
return {
|
||||
primary: typeof value === 'string' ? value : '',
|
||||
fallbacks: [],
|
||||
reasoning: {},
|
||||
};
|
||||
}
|
||||
if (item.type === 'prompt-editor') {
|
||||
if (Array.isArray(value)) {
|
||||
return value;
|
||||
}
|
||||
// Default to a single empty system prompt entry
|
||||
return [{ role: 'system', content: '' }];
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
// Filter out display-only fields (webhook-url/embed-code/qr-code-login types
|
||||
// and `__system.*`-named fields) that should not participate in form state,
|
||||
// validation, or value emission.
|
||||
@@ -574,7 +522,7 @@ export default function DynamicFormComponent({
|
||||
const rawValue = initialValues?.[item.name] ?? item.default;
|
||||
return {
|
||||
...acc,
|
||||
[item.name]: normalizeFieldValue(item, rawValue),
|
||||
[item.name]: normalizeDynamicFormFieldValue(item, rawValue),
|
||||
};
|
||||
}, {} as FormValues),
|
||||
});
|
||||
@@ -611,7 +559,10 @@ export default function DynamicFormComponent({
|
||||
const mergedValues = editableValueSpecs.reduce(
|
||||
(acc, item) => {
|
||||
const rawValue = initialValues[item.name] ?? item.default;
|
||||
acc[item.name] = normalizeFieldValue(item, rawValue) as object;
|
||||
acc[item.name] = normalizeDynamicFormFieldValue(
|
||||
item,
|
||||
rawValue,
|
||||
) as object;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, object>,
|
||||
|
||||
@@ -5,6 +5,80 @@ export type DynamicFormSaveValueSpec = Pick<
|
||||
'default' | 'name' | 'type'
|
||||
>;
|
||||
|
||||
const ARRAY_FIELD_TYPES = new Set([
|
||||
'array[string]',
|
||||
'array[file]',
|
||||
'knowledge-base-multi-selector',
|
||||
'resources-selector',
|
||||
'rich-tools-selector',
|
||||
'tools-selector',
|
||||
]);
|
||||
|
||||
const STRING_FIELD_TYPES = new Set([
|
||||
'string',
|
||||
'text',
|
||||
'select',
|
||||
'llm-model-selector',
|
||||
'embedding-model-selector',
|
||||
'rerank-model-selector',
|
||||
'knowledge-base-selector',
|
||||
'bot-selector',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Coerce empty dynamic-form defaults into controlled React values.
|
||||
* Metadata from older adapters and runners can omit `default`; inputs must
|
||||
* still receive a stable value from their first render.
|
||||
*/
|
||||
export function normalizeDynamicFormFieldValue(
|
||||
spec: DynamicFormSaveValueSpec,
|
||||
value: unknown,
|
||||
): unknown {
|
||||
if (spec.name === 'mcp-resources' || ARRAY_FIELD_TYPES.has(spec.type)) {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
if (spec.type === 'boolean') {
|
||||
return typeof value === 'boolean' ? value : false;
|
||||
}
|
||||
if (STRING_FIELD_TYPES.has(spec.type)) {
|
||||
return typeof value === 'string' ? value : '';
|
||||
}
|
||||
if (spec.type === 'model-fallback-selector') {
|
||||
if (value != null && typeof value === 'object' && !Array.isArray(value)) {
|
||||
const objectValue = value as Record<string, unknown>;
|
||||
return {
|
||||
primary:
|
||||
typeof objectValue.primary === 'string' ? objectValue.primary : '',
|
||||
fallbacks: Array.isArray(objectValue.fallbacks)
|
||||
? objectValue.fallbacks.filter(
|
||||
(fallback): fallback is string => typeof fallback === 'string',
|
||||
)
|
||||
: [],
|
||||
reasoning:
|
||||
objectValue.reasoning != null &&
|
||||
typeof objectValue.reasoning === 'object' &&
|
||||
!Array.isArray(objectValue.reasoning)
|
||||
? Object.fromEntries(
|
||||
Object.entries(objectValue.reasoning).filter(
|
||||
(entry): entry is [string, string] =>
|
||||
typeof entry[1] === 'string',
|
||||
),
|
||||
)
|
||||
: {},
|
||||
};
|
||||
}
|
||||
return {
|
||||
primary: typeof value === 'string' ? value : '',
|
||||
fallbacks: [],
|
||||
reasoning: {},
|
||||
};
|
||||
}
|
||||
if (spec.type === 'prompt-editor') {
|
||||
return Array.isArray(value) ? value : [{ role: 'system', content: '' }];
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
const reasoningLevels = new Set([
|
||||
'disabled',
|
||||
'enabled',
|
||||
|
||||
@@ -33,7 +33,7 @@ const getFormSchema = (t: (key: string) => string) =>
|
||||
|
||||
interface ProviderFormProps {
|
||||
providerId?: string;
|
||||
onFormSubmit: () => void;
|
||||
onFormSubmit: (providerUuid: string) => void | Promise<void>;
|
||||
onFormCancel: () => void;
|
||||
}
|
||||
|
||||
@@ -171,14 +171,16 @@ export default function ProviderForm({
|
||||
};
|
||||
|
||||
try {
|
||||
let savedProviderUuid = providerId;
|
||||
if (providerId) {
|
||||
await httpClient.updateModelProvider(providerId, data);
|
||||
toast.success(t('models.providerSaved'));
|
||||
} else {
|
||||
await httpClient.createModelProvider(data);
|
||||
const response = await httpClient.createModelProvider(data);
|
||||
savedProviderUuid = response.uuid;
|
||||
toast.success(t('models.providerCreated'));
|
||||
}
|
||||
onFormSubmit();
|
||||
await onFormSubmit(savedProviderUuid as string);
|
||||
} catch (err) {
|
||||
toast.error(t('models.providerSaveError') + (err as CustomApiError).msg);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
TrendingUp,
|
||||
} from 'lucide-react';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { getErrorMessage } from '../utils';
|
||||
|
||||
interface TokenSummary {
|
||||
total_calls: number;
|
||||
@@ -152,7 +153,7 @@ export default function TokenMonitoring({
|
||||
});
|
||||
setStats(result);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
setError(getErrorMessage(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
export function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) return error.message;
|
||||
if (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'msg' in error &&
|
||||
typeof error.msg === 'string'
|
||||
) {
|
||||
return error.msg;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
@@ -54,7 +54,7 @@ export function groupByCategory<T extends { categories?: string[] }>(
|
||||
}
|
||||
|
||||
let placed = false;
|
||||
for (const cat of cats) {
|
||||
for (const cat of new Set(cats)) {
|
||||
if (ordered.includes(cat as AdapterCategoryId)) {
|
||||
buckets.get(cat as AdapterCategoryId)!.push(item);
|
||||
placed = true;
|
||||
|
||||
@@ -363,7 +363,9 @@ export interface WizardProgress {
|
||||
step: number;
|
||||
selected_adapter: string | null;
|
||||
created_bot_uuid: string | null;
|
||||
created_pipeline_uuid?: string | null;
|
||||
bot_saved: boolean;
|
||||
message_received?: boolean;
|
||||
selected_runner: string | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -150,7 +150,9 @@ export class BackendClient extends BaseHttpClient {
|
||||
return this.get(`/api/v1/provider/models/llm/${uuid}`);
|
||||
}
|
||||
|
||||
public createProviderLLMModel(model: LLMModel): Promise<object> {
|
||||
public createProviderLLMModel(
|
||||
model: Omit<LLMModel, 'uuid'>,
|
||||
): Promise<{ uuid: string }> {
|
||||
return this.post('/api/v1/provider/models/llm', model);
|
||||
}
|
||||
|
||||
@@ -461,10 +463,24 @@ export class BackendClient extends BaseHttpClient {
|
||||
return this.post(`/api/v1/platform/bots/${botId}/logs`, request);
|
||||
}
|
||||
|
||||
public testHttpBotInbound(
|
||||
botId: string,
|
||||
message: string,
|
||||
): Promise<{ session_id: string; accepted_message_id: string }> {
|
||||
return this.post(`/api/v1/platform/bots/${botId}/test-inbound`, {
|
||||
message,
|
||||
});
|
||||
}
|
||||
|
||||
public getBotSessions(
|
||||
botId: string,
|
||||
limit: number = 100,
|
||||
offset: number = 0,
|
||||
options: {
|
||||
limit: number;
|
||||
offset: number;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
userQuery?: string;
|
||||
},
|
||||
): Promise<{
|
||||
sessions: Array<{
|
||||
session_id: string;
|
||||
@@ -484,8 +500,17 @@ export class BackendClient extends BaseHttpClient {
|
||||
}> {
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.append('botId', botId);
|
||||
queryParams.append('limit', limit.toString());
|
||||
queryParams.append('offset', offset.toString());
|
||||
queryParams.append('limit', options.limit.toString());
|
||||
queryParams.append('offset', options.offset.toString());
|
||||
if (options.startTime) {
|
||||
queryParams.append('startTime', options.startTime);
|
||||
}
|
||||
if (options.endTime) {
|
||||
queryParams.append('endTime', options.endTime);
|
||||
}
|
||||
if (options.userQuery) {
|
||||
queryParams.append('userQuery', options.userQuery);
|
||||
}
|
||||
return this.get(`/api/v1/monitoring/sessions?${queryParams.toString()}`);
|
||||
}
|
||||
|
||||
@@ -710,11 +735,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, {
|
||||
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(
|
||||
@@ -724,6 +770,7 @@ export class BackendClient extends BaseHttpClient {
|
||||
): Promise<string> {
|
||||
return this.getAuthenticatedObjectURL(
|
||||
`/api/v1/plugins/${author}/${name}/authenticated-assets/${filepath}`,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1046,12 +1093,21 @@ export class BackendClient extends BaseHttpClient {
|
||||
step: number;
|
||||
selected_adapter: string | null;
|
||||
created_bot_uuid: string | null;
|
||||
created_pipeline_uuid?: string | null;
|
||||
bot_saved: boolean;
|
||||
message_received?: boolean;
|
||||
selected_runner: string | null;
|
||||
}): Promise<void> {
|
||||
return this.put('/api/v1/system/wizard/progress', progress);
|
||||
}
|
||||
|
||||
public getWizardRecommendedModel(): Promise<{
|
||||
uuid: string;
|
||||
name: string;
|
||||
}> {
|
||||
return this.get('/api/v1/system/wizard/recommended-model');
|
||||
}
|
||||
|
||||
public getAsyncTasks(params?: {
|
||||
type?: string;
|
||||
kind?: string;
|
||||
@@ -1181,6 +1237,7 @@ export class BackendClient extends BaseHttpClient {
|
||||
public getAccountInfo(): Promise<{
|
||||
initialized: boolean;
|
||||
authenticated_invitation_acceptance_enabled?: boolean;
|
||||
invitation_registration_enabled?: boolean;
|
||||
password_login_enabled?: boolean;
|
||||
space_login_enabled?: boolean;
|
||||
}> {
|
||||
@@ -1328,12 +1385,18 @@ export class BackendClient extends BaseHttpClient {
|
||||
}
|
||||
|
||||
// ============ Space OAuth API (Redirect Flow) ============
|
||||
public getSpaceAuthorizeUrl(redirectUri: string): Promise<{
|
||||
public getSpaceAuthorizeUrl(
|
||||
redirectUri: string,
|
||||
options?: { cloudEntry?: boolean },
|
||||
): Promise<{
|
||||
authorize_url: string;
|
||||
}> {
|
||||
return this.get(
|
||||
'/api/v1/user/space/authorize-url',
|
||||
{ redirect_uri: redirectUri },
|
||||
{
|
||||
redirect_uri: redirectUri,
|
||||
...(options?.cloudEntry ? { cloud_entry: '1' } : {}),
|
||||
},
|
||||
{ skipWorkspace: true },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -91,7 +91,9 @@ export default function AcceptInvitationPage() {
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [passwordRegistrationEnabled, setPasswordRegistrationEnabled] =
|
||||
const [invitationRegistrationEnabled, setInvitationRegistrationEnabled] =
|
||||
useState(false);
|
||||
const [invitationCapabilitiesLoaded, setInvitationCapabilitiesLoaded] =
|
||||
useState(false);
|
||||
const [
|
||||
authenticatedInvitationAcceptanceEnabled,
|
||||
@@ -116,12 +118,16 @@ export default function AcceptInvitationPage() {
|
||||
backendClient
|
||||
.getAccountInfo()
|
||||
.then((info) => {
|
||||
setPasswordRegistrationEnabled(info.password_login_enabled !== false);
|
||||
setInvitationRegistrationEnabled(
|
||||
info.invitation_registration_enabled ??
|
||||
info.password_login_enabled !== false,
|
||||
);
|
||||
setAuthenticatedInvitationAcceptanceEnabled(
|
||||
info.authenticated_invitation_acceptance_enabled === true,
|
||||
);
|
||||
})
|
||||
.catch(() => setPasswordRegistrationEnabled(false));
|
||||
.catch(() => setInvitationRegistrationEnabled(false))
|
||||
.finally(() => setInvitationCapabilitiesLoaded(true));
|
||||
if (!invitationToken) {
|
||||
setErrorMessage(t('workspace.invitationMissing'));
|
||||
setStatus('error');
|
||||
@@ -311,7 +317,11 @@ export default function AcceptInvitationPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasLoginToken && authenticatedInvitationAcceptanceEnabled ? (
|
||||
{!invitationCapabilitiesLoaded ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="size-6 animate-spin" />
|
||||
</div>
|
||||
) : hasLoginToken && authenticatedInvitationAcceptanceEnabled ? (
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={status === 'submitting'}
|
||||
@@ -331,7 +341,7 @@ export default function AcceptInvitationPage() {
|
||||
{t('workspace.logoutAndReturn')}
|
||||
</Button>
|
||||
</div>
|
||||
) : passwordRegistrationEnabled ? (
|
||||
) : invitationRegistrationEnabled ? (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
@@ -376,15 +386,19 @@ export default function AcceptInvitationPage() {
|
||||
>
|
||||
{t('workspace.confirmPassword')}
|
||||
</label>
|
||||
<Input
|
||||
id="invite-password-confirm"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(event) =>
|
||||
setConfirmPassword(event.target.value)
|
||||
}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-3 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="invite-password-confirm"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(event) =>
|
||||
setConfirmPassword(event.target.value)
|
||||
}
|
||||
className="pl-10"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
className="w-full"
|
||||
@@ -396,14 +410,6 @@ export default function AcceptInvitationPage() {
|
||||
)}
|
||||
{t('workspace.registerAndAccept')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full"
|
||||
disabled={status === 'submitting'}
|
||||
onClick={() => navigate('/login?invitation=1')}
|
||||
>
|
||||
{t('workspace.alreadyHaveAccount')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
|
||||
@@ -202,7 +202,13 @@ export default function Login() {
|
||||
try {
|
||||
const currentOrigin = window.location.origin;
|
||||
const redirectUri = `${currentOrigin}/auth/space/callback`;
|
||||
const response = await httpClient.getSpaceAuthorizeUrl(redirectUri);
|
||||
const response = await httpClient.getSpaceAuthorizeUrl(redirectUri, {
|
||||
// Cloud Accounts must be launched from Space so a first visit can
|
||||
// lazily create and project the personal Workspace. Invitation login
|
||||
// remains on the OAuth callback path because it targets the invited
|
||||
// Workspace instead.
|
||||
cloudEntry: !getPendingInvitationToken(),
|
||||
});
|
||||
window.location.href = response.authorize_url;
|
||||
} catch {
|
||||
toast.error(t('common.spaceLoginFailed'));
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Check,
|
||||
Eye,
|
||||
Loader2,
|
||||
Pencil,
|
||||
RefreshCw,
|
||||
Wrench,
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import ProviderForm from '@/app/home/components/models-dialog/component/provider-form/ProviderForm';
|
||||
import type { ScannedProviderModel } from '@/app/infra/entities/api';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type ModelSetupMode = 'scan' | 'manual';
|
||||
type ScanFallbackReason = 'failed' | 'empty' | null;
|
||||
|
||||
export interface OwnModelSelection {
|
||||
source: ModelSetupMode;
|
||||
providerUuid: string;
|
||||
model: ScannedProviderModel;
|
||||
}
|
||||
|
||||
interface OwnModelSetupProps {
|
||||
onBack: () => void;
|
||||
onSelectionChange: (selection: OwnModelSelection | null) => void;
|
||||
}
|
||||
|
||||
export default function OwnModelSetup({
|
||||
onBack,
|
||||
onSelectionChange,
|
||||
}: OwnModelSetupProps) {
|
||||
const { t } = useTranslation();
|
||||
const [providerUuid, setProviderUuid] = useState<string | null>(null);
|
||||
const [showProviderForm, setShowProviderForm] = useState(true);
|
||||
const [mode, setMode] = useState<ModelSetupMode>('scan');
|
||||
const [models, setModels] = useState<ScannedProviderModel[]>([]);
|
||||
const [selectedModelId, setSelectedModelId] = useState<string | null>(null);
|
||||
const [isScanning, setIsScanning] = useState(false);
|
||||
const [scanFallbackReason, setScanFallbackReason] =
|
||||
useState<ScanFallbackReason>(null);
|
||||
const [manualModelName, setManualModelName] = useState('');
|
||||
const [manualContextLength, setManualContextLength] = useState('');
|
||||
const [manualVision, setManualVision] = useState(false);
|
||||
const [manualFunctionCall, setManualFunctionCall] = useState(false);
|
||||
|
||||
const parsedManualContextLength = useMemo(() => {
|
||||
if (!manualContextLength.trim()) return null;
|
||||
const value = Number(manualContextLength);
|
||||
return Number.isInteger(value) && value > 0 ? value : undefined;
|
||||
}, [manualContextLength]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== 'manual' || !providerUuid) return;
|
||||
if (!manualModelName.trim() || parsedManualContextLength === undefined) {
|
||||
onSelectionChange(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const abilities = [
|
||||
...(manualVision ? ['vision'] : []),
|
||||
...(manualFunctionCall ? ['func_call'] : []),
|
||||
];
|
||||
const modelName = manualModelName.trim();
|
||||
onSelectionChange({
|
||||
source: 'manual',
|
||||
providerUuid,
|
||||
model: {
|
||||
id: modelName,
|
||||
name: modelName,
|
||||
type: 'llm',
|
||||
abilities,
|
||||
context_length: parsedManualContextLength,
|
||||
already_added: false,
|
||||
},
|
||||
});
|
||||
}, [
|
||||
manualFunctionCall,
|
||||
manualModelName,
|
||||
manualVision,
|
||||
mode,
|
||||
onSelectionChange,
|
||||
parsedManualContextLength,
|
||||
providerUuid,
|
||||
]);
|
||||
|
||||
const scanModels = useCallback(
|
||||
async (uuid: string) => {
|
||||
setMode('scan');
|
||||
setIsScanning(true);
|
||||
setScanFallbackReason(null);
|
||||
setModels([]);
|
||||
setSelectedModelId(null);
|
||||
onSelectionChange(null);
|
||||
|
||||
try {
|
||||
const response = await httpClient.scanProviderModels(uuid, 'llm');
|
||||
const availableModels = response.models.filter(
|
||||
(model) => model.type === 'llm' && !model.already_added,
|
||||
);
|
||||
setModels(availableModels);
|
||||
if (availableModels.length === 0) {
|
||||
setScanFallbackReason('empty');
|
||||
setMode('manual');
|
||||
}
|
||||
} catch {
|
||||
setScanFallbackReason('failed');
|
||||
setMode('manual');
|
||||
} finally {
|
||||
setIsScanning(false);
|
||||
}
|
||||
},
|
||||
[onSelectionChange],
|
||||
);
|
||||
|
||||
const handleProviderSaved = useCallback(
|
||||
async (uuid: string) => {
|
||||
setProviderUuid(uuid);
|
||||
setShowProviderForm(false);
|
||||
await scanModels(uuid);
|
||||
},
|
||||
[scanModels],
|
||||
);
|
||||
|
||||
const handleSelectModel = useCallback(
|
||||
(model: ScannedProviderModel) => {
|
||||
if (!providerUuid) return;
|
||||
setSelectedModelId(model.id);
|
||||
onSelectionChange({ source: 'scan', providerUuid, model });
|
||||
},
|
||||
[onSelectionChange, providerUuid],
|
||||
);
|
||||
|
||||
const handleModeChange = useCallback(
|
||||
(value: string) => {
|
||||
setMode(value as ModelSetupMode);
|
||||
setSelectedModelId(null);
|
||||
onSelectionChange(null);
|
||||
},
|
||||
[onSelectionChange],
|
||||
);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
onSelectionChange(null);
|
||||
onBack();
|
||||
}, [onBack, onSelectionChange]);
|
||||
|
||||
const handleEditProvider = useCallback(() => {
|
||||
setSelectedModelId(null);
|
||||
onSelectionChange(null);
|
||||
setShowProviderForm(true);
|
||||
}, [onSelectionChange]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-4xl space-y-6">
|
||||
<div className="text-center">
|
||||
<h2 className="text-xl font-semibold">
|
||||
{t('wizard.aiEngine.ownModelSetupTitle')}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t('wizard.aiEngine.ownModelSetupDescription')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button variant="ghost" size="sm" onClick={handleBack}>
|
||||
<ArrowLeft className="mr-1.5 size-4" />
|
||||
{t('wizard.aiEngine.backToChoices')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showProviderForm ? (
|
||||
<Card className="mx-auto w-full max-w-3xl">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
{t('wizard.aiEngine.addProviderTitle')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t('wizard.aiEngine.addProviderDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ProviderForm
|
||||
providerId={providerUuid ?? undefined}
|
||||
onFormSubmit={handleProviderSaved}
|
||||
onFormCancel={() =>
|
||||
providerUuid ? setShowProviderForm(false) : handleBack()
|
||||
}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="mx-auto w-full max-w-3xl space-y-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3 border-b pb-3">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold">
|
||||
{t('wizard.aiEngine.selectModelTitle')}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('wizard.aiEngine.selectScannedModelDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
title={t('wizard.aiEngine.editProvider')}
|
||||
onClick={handleEditProvider}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Tabs value={mode} onValueChange={handleModeChange}>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="scan">
|
||||
{t('wizard.aiEngine.scanModelMode')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="manual">
|
||||
{t('wizard.aiEngine.manualModelMode')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="scan" className="mt-4">
|
||||
{isScanning ? (
|
||||
<div className="flex min-h-48 items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
{t('wizard.aiEngine.scanningModels')}
|
||||
</div>
|
||||
) : models.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{models.map((model) => {
|
||||
const selected = selectedModelId === model.id;
|
||||
return (
|
||||
<button
|
||||
key={model.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex min-h-20 items-center gap-3 rounded-md border p-3 text-left transition-colors hover:border-primary/60 hover:bg-accent/40',
|
||||
selected &&
|
||||
'border-primary bg-accent ring-1 ring-primary',
|
||||
)}
|
||||
onClick={() => handleSelectModel(model)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'flex size-5 shrink-0 items-center justify-center rounded-full border',
|
||||
selected &&
|
||||
'border-primary bg-primary text-primary-foreground',
|
||||
)}
|
||||
>
|
||||
{selected && <Check className="size-3" />}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-sm font-medium">
|
||||
{model.display_name || model.name}
|
||||
</span>
|
||||
<span className="block truncate text-xs text-muted-foreground">
|
||||
{model.name}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isScanning || !providerUuid}
|
||||
onClick={() => providerUuid && scanModels(providerUuid)}
|
||||
>
|
||||
<RefreshCw className="mr-1.5 size-4" />
|
||||
{t('wizard.aiEngine.rescanModels')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-48 flex-col items-center justify-center gap-3 border border-dashed p-6 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
scanFallbackReason === 'failed'
|
||||
? 'wizard.aiEngine.scanModelsFailed'
|
||||
: 'wizard.aiEngine.noScannedModels',
|
||||
)}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!providerUuid}
|
||||
onClick={() => providerUuid && scanModels(providerUuid)}
|
||||
>
|
||||
<RefreshCw className="mr-1.5 size-4" />
|
||||
{t('wizard.aiEngine.rescanModels')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="manual" className="mt-4 space-y-5">
|
||||
{scanFallbackReason && (
|
||||
<div className="border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-200">
|
||||
{t(
|
||||
scanFallbackReason === 'failed'
|
||||
? 'wizard.aiEngine.manualFallbackFailed'
|
||||
: 'wizard.aiEngine.manualFallbackEmpty',
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="wizard-manual-model-name">
|
||||
{t('wizard.aiEngine.manualModelId')}
|
||||
<span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="wizard-manual-model-name"
|
||||
value={manualModelName}
|
||||
onChange={(event) => setManualModelName(event.target.value)}
|
||||
placeholder={t('wizard.aiEngine.manualModelIdPlaceholder')}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('wizard.aiEngine.manualModelIdDescription')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 border-t pt-4">
|
||||
<p className="text-sm font-medium">
|
||||
{t('wizard.aiEngine.manualModelOptions')}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="wizard-manual-context-length">
|
||||
{t('models.contextLength')}
|
||||
</Label>
|
||||
<Input
|
||||
id="wizard-manual-context-length"
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
value={manualContextLength}
|
||||
onChange={(event) =>
|
||||
setManualContextLength(event.target.value)
|
||||
}
|
||||
placeholder={t('models.contextLengthPlaceholder')}
|
||||
/>
|
||||
{parsedManualContextLength === undefined && (
|
||||
<p className="text-xs text-destructive">
|
||||
{t('models.contextLengthInvalid')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="wizard-manual-vision"
|
||||
checked={manualVision}
|
||||
onCheckedChange={(checked) =>
|
||||
setManualVision(checked === true)
|
||||
}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="wizard-manual-vision"
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
<Eye className="size-4" />
|
||||
{t('models.visionAbility')}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="wizard-manual-function-call"
|
||||
checked={manualFunctionCall}
|
||||
onCheckedChange={(checked) =>
|
||||
setManualFunctionCall(checked === true)
|
||||
}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="wizard-manual-function-call"
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
<Wrench className="size-4" />
|
||||
{t('models.functionCallAbility')}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+666
-184
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,137 @@
|
||||
export function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) return error.message;
|
||||
|
||||
if (typeof error === 'object' && error !== null && 'msg' in error) {
|
||||
const message = (error as { msg?: unknown }).msg;
|
||||
if (typeof message === 'string') return message;
|
||||
}
|
||||
|
||||
return String(error);
|
||||
}
|
||||
|
||||
function createSigningSecret(): string {
|
||||
const bytes = new Uint8Array(32);
|
||||
crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(
|
||||
'',
|
||||
);
|
||||
}
|
||||
|
||||
export function ensureHttpBotSigningSecret(
|
||||
adapterName: string,
|
||||
config: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
if (
|
||||
adapterName !== 'http_bot' ||
|
||||
config.signature_required === false ||
|
||||
(typeof config.inbound_secret === 'string' && config.inbound_secret)
|
||||
) {
|
||||
return config;
|
||||
}
|
||||
|
||||
return {
|
||||
...config,
|
||||
inbound_secret: createSigningSecret(),
|
||||
};
|
||||
}
|
||||
|
||||
export function findDefaultPipeline<
|
||||
T extends { uuid?: string; is_default?: boolean },
|
||||
>(pipelines: T[]): T | undefined {
|
||||
return pipelines.find(
|
||||
(pipeline) =>
|
||||
pipeline.is_default === true &&
|
||||
typeof pipeline.uuid === 'string' &&
|
||||
pipeline.uuid.length > 0,
|
||||
);
|
||||
}
|
||||
|
||||
interface WebhookConfigItem {
|
||||
name: string;
|
||||
show_if?: {
|
||||
field: string;
|
||||
operator: 'eq' | 'neq' | 'in';
|
||||
value: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export function isWebhookModeEnabled(
|
||||
configItems: WebhookConfigItem[],
|
||||
configValues: Record<string, unknown>,
|
||||
): boolean {
|
||||
const webhookField = configItems.find((item) => item.name === 'webhook_url');
|
||||
if (!webhookField) return false;
|
||||
if (!webhookField.show_if) return true;
|
||||
|
||||
const condition = webhookField.show_if;
|
||||
const actualValue = configValues[condition.field];
|
||||
if (condition.operator === 'eq') return actualValue === condition.value;
|
||||
if (condition.operator === 'neq') return actualValue !== condition.value;
|
||||
return (
|
||||
Array.isArray(condition.value) && condition.value.includes(actualValue)
|
||||
);
|
||||
}
|
||||
|
||||
interface RequiredConfigItem {
|
||||
name: string;
|
||||
required: boolean;
|
||||
default: unknown;
|
||||
}
|
||||
|
||||
function isPlaceholderDefault(value: string, defaultValue: unknown): boolean {
|
||||
if (typeof defaultValue !== 'string' || value !== defaultValue.trim()) {
|
||||
return false;
|
||||
}
|
||||
return /(^|:\/\/)your-/i.test(value);
|
||||
}
|
||||
|
||||
export function isRequiredRunnerConfigComplete(
|
||||
configItems: RequiredConfigItem[],
|
||||
configValues: Record<string, unknown>,
|
||||
): boolean {
|
||||
return configItems
|
||||
.filter((item) => item.required)
|
||||
.every((item) => {
|
||||
const value = configValues[item.name];
|
||||
if (typeof value === 'string') {
|
||||
const normalizedValue = value.trim();
|
||||
return (
|
||||
normalizedValue.length > 0 &&
|
||||
!isPlaceholderDefault(normalizedValue, item.default)
|
||||
);
|
||||
}
|
||||
if (Array.isArray(value)) return value.length > 0;
|
||||
return value !== undefined && value !== null;
|
||||
});
|
||||
}
|
||||
|
||||
export function configureLocalAgentPrimaryModel(
|
||||
config: Record<string, unknown>,
|
||||
modelUuid: string,
|
||||
): Record<string, unknown> {
|
||||
const aiConfig = (config.ai ?? {}) as Record<string, unknown>;
|
||||
const runnerConfig = (aiConfig.runner ?? {}) as Record<string, unknown>;
|
||||
const localAgentConfig = (aiConfig['local-agent'] ?? {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const modelConfig = (localAgentConfig.model ?? {}) as Record<string, unknown>;
|
||||
|
||||
return {
|
||||
...config,
|
||||
ai: {
|
||||
...aiConfig,
|
||||
runner: { ...runnerConfig, runner: 'local-agent' },
|
||||
'local-agent': {
|
||||
...localAgentConfig,
|
||||
model: {
|
||||
...modelConfig,
|
||||
primary: modelUuid,
|
||||
fallbacks: Array.isArray(modelConfig.fallbacks)
|
||||
? modelConfig.fallbacks
|
||||
: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1827,14 +1827,80 @@ const enUS = {
|
||||
resaveBot: 'Re-save Configuration',
|
||||
botSaved:
|
||||
'Bot configuration saved and enabled. Check the logs to verify the connection.',
|
||||
waitingForMessage:
|
||||
'The bot is enabled. Send it a message from your IM platform to continue.',
|
||||
messageReceived:
|
||||
'The bot received an IM message. You can continue to the next step.',
|
||||
messageReceivedLocalAccountWarning:
|
||||
'The bot-side connection is configured correctly and received an IM message. Because you are not signed in with a LangBot Account, model calls may fail; continue to the next step to add your own model.',
|
||||
pageBotTestPrompt:
|
||||
'Page Bot is enabled. Click the chat bubble in the lower-right corner and send a message to verify the full conversation flow.',
|
||||
pageBotTestNotice:
|
||||
'For testing only. Embed the code on a real external webpage.',
|
||||
webhookTestPrompt:
|
||||
'The callback URL is ready. Configure it on the external platform, then send the bot a real message.',
|
||||
httpTestPrompt:
|
||||
'HTTP Bot is enabled. Send a real inbound message here to verify the connection.',
|
||||
httpTestDefaultMessage: 'Hello, this is a connection test message.',
|
||||
sendHttpTest: 'Send Test Message',
|
||||
httpTestAccepted:
|
||||
'The test message was accepted. It will appear in the log shortly.',
|
||||
httpTestMissingSecret:
|
||||
'Enter an inbound signing secret and save the configuration first.',
|
||||
httpTestFailed: 'Failed to send the test message: {{error}}',
|
||||
logsTitle: 'Bot Logs',
|
||||
logsDescription:
|
||||
'Monitor bot activity to verify the platform connection is working.',
|
||||
},
|
||||
aiEngine: {
|
||||
title: 'Select an AI Engine',
|
||||
title: 'Configure AI Engine',
|
||||
description:
|
||||
"Choose the AI engine that will power your bot's intelligence.",
|
||||
optionalDescription:
|
||||
'This step is optional. Choose how you want to continue with the current agent.',
|
||||
externalTitle: 'Connect an External Agent',
|
||||
externalDescription:
|
||||
'Connect Dify, n8n, Coze, or another platform and replace the bot pipeline.',
|
||||
ownModelTitle: 'Use My Own Model',
|
||||
ownModelDescription:
|
||||
'Add a provider, then scan or manually enter a model to finish setup.',
|
||||
ownModelSetupTitle: 'Add Your Own Model',
|
||||
ownModelSetupDescription:
|
||||
'Add a model provider. Chat models are scanned automatically, or you can enter a model ID manually.',
|
||||
addProviderTitle: 'Add Provider',
|
||||
addProviderDescription:
|
||||
'Enter the provider details and API key used to connect and scan models.',
|
||||
selectModelTitle: 'Choose a Model',
|
||||
selectScannedModelTitle: 'Choose a Model',
|
||||
selectScannedModelDescription:
|
||||
'The selected model will be the primary model of a new pipeline, and the bot will switch to it.',
|
||||
scanModelMode: 'Scan Models',
|
||||
manualModelMode: 'Add Manually',
|
||||
scanningModels: 'Scanning available models…',
|
||||
noScannedModels:
|
||||
'No available chat models were found. Check the provider configuration.',
|
||||
scanModelsFailed:
|
||||
'Model scanning failed. Check the URL and API key, then try again.',
|
||||
manualFallbackFailed:
|
||||
'Automatic scanning failed. Enter a model ID supported by the provider.',
|
||||
manualFallbackEmpty:
|
||||
'No models were found. Enter a model ID supported by the provider.',
|
||||
manualModelId: 'Model ID',
|
||||
manualModelIdPlaceholder: 'For example: gpt-4o',
|
||||
manualModelIdDescription:
|
||||
'Enter the model parameter used in model requests.',
|
||||
manualModelOptions: 'Optional Model Capabilities',
|
||||
editProvider: 'Edit provider',
|
||||
rescanModels: 'Scan models again',
|
||||
moreFeaturesTitle: 'Add More Agent Features',
|
||||
moreFeaturesDescription:
|
||||
'Open the workbench to add tools, knowledge bases, and other capabilities to the Agent that was just generated automatically.',
|
||||
runnerDescription:
|
||||
'Select a runner for the external agent and configure its connection.',
|
||||
backToChoices: 'Back to options',
|
||||
createExternal: 'Create and Bind',
|
||||
finishWithModel: 'Use Selected Model & Finish',
|
||||
openWorkbench: 'Open Workbench',
|
||||
},
|
||||
config: {
|
||||
botInfo: 'Bot Information',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user